make_post_sell/docs/make-offer.md
russell@unturf.com 5e13a59412
MPS-20 + MPS-21: docs — auction-house.md, make-offer.md, architecture index
- docs/auction-house.md: MPS-20 reference — state machine, models,
  bidding logic (validate_bid, soft-close, proxy resolution),
  buy-now flow, tick scheduling, routes, cart integration, live UI,
  email notifications, test summary
- docs/make-offer.md: MPS-21 reference — state machine, models,
  shop settings, lib/offer pure validators + orchestrators, tick,
  routes, cart integration, email notifications, test summary
- docs/architecture.md: MPS-20 + MPS-21 marked Complete in ticket
  index; new docs added to Related Docs table
- CLAUDE.md: new "Auction & Make-an-Offer" section listing tables,
  cart integration, cron scripts, form sections, route registration
  rules, and pointers to the per-feature docs

Total: 943 tests pass (no code change in this commit).
2026-05-09 21:46:18 -04:00

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

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