Public: anyone can read this; the crew can change it.
The webhook settles only the session its own order started #70
ledger/bazaar:ledger/33-the-webhook-settles-only-the-session-its → main 4 commits fork 1 thread · 1 unresolved
11 files
changed +440
−28
README.md +28 −4
| @@ -302,6 +302,24 @@ an idempotency key, so it is recorded once. Refunds go to Stripe first and to | ||
| 302 | 302 | the ledger second, with an idempotency key built from the order and what has |
| 303 | 303 | already been refunded. |
| 304 | 304 | |
| 305 | An order settles for the Checkout Session it started and for no other. That | |
| 306 | session is kept on the order in `checkout_session_id`, beside `payment_ref`, | |
| 307 | which becomes the payment intent the moment the order settles — so a webhook | |
| 308 | Stripe replays still names a session the order recognises. A delivery naming | |
| 309 | any other session (another order's, an expired one from before the price | |
| 310 | changed, one made by hand in the same Stripe account) settles nothing: it is | |
| 311 | written on the order as an `unmatched` event naming the session that arrived, | |
| 312 | answered `200` so Stripe stops retrying, and shown to an admin beside the | |
| 313 | payments, on `/admin/audit` with the rest of the money trail, and on the orders | |
| 314 | tab of the terminal. It says whose hand it was like every other ledger row | |
| 315 | here: `stripe` when a webhook brought it unasked, and the account paying when | |
| 316 | it was a shopper's return that read the session. A delivery for an order number | |
| 317 | this shop does not have is answered `200` and logged, with nothing written to | |
| 318 | the database — an order number is not a secret, and naming one is not | |
| 319 | permission to write a row. The shopper's return is held to the same rule: the | |
| 320 | session Stripe answers with has to be the one that was asked for and has to | |
| 321 | still name this order. | |
| 322 | ||
| 305 | 323 | Both of those paths, and the mock gateway, settle through one function — |
| 306 | 324 | `orders.settleFromGateway` — so there is one rule about what an amount means. |
| 307 | 325 | The gateway's amount is recorded, never believed: the money that moved is a |
| @@ -331,7 +349,8 @@ on `127.0.0.1` — the three endpoints this app calls, nothing else — points | ||
| 331 | 349 | `STRIPE_API_BASE` at it and boots the app in its own process against it, then |
| 332 | 350 | asserts what went down the wire: the amounts, the currency, the idempotency |
| 333 | 351 | keys, the shopper's return, the webhook arriving before and after it, a |
| 334 | return and a webhook that pay less than the order owes, a refund, Stripe | |
| 352 | return and a webhook that pay less than the order owes, a webhook naming | |
| 353 | another order's session and one naming no order at all, a refund, Stripe | |
| 335 | 354 | answering 500, Stripe stopped, and a live key refused with no request made at |
| 336 | 355 | all. No test in this repository talks to the network. |
| 337 | 356 | |
| @@ -469,7 +488,11 @@ what it wrote, and the page listens. | ||
| 469 | 488 | the branch that actually moves the row to `paid` says `order.paid`: a |
| 470 | 489 | charge that falls short of what the order owes is written to the ledger and |
| 471 | 490 | leaves the order awaiting payment, and the admin's screen is told nothing, |
| 472 | because a screen that said paid there would be lying about the money. Every call | |
| 491 | because a screen that said paid there would be lying about the money. A | |
| 492 | delivery naming a session the order never started says `order.unmatched`, | |
| 493 | once per session named: it moved no money, but it is what an admin should | |
| 494 | see arrive rather than find on the next reload, and a delivery Stripe | |
| 495 | retries wrote no row, so it says nothing a second time. Every call | |
| 473 | 496 | happens **after** the transaction commits. A stock movement made inside a |
| 474 | 497 | bigger transaction (an order taking its stock) is announced by whoever |
| 475 | 498 | commits that transaction, because a rollback would otherwise have told an |
| @@ -492,8 +515,9 @@ Live pages: **`/admin`** (the totals and the latest orders), **`/admin/orders`** | ||
| 492 | 515 | **`/admin/inventory`** (both tables — stock on hand and the ledger) and |
| 493 | 516 | **`/admin/orders/<number>`** (one order, keyed to its number: the status, the |
| 494 | 517 | totals, what is left to refund, the payments and the history move together as |
| 495 | a payment settles or a refund goes back). Their tables have fixed columns, so | |
| 496 | a row arriving cannot move the rows around it. No other page loads a script, | |
| 518 | a payment settles, a refund typed at the terminal goes back, or a delivery | |
| 519 | naming a session this order never started arrives). Their tables have fixed | |
| 520 | columns, so a row arriving cannot move the rows around it. No other page loads a script, | |
| 497 | 521 | and none of them has an inline one: the floor's `script-src` is `'self'`, with |
| 498 | 522 | no `unsafe-inline` and no `unsafe-eval`, and `static/live.js` is served under a |
| 499 | 523 | name made from its own contents (`/static/live.<hash>.js`) with a year-long |
src/admin.ts +3
| @@ -365,6 +365,9 @@ export function orderDetail(ctx: Ctx): Response { | ||
| 365 | 365 | if (!order) return notFound(ctx); |
| 366 | 366 | return render( |
| 367 | 367 | ctx, |
| 368 | // Live because the money can move while this page is open: a webhook | |
| 369 | // settling it, a refund typed at the terminal, or a delivery naming a | |
| 370 | // session this order never started. The payments column is the region. | |
| 368 | 371 | { title: order.number, admin: true, live: true }, |
| 369 | 372 | pages.order( |
| 370 | 373 | order, |
src/db.ts +12
| @@ -269,6 +269,16 @@ function migrate(handle: Database): void { | ||
| 269 | 269 | addColumn(handle, "order", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); |
| 270 | 270 | addColumn(handle, "order", "tax_rate_bp", "INTEGER NOT NULL DEFAULT 0"); |
| 271 | 271 | addColumn(handle, "order", "tax_label", "TEXT NOT NULL DEFAULT ''"); |
| 272 | // The Checkout Session an order started, kept beside payment_ref because | |
| 273 | // payment_ref becomes the payment intent the moment the order settles. A row | |
| 274 | // from before this column started its session in payment_ref, and says so. | |
| 275 | addColumn( | |
| 276 | handle, | |
| 277 | "order", | |
| 278 | "checkout_session_id", | |
| 279 | "TEXT NOT NULL DEFAULT ''", | |
| 280 | `UPDATE "order" SET checkout_session_id = payment_ref WHERE payment_ref LIKE 'cs_%'`, | |
| 281 | ); | |
| 272 | 282 | addColumn(handle, "variant", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); |
| 273 | 283 | addColumn(handle, "order_item", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); |
| 274 | 284 | // The account an order belongs to. Empty on the orders placed before there |
| @@ -395,6 +405,8 @@ export type Order = { | ||
| 395 | 405 | currency: string; |
| 396 | 406 | gateway: string; |
| 397 | 407 | payment_ref: string | null; |
| 408 | /** The Checkout Session this order started; only that one may settle it. */ | |
| 409 | checkout_session_id: string; | |
| 398 | 410 | idempotency_key: string; |
| 399 | 411 | created_at: string; |
| 400 | 412 | paid_at: string | null; |
src/gateway.ts +12 −1
| @@ -195,7 +195,15 @@ export async function startCheckout( | ||
| 195 | 195 | return stripe<Checkout>("/v1/checkout/sessions", form, `checkout:${order.number}`); |
| 196 | 196 | } |
| 197 | 197 | |
| 198 | export type SessionStatus = { payment_status: string; payment_intent: string | null; amount_total: number | null }; | |
| 198 | export type SessionStatus = { | |
| 199 | /** The session Stripe answered with, which has to be the one that was asked for. */ | |
| 200 | id: string; | |
| 201 | /** The order Stripe says this session is for, empty when it says nothing. */ | |
| 202 | client_reference_id: string; | |
| 203 | payment_status: string; | |
| 204 | payment_intent: string | null; | |
| 205 | amount_total: number | null; | |
| 206 | }; | |
| 199 | 207 | |
| 200 | 208 | export async function readCheckout(sessionId: string): Promise<StripeResult<SessionStatus>> { |
| 201 | 209 | const result = await stripeGet<any>(`/v1/checkout/sessions/${encodeURIComponent(sessionId)}`); |
| @@ -204,6 +212,9 @@ export async function readCheckout(sessionId: string): Promise<StripeResult<Sess | ||
| 204 | 212 | return { |
| 205 | 213 | ok: true, |
| 206 | 214 | value: { |
| 215 | id: typeof result.value.id === "string" ? result.value.id : "", | |
| 216 | client_reference_id: | |
| 217 | typeof result.value.client_reference_id === "string" ? result.value.client_reference_id : "", | |
| 207 | 218 | payment_status: String(result.value.payment_status ?? ""), |
| 208 | 219 | payment_intent: typeof intent === "string" ? intent : (intent?.id ?? null), |
| 209 | 220 | amount_total: typeof result.value.amount_total === "number" ? result.value.amount_total : null, |
src/orders.ts +60 −3
| @@ -123,11 +123,68 @@ export const recent = (limit = 50, query = ""): Order[] => { | ||
| 123 | 123 | .all(like(needle), limit); |
| 124 | 124 | }; |
| 125 | 125 | |
| 126 | /** Remember the gateway's handle for an unpaid order (a Stripe session id). */ | |
| 127 | export const rememberGatewayRef = (order: Order, ref: string): void => { | |
| 128 | db.query('UPDATE "order" SET payment_ref = ?2 WHERE id = ?1 AND status = \'pending_payment\'').run(order.id, ref); | |
| 126 | /** | |
| 127 | * Remember the Checkout Session an unpaid order was sent to. It is written in | |
| 128 | * its own column as well as in `payment_ref`, because `payment_ref` becomes the | |
| 129 | * payment intent the moment the order settles and the session has to outlive | |
| 130 | * that: a replayed event still names the session, and it still has to match. | |
| 131 | */ | |
| 132 | export const rememberCheckoutSession = (order: Order, sessionId: string): void => { | |
| 133 | db.query( | |
| 134 | `UPDATE "order" SET checkout_session_id = ?2, payment_ref = ?2 WHERE id = ?1 AND status = 'pending_payment'`, | |
| 135 | ).run(order.id, sessionId.slice(0, 200)); | |
| 129 | 136 | }; |
| 130 | 137 | |
| 138 | /** | |
| 139 | * Whether a gateway event is about the session this order actually started. | |
| 140 | * | |
| 141 | * An order number is not a secret — it travels in a success URL and in an | |
| 142 | * email — so naming one is not permission to settle it. Only the session this | |
| 143 | * order was sent to may pay it: another order's session, an expired session | |
| 144 | * from before the price changed and a session made by hand in the same Stripe | |
| 145 | * account all name an order and all say something else about the money. | |
| 146 | * | |
| 147 | * An order that started no session has none to match, so nothing settles it | |
| 148 | * this way. The mock gateway never comes through here; it settles directly. | |
| 149 | */ | |
| 150 | export const startedSession = (order: Order, sessionId: string): boolean => | |
| 151 | sessionId !== "" && sessionId === order.checkout_session_id; | |
| 152 | ||
| 153 | /** | |
| 154 | * An event that named a session this order never started. It is a fact about | |
| 155 | * this order, so it is written on this order — once per session named, because | |
| 156 | * a delivery the gateway retries is the same fact and not a new one. | |
| 157 | * | |
| 158 | * `by` is whoever caused the app to look: the gateway, when a webhook said it | |
| 159 | * unasked, and the shopper coming back from Stripe, when it was their return | |
| 160 | * that read the session. A row nobody's hand caused would be the one row in | |
| 161 | * this ledger that does not say whose it is. | |
| 162 | */ | |
| 163 | export function noteUnmatched(order: Order, sessionId: string, by: Actor): void { | |
| 164 | const detail = `a gateway event named ${sessionId || "no session"}; this order started ${ | |
| 165 | order.checkout_session_id || "none" | |
| 166 | }`; | |
| 167 | const written = db | |
| 168 | .query( | |
| 169 | `INSERT INTO order_event (order_id, kind, detail, actor_id, actor_name, actor_role) | |
| 170 | SELECT ?1, 'unmatched', ?2, ?3, ?4, ?5 | |
| 171 | WHERE NOT EXISTS (SELECT 1 FROM order_event WHERE order_id = ?1 AND kind = 'unmatched' AND detail = ?2)`, | |
| 172 | ) | |
| 173 | .run(order.id, detail, ...cells(by)).changes; | |
| 174 | // Said on the admin's stream like every other movement on an order, and on | |
| 175 | // the same terms: after the row is written, once per row. A delivery the | |
| 176 | // gateway retries wrote nothing, so it announces nothing — the admin sees | |
| 177 | // the fact arrive, not how many times Stripe said it. Which session was | |
| 178 | // named stays off the bus with every other gateway reference: the event | |
| 179 | // sends the page for it, and the page is behind the admin's door. | |
| 180 | if (written > 0) emit("order.unmatched", { id: order.id, number: order.number }); | |
| 181 | } | |
| 182 | ||
| 183 | /** The unmatched deliveries among an order's events: the one place that word is spelled. */ | |
| 184 | export const unmatchedIn = (events: OrderEvent[]): OrderEvent[] => events.filter((event) => event.kind === "unmatched"); | |
| 185 | ||
| 186 | export const unmatchedOf = (orderId: number): OrderEvent[] => unmatchedIn(eventsOf(orderId)); | |
| 187 | ||
| 131 | 188 | const sumPayments = (orderId: number, kind: "charge" | "refund"): number => |
| 132 | 189 | db |
| 133 | 190 | .query<{ total: number }, [number, string]>( |
src/pages/admin.ts +39 −9
| @@ -24,10 +24,11 @@ import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant } | ||
| 24 | 24 | import type { StockEntry } from "../inventory.ts"; |
| 25 | 25 | import type { Email } from "../mail.ts"; |
| 26 | 26 | import type { Session } from "../session.ts"; |
| 27 | import { unmatchedIn } from "../orders.ts"; | |
| 27 | 28 | import type { OrderEvent, Shortfall } from "../orders.ts"; |
| 28 | 29 | |
| 29 | 30 | /** What the ledger emits about an order, and about a stock movement. */ |
| 30 | const ORDER_EVENTS = "order.placed order.paid order.refunded order.cancelled"; | |
| 31 | const ORDER_EVENTS = "order.placed order.paid order.refunded order.cancelled order.unmatched"; | |
| 31 | 32 | const STOCK_EVENTS = "stock.moved"; |
| 32 | 33 | |
| 33 | 34 | /** |
| @@ -161,6 +162,30 @@ const who = (row: { actor_name: string; actor_role: string; actor_id: string }): | ||
| 161 | 162 | row.actor_id === "" ? "" : ` · ${escape(row.actor_id)}` |
| 162 | 163 | }</div>`; |
| 163 | 164 | |
| 165 | /** | |
| 166 | * Deliveries that named a session this order never started. They took no | |
| 167 | * money and changed nothing, and they sit with the payments because that is | |
| 168 | * where an admin looks when the money and the order disagree. Each says who | |
| 169 | * brought it — the gateway, or the shopper whose return read the session. | |
| 170 | */ | |
| 171 | function unmatchedNotice(events: OrderEvent[]): string { | |
| 172 | const unmatched = unmatchedIn(events); | |
| 173 | if (unmatched.length === 0) return ""; | |
| 174 | return `<p class="notice bad">${unmatched.length} gateway event${ | |
| 175 | unmatched.length === 1 ? "" : "s" | |
| 176 | } named a checkout session this order never started. Nothing was settled from ${ | |
| 177 | unmatched.length === 1 ? "it" : "them" | |
| 178 | }.</p> | |
| 179 | <table><tbody>${unmatched | |
| 180 | .map( | |
| 181 | (event) => | |
| 182 | `<tr><td class="muted" style="white-space:nowrap">${escape(event.at)}</td><td>${who( | |
| 183 | event, | |
| 184 | )}</td><td class="muted" style="word-break:break-all">${escape(event.detail)}</td></tr>`, | |
| 185 | ) | |
| 186 | .join("")}</tbody></table>`; | |
| 187 | } | |
| 188 | ||
| 164 | 189 | export function order( |
| 165 | 190 | found: Order, |
| 166 | 191 | items: OrderItem[], |
| @@ -214,14 +239,18 @@ ${orderTotals(found, refunded)} | ||
| 214 | 239 | </div> |
| 215 | 240 | <div> |
| 216 | 241 | <h2>Payments</h2> |
| 217 | ${ | |
| 218 | short | |
| 219 | ? `<p class="notice bad">Short by ${escape(formatCents(short.short_cents))}: ${escape( | |
| 220 | formatCents(short.charged_cents), | |
| 221 | )} taken against ${escape(formatCents(short.due_cents))} due. | |
| 242 | ${live( | |
| 243 | "order-payments", | |
| 244 | ORDER_EVENTS, | |
| 245 | `${ | |
| 246 | short | |
| 247 | ? `<p class="notice bad">Short by ${escape(formatCents(short.short_cents))}: ${escape( | |
| 248 | formatCents(short.charged_cents), | |
| 249 | )} taken against ${escape(formatCents(short.due_cents))} due. | |
| 222 | 250 | The order stays awaiting payment until someone decides what to do about it.</p>` |
| 223 | : "" | |
| 224 | } | |
| 251 | : "" | |
| 252 | } | |
| 253 | ${unmatchedNotice(events)} | |
| 225 | 254 | ${ |
| 226 | 255 | payments.length === 0 |
| 227 | 256 | ? '<p class="muted">No payments recorded.</p>' |
| @@ -243,7 +272,8 @@ ${orderTotals(found, refunded)} | ||
| 243 | 272 | event.kind, |
| 244 | 273 | )}</strong> ${escape(event.detail)}</td><td>${who(event)}</td></tr>`, |
| 245 | 274 | ) |
| 246 | .join("")}</tbody></table> | |
| 275 | .join("")}</tbody></table>`, | |
| 276 | )} | |
| 247 | 277 | ${ |
| 248 | 278 | |
| 249 | 279 | ? `<p><a href="/admin/mail#email-${email.id}">The receipt that was written</a></p>` |
src/server.test.ts +9
| @@ -2312,6 +2312,15 @@ test("the admin's live regions are marked with stable ids", async () => { | ||
| 2312 | 2312 | const inventory = regions(await admin.page("/admin/inventory")); |
| 2313 | 2313 | expect(inventory["stock-on-hand"]).toBe("stock.moved"); |
| 2314 | 2314 | expect(inventory["stock-ledger"]).toBe("stock.moved"); |
| 2315 | ||
| 2316 | // One order's own page: the money on it can move while it is open — a | |
| 2317 | // webhook settling it, a refund at the terminal, or a delivery naming a | |
| 2318 | // session it never started — so the payments column is a region too. | |
| 2319 | const { number } = await buy(asCustomer(), "COF-GUJI-250", 1); | |
| 2320 | const detail = regions(await admin.page(`/admin/orders/${number}`)); | |
| 2321 | expect(detail["order-payments"]).toContain("order.unmatched"); | |
| 2322 | expect(detail["order-payments"]).toContain("order.paid"); | |
| 2323 | ||
| 2315 | 2324 | // Fixed columns, so a row arriving cannot move the rows around it. |
| 2316 | 2325 | expect(await admin.page("/admin/inventory")).toContain('<table class="steady">'); |
| 2317 | 2326 | }); |
src/shop.ts +54 −8
| @@ -429,7 +429,7 @@ export async function payStripe(ctx: Ctx): Promise<Response> { | ||
| 429 | 429 | }); |
| 430 | 430 | if (!session.ok) return payPageFor(ctx, order, session.error); |
| 431 | 431 | orders.note(order.id, "checkout-started", actors.paying(ctx.who, order), session.value.id); |
| 432 | orders.rememberGatewayRef(order, session.value.id); | |
| 432 | orders.rememberCheckoutSession(order, session.value.id); | |
| 433 | 433 | // A form POST answered with a redirect off-site: the response says where it |
| 434 | 434 | // goes, so the policy on it allows that one hop and no other. |
| 435 | 435 | return handOff(session.value.url); |
| @@ -545,22 +545,40 @@ export async function receiptPage(ctx: Ctx): Promise<Response> { | ||
| 545 | 545 | */ |
| 546 | 546 | async function settleStripe(order: Order, by: actors.Actor): Promise<Order> { |
| 547 | 547 | if (order.status !== "pending_payment") return order; |
| 548 | if (order.gateway !== "stripe" || !order.payment_ref?.startsWith("cs_")) return order; | |
| 549 | const session = await gateway.readCheckout(order.payment_ref); | |
| 548 | const started = order.checkout_session_id; | |
| 549 | if (order.gateway !== "stripe" || !started.startsWith("cs_")) return order; | |
| 550 | // A charge already recorded is this session's answer already read — even a | |
| 551 | // short one. Asking Stripe again could only write the row that is there. | |
| 552 | if (orders.chargedCents(order.id) > 0) return order; | |
| 553 | const session = await gateway.readCheckout(started); | |
| 550 | 554 | if (!session.ok || session.value.payment_status !== "paid") return order; |
| 555 | // The same rule the webhook is held to, from the other direction: what came | |
| 556 | // back has to be the session this order started, and it has to still say it | |
| 557 | // is this order's. A session that names another order is somebody else's | |
| 558 | // money and is written down rather than spent here. | |
| 559 | if (session.value.id !== started || !nameMatches(session.value.client_reference_id, order)) { | |
| 560 | // Whoever is paying caused this read, so the row says them: on this path | |
| 561 | // the app asked, and it asked because they came back. | |
| 562 | orders.noteUnmatched(order, gatewayId(session.value.id), by); | |
| 563 | return order; | |
| 564 | } | |
| 551 | 565 | // Stripe's own number for what it took, believed as a number and no further: |
| 552 | 566 | // settleFromGateway compares it to what the order owes. |
| 553 | 567 | return orders.settleFromGateway( |
| 554 | 568 | order, |
| 555 | 569 | { |
| 556 | 570 | gateway: "stripe", |
| 557 | ref: session.value.payment_intent ?? order.payment_ref, | |
| 571 | ref: session.value.payment_intent ?? started, | |
| 558 | 572 | amount_cents: session.value.amount_total ?? 0, |
| 559 | 573 | }, |
| 560 | 574 | by, |
| 561 | 575 | ); |
| 562 | 576 | } |
| 563 | 577 | |
| 578 | /** A session that names no order contradicts nothing; one that names another does. */ | |
| 579 | const nameMatches = (clientReference: string, order: Order): boolean => | |
| 580 | clientReference === "" || clientReference === order.number; | |
| 581 | ||
| 564 | 582 | /** |
| 565 | 583 | * Stripe's webhook. Verified against STRIPE_WEBHOOK_SECRET before it is |
| 566 | 584 | * parsed; an unverified body is never read as an event. The bytes arrive as |
| @@ -579,15 +597,35 @@ export function stripeWebhook(ctx: Ctx): Response { | ||
| 579 | 597 | } |
| 580 | 598 | if (event?.type === "checkout.session.completed" || event?.type === "checkout.session.async_payment_succeeded") { |
| 581 | 599 | const session = event.data?.object ?? {}; |
| 582 | const number = String(session.client_reference_id ?? session.metadata?.order_number ?? "").slice(0, 20); | |
| 600 | const named = String(session.client_reference_id ?? session.metadata?.order_number ?? "") | |
| 601 | .slice(0, 20) | |
| 602 | .toUpperCase(); | |
| 603 | // The number goes no further than its own shape: what the log prints and | |
| 604 | // what the query is given is `BZ-…` or nothing at all. | |
| 605 | const number = mine.looksLikeNumber(named) ? named : ""; | |
| 583 | 606 | const order = number ? orders.byNumber(number) : null; |
| 584 | if (order && session.payment_status === "paid") | |
| 585 | // Nobody asked for this row: Stripe told us, so Stripe is what it names. | |
| 607 | // An event for an order this shop does not have leaves a line in the log | |
| 608 | // and nothing in the database: the caller chose that number, and a row | |
| 609 | // whose shape an unauthenticated caller chose is not a row we will write. | |
| 610 | if (!order) { | |
| 611 | console.warn(`[stripe] webhook ${gatewayId(event.id) || "?"} names ${number || "no order"}, not an order here`); | |
| 612 | return text("ok"); | |
| 613 | } | |
| 614 | // The order settles for the session it started and for no other. Anything | |
| 615 | // else is recorded against the order and answered 200, so a retry of it | |
| 616 | // stops: a delivery this app refuses is still a delivery it received. | |
| 617 | // Nobody asked for either row: Stripe told us, so Stripe is what they name. | |
| 618 | const sessionId = gatewayId(session.id); | |
| 619 | if (!orders.startedSession(order, sessionId)) { | |
| 620 | orders.noteUnmatched(order, sessionId, actors.STRIPE); | |
| 621 | return text("ok"); | |
| 622 | } | |
| 623 | if (session.payment_status === "paid") | |
| 586 | 624 | orders.settleFromGateway( |
| 587 | 625 | order, |
| 588 | 626 | { |
| 589 | 627 | gateway: "stripe", |
| 590 | ref: typeof session.payment_intent === "string" ? session.payment_intent : String(session.id ?? ""), | |
| 628 | ref: typeof session.payment_intent === "string" ? session.payment_intent : sessionId, | |
| 591 | 629 | amount_cents: typeof session.amount_total === "number" ? session.amount_total : 0, |
| 592 | 630 | }, |
| 593 | 631 | actors.STRIPE, |
| @@ -596,6 +634,14 @@ export function stripeWebhook(ctx: Ctx): Response { | ||
| 596 | 634 | return text("ok"); |
| 597 | 635 | } |
| 598 | 636 | |
| 637 | /** | |
| 638 | * A gateway's identifier, as far as this app will repeat one: Stripe's own | |
| 639 | * alphabet and nothing else, so nothing a caller writes reaches a log line or | |
| 640 | * an event as anything but `cs_…`. | |
| 641 | */ | |
| 642 | const gatewayId = (value: unknown): string => | |
| 643 | typeof value === "string" && /^[A-Za-z0-9_]{1,100}$/.test(value) ? value : ""; | |
| 644 | ||
| 599 | 645 | // --- limits ----------------------------------------------------------------- |
| 600 | 646 | |
| 601 | 647 | export function tooMany(ctx: Ctx, seconds: number, what: string): Response { |
src/stripe.test.ts +154 −2
| @@ -36,7 +36,10 @@ type Seen = { method: string; path: string; headers: Record<string, string>; for | ||
| 36 | 36 | /** Every request the app made, oldest first. */ |
| 37 | 37 | const seen: Seen[] = []; |
| 38 | 38 | /** The sessions the fake has created, by id, as Stripe would remember them. */ |
| 39 | const sessions = new Map<string, { payment_status: string; payment_intent: string; amount_total: number }>(); | |
| 39 | const sessions = new Map< | |
| 40 | string, | |
| 41 | { payment_status: string; payment_intent: string; amount_total: number; client_reference_id: string } | |
| 42 | >(); | |
| 40 | 43 | /** What each idempotency key already answered: a repeat is not a second one. */ |
| 41 | 44 | const replies = new Map<string, unknown>(); |
| 42 | 45 | /** While set, every call fails the way Stripe fails: a 5xx with a message. */ |
| @@ -90,7 +93,12 @@ const startFake = (port: number) => | ||
| 90 | 93 | 400, |
| 91 | 94 | ); |
| 92 | 95 | const id = `cs_test_${++counter}`; |
| 93 | sessions.set(id, { payment_status: "unpaid", payment_intent: `pi_test_${counter}`, amount_total: amount }); | |
| 96 | sessions.set(id, { | |
| 97 | payment_status: "unpaid", | |
| 98 | payment_intent: `pi_test_${counter}`, | |
| 99 | amount_total: amount, | |
| 100 | client_reference_id: form.get("client_reference_id") ?? "", | |
| 101 | }); | |
| 94 | 102 | return answer({ id, url: `${fakeBase()}/checkout/${id}`, amount_total: amount }); |
| 95 | 103 | } |
| 96 | 104 | if (request.method === "GET" && url.pathname.startsWith("/v1/checkout/sessions/")) { |
| @@ -120,6 +128,14 @@ const payAtStripe = (id: string, amount?: number): void => { | ||
| 120 | 128 | sessions.set(id, { ...session, payment_status: "paid", amount_total: amount ?? session.amount_total }); |
| 121 | 129 | }; |
| 122 | 130 | |
| 131 | /** | |
| 132 | * A session in the same Stripe account that names a different order: the shape | |
| 133 | * of a session made by hand, or one built for an order and pointed at another. | |
| 134 | */ | |
| 135 | const rename = (id: string, number: string): void => { | |
| 136 | sessions.set(id, { ...sessions.get(id)!, client_reference_id: number }); | |
| 137 | }; | |
| 138 | ||
| 123 | 139 | /** What the fake was sent, from `at` onwards. */ |
| 124 | 140 | const since = (at: number): Seen[] => seen.slice(at); |
| 125 | 141 | |
| @@ -300,6 +316,10 @@ const paymentsOf = (number: string): any[] => | ||
| 300 | 316 | const chargesOf = (number: string): any[] => paymentsOf(number).filter((row) => row.kind === "charge"); |
| 301 | 317 | const eventsOf = (number: string): any[] => |
| 302 | 318 | db.query<any, [number]>("SELECT * FROM order_event WHERE order_id = ?1 ORDER BY id").all(orderNamed(number).id); |
| 319 | /** The Checkout Session the order started, as the order itself remembers it. */ | |
| 320 | const startedSessionOf = (number: string): string => orderNamed(number).checkout_session_id; | |
| 321 | const unmatchedOf = (number: string): any[] => eventsOf(number).filter((event) => event.kind === "unmatched"); | |
| 322 | const eventRows = (): number => db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM order_event").get()!.n; | |
| 303 | 323 | const dueOf = (number: string): number => { |
| 304 | 324 | const order = orderNamed(number); |
| 305 | 325 | return order.total_cents - order.gift_card_cents; |
| @@ -708,6 +728,138 @@ test("a webhook after a return leaves exactly one charge", async () => { | ||
| 708 | 728 | expect(chargesOf(placed.number).length).toBe(1); |
| 709 | 729 | }); |
| 710 | 730 | |
| 731 | // --- the session the order started ------------------------------------------- | |
| 732 | ||
| 733 | /** The same event, said to be about another order number. */ | |
| 734 | const aboutOrder = (event: any, number: string): any => ({ | |
| 735 | ...event, | |
| 736 | data: { object: { ...event.data.object, client_reference_id: number, metadata: { order_number: number } } }, | |
| 737 | }); | |
| 738 | ||
| 739 | test("a webhook naming another order's session settles nothing and is written down", async () => { | |
| 740 | const shopper = asCustomer(); | |
| 741 | const ours = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]); | |
| 742 | const other = asCustomer(); | |
| 743 | const theirs = await placeOrder(other, [{ sku: "COF-GUJI-1000", quantity: 1 }]); | |
| 744 | await pressPayWithCard(shopper, ours); | |
| 745 | const theirSession = sessionIdIn((await pressPayWithCard(other, theirs)).headers.get("location")!); | |
| 746 | // Their session is paid, and for more than our order owes. | |
| 747 | payAtStripe(theirSession); | |
| 748 | expect(sessions.get(theirSession)!.amount_total).toBeGreaterThan(dueOf(ours.number)); | |
| 749 | ||
| 750 | const at = seen.length; | |
| 751 | const delivered = await deliverWebhook(aboutOrder(completedEvent(theirs.number, theirSession), ours.number)); | |
| 752 | expect(delivered.status).toBe(200); // 200, so Stripe stops retrying it | |
| 753 | expect(since(at).length).toBe(0); | |
| 754 | ||
| 755 | // Neither order moved: no charge, no payment, no paid. | |
| 756 | expect(orderNamed(ours.number).status).toBe("pending_payment"); | |
| 757 | expect(chargesOf(ours.number).length).toBe(0); | |
| 758 | expect(orderNamed(theirs.number).status).toBe("pending_payment"); | |
| 759 | expect(chargesOf(theirs.number).length).toBe(0); | |
| 760 | ||
| 761 | // The order carries the refusal, naming the session that arrived and the | |
| 762 | // session it actually started. | |
| 763 | const unmatched = unmatchedOf(ours.number); | |
| 764 | expect(unmatched.length).toBe(1); | |
| 765 | expect(unmatched[0].detail).toContain(theirSession); | |
| 766 | expect(unmatched[0].detail).toContain(startedSessionOf(ours.number)); | |
| 767 | // Whose hand it was: nobody asked for this row, the gateway brought it. | |
| 768 | expect(unmatched[0].actor_role).toBe("stripe"); | |
| 769 | expect(eventsOf(ours.number).some((event) => event.kind === "paid")).toBe(false); | |
| 770 | ||
| 771 | // A retry of the same delivery is the same fact, not a second row. | |
| 772 | expect((await deliverWebhook(aboutOrder(completedEvent(theirs.number, theirSession), ours.number))).status).toBe(200); | |
| 773 | expect(unmatchedOf(ours.number).length).toBe(1); | |
| 774 | ||
| 775 | // The admin sees it beside the payments. | |
| 776 | const staff = await admin(); | |
| 777 | const detail = await staff.page(`/admin/orders/${ours.number}`); | |
| 778 | expect(detail).toContain("named a checkout session this order never started"); | |
| 779 | expect(detail).toContain(theirSession); | |
| 780 | ||
| 781 | // And our own session still pays our own order: the rule refused a session, | |
| 782 | // not an order. | |
| 783 | payAtStripe(startedSessionOf(ours.number)); | |
| 784 | expect(await (await comeBack(shopper, ours)).text()).toContain("Thank you"); | |
| 785 | expect(orderNamed(ours.number).status).toBe("paid"); | |
| 786 | expect(chargesOf(ours.number).length).toBe(1); | |
| 787 | }); | |
| 788 | ||
| 789 | test("a webhook for an order number this shop does not have writes nothing at all", async () => { | |
| 790 | const shopper = asCustomer(); | |
| 791 | const placed = await placeOrder(shopper, [{ sku: "KIT-MUG-OAT", quantity: 1 }]); | |
| 792 | const started = await pressPayWithCard(shopper, placed); | |
| 793 | const sessionId = sessionIdIn(started.headers.get("location")!); | |
| 794 | payAtStripe(sessionId); | |
| 795 | const good = completedEvent(placed.number, sessionId); | |
| 796 | ||
| 797 | const before = eventRows(); | |
| 798 | for (const number of ["BZ-NOSUCHORDER", "not an order at all", ""]) { | |
| 799 | const delivered = await deliverWebhook(aboutOrder(good, number)); | |
| 800 | expect(delivered.status).toBe(200); | |
| 801 | expect(await delivered.text()).toBe("ok"); | |
| 802 | } | |
| 803 | // No row an unauthenticated caller chose the shape of, anywhere. | |
| 804 | expect(eventRows()).toBe(before); | |
| 805 | expect(orderNamed(placed.number).status).toBe("pending_payment"); | |
| 806 | expect(chargesOf(placed.number).length).toBe(0); | |
| 807 | ||
| 808 | // The event that names this order's own session still settles it. | |
| 809 | expect((await deliverWebhook(good)).status).toBe(200); | |
| 810 | expect(orderNamed(placed.number).status).toBe("paid"); | |
| 811 | }); | |
| 812 | ||
| 813 | test("the same good event delivered twice settles once", async () => { | |
| 814 | const shopper = asCustomer(); | |
| 815 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 1 }]); | |
| 816 | const started = await pressPayWithCard(shopper, placed); | |
| 817 | const sessionId = sessionIdIn(started.headers.get("location")!); | |
| 818 | payAtStripe(sessionId); | |
| 819 | const event = completedEvent(placed.number, sessionId); | |
| 820 | ||
| 821 | expect((await deliverWebhook(event)).status).toBe(200); | |
| 822 | expect((await deliverWebhook(event)).status).toBe(200); | |
| 823 | ||
| 824 | const order = orderNamed(placed.number); | |
| 825 | expect(order.status).toBe("paid"); | |
| 826 | expect(chargesOf(placed.number).length).toBe(1); | |
| 827 | expect(eventsOf(placed.number).filter((one) => one.kind === "paid").length).toBe(1); | |
| 828 | // The replay still matched: the order keeps the session it started even now | |
| 829 | // that payment_ref has become the payment intent. | |
| 830 | expect(unmatchedOf(placed.number).length).toBe(0); | |
| 831 | expect(order.checkout_session_id).toBe(sessionId); | |
| 832 | expect(order.payment_ref).toBe(sessions.get(sessionId)!.payment_intent); | |
| 833 | }); | |
| 834 | ||
| 835 | test("a session that comes back naming another order does not settle this one", async () => { | |
| 836 | const shopper = asCustomer(); | |
| 837 | const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 1 }]); | |
| 838 | const started = await pressPayWithCard(shopper, placed); | |
| 839 | const sessionId = sessionIdIn(started.headers.get("location")!); | |
| 840 | payAtStripe(sessionId); | |
| 841 | rename(sessionId, "BZ-SOMEONELSE"); | |
| 842 | ||
| 843 | const back = await comeBack(shopper, placed); | |
| 844 | expect(back.status).toBe(200); | |
| 845 | expect(await back.text()).not.toContain("Thank you"); | |
| 846 | expect(orderNamed(placed.number).status).toBe("pending_payment"); | |
| 847 | expect(chargesOf(placed.number).length).toBe(0); | |
| 848 | const unmatched = unmatchedOf(placed.number); | |
| 849 | expect(unmatched.length).toBe(1); | |
| 850 | expect(unmatched[0].detail).toContain(sessionId); | |
| 851 | // On this path somebody did ask: the shopper's return is what read the | |
| 852 | // session, so the row names the account paying rather than the gateway. | |
| 853 | expect(unmatched[0].actor_role).toBe("user"); | |
| 854 | expect(unmatched[0].actor_id).toBe(orderNamed(placed.number).buyer_id); | |
| 855 | ||
| 856 | // Stripe saying again that the session is this order's settles it. | |
| 857 | rename(sessionId, placed.number); | |
| 858 | expect(await (await comeBack(shopper, placed)).text()).toContain("Thank you"); | |
| 859 | expect(chargesOf(placed.number).length).toBe(1); | |
| 860 | expect(unmatchedOf(placed.number).length).toBe(1); | |
| 861 | }); | |
| 862 | ||
| 711 | 863 | // --- a payment that does not cover the order --------------------------------- |
| 712 | 864 | |
| 713 | 865 | test("a short return is written down and the order is not called paid", async () => { |
src/tty.ts +13 −1
| @@ -37,7 +37,15 @@ import { db } from "./db.ts"; | ||
| 37 | 37 | import { chosen } from "./gateway.ts"; |
| 38 | 38 | import { formatCents } from "./money.ts"; |
| 39 | 39 | import { history, addStock, setStock } from "./inventory.ts"; |
| 40 | import { itemsOf, paymentsOf, refund, refundableCents, refundedCents, shortfall } from "./orders.ts"; | |
| 40 | import { | |
| 41 | itemsOf, | |
| 42 | paymentsOf, | |
| 43 | refund, | |
| 44 | refundableCents, | |
| 45 | refundedCents, | |
| 46 | shortfall, | |
| 47 | unmatchedOf, | |
| 48 | } from "./orders.ts"; | |
| 41 | 49 | import { outbox } from "./mail.ts"; |
| 42 | 50 | import { atDesk } from "./actor.ts"; |
| 43 | 51 | import { atKeyboard } from "./who.ts"; |
| @@ -190,6 +198,10 @@ function detail(order: Order): string[] { | ||
| 190 | 198 | `${formatCents(short.charged_cents)} taken of ${formatCents(short.due_cents)} due`, |
| 191 | 199 | )}`, |
| 192 | 200 | ); |
| 201 | // And the other fact the admin page shows beside the payments: a gateway | |
| 202 | // event that named a session this order never started, which settled nothing. | |
| 203 | for (const event of unmatchedOf(order.id)) | |
| 204 | lines.push(` ${esc}33munmatched${esc}0m ${dim(event.detail)}`); | |
| 193 | 205 | return lines; |
| 194 | 206 | } |
| 195 | 207 |
src/units.test.ts +56
| @@ -943,6 +943,62 @@ test("the admin's screen is told about a payment only when the order becomes pai | ||
| 943 | 943 | } |
| 944 | 944 | }); |
| 945 | 945 | |
| 946 | test("a delivery for a session the order never started reaches the admin's screen once", () => { | |
| 947 | const heard: { type: string; data: any }[] = []; | |
| 948 | const unsubscribe = live.subscribe((event) => heard.push({ type: event.type, data: event.data })); | |
| 949 | try { | |
| 950 | const order = pendingOrder("BZ-UNMATCHED-LIVE", 5000); | |
| 951 | orders.rememberCheckoutSession(order, "cs_ours"); | |
| 952 | const started = orders.byNumber("BZ-UNMATCHED-LIVE")!; | |
| 953 | ||
| 954 | // Somebody else's session, named against this order: a row, and a line on | |
| 955 | // the stream so an open /admin sees it arrive rather than on next reload. | |
| 956 | orders.noteUnmatched(started, "cs_theirs", actors.STRIPE); | |
| 957 | expect(orders.unmatchedOf(started.id).length).toBe(1); | |
| 958 | // The number and the id, and not the session that was named: a gateway | |
| 959 | // reference stays on the page behind the admin's door. | |
| 960 | expect(heard.filter((event) => event.type === "order.unmatched")).toEqual([ | |
| 961 | { type: "order.unmatched", data: { id: started.id, number: "BZ-UNMATCHED-LIVE" } }, | |
| 962 | ]); | |
| 963 | // The row says whose hand it was, like every other row in this ledger: | |
| 964 | // the gateway's, because a webhook is the gateway telling us unasked. | |
| 965 | expect(orders.unmatchedOf(started.id)[0]!.actor_role).toBe("stripe"); | |
| 966 | expect(orders.unmatchedOf(started.id)[0]!.actor_name).toBe("stripe"); | |
| 967 | ||
| 968 | // Stripe retrying that delivery is the same fact: no second row, and | |
| 969 | // nothing said a second time. | |
| 970 | orders.noteUnmatched(started, "cs_theirs", actors.STRIPE); | |
| 971 | expect(orders.unmatchedOf(started.id).length).toBe(1); | |
| 972 | expect(heard.filter((event) => event.type === "order.unmatched").length).toBe(1); | |
| 973 | ||
| 974 | // The same session seen from the other path is still the same fact, so it | |
| 975 | // writes nothing — the shopper's return does not double the row. | |
| 976 | orders.noteUnmatched(started, "cs_theirs", { id: "u_7", name: "shopper", role: "user" }); | |
| 977 | expect(orders.unmatchedOf(started.id).length).toBe(1); | |
| 978 | ||
| 979 | // A different session is a different fact, and is both written and said. | |
| 980 | orders.noteUnmatched(started, "cs_third", { id: "u_7", name: "shopper", role: "user" }); | |
| 981 | expect(orders.unmatchedOf(started.id).length).toBe(2); | |
| 982 | expect(heard.filter((event) => event.type === "order.unmatched").length).toBe(2); | |
| 983 | expect(orders.unmatchedOf(started.id)[1]!.actor_name).toBe("shopper"); | |
| 984 | ||
| 985 | // Nothing about the money moved: the stream said what happened, which is | |
| 986 | // that nothing was settled. | |
| 987 | expect(orders.byNumber("BZ-UNMATCHED-LIVE")!.status).toBe("pending_payment"); | |
| 988 | expect(orders.paymentsOf(started.id).length).toBe(0); | |
| 989 | ||
| 990 | // And it is on the money trail, where an order's rows are read together: | |
| 991 | // a delivery that settled nothing is still something that happened to | |
| 992 | // this order, and the trail says who brought each one. | |
| 993 | const trail = audit.timeline({ orderId: started.id }).filter((entry) => entry.what === "unmatched"); | |
| 994 | expect(trail.length).toBe(2); | |
| 995 | expect(trail.map((entry) => entry.actor_role).sort()).toEqual(["stripe", "user"]); | |
| 996 | expect(trail.every((entry) => entry.cents === null)).toBe(true); | |
| 997 | } finally { | |
| 998 | unsubscribe(); | |
| 999 | } | |
| 1000 | }); | |
| 1001 | ||
| 946 | 1002 | test("a test file's database is its own, from the factory", () => { |
| 947 | 1003 | // bun test shares one process and one module registry across files, so this |
| 948 | 1004 | // is the assertion that keeps every other one honest: the database the app |