make_post_sell/docs/make-offer.md
russell@unturf.com 157e6f769a
feat: bounded SSE feeds for offer & auction state machines
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.
2026-05-12 21:03:50 -04:00

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 of Product.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 match offer.current_party; terminal-state offers reject all actions.
  • validate_round_cap — rejects when round_count >= max_rounds (forces accept/decline at the cap).
  • validate_floor — silent reject below Shop.offer_min_in_cents.
  • auto_resolve_open — classifies a new offer as accept / decline / queue using shop's threshold percentages; list_price=0 always queues.

Orchestrators (write MpsOfferEvent rows for audit):

  • open_offer — writes offer + OPEN event; applies auto-accept / auto-decline thresholds before queuing seller.
  • counter_offer — flips current_party, increments round_count, sets state COUNTERED, persists actor's message.
  • accept_offer — terminal; caller's responsibility to write a cart line item at offer.current_amount_in_cents.
  • decline_offer — terminal.
  • withdraw_offer — terminal; buyer-only (caller validates identity).
  • expire_offer — idempotent system action; flips non-terminal offers past expires_timestamp to 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 from offer_open view; 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).