# One Big Checkout Mistake: Assuming Payment Means Access
Most checkout bugs do not come from the payment provider. They come from our own app logic.
The big mistake is treating checkout like one button press:
- click pay
- get success callback
- unlock product
In real systems, payment confirmation and access provisioning are separate events. If you merge them into one step, users get charged but blocked. That is the worst trust break you can create.
A simple analogy: buying a train ticket is not the same as reaching your seat. You still need validation at the gate.
## Step 1: Model checkout as states, not boolean flags
If your code uses `isPaying = true` and `isSuccess = false`, it will collapse under real-world failures.
```ts
type CheckoutState =
| { kind: 'idle' }
| { kind: 'creatingOrder' }
| { kind: 'awaitingPayment'; orderId: string }
| { kind: 'verifyingAccess'; orderId: string }
| { kind: 'done' }
| { kind: 'error'; message: string };
```
This is the backbone. Every UI action should move between these explicit states.
## Step 2: Separate order creation from payment confirmation
```ts
async function createOrder(planId: string): Promise<{ orderId: string }> {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ planId }),
});
if (!res.ok) throw new Error('order creation failed');
return res.json();
}
```
Order creation should happen before redirecting to payment. Save `orderId` locally so refresh does not lose progress.
## Step 3: Verify access after payment callback
Payment callback means "money event happened." It does not always mean your entitlement table is already updated.
```ts
async function waitForEntitlement(orderId: string): Promise
for (let i = 0; i < 6; i += 1) {
const res = await fetch('/api/entitlements/' + orderId);
if (!res.ok) throw new Error('entitlement check failed');
const data = await res.json();
if (data.active === true) return true;
await new Promise((r) => setTimeout(r, 1200));
}
return false;
}
```
This retry window handles propagation delay without confusing the user.
## Step 4: Build UI language for each failure mode
Do not show one generic error for everything.
- payment failed -> "Your payment was not completed"
- verification timed out -> "Payment received, still confirming access"
- network error -> "Connection issue, tap retry"
Specific language reduces support tickets dramatically.
## Step 5: Recover after refresh
If users refresh during checkout, resume from stored `orderId` and re-run verification. Never force them to pay again just because the browser reloaded.
## Classroom takeaway
When teaching checkout architecture, I tell teams this:
1. Money flow and access flow are cousins, not twins.
2. Model states explicitly.
3. Verify entitlement before declaring success.
Do these three things, and checkout reliability improves immediately.