Public: anyone can read this; the crew can change it.
A payment short of what the order owes does not mark it paid #37
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/31-a-payment-short-of-what-the-order-owes-d → main 2 commits fork
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 | ||
| 171 | 171 | the ledger second, with an idempotency key built from the order and what has |
| 172 | 172 | already been refunded. |
| 173 | 173 | |
| 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 | ||
| 174 | 185 | What Stripe is asked for is the order's total less whatever a gift card |
| 175 | 186 | already paid — `dueCents` in `src/money.ts`, which the payment page, the |
| 176 | 187 | 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 | ||
| 189 | 200 | `STRIPE_API_BASE` at it and boots the app in its own process against it, then |
| 190 | 201 | asserts what went down the wire: the amounts, the currency, the idempotency |
| 191 | 202 | 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. | |
| 194 | 206 | |
| 195 | 207 | ## Environment |
| 196 | 208 |
src/admin.ts +1
| @@ -254,6 +254,7 @@ export function orderDetail(ctx: Ctx): Response { | ||
| 254 | 254 | forOrder(order.id), |
| 255 | 255 | ctx.session, |
| 256 | 256 | messageFrom(ctx), |
| 257 | orders.shortfall(order), | |
| 257 | 258 | ), |
| 258 | 259 | ); |
| 259 | 260 | } |
src/orders.ts +76 −15
| @@ -240,28 +240,89 @@ export function place(input: { | ||
| 240 | 240 | } |
| 241 | 241 | } |
| 242 | 242 | |
| 243 | export type Charge = { gateway: string; ref: string; amount_cents: number }; | |
| 244 | ||
| 243 | 245 | /** |
| 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. | |
| 246 | 249 | */ |
| 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) { | |
| 256 | 300 | db.query( |
| 257 | 301 | `UPDATE "order" SET status = 'paid', paid_at = datetime('now'), payment_ref = ?2, gateway = ?3 |
| 258 | 302 | 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)!; | |
| 261 | 306 | } |
| 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)!; | |
| 263 | 324 | })(); |
| 264 | return settle(paid); | |
| 325 | return settle(settled); | |
| 265 | 326 | } |
| 266 | 327 | |
| 267 | 328 | /** |
src/pages/admin.ts +10
| @@ -15,6 +15,7 @@ import type { Category, GiftCard, Order, OrderItem, Payment, TaxRate, Variant } | ||
| 15 | 15 | import type { StockEntry } from "../inventory.ts"; |
| 16 | 16 | import type { Email } from "../mail.ts"; |
| 17 | 17 | import type { Session } from "../session.ts"; |
| 18 | import type { Shortfall } from "../orders.ts"; | |
| 18 | 19 | |
| 19 | 20 | export const nav = (current: string): string => |
| 20 | 21 | `<nav>${[ |
| @@ -113,6 +114,7 @@ export function order( | ||
| 113 | 114 | email: Email | null, |
| 114 | 115 | session: Session, |
| 115 | 116 | message?: { text: string; bad?: boolean }, |
| 117 | short: Shortfall | null = null, | |
| 116 | 118 | ): string { |
| 117 | 119 | return ` |
| 118 | 120 | ${nav("orders")} |
| @@ -153,6 +155,14 @@ ${orderTotals(found, refunded)} | ||
| 153 | 155 | </div> |
| 154 | 156 | <div> |
| 155 | 157 | <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 | } | |
| 156 | 166 | ${ |
| 157 | 167 | payments.length === 0 |
| 158 | 168 | ? '<p class="muted">No payments recorded.</p>' |
src/pages/checkout.ts +19 −3
| @@ -11,6 +11,7 @@ import { dueCents, formatCents } from "../money.ts"; | ||
| 11 | 11 | import { codeBox, totals } from "./cart.ts"; |
| 12 | 12 | import type { Quote } from "../cart.ts"; |
| 13 | 13 | import type { Order, OrderItem, Payment } from "../db.ts"; |
| 14 | import type { Shortfall } from "../orders.ts"; | |
| 14 | 15 | import type { GatewayInfo } from "../gateway.ts"; |
| 15 | 16 | import type { Email } from "../mail.ts"; |
| 16 | 17 | import type { Session } from "../session.ts"; |
| @@ -196,12 +197,25 @@ export const statusPill = (status: string): string => | ||
| 196 | 197 | STATUS_WORDS[status] ?? status, |
| 197 | 198 | )}</span>`; |
| 198 | 199 | |
| 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 | ||
| 199 | 212 | export function confirmation( |
| 200 | 213 | order: Order, |
| 201 | 214 | items: OrderItem[], |
| 202 | 215 | refunded: number, |
| 203 | 216 | email: Email | null, |
| 204 | 217 | who: Who, |
| 218 | short: Shortfall | null = null, | |
| 205 | 219 | ): string { |
| 206 | 220 | const paid = order.status === "paid" || order.status === "refunded" || order.status === "partially_refunded"; |
| 207 | 221 | // A card's code is printed once, on the receipt. Say so here: a buyer who |
| @@ -211,10 +225,12 @@ export function confirmation( | ||
| 211 | 225 | <h1>${paid ? "Thank you" : "Order " + escape(order.number)}</h1> |
| 212 | 226 | <p class="lede">Order ${escape(order.number)} · ${statusPill(order.status)} · placed ${escape(order.created_at)} UTC</p> |
| 213 | 227 | ${ |
| 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. | |
| 216 | 232 | <a href="/pay/${escape(order.number)}?token=${escape(order.access_token)}">Finish paying</a>.</p>` |
| 217 | : "" | |
| 233 | : "" | |
| 218 | 234 | } |
| 219 | 235 | ${ |
| 220 | 236 | paid && email |
src/shop.ts +25 −13
| @@ -303,11 +303,21 @@ function openOrder(ctx: Ctx, tokenFrom: "query" | "form"): Order | null { | ||
| 303 | 303 | return token.length > 0 && equals(token, order.access_token) ? order : null; |
| 304 | 304 | } |
| 305 | 305 | |
| 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 | ||
| 306 | 317 | export function payPage(ctx: Ctx, error?: string): Response { |
| 307 | 318 | const order = openOrder(ctx, "query"); |
| 308 | 319 | 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)); | |
| 311 | 321 | return render( |
| 312 | 322 | ctx, |
| 313 | 323 | { title: `Pay ${order.number}` }, |
| @@ -320,8 +330,7 @@ export function payMock(ctx: Ctx): Response { | ||
| 320 | 330 | if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment"); |
| 321 | 331 | const order = openOrder(ctx, "form"); |
| 322 | 332 | 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)); | |
| 325 | 334 | if (chosen().kind !== "mock") return redirect(`/pay/${order.number}?token=${encodeURIComponent(order.access_token)}`); |
| 326 | 335 | const decision = field(ctx.form, "decision", 10); |
| 327 | 336 | if (decision !== "approve") { |
| @@ -329,7 +338,7 @@ export function payMock(ctx: Ctx): Response { | ||
| 329 | 338 | ctx.params.number = order.number; |
| 330 | 339 | return payPageFor(ctx, order, "The mock gateway declined this payment. Nothing was charged; try again."); |
| 331 | 340 | } |
| 332 | const paid = orders.markPaid(order, { | |
| 341 | const paid = orders.settleFromGateway(order, { | |
| 333 | 342 | gateway: "mock", |
| 334 | 343 | ref: `mock_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`, |
| 335 | 344 | amount_cents: dueCents(order), |
| @@ -349,8 +358,7 @@ export async function payStripe(ctx: Ctx): Promise<Response> { | ||
| 349 | 358 | if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment"); |
| 350 | 359 | const order = openOrder(ctx, "form"); |
| 351 | 360 | 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)); | |
| 354 | 362 | if (chosen().kind !== "stripe") return payPageFor(ctx, order, "Stripe is not configured; use the mock gateway."); |
| 355 | 363 | const back = `${publicOrigin()}/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`; |
| 356 | 364 | const session = await gateway.startCheckout(order, orders.itemsOf(order.id), { |
| @@ -375,7 +383,7 @@ export async function orderPage(ctx: Ctx): Promise<Response> { | ||
| 375 | 383 | return render( |
| 376 | 384 | ctx, |
| 377 | 385 | { 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)), | |
| 379 | 387 | ); |
| 380 | 388 | } |
| 381 | 389 | |
| @@ -441,17 +449,21 @@ export async function receiptPage(ctx: Ctx): Promise<Response> { | ||
| 441 | 449 | /** |
| 442 | 450 | * When a shopper comes back from Stripe, the session is read once and the |
| 443 | 451 | * 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. | |
| 445 | 455 | */ |
| 446 | 456 | async function settleStripe(order: Order): Promise<Order> { |
| 447 | 457 | if (order.status !== "pending_payment") return order; |
| 448 | 458 | if (order.gateway !== "stripe" || !order.payment_ref?.startsWith("cs_")) return order; |
| 449 | 459 | const session = await gateway.readCheckout(order.payment_ref); |
| 450 | 460 | 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, { | |
| 452 | 464 | gateway: "stripe", |
| 453 | 465 | 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, | |
| 455 | 467 | }); |
| 456 | 468 | } |
| 457 | 469 | |
| @@ -475,10 +487,10 @@ export async function stripeWebhook(ctx: Ctx): Promise<Response> { | ||
| 475 | 487 | const number = String(session.client_reference_id ?? session.metadata?.order_number ?? "").slice(0, 20); |
| 476 | 488 | const order = number ? orders.byNumber(number) : null; |
| 477 | 489 | if (order && session.payment_status === "paid") |
| 478 | orders.markPaid(order, { | |
| 490 | orders.settleFromGateway(order, { | |
| 479 | 491 | gateway: "stripe", |
| 480 | 492 | 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, | |
| 482 | 494 | }); |
| 483 | 495 | } |
| 484 | 496 | return text("ok"); |
src/stripe.test.ts +118 −5
| @@ -20,6 +20,7 @@ import { mkdtempSync, rmSync } from "node:fs"; | ||
| 20 | 20 | import { tmpdir } from "node:os"; |
| 21 | 21 | import { join } from "node:path"; |
| 22 | 22 | |
| 23 | import { formatCents } from "./money.ts"; | |
| 23 | 24 | import { stamp } from "./who.ts"; |
| 24 | 25 | |
| 25 | 26 | const WEBHOOK_SECRET = "whsec_a_fake_stripe_signs_with_this"; |
| @@ -96,9 +97,14 @@ let fake = startFake(0); | ||
| 96 | 97 | const FAKE_PORT = fake.port; |
| 97 | 98 | const fakeBase = (): string => `http://127.0.0.1:${FAKE_PORT}`; |
| 98 | 99 | |
| 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 }); | |
| 102 | 108 | }; |
| 103 | 109 | |
| 104 | 110 | /** What the fake was sent, from `at` onwards. */ |
| @@ -269,6 +275,8 @@ const orderNamed = (number: string): any => | ||
| 269 | 275 | const paymentsOf = (number: string): any[] => |
| 270 | 276 | db.query<any, [string]>("SELECT * FROM payment WHERE order_id = ?1 ORDER BY id").all(orderNamed(number).id); |
| 271 | 277 | 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); | |
| 272 | 280 | const dueOf = (number: string): number => { |
| 273 | 281 | const order = orderNamed(number); |
| 274 | 282 | return order.total_cents - order.gift_card_cents; |
| @@ -343,7 +351,7 @@ async function deliverWebhook(event: unknown, secret = WEBHOOK_SECRET): Promise< | ||
| 343 | 351 | }); |
| 344 | 352 | } |
| 345 | 353 | |
| 346 | const completedEvent = (number: string, sessionId: string) => ({ | |
| 354 | const completedEvent = (number: string, sessionId: string, amount?: number) => ({ | |
| 347 | 355 | id: "evt_test_1", |
| 348 | 356 | type: "checkout.session.completed", |
| 349 | 357 | data: { |
| @@ -353,7 +361,7 @@ const completedEvent = (number: string, sessionId: string) => ({ | ||
| 353 | 361 | client_reference_id: number, |
| 354 | 362 | payment_status: "paid", |
| 355 | 363 | payment_intent: sessions.get(sessionId)!.payment_intent, |
| 356 | amount_total: sessions.get(sessionId)!.amount_total, | |
| 364 | amount_total: amount ?? sessions.get(sessionId)!.amount_total, | |
| 357 | 365 | metadata: { order_number: number }, |
| 358 | 366 | }, |
| 359 | 367 | }, |
| @@ -583,6 +591,111 @@ test("a webhook after a return leaves exactly one charge", async () => { | ||
| 583 | 591 | expect(chargesOf(placed.number).length).toBe(1); |
| 584 | 592 | }); |
| 585 | 593 | |
| 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 | ||
| 586 | 699 | // --- refunds ----------------------------------------------------------------- |
| 587 | 700 | |
| 588 | 701 | 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"; | ||
| 28 | 28 | import { chosen } from "./gateway.ts"; |
| 29 | 29 | import { formatCents } from "./money.ts"; |
| 30 | 30 | 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"; | |
| 32 | 32 | import { outbox } from "./mail.ts"; |
| 33 | 33 | import { atKeyboard } from "./who.ts"; |
| 34 | 34 | import type { GiftCard, Order, TaxRate } from "./db.ts"; |
| @@ -132,6 +132,14 @@ function detail(order: Order): string[] { | ||
| 132 | 132 | lines.push(` ${dim("total")} ${formatCents(order.total_cents)} ${dim("refunded")} ${formatCents(refundedCents(order.id))}`); |
| 133 | 133 | for (const payment of paymentsOf(order.id)) |
| 134 | 134 | 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 | ); | |
| 135 | 143 | return lines; |
| 136 | 144 | } |
| 137 | 145 |
src/units.test.ts +53
| @@ -21,6 +21,7 @@ const desk = await import("./desk.ts"); | ||
| 21 | 21 | const discounts = await import("./discounts.ts"); |
| 22 | 22 | const giftcards = await import("./giftcards.ts"); |
| 23 | 23 | const tax = await import("./tax.ts"); |
| 24 | const orders = await import("./orders.ts"); | |
| 24 | 25 | const dbModule = await import("./db.ts"); |
| 25 | 26 | const { db, forgetPasswordSettings, getSetting, setSetting } = dbModule; |
| 26 | 27 | |
| @@ -590,6 +591,58 @@ test("a card's holder is told what happened, not what the shop wrote to itself", | ||
| 590 | 591 | expect(giftcards.entriesOf(card.id)[0]!.reason).toBe("chargeback from the buyer, do not honour"); |
| 591 | 592 | }); |
| 592 | 593 | |
| 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 | ||
| 593 | 646 | test("a test file's database is its own, from the factory", () => { |
| 594 | 647 | // bun test shares one process and one module registry across files, so this |
| 595 | 648 | // is the assertion that keeps every other one honest: the database the app |