Core
Orders
Order Creation Public Checkout

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

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.

VarRequiredPurposeWhat breaks
DATABASE_URLyesPostgres connectionEverything
FRONTEND_URLyesCORS allow-originCheckout blocked by browser
RAZORPAY_WEBHOOK_SECRETshould beVerify webhook HMACSee gotcha below

8. Gotchas & troubleshooting

  • Razorpay webhook signature is NOT verified. POST /webhooks/razorpay reads req.body without checking the X-Razorpay-Signature header. Anyone who knows the URL + a providerOrderId can forge a payment.captured event and trigger a seller notification. See webhooks-overview.md. Fix: verify crypto.createHmac('sha256', RAZORPAY_WEBHOOK_SECRET).update(rawBody) before trusting the event.
  • Orphan PaymentTransaction rows. If the shopper pays but the browser never calls /checkout AND the webhook fails (or is dropped), the PaymentTransaction stays in created. 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 no enforceUsageLimit middleware 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 /checkout with the same razorpayPaymentId returns the existing order (duplicate: true) — see L460-L476. This works because PaymentTransaction.providerPaymentId has a unique index. COD has no idempotency guard — double-clicks on a slow network can produce two orders.
  • Inventory check is done twice. /create-payment does an advisory checkInventoryAvailability (no lock) so we fail fast before charging. /checkout does an authoritative checkAndReduceInventory inside a prisma.$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 gets OUT_OF_STOCK. Manual refund required.
  • Tax is hardcoded to 0. taxAmount: 0 at L520 and L577. The tax-engine.md exists but is not wired into this route.
  • Rate limits. paymentLimiter on /create-payment, checkoutLimiter on /checkout. Both live in backend/middleware/rate-limit.ts. The webhook route has no rate limit.

9. Extension points

  • Reconciliation job. A cron that finds PaymentTransaction rows with status='captured', orderId=null, and createdAt < now - 15m and either auto-creates the order (trusting Razorpay's captured metadata for customer contact) or escalates to the seller.
  • Alternate gateways. PaymentTransaction.provider is already a string column. A new provider (Stripe, Cashfree) plugs in by adding a createOrder/verifySignature pair and a branch in /create-payment + /checkout. The Order write path is gateway-agnostic.
  • Abandoned-cart recovery. Since PaymentTransaction captures the Razorpay order id before the shopper pays, the metadata.contact/email on the captured webhook payload is enough to drive a recovery email/WhatsApp.

10. Related docs