make_post_sell/docs/make-offer.md
russell@unturf.com 227fc4e564
MPS-21: offer-page state notice + design-system Make-an-Offer settings form
- offer.j2 renders a state-aware notice (.offer-state-notice, .alert
  variants) above the action forms: declined / withdrawn / expired /
  accepted (+ pay-now hint for buyer) / your-turn / waiting — so the
  viewer always understands the offer state without depending on a flash
  a JS redirect would skip. _serialize_offer now exposes is_declined,
  is_withdrawn, is_expired, is_pending, is_countered, is_accepted.
- Shop-settings Make-an-Offer section restyled with the new
  .settings-form / .settings-form-grid / .settings-field /
  .settings-field-hint system (two-up grid, per-field hints, submit
  pinned right). Added a styleguide entry under #forms.
- Reworded the section blurb: auto-declined offers are NOT silent — the
  buyer is told their offer was too low; only the seller isn't pinged.
- Fixed --color-text-muted typo (→ --text-muted) on .offer-js-flash-info.

Tests: TestOfferRoutes gains 3 state-notice render tests;
TestSettingsFormStyleguide covers the styleguide + live shop-settings
markup and asserts the old wording is gone. 978 passed.
2026-05-12 16:34:21 -04:00

6.9 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

offer_open is registered before the product_slug catch-all so /p/{id}/offer is not shadowed.

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