Concept project
E-commerce Storefront with Test-Mode Checkout
A concept project with a working demo: a small storefront with search, filters, product variants, a persistent cart and a test-mode checkout. Prices are recalculated on the server, stock is reserved in one transaction, and orders are marked paid only by an idempotent webhook. A dev panel shows the API calls and SQL behind each step.

Overview#
Most shop demos stop at a pretty product grid and a cart. The hard parts of e-commerce come after the "Pay" button: who decides the price, what happens when two people buy the last item, and how you avoid charging someone twice when the network drops.
This concept is a small storefront called Common Goods with 18 fictional products. The live demo runs in your browser. A front end calls an in-browser API module that behaves like the NestJS service I would deploy, and a dev panel prints every request with the PostgreSQL statements it would run.
My role#
I did the whole build: the data model, the API behaviour, the checkout flow, the drawn product images, the interface and this write-up. It is a concept project, not client work. Every product, brand and order in it is made up.
The problem#
A storefront has to get four things right that a visitor never sees:
- Prices come from the server. Anything in the browser can be edited. If the order total is built from a price stored in the cart, someone will pay $0.01 for a hoodie.
- Stock cannot go negative. Two shoppers who check out at the same moment for the last pair of size 42 trainers must not both get them.
- Payments are retried. Mobile networks drop. A retried "create payment" request must not create a second charge.
- The payment result arrives separately. The browser can close before it learns the payment worked. The order has to be confirmed from the payment provider's side, and providers deliver that message more than once.
Approach & architecture#
- Cart (SKU + qty)then
- POST /api/cart/quotethen
- catalogue pricesthen
- order summary
- Details formthen
- ValidationPipethen
- POST /api/ordersthen
- BEGIN, lock rows, decrement stock, insert orderthen
- COMMIT
- POST /api/payments/intents + Idempotency-Keythen
- payment intentthen
- confirm with test card
- Providerthen
- POST /webhooks/paymentsthen
- dedupe event idthen
- UPDATE orders SET status = 'paid'
The cart only holds SKUs and quantities. The cart is saved in localStorage so it survives a reload. It also stores a display price, because a real client often does. The server reads only the SKU and the quantity. In the demo you can press tamper cart price to set a line to $0.01. The quote endpoint then reports that it ignored the client price, and the order uses the catalogue price.
One transaction reserves stock. The order handler locks the variant rows in SKU order, which avoids deadlocks when two carts share items. It then checks every line before it writes anything:
for (const line of lines) {
const variant = findVariant(tx.products, line.sku)!.variant;
if (variant.stock < line.qty) {
sql.push(`-- ${line.sku}: requested ${line.qty}, in stock ${variant.stock}`, "ROLLBACK;");
return fail(db, 409, "insufficient_stock", `Only ${variant.stock} left of ${line.name}…`, sql);
}
variant.stock -= line.qty;
sql.push(`UPDATE variants SET stock = stock - $2 WHERE sku = $1;`);
}The handlers are pure functions. They take the current state, work on a copy and return the new state only on success. A failure returns the original object, which is how the demo models a ROLLBACK. In the production design the database also enforces the rule itself:
CREATE TABLE variants (
sku text PRIMARY KEY,
product_id text NOT NULL REFERENCES products(id),
colour text NOT NULL,
size text,
price_cents integer NOT NULL CHECK (price_cents > 0),
stock integer NOT NULL CHECK (stock >= 0)
);
CREATE TABLE idempotency_keys (key text PRIMARY KEY, intent_id text NOT NULL, created_at timestamptz DEFAULT now());
CREATE TABLE webhook_events (id text PRIMARY KEY, received_at timestamptz DEFAULT now());Payment follows the PaymentIntent pattern. In the production design the payment step uses Stripe. The demo copies the shape of that flow without any network calls. The client creates an intent with an Idempotency-Key header. A repeat with the same key returns the stored intent and adds an Idempotent-Replayed: true header. The amount always comes from the order row. The browser turns the card fields into a payment-method id. In production, Stripe Elements does this inside an iframe, so card numbers never reach my API. The demo refuses any number that is not one of the three published test cards.
The webhook is the source of truth. Confirming the payment does not mark the order paid. The payment_intent.succeeded webhook does. It inserts the event id with ON CONFLICT DO NOTHING. If no row comes back, the event has already been handled and the handler stops. If the provider sends it again, nothing changes. The confirmation page has a button that replays the event so you can check this.
Key challenges#
- Validation in two places without two rule sets. The checkout DTO is one zod schema. The form uses it for instant field errors, and the order handler runs it again as its validation pipe. A "skip browser validation" switch sends a bad form so you can see the server's
422with errors for each field. - Selling the last unit. Rival buys my item places an order from a second shopper for every remaining unit of the first item in your cart. Your checkout then fails with
409and a plain message, and the cart drawer shows how many are left. - 3-D Secure. The card ending 3220 returns
requires_action. A modal dialog that traps focus stands in for the bank's page. Approving it completes the payment. Failing it, or pressing Escape, puts the intent back torequires_payment_methodso you can try another card on the same order. - Accessible overlays. The cart drawer and the 3-D Secure dialog trap focus, close on Escape and return focus to the button that opened them. Price filters warn when min is above max, and the filters are kept in the URL so a filtered view can be shared.
Tech stack#
- Demo: TypeScript, React and Next.js, zod for the checkout schema and Tailwind CSS. The "server" is two plain modules:
db.tsholds the tables and seed data, andapi.tsholds the handlers. Product images are inline SVG drawings, so the page makes no image requests. State is kept in localStorage. - Production design: Next.js for statically rendered product pages, NestJS for the cart, order and payment modules, PostgreSQL with the constraints above, and Stripe PaymentIntents with Elements for card entry and signed webhooks.
Results#
- A working buying flow: browse, filter, choose variants, cart, validated checkout, test payment with success, decline and 3-D Secure paths, confirmation and order history.
- Every server step is visible. The dev panel lists the method, path, status, headers and SQL for each request, including
BEGIN,ROLLBACKandCOMMIT. - The failure cases can be triggered on demand: tampered prices, out-of-stock at checkout, declined cards, failed authentication, retried intent creation and duplicate webhooks.
- The code under "View source" on the demo page is the code that runs.
This is a concept build without real users, real payments or real orders, so there are no sales or performance figures.
Try the demo#
Open the e-commerce storefront demo. Things to try:
- Add a hoodie to the cart, open the Dev panel, press tamper cart price, then check out. The server re-prices the line.
- Pay with
4000 0000 0000 3220and choose Complete authentication. Then press replay webhook on the confirmation page and see that the duplicate changes nothing. - Put an item in the cart, press rival buys my item, and try to check out. You get a
409and nothing is written. - Pay with
4000 0000 0000 0002to see a decline, then retry on the same order with4242 4242 4242 4242.
What I would do next#
- Move the handlers into a NestJS app with a real PostgreSQL database and run parallel checkout tests against the row locks.
- Replace the simulated payment with Stripe test keys, Elements and a signed webhook endpoint.
- Add a scheduled job that cancels unpaid orders after 30 minutes and returns their stock.
- Generate static product pages with
Productstructured data for search results.



