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.

Preview: first 50% is visible. Unlock to read the full article.
To view this content, you must be a member of CodeWithWilliamJiamin's Patreon at $1 or more
Already a qualifying Patreon member? Refresh to access this content.