From 3491bf75a5c3d7fc5bc905acc71f171b939af25d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:02:52 -0700 Subject: [PATCH 1/5] Add payment method update to billing page --- apps/cloud/src/routes/app/billing.tsx | 160 +++++++++++++++++- .../billing-payment-method-update.test.ts | 106 ++++++++++++ e2e/src/surfaces/autumn.ts | 61 +++++++ packages/react/src/api/analytics.tsx | 1 + 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 e2e/cloud/billing-payment-method-update.test.ts diff --git a/apps/cloud/src/routes/app/billing.tsx b/apps/cloud/src/routes/app/billing.tsx index 6d6f303874..48985bdd2c 100644 --- a/apps/cloud/src/routes/app/billing.tsx +++ b/apps/cloud/src/routes/app/billing.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { useCustomer, useListPlans } from "autumn-js/react"; import { trackEvent } from "@executor-js/react/api/analytics"; @@ -17,9 +18,125 @@ const PLAN_TAGLINES: Record = { enterprise: "Custom enterprise agreement", }; +// Marker appended to the setup success URL so the page knows, on return, that +// it just came back from the hosted card form and should wait for the new card. +const CARD_RETURN_PARAM = "card"; + +/** 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. + * + * Like checkout (see billing_.plans.tsx), the browser is redirected back before + * Stripe's webhook reaches Autumn, so the first fetch on return still shows the + * previous card. On detecting the return 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. + */ +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); + if (params.get(CARD_RETURN_PARAM) !== "updated") return; + params.delete(CARD_RETURN_PARAM); + const query = params.toString(); + window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}`); + 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 +229,47 @@ 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/e2e/cloud/billing-payment-method-update.test.ts b/e2e/cloud/billing-payment-method-update.test.ts new file mode 100644 index 0000000000..5ba36b49ea --- /dev/null +++ b/e2e/cloud/billing-payment-method-update.test.ts @@ -0,0 +1,106 @@ +// Cloud-only (billing, browser): an organization can change the card it is +// billed on from the billing page, and the page shows the new 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) and +// "Update card" opens a hosted setup session (`billing.setup_payment`). As +// with checkout, the browser is redirected back BEFORE the provider's webhook +// swaps the default payment method, so the first fetch on return still shows +// the previous card. The page tags its return URL, shows the card as updating, +// and refetches until the new card reflects. +// +// The emulator models the race faithfully: completing the hosted setup form +// redirects back immediately but does NOT change the card; the swap lands only +// when the webhook settles (autumn.settleSetup), which this test triggers to +// control the exact moment the backend becomes consistent. +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 · updating the card shows the new 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 start updating the 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("Enter a new card and return to the billing page", async () => { + 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.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 swapped. + await Effect.runPromise(autumn.settleSetup(sessionId)); + + await step("The new card appears without a reload", async () => { + await paymentMethodRow.getByText("Mastercard ending in 4444").waitFor({ timeout: 15_000 }); + await paymentMethodRow.getByRole("button", { name: "Update card" }).waitFor(); + }); + }); + + 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/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 }; From b892e93356db4e13453eb3a8f730318b7049ddea Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:38:44 -0700 Subject: [PATCH 2/5] Change an existing card through the billing portal --- apps/cloud/src/routes/app/billing.tsx | 49 +++++++++----- .../billing-payment-method-update.test.ts | 64 +++++++++++++------ 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/apps/cloud/src/routes/app/billing.tsx b/apps/cloud/src/routes/app/billing.tsx index 48985bdd2c..e44f003f21 100644 --- a/apps/cloud/src/routes/app/billing.tsx +++ b/apps/cloud/src/routes/app/billing.tsx @@ -18,9 +18,13 @@ const PLAN_TAGLINES: Record = { enterprise: "Custom enterprise agreement", }; -// Marker appended to the setup success URL so the page knows, on return, that -// it just came back from the hosted card form and should wait for the new card. +// 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`). */ @@ -71,14 +75,15 @@ 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. + * Refresh the customer after returning from the hosted card form or the portal. * - * Like checkout (see billing_.plans.tsx), the browser is redirected back before - * Stripe's webhook reaches Autumn, so the first fetch on return still shows the - * previous card. On detecting the return 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. + * 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); @@ -92,10 +97,15 @@ function useRefreshAfterCardUpdate(card: CardOnFile | null, refetch: () => void) // poll keys off state rather than living in this effect). useEffect(() => { const params = new URLSearchParams(window.location.search); - if (params.get(CARD_RETURN_PARAM) !== "updated") return; + 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); }, []); @@ -254,11 +264,20 @@ function BillingPage() { onClick={async () => { trackEvent("billing_payment_method_update_clicked", { has_card: card != null }); setOpeningCardForm(true); - // Tag the return URL so the page waits for the new card when the - // hosted form redirects back (the webhook that swaps the default - // payment method lands moments after the redirect). - const successUrl = `${window.location.origin}${window.location.pathname}?${CARD_RETURN_PARAM}=updated`; - await setupPayment({ successUrl }); + const returnTo = (marker: CardReturn) => + `${window.location.origin}${window.location.pathname}?${CARD_RETURN_PARAM}=${marker}`; + if (card) { + // A setup session never REPLACES an existing default card at the + // provider (it only sets one when none is on file), so changing + // the card goes through the billing portal, where the user adds + // a card and makes it the default. + await openCustomerPortal({ returnUrl: returnTo("managed") }); + } else { + // No card yet: the hosted card form sets it as the default. Tag + // the return URL so the page waits for the card when the form + // redirects back (the webhook lands moments after the redirect). + await setupPayment({ successUrl: returnTo("added") }); + } setOpeningCardForm(false); }} className="text-xs" diff --git a/e2e/cloud/billing-payment-method-update.test.ts b/e2e/cloud/billing-payment-method-update.test.ts index 5ba36b49ea..1ee68ffbeb 100644 --- a/e2e/cloud/billing-payment-method-update.test.ts +++ b/e2e/cloud/billing-payment-method-update.test.ts @@ -1,19 +1,24 @@ -// Cloud-only (billing, browser): an organization can change the card it is -// billed on from the billing page, and the page shows the new card WITHOUT a -// manual reload. +// 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) and -// "Update card" opens a hosted setup session (`billing.setup_payment`). As -// with checkout, the browser is redirected back BEFORE the provider's webhook -// swaps the default payment method, so the first fetch on return still shows -// the previous card. The page tags its return URL, shows the card as updating, -// and refetches until the new card reflects. +// reads the customer's default payment method (`payment_method` expand). Two +// journeys, because the provider treats them differently (verified against the +// live sandbox API): // -// The emulator models the race faithfully: completing the hosted setup form -// redirects back immediately but does NOT change the card; the swap lands only -// when the webhook settles (autumn.settleSetup), which this test triggers to -// control the exact moment the backend becomes consistent. +// 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"; @@ -35,7 +40,7 @@ const orgIdOf = (bearer: string): string => { }; scenario( - "Billing · updating the card shows the new card without a reload", + "Billing · adding and changing the card shows the current card without a reload", { timeout: 120_000 }, Effect.gen(function* () { yield* Billing; @@ -57,7 +62,7 @@ scenario( .locator("xpath=ancestor::div[contains(@class,'justify-between')][1]"); let sessionId = ""; - await step("Open the billing page and start updating the card", async () => { + 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, "/"); @@ -74,9 +79,9 @@ scenario( expect(sessionId, "captured the setup session id").toMatch(/^seti_/); }); - await step("Enter a new card and return to the billing page", async () => { - await page.locator("input[name='card_number']").fill("5555 5555 5555 4444"); - await page.locator("input[name='exp']").fill("11/31"); + 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 @@ -86,12 +91,31 @@ scenario( await paymentMethodRow.getByText("Updating card").waitFor({ timeout: 10_000 }); }); - // The provider webhook reaches Autumn: the org's default card is swapped. + // 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 }); - await paymentMethodRow.getByRole("button", { name: "Update card" }).waitFor(); + expect( + await paymentMethodRow.getByText("Updating card").count(), + "no webhook wait for a portal change", + ).toBe(0); }); }); From c786d4d3217c12976691a24c039bc71f0cb29b78 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:49:28 -0700 Subject: [PATCH 3/5] Recover the card button when the payment form fails to open --- apps/cloud/src/routes/app/billing.tsx | 29 +++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/cloud/src/routes/app/billing.tsx b/apps/cloud/src/routes/app/billing.tsx index e44f003f21..08a7f1d547 100644 --- a/apps/cloud/src/routes/app/billing.tsx +++ b/apps/cloud/src/routes/app/billing.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { useCustomer, useListPlans } from "autumn-js/react"; +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"; @@ -266,19 +267,21 @@ function BillingPage() { setOpeningCardForm(true); const returnTo = (marker: CardReturn) => `${window.location.origin}${window.location.pathname}?${CARD_RETURN_PARAM}=${marker}`; - if (card) { - // A setup session never REPLACES an existing default card at the - // provider (it only sets one when none is on file), so changing - // the card goes through the billing portal, where the user adds - // a card and makes it the default. - await openCustomerPortal({ returnUrl: returnTo("managed") }); - } else { - // No card yet: the hosted card form sets it as the default. Tag - // the return URL so the page waits for the card when the form - // redirects back (the webhook lands moments after the redirect). - await setupPayment({ successUrl: returnTo("added") }); - } - setOpeningCardForm(false); + // A setup session never REPLACES an existing default card at the + // provider (it only sets one when none is on file), so changing + // the card goes through the billing portal, where the user adds a + // card and makes it the default. With no card yet, the hosted card + // form sets it; its return URL is tagged so the page waits for the + // card when the form redirects back (the webhook lands moments + // after the redirect). Either call redirects the page on success; + // on failure the button must come back rather than sit on + // "Loading…" forever. + const open = card + ? openCustomerPortal({ returnUrl: returnTo("managed") }) + : setupPayment({ successUrl: returnTo("added") }); + await open + .catch(() => toast.error("Could not open the payment form. Try again.")) + .finally(() => setOpeningCardForm(false)); }} className="text-xs" > From 10782d8570da112e69e099dadbc22bf91ecc8479 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:03:10 -0700 Subject: [PATCH 4/5] Bump @executor-js/emulate to 0.14.2 --- bun.lock | 4 ++-- e2e/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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:*", From 1646e6ae0c4e41121816d7401017408df645790c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:08:26 -0700 Subject: [PATCH 5/5] Handle payment form failures with Effect --- apps/cloud/src/routes/app/billing.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/cloud/src/routes/app/billing.tsx b/apps/cloud/src/routes/app/billing.tsx index 08a7f1d547..6515982042 100644 --- a/apps/cloud/src/routes/app/billing.tsx +++ b/apps/cloud/src/routes/app/billing.tsx @@ -1,6 +1,7 @@ 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"; @@ -276,12 +277,17 @@ function BillingPage() { // after the redirect). Either call redirects the page on success; // on failure the button must come back rather than sit on // "Loading…" forever. - const open = card - ? openCustomerPortal({ returnUrl: returnTo("managed") }) - : setupPayment({ successUrl: returnTo("added") }); - await open - .catch(() => toast.error("Could not open the payment form. Try again.")) - .finally(() => setOpeningCardForm(false)); + const exit = await Effect.runPromiseExit( + Effect.tryPromise(() => + card + ? openCustomerPortal({ returnUrl: returnTo("managed") }) + : setupPayment({ successUrl: returnTo("added") }), + ), + ); + if (Exit.isFailure(exit)) { + toast.error("Could not open the payment form. Try again."); + } + setOpeningCardForm(false); }} className="text-xs" >