Skip to content

Commit e47df14

Browse files
committed
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 e5896ce commit e47df14

2 files changed

Lines changed: 61 additions & 38 deletions

File tree

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,16 @@ const CHECKOUT_RETURN_PARAM = "checkout";
3535
* otherwise show the old plan (and the upgrade CTA) until a manual reload.
3636
*
3737
* 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.
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).
4045
*/
41-
function useRefreshAfterCheckout(plans: Plan[] | undefined, refetch: () => void): void {
46+
function useRefreshAfterCheckout(plans: Plan[] | undefined, refetch: () => void): string | null {
47+
const [finalizingPlan, setFinalizingPlan] = useState<string | null>(null);
4248
const plansRef = useRef(plans);
4349
plansRef.current = plans;
4450

@@ -50,6 +56,7 @@ function useRefreshAfterCheckout(plans: Plan[] | undefined, refetch: () => void)
5056
params.delete(CHECKOUT_RETURN_PARAM);
5157
const query = params.toString();
5258
window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}`);
59+
setFinalizingPlan(attachedPlanId);
5360

5461
const reflected = () =>
5562
plansRef.current?.find((p) => p.id === attachedPlanId)?.customerEligibility?.status ===
@@ -61,12 +68,26 @@ function useRefreshAfterCheckout(plans: Plan[] | undefined, refetch: () => void)
6168
elapsed += 1500;
6269
if (reflected() || elapsed >= 20_000) {
6370
clearInterval(interval);
71+
setFinalizingPlan(null);
6472
return;
6573
}
6674
refetch();
6775
}, 1500);
6876
return () => clearInterval(interval);
6977
}, [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;
7091
}
7192

7293
const ENTERPRISE_FEATURES = [
@@ -133,7 +154,7 @@ function PlansPage() {
133154
void refetchCustomer();
134155
void refetchPlans();
135156
}, [refetchCustomer, refetchPlans]);
136-
useRefreshAfterCheckout(plans, refetchBilling);
157+
const finalizingPlan = useRefreshAfterCheckout(plans, refetchBilling);
137158

138159
const isLoading = customerLoading || plansLoading;
139160

@@ -193,6 +214,9 @@ function PlansPage() {
193214
const isScheduled = status === "scheduled";
194215
const isUpgradeAction = action === "upgrade" || action === "activate";
195216
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;
196220
// Offer the trial only when the plan defines one and this customer
197221
// is still eligible (trialAvailable is false once they've used it).
198222
const freeTrial = plan.freeTrial;
@@ -223,6 +247,9 @@ function PlansPage() {
223247
{plan.name}
224248
</p>
225249
{isCurrent && <Badge className="bg-muted text-foreground">Your plan</Badge>}
250+
{isFinalizing && (
251+
<Badge className="bg-primary/10 text-primary">Activating</Badge>
252+
)}
226253
{isCanceling && (
227254
<Badge className="bg-muted text-muted-foreground">Canceling</Badge>
228255
)}
@@ -249,6 +276,11 @@ function PlansPage() {
249276
<div className="flex h-9 items-center justify-center rounded-md border border-border bg-muted/30 text-sm font-medium text-muted-foreground">
250277
{isCurrent ? "Current plan" : "Scheduled"}
251278
</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>
252284
) : isEnterprise ? (
253285
<EnterpriseContactDialog />
254286
) : isCanceling ? (

e2e/cloud/billing-trial-checkout-stale.test.ts

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
// Cloud-only (billing, browser): completing the Team free-trial checkout should
2-
// leave the billing page showing the new plan WITHOUT a manual reload.
2+
// leave the billing page showing the new plan WITHOUT a manual reload, and
3+
// without ever flashing the stale upgrade CTA.
34
//
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.
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.
1113
//
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.
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.
1618
import { expect } from "@effect/vitest";
1719
import { Effect } from "effect";
1820

@@ -63,36 +65,25 @@ scenario(
6365

6466
await step("Complete checkout and return to the plans page", async () => {
6567
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.
6968
await page.waitForURL(/billing\/plans/, { timeout: 30_000 });
70-
await startTrial.waitFor();
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);
7175
});
7276

7377
// The Stripe webhook reaches Autumn: the customer is now on the Team trial.
74-
// From here on the billing backend is authoritative-consistent.
7578
await Effect.runPromise(autumn.settleCheckout(sessionId));
7679

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" });
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 () => {
8983
await teamCard.getByText("Current plan").waitFor({ timeout: 15_000 });
84+
await teamCard.getByText("Your plan").waitFor({ timeout: 5_000 });
9085
});
91-
92-
expect(
93-
reflectedWithoutReload,
94-
"the plans page reflects the completed checkout without a manual reload",
95-
).toBe(true);
86+
expect(await startTrial.count(), "the upgrade CTA never returns").toBe(0);
9687
});
9788
}),
9889
);

0 commit comments

Comments
 (0)