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

A payment short of what the order owes does not mark it paid #37

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/bazaar:ledger/31-a-payment-short-of-what-the-order-owes-d → main 2 commits fork

  • Merged. ledger/31-a-payment-short-of-what-the-order-owes-d was merged into main as 90638ee.

Review

Sign in to review this change.
9 files changed +325 −39
README.md +14 −2
@@ -171,6 +171,17 @@ an idempotency key, so it is recorded once. Refunds go to Stripe first and to
171171 the ledger second, with an idempotency key built from the order and what has
172172 already been refunded.
173173
174 Both of those paths, and the mock gateway, settle through one function —
175 `orders.settleFromGateway` — so there is one rule about what an amount means.
176 The gateway's amount is recorded, never believed: the money that moved is a
177 `payment` row whatever it was, and only an amount that covers what the order
178 owes marks the order paid. Less than that leaves the order awaiting payment
179 with a `short-payment` event saying what was asked for and what arrived; the
180 order page tells the shopper in plain words, `/admin/orders/<number>` shows
181 the shortfall beside the payments, and the payment step is closed so a second
182 charge cannot be taken against a key that could not record it. Nothing is
183 refunded or cancelled automatically — a short payment is a person's decision.
184
174185 What Stripe is asked for is the order's total less whatever a gift card
175186 already paid — `dueCents` in `src/money.ts`, which the payment page, the
176187 receipt, the mock and Stripe all read, so no two of them can drift. Usually
@@ -189,8 +200,9 @@ on `127.0.0.1` — the three endpoints this app calls, nothing else — points
189200 `STRIPE_API_BASE` at it and boots the app in its own process against it, then
190201 asserts what went down the wire: the amounts, the currency, the idempotency
191202 keys, the shopper's return, the webhook arriving before and after it, a
192 refund, Stripe answering 500, Stripe stopped, and a live key refused with no
193 request made at all. No test in this repository talks to the network.
203 return and a webhook that pay less than the order owes, a refund, Stripe
204 answering 500, Stripe stopped, and a live key refused with no request made at
205 all. No test in this repository talks to the network.
194206
195207 ## Environment
196208
src/admin.ts +1
@@ -254,6 +254,7 @@ export function orderDetail(ctx: Ctx): Response {
254254 forOrder(order.id),
255255 ctx.session,
256256 messageFrom(ctx),
257 orders.shortfall(order),
257258 ),
258259 );
259260 }
src/orders.ts +76 −15
@@ -240,28 +240,89 @@ export function place(input: {
240240 }
241241 }
242242
243 export type Charge = { gateway: string; ref: string; amount_cents: number };
244
243245 /**
244 * Record a successful charge. Idempotent: the payment's key is the order's,
245 * so a webhook and a browser returning at the same time write one charge.
246 * A pending order a gateway has paid something towards, but not enough. The
247 * order owes what it owes; what arrived is what arrived, and the difference is
248 * a fact both the shopper's page and the admin's read from here.
246249 */
247 export function markPaid(order: Order, charge: { gateway: string; ref: string; amount_cents: number }): Order {
248 const paid = db.transaction(() => {
249 const inserted = db
250 .query(
251 `INSERT OR IGNORE INTO payment (order_id, kind, amount_cents, gateway, gateway_ref, status, idempotency_key)
252 VALUES (?1, 'charge', ?2, ?3, ?4, 'succeeded', ?5)`,
253 )
254 .run(order.id, charge.amount_cents, charge.gateway, charge.ref, `charge:${order.number}`).changes;
255 if (inserted === 1) {
250 export type Shortfall = { charged_cents: number; due_cents: number; short_cents: number };
251
252 export function shortfall(order: Order): Shortfall | null {
253 if (order.status !== "pending_payment") return null;
254 const charged = chargedCents(order.id);
255 const due = dueCents(order);
256 return charged > 0 && charged < due ? { charged_cents: charged, due_cents: due, short_cents: due - charged } : null;
257 }
258
259 /**
260 * The one place a gateway's answer becomes an order's state: the Stripe
261 * return, the Stripe webhook and the mock all come through here, so there is
262 * one rule about what an amount means and not three.
263 *
264 * The money that moved is written down whatever it was — that is the trail —
265 * and only an amount that covers what the order owes marks it paid. Less than
266 * that leaves the order awaiting payment with an event saying what was asked
267 * for and what arrived: nothing is refunded and nothing is cancelled, because
268 * a short payment is a person's decision and this only refuses to lie about it.
269 *
270 * Idempotent: the charge's key is the order's, so a webhook and a browser
271 * returning at the same time write one charge and one event between them.
272 */
273 const alreadyShort = (orderId: number): boolean =>
274 db
275 .query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM order_event WHERE order_id = ?1 AND kind = 'short-payment'")
276 .get(orderId)!.n > 0;
277
278 export function settleFromGateway(order: Order, charge: Charge): Order {
279 // A gateway may say anything; a charge is a whole number of cents, and the
280 // payment ledger has no room for a row that moved nothing.
281 const amount = Number.isFinite(charge.amount_cents) ? Math.trunc(charge.amount_cents) : 0;
282 const ref = charge.ref.slice(0, 200);
283
284 const settled = db.transaction(() => {
285 const fresh = byId(order.id)!;
286 const inserted =
287 amount > 0
288 ? db
289 .query(
290 `INSERT OR IGNORE INTO payment (order_id, kind, amount_cents, gateway, gateway_ref, status, idempotency_key)
291 VALUES (?1, 'charge', ?2, ?3, ?4, 'succeeded', ?5)`,
292 )
293 .run(fresh.id, amount, charge.gateway, ref, `charge:${fresh.number}`).changes
294 : 0;
295 // Already recorded: the other arrival settled this order, whichever way.
296 if (inserted === 0 && amount > 0) return byId(fresh.id)!;
297
298 const due = dueCents(fresh);
299 if (amount >= due) {
256300 db.query(
257301 `UPDATE "order" SET status = 'paid', paid_at = datetime('now'), payment_ref = ?2, gateway = ?3
258302 WHERE id = ?1 AND status = 'pending_payment'`,
259 ).run(order.id, charge.ref, charge.gateway);
260 note(order.id, "paid", `${charge.gateway} ${charge.ref}`);
303 ).run(fresh.id, ref, charge.gateway);
304 note(fresh.id, "paid", `${charge.gateway} ${ref}`);
305 return byId(fresh.id)!;
261306 }
262 return byId(order.id)!;
307 // Short. The order stays where it was; the gateway's handle is kept so
308 // whoever looks at this can find the charge at the gateway.
309 db.query('UPDATE "order" SET payment_ref = ?2, gateway = ?3 WHERE id = ?1 AND status = \'pending_payment\'').run(
310 fresh.id,
311 ref,
312 charge.gateway,
313 );
314 // An amount of nothing wrote no row to be idempotent against, so the event
315 // guards itself: a repeated delivery of it is the same fact, not a new one.
316 if (amount <= 0 && alreadyShort(fresh.id)) return byId(fresh.id)!;
317 note(
318 fresh.id,
319 "short-payment",
320 `asked ${(due / 100).toFixed(2)} ${fresh.currency}, ${charge.gateway} paid ${(amount / 100).toFixed(2)} — ` +
321 `${((due - amount) / 100).toFixed(2)} short${ref ? ` (${ref})` : ""}`,
322 );
323 return byId(fresh.id)!;
263324 })();
264 return settle(paid);
325 return settle(settled);
265326 }
266327
267328 /**
src/pages/admin.ts +10
@@ -15,6 +15,7 @@ import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant }
1515 import type { StockEntry } from "../inventory.ts";
1616 import type { Email } from "../mail.ts";
1717 import type { Session } from "../session.ts";
18 import type { Shortfall } from "../orders.ts";
1819
1920 export const nav = (current: string): string =>
2021 `<nav>${[
@@ -113,6 +114,7 @@ export function order(
113114 email: Email | null,
114115 session: Session,
115116 message?: { text: string; bad?: boolean },
117 short: Shortfall | null = null,
116118 ): string {
117119 return `
118120 ${nav("orders")}
@@ -153,6 +155,14 @@ ${orderTotals(found, refunded)}
153155 </div>
154156 <div>
155157 <h2>Payments</h2>
158 ${
159 short
160 ? `<p class="notice bad">Short by ${escape(formatCents(short.short_cents))}: ${escape(
161 formatCents(short.charged_cents),
162 )} taken against ${escape(formatCents(short.due_cents))} due.
163 The order stays awaiting payment until someone decides what to do about it.</p>`
164 : ""
165 }
156166 ${
157167 payments.length === 0
158168 ? '<p class="muted">No payments recorded.</p>'
src/pages/checkout.ts +19 −3
@@ -11,6 +11,7 @@ import { dueCents, formatCents } from "../money.ts";
1111 import { codeBox, totals } from "./cart.ts";
1212 import type { Quote } from "../cart.ts";
1313 import type { Order, OrderItem, Payment } from "../db.ts";
14 import type { Shortfall } from "../orders.ts";
1415 import type { GatewayInfo } from "../gateway.ts";
1516 import type { Email } from "../mail.ts";
1617 import type { Session } from "../session.ts";
@@ -196,12 +197,25 @@ export const statusPill = (status: string): string =>
196197 STATUS_WORDS[status] ?? status,
197198 )}</span>`;
198199
200 /**
201 * A payment that did not cover the order, said plainly: what the shop asked
202 * for, what arrived, and that nobody is being asked to pay again here.
203 */
204 export const shortPayment = (order: Order, short: Shortfall): string =>
205 `<p class="notice bad"><strong>That payment was short.</strong>
206 ${escape(formatCents(short.charged_cents))} reached us against the ${escape(
207 formatCents(short.due_cents),
208 )} this order owes — ${escape(formatCents(short.short_cents))} less.
209 The order is not marked paid and nothing has shipped. The shop is looking at it; nothing more
210 has been taken, and if you need to talk to someone, quote ${escape(order.number)}.</p>`;
211
199212 export function confirmation(
200213 order: Order,
201214 items: OrderItem[],
202215 refunded: number,
203216 email: Email | null,
204217 who: Who,
218 short: Shortfall | null = null,
205219 ): string {
206220 const paid = order.status === "paid" || order.status === "refunded" || order.status === "partially_refunded";
207221 // A card's code is printed once, on the receipt. Say so here: a buyer who
@@ -211,10 +225,12 @@ export function confirmation(
211225 <h1>${paid ? "Thank you" : "Order " + escape(order.number)}</h1>
212226 <p class="lede">Order ${escape(order.number)} · ${statusPill(order.status)} · placed ${escape(order.created_at)} UTC</p>
213227 ${
214 order.status === "pending_payment"
215 ? `<p class="notice">This order is not paid yet.
228 short
229 ? shortPayment(order, short)
230 : order.status === "pending_payment"
231 ? `<p class="notice">This order is not paid yet.
216232 <a href="/pay/${escape(order.number)}?token=${escape(order.access_token)}">Finish paying</a>.</p>`
217 : ""
233 : ""
218234 }
219235 ${
220236 paid && email
src/shop.ts +25 −13
@@ -303,11 +303,21 @@ function openOrder(ctx: Ctx, tokenFrom: "query" | "form"): Order | null {
303303 return token.length > 0 && equals(token, order.access_token) ? order : null;
304304 }
305305
306 /** Where an order lives, for whoever holds its token. */
307 const orderLink = (order: Order): string =>
308 `/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`;
309
310 /**
311 * An order a gateway has already paid something towards is not offered to a
312 * gateway again: the charge row's key is the order's, so a second charge could
313 * be taken and not recorded. It goes back to the order, which says it is short.
314 */
315 const alreadyCharged = (order: Order): boolean => orders.shortfall(order) !== null;
316
306317 export function payPage(ctx: Ctx, error?: string): Response {
307318 const order = openOrder(ctx, "query");
308319 if (!order) return notFound(ctx);
309 if (order.status !== "pending_payment")
310 return redirect(`/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`);
320 if (order.status !== "pending_payment" || alreadyCharged(order)) return redirect(orderLink(order));
311321 return render(
312322 ctx,
313323 { title: `Pay ${order.number}` },
@@ -320,8 +330,7 @@ export function payMock(ctx: Ctx): Response {
320330 if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment");
321331 const order = openOrder(ctx, "form");
322332 if (!order) return notFound(ctx);
323 if (order.status !== "pending_payment")
324 return redirect(`/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`);
333 if (order.status !== "pending_payment" || alreadyCharged(order)) return redirect(orderLink(order));
325334 if (chosen().kind !== "mock") return redirect(`/pay/${order.number}?token=${encodeURIComponent(order.access_token)}`);
326335 const decision = field(ctx.form, "decision", 10);
327336 if (decision !== "approve") {
@@ -329,7 +338,7 @@ export function payMock(ctx: Ctx): Response {
329338 ctx.params.number = order.number;
330339 return payPageFor(ctx, order, "The mock gateway declined this payment. Nothing was charged; try again.");
331340 }
332 const paid = orders.markPaid(order, {
341 const paid = orders.settleFromGateway(order, {
333342 gateway: "mock",
334343 ref: `mock_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`,
335344 amount_cents: dueCents(order),
@@ -349,8 +358,7 @@ export async function payStripe(ctx: Ctx): Promise<Response> {
349358 if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment");
350359 const order = openOrder(ctx, "form");
351360 if (!order) return notFound(ctx);
352 if (order.status !== "pending_payment")
353 return redirect(`/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`);
361 if (order.status !== "pending_payment" || alreadyCharged(order)) return redirect(orderLink(order));
354362 if (chosen().kind !== "stripe") return payPageFor(ctx, order, "Stripe is not configured; use the mock gateway.");
355363 const back = `${publicOrigin()}/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`;
356364 const session = await gateway.startCheckout(order, orders.itemsOf(order.id), {
@@ -375,7 +383,7 @@ export async function orderPage(ctx: Ctx): Promise<Response> {
375383 return render(
376384 ctx,
377385 { title: `Order ${order.number}` },
378 pages.confirmation(order, orders.itemsOf(order.id), refunded, forOrder(order.id), ctx.who),
386 pages.confirmation(order, orders.itemsOf(order.id), refunded, forOrder(order.id), ctx.who, orders.shortfall(order)),
379387 );
380388 }
381389
@@ -441,17 +449,21 @@ export async function receiptPage(ctx: Ctx): Promise<Response> {
441449 /**
442450 * When a shopper comes back from Stripe, the session is read once and the
443451 * order is settled from Stripe's answer — the webhook does the same thing,
444 * and whichever arrives first wins because the charge has an idempotency key.
452 * through the same `orders.settleFromGateway`, and whichever arrives first
453 * wins because the charge has an idempotency key. Stripe's amount is recorded,
454 * not trusted: what it means for the order is decided there, against the total.
445455 */
446456 async function settleStripe(order: Order): Promise<Order> {
447457 if (order.status !== "pending_payment") return order;
448458 if (order.gateway !== "stripe" || !order.payment_ref?.startsWith("cs_")) return order;
449459 const session = await gateway.readCheckout(order.payment_ref);
450460 if (!session.ok || session.value.payment_status !== "paid") return order;
451 return orders.markPaid(order, {
461 // Stripe's own number for what it took, believed as a number and no further:
462 // settleFromGateway compares it to what the order owes.
463 return orders.settleFromGateway(order, {
452464 gateway: "stripe",
453465 ref: session.value.payment_intent ?? order.payment_ref,
454 amount_cents: session.value.amount_total ?? dueCents(order),
466 amount_cents: session.value.amount_total ?? 0,
455467 });
456468 }
457469
@@ -475,10 +487,10 @@ export async function stripeWebhook(ctx: Ctx): Promise<Response> {
475487 const number = String(session.client_reference_id ?? session.metadata?.order_number ?? "").slice(0, 20);
476488 const order = number ? orders.byNumber(number) : null;
477489 if (order && session.payment_status === "paid")
478 orders.markPaid(order, {
490 orders.settleFromGateway(order, {
479491 gateway: "stripe",
480492 ref: typeof session.payment_intent === "string" ? session.payment_intent : String(session.id ?? ""),
481 amount_cents: typeof session.amount_total === "number" ? session.amount_total : dueCents(order),
493 amount_cents: typeof session.amount_total === "number" ? session.amount_total : 0,
482494 });
483495 }
484496 return text("ok");
src/stripe.test.ts +118 −5
@@ -20,6 +20,7 @@ import { mkdtempSync, rmSync } from "node:fs";
2020 import { tmpdir } from "node:os";
2121 import { join } from "node:path";
2222
23 import { formatCents } from "./money.ts";
2324 import { stamp } from "./who.ts";
2425
2526 const WEBHOOK_SECRET = "whsec_a_fake_stripe_signs_with_this";
@@ -96,9 +97,14 @@ let fake = startFake(0);
9697 const FAKE_PORT = fake.port;
9798 const fakeBase = (): string => `http://127.0.0.1:${FAKE_PORT}`;
9899
99 /** The shopper finished Stripe's hosted page: the session is now paid. */
100 const payAtStripe = (id: string): void => {
101 sessions.set(id, { ...sessions.get(id)!, payment_status: "paid" });
100 /**
101 * The shopper finished Stripe's hosted page: the session is now paid. Stripe
102 * says what it collected, and `amount` is how this fake says something other
103 * than what the session asked for — a payment that did not cover the order.
104 */
105 const payAtStripe = (id: string, amount?: number): void => {
106 const session = sessions.get(id)!;
107 sessions.set(id, { ...session, payment_status: "paid", amount_total: amount ?? session.amount_total });
102108 };
103109
104110 /** What the fake was sent, from `at` onwards. */
@@ -269,6 +275,8 @@ const orderNamed = (number: string): any =>
269275 const paymentsOf = (number: string): any[] =>
270276 db.query<any, [string]>("SELECT * FROM payment WHERE order_id = ?1 ORDER BY id").all(orderNamed(number).id);
271277 const chargesOf = (number: string): any[] => paymentsOf(number).filter((row) => row.kind === "charge");
278 const eventsOf = (number: string): any[] =>
279 db.query<any, [number]>("SELECT * FROM order_event WHERE order_id = ?1 ORDER BY id").all(orderNamed(number).id);
272280 const dueOf = (number: string): number => {
273281 const order = orderNamed(number);
274282 return order.total_cents - order.gift_card_cents;
@@ -343,7 +351,7 @@ async function deliverWebhook(event: unknown, secret = WEBHOOK_SECRET): Promise<
343351 });
344352 }
345353
346 const completedEvent = (number: string, sessionId: string) => ({
354 const completedEvent = (number: string, sessionId: string, amount?: number) => ({
347355 id: "evt_test_1",
348356 type: "checkout.session.completed",
349357 data: {
@@ -353,7 +361,7 @@ const completedEvent = (number: string, sessionId: string) => ({
353361 client_reference_id: number,
354362 payment_status: "paid",
355363 payment_intent: sessions.get(sessionId)!.payment_intent,
356 amount_total: sessions.get(sessionId)!.amount_total,
364 amount_total: amount ?? sessions.get(sessionId)!.amount_total,
357365 metadata: { order_number: number },
358366 },
359367 },
@@ -583,6 +591,111 @@ test("a webhook after a return leaves exactly one charge", async () => {
583591 expect(chargesOf(placed.number).length).toBe(1);
584592 });
585593
594 // --- a payment that does not cover the order ---------------------------------
595
596 test("a short return is written down and the order is not called paid", async () => {
597 const shopper = new Shopper();
598 const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 2 }]);
599 const started = await pressPayWithCard(shopper, placed);
600 const sessionId = sessionIdIn(started.headers.get("location")!);
601 const due = dueOf(placed.number);
602
603 // Stripe says it took five dollars less than the session asked for.
604 payAtStripe(sessionId, due - 500);
605 const back = await comeBack(shopper, placed);
606 expect(back.status).toBe(200);
607
608 // The money moved, so the money is a row; the order is not paid for it.
609 const charges = chargesOf(placed.number);
610 expect(charges.length).toBe(1);
611 expect(charges[0].amount_cents).toBe(due - 500);
612 expect(charges[0].gateway_ref).toBe(sessions.get(sessionId)!.payment_intent);
613 expect(orderNamed(placed.number).status).toBe("pending_payment");
614 expect(orderNamed(placed.number).paid_at).toBeNull();
615
616 // The trail names what was asked for and what arrived.
617 const short = eventsOf(placed.number).filter((event) => event.kind === "short-payment");
618 expect(short.length).toBe(1);
619 expect(short[0].detail).toContain((due / 100).toFixed(2));
620 expect(short[0].detail).toContain(((due - 500) / 100).toFixed(2));
621 expect(short[0].detail).toContain("5.00 short");
622 expect(eventsOf(placed.number).some((event) => event.kind === "paid")).toBe(false);
623
624 // And the shopper is told, in words, rather than shown a thank-you.
625 const page = await back.text();
626 expect(page).toContain("That payment was short");
627 expect(page).toContain(formatCents(due - 500));
628 expect(page).toContain(formatCents(due));
629 expect(page).toContain(formatCents(500));
630 expect(page).not.toContain("Thank you");
631 expect(page).not.toContain("Finish paying");
632
633 // Paying again would take money the charge key could never record, so the
634 // payment step is closed and points at the order that says why.
635 const payAgain = await shopper.get(`/pay/${placed.number}?token=${encodeURIComponent(placed.token)}`);
636 expect(payAgain.status).toBe(303);
637 expect(payAgain.headers.get("location")).toContain(`/orders/${placed.number}`);
638
639 // A second return asks Stripe nothing and writes nothing.
640 const at = seen.length;
641 expect((await comeBack(shopper, placed)).status).toBe(200);
642 expect(since(at).length).toBe(0);
643 expect(chargesOf(placed.number).length).toBe(1);
644 expect(eventsOf(placed.number).filter((event) => event.kind === "short-payment").length).toBe(1);
645 });
646
647 test("a short webhook leaves the order pending and the shortfall on the admin page", async () => {
648 const shopper = new Shopper();
649 const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 1 }]);
650 const started = await pressPayWithCard(shopper, placed);
651 const sessionId = sessionIdIn(started.headers.get("location")!);
652 const due = dueOf(placed.number);
653 payAtStripe(sessionId, due - 250);
654
655 expect((await deliverWebhook(completedEvent(placed.number, sessionId, due - 250))).status).toBe(200);
656 const charges = chargesOf(placed.number);
657 expect(charges.length).toBe(1);
658 expect(charges[0].amount_cents).toBe(due - 250);
659 expect(orderNamed(placed.number).status).toBe("pending_payment");
660
661 // The admin sees the difference where the payments are listed.
662 const staff = await admin();
663 const detail = await staff.page(`/admin/orders/${placed.number}`);
664 expect(detail).toContain(`Short by ${formatCents(250)}`);
665 expect(detail).toContain(`${formatCents(due - 250)} taken against ${formatCents(due)} due`);
666 expect(detail).toContain("short-payment");
667
668 // The shopper returning after it does not turn the same short charge into a
669 // payment: one row, one event, and the order still waiting.
670 expect((await comeBack(shopper, placed)).status).toBe(200);
671 expect(chargesOf(placed.number).length).toBe(1);
672 expect(orderNamed(placed.number).status).toBe("pending_payment");
673 });
674
675 test("the exact amount still settles once, and a short event afterwards changes nothing", async () => {
676 const shopper = new Shopper();
677 const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 2 }]);
678 const started = await pressPayWithCard(shopper, placed);
679 const sessionId = sessionIdIn(started.headers.get("location")!);
680 const due = dueOf(placed.number);
681 payAtStripe(sessionId, due);
682
683 expect(await (await comeBack(shopper, placed)).text()).toContain("Thank you");
684 const order = orderNamed(placed.number);
685 expect(order.status).toBe("paid");
686 expect(chargesOf(placed.number).length).toBe(1);
687 expect(chargesOf(placed.number)[0].amount_cents).toBe(due);
688 expect(eventsOf(placed.number).filter((event) => event.kind === "paid").length).toBe(1);
689
690 // A paid order is not un-paid by a later delivery claiming a smaller amount:
691 // the charge is already recorded under this order's key.
692 expect((await deliverWebhook(completedEvent(placed.number, sessionId, due - 100))).status).toBe(200);
693 expect(orderNamed(placed.number).status).toBe("paid");
694 expect(chargesOf(placed.number).length).toBe(1);
695 expect(chargesOf(placed.number)[0].amount_cents).toBe(due);
696 expect(eventsOf(placed.number).some((event) => event.kind === "short-payment")).toBe(false);
697 });
698
586699 // --- refunds -----------------------------------------------------------------
587700
588701 test("an admin's refund goes to Stripe with the intent, the cents and a key", async () => {
src/tty.ts +9 −1
@@ -28,7 +28,7 @@ import { db } from "./db.ts";
2828 import { chosen } from "./gateway.ts";
2929 import { formatCents } from "./money.ts";
3030 import { history, addStock, setStock } from "./inventory.ts";
31 import { itemsOf, paymentsOf, recent, refund, refundableCents, refundedCents } from "./orders.ts";
31 import { itemsOf, paymentsOf, recent, refund, refundableCents, refundedCents, shortfall } from "./orders.ts";
3232 import { outbox } from "./mail.ts";
3333 import { atKeyboard } from "./who.ts";
3434 import type { GiftCard, Order, TaxRate } from "./db.ts";
@@ -132,6 +132,14 @@ function detail(order: Order): string[] {
132132 lines.push(` ${dim("total")} ${formatCents(order.total_cents)} ${dim("refunded")} ${formatCents(refundedCents(order.id))}`);
133133 for (const payment of paymentsOf(order.id))
134134 lines.push(` ${dim(payment.created_at)} ${payment.kind} ${formatCents(payment.amount_cents)} ${dim(payment.gateway_ref)}`);
135 // The same fact the admin page shows: what came in did not cover the order.
136 const short = shortfall(order);
137 if (short)
138 lines.push(
139 ` ${esc}33mshort by ${formatCents(short.short_cents)}${esc}0m ${dim(
140 `${formatCents(short.charged_cents)} taken of ${formatCents(short.due_cents)} due`,
141 )}`,
142 );
135143 return lines;
136144 }
137145
src/units.test.ts +53
@@ -21,6 +21,7 @@ const desk = await import("./desk.ts");
2121 const discounts = await import("./discounts.ts");
2222 const giftcards = await import("./giftcards.ts");
2323 const tax = await import("./tax.ts");
24 const orders = await import("./orders.ts");
2425 const dbModule = await import("./db.ts");
2526 const { db, forgetPasswordSettings, getSetting, setSetting } = dbModule;
2627
@@ -590,6 +591,58 @@ test("a card's holder is told what happened, not what the shop wrote to itself",
590591 expect(giftcards.entriesOf(card.id)[0]!.reason).toBe("chargeback from the buyer, do not honour");
591592 });
592593
594 /** An order waiting for a gateway, with a total for a payment to fall short of. */
595 const pendingOrder = (number: string, total: number) => {
596 db.query(
597 `INSERT INTO "order" (number, access_token, email, name, address1, city, postal, country,
598 subtotal_cents, shipping_cents, tax_cents, total_cents, gateway, idempotency_key)
599 VALUES (?1, 'token', '[email protected]', 'A Buyer', '1 Road', 'Town', '00000', 'UNITED STATES',
600 ?2, 0, 0, ?2, 'mock', ?1)`,
601 ).run(number, total);
602 return orders.byNumber(number)!;
603 };
604
605 const shortEvents = (orderId: number) => orders.eventsOf(orderId).filter((event) => event.kind === "short-payment");
606
607 test("a gateway's amount is compared to what the order owes before anything is called paid", () => {
608 // Short: the money that moved is written down, and the order is not paid.
609 const short = pendingOrder("BZ-SHORT-1", 5000);
610 const after = orders.settleFromGateway(short, { gateway: "mock", ref: "ref_short", amount_cents: 4500 });
611 expect(after.status).toBe("pending_payment");
612 expect(after.paid_at).toBeNull();
613 expect(orders.chargedCents(short.id)).toBe(4500);
614 expect(orders.shortfall(after)).toEqual({ charged_cents: 4500, due_cents: 5000, short_cents: 500 });
615 expect(shortEvents(short.id).length).toBe(1);
616 expect(shortEvents(short.id)[0]!.detail).toBe("asked 50.00 USD, mock paid 45.00 — 5.00 short (ref_short)");
617 expect(orders.eventsOf(short.id).some((event) => event.kind === "paid")).toBe(false);
618 // The gateway's handle is kept, so whoever looks can find the charge there.
619 expect(orders.byNumber("BZ-SHORT-1")!.payment_ref).toBe("ref_short");
620
621 // Exactly what is owed pays it, and so does more than that.
622 const exact = pendingOrder("BZ-EXACT-1", 5000);
623 expect(orders.settleFromGateway(exact, { gateway: "mock", ref: "ref_exact", amount_cents: 5000 }).status).toBe("paid");
624 expect(orders.shortfall(orders.byNumber("BZ-EXACT-1")!)).toBeNull();
625 const over = pendingOrder("BZ-OVER-1", 5000);
626 expect(orders.settleFromGateway(over, { gateway: "mock", ref: "ref_over", amount_cents: 5100 }).status).toBe("paid");
627 expect(orders.chargedCents(over.id)).toBe(5100);
628
629 // A gateway that names no amount moved no money we can write a row for, and
630 // it says so once however many times the same claim is delivered.
631 const nothing = pendingOrder("BZ-NOTHING-1", 5000);
632 for (let i = 0; i < 3; i++)
633 orders.settleFromGateway(nothing, { gateway: "stripe", ref: "pi_nothing", amount_cents: 0 });
634 expect(orders.paymentsOf(nothing.id).length).toBe(0);
635 expect(shortEvents(nothing.id).length).toBe(1);
636 expect(orders.byNumber("BZ-NOTHING-1")!.status).toBe("pending_payment");
637
638 // One key per order: a second arrival is the same charge, not another one.
639 const twice = pendingOrder("BZ-TWICE-1", 5000);
640 orders.settleFromGateway(twice, { gateway: "mock", ref: "ref_one", amount_cents: 5000 });
641 orders.settleFromGateway(twice, { gateway: "mock", ref: "ref_two", amount_cents: 5000 });
642 expect(orders.paymentsOf(twice.id).length).toBe(1);
643 expect(orders.eventsOf(twice.id).filter((event) => event.kind === "paid").length).toBe(1);
644 });
645
593646 test("a test file's database is its own, from the factory", () => {
594647 // bun test shares one process and one module registry across files, so this
595648 // is the assertion that keeps every other one honest: the database the app

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs