diff --git a/CLAUDE.md b/CLAUDE.md index 1712607..43fec81 100644 --- a/CLAUDE.md +++ b/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 diff --git a/docs/architecture.md b/docs/architecture.md index 3436c32..ad90456 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/auction-house.md b/docs/auction-house.md new file mode 100644 index 0000000..7cf762f --- /dev/null +++ b/docs/auction-house.md @@ -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). diff --git a/docs/make-offer.md b/docs/make-offer.md new file mode 100644 index 0000000..78761df --- /dev/null +++ b/docs/make-offer.md @@ -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).