diff --git a/apps/cloud/src/routes/app/billing.tsx b/apps/cloud/src/routes/app/billing.tsx index 6d6f303874..6515982042 100644 --- a/apps/cloud/src/routes/app/billing.tsx +++ b/apps/cloud/src/routes/app/billing.tsx @@ -1,5 +1,8 @@ +import { useEffect, useRef, useState } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { useCustomer, useListPlans } from "autumn-js/react"; +import { Effect, Exit } from "effect"; +import { toast } from "sonner"; import { trackEvent } from "@executor-js/react/api/analytics"; import { Button } from "@executor-js/react/components/button"; import { Badge } from "@executor-js/react/components/badge"; @@ -17,9 +20,135 @@ const PLAN_TAGLINES: Record = { enterprise: "Custom enterprise agreement", }; +// Marker appended to the return URL so the page knows, on return, where it just +// came back from. `added`: the hosted card form (setup session) — the card only +// lands once the provider's webhook is processed, so wait for it. `managed`: +// the billing portal — the provider reads the default card live, so one +// refetch reflects whatever the user did there. +const CARD_RETURN_PARAM = "card"; +type CardReturn = "added" | "managed"; + +/** The card Autumn reports as the customer's default payment method (the + * Stripe PaymentMethod object, expanded via `payment_method`). */ +type CardOnFile = { + readonly id: string; + readonly brand: string; + readonly last4: string; + readonly expMonth: number; + readonly expYear: number; +}; + +const CARD_BRANDS: Record = { + visa: "Visa", + mastercard: "Mastercard", + amex: "American Express", + discover: "Discover", + diners: "Diners Club", + jcb: "JCB", + unionpay: "UnionPay", +}; + +const cardOnFile = (paymentMethod: unknown): CardOnFile | null => { + if (typeof paymentMethod !== "object" || paymentMethod === null) return null; + const pm = paymentMethod as { id?: unknown; card?: unknown }; + if (typeof pm.id !== "string" || typeof pm.card !== "object" || pm.card === null) return null; + const card = pm.card as { + brand?: unknown; + last4?: unknown; + exp_month?: unknown; + exp_year?: unknown; + expMonth?: unknown; + expYear?: unknown; + }; + const expMonth = card.expMonth ?? card.exp_month; + const expYear = card.expYear ?? card.exp_year; + if ( + typeof card.brand !== "string" || + typeof card.last4 !== "string" || + typeof expMonth !== "number" || + typeof expYear !== "number" + ) { + return null; + } + return { id: pm.id, brand: card.brand, last4: card.last4, expMonth, expYear }; +}; + +const cardBrandLabel = (brand: string): string => + CARD_BRANDS[brand] ?? (brand ? brand.charAt(0).toUpperCase() + brand.slice(1) : "Card"); + +/** + * Refresh the customer after returning from the hosted card form or the portal. + * + * Like checkout (see billing_.plans.tsx), the browser is redirected back from + * the card form before Stripe's webhook reaches Autumn, so the first fetch on + * return still shows no card. On detecting the `added` marker, poll until the + * default payment method differs from the one we came back with (or a + * timeout). Returns true while that reconciliation is in flight so the page can + * show the card as updating rather than the stale one. The `managed` marker + * (portal) has no race: a single refetch is enough. + */ +function useRefreshAfterCardUpdate(card: CardOnFile | null, refetch: () => void): boolean { + const [previousCardId, setPreviousCardId] = useState(undefined); + const cardRef = useRef(card); + cardRef.current = card; + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + const armedAtRef = useRef(0); + + // One-shot: consume the URL marker into state (see the plans page for why the + // poll keys off state rather than living in this effect). + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const returned = params.get(CARD_RETURN_PARAM) as CardReturn | null; + if (returned !== "added" && returned !== "managed") return; + params.delete(CARD_RETURN_PARAM); + const query = params.toString(); + window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}`); + if (returned === "managed") { + refetchRef.current(); + return; + } + armedAtRef.current = Date.now(); + setPreviousCardId(cardRef.current?.id ?? null); + }, []); + + useEffect(() => { + if (previousCardId === undefined) return; + const reflected = () => (cardRef.current?.id ?? null) !== previousCardId; + + refetchRef.current(); + const interval = setInterval(() => { + if (reflected() || Date.now() - armedAtRef.current >= 20_000) { + clearInterval(interval); + setPreviousCardId(undefined); + return; + } + refetchRef.current(); + }, 1500); + return () => clearInterval(interval); + }, [previousCardId]); + + useEffect(() => { + if (previousCardId !== undefined && (card?.id ?? null) !== previousCardId) { + setPreviousCardId(undefined); + } + }, [previousCardId, card]); + + return previousCardId !== undefined; +} + function BillingPage() { - const { data: customer, openCustomerPortal, isLoading: customerLoading } = useCustomer(); + const { + data: customer, + openCustomerPortal, + setupPayment, + refetch: refetchCustomer, + isLoading: customerLoading, + } = useCustomer({ expand: ["payment_method"] }); const { data: plans, isLoading: plansLoading } = useListPlans(); + const card = cardOnFile(customer?.paymentMethod); + const cardUpdating = useRefreshAfterCardUpdate(card, refetchCustomer); + const [openingCardForm, setOpeningCardForm] = useState(false); if (customerLoading || plansLoading) { return ( @@ -112,6 +241,63 @@ function BillingPage() { {/* Divider */}
+ {/* Payment method */} +
+
+

Payment method

+

+ {cardUpdating ? ( + + + Updating card… + + ) : card ? ( + `${cardBrandLabel(card.brand)} ending in ${card.last4} · Expires ${String(card.expMonth).padStart(2, "0")}/${String(card.expYear).slice(-2)}` + ) : ( + "No card on file" + )} +

+
+ +
+ + {/* Divider */} +
+ {/* Usage */} {members && (
diff --git a/bun.lock b/bun.lock index 473028d7be..d79d6f4009 100644 --- a/bun.lock +++ b/bun.lock @@ -356,7 +356,7 @@ "version": "0.0.48", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.1", + "@executor-js/emulate": "^0.14.2", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -1779,7 +1779,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.14.1", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-MO72WgLgjyOJnWEiL8DrT6jAVs8C2VDz9oGUFkZ/x2GciEkiOkYG67u5/o+nqtPoxJTR06cqpkHjfrzCm1dqww=="], + "@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], diff --git a/e2e/cloud/billing-payment-method-update.test.ts b/e2e/cloud/billing-payment-method-update.test.ts new file mode 100644 index 0000000000..1ee68ffbeb --- /dev/null +++ b/e2e/cloud/billing-payment-method-update.test.ts @@ -0,0 +1,130 @@ +// 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, + }); + }), +); diff --git a/e2e/package.json b/e2e/package.json index 6ddd66d3f3..d605fedf77 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.1", + "@executor-js/emulate": "^0.14.2", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/e2e/src/surfaces/autumn.ts b/e2e/src/surfaces/autumn.ts index 05a99bafd7..23f8ed4a3e 100644 --- a/e2e/src/surfaces/autumn.ts +++ b/e2e/src/surfaces/autumn.ts @@ -78,6 +78,21 @@ export interface AutumnSurface { * the billing backend becomes consistent. The `sessionId` is the last path * segment of the hosted checkout URL the browser was sent to. */ readonly settleCheckout: (sessionId: string) => Effect.Effect; + /** Land the asynchronous webhook for a hosted card-update (setup) session, + * replacing the customer's default payment method. Same race as + * `settleCheckout`: the browser is redirected back first, the card only + * changes once this is called. The `sessionId` is the last path segment of + * the hosted setup URL the browser was sent to. */ + readonly settleSetup: (sessionId: string) => Effect.Effect; + /** The customer's default payment method as Autumn holds it (the + * `payment_method` expand on `customers.get_or_create`), or null when no + * card is on file. */ + readonly paymentMethod: ( + customerId: string, + ) => Effect.Effect< + { brand: string; last4: string; expMonth: number; expYear: number } | null, + unknown + >; /** Burn an org's entire remaining "executions" balance in one `balances.track`, * so the next `balances.check` reports `allowed: false`. The amount is the * default plan's included allotment (read from the plan seed, never hardcoded), @@ -182,6 +197,50 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { } }); + const settleSetup = (sessionId: string) => + Effect.gen(function* () { + const response = yield* Effect.promise(() => + fetch(`${autumnUrl}/checkout/setup/${encodeURIComponent(sessionId)}/settle`, { + method: "POST", + }), + ); + if (!response.ok) { + return yield* Effect.fail( + `autumn setup settle responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, + ); + } + }); + + const paymentMethod = (customerId: string) => + Effect.gen(function* () { + const response = yield* Effect.promise(() => + fetch(`${autumnUrl}/v1/customers.get_or_create`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ customer_id: customerId, expand: ["payment_method"] }), + }), + ); + if (!response.ok) { + return yield* Effect.fail( + `autumn customers.get_or_create responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, + ); + } + const body = (yield* Effect.promise(() => response.json())) as { + readonly payment_method?: { + readonly card?: { + readonly brand: string; + readonly last4: string; + readonly exp_month: number; + readonly exp_year: number; + }; + } | null; + }; + const card = body.payment_method?.card; + return card + ? { brand: card.brand, last4: card.last4, expMonth: card.exp_month, expYear: card.exp_year } + : null; + }); + // Track exactly the plan's included allotment in one event, driving // `remaining` (included - usage) to zero so the next check reports blocked. const exhaustExecutions = (customerId: string) => @@ -328,6 +387,8 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { usageEvents, customerIds, settleCheckout, + settleSetup, + paymentMethod, exhaustExecutions, attachPlan, armFault, diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 465e7b88c3..0d912b3844 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -196,6 +196,7 @@ export interface AnalyticsEvents { }; billing_manage_opened: {}; billing_cancel_plan_clicked: { plan_id: string }; + billing_payment_method_update_clicked: { has_card: boolean }; support_opened: {}; support_link_clicked: { label: string }; org_domain_added: { success: boolean };