examples/storefront Public
Move Stripe into a payments Worker
storefront-payments now holds both Stripe secrets. storefront-api creates and cancels PaymentIntents through its PaymentsService entrypoint over a service binding and keeps no Stripe code. The webhook moves into the same Worker unchanged, and storefront-stripe-webhook is deleted. Decision 0010 supersedes 0005; the checkout and fulfilment flows now route the charge and the webhook through payments.
Changed files
27 files changed325 additions232 deletionsagainst 94db762
- Changed.rebasely/architecture/flows/checkout.yaml+7−6
- Changed.rebasely/architecture/flows/order-fulfilment.yaml+4−4
- Changed.rebasely/architecture/system.yaml+11−9
- Changed.rebasely/architecture/views/checkout.yaml+1−1
- Changed.rebasely/kb/concepts/checkout.md+4−4
- Changed.rebasely/kb/concepts/payment-webhook.md+6−6
- Added.rebasely/kb/concepts/payments.md+35−0
- Changed.rebasely/kb/decisions/0005-stripe-payment-intents.md+3−2
- Added.rebasely/kb/decisions/0010-payments-worker.md+29−0
- Changed.rebasely/kb/guides/testing.md+2−2
- Changed.rebasely/kb/index.md+3−1
- Changedsrc/app.ts+2−2
- Changedsrc/env.ts+2−1
- Changedsrc/index.ts+1−2
- Changedsrc/payments.ts+3−37
- Changedtest/helpers/app.ts+1−1
- Addedtest/payments-webhook.test.ts+82−0
- Changedtest/stripe-payments.test.ts+1−1
- Removedtest/stripe-webhook.test.ts+0−81
- Addedworkers/payments/src/index.ts+23−0
- Addedworkers/payments/src/stripe.ts+37−0
- Addedworkers/payments/src/webhook.ts+55−0
- Addedworkers/payments/wrangler.jsonc+12−0
- Removedworkers/stripe-webhook/src/index.ts+0−7
- Removedworkers/stripe-webhook/src/webhook.ts+0−54
- Removedworkers/stripe-webhook/wrangler.jsonc+0−11
- Changedwrangler.jsonc+1−0
Changed.rebasely/architecture/flows/checkout.yaml+7−6
| @@ -1,19 +1,20 @@ | ||
| 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. Stripe reports the result to stripe-webhook, which | ||
| - marks the order paid through OrdersService. | ||
| + D1 batch, then asks the payments Worker for a PaymentIntent and hands its client secret to the | ||
| + browser, which confirms the payment with the Payment Element. Stripe reports the result to the | ||
| + payments Worker, which marks the order paid through OrdersService. | ||
| 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: payments, label: "PaymentsService.createIntent(order, total)" } | ||
| + - { from: payments, 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: stripe, to: stripe-webhook, label: "payment_intent.succeeded, signed" } | ||
| - - { from: stripe-webhook, to: storefront-api, label: "OrdersService.markPaid(order, intent)" } | ||
| + - { from: stripe, to: payments, label: "payment_intent.succeeded, signed" } | ||
| + - { from: payments, 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" } | ||
Changed.rebasely/architecture/flows/order-fulfilment.yaml+4−4
| @@ -1,12 +1,12 @@ | ||
| title: Fulfilling a paid order | ||
| description: >- | ||
| - From Stripe's payment_intent.succeeded to a shipment at the warehouse. The webhook marks the | ||
| - order paid through OrdersService, which announces it on order-events. fulfilment picks the event | ||
| + From Stripe's payment_intent.succeeded to a shipment at the warehouse. The payments Worker's | ||
| + webhook marks the order paid through OrdersService, which announces it on order-events. fulfilment picks the event | ||
| up, reads the order, creates the shipment with the order id as idempotency key, and records it. | ||
| actors: [warehouse] | ||
| steps: | ||
| - - { from: stripe, to: stripe-webhook, label: "payment_intent.succeeded, signed" } | ||
| - - { from: stripe-webhook, to: storefront-api, label: "OrdersService.markPaid(order, intent)" } | ||
| + - { from: stripe, to: payments, label: "payment_intent.succeeded, signed" } | ||
| + - { from: payments, to: storefront-api, label: "OrdersService.markPaid(order, intent)" } | ||
| - { from: storefront-api, to: db, label: mark the order paid } | ||
| - { from: storefront-api, to: order-events, label: send order.paid } | ||
| - { from: order-events, to: fulfilment, label: a batch of up to 10 events } | ||
Changed.rebasely/architecture/system.yaml+11−9
| @@ -6,21 +6,23 @@ | ||
| 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. A cron trigger expires unpaid orders every five minutes. | ||
| + Payments go through the payments Worker; this Worker has no Stripe key. | ||
| owns: [src/**] | ||
| talks_to: | ||
| - { to: db, via: d1 } | ||
| - { to: images, via: r2 } | ||
| - - { to: stripe, via: https } | ||
| + - { to: payments, via: rpc } | ||
| - { to: order-events, via: queue } | ||
| - stripe-webhook: | ||
| + payments: | ||
| 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/**] | ||
| + Worker storefront-payments (workers/payments), the only holder of the Stripe keys. Its | ||
| + PaymentsService entrypoint creates and cancels PaymentIntents for storefront-api; its fetch | ||
| + handler takes Stripe's webhooks and settles orders through OrdersService. | ||
| + owns: [workers/payments/**] | ||
| talks_to: | ||
| - { to: storefront-api, via: rpc } | ||
| - - { to: stripe, via: webhooks } | ||
| + - { to: stripe, via: https } | ||
| fulfilment: | ||
| kind: worker | ||
| description: >- | ||
| @@ -54,7 +56,7 @@ | ||
| 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, and Stripe | ||
| - reports the outcome to stripe-webhook. | ||
| + Stripe, through the stripe npm package, called only by the payments Worker. It creates a | ||
| + PaymentIntent per order; the shopper confirms it in the browser with the Payment Element, and | ||
| + Stripe reports the outcome to the payments Worker's webhook. | ||
| package: stripe | ||
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, stripe-webhook, db, stripe] | ||
| +components: [storefront-api, payments, db, stripe] | ||
Changed.rebasely/kb/concepts/checkout.md+4−4
| @@ -9,7 +9,7 @@ | ||
| - src/payments.ts | ||
| - src/expiry.ts | ||
| - migrations/0003_orders.sql | ||
| -reviewed: 2026-10-09 | ||
| +reviewed: 2026-10-15 | ||
| --- | ||
| # Checkout | ||
| @@ -23,9 +23,9 @@ | ||
| ## 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. | ||
| +After the batch, checkout calls `PaymentsService.createIntent` on the payments Worker over the `PAYMENTS` service binding ([[payments]]), which creates a Stripe PaymentIntent for the order total, and stores the intent's 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 our Workers, and storefront-api never holds a Stripe key ([[0010-payments-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. | ||
| +If the payments Worker or 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 | ||
| @@ -35,7 +35,7 @@ | ||
| An order holds its stock until it is paid or cancelled, so unpaid ones expire ([[0009-cancel-unpaid-orders-after-30-minutes]]). Every five minutes the cron trigger runs `expireOrders` (`src/expiry.ts`) over up to 50 orders that have been `pending_payment` for more than 30 minutes: | ||
| -1. Cancel the PaymentIntent at Stripe. If Stripe refuses because the intent succeeded or a payment is processing, the payment won the race: leave the order to the webhook. | ||
| +1. Cancel the PaymentIntent through `PaymentsService.cancelIntent`. If Stripe refuses because the intent succeeded or a payment is processing, the payment won the race: leave the order to the webhook. | ||
| 2. Otherwise cancel the order, which puts the stock back and sends `order.cancelled`. | ||
| Cancelling the intent first means a shopper cannot pay for an order after it has been cancelled. | ||
Changed.rebasely/kb/concepts/payment-webhook.md+6−6
| @@ -2,14 +2,14 @@ | ||
| id: payment-webhook | ||
| type: concept | ||
| owner: justin | ||
| -component: stripe-webhook | ||
| +component: payments | ||
| describes: | ||
| - - workers/stripe-webhook/src/** | ||
| -reviewed: 2026-10-09 | ||
| + - workers/payments/src/webhook.ts | ||
| +reviewed: 2026-10-15 | ||
| --- | ||
| # 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. | ||
| +An order is paid when Stripe says so, never when the browser says so. Stripe sends events to `/stripe/webhook` on the `storefront-payments` Worker ([[payments]]), which turns them into order changes. | ||
| ## What it does | ||
| @@ -18,7 +18,7 @@ | ||
| 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]]). | ||
| +The payments Worker has no database binding. It reaches orders only through the service binding `ORDERS` ([[0006-one-writer-for-the-database]]). Until October 15 this code was a Worker of its own, `storefront-stripe-webhook`; it moved into the payments Worker unchanged ([[0010-payments-worker]]). | ||
| ## Retries and repeats | ||
| @@ -35,4 +35,4 @@ | ||
| ## 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. | ||
| +`stripe listen --forward-to localhost:8788/stripe/webhook` prints a webhook secret for `workers/payments/.dev.vars` and forwards test-mode events. Run the Worker with `npx wrangler dev -c workers/payments/wrangler.jsonc --port 8788` next to storefront-api. | ||
Added.rebasely/kb/concepts/payments.md+35−0
| @@ -0,0 +1,35 @@ | ||
| +--- | ||
| +id: payments | ||
| +type: concept | ||
| +owner: justin | ||
| +component: payments | ||
| +describes: | ||
| + - workers/payments/src/index.ts | ||
| + - workers/payments/src/stripe.ts | ||
| + - src/payments.ts | ||
| +reviewed: 2026-10-15 | ||
| +--- | ||
| +# The payments Worker | ||
| + | ||
| +`storefront-payments` (`workers/payments`) is the only Worker that talks to Stripe or holds its keys. It has two faces. | ||
| + | ||
| +## PaymentsService, for storefront-api | ||
| + | ||
| +storefront-api reaches it through the `PAYMENTS` service binding, entrypoint `PaymentsService`. The binding is typed as the `Payments` interface in `src/payments.ts`, the same interface tests implement with `FakePayments`: | ||
| + | ||
| +| Call | Used by | What it does | | ||
| +| --- | --- | --- | | ||
| +| `createIntent({ order_id, amount_cents, currency, email })` | checkout | creates a PaymentIntent with idempotency key `order-<id>` and returns its id and client secret | | ||
| +| `cancelIntent(intent_id)` | expiry | cancels the intent; false if it already succeeded or is processing | | ||
| + | ||
| +Service binding calls do not leave Cloudflare, and the entrypoint is not reachable from the internet. The Stripe adapter itself is `workers/payments/src/stripe.ts`. | ||
| + | ||
| +## The webhook, for Stripe | ||
| + | ||
| +Its fetch handler serves `POST /stripe/webhook` and nothing else ([[payment-webhook]]). It settles orders through storefront-api's `OrdersService` over the `ORDERS` binding, because only storefront-api writes to D1 ([[0006-one-writer-for-the-database]]). | ||
| + | ||
| +So the two Workers bind each other: storefront-api calls payments to charge, payments calls storefront-api to record the result. Deploy order matters only when an interface changes: add a method on the side being called first. | ||
| + | ||
| +## Secrets | ||
| + | ||
| +`STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`, set with `npx wrangler secret put -c workers/payments/wrangler.jsonc`. Rotating the secret key means redeploying nothing else. Why the keys moved here is in [[0010-payments-worker]]. | ||
Changed.rebasely/kb/decisions/0005-stripe-payment-intents.md+3−2
| @@ -2,8 +2,9 @@ | ||
| id: 0005-stripe-payment-intents | ||
| type: decision | ||
| owner: justin | ||
| -status: accepted | ||
| -reviewed: 2026-10-02 | ||
| +status: superseded | ||
| +superseded-by: 0010-payments-worker | ||
| +reviewed: 2026-10-15 | ||
| --- | ||
| # 0005 Charge with Stripe PaymentIntents from storefront-api | ||
Added.rebasely/kb/decisions/0010-payments-worker.md+29−0
| @@ -0,0 +1,29 @@ | ||
| +--- | ||
| +id: 0010-payments-worker | ||
| +type: decision | ||
| +owner: justin | ||
| +status: accepted | ||
| +reviewed: 2026-10-15 | ||
| +--- | ||
| +# 0010 Put everything that talks to Stripe in a payments Worker | ||
| + | ||
| +## Context | ||
| + | ||
| +[[0005-stripe-payment-intents]] had storefront-api create PaymentIntents itself with `STRIPE_SECRET_KEY`, and a separate Worker, `storefront-stripe-webhook`, verify Stripe's webhooks. That left the secret key in the Worker with the largest surface (every public route, the admin routes, image uploads), and split Stripe code across two Workers that had to agree on metadata and on the `stripe` package version. Adding refunds would have meant a third place. | ||
| + | ||
| +## Decision | ||
| + | ||
| +A `storefront-payments` Worker owns every Stripe call and both Stripe secrets ([[payments]]): | ||
| + | ||
| +- storefront-api calls its `PaymentsService` entrypoint over a service binding to create and cancel intents. It keeps the `Payments` interface and no Stripe code. | ||
| +- The webhook moves into it unchanged ([[payment-webhook]]), and `storefront-stripe-webhook` is deleted. | ||
| +- Results still reach orders through OrdersService ([[0006-one-writer-for-the-database]]). | ||
| + | ||
| +Still PaymentIntents confirmed with the Payment Element, still the order id as idempotency key. What changes is where the calls are made. | ||
| + | ||
| +## Consequences | ||
| + | ||
| +- A bug in a public route cannot read the Stripe secret key, because that Worker does not have it. | ||
| +- One Worker to change for refunds, a new API version or a second provider. | ||
| +- The two Workers bind each other, so a change to either interface has to be deployed in order. Checkout gains one service binding call, which stays inside Cloudflare. | ||
| +- Stripe's webhook endpoint moves to the payments Worker's route. Register the new URL in the Stripe dashboard before deleting the old Worker, and keep both signing secrets until the old endpoint stops receiving events. | ||
Changed.rebasely/kb/guides/testing.md+2−2
| @@ -4,7 +4,7 @@ | ||
| owner: justin | ||
| describes: | ||
| - test/helpers/** | ||
| -reviewed: 2026-10-09 | ||
| +reviewed: 2026-10-15 | ||
| --- | ||
| # Running and writing tests | ||
| @@ -22,7 +22,7 @@ | ||
| - `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 whose `objects` map tests can inspect, and `IMAGES_URL` is `https://images.shop.test`. | ||
| - `ORDER_EVENTS` is `TestQueue` (`test/helpers/queue.ts`), which keeps every message in `sent`. | ||
| -- Payments are `FakePayments` (`test/helpers/payments.ts`), which records every charge, fails the next one when `failNext` is set, and refuses to cancel the intents in `uncancellable`. `test/stripe-payments.test.ts` checks the real Stripe adapter against a fake `fetch`, so no test calls Stripe. | ||
| +- `PAYMENTS` is `FakePayments` (`test/helpers/payments.ts`), standing in for the payments Worker, which records every charge, fails the next one when `failNext` is set, and refuses to cancel the intents in `uncancellable`. `test/stripe-payments.test.ts` checks the payments Worker's Stripe adapter against a fake `fetch`, and `test/payments-webhook.test.ts` runs its webhook against the test database, 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, `payments` is the fake the app was given and `events` is its queue. | ||
Changed.rebasely/kb/index.md+3−1
| @@ -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. | ||
| +- [[payments]]: the Worker that holds the Stripe keys and everything that uses them. | ||
| - [[payment-webhook]]: how Stripe's events mark orders paid or cancelled. | ||
| - [[order-events]]: the queue that announces paid and cancelled orders. | ||
| - [[fulfilment]]: how paid orders become shipments at the warehouse. | ||
| @@ -18,11 +19,12 @@ | ||
| - [[0002-serve-images-through-the-api]]: images were served by storefront-api (superseded). | ||
| - [[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. | ||
| +- [[0005-stripe-payment-intents]]: PaymentIntents created by storefront-api (superseded). | ||
| - [[0006-one-writer-for-the-database]]: other Workers change orders through OrdersService. | ||
| - [[0007-at-least-once-with-idempotency-keys]]: repeats are expected and harmless. | ||
| - [[0008-images-from-the-bucket-domain]]: browsers load images from R2's custom domain. | ||
| - [[0009-cancel-unpaid-orders-after-30-minutes]]: unpaid orders give their stock back. | ||
| +- [[0010-payments-worker]]: Stripe calls and keys live in a payments Worker. | ||
| ## Guides | ||
Changedsrc/app.ts+2−2
| @@ -8,7 +8,7 @@ | ||
| import { errorResponse, HttpError, json, readJson, requireAdmin } from "./http.ts"; | ||
| import { imageUrls, putProductImage } from "./images.ts"; | ||
| import { getOrder } from "./orders.ts"; | ||
| -import { stripePayments, type Payments } from "./payments.ts"; | ||
| +import type { Payments } from "./payments.ts"; | ||
| import { Router } from "./router.ts"; | ||
| export type Deps = { | ||
| @@ -20,7 +20,7 @@ | ||
| export const defaultDeps: Deps = { | ||
| now: Date.now, | ||
| newId: () => crypto.randomUUID(), | ||
| - payments: (env) => stripePayments(env.STRIPE_SECRET_KEY), | ||
| + payments: (env) => env.PAYMENTS, | ||
| }; | ||
| export function createApp(env: Env, deps: Deps = defaultDeps) { | ||
Changedsrc/env.ts+2−1
| @@ -1,11 +1,12 @@ | ||
| // The bindings and secrets of the storefront-api Worker (wrangler.jsonc). | ||
| import type { OrderEvent } from "./events.ts"; | ||
| +import type { Payments } from "./payments.ts"; | ||
| export interface Env { | ||
| DB: D1Database; | ||
| IMAGES: R2Bucket; | ||
| ORDER_EVENTS: Queue<OrderEvent>; | ||
| + PAYMENTS: Payments; // storefront-payments, entrypoint PaymentsService | ||
| IMAGES_URL: string; // the bucket's custom domain, where browsers load images | ||
| ADMIN_TOKEN: string; | ||
| - STRIPE_SECRET_KEY: string; | ||
| } | ||
Changedsrc/index.ts+1−2
| @@ -3,7 +3,6 @@ | ||
| import type { Env } from "./env.ts"; | ||
| import { expireOrders } from "./expiry.ts"; | ||
| import { cancelOrder, getOrder, markPaid, recordShipment, type Order, type Settled } from "./orders.ts"; | ||
| -import { stripePayments } from "./payments.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 | ||
| @@ -33,7 +32,7 @@ | ||
| // triggers.crons in wrangler.jsonc: every five minutes. | ||
| async scheduled(controller, env) { | ||
| - const report = await expireOrders(env.DB, env.ORDER_EVENTS, stripePayments(env.STRIPE_SECRET_KEY), controller.scheduledTime); | ||
| + const report = await expireOrders(env.DB, env.ORDER_EVENTS, env.PAYMENTS, controller.scheduledTime); | ||
| if (report.cancelled.length || report.left_to_webhook.length) console.log(JSON.stringify({ msg: "expired unpaid orders", ...report })); | ||
| }, | ||
| } satisfies ExportedHandler<Env>; | ||
Changedsrc/payments.ts+3−37
| @@ -1,8 +1,7 @@ | ||
| -// The payment provider, behind the two calls checkout needs. Stripe in production; tests pass | ||
| -// their own Payments. | ||
| +// Payments, behind the two calls checkout and expiry need. In production this is the payments | ||
| +// Worker's PaymentsService over the PAYMENTS service binding (workers/payments); tests pass their | ||
| +// own. storefront-api does not talk to Stripe or hold its secret key. | ||
| -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 }; | ||
| @@ -13,37 +12,4 @@ | ||
| // True when the intent is cancelled, false when it can no longer be (it succeeded, or a | ||
| // payment is processing). In that case the webhook settles the order. | ||
| cancelIntent(intentId: string): Promise<boolean>; | ||
| -} | ||
| - | ||
| -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); | ||
| - return true; | ||
| - } catch (e) { | ||
| - if (e instanceof Stripe.errors.StripeInvalidRequestError && e.code === "payment_intent_unexpected_state") return false; | ||
| - throw e; | ||
| - } | ||
| - }, | ||
| - }; | ||
| } | ||
Changedtest/helpers/app.ts+1−1
| @@ -13,8 +13,8 @@ | ||
| export function setup() { | ||
| const events = testQueue<OrderEvent>(); | ||
| - const env: Env = { DB: testDb(), IMAGES: testBucket(), ORDER_EVENTS: events, IMAGES_URL: "https://images.shop.test", ADMIN_TOKEN, STRIPE_SECRET_KEY: "sk_test_unused" }; | ||
| const payments = new FakePayments(); | ||
| + const env: Env = { DB: testDb(), IMAGES: testBucket(), ORDER_EVENTS: events, PAYMENTS: payments, IMAGES_URL: "https://images.shop.test", ADMIN_TOKEN }; | ||
| let n = 0; | ||
| const app = createApp(env, { now: () => NOW, newId: () => `id-${++n}`, payments: () => payments }); | ||
Addedtest/payments-webhook.test.ts+82−0
| @@ -0,0 +1,82 @@ | ||
| +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/payments/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, | ||
| + STRIPE_SECRET_KEY: "sk_test_unused", | ||
| + ORDERS: { | ||
| + markPaid: (id, intent) => markPaid(s.env.DB, s.events, id, intent, NOW), | ||
| + cancel: (id) => cancelOrder(s.env.DB, s.events, 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 and announces it", 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"); | ||
| + assert.deepEqual(s.events.sent, [{ type: "order.paid", order_id: s.order.id, at: new Date(NOW).toISOString() }]); | ||
| +}); | ||
| + | ||
| +test("a repeated delivery changes nothing but sends the event again", 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"); | ||
| + assert.equal(s.events.sent.length, 2, "at least once: fulfilment dedupes by order id"); | ||
| +}); | ||
| + | ||
| +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"); | ||
| + assert.deepEqual(s.events.sent, []); | ||
| +}); | ||
| + | ||
| +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"); | ||
| + assert.deepEqual(s.events.sent.map((e) => e.type), ["order.cancelled"]); | ||
| +}); | ||
| + | ||
| +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); | ||
| +}); | ||
Changedtest/stripe-payments.test.ts+1−1
| @@ -1,6 +1,6 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { test } from "node:test"; | ||
| -import { stripePayments } from "../src/payments.ts"; | ||
| +import { stripePayments } from "../workers/payments/src/stripe.ts"; | ||
| type Seen = { url: string; method: string; headers: Headers; body: URLSearchParams }; | ||
Removedtest/stripe-webhook.test.ts+0−81
| @@ -1,81 +0,0 @@ | ||
| -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, s.events, id, intent, NOW), | ||
| - cancel: (id) => cancelOrder(s.env.DB, s.events, 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 and announces it", 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"); | ||
| - assert.deepEqual(s.events.sent, [{ type: "order.paid", order_id: s.order.id, at: new Date(NOW).toISOString() }]); | ||
| -}); | ||
| - | ||
| -test("a repeated delivery changes nothing but sends the event again", 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"); | ||
| - assert.equal(s.events.sent.length, 2, "at least once: fulfilment dedupes by order id"); | ||
| -}); | ||
| - | ||
| -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"); | ||
| - assert.deepEqual(s.events.sent, []); | ||
| -}); | ||
| - | ||
| -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"); | ||
| - assert.deepEqual(s.events.sent.map((e) => e.type), ["order.cancelled"]); | ||
| -}); | ||
| - | ||
| -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); | ||
| -}); | ||
Addedworkers/payments/src/index.ts+23−0
| @@ -0,0 +1,23 @@ | ||
| +import { WorkerEntrypoint } from "cloudflare:workers"; | ||
| +import type { Charge, Intent, Payments } from "../../../src/payments.ts"; | ||
| +import { stripePayments } from "./stripe.ts"; | ||
| +import { handleWebhook, type Env } from "./webhook.ts"; | ||
| + | ||
| +// What storefront-api calls through its PAYMENTS service binding (entrypoint PaymentsService). | ||
| +// The Stripe secret key never leaves this Worker. | ||
| +export class PaymentsService extends WorkerEntrypoint<Env> implements Payments { | ||
| + createIntent(charge: Charge): Promise<Intent> { | ||
| + return stripePayments(this.env.STRIPE_SECRET_KEY).createIntent(charge); | ||
| + } | ||
| + | ||
| + cancelIntent(intentId: string): Promise<boolean> { | ||
| + return stripePayments(this.env.STRIPE_SECRET_KEY).cancelIntent(intentId); | ||
| + } | ||
| +} | ||
| + | ||
| +// The public side: Stripe's webhook at /stripe/webhook, and nothing else. | ||
| +export default { | ||
| + fetch(req, env) { | ||
| + return handleWebhook(req, env); | ||
| + }, | ||
| +} satisfies ExportedHandler<Env>; | ||
Addedworkers/payments/src/stripe.ts+37−0
| @@ -0,0 +1,37 @@ | ||
| +// Stripe behind the Payments interface. Only the payments Worker holds STRIPE_SECRET_KEY. | ||
| + | ||
| +import Stripe from "stripe"; | ||
| +import type { Payments } from "../../../src/payments.ts"; | ||
| + | ||
| +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); | ||
| + return true; | ||
| + } catch (e) { | ||
| + if (e instanceof Stripe.errors.StripeInvalidRequestError && e.code === "payment_intent_unexpected_state") return false; | ||
| + throw e; | ||
| + } | ||
| + }, | ||
| + }; | ||
| +} | ||
Addedworkers/payments/src/webhook.ts+55−0
| @@ -0,0 +1,55 @@ | ||
| +// 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_SECRET_KEY: string; | ||
| + 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" } }); | ||
| +} | ||
Addedworkers/payments/wrangler.jsonc+12−0
| @@ -0,0 +1,12 @@ | ||
| +// storefront-payments: everything that talks to Stripe. storefront-api calls its PaymentsService | ||
| +// entrypoint to create and cancel PaymentIntents, and Stripe sends webhooks to /stripe/webhook. | ||
| +// It settles orders through storefront-api's OrdersService and has no database binding. | ||
| +// Secrets: STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET (npx wrangler secret put ...). | ||
| +{ | ||
| + "$schema": "../../node_modules/wrangler/config-schema.json", | ||
| + "name": "storefront-payments", | ||
| + "main": "src/index.ts", | ||
| + "compatibility_date": "2026-09-01", | ||
| + "observability": { "enabled": true }, | ||
| + "services": [{ "binding": "ORDERS", "service": "storefront-api", "entrypoint": "OrdersService" }] | ||
| +} | ||
Removedworkers/stripe-webhook/src/index.ts+0−7
| @@ -1,7 +0,0 @@ | ||
| -import { handleWebhook, type Env } from "./webhook.ts"; | ||
| - | ||
| -export default { | ||
| - fetch(req, env) { | ||
| - return handleWebhook(req, env); | ||
| - }, | ||
| -} satisfies ExportedHandler<Env>; | ||
Removedworkers/stripe-webhook/src/webhook.ts+0−54
| @@ -1,54 +0,0 @@ | ||
| -// 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" } }); | ||
| -} | ||
Removedworkers/stripe-webhook/wrangler.jsonc+0−11
| @@ -1,11 +0,0 @@ | ||
| -// 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" }] | ||
| -} | ||
Changedwrangler.jsonc+1−0
| @@ -18,6 +18,7 @@ | ||
| // storefront-images is public on this custom domain (R2 > Settings > Custom Domains). | ||
| "vars": { "IMAGES_URL": "https://images.larkspur.shop" }, | ||
| "triggers": { "crons": ["*/5 * * * *"] }, | ||
| + "services": [{ "binding": "PAYMENTS", "service": "storefront-payments", "entrypoint": "PaymentsService" }], | ||
| "queues": { | ||
| "producers": [{ "binding": "ORDER_EVENTS", "queue": "order-events" }] | ||
| } | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.