Public: anyone can read this; the crew can change it.

The webhook settles only the session its own order started #70

Closed opened this
Closed without merging

ledger/bazaar:ledger/33-the-webhook-settles-only-the-session-its → main 4 commits fork 1 thread · 1 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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
302302 the ledger second, with an idempotency key built from the order and what has
303303 already been refunded.
304304
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
305323 Both of those paths, and the mock gateway, settle through one function —
306324 `orders.settleFromGateway` — so there is one rule about what an amount means.
307325 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
331349 `STRIPE_API_BASE` at it and boots the app in its own process against it, then
332350 asserts what went down the wire: the amounts, the currency, the idempotency
333351 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
335354 answering 500, Stripe stopped, and a live key refused with no request made at
336355 all. No test in this repository talks to the network.
337356
@@ -469,7 +488,11 @@ what it wrote, and the page listens.
469488 the branch that actually moves the row to `paid` says `order.paid`: a
470489 charge that falls short of what the order owes is written to the ledger and
471490 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
473496 happens **after** the transaction commits. A stock movement made inside a
474497 bigger transaction (an order taking its stock) is announced by whoever
475498 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`**
492515 **`/admin/inventory`** (both tables — stock on hand and the ledger) and
493516 **`/admin/orders/<number>`** (one order, keyed to its number: the status, the
494517 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,
497521 and none of them has an inline one: the floor's `script-src` is `'self'`, with
498522 no `unsafe-inline` and no `unsafe-eval`, and `static/live.js` is served under a
499523 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 {
365365 if (!order) return notFound(ctx);
366366 return render(
367367 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.
368371 { title: order.number, admin: true, live: true },
369372 pages.order(
370373 order,
src/db.ts +12
@@ -269,6 +269,16 @@ function migrate(handle: Database): void {
269269 addColumn(handle, "order", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
270270 addColumn(handle, "order", "tax_rate_bp", "INTEGER NOT NULL DEFAULT 0");
271271 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 );
272282 addColumn(handle, "variant", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
273283 addColumn(handle, "order_item", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
274284 // The account an order belongs to. Empty on the orders placed before there
@@ -395,6 +405,8 @@ export type Order = {
395405 currency: string;
396406 gateway: string;
397407 payment_ref: string | null;
408 /** The Checkout Session this order started; only that one may settle it. */
409 checkout_session_id: string;
398410 idempotency_key: string;
399411 created_at: string;
400412 paid_at: string | null;
src/gateway.ts +12 −1
@@ -195,7 +195,15 @@ export async function startCheckout(
195195 return stripe<Checkout>("/v1/checkout/sessions", form, `checkout:${order.number}`);
196196 }
197197
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 };
199207
200208 export async function readCheckout(sessionId: string): Promise<StripeResult<SessionStatus>> {
201209 const result = await stripeGet<any>(`/v1/checkout/sessions/${encodeURIComponent(sessionId)}`);
@@ -204,6 +212,9 @@ export async function readCheckout(sessionId: string): Promise<StripeResult<Sess
204212 return {
205213 ok: true,
206214 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 : "",
207218 payment_status: String(result.value.payment_status ?? ""),
208219 payment_intent: typeof intent === "string" ? intent : (intent?.id ?? null),
209220 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[] => {
123123 .all(like(needle), limit);
124124 };
125125
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));
129136 };
130137
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
131188 const sumPayments = (orderId: number, kind: "charge" | "refund"): number =>
132189 db
133190 .query<{ total: number }, [number, string]>(
src/pages/admin.ts +39 −9
@@ -24,10 +24,11 @@ import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant }
2424 import type { StockEntry } from "../inventory.ts";
2525 import type { Email } from "../mail.ts";
2626 import type { Session } from "../session.ts";
27 import { unmatchedIn } from "../orders.ts";
2728 import type { OrderEvent, Shortfall } from "../orders.ts";
2829
2930 /** 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";
3132 const STOCK_EVENTS = "stock.moved";
3233
3334 /**
@@ -161,6 +162,30 @@ const who = (row: { actor_name: string; actor_role: string; actor_id: string }):
161162 row.actor_id === "" ? "" : ` · ${escape(row.actor_id)}`
162163 }</div>`;
163164
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
164189 export function order(
165190 found: Order,
166191 items: OrderItem[],
@@ -214,14 +239,18 @@ ${orderTotals(found, refunded)}
214239 </div>
215240 <div>
216241 <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.
222250 The order stays awaiting payment until someone decides what to do about it.</p>`
223 : ""
224 }
251 : ""
252 }
253 ${unmatchedNotice(events)}
225254 ${
226255 payments.length === 0
227256 ? '<p class="muted">No payments recorded.</p>'
@@ -243,7 +272,8 @@ ${orderTotals(found, refunded)}
243272 event.kind,
244273 )}</strong> ${escape(event.detail)}</td><td>${who(event)}</td></tr>`,
245274 )
246 .join("")}</tbody></table>
275 .join("")}</tbody></table>`,
276 )}
247277 ${
248278 email
249279 ? `<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 () => {
23122312 const inventory = regions(await admin.page("/admin/inventory"));
23132313 expect(inventory["stock-on-hand"]).toBe("stock.moved");
23142314 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
23152324 // Fixed columns, so a row arriving cannot move the rows around it.
23162325 expect(await admin.page("/admin/inventory")).toContain('<table class="steady">');
23172326 });
src/shop.ts +54 −8
@@ -429,7 +429,7 @@ export async function payStripe(ctx: Ctx): Promise<Response> {
429429 });
430430 if (!session.ok) return payPageFor(ctx, order, session.error);
431431 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);
433433 // A form POST answered with a redirect off-site: the response says where it
434434 // goes, so the policy on it allows that one hop and no other.
435435 return handOff(session.value.url);
@@ -545,22 +545,40 @@ export async function receiptPage(ctx: Ctx): Promise<Response> {
545545 */
546546 async function settleStripe(order: Order, by: actors.Actor): Promise<Order> {
547547 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);
550554 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 }
551565 // Stripe's own number for what it took, believed as a number and no further:
552566 // settleFromGateway compares it to what the order owes.
553567 return orders.settleFromGateway(
554568 order,
555569 {
556570 gateway: "stripe",
557 ref: session.value.payment_intent ?? order.payment_ref,
571 ref: session.value.payment_intent ?? started,
558572 amount_cents: session.value.amount_total ?? 0,
559573 },
560574 by,
561575 );
562576 }
563577
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
564582 /**
565583 * Stripe's webhook. Verified against STRIPE_WEBHOOK_SECRET before it is
566584 * parsed; an unverified body is never read as an event. The bytes arrive as
@@ -579,15 +597,35 @@ export function stripeWebhook(ctx: Ctx): Response {
579597 }
580598 if (event?.type === "checkout.session.completed" || event?.type === "checkout.session.async_payment_succeeded") {
581599 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 : "";
583606 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")
586624 orders.settleFromGateway(
587625 order,
588626 {
589627 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,
591629 amount_cents: typeof session.amount_total === "number" ? session.amount_total : 0,
592630 },
593631 actors.STRIPE,
@@ -596,6 +634,14 @@ export function stripeWebhook(ctx: Ctx): Response {
596634 return text("ok");
597635 }
598636
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
599645 // --- limits -----------------------------------------------------------------
600646
601647 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
3636 /** Every request the app made, oldest first. */
3737 const seen: Seen[] = [];
3838 /** 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 >();
4043 /** What each idempotency key already answered: a repeat is not a second one. */
4144 const replies = new Map<string, unknown>();
4245 /** While set, every call fails the way Stripe fails: a 5xx with a message. */
@@ -90,7 +93,12 @@ const startFake = (port: number) =>
9093 400,
9194 );
9295 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 });
94102 return answer({ id, url: `${fakeBase()}/checkout/${id}`, amount_total: amount });
95103 }
96104 if (request.method === "GET" && url.pathname.startsWith("/v1/checkout/sessions/")) {
@@ -120,6 +128,14 @@ const payAtStripe = (id: string, amount?: number): void => {
120128 sessions.set(id, { ...session, payment_status: "paid", amount_total: amount ?? session.amount_total });
121129 };
122130
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
123139 /** What the fake was sent, from `at` onwards. */
124140 const since = (at: number): Seen[] => seen.slice(at);
125141
@@ -300,6 +316,10 @@ const paymentsOf = (number: string): any[] =>
300316 const chargesOf = (number: string): any[] => paymentsOf(number).filter((row) => row.kind === "charge");
301317 const eventsOf = (number: string): any[] =>
302318 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;
303323 const dueOf = (number: string): number => {
304324 const order = orderNamed(number);
305325 return order.total_cents - order.gift_card_cents;
@@ -708,6 +728,138 @@ test("a webhook after a return leaves exactly one charge", async () => {
708728 expect(chargesOf(placed.number).length).toBe(1);
709729 });
710730
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
711863 // --- a payment that does not cover the order ---------------------------------
712864
713865 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";
3737 import { chosen } from "./gateway.ts";
3838 import { formatCents } from "./money.ts";
3939 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";
4149 import { outbox } from "./mail.ts";
4250 import { atDesk } from "./actor.ts";
4351 import { atKeyboard } from "./who.ts";
@@ -190,6 +198,10 @@ function detail(order: Order): string[] {
190198 `${formatCents(short.charged_cents)} taken of ${formatCents(short.due_cents)} due`,
191199 )}`,
192200 );
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)}`);
193205 return lines;
194206 }
195207
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
943943 }
944944 });
945945
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
9461002 test("a test file's database is its own, from the factory", () => {
9471003 // bun test shares one process and one module registry across files, so this
9481004 // is the assertion that keeps every other one honest: the database the app

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs