New text/event-stream endpoints — /o/{offer_id}/events (buyer/seller only)
and /a/{auction_id}/events (public). Each polls the row ~every 1.5s, emits
a `data: {json}` frame on connect and whenever the state-machine state
changes, sends a heartbeat comment, then closes after ~25s so the browser
EventSource reconnects — "bounded" because uWSGI is sync (~16 worker
threads) and a long-lived SSE would starve the pool. Shared helper
lib/sse.py (sse_response / event_stream); it uses its own short-lived DB
session per poll (request.dbsession is already closed by pyramid_tm by the
time the streaming generator runs). Timings come from settings
(app.sse.hold_seconds / app.sse.poll_interval_seconds; test.ini sets them
tiny so the streaming tests finish in ~0.06s).
Client: auction.js opens the EventSource and feeds each frame into its
existing applyState(); it falls back to polling /a/{id}.json every 5s
where EventSource is unavailable. offer.js opens the EventSource on the
offer page and reload()s on a state change (the whole layout depends on
state / can_act). offer.j2 carries data-offer-state. Caddy auto-detects
text/event-stream and stops buffering — no Salt change.
Tests: 4 new functional tests (both endpoints stream the right
content-type + a state frame; 404 for outsiders / unknown ids). 994 passed.
9.1 KiB
Make-an-Offer Mode (MPS-21)
Buyer proposes a price for any product in pricing_mode=3 or 4. Seller
counters, accepts, or declines. Auto-accept and auto-decline thresholds
filter lowballs and instantly close high offers without queueing.
State Machine
┌─────────┐
buyer │ open │
opens → └────┬────┘
│
▼
┌─────────┐
│ pending │ ◄─────────┐
└────┬────┘ │
┌─────────┼─────────┐ │
▼ ▼ ▼ │ counter
accepted countered declined │ flips party
↓ │
(round_count++) │
└─────────────────────┘
terminal: accepted, declined, expired, withdrawn, paid
| state | description |
|---|---|
| 0 pending | open offer awaiting current_party action |
| 1 accepted | terminal; cart line item created at agreed amount |
| 2 countered | counter active (still pending to other party) |
| 3 declined | terminal; rejected |
| 4 expired | terminal; auto-expired past expires_timestamp |
| 5 withdrawn | terminal; buyer pulled the offer |
| 6 paid | terminal; cart payment succeeded |
current_party flips between BUYER (0) and SELLER (1) on each
counter; round_count caps at Shop.offer_max_rounds (default 3).
Models
MpsOffer— one row per negotiation (product, shop, buyer, current_amount, current_party, state, expires_timestamp, round_count, buyer_message, seller_message, paid_timestamp).MpsOfferEvent— audit log; one row per action (open / counter / accept / decline / withdraw / expire / pay) with actor_user_id.MpsCartOffer— cart-side association so checkout pays the agreed amount instead ofProduct.price_in_cents.
Shop Settings (form_section: offer-settings)
| field | default | purpose |
|---|---|---|
offer_enabled |
False | shop master toggle |
offer_min_in_cents |
NULL | reject offers below this floor (silent) |
offer_auto_accept_threshold_pct |
95 | offer ≥ N% of list → auto-accept |
offer_auto_decline_threshold_pct |
50 | offer < N% of list → auto-decline |
offer_expiration_hours |
168 (7d) | how long an offer stays open |
offer_max_rounds |
3 | counter cap before forcing accept/decline |
offer_min_buyer_account_age_hours |
0 | anti-spam (default open) |
Per-product override: Product.allow_offers (Boolean, nullable). NULL
inherits shop setting; True/False overrides.
Logic (lib/offer.py)
Pure validators:
validate_actor_turn— actor's party must matchoffer.current_party; terminal-state offers reject all actions.validate_round_cap— rejects whenround_count >= max_rounds(forces accept/decline at the cap).validate_floor— silent reject belowShop.offer_min_in_cents.auto_resolve_open— classifies a new offer as accept / decline / queue using shop's threshold percentages;list_price=0always queues.
Orchestrators (write MpsOfferEvent rows for audit):
open_offer— writes offer + OPEN event; applies auto-accept / auto-decline thresholds before queuing seller.counter_offer— flipscurrent_party, incrementsround_count, sets state COUNTERED, persists actor's message.accept_offer— terminal; caller's responsibility to write a cart line item atoffer.current_amount_in_cents.decline_offer— terminal.withdraw_offer— terminal; buyer-only (caller validates identity).expire_offer— idempotent system action; flips non-terminal offers pastexpires_timestampto EXPIRED.mark_paid— cart-success hook; ACCEPTED → PAID; raises if not in ACCEPTED state.
Self-offer (buyer == seller) blocking is the view layer's job (same pattern as auctions).
Tick (scripts/offer_tick.py)
Scans non-terminal offers (PENDING / COUNTERED) past expires_timestamp
and calls expire_offer on each. Idempotent.
Recommended cron: every 15 minutes — offers expire at hour granularity so coarse polling is enough.
Routes
POST /p/{product_id}/offer open new offer (login required)
GET /o/{offer_id} offer detail page (buyer + seller only)
POST /o/{offer_id}/counter counter the current amount
POST /o/{offer_id}/accept accept current amount (terminal)
POST /o/{offer_id}/decline decline current amount (terminal)
POST /o/{offer_id}/withdraw buyer-only terminal pull
POST /o/{offer_id}/checkout buyer pays accepted offer
GET /s/{shop_id}/offers operator inbox of all offers for the shop
GET /o/{offer_id}/events bounded SSE feed of the offer's state (buyer/seller only)
Live updates (bounded SSE)
/o/{offer_id}/events is a text/event-stream that polls the offer row
every ~1.5s, emits a data: {json} frame on connect and whenever the
state-machine state changes, sends a heartbeat comment, then closes after
~25s — the browser EventSource reconnects. This caps the worker-thread
hold per client (uWSGI is sync, ~16 threads; a truly long-lived SSE would
starve the pool). Shared helper: lib/sse.py (sse_response /
event_stream). Timings are settings (app.sse.hold_seconds,
app.sse.poll_interval_seconds; test.ini sets them tiny). The
generator uses its own short-lived DB session per poll (not
request.dbsession, which pyramid_tm has already closed by then).
offer.js opens the EventSource on the offer page and reload()s on a
state change (the whole page layout depends on state/can_act). Auctions
have the same: /a/{auction_id}/events (public) + auction.js calls
applyState() on each frame.
offer_open is registered before the product_slug catch-all so
/p/{id}/offer is not shadowed.
Operator inbox (/s/{shop_id}/offers)
views/offer.py:shop_offers (@shop_editor_required) lists every offer
for the shop — open (pending/countered) first, sorted by last action, then
terminal offers — in shop_offers.j2. Each row links to /o/{id} and to
the buyer's profile (/profile/{handle}?shop={shop_id}). Reachable from
/actions/view via the "🤝 Offers" button (shown when shop.offer_enabled).
Incoming offers still email the shop owners (send_offer_received_email);
this inbox is the in-app counterpart.
Identity / privacy
Offer history and the offer page show the buyer's display name
(User.display_name, which is the public name handle — full_name is
private) linked to /profile/{handle}, never the email. The profile page
reveals the email only to the user themselves, or to a shop owner/editor
viewing in that shop's context (?shop={shop_id}) when the profile user
has actually transacted there (an offer or an invoice) — see
views/user.py:user_profile. _serialize_offer carries buyer_name /
buyer_handle and per-event actor_name / actor_handle / actor_id
(no email).
Capability-driven presentation
Every POST route works as a plain browser form submit: the server flashes
a status message and 302-redirects to /o/{offer_id} (or back to the
product page on error). When JS is available, static/js/offer.js
intercepts the submit, posts with X-Requested-With: XMLHttpRequest, and
the same handlers return JSON instead of redirecting — the JS then
navigates to /o/{offer_id} without a full reload of the originating
page. The no-JS path is the source of truth; JSON is an enhancement.
Functional coverage: TestOfferRoutes drives the JSON path,
TestOfferNoJsFallback the plain-POST path.
offer.j2 also renders a state-aware notice (.offer-state-notice,
styled via the .alert variants) above the action forms — declined /
withdrawn / expired / accepted (+ pay-now hint for the buyer) / your-turn
/ waiting — so the viewer always understands the offer's state without
relying on a flash message that a JS redirect would skip. The booleans
come from _serialize_offer (is_declined, is_withdrawn, is_expired,
is_accepted, is_paid, plus can_act / is_open).
Cart Integration
When cart.cart_offers has one row, cart.total_price_in_cents
short-circuits to offer.current_amount_in_cents + handling +
gift-card-purchases. After standard cart payment success,
_finalize_auction_offer_state calls lib/offer.mark_paid which
flips state=PAID and writes the OFFER_EVENT_PAY audit row.
Email Notifications
OFFER_RECEIVED— sent to all shop owners when a new pending offer arrives (sent fromoffer_openview; auto-accept and auto-decline paths use different emails).OFFER_ACCEPTED— sent to the buyer when the seller (or buyer themselves) accepts the current amount.
Decline / counter / expire emails are not yet sent (deferred — same constraint as auction tick-driven emails: cron has no request).
Testing
5 model state helpers + 12 lib pure-function unit tests + 10 lib integration tests + 12 view functional tests + 9 form section functional tests + 13 cart integration tests + 4 tick tests + 4 email tests = 69 net new tests for MPS-21 (some shared with MPS-20 in cart integration).