Ecommerce for Jewellery
A checkout that can't oversell the last unit, even when two people click buy at once.
What it does
A real store, not a checkout demo
Full customer flow — browse, a product page with reviews, a persistent cart, checkout with saved addresses and coupons, Razorpay's hosted widget, order history with retry-on-failure and returns. Behind it, an admin suite: product management with an image pipeline that resizes and re-encodes uploads to WebP, coupons, a returns queue that triggers real refunds, and review moderation.
Architecture
Checkout: reserve first, verify twice
Client
Cart, address, and coupon posted to /api/checkout/create-order behind an auth + rate-limit gate. Client-side prices are never trusted.
Prisma transaction
First releases any of the user's own PENDING orders older than 30 min back to stock, then re-derives the total from the database and reserves stock + coupon usage in one transaction — see below for how.
Razorpay order · third-party
Order ID created and stored on the local Order row (status: PENDING).
runs in parallel
Client → /verify
Browser posts the widget's signature; HMAC-verified with crypto.timingSafeEqual.
Razorpay → webhook · third-party
Server-to-server payment.captured event, independently HMAC-verified against the raw request body.
Order → PAID
A conditional update from PENDING to PAID — only one of the two paths gets to flip it. A payment.failed webhook or a bad signature does the reverse: rolls stock and coupon back and marks the order FAILED.
Confirmation email
Sent to the customer (and an admin alert) the moment the order flips to PAID.
Both verification paths can legitimately race — the browser can close before /verify runs, and the webhook exists exactly to reconcile that. Whichever request wins the conditional update is the one that counts; the loser is a harmless no-op.
The interesting part
Closing the overselling race without a lock
The naive version reads the stock count, checks it in application code, then writes a decrement — a gap where two concurrent buyers can both pass the check for the last unit. This folds the check into the write instead:
await tx.product.updateMany({
where: { id: item.productId, stock: { gte: item.quantity } },
data: { stock: { decrement: item.quantity } },
})
// count === 0 means someone else got there firstPostgres evaluates the WHERE clause atomically at write time, so under a real race only one request matches a row — the other gets count === 0 and a clean out-of-stock error instead of an order that can never ship. The same pattern guards coupon usage limits too.
Honest edge case: stale orders are released back to stock lazily, on the user's next checkout attempt rather than a scheduled sweep — so a payment completing after that window could theoretically arrive late. Rare, and acknowledged in the repo rather than glossed over.
Also in the admin suite
AI product copy, with a leash on it
Product and curation pages have a "generate copy" button — an admin types a rough draft or a few keywords, and Claude Haiku returns a title, a Markdown description, and a search-snippet meta description as one structured object, parsed straight into a Zod schema by the Anthropic SDK rather than hoping a JSON blob comes back clean:
response = await client.messages.parse({
model: 'claude-haiku-4-5',
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: input }],
output_config: { format: zodOutputFormat(GeneratedCopySchema) },
})The system prompt is explicit about what it can't do: never invent a material, occasion, or count that isn't in the admin's input, and drop the "Highlights" section entirely rather than pad it when there's nothing concrete to list. A rate limit (15 generations per 10 minutes per admin), a model refusal, and a raw API error each get their own distinct response — none of them silently hand back an empty field.