Core
Catalog
Checkout

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

7. Env vars & config

VarRequiredPurposeWhat breaks
RAZORPAY_KEY_IDplatform fallbackDefault Razorpay key IDFalls back to per-store paymentSettings.razorpayKeyId; per-tenant creds always win
RAZORPAY_KEY_SECRETplatform fallbackDefault Razorpay secretSame as above; used for signature verification when per-store secret not set
RAZORPAY_WEBHOOK_SECRETyes (prod)HMAC secret for the /webhooks/razorpay endpointWebhook spoofing possible if unset; orphan-payment flagging fails
NEXT_PUBLIC_API_URLyesBase URL the checkout page fetches fromCatalog 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-payment and /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-payment is advisory only — a second buyer can still race past it, which is why the real check runs again at Order creation and throws OUT_OF_STOCK.
  • Signature verification is mandatory for Razorpay orders. Without valid razorpayOrderId + razorpayPaymentId + razorpaySignature the 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. The payment.captured webhook marks the PaymentTransaction with needsOrderCreation:true and 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 handler callback can fire twice if the buyer clicks retry. Idempotency is handled at public-catalog.ts:L460-L476 via PaymentTransaction.providerPaymentId unique constraint; the second call returns { duplicate: true } with the existing order.
  • Delivery charge uses store.shippingSettings.flatDeliveryCharge with freeDeliveryAbove threshold — both /create-payment and /checkout recompute 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 with paymentStatus:'cod', financialStatus:'pending'.
  • Additional gateways (Stripe, Cashfree) — add a new provider discriminator on PaymentTransaction, a new /create-payment-<provider> endpoint, and a verifier analogous to RazorpayService.verifySignature. Keep the final /checkout handler gateway-agnostic.
  • Multi-currencystore.currency is 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 the processedItems loop in /checkout and update taxAmount + totalAmount before 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 }.

10. Related docs