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).
This commit is contained in:
russell@unturf.com 2026-05-09 21:46:18 -04:00
parent 2abf103676
commit 5e13a59412
No known key found for this signature in database
4 changed files with 303 additions and 2 deletions

View file

@ -404,6 +404,41 @@ Depth 20 covers any legitimate nesting while keeping N well below our exponentia
Bleach version: 6.3.0 (html5lib 1.1 vendored inside bleach).
Every webapp calling `bleach.clean(user_html)` is exposed — this is our correct fix.
## Auction & Make-an-Offer (MPS-20 + MPS-21)
Both ride on `Product.pricing_mode` (Integer, 0=fixed, 1=auction,
2=auction+buy_now, 3=offer, 4=offer+buy_now). Owner flips via product
edit form; the system creates a draft `MpsAuction` row when flipping
into auction mode.
Tables: `mps_auction`, `mps_bid`, `mps_auction_watcher`, `mps_offer`,
`mps_offer_event`, `mps_cart_auction`, `mps_cart_offer`.
Cart integration overrides `Cart.total_price_in_cents` when a
`cart_auction` or `cart_offer` association exists — pays the winning
bid or accepted offer amount instead of `Product.price_in_cents`.
State transitions are driven by cron:
- `make_post_sell.scripts.auction_tick` (every minute) —
SCHEDULED→ACTIVE on start_timestamp pass, ACTIVE→ENDED on
end_timestamp pass.
- `make_post_sell.scripts.offer_tick` (every 15min) —
PENDING/COUNTERED→EXPIRED past expires_timestamp.
Form sections:
- `pricing_mode` + `allow_offers` on product edit.
- `offer-settings` on shop settings (7 fields:
offer_enabled, auto_accept_threshold_pct, auto_decline_threshold_pct,
offer_min, offer_expiration_hours, offer_max_rounds,
offer_min_buyer_account_age_hours).
Routes (registered before `product_slug` / `shop_slug` catch-alls):
- `/a/{auction_id}` + `/a/{id}.json` + `/a/{id}/{bid,buy-now,watch,checkout}`
- `/p/{product_id}/offer` (open) + `/o/{offer_id}` + `/o/{id}/{counter,accept,decline,withdraw,checkout}`
See `docs/auction-house.md` and `docs/make-offer.md` for full state
machines and architecture.
## Feature Kill Switches (MPS-22)
Global feature flags live in `data/development.ini` (and override via env var

View file

@ -232,8 +232,8 @@ mps_page_session (raw rows)
| [MPS-17](tickets/mps-17.md) | REST API v1 — HMAC-signed product/content + upload | Open |
| [MPS-18](tickets/mps-18.md) | Karaoke Mode — Diagnose & Fix Vocal Isolation | Open (Broken in prod) |
| [MPS-19](tickets/mps-19.md) | BitTorrent / Magnet Link — Diagnose & Fix Distribution | Open (Broken in prod) |
| [MPS-20](tickets/mps-20.md) | Auction House Mode (eBay-style Bidding) | Proposed |
| [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Proposed |
| [MPS-20](tickets/mps-20.md) | Auction House Mode (eBay-style Bidding) | Complete |
| [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Complete |
| [MPS-22](tickets/mps-22.md) | Kill-Switch Feature Flags — Karaoke + Torrent Off by Default | Complete |
## Related Docs
@ -244,4 +244,6 @@ mps_page_session (raw rows)
| [JavaScript](JAVASCRIPT.md) | Client-side JS architecture |
| [Karaoke Pipeline](karaoke-pipeline.md) | Vocal isolation: pipeline, on-demand, streaming architecture |
| [Sandbox Mode](sandbox-mode.md) | Creative filter system |
| [Auction House](auction-house.md) | MPS-20: state machine, bidding logic, soft-close, proxy |
| [Make-an-Offer](make-offer.md) | MPS-21: state machine, counter rounds, auto-accept/decline |
| [Testing Performance](testing-performance.md) | Test suite optimization |

120
docs/auction-house.md Normal file
View file

@ -0,0 +1,120 @@
# Auction House Mode (MPS-20)
eBay-style bidding for any product. Shop owner flips `Product.pricing_mode`
to `1` (auction) or `2` (auction + buy-now) on the product edit page; the
system creates a draft `MpsAuction` row and the auction page goes live
once the owner schedules `start_timestamp` and `end_timestamp`.
## State Machine
```
draft → scheduled → active → ended → settled
↘ cancelled
```
| state | transition |
|-------|-----------|
| 0 draft | owner editing; not visible to buyers |
| 1 scheduled | countdown to start_timestamp; tick promotes to active |
| 2 active | accepting bids; soft-close extends end_timestamp |
| 3 ended | bidding closed; winner determined; payment_deadline_timestamp set |
| 4 settled | winner paid via cart; product transferred (mark_paid hook) |
| 5 cancelled | owner aborted (pre-active only) |
## Models
- `MpsAuction` — one per Product (unique index on `product_id`); fields
for state, timestamps, prices (start/reserve/buy_now), bid_increment,
soft_close_seconds, winner_user_id, payment_deadline_timestamp.
- `MpsBid` — one row per bid; `is_winning` flag flips when outbid.
Stores `max_proxy_in_cents` for proxy bidding.
- `MpsAuctionWatcher` — user follows the auction; drives notifications.
- `MpsCartAuction` — cart-side association so checkout pays the
winning bid amount instead of `Product.price_in_cents`.
## Bidding Logic (`lib/auction.py`)
Pure helpers:
- `validate_bid` — state must be ACTIVE; first bid >= start_price;
subsequent >= current_high + increment.
- `is_within_soft_close` / `extended_end_timestamp` — soft-close math.
- `resolve_proxy` — eBay-style: higher proxy wins; loser auto-bids
defending bidder up to `min(loser_proxy + increment, winner_proxy)`;
ties go to the existing top.
Orchestrator:
- `place_bid(auction, bidder, amount, max_proxy)` — validates, writes
the new `MpsBid`, marks prior winning bid `is_winning=False` with
`outbid_timestamp`, applies proxy resolution, applies soft-close,
flushes. Raises `BidRejected` on rejection.
## Buy-Now (mode 2)
`POST /a/{id}/buy-now` from a buyer places a bid at
`buy_now_price_in_cents`, sets `state=ENDED`, records winner. Buyer
proceeds to `/a/{id}/checkout` to pay.
## Soft-Close (anti-snipe)
A bid placed within `soft_close_seconds` of `end_timestamp` extends the
end by `soft_close_seconds`. `original_end_timestamp` preserves the
scheduled close for audit.
## Tick (`scripts/auction_tick.py`)
Cron-driven state transitions:
- `SCHEDULED + start_timestamp <= now → ACTIVE`
- `ACTIVE + end_timestamp <= now → ENDED`
- records winner from `is_winning` bid (if any)
- sets `payment_deadline_timestamp = now + 48h`
Recommended cron: every minute (`* * * * *`). 60s default soft-close
window means 1-min granularity is fine.
## Routes
```
GET /a/{auction_id} live page (auction.j2 + auction.js)
GET /a/{auction_id}.json JSON state for poll
POST /a/{auction_id}/bid place bid (login required, no self-bid)
POST /a/{auction_id}/buy-now end auction at buy_now (mode 2)
POST /a/{auction_id}/watch toggle watcher
POST /a/{auction_id}/checkout winner pays via standard cart flow
```
## Cart Integration
When `cart.cart_auctions` has one row, `cart.total_price_in_cents`
short-circuits to the winning bid amount + handling +
gift-card-purchases. After standard cart payment success,
`_finalize_auction_offer_state` flips `state=SETTLED` and records
`winner_user_id` + `winning_bid_id`.
## Live UI (`static/js/auction.js`)
- Countdown clock ticks every 1s (data-end-timestamp attribute)
- Polls `/a/{id}.json` every 5s for state changes
- AJAX bid submit; success/error flash without page reload
The page works fully without JS (capability-driven presentation).
JS enhances when available.
## Email Notifications
- `AUCTION_OUTBID` — sent to previous high bidder when their bid is
beaten (sent from `auction_bid` view, after `place_bid` succeeds
and a different user takes the lead).
Tick-driven won emails (when an auction ends) are deferred — tick
scripts run from cron without a Pyramid request context. A future
commit will either wire a request-less email path or queue the events
for the next view to flush.
## Testing
712 baseline tests + 27 (foundation) + 24 (lib/auction) + 11 (views)
+ 9 (form sections) + 13 (cart integration) + 10 (tick) + 4 (emails)
+ 3 (UI) = 813 net new across MPS-20 (some shared with MPS-21).

144
docs/make-offer.md Normal file
View file

@ -0,0 +1,144 @@
# 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).