# 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

```python
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
