Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,16 @@ people (or two devices for the same person) can both resume the same session.

The checkout session moves through a single state machine (`status` field):

- **created** — session just made for a selected listing; inventory hold placed.
- **active** — fan is viewing/resuming the session; inventory still held, price/inventory
- **active** — session created with inventory held; fan is viewing/resuming; price/inventory
can still change underneath it.
- **pending_payment** — fan submitted payment; awaiting provider result. This is the window
where a second device resuming the same session is the actual duplicate-order hazard the
prompt calls out.
- **completed** — order placed successfully. Terminal.
- **expired** — inventory hold lapsed before completion. Terminal unless the fan starts a
fresh session (re-priced/re-held).
- **failed** — payment or completion failed. Fan may retry (transitions back to **active**)
or the session expires.
- **failed** — payment or completion failed. Fan may retry (claims again through
**pending_payment**) or the session expires.

## Price Reconfirmation

Expand All @@ -50,3 +49,8 @@ own `expiresAt` implies the hold is still good — the hold can lapse independen
shorter TTL than the session, or inventory reclaimed for other reasons). A session can be
unexpired but reference inventory that is no longer held; that combination surfaces to the
fan as a distinct "listing no longer available" state, not the same as "session expired."
Conversely, when the session clock itself lapses, checkout also releases the hold
(request-time via `expireIfNeeded`, or in the background via `expireLapsedSessions` /
`SessionExpirySweeper`) so inventory isn't stranded — `expiryReason` stays
`session_lapsed` (why the session died), not `hold_released` (hold disappeared while the
session was still live).
317 changes: 107 additions & 210 deletions README.md

Large diffs are not rendered by default.

21 changes: 11 additions & 10 deletions apps/api/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { PrismaClient } from '@prisma/client';
import type { CreateUserInput, User } from '@repo/api-contracts';
import { DEMO_CATALOG } from '@repo/api-contracts';
import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify';

import { CheckoutService } from './domain/checkout-service';
import { EventLog } from './domain/events';
import type { InventoryProvider } from './domain/inventory-provider';
import { FakeInventoryProvider } from './domain/inventory-provider';
import { FakePaymentProvider } from './domain/payment-provider';
import { SessionExpirySweeper } from './domain/session-expiry-sweeper';
import { InMemorySessionStore } from './domain/session-store';

const prisma = new PrismaClient();
Expand Down Expand Up @@ -37,16 +39,8 @@ const inventoryProvider = new FakeInventoryProvider();
const paymentProvider = new FakePaymentProvider();
const eventLog = new EventLog();

// Demo catalog — presentation fixtures in @repo/ui key off these listing ids.
const DEMO_LISTINGS: Array<{ listingId: string; priceCents: number }> = [
{ listingId: 'listing_1', priceCents: 15400 },
{ listingId: 'listing_2', priceCents: 12500 },
{ listingId: 'listing_3', priceCents: 8900 },
{ listingId: 'listing_4', priceCents: 21000 },
{ listingId: 'listing_5', priceCents: 167600 },
];

for (const listing of DEMO_LISTINGS) {
// Demo catalog — shared with UI presentation via @repo/api-contracts.
for (const listing of DEMO_CATALOG) {
inventoryProvider.seedListing(listing.listingId, listing.priceCents);
}

Expand All @@ -57,6 +51,13 @@ const checkoutService = new CheckoutService(
eventLog,
);

/** In-process TTL sweeper bound to the shared checkout service. */
export function createSessionExpirySweeper(
options: ConstructorParameters<typeof SessionExpirySweeper>[1] = {},
): SessionExpirySweeper {
return new SessionExpirySweeper(checkoutService, options);
}

// Wire up your auth provider (e.g. Clerk) here to populate userId.
export function createContext({ req: _req }: CreateFastifyContextOptions): Context {
return {
Expand Down
126 changes: 115 additions & 11 deletions apps/api/src/domain/checkout-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,34 @@ describe('CheckoutService', () => {
expect(completed.status).toBe('completed');
});

it('returns live hold price on resume so clients can map price_changed', async () => {
const { service, inventory } = setup();
const session = await service.createSession('listing_1');
inventory.setPrice('listing_1', 5000);

const resumed = await service.resumeSession(session.id, 'web');

expect(resumed.session.status).toBe('active');
expect(resumed.livePriceCents).toBe(5000);
});

it('expires a session once its expiresAt has passed', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
const { service } = setup();
const session = await service.createSession('listing_1');
try {
const { service, inventory } = setup();
const session = await service.createSession('listing_1');

jest.setSystemTime(new Date('2026-01-01T00:11:00.000Z'));
const resumed = await service.resumeSession(session.id, 'mobile');
jest.setSystemTime(new Date('2026-01-01T00:11:00.000Z'));
const resumed = await service.resumeSession(session.id, 'mobile');

expect(resumed.status).toBe('expired');
jest.useRealTimers();
expect(resumed.session.status).toBe('expired');
expect(resumed.session.expiryReason).toBe('session_lapsed');
// TTL lapse must free inventory — otherwise the listing stays held forever.
await expect(inventory.getHoldStatus('listing_1')).resolves.toMatchObject({ held: false });
await expect(service.createSession('listing_1')).resolves.toMatchObject({ status: 'active' });
} finally {
jest.useRealTimers();
}
});

it('marks a session expired on resume if inventory hold was released independently', async () => {
Expand All @@ -62,7 +80,7 @@ describe('CheckoutService', () => {

const resumed = await service.resumeSession(session.id, 'web');

expect(resumed.status).toBe('expired');
expect(resumed.session.status).toBe('expired');
});

it('blocks completion when price changed and has not been reconfirmed', async () => {
Expand Down Expand Up @@ -145,10 +163,11 @@ describe('CheckoutService', () => {
inventory.releaseListing('listing_2');
const dropped = await service.resumeSession(held.id, 'web');

expect(lapsed.status).toBe('expired');
expect(lapsed.expiryReason).toBe('session_lapsed');
expect(dropped.status).toBe('expired');
expect(dropped.expiryReason).toBe('hold_released');
expect(lapsed.session.status).toBe('expired');
expect(lapsed.session.expiryReason).toBe('session_lapsed');
await expect(inventory.getHoldStatus('listing_1')).resolves.toMatchObject({ held: false });
expect(dropped.session.status).toBe('expired');
expect(dropped.session.expiryReason).toBe('hold_released');
} finally {
jest.useRealTimers();
}
Expand Down Expand Up @@ -272,4 +291,89 @@ describe('CheckoutService', () => {
});
jest.useRealTimers();
});

describe('expireLapsedSessions', () => {
it.each([
{
name: 'active session past TTL',
listingId: 'listing_1',
advanceMs: 11 * 60 * 1000,
mutate: null as null | 'pending_payment',
expectSwept: 1,
expectHeld: false,
expectStatus: 'expired' as const,
expectReason: 'session_lapsed' as const,
},
{
name: 'active session still within TTL',
listingId: 'listing_1',
advanceMs: 60 * 1000,
mutate: null as null | 'pending_payment',
expectSwept: 0,
expectHeld: true,
expectStatus: 'active' as const,
expectReason: undefined,
},
{
name: 'pending_payment past TTL',
listingId: 'listing_1',
advanceMs: 11 * 60 * 1000,
mutate: 'pending_payment' as const,
expectSwept: 0,
expectHeld: true,
expectStatus: 'pending_payment' as const,
expectReason: undefined,
},
])(
'handles $name',
async ({
listingId,
advanceMs,
mutate,
expectSwept,
expectHeld,
expectStatus,
expectReason,
}) => {
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
try {
const { service, store, inventory } = setup();
const session = await service.createSession(listingId);
if (mutate) {
store.casUpdate(session.id, 'active', (current) => ({
...current,
status: mutate,
}));
}

jest.advanceTimersByTime(advanceMs);
await expect(service.expireLapsedSessions()).resolves.toBe(expectSwept);

const after = store.get(session.id);
expect(after?.status).toBe(expectStatus);
expect(after?.expiryReason).toBe(expectReason);
await expect(inventory.getHoldStatus(listingId)).resolves.toMatchObject({
held: expectHeld,
});
} finally {
jest.useRealTimers();
}
},
);

it('leaves completed sessions and their sold inventory alone', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
try {
const { service, inventory } = setup();
const session = await service.createSession('listing_1');
await service.completeSession(session.id, 'web');

jest.advanceTimersByTime(11 * 60 * 1000);
await expect(service.expireLapsedSessions()).resolves.toBe(0);
await expect(inventory.getHoldStatus('listing_1')).resolves.toMatchObject({ held: true });
} finally {
jest.useRealTimers();
}
});
});
});
96 changes: 75 additions & 21 deletions apps/api/src/domain/checkout-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { CheckoutSession, CheckoutSurface, SessionExpiryReason } from '@repo/api-contracts';
import type {
CheckoutSession,
CheckoutSurface,
ResumeSessionResult,
SessionExpiryReason,
} from '@repo/api-contracts';
import { nanoid } from 'nanoid';

import type { EventLog } from './events';
Expand Down Expand Up @@ -75,17 +80,22 @@ export class CheckoutService {
return session;
}

async resumeSession(id: string, surface: CheckoutSurface): Promise<CheckoutSession> {
async resumeSession(id: string, surface: CheckoutSurface): Promise<ResumeSessionResult> {
// Session expiration and the inventory hold are two independent clocks: a
// session can be unexpired but reference inventory that is no longer held,
// so resume checks the hold live rather than trusting `expiresAt`.
let session = await this.expireIfNeeded(this.mustGet(id));
let livePriceCents: number | null = null;
if (!this.isTerminal(session)) {
const holdStatus = await this.inventory.getHoldStatus(session.listingId);
if (!holdStatus.held) session = this.expireNow(session, 'hold_released');
if (!holdStatus.held) {
session = this.expireNow(session, 'hold_released');
} else {
livePriceCents = holdStatus.currentPrice;
}
}
this.events.emit({ name: 'session_resumed', sessionId: id, toSurface: surface });
return session;
return { session, livePriceCents };
}

async confirmPrice(id: string): Promise<CheckoutSession> {
Expand All @@ -97,11 +107,10 @@ export class CheckoutService {
if (session.status === 'pending_payment') throw new ConflictError(id);

const holdStatus = await this.inventory.getHoldStatus(session.listingId);
const updated = this.store.casUpdate(id, session.status, (session) => ({
...session,
const updated = this.mustCasUpdate(id, session.status, (current) => ({
...current,
acknowledgedPrice: holdStatus.currentPrice,
}));
if (!updated) throw new ConflictError(id);
this.events.emit({ name: 'price_reconfirmed', sessionId: id });
return updated;
}
Expand All @@ -128,29 +137,28 @@ export class CheckoutService {
// Claim the session before charging. Whichever surface wins this swap owns
// the payment attempt; the loser gets a ConflictError instead of a
// duplicate order.
const claimed = this.store.casUpdate(id, session.status, (session) => ({
...session,
this.mustCasUpdate(id, session.status, (current) => ({
...current,
status: 'pending_payment',
}));
if (!claimed) throw new ConflictError(id);

const outcome = await this.payment.charge(id, holdStatus.currentPrice);
if (outcome === 'succeeded') {
const completed = this.store.casUpdate(id, 'pending_payment', (session) => ({
...session,
const completed = this.mustCasUpdate(id, 'pending_payment', (current) => ({
...current,
status: 'completed',
}));
this.events.emit({ name: 'session_completed', sessionId: id, surface });
return completed as CheckoutSession;
return completed;
}

const failed = this.store.casUpdate(id, 'pending_payment', (session) => ({
...session,
const failed = this.mustCasUpdate(id, 'pending_payment', (current) => ({
...current,
status: 'failed',
failureReason: outcome,
}));
this.events.emit({ name: 'session_failed', sessionId: id, surface });
return failed as CheckoutSession;
return failed;
}

/**
Expand All @@ -173,25 +181,71 @@ export class CheckoutService {
return expired;
}

/**
* Background / on-demand sweep: expire sessions past `expiresAt` and free
* their holds. Same lapse path as request-time TTL. Skips mid-charge.
* @returns how many sessions this pass newly marked session_lapsed
*/
async expireLapsedSessions(): Promise<number> {
let swept = 0;
for (const session of this.store.list()) {
if (!this.isEligibleForSessionClockLapse(session)) continue;
const expired = await this.lapseForSessionClock(session);
if (expired) swept += 1;
}
return swept;
}

private mustGet(id: string): CheckoutSession {
const session = this.store.get(id);
if (!session) throw new SessionNotFoundError(id);
return session;
}

private mustCasUpdate(
id: string,
expectedStatus: CheckoutSession['status'],
updater: (session: CheckoutSession) => CheckoutSession,
): CheckoutSession {
const updated = this.store.casUpdate(id, expectedStatus, updater);
if (!updated) throw new ConflictError(id);
return updated;
}

private isTerminal(session: CheckoutSession): boolean {
return session.status === 'completed' || session.status === 'expired';
}

/** Active/failed past TTL — not terminal, not mid-charge. */
private isEligibleForSessionClockLapse(session: CheckoutSession): boolean {
if (this.isTerminal(session)) return false;
if (session.status === 'pending_payment') return false;
return new Date(session.expiresAt).getTime() < Date.now();
}

private async expireIfNeeded(session: CheckoutSession): Promise<CheckoutSession> {
if (this.isTerminal(session)) return session;
if (new Date(session.expiresAt).getTime() >= Date.now()) return session;
return this.expireNow(session, 'session_lapsed');
if (!this.isEligibleForSessionClockLapse(session)) return session;
await this.lapseForSessionClock(session);
return this.mustGet(session.id);
}

/**
* Mark session_lapsed then free the hold. CAS first so a concurrent
* pending_payment claim cannot lose its inventory underneath it.
* @returns true when this call won the expire CAS
*/
private async lapseForSessionClock(session: CheckoutSession): Promise<boolean> {
const expired = this.expireNow(session, 'session_lapsed');
if (expired.status !== 'expired' || expired.expiryReason !== 'session_lapsed') {
return false;
}
await this.inventory.releaseHold(session.listingId);
return true;
}

private expireNow(session: CheckoutSession, reason: SessionExpiryReason): CheckoutSession {
const expired = this.store.casUpdate(session.id, session.status, (session) => ({
...session,
const expired = this.store.casUpdate(session.id, session.status, (current) => ({
...current,
status: 'expired',
expiryReason: reason,
}));
Expand Down
Loading