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.
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_biddershop 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)
MpsAuctionstate helpers (is_active,is_ended,time_remaining_ms)MpsAuction.current_highreturns max bid amountMpsAuction.reserve_metbooleanMpsBid.is_winningflag flips correctlylib/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_onlybased onpricing_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_tickjob: scheduled → active whenstart_timestamppasses; active → ended whenend_timestamppasses
Functional (test_functional.py)
GET /a/{id}renders for anon, signed-in non-bidder, owner, current high bidderPOST /a/{id}/bid— anon → redirect to login; signed in → 200POST /a/{id}/bidrate limit: 2 bids in 1s → second 429POST /a/{id}/bidself-bid → 403 with flash messagePOST /a/{id}/buy-now— mode 1 → 404; mode 2 → 200, auction endsPOST /a/{id}/watchtoggle inserts/removesMpsAuctionWatcherrowPOST /a/{id}/cancel— pre-active by owner → 200; active by owner → 403- Owner dashboard
/s/{shop_id}/auctionslists all states with counts - Watch mode SPA: navigating between auction products updates auction-specific
elements (current high, time left, bid form
auction_id) auction.jsonendpoint returns full state shape for poll
Verification
source vars.sh && make test— all pass- 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
- Test reserve-not-met path
- Test second-bidder fallback
- Email log shows all 6 notification types fire
- Push → CI green → deploy → bump GIT_HASH
- Update marketing portal index.html + pricing.html