// 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, }); }),);
// 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,
});
}),
);