diff --git a/CLAUDE.md b/CLAUDE.md index d8c852d..f19beec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,10 @@ Mallok decides the contracts on both sides. Its documentation is the reference: - **`lib/scheduled.ts`** does one small piece of work per minute tick: a repricing chunk in progress, else a rate fetch if due, else clearing expired carts. State between ticks is in `p_shop_state`. - **Migrations**: additive and idempotent. Mallok's migrator drops whole-line `--` comments, then splits on `;` — a comment must have a line to itself. They run on the Worker's first request, not from a CLI. - **The cart route** (`routes/cart.ts`, `POST /_mallok/p/shop/cart`) takes a plain form POST and answers 303. The cart cookie is scoped to `/_mallok/p/shop`: Mallok bypasses its edge cache for any public request that carries a cookie. -- Pure logic (`money`, `pricing`, `ecb`, `order-state`, `currency`) takes no database; DB functions take a `D1Database` and, where time matters, a `now`. +- **Orders and payment are built but not reachable** (design §14). `lib/orders.ts` places an order and confirms its payment, `lib/order-fulfilment.ts` ships, cancels and refunds, `lib/outbox.ts` records what each change still owes, `lib/stripe-webhook.ts` decides what a Stripe delivery means and which status to answer, `lib/stripe-signature.ts` and `lib/stripe-client.ts` talk to Stripe over `fetch`, `lib/order-email.ts` builds the buyer's emails. No route, page or admin screen calls them, and nothing drains the outbox: those wait for Mallok. Do not add a route that works around that. +- **A change to an order is conditional on the status it was read in** (`ORDER_STILL_IN_STATUS` in `lib/order-guard.ts`), and the statement that changes the status comes last in its batch. That is what makes a racing second call write nothing. +- **A change to an order writes its outbox row in the same batch** (`orderChangedOutbox`). What follows the change — an email, a purge — is owed from that row. Never send from a function's return value: it is lost whenever the Worker stops after the batch. Rows are read back by `seq`, the order they were committed in, never by `created_at`, which is whatever time the caller passed. +- Pure logic (`money`, `pricing`, `ecb`, `order-state`, `currency`, `availability`, `stripe-signature`) takes no database; DB functions take a `D1Database` and, where time matters, a `now`. ### The theme and content @@ -66,6 +69,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference: - No render-time hook with database access, so prices and variants are not on pages yet. - Plugin routes cannot render through the theme, so there is no cart page yet. - Plugin admin panels are read-only tables; variants are seeded from `seed/shop-sample.sql`. +- Plugin routes receive a parsed body, so a Stripe signature cannot be checked in one: there is no webhook route, and so no checkout. - `reference[]` fields are not resolved, so a product names one collection. Each is a task in Mallok's plan for plugin API 2. When Mallok ships one, upgrade, remove the corresponding limitation here, and prove the new behaviour with a test or the smoke run. @@ -75,6 +79,8 @@ Each is a task in Mallok's plan for plugin API 2. When Mallok ships one, upgrade - Tests in `test/shop` and `test/theme` run inside workerd against the site's own Worker. `test/shop/helpers.ts` brings a site up through Mallok's HTTP API (first request → admin → token → settings → plugin enabled); tables are created by Mallok's migrator, never by hand. Create content with `createContent`, not by inserting rows. - Mallok caches pages in `caches.default`. In a test, create all content before requesting any page. - A test for a fix must be seen failing without the fix. For new guards, break the guard and confirm the test goes red. +- A race is tested by running the calls with `Promise.all`, and such a test only counts once breaking the guard turns it red: that is the proof the two calls really interleave. +- `countD1Calls` in `test/shop/helpers.ts` counts round trips; use it wherever the number is a design constraint. `interceptBatches` runs a hook around each batch: it is how a test changes the data between a function's reading and its writing, or loses the answer to a write that committed. - `npm run smoke:shop` is the only place theme, plugin, content and the Mallok CLI run together; run it when touching any of them. ## Commerce invariants (do not "simplify" them away) @@ -82,7 +88,15 @@ Each is a task in Mallok's plan for plugin API 2. When Mallok ships one, upgrade - Money is integer minor units (`lib/money.ts`), never a float. - A cart line is a variant and a quantity, never a price; `priceCart` recomputes from the database and reports every problem at once. - MOQ and stock are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. -- `stock` carries `CHECK (stock >= 0)`. `test/shop/schema.test.ts` proves a failing decrement rolls back its whole D1 batch, and that `WHERE stock >= qty` does not — the payment write must rely on the constraint. +- `stock` carries `CHECK (stock >= 0)`. `test/shop/schema.test.ts` proves a failing decrement rolls back its whole D1 batch, and that `WHERE stock >= qty` does not — the payment write relies on the constraint. +- Stock comes off when a payment is confirmed, never when an order is placed, and only for stock-tracked variants. `markOrderPaid` is one batch — event, stock, ledger, outbox, status — and must stay one: splitting it brings back the state where stock is taken for an unpaid order, or an order is paid and its email never owed. +- One payment takes stock once, however it is reported: the same event again, a different event for the same payment intent, or two deliveries at the same moment. The parallel tests in `test/shop/orders.test.ts` hold this; do not weaken them into sequential ones. +- What to do with a payment is read from the data — is it on record, what status is the order in, is the stock there — and read again after a write that failed. Never from the text of an error. +- A payment the order cannot take (cancelled, or settled by another payment) is recorded as `refused` with an outbox row naming the payment to refund. The order and the stock are not touched, and the money is never left without a trace. +- A webhook body is trusted only after `verifyStripeSignature` has passed on the bytes as received. Answer 5xx only for what delivering again could change — a database failure; Stripe redelivers anything that is not a 2xx for three days. +- Money columns check `typeof(x) = 'integer'`: SQLite stores 99.5 in an `INTEGER` column rather than refuse it. +- A Stripe failure is described by Stripe's identifiers and the status, never its free-text message: nothing rules out that text repeating a buyer's email address. +- An order line is a snapshot of SKU, name and unit price. Nothing that later happens to the product may change a past order. - A `manual` price is never overwritten; the base price is never rewritten; an `auto` price moves only past the drift threshold. - Order status changes only through `lib/order-state.ts`. - Every external input passes Zod; every SQL value is bound; redirects go only to same-site paths; logs carry no personal data. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 260cac5..dbb8b82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,11 @@ same pull request and explain the new reasoning. | MOQ | Enforced by the form and again by the server. A form can be bypassed. | | Stock | Protected by `CHECK (stock >= 0)`. A decrement that would oversell fails, and rolls back the whole D1 batch it is part of — keep it that way rather than relying on `WHERE stock >= qty`, which matches no row without failing. | | Manual prices | Never overwritten by an exchange-rate refresh. | +| Payment | One D1 batch: the event, the stock, the ledger, the outbox row and the order's status commit together or not at all. Every statement in it is conditional on the order's status, so a second delivery racing the first writes nothing. | +| Order status | Changed only through `lib/order-state.ts`, and written only if the order is still in the status that was checked. | +| Follow-ups | Whatever must happen after an order changes — an email, a purge — is a row in `p_shop_outbox`, written in the same batch as the change. Never something done afterwards on the strength of a return value. | +| Order lines | Snapshots. Renaming, repricing or delisting a product never changes a past order. | +| Webhooks | Verified against the bytes as received before anything is parsed. 5xx only for what delivering again could change; everything else is answered 200 and written down. | | Language | Decided by the URL alone. Never redirect or switch by IP — crawlers would see one language. | | References | A `reference` field names the target's slug **in the same language**. `test/content.test.ts` checks the sample content. | | Bundle identity | A bundle needs a `mallok.json` only when something outside the content names it: the sample variants attach to the product by its translation group. Where the file exists, `test/content.test.ts` keeps it in step with the bundle. | diff --git a/README.md b/README.md index c42099a..73f8536 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Nundar builds on `mallok@0.1.0-rc.9`. Some of the shop needs extension points Ma | **Catalogue data** | Variants, prices as integer minor units, stock, MOQ, lead time, a made-to-order policy | Editing them in the admin (read-only for now; the sample data is loaded from SQL) | | **Pricing** | USD base price; EUR and GBP derived from ECB rates with a buffer, rounding to a price point and a drift threshold; manual prices never overwritten | | | **Cart** | Add, set and remove through a plain form POST, with MOQ and stock enforced server-side | The cart page; submitting a cart as one inquiry | -| **Checkout** | | Payment, orders and order email (the next phase) | +| **Orders and payment** | The logic, tested and not yet reachable: orders with line snapshots, a payment that takes stock exactly once however often Stripe reports it, oversold orders, refunds that return stock, Stripe signature checks, order emails in four languages | The checkout and order pages, the webhook route, and order handling in the admin | The reasoning and the plan are in [`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md`](docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md). @@ -70,6 +70,8 @@ The reasoning and the plan are in [`docs/superpowers/specs/2026-09-30-nundar-on- - The cart stores variants and quantities only — never a price. - MOQ is enforced by the form *and* by the server, because a form can be bypassed. - Stock carries a database constraint, so a payment's decrement cannot go negative: a test proves the whole D1 batch rolls back. +- Stock comes off when a payment is confirmed, never before, and once: tests deliver the same payment twice at the same moment. +- A payment is believed only with Stripe's signature on the exact bytes received, and only for five minutes. - A manually set price is never overwritten by an exchange-rate refresh. - Language is decided by the URL alone, never by the visitor's IP. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4ea72a8..72d6e00 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -59,7 +59,7 @@ Nundar 基于 `mallok@0.1.0-rc.9`。商城的一部分功能需要 Mallok 目前 | **目录数据** | 规格、以整数最小单位存储的价格、库存、起订量、交期、按单生产策略 | 在后台编辑这些数据(目前只读,示例数据由 SQL 载入) | | **定价** | 美元基准价;欧元和英镑按 ECB 汇率换算,带缓冲、价位取整和漂移阈值;手动价格永不被覆盖 | | | **购物车** | 通过普通表单提交加入、修改、移除,服务端校验起订量和库存 | 购物车页;把购物车作为一次询盘提交 | -| **结账** | | 付款、订单和订单邮件(下一阶段) | +| **订单与付款** | 逻辑已写好并有测试,但还没有入口可以用到:带行快照的订单、无论 Stripe 通知多少次都只扣一次库存的付款、超卖订单、退还库存的退款、Stripe 签名校验、四种语言的订单邮件 | 结账页和订单页、webhook 路由、后台的订单处理 | 原因和计划见 [`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md`](docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md)。 @@ -69,6 +69,8 @@ Nundar 基于 `mallok@0.1.0-rc.9`。商城的一部分功能需要 Mallok 目前 - 购物车只存规格和数量,绝不存价格。 - 起订量由表单校验,**并且**由服务端再校验一次,因为表单可以被绕过。 - 库存带数据库约束,付款时的扣减不可能变成负数:有测试证明整个 D1 batch 会回滚。 +- 库存只在付款确认后扣减,绝不提前,而且只扣一次:有测试让同一笔付款在同一时刻到达两次。 +- 一笔付款只有带着 Stripe 对原始字节的签名才会被采信,而且只在五分钟内有效。 - 手动设置的价格永远不会被汇率刷新覆盖。 - 语言只由网址决定,绝不按访客 IP 判断。 diff --git a/SECURITY.md b/SECURITY.md index a27f641..fbdd758 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,7 +41,9 @@ security document describes each. Nundar implements none of them again. | Data | Where it lives | Notes | |---|---|---| -| Card numbers, CVV | **Nowhere in Nundar** | Payment is not implemented yet. The design sends buyers to Stripe's hosted checkout, so card data will never reach this code. | +| Card numbers, CVV | **Nowhere in Nundar** | Payment goes through Stripe's hosted checkout, so card data never reaches this code. A test lists the columns an order may have. No checkout route exists yet. | +| Buyer email and shipping address | D1, on the order | Written when an order is placed, which nothing can do yet: the order logic is built and has no route. Never written to a log. | +| Stripe keys and the webhook signing secret | Not stored yet | They will be plugin secrets, which Mallok encrypts. No code path reads them today. | | Cart contents | D1, keyed by an unguessable 128-bit id | Variant ids and quantities only — **never prices**. A test asserts the table has no price column. | | The cart cookie | `HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS, scoped to `/_mallok/p/shop` | It identifies a cart and nothing else. It is not a session and grants nothing in the admin. | | Prices, stock, variants | D1 | Changed only through the admin or the plugin's own scheduled repricing. | @@ -77,6 +79,52 @@ These are deliberate and should not be "simplified" away: at write time, so a price set by hand between the read and the write still stands. +### Payment, built and not yet reachable + +The order and payment logic is in the repository with its tests, and no route +calls it yet. These controls are in that code now, so that they are not left +to be remembered when the routes are written: + +- **A payment is believed only with Stripe's signature** over the bytes as + received, compared in constant time, and only within five minutes of the + time Stripe signed it. Only the `v1` scheme is read. With no signing secret + configured, everything is refused. +- **No amount comes from a client.** An order's lines are priced by the + server, and the call that opens a Checkout session takes a single amount, + the order's total, rather than line items a client could have shaped. +- **Stock is taken when a payment is confirmed, never before.** Taking it when + an order is placed would let scripted, unpaid orders empty the catalogue. +- **A payment takes stock once**, whether Stripe delivers the event twice, + sends two events for one payment, or delivers twice at the same moment. + Every write is conditional on the order's status, and tests run the + deliveries in parallel. +- **A payment is all or nothing.** The event, the stock, the ledger, what is + owed afterwards and the order's status are one D1 batch. An order whose + stock has gone is marked `oversold` with nothing taken, for a person to + refund. +- **Money taken is never left without a trace.** A payment for an order that + cannot take it — a cancelled one, or one another payment has settled — is + recorded with the payment to refund, and the order and the stock are left + alone. +- **A webhook fails only for what a retry could fix.** Anything else answered + with an error would be redelivered for three days. A payment naming an + order this shop does not have is answered and not acted on: every shop on a + Stripe account sees every payment of that account. +- **Money is a whole number at the database too.** The order tables check the + storage type of every amount, so a fraction of a minor unit cannot be + stored even by code that forgot to round. +- **A Stripe error never carries what was sent.** It is described by Stripe's + own identifiers and the HTTP status. The free-text message is dropped: + nothing rules out its repeating a value that was sent, such as an email + address. +- **An order's status moves only as the state machine allows**, and only if + the order is still where it was read. An oversold order cannot ship. +- **Order lines are snapshots.** A later change to a product cannot alter what + a past order says was bought, or for how much. +- **Emails escape everything a person typed** — product names, SKUs, tracking + numbers — before it becomes markup in a buyer's inbox. +- **A failure reason never carries personal data.** It names the order by id. + ## Known residual risks Stated plainly rather than left for an auditor to find: @@ -96,6 +144,20 @@ Stated plainly rather than left for an auditor to find: exists, do not show prices by another route. - **Sample content and sample variants are public test data.** Do not load `seed/shop-sample.sql` into a production database. +- **Nobody is told about a payment that has to be refunded.** An oversold + order, and a payment for an order that was cancelled while its buyer was + still on Stripe's page, are both recorded with a row in the outbox — and + nothing reads the outbox yet. Until something does, a person finds them + only by looking. Cancelling an order should also expire its Checkout + session, so that the second case cannot arise; that is not built either. +- **The amount Stripe reports is not compared with the order's total.** The + session is opened for the order's total by the server, so they agree unless + something on the Stripe side changes what the buyer pays. Not yet decided; + see the design's §14. +- **Orders that are never paid stay in the database as `pending`**, with the + buyer's email and address, until something removes them. Nothing does yet. +- **An order that costs nothing cannot be completed.** Stripe is never asked + to charge zero, and nothing confirms a free order without it yet. ## Dependency posture diff --git a/docs/superpowers/plans/2026-10-05-phase2a-payment-core.md b/docs/superpowers/plans/2026-10-05-phase2a-payment-core.md new file mode 100644 index 0000000..312bd66 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-phase2a-payment-core.md @@ -0,0 +1,103 @@ +# Phase 2A: the payment core + +> Date: 2026-10-05 +> Design: `docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md` §4.2, §5.4, §8, §14 +> Builds on: `mallok@0.1.0-rc.9` + +## Goal + +Phase 1B waits for extension points Mallok has not released. This phase builds the part of phase 2 that needs none of them: everything about orders and payment that is logic and data, with no route, no page and no admin screen. + +It finishes the port list in the design's §8. **Nothing in the deployed Worker calls this code yet.** The Worker's bundle grows by the migration's text and nothing else. + +One change reaches back into phase 1A: the first migration's price, stock, minimum-order and cart-quantity columns now check their storage type. It was edited in place, which is only possible because nothing has been deployed from it. + +## What was built + +**The tables (`migrations/0002_orders.sql`)** +- `p_shop_order`, `p_shop_order_line`, `p_shop_stripe_event`, `p_shop_stock_adjustment`, `p_shop_outbox`. +- By constraint: an order's total is the sum of its parts; money is stored as whole numbers; one payment intent belongs to one order; one payment is recorded once whatever its event id; an order has one line per variant; a stock movement an order causes can be written once; a duty in the outbox can be written once. + +**Orders (`lib/orders.ts`)** +- `createPendingOrder`: the order and its line snapshots in one batch, one round trip. Stock is not touched. +- `getOrder`: by id or by order number, with lines, in one round trip. +- `markOrderPaid`: the event, the stock, the ledger, the outbox row and the order's status in one batch. Two round trips whatever the number of lines. Safe to call any number of times, at any moment, for the same payment. Its outcome is one of `paid`, `oversold`, `refused` and `duplicate`. + +**After the order (`lib/order-fulfilment.ts`)** +- `shipOrder`, `markDelivered`, `cancelOrder`, `refundOrder`. Each is checked against the state machine and written only if the order is still where it was read. +- A refund returns what the ledger says the payment took, in the same batch that marks the order refunded. + +**The outbox (`lib/outbox.ts`, `lib/order-guard.ts`)** +- Every batch that changes an order writes a row saying so, under the same condition as the rest of the batch. A payment the order could not take writes one naming the payment to refund. +- Reading what is owed, and marking it handled. Nothing drains it yet. + +**Availability (`lib/availability.ts`)** +- A variant's state — in stock, made to order, out of stock — and which products changed state between two readings. A payment and a refund both report it, for the purge the design's §5.1 asks for. + +**Stripe (`lib/stripe-signature.ts`, `lib/stripe-client.ts`, `lib/stripe-webhook.ts`)** +- Signature verification on WebCrypto. +- Opening a hosted Checkout session and refunding a payment, over `fetch`. +- The webhook's decision: given the raw body, the signature header and the secret, what happened and which status to answer with. + +**Email (`lib/order-email.ts`)** +- The payment confirmation and the shipping notice, in English, German, French and Spanish, each with a plain-text part. + +## How it was verified + +| Check | Result | +|---|---| +| `npm run lint`, `npm run typecheck` | Pass | +| `npm run test:project` | 10 of 10 | +| `npm run test:shop` | 280 of 280, inside workerd; 150 of them new | +| `npm run build` | Pass; 398 KiB gzip, 2 KiB more than before | +| `npm run smoke`, `npm run smoke:shop` | Pass, on a real local Worker, with the new migration applied by Mallok's migrator | + +The tests were written first and seen to fail before any of the code existed. + +Then every guard was broken, one at a time, to see whether a test noticed: 100 mutations, 100 caught. Among them the ones that only show when two calls run at the same moment — the status condition on the stock decrement, on a refund's restock and on a status change. Those being caught is what shows the parallel tests really interleave. + +Then the code went to an independent reviewer, who was given the properties it must hold and not how it holds them. The five defects that came back are in the design's §14, each fixed with a test that fails without the fix. The reworked code went back for a second pass, which replayed the five, ran randomly interleaved payments, cancellations, shipments and refunds without a violation — 2,500 rounds by its report, and 1,000 run again here against the final code — and found three smaller things; two were fixed and one is recorded. + +## What implementation uncovered + +**About the previous code** +- A crash between its stock decrement and its order update left stock taken for an order still marked unpaid; the redelivery then took it again. +- Two near-simultaneous deliveries of one event could both pass its "already processed" check and both decrement. +- A second event for a payment already recorded was refused and answered 5xx, which Stripe retries for three days. +- A payment without an order id answered 400, so every payment the same Stripe account took elsewhere would be redelivered. +- Its six-character order numbers would collide about once a month at a thousand orders a day. +- Its confirmation template could state a lead time, and nothing supplied one. + +**About this code, from the independent review** +- A return value is not a place to keep a duty. "This call paid the order, so send the email" is lost whenever the Worker stops after the batch, or the batch's answer does not come back. The duty has to be a row in the same batch. +- "Fail loudly" is the wrong reflex for a webhook. Loud, to Stripe, means three days of redelivery; for anything a retry cannot change, the right kind of loud is a record a person will find. +- A reading is stale by the time it is acted on. The oversold write trusted a reading of the stock that another order's refund could overturn. +- A check on a product is not a check on its factors: 99.5 × 10 is a whole number. +- A timestamp is not an order. The outbox sorted by the time each change was made with, which the caller supplies; rows now carry the sequence they were committed in. +- One failure of a write says little about the next attempt when the data moved in between. A write that throws is tried once more if the next reading still calls for it. + +**About D1 and workerd** +- `UPDATE … RETURNING` and `INSERT … SELECT … WHERE … RETURNING` work inside a batch, and are how a statement reports that it matched: clearer than counting changes. +- A batch may hold reads beside its writes. The stock before and after a payment is read in the payment's own transaction, so the report of what changed cannot be skewed by another order. +- An `INTEGER` column stores 99.5 as a real number. Only a `typeof` check refuses it. +- WebCrypto refuses an HMAC key of length zero with a `DataError`. An unset signing secret is refused before that, with a reason. + +**About Mallok** +- The core builds a product's structured data itself and emits it in `page.head`, and a theme cannot add a JSON-LD block. The render-data hook Mallok plans puts a price on the page; nothing yet lets a plugin put it in the structured data. Added to the list for Mallok (design §7). +- A job a plugin enqueues through Mallok would be a second write, after the plugin's own batch. Unless the enqueue can join that batch, a plugin whose follow-up must not be lost needs an outbox of its own. Noted for Mallok's job API (design §7, API-8). +- Mallok's own plugin builds its emails in code with `escapeHtml`, with operator overrides in settings. The order emails follow that, rather than the Liquid templates the design first named. +- Site-level email settings are being added in Mallok and are not released. The order emails need them, or a key of the plugin's own, before anything is sent. + +## What waits + +**For Mallok** +- The webhook route: a raw-body route (design §7 API-5). +- The checkout, cart and order pages: plugin pages through the theme (API-3, API-4). +- Order handling in the admin: actions with parameters and related rows (API-6). +- Purging on an availability change: plugin cache tags (API-2). + +**For a decision** — listed in the design's §14, none decided here: who drains the outbox and who is told; a payment for a cancelled order; orders never paid; orders that cost nothing; Stripe's Adaptive Pricing; cross-checking the amount; emptying the cart; how a refund is ordered and whether it restocks a shipped order; `oversold → cancelled`; pinning the Stripe API version; email settings; the order-number prefix; whether a payment for an unknown order is recorded; what deleting a variant does to orders that name it. + +**For a real account** +- A Stripe test-mode purchase end to end. +- CPU time and D1 usage of a payment on the Workers Free plan. diff --git a/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md b/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md index 431f420..1ad6acc 100644 --- a/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md +++ b/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md @@ -116,7 +116,17 @@ p_shop_stock_adjustment(id, variant_id, delta, reason, ref_id, created_at) **`stock_policy` was added in phase 1A.** The owner's decision to show availability as a state (§11 decision 6) needs the data to say which state applies: a `track` variant is limited by its stock, and a `made_to_order` variant is always orderable and shows its lead time instead. -The order tables are created in phase 2, with the payment flow that uses them. +**The order tables exist since 2026-10-05** (`migrations/0002_orders.sql`), created with the payment core (§14). They differ from the sketch above in seven places, each for a reason: + +- **A fifth table, `p_shop_outbox`.** What still has to happen because an order changed — the buyer's email, the purge of a product's pages, a person told about a payment to refund — is a row written in the same batch as the change. Sending the email after the batch would lose it whenever the Worker stopped in between, with nothing to say it was owed. Rows are handed out by the table's own sequence, the order they were committed in, so that "shipped" is carried out before "delivered"; a timestamp is whatever the change was made with and cannot say that. +- **`p_shop_stripe_event` also holds the order, the payment intent and the outcome** (`paid`, `oversold`, `refused`), and is unique on the event's type with the payment intent. Stripe can send two events about one object; one payment is acted on once. +- **Money columns check their own storage type** (`typeof(x) = 'integer'`). SQLite keeps 99.5 in an `INTEGER` column as a real number rather than refuse it. +- `p_shop_order.total_minor` carries `CHECK (total_minor = subtotal_minor + shipping_minor + tax_minor)`. An order cannot hold a total that is not the sum of its parts. +- `p_shop_order.status` has no `CHECK`. The state machine in `lib/order-state.ts` decides which statuses exist; a `CHECK` could only be changed later by rebuilding the table, and migrations here are additive. +- `p_shop_order_line.id` is `:`, and the pair is also `UNIQUE`. The payment write reads a line's quantity by that pair and relies on finding exactly one. +- `p_shop_stock_adjustment.id` is `::` for a movement an order causes. The same movement cannot be written twice: a second attempt fails and takes its batch with it. + +Timestamps are ISO text, as everywhere in the plugin. ## 5. Request flows @@ -175,21 +185,18 @@ The order tables are created in phase 2, with the payment flow that uses them. ### 5.4 Stripe webhook -The route is `POST /_mallok/p/shop/stripe/webhook`. It needs the raw request body (§7 API-5). +The route is `POST /_mallok/p/shop/stripe/webhook`. It needs the raw request body (§7 API-5). The decision it makes is built and tested already (`lib/stripe-webhook.ts`, §14); the route that calls it waits for API-5. -1. Verify the signature over the raw body. This is `src/lib/stripe/webhook.ts`, ported unchanged. -2. If `p_shop_stripe_event` already has the event, answer 200 without acting. -3. Run one batch: - - insert the event; - - decrement each line's stock; - - write the stock adjustments; - - move the order `pending → paid`. -4. The batch can fail in two ways: - - **On a `CHECK` failure**, a second batch inserts the event and marks the order `oversold`, and the response is 200 so Stripe stops. - - **On a primary-key conflict on the event**, a concurrent delivery won the race; answer 200. -5. After success, queue the confirmation email in `orders.locale` through `ctx.sendEmail`, and purge affected product tags only when their availability state changed. - -A genuine failure answers non-2xx so Stripe redelivers (2026-09-03 §8). +1. **Verify** the signature over the raw body, with a five-minute tolerance on its timestamp (`lib/stripe-signature.ts`). A failure answers 400 and does nothing. +2. **Sort.** Any event type other than `payment_intent.succeeded` answers 200 and does nothing. So does a payment that carries no order id: the same Stripe account may take payments that did not come through this shop. A payment event that carries no payment answers 400: waving it through would swallow a real payment in silence. +3. **Read**, in one batch: is this payment on record — the same event, or another event of the same type about the same payment intent; what status is the order in; is the stock its tracked lines need there. +4. **Act**, in one batch, every statement of which is conditional on the order still being in the status that was read: + - *The order may be paid and the stock is there.* Record the event; decrement the stock of the order's stock-tracked lines; write their stock adjustments; put `order.paid` in the outbox; move the order `pending → paid`. A made-to-order line has no stock to take and is left out of the decrement. + - *The order may be paid and the stock is not there.* Record the event; put `order.oversold` in the outbox; mark the order `oversold`, keeping the payment intent on it. Every statement is also conditional on the stock still being short. + - *The order may not be paid* — it was cancelled, or another payment has settled it. Record the event as `refused` and put `payment.refused`, naming the payment intent, in the outbox. The order and the stock are not touched. +5. **If the batch failed, or found the order moved**, read again and act on what is true, rather than on the text of an error. That is how a decrement that trips the stock constraint becomes `oversold`, and how losing a race becomes "already on record". A write that threw is tried once more if the next reading still calls for it — stock can go and come back in between — and the same write throwing twice running is a real failure. Four passes at most. +6. **Answer by one question: could delivering this again change the result?** A database failure answers 5xx, and Stripe redelivers (2026-09-03 §8). Nothing else does. A payment naming an order this database does not have answers 200: another shop on the same Stripe account sees every payment of the account, and three days of retries would not make the order appear. +7. **What follows a payment is owed from the outbox**, not from this request's result: the confirmation email in `orders.locale` through `ctx.sendEmail`, and the purge of the product tags whose availability state changed. A route drains the order's outbox after any outcome that names an order. ### 5.5 Exchange rates @@ -236,11 +243,12 @@ Mallok's roadmap says a storefront needs "exactly the six plugin capabilities 0. | API-5 | An opt-in raw-body route (no pre-parsing) | The body is parsed before the handler runs (`mallok: src/worker/plugin-runtime.ts:368-394`). Stripe's signature cannot be checked | 5.4 | | API-6 | Editable plugin records: create and edit forms, repeatable rows, actions with parameters, detail views, and a scope check on panel reads | Panels are read-only. Actions get ids only. "A plugin ships no frontend code" (`mallok: docs/PLUGIN_API.md:366`) | 5.6 | | API-7 | Call `onContentSave`, and add a content-delete hook | `runOnContentSave` exists but has no call site (`mallok: src/worker/plugin-runtime.ts:123`) | Keeping variants in step with products | -| API-8 | Per-plugin error isolation in `scheduled`, and a plugin job/enqueue API over the `job` table | One `try/catch` wraps all plugins (`mallok: src/worker/scheduled.ts:56-71`). There is no enqueue (`mallok: docs/PLUGIN_API.md §7.4`) | 5.5, email retries | +| API-8 | Per-plugin error isolation in `scheduled`, and a plugin job/enqueue API over the `job` table. An enqueue the plugin can put in its own `batch` would let the job commit with the change that causes it; without that, the shop keeps the outbox of §4.2 (found 2026-10-05) | One `try/catch` wraps all plugins (`mallok: src/worker/scheduled.ts:56-71`). There is no enqueue (`mallok: docs/PLUGIN_API.md §7.4`) | 5.5, email retries | | API-9 | **Shipped in 0.1.0-rc.9.** Export the helpers official plugins already use (`escapeHtml`, the restricted Liquid text/email renderer) from `mallok/worker` | The inquiry plugin imports them from internals | Order email | | API-10 | Third-party starter registration in `createMallok` | Starters are a compiled-in list (`mallok: src/starters/index.ts`) | Starter | | API-11 | ~~An "always prefix" locale mode~~ — **not needed**: the unprefixed default language is accepted (§11 decision 1) | | | | API-12 | ~~A translation-completeness view and admin roles~~ — **deferred**: one administrator is accepted for now (§11 decision 7) | | | +| — | A way for `renderData` to add to the page's structured data: the plugin's `offers` merged into the core's own Product node in `page.head` | The core builds the Product JSON-LD itself and emits it inside `page.head` (`mallok: src/core/view.ts`, `contentJsonLd`, `buildHeadTags`). A theme cannot emit a JSON-LD block of its own: the script check rejects any `` and `on*=` pass (`mallok: src/core/theme-package.ts:262-264`) | The commerce theme declares the currency-switch script and would otherwise bypass every script check | 5.1 | | — | Resolve `reference[]` fields in `content.refs` and `content.backrefs` | Only `reference` is resolved (`mallok: src/worker/render.ts:386,406`); a `reference[]` reaches a template as bare slugs | A product in more than one collection (§4.1) | | — | **Shipped in 0.1.0-rc.9.** Supply `recent.` on the home page for every kind, as `THEME_FORMAT.md §7.4` documents | The Worker's home page loads articles only (`mallok: src/worker/pages/home.page.ts`); the static build supplies every kind, so the two paths disagree | Products and application notes on the home page | @@ -259,10 +267,10 @@ These changes raise the plugin contract version (`pluginApi`), and Mallok's own | Current module | Fate | |---|---| -| `src/lib/money.ts`, `src/lib/orders/state.ts`, `src/lib/stripe/webhook.ts`, `src/lib/stripe/client.ts` | Port unchanged; they are pure and already use `fetch`, not an SDK | -| `src/lib/pricing*`, `src/lib/cart/pricing.ts`, `src/lib/orders/orders.ts`, `src/lib/orders/admin.ts` | Port the logic; rewrite data access from Drizzle to raw SQL; move payment writes to one batch (§5.4) | -| `src/lib/cart/cart.ts`, `cookie.ts` | Rewrite for D1 and the path-scoped cookie | -| `src/lib/email/templates.ts` | Rewrite as plugin email templates (Liquid) | +| `src/lib/money.ts`, `src/lib/orders/state.ts`, `src/lib/stripe/webhook.ts`, `src/lib/stripe/client.ts` | Port unchanged; they are pure and already use `fetch`, not an SDK. **Done** (phase 1A and §14); what review changed on the way is listed in §14 | +| `src/lib/pricing*`, `src/lib/cart/pricing.ts`, `src/lib/orders/orders.ts`, `src/lib/orders/admin.ts` | Port the logic; rewrite data access from Drizzle to raw SQL; move payment writes to one batch (§5.4). **Done** (phase 1A and §14) | +| `src/lib/cart/cart.ts`, `cookie.ts` | Rewrite for D1 and the path-scoped cookie. **Done** (phase 1A) | +| `src/lib/email/templates.ts` | Rewrite as plugin email content. **Done** (§14), as code with Mallok's `escapeHtml`, which is how Mallok's own inquiry plugin builds its emails. Operator-editable Liquid overrides come with the settings that would hold them | | `src/lib/seo/jsonld.ts` | The Offer part moves into the render-data hook; Product and BreadcrumbList come from Mallok | | `src/lib/storefront/i18n.ts` | Becomes the commerce theme's `locales/*.json` | | `src/lib/seo*`, sitemap, robots, locales config, `src/lib/markdown.ts`, `src/lib/media/*`, `src/lib/auth/*`, `src/lib/admin/*`, `src/lib/settings/*` | Dropped: Mallok core provides them. The security contact becomes a Mallok core proposal | @@ -274,7 +282,7 @@ The Next.js application stays in maintenance mode, with security and correctness ## 9. Phases -The phases follow Mallok's own roadmap (`mallok: docs/PRODUCT_VISION.md §9`: 0.2 inquiry cart, 0.3 payment, 1.0 storefront). Nundar builds on the published `mallok@0.1.0-rc.7` and moves to the Mallok release that carries the P0 extension points once the owner publishes it (§11 decision 8). +The phases follow Mallok's own roadmap (`mallok: docs/PRODUCT_VISION.md §9`: 0.2 inquiry cart, 0.3 payment, 1.0 storefront). Nundar was started on the published `mallok@0.1.0-rc.7`, is on `0.1.0-rc.9` now (§12), and moves to the Mallok release that carries the P0 extension points once the owner publishes it (§11 decision 8). **Phase 0: decisions, no code** - §11 is answered (2026-10-01). @@ -296,6 +304,7 @@ The phases follow Mallok's own roadmap (`mallok: docs/PRODUCT_VISION.md §9`: 0. - cold product render p50/p95 CPU and D1 round trips are measured on a real Free account. **Phase 2: payment (Mallok 0.3)** +- *Built ahead, 2026-10-05 (§14):* everything below that needs no Mallok extension point — the order tables, placing an order, the payment batch, refunds, the webhook's decision, the Stripe calls and the order emails. Not built: every route, page and admin screen. - Mallok: API-5, 6 (actions with parameters) and 8. - Shop plugin: - Stripe Checkout and the webhook; @@ -402,3 +411,54 @@ Still open in Mallok, and so still shaping this code: the render-data hook, plug - **Not verified:** - Mallok's CPU figures, which are its own single measurements; - the behaviour of any Mallok change proposed in §7, none of which exists yet. + + +## 14. What the payment core changed or found (2026-10-05) + +Phase 1B waits for Mallok. In the meantime the part of phase 2 that needs nothing from Mallok was built: `migrations/0002_orders.sql` and, in `lib/`, `orders.ts`, `order-fulfilment.ts`, `outbox.ts`, `order-guard.ts`, `availability.ts`, `stripe-signature.ts`, `stripe-client.ts`, `stripe-webhook.ts` and `order-email.ts`. **Nothing in the deployed Worker calls any of it yet**: there is no checkout route, no webhook route and no admin screen. It is logic and tests, waiting for its routes. + +**What review changed while porting.** The previous code was the starting point, not the result. + +- *The payment is one batch.* The previous `markOrderPaid` decremented line by line, put stock back by hand when a later line failed, and wrote the order and the event in separate statements. A crash between two of them left stock taken for an order that was not paid. Now the writes commit together or not at all, and the cost is two round trips whatever the number of lines. +- *Oversold is decided from the data, not from the error.* When the batch fails, the code does not read a reason out of the error message; it reads the payment, the order and the stock again and acts on what is true. The message's wording is not a contract. +- *Every write is conditional on the order's status.* Two deliveries racing each other both read "pending"; the second one's batch then finds the order already paid and writes nothing. The previous code had no such guard, and two near-simultaneous deliveries could both decrement. Tests deliver in parallel and prove one decrement. +- *One payment is one payment, whatever the event id.* Stripe documents that two events can be sent for one object, to be told apart by the object's id and the event's type. A second event for a payment already on record is answered and not acted on; the previous code refused it as an illegal transition and answered 5xx, for three days. +- *Made-to-order lines take no stock.* `stock_policy` did not exist before. Without the distinction, a made-to-order variant's zero stock would fail the constraint and every such order would be marked oversold. +- *Order numbers are longer.* Six hexadecimal characters are 24 bits: at a thousand orders a day, two would collide about once a month, and a collision is a failed checkout. Eight characters of Crockford's base 32 are 40 bits, and the alphabet has no I, L, O or U. +- *The signature header is parsed strictly.* The timestamp is digits and nothing else; `123=x`, `1e9` and `0x10` were all read as numbers. Reading only `v1` signatures, as Stripe's documentation asks, is unchanged, and a test now holds it. +- *A lead time is stated only when every line has one*, and is the slowest of them. The previous template could print one, but nothing supplied it. +- *A Stripe answer that is not JSON* — an outage page from a proxy — is reported as a failure with its status, instead of surfacing as a parse error. +- *`createPaymentIntent` was not ported.* The shop uses Stripe's hosted Checkout (2026-09-03 §13), and nothing called it. + +**What an independent review then found.** The code was given to a second reviewer with the properties it must hold and no account of how it holds them. The transaction logic stood. Five defects did not, and each was fixed with a test that fails without the fix: + +1. *The webhook answered 5xx for things no retry could fix.* A payment naming an order this database does not have — the ordinary case for a second shop, a staging copy or a local listener on the same Stripe account — and a payment for a cancelled order both failed for three days, and the second left no trace in the database of money that had been taken. Now the first answers 200, and the second is recorded as `refused` with an outbox row naming the payment to refund. +2. *What follows a payment could be lost for good.* The confirmation email was to be sent after the payment's batch, on the strength of its return value. A Worker stopped between the two, or a batch whose answer never came back, left an order paid and its email never sent — and every later delivery of the event saw "already processed". The outbox (§4.2) closes this: the duty is written with the payment. +3. *An order with a total of zero could never be paid.* Stripe creates no payment intent for a free Checkout session, so the event the shop waits for never arrives. `createCheckoutSession` now refuses a total that is not a positive whole amount; an order that costs nothing has to be confirmed without Stripe, which is left to the checkout route (below). +4. *Oversold was written on a stale reading.* Stock returned by another order's refund between the reading and the write still left the order marked oversold. The write is now conditional on the stock still being short, and the next reading pays the order. +5. *A fractional unit price could be stored.* 99.5 × 10 is a whole number, and only the product was checked. Each line is now checked on its own, and the money columns check their storage type. The same check went into the first migration — price, stock, minimum order and cart quantity — by editing it in place: nothing had been deployed from it, and a `CHECK` cannot be added to an existing table afterwards. + +A second pass over the reworked code found nothing against those properties, and three smaller things. *A payment whose write lost the stock for a moment was given up on*: stock taken just before the batch and returned just after left the next reading calling for the same write, which the code refused to repeat; it is now tried once more. *The outbox handed rows out by timestamp*, and a timestamp is supplied by whoever makes the change: three changes carrying the same one came back as delivered, paid, shipped. Rows now carry a sequence. *A payment on record under one order and reported again naming another* is answered as a duplicate, and the answer names the order in the event rather than the one the payment was recorded under; the checkout cannot produce this, since one order has one payment intent, and it is left as it is. + +The first review also pointed out that Stripe's failure message is free text, that nothing rules out its repeating a value that was sent, and that one value sent is the buyer's email address. Whether it does could not be confirmed from Stripe's documentation, so errors are now built from Stripe's identifiers (type, code, parameter name) and the status, never from its free text. + +**What was checked against current documentation** (2026-10-05): Stripe's manual signature verification, its retry schedule, its guidance on duplicate events, the Checkout Session and Refund parameters used here, how idempotency keys behave, no-cost orders, and the shape of its error object; Cloudflare D1's limits. D1's documentation gives 50 queries per invocation on the Free plan and does not say how the statements inside a `batch()` are counted, so nothing here assumes a batch is free: confirming a payment is ten statements in two round trips however many lines the order has, and placing an order is two statements in one. + +**How it was verified.** 150 new tests inside workerd, against the site's own Worker and Mallok's migrator. Then 100 guards were broken one at a time — the signature check, the tolerance, each status condition, the made-to-order filter, each outbox row, each escape in the emails, each constraint in the migration — and every one turned a test red, including those that can only be seen when two calls run at the same moment. Two further type checks, on an order's subtotal and its shipping, cannot be told apart by any test — the total has to equal their sum, so either one catches what the other would — and are tested as a pair. + +**Left open, for when the routes are built.** None of these is decided here. + +1. *Nothing reads the outbox yet.* The rows are written; carrying them out — sending the email, purging, telling a person about a refund that is due — belongs with the routes and the cron step that will drain it, and with the choice of who is told. A row does not say which products' availability changed, so a consumer working from the row alone purges every product on the order. +2. *A payment for a cancelled order* is recorded for a refund. Better that it could not happen: cancelling an order should expire its Checkout session. +3. *Orders that are never paid stay `pending`.* Stripe sends `checkout.session.expired`; acting on it would cancel them. +4. *Orders that cost nothing.* The checkout route must confirm them without Stripe. There is no function for that yet. +5. *Stripe's Adaptive Pricing* shows a buyer their local currency at Stripe's own rate. The shop prices each currency itself (§5.5), so the two overlap; whether to switch it off per session is a decision. +6. *The amount is not cross-checked.* The event's amount and currency are not compared with the order's. With Adaptive Pricing they can legitimately differ, which is why this waits for item 5. +7. *The cart is not emptied after payment.* An order does not record which cart it came from. +8. *Refunds.* The action must refund at Stripe first and record it second. Stripe keeps an idempotency key, with the answer it gave — a 5xx included — for at least 24 hours; after that a retry is refused as already refunded, which the action has to treat as done. The same holds for opening a Checkout session, whose key is the order: an order whose first attempt Stripe failed cannot open a session for a day, and the buyer has to place a new one. And a refund returns the stock the payment took even when the order has shipped: right if the goods come back, wrong if they do not. +9. *`oversold → cancelled`* is a legal move, and it leaves a payment that was taken with no order to refund it from. +10. *The Stripe API version is not pinned.* The two calls run on whatever version the account defaults to. +11. *Email settings.* In `mallok@0.1.0-rc.9`, `ctx.sendEmail` uses the calling plugin's own Resend key and sender address. Mallok is adding site-level email settings, which are not in a release yet; with them the shop plugin declares neither, and until then it would need its own. +12. *The order-number prefix* is `ND-`. A shop may want its own. +13. *A payment naming an order this database does not have leaves no record*, only the outcome a route will log. That is right for a second shop on the same Stripe account, and wrong for a database restored from an old backup: real payments would be answered 200 and forgotten. Recording them all would fill the table with another shop's payments; whether to is undecided. +14. *A variant deleted outright* is skipped by the payment write: the order is paid, and nothing is decremented or written to the ledger for that line. Nothing deletes variants today — they are archived — and whatever first does has to deal with this. diff --git a/src/plugins/shop/index.ts b/src/plugins/shop/index.ts index 158c4b9..d3fdcf4 100644 --- a/src/plugins/shop/index.ts +++ b/src/plugins/shop/index.ts @@ -12,6 +12,7 @@ import { definePlugin, type PluginContext } from 'mallok/worker'; import { runScheduledTick } from './lib/scheduled.js'; import shopSql from './migrations/0001_shop.sql'; +import ordersSql from './migrations/0002_orders.sql'; import manifest from './plugin.json'; import { cart } from './routes/cart.js'; @@ -26,7 +27,10 @@ async function scheduled(ctx: PluginContext): Promise { export const shop = definePlugin({ manifest, - migrations: [{ id: 'plugin:shop:0001_shop', sql: shopSql }], + migrations: [ + { id: 'plugin:shop:0001_shop', sql: shopSql }, + { id: 'plugin:shop:0002_orders', sql: ordersSql }, + ], hooks: { scheduled }, routes: { cart }, }); diff --git a/src/plugins/shop/lib/availability.ts b/src/plugins/shop/lib/availability.ts new file mode 100644 index 0000000..6eae026 --- /dev/null +++ b/src/plugins/shop/lib/availability.ts @@ -0,0 +1,55 @@ +/** + * Availability as a state. + * + * A page says "in stock" or "made to order", never how many are left: an + * exact count would have to be purged from the edge cache on every sale, + * while a state changes rarely. So what matters to a cached page is not that + * stock moved, but whether it moved across the line between states. + */ + +export type Availability = 'in_stock' | 'made_to_order' | 'out_of_stock'; + +/** The columns of `p_shop_variant` that decide a variant's availability. */ +export interface StockRow { + id: string; + product_group: string; + stock: number; + moq: number; + stock_policy: string; +} + +/** + * The state a variant is in. + * + * A tracked variant is in stock while one minimum order can still be filled. + * Five left with a minimum order of ten is out of stock: nobody can buy it, + * and `quantityIssue` in `cart-pricing.ts` would refuse them. The two must + * agree, and `test/shop/availability.test.ts` holds them to it. + */ +export function availabilityOf( + variant: Pick, +): Availability { + if (variant.stock_policy !== 'track') { + return 'made_to_order'; + } + return variant.stock >= variant.moq ? 'in_stock' : 'out_of_stock'; +} + +/** + * The products whose availability differs between two readings of the same + * variants — the products whose cached pages now say something untrue. + */ +export function changedProductGroups( + before: readonly StockRow[], + after: readonly StockRow[], +): string[] { + const earlier = new Map(before.map((row) => [row.id, availabilityOf(row)])); + const changed = new Set(); + for (const row of after) { + const was = earlier.get(row.id); + if (was !== undefined && was !== availabilityOf(row)) { + changed.add(row.product_group); + } + } + return [...changed].sort(); +} diff --git a/src/plugins/shop/lib/order-email.ts b/src/plugins/shop/lib/order-email.ts new file mode 100644 index 0000000..98078a2 --- /dev/null +++ b/src/plugins/shop/lib/order-email.ts @@ -0,0 +1,205 @@ +/** + * The two emails an order sends its buyer: payment confirmed, and shipped. + * + * Each is written in the language the order was placed in, and each carries a + * plain-text part beside the HTML: HTML alone renders blank in some clients + * and raises the spam score. + * + * These build the content only. Sending is Mallok's `ctx.sendEmail`. + */ + +import { escapeHtml } from 'mallok/worker'; +import { formatMoney } from './money.js'; +import type { OrderDetail } from './orders.js'; + +export interface EmailContent { + readonly subject: string; + readonly text: string; + readonly html: string; +} + +interface Copy { + readonly confirmSubject: (orderNo: string) => string; + readonly confirmIntro: string; + readonly itemsHeading: string; + readonly totalLabel: string; + readonly leadTime: (days: number) => string; + readonly shipSubject: (orderNo: string) => string; + readonly shipIntro: string; + readonly trackingLabel: string; + readonly signOff: string; +} + +const ENGLISH: Copy = { + confirmSubject: (orderNo) => `Order ${orderNo} confirmed`, + confirmIntro: 'Thank you for your order. We have received your payment.', + itemsHeading: 'Items', + totalLabel: 'Total', + leadTime: (days) => `Expected to ship within ${days} business days.`, + shipSubject: (orderNo) => `Order ${orderNo} has shipped`, + shipIntro: 'Your order is on its way.', + trackingLabel: 'Tracking number', + signOff: 'If you have any questions, simply reply to this email.', +}; + +const COPY: Readonly> = { + en: ENGLISH, + de: { + confirmSubject: (orderNo) => `Bestellung ${orderNo} bestätigt`, + confirmIntro: + 'Vielen Dank für Ihre Bestellung. Ihre Zahlung ist bei uns eingegangen.', + itemsHeading: 'Artikel', + totalLabel: 'Gesamt', + leadTime: (days) => + `Voraussichtlicher Versand innerhalb von ${days} Werktagen.`, + shipSubject: (orderNo) => `Bestellung ${orderNo} wurde versandt`, + shipIntro: 'Ihre Bestellung ist unterwegs.', + trackingLabel: 'Sendungsnummer', + signOff: 'Bei Fragen antworten Sie einfach auf diese E-Mail.', + }, + fr: { + confirmSubject: (orderNo) => `Commande ${orderNo} confirmée`, + confirmIntro: + 'Merci pour votre commande. Nous avons bien reçu votre paiement.', + itemsHeading: 'Articles', + totalLabel: 'Total', + leadTime: (days) => `Expédition prévue sous ${days} jours ouvrés.`, + shipSubject: (orderNo) => `Commande ${orderNo} expédiée`, + shipIntro: 'Votre commande est en route.', + trackingLabel: 'Numéro de suivi', + signOff: 'Pour toute question, répondez simplement à cet e-mail.', + }, + es: { + confirmSubject: (orderNo) => `Pedido ${orderNo} confirmado`, + confirmIntro: 'Gracias por su pedido. Hemos recibido su pago.', + itemsHeading: 'Artículos', + totalLabel: 'Total', + leadTime: (days) => `Envío previsto en ${days} días hábiles.`, + shipSubject: (orderNo) => `Pedido ${orderNo} enviado`, + shipIntro: 'Su pedido está en camino.', + trackingLabel: 'Número de seguimiento', + signOff: 'Si tiene alguna pregunta, responda a este correo.', + }, +}; + +/** + * The language to write in, as one of the keys above. + * + * A regional variant uses its language (`de-AT` reads German); anything + * without a translation reads English. The same key then formats the money, + * so a locale that is not even well-formed cannot stop the email from being + * built. + */ +function languageOf(locale: string): string { + const language = locale.toLowerCase().split('-')[0] ?? ''; + return Object.hasOwn(COPY, language) ? language : 'en'; +} + +function layout(title: string, bodyHtml: string): string { + return ` + + +

${escapeHtml(title)}

+${bodyHtml} + +`; +} + +/** The confirmation sent once, when an order's payment is confirmed. */ +export function orderConfirmationEmail( + order: Pick< + OrderDetail, + | 'orderNo' + | 'currency' + | 'totalMinor' + | 'locale' + | 'lines' + | 'leadTimeDaysMax' + >, +): EmailContent { + const language = languageOf(order.locale); + const copy = COPY[language] ?? ENGLISH; + const money = (minor: number) => formatMoney(minor, order.currency, language); + + const subject = copy.confirmSubject(order.orderNo); + const leadTimeLine = + order.leadTimeDaysMax === null + ? null + : copy.leadTime(order.leadTimeDaysMax); + + const text = [ + copy.confirmIntro, + '', + `${copy.itemsHeading}:`, + ...order.lines.map( + (line) => + `- ${line.name} (${line.sku}) × ${line.quantity} — ${money( + line.unitPriceMinor * line.quantity, + )}`, + ), + '', + `${copy.totalLabel}: ${money(order.totalMinor)}`, + ...(leadTimeLine === null ? [] : ['', leadTimeLine]), + '', + copy.signOff, + ].join('\n'); + + // Names and SKUs are typed by people in the admin and reach a buyer's + // inbox as markup, so every one of them is escaped. + const items = order.lines + .map( + (line) => + `
  • ${escapeHtml(line.name)} (${escapeHtml(line.sku)}) × ${ + line.quantity + } — ${escapeHtml(money(line.unitPriceMinor * line.quantity))}
  • `, + ) + .join(''); + + const html = layout( + subject, + `

    ${escapeHtml(copy.confirmIntro)}

    +

    ${escapeHtml(copy.itemsHeading)}

    +
      ${items}
    +

    ${escapeHtml(copy.totalLabel)}: ${escapeHtml( + money(order.totalMinor), + )}

    +${leadTimeLine === null ? '' : `

    ${escapeHtml(leadTimeLine)}

    `} +

    ${escapeHtml(copy.signOff)}

    `, + ); + + return { subject, text, html }; +} + +/** The notice sent when an order ships. */ +export function shippingNotificationEmail(input: { + readonly orderNo: string; + readonly locale: string; + readonly trackingNo: string | null; +}): EmailContent { + const copy = COPY[languageOf(input.locale)] ?? ENGLISH; + const subject = copy.shipSubject(input.orderNo); + + const text = [ + copy.shipIntro, + ...(input.trackingNo === null + ? [] + : ['', `${copy.trackingLabel}: ${input.trackingNo}`]), + '', + copy.signOff, + ].join('\n'); + + const html = layout( + subject, + `

    ${escapeHtml(copy.shipIntro)}

    +${ + input.trackingNo === null + ? '' + : `

    ${escapeHtml(copy.trackingLabel)}: ${escapeHtml( + input.trackingNo, + )}

    ` +} +

    ${escapeHtml(copy.signOff)}

    `, + ); + + return { subject, text, html }; +} diff --git a/src/plugins/shop/lib/order-fulfilment.ts b/src/plugins/shop/lib/order-fulfilment.ts new file mode 100644 index 0000000..652083b --- /dev/null +++ b/src/plugins/shop/lib/order-fulfilment.ts @@ -0,0 +1,199 @@ +/** + * What a person does to an order after it is placed: ship it, mark it + * delivered, cancel it, refund it. + * + * Every change is checked against the state machine in `order-state.ts`, and + * then written only if the order is still in the status that was checked. Two + * people pressing the same button, or one pressing it twice, change the order + * once; the other attempt is refused and writes nothing. + * + * Every change also puts a row in the outbox, in the same batch: what the + * buyer is to be told about it is owed from that moment, whatever happens to + * the request that made the change. + */ + +import { changedProductGroups, type StockRow } from './availability.js'; +import { ORDER_STILL_IN_STATUS } from './order-guard.js'; +import { assertTransition, type OrderStatus } from './order-state.js'; +import { OrderNotFoundError } from './orders.js'; +import { orderChangedOutbox } from './outbox.js'; + +interface OrderChange { + readonly orderId: string; + readonly now: Date; +} + +async function statusOf(db: D1Database, orderId: string): Promise { + const row = await db + .prepare('SELECT status FROM p_shop_order WHERE id = ?') + .bind(orderId) + .first<{ status: string }>(); + if (row === null) { + throw new OrderNotFoundError(orderId); + } + return row.status as OrderStatus; +} + +function changedUnderneath(orderId: string): Error { + return new Error( + `Order ${orderId} changed while it was being updated; nothing was written`, + ); +} + +/** Moves an order to `to`, if the state machine allows it from where it is. */ +async function move( + db: D1Database, + input: OrderChange, + to: Exclude, + trackingNo: string | null = null, +): Promise { + const { orderId, now } = input; + const from = await statusOf(db, orderId); + assertTransition(from, to); + + const results = await db.batch>([ + orderChangedOutbox(db, { orderId, from, to, now }), + // Last: the outbox row tests the status this statement changes. + db + .prepare( + `UPDATE p_shop_order + SET status = ?, tracking_no = COALESCE(?, tracking_no), updated_at = ? + WHERE id = ? AND status = ? + RETURNING id`, + ) + .bind(to, trackingNo, now.toISOString(), orderId, from), + ]); + if ((results[1]?.results ?? []).length === 0) { + throw changedUnderneath(orderId); + } +} + +/** Ships a paid order: records the tracking number and moves it on. */ +export async function shipOrder( + db: D1Database, + input: OrderChange & { readonly trackingNo: string }, +): Promise { + const trackingNo = input.trackingNo.trim(); + if (trackingNo === '') { + // "Shipped" with nothing to track helps neither support nor the buyer. + throw new Error('A tracking number is required to ship an order'); + } + await move(db, input, 'shipped', trackingNo); +} + +export async function markDelivered( + db: D1Database, + input: OrderChange, +): Promise { + await move(db, input, 'delivered'); +} + +/** + * Cancels an order that has not been paid for, or one that was oversold. + * + * A paid order is not cancelled, it is refunded: its stock and its money + * both have to go back. + */ +export async function cancelOrder( + db: D1Database, + input: OrderChange, +): Promise { + await move(db, input, 'cancelled'); +} + +function restockedVariants( + db: D1Database, + orderId: string, +): D1PreparedStatement { + return db + .prepare( + `SELECT v.id, v.product_group, v.stock, v.moq, v.stock_policy + FROM p_shop_variant AS v + WHERE v.id IN ( + SELECT variant_id FROM p_shop_stock_adjustment + WHERE ref_id = ? AND reason = 'order_paid' + ) + ORDER BY v.id`, + ) + .bind(orderId); +} + +/** + * Records a refund: the order moves to `refunded` and the stock it took goes + * back, in one batch. + * + * The stock returned is what the ledger says the payment took, not what the + * order asked for. An oversold order took nothing, so it gets nothing back; + * putting its quantity "back" would conjure stock out of nothing. + * + * This is the bookkeeping only. Returning the money is `createRefund` in + * `stripe-client.ts`, and a caller does that first: an order must never read + * "refunded" while the buyer is still waiting for the money. + * + * Returns the products whose availability the returned stock changed, by + * product group, so their cached pages can be purged. + */ +export async function refundOrder( + db: D1Database, + input: OrderChange, +): Promise<{ availabilityChanged: readonly string[] }> { + const { orderId } = input; + const nowIso = input.now.toISOString(); + const from = await statusOf(db, orderId); + assertTransition(from, 'refunded'); + + const results = await db.batch>([ + restockedVariants(db, orderId), + // `delta` is negative for stock that was taken, so subtracting it adds + // the same amount back. + db + .prepare( + `UPDATE p_shop_variant + SET stock = stock - ( + SELECT a.delta FROM p_shop_stock_adjustment AS a + WHERE a.ref_id = ? AND a.reason = 'order_paid' + AND a.variant_id = p_shop_variant.id + ), + updated_at = ? + WHERE id IN ( + SELECT variant_id FROM p_shop_stock_adjustment + WHERE ref_id = ? AND reason = 'order_paid' + ) + AND ${ORDER_STILL_IN_STATUS}`, + ) + .bind(orderId, nowIso, orderId, orderId, from), + // As with a payment, the row's id is built from what it records: a + // second refund of the same order cannot be written. + db + .prepare( + `INSERT INTO p_shop_stock_adjustment + (id, variant_id, delta, reason, ref_id, created_at) + SELECT 'refund:' || a.ref_id || ':' || a.variant_id, a.variant_id, + -a.delta, 'refund', a.ref_id, ? + FROM p_shop_stock_adjustment AS a + WHERE a.ref_id = ? AND a.reason = 'order_paid' + AND ${ORDER_STILL_IN_STATUS}`, + ) + .bind(nowIso, orderId, orderId, from), + orderChangedOutbox(db, { orderId, from, to: 'refunded', now: input.now }), + // Last: every statement above tests the status this one changes. + db + .prepare( + `UPDATE p_shop_order SET status = 'refunded', updated_at = ? + WHERE id = ? AND status = ? + RETURNING id`, + ) + .bind(nowIso, orderId, from), + restockedVariants(db, orderId), + ]); + + if ((results[4]?.results ?? []).length === 0) { + throw changedUnderneath(orderId); + } + return { + availabilityChanged: changedProductGroups( + (results[0]?.results ?? []) as unknown as StockRow[], + (results[5]?.results ?? []) as unknown as StockRow[], + ), + }; +} diff --git a/src/plugins/shop/lib/order-guard.ts b/src/plugins/shop/lib/order-guard.ts new file mode 100644 index 0000000..daa7f17 --- /dev/null +++ b/src/plugins/shop/lib/order-guard.ts @@ -0,0 +1,24 @@ +/** + * The condition every write that changes an order carries. + * + * An order is read, a decision is made from its status, and then something is + * written. Between the reading and the writing another request may have moved + * the order: a second delivery of the same payment, a second press of the + * same button. So each statement of the write is conditional on the order + * still being in the status that was read. D1 runs a batch as one + * transaction; a batch that arrives second finds the condition false in + * every statement, and writes nothing. + * + * The statement that changes the status goes last in its batch, because the + * others test the status it changes. + */ + +/** Binds the order id, then the status. */ +export const ORDER_STILL_IN_STATUS = + 'EXISTS (SELECT 1 FROM p_shop_order WHERE id = ? AND status = ?)'; + +/** A condition for a WHERE clause, with the values it binds, in order. */ +export interface SqlCondition { + readonly sql: string; + readonly binds: readonly string[]; +} diff --git a/src/plugins/shop/lib/orders.ts b/src/plugins/shop/lib/orders.ts new file mode 100644 index 0000000..ce4d15c --- /dev/null +++ b/src/plugins/shop/lib/orders.ts @@ -0,0 +1,738 @@ +/** + * Orders: placing one, reading one, and confirming its payment. + * + * Three rules shape everything here. + * + * **Stock comes off when payment is confirmed, never before.** Taking it at + * add-to-cart or when the order is placed would let anyone empty the + * catalogue with scripted orders that are never paid. The price of that + * choice is a small chance that two buyers pay for the last unit; the second + * payment is caught below and the order marked `oversold` for a refund. + * + * **A payment is one D1 batch.** Recording the event, taking the stock, + * writing the ledger and moving the order to `paid` commit together or not at + * all. What makes that safe is the `CHECK (stock >= 0)` on the variant: a + * decrement that would oversell fails its statement, and a failed statement + * rolls the whole batch back (`test/shop/schema.test.ts` proves both halves). + * There is no compensation code, because there is never anything to undo. + * + * **What follows a payment is written with it.** The same batch puts a row + * in the outbox (`outbox.ts`). The buyer's email is sent from that row, never + * from the value this module returns: a return value is lost whenever the + * Worker stops, or the answer to the batch does not come back. + */ + +import { changedProductGroups, type StockRow } from './availability.js'; +import type { PricedCart } from './cart-pricing.js'; +import { type Currency, isCurrency } from './currency.js'; +import { sumMinor } from './money.js'; +import { ORDER_STILL_IN_STATUS, type SqlCondition } from './order-guard.js'; +import { + assertTransition, + canTransition, + type OrderStatus, +} from './order-state.js'; +import { orderChangedOutbox, paymentRefusedOutbox } from './outbox.js'; + +/** Thrown by anything asked to act on an order that does not exist. */ +export class OrderNotFoundError extends Error { + readonly orderId: string; + + constructor(orderId: string) { + super(`Order ${orderId} not found`); + this.name = 'OrderNotFoundError'; + this.orderId = orderId; + } +} + +export interface ShippingAddress { + readonly recipient: string; + readonly line1: string; + readonly line2?: string; + readonly city: string; + readonly state?: string; + readonly postalCode: string; + /** ISO 3166-1 alpha-2. */ + readonly country: string; + readonly phone?: string; +} + +type SuccessfulCart = Extract; + +/** The Stripe event that confirms a payment. */ +export const PAYMENT_EVENT_TYPE = 'payment_intent.succeeded'; + +/** + * Crockford's base 32: no I, L, O or U, so a number read over the phone or + * copied from paper is not mistaken for another. + */ +const ORDER_NO_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +/** + * The number a buyer sees: the date and a random suffix, so it says nothing + * about how many orders the shop takes. + * + * Eight characters are forty bits. Six hexadecimal ones, as before, are + * twenty-four: at a thousand orders a day two of them would collide about + * once a month, and a collision is a checkout that fails. + */ +function newOrderNo(now: Date): string { + const date = now.toISOString().slice(2, 10).replace(/-/g, ''); + let suffix = ''; + for (const byte of crypto.getRandomValues(new Uint8Array(8))) { + // 256 is a multiple of 32, so the low five bits are uniform. + suffix += ORDER_NO_ALPHABET.charAt(byte & 31); + } + return `ND-${date}-${suffix}`; +} + +/** + * Places an order that is waiting for payment. + * + * The order and its lines are written in one batch, and the lines travel as + * a single JSON parameter, so the cost is one round trip however long the + * cart is. Each line is a snapshot of the SKU, the name and the unit price at + * this moment. + * + * Stock is not touched (see the note at the top of this file). + */ +export async function createPendingOrder( + db: D1Database, + input: { + readonly cart: SuccessfulCart; + readonly locale: string; + readonly email: string; + readonly shippingAddress: ShippingAddress; + readonly shippingMinor?: number; + readonly taxMinor?: number; + readonly now: Date; + }, +): Promise<{ id: string; orderNo: string; totalMinor: number }> { + const { cart } = input; + if (cart.lines.length === 0) { + throw new Error('An order needs at least one line'); + } + + for (const line of cart.lines) { + // Checked one by one: 99.5 times 10 is a whole number, and a check on + // the product alone would let the half through into a stored price. + if ( + !Number.isInteger(line.unitPriceMinor) || + line.unitPriceMinor < 0 || + !Number.isInteger(line.quantity) || + line.quantity <= 0 + ) { + throw new Error( + `Line ${line.variantId} needs a whole, non-negative unit price in minor units and a whole, positive quantity`, + ); + } + } + + const id = crypto.randomUUID(); + const orderNo = newOrderNo(input.now); + const shippingMinor = input.shippingMinor ?? 0; + const taxMinor = input.taxMinor ?? 0; + // Added up from the lines being written, so an order's subtotal can never + // disagree with its own lines. + const subtotalMinor = sumMinor( + cart.lines.map((line) => line.unitPriceMinor * line.quantity), + ); + const totalMinor = sumMinor([subtotalMinor, shippingMinor, taxMinor]); + const nowIso = input.now.toISOString(); + + await db.batch([ + db + .prepare( + `INSERT INTO p_shop_order + (id, order_no, status, currency, subtotal_minor, shipping_minor, + tax_minor, total_minor, email, shipping_address, locale, + created_at, updated_at) + VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + id, + orderNo, + cart.currency, + subtotalMinor, + shippingMinor, + taxMinor, + totalMinor, + input.email, + JSON.stringify(input.shippingAddress), + input.locale, + nowIso, + nowIso, + ), + db + .prepare( + `INSERT INTO p_shop_order_line + (id, order_id, variant_id, sku_snapshot, name_snapshot, + unit_price_minor, quantity) + SELECT ? || ':' || json_extract(value, '$.v'), ?, + json_extract(value, '$.v'), json_extract(value, '$.s'), + json_extract(value, '$.n'), json_extract(value, '$.p'), + json_extract(value, '$.q') + FROM json_each(?)`, + ) + .bind( + id, + id, + JSON.stringify( + cart.lines.map((line) => ({ + v: line.variantId, + s: line.sku, + n: line.name, + p: line.unitPriceMinor, + q: line.quantity, + })), + ), + ), + ]); + + return { id, orderNo, totalMinor }; +} + +export interface OrderLine { + readonly variantId: string; + readonly sku: string; + readonly name: string; + readonly unitPriceMinor: number; + readonly quantity: number; +} + +export interface OrderDetail { + readonly id: string; + readonly orderNo: string; + readonly status: OrderStatus; + readonly currency: Currency; + readonly subtotalMinor: number; + readonly shippingMinor: number; + readonly taxMinor: number; + readonly totalMinor: number; + readonly email: string; + /** Empty when the stored address cannot be read. */ + readonly shippingAddress: Partial; + readonly locale: string; + readonly trackingNo: string | null; + readonly stripePaymentIntentId: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly lines: readonly OrderLine[]; + /** + * Business days within which the whole order can leave, going by the + * variants' lead times as they stand now; null unless every line states one. + */ + readonly leadTimeDaysMax: number | null; +} + +interface OrderRow { + id: string; + order_no: string; + status: string; + currency: string; + subtotal_minor: number; + shipping_minor: number; + tax_minor: number; + total_minor: number; + email: string; + shipping_address: string; + locale: string; + tracking_no: string | null; + stripe_payment_intent_id: string | null; + created_at: string; + updated_at: string; +} + +interface LineRow { + variant_id: string; + sku_snapshot: string; + name_snapshot: string; + unit_price_minor: number; + quantity: number; +} + +interface LeadTimeRow { + lines: number; + stated: number; + max_days: number | null; +} + +function parseAddress(json: string): Partial { + try { + const value: unknown = JSON.parse(json); + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Partial; + } + } catch { + // A malformed address must not make the order impossible to open. + } + return {}; +} + +/** Reads one order with its lines, by id or by the number the buyer holds. */ +export async function getOrder( + db: D1Database, + by: { readonly id: string } | { readonly orderNo: string }, +): Promise { + // One of two literals, never anything a caller supplied. + const column = 'id' in by ? 'id' : 'order_no'; + const key = 'id' in by ? by.id : by.orderNo; + const orderId = `(SELECT id FROM p_shop_order WHERE ${column} = ?)`; + + const [orderResult, lineResult, leadTimeResult] = await db.batch< + OrderRow | LineRow | LeadTimeRow + >([ + db + .prepare( + `SELECT id, order_no, status, currency, subtotal_minor, shipping_minor, + tax_minor, total_minor, email, shipping_address, locale, + tracking_no, stripe_payment_intent_id, created_at, updated_at + FROM p_shop_order WHERE ${column} = ?`, + ) + .bind(key), + db + .prepare( + `SELECT variant_id, sku_snapshot, name_snapshot, unit_price_minor, + quantity + FROM p_shop_order_line WHERE order_id = ${orderId} + ORDER BY variant_id`, + ) + .bind(key), + db + .prepare( + `SELECT COUNT(*) AS lines, COUNT(v.lead_time_max) AS stated, + MAX(v.lead_time_max) AS max_days + FROM p_shop_order_line AS l + LEFT JOIN p_shop_variant AS v ON v.id = l.variant_id + WHERE l.order_id = ${orderId}`, + ) + .bind(key), + ]); + + const row = (orderResult?.results ?? [])[0] as OrderRow | undefined; + if (row === undefined) { + return null; + } + if (!isCurrency(row.currency)) { + // Formatting money in a currency the shop does not know would print a + // wrong amount. Better that the order cannot be shown than shown wrong. + throw new Error(`Order ${row.id} is in an unknown currency`); + } + const leadTime = (leadTimeResult?.results ?? [])[0] as + | LeadTimeRow + | undefined; + + return { + id: row.id, + orderNo: row.order_no, + status: row.status as OrderStatus, + currency: row.currency, + subtotalMinor: row.subtotal_minor, + shippingMinor: row.shipping_minor, + taxMinor: row.tax_minor, + totalMinor: row.total_minor, + email: row.email, + shippingAddress: parseAddress(row.shipping_address), + locale: row.locale, + trackingNo: row.tracking_no, + stripePaymentIntentId: row.stripe_payment_intent_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + lines: ((lineResult?.results ?? []) as LineRow[]).map((line) => ({ + variantId: line.variant_id, + sku: line.sku_snapshot, + name: line.name_snapshot, + unitPriceMinor: line.unit_price_minor, + quantity: line.quantity, + })), + // The slowest line decides when the order can leave. One line without a + // stated lead time means no promise can be made for the order at all. + leadTimeDaysMax: + leadTime !== undefined && + leadTime.lines > 0 && + leadTime.stated === leadTime.lines + ? leadTime.max_days + : null, + }; +} + +/** + * What a call to `markOrderPaid` did. + * + * - `paid`: this call confirmed the payment and took the stock. + * - `oversold`: this call found the stock gone and marked the order so. + * - `refused`: the order may not be paid — it was cancelled, or another + * payment has settled it. The payment is on record and has to be refunded. + * - `duplicate`: this payment had been dealt with before, in any of the ways + * above. Nothing was written. + */ +export type PaymentOutcome = 'paid' | 'oversold' | 'refused' | 'duplicate'; + +export interface PaymentResult { + readonly outcome: PaymentOutcome; + /** The order's status once this call has returned. */ + readonly status: OrderStatus; + /** + * Products whose availability state this call changed, by product group: + * the ones whose cached pages can be purged at once. Empty unless the + * outcome is `paid`. + * + * A convenience only. What must happen after a payment is in the outbox. + */ + readonly availabilityChanged: readonly string[]; +} + +interface PaymentInput { + readonly orderId: string; + readonly eventId: string; + readonly paymentIntentId: string; + readonly now: Date; +} + +interface PaymentState { + readonly order: { readonly status: OrderStatus } | null; + /** + * This payment is on record already: the same event, or another event of + * the same type about the same payment intent. + */ + readonly seen: boolean; + /** Some stock-tracked line asks for more than there is. */ + readonly shortOfStock: boolean; +} + +/** Binds the order id. */ +const ORDER_SHORT_OF_STOCK = `EXISTS ( + SELECT 1 + FROM p_shop_order_line AS l + JOIN p_shop_variant AS v ON v.id = l.variant_id + WHERE l.order_id = ? AND v.stock_policy = 'track' AND v.stock < l.quantity +)`; + +/** The payment, the order and whether its stock is there, in one round trip. */ +async function readPaymentState( + db: D1Database, + input: PaymentInput, +): Promise { + const [seenResult, orderResult, shortResult] = await db.batch< + Record + >([ + // Stripe delivers an event more than once, and can send two events about + // one object, to be told apart by the object's id and the event's type. + db + .prepare( + `SELECT 1 AS seen FROM p_shop_stripe_event + WHERE event_id = ? OR (type = ? AND payment_intent_id = ?) + LIMIT 1`, + ) + .bind(input.eventId, PAYMENT_EVENT_TYPE, input.paymentIntentId), + db + .prepare('SELECT status FROM p_shop_order WHERE id = ?') + .bind(input.orderId), + db.prepare(`SELECT ${ORDER_SHORT_OF_STOCK} AS short`).bind(input.orderId), + ]); + + const order = (orderResult?.results ?? [])[0] as + | { status: string } + | undefined; + return { + order: order === undefined ? null : { status: order.status as OrderStatus }, + seen: (seenResult?.results ?? []).length > 0, + shortOfStock: (shortResult?.results ?? [])[0]?.short === 1, + }; +} + +function variantsOfOrder(db: D1Database, orderId: string): D1PreparedStatement { + return db + .prepare( + `SELECT v.id, v.product_group, v.stock, v.moq, v.stock_policy + FROM p_shop_variant AS v + WHERE v.id IN ( + SELECT variant_id FROM p_shop_order_line WHERE order_id = ? + ) + ORDER BY v.id`, + ) + .bind(orderId); +} + +/** The record that a payment was acted on, and how. */ +function recordEvent( + db: D1Database, + input: PaymentInput, + outcome: Exclude, + condition: SqlCondition, +): D1PreparedStatement { + return db + .prepare( + `INSERT INTO p_shop_stripe_event + (event_id, type, order_id, payment_intent_id, outcome, processed_at) + SELECT ?, ?, ?, ?, ?, ? WHERE ${condition.sql} + RETURNING event_id`, + ) + .bind( + input.eventId, + PAYMENT_EVENT_TYPE, + input.orderId, + input.paymentIntentId, + outcome, + input.now.toISOString(), + ...condition.binds, + ); +} + +/** + * The payment itself: five writes between two readings of the stock. + * + * Returns null when the order had already left `from` by the time the batch + * ran — a delivery racing this one got there first — in which case nothing + * was written. Throws when a statement failed, which is how a line that + * would oversell shows itself. + */ +async function writePaid( + db: D1Database, + input: PaymentInput, + from: OrderStatus, +): Promise { + const { orderId } = input; + const nowIso = input.now.toISOString(); + const stillInStatus: SqlCondition = { + sql: ORDER_STILL_IN_STATUS, + binds: [orderId, from], + }; + + const results = await db.batch>([ + variantsOfOrder(db, orderId), + // Before any stock moves: should this payment somehow be on record + // already, the record's own keys stop the batch here. + recordEvent(db, input, 'paid', stillInStatus), + // Stock-tracked lines only. A made-to-order variant has no stock to take: + // decrementing its zero would trip the CHECK and turn every such order + // into "oversold". + db + .prepare( + `UPDATE p_shop_variant + SET stock = stock - ( + SELECT l.quantity FROM p_shop_order_line AS l + WHERE l.order_id = ? AND l.variant_id = p_shop_variant.id + ), + updated_at = ? + WHERE stock_policy = 'track' + AND id IN ( + SELECT variant_id FROM p_shop_order_line WHERE order_id = ? + ) + AND ${ORDER_STILL_IN_STATUS}`, + ) + .bind(orderId, nowIso, orderId, orderId, from), + // The ledger row's id is built from the order and the variant, so the + // same movement written twice fails, and takes its batch with it. + db + .prepare( + `INSERT INTO p_shop_stock_adjustment + (id, variant_id, delta, reason, ref_id, created_at) + SELECT 'order_paid:' || l.order_id || ':' || l.variant_id, + l.variant_id, -l.quantity, 'order_paid', l.order_id, ? + FROM p_shop_order_line AS l + JOIN p_shop_variant AS v ON v.id = l.variant_id + WHERE l.order_id = ? AND v.stock_policy = 'track' + AND ${ORDER_STILL_IN_STATUS}`, + ) + .bind(nowIso, orderId, orderId, from), + // What is owed because of this payment, committed with it. + orderChangedOutbox(db, { orderId, from, to: 'paid', now: input.now }), + // Last: every statement above tests the status this one changes. + db + .prepare( + `UPDATE p_shop_order + SET status = 'paid', stripe_payment_intent_id = ?, updated_at = ? + WHERE id = ? AND status = ? + RETURNING id`, + ) + .bind(input.paymentIntentId, nowIso, orderId, from), + variantsOfOrder(db, orderId), + ]); + + if ((results[5]?.results ?? []).length === 0) { + return null; + } + return { + outcome: 'paid', + status: 'paid', + availabilityChanged: changedProductGroups( + (results[0]?.results ?? []) as unknown as StockRow[], + (results[6]?.results ?? []) as unknown as StockRow[], + ), + }; +} + +/** + * Records a payment that cannot be fulfilled. The payment intent is kept on + * the order: it is what a person now has to refund. + * + * Conditional on the stock still being short as well as on the status. Stock + * that came back between the reading and this write — another order's refund + * — means the order can be paid after all, and must not be sent to a person + * for a refund it does not need. + * + * Returns false when either condition no longer held, with nothing written. + */ +async function writeOversold( + db: D1Database, + input: PaymentInput, + from: OrderStatus, +): Promise { + assertTransition(from, 'oversold'); + const { orderId } = input; + const stillShort: SqlCondition = { + sql: ORDER_SHORT_OF_STOCK, + binds: [orderId], + }; + + const results = await db.batch>([ + recordEvent(db, input, 'oversold', { + sql: `${ORDER_STILL_IN_STATUS} AND ${ORDER_SHORT_OF_STOCK}`, + binds: [orderId, from, orderId], + }), + orderChangedOutbox(db, { + orderId, + from, + to: 'oversold', + now: input.now, + andIf: stillShort, + }), + db + .prepare( + `UPDATE p_shop_order + SET status = 'oversold', stripe_payment_intent_id = ?, updated_at = ? + WHERE id = ? AND status = ? AND ${ORDER_SHORT_OF_STOCK} + RETURNING id`, + ) + .bind( + input.paymentIntentId, + input.now.toISOString(), + orderId, + from, + orderId, + ), + ]); + return (results[2]?.results ?? []).length > 0; +} + +/** + * Records a payment for an order that may not be paid, and nothing else: the + * order and the stock are left exactly as they are. + * + * The money has been taken all the same. So it goes on record, with an + * outbox row for a person to refund it, rather than being turned away + * without a trace. + * + * Returns false when the order had already left `from`. + */ +async function writeRefused( + db: D1Database, + input: PaymentInput, + from: OrderStatus, +): Promise { + const results = await db.batch>([ + recordEvent(db, input, 'refused', { + sql: ORDER_STILL_IN_STATUS, + binds: [input.orderId, from], + }), + paymentRefusedOutbox(db, { + orderId: input.orderId, + from, + eventId: input.eventId, + paymentIntentId: input.paymentIntentId, + now: input.now, + }), + ]); + return (results[0]?.results ?? []).length > 0; +} + +type PaymentAction = 'pay' | 'oversold' | 'refuse'; + +/** + * Handles a confirmed payment for an order. + * + * Safe to call any number of times, at any moment, for the same payment: the + * stock is taken once. An order whose stock has gone is marked `oversold` + * with nothing decremented. An order that may not be paid at all is left + * alone, and the payment recorded for a refund. + * + * It works in passes. A pass reads what is true — is the payment on record, + * what status is the order in, is its stock there — and makes one attempt to + * act on it. A write that fails, or finds the order moved, is not explained + * from its error: the next pass reads again and acts on what it finds. That + * is how a line that would oversell becomes `oversold`, and how losing a race + * becomes `duplicate`, without either depending on the wording of an error. + * + * A write that throws is tried once more if the next reading still calls for + * it. Stock can go and come back between a reading and a write — one order + * takes it, another's refund returns it — and the first failure then says + * nothing about the second attempt. The same write throwing twice running is + * a real failure. + * + * Throws `OrderNotFoundError` for an order that does not exist, and rethrows + * a database failure. A caller answering a webhook turns the second into a + * non-2xx response, so that Stripe delivers the event again. + */ +export async function markOrderPaid( + db: D1Database, + input: PaymentInput, +): Promise { + let failed: + | { action: PaymentAction; error: unknown; times: number } + | undefined; + + // Paying can fail into oversold, and oversold can find the stock back and + // return to paying. A third change of mind is not worth waiting for. + for (let pass = 0; pass < 4; pass += 1) { + const state = await readPaymentState(db, input); + if (state.order === null) { + throw new OrderNotFoundError(input.orderId); + } + const from = state.order.status; + if (state.seen) { + return { outcome: 'duplicate', status: from, availabilityChanged: [] }; + } + + const action: PaymentAction = !canTransition(from, 'paid') + ? 'refuse' + : state.shortOfStock + ? 'oversold' + : 'pay'; + // Thrown twice running by the same write: a real failure, to be retried + // by whoever delivered the event. + if (failed?.action === action && failed.times >= 2) { + throw failed.error; + } + + try { + if (action === 'pay') { + const paid = await writePaid(db, input, from); + if (paid !== null) { + return paid; + } + } else if (action === 'oversold') { + if (await writeOversold(db, input, from)) { + return { + outcome: 'oversold', + status: 'oversold', + availabilityChanged: [], + }; + } + } else if (await writeRefused(db, input, from)) { + return { outcome: 'refused', status: from, availabilityChanged: [] }; + } + failed = undefined; + } catch (error) { + failed = { + action, + error, + times: failed?.action === action ? failed.times + 1 : 1, + }; + } + } + + throw ( + failed?.error ?? new Error(`Order ${input.orderId} could not be settled`) + ); +} diff --git a/src/plugins/shop/lib/outbox.ts b/src/plugins/shop/lib/outbox.ts new file mode 100644 index 0000000..09bd20a --- /dev/null +++ b/src/plugins/shop/lib/outbox.ts @@ -0,0 +1,203 @@ +/** + * The outbox: what still has to happen because an order changed. + * + * A payment is followed by an email to the buyer and a purge of the product's + * cached pages. Doing those after the payment's batch has committed leaves a + * gap: if the Worker stops in between — or the answer to the batch is lost on + * the way back — the email is never sent and nothing records that it was + * owed. A later delivery of the same event finds the order paid and has no + * way to tell that the follow-up is still missing. + * + * So the duty is written with the change. Every batch that moves an order + * also inserts a row here, under the same condition as the rest of the batch, + * and whoever carries the follow-up out marks the row handled afterwards. A + * row may be handled twice if the marking is what fails; it cannot be lost. + * + * A row says that something changed, not everything about the change. Which + * products' availability a payment altered is known only to the call that + * made it; a consumer working from the row alone purges every product on the + * order, which is always safe and only sometimes more than was needed. + * + * This module writes the rows and reads them back. Nothing consumes them yet: + * that arrives with the routes. + */ + +import { ORDER_STILL_IN_STATUS, type SqlCondition } from './order-guard.js'; +import type { OrderStatus } from './order-state.js'; + +export type OutboxTopic = + | `order.${Exclude}` + | 'payment.refused'; + +function insertOutbox( + db: D1Database, + row: { + readonly id: string; + readonly topic: OutboxTopic; + readonly orderId: string; + readonly ref: string | null; + readonly now: Date; + }, + condition: SqlCondition, +): D1PreparedStatement { + return db + .prepare( + `INSERT INTO p_shop_outbox (id, topic, order_id, ref, created_at) + SELECT ?, ?, ?, ?, ? WHERE ${condition.sql}`, + ) + .bind( + row.id, + row.topic, + row.orderId, + row.ref, + row.now.toISOString(), + ...condition.binds, + ); +} + +/** + * The outbox row for an order moving from one status to another, to go in + * the same batch as the statement that moves it — and before it, since the + * row is conditional on the status that statement changes. + * + * The row's id is the topic and the order. An order enters a status at most + * once, so the same change cannot be recorded twice. + */ +export function orderChangedOutbox( + db: D1Database, + input: { + readonly orderId: string; + readonly from: OrderStatus; + readonly to: Exclude; + readonly now: Date; + /** Something else that must also hold for the change to be made. */ + readonly andIf?: SqlCondition; + }, +): D1PreparedStatement { + const topic: OutboxTopic = `order.${input.to}`; + return insertOutbox( + db, + { + id: `${topic}:${input.orderId}`, + topic, + orderId: input.orderId, + ref: null, + now: input.now, + }, + { + sql: + input.andIf === undefined + ? ORDER_STILL_IN_STATUS + : `${ORDER_STILL_IN_STATUS} AND ${input.andIf.sql}`, + binds: [input.orderId, input.from, ...(input.andIf?.binds ?? [])], + }, + ); +} + +/** + * The outbox row for a payment an order could not take: money has been + * received and has to be refunded by a person. Keyed by the event, since an + * order can be sent more than one such payment. + */ +export function paymentRefusedOutbox( + db: D1Database, + input: { + readonly orderId: string; + /** The status the order was in, and must still be in, to refuse. */ + readonly from: OrderStatus; + readonly eventId: string; + readonly paymentIntentId: string; + readonly now: Date; + }, +): D1PreparedStatement { + return insertOutbox( + db, + { + id: `payment.refused:${input.eventId}`, + topic: 'payment.refused', + orderId: input.orderId, + ref: input.paymentIntentId, + now: input.now, + }, + { sql: ORDER_STILL_IN_STATUS, binds: [input.orderId, input.from] }, + ); +} + +export interface OutboxRow { + readonly id: string; + readonly topic: OutboxTopic; + readonly orderId: string; + readonly ref: string | null; + readonly createdAt: string; +} + +/** + * The rows still owed, in the order they were committed — all of them, or + * one order's. + * + * That order is what a consumer must keep: an order's "shipped" is handed + * over before its "delivered". It comes from the table's own sequence, not + * from `createdAt`, which is whatever time the change was made with. + * + * Bounded, because the place this will be drained from is the site's shared + * once-a-minute cron. + */ +export async function pendingOutbox( + db: D1Database, + input: { readonly limit: number; readonly orderId?: string }, +): Promise { + const statement = + input.orderId === undefined + ? db + .prepare( + `SELECT id, topic, order_id, ref, created_at FROM p_shop_outbox + WHERE handled_at IS NULL + ORDER BY seq LIMIT ?`, + ) + .bind(input.limit) + : db + .prepare( + `SELECT id, topic, order_id, ref, created_at FROM p_shop_outbox + WHERE handled_at IS NULL AND order_id = ? + ORDER BY seq LIMIT ?`, + ) + .bind(input.orderId, input.limit); + + const { results } = await statement.all<{ + id: string; + topic: string; + order_id: string; + ref: string | null; + created_at: string; + }>(); + return results.map((row) => ({ + id: row.id, + topic: row.topic as OutboxTopic, + orderId: row.order_id, + ref: row.ref, + createdAt: row.created_at, + })); +} + +/** + * Marks rows handled, once what follows from them has been handed over. + * + * A row already handled keeps the time it was first handled. + */ +export async function markOutboxHandled( + db: D1Database, + ids: readonly string[], + now: Date, +): Promise { + if (ids.length === 0) { + return; + } + await db + .prepare( + `UPDATE p_shop_outbox SET handled_at = ? + WHERE handled_at IS NULL + AND id IN (SELECT value FROM json_each(?))`, + ) + .bind(now.toISOString(), JSON.stringify(ids)) + .run(); +} diff --git a/src/plugins/shop/lib/stripe-client.ts b/src/plugins/shop/lib/stripe-client.ts new file mode 100644 index 0000000..b223098 --- /dev/null +++ b/src/plugins/shop/lib/stripe-client.ts @@ -0,0 +1,185 @@ +/** + * The two Stripe calls the shop makes, over `fetch`, with no SDK. + * + * Opening a hosted Checkout session and refunding a payment are the whole + * surface. Calling the REST API directly keeps a vendor SDK out of the Worker, + * which matters twice over for code other people deploy as their own. + */ + +import type { Currency } from './currency.js'; + +const API_BASE = 'https://api.stripe.com/v1'; + +interface StripeErrorBody { + readonly error?: { + readonly type?: unknown; + readonly code?: unknown; + readonly param?: unknown; + }; +} + +/** + * One of Stripe's own identifiers — an error type, an error code, a parameter + * name — or nothing. Anything that is not shaped like an identifier is left + * out rather than passed on. + */ +function identifier(value: unknown): string | null { + return typeof value === 'string' && /^[\w.[\]-]{1,100}$/.test(value) + ? value + : null; +} + +/** + * What went wrong, for the logs, built only from Stripe's identifiers. + * + * Never from the free-text message. Stripe writes it for a person, nothing + * says it will not repeat a value that was sent, and one of the values sent + * is a buyer's email address. Never the key, and never the whole response. + */ +function describeFailure(payload: unknown, status: number): string { + const error = (payload as StripeErrorBody | null)?.error; + const parts = [ + identifier(error?.type), + identifier(error?.code), + identifier(error?.param), + ].filter((part) => part !== null); + return `Stripe: ${[...parts, `status ${status}`].join(', ')}`; +} + +async function stripeRequest( + secretKey: string, + path: string, + body: Readonly>, + idempotencyKey: string, + fetchImpl: typeof fetch, +): Promise { + const response = await fetchImpl(`${API_BASE}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${secretKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', + // What stops a retried request from charging, or refunding, twice. + 'Idempotency-Key': idempotencyKey, + }, + body: new URLSearchParams(body).toString(), + }); + + // A proxy's outage page is HTML, not Stripe's JSON; that is a failure to + // report, not a parse error to crash on. + const payload: unknown = await response.json().catch(() => null); + + if (!response.ok || payload === null) { + throw new Error(describeFailure(payload, response.status)); + } + + return payload as T; +} + +export interface CheckoutSession { + readonly id: string; + /** Where to send the buyer to pay. */ + readonly url: string; +} + +/** + * Opens a Stripe-hosted Checkout session for an order. + * + * The hosted page needs no client-side code, and card numbers never reach + * this Worker. The cost is that the buyer leaves the site to pay. + * + * The amount is the order's total as the server computed it — one line item, + * so nothing a client sent can take part in what is charged. + * + * The order id goes into `payment_intent_data.metadata` as well as the + * session's own metadata. The webhook receives `payment_intent.succeeded`, + * and the intent's metadata is the only way it can find the order. + */ +export async function createCheckoutSession( + secretKey: string, + input: { + readonly amountMinor: number; + readonly currency: Currency; + readonly orderId: string; + readonly orderNo: string; + readonly productName: string; + readonly successUrl: string; + readonly cancelUrl: string; + readonly customerEmail?: string; + }, + fetchImpl: typeof fetch = fetch, +): Promise { + // Stripe is never asked to charge nothing. A session for a total of zero + // creates no payment intent, so `payment_intent.succeeded` would never + // arrive and the order would wait for ever. An order that costs nothing is + // confirmed without Stripe, by whoever calls this. + if (!Number.isInteger(input.amountMinor) || input.amountMinor <= 0) { + throw new Error( + 'A Checkout session needs a positive amount in whole minor units', + ); + } + + const body: Record = { + mode: 'payment', + 'line_items[0][quantity]': '1', + 'line_items[0][price_data][currency]': input.currency.toLowerCase(), + 'line_items[0][price_data][unit_amount]': String(input.amountMinor), + 'line_items[0][price_data][product_data][name]': input.productName, + success_url: input.successUrl, + cancel_url: input.cancelUrl, + 'metadata[order_id]': input.orderId, + 'metadata[order_no]': input.orderNo, + 'payment_intent_data[metadata][order_id]': input.orderId, + 'payment_intent_data[metadata][order_no]': input.orderNo, + }; + + if (input.customerEmail !== undefined) { + body.customer_email = input.customerEmail; + } + + return stripeRequest( + secretKey, + '/checkout/sessions', + body, + // A double submit of the same order reuses the session. + `checkout:${input.orderId}`, + fetchImpl, + ); +} + +export interface Refund { + readonly id: string; + readonly status: string; +} + +/** The reasons Stripe accepts for a refund. */ +export type RefundReason = 'duplicate' | 'fraudulent' | 'requested_by_customer'; + +/** + * Refunds a payment. No amount is sent, so the whole payment is refunded. + * + * Keyed on the payment intent: asking twice, because the first answer was + * lost on the way back, must not refund twice. + */ +export async function createRefund( + secretKey: string, + input: { + readonly paymentIntentId: string; + readonly reason?: RefundReason; + }, + fetchImpl: typeof fetch = fetch, +): Promise { + const body: Record = { + payment_intent: input.paymentIntentId, + }; + if (input.reason !== undefined) { + body.reason = input.reason; + } + + return stripeRequest( + secretKey, + '/refunds', + body, + `refund:${input.paymentIntentId}`, + fetchImpl, + ); +} diff --git a/src/plugins/shop/lib/stripe-signature.ts b/src/plugins/shop/lib/stripe-signature.ts new file mode 100644 index 0000000..4985507 --- /dev/null +++ b/src/plugins/shop/lib/stripe-signature.ts @@ -0,0 +1,118 @@ +/** + * Stripe webhook signature verification, on WebCrypto, without the Stripe SDK. + * + * The `Stripe-Signature` header has the form `t=,v1=`. + * The signed payload is `.`, and the algorithm is + * HMAC-SHA256 keyed by the endpoint's signing secret. + * + * Verification is the webhook's whole reason to exist: without it, anyone can + * post "payment succeeded" and be sent goods for free. The timestamp is part + * of what is signed, and the window around it is what limits a replay. + * + * It needs the body exactly as Stripe sent it. Parsing the JSON and writing + * it out again changes the bytes, and the signature no longer matches. + */ + +/** Stripe's own libraries allow five minutes between signing and receipt. */ +const TOLERANCE_SECONDS = 5 * 60; + +export type VerificationResult = + | { readonly ok: true; readonly timestamp: number } + | { readonly ok: false; readonly reason: string }; + +function parseHeader(header: string): { + timestamp: number | null; + signatures: string[]; +} { + let timestamp: number | null = null; + const signatures: string[] = []; + + for (const part of header.split(',')) { + const separator = part.indexOf('='); + if (separator === -1) { + continue; + } + const key = part.slice(0, separator).trim(); + const value = part.slice(separator + 1).trim(); + if (value === '') { + continue; + } + if (key === 't') { + // Digits and nothing else. `Number` alone would also read `1e9`, `0x10` + // and `123.0` as integers. + timestamp = /^\d{1,15}$/.test(value) ? Number(value) : null; + } else if (key === 'v1') { + // Only `v1` is read. Stripe adds a `v0` signature to test events, and + // accepting any other scheme would be a downgrade. + signatures.push(value); + } + } + + return { timestamp, signatures }; +} + +async function hmacHex(secret: string, payload: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const mac = await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(payload), + ); + return [...new Uint8Array(mac)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +/** Constant-time, so timing cannot be used to recover the signature. */ +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let diff = 0; + for (let index = 0; index < a.length; index += 1) { + diff |= a.charCodeAt(index) ^ b.charCodeAt(index); + } + return diff === 0; +} + +export async function verifyStripeSignature( + rawBody: string, + signatureHeader: string, + secret: string, + nowSeconds: number = Math.floor(Date.now() / 1000), +): Promise { + // An unset secret must refuse everything, and say so. WebCrypto throws on + // a key of length zero; were it ever to accept one, the MAC for an empty + // key is a value anyone can compute. + if (secret === '') { + return { ok: false, reason: 'No webhook secret configured' }; + } + + const { timestamp, signatures } = parseHeader(signatureHeader); + + if (timestamp === null || signatures.length === 0) { + return { ok: false, reason: 'Malformed Stripe-Signature header' }; + } + + if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) { + return { ok: false, reason: 'Signature timestamp outside tolerance' }; + } + + const expected = await hmacHex(secret, `${timestamp}.${rawBody}`); + + // While a secret is being rolled Stripe sends one `v1` signature per active + // secret; a match on any of them passes. + const matched = signatures.some((candidate) => + timingSafeEqual(candidate, expected), + ); + + return matched + ? { ok: true, timestamp } + : { ok: false, reason: 'Signature mismatch' }; +} diff --git a/src/plugins/shop/lib/stripe-webhook.ts b/src/plugins/shop/lib/stripe-webhook.ts new file mode 100644 index 0000000..08a5d6b --- /dev/null +++ b/src/plugins/shop/lib/stripe-webhook.ts @@ -0,0 +1,213 @@ +/** + * What the shop does with a delivery from Stripe. + * + * The webhook is the only trustworthy word on whether an order was paid. A + * browser closes, or a connection drops, right after payment all the time, so + * the buyer coming back to a "thank you" page proves nothing. + * + * This is the decision and nothing else: it takes the raw body, the signature + * header and the signing secret, and says what happened and which HTTP status + * to answer with. A route reads the request, calls this, and answers. + * + * **The status is chosen by one question: could delivering this again change + * the result?** Stripe delivers an event again, for up to three days, + * whenever the answer is not a 2xx. A database that could not be reached is + * worth another try, and answers 5xx. Nothing else is: an event this shop + * does not act on, a payment for an order it does not have, a payment for an + * order that was cancelled. Those answer 200, and what a person has to do + * about them is written down — in the outbox, or in the outcome a route logs + * — rather than signalled by failing for three days. + * + * **What follows a payment is not decided from the outcome returned here.** + * The email to the buyer is owed from the outbox row the payment's own batch + * wrote. A route drains the order's outbox after any outcome that names an + * order, `duplicate` included: a duplicate is what a redelivery looks like + * after a first attempt that paid the order and then died. + */ + +import { z } from 'zod'; +import type { OrderStatus } from './order-state.js'; +import { + markOrderPaid, + OrderNotFoundError, + PAYMENT_EVENT_TYPE, +} from './orders.js'; +import { verifyStripeSignature } from './stripe-signature.js'; + +export type WebhookOutcome = + /** Not from Stripe, or not readable. Nothing was done. */ + | { readonly status: 400; readonly kind: 'rejected'; readonly reason: string } + /** Genuine, and nothing this shop acts on. */ + | { readonly status: 200; readonly kind: 'ignored'; readonly reason: string } + /** + * A payment naming an order this database does not have: made through + * another shop on the same Stripe account, or for an order that was lost. + * Worth a warning in the logs, and no retry could find the order. + */ + | { + readonly status: 200; + readonly kind: 'unmatched'; + readonly orderId: string; + } + /** The order was paid by this delivery. */ + | { + readonly status: 200; + readonly kind: 'paid'; + readonly orderId: string; + readonly availabilityChanged: readonly string[]; + } + /** Paid for, and the stock had gone. A person has to refund it. */ + | { + readonly status: 200; + readonly kind: 'oversold'; + readonly orderId: string; + } + /** + * The order could not take a payment: it was cancelled, or another payment + * had settled it. The payment is on record for a person to refund. + */ + | { + readonly status: 200; + readonly kind: 'refused'; + readonly orderId: string; + readonly orderStatus: OrderStatus; + } + /** This payment had been dealt with before. */ + | { + readonly status: 200; + readonly kind: 'duplicate'; + readonly orderId: string; + readonly orderStatus: OrderStatus; + } + /** Could not be processed now; Stripe should deliver it again. */ + | { + readonly status: 500; + readonly kind: 'failed'; + readonly orderId: string; + readonly reason: string; + }; + +const envelopeSchema = z.object({ + id: z.string().min(1).max(255), + type: z.string().min(1).max(255), +}); + +const paymentSchema = z.object({ + data: z.object({ + object: z.object({ + id: z.string().min(1).max(255), + metadata: z.record(z.string(), z.string()).nullish(), + }), + }), +}); + +const MALFORMED: WebhookOutcome = { + status: 400, + kind: 'rejected', + reason: 'Malformed payload', +}; + +export async function handleStripeWebhook( + db: D1Database, + input: { + /** The body exactly as received. Never a parsed and re-serialised copy. */ + readonly rawBody: string; + /** The `Stripe-Signature` header; empty when the request had none. */ + readonly signatureHeader: string; + readonly secret: string; + readonly now: Date; + }, +): Promise { + const verification = await verifyStripeSignature( + input.rawBody, + input.signatureHeader, + input.secret, + Math.floor(input.now.getTime() / 1000), + ); + if (!verification.ok) { + return { status: 400, kind: 'rejected', reason: verification.reason }; + } + + let json: unknown; + try { + json = JSON.parse(input.rawBody); + } catch { + return MALFORMED; + } + const envelope = envelopeSchema.safeParse(json); + if (!envelope.success) { + return MALFORMED; + } + + if (envelope.data.type !== PAYMENT_EVENT_TYPE) { + return { status: 200, kind: 'ignored', reason: 'Event type not handled' }; + } + + // A payment event that does not carry a payment is not something to wave + // through: it would be a real payment swallowed in silence. Refusing it + // keeps it in Stripe's list of failed deliveries, where it can be seen. + const payment = paymentSchema.safeParse(json); + if (!payment.success) { + return MALFORMED; + } + const orderId = payment.data.data.object.metadata?.order_id ?? ''; + if (orderId === '') { + // A payment with no order id was not made through this shop's checkout: + // the same Stripe account may also take payments for an invoice or a + // payment link. It is not a failure, and retrying would never change it. + return { + status: 200, + kind: 'ignored', + reason: 'Payment carries no order id', + }; + } + + try { + const result = await markOrderPaid(db, { + orderId, + eventId: envelope.data.id, + paymentIntentId: payment.data.data.object.id, + now: input.now, + }); + + switch (result.outcome) { + case 'paid': + return { + status: 200, + kind: 'paid', + orderId, + availabilityChanged: result.availabilityChanged, + }; + case 'oversold': + // The money has been taken and the goods are not there. Delivering + // the event again cannot make stock appear. + return { status: 200, kind: 'oversold', orderId }; + case 'refused': + return { + status: 200, + kind: 'refused', + orderId, + orderStatus: result.status, + }; + case 'duplicate': + return { + status: 200, + kind: 'duplicate', + orderId, + orderStatus: result.status, + }; + } + } catch (error) { + if (error instanceof OrderNotFoundError) { + return { status: 200, kind: 'unmatched', orderId }; + } + // The reason is for the logs. It names the order by id and never carries + // an email address or a postal address. + return { + status: 500, + kind: 'failed', + orderId, + reason: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/plugins/shop/migrations/0001_shop.sql b/src/plugins/shop/migrations/0001_shop.sql index 652856e..c364eef 100644 --- a/src/plugins/shop/migrations/0001_shop.sql +++ b/src/plugins/shop/migrations/0001_shop.sql @@ -7,6 +7,10 @@ -- Mallok's migrator drops whole-line comments and then splits on semicolons, -- so a comment must have a line to itself and never trail a line of SQL. -- Every statement is idempotent. +-- +-- Prices and quantities check their own storage type. SQLite keeps a value +-- such as 99.5 in an INTEGER column as a real number rather than refuse it, +-- and a price here is integer minor units or it is nothing. CREATE TABLE IF NOT EXISTS p_shop_variant ( id TEXT PRIMARY KEY, @@ -16,14 +20,16 @@ CREATE TABLE IF NOT EXISTS p_shop_variant ( option_values TEXT NOT NULL DEFAULT '{}', -- The minimum order quantity. Enforced on the page, at add-to-cart and at -- checkout, never display copy alone - moq INTEGER NOT NULL DEFAULT 1 CHECK (moq >= 1), + moq INTEGER NOT NULL DEFAULT 1 + CHECK (typeof(moq) = 'integer' AND moq >= 1), -- Lead time as a range of business days. Null when it is not stated lead_time_min INTEGER, lead_time_max INTEGER, -- The CHECK is what lets a payment's stock decrement share one D1 batch: a -- decrement that would go negative fails its statement and rolls the whole -- batch back - stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0), + stock INTEGER NOT NULL DEFAULT 0 + CHECK (typeof(stock) = 'integer' AND stock >= 0), -- track: stock is a hard limit. made_to_order: always orderable, and the -- lead time is what the buyer is told stock_policy TEXT NOT NULL DEFAULT 'track' @@ -43,7 +49,8 @@ CREATE TABLE IF NOT EXISTS p_shop_price ( variant_id TEXT NOT NULL, currency TEXT NOT NULL, -- Integer minor units, never a float - amount_minor INTEGER NOT NULL CHECK (amount_minor >= 0), + amount_minor INTEGER NOT NULL + CHECK (typeof(amount_minor) = 'integer' AND amount_minor >= 0), -- base: entered by hand in the base currency. auto: derived from the base -- price at an exchange rate. manual: entered by hand for this currency, and -- never recomputed @@ -81,7 +88,8 @@ CREATE INDEX IF NOT EXISTS p_shop_cart_expiry ON p_shop_cart (expires_at); CREATE TABLE IF NOT EXISTS p_shop_cart_line ( cart_id TEXT NOT NULL, variant_id TEXT NOT NULL, - quantity INTEGER NOT NULL CHECK (quantity > 0), + quantity INTEGER NOT NULL + CHECK (typeof(quantity) = 'integer' AND quantity > 0), PRIMARY KEY (cart_id, variant_id) ); diff --git a/src/plugins/shop/migrations/0002_orders.sql b/src/plugins/shop/migrations/0002_orders.sql new file mode 100644 index 0000000..e39d006 --- /dev/null +++ b/src/plugins/shop/migrations/0002_orders.sql @@ -0,0 +1,143 @@ +-- Shop plugin: orders, their lines, payment events, the stock ledger and the +-- outbox. +-- +-- Mallok's migrator drops whole-line comments and then splits on semicolons, +-- so a comment must have a line to itself, and none here contains a +-- semicolon. Every statement is idempotent. +-- +-- Money columns check their own storage type. SQLite keeps a value such as +-- 99.5 in an INTEGER column as a real number rather than refuse it, and money +-- here is integer minor units or it is nothing. + +CREATE TABLE IF NOT EXISTS p_shop_order ( + id TEXT PRIMARY KEY, + -- The number a buyer sees and quotes: a date and a random suffix, so it + -- says nothing about how many orders the shop takes + order_no TEXT NOT NULL UNIQUE, + -- pending | paid | shipped | delivered | cancelled | refunded | oversold. + -- Deliberately not a CHECK: the order state machine is the one place that + -- decides which statuses exist and which moves are legal, and a CHECK could + -- only be changed later by rebuilding this table + status TEXT NOT NULL DEFAULT 'pending', + currency TEXT NOT NULL, + subtotal_minor INTEGER NOT NULL + CHECK (typeof(subtotal_minor) = 'integer' AND subtotal_minor >= 0), + shipping_minor INTEGER NOT NULL DEFAULT 0 + CHECK (typeof(shipping_minor) = 'integer' AND shipping_minor >= 0), + tax_minor INTEGER NOT NULL DEFAULT 0 + CHECK (typeof(tax_minor) = 'integer' AND tax_minor >= 0), + -- What is charged. It is the sum of its parts or the row is refused + total_minor INTEGER NOT NULL + CHECK (typeof(total_minor) = 'integer' + AND total_minor = subtotal_minor + shipping_minor + tax_minor), + stripe_session_id TEXT, + -- The payment that settled the order, which is also what a refund names. + -- Unique, so a payment that settled one order can never settle another. + -- Orders still waiting for payment hold NULL, which does not collide + stripe_payment_intent_id TEXT UNIQUE, + -- The only channel an order notification has + email TEXT NOT NULL, + -- JSON object: recipient, line1, line2, city, state, postalCode, country, + -- phone + shipping_address TEXT NOT NULL, + -- The language the order was placed in, so the buyer is written to in it + locale TEXT NOT NULL, + tracking_no TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS p_shop_order_status + ON p_shop_order (status, created_at); + +-- An order line is a snapshot. Renaming, repricing or delisting the product +-- later must not change what a past order says was bought, or for how much +CREATE TABLE IF NOT EXISTS p_shop_order_line ( + -- ':' + id TEXT PRIMARY KEY, + order_id TEXT NOT NULL, + variant_id TEXT NOT NULL, + sku_snapshot TEXT NOT NULL, + name_snapshot TEXT NOT NULL, + unit_price_minor INTEGER NOT NULL + CHECK (typeof(unit_price_minor) = 'integer' AND unit_price_minor >= 0), + quantity INTEGER NOT NULL + CHECK (typeof(quantity) = 'integer' AND quantity > 0), + -- One line per variant. The payment write reads a line's quantity by this + -- pair and relies on there being exactly one + UNIQUE (order_id, variant_id) +); + +-- Every payment event acted on, and what was done about it. Stripe delivers +-- an event more than once, and acting on one twice would take the stock twice +CREATE TABLE IF NOT EXISTS p_shop_stripe_event ( + event_id TEXT PRIMARY KEY, + type TEXT NOT NULL, + order_id TEXT NOT NULL, + payment_intent_id TEXT NOT NULL, + -- paid: the order was paid. oversold: paid for, and the stock had gone. + -- refused: the order could not take a payment, so this one has to be + -- refunded by a person + outcome TEXT NOT NULL, + processed_at TEXT NOT NULL, + -- Stripe can send two events about one object, to be told apart by the + -- object's id and the event's type. One payment is acted on once + UNIQUE (type, payment_intent_id) +); + +CREATE INDEX IF NOT EXISTS p_shop_stripe_event_order + ON p_shop_stripe_event (order_id); + +-- The stock ledger: every change to a variant's stock, and why +CREATE TABLE IF NOT EXISTS p_shop_stock_adjustment ( + -- For a movement an order causes: '::'. Built + -- from what the row records, so the same movement cannot be written twice + id TEXT PRIMARY KEY, + variant_id TEXT NOT NULL, + delta INTEGER NOT NULL CHECK (typeof(delta) = 'integer' AND delta <> 0), + -- order_paid | refund | manual + reason TEXT NOT NULL, + -- The order the movement belongs to. NULL for a manual correction + ref_id TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS p_shop_stock_adjustment_ref + ON p_shop_stock_adjustment (ref_id, reason); + +CREATE INDEX IF NOT EXISTS p_shop_stock_adjustment_variant + ON p_shop_stock_adjustment (variant_id, created_at); + +-- The outbox: what still has to happen because something changed. The +-- buyer's email, the purge of a product's cached pages, a person told about a +-- payment to refund. +-- +-- A row is written in the same batch as the change it follows from, so the +-- change and the duty to follow it up cannot come apart. Sending an email +-- after the batch instead would lose it whenever the Worker stopped in +-- between, and nothing would know it was owed +CREATE TABLE IF NOT EXISTS p_shop_outbox ( + -- The order the rows were committed in, which is the order to carry them + -- out in: "shipped" before "delivered". A timestamp cannot say this. It is + -- supplied by whoever makes the change, and two changes can carry the same + -- one + seq INTEGER PRIMARY KEY AUTOINCREMENT, + -- ':' for a change of status, which an order goes through + -- at most once. ':' for a refused payment. Built from what + -- the row records, so the same duty cannot be written twice + id TEXT NOT NULL UNIQUE, + -- order.paid | order.oversold | order.shipped | order.delivered | + -- order.cancelled | order.refunded | payment.refused + topic TEXT NOT NULL, + order_id TEXT NOT NULL, + -- For payment.refused: the payment intent that has to be refunded + ref TEXT, + created_at TEXT NOT NULL, + -- NULL until everything that follows from the row has been handed over + handled_at TEXT +); + +-- Only the rows still owed, so finding them stays cheap however long the +-- shop has been running +CREATE INDEX IF NOT EXISTS p_shop_outbox_pending + ON p_shop_outbox (seq) WHERE handled_at IS NULL; diff --git a/test/shop/availability.test.ts b/test/shop/availability.test.ts new file mode 100644 index 0000000..edb54eb --- /dev/null +++ b/test/shop/availability.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + availabilityOf, + changedProductGroups, + type StockRow, +} from '../../src/plugins/shop/lib/availability.js'; +import { quantityIssue } from '../../src/plugins/shop/lib/cart-pricing.js'; + +function row(overrides: Partial = {}): StockRow { + return { + id: 'v1', + product_group: 'group-a', + stock: 100, + moq: 10, + stock_policy: 'track', + ...overrides, + }; +} + +describe('availabilityOf', () => { + it('is in stock while at least one minimum order can be filled', () => { + expect(availabilityOf(row({ stock: 10, moq: 10 }))).toBe('in_stock'); + expect(availabilityOf(row({ stock: 500, moq: 10 }))).toBe('in_stock'); + }); + + it('is out of stock once a minimum order cannot be filled', () => { + // Five left with a minimum order of ten cannot be bought, so saying "in + // stock" would send the buyer to a cart that refuses them. + expect(availabilityOf(row({ stock: 5, moq: 10 }))).toBe('out_of_stock'); + expect(availabilityOf(row({ stock: 0, moq: 1 }))).toBe('out_of_stock'); + }); + + it('is made to order whatever the stock says', () => { + expect( + availabilityOf(row({ stock: 0, stock_policy: 'made_to_order' })), + ).toBe('made_to_order'); + expect( + availabilityOf(row({ stock: 999, stock_policy: 'made_to_order' })), + ).toBe('made_to_order'); + }); + + it('never disagrees with what the cart will accept', () => { + // The page states availability; the cart enforces it. They are two + // functions, so this pins them to one rule: a variant is out of stock + // exactly when its own minimum order is refused for lack of stock. + for (const stock_policy of ['track', 'made_to_order']) { + for (const moq of [1, 2, 10, 50]) { + for (const stock of [0, 1, 9, 10, 11, 49, 50, 51, 1000]) { + const variant = { + ...row({ stock, moq, stock_policy }), + sku: 'SKU', + status: 'active', + }; + const refused = + quantityIssue(variant, moq)?.kind === 'insufficient_stock'; + + expect( + availabilityOf(variant) === 'out_of_stock', + `${stock_policy}, stock ${stock}, minimum order ${moq}`, + ).toBe(refused); + } + } + } + }); +}); + +describe('changedProductGroups', () => { + it('is empty when every variant is as available as it was', () => { + const before = [ + row({ id: 'v1', stock: 100 }), + row({ id: 'v2', stock: 50 }), + ]; + const after = [row({ id: 'v1', stock: 90 }), row({ id: 'v2', stock: 40 })]; + + expect(changedProductGroups(before, after)).toEqual([]); + }); + + it('names the product whose variant crossed the line', () => { + const before = [ + row({ id: 'v1', product_group: 'group-a', stock: 15 }), + row({ id: 'v2', product_group: 'group-b', stock: 100 }), + ]; + const after = [ + row({ id: 'v1', product_group: 'group-a', stock: 5 }), + row({ id: 'v2', product_group: 'group-b', stock: 90 }), + ]; + + expect(changedProductGroups(before, after)).toEqual(['group-a']); + }); + + it('names a product once however many of its variants changed', () => { + const before = [row({ id: 'v1', stock: 10 }), row({ id: 'v2', stock: 10 })]; + const after = [row({ id: 'v1', stock: 0 }), row({ id: 'v2', stock: 0 })]; + + expect(changedProductGroups(before, after)).toEqual(['group-a']); + }); + + it('sees a product come back as well as go', () => { + const before = [row({ stock: 0 })]; + const after = [row({ stock: 10 })]; + + expect(changedProductGroups(before, after)).toEqual(['group-a']); + }); + + it('ignores a made-to-order variant, whose stock says nothing', () => { + const before = [row({ stock: 10, stock_policy: 'made_to_order' })]; + const after = [row({ stock: 0, stock_policy: 'made_to_order' })]; + + expect(changedProductGroups(before, after)).toEqual([]); + }); +}); diff --git a/test/shop/helpers.ts b/test/shop/helpers.ts index 355a981..57f1b95 100644 --- a/test/shop/helpers.ts +++ b/test/shop/helpers.ts @@ -9,6 +9,7 @@ */ import { env, SELF } from 'cloudflare:test'; +import { vi } from 'vitest'; export const ORIGIN = 'https://shop-test.example'; @@ -263,6 +264,11 @@ export async function setRate( export async function clearShopTables(): Promise { await db().batch( [ + 'p_shop_outbox', + 'p_shop_stock_adjustment', + 'p_shop_stripe_event', + 'p_shop_order_line', + 'p_shop_order', 'p_shop_cart_line', 'p_shop_cart', 'p_shop_price', @@ -272,3 +278,130 @@ export async function clearShopTables(): Promise { ].map((table) => db().prepare(`DELETE FROM ${table}`)), ); } + +export async function stockOf(variantId: string): Promise { + const row = await db() + .prepare('SELECT stock FROM p_shop_variant WHERE id = ?') + .bind(variantId) + .first<{ stock: number }>(); + return row?.stock ?? null; +} + +export async function setStock( + variantId: string, + stock: number, +): Promise { + await db() + .prepare('UPDATE p_shop_variant SET stock = ? WHERE id = ?') + .bind(stock, variantId) + .run(); +} + +/** + * A database that is the real one, except at the moments a test names. + * + * `before` and `after` run around each `batch`, counted from zero. A test + * uses them to do what cannot be arranged from outside: change the data + * between a function's reading and its writing, or make a write that has + * committed look as if its answer never came back. + */ +export function interceptBatches(hooks: { + readonly before?: (index: number) => Promise | void; + readonly after?: (index: number) => Promise | void; +}): D1Database { + const real = db(); + let index = 0; + return { + prepare: (sql: string) => real.prepare(sql), + batch: async (statements: D1PreparedStatement[]) => { + const current = index; + index += 1; + await hooks.before?.(current); + const results = await real.batch(statements); + await hooks.after?.(current); + return results; + }, + } as unknown as D1Database; +} + +export interface OutboxEntry { + id: string; + topic: string; + order_id: string; + ref: string | null; + handled_at: string | null; +} + +/** Every outbox row, in the order it was committed. */ +export async function outboxRows(): Promise { + const { results } = await db() + .prepare( + `SELECT id, topic, order_id, ref, handled_at FROM p_shop_outbox + ORDER BY seq`, + ) + .all(); + return results; +} + +/** + * Runs `work` and returns how many calls reached D1 while it ran. + * + * Counted on the prototypes the real objects share: a statement's own + * execution, and the database's batch. A batch is one call however many + * statements it carries, which is the unit a round trip is measured in. + */ +export async function countD1Calls( + work: () => Promise, +): Promise { + const statementPrototype = Object.getPrototypeOf( + db().prepare('SELECT 1'), + ) as D1PreparedStatement; + const databasePrototype = Object.getPrototypeOf(db()) as D1Database; + const spies = [ + vi.spyOn(databasePrototype, 'batch'), + vi.spyOn(statementPrototype, 'run'), + vi.spyOn(statementPrototype, 'all'), + vi.spyOn(statementPrototype, 'first'), + ]; + try { + await work(); + return spies.reduce((total, spy) => total + spy.mock.calls.length, 0); + } finally { + for (const spy of spies) { + spy.mockRestore(); + } + } +} + +/** + * Signs a webhook body the way Stripe does: HMAC-SHA256 over + * `.`, keyed by the endpoint's signing secret, as hex. + * + * Written out here rather than borrowed from the code under test, so a + * mistake in the verifier cannot be mirrored by the signer. + * + * An empty secret is signed with a single zero byte. HMAC pads a short key + * with zero bytes, so that is the same MAC an empty key gives — the one an + * attacker would compute against a shop with no secret set — and WebCrypto + * refuses to import a key of length zero. + */ +export async function signStripePayload( + payload: string, + timestamp: number, + secret: string, +): Promise { + const mac = new Uint8Array( + await crypto.subtle.sign( + 'HMAC', + await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret === '' ? '\u0000' : secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ), + new TextEncoder().encode(`${timestamp}.${payload}`), + ), + ); + return [...mac].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/test/shop/order-email.test.ts b/test/shop/order-email.test.ts new file mode 100644 index 0000000..dd3dfab --- /dev/null +++ b/test/shop/order-email.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { + orderConfirmationEmail, + shippingNotificationEmail, +} from '../../src/plugins/shop/lib/order-email.js'; + +const ORDER = { + orderNo: 'ND-261005-7K3M9QXA', + currency: 'USD' as const, + totalMinor: 99_000, + locale: 'en', + lines: [ + { + variantId: 'dn50', + name: 'Stainless Steel Ball Valve DN50', + sku: 'BV-316L-DN50-NPT', + quantity: 10, + unitPriceMinor: 9900, + }, + ], + leadTimeDaysMax: 20, +}; + +describe('orderConfirmationEmail', () => { + const mail = orderConfirmationEmail(ORDER); + + it('puts the order number in the subject, so a reply can be traced', () => { + expect(mail.subject).toBe('Order ND-261005-7K3M9QXA confirmed'); + }); + + it('always carries a plain-text part beside the HTML', () => { + // HTML alone renders blank in some clients and raises the spam score. + expect(mail.text.length).toBeGreaterThan(0); + expect(mail.html.length).toBeGreaterThan(0); + }); + + it('lists each line with its quantity and its money, formatted', () => { + expect(mail.text).toContain( + '- Stainless Steel Ball Valve DN50 (BV-316L-DN50-NPT) × 10 — $990.00', + ); + expect(mail.html).toContain('Stainless Steel Ball Valve DN50'); + expect(mail.html).toContain('$990.00'); + }); + + it('states the total in the order’s own currency', () => { + expect(mail.text).toContain('Total: $990.00'); + }); + + it('tells the buyer the lead time when there is one to tell', () => { + expect(mail.text).toContain('Expected to ship within 20 business days.'); + expect(mail.html).toContain('Expected to ship within 20 business days.'); + }); + + it('says nothing about lead time when none can be promised', () => { + const silent = orderConfirmationEmail({ ...ORDER, leadTimeDaysMax: null }); + + expect(silent.text).not.toMatch(/business days/); + expect(silent.html).not.toMatch(/business days/); + }); + + it('writes in the language the order was placed in', () => { + expect(orderConfirmationEmail({ ...ORDER, locale: 'de' }).subject).toBe( + 'Bestellung ND-261005-7K3M9QXA bestätigt', + ); + expect(orderConfirmationEmail({ ...ORDER, locale: 'fr' }).subject).toBe( + 'Commande ND-261005-7K3M9QXA confirmée', + ); + expect(orderConfirmationEmail({ ...ORDER, locale: 'es' }).subject).toBe( + 'Pedido ND-261005-7K3M9QXA confirmado', + ); + }); + + it('uses the language of a regional variant, and English for one it has no text for', () => { + expect( + orderConfirmationEmail({ ...ORDER, locale: 'de-AT' }).subject, + ).toMatch(/^Bestellung/); + // A language without a translation gets English, and a locale that is + // not even well-formed must not stop the email from being built. + expect(orderConfirmationEmail({ ...ORDER, locale: 'ja' }).subject).toMatch( + /^Order/, + ); + expect( + orderConfirmationEmail({ ...ORDER, locale: 'not a locale!' }).text, + ).toContain('$990.00'); + }); + + it('formats money the way the recipient’s language writes it', () => { + const german = orderConfirmationEmail({ + ...ORDER, + locale: 'de', + currency: 'EUR', + }); + + expect(german.text).toMatch(/990,00\s€/); + }); + + it('escapes product names, so a crafted name cannot inject markup', () => { + const [line] = ORDER.lines; + if (line === undefined) { + throw new Error('The fixture order has no line'); + } + const nasty = orderConfirmationEmail({ + ...ORDER, + lines: [ + { ...line, name: '', sku: '">' }, + ], + }); + + expect(nasty.html).not.toContain(''); + }); +}); + +describe('shippingNotificationEmail', () => { + const mail = shippingNotificationEmail({ + orderNo: 'ND-261005-7K3M9QXA', + locale: 'en', + trackingNo: 'TRACK123456', + }); + + it('names the order in the subject', () => { + expect(mail.subject).toBe('Order ND-261005-7K3M9QXA has shipped'); + }); + + it('carries the tracking number in both parts', () => { + expect(mail.text).toContain('Tracking number: TRACK123456'); + expect(mail.html).toContain('TRACK123456'); + }); + + it('leaves the tracking line out when there is no tracking number', () => { + const without = shippingNotificationEmail({ + orderNo: 'ND-1', + locale: 'en', + trackingNo: null, + }); + + expect(without.text).not.toMatch(/tracking number/i); + expect(without.html).not.toMatch(/tracking number/i); + expect(without.text).toContain('Your order is on its way.'); + }); + + it('is written in the order’s language', () => { + const german = shippingNotificationEmail({ + orderNo: 'ND-1', + locale: 'de', + trackingNo: 'T1', + }); + + expect(german.subject).toBe('Bestellung ND-1 wurde versandt'); + expect(german.text).toContain('Sendungsnummer: T1'); + }); + + it('escapes the tracking number in the HTML part', () => { + // It is typed by a person in the admin, and it ends up in a buyer's inbox. + const nasty = shippingNotificationEmail({ + orderNo: 'ND-1', + locale: 'en', + trackingNo: 'T1', + }); + + expect(nasty.html).not.toContain('T1'); + expect(nasty.html).toContain('<b>T1</b>'); + }); +}); diff --git a/test/shop/order-fulfilment.test.ts b/test/shop/order-fulfilment.test.ts new file mode 100644 index 0000000..7eda46a --- /dev/null +++ b/test/shop/order-fulfilment.test.ts @@ -0,0 +1,443 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { CartLine } from '../../src/plugins/shop/lib/cart.js'; +import { priceCart } from '../../src/plugins/shop/lib/cart-pricing.js'; +import { + cancelOrder, + markDelivered, + refundOrder, + shipOrder, +} from '../../src/plugins/shop/lib/order-fulfilment.js'; +import { + createPendingOrder, + getOrder, + markOrderPaid, + OrderNotFoundError, +} from '../../src/plugins/shop/lib/orders.js'; +import { + clearShopTables, + countD1Calls, + createProduct, + createVariant, + db, + ensureSite, + interceptBatches, + outboxRows, + setPrice, + setStock, + stockOf, + type TestProduct, +} from './helpers.js'; + +const NOW = new Date('2026-10-05T08:00:00.000Z'); +const LATER = new Date('2026-10-06T09:30:00.000Z'); + +let valve: TestProduct; + +async function makeOrder( + lines: readonly CartLine[] = [{ variantId: 'dn50', quantity: 10 }], +): Promise<{ id: string; orderNo: string }> { + const cart = await priceCart(db(), { + lines, + locale: 'en', + defaultLocale: 'en', + currency: 'USD', + }); + if (!cart.ok) { + throw new Error(`The fixture cart did not price: ${JSON.stringify(cart)}`); + } + return createPendingOrder(db(), { + cart, + locale: 'en', + email: 'jane@example.com', + shippingAddress: { + recipient: 'Jane Buyer', + line1: '1 Harbour Road', + city: 'Aberdeen', + postalCode: 'AB11 5RY', + country: 'GB', + }, + now: NOW, + }); +} + +async function paidOrder( + lines?: readonly CartLine[], +): Promise<{ id: string; orderNo: string }> { + const order = await makeOrder(lines); + await markOrderPaid(db(), { + orderId: order.id, + eventId: `evt_${order.id}`, + paymentIntentId: `pi_${order.id}`, + now: NOW, + }); + return order; +} + +async function statusOf(orderId: string): Promise { + return (await getOrder(db(), { id: orderId }))?.status; +} + +async function reasons(orderId: string): Promise { + const { results } = await db() + .prepare( + 'SELECT reason FROM p_shop_stock_adjustment WHERE ref_id = ? ORDER BY reason', + ) + .bind(orderId) + .all<{ reason: string }>(); + return results.map((row) => row.reason); +} + +/** The topics owed for one order, in the order they were written. */ +async function owed(orderId: string): Promise { + return (await outboxRows()) + .filter((row) => row.order_id === orderId) + .map((row) => row.topic); +} + +/** How many of several attempts went through, and why the rest did not. */ +async function settle( + attempts: readonly Promise[], +): Promise<{ fulfilled: number; reasons: string[] }> { + const settled = await Promise.allSettled(attempts); + return { + fulfilled: settled.filter((result) => result.status === 'fulfilled').length, + reasons: settled.flatMap((result) => + result.status === 'rejected' ? [String(result.reason)] : [], + ), + }; +} + +beforeAll(async () => { + await ensureSite(); + valve = await createProduct({ + title: 'Stainless ball valve', + slug: 'fulfilment-ball-valve', + }); +}); + +beforeEach(async () => { + await clearShopTables(); + await createVariant({ + id: 'dn50', + productGroup: valve.translationGroup, + sku: 'BV-DN50', + moq: 10, + stock: 100, + }); + await setPrice({ + variantId: 'dn50', + currency: 'USD', + amountMinor: 9900, + source: 'base', + }); +}); + +describe('shipOrder', () => { + it('records the tracking number and moves the order to shipped', async () => { + const order = await paidOrder(); + + await shipOrder(db(), { + orderId: order.id, + trackingNo: ' TRACK-123 ', + now: LATER, + }); + + const detail = await getOrder(db(), { id: order.id }); + expect(detail?.status).toBe('shipped'); + expect(detail?.trackingNo).toBe('TRACK-123'); + expect(detail?.updatedAt).toBe(LATER.toISOString()); + }); + + it('owes the buyer a shipping notice from the moment the order ships', async () => { + // Written in the same batch as the change: a notice sent afterwards + // would be lost whenever the request died in between. + const order = await paidOrder(); + + await shipOrder(db(), { orderId: order.id, trackingNo: 'T-1', now: LATER }); + + expect(await owed(order.id)).toEqual(['order.paid', 'order.shipped']); + }); + + it('costs two round trips: one to read the status, one to change it', async () => { + const order = await paidOrder(); + + const calls = await countD1Calls(() => + shipOrder(db(), { orderId: order.id, trackingNo: 'T-1', now: LATER }), + ); + + expect(calls).toBe(2); + }); + + it('has shipped the order, and owes the notice, even when the answer is lost', async () => { + // To the caller this looks like a failure. The order has shipped all the + // same, the notice is owed, and pressing the button again is refused + // rather than repeated. + const order = await paidOrder(); + const database = interceptBatches({ + after: () => { + throw new Error('The connection was lost'); + }, + }); + + await expect( + shipOrder(database, { orderId: order.id, trackingNo: 'T-1', now: LATER }), + ).rejects.toThrow('The connection was lost'); + + expect(await statusOf(order.id)).toBe('shipped'); + expect(await owed(order.id)).toEqual(['order.paid', 'order.shipped']); + await expect( + shipOrder(db(), { orderId: order.id, trackingNo: 'T-1', now: LATER }), + ).rejects.toThrow(/transition/i); + expect(await owed(order.id)).toEqual(['order.paid', 'order.shipped']); + }); + + it('refuses to ship without a tracking number', async () => { + // "Shipped" with nothing to track helps neither support nor the buyer. + const order = await paidOrder(); + + await expect( + shipOrder(db(), { orderId: order.id, trackingNo: ' ', now: LATER }), + ).rejects.toThrow(/tracking number/i); + expect(await statusOf(order.id)).toBe('paid'); + expect(await owed(order.id)).toEqual(['order.paid']); + }); + + it('refuses to ship an order that has not been paid', async () => { + const order = await makeOrder(); + + await expect( + shipOrder(db(), { orderId: order.id, trackingNo: 'T-1', now: LATER }), + ).rejects.toThrow(/transition/i); + expect(await statusOf(order.id)).toBe('pending'); + expect(await owed(order.id)).toEqual([]); + }); + + it('refuses to ship an oversold order', async () => { + const order = await makeOrder(); + await setStock('dn50', 0); + await markOrderPaid(db(), { + orderId: order.id, + eventId: 'evt_over', + paymentIntentId: 'pi_over', + now: NOW, + }); + + await expect( + shipOrder(db(), { orderId: order.id, trackingNo: 'T-1', now: LATER }), + ).rejects.toThrow(/transition/i); + }); + + it('throws for an order that does not exist', async () => { + await expect( + shipOrder(db(), { orderId: 'nope', trackingNo: 'T-1', now: LATER }), + ).rejects.toThrow(OrderNotFoundError); + expect(await outboxRows()).toEqual([]); + }); +}); + +describe('markDelivered', () => { + it('moves a shipped order to delivered', async () => { + const order = await paidOrder(); + await shipOrder(db(), { orderId: order.id, trackingNo: 'T-1', now: NOW }); + + await markDelivered(db(), { orderId: order.id, now: LATER }); + + expect(await statusOf(order.id)).toBe('delivered'); + }); + + it('refuses an order that has not shipped', async () => { + const order = await paidOrder(); + + await expect( + markDelivered(db(), { orderId: order.id, now: LATER }), + ).rejects.toThrow(/transition/i); + }); +}); + +describe('cancelOrder', () => { + it('cancels an order that is still waiting for payment', async () => { + const order = await makeOrder(); + + await cancelOrder(db(), { orderId: order.id, now: LATER }); + + expect(await statusOf(order.id)).toBe('cancelled'); + }); + + it('refuses to cancel a paid order — that is a refund', async () => { + const order = await paidOrder(); + + await expect( + cancelOrder(db(), { orderId: order.id, now: LATER }), + ).rejects.toThrow(/transition/i); + expect(await statusOf(order.id)).toBe('paid'); + }); + + it('lets only one of two simultaneous cancellations through', async () => { + const order = await makeOrder(); + + const outcome = await settle([ + cancelOrder(db(), { orderId: order.id, now: LATER }), + cancelOrder(db(), { orderId: order.id, now: LATER }), + ]); + + expect(outcome.fulfilled).toBe(1); + expect(await statusOf(order.id)).toBe('cancelled'); + expect(await owed(order.id)).toEqual(['order.cancelled']); + }); +}); + +describe('refundOrder', () => { + it('returns the stock that was taken when the order was paid', async () => { + const order = await paidOrder(); + expect(await stockOf('dn50')).toBe(90); + + await refundOrder(db(), { orderId: order.id, now: LATER }); + + expect(await stockOf('dn50')).toBe(100); + expect(await statusOf(order.id)).toBe('refunded'); + }); + + it('writes a refund adjustment for the audit trail', async () => { + const order = await paidOrder(); + + await refundOrder(db(), { orderId: order.id, now: LATER }); + + expect(await reasons(order.id)).toEqual(['order_paid', 'refund']); + const sum = await db() + .prepare( + 'SELECT SUM(delta) AS net FROM p_shop_stock_adjustment WHERE ref_id = ?', + ) + .bind(order.id) + .first<{ net: number }>(); + expect(sum?.net).toBe(0); + }); + + it('does not invent stock when refunding an oversold order', async () => { + // An oversold order never took the stock, so there is none to give back. + // The refund follows what the ledger recorded, not what the order asked for. + const order = await makeOrder(); + await setStock('dn50', 1); + await markOrderPaid(db(), { + orderId: order.id, + eventId: 'evt_over', + paymentIntentId: 'pi_over', + now: NOW, + }); + + await refundOrder(db(), { orderId: order.id, now: LATER }); + + expect(await stockOf('dn50')).toBe(1); + expect(await statusOf(order.id)).toBe('refunded'); + expect(await reasons(order.id)).toEqual([]); + }); + + it('refuses to refund an order that was never paid', async () => { + const order = await makeOrder(); + + await expect( + refundOrder(db(), { orderId: order.id, now: LATER }), + ).rejects.toThrow(/transition/i); + expect(await stockOf('dn50')).toBe(100); + }); + + it('cannot be refunded twice', async () => { + const order = await paidOrder(); + await refundOrder(db(), { orderId: order.id, now: LATER }); + + await expect( + refundOrder(db(), { orderId: order.id, now: LATER }), + ).rejects.toThrow(/transition/i); + + expect(await stockOf('dn50')).toBe(100); + expect(await reasons(order.id)).toEqual(['order_paid', 'refund']); + }); + + it('returns the stock once when two refunds are made at the same moment', async () => { + const order = await paidOrder(); + + const outcome = await settle([ + refundOrder(db(), { orderId: order.id, now: LATER }), + refundOrder(db(), { orderId: order.id, now: LATER }), + ]); + + expect(outcome.fulfilled).toBe(1); + expect(await stockOf('dn50')).toBe(100); + expect(await reasons(order.id)).toEqual(['order_paid', 'refund']); + expect(await owed(order.id)).toEqual(['order.paid', 'order.refunded']); + }); + + it('ships or refunds, never both, when the two happen at the same moment', async () => { + const order = await paidOrder(); + + const outcome = await settle([ + shipOrder(db(), { orderId: order.id, trackingNo: 'T-1', now: LATER }), + refundOrder(db(), { orderId: order.id, now: LATER }), + ]); + + expect(outcome.fulfilled).toBe(1); + const status = await statusOf(order.id); + if (status === 'refunded') { + expect(await stockOf('dn50')).toBe(100); + expect(await owed(order.id)).toEqual(['order.paid', 'order.refunded']); + } else { + // Shipped: nothing went back to stock, and no refund is on the ledger. + expect(status).toBe('shipped'); + expect(await stockOf('dn50')).toBe(90); + expect(await reasons(order.id)).toEqual(['order_paid']); + expect(await owed(order.id)).toEqual(['order.paid', 'order.shipped']); + } + }); + + it('has refunded the order once, and owes the follow-up, even when the answer is lost', async () => { + const order = await paidOrder(); + const database = interceptBatches({ + after: () => { + throw new Error('The connection was lost'); + }, + }); + + await expect( + refundOrder(database, { orderId: order.id, now: LATER }), + ).rejects.toThrow('The connection was lost'); + + expect(await statusOf(order.id)).toBe('refunded'); + expect(await stockOf('dn50')).toBe(100); + expect(await owed(order.id)).toEqual(['order.paid', 'order.refunded']); + // Trying again cannot return the stock a second time. + await expect( + refundOrder(db(), { orderId: order.id, now: LATER }), + ).rejects.toThrow(/transition/i); + expect(await stockOf('dn50')).toBe(100); + expect(await reasons(order.id)).toEqual(['order_paid', 'refund']); + }); + + it('refunds a shipped order too', async () => { + const order = await paidOrder(); + await shipOrder(db(), { orderId: order.id, trackingNo: 'T-1', now: NOW }); + + await refundOrder(db(), { orderId: order.id, now: LATER }); + + expect(await statusOf(order.id)).toBe('refunded'); + expect(await stockOf('dn50')).toBe(100); + }); + + it('reports the product that became available again', async () => { + // Ten in stock with a minimum order of ten: the order takes all of it, + // and the refund puts the product back on sale. + await setStock('dn50', 10); + const order = await paidOrder(); + expect(await stockOf('dn50')).toBe(0); + + const result = await refundOrder(db(), { orderId: order.id, now: LATER }); + + expect(result.availabilityChanged).toEqual([valve.translationGroup]); + }); + + it('reports nothing when availability did not change', async () => { + const order = await paidOrder(); + + const result = await refundOrder(db(), { orderId: order.id, now: LATER }); + + expect(result.availabilityChanged).toEqual([]); + }); +}); diff --git a/test/shop/orders.test.ts b/test/shop/orders.test.ts new file mode 100644 index 0000000..7fb0c76 --- /dev/null +++ b/test/shop/orders.test.ts @@ -0,0 +1,1106 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { CartLine } from '../../src/plugins/shop/lib/cart.js'; +import { + type PricedCart, + priceCart, +} from '../../src/plugins/shop/lib/cart-pricing.js'; +import { cancelOrder } from '../../src/plugins/shop/lib/order-fulfilment.js'; +import { + createPendingOrder, + getOrder, + markOrderPaid, + OrderNotFoundError, + type ShippingAddress, +} from '../../src/plugins/shop/lib/orders.js'; +import { + clearShopTables, + countD1Calls, + createProduct, + createVariant, + db, + ensureSite, + interceptBatches, + outboxRows, + setPrice, + setStock, + stockOf, + type TestProduct, +} from './helpers.js'; + +const NOW = new Date('2026-10-05T08:00:00.000Z'); +const LATER = new Date('2026-10-05T08:05:00.000Z'); + +const EMAIL = 'jane@example.com'; +const ADDRESS: ShippingAddress = { + recipient: 'Jane Buyer', + line1: '1 Harbour Road', + city: 'Aberdeen', + postalCode: 'AB11 5RY', + country: 'GB', +}; + +type SuccessfulCart = Extract; + +let valve: TestProduct; +let gasket: TestProduct; + +async function pricedCart( + lines: readonly CartLine[] = [{ variantId: 'dn50', quantity: 10 }], +): Promise { + const result = await priceCart(db(), { + lines, + locale: 'en', + defaultLocale: 'en', + currency: 'USD', + }); + if (!result.ok) { + throw new Error( + `The fixture cart did not price: ${JSON.stringify(result)}`, + ); + } + return result; +} + +async function pendingOrder( + lines?: readonly CartLine[], + locale = 'en', +): Promise<{ id: string; orderNo: string; totalMinor: number }> { + return createPendingOrder(db(), { + cart: await pricedCart(lines), + locale, + email: EMAIL, + shippingAddress: ADDRESS, + now: NOW, + }); +} + +function pay( + orderId: string, + eventId = 'evt_1', + paymentIntentId = 'pi_1', + database: D1Database = db(), +): ReturnType { + return markOrderPaid(database, { + orderId, + eventId, + paymentIntentId, + now: LATER, + }); +} + +async function events(): Promise< + { + event_id: string; + order_id: string; + payment_intent_id: string; + outcome: string; + }[] +> { + const { results } = await db() + .prepare( + `SELECT event_id, order_id, payment_intent_id, outcome + FROM p_shop_stripe_event ORDER BY event_id`, + ) + .all<{ + event_id: string; + order_id: string; + payment_intent_id: string; + outcome: string; + }>(); + return results; +} + +interface OrderRow { + id: string; + order_no: string; + status: string; + currency: string; + subtotal_minor: number; + shipping_minor: number; + tax_minor: number; + total_minor: number; + stripe_payment_intent_id: string | null; + email: string; + shipping_address: string; + locale: string; + created_at: string; + updated_at: string; +} + +async function orderRow(id: string): Promise { + const row = await db() + .prepare('SELECT * FROM p_shop_order WHERE id = ?') + .bind(id) + .first(); + if (row === null) { + throw new Error(`No order ${id}`); + } + return row; +} + +async function count(table: string): Promise { + const row = await db() + .prepare(`SELECT COUNT(*) AS n FROM ${table}`) + .first<{ n: number }>(); + return row?.n ?? 0; +} + +async function adjustments(): Promise< + { variant_id: string; delta: number; reason: string; ref_id: string }[] +> { + const { results } = await db() + .prepare( + `SELECT variant_id, delta, reason, ref_id FROM p_shop_stock_adjustment + ORDER BY variant_id, reason`, + ) + .all<{ + variant_id: string; + delta: number; + reason: string; + ref_id: string; + }>(); + return results; +} + +beforeAll(async () => { + await ensureSite(); + valve = await createProduct({ + title: 'Stainless ball valve', + slug: 'orders-ball-valve', + }); + gasket = await createProduct({ + title: 'PTFE gasket', + slug: 'orders-gasket', + }); +}); + +beforeEach(async () => { + await clearShopTables(); + await createVariant({ + id: 'dn50', + productGroup: valve.translationGroup, + sku: 'BV-DN50', + moq: 10, + stock: 100, + }); + await createVariant({ + id: 'dn80', + productGroup: valve.translationGroup, + sku: 'BV-DN80', + moq: 1, + stock: 100, + }); + await createVariant({ + id: 'gasket-custom', + productGroup: gasket.translationGroup, + sku: 'GK-CUSTOM', + moq: 1, + stock: 0, + stockPolicy: 'made_to_order', + }); + for (const [variantId, amountMinor] of [ + ['dn50', 9900], + ['dn80', 14900], + ['gasket-custom', 250], + ] as const) { + await setPrice({ variantId, currency: 'USD', amountMinor, source: 'base' }); + } +}); + +describe('createPendingOrder', () => { + it('creates the order as pending, in the cart’s currency, with a readable number', async () => { + const order = await pendingOrder(); + + const row = await orderRow(order.id); + expect(row.status).toBe('pending'); + expect(row.currency).toBe('USD'); + expect(row.subtotal_minor).toBe(99_000); + expect(row.total_minor).toBe(99_000); + expect(order.totalMinor).toBe(99_000); + expect(row.email).toBe(EMAIL); + expect(JSON.parse(row.shipping_address)).toEqual(ADDRESS); + expect(row.created_at).toBe(NOW.toISOString()); + + // The date the order was placed, then eight characters from an alphabet + // without the letters that are misread aloud or on paper (I, L, O, U). + expect(row.order_no).toBe(order.orderNo); + expect(order.orderNo).toMatch(/^ND-261005-[0-9A-HJKMNP-TV-Z]{8}$/); + }); + + it('gives two orders placed in the same instant different numbers', async () => { + const first = await pendingOrder(); + const second = await pendingOrder(); + + expect(first.orderNo).not.toBe(second.orderNo); + expect(first.id).not.toBe(second.id); + }); + + it('snapshots SKU, name and unit price, so later edits cannot rewrite history', async () => { + const order = await pendingOrder(); + + // The catalogue moves on after the order is placed. + await setPrice({ + variantId: 'dn50', + currency: 'USD', + amountMinor: 19_900, + source: 'base', + }); + await db() + .prepare("UPDATE p_shop_variant SET sku = 'RENAMED' WHERE id = 'dn50'") + .run(); + + const detail = await getOrder(db(), { id: order.id }); + expect(detail?.lines).toEqual([ + { + variantId: 'dn50', + sku: 'BV-DN50', + name: 'Stainless ball valve', + unitPriceMinor: 9900, + quantity: 10, + }, + ]); + expect(detail?.totalMinor).toBe(99_000); + }); + + it('does not touch stock — that happens only once payment is confirmed', async () => { + // Decrementing at order time would let anyone empty the catalogue with + // scripted orders that are never paid. + await pendingOrder(); + + expect(await stockOf('dn50')).toBe(100); + expect(await count('p_shop_stock_adjustment')).toBe(0); + }); + + it('records the language, so the buyer is written to in it', async () => { + const order = await pendingOrder(undefined, 'de'); + + expect((await orderRow(order.id)).locale).toBe('de'); + }); + + it('adds shipping and tax into the total', async () => { + const order = await createPendingOrder(db(), { + cart: await pricedCart(), + locale: 'en', + email: EMAIL, + shippingAddress: ADDRESS, + shippingMinor: 1500, + taxMinor: 500, + now: NOW, + }); + + const row = await orderRow(order.id); + expect(row.shipping_minor).toBe(1500); + expect(row.tax_minor).toBe(500); + expect(row.total_minor).toBe(101_000); + expect(order.totalMinor).toBe(101_000); + }); + + it('takes the subtotal from the lines it writes, not from a figure handed in', async () => { + // An order whose subtotal disagreed with its own lines would charge one + // amount and list another. + const cart = await pricedCart(); + + const order = await createPendingOrder(db(), { + cart: { ...cart, subtotalMinor: 1 }, + locale: 'en', + email: EMAIL, + shippingAddress: ADDRESS, + now: NOW, + }); + + expect((await orderRow(order.id)).subtotal_minor).toBe(99_000); + expect(order.totalMinor).toBe(99_000); + }); + + it('refuses a line whose unit price is not a whole number of minor units', async () => { + // 99.5 × 10 is 995, a whole number. A check on the line total alone + // would let the half-cent through into the stored unit price. + const cart = await pricedCart(); + const [line] = cart.lines; + if (line === undefined) { + throw new Error('The fixture cart has no line'); + } + + for (const bad of [ + { ...line, unitPriceMinor: 99.5 }, + { ...line, unitPriceMinor: -1 }, + { ...line, quantity: 1.5 }, + { ...line, quantity: 0 }, + ]) { + await expect( + createPendingOrder(db(), { + cart: { ...cart, lines: [bad] }, + locale: 'en', + email: EMAIL, + shippingAddress: ADDRESS, + now: NOW, + }), + ).rejects.toThrow(/whole/); + } + expect(await count('p_shop_order')).toBe(0); + }); + + it('owes nothing yet: an order waiting for payment has no follow-up', async () => { + await pendingOrder(); + + expect(await outboxRows()).toEqual([]); + }); + + it('cannot hold a total that is not the sum of its parts', async () => { + const order = await pendingOrder(); + + await expect( + db() + .prepare('UPDATE p_shop_order SET total_minor = 1 WHERE id = ?') + .bind(order.id) + .run(), + ).rejects.toThrow(/CHECK/i); + }); + + it('writes the order and all its lines in one round trip', async () => { + const cart = await pricedCart([ + { variantId: 'dn50', quantity: 10 }, + { variantId: 'dn80', quantity: 2 }, + { variantId: 'gasket-custom', quantity: 40 }, + ]); + + let order: { id: string } | undefined; + const calls = await countD1Calls(async () => { + order = await createPendingOrder(db(), { + cart, + locale: 'en', + email: EMAIL, + shippingAddress: ADDRESS, + now: NOW, + }); + }); + + expect(calls).toBe(1); + expect(await count('p_shop_order_line')).toBe(3); + expect((await orderRow(order?.id ?? '')).total_minor).toBe(138_800); + }); + + it('leaves no order behind when one of its lines cannot be written', async () => { + // The same variant twice breaks the line table's uniqueness. The order + // row is written first in the same batch and has to go with it. + const cart = await pricedCart(); + const [line] = cart.lines; + if (line === undefined) { + throw new Error('The fixture cart has no line'); + } + + await expect( + createPendingOrder(db(), { + cart: { ...cart, lines: [line, line] }, + locale: 'en', + email: EMAIL, + shippingAddress: ADDRESS, + now: NOW, + }), + ).rejects.toThrow(/UNIQUE/i); + + expect(await count('p_shop_order')).toBe(0); + expect(await count('p_shop_order_line')).toBe(0); + }); +}); + +describe('markOrderPaid', () => { + it('moves the order to paid and takes the stock', async () => { + const order = await pendingOrder(); + + const result = await pay(order.id); + + expect(result.outcome).toBe('paid'); + expect(result.status).toBe('paid'); + expect(await stockOf('dn50')).toBe(90); + + const row = await orderRow(order.id); + expect(row.status).toBe('paid'); + expect(row.stripe_payment_intent_id).toBe('pi_1'); + expect(row.updated_at).toBe(LATER.toISOString()); + }); + + it('writes a stock adjustment for the audit trail', async () => { + const order = await pendingOrder(); + await pay(order.id); + + expect(await adjustments()).toEqual([ + { + variant_id: 'dn50', + delta: -10, + reason: 'order_paid', + ref_id: order.id, + }, + ]); + }); + + it('records the payment, and what was done about it', async () => { + const order = await pendingOrder(); + await pay(order.id, 'evt_1', 'pi_1'); + + expect(await events()).toEqual([ + { + event_id: 'evt_1', + order_id: order.id, + payment_intent_id: 'pi_1', + outcome: 'paid', + }, + ]); + }); + + it('writes what is owed for the payment in the payment’s own batch', async () => { + const order = await pendingOrder(); + await pay(order.id); + + expect(await outboxRows()).toEqual([ + { + id: `order.paid:${order.id}`, + topic: 'order.paid', + order_id: order.id, + ref: null, + handled_at: null, + }, + ]); + }); + + it('costs two round trips however many lines the order has', async () => { + const order = await pendingOrder([ + { variantId: 'dn50', quantity: 10 }, + { variantId: 'dn80', quantity: 2 }, + { variantId: 'gasket-custom', quantity: 40 }, + ]); + + const calls = await countD1Calls(() => pay(order.id)); + + expect(calls).toBe(2); + expect(await stockOf('dn50')).toBe(90); + expect(await stockOf('dn80')).toBe(98); + }); + + it('does not decrement a redelivered event a second time', async () => { + const order = await pendingOrder(); + + await pay(order.id, 'evt_dup'); + const second = await pay(order.id, 'evt_dup'); + + expect(second.outcome).toBe('duplicate'); + expect(second.status).toBe('paid'); + expect(await stockOf('dn50')).toBe(90); + expect(await count('p_shop_stock_adjustment')).toBe(1); + expect(await outboxRows()).toHaveLength(1); + }); + + it('never acts on an event that is already on record', async () => { + // The record of payments is its own guard, apart from what the order + // says: a payment found there is answered and nothing else happens. + const order = await pendingOrder(); + await db() + .prepare( + `INSERT INTO p_shop_stripe_event + (event_id, type, order_id, payment_intent_id, outcome, processed_at) + VALUES ('evt_seen', 'payment_intent.succeeded', ?, 'pi_seen', + 'paid', ?)`, + ) + .bind(order.id, NOW.toISOString()) + .run(); + + const result = await pay(order.id, 'evt_seen', 'pi_other'); + + expect(result.outcome).toBe('duplicate'); + expect(await stockOf('dn50')).toBe(100); + expect((await orderRow(order.id)).status).toBe('pending'); + }); + + it('decrements once when the same event is delivered twice at the same moment', async () => { + // Both deliveries read "not on record, order pending" before either + // writes. Every statement of the second one's batch is conditional on the + // order still being pending, so it finds nothing to do. + const order = await pendingOrder(); + + const results = await Promise.all([ + pay(order.id, 'evt_race'), + pay(order.id, 'evt_race'), + ]); + + expect(results.map((result) => result.outcome).sort()).toEqual([ + 'duplicate', + 'paid', + ]); + expect(await stockOf('dn50')).toBe(90); + expect(await count('p_shop_stock_adjustment')).toBe(1); + expect(await count('p_shop_stripe_event')).toBe(1); + expect(await outboxRows()).toHaveLength(1); + }); + + it('treats a second event for the same payment as the same payment', async () => { + // Stripe says two separate events can describe one object, and that the + // object's id with the event's type is how to tell. One payment takes + // stock once. + const order = await pendingOrder(); + + await pay(order.id, 'evt_a', 'pi_same'); + const second = await pay(order.id, 'evt_b', 'pi_same'); + + expect(second.outcome).toBe('duplicate'); + expect(second.status).toBe('paid'); + expect(await stockOf('dn50')).toBe(90); + expect(await count('p_shop_stripe_event')).toBe(1); + }); + + it('decrements once when two events for the same payment arrive at the same moment', async () => { + const order = await pendingOrder(); + + const results = await Promise.all([ + pay(order.id, 'evt_a', 'pi_same'), + pay(order.id, 'evt_b', 'pi_same'), + ]); + + expect(results.map((result) => result.outcome).sort()).toEqual([ + 'duplicate', + 'paid', + ]); + expect(await stockOf('dn50')).toBe(90); + expect(await count('p_shop_stock_adjustment')).toBe(1); + }); + + it('pays once and records the other when two different payments arrive at the same moment', async () => { + // A buyer charged twice. One payment settles the order; the other must + // not vanish — it is money to give back. + const order = await pendingOrder(); + + const results = await Promise.all([ + pay(order.id, 'evt_a', 'pi_first'), + pay(order.id, 'evt_b', 'pi_second'), + ]); + + expect(results.map((result) => result.outcome).sort()).toEqual([ + 'paid', + 'refused', + ]); + expect(await stockOf('dn50')).toBe(90); + expect((await events()).map((event) => event.outcome).sort()).toEqual([ + 'paid', + 'refused', + ]); + expect((await outboxRows()).map((row) => row.topic).sort()).toEqual([ + 'order.paid', + 'payment.refused', + ]); + }); + + it('records a different payment for an order that is already paid, and leaves the order alone', async () => { + const order = await pendingOrder(); + await pay(order.id, 'evt_a', 'pi_first'); + + const second = await pay(order.id, 'evt_b', 'pi_second'); + + expect(second.outcome).toBe('refused'); + expect(second.status).toBe('paid'); + expect(await stockOf('dn50')).toBe(90); + // The order still names the payment that settled it. + expect((await orderRow(order.id)).stripe_payment_intent_id).toBe( + 'pi_first', + ); + // The second payment is on record, with what has to be refunded. + expect(await outboxRows()).toContainEqual({ + id: 'payment.refused:evt_b', + topic: 'payment.refused', + order_id: order.id, + ref: 'pi_second', + handled_at: null, + }); + }); + + it('marks the order oversold when the stock went between order and payment', async () => { + const order = await pendingOrder(); + await setStock('dn50', 2); + + const result = await pay(order.id, 'evt_oversold', 'pi_2'); + + expect(result.outcome).toBe('oversold'); + // Never below zero, and never a partial decrement. + expect(await stockOf('dn50')).toBe(2); + expect(await count('p_shop_stock_adjustment')).toBe(0); + + const row = await orderRow(order.id); + expect(row.status).toBe('oversold'); + // The payment is on the order: it is what has to be refunded. + expect(row.stripe_payment_intent_id).toBe('pi_2'); + expect((await events())[0]?.outcome).toBe('oversold'); + expect((await outboxRows()).map((entry) => entry.topic)).toEqual([ + 'order.oversold', + ]); + }); + + it('answers a redelivery of an oversold payment without acting again', async () => { + const order = await pendingOrder(); + await setStock('dn50', 2); + await pay(order.id, 'evt_oversold', 'pi_2'); + + const second = await pay(order.id, 'evt_oversold', 'pi_2'); + + expect(second.outcome).toBe('duplicate'); + expect(second.status).toBe('oversold'); + expect(await stockOf('dn50')).toBe(2); + }); + + it('takes nothing from an earlier line when a later one cannot be satisfied', async () => { + // The previous implementation decremented line by line and put stock back + // by hand on failure. Here the batch is the transaction: there is nothing + // to put back. + const order = await pendingOrder([ + { variantId: 'dn50', quantity: 10 }, + { variantId: 'dn80', quantity: 5 }, + ]); + await setStock('dn80', 1); + + const result = await pay(order.id); + + expect(result.outcome).toBe('oversold'); + expect(await stockOf('dn50')).toBe(100); + expect(await stockOf('dn80')).toBe(1); + expect(await count('p_shop_stock_adjustment')).toBe(0); + }); + + it('marks oversold when the stock goes after the reading and before the write', async () => { + // The reading says the stock is there; by the time the batch runs it is + // not. The decrement trips the constraint, the batch rolls back, and the + // next reading decides. + const order = await pendingOrder(); + const database = interceptBatches({ + before: async (index) => { + if (index === 1) { + await setStock('dn50', 2); + } + }, + }); + + const result = await pay(order.id, 'evt_1', 'pi_1', database); + + expect(result.outcome).toBe('oversold'); + expect(await stockOf('dn50')).toBe(2); + expect(await count('p_shop_stock_adjustment')).toBe(0); + }); + + it('pays after all when the stock comes back before oversold is written', async () => { + // Another order's refund returns the stock between the reading that saw + // it short and the write. An order that can be fulfilled must not be sent + // to a person for a refund it does not need. + const order = await pendingOrder(); + await setStock('dn50', 2); + const database = interceptBatches({ + before: async (index) => { + if (index === 1) { + await setStock('dn50', 100); + } + }, + }); + + const result = await pay(order.id, 'evt_1', 'pi_1', database); + + expect(result.outcome).toBe('paid'); + expect(await stockOf('dn50')).toBe(90); + expect((await orderRow(order.id)).status).toBe('paid'); + expect((await outboxRows()).map((entry) => entry.topic)).toEqual([ + 'order.paid', + ]); + }); + + it('gives the last stock to one of two orders paid at the same moment', async () => { + await setStock('dn50', 10); + const first = await pendingOrder(); + const second = await pendingOrder(); + + const results = await Promise.all([ + pay(first.id, 'evt_first', 'pi_first'), + pay(second.id, 'evt_second', 'pi_second'), + ]); + + expect(results.map((result) => result.outcome).sort()).toEqual([ + 'oversold', + 'paid', + ]); + expect(await stockOf('dn50')).toBe(0); + expect(await count('p_shop_stock_adjustment')).toBe(1); + }); + + it('does not take stock for a made-to-order line, and does not call it oversold', async () => { + // A made-to-order variant has no stock to take: its stock is zero and + // stays zero. Decrementing it would trip the constraint and turn every + // such order into "oversold". + const order = await pendingOrder([ + { variantId: 'dn50', quantity: 10 }, + { variantId: 'gasket-custom', quantity: 40 }, + ]); + + const result = await pay(order.id); + + expect(result.outcome).toBe('paid'); + expect(await stockOf('gasket-custom')).toBe(0); + expect(await stockOf('dn50')).toBe(90); + expect(await adjustments()).toEqual([ + { + variant_id: 'dn50', + delta: -10, + reason: 'order_paid', + ref_id: order.id, + }, + ]); + }); + + it('records a payment for a cancelled order, and changes neither order nor stock', async () => { + // The buyer paid on Stripe's page after the order was cancelled. The + // order cannot take the payment, but the money has been taken: it goes on + // record, with a row for a person to refund it. + const order = await pendingOrder(); + await cancelOrder(db(), { orderId: order.id, now: NOW }); + + const result = await pay(order.id, 'evt_late', 'pi_3'); + + expect(result.outcome).toBe('refused'); + expect(result.status).toBe('cancelled'); + expect(await stockOf('dn50')).toBe(100); + expect(await count('p_shop_stock_adjustment')).toBe(0); + const row = await orderRow(order.id); + expect(row.status).toBe('cancelled'); + expect(row.stripe_payment_intent_id).toBeNull(); + expect(await events()).toEqual([ + { + event_id: 'evt_late', + order_id: order.id, + payment_intent_id: 'pi_3', + outcome: 'refused', + }, + ]); + expect(await outboxRows()).toContainEqual({ + id: 'payment.refused:evt_late', + topic: 'payment.refused', + order_id: order.id, + ref: 'pi_3', + handled_at: null, + }); + }); + + it('records a refused payment once, however often it is delivered', async () => { + const order = await pendingOrder(); + await cancelOrder(db(), { orderId: order.id, now: NOW }); + + const results = await Promise.all([ + pay(order.id, 'evt_late', 'pi_3'), + pay(order.id, 'evt_late', 'pi_3'), + pay(order.id, 'evt_other', 'pi_3'), + ]); + const again = await pay(order.id, 'evt_late', 'pi_3'); + + expect( + results.filter((result) => result.outcome === 'refused'), + ).toHaveLength(1); + expect(again.outcome).toBe('duplicate'); + expect(await count('p_shop_stripe_event')).toBe(1); + expect( + (await outboxRows()).filter((row) => row.topic === 'payment.refused'), + ).toHaveLength(1); + }); + + it('pays or cancels, never both, when the two happen at the same moment', async () => { + const order = await pendingOrder(); + + const [payment, cancellation] = await Promise.allSettled([ + pay(order.id), + cancelOrder(db(), { orderId: order.id, now: LATER }), + ]); + + const status = (await orderRow(order.id)).status; + if (status === 'paid') { + // The payment won: the cancellation found the order moved and refused. + expect(payment).toMatchObject({ value: { outcome: 'paid' } }); + expect(cancellation.status).toBe('rejected'); + expect(await stockOf('dn50')).toBe(90); + } else { + // The cancellation won: the payment is recorded for a refund. + expect(status).toBe('cancelled'); + expect(payment).toMatchObject({ value: { outcome: 'refused' } }); + expect(cancellation.status).toBe('fulfilled'); + expect(await stockOf('dn50')).toBe(100); + } + }); + + it('throws for an order that does not exist, and writes nothing', async () => { + await expect(pay('no-such-order', 'evt_x', 'pi_x')).rejects.toThrow( + OrderNotFoundError, + ); + expect(await count('p_shop_stripe_event')).toBe(0); + expect(await outboxRows()).toEqual([]); + }); + + it('rethrows a failure that is neither a race nor a shortage', async () => { + // The database going away is not something to explain as "oversold" or + // "already handled". It has to reach the caller, who answers so that the + // event is delivered again. + const order = await pendingOrder(); + let writes = 0; + const database = interceptBatches({ + before: (index) => { + // Even batches are readings, odd ones are writes. + if (index % 2 === 1) { + writes += 1; + throw new Error('D1 is unavailable'); + } + }, + }); + + await expect(pay(order.id, 'evt_1', 'pi_1', database)).rejects.toThrow( + 'D1 is unavailable', + ); + + // Tried, tried once more, and gave up rather than hammer the database. + expect(writes).toBe(2); + expect((await orderRow(order.id)).status).toBe('pending'); + expect(await stockOf('dn50')).toBe(100); + expect(await count('p_shop_stripe_event')).toBe(0); + }); + + it('pays when the stock is gone at the write and back by the next reading', async () => { + // Another order takes the stock just before this one's batch, and its + // refund returns it just after. The batch fails on the constraint; the + // next reading finds the stock there and calls for paying again. The + // first failure says nothing about that second attempt. + const order = await pendingOrder(); + const database = interceptBatches({ + before: async (index) => { + if (index === 1) { + await setStock('dn50', 2); + } + if (index === 2) { + await setStock('dn50', 100); + } + }, + }); + + const result = await pay(order.id, 'evt_1', 'pi_1', database); + + expect(result.outcome).toBe('paid'); + expect(await stockOf('dn50')).toBe(90); + expect(await count('p_shop_stock_adjustment')).toBe(1); + }); + + it('gives up after four passes that all lose, having written nothing', async () => { + // Stock that is gone at every write and back at every reading. Four + // passes is where it stops; whoever delivered the event delivers it + // again, and by then the dust has settled. + const order = await pendingOrder(); + let batches = 0; + const database = interceptBatches({ + before: async (index) => { + batches += 1; + // Before each write the stock is the opposite of what was just read. + if (index === 1 || index === 5) { + await setStock('dn50', 2); + } + if (index === 3 || index === 7) { + await setStock('dn50', 100); + } + }, + }); + + await expect(pay(order.id, 'evt_1', 'pi_1', database)).rejects.toThrow( + /could not be settled/, + ); + + expect(batches).toBe(8); + expect((await orderRow(order.id)).status).toBe('pending'); + expect(await stockOf('dn50')).toBe(100); + expect(await count('p_shop_stripe_event')).toBe(0); + expect(await outboxRows()).toEqual([]); + + // The next delivery finds a quiet database and pays the order. + expect((await pay(order.id)).outcome).toBe('paid'); + }); + + it('still owes the follow-up when the payment commits and its answer is lost', async () => { + // The batch reaches the database and commits; the answer never comes + // back. To this call it looks like a failure, and the next reading finds + // the payment already on record. Whoever called gets "duplicate" and + // sends nothing. The outbox row, written in the payment's own batch, is + // what keeps the buyer's email from being lost with the answer. + const order = await pendingOrder(); + const database = interceptBatches({ + after: (index) => { + if (index === 1) { + throw new Error('The connection was lost'); + } + }, + }); + + const result = await pay(order.id, 'evt_1', 'pi_1', database); + + expect(result.outcome).toBe('duplicate'); + expect(result.status).toBe('paid'); + expect(await stockOf('dn50')).toBe(90); + expect(await outboxRows()).toEqual([ + { + id: `order.paid:${order.id}`, + topic: 'order.paid', + order_id: order.id, + ref: null, + handled_at: null, + }, + ]); + }); + + it('still owes the follow-up when an oversold order is recorded and its answer is lost', async () => { + const order = await pendingOrder(); + await setStock('dn50', 2); + const database = interceptBatches({ + after: (index) => { + if (index === 1) { + throw new Error('The connection was lost'); + } + }, + }); + + const result = await pay(order.id, 'evt_1', 'pi_1', database); + + expect(result.outcome).toBe('duplicate'); + expect(result.status).toBe('oversold'); + expect((await orderRow(order.id)).stripe_payment_intent_id).toBe('pi_1'); + expect((await outboxRows()).map((row) => row.topic)).toEqual([ + 'order.oversold', + ]); + }); + + it('still owes the refund when a refused payment is recorded and its answer is lost', async () => { + const order = await pendingOrder(); + await cancelOrder(db(), { orderId: order.id, now: NOW }); + const database = interceptBatches({ + after: (index) => { + if (index === 1) { + throw new Error('The connection was lost'); + } + }, + }); + + const result = await pay(order.id, 'evt_late', 'pi_3', database); + + expect(result.outcome).toBe('duplicate'); + expect(result.status).toBe('cancelled'); + expect((await events()).map((event) => event.outcome)).toEqual(['refused']); + expect(await outboxRows()).toContainEqual({ + id: 'payment.refused:evt_late', + topic: 'payment.refused', + order_id: order.id, + ref: 'pi_3', + handled_at: null, + }); + }); + + it('reports the product whose availability the payment changed', async () => { + // 15 in stock with a minimum order of 10 is orderable; 5 is not. That is + // the moment the product's pages have to be purged. + await setStock('dn50', 15); + const order = await pendingOrder(); + + const result = await pay(order.id); + + expect(result.availabilityChanged).toEqual([valve.translationGroup]); + }); + + it('reports nothing when the product is as available as it was', async () => { + const order = await pendingOrder([ + { variantId: 'dn50', quantity: 10 }, + { variantId: 'gasket-custom', quantity: 40 }, + ]); + + const result = await pay(order.id); + + // 90 left of a variant whose minimum order is 10: still in stock. + expect(result.availabilityChanged).toEqual([]); + }); +}); + +describe('getOrder', () => { + it('finds an order by its number, with its lines and address', async () => { + const created = await pendingOrder([ + { variantId: 'dn50', quantity: 10 }, + { variantId: 'dn80', quantity: 2 }, + ]); + + const order = await getOrder(db(), { orderNo: created.orderNo }); + + expect(order).toMatchObject({ + id: created.id, + orderNo: created.orderNo, + status: 'pending', + currency: 'USD', + subtotalMinor: 128_800, + shippingMinor: 0, + taxMinor: 0, + totalMinor: 128_800, + email: EMAIL, + locale: 'en', + trackingNo: null, + stripePaymentIntentId: null, + shippingAddress: ADDRESS, + }); + expect(order?.lines.map((line) => line.sku)).toEqual([ + 'BV-DN50', + 'BV-DN80', + ]); + }); + + it('costs one round trip', async () => { + const created = await pendingOrder(); + + const calls = await countD1Calls(() => + getOrder(db(), { orderNo: created.orderNo }), + ); + + expect(calls).toBe(1); + }); + + it('returns null for an order that does not exist', async () => { + expect(await getOrder(db(), { orderNo: 'ND-NOPE' })).toBeNull(); + expect(await getOrder(db(), { id: 'no-such-id' })).toBeNull(); + }); + + it('survives a corrupt stored address', async () => { + // A malformed address must not make the order impossible to open. + const created = await pendingOrder(); + await db() + .prepare( + "UPDATE p_shop_order SET shipping_address = 'not json' WHERE id = ?", + ) + .bind(created.id) + .run(); + + const order = await getOrder(db(), { id: created.id }); + expect(order?.shippingAddress).toEqual({}); + }); + + it('states a lead time only when every line has one', async () => { + await db() + .prepare("UPDATE p_shop_variant SET lead_time_max = 20 WHERE id = 'dn50'") + .run(); + await db() + .prepare( + "UPDATE p_shop_variant SET lead_time_max = 35 WHERE id = 'gasket-custom'", + ) + .run(); + + const both = await pendingOrder([ + { variantId: 'dn50', quantity: 10 }, + { variantId: 'gasket-custom', quantity: 40 }, + ]); + // The slowest line decides when the order can leave. + expect((await getOrder(db(), { id: both.id }))?.leadTimeDaysMax).toBe(35); + + // dn80 states no lead time, so no promise can be made for the order. + const mixed = await pendingOrder([ + { variantId: 'dn50', quantity: 10 }, + { variantId: 'dn80', quantity: 1 }, + ]); + expect( + (await getOrder(db(), { id: mixed.id }))?.leadTimeDaysMax, + ).toBeNull(); + }); +}); diff --git a/test/shop/outbox.test.ts b/test/shop/outbox.test.ts new file mode 100644 index 0000000..3f4dfa6 --- /dev/null +++ b/test/shop/outbox.test.ts @@ -0,0 +1,227 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + markOutboxHandled, + orderChangedOutbox, + paymentRefusedOutbox, + pendingOutbox, +} from '../../src/plugins/shop/lib/outbox.js'; +import { clearShopTables, countD1Calls, db, ensureSite } from './helpers.js'; + +const T0 = new Date('2026-10-05T08:00:00.000Z'); +const T1 = new Date('2026-10-05T08:01:00.000Z'); +const T2 = new Date('2026-10-05T08:02:00.000Z'); + +/** An order row with just enough in it to be in a status. */ +async function orderIn(id: string, status: string): Promise { + await db() + .prepare( + `INSERT INTO p_shop_order + (id, order_no, status, currency, subtotal_minor, total_minor, email, + shipping_address, locale, created_at, updated_at) + VALUES (?, ?, ?, 'USD', 100, 100, 'a@example.com', '{}', 'en', ?, ?)`, + ) + .bind(id, `ND-${id}`, status, T0.toISOString(), T0.toISOString()) + .run(); +} + +async function handledAt(id: string): Promise { + const row = await db() + .prepare('SELECT handled_at FROM p_shop_outbox WHERE id = ?') + .bind(id) + .first<{ handled_at: string | null }>(); + return row?.handled_at; +} + +describe('the outbox', () => { + beforeAll(ensureSite); + beforeEach(clearShopTables); + + it('writes a row for a change only while the order is in the status it was read in', async () => { + await orderIn('o1', 'pending'); + + // The order is pending, as read: the row is written. + await orderChangedOutbox(db(), { + orderId: 'o1', + from: 'pending', + to: 'paid', + now: T1, + }).run(); + // Read as paid, which it is not: nothing is written. + await orderChangedOutbox(db(), { + orderId: 'o1', + from: 'paid', + to: 'shipped', + now: T1, + }).run(); + + expect(await pendingOutbox(db(), { limit: 10 })).toEqual([ + { + id: 'order.paid:o1', + topic: 'order.paid', + orderId: 'o1', + ref: null, + createdAt: T1.toISOString(), + }, + ]); + }); + + it('writes nothing when a further condition does not hold', async () => { + await orderIn('o1', 'pending'); + + await orderChangedOutbox(db(), { + orderId: 'o1', + from: 'pending', + to: 'oversold', + now: T1, + andIf: { sql: '? = ?', binds: ['one', 'another'] }, + }).run(); + expect(await pendingOutbox(db(), { limit: 10 })).toEqual([]); + + await orderChangedOutbox(db(), { + orderId: 'o1', + from: 'pending', + to: 'oversold', + now: T1, + andIf: { sql: '? = ?', binds: ['same', 'same'] }, + }).run(); + expect( + (await pendingOutbox(db(), { limit: 10 })).map((row) => row.id), + ).toEqual(['order.oversold:o1']); + }); + + it('keys a refused payment by its event and names the payment to refund', async () => { + await orderIn('o1', 'cancelled'); + + await paymentRefusedOutbox(db(), { + orderId: 'o1', + from: 'cancelled', + eventId: 'evt_1', + paymentIntentId: 'pi_1', + now: T1, + }).run(); + // A second, different payment sent to the same order is its own duty. + await paymentRefusedOutbox(db(), { + orderId: 'o1', + from: 'cancelled', + eventId: 'evt_2', + paymentIntentId: 'pi_2', + now: T2, + }).run(); + + expect(await pendingOutbox(db(), { limit: 10 })).toEqual([ + { + id: 'payment.refused:evt_1', + topic: 'payment.refused', + orderId: 'o1', + ref: 'pi_1', + createdAt: T1.toISOString(), + }, + { + id: 'payment.refused:evt_2', + topic: 'payment.refused', + orderId: 'o1', + ref: 'pi_2', + createdAt: T2.toISOString(), + }, + ]); + }); + + it('returns what is owed in the order it was committed, bounded, for all orders or for one', async () => { + // The times are deliberately out of step with the order of writing: a + // time is whatever the change was made with, and proves nothing about + // which change came first. + await orderIn('o1', 'pending'); + await orderIn('o2', 'pending'); + await orderIn('o3', 'pending'); + for (const [orderId, now] of [ + ['o2', T2], + ['o1', T0], + ['o3', T1], + ] as const) { + await orderChangedOutbox(db(), { + orderId, + from: 'pending', + to: 'paid', + now, + }).run(); + } + + expect( + (await pendingOutbox(db(), { limit: 10 })).map((row) => row.orderId), + ).toEqual(['o2', 'o1', 'o3']); + expect( + (await pendingOutbox(db(), { limit: 2 })).map((row) => row.orderId), + ).toEqual(['o2', 'o1']); + expect( + (await pendingOutbox(db(), { limit: 10, orderId: 'o3' })).map( + (row) => row.id, + ), + ).toEqual(['order.paid:o3']); + }); + + it('keeps an order’s changes in sequence even when they carry the same time', async () => { + // "Delivered" sorts before "paid" and "shipped" by name. A buyer told in + // that order would hear that the parcel arrived before it left. + await orderIn('o1', 'pending'); + for (const [from, to] of [ + ['pending', 'paid'], + ['paid', 'shipped'], + ['shipped', 'delivered'], + ] as const) { + await db().batch([ + orderChangedOutbox(db(), { orderId: 'o1', from, to, now: T0 }), + db() + .prepare("UPDATE p_shop_order SET status = ? WHERE id = 'o1'") + .bind(to), + ]); + } + + expect( + (await pendingOutbox(db(), { limit: 10, orderId: 'o1' })).map( + (row) => row.topic, + ), + ).toEqual(['order.paid', 'order.shipped', 'order.delivered']); + }); + + it('stops returning a row once it is marked handled, and keeps the first time', async () => { + await orderIn('o1', 'pending'); + await orderIn('o2', 'pending'); + for (const orderId of ['o1', 'o2']) { + await orderChangedOutbox(db(), { + orderId, + from: 'pending', + to: 'paid', + now: T0, + }).run(); + } + + await markOutboxHandled(db(), ['order.paid:o1'], T1); + // Handling it again — the marking is what a retry repeats — changes + // nothing, and an id that does not exist is not an error. + await markOutboxHandled(db(), ['order.paid:o1', 'order.paid:nope'], T2); + + expect( + (await pendingOutbox(db(), { limit: 10 })).map((row) => row.id), + ).toEqual(['order.paid:o2']); + expect(await handledAt('order.paid:o1')).toBe(T1.toISOString()); + expect(await handledAt('order.paid:o2')).toBeNull(); + }); + + it('marks any number of rows in one round trip, and none in none', async () => { + await orderIn('o1', 'pending'); + const ids: string[] = []; + for (const to of ['paid', 'oversold', 'cancelled'] as const) { + await orderChangedOutbox(db(), { + orderId: 'o1', + from: 'pending', + to, + now: T0, + }).run(); + ids.push(`order.${to}:o1`); + } + + expect(await countD1Calls(() => markOutboxHandled(db(), ids, T1))).toBe(1); + expect(await countD1Calls(() => markOutboxHandled(db(), [], T1))).toBe(0); + expect(await pendingOutbox(db(), { limit: 10 })).toEqual([]); + }); +}); diff --git a/test/shop/rates.test.ts b/test/shop/rates.test.ts index c221162..245ceed 100644 --- a/test/shop/rates.test.ts +++ b/test/shop/rates.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { DEFAULT_PRICING_RULES } from '../../src/plugins/shop/lib/pricing.js'; import { readRates, @@ -7,6 +7,7 @@ import { } from '../../src/plugins/shop/lib/rates.js'; import { clearShopTables, + countD1Calls, db, ensureSite, priceOf, @@ -257,31 +258,12 @@ describe('repricing', () => { await setRate('EUR', 0.92); await setRate('GBP', 0.79); - // Count every call that reaches D1, on the prototypes the real objects - // share: a statement's own execution, and the database's batch. - const statementPrototype = Object.getPrototypeOf( - db().prepare('SELECT 1'), - ) as D1PreparedStatement; - const databasePrototype = Object.getPrototypeOf(db()) as D1Database; - const spies = [ - vi.spyOn(databasePrototype, 'batch'), - vi.spyOn(statementPrototype, 'run'), - vi.spyOn(statementPrototype, 'all'), - vi.spyOn(statementPrototype, 'first'), - ]; - - let result: Awaited>; - let calls = 0; - try { + let result: Awaited> | undefined; + const calls = await countD1Calls(async () => { result = await reprice('', 50); - calls = spies.reduce((total, spy) => total + spy.mock.calls.length, 0); - } finally { - for (const spy of spies) { - spy.mockRestore(); - } - } + }); - expect(result.updated).toBe(80); + expect(result?.updated).toBe(80); expect(calls).toBe(2); }); }); diff --git a/test/shop/schema.test.ts b/test/shop/schema.test.ts index 9b293e7..b65e1fe 100644 --- a/test/shop/schema.test.ts +++ b/test/shop/schema.test.ts @@ -31,20 +31,269 @@ describe('the shop schema', () => { expect(results.map((row) => row.name)).toEqual([ 'p_shop_cart', 'p_shop_cart_line', + 'p_shop_order', + 'p_shop_order_line', + 'p_shop_outbox', 'p_shop_price', 'p_shop_rate', 'p_shop_state', + 'p_shop_stock_adjustment', + 'p_shop_stripe_event', 'p_shop_variant', ]); const applied = await db() - .prepare("SELECT id FROM migration WHERE id LIKE 'plugin:shop:%'") + .prepare( + "SELECT id FROM migration WHERE id LIKE 'plugin:shop:%' ORDER BY id", + ) .all<{ id: string }>(); expect(applied.results.map((row) => row.id)).toEqual([ 'plugin:shop:0001_shop', + 'plugin:shop:0002_orders', ]); }); + it('refuses a fractional price, stock, minimum order or cart quantity', async () => { + // SQLite would keep each of these in its INTEGER column as a real number. + // The column's own type check is what refuses them. + await createVariant({ id: 'v-types', productGroup: 'g' }); + const now = '2026-10-01T00:00:00.000Z'; + + await expect( + db() + .prepare("UPDATE p_shop_variant SET stock = 5.5 WHERE id = 'v-types'") + .run(), + ).rejects.toThrow(/CHECK/i); + await expect( + db() + .prepare("UPDATE p_shop_variant SET moq = 1.5 WHERE id = 'v-types'") + .run(), + ).rejects.toThrow(/CHECK/i); + await expect( + db() + .prepare( + `INSERT INTO p_shop_price + (variant_id, currency, amount_minor, source, updated_at) + VALUES ('v-types', 'USD', 99.5, 'base', ?)`, + ) + .bind(now) + .run(), + ).rejects.toThrow(/CHECK/i); + await expect( + db() + .prepare( + `INSERT INTO p_shop_cart_line (cart_id, variant_id, quantity) + VALUES ('c-types', 'v-types', 2.5)`, + ) + .run(), + ).rejects.toThrow(/CHECK/i); + + // A whole number written as text, the way a form field arrives, is + // converted by the column and accepted. + await db() + .prepare("UPDATE p_shop_variant SET stock = '7' WHERE id = 'v-types'") + .run(); + const row = await db() + .prepare( + "SELECT stock, typeof(stock) AS kind FROM p_shop_variant WHERE id = 'v-types'", + ) + .first<{ stock: number; kind: string }>(); + expect(row).toEqual({ stock: 7, kind: 'integer' }); + }); + + it('stores no card data and no price a client could have sent on an order', async () => { + // What an order may hold is listed here on purpose: a new column has to + // be added to this list by someone who has thought about what it stores. + const columns = async (table: string): Promise => { + const { results } = await db() + .prepare(`SELECT name FROM pragma_table_info('${table}')`) + .all<{ name: string }>(); + return results.map((row) => row.name).sort(); + }; + + expect(await columns('p_shop_order')).toEqual([ + 'created_at', + 'currency', + 'email', + 'id', + 'locale', + 'order_no', + 'shipping_address', + 'shipping_minor', + 'status', + 'stripe_payment_intent_id', + 'stripe_session_id', + 'subtotal_minor', + 'tax_minor', + 'total_minor', + 'tracking_no', + 'updated_at', + ]); + expect(await columns('p_shop_order_line')).toEqual([ + 'id', + 'name_snapshot', + 'order_id', + 'quantity', + 'sku_snapshot', + 'unit_price_minor', + 'variant_id', + ]); + }); + + it('lets one payment belong to one order only', async () => { + const order = (id: string, paymentIntent: string | null) => + db() + .prepare( + `INSERT INTO p_shop_order + (id, order_no, status, currency, subtotal_minor, total_minor, + stripe_payment_intent_id, email, shipping_address, locale, + created_at, updated_at) + VALUES (?, ?, 'pending', 'USD', 100, 100, ?, 'a@example.com', '{}', + 'en', '2026-10-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z')`, + ) + .bind(id, `ND-${id}`, paymentIntent) + .run(); + + // Any number of orders may be waiting for a payment… + await order('o1', null); + await order('o2', null); + // …but a payment that settled one order cannot settle another. + await order('o3', 'pi_1'); + await expect(order('o4', 'pi_1')).rejects.toThrow(/UNIQUE/i); + }); + + it('holds one line per variant on an order', async () => { + // The payment write reads a line's quantity by order and variant and + // relies on finding exactly one. + const line = (id: string) => + db() + .prepare( + `INSERT INTO p_shop_order_line + (id, order_id, variant_id, sku_snapshot, name_snapshot, + unit_price_minor, quantity) + VALUES (?, 'o1', 'v1', 'SKU', 'Name', 100, 1)`, + ) + .bind(id) + .run(); + + await line('first'); + await expect(line('second')).rejects.toThrow(/UNIQUE/i); + }); + + it('refuses order money that is not a whole number of minor units', async () => { + // SQLite keeps 99.5 in an INTEGER column as a real number rather than + // refuse it. Each money column checks its own storage type instead. + const line = (unitPrice: number, quantity: number) => + db() + .prepare( + `INSERT INTO p_shop_order_line + (id, order_id, variant_id, sku_snapshot, name_snapshot, + unit_price_minor, quantity) + VALUES ('l-' || ? || '-' || ?, 'o-types', 'v-' || ? || '-' || ?, + 'SKU', 'Name', ?, ?)`, + ) + .bind(unitPrice, quantity, unitPrice, quantity, unitPrice, quantity) + .run(); + const order = (subtotal: number, shipping: number) => + db() + .prepare( + `INSERT INTO p_shop_order + (id, order_no, currency, subtotal_minor, shipping_minor, + total_minor, email, shipping_address, locale, created_at, + updated_at) + VALUES ('o-' || ? || '-' || ?, 'ND-' || ? || '-' || ?, 'USD', ?, ?, + ?, 'a@example.com', '{}', 'en', + '2026-10-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z')`, + ) + .bind( + subtotal, + shipping, + subtotal, + shipping, + subtotal, + shipping, + subtotal + shipping, + ) + .run(); + + await line(9900, 10); + await expect(line(99.5, 10)).rejects.toThrow(/CHECK/i); + await expect(line(9900, 1.5)).rejects.toThrow(/CHECK/i); + + await order(9900, 500); + await expect(order(99.5, 0)).rejects.toThrow(/CHECK/i); + await expect(order(9900, 0.5)).rejects.toThrow(/CHECK/i); + // Two halves that add up to a whole total: only the parts' own checks + // can refuse this one. + await expect(order(99.5, 0.5)).rejects.toThrow(/CHECK/i); + + await expect( + db() + .prepare( + `INSERT INTO p_shop_stock_adjustment + (id, variant_id, delta, reason, created_at) + VALUES ('half', 'v1', -1.5, 'manual', '2026-10-01T00:00:00.000Z')`, + ) + .run(), + ).rejects.toThrow(/CHECK/i); + }); + + it('acts on one payment once, whatever id its event carries', async () => { + // Stripe can send two events about one object. The record is unique on + // the event's type and the payment, so the second cannot be written. + const event = (eventId: string, type: string, paymentIntent: string) => + db() + .prepare( + `INSERT INTO p_shop_stripe_event + (event_id, type, order_id, payment_intent_id, outcome, + processed_at) + VALUES (?, ?, 'o1', ?, 'paid', '2026-10-01T00:00:00.000Z')`, + ) + .bind(eventId, type, paymentIntent) + .run(); + + await event('evt_1', 'payment_intent.succeeded', 'pi_1'); + await expect( + event('evt_2', 'payment_intent.succeeded', 'pi_1'), + ).rejects.toThrow(/UNIQUE/i); + await expect( + event('evt_1', 'payment_intent.succeeded', 'pi_2'), + ).rejects.toThrow(/UNIQUE|PRIMARY/i); + // A different kind of event about the same payment is a different fact. + await event('evt_3', 'charge.refunded', 'pi_1'); + }); + + it('holds one outbox row per change, so a duty cannot be written twice', async () => { + const row = () => + db() + .prepare( + `INSERT INTO p_shop_outbox (id, topic, order_id, created_at) + VALUES ('order.paid:o1', 'order.paid', 'o1', + '2026-10-01T00:00:00.000Z')`, + ) + .run(); + + await row(); + await expect(row()).rejects.toThrow(/UNIQUE|PRIMARY/i); + }); + + it('records one stock movement per order, variant and reason', async () => { + // The ledger row's id is built from what it records, so writing the same + // movement twice fails — and takes the batch it is in with it. + const movement = () => + db() + .prepare( + `INSERT INTO p_shop_stock_adjustment + (id, variant_id, delta, reason, ref_id, created_at) + VALUES ('order_paid:o1:v1', 'v1', -10, 'order_paid', 'o1', + '2026-10-01T00:00:00.000Z')`, + ) + .run(); + + await movement(); + await expect(movement()).rejects.toThrow(/UNIQUE|PRIMARY/i); + }); + it('stores no price on a cart line', async () => { // A cart holds variants and quantities only. A price column here would be // a place for a client-supplied amount to hide. diff --git a/test/shop/stripe-client.test.ts b/test/shop/stripe-client.test.ts new file mode 100644 index 0000000..0ecd7fa --- /dev/null +++ b/test/shop/stripe-client.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest'; +import { + createCheckoutSession, + createRefund, +} from '../../src/plugins/shop/lib/stripe-client.js'; + +interface Captured { + readonly url: string; + readonly init: RequestInit; +} + +/** A `fetch` that records what it was asked and answers with fixed JSON. */ +function recordingFetch(response: unknown, status = 200) { + const calls: Captured[] = []; + const impl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return new Response(JSON.stringify(response), { + status, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch; + + return { impl, calls }; +} + +function only(calls: readonly Captured[]): Captured { + const [call] = calls; + if (call === undefined || calls.length !== 1) { + throw new Error(`Expected one request, saw ${calls.length}`); + } + return call; +} + +function bodyOf(call: Captured): URLSearchParams { + return new URLSearchParams(String(call.init.body)); +} + +function headersOf(call: Captured): Record { + return call.init.headers as Record; +} + +const SESSION = { + amountMinor: 99_000, + currency: 'USD' as const, + orderId: 'order-1', + orderNo: 'ND-261005-7K3M9QXA', + productName: 'Order ND-261005-7K3M9QXA', + successUrl: 'https://shop.example/ok', + cancelUrl: 'https://shop.example/cart', +}; + +describe('createCheckoutSession', () => { + it('charges the server-computed total as a single line item', async () => { + const { impl, calls } = recordingFetch({ + id: 'cs_1', + url: 'https://checkout.stripe.com/c/pay/cs_1', + }); + + const session = await createCheckoutSession('sk_test_123', SESSION, impl); + + const call = only(calls); + const body = bodyOf(call); + expect(call.url).toBe('https://api.stripe.com/v1/checkout/sessions'); + expect(call.init.method).toBe('POST'); + expect(body.get('mode')).toBe('payment'); + expect(body.get('line_items[0][price_data][unit_amount]')).toBe('99000'); + expect(body.get('line_items[0][price_data][currency]')).toBe('usd'); + expect(body.get('line_items[0][quantity]')).toBe('1'); + expect(session).toEqual({ + id: 'cs_1', + url: 'https://checkout.stripe.com/c/pay/cs_1', + }); + }); + + it('puts the order id on the payment intent, which is what the webhook reads', async () => { + const { impl, calls } = recordingFetch({ id: 'cs_1', url: 'https://x' }); + + await createCheckoutSession( + 'sk_test_123', + { ...SESSION, orderId: 'order-99', orderNo: 'ND-9' }, + impl, + ); + + // The webhook receives `payment_intent.succeeded`. Without the order id + // in the intent's own metadata it could not find the order to mark paid. + const body = bodyOf(only(calls)); + expect(body.get('payment_intent_data[metadata][order_id]')).toBe( + 'order-99', + ); + expect(body.get('payment_intent_data[metadata][order_no]')).toBe('ND-9'); + expect(body.get('metadata[order_id]')).toBe('order-99'); + }); + + it('keys idempotency on the order, so a double submit reuses the session', async () => { + const { impl, calls } = recordingFetch({ id: 'cs_1', url: 'https://x' }); + + await createCheckoutSession( + 'sk_test_123', + { ...SESSION, orderId: 'order-3' }, + impl, + ); + + const headers = headersOf(only(calls)); + expect(headers['Idempotency-Key']).toBe('checkout:order-3'); + expect(headers.Authorization).toBe('Bearer sk_test_123'); + }); + + it('prefills the buyer’s email only when one is given', async () => { + const without = recordingFetch({ id: 'cs_1', url: 'https://x' }); + await createCheckoutSession('sk_test_123', SESSION, without.impl); + expect(bodyOf(only(without.calls)).has('customer_email')).toBe(false); + + const withEmail = recordingFetch({ id: 'cs_1', url: 'https://x' }); + await createCheckoutSession( + 'sk_test_123', + { ...SESSION, customerEmail: 'buyer@example.com' }, + withEmail.impl, + ); + expect(bodyOf(only(withEmail.calls)).get('customer_email')).toBe( + 'buyer@example.com', + ); + }); + + it('names what Stripe refused by its identifiers, never by its free text', async () => { + // The message is free text written for a person, and nothing says it + // will not repeat a value that was sent. One of those is the buyer's + // email address, and this text ends up in the logs. + const { impl } = recordingFetch( + { + error: { + type: 'invalid_request_error', + code: 'email_invalid', + param: 'customer_email', + message: 'Invalid email address: buyer@example.com', + request_log_url: 'https://dashboard.stripe.com/logs/req_secret', + }, + }, + 400, + ); + + const failure = await createCheckoutSession( + 'sk_test_123', + { ...SESSION, customerEmail: 'buyer@example.com' }, + impl, + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe( + 'Stripe: invalid_request_error, email_invalid, customer_email, status 400', + ); + }); + + it('leaves out anything in an error that is not shaped like an identifier', async () => { + const { impl } = recordingFetch( + { + error: { + type: 'api_error', + code: 'not a code: buyer@example.com', + param: { nested: 'object' }, + }, + }, + 500, + ); + + await expect( + createCheckoutSession('sk_test_123', SESSION, impl), + ).rejects.toThrow(/^Stripe: api_error, status 500$/); + }); + + it('still says which status came back when Stripe explains nothing', async () => { + const { impl } = recordingFetch({}, 500); + + await expect( + createCheckoutSession('sk_test_123', SESSION, impl), + ).rejects.toThrow(/^Stripe: status 500$/); + }); + + it('never asks Stripe to charge nothing, or a fraction of a minor unit', async () => { + // A session for a total of zero creates no payment intent, so the event + // that marks an order paid would never arrive. + const { impl, calls } = recordingFetch({ id: 'cs_1', url: 'https://x' }); + + for (const amountMinor of [0, -100, 99.5, Number.NaN]) { + await expect( + createCheckoutSession('sk_test_123', { ...SESSION, amountMinor }, impl), + ).rejects.toThrow(/positive amount/); + } + expect(calls).toHaveLength(0); + }); + + it('throws rather than crash when the answer is not JSON at all', async () => { + // An outage page from a proxy in between is HTML, not Stripe's JSON. + const impl = (async () => + new Response('Bad gateway', { + status: 502, + })) as unknown as typeof fetch; + + await expect( + createCheckoutSession('sk_test_123', SESSION, impl), + ).rejects.toThrow(/502/); + }); +}); + +describe('createRefund', () => { + it('refunds against the payment intent', async () => { + const { impl, calls } = recordingFetch({ id: 're_1', status: 'succeeded' }); + + const refund = await createRefund( + 'sk_test_123', + { paymentIntentId: 'pi_9' }, + impl, + ); + + const call = only(calls); + expect(call.url).toBe('https://api.stripe.com/v1/refunds'); + expect(bodyOf(call).get('payment_intent')).toBe('pi_9'); + expect(bodyOf(call).has('reason')).toBe(false); + expect(refund).toEqual({ id: 're_1', status: 'succeeded' }); + }); + + it('keys idempotency on the payment intent, so a retry cannot refund twice', async () => { + const { impl, calls } = recordingFetch({ id: 're_1', status: 'succeeded' }); + + await createRefund( + 'sk_test_123', + { paymentIntentId: 'pi_9', reason: 'requested_by_customer' }, + impl, + ); + + const call = only(calls); + expect(headersOf(call)['Idempotency-Key']).toBe('refund:pi_9'); + expect(bodyOf(call).get('reason')).toBe('requested_by_customer'); + }); +}); diff --git a/test/shop/stripe-signature.test.ts b/test/shop/stripe-signature.test.ts new file mode 100644 index 0000000..cb59178 --- /dev/null +++ b/test/shop/stripe-signature.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { verifyStripeSignature } from '../../src/plugins/shop/lib/stripe-signature.js'; +import { signStripePayload } from './helpers.js'; + +const SECRET = 'whsec_test_secret'; +const PAYLOAD = '{"id":"evt_1","type":"payment_intent.succeeded"}'; + +async function header(payload: string, offsetSeconds = 0, secret = SECRET) { + const timestamp = Math.floor(Date.now() / 1000) + offsetSeconds; + return `t=${timestamp},v1=${await signStripePayload(payload, timestamp, secret)}`; +} + +describe('verifyStripeSignature', () => { + it('accepts a correctly signed payload', async () => { + const result = await verifyStripeSignature( + PAYLOAD, + await header(PAYLOAD), + SECRET, + ); + expect(result.ok).toBe(true); + }); + + it('rejects a payload that was altered after signing', async () => { + const signature = await header(PAYLOAD); + const result = await verifyStripeSignature( + PAYLOAD.replace('evt_1', 'evt_2'), + signature, + SECRET, + ); + + expect(result).toEqual({ ok: false, reason: 'Signature mismatch' }); + }); + + it('rejects a signature made with a different secret', async () => { + const result = await verifyStripeSignature( + PAYLOAD, + await header(PAYLOAD, 0, 'whsec_wrong'), + SECRET, + ); + expect(result.ok).toBe(false); + }); + + it('rejects a stale timestamp, so a captured request cannot be replayed', async () => { + const result = await verifyStripeSignature( + PAYLOAD, + await header(PAYLOAD, -60 * 60), + SECRET, + ); + + expect(result).toEqual({ + ok: false, + reason: 'Signature timestamp outside tolerance', + }); + }); + + it('rejects a timestamp far in the future', async () => { + const result = await verifyStripeSignature( + PAYLOAD, + await header(PAYLOAD, 60 * 60), + SECRET, + ); + expect(result.ok).toBe(false); + }); + + it('accepts a timestamp just inside the five-minute tolerance', async () => { + const result = await verifyStripeSignature( + PAYLOAD, + await header(PAYLOAD, -(5 * 60 - 5)), + SECRET, + ); + expect(result.ok).toBe(true); + }); + + it('rejects a malformed header', async () => { + for (const bad of ['', 'garbage', 't=abc,v1=def', 'v1=onlysignature']) { + const result = await verifyStripeSignature(PAYLOAD, bad, SECRET); + expect(result.ok).toBe(false); + } + }); + + it('reads the timestamp as digits and nothing else', async () => { + // Each of these is the right moment written another way, signed over the + // text exactly as written, so only the parsing of `t` decides. + const now = Math.floor(Date.now() / 1000); + for (const written of [ + `${now}.0`, + `${now / 1000}e3`, + `0x${now.toString(16)}`, + ` ${now}=x`, + `+${now}`, + ]) { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const mac = new Uint8Array( + await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(`${now}.${PAYLOAD}`), + ), + ); + const signature = [...mac] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + + const result = await verifyStripeSignature( + PAYLOAD, + `t=${written},v1=${signature}`, + SECRET, + now, + ); + + expect(result, written).toEqual({ + ok: false, + reason: 'Malformed Stripe-Signature header', + }); + } + }); + + it('ignores every scheme but v1, so a downgrade cannot pass', async () => { + // Stripe sends a `v0` signature alongside `v1` on test events. A header + // whose only correct signature sits under another scheme must not verify. + const timestamp = Math.floor(Date.now() / 1000); + const good = await signStripePayload(PAYLOAD, timestamp, SECRET); + + const result = await verifyStripeSignature( + PAYLOAD, + `t=${timestamp},v0=${good}`, + SECRET, + ); + expect(result.ok).toBe(false); + }); + + it('accepts a header carrying several v1 signatures during a secret rotation', async () => { + const timestamp = Math.floor(Date.now() / 1000); + const good = await signStripePayload(PAYLOAD, timestamp, SECRET); + const other = await signStripePayload(PAYLOAD, timestamp, 'whsec_other'); + + const result = await verifyStripeSignature( + PAYLOAD, + `t=${timestamp},v1=${other},v1=${good}`, + SECRET, + ); + expect(result.ok).toBe(true); + }); + + it('refuses to verify when no secret is configured', async () => { + // An empty secret must never mean "accept anything": HMAC with an empty + // key is a value anyone can compute. + const timestamp = Math.floor(Date.now() / 1000); + const forged = await signStripePayload(PAYLOAD, timestamp, ''); + + const result = await verifyStripeSignature( + PAYLOAD, + `t=${timestamp},v1=${forged}`, + '', + ); + + expect(result).toEqual({ + ok: false, + reason: 'No webhook secret configured', + }); + }); +}); diff --git a/test/shop/stripe-webhook.test.ts b/test/shop/stripe-webhook.test.ts new file mode 100644 index 0000000..011e8e6 --- /dev/null +++ b/test/shop/stripe-webhook.test.ts @@ -0,0 +1,433 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { priceCart } from '../../src/plugins/shop/lib/cart-pricing.js'; +import { createPendingOrder } from '../../src/plugins/shop/lib/orders.js'; +import { handleStripeWebhook } from '../../src/plugins/shop/lib/stripe-webhook.js'; +import { + clearShopTables, + createProduct, + createVariant, + db, + ensureSite, + outboxRows, + setPrice, + setStock, + signStripePayload, + stockOf, + type TestProduct, +} from './helpers.js'; + +const SECRET = 'whsec_test_secret'; +const NOW = new Date('2026-10-05T08:00:00.000Z'); +const BUYER_EMAIL = 'jane@example.com'; + +let valve: TestProduct; + +async function pendingOrder(): Promise<{ id: string; orderNo: string }> { + const cart = await priceCart(db(), { + lines: [{ variantId: 'dn50', quantity: 10 }], + locale: 'en', + defaultLocale: 'en', + currency: 'USD', + }); + if (!cart.ok) { + throw new Error(`The fixture cart did not price: ${JSON.stringify(cart)}`); + } + return createPendingOrder(db(), { + cart, + locale: 'en', + email: BUYER_EMAIL, + shippingAddress: { + recipient: 'Jane Buyer', + line1: '1 Harbour Road', + city: 'Aberdeen', + postalCode: 'AB11 5RY', + country: 'GB', + }, + now: NOW, + }); +} + +/** The event Stripe sends when a payment succeeds, for one of our orders. */ +function paymentSucceeded( + orderId: string | null, + overrides: { eventId?: string; paymentIntentId?: string } = {}, +): unknown { + return { + id: overrides.eventId ?? 'evt_1', + object: 'event', + type: 'payment_intent.succeeded', + data: { + object: { + id: overrides.paymentIntentId ?? 'pi_1', + object: 'payment_intent', + amount: 99_000, + currency: 'usd', + metadata: orderId === null ? {} : { order_id: orderId }, + }, + }, + }; +} + +/** Delivers a body as Stripe would: signed, with the time it was signed at. */ +async function deliver( + body: string, + options: { + signedAt?: Date; + receivedAt?: Date; + signingSecret?: string; + configuredSecret?: string; + } = {}, +): ReturnType { + const signedAt = options.signedAt ?? NOW; + const timestamp = Math.floor(signedAt.getTime() / 1000); + const signature = await signStripePayload( + body, + timestamp, + options.signingSecret ?? SECRET, + ); + return handleStripeWebhook(db(), { + rawBody: body, + signatureHeader: `t=${timestamp},v1=${signature}`, + secret: options.configuredSecret ?? SECRET, + now: options.receivedAt ?? signedAt, + }); +} + +async function statusOf(orderId: string): Promise { + const row = await db() + .prepare('SELECT status FROM p_shop_order WHERE id = ?') + .bind(orderId) + .first<{ status: string }>(); + return row?.status; +} + +async function eventCount(): Promise { + const row = await db() + .prepare('SELECT COUNT(*) AS n FROM p_shop_stripe_event') + .first<{ n: number }>(); + return row?.n ?? 0; +} + +beforeAll(async () => { + await ensureSite(); + valve = await createProduct({ + title: 'Stainless ball valve', + slug: 'webhook-ball-valve', + }); +}); + +beforeEach(async () => { + await clearShopTables(); + await createVariant({ + id: 'dn50', + productGroup: valve.translationGroup, + sku: 'BV-DN50', + moq: 10, + stock: 100, + }); + await setPrice({ + variantId: 'dn50', + currency: 'USD', + amountMinor: 9900, + source: 'base', + }); +}); + +describe('handleStripeWebhook', () => { + it('marks the order paid for a correctly signed payment event', async () => { + const order = await pendingOrder(); + + const outcome = await deliver(JSON.stringify(paymentSucceeded(order.id))); + + expect(outcome).toEqual({ + status: 200, + kind: 'paid', + orderId: order.id, + availabilityChanged: [], + }); + expect(await statusOf(order.id)).toBe('paid'); + expect(await stockOf('dn50')).toBe(90); + // What follows the payment is owed from this row, not from the outcome. + expect((await outboxRows()).map((row) => row.id)).toEqual([ + `order.paid:${order.id}`, + ]); + }); + + it('refuses a forged event, and changes nothing', async () => { + // Without this check anyone could post "payment succeeded" and be sent + // the goods for free. + const order = await pendingOrder(); + + const outcome = await deliver(JSON.stringify(paymentSucceeded(order.id)), { + signingSecret: 'whsec_attacker_guess', + }); + + expect(outcome).toEqual({ + status: 400, + kind: 'rejected', + reason: 'Signature mismatch', + }); + expect(await statusOf(order.id)).toBe('pending'); + expect(await stockOf('dn50')).toBe(100); + expect(await eventCount()).toBe(0); + expect(await outboxRows()).toEqual([]); + }); + + it('refuses a body that was changed after Stripe signed it', async () => { + // A real, signed event for someone else's order, pointed at this one. + const order = await pendingOrder(); + const signedBody = JSON.stringify(paymentSucceeded('some-other-order')); + const timestamp = Math.floor(NOW.getTime() / 1000); + const signature = await signStripePayload(signedBody, timestamp, SECRET); + + const outcome = await handleStripeWebhook(db(), { + rawBody: signedBody.replace('some-other-order', order.id), + signatureHeader: `t=${timestamp},v1=${signature}`, + secret: SECRET, + now: NOW, + }); + + expect(outcome.status).toBe(400); + expect(await statusOf(order.id)).toBe('pending'); + }); + + it('refuses a delivery replayed after the tolerance has passed', async () => { + const order = await pendingOrder(); + + const outcome = await deliver(JSON.stringify(paymentSucceeded(order.id)), { + signedAt: NOW, + receivedAt: new Date(NOW.getTime() + 60 * 60 * 1000), + }); + + expect(outcome).toEqual({ + status: 400, + kind: 'rejected', + reason: 'Signature timestamp outside tolerance', + }); + expect(await statusOf(order.id)).toBe('pending'); + }); + + it('refuses everything while no signing secret is configured', async () => { + const order = await pendingOrder(); + + const outcome = await deliver(JSON.stringify(paymentSucceeded(order.id)), { + configuredSecret: '', + }); + + expect(outcome.status).toBe(400); + expect(await statusOf(order.id)).toBe('pending'); + }); + + it('refuses a signed body that is not JSON', async () => { + const outcome = await deliver('not json at all'); + + expect(outcome).toEqual({ + status: 400, + kind: 'rejected', + reason: 'Malformed payload', + }); + }); + + it('refuses signed JSON that is not an event', async () => { + const outcome = await deliver(JSON.stringify({ hello: 'world' })); + + expect(outcome).toEqual({ + status: 400, + kind: 'rejected', + reason: 'Malformed payload', + }); + }); + + it('refuses a payment event that carries no payment', async () => { + // Waving it through as "not ours" would swallow a real payment in + // silence if the event ever arrived in a shape this code cannot read. + const order = await pendingOrder(); + + const outcome = await deliver( + JSON.stringify({ + id: 'evt_odd', + type: 'payment_intent.succeeded', + data: { object: { metadata: { order_id: order.id } } }, + }), + ); + + expect(outcome).toEqual({ + status: 400, + kind: 'rejected', + reason: 'Malformed payload', + }); + expect(await statusOf(order.id)).toBe('pending'); + }); + + it('answers 200 to an event type it does not act on', async () => { + // Anything but a 2xx makes Stripe deliver the event again, for days. + const order = await pendingOrder(); + + const outcome = await deliver( + JSON.stringify({ + id: 'evt_other', + type: 'charge.succeeded', + data: { object: { id: 'ch_1', metadata: { order_id: order.id } } }, + }), + ); + + expect(outcome.status).toBe(200); + expect(outcome.kind).toBe('ignored'); + expect(await statusOf(order.id)).toBe('pending'); + expect(await eventCount()).toBe(0); + }); + + it('answers 200 to a payment that is not for one of its orders', async () => { + // The same Stripe account may take payments for something else: an + // invoice, a payment link. Those carry no order id and are not failures. + const outcome = await deliver(JSON.stringify(paymentSucceeded(null))); + + expect(outcome.status).toBe(200); + expect(outcome.kind).toBe('ignored'); + expect(await eventCount()).toBe(0); + }); + + it('acts once when the same signed delivery is replayed inside the tolerance', async () => { + // The timestamp check cannot stop a replay made within five minutes. + // The record of processed events does. + const order = await pendingOrder(); + const body = JSON.stringify(paymentSucceeded(order.id)); + + await deliver(body); + const replay = await deliver(body, { + signedAt: NOW, + receivedAt: new Date(NOW.getTime() + 60 * 1000), + }); + + expect(replay).toEqual({ + status: 200, + kind: 'duplicate', + orderId: order.id, + orderStatus: 'paid', + }); + expect(await stockOf('dn50')).toBe(90); + }); + + it('acts once when Stripe redelivers the event with a fresh signature', async () => { + const order = await pendingOrder(); + const body = JSON.stringify(paymentSucceeded(order.id)); + + await deliver(body); + const redelivery = await deliver(body, { + signedAt: new Date(NOW.getTime() + 30 * 60 * 1000), + }); + + expect(redelivery.kind).toBe('duplicate'); + expect(await stockOf('dn50')).toBe(90); + expect(await eventCount()).toBe(1); + }); + + it('answers 200 for an oversold order, so Stripe stops and a person takes over', async () => { + const order = await pendingOrder(); + await setStock('dn50', 2); + + const outcome = await deliver(JSON.stringify(paymentSucceeded(order.id))); + + expect(outcome).toEqual({ + status: 200, + kind: 'oversold', + orderId: order.id, + }); + expect(await statusOf(order.id)).toBe('oversold'); + expect(await stockOf('dn50')).toBe(2); + }); + + it('reports the product whose availability the payment changed', async () => { + await setStock('dn50', 15); + const order = await pendingOrder(); + + const outcome = await deliver(JSON.stringify(paymentSucceeded(order.id))); + + expect(outcome).toMatchObject({ + kind: 'paid', + availabilityChanged: [valve.translationGroup], + }); + }); + + it('answers 200 for a payment naming an order this shop does not have', async () => { + // Another shop on the same Stripe account, a staging copy, a local + // `stripe listen`: each sees every payment of the account. Delivering + // this one again for three days would never make the order appear. + const outcome = await deliver( + JSON.stringify(paymentSucceeded('no-such-order')), + ); + + expect(outcome).toEqual({ + status: 200, + kind: 'unmatched', + orderId: 'no-such-order', + }); + expect(await eventCount()).toBe(0); + expect(await outboxRows()).toEqual([]); + }); + + it('answers 200 for a payment on a cancelled order, and puts it on record for a refund', async () => { + // Money taken for an order that cannot take it. A retry cannot change + // that, so it does not answer 5xx; and it must not vanish, so it is + // written down. + const order = await pendingOrder(); + await db() + .prepare("UPDATE p_shop_order SET status = 'cancelled' WHERE id = ?") + .bind(order.id) + .run(); + + const outcome = await deliver( + JSON.stringify( + paymentSucceeded(order.id, { paymentIntentId: 'pi_late' }), + ), + ); + + expect(outcome).toEqual({ + status: 200, + kind: 'refused', + orderId: order.id, + orderStatus: 'cancelled', + }); + expect(await statusOf(order.id)).toBe('cancelled'); + expect(await stockOf('dn50')).toBe(100); + expect(await outboxRows()).toEqual([ + { + id: 'payment.refused:evt_1', + topic: 'payment.refused', + order_id: order.id, + ref: 'pi_late', + handled_at: null, + }, + ]); + }); + + it('answers 500 only when delivering again could help, without personal data in the reason', async () => { + // The order table gone is the stand-in for a database that cannot be + // reached: the one kind of failure a later delivery can get past. + const order = await pendingOrder(); + await db() + .prepare('ALTER TABLE p_shop_order RENAME TO p_shop_order_away') + .run(); + + let outcome: Awaited>; + try { + outcome = await deliver(JSON.stringify(paymentSucceeded(order.id))); + } finally { + await db() + .prepare('ALTER TABLE p_shop_order_away RENAME TO p_shop_order') + .run(); + } + + expect(outcome.status).toBe(500); + expect(outcome.kind).toBe('failed'); + // The reason goes to the logs. + expect(JSON.stringify(outcome)).not.toContain(BUYER_EMAIL); + expect(JSON.stringify(outcome)).not.toContain('Harbour Road'); + expect(await statusOf(order.id)).toBe('pending'); + expect(await stockOf('dn50')).toBe(100); + + // And the delivery Stripe then repeats goes through. + const again = await deliver(JSON.stringify(paymentSucceeded(order.id))); + expect(again.kind).toBe('paid'); + }); +});