Files
simplex-chat/apps/simplex-badge-service/web/test/stripe.test.ts
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

600 lines
29 KiB
TypeScript

import assert from "node:assert/strict";
import { headingOf, installPage, inViewOf, primaryOf, settle, timedTest, until } from "./boot.js";
import { StubElement } from "./stub-dom.js";
const cardTest = timedTest(3000);
const page = installPage();
const { app, history, location, storage, fetches } = page;
const keyMeta = new StubElement("meta");
keyMeta.setAttribute("id", "stripe-publishable-key");
keyMeta.setAttribute("name", "stripe-publishable-key");
keyMeta.setAttribute("content", "");
page.document.byId.set("stripe-publishable-key", keyMeta);
function configureKey(key: string): void { keyMeta.setAttribute("content", key); }
const stripe = await import("../src/stripe.js");
const screens = await import("../src/screens.js");
await import("../src/main.js");
type UnpaidOrder = import("../src/order.js").UnpaidOrder;
type InvoiceView = import("../src/api.js").InvoiceView;
const HELD_CODE = "SB-YDC8A-YGQTM-PUYZ9-2TUXP";
const CLIENT_SECRET = "cs_test_a1b2c3d4";
const PUBLISHABLE_KEY = "pk_test_51NotARealKey";
const APPEARANCE = { theme: "stripe" } as const;
const RETURN_URL = "https://badges.example/";
function render(node: unknown): StubElement { return node as unknown as StubElement; }
const order: UnpaidOrder = {
orderId: "inv_card", badgeType: "legend", months: 12,
createdAt: "2026-08-28T11:02:19Z", status: "open",
code: HELD_CODE,
} as unknown as UnpaidOrder;
const cardInvoice: InvoiceView = {
status: "open", amount: 42000, currency: "usd", clientSecret: CLIENT_SECRET,
};
function assertNoCode(node: StubElement, where: string): void {
const dump = node.serialize();
for (const form of [HELD_CODE, HELD_CODE.replace(/-/g, ""), "SB-"]) {
assert.ok(!dump.includes(form), `${where} leaked a code (${form})`);
}
}
cardTest("stripe: with no publishable key the card lane is unavailable, and nothing loads", () => {
for (const [what, key] of [["absent", undefined], ["empty", ""], ["whitespace", " "]] as const) {
const plan = stripe.cardPlan(key, false);
assert.equal(plan.kind, "unavailable", `a ${what} key configures no card form`);
assert.equal(plan.kind === "unavailable" ? plan.reason : "", "unconfigured");
}
});
cardTest("stripe: with a key configured the plan is to LOAD, and carries the key", () => {
const plan = stripe.cardPlan(` ${PUBLISHABLE_KEY} `, false);
assert.equal(plan.kind, "load");
assert.equal(plan.kind === "load" ? plan.publishableKey : "", PUBLISHABLE_KEY, "trimmed, as a config value is");
});
cardTest("stripe: offline with a key is a failure screen, not a form that never fills", () => {
const plan = stripe.cardPlan(PUBLISHABLE_KEY, true);
assert.equal(plan.kind, "unavailable");
assert.equal(plan.kind === "unavailable" ? plan.reason : "", "offline");
});
cardTest("stripe: a missing key wins over offline — the page is unconfigured either way", () => {
const plan = stripe.cardPlan("", true);
assert.equal(plan.kind, "unavailable");
assert.equal(plan.kind === "unavailable" ? plan.reason : "", "unconfigured");
});
cardTest("stripe: the script URL is Stripe's own origin, which may not be self-hosted", () => {
assert.ok(stripe.STRIPE_JS_URL.startsWith("https://js.stripe.com/"), stripe.STRIPE_JS_URL);
});
interface Trace {
calls: string[]; target: unknown; secret: string; key: string;
createArg: string;
appearance: string;
returnUrl: string;
destroys: number;
}
function fakeStripe(over: {
elementsFails?: boolean; mountThrows?: boolean; destroyThrows?: boolean;
confirmResult?: import("../src/stripe.js").PaymentIntentResult; confirmRejects?: boolean;
} = {}): { load: import("../src/stripe.js").LoadStripeJs; trace: Trace; loaded: string[] } {
const trace: Trace = { calls: [], target: null, secret: "", key: "", createArg: "", appearance: "", returnUrl: "", destroys: 0 };
const loaded: string[] = [];
const elements = {
create: (kind: "payment") => {
trace.calls.push("create");
trace.createArg = kind;
return {
mount: (target: unknown) => {
trace.calls.push("mount");
trace.target = target;
if (over.mountThrows === true) throw new Error("no such node");
},
destroy: () => {
trace.calls.push("destroy");
trace.destroys += 1;
if (over.destroyThrows === true) throw new Error("already gone");
},
};
},
};
const load: import("../src/stripe.js").LoadStripeJs = async (src) => {
loaded.push(src);
return (key: string) => {
trace.calls.push("Stripe");
trace.key = key;
return {
elements: (options: { clientSecret: string; appearance?: { theme?: string } }) => {
trace.calls.push("elements");
trace.secret = options.clientSecret;
trace.appearance = options.appearance?.theme ?? "";
if (over.elementsFails === true) throw new Error("no such intent");
return elements;
},
confirmPayment: async (options: { confirmParams: { return_url: string } }) => {
trace.calls.push("confirmPayment");
trace.returnUrl = options.confirmParams.return_url;
if (over.confirmRejects === true) throw new Error("network");
return over.confirmResult ?? { paymentIntent: { status: "succeeded" } };
},
};
};
};
return { load, trace, loaded };
}
function loadPlan(): import("../src/stripe.js").LoadPlan {
const plan = stripe.cardPlan(PUBLISHABLE_KEY, false);
assert.equal(plan.kind, "load");
return plan as import("../src/stripe.js").LoadPlan;
}
cardTest("stripe: mounting follows Stripe's script rule — init the SDK, create the element, mount it", async () => {
const { load, trace, loaded } = fakeStripe();
const target = { the: "mount point" };
const result = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
assert.equal(result.kind, "mounted");
assert.deepEqual(trace.calls, ["Stripe", "elements", "create", "mount"]);
assert.deepEqual(loaded, [stripe.STRIPE_JS_URL], "loaded once, from js.stripe.com");
assert.equal(trace.key, PUBLISHABLE_KEY);
assert.equal(trace.secret, CLIENT_SECRET, "the client_secret, and nothing else about the order");
assert.equal(trace.target, target, "the Element goes into the node it was handed");
assert.equal(trace.createArg, "payment", "the Payment Element, with no field configuration of ours");
});
cardTest("stripe: the appearance follows the site theme, resolving system by the OS", () => {
for (const [label, t, osDark, ink] of [
["forced light ignores a dark OS", "light", true, "#0D1B3A"],
["forced dark ignores a light OS", "dark", false, "#EAF2FF"],
["system follows a dark OS", "system", true, "#EAF2FF"],
["system follows a light OS", "system", false, "#0D1B3A"],
] as const) {
const a = stripe.appearanceFor(t, osDark);
assert.equal(a.theme, "flat", label);
assert.equal(a.variables?.colorText, ink, label);
}
});
cardTest("stripe: the chosen appearance is handed to the SDK, so the Element matches the page", async () => {
const { load, trace } = fakeStripe();
await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: { theme: "night" }, returnUrl: RETURN_URL, loadStripe: load });
assert.equal(trace.appearance, "night", "the dark theme reached elements()");
});
cardTest("stripe: a mounted form can be torn down, once, and a throwing teardown is survivable", async () => {
const { load, trace } = fakeStripe();
const mounted = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
if (mounted.kind !== "mounted") throw new Error("expected a mounted form");
assert.equal(trace.destroys, 0, "mounting tears nothing down");
mounted.destroy();
assert.equal(trace.destroys, 1, "Stripe's own teardown is what takes the iframes with it");
mounted.destroy();
assert.equal(trace.destroys, 1, "idempotent: a second release destroys nothing twice");
const thrower = fakeStripe({ destroyThrows: true });
const second = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: thrower.load });
if (second.kind !== "mounted") throw new Error("expected a mounted form");
assert.doesNotThrow(() => { second.destroy(); });
assert.equal(thrower.trace.destroys, 1);
});
cardTest("stripe: a script that does not load is a failure, and nothing is mounted", async () => {
const { trace } = fakeStripe();
const result = await stripe.mountCard({
plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL,
loadStripe: async () => { throw new Error("blocked"); },
});
assert.equal(result.kind, "failed");
assert.equal(result.kind === "failed" ? result.reason : "", "script");
assert.deepEqual(trace.calls, [], "nothing of Stripe's was touched");
});
cardTest("stripe: an SDK that refuses the client secret is its own failure", async () => {
const { load } = fakeStripe({ elementsFails: true });
const result = await stripe.mountCard({ plan: loadPlan(), clientSecret: "cs_gone", target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
assert.equal(result.kind, "failed");
assert.equal(result.kind === "failed" ? result.reason : "", "sdk");
});
cardTest("stripe: a mount that throws leaves no half-usable form", async () => {
const { load } = fakeStripe({ mountThrows: true });
const result = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
assert.equal(result.kind, "failed");
assert.equal(result.kind === "failed" ? result.reason : "", "sdk");
});
async function confirmWith(over: Parameters<typeof fakeStripe>[0]): Promise<{
outcome: import("../src/stripe.js").ConfirmOutcome; trace: Trace;
}> {
const { load, trace } = fakeStripe(over);
const mounted = await stripe.mountCard({ plan: loadPlan(), clientSecret: CLIENT_SECRET, target: {}, appearance: APPEARANCE, returnUrl: RETURN_URL, loadStripe: load });
if (mounted.kind !== "mounted") throw new Error(`expected a mounted form, got ${mounted.kind}`);
return { outcome: await mounted.confirm(), trace };
}
cardTest("stripe: confirming is confirmPayment(), and a succeeded intent is `submitted` only", async () => {
const { outcome, trace } = await confirmWith({});
assert.deepEqual(outcome, { kind: "submitted" });
assert.equal(trace.calls.at(-1), "confirmPayment");
assert.equal(trace.returnUrl, RETURN_URL, "confirm is handed the return URL Stripe requires");
assert.ok(!("paid" in outcome) && !("settledAt" in outcome));
});
cardTest("stripe: a processing intent is also `submitted` — the poller settles it", async () => {
const { outcome } = await confirmWith({ confirmResult: { paymentIntent: { status: "processing" } } });
assert.deepEqual(outcome, { kind: "submitted" });
});
cardTest("stripe: a refusal is the reason Stripe gave, and the form stays", async () => {
const { outcome } = await confirmWith({ confirmResult: { error: { message: "Your card was declined." } } });
assert.deepEqual(outcome, { kind: "error", message: "Your card was declined." });
});
cardTest("stripe: an error with no message of its own still says something usable", async () => {
const { outcome } = await confirmWith({ confirmResult: { error: {} } });
assert.deepEqual(outcome, { kind: "error", message: stripe.CONFIRM_FAILED });
});
cardTest("stripe: an intent left in a non-terminal status is an error, not a submission", async () => {
const { outcome } = await confirmWith({ confirmResult: { paymentIntent: { status: "requires_payment_method" } } });
assert.deepEqual(outcome, { kind: "error", message: stripe.CONFIRM_FAILED });
});
cardTest("stripe: a confirm that THREW is an error, and never a submission", async () => {
const { outcome } = await confirmWith({ confirmRejects: true });
assert.equal(outcome.kind, "error", "a rejected confirm must not move the page to the confirming screen");
});
cardTest("screens: the card fields are disabled until the Element is actually mounted", () => {
let paid = 0;
const mount = screens.cardMount();
const fields = screens.cardFields({ mount, total: "$420.00", onPay: () => { paid += 1; } });
const node = render(fields.node);
assert.equal(node.all("div.card-mount")[0], render(mount), "the Element goes into the node handed in");
const pay = node.all("button.primary")[0]!;
assert.ok(pay.textContent.includes("$420.00"));
assert.ok(pay.hasAttribute("disabled"), "Stripe.js has not run yet");
assert.ok(node.textContent.includes(screens.CARD_LOADING));
pay.click();
assert.equal(paid, 0, "a disabled control does nothing");
fields.enable();
assert.ok(!pay.hasAttribute("disabled"));
pay.click();
assert.equal(paid, 1);
fields.busy(true);
pay.click();
assert.equal(paid, 1, "one press, one attempt — there is no idempotency key");
fields.busy(false);
fields.error("Your card was declined.");
assert.ok(node.textContent.includes("Your card was declined."));
});
cardTest("screens: the card form renders the body it is given, and no second mount point", () => {
const marker = new StubElement("p");
marker.textContent = "custom body marker";
const withBody = render(screens.cardForm({
order, invoice: cardInvoice, resumed: false,
body: marker as unknown as HTMLElement,
}));
assert.ok(withBody.textContent.includes("custom body marker"), "the body handed in is rendered");
assert.ok(withBody.textContent.includes("Legend"));
assert.ok(withBody.textContent.includes("$420.00"));
assert.ok(withBody.textContent.includes("inv_card"), "the reference is on every screen that may need support");
assert.equal(withBody.all("div.card-mount").length, 0, "a supplied body brings no mount point of its own");
assert.ok(!withBody.textContent.includes(CLIENT_SECRET), "the client secret is never rendered");
assertNoCode(withBody, "cardForm/body");
assert.equal(withBody.all("svg.qr").length, 0, "the store rules: no QR on an unpaid screen");
const bare = render(screens.cardForm({ order, invoice: cardInvoice, resumed: false }));
assert.equal(bare.all("div.card-mount").length, 1, "the default body is the mount point");
});
cardTest("screens: a card form that could not load is a screen, not a blank panel", () => {
let retried = 0;
let fresh = 0;
for (const [reason, expected] of [["offline", "You are offline"], ["script", "served by Stripe"], ["sdk", "served by Stripe"]] as const) {
const p = render(screens.cardUnavailable({
order, reason, onRetry: () => { retried += 1; }, onNewInvoice: () => { fresh += 1; },
}));
assert.equal(p.all("h1")[0]!.textContent, "The card form did not load");
assert.ok(p.textContent.includes(expected), `${reason}: ${p.textContent}`);
assert.ok(p.textContent.includes("Nothing was charged"));
assert.ok(p.textContent.includes("still waiting to be paid"));
assert.ok(p.textContent.includes("inv_card"));
assert.equal(p.all("div.card-mount").length, 0, "no mount point on a screen with nothing to mount");
assertNoCode(p, `cardUnavailable/${reason}`);
}
const p = render(screens.cardUnavailable({ order, reason: "script", onRetry: () => { retried += 1; }, onNewInvoice: () => { fresh += 1; } }));
p.all("button.primary")[0]!.click();
p.all("button.secondary")[0]!.click();
assert.equal(retried, 1, "[ Try again ] retries");
assert.equal(fresh, 1, "and the buyer is never stranded");
});
const screen = (): StubElement => inViewOf(app);
const heading = (): string => headingOf(screen());
function expireStoredOrder(orderId: string): void {
const orders = JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, unknown>>;
orders.find((o) => o.orderId === orderId)!.status = "expired";
storage.setItem("sb.orders.v1", JSON.stringify(orders));
}
function scripts(): StubElement[] {
return page.document.head.children.filter((c): c is StubElement => c instanceof StubElement);
}
function stripeTags(): StubElement[] {
return scripts().filter((s) => (s.getAttribute("src") ?? "").includes("js.stripe.com"));
}
cardTest("main: nothing of Stripe's is fetched at page load", () => {
assert.equal(heading(), "Support SimpleX", "the boot is the landing screen");
assert.equal(scripts().length, 0, "the shell adds no script of its own");
assert.equal(stripeTags().length, 0, "Stripe.js is loaded on the card path and nowhere else");
assert.ok(!fetches.some((f) => f.url.includes("stripe")), "and nothing requested it another way");
});
function walkToCard(): void {
const primary = (): StubElement => primaryOf(screen())!;
if (heading() === "Support SimpleX") primary().click();
if (heading() === "Choose your badge") {
screen().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!.click();
primary().click();
}
if (heading() === "How long?") {
screen().all("button.choice").find((c) => c.textContent.startsWith("12 months"))!.click();
primary().click();
}
assert.equal(heading(), "Check your order");
screen().all("button.choice").find((c) => c.textContent.startsWith("Card"))!.click();
}
function cardCreated(invoiceId: string): void {
page.respondWith({
status: 200,
body: {
invoiceId, badgeType: "legend", months: 12,
amount: 42000, currency: "usd", expiresAt: "2126-08-28T13:00:00Z",
clientSecret: CLIENT_SECRET,
},
});
}
cardTest("main: with NO key the card path is unavailable, and still loads nothing", async () => {
walkToCard();
cardCreated("inv_card_1");
screen().all("button.primary")[0]!.click();
await until(() => location.search === "?order=inv_card_1", "the card invoice");
await settle();
assert.equal(heading(), "The card form did not load");
assert.ok(screen().textContent.includes("Card payment is not available on this page."),
"an unconfigured page names the card lane as unavailable, and offers no form");
assert.equal(screen().all("div.card-mount").length, 0);
assert.equal(stripeTags().length, 0, "no key, no script — the gate is before the load");
assert.ok(!screen().textContent.includes(CLIENT_SECRET), "the client secret is never on screen");
const stored = JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, string>>;
assert.ok(stored[0]!.code!.startsWith("SB-"));
assert.ok(!screen().serialize().includes(stored[0]!.code!), "and never on an unpaid screen");
assert.equal(storage.getItem("sb.orders.v1")!.includes(CLIENT_SECRET), false,
"the store rules: clientSecret is never written to rest");
});
cardTest("main: the order summary withholds Pay while a card payment awaits confirmation", async () => {
const orders = JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, unknown>>;
orders.find((o) => o.orderId === "inv_card_1")!.submitted = true;
storage.setItem("sb.orders.v1", JSON.stringify(orders));
history.back();
await settle();
assert.equal(heading(), "How long?", "Back lands on the wizard");
primaryOf(screen())!.click();
assert.equal(heading(), "Check your order");
assert.ok(screen().textContent.includes(screens.AWAITING_CARD_TITLE), screen().textContent);
assert.equal(screen().all("button.primary").length, 0, "and no Pay button at all");
assert.ok(!screen().textContent.includes("Pay $"), "not even a disabled one to press");
const link = screen().all("a.link")[0]!;
assert.equal(link.getAttribute("href"), "?order=inv_card_1", "the order is linked, so nobody is stranded");
assert.ok(screen().textContent.includes("When its invoice expires, a new one can be started there."),
"and the way out is named: the closed-window screen offers a new invoice once this one expires");
const before = fetches.length;
await settle();
assert.equal(fetches.slice(before).filter((f) => f.url === "/api/invoice").length, 0);
});
cardTest("main: Back from a payment screen to the order summary, and Pay still works", async () => {
expireStoredOrder("inv_card_1");
history.back();
await settle();
assert.equal(heading(), "How long?", "Back lands on the wizard, not on the replaced #/checkout");
screen().all("button.choice").find((c) => c.textContent.startsWith("3 months"))!.click();
primaryOf(screen())!.click();
assert.equal(heading(), "Check your order");
assert.ok(screen().textContent.includes("$140.00"), `3 legend months are $140: ${screen().textContent}`);
screen().all("button.choice").find((c) => c.textContent.startsWith("Card"))!.click();
configureKey(PUBLISHABLE_KEY);
const before = fetches.length;
page.respondWith({
status: 200,
body: {
invoiceId: "inv_card_2", badgeType: "legend", months: 3,
amount: 14000, currency: "usd", expiresAt: "2126-08-28T13:00:00Z",
clientSecret: CLIENT_SECRET,
},
});
screen().all("button.primary")[0]!.click();
await until(() => location.search === "?order=inv_card_2", "the second card invoice");
await settle();
const post = fetches.slice(before).find((f) => f.url === "/api/invoice");
assert.ok(post, "Pay must actually submit — a dead primary button is worse than an error");
const body = JSON.parse(String(post.init!.body)) as Record<string, string>;
assert.equal(body.priceId, "price_legend", "the level the panel was drawn with");
assert.equal(body.offerId, "offer_3m", "and the duration the buyer just chose, not the old order's");
assert.equal(body.method, "card");
});
cardTest("main: with a key configured the card form loads Stripe.js and awaits its Element", () => {
assert.equal(heading(), "Pay by card");
assert.equal(screen().all("div.card-mount").length, 1, "the Payment Element mounts here");
assert.equal(stripeTags().length, 1, "exactly one script tag, and only now");
assert.equal(stripeTags()[0]!.getAttribute("src"), stripe.STRIPE_JS_URL);
const pay = screen().all("button.primary")[0]!;
assert.ok(pay.hasAttribute("disabled"), "nothing is payable until the Element is mounted");
});
cardTest("main: a Stripe.js that fails to load lands on the failure screen, not a blank panel", async () => {
stripeTags()[0]!.dispatch("error");
await settle();
assert.equal(heading(), "The card form did not load");
assert.ok(screen().textContent.includes("Nothing was charged"));
assert.ok(screen().textContent.includes("inv_card"), "with the reference, so support is reachable");
assert.equal(screen().all("div.card-mount").length, 0, "and no empty box where the fields were");
});
const elements: Array<{ node: unknown; destroyed: boolean }> = [];
const confirms: number[] = [];
let lastReturnUrl = "";
let heldConfirm: { promise: Promise<void>; release: () => void } | null = null;
function holdNextConfirm(): void {
let release = (): void => {};
const promise = new Promise<void>((r) => { release = r; });
heldConfirm = { promise, release };
}
(globalThis as unknown as { window: Record<string, unknown> }).window.Stripe = (key: string) => {
assert.equal(key, PUBLISHABLE_KEY, "the page's own configured key, and no other");
return {
elements: (options: { clientSecret: string }) => {
assert.equal(options.clientSecret, CLIENT_SECRET);
return {
create: (kind: string) => {
assert.equal(kind, "payment", "the Payment Element, not a bare card field");
const element = { node: null as unknown, destroyed: false };
elements.push(element);
return {
mount: (node: unknown) => { element.node = node; },
destroy: () => { element.destroyed = true; },
};
},
};
},
confirmPayment: async (options: { confirmParams: { return_url: string } }) => {
confirms.push(1);
lastReturnUrl = options.confirmParams.return_url;
if (heldConfirm !== null) await heldConfirm.promise;
return { paymentIntent: { status: "succeeded" } };
},
};
};
cardTest("main: [ Try again ] asks for the script again, and a load mounts the Element", async () => {
screen().all("button.primary")[0]!.click(); // [ Try again ]
await settle();
assert.equal(heading(), "Pay by card");
assert.equal(stripeTags().length, 1, "the failed load is retried with a fresh tag, and only that");
stripeTags()[0]!.dispatch("load");
await settle();
assert.equal(elements.length, 1, "the Element was mounted");
assert.equal(elements[0]!.node, screen().all("div.card-mount")[0], "into the node on screen");
const pay = screen().all("button.primary")[0]!;
assert.ok(!pay.hasAttribute("disabled"), "and only now is the order payable");
assert.equal(confirms.length, 0, "mounting confirms nothing");
});
cardTest("main: a remount DESTROYS the Element it replaces, and loads no second script", async () => {
page.setOffline(true);
await settle();
try {
assert.equal(heading(), "The card form did not load");
assert.ok(screen().textContent.includes("You are offline"), screen().textContent);
assert.equal(elements.length, 1, "and nothing new was mounted");
assert.equal(elements[0]!.destroyed, true, "the Element that was on screen is gone");
} finally {
// Restored even if an assertion above fails, or the offline backoff on real timers would hang this process.
page.setOffline(false);
await settle();
}
assert.equal(heading(), "Pay by card");
assert.equal(elements.length, 2, "coming back mounts a fresh Element");
assert.equal(elements[1]!.destroyed, false, "which is the live one");
assert.equal(elements[1]!.node, screen().all("div.card-mount")[0]);
assert.equal(stripeTags().length, 1, "one load per page: the global is reused, not re-fetched");
assert.ok(!screen().all("button.primary")[0]!.hasAttribute("disabled"), "and it is payable again");
});
cardTest("main: a connectivity flap must not remount the form under an in-flight confirm", async () => {
holdNextConfirm();
const mounted = elements.length;
const node = screen().all("div.card-mount")[0];
screen().all("button.primary")[0]!.click(); // Pay
await settle();
assert.equal(confirms.length, 1, "the press issued one confirm");
assert.ok(screen().all("button.primary")[0]!.hasAttribute("disabled"), "and the button went busy");
page.setOffline(true);
await settle();
page.setOffline(false);
await settle();
assert.equal(heading(), "Pay by card", "the form the confirm belongs to is still the screen");
assert.equal(elements.length, mounted, "nothing was remounted under the in-flight confirm");
assert.equal(elements[mounted - 1]!.destroyed, false, "and the live Element was not torn down");
assert.equal(screen().all("div.card-mount")[0], node, "it is the same form, with whatever was typed into it");
assert.ok(screen().all("button.primary")[0]!.hasAttribute("disabled"),
"the busy state survives the repaint — a re-enabled button is a second charge");
assert.equal(confirms.length, 1, "and still exactly one confirm was issued");
heldConfirm!.release();
heldConfirm = null;
await until(() => heading() === "Payment received", "the confirming screen once the confirm resolves");
assert.equal(confirms.length, 1);
});
cardTest("main: a real confirm ALSO lands on the confirming screen, and the confirming screen alone", async () => {
assert.equal(heading(), "Payment received");
assert.ok(screen().textContent.includes("Waiting for the card network to confirm."),
"the confirming screen waits: a confirm is a hint, the provider is what settles it");
assert.equal(confirms.length, 1, "one press, one confirm");
assert.ok(fetches.some((f) => f.url.startsWith("/api/invoice/inv_card_2")), "the waiting loop is polling");
assert.ok(lastReturnUrl.length > 0 && !lastReturnUrl.includes("inv_card_2"),
`the return URL leaks no order id: ${lastReturnUrl}`);
assert.ok(elements.every((e) => e.destroyed), "and the form it left behind took its Element with it");
assert.equal(screen().all("button").filter((b) => b.textContent === "Buy a new code").length, 0);
assert.ok(!screen().textContent.includes("Here is your code"));
const stored = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, string>>)
.find((o) => o.orderId === "inv_card_2")!;
assert.equal(stored.status, "open", "nothing about the order changed: the provider decides");
assert.ok(!screen().serialize().includes(stored.code!));
assert.equal(stored.submitted, true);
assert.equal(storage.getItem("sb.session.v1"), null, "and not in the session");
});
cardTest("main: the code appears only when the SERVER says paid", async () => {
const stored = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<Record<string, string>>)
.find((o) => o.orderId === "inv_card_2")!;
page.respondWith({
status: 200,
body: { status: "paid", badgeType: "legend", months: 3, settledAt: "2026-08-28T11:20:00Z" },
});
page.fire("popstate");
await until(() => heading().startsWith("Paid"), "codeIssued");
assert.ok(screen().serialize().includes(stored.code!), "the code screen is the one screen that shows it");
});