Skip to content

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.

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

Bidding is gated on a registration row that must satisfy all four conditions, checked on every single bid:

  1. status = 'verified'
  2. termsAcceptedAt is set
  3. stripePaymentMethodId is set — a real card on file, collected through a SetupIntent (createBidderSetupIntentcompleteBidderCardSetup)
  4. 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.

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 + bidIncrement
otherwise → startingBid

Only open accepts bids. closed_pending_payment and reoffered are charge-aware intermediate states, and the single status check covers them all.

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.

resolveAntiSnipeEndAt is pure and easy to reason about:

insideWindow = (endAt - bidPlacedAt) <= windowSeconds
if !insideWindow → no extension
proposedEnd = bidPlacedAt + extensionSeconds
cappedEnd = min(proposedEnd, maxEndAt)
if cappedEnd <= endAt → no extension
else → endAt = cappedEnd

Config 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.

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.

/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.

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.

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.