Skip to content

Commit b121d83

Browse files
committed
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.
1 parent 529b908 commit b121d83

6 files changed

Lines changed: 250 additions & 5 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: 72 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,54 @@ 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), then strip the marker so a later manual reload
39+
* does not re-arm the poll.
40+
*/
41+
function useRefreshAfterCheckout(plans: Plan[] | undefined, refetch: () => void): void {
42+
const plansRef = useRef(plans);
43+
plansRef.current = plans;
44+
45+
useEffect(() => {
46+
const params = new URLSearchParams(window.location.search);
47+
const attachedPlanId = params.get(CHECKOUT_RETURN_PARAM);
48+
if (!attachedPlanId) return;
49+
50+
params.delete(CHECKOUT_RETURN_PARAM);
51+
const query = params.toString();
52+
window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}`);
53+
54+
const reflected = () =>
55+
plansRef.current?.find((p) => p.id === attachedPlanId)?.customerEligibility?.status ===
56+
"active";
57+
58+
let elapsed = 0;
59+
refetch();
60+
const interval = setInterval(() => {
61+
elapsed += 1500;
62+
if (reflected() || elapsed >= 20_000) {
63+
clearInterval(interval);
64+
return;
65+
}
66+
refetch();
67+
}, 1500);
68+
return () => clearInterval(interval);
69+
}, [refetch]);
70+
}
71+
2472
const ENTERPRISE_FEATURES = [
2573
"Self-hosted or dedicated cloud deployment support",
2674
"SSO / SAML & SCIM provisioning",
@@ -67,10 +115,26 @@ const ACTION_LABELS: Record<string, string> = {
67115
const PLAN_ORDER = ["free", "team", "enterprise"];
68116

69117
function PlansPage() {
70-
const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer();
71-
const { data: plans, isLoading: plansLoading, isFetching } = useListPlans();
118+
const {
119+
attach,
120+
openCustomerPortal,
121+
isLoading: customerLoading,
122+
refetch: refetchCustomer,
123+
} = useCustomer();
124+
const {
125+
data: plans,
126+
isLoading: plansLoading,
127+
isFetching,
128+
refetch: refetchPlans,
129+
} = useListPlans();
72130
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
73131

132+
const refetchBilling = useCallback(() => {
133+
void refetchCustomer();
134+
void refetchPlans();
135+
}, [refetchCustomer, refetchPlans]);
136+
useRefreshAfterCheckout(plans, refetchBilling);
137+
74138
const isLoading = customerLoading || plansLoading;
75139

76140
const selfServePlans = PLAN_ORDER.flatMap((id) =>
@@ -215,7 +279,11 @@ function PlansPage() {
215279
});
216280
}
217281
setLoadingPlan(plan.id);
218-
await attach({ planId: plan.id, redirectMode: "always" });
282+
// Tag the return URL so the page refetches billing
283+
// data when Stripe redirects back (the webhook that
284+
// activates the plan lands moments after the redirect).
285+
const successUrl = `${window.location.origin}${window.location.pathname}?${CHECKOUT_RETURN_PARAM}=${plan.id}`;
286+
await attach({ planId: plan.id, redirectMode: "always", successUrl });
219287
setLoadingPlan(null);
220288
}}
221289
className={[
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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.
3+
//
4+
// Repro of a reported bug: a user starts the free trial, completes Stripe
5+
// checkout, and is redirected back to the plans page, which STILL shows the
6+
// upgrade/"Start free trial" call to action. A manual reload then shows the
7+
// active trial. The cause is client-side: autumn-js fetches the customer once
8+
// on load (staleTime 60s, refetchOnWindowFocus off) and the redirect back from
9+
// Stripe lands before Autumn has processed Stripe's webhook, so that single
10+
// fetch sees the old plan and the page never refetches on its own.
11+
//
12+
// The emulator models this faithfully: completing the hosted checkout redirects
13+
// back immediately but does NOT activate the subscription; the activation lands
14+
// only when the webhook settles (autumn.settleCheckout). The reload control
15+
// proves the backend is consistent, isolating the failure to the stale client.
16+
import { expect } from "@effect/vitest";
17+
import { Effect } from "effect";
18+
19+
import { scenario } from "../src/scenario";
20+
import { Autumn, Billing, Browser, Target } from "../src/services";
21+
22+
scenario(
23+
"Billing · completing the trial checkout shows the new plan without a reload",
24+
{ timeout: 120_000 },
25+
Effect.gen(function* () {
26+
// Gates: billing enforced here AND the Autumn emulator is observable (so we
27+
// can land the checkout webhook). Yield before any work so a target missing
28+
// either capability skips cleanly rather than failing.
29+
yield* Billing;
30+
const autumn = yield* Autumn;
31+
const target = yield* Target;
32+
const browser = yield* Browser;
33+
34+
const identity = yield* target.newIdentity();
35+
36+
yield* browser.session(identity, async ({ page, step }) => {
37+
const teamCard = page
38+
.getByText("Team", { exact: true })
39+
.locator("xpath=ancestor::div[contains(@class,'rounded-xl')][1]");
40+
const startTrial = teamCard.getByRole("button", { name: "Start free trial" });
41+
42+
await step("A fresh org is offered the Team free trial", async () => {
43+
// Billing requests are org-scoped via the URL slug header, so reach the
44+
// plans page through the org-scoped URL (a bare /billing/plans would fire
45+
// the first fetch before the slug resolves and 401). Land on "/" to
46+
// canonicalize, then open the slug-scoped plans page.
47+
await page.goto("/", { waitUntil: "networkidle" });
48+
const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0];
49+
await page.goto(`/${slug}/billing/plans`, { waitUntil: "networkidle" });
50+
await page.getByRole("heading", { name: "Choose a plan" }).waitFor();
51+
await startTrial.waitFor();
52+
});
53+
54+
let sessionId = "";
55+
await step("Start the trial and land on the hosted checkout", async () => {
56+
await startTrial.click();
57+
// attach() redirects the whole page to the checkout URL.
58+
await page.waitForURL(/\/checkout\//, { timeout: 30_000 });
59+
sessionId = new URL(page.url()).pathname.split("/").filter(Boolean).pop() ?? "";
60+
expect(sessionId, "captured the checkout session id").toMatch(/^cs_/);
61+
await page.locator("button.checkout-pay-btn").waitFor();
62+
});
63+
64+
await step("Complete checkout and return to the plans page", async () => {
65+
await page.locator("button.checkout-pay-btn").click();
66+
// Checkout completes and redirects back to the success_url (the plans
67+
// page). The webhook has NOT landed yet, so the trial is still offered:
68+
// exactly the window the bug lives in.
69+
await page.waitForURL(/billing\/plans/, { timeout: 30_000 });
70+
await startTrial.waitFor();
71+
});
72+
73+
// The Stripe webhook reaches Autumn: the customer is now on the Team trial.
74+
// From here on the billing backend is authoritative-consistent.
75+
await Effect.runPromise(autumn.settleCheckout(sessionId));
76+
77+
// The page the user was returned to never reloads. If the UI refetched
78+
// after returning from checkout it would drop the trial CTA within a few
79+
// seconds; today it does not, so this stays visible.
80+
const reflectedWithoutReload = await startTrial
81+
.waitFor({ state: "hidden", timeout: 8_000 })
82+
.then(() => true)
83+
.catch(() => false);
84+
85+
// Control: a manual reload surfaces the active trial, proving the backend
86+
// was consistent all along and the only thing missing was a client refetch.
87+
await step("A manual reload shows the active trial", async () => {
88+
await page.reload({ waitUntil: "networkidle" });
89+
await teamCard.getByText("Current plan").waitFor({ timeout: 15_000 });
90+
});
91+
92+
expect(
93+
reflectedWithoutReload,
94+
"the plans page reflects the completed checkout without a manual reload",
95+
).toBe(true);
96+
});
97+
}),
98+
);

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;

e2e/src/surfaces/autumn.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ export interface AutumnSurface {
3434
readonly expectUsage: (
3535
query: UsageQuery & { readonly count: number },
3636
) => Effect.Effect<readonly UsageEvent[], unknown>;
37+
/** Land the asynchronous checkout webhook for a checkout session, activating
38+
* its subscription. In production this is Stripe's `checkout.session.completed`
39+
* reaching Autumn shortly after the browser is redirected back; the emulator
40+
* defers activation until this is called, so a test controls the exact moment
41+
* the billing backend becomes consistent. The `sessionId` is the last path
42+
* segment of the hosted checkout URL the browser was sent to. */
43+
readonly settleCheckout: (sessionId: string) => Effect.Effect<void, unknown>;
3744
}
3845

3946
export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => {
@@ -71,8 +78,21 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => {
7178
}));
7279
});
7380

81+
const settleCheckout = (sessionId: string) =>
82+
Effect.gen(function* () {
83+
const response = yield* Effect.promise(() =>
84+
fetch(`${autumnUrl}/checkout/${encodeURIComponent(sessionId)}/settle`, { method: "POST" }),
85+
);
86+
if (!response.ok) {
87+
return yield* Effect.fail(
88+
`autumn checkout settle responded ${response.status}: ${yield* Effect.promise(() => response.text())}`,
89+
);
90+
}
91+
});
92+
7493
return {
7594
usageEvents,
95+
settleCheckout,
7696
expectUsage: (query) =>
7797
usageEvents(query).pipe(
7898
Effect.filterOrFail(

0 commit comments

Comments
 (0)