What Usually Breaks
The most expensive bug in paid products is simple: payment succeeded, but access did not unlock. This happens when checkout is modeled as one event instead of two systems.
Today we are going to build a checkout flow where payment and entitlement are intentionally decoupled.
Step 1: Model checkout states explicitly
type CheckoutState =
| { kind: 'idle' }
| { kind: 'creating_order' }
| { kind: 'awaiting_confirmation'; orderId: string }
| { kind: 'paid'; orderId: string; receiptId: string }
| { kind: 'entitled'; orderId: string; plan: string }
| { kind: 'failed'; reason: string };
Step 2: Make entitlement updates idempotent
Webhook retries are normal. If your grant function is not idempotent, users will hit weird edge cases.
async function grantEntitlement(input: {
userId: string;
productId: string;
receiptId: string;
}) {
const key = `${input.userId}:${input.receiptId}`;
if (await db.entitlements.exists(key)) return 'already_granted';
await db.entitlements.insert({
idempotencyKey: key,
userId: input.userId,
productId: input.productId,
grantedAt: new Date().toISOString(),
});
return 'granted';
}
Step 3: UI should poll entitlement, not assume it
async function waitForAccess(userId: string, timeoutMs = 20000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const hasAccess = await api.get(`/entitlements/${userId}`);
if (hasAccess.ok) return true;
await new Promise(r => setTimeout(r, 1200));
}
return false;
}
Step 4: Add a support-safe recovery path
Provide a “Restore Access” action that replays entitlement checks without charging again.