Auction bidding flow
Auctions are a separate subsystem from the cart: no holds, no PaymentIntent per item. A bidder puts a card on file first, and the winning lot is charged off-session the moment it closes.
Primary code: packages/core/src/server/auctions/ —
place-bid.ts, bid-validation.ts, anti-snipe.ts, close-lot.ts,
charge-winner.ts, register-bidder.ts. Router:
packages/core/src/server/trpc/routers/auctions.ts.
Lifecycle
Section titled “Lifecycle”stateDiagram-v2
[*] --> scheduled
scheduled --> open: adminStartAuctionLot
open --> open: bid (anti-snipe may extend endAt)
open --> paused: adminPauseAuctionLot
paused --> open: adminResumeAuctionLot
open --> closed_pending_payment: endAt passed, has high bidder
open --> unsold: endAt passed, no qualifying bid
closed_pending_payment --> paid: winner charge succeeds
closed_pending_payment --> reoffered: charge fails, runner-up offered
reoffered --> paid: runner-up charge succeeds
reoffered --> unsold: 3 attempts exhausted
Becoming a bidder
Section titled “Becoming a bidder”Bidding is gated on a registration row that must satisfy all four conditions, checked on every single bid:
status = 'verified'termsAcceptedAtis setstripePaymentMethodIdis set — a real card on file, collected through a SetupIntent (createBidderSetupIntent→completeBidderCardSetup)- a phone number on the profile
The phone check lives in placeBid as well as in registerBidder: bid
notifications (outbid, winner, payment deadline) go out by phone, and rows
verified before that rule shipped would otherwise slip through.
Validating a bid
Section titled “Validating a bid”validateAuctionBid returns accepted, or one of five rejection codes. Order
matters:
| Order | Check | Code |
|---|---|---|
| 1 | Event is live |
EVENT_NOT_LIVE |
| 2 | Lot is open |
LOT_NOT_OPEN |
| 3 | endAt still in the future |
LOT_ENDED |
| 4 | Registration verified, terms accepted, card on file | BIDDER_NOT_VERIFIED |
| 5 | Amount ≥ next minimum | BID_BELOW_MINIMUM |
Event status is checked before lot status, so a lot mistakenly left open
on a draft or preview event can never take a bid. The copy differentiates
pre-live (“not live yet”) from post-live (“has ended”) so a stale tab after
settlement does not read as “try again later”.
Next minimum:
currentBid > 0 → currentBid + bidIncrementotherwise → startingBidOnly open accepts bids. closed_pending_payment and reoffered are
charge-aware intermediate states, and the single status check covers them all.
Self-raise
Section titled “Self-raise”The standing high bidder may raise their own bid (decision D-A01, 2026-09-06). It still has to clear the same next minimum, so a self-raise can never equal or undercut the standing bid.
Anti-snipe
Section titled “Anti-snipe”resolveAntiSnipeEndAt is pure and easy to reason about:
insideWindow = (endAt - bidPlacedAt) <= windowSecondsif !insideWindow → no extensionproposedEnd = bidPlacedAt + extensionSecondscappedEnd = min(proposedEnd, maxEndAt)if cappedEnd <= endAt → no extensionelse → endAt = cappedEndConfig resolves in this order: explicit override (tests) → the event row →
defaults of 10 s window / 10 s extension / 5 min cap. The cap is computed
from the lot’s scheduledEndAt, not its current endAt, so an
admin-extended lot cannot bypass it and an extension chain cannot run forever.
Writing the bid
Section titled “Writing the bid”Inside one transaction:
SELECT pg_advisory_xact_lock(hashtext('<lotId>'));then the update is written with a status = 'open' predicate and
.returning() is inspected. That predicate is not redundant: validation ran
before the lock, so a concurrent adminPauseAuctionLot may have won the race.
If it did, zero rows match, and the bid raises the same LOT_NOT_OPEN
rejection — rolling back the winning-bid insert with it. .returning() is used
instead of the driver’s affected-row count so a driver without a count field
cannot silently reject every bid.
The prior high bidder is snapshotted before the status flip so the outbid email can be sent after the transaction commits. It is null on the first bid for a lot, and on a self-raise.
Closing a lot
Section titled “Closing a lot”/api/cron/auction-close walks every open lot whose endAt has passed and
calls closeAuctionLot once per lot — one transaction, one advisory lock each.
Two cron invocations racing on the same lot serialise on the lock, and the
loser’s UPDATE matches zero rows because the status is no longer open.
open → closed_pending_payment (currentBid > 0 and a high bidder exists)open → unsold (no qualifying winner)The status machine is additionally enforced in the database by the
auction_lots_status_check constraint. paymentDeadline is set to
now + paymentDeadlineHours and bounds the runner-up chain.
Charging the winner
Section titled “Charging the winner”Immediately after a close that produced an invoice, the cron calls
chargeAuctionWinner: an off-session PaymentIntent against the card on
file. On any failure it falls back to the next-highest distinct bidder at
their own bid amount, not at the failed top bid. The chain caps at three
charge attempts, after which the lot is marked unsold.
There is deliberately no SCA-recovery wait. The event is time-sensitive and a bidder who has left the venue should not gate a lot for hours. The trade-off is explicit: a card that demands 3DS can lose the lot to a runner-up, and the original winner gets a courtesy decline email explaining what happened.
Idempotency: auction_invoices.stripe_payment_intent_id carries a partial
unique index, so webhook redelivery is a no-op, and the invoice status update is
conditional to block a double paid transition.
Admin controls
Section titled “Admin controls”adminPauseAuctionLot, adminResumeAuctionLot, adminStartAuctionLot,
adminExtendAuctionLot, adminCloseAuctionLot, adminForceReoffer,
adminCancelInvoice and adminMarkInvoicePaid are all admin-gated —
adminMarkInvoicePaid additionally to finance, which is why
/admin/auctions/[id]/winners lists both manage_events and view_bookings in
PAGE_ACCESS.
