Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion apps/cloud/src/extensions/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ const handler = Effect.gen(function* () {
},
clientOptions: {
secretKey: env.AUTUMN_SECRET_KEY ?? "",
...(env.AUTUMN_API_URL ? { serverURL: env.AUTUMN_API_URL } : {}),
// autumn-js's handler reads `baseURL` to override the Autumn endpoint
// (not `serverURL`, which it silently ignores). Without this, a non-prod
// AUTUMN_API_URL (the e2e emulator, a self-hosted Autumn) is dropped and
// every billing request goes to production Autumn and 401s.
...(env.AUTUMN_API_URL ? { baseURL: env.AUTUMN_API_URL } : {}),
},
pathPrefix: "/api/billing",
}),
Expand Down
76 changes: 72 additions & 4 deletions apps/cloud/src/routes/app/billing_.plans.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, 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";
Expand All @@ -21,6 +21,54 @@ export const Route = createFileRoute("/{-$orgSlug}/billing_/plans")({
component: PlansPage,
});

// Marker appended to the checkout success URL so the page knows, on return, that
// it just came back from Stripe and which plan was attached.
const CHECKOUT_RETURN_PARAM = "checkout";

/**
* Refresh billing data after returning from a redirect checkout.
*
* autumn-js fetches the customer once on load and does not refetch on its own
* (staleTime 60s, no refetch on focus), while Stripe's `checkout.session.completed`
* webhook reaches Autumn moments AFTER the browser is redirected back. So the
* single fetch on the success page sees the pre-checkout plan and the page would
* otherwise show the old plan (and the upgrade CTA) until a manual reload.
*
* On detecting the return marker, poll the billing data until the attached plan
* shows as active (or a timeout), then strip the marker so a later manual reload
* does not re-arm the poll.
*/
function useRefreshAfterCheckout(plans: Plan[] | undefined, refetch: () => void): void {
const plansRef = useRef(plans);
plansRef.current = plans;

useEffect(() => {
const params = new URLSearchParams(window.location.search);
const attachedPlanId = params.get(CHECKOUT_RETURN_PARAM);
if (!attachedPlanId) return;

params.delete(CHECKOUT_RETURN_PARAM);
const query = params.toString();
window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}`);

const reflected = () =>
plansRef.current?.find((p) => p.id === attachedPlanId)?.customerEligibility?.status ===
"active";

let elapsed = 0;
refetch();
const interval = setInterval(() => {
elapsed += 1500;
if (reflected() || elapsed >= 20_000) {
clearInterval(interval);
return;
}
refetch();
}, 1500);
return () => clearInterval(interval);
}, [refetch]);
}

const ENTERPRISE_FEATURES = [
"Self-hosted or dedicated cloud deployment support",
"SSO / SAML & SCIM provisioning",
Expand Down Expand Up @@ -67,10 +115,26 @@ const ACTION_LABELS: Record<string, string> = {
const PLAN_ORDER = ["free", "team", "enterprise"];

function PlansPage() {
const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer();
const { data: plans, isLoading: plansLoading, isFetching } = useListPlans();
const {
attach,
openCustomerPortal,
isLoading: customerLoading,
refetch: refetchCustomer,
} = useCustomer();
const {
data: plans,
isLoading: plansLoading,
isFetching,
refetch: refetchPlans,
} = useListPlans();
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);

const refetchBilling = useCallback(() => {
void refetchCustomer();
void refetchPlans();
}, [refetchCustomer, refetchPlans]);
useRefreshAfterCheckout(plans, refetchBilling);

const isLoading = customerLoading || plansLoading;

const selfServePlans = PLAN_ORDER.flatMap((id) =>
Expand Down Expand Up @@ -215,7 +279,11 @@ function PlansPage() {
});
}
setLoadingPlan(plan.id);
await attach({ planId: plan.id, redirectMode: "always" });
// Tag the return URL so the page refetches billing
// data when Stripe redirects back (the webhook that
// activates the plan lands moments after the redirect).
const successUrl = `${window.location.origin}${window.location.pathname}?${CHECKOUT_RETURN_PARAM}=${plan.id}`;
await attach({ planId: plan.id, redirectMode: "always", successUrl });
setLoadingPlan(null);
}}
className={[
Expand Down
98 changes: 98 additions & 0 deletions e2e/cloud/billing-trial-checkout-stale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Cloud-only (billing, browser): completing the Team free-trial checkout should
// leave the billing page showing the new plan WITHOUT a manual reload.
//
// Repro of a reported bug: a user starts the free trial, completes Stripe
// checkout, and is redirected back to the plans page, which STILL shows the
// upgrade/"Start free trial" call to action. A manual reload then shows the
// active trial. The cause is client-side: autumn-js fetches the customer once
// on load (staleTime 60s, refetchOnWindowFocus off) and the redirect back from
// Stripe lands before Autumn has processed Stripe's webhook, so that single
// fetch sees the old plan and the page never refetches on its own.
//
// The emulator models this faithfully: completing the hosted checkout redirects
// back immediately but does NOT activate the subscription; the activation lands
// only when the webhook settles (autumn.settleCheckout). The reload control
// proves the backend is consistent, isolating the failure to the stale client.
import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { Autumn, Billing, Browser, Target } from "../src/services";

scenario(
"Billing · completing the trial checkout shows the new plan without a reload",
{ timeout: 120_000 },
Effect.gen(function* () {
// Gates: billing enforced here AND the Autumn emulator is observable (so we
// can land the checkout webhook). Yield before any work so a target missing
// either capability skips cleanly rather than failing.
yield* Billing;
const autumn = yield* Autumn;
const target = yield* Target;
const browser = yield* Browser;

const identity = yield* target.newIdentity();

yield* browser.session(identity, async ({ page, step }) => {
const teamCard = page
.getByText("Team", { exact: true })
.locator("xpath=ancestor::div[contains(@class,'rounded-xl')][1]");
const startTrial = teamCard.getByRole("button", { name: "Start free trial" });

await step("A fresh org is offered the Team free trial", async () => {
// Billing requests are org-scoped via the URL slug header, so reach the
// plans page through the org-scoped URL (a bare /billing/plans would fire
// the first fetch before the slug resolves and 401). Land on "/" to
// canonicalize, then open the slug-scoped plans page.
await page.goto("/", { waitUntil: "networkidle" });
const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0];
await page.goto(`/${slug}/billing/plans`, { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "Choose a plan" }).waitFor();
await startTrial.waitFor();
});

let sessionId = "";
await step("Start the trial and land on the hosted checkout", async () => {
await startTrial.click();
// attach() redirects the whole page to the checkout URL.
await page.waitForURL(/\/checkout\//, { timeout: 30_000 });
sessionId = new URL(page.url()).pathname.split("/").filter(Boolean).pop() ?? "";
expect(sessionId, "captured the checkout session id").toMatch(/^cs_/);
await page.locator("button.checkout-pay-btn").waitFor();
});

await step("Complete checkout and return to the plans page", async () => {
await page.locator("button.checkout-pay-btn").click();
// Checkout completes and redirects back to the success_url (the plans
// page). The webhook has NOT landed yet, so the trial is still offered:
// exactly the window the bug lives in.
await page.waitForURL(/billing\/plans/, { timeout: 30_000 });
await startTrial.waitFor();
});

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

// The page the user was returned to never reloads. If the UI refetched
// after returning from checkout it would drop the trial CTA within a few
// seconds; today it does not, so this stays visible.
const reflectedWithoutReload = await startTrial
.waitFor({ state: "hidden", timeout: 8_000 })
.then(() => true)
.catch(() => false);

// Control: a manual reload surfaces the active trial, proving the backend
// was consistent all along and the only thing missing was a client refetch.
await step("A manual reload shows the active trial", async () => {
await page.reload({ waitUntil: "networkidle" });
await teamCard.getByText("Current plan").waitFor({ timeout: 15_000 });
});

expect(
reflectedWithoutReload,
"the plans page reflects the completed checkout without a manual reload",
).toBe(true);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
});
}),
);
51 changes: 51 additions & 0 deletions e2e/setup/autumn-plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// The plan catalog the Autumn emulator advertises, derived from the deploy-time
// source of truth (repo-root `autumn.config.ts`). In production Autumn learns
// these plans via `atmn` sync; the emulator has no such sync, so the e2e boot
// seeds them. Keeping this derived from autumn.config.ts means plans, prices,
// and the Team free trial can never drift from what the app actually ships.
import { free, team, enterprise } from "../../autumn.config";

type AtmnPlan = {
id: string;
name: string;
addOn?: boolean;
autoEnable?: boolean;
price?: { amount: number; interval: string };
freeTrial?: { durationLength: number; durationType: string; cardRequired?: boolean };
items?: ReadonlyArray<{ featureId: string; included?: number; unlimited?: boolean }>;
};

export interface AutumnSeedPlan {
id: string;
name: string;
add_on: boolean;
auto_enable: boolean;
price: { amount: number; interval: string } | null;
free_trial: { duration_length: number; duration_type: string; card_required: boolean } | null;
items: Array<{ feature_id: string; included?: number; unlimited?: boolean }>;
}

const toSeed = (plan: AtmnPlan): AutumnSeedPlan => ({
id: plan.id,
name: plan.name,
add_on: plan.addOn ?? false,
auto_enable: plan.autoEnable ?? false,
price: plan.price ? { amount: plan.price.amount, interval: plan.price.interval } : null,
free_trial: plan.freeTrial
? {
duration_length: plan.freeTrial.durationLength,
duration_type: plan.freeTrial.durationType,
card_required: plan.freeTrial.cardRequired ?? false,
}
: null,
items: (plan.items ?? []).map((it) => ({
feature_id: it.featureId,
included: it.included,
unlimited: it.unlimited,
})),
});

/** Free, Team (card-required 14-day trial), and Enterprise, in display order. */
export const AUTUMN_PLAN_SEED: AutumnSeedPlan[] = [free, team, enterprise].map((p) =>
toSeed(p as AtmnPlan),
);
4 changes: 4 additions & 0 deletions e2e/setup/cloud.boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
import { createEmulator } from "@executor-js/emulate";

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

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

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

const workosUrl = options.workosPublicUrl ?? workos.url;
Expand Down
20 changes: 20 additions & 0 deletions e2e/src/surfaces/autumn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export interface AutumnSurface {
readonly expectUsage: (
query: UsageQuery & { readonly count: number },
) => Effect.Effect<readonly UsageEvent[], unknown>;
/** Land the asynchronous checkout webhook for a checkout session, activating
* its subscription. In production this is Stripe's `checkout.session.completed`
* reaching Autumn shortly after the browser is redirected back; the emulator
* defers activation until this is called, so a test controls the exact moment
* 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<void, unknown>;
}

export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => {
Expand Down Expand Up @@ -71,8 +78,21 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => {
}));
});

const settleCheckout = (sessionId: string) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() =>
fetch(`${autumnUrl}/checkout/${encodeURIComponent(sessionId)}/settle`, { method: "POST" }),
);
if (!response.ok) {
return yield* Effect.fail(
`autumn checkout settle responded ${response.status}: ${yield* Effect.promise(() => response.text())}`,
);
}
});

return {
usageEvents,
settleCheckout,
expectUsage: (query) =>
usageEvents(query).pipe(
Effect.filterOrFail(
Expand Down
Loading