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:
parent
2abf103676
commit
5e13a59412
4 changed files with 303 additions and 2 deletions
35
CLAUDE.md
35
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
120
docs/auction-house.md
Normal 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
144
docs/make-offer.md
Normal 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue