From 845d40f2fed8ae6e1ca124e88c5ad35984bcc881 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:06:09 -0700 Subject: [PATCH 1/2] Add Autumn setup_payment and payment_method expand --- CHANGELOG.md | 12 +- packages/@emulators/adapter-next/package.json | 2 +- packages/@emulators/apple/package.json | 2 +- packages/@emulators/autumn/package.json | 2 +- .../__tests__/autumn-setup-payment.test.ts | 130 ++++++++++++++++ packages/@emulators/autumn/src/entities.ts | 29 ++++ packages/@emulators/autumn/src/index.ts | 22 ++- packages/@emulators/autumn/src/manifest.ts | 18 ++- packages/@emulators/autumn/src/routes/api.ts | 43 +++++- .../@emulators/autumn/src/routes/checkout.ts | 139 +++++++++++++++++- .../@emulators/autumn/src/routes/openapi.ts | 24 +++ packages/@emulators/autumn/src/serialize.ts | 35 ++++- packages/@emulators/autumn/src/store.ts | 4 +- packages/@emulators/aws/package.json | 2 +- packages/@emulators/clerk/package.json | 2 +- packages/@emulators/cloudflare/package.json | 2 +- packages/@emulators/core/package.json | 2 +- packages/@emulators/github/package.json | 2 +- packages/@emulators/gitlab/package.json | 2 +- packages/@emulators/google/package.json | 2 +- packages/@emulators/mcp/package.json | 2 +- packages/@emulators/microsoft/package.json | 2 +- packages/@emulators/mongoatlas/package.json | 2 +- packages/@emulators/okta/package.json | 2 +- packages/@emulators/posthog/package.json | 2 +- packages/@emulators/resend/package.json | 2 +- packages/@emulators/slack/package.json | 2 +- packages/@emulators/spotify/package.json | 2 +- packages/@emulators/stripe/package.json | 2 +- packages/@emulators/vercel/package.json | 2 +- packages/@emulators/workos/package.json | 2 +- packages/@emulators/x/package.json | 2 +- packages/emulate/package.json | 2 +- skills/autumn/SKILL.md | 15 +- 34 files changed, 471 insertions(+), 46 deletions(-) create mode 100644 packages/@emulators/autumn/src/__tests__/autumn-setup-payment.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1e9d015..969f51e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,21 @@ # Changelog -## 0.14.1 +## 0.14.2 ### New Features -- **Autumn `balances.update`** — the Autumn emulator now supports the SDK's balance update call (`POST /v1/balances.update`) for reconciling continuous-use features such as seats. Exactly one of `usage`, `remaining`, or `add_to_balance` is required; the update is recorded as an adjustment event, so `events.list` shows the reconciliation and `balances.check` and `customers.get_or_create` reflect it from the same state. `remaining` is rejected on unlimited balances, unknown customers 404 with Autumn's real `customer_not_found` code (update is a non-creating endpoint upstream, unlike track and check), and a feature the customer's plan does not carry 404s. +- **Autumn payment method setup** — the Autumn emulator now supports changing the card on file end to end. `POST /v1/billing.setup_payment` (autumn-js `billing.setupPayment`) opens a Stripe setup-mode checkout and returns `{ customer_id, entity_id?, url }`; the hosted page at `GET /checkout/setup/:sessionId` captures a card (`card_number`, `exp`, both editable so a test can pick the brand) and redirects to `success_url`. As with the existing checkout flow, the customer's default payment method is deliberately not replaced until the `checkout.session.completed` webhook lands, modelled by `POST /checkout/setup/:sessionId/settle` (or the customer-wide `POST /checkout/settle`, which now settles setup sessions too). `customers.get_or_create` honours `expand: ["payment_method"]`, returning the Stripe PaymentMethod (`id`, `type`, `card.brand`, `card.last4`, `card.exp_month`, `card.exp_year`) or null, and omitting the field entirely when `expand` does not ask for it. Settling a paid checkout also leaves a visa 4242 on file when the customer had no card, and customers can be seeded with a `payment_method`. +## 0.14.1 + +### New Features + +- **Autumn `balances.update`** — the Autumn emulator now supports the SDK's balance update call (`POST /v1/balances.update`) for reconciling continuous-use features such as seats. Exactly one of `usage`, `remaining`, or `add_to_balance` is required; the update is recorded as an adjustment event, so `events.list` shows the reconciliation and `balances.check` and `customers.get_or_create` reflect it from the same state. `remaining` is rejected on unlimited balances, unknown customers 404 with Autumn's real `customer_not_found` code (update is a non-creating endpoint upstream, unlike track and check), and a feature the customer's plan does not carry 404s. + ## 0.14.0 ### New Features @@ -35,14 +41,12 @@ - **MCP OAuth compliance scenario knobs** — the MCP emulator gains a seedable `oauth` config for exercising RFC 7591/8414/9728 client compliance: `issuerOverride` (advertise a lying issuer), `resourceOverride` (protected-resource metadata naming a foreign resource), `tokenEndpointAuthMethods` (advertise an exact list, or `"omit"` to drop the field so RFC 8414's client_secret_basic default applies), `dcrAuthMethodOverride` (the DCR response substitutes the requested token auth method), and `rejectClientNameContaining` (registration rejects branded client names with `invalid_client_metadata`). The token endpoint now supports HTTP Basic client authentication and enforces the registered method strictly: `client_secret_basic` clients must use Basic, `client_secret_post` clients must use the form body. - ## 0.13.6 ### New Features - **WorkOS session logout** — the WorkOS emulator now serves AuthKit's session-end endpoint (`GET /user_management/sessions/logout?session_id=...&return_to=...`), the URL the Node SDK's `getLogoutUrl` builds. It revokes the session (its refresh token stops minting tokens, matching real WorkOS) and redirects to `return_to`, or renders a signed-out page when none is given. Apps can now exercise their full sign-out flow, including the hop through WorkOS, against the emulator. - ## 0.13.5 ### Bug Fixes diff --git a/packages/@emulators/adapter-next/package.json b/packages/@emulators/adapter-next/package.json index b06994b88..c1a92ef45 100644 --- a/packages/@emulators/adapter-next/package.json +++ b/packages/@emulators/adapter-next/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/adapter-next", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/apple/package.json b/packages/@emulators/apple/package.json index 9ed983312..245b374f2 100644 --- a/packages/@emulators/apple/package.json +++ b/packages/@emulators/apple/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/apple", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/autumn/package.json b/packages/@emulators/autumn/package.json index 88db2f2a4..bddcf92a0 100644 --- a/packages/@emulators/autumn/package.json +++ b/packages/@emulators/autumn/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/autumn", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/autumn/src/__tests__/autumn-setup-payment.test.ts b/packages/@emulators/autumn/src/__tests__/autumn-setup-payment.test.ts new file mode 100644 index 000000000..a2ca6b2b0 --- /dev/null +++ b/packages/@emulators/autumn/src/__tests__/autumn-setup-payment.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createServer, serve } from "@emulators/core"; +import { Autumn } from "autumn-js"; + +import { autumnPlugin, seedFromConfig } from "../index.js"; +import { manifest } from "../manifest.js"; + +// Drives "change the billing card" end to end through the real autumn-js SDK: +// billing.setup_payment opens a hosted setup page, submitting it captures a +// card and redirects back, and the customer's default payment method only +// changes once the Stripe webhook settles. + +const PORT = 41885; +const BASE = `http://localhost:${PORT}`; +const SUCCESS_URL = `${BASE}/back-to-billing`; + +let httpServer: ReturnType; +let autumn: Autumn; + +beforeAll(() => { + const { app, store } = createServer(autumnPlugin, { + port: PORT, + baseUrl: BASE, + manifest, + fallbackUser: { login: "am_emulate_admin", id: 1, scopes: [] }, + }); + seedFromConfig(store, BASE, { + plans: [ + { id: "free", name: "Free", auto_enable: true, items: [{ feature_id: "executions", included: 10000 }] }, + { + id: "team", + name: "Team", + price: { amount: 150, interval: "month" }, + items: [{ feature_id: "executions", included: 250000 }], + }, + ], + }); + httpServer = serve({ fetch: app.fetch, port: PORT }); + autumn = new Autumn({ secretKey: "am_test_emulate", serverURL: BASE }); +}); + +afterAll(async () => { + await new Promise((resolve) => httpServer.close(() => resolve())); +}); + +// The SDK types the expanded field as `any`, so read it through a narrow shape. +interface Card { + id: string; + type: string; + card: { brand: string; last4: string; exp_month: number; exp_year: number }; +} + +const cardOnFile = async (customerId: string): Promise => { + const customer = await autumn.customers.getOrCreate({ customerId, expand: ["payment_method"] }); + return (customer.paymentMethod ?? null) as Card | null; +}; + +describe("autumn emulator: setup_payment (change the card on file)", () => { + const CUSTOMER = "org_setup"; + + it("setup_payment returns a hosted setup URL", async () => { + const res = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL }); + expect(res.customerId).toBe(CUSTOMER); + expect(res.url, "a hosted setup URL is returned").toContain("/checkout/setup/"); + + // A fresh customer has no card on file yet. + expect(await cardOnFile(CUSTOMER), "no card before setup").toBeNull(); + }); + + it("the hosted setup page renders a save-card form", async () => { + const { url } = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL }); + const page = await fetch(url); + expect(page.status).toBe(200); + const html = await page.text(); + expect(html, "the form offers to save the card").toContain("Save card"); + expect(html, "the page is the payment method update page").toContain("Update payment method"); + }); + + it("submitting the card redirects to success_url but does not yet replace the card", async () => { + const { url } = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL }); + const sessionId = new URL(url).pathname.split("/").pop()!; + + const completed = await fetch(`${BASE}/checkout/setup/${sessionId}/complete`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "card_number=5555 5555 5555 4444&exp=11/31", + redirect: "manual", + }); + expect(completed.status, "completion redirects").toBe(302); + expect(completed.headers.get("location"), "back to the app").toBe(SUCCESS_URL); + + // The webhook has not landed: the customer still has the OLD card (none). + expect(await cardOnFile(CUSTOMER), "card unchanged before settle").toBeNull(); + + const settled = await fetch(`${BASE}/checkout/setup/${sessionId}/settle`, { method: "POST" }); + expect(settled.ok).toBe(true); + expect(await settled.json()).toEqual({ settled: 1 }); + + const card = await cardOnFile(CUSTOMER); + expect(card?.type).toBe("card"); + expect(card?.id, "a Stripe-style payment method id").toMatch(/^pm_emulate_/); + expect(card?.card).toMatchObject({ brand: "mastercard", last4: "4444", exp_month: 11, exp_year: 2031 }); + }); + + it("settling an unknown setup session 404s", async () => { + const res = await fetch(`${BASE}/checkout/setup/seti_emulate_nope/settle`, { method: "POST" }); + expect(res.status).toBe(404); + expect(await res.json()).toMatchObject({ code: "not_found" }); + }); + + it("get_or_create omits payment_method when expand does not ask for it", async () => { + const customer = (await autumn.customers.getOrCreate({ customerId: CUSTOMER })) as Record; + expect("paymentMethod" in customer, "field is absent without expand").toBe(false); + }); + + it("paying for a plan leaves a visa on file", async () => { + const PAYING = "org_pays"; + const attach = await autumn.billing.attach({ customerId: PAYING, planId: "team", successUrl: SUCCESS_URL }); + const sessionId = new URL(attach.paymentUrl!).pathname.split("/").pop()!; + await fetch(`${BASE}/checkout/${sessionId}/complete`, { method: "POST", redirect: "manual" }); + await fetch(`${BASE}/checkout/settle`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ customer_id: PAYING }), + }); + + const card = await cardOnFile(PAYING); + expect(card?.card).toMatchObject({ brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030 }); + }); +}); diff --git a/packages/@emulators/autumn/src/entities.ts b/packages/@emulators/autumn/src/entities.ts index bc85b61f3..71a569c5d 100644 --- a/packages/@emulators/autumn/src/entities.ts +++ b/packages/@emulators/autumn/src/entities.ts @@ -23,6 +23,9 @@ export interface AutumnCustomer extends Entity { /** Plan ids whose free trial this customer has already consumed. Once used, * the plan's `trial_available` flips to false (Autumn offers a trial once). */ trials_used?: string[]; + /** The customer's default payment method, as Stripe holds it. Only returned + * by `customers.get_or_create` when `expand` asks for `payment_method`. */ + payment_method?: AutumnPaymentMethod | null; } export interface AutumnTrackEvent extends Entity { @@ -64,3 +67,29 @@ export interface AutumnCheckout extends Entity { * to `settled` (webhook processed, subscription active). */ status: "pending" | "completed" | "settled"; } + +/** A Stripe PaymentMethod as Autumn surfaces it on an expanded customer. Only + * the card fields an application realistically renders are modelled. */ +export interface AutumnPaymentMethod { + id: string; + type: "card"; + card: { brand: string; last4: string; exp_month: number; exp_year: number }; +} + +/** A Stripe Checkout session in `mode: "setup"`, opened by + * `billing.setup_payment` so a customer can replace the card on file. The + * hosted page captures a card and redirects to `success_url`, but the + * customer's default payment method only changes once the asynchronous + * `checkout.session.completed` webhook is processed, modelled here by + * `settle` (the same race as the checkout flow above). */ +export interface AutumnSetupSession extends Entity { + session_id: string; + customer_id: string; + success_url: string; + /** `pending` (setup open) to `completed` (card captured, webhook in flight) + * to `settled` (webhook processed, default payment method replaced). */ + status: "pending" | "completed" | "settled"; + /** The card captured when the hosted page was submitted, applied to the + * customer at settle. */ + payment_method?: AutumnPaymentMethod; +} diff --git a/packages/@emulators/autumn/src/index.ts b/packages/@emulators/autumn/src/index.ts index fb191c086..5436cc04e 100644 --- a/packages/@emulators/autumn/src/index.ts +++ b/packages/@emulators/autumn/src/index.ts @@ -4,7 +4,7 @@ import { getAutumnStore, type AutumnStore } from "./store.js"; import { autumnApiRoutes } from "./routes/api.js"; import { checkoutRoutes } from "./routes/checkout.js"; import { openapiRoutes } from "./routes/openapi.js"; -import type { AutumnSubscription, AutumnPlan, AutumnPlanItem } from "./entities.js"; +import type { AutumnSubscription, AutumnPaymentMethod, AutumnPlan, AutumnPlanItem } from "./entities.js"; export { getAutumnStore, type AutumnStore } from "./store.js"; export * from "./entities.js"; @@ -26,6 +26,9 @@ export interface AutumnSeedConfig { name?: string; email?: string; subscriptions?: AutumnSubscription[]; + /** The card already on file for this customer. `id` is optional; the + * emulator mints a Stripe-style one when it is omitted. */ + payment_method?: Omit & { id?: string }; }>; /** Plan catalog the emulator advertises via `plans.list` and attaches via * `billing.attach`. In production these are synced from `autumn.config.ts`; @@ -54,26 +57,37 @@ function seedPlans(as: AutumnStore, plans: AutumnSeedPlan[]): void { }); } +function seedPaymentMethod( + seed: (Omit & { id?: string }) | undefined, + index: number, +): AutumnPaymentMethod | undefined { + if (!seed) return undefined; + return { id: seed.id ?? `pm_emulate_seed_${index}`, type: "card", card: seed.card }; +} + export function seedFromConfig(store: Store, _baseUrl: string, config: AutumnSeedConfig): void { const as = getAutumnStore(store); if (config.plans) seedPlans(as, config.plans); - for (const customer of config.customers ?? []) { + (config.customers ?? []).forEach((customer, index) => { + const paymentMethod = seedPaymentMethod(customer.payment_method, index + 1); const existing = as.customers.findOneBy("customer_id", customer.id); if (existing) { as.customers.update(existing.id, { name: customer.name ?? existing.name, email: customer.email ?? existing.email, subscriptions: customer.subscriptions ?? existing.subscriptions, + payment_method: paymentMethod ?? existing.payment_method, }); - continue; + return; } as.customers.insert({ customer_id: customer.id, name: customer.name ?? null, email: customer.email ?? null, subscriptions: customer.subscriptions ?? [], + payment_method: paymentMethod, }); - } + }); } export const autumnPlugin: ServicePlugin = { diff --git a/packages/@emulators/autumn/src/manifest.ts b/packages/@emulators/autumn/src/manifest.ts index 06a101347..59abcc12d 100644 --- a/packages/@emulators/autumn/src/manifest.ts +++ b/packages/@emulators/autumn/src/manifest.ts @@ -9,6 +9,7 @@ export const manifest: ServiceManifest = { surfaces: [ { id: "rest", kind: "rest", title: "Autumn v1 API", status: "partial", basePath: "/v1" }, { id: "checkout", kind: "ui", title: "Hosted checkout", status: "partial", basePath: "/checkout" }, + { id: "setup", kind: "ui", title: "Hosted payment method setup", status: "partial", basePath: "/checkout/setup" }, ], auth: [{ id: "api-key", title: "Autumn secret key", type: "api-key", status: "supported" }], specs: [ @@ -31,6 +32,12 @@ export const manifest: ServiceManifest = { { operationId: "balances.update", method: "POST", path: "/v1/balances.update", status: "hand-authored" }, { operationId: "plans.list", method: "POST", path: "/v1/plans.list", status: "hand-authored" }, { operationId: "billing.attach", method: "POST", path: "/v1/billing.attach", status: "hand-authored" }, + { + operationId: "billing.setup_payment", + method: "POST", + path: "/v1/billing.setup_payment", + status: "hand-authored", + }, { operationId: "billing.open_customer_portal", method: "POST", @@ -62,8 +69,14 @@ export const manifest: ServiceManifest = { { key: "customers", title: "Customers", - description: "Customers keyed by id, each with optional subscriptions.", - example: [{ id: "org_123", subscriptions: [{ plan_id: "team", status: "active" }] }], + description: "Customers keyed by id, each with optional subscriptions and an optional card on file.", + example: [ + { + id: "org_123", + subscriptions: [{ plan_id: "team", status: "active" }], + payment_method: { type: "card", card: { brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030 } }, + }, + ], }, ], example: { @@ -78,6 +91,7 @@ export const manifest: ServiceManifest = { { name: "autumn.events" }, { name: "autumn.plans" }, { name: "autumn.checkouts" }, + { name: "autumn.setups" }, ], }, connections: [ diff --git a/packages/@emulators/autumn/src/routes/api.ts b/packages/@emulators/autumn/src/routes/api.ts index 61e5b3b44..681038a2d 100644 --- a/packages/@emulators/autumn/src/routes/api.ts +++ b/packages/@emulators/autumn/src/routes/api.ts @@ -9,6 +9,19 @@ import { balanceForFeature, } from "../serialize.js"; +/** Autumn's `expand` request param: an array of field names, and (for hand-rolled + * HTTP callers) a comma-separated string. Anything else expands nothing. */ +function parseExpand(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); + if (typeof value === "string") { + return value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + } + return []; +} + /** Autumn v1 RPC-style API (paths mirror autumn-js: /v1/.). */ export function autumnApiRoutes(ctx: RouteContext): void { const { app, store, baseUrl } = ctx; @@ -20,7 +33,7 @@ export function autumnApiRoutes(ctx: RouteContext): void { if (!id) return c.json({ message: "customer_id is required", code: "invalid_request" }, 400); const data = (body.customer_data as Record | undefined) ?? body; const customer = ensureCustomer(as(), id, data); - return c.json(serializeCustomer(as(), customer)); + return c.json(serializeCustomer(as(), customer, { expand: parseExpand(body.expand) })); }); app.post("/v1/customers.list", async (c) => { @@ -209,6 +222,34 @@ export function autumnApiRoutes(ctx: RouteContext): void { return c.json({ customer_id: customerId, payment_url: null, invoice: null, required_action: null }); }); + // Open a Stripe Checkout session in `mode: "setup"` so the customer can + // replace the card on file. Like the real flow, the returned `url` is a + // hosted page; completing it redirects back to `success_url`, but the + // default payment method only changes when the asynchronous + // `checkout.session.completed` webhook is processed (see /checkout/setup). + app.post("/v1/billing.setup_payment", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Record; + const customerId = String(body.customer_id ?? body.customerId ?? ""); + if (!customerId) return c.json({ message: "customer_id is required", code: "invalid_request" }, 400); + const entityId = typeof body.entity_id === "string" ? body.entity_id : undefined; + const successUrl = String(body.success_url ?? body.successUrl ?? ""); + const store = as(); + ensureCustomer(store, customerId, body); + const session = store.setups.insert({ + session_id: "", + customer_id: customerId, + success_url: successUrl, + status: "pending", + }); + const sessionId = `seti_emulate_${session.id}`; + store.setups.update(session.id, { session_id: sessionId }); + return c.json({ + customer_id: customerId, + ...(entityId ? { entity_id: entityId } : {}), + url: `${baseUrl}/checkout/setup/${sessionId}`, + }); + }); + app.post("/v1/billing.open_customer_portal", async (c) => { const body = (await c.req.json().catch(() => ({}))) as Record; const customerId = String(body.customer_id ?? body.customerId ?? ""); diff --git a/packages/@emulators/autumn/src/routes/checkout.ts b/packages/@emulators/autumn/src/routes/checkout.ts index bd71c6b1e..c9d4d5a94 100644 --- a/packages/@emulators/autumn/src/routes/checkout.ts +++ b/packages/@emulators/autumn/src/routes/checkout.ts @@ -1,22 +1,86 @@ -import type { RouteContext, CheckoutLineItem } from "@emulators/core"; +import type { RouteContext, CheckoutLineItem, Store } from "@emulators/core"; import { renderCheckoutPage, renderCardPage } from "@emulators/core"; import { getAutumnStore, type AutumnStore } from "../store.js"; -import type { AutumnCheckout } from "../entities.js"; -import { activateSubscription } from "../serialize.js"; +import type { AutumnCheckout, AutumnPaymentMethod, AutumnSetupSession } from "../entities.js"; +import { activateSubscription, defaultCard, nextPaymentMethodId } from "../serialize.js"; const SERVICE_LABEL = "Autumn"; /** Process a completed checkout the way Autumn processes Stripe's asynchronous * `checkout.session.completed` webhook: now (and only now) does the customer's - * subscription actually become active. A card-required trial lands `trialing`. */ -function settle(as: AutumnStore, session: AutumnCheckout): void { + * subscription actually become active. A card-required trial lands `trialing`. + * Paying also leaves a card on file, as the Stripe subscription does. */ +function settle(store: Store, as: AutumnStore, session: AutumnCheckout): void { const customer = as.customers.findOneBy("customer_id", session.customer_id); const plan = as.plans.findOneBy("plan_id", session.plan_id); if (customer && plan) activateSubscription(as, customer, plan, { trial: plan.free_trial != null }); + if (customer && !customer.payment_method) { + as.customers.update(customer.id, { payment_method: defaultCard(nextPaymentMethodId(store)) }); + } as.checkouts.update(session.id, { status: "settled" }); } +/** Read the card the hosted setup page submitted. The brand follows the real + * issuer ranges Stripe's test cards use (4 Visa, 5 Mastercard, 3 Amex). */ +function cardFromForm(cardNumber: string, exp: string, id: string): AutumnPaymentMethod { + const digits = cardNumber.replace(/\D/g, ""); + const brand = digits.startsWith("4") + ? "visa" + : digits.startsWith("5") + ? "mastercard" + : digits.startsWith("3") + ? "amex" + : "card"; + const [rawMonth = "", rawYear = ""] = exp.split("/"); + const month = Number(rawMonth.trim()); + const year = Number(rawYear.trim()); + const expYear = Number.isFinite(year) && year > 0 ? (year < 100 ? 2000 + year : year) : 2030; + return { + id, + type: "card", + card: { + brand, + last4: digits.slice(-4) || "4242", + exp_month: Number.isFinite(month) && month > 0 ? month : 12, + exp_year: expYear, + }, + }; +} + +/** Process a completed setup session the way Autumn processes Stripe's + * `checkout.session.completed` webhook for a standalone setup checkout: the + * captured card becomes the customer's default payment method. */ +function settleSetup(as: AutumnStore, session: AutumnSetupSession): void { + const customer = as.customers.findOneBy("customer_id", session.customer_id); + if (customer && session.payment_method) { + as.customers.update(customer.id, { payment_method: session.payment_method }); + } + as.setups.update(session.id, { status: "settled" }); +} + +function setupPage(session: AutumnSetupSession): string { + return renderCardPage( + "Update payment method", + "Save a new card for future payments.", + `
+
+ +
+ +
+ + +
+
+
Card fields are simulated. The card is saved on file.
+
+ +
`, + SERVICE_LABEL, + ); +} + export function checkoutRoutes(ctx: RouteContext): void { const { app, store } = ctx; const as = () => getAutumnStore(store); @@ -30,8 +94,67 @@ export function checkoutRoutes(ctx: RouteContext): void { if (!customerId) return c.json({ message: "customer_id is required", code: "invalid_request" }, 400); const store = as(); const sessions = store.checkouts.findBy("customer_id", customerId).filter((s) => s.status !== "settled"); - for (const session of sessions) settle(store, session); - return c.json({ settled: sessions.length }); + for (const session of sessions) settle(ctx.store, store, session); + const setups = store.setups.findBy("customer_id", customerId).filter((s) => s.status !== "settled"); + for (const session of setups) settleSetup(store, session); + return c.json({ settled: sessions.length + setups.length }); + }); + + // Hosted Stripe "setup mode" checkout: capture a card without charging it. + app.get("/checkout/setup/:sessionId", (c) => { + const store = as(); + const session = store.setups.findOneBy("session_id", c.req.param("sessionId")); + if (!session) { + return c.html( + renderCardPage( + "Setup not found", + "This setup session does not exist.", + '

The session id is invalid or has been removed.

', + SERVICE_LABEL, + ), + 404, + ); + } + if (session.status === "settled") { + return c.html( + renderCardPage( + "Payment method updated", + "This card is already on file.", + '

Payment method saved

', + SERVICE_LABEL, + ), + ); + } + return c.html(setupPage(session)); + }); + + // The browser submits the hosted setup page here. The card is captured and + // the browser is redirected back, but the customer's default payment method + // is deliberately NOT replaced yet: like real Stripe, that happens out of + // band when the webhook is processed (see /checkout/setup/:id/settle). + app.post("/checkout/setup/:sessionId/complete", async (c) => { + const store = as(); + const session = store.setups.findOneBy("session_id", c.req.param("sessionId")); + if (!session) { + return c.html(renderCardPage("Setup not found", "This setup session does not exist.", "", SERVICE_LABEL), 404); + } + const form = await c.req.parseBody().catch(() => ({}) as Record); + const cardNumber = typeof form.card_number === "string" ? form.card_number : "4242 4242 4242 4242"; + const exp = typeof form.exp === "string" ? form.exp : "12/30"; + store.setups.update(session.id, { + status: "completed", + payment_method: cardFromForm(cardNumber, exp, nextPaymentMethodId(ctx.store)), + }); + return c.redirect(session.success_url || "/"); + }); + + // Settle a single setup session explicitly (the Stripe webhook landing). + app.post("/checkout/setup/:sessionId/settle", (c) => { + const store = as(); + const session = store.setups.findOneBy("session_id", c.req.param("sessionId")); + if (!session) return c.json({ message: "setup session not found", code: "not_found" }, 404); + settleSetup(store, session); + return c.json({ settled: 1 }); }); app.get("/checkout/:sessionId", (c) => { @@ -112,7 +235,7 @@ export function checkoutRoutes(ctx: RouteContext): void { const store = as(); const session = store.checkouts.findOneBy("session_id", c.req.param("sessionId")); if (!session) return c.json({ message: "checkout not found", code: "not_found" }, 404); - settle(store, session); + settle(ctx.store, store, session); return c.json({ settled: 1 }); }); } diff --git a/packages/@emulators/autumn/src/routes/openapi.ts b/packages/@emulators/autumn/src/routes/openapi.ts index e07a1da45..4f0c331ef 100644 --- a/packages/@emulators/autumn/src/routes/openapi.ts +++ b/packages/@emulators/autumn/src/routes/openapi.ts @@ -57,6 +57,12 @@ function buildSpec(baseUrl: string): Record { }, name: { type: "string" }, email: { type: "string" }, + expand: { + type: "array", + items: { type: "string", enum: ["payment_method"] }, + description: + "Fields to expand on the returned customer. `payment_method` adds the default card (or null); omit it and the field is absent.", + }, }, ["customer_id"], "The customer to fetch or create.", @@ -147,6 +153,24 @@ function buildSpec(baseUrl: string): Record { }, }, }, + "/v1/billing.setup_payment": { + post: { + operationId: "billing.setup_payment", + tags: ["billing"], + summary: "Open a hosted setup checkout to replace the card on file", + requestBody: jsonBody( + { + customer_id: { type: "string" }, + entity_id: { type: "string" }, + plan_id: { type: "string" }, + success_url: { type: "string" }, + }, + ["customer_id"], + "The customer whose payment method is being set up. Returns `{ customer_id, entity_id?, url }`; the card only replaces the default once the setup session settles.", + ), + responses: { "200": ok("The hosted setup URL."), "400": ok("Validation error.") }, + }, + }, "/v1/plans.list": { post: { operationId: "plans.list", diff --git a/packages/@emulators/autumn/src/serialize.ts b/packages/@emulators/autumn/src/serialize.ts index 11476b9b0..fb87c75ff 100644 --- a/packages/@emulators/autumn/src/serialize.ts +++ b/packages/@emulators/autumn/src/serialize.ts @@ -1,8 +1,10 @@ // Shared state transitions and SDK-shaped serialization for the Autumn // emulator. Field names are the snake_case keys the real Autumn v1 API returns; // the autumn-js SDK remaps them to camelCase on the way in. +import type { Store } from "@emulators/core"; + import type { AutumnStore } from "./store.js"; -import type { AutumnCustomer, AutumnPlan, AutumnSubscription } from "./entities.js"; +import type { AutumnCustomer, AutumnPaymentMethod, AutumnPlan, AutumnSubscription } from "./entities.js"; const DAY_MS = 86_400_000; @@ -155,7 +157,35 @@ export function balanceForFeature( return balancesFor(as, customer)[featureId]; } -export function serializeCustomer(as: AutumnStore, customer: AutumnCustomer): Record { +/** Mint the next Stripe-style PaymentMethod id for this instance. Stripe ids + * are unique per object, so the counter lives on the store rather than on any + * one customer (a customer can replace its card any number of times). */ +export function nextPaymentMethodId(store: Store): string { + const next = (store.getData("autumn.payment_method_seq") ?? 0) + 1; + store.setData("autumn.payment_method_seq", next); + return `pm_emulate_${next}`; +} + +/** The card a paid checkout leaves on file when the customer had none. */ +export function defaultCard(id: string): AutumnPaymentMethod { + return { id, type: "card", card: { brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030 } }; +} + +export interface SerializeCustomerOptions { + /** Autumn's `expand` request param. `payment_method` adds the customer's + * default card (or null); without it the field is omitted entirely, which + * is exactly what real Autumn does. */ + expand?: string[]; +} + +export function serializeCustomer( + as: AutumnStore, + customer: AutumnCustomer, + options: SerializeCustomerOptions = {}, +): Record { + const expanded: Record = options.expand?.includes("payment_method") + ? { payment_method: customer.payment_method ?? null } + : {}; return { id: customer.customer_id, created_at: Date.parse(customer.created_at) || Date.now(), @@ -174,6 +204,7 @@ export function serializeCustomer(as: AutumnStore, customer: AutumnCustomer): Re invoices: [], products: [], features: {}, + ...expanded, }; } diff --git a/packages/@emulators/autumn/src/store.ts b/packages/@emulators/autumn/src/store.ts index 7f4ce8881..8f18cbcf6 100644 --- a/packages/@emulators/autumn/src/store.ts +++ b/packages/@emulators/autumn/src/store.ts @@ -1,12 +1,13 @@ import { Store, type Collection } from "@emulators/core"; -import type { AutumnCustomer, AutumnTrackEvent, AutumnPlan, AutumnCheckout } from "./entities.js"; +import type { AutumnCustomer, AutumnTrackEvent, AutumnPlan, AutumnCheckout, AutumnSetupSession } from "./entities.js"; export interface AutumnStore { customers: Collection; events: Collection; plans: Collection; checkouts: Collection; + setups: Collection; } export function getAutumnStore(store: Store): AutumnStore { @@ -15,5 +16,6 @@ export function getAutumnStore(store: Store): AutumnStore { events: store.collection("autumn.events", ["customer_id", "feature_id"]), plans: store.collection("autumn.plans", ["plan_id"]), checkouts: store.collection("autumn.checkouts", ["session_id", "customer_id"]), + setups: store.collection("autumn.setups", ["session_id", "customer_id"]), }; } diff --git a/packages/@emulators/aws/package.json b/packages/@emulators/aws/package.json index 299f9b2d8..98c0ef655 100644 --- a/packages/@emulators/aws/package.json +++ b/packages/@emulators/aws/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/aws", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/clerk/package.json b/packages/@emulators/clerk/package.json index 9fd9fbea9..a6afca61e 100644 --- a/packages/@emulators/clerk/package.json +++ b/packages/@emulators/clerk/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/clerk", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/cloudflare/package.json b/packages/@emulators/cloudflare/package.json index c02ea5947..9d586e450 100644 --- a/packages/@emulators/cloudflare/package.json +++ b/packages/@emulators/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/cloudflare", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/core/package.json b/packages/@emulators/core/package.json index 17a37e987..942c2fc90 100644 --- a/packages/@emulators/core/package.json +++ b/packages/@emulators/core/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/core", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/github/package.json b/packages/@emulators/github/package.json index 2406c0cca..1fcff3bce 100644 --- a/packages/@emulators/github/package.json +++ b/packages/@emulators/github/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/github", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/gitlab/package.json b/packages/@emulators/gitlab/package.json index 699965dba..a2889d26b 100644 --- a/packages/@emulators/gitlab/package.json +++ b/packages/@emulators/gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/gitlab", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/google/package.json b/packages/@emulators/google/package.json index f5b80e60d..6acd5aea3 100644 --- a/packages/@emulators/google/package.json +++ b/packages/@emulators/google/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/google", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/mcp/package.json b/packages/@emulators/mcp/package.json index 92d8b07bd..14509ca3a 100644 --- a/packages/@emulators/mcp/package.json +++ b/packages/@emulators/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/mcp", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/microsoft/package.json b/packages/@emulators/microsoft/package.json index 6b8e1bf3c..b518e272a 100644 --- a/packages/@emulators/microsoft/package.json +++ b/packages/@emulators/microsoft/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/microsoft", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/mongoatlas/package.json b/packages/@emulators/mongoatlas/package.json index ae2639f00..aa8d626bf 100644 --- a/packages/@emulators/mongoatlas/package.json +++ b/packages/@emulators/mongoatlas/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/mongoatlas", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/okta/package.json b/packages/@emulators/okta/package.json index 76e63a6c7..3a3b16b3e 100644 --- a/packages/@emulators/okta/package.json +++ b/packages/@emulators/okta/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/okta", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/posthog/package.json b/packages/@emulators/posthog/package.json index d346f0af6..7606997e1 100644 --- a/packages/@emulators/posthog/package.json +++ b/packages/@emulators/posthog/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/posthog", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/resend/package.json b/packages/@emulators/resend/package.json index e334e51e3..4588a5c7c 100644 --- a/packages/@emulators/resend/package.json +++ b/packages/@emulators/resend/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/resend", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/slack/package.json b/packages/@emulators/slack/package.json index 765c3f881..67ebdc80b 100644 --- a/packages/@emulators/slack/package.json +++ b/packages/@emulators/slack/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/slack", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/spotify/package.json b/packages/@emulators/spotify/package.json index db08c40c2..052fb3b38 100644 --- a/packages/@emulators/spotify/package.json +++ b/packages/@emulators/spotify/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/spotify", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/stripe/package.json b/packages/@emulators/stripe/package.json index 28678cea9..76fec20f2 100644 --- a/packages/@emulators/stripe/package.json +++ b/packages/@emulators/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/stripe", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/vercel/package.json b/packages/@emulators/vercel/package.json index 7a9c0a441..0e8514ba6 100644 --- a/packages/@emulators/vercel/package.json +++ b/packages/@emulators/vercel/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/vercel", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/workos/package.json b/packages/@emulators/workos/package.json index 0e452d167..147630443 100644 --- a/packages/@emulators/workos/package.json +++ b/packages/@emulators/workos/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/workos", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/@emulators/x/package.json b/packages/@emulators/x/package.json index 8a2d40d4b..e809c09ea 100644 --- a/packages/@emulators/x/package.json +++ b/packages/@emulators/x/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/x", - "version": "0.14.1", + "version": "0.14.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/packages/emulate/package.json b/packages/emulate/package.json index c0469e775..028a86944 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/emulate", - "version": "0.14.1", + "version": "0.14.2", "description": "Local drop-in replacement services for CI and no-network sandboxes", "license": "Apache-2.0", "type": "module", diff --git a/skills/autumn/SKILL.md b/skills/autumn/SKILL.md index 8156b72aa..edc06ea78 100644 --- a/skills/autumn/SKILL.md +++ b/skills/autumn/SKILL.md @@ -6,7 +6,7 @@ allowed-tools: Bash(npx emulate:*), Bash(curl:*) # Autumn Emulator -Stateful Autumn billing emulation: customers (get_or_create), seedable subscriptions, a seedable plan catalog with per-customer eligibility (`plans.list`), usage tracking (`balances.track`), balance reconciliation (`balances.update`), feature access checks (`balances.check`), `billing.attach` / `billing.open_customer_portal`, and a hosted checkout flow for paid plans and card-required free trials. +Stateful Autumn billing emulation: customers (get_or_create), seedable subscriptions, a seedable plan catalog with per-customer eligibility (`plans.list`), usage tracking (`balances.track`), balance reconciliation (`balances.update`), feature access checks (`balances.check`), `billing.attach` / `billing.setup_payment` / `billing.open_customer_portal`, a hosted checkout flow for paid plans and card-required free trials, and a hosted setup flow for changing the card on file. ## Start @@ -69,4 +69,17 @@ curl -X POST "$AUTUMN_EMULATOR_URL/checkout/settle" -H "Content-Type: applicatio This deferral lets a test reproduce the real "page is stale until reload" race: the redirect back lands before the subscription is active. +## Changing the card on file + +`billing.setupPayment({ customerId, successUrl })` returns `{ customer_id, url }`, where `url` is a hosted setup page (`GET /checkout/setup/:sessionId`). Submitting it (`POST /checkout/setup/:sessionId/complete` with `card_number` and `exp`) redirects to `success_url` but does NOT replace the default card yet: that lands with the webhook, so settle the session (`POST /checkout/setup/:sessionId/settle`, or the customer-wide `POST /checkout/settle`). + +Read the card back with `expand`: + +```ts +const customer = await autumn.customers.getOrCreate({ customerId: "org_123", expand: ["payment_method"] }); +// customer.paymentMethod: { id, type: "card", card: { brand, last4, exp_month, exp_year } } or null +``` + +Without `expand`, the field is omitted entirely, as in real Autumn. A paid checkout also leaves a visa 4242 on file when the customer had no card. + Inspect calls at `GET /_emulate/ledger`; reset with `POST /_emulate/reset`. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. From c25a62850c8a39ffcb0aec79dac06b52eeebd6cb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:41:53 -0700 Subject: [PATCH 2/2] Model portal card update and setup default semantics --- CHANGELOG.md | 4 +- .../__tests__/autumn-setup-payment.test.ts | 93 +++++++++++++ packages/@emulators/autumn/src/entities.ts | 26 +++- packages/@emulators/autumn/src/index.ts | 2 + packages/@emulators/autumn/src/manifest.ts | 4 +- packages/@emulators/autumn/src/routes/api.ts | 14 +- .../@emulators/autumn/src/routes/checkout.ts | 54 +++----- .../@emulators/autumn/src/routes/openapi.ts | 22 ++- .../@emulators/autumn/src/routes/portal.ts | 129 ++++++++++++++++++ packages/@emulators/autumn/src/serialize.ts | 41 +++++- packages/@emulators/autumn/src/store.ts | 11 +- skills/autumn/SKILL.md | 12 +- 12 files changed, 356 insertions(+), 56 deletions(-) create mode 100644 packages/@emulators/autumn/src/routes/portal.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 969f51e7f..097d70af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ ### New Features -- **Autumn payment method setup** — the Autumn emulator now supports changing the card on file end to end. `POST /v1/billing.setup_payment` (autumn-js `billing.setupPayment`) opens a Stripe setup-mode checkout and returns `{ customer_id, entity_id?, url }`; the hosted page at `GET /checkout/setup/:sessionId` captures a card (`card_number`, `exp`, both editable so a test can pick the brand) and redirects to `success_url`. As with the existing checkout flow, the customer's default payment method is deliberately not replaced until the `checkout.session.completed` webhook lands, modelled by `POST /checkout/setup/:sessionId/settle` (or the customer-wide `POST /checkout/settle`, which now settles setup sessions too). `customers.get_or_create` honours `expand: ["payment_method"]`, returning the Stripe PaymentMethod (`id`, `type`, `card.brand`, `card.last4`, `card.exp_month`, `card.exp_year`) or null, and omitting the field entirely when `expand` does not ask for it. Settling a paid checkout also leaves a visa 4242 on file when the customer had no card, and customers can be seeded with a `payment_method`. +- **Autumn payment method setup and billing portal** — the Autumn emulator now models the card on file end to end. `POST /v1/billing.setup_payment` (autumn-js `billing.setupPayment`) opens a Stripe setup-mode checkout and returns `{ customer_id, entity_id?, url }`; the hosted page at `GET /checkout/setup/:sessionId` captures a card (`card_number`, `exp`, both editable so a test can pick the brand) and redirects to `success_url`. As with the existing checkout flow, nothing changes on the customer until the `checkout.session.completed` webhook lands, modelled by `POST /checkout/setup/:sessionId/settle` (or the customer-wide `POST /checkout/settle`, which now settles setup sessions too). Settling matches real Autumn's surprising behaviour: a setup session only sets the default payment method when the customer has none, and never replaces an existing card, because Autumn's setup-checkout handler reads the current default first and re-sets that same card. +- **Autumn hosted billing portal** — `POST /v1/billing.open_customer_portal` now returns a URL that actually serves a page. `GET /checkout/portal/:customerId` shows the customer's current plan, the card on file (or "No payment method"), a form to update it, and a link back to the `return_url` the portal session was opened with (recorded in the new `autumn.portals` collection). `POST /checkout/portal/:customerId/payment-method` changes the card IMMEDIATELY, with no settle step, because real Stripe swaps the default inside the portal and Autumn reads the card live on every expand. This is how an application under test changes a card. Unknown customers 404. +- **Autumn `payment_method` expand** — `customers.get_or_create` honours `expand: ["payment_method"]`, returning the Stripe PaymentMethod (`id`, `type`, `card.brand`, `card.last4`, `card.exp_month`, `card.exp_year`), and omitting the field entirely when there is no card or when `expand` does not ask for it. Settling a paid checkout also leaves a visa 4242 on file when the customer had no card, and customers can be seeded with a `payment_method`. diff --git a/packages/@emulators/autumn/src/__tests__/autumn-setup-payment.test.ts b/packages/@emulators/autumn/src/__tests__/autumn-setup-payment.test.ts index a2ca6b2b0..bcbd8b03b 100644 --- a/packages/@emulators/autumn/src/__tests__/autumn-setup-payment.test.ts +++ b/packages/@emulators/autumn/src/__tests__/autumn-setup-payment.test.ts @@ -52,6 +52,7 @@ interface Card { const cardOnFile = async (customerId: string): Promise => { const customer = await autumn.customers.getOrCreate({ customerId, expand: ["payment_method"] }); + // Real Autumn omits the key entirely when no card is on file, even when expanded. return (customer.paymentMethod ?? null) as Card | null; }; @@ -113,6 +114,30 @@ describe("autumn emulator: setup_payment (change the card on file)", () => { expect("paymentMethod" in customer, "field is absent without expand").toBe(false); }); + // Real Autumn's setup-checkout webhook handler reads the customer's existing + // default payment method first and re-sets that same card when one exists, + // so a second setup session cannot change the card. Confirmed live: a visa + // 4242 stayed default after a mastercard 4444 was saved through setup. + it("a second setup session settles but leaves the existing card in place", async () => { + const before = await cardOnFile(CUSTOMER); + expect(before?.card, "the first setup left a mastercard on file").toMatchObject({ last4: "4444" }); + + const { url } = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL }); + const sessionId = new URL(url).pathname.split("/").pop()!; + await fetch(`${BASE}/checkout/setup/${sessionId}/complete`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "card_number=4242 4242 4242 4242&exp=01/33", + redirect: "manual", + }); + const settled = await fetch(`${BASE}/checkout/setup/${sessionId}/settle`, { method: "POST" }); + expect(await settled.json(), "the session still settles").toEqual({ settled: 1 }); + + const after = await cardOnFile(CUSTOMER); + expect(after?.card, "the old card is still the default").toMatchObject({ brand: "mastercard", last4: "4444" }); + expect(after?.id, "and it is the same payment method").toBe(before?.id); + }); + it("paying for a plan leaves a visa on file", async () => { const PAYING = "org_pays"; const attach = await autumn.billing.attach({ customerId: PAYING, planId: "team", successUrl: SUCCESS_URL }); @@ -128,3 +153,71 @@ describe("autumn emulator: setup_payment (change the card on file)", () => { expect(card?.card).toMatchObject({ brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030 }); }); }); + +describe("autumn emulator: billing portal (change the card on file)", () => { + const CUSTOMER = "org_portal"; + const RETURN_URL = `${BASE}/settings/billing`; + + const openPortal = async (): Promise => { + const res = await autumn.billing.openCustomerPortal({ customerId: CUSTOMER, returnUrl: RETURN_URL }); + expect(res.customerId).toBe(CUSTOMER); + return res.url; + }; + + it("the portal page shows the card on file and links back to the app", async () => { + // Give the customer a card the only supported way: a first setup session. + const setup = await autumn.billing.setupPayment({ customerId: CUSTOMER, successUrl: SUCCESS_URL }); + const sessionId = new URL(setup.url).pathname.split("/").pop()!; + await fetch(`${BASE}/checkout/setup/${sessionId}/complete`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "card_number=4242 4242 4242 4242&exp=12/30", + redirect: "manual", + }); + await fetch(`${BASE}/checkout/setup/${sessionId}/settle`, { method: "POST" }); + + const url = await openPortal(); + expect(url, "the portal URL points at the hosted page").toContain(`/checkout/portal/${CUSTOMER}`); + + const page = await fetch(url); + expect(page.status).toBe(200); + const html = await page.text(); + expect(html, "the portal is identified").toContain("Billing portal"); + expect(html, "the current card brand").toContain("visa"); + expect(html, "the current card last4").toContain("4242"); + expect(html, "the update form").toContain("Update payment method"); + expect(html, "the return_url recorded when the portal was opened").toContain(`href="${RETURN_URL}"`); + }); + + it("updating the card in the portal applies immediately, with no settle step", async () => { + const res = await fetch(`${BASE}/checkout/portal/${CUSTOMER}/payment-method`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "card_number=5555 5555 5555 4444&exp=11/31", + redirect: "manual", + }); + expect(res.status, "the portal redirects back to itself").toBe(302); + expect(res.headers.get("location")).toBe(`/checkout/portal/${CUSTOMER}`); + + // No settle: Stripe owns the portal and swaps the default synchronously. + const card = await cardOnFile(CUSTOMER); + expect(card?.card).toMatchObject({ brand: "mastercard", last4: "4444", exp_month: 11, exp_year: 2031 }); + + const html = await (await fetch(`${BASE}/checkout/portal/${CUSTOMER}`)).text(); + expect(html, "the page reflects the new card").toContain("mastercard"); + }); + + it("the portal page 404s for an unknown customer", async () => { + const res = await fetch(`${BASE}/checkout/portal/org_does_not_exist`); + expect(res.status).toBe(404); + expect(await res.text()).toContain("Customer not found"); + }); + + it("the return link is omitted when the portal was opened without a return_url", async () => { + const NO_RETURN = "org_portal_bare"; + const res = await autumn.billing.openCustomerPortal({ customerId: NO_RETURN }); + const html = await (await fetch(res.url)).text(); + expect(html, "no card yet").toContain("No payment method"); + expect(html, "no return link").not.toContain("Return to"); + }); +}); diff --git a/packages/@emulators/autumn/src/entities.ts b/packages/@emulators/autumn/src/entities.ts index 71a569c5d..c8d0e2223 100644 --- a/packages/@emulators/autumn/src/entities.ts +++ b/packages/@emulators/autumn/src/entities.ts @@ -77,19 +77,31 @@ export interface AutumnPaymentMethod { } /** A Stripe Checkout session in `mode: "setup"`, opened by - * `billing.setup_payment` so a customer can replace the card on file. The - * hosted page captures a card and redirects to `success_url`, but the - * customer's default payment method only changes once the asynchronous + * `billing.setup_payment` so a customer can put a card on file. The hosted + * page captures a card and redirects to `success_url`, but the customer's + * default payment method only changes once the asynchronous * `checkout.session.completed` webhook is processed, modelled here by - * `settle` (the same race as the checkout flow above). */ + * `settle` (the same race as the checkout flow above). Settling only sets the + * default when the customer has no card yet; see `settleSetup` in + * routes/checkout.ts for why replacing a card needs the billing portal. */ export interface AutumnSetupSession extends Entity { session_id: string; customer_id: string; success_url: string; /** `pending` (setup open) to `completed` (card captured, webhook in flight) - * to `settled` (webhook processed, default payment method replaced). */ + * to `settled` (webhook processed). */ status: "pending" | "completed" | "settled"; - /** The card captured when the hosted page was submitted, applied to the - * customer at settle. */ + /** The card captured when the hosted page was submitted. It becomes the + * customer's default at settle only when the customer had no card. */ payment_method?: AutumnPaymentMethod; } + +/** A Stripe billing portal session, opened by `billing.open_customer_portal`. + * Only the `return_url` matters to an application under test: the hosted + * portal page renders a link back to it. Stripe portal sessions are + * single-use and short-lived; the emulator keeps them so the page can find + * the most recent one for a customer. */ +export interface AutumnPortalSession extends Entity { + customer_id: string; + return_url: string; +} diff --git a/packages/@emulators/autumn/src/index.ts b/packages/@emulators/autumn/src/index.ts index 5436cc04e..9e875cbc6 100644 --- a/packages/@emulators/autumn/src/index.ts +++ b/packages/@emulators/autumn/src/index.ts @@ -3,6 +3,7 @@ import type { Hono, Store, WebhookDispatcher, TokenMap, AppEnv, RouteContext, Se import { getAutumnStore, type AutumnStore } from "./store.js"; import { autumnApiRoutes } from "./routes/api.js"; import { checkoutRoutes } from "./routes/checkout.js"; +import { portalRoutes } from "./routes/portal.js"; import { openapiRoutes } from "./routes/openapi.js"; import type { AutumnSubscription, AutumnPaymentMethod, AutumnPlan, AutumnPlanItem } from "./entities.js"; @@ -96,6 +97,7 @@ export const autumnPlugin: ServicePlugin = { const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; autumnApiRoutes(ctx); checkoutRoutes(ctx); + portalRoutes(ctx); openapiRoutes(ctx); }, seed(_store: Store, _baseUrl: string): void { diff --git a/packages/@emulators/autumn/src/manifest.ts b/packages/@emulators/autumn/src/manifest.ts index 59abcc12d..9ea2ad48a 100644 --- a/packages/@emulators/autumn/src/manifest.ts +++ b/packages/@emulators/autumn/src/manifest.ts @@ -4,12 +4,13 @@ export const manifest: ServiceManifest = { id: "autumn", name: "Autumn", description: - "Stateful Autumn billing emulator: customers (with seedable subscriptions and a plan catalog), usage tracking, feature access checks, plan eligibility, and a hosted checkout flow for paid plans and card-required free trials.", + "Stateful Autumn billing emulator: customers (with seedable subscriptions and a plan catalog), usage tracking, feature access checks, plan eligibility, a hosted checkout flow for paid plans and card-required free trials, a hosted setup flow for putting a card on file, and a hosted billing portal for changing it.", docsUrl: "https://docs.emulators.dev/autumn", surfaces: [ { id: "rest", kind: "rest", title: "Autumn v1 API", status: "partial", basePath: "/v1" }, { id: "checkout", kind: "ui", title: "Hosted checkout", status: "partial", basePath: "/checkout" }, { id: "setup", kind: "ui", title: "Hosted payment method setup", status: "partial", basePath: "/checkout/setup" }, + { id: "portal", kind: "ui", title: "Hosted billing portal", status: "partial", basePath: "/checkout/portal" }, ], auth: [{ id: "api-key", title: "Autumn secret key", type: "api-key", status: "supported" }], specs: [ @@ -92,6 +93,7 @@ export const manifest: ServiceManifest = { { name: "autumn.plans" }, { name: "autumn.checkouts" }, { name: "autumn.setups" }, + { name: "autumn.portals" }, ], }, connections: [ diff --git a/packages/@emulators/autumn/src/routes/api.ts b/packages/@emulators/autumn/src/routes/api.ts index 681038a2d..5a0751783 100644 --- a/packages/@emulators/autumn/src/routes/api.ts +++ b/packages/@emulators/autumn/src/routes/api.ts @@ -230,7 +230,10 @@ export function autumnApiRoutes(ctx: RouteContext): void { app.post("/v1/billing.setup_payment", async (c) => { const body = (await c.req.json().catch(() => ({}))) as Record; const customerId = String(body.customer_id ?? body.customerId ?? ""); - if (!customerId) return c.json({ message: "customer_id is required", code: "invalid_request" }, 400); + if (!customerId) { + // Real Autumn's validation error for a missing/invalid field. + return c.json({ message: "customer_id: must be a string (received undefined)", code: "invalid_inputs" }, 400); + } const entityId = typeof body.entity_id === "string" ? body.entity_id : undefined; const successUrl = String(body.success_url ?? body.successUrl ?? ""); const store = as(); @@ -250,11 +253,18 @@ export function autumnApiRoutes(ctx: RouteContext): void { }); }); + // Open a Stripe billing portal session. The returned `url` is the hosted + // portal page, where the customer can change the card on file. The session + // is recorded so that page can link back to the application's `return_url`, + // as Stripe's portal does. app.post("/v1/billing.open_customer_portal", async (c) => { const body = (await c.req.json().catch(() => ({}))) as Record; const customerId = String(body.customer_id ?? body.customerId ?? ""); if (!customerId) return c.json({ message: "customer_id is required", code: "invalid_request" }, 400); - ensureCustomer(as(), customerId, body); + const store = as(); + ensureCustomer(store, customerId, body); + const returnUrl = String(body.return_url ?? body.returnUrl ?? ""); + store.portals.insert({ customer_id: customerId, return_url: returnUrl }); return c.json({ customer_id: customerId, url: `${baseUrl}/checkout/portal/${customerId}` }); }); diff --git a/packages/@emulators/autumn/src/routes/checkout.ts b/packages/@emulators/autumn/src/routes/checkout.ts index c9d4d5a94..931832f6c 100644 --- a/packages/@emulators/autumn/src/routes/checkout.ts +++ b/packages/@emulators/autumn/src/routes/checkout.ts @@ -2,8 +2,8 @@ import type { RouteContext, CheckoutLineItem, Store } from "@emulators/core"; import { renderCheckoutPage, renderCardPage } from "@emulators/core"; import { getAutumnStore, type AutumnStore } from "../store.js"; -import type { AutumnCheckout, AutumnPaymentMethod, AutumnSetupSession } from "../entities.js"; -import { activateSubscription, defaultCard, nextPaymentMethodId } from "../serialize.js"; +import type { AutumnCheckout, AutumnSetupSession } from "../entities.js"; +import { activateSubscription, cardFromForm, defaultCard, nextPaymentMethodId } from "../serialize.js"; const SERVICE_LABEL = "Autumn"; @@ -21,39 +21,22 @@ function settle(store: Store, as: AutumnStore, session: AutumnCheckout): void { as.checkouts.update(session.id, { status: "settled" }); } -/** Read the card the hosted setup page submitted. The brand follows the real - * issuer ranges Stripe's test cards use (4 Visa, 5 Mastercard, 3 Amex). */ -function cardFromForm(cardNumber: string, exp: string, id: string): AutumnPaymentMethod { - const digits = cardNumber.replace(/\D/g, ""); - const brand = digits.startsWith("4") - ? "visa" - : digits.startsWith("5") - ? "mastercard" - : digits.startsWith("3") - ? "amex" - : "card"; - const [rawMonth = "", rawYear = ""] = exp.split("/"); - const month = Number(rawMonth.trim()); - const year = Number(rawYear.trim()); - const expYear = Number.isFinite(year) && year > 0 ? (year < 100 ? 2000 + year : year) : 2030; - return { - id, - type: "card", - card: { - brand, - last4: digits.slice(-4) || "4242", - exp_month: Number.isFinite(month) && month > 0 ? month : 12, - exp_year: expYear, - }, - }; -} - /** Process a completed setup session the way Autumn processes Stripe's - * `checkout.session.completed` webhook for a standalone setup checkout: the - * captured card becomes the customer's default payment method. */ + * `checkout.session.completed` webhook for a standalone setup checkout. + * + * Surprising, but this is the real behaviour: a setup session never REPLACES + * a card that is already on file. Autumn's `handleStandaloneSetupCheckout` + * calls `updateDefaultPaymentMethod`, which asks `getCusPaymentMethod` for + * the customer's existing `invoice_settings.default_payment_method` first and, + * when one exists, simply re-sets that same card. Verified against the live + * sandbox: a visa 4242 stayed the default after a second setup session saved + * a mastercard 4444. The session still settles; only the customer is + * untouched. An application that must CHANGE the card has to send the + * customer to the billing portal (see routes/portal.ts), where Stripe itself + * swaps the default. */ function settleSetup(as: AutumnStore, session: AutumnSetupSession): void { const customer = as.customers.findOneBy("customer_id", session.customer_id); - if (customer && session.payment_method) { + if (customer && session.payment_method && !customer.payment_method) { as.customers.update(customer.id, { payment_method: session.payment_method }); } as.setups.update(session.id, { status: "settled" }); @@ -62,7 +45,7 @@ function settleSetup(as: AutumnStore, session: AutumnSetupSession): void { function setupPage(session: AutumnSetupSession): string { return renderCardPage( "Update payment method", - "Save a new card for future payments.", + "Save a card for future payments.", `
@@ -130,8 +113,9 @@ export function checkoutRoutes(ctx: RouteContext): void { // The browser submits the hosted setup page here. The card is captured and // the browser is redirected back, but the customer's default payment method - // is deliberately NOT replaced yet: like real Stripe, that happens out of - // band when the webhook is processed (see /checkout/setup/:id/settle). + // is deliberately NOT set yet: like real Stripe, that happens out of band + // when the webhook is processed (see /checkout/setup/:id/settle, which only + // sets the default when the customer has no card). app.post("/checkout/setup/:sessionId/complete", async (c) => { const store = as(); const session = store.setups.findOneBy("session_id", c.req.param("sessionId")); diff --git a/packages/@emulators/autumn/src/routes/openapi.ts b/packages/@emulators/autumn/src/routes/openapi.ts index 4f0c331ef..501c2459b 100644 --- a/packages/@emulators/autumn/src/routes/openapi.ts +++ b/packages/@emulators/autumn/src/routes/openapi.ts @@ -166,11 +166,31 @@ function buildSpec(baseUrl: string): Record { success_url: { type: "string" }, }, ["customer_id"], - "The customer whose payment method is being set up. Returns `{ customer_id, entity_id?, url }`; the card only replaces the default once the setup session settles.", + "The customer whose payment method is being set up. Returns `{ customer_id, entity_id?, url }`. The captured card becomes the default only when the setup session settles AND the customer had no card: like real Autumn, a setup session never replaces an existing default. Use the billing portal to change a card.", ), responses: { "200": ok("The hosted setup URL."), "400": ok("Validation error.") }, }, }, + "/v1/billing.open_customer_portal": { + post: { + operationId: "billing.open_customer_portal", + tags: ["billing"], + summary: "Open a hosted billing portal session", + requestBody: jsonBody( + { + customer_id: { type: "string" }, + return_url: { + type: "string", + description: + "Where the portal page links back to. Recorded on the session; omit it and no link is shown.", + }, + }, + ["customer_id"], + "The customer whose billing portal to open. Returns `{ customer_id, url }`; the portal page changes the card on file immediately, with no settle step.", + ), + responses: { "200": ok("The hosted portal URL."), "400": ok("Validation error.") }, + }, + }, "/v1/plans.list": { post: { operationId: "plans.list", diff --git a/packages/@emulators/autumn/src/routes/portal.ts b/packages/@emulators/autumn/src/routes/portal.ts new file mode 100644 index 000000000..8c7073f0a --- /dev/null +++ b/packages/@emulators/autumn/src/routes/portal.ts @@ -0,0 +1,129 @@ +import type { RouteContext } from "@emulators/core"; +import { renderCardPage, escapeHtml, escapeAttr } from "@emulators/core"; + +import { getAutumnStore, type AutumnStore } from "../store.js"; +import type { AutumnCustomer } from "../entities.js"; +import { cardFromForm, nextPaymentMethodId } from "../serialize.js"; + +const SERVICE_LABEL = "Autumn"; + +/** The Stripe billing portal session most recently opened for this customer. + * Stripe portal sessions are single-use; the emulator keeps them all and the + * page reads the newest so the return link matches the latest visit. */ +function latestPortal(as: AutumnStore, customerId: string): { return_url: string } | undefined { + const sessions = as.portals.findBy("customer_id", customerId); + return sessions.length ? sessions[sessions.length - 1] : undefined; +} + +function notFoundPage(): string { + return renderCardPage( + "Customer not found", + "This billing portal link is not valid.", + '

The customer id is unknown or has been removed.

', + SERVICE_LABEL, + ); +} + +function planSummary(as: AutumnStore, customer: AutumnCustomer): string { + const subs = customer.subscriptions ?? []; + if (!subs.length) return '

No active subscription

'; + return subs + .map((sub) => { + const plan = as.plans.findOneBy("plan_id", sub.plan_id); + const price = plan?.price ? `$${plan.price.amount} / ${plan.price.interval}` : "Free"; + return `
+ ${escapeHtml(plan?.name ?? sub.plan_id)} + ${escapeHtml(sub.status)} + ${escapeHtml(price)} +
`; + }) + .join("\n"); +} + +function cardSummary(customer: AutumnCustomer): string { + const pm = customer.payment_method; + if (!pm) return '

No payment method

'; + const exp = `${String(pm.card.exp_month).padStart(2, "0")}/${pm.card.exp_year}`; + return `
+ ${escapeHtml(pm.card.brand)} + •••• ${escapeHtml(pm.card.last4)} + Expires ${escapeHtml(exp)} +
`; +} + +function returnLink(returnUrl: string | undefined): string { + if (!returnUrl) return ""; + let label = returnUrl; + try { + label = new URL(returnUrl).host || returnUrl; + } catch { + // A relative or malformed return_url is shown verbatim. + } + return `

Return to ${escapeHtml(label)}

`; +} + +function portalPage(as: AutumnStore, customer: AutumnCustomer): string { + const action = `/checkout/portal/${encodeURIComponent(customer.customer_id)}/payment-method`; + return renderCardPage( + "Billing portal", + "Manage your plan and the card on file.", + `
Current plan
+${planSummary(as, customer)} +
Payment method
+${cardSummary(customer)} + +
+ +
+ +
+ + +
+
+
Card fields are simulated. The new card replaces the default immediately.
+
+ + +${returnLink(latestPortal(as, customer.customer_id)?.return_url)}`, + SERVICE_LABEL, + ); +} + +/** The hosted Stripe billing portal, the page `billing.open_customer_portal` + * sends a customer to. Only the parts an application under test depends on + * are modelled: the current plan, the card on file, and a form that changes + * the card. + * + * Unlike the setup checkout flow, updating the card here takes effect + * IMMEDIATELY. Stripe owns the portal and swaps the customer's default + * payment method inside Stripe before the page returns, and Autumn reads the + * card live from Stripe on every `payment_method` expand, so there is no + * webhook to wait for and no race to reproduce. This is why an application + * that must CHANGE a card uses the portal rather than `billing.setup_payment` + * (see settleSetup in routes/checkout.ts). */ +export function portalRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const as = () => getAutumnStore(store); + + app.get("/checkout/portal/:customerId", (c) => { + const autumn = as(); + const customer = autumn.customers.findOneBy("customer_id", c.req.param("customerId")); + if (!customer) return c.html(notFoundPage(), 404); + return c.html(portalPage(autumn, customer)); + }); + + app.post("/checkout/portal/:customerId/payment-method", async (c) => { + const customerId = c.req.param("customerId"); + const autumn = as(); + const customer = autumn.customers.findOneBy("customer_id", customerId); + if (!customer) return c.html(notFoundPage(), 404); + const form = await c.req.parseBody().catch(() => ({}) as Record); + const cardNumber = typeof form.card_number === "string" ? form.card_number : "4242 4242 4242 4242"; + const exp = typeof form.exp === "string" ? form.exp : "12/30"; + autumn.customers.update(customer.id, { + payment_method: cardFromForm(cardNumber, exp, nextPaymentMethodId(store)), + }); + return c.redirect(`/checkout/portal/${encodeURIComponent(customerId)}`); + }); +} diff --git a/packages/@emulators/autumn/src/serialize.ts b/packages/@emulators/autumn/src/serialize.ts index fb87c75ff..9c85ae48e 100644 --- a/packages/@emulators/autumn/src/serialize.ts +++ b/packages/@emulators/autumn/src/serialize.ts @@ -171,10 +171,40 @@ export function defaultCard(id: string): AutumnPaymentMethod { return { id, type: "card", card: { brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030 } }; } +/** Read a card out of a hosted page's form fields (the setup checkout and the + * billing portal both post the same `card_number` and `exp` pair). The brand + * follows the real issuer ranges Stripe's test cards use (4 Visa, + * 5 Mastercard, 3 Amex). */ +export function cardFromForm(cardNumber: string, exp: string, id: string): AutumnPaymentMethod { + const digits = cardNumber.replace(/\D/g, ""); + const brand = digits.startsWith("4") + ? "visa" + : digits.startsWith("5") + ? "mastercard" + : digits.startsWith("3") + ? "amex" + : "card"; + const [rawMonth = "", rawYear = ""] = exp.split("/"); + const month = Number(rawMonth.trim()); + const year = Number(rawYear.trim()); + const expYear = Number.isFinite(year) && year > 0 ? (year < 100 ? 2000 + year : year) : 2030; + return { + id, + type: "card", + card: { + brand, + last4: digits.slice(-4) || "4242", + exp_month: Number.isFinite(month) && month > 0 ? month : 12, + exp_year: expYear, + }, + }; +} + export interface SerializeCustomerOptions { /** Autumn's `expand` request param. `payment_method` adds the customer's - * default card (or null); without it the field is omitted entirely, which - * is exactly what real Autumn does. */ + * default card. Without the expand, or when no card is on file, the field + * is omitted entirely (verified against the live sandbox API: a customer + * with no card gets no `payment_method` key even when expanded). */ expand?: string[]; } @@ -183,9 +213,10 @@ export function serializeCustomer( customer: AutumnCustomer, options: SerializeCustomerOptions = {}, ): Record { - const expanded: Record = options.expand?.includes("payment_method") - ? { payment_method: customer.payment_method ?? null } - : {}; + const expanded: Record = + options.expand?.includes("payment_method") && customer.payment_method + ? { payment_method: customer.payment_method } + : {}; return { id: customer.customer_id, created_at: Date.parse(customer.created_at) || Date.now(), diff --git a/packages/@emulators/autumn/src/store.ts b/packages/@emulators/autumn/src/store.ts index 8f18cbcf6..c2aab854f 100644 --- a/packages/@emulators/autumn/src/store.ts +++ b/packages/@emulators/autumn/src/store.ts @@ -1,6 +1,13 @@ import { Store, type Collection } from "@emulators/core"; -import type { AutumnCustomer, AutumnTrackEvent, AutumnPlan, AutumnCheckout, AutumnSetupSession } from "./entities.js"; +import type { + AutumnCustomer, + AutumnTrackEvent, + AutumnPlan, + AutumnCheckout, + AutumnSetupSession, + AutumnPortalSession, +} from "./entities.js"; export interface AutumnStore { customers: Collection; @@ -8,6 +15,7 @@ export interface AutumnStore { plans: Collection; checkouts: Collection; setups: Collection; + portals: Collection; } export function getAutumnStore(store: Store): AutumnStore { @@ -17,5 +25,6 @@ export function getAutumnStore(store: Store): AutumnStore { plans: store.collection("autumn.plans", ["plan_id"]), checkouts: store.collection("autumn.checkouts", ["session_id", "customer_id"]), setups: store.collection("autumn.setups", ["session_id", "customer_id"]), + portals: store.collection("autumn.portals", ["customer_id"]), }; } diff --git a/skills/autumn/SKILL.md b/skills/autumn/SKILL.md index edc06ea78..33cd358b7 100644 --- a/skills/autumn/SKILL.md +++ b/skills/autumn/SKILL.md @@ -6,7 +6,7 @@ allowed-tools: Bash(npx emulate:*), Bash(curl:*) # Autumn Emulator -Stateful Autumn billing emulation: customers (get_or_create), seedable subscriptions, a seedable plan catalog with per-customer eligibility (`plans.list`), usage tracking (`balances.track`), balance reconciliation (`balances.update`), feature access checks (`balances.check`), `billing.attach` / `billing.setup_payment` / `billing.open_customer_portal`, a hosted checkout flow for paid plans and card-required free trials, and a hosted setup flow for changing the card on file. +Stateful Autumn billing emulation: customers (get_or_create), seedable subscriptions, a seedable plan catalog with per-customer eligibility (`plans.list`), usage tracking (`balances.track`), balance reconciliation (`balances.update`), feature access checks (`balances.check`), `billing.attach` / `billing.setup_payment` / `billing.open_customer_portal`, a hosted checkout flow for paid plans and card-required free trials, a hosted setup flow for putting a card on file, and a hosted billing portal for changing it. ## Start @@ -69,9 +69,15 @@ curl -X POST "$AUTUMN_EMULATOR_URL/checkout/settle" -H "Content-Type: applicatio This deferral lets a test reproduce the real "page is stale until reload" race: the redirect back lands before the subscription is active. -## Changing the card on file +## Putting a card on file -`billing.setupPayment({ customerId, successUrl })` returns `{ customer_id, url }`, where `url` is a hosted setup page (`GET /checkout/setup/:sessionId`). Submitting it (`POST /checkout/setup/:sessionId/complete` with `card_number` and `exp`) redirects to `success_url` but does NOT replace the default card yet: that lands with the webhook, so settle the session (`POST /checkout/setup/:sessionId/settle`, or the customer-wide `POST /checkout/settle`). +`billing.setupPayment({ customerId, successUrl })` returns `{ customer_id, url }`, where `url` is a hosted setup page (`GET /checkout/setup/:sessionId`). Submitting it (`POST /checkout/setup/:sessionId/complete` with `card_number` and `exp`) redirects to `success_url` but does NOT set the default card yet: that lands with the webhook, so settle the session (`POST /checkout/setup/:sessionId/settle`, or the customer-wide `POST /checkout/settle`). + +A setup session never REPLACES an existing default card. This mirrors real Autumn, whose setup-checkout webhook handler reads the customer's current default payment method first and re-sets that same card when one exists. So the second setup session for a customer settles normally and leaves the old card in place. Use the billing portal to change a card. + +## Changing the card on file (billing portal) + +`billing.openCustomerPortal({ customerId, returnUrl })` returns `{ customer_id, url }` pointing at `GET /checkout/portal/:customerId`. The page shows the current plan, the card on file (or "No payment method"), a form to update the card, and a link back to `return_url` when one was given. Submitting the form (`POST /checkout/portal/:customerId/payment-method` with `card_number` and `exp`) changes the customer's card IMMEDIATELY, with no settle step: real Stripe owns the portal and swaps the default inside Stripe, and Autumn reads the card live on every expand. An unknown customer 404s. Read the card back with `expand`: