// Cloud-only (billing, browser): an organization can add the card it is billed // on and later change it from the billing page, and the page shows the current // card WITHOUT a manual reload. // // The card lives at the billing provider, never in the app: the billing page // reads the customer's default payment method (`payment_method` expand). Two // journeys, because the provider treats them differently (verified against the // live sandbox API): // // 1. No card yet — "Add card" opens a hosted setup session // (`billing.setup_payment`). The browser is redirected back BEFORE the // provider's webhook sets the default card, so the page tags its return // URL, shows the card as updating, and refetches until it reflects. // 2. A card on file — a setup session never REPLACES an existing default, so // "Update card" opens the billing portal (`billing.open_customer_portal`) // where the user adds a card and makes it the default. The provider reads // the default live, so one refetch on return shows the new card. // // The emulator models both faithfully: completing the hosted setup form // redirects back immediately but does NOT set the card until the webhook // settles (autumn.settleSetup); the portal applies the card at once. import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Autumn, Billing, Browser, Mcp, Target } from "../src/services"; import type { Identity } from "../src/target"; import { visit } from "../src/surfaces/browser"; const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; /** The org the bearer is scoped to — the Autumn customer id every billing call * is made against — read from the JWT's public claims. */ const orgIdOf = (bearer: string): string => { const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as { readonly org_id?: string; }; if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim"); return claims.org_id; }; scenario( "Billing · adding and changing the card shows the current card without a reload", { timeout: 120_000 }, Effect.gen(function* () { yield* Billing; const autumn = yield* Autumn; const target = yield* Target; const browser = yield* Browser; const mcp = yield* Mcp; const identity = yield* target.newIdentity(); const bearer = yield* mcp.mintBearer(emailOf(identity)); const customerId = orgIdOf(bearer); const before = yield* autumn.paymentMethod(customerId); expect(before, "a fresh org has no card on file").toBeNull(); yield* browser.session(identity, async ({ page, step }) => { const paymentMethodRow = page .getByText("Payment method", { exact: true }) .locator("xpath=ancestor::div[contains(@class,'justify-between')][1]"); let sessionId = ""; await step("Open the billing page and add a card", async () => { // Billing requests are org-scoped via the URL slug header (see // billing-trial-checkout-stale.test.ts for why we wait for the slug). await visit(page, "/"); await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]; await visit(page, `/${slug}/billing`); await paymentMethodRow.getByText("No card on file").waitFor(); await paymentMethodRow.getByRole("button", { name: "Add card" }).click(); // setupPayment() redirects the whole page to the hosted setup URL. await page.waitForURL(/\/checkout\/setup\//, { timeout: 30_000 }); sessionId = new URL(page.url()).pathname.split("/").filter(Boolean).pop() ?? ""; expect(sessionId, "captured the setup session id").toMatch(/^seti_/); }); await step("Save the card and return to the billing page", async () => { await page.locator("input[name='card_number']").fill("4242 4242 4242 4242"); await page.locator("input[name='exp']").fill("12/30"); await page.locator("button.checkout-pay-btn").click(); await page.waitForURL(/\/billing(\?|$)/, { timeout: 30_000 }); // The webhook has NOT landed yet, but the page knows from the return // marker that a card was just saved, so it shows the card as updating // rather than "No card on file" (which would read as if nothing // happened). This is the key user-facing guarantee. await paymentMethodRow.getByText("Updating card").waitFor({ timeout: 10_000 }); }); // The provider webhook reaches Autumn: the org's default card is set. await Effect.runPromise(autumn.settleSetup(sessionId)); await step("The new card appears without a reload", async () => { await paymentMethodRow.getByText("Visa ending in 4242").waitFor({ timeout: 15_000 }); }); await step("Change the card in the billing portal", async () => { await paymentMethodRow.getByRole("button", { name: "Update card" }).click(); // openCustomerPortal() redirects the whole page to the hosted portal. await page.waitForURL(/\/checkout\/portal\//, { timeout: 30_000 }); await page.locator("input[name='card_number']").fill("5555 5555 5555 4444"); await page.locator("input[name='exp']").fill("11/31"); await page.locator("button.checkout-pay-btn").click(); await page.getByText("4444").first().waitFor({ timeout: 10_000 }); await page.getByRole("link", { name: /^Return to/ }).click(); await page.waitForURL(/\/billing(\?|$)/, { timeout: 30_000 }); }); await step("The billing page shows the card chosen in the portal", async () => { await paymentMethodRow.getByText("Mastercard ending in 4444").waitFor({ timeout: 15_000 }); expect( await paymentMethodRow.getByText("Updating card").count(), "no webhook wait for a portal change", ).toBe(0); }); }); const after = yield* autumn.paymentMethod(customerId); expect(after, "the billing provider holds the new card").toEqual({ brand: "mastercard", last4: "4444", expMonth: 11, expYear: 2031, }); }), );