Order creation — public checkout
How an anonymous shopper on the public catalog turns a cart into a persisted
Order. Audience: new dev with Node/React experience, no Eziseller context.
1. Overview
The public catalog (/catalog/[slug]) lets anyone — no account needed — add items to a browser-local cart and check out against a seller's store. Checkout supports two payment methods: Razorpay (online) and COD. For Razorpay, the Order row is not created when the customer clicks "Pay"; it is created only after the browser returns a Razorpay payment id + signature and the backend re-verifies stock. Until then, only a PaymentTransaction row exists in created state. COD skips Razorpay entirely and creates the Order directly.
This is intentional: we don't want pending/abandoned Order rows polluting the seller's dashboard, and we want stock decremented only once — at the moment money is confirmed.
2. Architecture
The frontend talks to Razorpay's hosted checkout directly (SDK popup); the backend never sees the card. The webhook is a fallback path for when the shopper closes the tab mid-payment.
3. Data model
See schema.prisma. PaymentTransaction is keyed by providerOrderId (unique per Razorpay order) and providerPaymentId (unique per captured payment) — the latter is the idempotency key on retry.
4. Key flows
4.1 Razorpay online payment (happy path)
4.2 Razorpay webhook fallback
If the shopper closes the browser after paying but before the /checkout POST lands, Razorpay calls POST /webhooks/razorpay. The handler marks the PaymentTransaction as captured with needsOrderCreation: true in metadata and fires a payment_alert in-app notification so the seller can reconcile manually. No order is auto-created from the webhook — see gotchas.
4.3 COD
Skips /create-payment entirely. POST /:slug/checkout with paymentMethod: 'cod' runs the same inventory-reduce transaction and writes paymentStatus: 'cod', financialStatus: 'pending'.
5. Lifecycle / state machine
Note the Order.status starts at new here (not pending as in draft-order approvals) — see order-lifecycle.md.
6. Key files
- public-catalog.ts:L300-L411 —
POST /:slug/create-payment: DB-side price recompute, advisory stock check, Razorpay order,PaymentTransactioninsert - public-catalog.ts:L414-L718 —
POST /:slug/checkout: signature verify, idempotency onproviderPaymentId, atomiccheckAndReduceInventory+Order.create, usage increment at L660 - public-catalog.ts:L724-L798 —
POST /webhooks/razorpay: orphan-payment fallback - CheckoutPageClient.tsx — loads Razorpay script, opens popup, posts signature back
- RazorpayService —
createOrder,verifySignature(HMAC-SHA256)
7. Env vars & config
Razorpay credentials are per-store, stored on Store.paymentSettings.razorpayKeyId/Secret — not env vars. The only global env involved is FRONTEND_URL (for CORS on the public routes). See razorpay-integration.md for how per-store keys differ from the platform subscription keys.
| Var | Required | Purpose | What breaks |
|---|---|---|---|
DATABASE_URL | yes | Postgres connection | Everything |
FRONTEND_URL | yes | CORS allow-origin | Checkout blocked by browser |
RAZORPAY_WEBHOOK_SECRET | should be | Verify webhook HMAC | See gotcha below |
8. Gotchas & troubleshooting
- Razorpay webhook signature is NOT verified.
POST /webhooks/razorpayreadsreq.bodywithout checking theX-Razorpay-Signatureheader. Anyone who knows the URL + aproviderOrderIdcan forge apayment.capturedevent and trigger a seller notification. See webhooks-overview.md. Fix: verifycrypto.createHmac('sha256', RAZORPAY_WEBHOOK_SECRET).update(rawBody)before trusting the event. - Orphan
PaymentTransactionrows. If the shopper pays but the browser never calls/checkoutAND the webhook fails (or is dropped), thePaymentTransactionstays increated. There's no reconciliation job — the seller will never know. Mitigation today: scheduled manual audit via Razorpay dashboard. - Usage-limit increment has no middleware gate.
incrementUsage(userId, 'orders', req)at line 660 runs after the order is written, and there is noenforceUsageLimitmiddleware on this route. A seller past their plan cap can still receive orders via public checkout. See usage-limits.md. - Double-submit idempotency. Retrying
POST /checkoutwith the samerazorpayPaymentIdreturns the existing order (duplicate: true) — see L460-L476. This works becausePaymentTransaction.providerPaymentIdhas a unique index. COD has no idempotency guard — double-clicks on a slow network can produce two orders. - Inventory check is done twice.
/create-paymentdoes an advisorycheckInventoryAvailability(no lock) so we fail fast before charging./checkoutdoes an authoritativecheckAndReduceInventoryinside aprisma.$transaction(with row locks). The advisory check can pass and the authoritative check can still fail if another order landed in between — the customer is charged and then getsOUT_OF_STOCK. Manual refund required. - Tax is hardcoded to 0.
taxAmount: 0at L520 and L577. The tax-engine.md exists but is not wired into this route. - Rate limits.
paymentLimiteron/create-payment,checkoutLimiteron/checkout. Both live inbackend/middleware/rate-limit.ts. The webhook route has no rate limit.
9. Extension points
- Reconciliation job. A cron that finds
PaymentTransactionrows withstatus='captured',orderId=null, andcreatedAt < now - 15mand either auto-creates the order (trusting Razorpay's captured metadata for customer contact) or escalates to the seller. - Alternate gateways.
PaymentTransaction.provideris already a string column. A new provider (Stripe, Cashfree) plugs in by adding acreateOrder/verifySignaturepair and a branch in/create-payment+/checkout. TheOrderwrite path is gateway-agnostic. - Abandoned-cart recovery. Since
PaymentTransactioncaptures the Razorpay order id before the shopper pays, themetadata.contact/emailon the captured webhook payload is enough to drive a recovery email/WhatsApp.
10. Related docs
- order-lifecycle.md — why status starts at
newhere vspendingfor draft approvals - order-creation-manual.md — dashboard-side path
- catalog/checkout.md — frontend checkout UX and cart handoff
- tax-and-invoices/tax-engine.md — not yet wired into this route
- billing/razorpay-integration.md — per-store vs platform keys
- webhooks-overview.md — webhook signature verification status
- billing/usage-limits.md — why this route bypasses the limit gate