Public Catalog Checkout
Multi-step, public (unauthenticated) checkout on a seller's storefront with Razorpay online payment and COD. Audience: new dev wiring or debugging the buyer-facing flow.
1. Overview
Buyers land on /catalog/[slug]/checkout after adding items to the cart. The page is a single client component with a linear step machine — customer info, shipping address, review, payment — that calls two public backend endpoints: create-payment (creates a Razorpay order server-side) and checkout (verifies the signature, atomically reduces inventory, and persists the Order). A Razorpay webhook provides a safety net when the buyer closes the tab after paying but before the frontend can confirm. Prices are always recomputed server-side from the DB; the client total is never trusted.
2. Architecture
The checkout page is rendered via a tiny Next.js server component (page.tsx) that hydrates the client with store config. All mutating calls hit the backend's /api/public/catalog/:slug/* routes — no auth token required, but rate-limited per IP.
3. Data model
The customer address is denormalised into Order.customer JSON (snapshot), and a Customer row is upserted by (userId, phone) for CRM. See schema.prisma.
4. Key flows
4.1 Full checkout sequence (Razorpay)
COD skips the create-payment + Razorpay steps and posts directly to /checkout with paymentMethod:'cod'.
5. Lifecycle / state machine
Note: Eziseller does not create an Order in a pending_payment state before payment succeeds — the Order only exists after signature verification. The intermediate state lives in PaymentTransaction.
6. Key files
- src/app/catalog/[slug]/checkout/page.tsx:L1-L27 — server shell, fetches catalog config
- src/app/catalog/[slug]/checkout/_components/CheckoutPageClient.tsx — multi-step form, Razorpay modal wiring, submit
- backend/routes/public-catalog.ts:L300-L411 —
POST /:slug/create-payment(Razorpay order create + inventory precheck) - backend/routes/public-catalog.ts:L413-L718 —
POST /:slug/checkout(signature verify, tx, Order create, idempotency) - backend/routes/public-catalog.ts:L720-L798 —
POST /webhooks/razorpay(orphan-payment safety net) - backend/lib/razorpay-service.ts — per-tenant Razorpay client + HMAC signature verify
- backend/lib/validation.ts —
publicCheckoutSchema(Zod)
7. Env vars & config
| Var | Required | Purpose | What breaks |
|---|---|---|---|
RAZORPAY_KEY_ID | platform fallback | Default Razorpay key ID | Falls back to per-store paymentSettings.razorpayKeyId; per-tenant creds always win |
RAZORPAY_KEY_SECRET | platform fallback | Default Razorpay secret | Same as above; used for signature verification when per-store secret not set |
RAZORPAY_WEBHOOK_SECRET | yes (prod) | HMAC secret for the /webhooks/razorpay endpoint | Webhook spoofing possible if unset; orphan-payment flagging fails |
NEXT_PUBLIC_API_URL | yes | Base URL the checkout page fetches from | Catalog fetch in page.tsx returns 404 |
Per-store Razorpay creds live in Store.paymentSettings JSON; the backend creates a new Razorpay instance per request via RazorpayService.createInstance(keyId, keySecret).
8. Gotchas & troubleshooting
- Rate limit is 60/min per IP on
/create-paymentand/checkout(paymentLimiter/checkoutLimiter). A buyer who retries rapidly after a failed UPI collect can get 429 — UI must disable the pay button until response returns. - Tax is hardcoded to 0 on every OrderItem and the Order (
taxAmount: 0). See tax-engine.md — the tax system exists in settings but is not applied in this checkout path yet. - Inventory is check-and-reduce inside a single Prisma
$transaction(public-catalog.ts:L546). The earlier check in/create-paymentis advisory only — a second buyer can still race past it, which is why the real check runs again at Order creation and throwsOUT_OF_STOCK. - Signature verification is mandatory for Razorpay orders. Without valid
razorpayOrderId + razorpayPaymentId + razorpaySignaturethe route 400s before touching the DB — do not relax this for "test mode". - Orphan payments — if the buyer pays and closes the tab before the client posts to
/checkout, no Order is created. Thepayment.capturedwebhook marks thePaymentTransactionwithneedsOrderCreation:trueand notifies the seller. There is no automated cleanup/reconciliation job — sellers must create the order manually from the dashboard. - Refunds do not restore stock. Inventory is decremented on Order create; the refund flow (seller-side) does not re-increment. File an ops ticket or adjust inventory by hand after a refund.
- Double-submit on slow Razorpay modal — the client's
handlercallback can fire twice if the buyer clicks retry. Idempotency is handled at public-catalog.ts:L460-L476 viaPaymentTransaction.providerPaymentIdunique constraint; the second call returns{ duplicate: true }with the existing order. - Delivery charge uses
store.shippingSettings.flatDeliveryChargewithfreeDeliveryAbovethreshold — both/create-paymentand/checkoutrecompute it; if they disagree (e.g., seller changes settings mid-checkout) the Razorpay-captured amount may not match the Order total. Not currently reconciled.
9. Extension points
- COD — already wired;
paymentMethod:'cod'skips Razorpay entirely and creates the Order withpaymentStatus:'cod',financialStatus:'pending'. - Additional gateways (Stripe, Cashfree) — add a new
providerdiscriminator onPaymentTransaction, a new/create-payment-<provider>endpoint, and a verifier analogous toRazorpayService.verifySignature. Keep the final/checkouthandler gateway-agnostic. - Multi-currency —
store.currencyis already read and stored on Order; Razorpay order creation uses it. Gateway must support the currency; delivery thresholds are currency-naive. - Tax — hook
TaxService(see tax-engine.md) into theprocessedItemsloop in/checkoutand updatetaxAmount+totalAmountbefore Razorpay amount is locked in/create-payment. - Pending-payment cleanup — add a cron in backend/schedulers to expire
PaymentTransaction { status:'created', createdAt < now-30m, orderId: null }.