Rebasely Beta: data may be purged

examples/storefront Public

Mark orders paid from Stripe's webhook in a Worker of its own

storefront-stripe-webhook verifies the signature and settles the order through a new OrdersService entrypoint on storefront-api, so the API stays the only Worker that writes to D1. markPaid and cancel only move an order forward from pending_payment, which makes Stripe's repeated deliveries harmless.
82cc041615a51fa2a6fac71098d7ec8c6b818dd9
Parent 582fe0a Browse filesHistory

Changed files

14 files changed292 additions10 deletionsagainst 582fe0a

  1. Changed.rebasely/architecture/flows/checkout.yaml+5−1
  2. Changed.rebasely/architecture/system.yaml+15−3
  3. Changed.rebasely/architecture/views/checkout.yaml+1−1
  4. Changed.rebasely/kb/concepts/checkout.md+4−4
  5. Added.rebasely/kb/concepts/payment-webhook.md+38−0
  6. Added.rebasely/kb/decisions/0006-one-writer-for-the-database.md+22−0
  7. Changed.rebasely/kb/index.md+2−0
  8. ChangedREADME.md+7−1
  9. Changedsrc/index.ts+15−0
  10. Changedsrc/orders.ts+34−0
  11. Addedtest/stripe-webhook.test.ts+77−0
  12. Addedworkers/stripe-webhook/src/index.ts+7−0
  13. Addedworkers/stripe-webhook/src/webhook.ts+54−0
  14. Addedworkers/stripe-webhook/wrangler.jsonc+11−0
Changed.rebasely/architecture/flows/checkout.yaml+5−1
@@ -2,7 +2,8 @@
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.
+ confirms the payment with the Payment Element. Stripe reports the result to stripe-webhook, which
+ marks the order paid through OrdersService.
actors: [shopper]
steps:
- { from: shopper, to: storefront-api, label: "POST /carts/:id/checkout, email and address" }
@@ -12,4 +13,7 @@
- { 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: stripe, to: stripe-webhook, label: "payment_intent.succeeded, signed" }
+ - { from: stripe-webhook, to: storefront-api, label: "OrdersService.markPaid(order, intent)" }
+ - { from: storefront-api, to: db, label: mark the order paid if it is still pending }
- { from: shopper, to: storefront-api, label: "GET /orders/:id until it is paid" }
View file
Changed.rebasely/architecture/system.yaml+15−3
@@ -2,13 +2,24 @@
storefront-api:
kind: worker
description: >-
- The public HTTP API (wrangler.jsonc, Worker storefront-api). Shoppers browse the catalog and
- fill carts; the merchant edits products and uploads their images with the admin token.
+ The public HTTP API (wrangler.jsonc, Worker storefront-api). Shoppers browse the catalog, fill
+ carts and check out; the merchant edits products and uploads their images with the admin
+ token. Its OrdersService entrypoint is how the other Workers change orders: it is the only
+ Worker with the database binding.
owns: [src/**]
talks_to:
- { to: db, via: d1 }
- { to: images, via: r2 }
- { to: stripe, via: https }
+ stripe-webhook:
+ kind: worker
+ description: >-
+ Worker storefront-stripe-webhook (workers/stripe-webhook). Verifies Stripe's webhook
+ signatures and settles the order of each PaymentIntent through OrdersService.
+ owns: [workers/stripe-webhook/**]
+ talks_to:
+ - { to: storefront-api, via: rpc }
+ - { to: stripe, via: webhooks }
db:
kind: d1
description: >-
@@ -26,5 +37,6 @@
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.
+ the secret key; the shopper confirms it in the browser with the Payment Element, and Stripe
+ reports the outcome to stripe-webhook.
package: stripe
View file
Changed.rebasely/architecture/views/checkout.yaml+1−1
@@ -1,2 +1,2 @@
description: Everything a checkout touches, from the cart to the charge.
-components: [storefront-api, db, stripe]
+components: [storefront-api, stripe-webhook, db, stripe]
View file
Changed.rebasely/kb/concepts/checkout.md+4−4
@@ -8,7 +8,7 @@
- src/orders.ts
- src/payments.ts
- migrations/0003_orders.sql
-reviewed: 2026-10-02
+reviewed: 2026-10-05
---
# Checkout
@@ -35,8 +35,8 @@
| 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 |
+| `paid` | Stripe reported the payment succeeded ([[payment-webhook]]) |
+| `cancelled` | Not paid; the stock went back in the same batch |
| `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.
+State changes after checkout are in `src/orders.ts` (`markPaid`, `cancelOrder`) and only move forward from `pending_payment`, so repeating one is harmless. `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/concepts/payment-webhook.md+38−0
@@ -0,0 +1,38 @@
+---
+id: payment-webhook
+type: concept
+owner: justin
+component: stripe-webhook
+describes:
+ - workers/stripe-webhook/src/**
+reviewed: 2026-10-05
+---
+# The payment webhook
+
+An order is paid when Stripe says so, never when the browser says so. Stripe sends events to `https://<stripe-webhook host>/stripe/webhook`, and the `storefront-stripe-webhook` Worker turns them into order changes.
+
+## What it does
+
+1. Verifies the `stripe-signature` header against `STRIPE_WEBHOOK_SECRET` with Web Crypto. A bad or missing signature gets a 400 and changes nothing.
+2. For `payment_intent.succeeded`, calls `OrdersService.markPaid(order, intent)` on storefront-api. The order id comes from the intent's metadata, which checkout set ([[checkout]]).
+3. For `payment_intent.canceled`, calls `OrdersService.cancel(order)`, which puts the stock back.
+4. Answers 200 to everything else, including events for intents without an `order_id`, so Stripe stops retrying them.
+
+The Worker has no database binding. It reaches orders only through the service binding `ORDERS` ([[0006-one-writer-for-the-database]]).
+
+## Retries and repeats
+
+Stripe delivers each event at least once and retries anything that does not get a 2xx. Both calls are safe to repeat:
+
+- `markPaid` only moves an order from `pending_payment` to `paid`, and only when the intent id matches the one stored at checkout.
+- `cancel` puts stock back in the same batch that cancels the order, and only while the order is pending.
+
+If OrdersService throws, the webhook answers 500 and Stripe tries again later.
+
+## A payment for a cancelled order
+
+If an order was cancelled and its payment still succeeds, `markPaid` leaves the order cancelled and the Worker logs `payment for a cancelled order, refund it` as an error. That should not happen once unpaid orders cancel their intent first, but the log line is there to search for.
+
+## Local testing
+
+`stripe listen --forward-to localhost:8788/stripe/webhook` prints a webhook secret for `.dev.vars` and forwards test-mode events. Run the Worker with `npx wrangler dev -c workers/stripe-webhook/wrangler.jsonc --port 8788` next to storefront-api.
View file
Added.rebasely/kb/decisions/0006-one-writer-for-the-database.md+22−0
@@ -0,0 +1,22 @@
+---
+id: 0006-one-writer-for-the-database
+type: decision
+owner: justin
+status: accepted
+reviewed: 2026-10-05
+---
+# 0006 storefront-api is the only Worker that writes to D1
+
+## Context
+
+Payment results arrive at a separate Worker ([[payment-webhook]]) so its URL, secret and retries stay apart from the public API. That Worker has to change orders. It could bind the database and run its own SQL, or ask storefront-api to do it.
+
+## Decision
+
+Only storefront-api has the `DB` binding. Other Workers change orders through its `OrdersService` entrypoint over a service binding (`ORDERS`), with calls that name the change: `markPaid`, `cancel`. The SQL for an order's states lives in one file, `src/orders.ts`.
+
+## Consequences
+
+- The rules for moving an order between states, and for giving stock back, are written once and tested once.
+- A schema change only has to be deployed with one Worker.
+- Service binding calls stay inside Cloudflare and add no network hop worth measuring, but they couple deploys: OrdersService methods are an interface, so add new ones before callers use them and remove old ones only after.
View file
Changed.rebasely/kb/index.md+2−0
@@ -8,6 +8,7 @@
- [[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.
+- [[payment-webhook]]: how Stripe's events mark orders paid or cancelled.
## Decisions
@@ -16,6 +17,7 @@
- [[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.
+- [[0006-one-writer-for-the-database]]: other Workers change orders through OrdersService.
## Guides
View file
ChangedREADME.md+7−1
@@ -2,6 +2,11 @@
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.
+There are two Workers:
+
+- `storefront-api` (`src/`) is the public API and the only Worker that writes to the database.
+- `storefront-stripe-webhook` (`workers/stripe-webhook/`) receives Stripe's events and settles orders through storefront-api's `OrdersService` entrypoint.
+
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.
## Running it
@@ -52,7 +57,8 @@
## Layout
```
-src/ storefront-api: router, catalog, carts, images, checkout
+src/ storefront-api: router, catalog, carts, images, checkout, orders
+workers/ the other Workers, each with its own wrangler.jsonc
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
Changedsrc/index.ts+15−0
@@ -1,5 +1,20 @@
+import { WorkerEntrypoint } from "cloudflare:workers";
import { createApp } from "./app.ts";
import type { Env } from "./env.ts";
+import { cancelOrder, markPaid, type Settled } from "./orders.ts";
+
+// The calls the shop's other Workers make through a service binding (entrypoint OrdersService).
+// storefront-api stays the only Worker that writes to the database; see
+// .rebasely/kb/decisions/0006-one-writer-for-the-database.md.
+export class OrdersService extends WorkerEntrypoint<Env> {
+ markPaid(orderId: string, paymentIntentId: string): Promise<Settled> {
+ return markPaid(this.env.DB, orderId, paymentIntentId, Date.now());
+ }
+
+ cancel(orderId: string): Promise<Settled> {
+ return cancelOrder(this.env.DB, orderId, Date.now());
+ }
+}
export default {
fetch(req, env) {
View file
Changedsrc/orders.ts+34−0
@@ -60,3 +60,37 @@
created_at: new Date(row.created_at).toISOString(),
};
}
+
+// What the payment side learns back: the order's status after the call, and whether the call
+// changed it. Both calls are idempotent, because Stripe delivers webhooks at least once.
+export type Settled = { order_id: string; status: OrderStatus; changed: boolean };
+
+export async function markPaid(db: D1Database, orderId: string, paymentIntentId: string, now: number): Promise<Settled> {
+ const r = await db
+ .prepare("UPDATE orders SET status = 'paid', updated_at = ? WHERE id = ? AND payment_intent_id = ? AND status = 'pending_payment'")
+ .bind(now, orderId, paymentIntentId)
+ .run();
+ return settled(db, orderId, r.meta.changes > 0);
+}
+
+// Cancels an order that is still waiting for payment and puts its stock back, in one batch. The
+// stock update runs first and only while the order is pending, so a second call changes nothing.
+export async function cancelOrder(db: D1Database, orderId: string, now: number): Promise<Settled> {
+ const [, cancel] = await db.batch([
+ db
+ .prepare(
+ `UPDATE products SET stock = stock + (SELECT quantity FROM order_items WHERE order_id = ?1 AND product_id = products.id), updated_at = ?2
+ WHERE id IN (SELECT product_id FROM order_items WHERE order_id = ?1)
+ AND EXISTS (SELECT 1 FROM orders WHERE id = ?1 AND status = 'pending_payment')`,
+ )
+ .bind(orderId, now),
+ db.prepare("UPDATE orders SET status = 'cancelled', updated_at = ? WHERE id = ? AND status = 'pending_payment'").bind(now, orderId),
+ ]);
+ return settled(db, orderId, (cancel?.meta.changes ?? 0) > 0);
+}
+
+async function settled(db: D1Database, orderId: string, changed: boolean): Promise<Settled> {
+ const status = await db.prepare("SELECT status FROM orders WHERE id = ?").bind(orderId).first<OrderStatus>("status");
+ if (!status) throw new Error(`no order ${orderId}`);
+ return { order_id: orderId, status, changed };
+}
View file
Addedtest/stripe-webhook.test.ts+77−0
@@ -0,0 +1,77 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import Stripe from "stripe";
+import { cancelOrder, markPaid } from "../src/orders.ts";
+import { handleWebhook, type Env } from "../workers/stripe-webhook/src/webhook.ts";
+import { NOW, setup } from "./helpers/app.ts";
+
+const SECRET = "whsec_test_secret";
+const CHECKOUT = { email: "ada@park.test", ship_to: { name: "Ada Park", line1: "12 Alder St", city: "Portland", postal_code: "97205", country: "US" } };
+
+// A paid-for order waiting for its webhook, and the webhook Worker wired to the same database
+// the way the ORDERS service binding wires it in production.
+async function pendingOrder() {
+ const s = setup();
+ await s.addProduct("wool-socks", { price_cents: 1800, stock: 3 });
+ const cartId = (await s.call("POST", "/carts")).body.cart.id;
+ await s.call("PUT", `/carts/${cartId}/items/wool-socks`, { quantity: 2 });
+ const order = (await s.call("POST", `/carts/${cartId}/checkout`, CHECKOUT)).body.order;
+ const env: Env = {
+ STRIPE_WEBHOOK_SECRET: SECRET,
+ ORDERS: {
+ markPaid: (id, intent) => markPaid(s.env.DB, id, intent, NOW),
+ cancel: (id) => cancelOrder(s.env.DB, id, NOW),
+ },
+ };
+ const deliver = async (type: string, intent: Record<string, unknown>, secret = SECRET) => {
+ const payload = JSON.stringify({ id: `evt_${type}`, object: "event", type, data: { object: { object: "payment_intent", ...intent } } });
+ const header = await Stripe.webhooks.generateTestHeaderStringAsync({ payload, secret, cryptoProvider: Stripe.createSubtleCryptoProvider() });
+ const req = new Request("https://hooks.shop.test/stripe/webhook", { method: "POST", body: payload, headers: { "stripe-signature": header } });
+ return handleWebhook(req, env);
+ };
+ const status = async () => (await s.call("GET", `/orders/${order.id}`)).body.order.status;
+ return { ...s, order, deliver, status };
+}
+
+test("payment_intent.succeeded marks the order paid", async () => {
+ const s = await pendingOrder();
+ const res = await s.deliver("payment_intent.succeeded", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } });
+ assert.equal(res.status, 200);
+ assert.equal(await s.status(), "paid");
+});
+
+test("a repeated delivery changes nothing", async () => {
+ const s = await pendingOrder();
+ const intent = { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } };
+ await s.deliver("payment_intent.succeeded", intent);
+ assert.equal((await s.deliver("payment_intent.succeeded", intent)).status, 200);
+ assert.equal(await s.status(), "paid");
+});
+
+test("an intent that is not the order's own does not pay it", async () => {
+ const s = await pendingOrder();
+ await s.deliver("payment_intent.succeeded", { id: "pi_someone_else", metadata: { order_id: s.order.id } });
+ assert.equal(await s.status(), "pending_payment");
+});
+
+test("payment_intent.canceled cancels the order and returns the stock", async () => {
+ const s = await pendingOrder();
+ await s.deliver("payment_intent.canceled", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } });
+ assert.equal(await s.status(), "cancelled");
+ assert.equal((await s.call("GET", "/products/wool-socks")).body.product.stock, 3);
+ await s.deliver("payment_intent.canceled", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } });
+ assert.equal((await s.call("GET", "/products/wool-socks")).body.product.stock, 3, "only once");
+});
+
+test("refuses a payload that Stripe did not sign", async () => {
+ const s = await pendingOrder();
+ const res = await s.deliver("payment_intent.succeeded", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } }, "whsec_wrong");
+ assert.equal(res.status, 400);
+ assert.equal(await s.status(), "pending_payment");
+});
+
+test("acknowledges events it does not handle", async () => {
+ const s = await pendingOrder();
+ assert.equal((await s.deliver("payment_intent.created", { id: "pi_1", metadata: {} })).status, 200);
+ assert.equal((await s.deliver("payment_intent.succeeded", { id: "pi_2", metadata: {} })).status, 200);
+});
View file
Addedworkers/stripe-webhook/src/index.ts+7−0
@@ -0,0 +1,7 @@
+import { handleWebhook, type Env } from "./webhook.ts";
+
+export default {
+ fetch(req, env) {
+ return handleWebhook(req, env);
+ },
+} satisfies ExportedHandler<Env>;
View file
Addedworkers/stripe-webhook/src/webhook.ts+54−0
@@ -0,0 +1,54 @@
+// Stripe's webhook: verifies the signature, then settles the order the PaymentIntent belongs to.
+// Stripe retries any delivery that does not get a 2xx, for up to three days, so every outcome
+// here is either done or safe to repeat.
+
+import Stripe from "stripe";
+import type { Settled } from "../../../src/orders.ts";
+
+// storefront-api's OrdersService, as this Worker sees it through the ORDERS service binding.
+export interface Orders {
+ markPaid(orderId: string, paymentIntentId: string): Promise<Settled>;
+ cancel(orderId: string): Promise<Settled>;
+}
+
+export interface Env {
+ ORDERS: Orders;
+ STRIPE_WEBHOOK_SECRET: string;
+}
+
+const subtle = Stripe.createSubtleCryptoProvider();
+
+export async function handleWebhook(req: Request, env: Env): Promise<Response> {
+ if (new URL(req.url).pathname !== "/stripe/webhook") return text(404, "not found");
+ if (req.method !== "POST") return text(405, "POST only");
+ const signature = req.headers.get("stripe-signature");
+ if (!signature) return text(400, "missing stripe-signature");
+
+ let event: Stripe.Event;
+ try {
+ event = await Stripe.webhooks.constructEventAsync(await req.text(), signature, env.STRIPE_WEBHOOK_SECRET, undefined, subtle);
+ } catch {
+ return text(400, "bad signature");
+ }
+
+ if (event.type === "payment_intent.succeeded" || event.type === "payment_intent.canceled") {
+ const intent = event.data.object;
+ const orderId = intent.metadata.order_id;
+ if (!orderId) return text(200, "not a storefront payment");
+ const result = event.type === "payment_intent.succeeded" ? await env.ORDERS.markPaid(orderId, intent.id) : await env.ORDERS.cancel(orderId);
+ log(event, result);
+ if (event.type === "payment_intent.succeeded" && result.status === "cancelled") {
+ // The money arrived for an order we had already given up on. Someone has to refund it.
+ console.error(JSON.stringify({ msg: "payment for a cancelled order, refund it", order: orderId, payment_intent: intent.id }));
+ }
+ }
+ return text(200, "ok");
+}
+
+function log(event: Stripe.Event, r: Settled): void {
+ console.log(JSON.stringify({ msg: "stripe event", type: event.type, id: event.id, order: r.order_id, status: r.status, changed: r.changed }));
+}
+
+function text(status: number, body: string): Response {
+ return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
+}
View file
Addedworkers/stripe-webhook/wrangler.jsonc+11−0
@@ -0,0 +1,11 @@
+// storefront-stripe-webhook: receives Stripe's webhook events at /stripe/webhook. It has no
+// database binding; it settles orders through storefront-api's OrdersService.
+// Secret: STRIPE_WEBHOOK_SECRET (npx wrangler secret put STRIPE_WEBHOOK_SECRET).
+{
+ "$schema": "../../node_modules/wrangler/config-schema.json",
+ "name": "storefront-stripe-webhook",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-09-01",
+ "observability": { "enabled": true },
+ "services": [{ "binding": "ORDERS", "service": "storefront-api", "entrypoint": "OrdersService" }]
+}
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.