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

A gift card must not leave a balance Stripe will refuse #59

Merged opened this

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

Merged

ledger/bazaar:ledger/20-a-gift-card-must-not-leave-a-balance-str → main 5 commits fork 2 threads · 2 unresolved

  • Merged. ledger/20-a-gift-card-must-not-leave-a-balance-str was merged into main as de72b2c.

Review

Sign in to review this change.
9 files changed +429 −13
README.md +16
@@ -119,6 +119,22 @@ the goods, plus gift cards being bought — and only then is a card's balance
119119 taken off to leave what the gateway is asked for. `bun test` reconciles a
120120 cart with all three against the order it becomes.
121121
122 A gift card is never allowed to leave a balance the gateway would refuse.
123 Stripe will not take a charge under fifty cents in USD, so a $24.80 card
124 against a $25.00 order would strand the shopper with twenty cents nothing
125 will accept and no way to raise the card. The minimum is named per currency
126 in `gateway.minimumCents(currency)`, next to the gateway that has it — zero
127 for the mock, which takes whatever it is handed — and the card either pays
128 the whole total or holds back just enough that at least the minimum is left
129 to pay. The cart and the checkout page say which of those happened in a
130 sentence, with how much of the card was spent and how much stays on it — and
131 when the total is itself at or under the minimum, that the card is staying out
132 of the order altogether, which is true whether the order can be paid by card
133 (a total exactly at the minimum) or not at all (one under it). The selected
134 order in the terminal says the same split under its total, so an operator
135 reading a $25.00 order against a $0.50 payment can see where the rest went
136 (`desk.giftCardSplit`).
137
122138 ## An account buys; browsing does not need one
123139
124140 `/`, a category, a product page, the cart, adding to it, changing it and
src/cart.ts +68 −2
@@ -11,6 +11,8 @@ import * as discounts from "./discounts.ts";
1111 import * as giftcards from "./giftcards.ts";
1212 import * as tax from "./tax.ts";
1313 import { variantsByIds, type VariantLine } from "./catalog.ts";
14 import { minimumCents } from "./gateway.ts";
15 import { CURRENCY, formatCents, giftCardSpend } from "./money.ts";
1416 import { sign, unsign } from "./sign.ts";
1517 import { NONE, type Applied } from "./codes.ts";
1618
@@ -48,6 +50,8 @@ export type Quote = {
4850 gift_card_last4: string;
4951 gift_card_cents: number;
5052 gift_card_balance_cents: number;
53 /** What the card did, in a sentence, so the arithmetic can be followed. */
54 gift_card_note: string;
5155 total_cents: number;
5256 /** What the gateway is asked for: the total less the gift card. */
5357 due_cents: number;
@@ -60,6 +64,54 @@ export type Quote = {
6064 export const SHIPPING_CENTS = 600;
6165 export const FREE_SHIPPING_OVER_CENTS = 7500;
6266
67 /**
68 * What the card did, said once, for the cart and the checkout page. The
69 * shopper is told how much of the card was spent and how much stays on it,
70 * and — when the gateway's minimum made the card hold something back — that
71 * this is why the numbers are not the ones they expected.
72 *
73 * Exported for the tests: it takes what `quote` worked out rather than working
74 * anything out itself, so the sentences can be read at totals this catalog
75 * cannot reach.
76 */
77 export function giftCardNote(input: {
78 last4: string;
79 balance_cents: number;
80 spent_cents: number;
81 total_cents: number;
82 minimum_cents: number;
83 }): string {
84 const card = `Gift card ····${input.last4}`;
85 const left = input.balance_cents - input.spent_cents;
86 const stays = left === 0 ? "nothing is left on it" : `${formatCents(left)} is left on it`;
87 const due = input.total_cents - input.spent_cents;
88 if (input.spent_cents === input.total_cents)
89 return `${card} pays all ${formatCents(input.total_cents)} of this order — ${stays}.`;
90 // Nothing spent means the total is at or under the minimum: any of the card
91 // would leave the gateway less than it takes. At the minimum exactly the
92 // whole order still goes on a card; under it, no card payment can be made at
93 // all, and saying otherwise would promise a checkout that refuses.
94 if (input.spent_cents === 0)
95 return input.total_cents < input.minimum_cents
96 ? `${card} stays out of this order: at ${formatCents(input.total_cents)} it is under the ${formatCents(
97 input.minimum_cents,
98 )} a card payment has to reach, so it cannot be paid yet and the card keeps ${formatCents(input.balance_cents)}.`
99 : `${card} stays out of this order: spending any of it would leave less than the ${formatCents(
100 input.minimum_cents,
101 )} a card payment has to reach, so the whole ${formatCents(
102 input.total_cents,
103 )} goes on a card and the card keeps ${formatCents(input.balance_cents)}.`;
104 if (input.spent_cents === input.balance_cents)
105 return `${card} pays ${formatCents(input.spent_cents)}, all that was on it, and ${formatCents(
106 due,
107 )} goes on a card.`;
108 return `${card} pays ${formatCents(input.spent_cents)} and holds ${formatCents(
109 left,
110 )} back: a payment cannot be smaller than ${formatCents(input.minimum_cents)}, so ${formatCents(
111 due,
112 )} goes on a card.`;
113 }
114
63115 const shippingFor = (goods: number): number =>
64116 goods === 0 || goods >= FREE_SHIPPING_OVER_CENTS ? 0 : SHIPPING_CENTS;
65117
@@ -110,7 +162,8 @@ export function add(lines: CartLine[], variantId: number, quantity: number): Car
110162 * The order of the arithmetic is fixed and every step is integer cents:
111163 * goods, less the discount, plus shipping, plus tax on what is left of the
112164 * goods, plus gift cards being bought — and only then is a gift card's balance
113 * taken off the total to leave what the gateway is asked for.
165 * taken off the total to leave what the gateway is asked for, never leaving it
166 * less than the gateway's minimum.
114167 */
115168 export function quote(lines: CartLine[], options: { codes?: Applied; destination?: tax.Destination } = {}): Quote {
116169 const variants = variantsByIds(lines.map((line) => line.variantId));
@@ -175,15 +228,27 @@ export function quote(lines: CartLine[], options: { codes?: Applied; destination
175228 let giftCardCents = 0;
176229 let giftCardLast4 = "";
177230 let giftCardBalance = 0;
231 let giftCardNoteText = "";
178232 if (codes.gift !== "") {
179233 const card = giftcards.byCode(codes.gift);
180234 if (!card) codeProblems.push("That gift card is not one this shop knows.");
181235 else if (card.active !== 1) codeProblems.push(`Gift card ····${card.last4} is no longer usable.`);
182236 else if (card.balance_cents <= 0) codeProblems.push(`Gift card ····${card.last4} has nothing left on it.`);
183237 else {
238 // A card pays as much as it can, but never so much that the gateway is
239 // left an amount it will refuse: a shopper cannot raise a card's balance
240 // and cannot pay the last twenty cents any other way.
241 const minimum = minimumCents(CURRENCY);
184242 giftCardLast4 = card.last4;
185243 giftCardBalance = card.balance_cents;
186 giftCardCents = Math.min(card.balance_cents, Math.max(0, total));
244 giftCardCents = giftCardSpend(total, card.balance_cents, minimum);
245 giftCardNoteText = total <= 0 ? "" : giftCardNote({
246 last4: card.last4,
247 balance_cents: card.balance_cents,
248 spent_cents: giftCardCents,
249 total_cents: Math.max(0, total),
250 minimum_cents: minimum,
251 });
187252 }
188253 }
189254
@@ -205,6 +270,7 @@ export function quote(lines: CartLine[], options: { codes?: Applied; destination
205270 gift_card_last4: giftCardLast4,
206271 gift_card_cents: giftCardCents,
207272 gift_card_balance_cents: giftCardBalance,
273 gift_card_note: giftCardNoteText,
208274 total_cents: total,
209275 due_cents: total - giftCardCents,
210276 problems,
src/desk.ts +22 −1
@@ -28,7 +28,7 @@ import * as giftcards from "./giftcards.ts";
2828 import * as orders from "./orders.ts";
2929 import * as tax from "./tax.ts";
3030 import { db } from "./db.ts";
31 import { formatCents, parseCents } from "./money.ts";
31 import { dueCents, formatCents, parseCents } from "./money.ts";
3232 import type { Order } from "./db.ts";
3333 import type { Who } from "./who.ts";
3434
@@ -311,6 +311,27 @@ export function look(typed: string): Done {
311311 return no("That is not a code this shop knows.");
312312 }
313313
314 /**
315 * What a gift card paid on an order, in one line, or "" when no card was on
316 * it. A total of $25.00 against a payment of $0.50 is not a mistake when a
317 * card paid the rest, and an operator reading an order should not have to open
318 * the cards tab to work that out — least of all now that the gateway's minimum
319 * can leave a card holding a few cents back.
320 *
321 * This is what was split when the order was placed, not what is on the card
322 * now: a refund puts money back through `gift_card_entry`, which the cards tab
323 * shows with its reason.
324 */
325 export function giftCardSplit(order: { id: number; gift_card_cents: number; total_cents: number }): string {
326 if (order.gift_card_cents <= 0) return "";
327 const card = giftcards.cardSpentOn(order.id);
328 const named = card ? `····${card.last4}` : "a gift card";
329 const due = dueCents(order);
330 return due === 0
331 ? `gift card ${named} paid all ${formatCents(order.total_cents)}`
332 : `gift card ${named} paid ${formatCents(order.gift_card_cents)}, ${formatCents(due)} on a card`;
333 }
334
314335 export const RATE_SHAPE = "COUNTRY / REGION / 8.75 / what to call it";
315336
316337 /** "UNITED STATES / CA / 8.75 / California sales tax"; an empty region is the country's own. */
src/gateway.ts +35 −1
@@ -12,7 +12,7 @@
1212 */
1313
1414 import { createHmac, timingSafeEqual } from "node:crypto";
15 import { dueCents } from "./money.ts";
15 import { CURRENCY, dueCents } from "./money.ts";
1616 import type { Order, OrderItem } from "./db.ts";
1717
1818 export type GatewayKind = "stripe" | "mock";
@@ -56,6 +56,40 @@ export function chosen(): GatewayInfo {
5656 return { kind: "mock", label: "Mock gateway", note: "No STRIPE_SECRET_KEY is set." };
5757 }
5858
59 /**
60 * The least a gateway will take, in the currency's smallest unit.
61 *
62 * Stripe refuses a Checkout Session under about half a dollar and says so in
63 * its own words, which a shopper reads as this shop being broken. The number
64 * lives here, next to the gateway that has it, so the till can leave at least
65 * this much to be paid rather than a balance nothing will accept. A currency
66 * the table does not know takes the common fifty, which errs towards asking a
67 * gift card to hold back a little rather than towards a refused payment.
68 *
69 * The mock has no minimum: it takes whatever it is handed, and a preview of
70 * this shop must not price a cart differently from the shop itself by more
71 * than the gateway in front of it.
72 *
73 * The table names this shop's own currency and the few that are not the common
74 * fifty; everything else — the euro, the Canadian, Australian and Swiss
75 * dollars, the yen, whose fifty whole units are its minimum — takes the
76 * fallback, so there is one number to keep right per row rather than ten that
77 * mostly repeat and can go stale unnoticed.
78 */
79 const STRIPE_MINIMUM_CENTS: Record<string, number> = {
80 USD: 50,
81 GBP: 30,
82 DKK: 250,
83 NOK: 300,
84 SEK: 300,
85 };
86 const STRIPE_MINIMUM_DEFAULT_CENTS = 50;
87
88 export function minimumCents(currency: string = CURRENCY, kind: GatewayKind = chosen().kind): number {
89 if (kind !== "stripe") return 0;
90 return STRIPE_MINIMUM_CENTS[currency.trim().toUpperCase()] ?? STRIPE_MINIMUM_DEFAULT_CENTS;
91 }
92
5993 // --- Stripe -----------------------------------------------------------------
6094
6195 type StripeResult<T> = { ok: true; value: T } | { ok: false; error: string };
src/money.ts +17
@@ -41,6 +41,23 @@ export function parseQuantity(input: unknown, max = 99): number {
4141 export const dueCents = (order: { total_cents: number; gift_card_cents: number }): number =>
4242 order.total_cents - order.gift_card_cents;
4343
44 /**
45 * How much of a gift card an order may spend, given the least the gateway
46 * will take. A card that can pay the whole total pays it; otherwise it pays
47 * everything it can, unless that would leave a balance below the minimum —
48 * then it holds a little back, so what is left is an amount a card can
49 * actually be charged. An order under the minimum to begin with is beyond
50 * saving this way, and the card stays out of it rather than being spent on a
51 * payment that cannot complete.
52 */
53 export function giftCardSpend(total_cents: number, balance_cents: number, minimum_cents: number): number {

Approved. The rule is right, it is in one place, and the order path reuses it.

What I checked:

  • The arithmetic. money.giftCardSpend has three outcomes and no fourth: the card covers the total, the card spends everything because what is left is already at or over the minimum, or it holds back so exactly the minimum is left. Every path leaves due_cents either 0 or >= the minimum, and gift_card_cents + due_cents === total_cents throughout. The card-below-a-sub-minimum-total case leaves the card unspent rather than burning it on an order no gateway can finish, which is the right call.
  • One place. cart.quote is the only caller, and orders.create re-prices through quote inside the same transaction that spends the card, so the stored order cannot disagree with the page. minimumCents lives next to the gateway that has the minimum and returns 0 for the mock.
  • The tests really bind. I replaced giftCardSpend with the old Math.min(balance, total) and four tests went red, including the end-to-end one; restored, bun test is 128/128 and green under --randomize. Teaching the fake Stripe to refuse under fifty cents is the right move — the flow test now fails the way the real gateway would.
  • Security. No new input, no new route; gift_card_note is built from server-side amounts and a stored last4 and goes out through escape. On the preview the floor still holds: /admin 403 to an anon, /admin/outbox 404, a POST with no CSRF 403, a cross-origin POST 403, a 300 KB body 413, an order with a wrong token 404.
  • Both doors. desk.giftCardSplit sits on the orders tab, which desk.needsAdminToRead already closes to a non-admin, and it says the same thing the admin order page's gift-card row says. README covers both.

Two things to know, neither blocking:

  1. The preview cannot show this. It runs the mock, whose minimum is 0, so a card short of the total still spends everything there and the new sentence about holding back never appears. I verified it by running the branch locally with a test key: a $15.77 cart with a $15.57 card renders Gift card ····UQHV pays $15.27 and holds $0.30 back: a payment cannot be smaller than $0.50, so $0.50 goes on a card. above a To pay $0.50 row, on both /cart and /checkout. Worth saying in the pull body that the preview is not where this is seen — the body already does, which is why this is a note and not a request.
  2. It conflicts with main. git merge-tree origin/main <head> reports content conflicts in README.md and src/desk.ts (main moved under it with the order-ownership work). It needs a merge before it can land; the code in the conflict is prose and an import, so it should be small.

One thing left for later, not for this pull: the "it cannot be paid yet" sentence describes a total under the minimum, but nothing actually stops such an order being placed and sent to a gateway that will refuse it — the same hole a steep discount could open without a gift card in sight. It is unreachable in this catalog (cheapest total is $15.77, cards start at $25), and it is outside what #20 asked for; worth its own issue if a 99%-off code ever ships.

Small taste note only: the five-currency table is more than a USD-only shop needs, but it is documented, tested, and one number per row, so it will not rot quietly.

54 const total = Math.max(0, total_cents);
55 const balance = Math.max(0, balance_cents);
56 if (balance >= total) return total;

x

Approved. (Please ignore the one-character comment on this same line just above — a probe while working out that this forge's review API wants a line anchor. The review is this one.)

What I checked. Read the change merged onto current main (the pull is based on f3e7e8a; merge-tree against b40cb34 is clean) and ran the suite in that merged tree: 108 pass, 0 fail. Then I reverted this function to the old Math.min(balance, total) and got exactly four failures — both new tests in units.test.ts and both in stripe.test.ts — so every behaviour added is genuinely gated by a test, including the fake Stripe now refusing anything under fifty cents the way the real one does.

On the preview, as a shopper: bought a $25 gift card through the mock, read the code off the receipt, applied it to a $47.23 cart. Cart and checkout both print Gift card ····TRGJ pays $25.00, all that was on it, and $22.23 goes on a card. — escaped, under the totals, no layout shift, page in ~120 ms, security floor intact on every response (CSP with script-src 'none', nosniff, referrer-policy, HttpOnly sid). The hold-back branch cannot appear there because the preview runs the mock and the mock's minimum is zero — which is what the issue asked for, so that is right rather than missing.

Why it holds where it matters. orders.place re-prices through cart.quote inside the transaction and spends priced.gift_card_cents, so the rule reaches the stored order and the card's ledger rather than only the page — the Stripe test proves the card keeps its 30 cents and the session is for the 50 that were left. The short-payment path never re-charges a remainder, so there is no second door that could ask a gateway for a sub-minimum amount. Nothing touches identity, request bodies, rate limits or SQL; the arithmetic stays integer cents and the rule is one function with one name. Every box in the issue is ticked.

Three notes, none blocking.

  1. giftCardNote's "is held back" branch says the order "is paid in one go" — but that branch only fires when the total is at or below the minimum, where the gateway would refuse that payment too, so the sentence promises something that cannot happen; the same branch fires when the total equals the minimum exactly, where "under the $0.50 a card payment has to reach" is off by a cent. Unreachable today (any taxable goods pull in $6 shipping, gift-card variants start at $25), which is why I am not asking for a change — but it is wrong copy waiting on a price change.
  2. STRIPE_MINIMUM_CENTS carries ten currencies while money.CURRENCY is "USD" and nothing else is reachable. The values are right (GBP 30, DKK 250, NOK/SEK 300, JPY 50 in whole yen); only USD and the fallback are exercised. Fine as documentation next to the gateway, worth trimming if it goes stale.
  3. Terminal parity is satisfied — this adds no shopper action, and the cards tab already shows a balance with its entries. But tty.ts's detail() prints total, payments and shortfall and never the gift-card split, so an operator now meets more orders whose total is $25.00 against a $0.50 payment with nothing on screen explaining the gap. One line in a later pull.

README says what the rule is and why, no new dependencies, one commit, nothing dead.

57 if (total - balance >= minimum_cents) return balance;
58 return Math.max(0, total - minimum_cents);
59 }
60
4461 /** Totals are recomputed from lines, never carried. */
4562 export function sumLines(lines: { unit_price_cents: number; quantity: number }[]): number {
4663 return lines.reduce((total, line) => total + line.unit_price_cents * line.quantity, 0);
src/pages/cart.ts +5
@@ -39,6 +39,11 @@ export function totals(quote: Quote): string {
3939 row("To pay", escape(formatCents(quote.due_cents)), "grand")
4040 : ""
4141 }
42 ${
43 quote.gift_card_note !== ""
44 ? `<p class="muted" style="margin:.5rem 0 0; font-size:.88rem">${escape(quote.gift_card_note)}</p>`
45 : ""
46 }
4247 </div>`;
4348 }
4449
src/stripe.test.ts +104
@@ -43,6 +43,14 @@ const replies = new Map<string, unknown>();
4343 let failing = false;
4444 let counter = 0;
4545
46 /**
47 * The real Stripe refuses a session under fifty cents in USD, which is a
48 * shopper stranded with an order nothing will take. The fake refuses it too,
49 * so the rule that keeps a gift card from leaving such a balance is tested
50 * against a gateway that would punish getting it wrong.
51 */
52 const FAKE_MINIMUM_CENTS = 50;
53
4654 const errorBody = (message: string, status: number): Response =>
4755 Response.json({ error: { type: "api_error", message } }, { status });
4856
@@ -76,6 +84,11 @@ const startFake = (port: number) =>
7684 for (let i = 0; form.has(`line_items[${i}][quantity]`); i++)
7785 amount +=
7886 Number(form.get(`line_items[${i}][price_data][unit_amount]`)) * Number(form.get(`line_items[${i}][quantity]`));
87 if (amount < FAKE_MINIMUM_CENTS)
88 return errorBody(
89 `The Checkout Session's total amount due must convert to at least ${FAKE_MINIMUM_CENTS} cents.`,
90 400,
91 );
7992 const id = `cs_test_${++counter}`;
8093 sessions.set(id, { payment_status: "unpaid", payment_intent: `pi_test_${counter}`, amount_total: amount });
8194 return answer({ id, url: `${fakeBase()}/checkout/${id}`, amount_total: amount });
@@ -508,6 +521,97 @@ test("a discount and a gift card come off what Stripe is asked for", async () =>
508521 expect(charges[0].amount_cents).toBe(order.total_cents - order.gift_card_cents);
509522 });
510523
524 /** What the cart page says an order comes to, read off the page as a shopper does. */
525 async function cartTotalCents(shopper: Shopper): Promise<number> {
526 const cart = await shopper.page("/cart");
527 const shown = /<span>Total<\/span><span class="price">\$([\d,]+\.\d\d)</.exec(cart)![1]!;
528 return Math.round(Number(shown.replace(/,/g, "")) * 100);
529 }
530
531 /** "2480" -> "24.80", the way an admin types an amount into the card form. */
532 const dollars = (cents: number): string => (cents / 100).toFixed(2);
533
534 test("a gift card holds back enough that Stripe will still take what is left", async () => {
535 // The fake refuses what the real Stripe refuses, so this is what the shopper
536 // would hit if the card simply spent everything it had.
537 const refused = await fetch(`${fakeBase()}/v1/checkout/sessions`, {
538 method: "POST",
539 headers: { "content-type": "application/x-www-form-urlencoded" },
540 body: new URLSearchParams({
541 "line_items[0][quantity]": "1",
542 "line_items[0][price_data][unit_amount]": "20",
543 }).toString(),
544 });
545 expect(refused.status).toBe(400);
546
547 // A card twenty cents short of the order: min(balance, total) would leave
548 // twenty cents due, which is an order nobody could ever pay.
549 const measure = new Shopper();
550 const product = await measure.page(`/p/${slugOf("COF-GUJI-250")}`);
551 expect(
552 (
553 await measure.post(
554 "/cart/add",
555 { variant: String(variantId("COF-GUJI-250")), quantity: "1" },
556 Shopper.tokenIn(product),
557 )
558 ).status,
559 ).toBe(303);
560 const total = await cartTotalCents(measure);
561 const code = await issueGiftCard(dollars(total - 20), "twenty cents short of an order");
562
563 const shopper = asCustomer();
564 const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 1 }], [code]);
565 const order = orderNamed(placed.number);
566 expect(order.total_cents).toBe(total);
567 expect(order.gift_card_cents).toBe(total - 50);
568 expect(dueOf(placed.number)).toBe(50);
569
570 // The card kept the difference rather than spending it into a dead end.
571 const card = db
572 .query<any, [number]>("SELECT g.* FROM gift_card g JOIN gift_card_entry e ON e.gift_card_id = g.id WHERE e.order_id = ?1")
573 .get(order.id)!;
574 expect(card.balance_cents).toBe(30);
575
576 // And Stripe takes it: a session for exactly the fifty cents that were left.
577 const at = seen.length;
578 const started = await pressPayWithCard(shopper, placed);
579 expect(started.status).toBe(303);
580 const form = since(at)[0]!.form;
581 expect(wireTotal(form)).toBe(50);
582 expect(wireTotal(form)).toBe(dueOf(placed.number));
583 expect(orderNamed(placed.number).payment_ref).toBe(sessionIdIn(started.headers.get("location")!));
584
585 // Paying it settles the order for what was asked, and nothing is stranded.
586 payAtStripe(orderNamed(placed.number).payment_ref);
587 await comeBack(shopper, placed);
588 expect(orderNamed(placed.number).status).toBe("paid");
589 expect(chargesOf(placed.number)[0].amount_cents).toBe(50);
590 });
591
592 test("the cart says how much of a card it used and how much stays on it", async () => {
593 const shopper = new Shopper();
594 const product = await shopper.page(`/p/${slugOf("COF-GUJI-250")}`);
595 await shopper.post(
596 "/cart/add",
597 { variant: String(variantId("COF-GUJI-250")), quantity: "1" },
598 Shopper.tokenIn(product),
599 );
600 const total = await cartTotalCents(shopper);
601 const code = await issueGiftCard(dollars(total - 20), "for the sentence on the page");
602 const cart = await shopper.page("/cart");
603 expect((await shopper.post("/cart/code", { code, back: "cart" }, Shopper.tokenIn(cart))).status).toBe(303);
604
605 // The card is twenty cents short of the total, so it pays the order down to
606 // the fifty cents Stripe will take and keeps the thirty it cannot use.
607 const sentence = `pays ${formatted(total - 50)} and holds $0.30 back: a payment cannot be smaller than $0.50, so $0.50 goes on a card.`;
608 expect(await shopper.page("/cart")).toContain(sentence);
609 expect(await shopper.page("/checkout")).toContain(sentence);
610 });
611
612 /** 2480 -> "$24.80", as the page prints it. */
613 const formatted = (cents: number): string => `$${dollars(cents)}`;
614
511615 // --- coming back -------------------------------------------------------------
512616
513617 test("the shopper coming back settles the order once", async () => {
src/tty.ts +3
@@ -177,6 +177,9 @@ function detail(order: Order): string[] {
177177 )}`,
178178 );
179179 lines.push(` ${dim("total")} ${formatCents(order.total_cents)} ${dim("refunded")} ${formatCents(refundedCents(order.id))}`);
180 // Why the payments below can add up to less than the total.
181 const split = desk.giftCardSplit(order);
182 if (split !== "") lines.push(` ${dim(split)}`);
180183 for (const payment of paymentsOf(order.id))
181184 lines.push(` ${dim(payment.created_at)} ${payment.kind} ${formatCents(payment.amount_cents)} ${dim(payment.gateway_ref)}`);
182185 // The same fact the admin page shows: what came in did not cover the order.
src/units.test.ts +159 −9
@@ -8,10 +8,10 @@ import { freshData } from "./fresh.ts";
88 // or into another test file's rows, whichever order the files run in.
99 const dataDir = await freshData("units");
1010
11 const { dueCents, formatCents, parseCents, parseQuantity, sumLines } = await import("./money.ts");
12 const { encode, decode, put, add, quote } = await import("./cart.ts");
11 const { dueCents, formatCents, giftCardSpend, parseCents, parseQuantity, sumLines } = await import("./money.ts");
12 const { encode, decode, put, add, quote, giftCardNote } = await import("./cart.ts");
1313 const { sign, unsign, loadSecret } = await import("./sign.ts");
14 const { apiBase, verifyWebhook, chosen } = await import("./gateway.ts");
14 const { apiBase, verifyWebhook, chosen, minimumCents } = await import("./gateway.ts");
1515 const limit = await import("./ratelimit.ts");
1616 const mine = await import("./mine.ts");
1717 const peer = await import("./peer.ts");
@@ -160,6 +160,132 @@ test("a percent discount, a part-spent gift card and a regional rate reconcile i
160160 expect(priced.code_problems).toEqual([]);
161161 });
162162
163 test("a gateway's minimum is the gateway's, and the mock has none", () => {
164 expect(minimumCents("USD", "stripe")).toBe(50);
165 expect(minimumCents("gbp", "stripe")).toBe(30);
166 expect(minimumCents("DKK", "stripe")).toBe(250);
167 expect(minimumCents("NOK", "stripe")).toBe(300);
168 expect(minimumCents("EUR", "stripe")).toBe(50); // the fallback, which is the euro's own
169 expect(minimumCents("ZWL", "stripe")).toBe(50); // a currency the table does not know
170 expect(minimumCents("USD", "mock")).toBe(0);
171 });
172
173 test("a gift card pays all it can, or holds back enough to leave a payable balance", () => {
174 // $25.00 due, a gateway that will not take less than $0.50.
175 expect(giftCardSpend(2500, 2500, 50)).toBe(2500); // the card covers it exactly
176 expect(giftCardSpend(2500, 9000, 50)).toBe(2500); // and never spends more than the total
177 expect(giftCardSpend(2500, 2499, 50)).toBe(2450); // a cent short is not 1 cent due
178 expect(giftCardSpend(2500, 2480, 50)).toBe(2450); // would have left 20 cents
179 expect(giftCardSpend(2500, 2450, 50)).toBe(2450); // leaves exactly the minimum
180 expect(giftCardSpend(2500, 1000, 50)).toBe(1000); // nowhere near: spend the lot
181 // An order below the minimum cannot be completed whatever the card does, so
182 // the card is not spent on it.
183 expect(giftCardSpend(30, 20, 50)).toBe(0);
184 // A gateway with no minimum is the arithmetic this shop always had.
185 expect(giftCardSpend(2500, 2499, 0)).toBe(2499);
186 });
187
188 /** Price a cart with a named gateway in front of it, whatever the shell set. */
189 function withKey<T>(key: string | undefined, run: () => T): T {
190 const saved = process.env.STRIPE_SECRET_KEY;
191 if (key === undefined) delete process.env.STRIPE_SECRET_KEY;
192 else process.env.STRIPE_SECRET_KEY = key;
193 try {
194 return run();
195 } finally {
196 if (saved === undefined) delete process.env.STRIPE_SECRET_KEY;
197 else process.env.STRIPE_SECRET_KEY = saved;
198 }
199 }
200
201 const withStripe = <T,>(run: () => T): T => withKey("sk_test_units_never_calls_it", run);
202 const withMock = <T,>(run: () => T): T => withKey(undefined, run);
203
204 test("a gift card never leaves a balance Stripe would refuse", () => {
205 // $19.00 of coffee, $6.00 of postage, Oregon and its nothing per cent: $25.00.
206 const lines = [{ variantId: variantId("COF-GUJI-250"), quantity: 1 }];
207 const destination = { country: "United States", region: "OR" };
208 const priceWith = (cents: number) => {
209 const { code } = giftcards.issue({ cents, note: `a card worth ${cents}`, by: ADA });
210 return withStripe(() => quote(lines, { codes: { discount: "", gift: code }, destination }));
211 };
212
213 const exact = priceWith(2500);
214 expect(exact.total_cents).toBe(2500);
215 expect(exact.gift_card_cents).toBe(2500);
216 expect(exact.due_cents).toBe(0);
217 expect(exact.gift_card_note).toBe(
218 `Gift card ····${exact.gift_card_last4} pays all $25.00 of this order — nothing is left on it.`,
219 );
220
221 // One cent short: paying the whole card would ask Stripe for a cent.
222 const short = priceWith(2499);
223 expect(short.gift_card_cents).toBe(2450);
224 expect(short.due_cents).toBe(50);
225 expect(short.gift_card_note).toBe(
226 `Gift card ····${short.gift_card_last4} pays $24.50 and holds $0.49 back:` +
227 " a payment cannot be smaller than $0.50, so $0.50 goes on a card.",
228 );
229
230 // The bug as it was reported: a $24.80 card against a $25.00 order.
231 const stub = priceWith(2480);
232 expect(stub.gift_card_cents).toBe(2450);
233 expect(stub.due_cents).toBe(50);
234 expect(stub.gift_card_balance_cents - stub.gift_card_cents).toBe(30);
235
236 // A card that leaves exactly the minimum spends everything it has.
237 const exactly = priceWith(2450);
238 expect(exactly.gift_card_cents).toBe(2450);
239 expect(exactly.due_cents).toBe(50);
240 expect(exactly.gift_card_note).toBe(
241 `Gift card ····${exactly.gift_card_last4} pays $24.50, all that was on it, and $0.50 goes on a card.`,
242 );
243
244 // Whatever the card, what is left is nothing or something a card can take,
245 // and the two parts still add up to the total.
246 for (const cents of [1, 49, 50, 2449, 2450, 2451, 2480, 2499, 2500, 2501, 9999]) {
247 const priced = priceWith(cents);
248 expect(priced.gift_card_cents + priced.due_cents).toBe(priced.total_cents);
249 expect(priced.due_cents === 0 || priced.due_cents >= 50).toBe(true);
250 expect(priced.gift_card_cents).toBeLessThanOrEqual(priced.gift_card_balance_cents);
251 }
252 });
253
254 test("a card that stays out of an order says truthfully why", () => {
255 // Nothing this catalog sells reaches a total at or under fifty cents, so the
256 // sentence is read where it is written: what `quote` would hand it, at a
257 // minimum big enough to make the branch fire.
258 const held = { last4: "TRGJ", balance_cents: 2500, spent_cents: 0, minimum_cents: 5000 };
259
260 // At the minimum exactly, a card payment for the whole order is one the
261 // gateway takes — the card is out of it, not the order.
262 expect(giftCardNote({ ...held, total_cents: 5000 })).toBe(
263 "Gift card ····TRGJ stays out of this order: spending any of it would leave less than the $50.00" +
264 " a card payment has to reach, so the whole $50.00 goes on a card and the card keeps $25.00.",
265 );
266
267 // Under it, no card payment can be made at all, and promising one would send
268 // the shopper to a checkout that refuses.
269 expect(giftCardNote({ ...held, total_cents: 4999 })).toBe(
270 "Gift card ····TRGJ stays out of this order: at $49.99 it is under the $50.00" +
271 " a card payment has to reach, so it cannot be paid yet and the card keeps $25.00.",
272 );
273 });
274
275 test("with no gateway minimum a gift card still pays everything it can", () => {
276 const lines = [{ variantId: variantId("COF-GUJI-250"), quantity: 1 }];
277 const { code } = giftcards.issue({ cents: 2499, note: "against the mock", by: ADA });
278 const priced = withMock(() => {
279 expect(chosen().kind).toBe("mock");
280 return quote(lines, {
281 codes: { discount: "", gift: code },
282 destination: { country: "United States", region: "OR" },
283 });
284 });
285 expect(priced.gift_card_cents).toBe(2499);
286 expect(priced.due_cents).toBe(1);
287 });
288
163289 test("the rate follows the address, and tax is taken after the discount", () => {
164290 const goods = variantId("COF-GUJI-250");
165291 const lines = [{ variantId: goods, quantity: 3 }];
@@ -378,16 +504,20 @@ test("the settings the admin password left behind are deleted, and nothing else
378504 expect(getSetting("tax_default_bp")).toBe("800");
379505 });
380506
381 /** An order row to hang a redemption on, without going through checkout. */
382 const bareOrder = (number: string): number =>
507 /** An order row to hang a redemption or a card's spend on, without checkout. */
508 const bareOrder = (number: string, money: { total_cents: number; gift_card_cents: number } = { total_cents: 0, gift_card_cents: 0 }): number =>
383509 db
384 .query<{ id: number }, [string]>(
510 .query<{ id: number }, [string, number, number]>(
385511 `INSERT INTO "order" (number, access_token, email, name, address1, city, postal, country,
386 subtotal_cents, shipping_cents, tax_cents, total_cents, idempotency_key)
512 subtotal_cents, shipping_cents, tax_cents, gift_card_cents, total_cents, idempotency_key)
387513 VALUES (?1, 'token', '[email protected]', 'A Buyer', '1 Road', 'Town', '00000', 'UNITED STATES',
388 0, 0, 0, 0, ?1) RETURNING id`,
514 ?2, 0, 0, ?3, ?2, ?1) RETURNING id`,
389515 )
390 .get(number)!.id;
516 .get(number, money.total_cents, money.gift_card_cents)!.id;
517
518 /** That row back, as the terminal and the admin pages read it. */
519 const orderRow = (id: number): import("./db.ts").Order =>
520 db.query<import("./db.ts").Order, [number]>('SELECT * FROM "order" WHERE id = ?1').get(id)!;
391521
392522 test("a code's limits and expiry are enforced where the use is taken, not only where it is shown", () => {
393523 const orderId = bareOrder("BZ-REDEEM-1");
@@ -717,6 +847,26 @@ test("a card's holder is told what happened, not what the shop wrote to itself",
717847 expect(giftcards.entriesOf(card.id)[0]!.reason).toBe("chargeback from the buyer, do not honour");
718848 });
719849
850 test("an order tells the terminal in one line what a card paid and what a card owes", () => {
851 // The screen shows a total and the payments under it. When a gift card paid
852 // part of the total — and the gateway's minimum can now leave it paying all
853 // but fifty cents — the two do not add up, and this is the line that says why.
854 const part = giftcards.issue({ cents: 1000, note: "a tenner towards it", by: ADA });
855 const partly = bareOrder("BZ-SPLIT-PART", { total_cents: 2500, gift_card_cents: 1000 });
856 expect(giftcards.spend(part.card.id, 1000, "paid on BZ-SPLIT-PART", ADA, partly)).toBe(true);
857 expect(desk.giftCardSplit(orderRow(partly))).toBe(`gift card ····${part.card.last4} paid $10.00, $15.00 on a card`);
858
859 // A card that covered the lot leaves the gateway nothing to be asked for,
860 // and an order with no payment at all under it.
861 const whole = giftcards.issue({ cents: 2500, note: "enough for all of it", by: ADA });
862 const covered = bareOrder("BZ-SPLIT-WHOLE", { total_cents: 2500, gift_card_cents: 2500 });
863 expect(giftcards.spend(whole.card.id, 2500, "paid on BZ-SPLIT-WHOLE", ADA, covered)).toBe(true);
864 expect(desk.giftCardSplit(orderRow(covered))).toBe(`gift card ····${whole.card.last4} paid all $25.00`);
865
866 // No card, nothing to explain: the line is not printed at all.
867 expect(desk.giftCardSplit(orderRow(bareOrder("BZ-SPLIT-NONE", { total_cents: 2500, gift_card_cents: 0 })))).toBe("");
868 });
869
720870 /** An order waiting for a gateway, with a total for a payment to fall short of. */
721871 const pendingOrder = (number: string, total: number) => {
722872 db.query(

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs