make_post_sell/docs/auction-house.md
russell@unturf.com 2cad2482b8
MPS-20/MPS-21: capability-driven presentation for auction & offer actions
Every bid/buy-now/watch and offer open/counter/accept/decline/withdraw
POST now works as a plain browser submit: flash + 302 redirect to the
auction/offer page. JSON is returned only when the request carries
X-Requested-With: XMLHttpRequest. Adds offer.js progressive-enhancement
layer (mirrors auction.js); pay-now CTA on accepted offers; .offer-js-flash
styling; grid layout for offer/action forms. offer_accept emails the
buyer only on the transition into ACCEPTED.

Tests: TestOfferRoutes/TestAuctionRoutes now drive the JSON path via an
AJAX helper; new TestOfferNoJsFallback/TestAuctionNoJsFallback cover the
plain-POST redirect path. 973 passed.
2026-05-12 12:04:19 -04:00

4.8 KiB

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): bid, buy-now, and watch POSTs flash a status message and 302-redirect back to /a/{auction_id} for a plain browser submit; they return JSON only when the request carries X-Requested-With: XMLHttpRequest. The no-JS path is the source of truth; JSON is an enhancement. Functional coverage: TestAuctionRoutes drives the JSON path, TestAuctionNoJsFallback the plain-POST path.

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