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.
205 lines
9.1 KiB
Markdown
205 lines
9.1 KiB
Markdown
# 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).
|