Skip to content

Commit 15cf78e

Browse files
authored
fix(billing): refresh plan after returning from checkout (#1215)
* fix(billing): refresh plan after returning from checkout After completing a redirect checkout (e.g. the Team free trial), the browser is sent back to the plans page before Stripe's checkout.session.completed webhook reaches Autumn. autumn-js fetches the customer once on load and does not refetch on its own (staleTime 60s, refetchOnWindowFocus off), so the page showed the old plan and the upgrade CTA until a manual reload. Tag the checkout success URL with the attached plan id and, on return, poll the billing data until that plan shows active (or a timeout), then strip the marker. Also fix the billing proxy passing the wrong key to autumn-js: the handler reads clientOptions.baseURL, not serverURL, so AUTUMN_API_URL (the e2e emulator or a self-hosted Autumn) was silently dropped and every billing request hit production Autumn and 401'd. Adds a cloud e2e scenario that drives the full trial-checkout flow against the Autumn emulator and asserts the plan updates without a reload, plus the plan catalog seed the billing UI needs. * test(e2e): consume @executor-js/emulate 0.9.0 for the trial-checkout scenario * fix(billing): show purchased plan as activating on checkout return Instead of refetching into the stale upgrade CTA (which flashes 'Start free trial' on a plan the user just paid for), the page reads the purchased plan from the return marker and shows it as Activating while it polls, resolving to the active plan once the webhook lands. No manual reload, no stale state.
1 parent 6748810 commit 15cf78e

8 files changed

Lines changed: 278 additions & 8 deletions

File tree

apps/cloud/src/extensions/billing/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ const handler = Effect.gen(function* () {
8686
},
8787
clientOptions: {
8888
secretKey: env.AUTUMN_SECRET_KEY ?? "",
89-
...(env.AUTUMN_API_URL ? { serverURL: env.AUTUMN_API_URL } : {}),
89+
// autumn-js's handler reads `baseURL` to override the Autumn endpoint
90+
// (not `serverURL`, which it silently ignores). Without this, a non-prod
91+
// AUTUMN_API_URL (the e2e emulator, a self-hosted Autumn) is dropped and
92+
// every billing request goes to production Autumn and 401s.
93+
...(env.AUTUMN_API_URL ? { baseURL: env.AUTUMN_API_URL } : {}),
9094
},
9195
pathPrefix: "/api/billing",
9296
}),

apps/cloud/src/routes/app/billing_.plans.tsx

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useCallback, useEffect, useRef, useState } from "react";
22
import { createFileRoute, Link } from "@tanstack/react-router";
33
import { useCustomer, useListPlans } from "autumn-js/react";
44
import { trackEvent } from "@executor-js/react/api/analytics";
@@ -21,6 +21,75 @@ export const Route = createFileRoute("/{-$orgSlug}/billing_/plans")({
2121
component: PlansPage,
2222
});
2323

24+
// Marker appended to the checkout success URL so the page knows, on return, that
25+
// it just came back from Stripe and which plan was attached.
26+
const CHECKOUT_RETURN_PARAM = "checkout";
27+
28+
/**
29+
* Refresh billing data after returning from a redirect checkout.
30+
*
31+
* autumn-js fetches the customer once on load and does not refetch on its own
32+
* (staleTime 60s, no refetch on focus), while Stripe's `checkout.session.completed`
33+
* webhook reaches Autumn moments AFTER the browser is redirected back. So the
34+
* single fetch on the success page sees the pre-checkout plan and the page would
35+
* otherwise show the old plan (and the upgrade CTA) until a manual reload.
36+
*
37+
* On detecting the return marker, poll the billing data until the attached plan
38+
* shows as active (or a timeout). While that reconciliation is in flight this
39+
* returns the attached plan id so the page can show that plan as "Activating"
40+
* rather than the pre-checkout upgrade CTA, which would otherwise read as if the
41+
* purchase did not happen. The marker is stripped immediately so a later manual
42+
* reload does not re-arm the poll.
43+
*
44+
* @returns the plan id being finalized, or null once it reflects (or times out).
45+
*/
46+
function useRefreshAfterCheckout(plans: Plan[] | undefined, refetch: () => void): string | null {
47+
const [finalizingPlan, setFinalizingPlan] = useState<string | null>(null);
48+
const plansRef = useRef(plans);
49+
plansRef.current = plans;
50+
51+
useEffect(() => {
52+
const params = new URLSearchParams(window.location.search);
53+
const attachedPlanId = params.get(CHECKOUT_RETURN_PARAM);
54+
if (!attachedPlanId) return;
55+
56+
params.delete(CHECKOUT_RETURN_PARAM);
57+
const query = params.toString();
58+
window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}`);
59+
setFinalizingPlan(attachedPlanId);
60+
61+
const reflected = () =>
62+
plansRef.current?.find((p) => p.id === attachedPlanId)?.customerEligibility?.status ===
63+
"active";
64+
65+
let elapsed = 0;
66+
refetch();
67+
const interval = setInterval(() => {
68+
elapsed += 1500;
69+
if (reflected() || elapsed >= 20_000) {
70+
clearInterval(interval);
71+
setFinalizingPlan(null);
72+
return;
73+
}
74+
refetch();
75+
}, 1500);
76+
return () => clearInterval(interval);
77+
}, [refetch]);
78+
79+
// Drop the optimistic state the moment the refetched data reflects the plan,
80+
// so it does not linger until the next poll tick after the webhook lands.
81+
useEffect(() => {
82+
if (
83+
finalizingPlan &&
84+
plans?.find((p) => p.id === finalizingPlan)?.customerEligibility?.status === "active"
85+
) {
86+
setFinalizingPlan(null);
87+
}
88+
}, [finalizingPlan, plans]);
89+
90+
return finalizingPlan;
91+
}
92+
2493
const ENTERPRISE_FEATURES = [
2594
"Self-hosted or dedicated cloud deployment support",
2695
"SSO / SAML & SCIM provisioning",
@@ -67,10 +136,26 @@ const ACTION_LABELS: Record<string, string> = {
67136
const PLAN_ORDER = ["free", "team", "enterprise"];
68137

69138
function PlansPage() {
70-
const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer();
71-
const { data: plans, isLoading: plansLoading, isFetching } = useListPlans();
139+
const {
140+
attach,
141+
openCustomerPortal,
142+
isLoading: customerLoading,
143+
refetch: refetchCustomer,
144+
} = useCustomer();
145+
const {
146+
data: plans,
147+
isLoading: plansLoading,
148+
isFetching,
149+
refetch: refetchPlans,
150+
} = useListPlans();
72151
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
73152

153+
const refetchBilling = useCallback(() => {
154+
void refetchCustomer();
155+
void refetchPlans();
156+
}, [refetchCustomer, refetchPlans]);
157+
const finalizingPlan = useRefreshAfterCheckout(plans, refetchBilling);
158+
74159
const isLoading = customerLoading || plansLoading;
75160

76161
const selfServePlans = PLAN_ORDER.flatMap((id) =>
@@ -129,6 +214,9 @@ function PlansPage() {
129214
const isScheduled = status === "scheduled";
130215
const isUpgradeAction = action === "upgrade" || action === "activate";
131216
const isEnterprise = plan.id === "enterprise";
217+
// Just back from checkout for this plan and the webhook has not
218+
// landed yet: show it as activating instead of the stale CTA.
219+
const isFinalizing = plan.id === finalizingPlan && !isCurrent && !isScheduled;
132220
// Offer the trial only when the plan defines one and this customer
133221
// is still eligible (trialAvailable is false once they've used it).
134222
const freeTrial = plan.freeTrial;
@@ -159,6 +247,9 @@ function PlansPage() {
159247
{plan.name}
160248
</p>
161249
{isCurrent && <Badge className="bg-muted text-foreground">Your plan</Badge>}
250+
{isFinalizing && (
251+
<Badge className="bg-primary/10 text-primary">Activating</Badge>
252+
)}
162253
{isCanceling && (
163254
<Badge className="bg-muted text-muted-foreground">Canceling</Badge>
164255
)}
@@ -185,6 +276,11 @@ function PlansPage() {
185276
<div className="flex h-9 items-center justify-center rounded-md border border-border bg-muted/30 text-sm font-medium text-muted-foreground">
186277
{isCurrent ? "Current plan" : "Scheduled"}
187278
</div>
279+
) : isFinalizing ? (
280+
<div className="flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-sm font-medium text-muted-foreground">
281+
<span className="size-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
282+
Activating…
283+
</div>
188284
) : isEnterprise ? (
189285
<EnterpriseContactDialog />
190286
) : isCanceling ? (
@@ -215,7 +311,11 @@ function PlansPage() {
215311
});
216312
}
217313
setLoadingPlan(plan.id);
218-
await attach({ planId: plan.id, redirectMode: "always" });
314+
// Tag the return URL so the page refetches billing
315+
// data when Stripe redirects back (the webhook that
316+
// activates the plan lands moments after the redirect).
317+
const successUrl = `${window.location.origin}${window.location.pathname}?${CHECKOUT_RETURN_PARAM}=${plan.id}`;
318+
await attach({ planId: plan.id, redirectMode: "always", successUrl });
219319
setLoadingPlan(null);
220320
}}
221321
className={[

bun.lock

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Cloud-only (billing, browser): completing the Team free-trial checkout should
2+
// leave the billing page showing the new plan WITHOUT a manual reload, and
3+
// without ever flashing the stale upgrade CTA.
4+
//
5+
// Guards a reported bug + its fix: a user starts the free trial, completes
6+
// Stripe checkout, and is redirected back to the plans page. The redirect lands
7+
// before Autumn has processed Stripe's webhook, and autumn-js fetches the
8+
// customer once on load (staleTime 60s, refetchOnWindowFocus off), so the page
9+
// used to show the old plan and the "Start free trial" CTA until a manual
10+
// reload. The fix tags the checkout return URL with the purchased plan, then on
11+
// return shows that plan as "Activating" while it refetches until the webhook
12+
// lands, resolving to "Your plan" with no reload.
13+
//
14+
// The emulator models the race faithfully: completing the hosted checkout
15+
// redirects back immediately but does NOT activate the subscription; activation
16+
// lands only when the webhook settles (autumn.settleCheckout), which this test
17+
// triggers to control the exact moment the backend becomes consistent.
18+
import { expect } from "@effect/vitest";
19+
import { Effect } from "effect";
20+
21+
import { scenario } from "../src/scenario";
22+
import { Autumn, Billing, Browser, Target } from "../src/services";
23+
24+
scenario(
25+
"Billing · completing the trial checkout shows the new plan without a reload",
26+
{ timeout: 120_000 },
27+
Effect.gen(function* () {
28+
// Gates: billing enforced here AND the Autumn emulator is observable (so we
29+
// can land the checkout webhook). Yield before any work so a target missing
30+
// either capability skips cleanly rather than failing.
31+
yield* Billing;
32+
const autumn = yield* Autumn;
33+
const target = yield* Target;
34+
const browser = yield* Browser;
35+
36+
const identity = yield* target.newIdentity();
37+
38+
yield* browser.session(identity, async ({ page, step }) => {
39+
const teamCard = page
40+
.getByText("Team", { exact: true })
41+
.locator("xpath=ancestor::div[contains(@class,'rounded-xl')][1]");
42+
const startTrial = teamCard.getByRole("button", { name: "Start free trial" });
43+
44+
await step("A fresh org is offered the Team free trial", async () => {
45+
// Billing requests are org-scoped via the URL slug header, so reach the
46+
// plans page through the org-scoped URL (a bare /billing/plans would fire
47+
// the first fetch before the slug resolves and 401). Land on "/" to
48+
// canonicalize, then open the slug-scoped plans page.
49+
await page.goto("/", { waitUntil: "networkidle" });
50+
const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0];
51+
await page.goto(`/${slug}/billing/plans`, { waitUntil: "networkidle" });
52+
await page.getByRole("heading", { name: "Choose a plan" }).waitFor();
53+
await startTrial.waitFor();
54+
});
55+
56+
let sessionId = "";
57+
await step("Start the trial and land on the hosted checkout", async () => {
58+
await startTrial.click();
59+
// attach() redirects the whole page to the checkout URL.
60+
await page.waitForURL(/\/checkout\//, { timeout: 30_000 });
61+
sessionId = new URL(page.url()).pathname.split("/").filter(Boolean).pop() ?? "";
62+
expect(sessionId, "captured the checkout session id").toMatch(/^cs_/);
63+
await page.locator("button.checkout-pay-btn").waitFor();
64+
});
65+
66+
await step("Complete checkout and return to the plans page", async () => {
67+
await page.locator("button.checkout-pay-btn").click();
68+
await page.waitForURL(/billing\/plans/, { timeout: 30_000 });
69+
// The webhook has NOT landed yet, but the page knows from the return
70+
// marker which plan was purchased, so it shows that plan as activating
71+
// rather than the stale upgrade CTA (which would read as if nothing
72+
// happened). This is the key user-facing guarantee.
73+
await teamCard.getByText("Activating", { exact: true }).waitFor({ timeout: 10_000 });
74+
expect(await startTrial.count(), "the stale trial CTA is not shown on return").toBe(0);
75+
});
76+
77+
// The Stripe webhook reaches Autumn: the customer is now on the Team trial.
78+
await Effect.runPromise(autumn.settleCheckout(sessionId));
79+
80+
// Without any reload, the activating state resolves to the active plan as
81+
// the polled refetch picks up the now-consistent backend.
82+
await step("The plan resolves to active without a reload", async () => {
83+
await teamCard.getByText("Current plan").waitFor({ timeout: 15_000 });
84+
await teamCard.getByText("Your plan").waitFor({ timeout: 5_000 });
85+
});
86+
expect(await startTrial.count(), "the upgrade CTA never returns").toBe(0);
87+
});
88+
}),
89+
);

e2e/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
},
2323
"dependencies": {
2424
"@executor-js/api": "workspace:*",
25-
"@executor-js/emulate": "^0.8.1",
25+
"@executor-js/emulate": "^0.9.0",
2626
"@executor-js/mcporter": "^0.11.4",
2727
"@executor-js/plugin-graphql": "workspace:*",
2828
"@executor-js/plugin-mcp": "workspace:*",

e2e/setup/autumn-plans.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// The plan catalog the Autumn emulator advertises, derived from the deploy-time
2+
// source of truth (repo-root `autumn.config.ts`). In production Autumn learns
3+
// these plans via `atmn` sync; the emulator has no such sync, so the e2e boot
4+
// seeds them. Keeping this derived from autumn.config.ts means plans, prices,
5+
// and the Team free trial can never drift from what the app actually ships.
6+
import { free, team, enterprise } from "../../autumn.config";
7+
8+
type AtmnPlan = {
9+
id: string;
10+
name: string;
11+
addOn?: boolean;
12+
autoEnable?: boolean;
13+
price?: { amount: number; interval: string };
14+
freeTrial?: { durationLength: number; durationType: string; cardRequired?: boolean };
15+
items?: ReadonlyArray<{ featureId: string; included?: number; unlimited?: boolean }>;
16+
};
17+
18+
export interface AutumnSeedPlan {
19+
id: string;
20+
name: string;
21+
add_on: boolean;
22+
auto_enable: boolean;
23+
price: { amount: number; interval: string } | null;
24+
free_trial: { duration_length: number; duration_type: string; card_required: boolean } | null;
25+
items: Array<{ feature_id: string; included?: number; unlimited?: boolean }>;
26+
}
27+
28+
const toSeed = (plan: AtmnPlan): AutumnSeedPlan => ({
29+
id: plan.id,
30+
name: plan.name,
31+
add_on: plan.addOn ?? false,
32+
auto_enable: plan.autoEnable ?? false,
33+
price: plan.price ? { amount: plan.price.amount, interval: plan.price.interval } : null,
34+
free_trial: plan.freeTrial
35+
? {
36+
duration_length: plan.freeTrial.durationLength,
37+
duration_type: plan.freeTrial.durationType,
38+
card_required: plan.freeTrial.cardRequired ?? false,
39+
}
40+
: null,
41+
items: (plan.items ?? []).map((it) => ({
42+
feature_id: it.featureId,
43+
included: it.included,
44+
unlimited: it.unlimited,
45+
})),
46+
});
47+
48+
/** Free, Team (card-required 14-day trial), and Enterprise, in display order. */
49+
export const AUTUMN_PLAN_SEED: AutumnSeedPlan[] = [free, team, enterprise].map((p) =>
50+
toSeed(p as AtmnPlan),
51+
);

e2e/setup/cloud.boot.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
1111
import { createEmulator } from "@executor-js/emulate";
1212

1313
import { bootProcesses, waitForHttp } from "./boot";
14+
import { AUTUMN_PLAN_SEED } from "./autumn-plans";
1415

1516
export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));
1617

@@ -65,6 +66,9 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted>
6566
const autumn = await createEmulator({
6667
service: "autumn",
6768
port: options.autumnPort,
69+
// Seed the plan catalog so the billing UI (plans, eligibility, trial
70+
// checkout) has real plans to render. Derived from autumn.config.ts.
71+
seed: { autumn: { plans: AUTUMN_PLAN_SEED } },
6872
});
6973

7074
const workosUrl = options.workosPublicUrl ?? workos.url;

0 commit comments

Comments
 (0)