make_post_sell/docs/tickets/mps-20.md
russell@unturf.com b3d9b2b39c
docs: tickets MPS-18..21 — karaoke/torrent fixes + auction/offer proposals
MPS-18: diagnose and fix broken karaoke vocal isolation pipeline.
MPS-19: diagnose and fix broken torrent / magnet link distribution.
MPS-20: propose eBay-style auction house mode (bidding, reserve,
soft-close, proxy, buy-now).
MPS-21: propose make-an-offer mode (counter/accept/decline/expire
state machine, auto-accept and auto-decline thresholds).

Each ticket carries proposal, full file list, models, state machine,
GTM plan, and unit/integration/functional test requirements.

architecture.md ticket index extended with MPS-17 (was missing) plus
the four new tickets.
2026-05-09 16:13:57 -04:00

13 KiB

MPS-20: Auction House Mode (eBay-style Bidding)

Status

PROPOSED — does not exist. Today every product has a fixed price column. No code path supports rising-price bidding, reserve prices, soft-close extensions, or proxy bidding.

Why This Belongs in MPS

MPS already owns:

  • Product lifecycle (visibility, ownership, downloads)
  • Shop settings + form-section pattern
  • Cart + checkout pipeline (Stripe / PayPal / crypto / gift cards)
  • Comments + watch mode + SPA navigation
  • Email notifications

An auction is a product whose price function is max(bids) instead of a constant. We extend, not rebuild. Same checkout. Same payouts. Same fees.

Modes

A shop owner toggles per product:

pricing_mode Behavior
0 (fixed) Today's behavior — buy now at price
1 (auction) Rising bids until end timestamp; winner pays high bid
2 (auction + buy_now) Both paths live; buy-now ends the auction immediately
3 (offer) See MPS-21 — make-an-offer mode

This ticket covers modes 1 and 2. MPS-21 covers mode 3.

State Machine

              ┌──────────┐
              │  draft   │  owner editing, not visible
              └────┬─────┘
                   │ schedule
                   ▼
              ┌──────────┐
              │scheduled │  visible, countdown to start
              └────┬─────┘
                   │ start_timestamp passes
                   ▼
              ┌──────────┐    bid placed
              │  active  │ ◄──────────┐
              └────┬─────┘            │
                   │                  │
       end_timestamp │   ┌────────────┴────────┐
       passes        │   │ soft-close: bid in  │
       no buy_now    │   │ last N seconds      │
                     ▼   │ extends end by N    │
              ┌──────────┴┐
              │   ended   │  winner determined; payment window opens
              └────┬──────┘
                   │ winner pays via cart
                   ▼
              ┌──────────┐
              │  settled │  funds captured, product transferred
              └──────────┘

Side branches:
  active --buy_now-→ ended (winner = buy_now buyer; bid refunds n/a)
  ended  --no-pay-→ relisted or default-to-second-bidder (configurable)
  any    --cancel-→ cancelled (owner action; pre-active only without admin override)

Models

MpsAuction

Column Type Purpose
id UUID row id
product_id UUID FK unique one auction per product
shop_id UUID FK shop
state int 0=draft 1=scheduled 2=active 3=ended 4=settled 5=cancelled
start_timestamp int (ms) when bidding opens
end_timestamp int (ms) when bidding closes (extended by soft-close)
original_end_timestamp int (ms) scheduled close; never updated
start_price_in_cents int minimum opening bid
reserve_price_in_cents int nullable hidden floor; below = no winner
buy_now_price_in_cents int nullable mode 2 only
bid_increment_in_cents int min step between bids; default 5% of current high
soft_close_seconds int default 60; bid in last N → extend end by N
winner_user_id UUID FK nullable populated when state=ended
winning_bid_id UUID FK nullable populated when state=ended
payment_deadline_timestamp int (ms) nullable winner-pay-by; default end + 48h
currency str(3) inherits shop default

MpsBid

Column Type Purpose
id UUID row id
auction_id UUID FK auction
bidder_user_id UUID FK bidder
amount_in_cents int actual bid amount (proxy bidding fills up)
max_proxy_in_cents int bidder's secret max; proxy auto-bids up to this
created_timestamp int (ms) bid placement
outbid_timestamp int (ms) nullable when this bid was passed
is_winning bool true for current high bid only

MpsAuctionWatcher

Column Type Purpose
id UUID row id
auction_id UUID FK auction
user_id UUID FK watcher
created_timestamp int (ms) when added
notify_on_outbid bool default True
notify_on_ending_soon bool default True (1h, 5min)

Views / Routes

GET  /a/{auction_id}                       auction page (live)
GET  /a/{auction_id}.json                  auction state poll (1s for active, 10s for scheduled)
POST /a/{auction_id}/bid                   place bid (form_section=bid)
POST /a/{auction_id}/buy-now               buy-now (mode 2)
POST /a/{auction_id}/watch                 toggle watcher
POST /a/{auction_id}/cancel                owner cancel (pre-active only)
GET  /s/{shop_id}/auctions                 owner's auction dashboard
POST /s/{shop_id}/products/{id}/auction    create or update auction (form_section=auction)
GET  /u/{user_id}/auctions                 user's bids + watches

State transitions (scheduled → active → ended) run via a periodic job — add a tick to the existing background task system (or a new scripts/auction_tick.py cron). On settled-by-payment, the cart's normal post-payment hooks already do product transfer; no new path.

Soft-Close Algorithm

def place_bid(auction, bidder, amount, max_proxy):
    now = now_ms()
    if auction.state != STATE_ACTIVE:
        raise BidRejected("auction not active")
    if amount < (auction.current_high + auction.bid_increment_in_cents):
        raise BidRejected("bid too low")
    bid = MpsBid(auction_id=auction.id, bidder_user_id=bidder.id,
                 amount_in_cents=amount, max_proxy_in_cents=max_proxy)
    DBSession.add(bid)
    _resolve_proxy(auction, bid)  # auto-bid against existing max_proxy bids
    if (auction.end_timestamp - now) < (auction.soft_close_seconds * 1000):
        auction.end_timestamp = now + (auction.soft_close_seconds * 1000)
    notify_outbid(auction)  # email + watcher inbox

Pure-function _resolve_proxy — unit-testable in isolation, no DB hit beyond bid insert. Tarjan-style: handle the bid + proxy chain in a single pass (no O(N²) loop over all bids).

Cart Integration

When state=ended and winner_user_id matches the request user, the auction product appears in cart at winning_bid.amount_in_cents. Existing checkout (views/cart.py) handles payment + transfer. After cart success hook, set auction.state=4 (settled).

If payment_deadline_timestamp passes without payment, run a settle-task:

  • Default: relist to second-highest bidder at their bid price (configurable auction_default_to_second_bidder shop setting)
  • Alternative: cancel + return to seller (auctioneer chooses)

Form Section

Add auction to form_section routing in views/product.py:

field purpose
pricing_mode 0/1/2/3
auction_start_timestamp datetime-local input → ms
auction_end_timestamp datetime-local input → ms
start_price dollars → cents
reserve_price dollars → cents (nullable)
buy_now_price dollars → cents (mode 2 only)
bid_increment dollars → cents
soft_close_seconds int

Notifications

Email + on-site:

  • Bid received (to seller)
  • Outbid (to previous high bidder)
  • Auction ending in 1h / 5min (to watchers + bidders)
  • Won — pay by {deadline} (to winner)
  • Lost (to underbidders)
  • Payment received / settled (to seller + winner)

Anti-Abuse

  • Rate limit POST /a/{id}/bid — 1 bid per bidder per second
  • Min bidder account age (configurable per shop, default 0 = open)
  • Min cart history (configurable, default 0)
  • Block self-bid (bidder_user_id == seller_user_id) at form layer
  • Reserve-not-met UX: show "reserve not met" indicator without leaking reserve price

Go-to-Market

Surface Action
docs/auction-house.md New: state machine + dot diagram + APIs
docs/architecture.md Add auction tables + pricing_mode to feature matrix
/styleguide Bid form, countdown clock, reserve indicator, watcher toggle, soft-close pulse
~/git/www.makepostsell.com/index.html Feature card: "Run auctions. Reserve prices, soft-close, proxy bids. Same payments, same fees."
~/git/www.makepostsell.com/pricing.html Auction mode listed in plan includes
Marketing copy Position: commission-free auctions vs. eBay's 13.25% final value fee

Files

File Change
make_post_sell/models/auction.py New: MpsAuction, MpsBid, MpsAuctionWatcher
make_post_sell/models/__init__.py Imports
make_post_sell/models/meta.py Register all three tables
make_post_sell/models/product.py Add pricing_mode (Integer, default=0), auction relationship
make_post_sell/models/cart.py Auction-product cart line item handling
make_post_sell/views/auction.py New: all auction route handlers
make_post_sell/views/product.py form_section=auction handler
make_post_sell/views/cart.py Recognize auction-won line item; price = winning bid
make_post_sell/views/shop.py auctions_dashboard view
make_post_sell/lib/auction.py Pure-function bid resolution + proxy + soft-close logic
make_post_sell/lib/auction_tick.py Scheduled state transitions + ending-soon notifications
make_post_sell/lib/email_notifications.py Auction email templates
make_post_sell/routes.py All auction routes
make_post_sell/templates/auction.j2 Live auction page
make_post_sell/templates/auctions_dashboard.j2 Owner dashboard
make_post_sell/templates/product_edit.j2 Auction config form section
make_post_sell/templates/styleguide.j2 Component examples
make_post_sell/static/js/auction.js Live countdown, bid form, soft-close pulse, JSON poll
make_post_sell/static/css/common.css Auction component styles (tokens-only)
make_post_sell/scripts/alembic/versions/XXXX_auction_tables.py Migration with _table_exists + _column_exists guards
make_post_sell/scripts/auction_tick.py CLI entry point for cron
make_post_sell/tests/test_models.py Unit tests
make_post_sell/tests/test_integration.py Integration tests
make_post_sell/tests/test_functional.py Functional tests

Tests

Unit (test_models.py)

  • MpsAuction state helpers (is_active, is_ended, time_remaining_ms)
  • MpsAuction.current_high returns max bid amount
  • MpsAuction.reserve_met boolean
  • MpsBid.is_winning flag flips correctly
  • lib/auction._resolve_proxy — proxy bidding outcomes:
    • solo proxy bid: bid recorded at start_price + increment
    • two competing proxies: high proxy wins at low_proxy + increment
    • chained proxies: terminate cleanly, no infinite loop
  • Soft-close: bid > N sec from end → no extension; bid < N sec → end pushed
  • Product.is_auction / Product.is_buy_now_only based on pricing_mode

Integration (test_integration.py)

  • Place bid below increment → rejected; above → accepted; outbid email sent
  • Buy-now in mode 2 ends auction, sets winner, refunds bid hold (n/a — no holds)
  • Auction ends without bids meeting reserve → no winner; state=ended, winner_user_id IS NULL
  • Winner pays via cart → state=settled; product transfer fires
  • Winner does not pay by deadline → second bidder gets the option (configurable per shop)
  • Watcher receives ending-soon email at 1h and 5min before end
  • BYOB: auction cover/preview images use shop bucket
  • auction_tick job: scheduled → active when start_timestamp passes; active → ended when end_timestamp passes

Functional (test_functional.py)

  • GET /a/{id} renders for anon, signed-in non-bidder, owner, current high bidder
  • POST /a/{id}/bid — anon → redirect to login; signed in → 200
  • POST /a/{id}/bid rate limit: 2 bids in 1s → second 429
  • POST /a/{id}/bid self-bid → 403 with flash message
  • POST /a/{id}/buy-now — mode 1 → 404; mode 2 → 200, auction ends
  • POST /a/{id}/watch toggle inserts/removes MpsAuctionWatcher row
  • POST /a/{id}/cancel — pre-active by owner → 200; active by owner → 403
  • Owner dashboard /s/{shop_id}/auctions lists all states with counts
  • Watch mode SPA: navigating between auction products updates auction-specific elements (current high, time left, bid form auction_id)
  • auction.json endpoint returns full state shape for poll

Verification

  1. source vars.sh && make test — all pass
  2. Local dev: create draft auction, schedule, watch state → active, place bids from two browser sessions, observe soft-close extension, end auction, pay as winner, verify settled state + product transferred
  3. Test reserve-not-met path
  4. Test second-bidder fallback
  5. Email log shows all 6 notification types fire
  6. Push → CI green → deploy → bump GIT_HASH
  7. Update marketing portal index.html + pricing.html