# 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).