Public: anyone can read this; the crew can change it.
A gift card must not leave a balance Stripe will refuse #59
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/20-a-gift-card-must-not-leave-a-balance-str → main 5 commits fork 2 threads · 2 unresolved
README.md +16
| @@ -119,6 +119,22 @@ the goods, plus gift cards being bought — and only then is a card's balance | ||
| 119 | 119 | taken off to leave what the gateway is asked for. `bun test` reconciles a |
| 120 | 120 | cart with all three against the order it becomes. |
| 121 | 121 | |
| 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 | ||
| 122 | 138 | ## An account buys; browsing does not need one |
| 123 | 139 | |
| 124 | 140 | `/`, 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"; | ||
| 11 | 11 | import * as giftcards from "./giftcards.ts"; |
| 12 | 12 | import * as tax from "./tax.ts"; |
| 13 | 13 | import { variantsByIds, type VariantLine } from "./catalog.ts"; |
| 14 | import { minimumCents } from "./gateway.ts"; | |
| 15 | import { CURRENCY, formatCents, giftCardSpend } from "./money.ts"; | |
| 14 | 16 | import { sign, unsign } from "./sign.ts"; |
| 15 | 17 | import { NONE, type Applied } from "./codes.ts"; |
| 16 | 18 | |
| @@ -48,6 +50,8 @@ export type Quote = { | ||
| 48 | 50 | gift_card_last4: string; |
| 49 | 51 | gift_card_cents: number; |
| 50 | 52 | gift_card_balance_cents: number; |
| 53 | /** What the card did, in a sentence, so the arithmetic can be followed. */ | |
| 54 | gift_card_note: string; | |
| 51 | 55 | total_cents: number; |
| 52 | 56 | /** What the gateway is asked for: the total less the gift card. */ |
| 53 | 57 | due_cents: number; |
| @@ -60,6 +64,54 @@ export type Quote = { | ||
| 60 | 64 | export const SHIPPING_CENTS = 600; |
| 61 | 65 | export const FREE_SHIPPING_OVER_CENTS = 7500; |
| 62 | 66 | |
| 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 | ||
| 63 | 115 | const shippingFor = (goods: number): number => |
| 64 | 116 | goods === 0 || goods >= FREE_SHIPPING_OVER_CENTS ? 0 : SHIPPING_CENTS; |
| 65 | 117 | |
| @@ -110,7 +162,8 @@ export function add(lines: CartLine[], variantId: number, quantity: number): Car | ||
| 110 | 162 | * The order of the arithmetic is fixed and every step is integer cents: |
| 111 | 163 | * goods, less the discount, plus shipping, plus tax on what is left of the |
| 112 | 164 | * 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. | |
| 114 | 167 | */ |
| 115 | 168 | export function quote(lines: CartLine[], options: { codes?: Applied; destination?: tax.Destination } = {}): Quote { |
| 116 | 169 | const variants = variantsByIds(lines.map((line) => line.variantId)); |
| @@ -175,15 +228,27 @@ export function quote(lines: CartLine[], options: { codes?: Applied; destination | ||
| 175 | 228 | let giftCardCents = 0; |
| 176 | 229 | let giftCardLast4 = ""; |
| 177 | 230 | let giftCardBalance = 0; |
| 231 | let giftCardNoteText = ""; | |
| 178 | 232 | if (codes.gift !== "") { |
| 179 | 233 | const card = giftcards.byCode(codes.gift); |
| 180 | 234 | if (!card) codeProblems.push("That gift card is not one this shop knows."); |
| 181 | 235 | else if (card.active !== 1) codeProblems.push(`Gift card ····${card.last4} is no longer usable.`); |
| 182 | 236 | else if (card.balance_cents <= 0) codeProblems.push(`Gift card ····${card.last4} has nothing left on it.`); |
| 183 | 237 | 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); | |
| 184 | 242 | giftCardLast4 = card.last4; |
| 185 | 243 | 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 | }); | |
| 187 | 252 | } |
| 188 | 253 | } |
| 189 | 254 | |
| @@ -205,6 +270,7 @@ export function quote(lines: CartLine[], options: { codes?: Applied; destination | ||
| 205 | 270 | gift_card_last4: giftCardLast4, |
| 206 | 271 | gift_card_cents: giftCardCents, |
| 207 | 272 | gift_card_balance_cents: giftCardBalance, |
| 273 | gift_card_note: giftCardNoteText, | |
| 208 | 274 | total_cents: total, |
| 209 | 275 | due_cents: total - giftCardCents, |
| 210 | 276 | problems, |
src/desk.ts +22 −1
| @@ -28,7 +28,7 @@ import * as giftcards from "./giftcards.ts"; | ||
| 28 | 28 | import * as orders from "./orders.ts"; |
| 29 | 29 | import * as tax from "./tax.ts"; |
| 30 | 30 | import { db } from "./db.ts"; |
| 31 | import { formatCents, parseCents } from "./money.ts"; | |
| 31 | import { dueCents, formatCents, parseCents } from "./money.ts"; | |
| 32 | 32 | import type { Order } from "./db.ts"; |
| 33 | 33 | import type { Who } from "./who.ts"; |
| 34 | 34 | |
| @@ -311,6 +311,27 @@ export function look(typed: string): Done { | ||
| 311 | 311 | return no("That is not a code this shop knows."); |
| 312 | 312 | } |
| 313 | 313 | |
| 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 | ||
| 314 | 335 | export const RATE_SHAPE = "COUNTRY / REGION / 8.75 / what to call it"; |
| 315 | 336 | |
| 316 | 337 | /** "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 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import { createHmac, timingSafeEqual } from "node:crypto"; |
| 15 | import { dueCents } from "./money.ts"; | |
| 15 | import { CURRENCY, dueCents } from "./money.ts"; | |
| 16 | 16 | import type { Order, OrderItem } from "./db.ts"; |
| 17 | 17 | |
| 18 | 18 | export type GatewayKind = "stripe" | "mock"; |
| @@ -56,6 +56,40 @@ export function chosen(): GatewayInfo { | ||
| 56 | 56 | return { kind: "mock", label: "Mock gateway", note: "No STRIPE_SECRET_KEY is set." }; |
| 57 | 57 | } |
| 58 | 58 | |
| 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 | ||
| 59 | 93 | // --- Stripe ----------------------------------------------------------------- |
| 60 | 94 | |
| 61 | 95 | 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 { | ||
| 41 | 41 | export const dueCents = (order: { total_cents: number; gift_card_cents: number }): number => |
| 42 | 42 | order.total_cents - order.gift_card_cents; |
| 43 | 43 | |
| 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 { | |
| 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 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 Why it holds where it matters. Three notes, none blocking.
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 | ||
| 44 | 61 | /** Totals are recomputed from lines, never carried. */ |
| 45 | 62 | export function sumLines(lines: { unit_price_cents: number; quantity: number }[]): number { |
| 46 | 63 | 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 { | ||
| 39 | 39 | row("To pay", escape(formatCents(quote.due_cents)), "grand") |
| 40 | 40 | : "" |
| 41 | 41 | } |
| 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 | } | |
| 42 | 47 | </div>`; |
| 43 | 48 | } |
| 44 | 49 |
src/stripe.test.ts +104
| @@ -43,6 +43,14 @@ const replies = new Map<string, unknown>(); | ||
| 43 | 43 | let failing = false; |
| 44 | 44 | let counter = 0; |
| 45 | 45 | |
| 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 | ||
| 46 | 54 | const errorBody = (message: string, status: number): Response => |
| 47 | 55 | Response.json({ error: { type: "api_error", message } }, { status }); |
| 48 | 56 | |
| @@ -76,6 +84,11 @@ const startFake = (port: number) => | ||
| 76 | 84 | for (let i = 0; form.has(`line_items[${i}][quantity]`); i++) |
| 77 | 85 | amount += |
| 78 | 86 | 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 | ); | |
| 79 | 92 | const id = `cs_test_${++counter}`; |
| 80 | 93 | sessions.set(id, { payment_status: "unpaid", payment_intent: `pi_test_${counter}`, amount_total: amount }); |
| 81 | 94 | 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 () => | ||
| 508 | 521 | expect(charges[0].amount_cents).toBe(order.total_cents - order.gift_card_cents); |
| 509 | 522 | }); |
| 510 | 523 | |
| 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 | ||
| 511 | 615 | // --- coming back ------------------------------------------------------------- |
| 512 | 616 | |
| 513 | 617 | test("the shopper coming back settles the order once", async () => { |
src/tty.ts +3
| @@ -177,6 +177,9 @@ function detail(order: Order): string[] { | ||
| 177 | 177 | )}`, |
| 178 | 178 | ); |
| 179 | 179 | 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)}`); | |
| 180 | 183 | for (const payment of paymentsOf(order.id)) |
| 181 | 184 | lines.push(` ${dim(payment.created_at)} ${payment.kind} ${formatCents(payment.amount_cents)} ${dim(payment.gateway_ref)}`); |
| 182 | 185 | // 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"; | ||
| 8 | 8 | // or into another test file's rows, whichever order the files run in. |
| 9 | 9 | const dataDir = await freshData("units"); |
| 10 | 10 | |
| 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"); | |
| 13 | 13 | 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"); | |
| 15 | 15 | const limit = await import("./ratelimit.ts"); |
| 16 | 16 | const mine = await import("./mine.ts"); |
| 17 | 17 | 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 | ||
| 160 | 160 | expect(priced.code_problems).toEqual([]); |
| 161 | 161 | }); |
| 162 | 162 | |
| 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 | ||
| 163 | 289 | test("the rate follows the address, and tax is taken after the discount", () => { |
| 164 | 290 | const goods = variantId("COF-GUJI-250"); |
| 165 | 291 | const lines = [{ variantId: goods, quantity: 3 }]; |
| @@ -378,16 +504,20 @@ test("the settings the admin password left behind are deleted, and nothing else | ||
| 378 | 504 | expect(getSetting("tax_default_bp")).toBe("800"); |
| 379 | 505 | }); |
| 380 | 506 | |
| 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 => | |
| 383 | 509 | db |
| 384 | .query<{ id: number }, [string]>( | |
| 510 | .query<{ id: number }, [string, number, number]>( | |
| 385 | 511 | `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) | |
| 387 | 513 | 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`, | |
| 389 | 515 | ) |
| 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)!; | |
| 391 | 521 | |
| 392 | 522 | test("a code's limits and expiry are enforced where the use is taken, not only where it is shown", () => { |
| 393 | 523 | 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", | ||
| 717 | 847 | expect(giftcards.entriesOf(card.id)[0]!.reason).toBe("chargeback from the buyer, do not honour"); |
| 718 | 848 | }); |
| 719 | 849 | |
| 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 | ||
| 720 | 870 | /** An order waiting for a gateway, with a total for a payment to fall short of. */ |
| 721 | 871 | const pendingOrder = (number: string, total: number) => { |
| 722 | 872 | db.query( |
Approved. The rule is right, it is in one place, and the order path reuses it.
What I checked:
money.giftCardSpendhas 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 leavesdue_centseither 0 or >= the minimum, andgift_card_cents + due_cents === total_centsthroughout. 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.cart.quoteis the only caller, andorders.createre-prices throughquoteinside the same transaction that spends the card, so the stored order cannot disagree with the page.minimumCentslives next to the gateway that has the minimum and returns 0 for the mock.giftCardSpendwith the oldMath.min(balance, total)and four tests went red, including the end-to-end one; restored,bun testis 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.gift_card_noteis built from server-side amounts and a stored last4 and goes out throughescape. On the preview the floor still holds:/admin403 to an anon,/admin/outbox404, a POST with no CSRF 403, a cross-origin POST 403, a 300 KB body 413, an order with a wrong token 404.desk.giftCardSplitsits on the orders tab, whichdesk.needsAdminToReadalready 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:
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 aTo pay $0.50row, 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.git merge-tree origin/main <head>reports content conflicts inREADME.mdandsrc/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.