# 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 (one-shot; fallback poll)
GET  /a/{auction_id}/events     bounded SSE feed of auction state (public)
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)
- Live state via a **bounded SSE feed** `/a/{id}/events` (`auction_events`
  view → `lib/sse.py`): polls the row ~every 1.5s, emits a `data:` frame
  on connect and on bid/soft-close/state change, heartbeats, then closes
  after ~25s so `EventSource` reconnects (uWSGI sync workers can't hold
  long-lived connections). `auction.js` calls `applyState()` per frame.
  Where `EventSource` is unavailable it falls back to polling
  `/a/{id}.json` every 5s. The countdown is NOT part of the SSE change
  signal — the client derives it from `end_timestamp`.
- 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).
