CogniPrep sells practice for hiring assessments, and some of that is bought by schools and colleges on behalf of a cohort rather than by one candidate with a card. A school gets a code with a redemption cap: so many discounted purchases, then no more.
That cap sounds like a counter. It is not, and the reason is a timing problem that any capped promotion has.
Two obvious ways to count, both wrong
Count completed payments. A hundred students open checkout on the last remaining slot, all hundred pay, all hundred succeed. The cap was enforced against a number that was correct when each of them read it.
Count checkout starts. One student opens a checkout page, wanders off, closes the tab. That slot is gone forever, and there is nothing in the system that will ever give it back.
So a redemption is not a counter increment. It is a row with a lifecycle:
reserved -> holds a slot, with an expiry
completed -> payment landed, slot permanently consumed
released -> abandoned or never started, slot returned
And the cap is measured against completed + reserved, where the reserved half only counts while it is still live:
and(
eq(redemptions.status, 'reserved'),
gt(redemptions.expires_at, now)
)
That predicate is the whole self-healing property. A reserved row stops counting against the cap the moment its expires_at passes, with nobody doing anything. There is no sweeper job, no cron, no cleanup task that can fall over and silently oversell a school's allocation. An abandoned checkout returns its slot by doing nothing at all.
Two TTLs, and the order between them is load-bearing
Here is the part I got wrong first and would get wrong again without the comment I left:
/** How long the Stripe Checkout session stays open. */
export const CHECKOUT_SESSION_TTL_MS = 31 * 60 * 1000; // 31 minutes
/** How long a reservation holds a slot. */
export const RESERVATION_TTL_MS = 40 * 60 * 1000; // 40 minutes
The Stripe session must expire before the reservation does. If the reservation goes first, a student who pays at the last possible second lands a successful payment against a reservation that no longer exists, and the webhook has nothing to settle. Giving the reservation nine extra minutes means the session is always the first of the two to die, and the checkout.session.expired event has a window in which to arrive and release the slot early rather than waiting for the clock.
The 31 is not a rounding artefact either. Stripe requires expires_at to be at least 30 minutes out, so 31 leaves headroom for the round trip and any clock skew between us and them.
Worth saying out loud: these are two numbers in two different systems, one of which is not ours. The only thing keeping them ordered correctly is a comment. There is no constraint that can express "this Stripe field must be smaller than that Postgres column", and no test currently asserts it either, which is the honest state of it.
The session expiry is only set when a discount is applied
A detail I like, because it is a decision not to be clever:
/**
* Value for the session's `expires_at`, in unix seconds, set only when a
* discount was applied, so the session cannot outlive the slot it reserved.
*
* Undefined without a discount, which leaves Stripe's 24-hour default in
* place.
*/
expiresAt?: number;
Full-price checkouts have no reservation to bound. Giving them a 31-minute expiry "for consistency" would strand every buyer who opens the pricing page, gets distracted, and comes back after lunch. The short TTL exists to protect a held slot, so it applies exactly to the sessions that hold one.
Reserve, charge, attach, and release if anything threw
The ordering in the module that bridges a code to a Checkout session:
- reserve a slot, failing closed if the school's allocation is spent
- create the Stripe session at the discounted amount
- record the session id against the reservation
- release the reservation if step 2 threw
Step 3 is not bookkeeping. It is what makes the checkout.session.expired webhook able to find the right row later: the event carries a session id and nothing else we chose.
Step 4 is the one that is easy to skip because it almost never runs. A Stripe API error after a successful reservation leaves a slot held for a checkout that does not exist. It would heal on its own in 40 minutes, which is exactly the kind of "it is fine eventually" that makes a cohort of 30 students hit a spurious wall on the day their teacher told them to buy.
Concurrency: one lock, and no apology for it
Postgres runs at READ COMMITTED by default, which means two reservations arriving together both read "one slot left" and both insert. So every reservation takes a SELECT ... FOR UPDATE on the code row before it counts anything, which serialises attempts per code.
That is a coarse lock, and here it costs nothing. A school code sees a handful of redemptions a day, not a thousand a second. The contention argument against row locks is real at volume and irrelevant at this one, and I would rather have the obviously correct version than the clever version in the path that decides whether a student gets charged the right amount.
Settlement is idempotent in both directions
Stripe retries webhooks, so both transitions guard on the state they expect:
.set({ status: 'completed', completed_at: new Date() })
.where(and(eq(redemptions.id, redemptionId), eq(redemptions.status, 'reserved')))
.returning({ id: redemptions.id })
A second delivery updates zero rows and returns false, which the caller treats as "already settled" rather than an error. Release works the same way, so releasing an already-completed redemption cannot un-sell a purchase.
One deliberate asymmetry: a payment that lands against an expired reservation is still completed. The money arrived, so it is honoured, even though in a rare boundary race it can push a school one over its cap. Being one redemption generous beats telling someone their successful payment did not count.
The thing that is not about Stripe at all
The last piece is the period the cap is counted over. It is stored as a short string on each redemption row:
getAcademicYearPeriod(new Date('2027-08-31')); // '2026-27'
A school's allocation resets with the academic year, not the calendar year, because the cohort that used it in the spring has left by September. Storing the period as a key rather than deriving it from a date range at query time makes counting a school's usage an indexed equality match, and it keeps history intact when the period rolls over.
None of the above changes what a candidate sees. They type a code, the price goes down, and the practice hubs and assessment centre exercises unlock the same way they would at full price. The lifecycle exists so the school that handed out the code is not the one who finds out it was oversold.
Top comments (0)