Rebasely Beta: data may be purged

examples/storefront Public

Check out carts into orders and charge them with Stripe

POST /carts/:id/checkout creates the order, takes the stock and closes the cart in one D1 batch. The CHECK on products.stock refuses a batch that would oversell. The PaymentIntent is created with the order id as its idempotency key, so a retried checkout gets the same client secret, and a failure at Stripe undoes the checkout.
582fe0a0c2eb73c3db98001bde8c1c1a8086bd13
Parent 0d7e6e2 Browse filesHistory

Changed files

22 files changed587 additions13 deletionsagainst 0d7e6e2

  1. Added.rebasely/architecture/flows/checkout.yaml+15−0
  2. Changed.rebasely/architecture/system.yaml+8−1
  3. Added.rebasely/architecture/views/checkout.yaml+2−0
  4. Changed.rebasely/kb/concepts/carts.md+2−2
  5. Added.rebasely/kb/concepts/checkout.md+42−0
  6. Added.rebasely/kb/decisions/0004-take-stock-at-checkout.md+22−0
  7. Added.rebasely/kb/decisions/0005-stripe-payment-intents.md+22−0
  8. Changed.rebasely/kb/guides/testing.md+3−2
  9. Changed.rebasely/kb/index.md+3−0
  10. ChangedREADME.md+6−2
  11. Addedmigrations/0003_orders.sql+28−0
  12. Changedpackage-lock.json+22−2
  13. Changedpackage.json+3−0
  14. Changedsrc/app.ts+21−1
  15. Addedsrc/checkout.ts+114−0
  16. Changedsrc/env.ts+1−0
  17. Addedsrc/orders.ts+62−0
  18. Addedsrc/payments.ts+47−0
  19. Addedtest/checkout.test.ts+96−0
  20. Changedtest/helpers/app.ts+5−3
  21. Addedtest/helpers/payments.ts+23−0
  22. Addedtest/stripe-payments.test.ts+40−0
Added.rebasely/architecture/flows/checkout.yaml+15−0
@@ -0,0 +1,15 @@
+title: Checking out a cart
+description: >-
+ The shopper checks out an open cart. storefront-api takes the stock and creates the order in one
+ D1 batch, then asks Stripe for a PaymentIntent and hands its client secret to the browser, which
+ confirms the payment with the Payment Element.
+actors: [shopper]
+steps:
+ - { from: shopper, to: storefront-api, label: "POST /carts/:id/checkout, email and address" }
+ - { from: storefront-api, to: db, label: read the cart and check the stock }
+ - { from: storefront-api, to: db, label: "one batch: insert the order, take the stock, close the cart" }
+ - { from: storefront-api, to: stripe, label: create a PaymentIntent for the order total }
+ - { from: storefront-api, to: db, label: save the PaymentIntent id on the order }
+ - { from: storefront-api, to: shopper, label: the order and the client secret }
+ - { from: shopper, to: stripe, label: confirm the payment with the Payment Element }
+ - { from: shopper, to: storefront-api, label: "GET /orders/:id until it is paid" }
View file
Changed.rebasely/architecture/system.yaml+8−1
@@ -8,10 +8,11 @@
talks_to:
- { to: db, via: d1 }
- { to: images, via: r2 }
+ - { to: stripe, via: https }
db:
kind: d1
description: >-
- The storefront database: products, carts and cart items. Every schema change is a numbered
+ The storefront database: products, carts, orders and their lines. Every schema change is a numbered
file in migrations/, applied with wrangler d1 migrations apply.
binding: DB
owns: [migrations/**]
@@ -21,3 +22,9 @@
Bucket storefront-images: product images under products/<product>/<hash>.<ext>. A key is
never overwritten, so every image URL can be cached for a year.
binding: IMAGES
+ stripe:
+ kind: external
+ description: >-
+ Stripe, through the stripe npm package. storefront-api creates a PaymentIntent per order with
+ the secret key; the shopper confirms it in the browser with the Payment Element.
+ package: stripe
View file
Added.rebasely/architecture/views/checkout.yaml+2−0
@@ -0,0 +1,2 @@
+description: Everything a checkout touches, from the cart to the charge.
+components: [storefront-api, db, stripe]
View file
Changed.rebasely/kb/concepts/carts.md+2−2
@@ -7,7 +7,7 @@
- src/carts.ts
- src/money.ts
- migrations/0002_carts.sql
-reviewed: 2026-10-01
+reviewed: 2026-10-02
---
# Carts
@@ -23,7 +23,7 @@
- never more than the product's current stock (the error says how many are left);
- only active products, and only while the cart is open.
-Setting a quantity checks stock but does not hold it. Stock is only taken when the cart is checked out.
+Setting a quantity checks stock but does not hold it. Stock is only taken when the cart is checked out ([[checkout]], [[0004-take-stock-at-checkout]]).
## Prices and totals
View file
Added.rebasely/kb/concepts/checkout.md+42−0
@@ -0,0 +1,42 @@
+---
+id: checkout
+type: concept
+owner: justin
+component: storefront-api
+describes:
+ - src/checkout.ts
+ - src/orders.ts
+ - src/payments.ts
+ - migrations/0003_orders.sql
+reviewed: 2026-10-02
+---
+# Checkout
+
+`POST /carts/:id/checkout` with an email and a shipping address turns an open cart into an order and returns what the browser needs to take the payment. The steps are in the checkout flow on the Architecture tab.
+
+## Taking stock
+
+Stock is taken here and nowhere else ([[0004-take-stock-at-checkout]]). The order, its lines, one `UPDATE products SET stock = stock - n` per line and closing the cart go to D1 as one batch. `products.stock` has `CHECK (stock >= 0)`, so when two shoppers race for the last pair of socks, the second batch fails as a whole and that shopper gets `409 out_of_stock` with what is left of each item. A first pass checks stock before the batch so the common case gets the same answer without a failed write.
+
+Lines keep the price that was in the cart. The shopper pays what the cart showed them.
+
+## Charging
+
+After the batch, checkout asks the payment provider for a PaymentIntent for the order total ([[0005-stripe-payment-intents]]) and stores its id on the order. The response has the order and the intent's `client_secret`; the browser confirms the payment with Stripe directly, so card details never reach the Worker.
+
+If Stripe fails or times out, nothing has been charged, so the checkout is undone in one batch: the stock goes back, the order is deleted and the cart opens again. The shopper gets `502 payments_unavailable` and can retry.
+
+## Retries
+
+Checking out the same cart again while its order is waiting for payment returns that order and the same client secret: the intent is created with the idempotency key `order-<id>`, so Stripe returns the existing one. Once the order is paid or cancelled, the cart answers `409 cart_closed`.
+
+## Order states
+
+| Status | Meaning |
+| --- | --- |
+| `pending_payment` | Created at checkout; stock is held for it |
+| `paid` | Stripe reported the payment succeeded |
+| `cancelled` | Not paid; the stock went back |
+| `fulfilled` | Handed to the warehouse |
+
+`GET /orders/:id` returns the order. The id is a random UUID that only the shopper who checked out has.
View file
Added.rebasely/kb/decisions/0004-take-stock-at-checkout.md+22−0
@@ -0,0 +1,22 @@
+---
+id: 0004-take-stock-at-checkout
+type: decision
+owner: justin
+status: accepted
+reviewed: 2026-10-02
+---
+# 0004 Take stock at checkout, not when items go in the cart
+
+## Context
+
+Two shoppers can want the last item. The stock has to be taken at some point between adding it to a cart and paying for it. Holding it from the moment it goes in the cart is the friendliest for the shopper who got there first, but most carts are abandoned, and each one would hold stock until a timer released it.
+
+## Decision
+
+Adding to a cart only checks the stock ([[carts]]). Checkout takes it, in the same D1 batch that creates the order, and the `CHECK (stock >= 0)` constraint on `products` refuses a batch that would oversell ([[checkout]]). Stock comes back if the order is cancelled.
+
+## Consequences
+
+- No reservations to expire for abandoned carts; a cart costs nothing until checkout.
+- A shopper can see an item in their cart and then be told at checkout that it sold out. The error lists what is left so the client can offer the remaining quantity.
+- Stock is held from checkout until the payment succeeds or the order is cancelled, so unpaid orders need to expire.
View file
Added.rebasely/kb/decisions/0005-stripe-payment-intents.md+22−0
@@ -0,0 +1,22 @@
+---
+id: 0005-stripe-payment-intents
+type: decision
+owner: justin
+status: accepted
+reviewed: 2026-10-02
+---
+# 0005 Charge with Stripe PaymentIntents from storefront-api
+
+## Context
+
+The shop needs card payments, and later wallets, without card data touching our Workers. Stripe offers a hosted Checkout page or PaymentIntents confirmed in our own page with the Payment Element.
+
+## Decision
+
+Checkout creates a PaymentIntent with the `stripe` package from storefront-api, using the secret key `STRIPE_SECRET_KEY`. The browser confirms it with the client secret. The order id goes in the intent's metadata and in the idempotency key, `order-<id>`.
+
+## Consequences
+
+- The payment form is part of our page, and the payment methods are whatever is turned on in the Stripe dashboard (`automatic_payment_methods`).
+- storefront-api holds the Stripe secret key, next to the public API.
+- The order is paid only when Stripe says so, which needs a webhook.
View file
Changed.rebasely/kb/guides/testing.md+3−2
@@ -4,7 +4,7 @@
owner: justin
describes:
- test/helpers/**
-reviewed: 2026-10-01
+reviewed: 2026-10-02
---
# Running and writing tests
@@ -21,9 +21,10 @@
- `DB` is `TestD1` (`test/helpers/d1.ts`), a small D1 stand-in over `node:sqlite` with every file in `migrations/` applied in order and foreign keys on. It covers `prepare`, `bind`, `first`, `all`, `run` and `batch`, and a batch is a transaction, as in D1.
- `IMAGES` is `TestR2` (`test/helpers/r2.ts`), an in-memory bucket.
+- Payments are `FakePayments` (`test/helpers/payments.ts`), which records every charge and fails the next one when `failNext` is set. `test/stripe-payments.test.ts` checks the real Stripe adapter against a fake `fetch`, so no test calls Stripe.
- The clock is fixed and ids count up (`id-1`, `id-2`), so assertions can name them.
-Tests talk HTTP to the handler through `call(method, path, body, headers)`, which returns the status, headers and parsed JSON. `addProduct` creates a product with the admin token.
+Tests talk HTTP to the handler through `call(method, path, body, headers)`, which returns the status, headers and parsed JSON. `addProduct` creates a product with the admin token, and `payments` is the fake the app was given.
## Writing one
View file
Changed.rebasely/kb/index.md+3−0
@@ -7,12 +7,15 @@
- [[catalog]]: products, prices and stock, and how the merchant edits them.
- [[carts]]: what a cart holds and the limits it enforces.
- [[product-images]]: how images get into R2 and back out.
+- [[checkout]]: how a cart becomes an order, and how stock and payment are handled.
## Decisions
- [[0001-d1-for-shop-data]]: one D1 database for everything the shop sells.
- [[0002-serve-images-through-the-api]]: images are served by storefront-api.
- [[0003-money-in-whole-cents]]: prices and totals are integers.
+- [[0004-take-stock-at-checkout]]: carts check stock, checkout takes it.
+- [[0005-stripe-payment-intents]]: payments are Stripe PaymentIntents.
## Guides
View file
ChangedREADME.md+6−2
@@ -1,6 +1,6 @@
# storefront
-The API behind a small online shop, running on Cloudflare Workers. Products, carts and orders live in D1, product images in R2.
+The API behind a small online shop, running on Cloudflare Workers. Products, carts and orders live in D1, product images in R2, and payments go through Stripe.
It is a plain fetch handler with a small router, no framework, and the tests run the real SQL migrations on Node's built-in SQLite, so `npm test` needs nothing but Node 24.
@@ -20,6 +20,8 @@
npm run dev
```
+Checkout needs Stripe: add `STRIPE_SECRET_KEY` to `.dev.vars` with a secret key from your Stripe account's test mode.
+
Then add a product and look at the catalog:
```bash
@@ -42,13 +44,15 @@
| `GET /carts/:id` | A cart with its lines and totals |
| `PUT /carts/:id/items/:product` | Set a quantity, `{ "quantity": n }` |
| `DELETE /carts/:id/items/:product` | Remove a line |
+| `POST /carts/:id/checkout` | Create the order and its PaymentIntent, `{ email, ship_to }` |
+| `GET /orders/:id` | An order and its status |
Errors are JSON, `{ "error": "<code>", "message": "..." }`, with extra fields where they help (an `out_of_stock` error says how many are `available`).
## Layout
```
-src/ storefront-api: router, catalog, carts, images
+src/ storefront-api: router, catalog, carts, images, checkout
migrations/ D1 schema, applied in order
test/ node --test suites and the D1 and R2 stand-ins
.rebasely/ architecture map, knowledge base and CI workflow
View file
Addedmigrations/0003_orders.sql+28−0
@@ -0,0 +1,28 @@
+-- Orders: a checked-out cart, its address and what it costs. Lines copy the name and price at
+-- checkout, so later catalog edits do not change past orders.
+CREATE TABLE orders (
+ id TEXT PRIMARY KEY,
+ cart_id TEXT NOT NULL UNIQUE REFERENCES carts (id),
+ status TEXT NOT NULL DEFAULT 'pending_payment'
+ CHECK (status IN ('pending_payment', 'paid', 'cancelled', 'fulfilled')),
+ email TEXT NOT NULL,
+ ship_to TEXT NOT NULL, -- JSON: name, line1, line2, city, postal_code, country
+ subtotal_cents INTEGER NOT NULL,
+ shipping_cents INTEGER NOT NULL,
+ total_cents INTEGER NOT NULL,
+ currency TEXT NOT NULL,
+ payment_intent_id TEXT UNIQUE,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE INDEX orders_by_status ON orders (status, created_at);
+
+CREATE TABLE order_items (
+ order_id TEXT NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
+ product_id TEXT NOT NULL REFERENCES products (id),
+ name TEXT NOT NULL,
+ quantity INTEGER NOT NULL CHECK (quantity > 0),
+ unit_price_cents INTEGER NOT NULL,
+ PRIMARY KEY (order_id, product_id)
+);
View file
Changedpackage-lock.json+22−2
@@ -8,6 +8,9 @@
"name": "storefront",
"version": "0.1.0",
"license": "MIT",
+ "dependencies": {
+ "stripe": "^22.6.2"
+ },
"devDependencies": {
"@cloudflare/workers-types": "^5.20261016.1",
"@types/node": "^24.19.2",
@@ -28,7 +31,7 @@
"version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -374,6 +377,23 @@
"node": ">=16.20.0"
}
},
+ "node_modules/stripe": {
+ "version": "22.6.2",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.6.2.tgz",
+ "integrity": "sha512-PwRE2scocvXqDKPiskMa5vRJEAEkq46W69XyUYmAwrNSLr675lQnfqLwnsndAWizwgihOWw/irLmoFRo3ez7ew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@@ -413,7 +433,7 @@
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
}
}
View file
Changedpackage.json+3−0
@@ -18,5 +18,8 @@
"@cloudflare/workers-types": "^5.20261016.1",
"@types/node": "^24.19.2",
"typescript": "^7.0.2"
+ },
+ "dependencies": {
+ "stripe": "^22.6.2"
}
}
View file
Changedsrc/app.ts+21−1
@@ -3,17 +3,25 @@
import { createCart, getCart, setQuantity } from "./carts.ts";
import { getProduct, listProducts, parseProductInput, upsertProduct } from "./catalog.ts";
+import { checkout } from "./checkout.ts";
import type { Env } from "./env.ts";
import { errorResponse, HttpError, json, readJson, requireAdmin } from "./http.ts";
import { putProductImage, serveImage } from "./images.ts";
+import { getOrder } from "./orders.ts";
+import { stripePayments, type Payments } from "./payments.ts";
import { Router } from "./router.ts";
export type Deps = {
now: () => number;
newId: () => string;
+ payments: (env: Env) => Payments;
};
-export const defaultDeps: Deps = { now: Date.now, newId: () => crypto.randomUUID() };
+export const defaultDeps: Deps = {
+ now: Date.now,
+ newId: () => crypto.randomUUID(),
+ payments: (env) => stripePayments(env.STRIPE_SECRET_KEY),
+};
export function createApp(env: Env, deps: Deps = defaultDeps) {
const r = new Router();
@@ -54,6 +62,18 @@
});
r.on("DELETE", "/carts/:id/items/:product", async (_req, p) => json({ cart: await setQuantity(env.DB, p.id!, p.product!, 0, deps.now()) }));
+
+ r.on("POST", "/carts/:id/checkout", async (req, p) => {
+ const result = await checkout(env.DB, deps.payments(env), p.id!, await readJson(req), deps);
+ return json({ order: result.order, payment: { provider: "stripe", client_secret: result.client_secret } }, result.created ? 201 : 200);
+ });
+
+ // The order id is a random UUID that only the shopper who checked out has.
+ r.on("GET", "/orders/:id", async (_req, p) => {
+ const order = await getOrder(env.DB, p.id!);
+ if (!order) throw new HttpError(404, "order_not_found", "No such order.");
+ return json({ order });
+ });
return {
async fetch(req: Request): Promise<Response> {
View file
Addedsrc/checkout.ts+114−0
@@ -0,0 +1,114 @@
+// Checkout: turns an open cart into an order, takes the stock, and asks the payment provider for
+// the PaymentIntent the shopper pays in the browser. See .rebasely/kb/concepts/checkout.md.
+
+import { getCart } from "./carts.ts";
+import { HttpError } from "./http.ts";
+import { getOrder, type Address, type Order } from "./orders.ts";
+import type { Payments } from "./payments.ts";
+
+export type CheckoutResult = { order: Order; client_secret: string; created: boolean };
+
+type Clock = { now: () => number; newId: () => string };
+
+export async function checkout(db: D1Database, payments: Payments, cartId: string, body: Record<string, unknown>, deps: Clock): Promise<CheckoutResult> {
+ const { email, ship_to } = parseCheckout(body);
+
+ // A retry after a timeout finds the order the first attempt created. Creating the intent is
+ // idempotent per order, so the shopper gets the same client secret back.
+ const existing = await db.prepare("SELECT id, status FROM orders WHERE cart_id = ?").bind(cartId).first<{ id: string; status: string }>();
+ if (existing) {
+ if (existing.status !== "pending_payment") throw new HttpError(409, "cart_closed", "This cart has been checked out.");
+ const order = (await getOrder(db, existing.id))!;
+ const intent = await payments.createIntent({ order_id: order.id, amount_cents: order.total_cents, currency: order.currency, email: order.email });
+ return { order, client_secret: intent.client_secret, created: false };
+ }
+
+ const cart = await getCart(db, cartId);
+ if (!cart) throw new HttpError(404, "cart_not_found", "No such cart.");
+ if (cart.status !== "open") throw new HttpError(409, "cart_closed", "This cart has been checked out.");
+ if (!cart.items.length) throw new HttpError(409, "cart_empty", "The cart is empty.");
+ await assertInStock(db, cart.items);
+
+ const id = deps.newId();
+ const now = deps.now();
+ const statements = [
+ db
+ .prepare(
+ `INSERT INTO orders (id, cart_id, email, ship_to, subtotal_cents, shipping_cents, total_cents, currency, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .bind(id, cartId, email, JSON.stringify(ship_to), cart.subtotal_cents, cart.shipping_cents, cart.total_cents, cart.currency, now, now),
+ ...cart.items.flatMap((i) => [
+ db
+ .prepare("INSERT INTO order_items (order_id, product_id, name, quantity, unit_price_cents) VALUES (?, ?, ?, ?, ?)")
+ .bind(id, i.product_id, i.name, i.quantity, i.unit_price_cents),
+ // products.stock has CHECK (stock >= 0): if another checkout took the last one first, this
+ // statement fails and D1 rolls back the whole batch.
+ db.prepare("UPDATE products SET stock = stock - ?, updated_at = ? WHERE id = ?").bind(i.quantity, now, i.product_id),
+ ]),
+ db.prepare("UPDATE carts SET status = 'checked_out', updated_at = ? WHERE id = ?").bind(now, cartId),
+ ];
+ try {
+ await db.batch(statements);
+ } catch (e) {
+ const msg = String(e);
+ if (/CHECK constraint failed/i.test(msg)) await assertInStock(db, cart.items);
+ if (/UNIQUE constraint failed: orders\.cart_id/i.test(msg)) throw new HttpError(409, "cart_closed", "This cart has been checked out.");
+ throw e;
+ }
+
+ let clientSecret: string;
+ try {
+ const intent = await payments.createIntent({ order_id: id, amount_cents: cart.total_cents, currency: cart.currency, email });
+ await db.prepare("UPDATE orders SET payment_intent_id = ? WHERE id = ?").bind(intent.id, id).run();
+ clientSecret = intent.client_secret;
+ } catch (e) {
+ // Nothing was charged, so undo the checkout completely: the stock goes back and the cart
+ // opens again for the shopper to retry.
+ console.error(JSON.stringify({ msg: "creating the payment intent failed", order: id, error: String(e) }));
+ await db.batch([
+ ...cart.items.map((i) => db.prepare("UPDATE products SET stock = stock + ? WHERE id = ?").bind(i.quantity, i.product_id)),
+ db.prepare("DELETE FROM orders WHERE id = ?").bind(id),
+ db.prepare("UPDATE carts SET status = 'open', updated_at = ? WHERE id = ?").bind(now, cartId),
+ ]);
+ throw new HttpError(502, "payments_unavailable", "The payment provider did not answer. Nothing was charged; try again.");
+ }
+ return { order: (await getOrder(db, id))!, client_secret: clientSecret, created: true };
+}
+
+async function assertInStock(db: D1Database, items: { product_id: string; quantity: number }[]): Promise<void> {
+ const short: { product_id: string; available: number }[] = [];
+ for (const i of items) {
+ const p = await db.prepare("SELECT stock, active FROM products WHERE id = ?").bind(i.product_id).first<{ stock: number; active: number }>();
+ const available = p && p.active === 1 ? p.stock : 0;
+ if (available < i.quantity) short.push({ product_id: i.product_id, available });
+ }
+ if (short.length) throw new HttpError(409, "out_of_stock", "Some items are no longer available in that quantity.", { items: short });
+}
+
+const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+export function parseCheckout(body: Record<string, unknown>): { email: string; ship_to: Address } {
+ const email = typeof body.email === "string" ? body.email.trim() : "";
+ if (!EMAIL.test(email) || email.length > 254) throw new HttpError(400, "bad_email", "email is an email address.");
+ const raw = body.ship_to;
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new HttpError(400, "bad_address", "ship_to is the shipping address.");
+ const a = raw as Record<string, unknown>;
+ const field = (name: keyof Address, max: number, required = true): string => {
+ const v = a[name] ?? "";
+ if (typeof v !== "string" || v.length > max || (required && !v.trim())) {
+ throw new HttpError(400, "bad_address", `ship_to.${name} is ${required ? "required, " : ""}at most ${max} characters.`);
+ }
+ return v.trim();
+ };
+ const ship_to: Address = {
+ name: field("name", 100),
+ line1: field("line1", 200),
+ line2: field("line2", 200, false),
+ city: field("city", 100),
+ postal_code: field("postal_code", 20),
+ country: field("country", 2).toUpperCase(),
+ };
+ if (!/^[A-Z]{2}$/.test(ship_to.country)) throw new HttpError(400, "bad_address", "ship_to.country is a two-letter country code.");
+ return { email, ship_to };
+}
View file
Changedsrc/env.ts+1−0
@@ -3,4 +3,5 @@
DB: D1Database;
IMAGES: R2Bucket;
ADMIN_TOKEN: string;
+ STRIPE_SECRET_KEY: string;
}
View file
Addedsrc/orders.ts+62−0
@@ -0,0 +1,62 @@
+// Orders: what a cart becomes at checkout. An order starts as pending_payment and moves on when
+// the payment provider reports the outcome.
+
+import type { Totals } from "./money.ts";
+
+export type OrderStatus = "pending_payment" | "paid" | "cancelled" | "fulfilled";
+
+export type Address = {
+ name: string;
+ line1: string;
+ line2: string;
+ city: string;
+ postal_code: string;
+ country: string;
+};
+
+export type OrderItem = { product_id: string; name: string; quantity: number; unit_price_cents: number };
+
+export type Order = {
+ id: string;
+ status: OrderStatus;
+ email: string;
+ ship_to: Address;
+ items: OrderItem[];
+ created_at: string;
+} & Totals;
+
+type Row = {
+ id: string;
+ status: OrderStatus;
+ email: string;
+ ship_to: string;
+ subtotal_cents: number;
+ shipping_cents: number;
+ total_cents: number;
+ currency: string;
+ created_at: number;
+};
+
+export async function getOrder(db: D1Database, id: string): Promise<Order | null> {
+ const row = await db
+ .prepare("SELECT id, status, email, ship_to, subtotal_cents, shipping_cents, total_cents, currency, created_at FROM orders WHERE id = ?")
+ .bind(id)
+ .first<Row>();
+ if (!row) return null;
+ const { results } = await db
+ .prepare("SELECT product_id, name, quantity, unit_price_cents FROM order_items WHERE order_id = ? ORDER BY product_id")
+ .bind(id)
+ .all<OrderItem>();
+ return {
+ id: row.id,
+ status: row.status,
+ email: row.email,
+ ship_to: JSON.parse(row.ship_to) as Address,
+ items: results,
+ subtotal_cents: row.subtotal_cents,
+ shipping_cents: row.shipping_cents,
+ total_cents: row.total_cents,
+ currency: row.currency,
+ created_at: new Date(row.created_at).toISOString(),
+ };
+}
View file
Addedsrc/payments.ts+47−0
@@ -0,0 +1,47 @@
+// The payment provider, behind the two calls checkout needs. Stripe in production; tests pass
+// their own Payments.
+
+import Stripe from "stripe";
+
+export type Charge = { order_id: string; amount_cents: number; currency: string; email: string };
+export type Intent = { id: string; client_secret: string };
+
+export interface Payments {
+ // Creates the PaymentIntent the shopper pays in the browser. Calling it again for the same
+ // order returns the same intent.
+ createIntent(charge: Charge): Promise<Intent>;
+ cancelIntent(intentId: string): Promise<void>;
+}
+
+export function stripePayments(secretKey: string, fetchFn?: typeof fetch): Payments {
+ const stripe = new Stripe(secretKey, {
+ httpClient: Stripe.createFetchHttpClient(fetchFn),
+ maxNetworkRetries: 2,
+ timeout: 10_000,
+ });
+ return {
+ async createIntent(c) {
+ const intent = await stripe.paymentIntents.create(
+ {
+ amount: c.amount_cents,
+ currency: c.currency,
+ receipt_email: c.email,
+ automatic_payment_methods: { enabled: true },
+ metadata: { order_id: c.order_id },
+ },
+ { idempotencyKey: `order-${c.order_id}` },
+ );
+ if (!intent.client_secret) throw new Error(`PaymentIntent ${intent.id} has no client_secret`);
+ return { id: intent.id, client_secret: intent.client_secret };
+ },
+ async cancelIntent(intentId) {
+ try {
+ await stripe.paymentIntents.cancel(intentId);
+ } catch (e) {
+ // Already canceled, or paid in the meantime: the webhook settles the order either way.
+ if (e instanceof Stripe.errors.StripeInvalidRequestError && e.code === "payment_intent_unexpected_state") return;
+ throw e;
+ }
+ },
+ };
+}
View file
Addedtest/checkout.test.ts+96−0
@@ -0,0 +1,96 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { setup } from "./helpers/app.ts";
+
+const SHIP_TO = { name: "Ada Park", line1: "12 Alder St", city: "Portland", postal_code: "97205", country: "us" };
+const CHECKOUT = { email: "ada@park.test", ship_to: SHIP_TO };
+
+async function filledCart(stock = 3) {
+ const s = setup();
+ await s.addProduct("wool-socks", { name: "Wool socks", price_cents: 1800, stock });
+ await s.addProduct("canvas-tote", { name: "Canvas tote", price_cents: 2400, stock: 10 });
+ const cartId = (await s.call("POST", "/carts")).body.cart.id as string;
+ await s.call("PUT", `/carts/${cartId}/items/wool-socks`, { quantity: 2 });
+ await s.call("PUT", `/carts/${cartId}/items/canvas-tote`, { quantity: 1 });
+ return { ...s, cartId };
+}
+
+async function stockOf(s: ReturnType<typeof setup>, id: string): Promise<number> {
+ return (await s.call("GET", `/products/${id}`)).body.product.stock;
+}
+
+test("checkout creates a pending order, takes the stock and returns the client secret", async () => {
+ const s = await filledCart();
+ const res = await s.call("POST", `/carts/${s.cartId}/checkout`, CHECKOUT);
+ assert.equal(res.status, 201);
+ const { order, payment } = res.body;
+ assert.equal(order.status, "pending_payment");
+ assert.equal(order.total_cents, 6000);
+ assert.equal(order.ship_to.country, "US");
+ assert.equal(order.items.length, 2);
+ assert.equal(payment.client_secret, `pi_${order.id}_secret`);
+ assert.deepEqual(s.payments.charges, [{ order_id: order.id, amount_cents: 6000, currency: "usd", email: "ada@park.test" }]);
+ assert.equal(await stockOf(s, "wool-socks"), 1);
+ assert.equal((await s.call("GET", `/carts/${s.cartId}`)).body.cart.status, "checked_out");
+ assert.equal((await s.call("GET", `/orders/${order.id}`)).body.order.status, "pending_payment");
+});
+
+test("a retried checkout returns the same order and takes no more stock", async () => {
+ const s = await filledCart();
+ const first = await s.call("POST", `/carts/${s.cartId}/checkout`, CHECKOUT);
+ const again = await s.call("POST", `/carts/${s.cartId}/checkout`, CHECKOUT);
+ assert.equal(again.status, 200);
+ assert.equal(again.body.order.id, first.body.order.id);
+ assert.equal(again.body.payment.client_secret, first.body.payment.client_secret);
+ assert.equal(await stockOf(s, "wool-socks"), 1);
+});
+
+test("a checked-out cart can no longer change", async () => {
+ const s = await filledCart();
+ await s.call("POST", `/carts/${s.cartId}/checkout`, CHECKOUT);
+ const res = await s.call("PUT", `/carts/${s.cartId}/items/wool-socks`, { quantity: 1 });
+ assert.equal(res.body.error, "cart_closed");
+});
+
+test("refuses the checkout when stock ran out after the item was added", async () => {
+ const s = await filledCart();
+ await s.addProduct("wool-socks", { name: "Wool socks", price_cents: 1800, stock: 1 });
+ const res = await s.call("POST", `/carts/${s.cartId}/checkout`, CHECKOUT);
+ assert.equal(res.status, 409);
+ assert.equal(res.body.error, "out_of_stock");
+ assert.deepEqual(res.body.items, [{ product_id: "wool-socks", available: 1 }]);
+ assert.equal(await stockOf(s, "canvas-tote"), 10, "nothing was taken");
+ assert.equal((await s.call("GET", `/carts/${s.cartId}`)).body.cart.status, "open");
+});
+
+test("the stock constraint stops two checkouts from both taking the last items", async () => {
+ const s = await filledCart(2);
+ const other = (await s.call("POST", "/carts")).body.cart.id;
+ await s.call("PUT", `/carts/${other}/items/wool-socks`, { quantity: 2 });
+ assert.equal((await s.call("POST", `/carts/${other}/checkout`, CHECKOUT)).status, 201);
+ const res = await s.call("POST", `/carts/${s.cartId}/checkout`, CHECKOUT);
+ assert.equal(res.body.error, "out_of_stock");
+ assert.equal(await stockOf(s, "wool-socks"), 0);
+});
+
+test("when the payment provider fails, the checkout is undone", async () => {
+ const s = await filledCart();
+ s.payments.failNext = true;
+ const res = await s.call("POST", `/carts/${s.cartId}/checkout`, CHECKOUT);
+ assert.equal(res.status, 502);
+ assert.equal(res.body.error, "payments_unavailable");
+ assert.equal(await stockOf(s, "wool-socks"), 3);
+ assert.equal((await s.call("GET", `/carts/${s.cartId}`)).body.cart.status, "open");
+ assert.equal((await s.call("POST", `/carts/${s.cartId}/checkout`, CHECKOUT)).status, 201, "and can be retried");
+});
+
+test("checkout needs an email, a full address and a non-empty cart", async () => {
+ const s = await filledCart();
+ assert.equal((await s.call("POST", `/carts/${s.cartId}/checkout`, { ...CHECKOUT, email: "ada" })).body.error, "bad_email");
+ assert.equal((await s.call("POST", `/carts/${s.cartId}/checkout`, { email: "ada@park.test" })).body.error, "bad_address");
+ const noCity = { ...CHECKOUT, ship_to: { ...SHIP_TO, city: " " } };
+ assert.equal((await s.call("POST", `/carts/${s.cartId}/checkout`, noCity)).body.error, "bad_address");
+ const empty = (await s.call("POST", "/carts")).body.cart.id;
+ assert.equal((await s.call("POST", `/carts/${empty}/checkout`, CHECKOUT)).body.error, "cart_empty");
+ assert.equal((await s.call("GET", "/orders/nope")).status, 404);
+});
View file
Changedtest/helpers/app.ts+5−3
@@ -3,15 +3,17 @@
import { createApp } from "../../src/app.ts";
import type { Env } from "../../src/env.ts";
import { testDb } from "./d1.ts";
+import { FakePayments } from "./payments.ts";
import { testBucket } from "./r2.ts";
export const ADMIN_TOKEN = "test-admin-token";
export const NOW = Date.UTC(2026, 9, 1, 12, 0, 0);
export function setup() {
- const env: Env = { DB: testDb(), IMAGES: testBucket(), ADMIN_TOKEN };
+ const env: Env = { DB: testDb(), IMAGES: testBucket(), ADMIN_TOKEN, STRIPE_SECRET_KEY: "sk_test_unused" };
+ const payments = new FakePayments();
let n = 0;
- const app = createApp(env, { now: () => NOW, newId: () => `id-${++n}` });
+ const app = createApp(env, { now: () => NOW, newId: () => `id-${++n}`, payments: () => payments });
async function call(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const init: RequestInit = { method, headers: { ...headers } };
@@ -35,5 +37,5 @@
return res.body.product;
}
- return { env, app, call, admin, addProduct };
+ return { env, app, call, admin, addProduct, payments };
}
View file
Addedtest/helpers/payments.ts+23−0
@@ -0,0 +1,23 @@
+// A Payments stand-in that records what checkout asked for. Set `failNext` to make the next
+// createIntent throw, as when the provider times out.
+
+import type { Charge, Intent, Payments } from "../../src/payments.ts";
+
+export class FakePayments implements Payments {
+ readonly charges: Charge[] = [];
+ readonly cancelled: string[] = [];
+ failNext = false;
+
+ async createIntent(c: Charge): Promise<Intent> {
+ if (this.failNext) {
+ this.failNext = false;
+ throw new Error("connect ETIMEDOUT");
+ }
+ this.charges.push(c);
+ return { id: `pi_${c.order_id}`, client_secret: `pi_${c.order_id}_secret` };
+ }
+
+ async cancelIntent(intentId: string): Promise<void> {
+ this.cancelled.push(intentId);
+ }
+}
View file
Addedtest/stripe-payments.test.ts+40−0
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { stripePayments } from "../src/payments.ts";
+
+type Seen = { url: string; method: string; headers: Headers; body: URLSearchParams };
+
+function fakeStripe(respond: (seen: Seen) => { status?: number; body: unknown }) {
+ const seen: Seen[] = [];
+ const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const s = { url: String(input), method: init?.method ?? "GET", headers: new Headers(init?.headers), body: new URLSearchParams(String(init?.body ?? "")) };
+ seen.push(s);
+ const { status = 200, body } = respond(s);
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "request-id": "req_test" } });
+ }) as typeof fetch;
+ return { seen, fetchFn };
+}
+
+test("creates a PaymentIntent for the order total with the order id as idempotency key", async () => {
+ const stripe = fakeStripe(() => ({ body: { id: "pi_123", object: "payment_intent", client_secret: "pi_123_secret_abc" } }));
+ const intent = await stripePayments("sk_test_123", stripe.fetchFn).createIntent({ order_id: "ord-1", amount_cents: 6000, currency: "usd", email: "ada@park.test" });
+ assert.deepEqual(intent, { id: "pi_123", client_secret: "pi_123_secret_abc" });
+
+ const [req] = stripe.seen;
+ assert.equal(req!.method, "POST");
+ assert.equal(new URL(req!.url).pathname, "/v1/payment_intents");
+ assert.equal(req!.headers.get("idempotency-key"), "order-ord-1");
+ assert.equal(req!.body.get("amount"), "6000");
+ assert.equal(req!.body.get("currency"), "usd");
+ assert.equal(req!.body.get("metadata[order_id]"), "ord-1");
+ assert.equal(req!.body.get("automatic_payment_methods[enabled]"), "true");
+});
+
+test("cancelling an intent that is no longer cancellable is not an error", async () => {
+ const stripe = fakeStripe(() => ({
+ status: 400,
+ body: { error: { type: "invalid_request_error", code: "payment_intent_unexpected_state", message: "This PaymentIntent has already succeeded." } },
+ }));
+ await stripePayments("sk_test_123", stripe.fetchFn).cancelIntent("pi_123");
+ assert.equal(new URL(stripe.seen[0]!.url).pathname, "/v1/payment_intents/pi_123/cancel");
+});
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.