Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 187 additions & 1 deletion apps/cloud/src/routes/app/billing.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,9 +20,135 @@ const PLAN_TAGLINES: Record<string, string> = {
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<string, string> = {
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<string | null | undefined>(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 (
Expand Down Expand Up @@ -112,6 +241,63 @@ function BillingPage() {
{/* Divider */}
<div className="h-px bg-border/50 my-2" />

{/* Payment method */}
<div className="flex items-center justify-between py-4">
<div>
<p className="text-sm font-medium text-foreground leading-none">Payment method</p>
<p className="mt-1 text-xs text-muted-foreground leading-none">
{cardUpdating ? (
<span className="inline-flex items-center gap-1.5">
<span className="size-3 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
Updating card…
</span>
) : card ? (
`${cardBrandLabel(card.brand)} ending in ${card.last4} · Expires ${String(card.expMonth).padStart(2, "0")}/${String(card.expYear).slice(-2)}`
) : (
"No card on file"
)}
</p>
</div>
<Button
variant="outline"
size="sm"
type="button"
disabled={openingCardForm || cardUpdating}
onClick={async () => {
trackEvent("billing_payment_method_update_clicked", { has_card: card != null });
setOpeningCardForm(true);
const returnTo = (marker: CardReturn) =>
`${window.location.origin}${window.location.pathname}?${CARD_RETURN_PARAM}=${marker}`;
// 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 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"
>
{openingCardForm ? "Loading…" : card ? "Update card" : "Add card"}
</Button>
</div>

{/* Divider */}
<div className="h-px bg-border/50 my-2" />

{/* Usage */}
{members && (
<div className="py-4">
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 130 additions & 0 deletions e2e/cloud/billing-payment-method-update.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}),
);
2 changes: 1 addition & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Loading
Loading