Ecommerce Process Flow: Six Stages, and the One Joint That Breaks
The six stages mapped to the files that implement them in a shipped store, plus the four failure modes that all cluster at one hop: order processing.
An ecommerce process flow has six stages: discovery, selection, checkout, order processing, fulfilment and post-sale. Drawn as a flowchart it looks like a straight line. Implemented, the line has one dangerous joint — the handoff where the payment provider tells your system a sale happened — and most of the failure modes cluster there.
Most flowchart articles stop at the boxes. This one walks the same six stages through a store that is actually built: the ASoc template marketplace, where the flow is 1,545 lines of TypeScript and 271 lines of SQL. Each stage below names the file that implements it and the way it fails. The goods are digital, so fulfilment is a signed URL rather than a pallet, but stages 1–4 and 6 are identical for physical goods.
The flow, with the implementation beside it
| Stage | What happens | Where it lives here | How it fails |
|---|---|---|---|
| 1. Discovery | Buyer finds a product | src/app/templates/, 110 available products | Nothing to fail; it is a static page |
| 2. Selection | Buyer picks a variant | EditionPicker, src/data/catalog.ts | Showing an edition that isn't shippable |
| 3. Checkout | Payment is taken | src/lib/actions/checkout.ts → hosted checkout | Selling a tier that is only half-configured |
| 4. Order processing | The sale becomes a record | src/lib/lemonsqueezy/webhook.ts | Forged, duplicated, lost or orphaned events |
| 5. Fulfilment | Buyer receives the goods | src/app/api/download/route.ts | Serving someone who did not buy |
| 6. Post-sale | Email, account, refunds | purchaseWelcome.ts, /dashboard, refund.ts | Silent email loss; refunding after delivery |
Stage 4 has four failure modes of its own and is where every one of our real defects landed. Budget accordingly: in a flowchart it is one box.
Stage 1–2: discovery and selection decide what stage 5 can honour
The front half of the flow is a catalog problem, and the only trap is promising something fulfilment cannot deliver. Our catalog models products across two axes — status (available / coming-soon) and per-edition readiness — and 116 of 139 editions are marked ready. The picker must offer only the ready ones, because stage 5 refuses the rest. When those two drift, a buyer pays and then meets an error.
The structural fix is to make the unshippable case refuse early and indistinguishably. Our backend starter has no zip in the bucket yet, so the download resolver rejects it before it does any entitlement or storage work, and returns the same result as an unknown edition:
if (framework === "backend" && !BACKEND_RELEASED) {
Telling an entitled owner "you are entitled, but the file is missing" is a worse experience than a flat refusal, and it leaks the state of your release pipeline into an error message.
Stage 3: checkout, where the only safe input is the variant
The buyer leaves for a hosted checkout. The single most important rule at this hop is that nothing about who they are comes from the browser. getCheckoutUrl takes a tier and derives everything else from the verified session:
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
const claims = data?.claims;
const userId = claims?.sub;
const email = typeof claims?.email === "string" ? claims.email : undefined;
if (!userId || !email) {
return { ok: false, reason: "unauthenticated" };
}
If the user id were a request parameter, a caller could attach a purchase to someone else's account — which is also an entitlement grant, since stage 4 trusts that id. The second guard is less obvious and cost us a near-miss: a checkout is refused unless both the variant UUID (which builds the URL) and the numeric variant id (which the webhook will later match on) are configured. Configure only the first and the store happily takes money for an order that stage 4 cannot map to a tier, and therefore ignores.
Stage 4: order processing is the joint the whole flow hangs from
Everything downstream believes what this step writes. It runs in a fixed order, and the order is the design:
verify signature → parse → validate store id + test_mode
→ map variant → tier → atomic order + slot creation → refund handling
Four distinct things go wrong here, and a flowchart shows none of them.
The event is forged. Verification comes first, and a failed signature performs zero writes and returns a generic 401. The HMAC must be computed over the raw body, which is why the route calls req.text() and never req.json() first — re-serializing a parsed object can differ byte-for-byte from what was signed.
The event arrives twice. Providers retry. Idempotency is pushed into the database rather than application code: create_order_with_slots inserts with on conflict (ls_order_id) do nothing, and a null returned id means the order already existed. The RPC is the sole arbiter of "was this new?", so a duplicate delivery creates no second entitlement and triggers no second email.
The event arrives out of order, or not at all. A refund can land before the create (or after a lost create). refundOrder returns false when no such order exists, and that case raises an alert instead of being swallowed:
onOrphanRefund: ({ lsOrderId }) => {
console.error("lemonsqueezy webhook ALERT: refund for unknown order", {
lsOrderId,
});
},
The order has no user attached. A buyer who reached the provider's checkout outside our site arrives with no user_id. The order is still recorded — losing a paid order is never the right answer — and an onUnattachedOrder alert fires for manual attachment.
The other half of this stage is atomicity. Order creation and slot creation are one transaction; refund revocation and the status change are one transaction. A crash between "revoke the slots" and "mark the order refunded" would leave a refunded buyer with live access, and refund_order is written so that replaying it on an already-refunded order still re-revokes any slot somehow still active.
Stage 5: fulfilment is a sequence of refusals
For digital goods the delivery is a short-lived signed URL, and the endpoint is best read as a list of ways to say no, in deliberate order:
- No session → 401.
- Email not verified → refused, the same gate redemption uses.
- Missing or malformed
product/framework→ refused before any lookup. - Unreleased edition → refused before entitlement work.
authorizeDownloadover server-held slots → the single authorization decision.- Rate limit and audit, in one atomic step.
- Signed URL, valid for 60 seconds.
Step 5 is the only one that is about entitlement, and it reads slots straight from the database with an explicit user_id filter — the admin client bypasses row-level security, so that filter is the boundary, not a redundancy on top of it. The dashboard's ownership lookup is a rendering convenience and is re-checked here on every request.
Step 6 is where we shipped a real bug. The hourly limit was a check-then-act: read the count, then insert the audit row in a separate statement. Parallel requests all read the same count, all passed, and all inserted, so a burst bypassed the cap — CWE-367, straightforwardly. Both halves now happen inside one SECURITY DEFINER RPC serialized by a per-user advisory lock, which returns -1 when over the limit and inserts nothing, because a rejected download is not a download. The limit itself scales with what the buyer bought rather than being flat: All-Access legitimately pulls 116 editions on day one, and a flat 30/hour would wall them off a quarter of the way through. The mechanics are in gated file downloads in Next.js.
Stage 6: post-sale is three separate flows
The confirmation email is the hop most likely to fail silently. Sending it fire-and-forget looks right and is wrong on serverless: an un-awaited promise has no guaranteed lifetime once the response is sent, so the invocation can terminate mid-send and the email vanishes intermittently. It is scheduled with after() from next/server instead, which keeps the invocation alive without delaying the webhook's response. Awaiting it would be worse — a slow mail provider would stall the response and invite the provider to retry a delivery that already succeeded. What belongs on the page the buyer lands on is covered in order confirmation pages in Next.js.
The account surface aggregates every active slot across all of the buyer's orders into one deduplicated list, using the same authorizeDownload the endpoint uses. It computed this per order once, and a buyer who owned one template and then bought All-Access saw it listed twice.
Refunds are the stage most flowcharts label "returns" and leave at that. Ours is a pure eligibility function so the disabled button and the server-side enforcement cannot disagree, with four refusal reasons: already refunded, a request pending, the 14-day window expired, or — the interesting one — the buyer already downloaded. "Downloaded" is coverage-based: any successful delivery covered by any of this order's slots blocks the refund, via the same slotCovers predicate. That over-blocks slightly when one product is owned through two orders, which is the money-safe direction to be wrong in. Policy drafting is a separate job from the code: see digital product refund policy.
Digital versus physical: where the flow diverges
| Stage | Digital goods | Physical goods |
|---|---|---|
| Selection | Edition / licence variant | Size, colour, quantity against stock |
| Order processing | Grant an entitlement | Reserve inventory, then grant |
| Fulfilment | Signed URL, repeatable forever | Pick, pack, ship, one time |
| Post-sale | Re-download, licence questions | Tracking, delivery exceptions, returns |
| Hardest hop | Stage 4, the webhook | Stage 5, the warehouse |
The useful asymmetry: digital fulfilment is infinitely repeatable, so your controls are about access and abuse. Physical fulfilment happens once, so the controls are about inventory truth. Both inherit the same stage 4.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Paid orders never appear in your database | Webhook signature failing, or the endpoint 500s and the provider gives up | Verify over the raw body; log the rejection reason server-side |
| Buyers get two entitlements for one purchase | Idempotency in app code, not the database | on conflict (…) do nothing and branch on what it returns |
| A refund leaves access active | Revoke and status update in separate statements | One transactional RPC |
| Some buyers never get the confirmation email | Un-awaited promise on a serverless runtime | Schedule it with after() / waitUntil() |
| Download quota exceeded under load | Check-then-act across two statements | Count and insert in one locked transaction |
| An order exists with no customer attached | Buyer checked out outside your site | Record it anyway and alert for manual attachment |
| A buyer refunds after taking the files | Eligibility ignores delivery history | Block on coverage of any successful delivery |
FAQ
What are the stages of an ecommerce process flow? Discovery, selection, checkout, order processing, fulfilment and post-sale. Order processing is the stage that turns a payment into a record your system trusts, and it is where most implementation risk sits.
Which stage of the ecommerce flow breaks most often? The handoff from the payment provider back to your application. It can be forged, duplicated, delivered out of order, lost, or arrive with no customer attached — four of those five needed explicit handling here.
Does a digital product need the same process flow? The same six stages, with fulfilment replaced by access control. Because a digital download is repeatable, the flow gains rate limiting and an audit trail that a physical flow does not need.
Do I need a flowchart before building this? A flowchart is useful for agreeing on the stages with non-engineers. It will not show you the four failure modes at stage 4, so do not mistake a clean diagram for a designed system.
Templates that start at stage 1
Stages 1 and 2 are a front-end problem, and they are the part a template actually removes. The Next.js landing page templates and Tailwind landing page templates ship the static-by-default structure, metadata and accessibility work already done, leaving your effort for the joint that needs it.
