Skip to content

Commit 2d9c273

Browse files
authored
feat(autumn): plan catalog, eligibility, and hosted checkout flow (#7)
* feat(autumn): plan catalog, eligibility, and hosted checkout flow Extends the Autumn emulator beyond customers and usage so it can drive the billing UI: - Seedable plan catalog returned by plans.list, with per-customer customer_eligibility (status, attach_action, trialing, trial_available) computed from the customer's subscriptions. - billing.attach and billing.open_customer_portal. A paid plan or a card-required free trial routes attach through a hosted checkout page. - Hosted checkout: GET /checkout/:id renders via the shared renderCheckoutPage, POST /checkout/:id/complete redirects to success_url WITHOUT activating, and /checkout/settle (or /checkout/:id/settle) activates the subscription. This mirrors Stripe: completing checkout redirects back before the checkout.session.completed webhook lands, so activation is deferred to settle. A card-required trial activates as trialing. - customers.get_or_create now returns SDK-shaped subscriptions and per-feature balances derived from the active plan. Validated against the real autumn-js SDK in a new test. * chore(release): 0.9.0
1 parent d701b79 commit 2d9c273

33 files changed

Lines changed: 710 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
# Changelog
22

3-
## 0.8.1
3+
## 0.9.0
44

55
<!-- release:start -->
66

77
### New Features
88

9+
- **Autumn checkout and free-trial flow** — the Autumn emulator now backs a real billing UI end to end. `plans.list` returns a seedable plan catalog with per-customer eligibility (`attach_action`, `status`, `trialing`, `trial_available`), `billing.attach` and `billing.open_customer_portal` are supported, and a paid plan or a card-required free trial routes attach through a hosted checkout page. Completing checkout redirects to the app's `success_url` without activating the subscription; activation lands when the checkout settles (`POST /checkout/settle`), mirroring Stripe's `checkout.session.completed` webhook arriving after the redirect. `customers.get_or_create` now returns SDK-shaped subscriptions and per-feature balances. This makes the "billing page stays stale until a reload after checkout" race reproducible in tests.
10+
11+
<!-- release:end -->
12+
13+
## 0.8.1
14+
15+
### New Features
16+
917
- **GitLab GraphQL emulator** — a new `gitlab` emulator serves GitLab's full GraphQL schema with real graphql-js introspection and validation. It loads the complete published SDL (4000+ types) so generated GraphQL clients see the same surface they would in production, and rejects malformed operations with verbatim graphql-js validation errors. The hosted `gitlab.emulators.dev` host and `createEmulator({ service: "gitlab" })` both expose the schema at `/api/graphql`, with metadata and echo queries resolving and an honest unauthenticated `currentUser`.
1018

1119
### Bug Fixes
1220

1321
- **WorkOS invitation memberships** — sending an organization invitation now also creates a pending organization membership (and the invited user when one does not exist yet), matching real WorkOS. `listOrganizationMemberships` with status `pending` returns invited but not yet joined people, so consumers can list invited members and count seats accurately. Accepting the invitation activates that membership instead of leaving a duplicate.
1422
- **Publishable `emulate` package** — the published package no longer declares the bundled `@emulators/workos` and `@emulators/autumn` workspace packages as runtime dependencies (they are bundled, so it now lists them as dev dependencies like the other emulators), and it now declares the third-party SDKs the bundle resolves at runtime (`@aws-sdk/*`, `googleapis`, `@octokit/rest`, `@workos-inc/node`, `stripe`, and others). A clean `npm install` of the tarball now resolves and boots every service emulator.
15-
<!-- release:end -->
1623

1724
## 0.7.5
1825

packages/@emulators/adapter-next/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@emulators/adapter-next",
3-
"version": "0.8.1",
3+
"version": "0.9.0",
44
"license": "Apache-2.0",
55
"type": "module",
66
"main": "./dist/index.js",

packages/@emulators/apple/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@emulators/apple",
3-
"version": "0.8.1",
3+
"version": "0.9.0",
44
"license": "Apache-2.0",
55
"type": "module",
66
"main": "./dist/index.js",

packages/@emulators/autumn/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@emulators/autumn",
3-
"version": "0.8.1",
3+
"version": "0.9.0",
44
"license": "Apache-2.0",
55
"type": "module",
66
"main": "./dist/index.js",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
2+
import { createServer, serve } from "@emulators/core";
3+
import { Autumn } from "autumn-js";
4+
5+
import { autumnPlugin, seedFromConfig } from "../index.js";
6+
import { manifest } from "../manifest.js";
7+
8+
// Drives the card-required free-trial checkout flow through the real autumn-js
9+
// SDK (zod-validated responses) plus raw fetch for the hosted checkout page.
10+
// This is the contract the cloud app's billing UI depends on.
11+
12+
const PORT = 41875;
13+
const BASE = `http://localhost:${PORT}`;
14+
const SUCCESS_URL = `${BASE}/back-to-billing`;
15+
16+
let httpServer: ReturnType<typeof serve>;
17+
let autumn: Autumn;
18+
19+
beforeAll(() => {
20+
const { app, store } = createServer(autumnPlugin, {
21+
port: PORT,
22+
baseUrl: BASE,
23+
manifest,
24+
fallbackUser: { login: "am_emulate_admin", id: 1, scopes: [] },
25+
});
26+
// Mirror the executor plan catalog: a free plan and a card-required Team trial.
27+
seedFromConfig(store, BASE, {
28+
plans: [
29+
{ id: "free", name: "Free", auto_enable: true, items: [{ feature_id: "executions", included: 10000 }] },
30+
{
31+
id: "team",
32+
name: "Team",
33+
price: { amount: 150, interval: "month" },
34+
free_trial: { duration_length: 14, duration_type: "day", card_required: true },
35+
items: [{ feature_id: "executions", included: 250000 }],
36+
},
37+
],
38+
});
39+
httpServer = serve({ fetch: app.fetch, port: PORT });
40+
autumn = new Autumn({ secretKey: "am_test_emulate", serverURL: BASE });
41+
});
42+
43+
afterAll(async () => {
44+
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
45+
});
46+
47+
const teamPlan = async (customerId: string) => {
48+
const { list } = await autumn.plans.list({ customerId });
49+
const team = list.find((p) => p.id === "team")!;
50+
const free = list.find((p) => p.id === "free")!;
51+
return { team, free };
52+
};
53+
54+
describe("autumn emulator: card-required free-trial checkout", () => {
55+
const CUSTOMER = "org_trial";
56+
57+
it("a fresh customer is offered the Team trial and sits on free", async () => {
58+
const { team, free } = await teamPlan(CUSTOMER);
59+
expect(team.freeTrial, "Team advertises a free trial").not.toBeNull();
60+
expect(team.customerEligibility?.trialAvailable, "trial is available").toBe(true);
61+
expect(team.customerEligibility?.attachAction, "not yet on Team").toBe("upgrade");
62+
expect(team.customerEligibility?.status, "not the current plan").toBeUndefined();
63+
expect(free.customerEligibility?.status, "free is the current plan").toBe("active");
64+
});
65+
66+
it("attach returns a checkout payment URL (card required)", async () => {
67+
const res = await autumn.billing.attach({ customerId: CUSTOMER, planId: "team", successUrl: SUCCESS_URL });
68+
expect(res.paymentUrl, "a checkout URL is returned").toContain("/checkout/");
69+
70+
// Attaching alone must NOT activate the subscription: the customer is still
71+
// on free until the checkout completes and the webhook settles.
72+
const { team } = await teamPlan(CUSTOMER);
73+
expect(team.customerEligibility?.status, "still not active after attach").toBeUndefined();
74+
});
75+
76+
it("completing checkout redirects to success_url but does not yet activate", async () => {
77+
const { paymentUrl } = await autumn.billing.attach({
78+
customerId: CUSTOMER,
79+
planId: "team",
80+
successUrl: SUCCESS_URL,
81+
});
82+
const sessionId = new URL(paymentUrl!).pathname.split("/").pop()!;
83+
84+
const page = await fetch(paymentUrl!);
85+
expect(page.status).toBe(200);
86+
expect(await page.text(), "checkout page names the plan").toContain("Team");
87+
88+
const completed = await fetch(`${BASE}/checkout/${sessionId}/complete`, {
89+
method: "POST",
90+
redirect: "manual",
91+
});
92+
expect(completed.status, "completion redirects").toBe(302);
93+
expect(completed.headers.get("location"), "back to the app").toBe(SUCCESS_URL);
94+
95+
// The webhook has not landed yet: the customer is STILL on free. This is the
96+
// exact window in which the billing UI shows the stale plan.
97+
const customer = await autumn.customers.getOrCreate({ customerId: CUSTOMER });
98+
expect(customer.subscriptions ?? [], "no active subscription before settle").toHaveLength(0);
99+
});
100+
101+
it("settling the checkout activates the Team trial", async () => {
102+
const res = await fetch(`${BASE}/checkout/settle`, {
103+
method: "POST",
104+
headers: { "content-type": "application/json" },
105+
body: JSON.stringify({ customer_id: CUSTOMER }),
106+
});
107+
expect(res.ok).toBe(true);
108+
109+
const { team, free } = await teamPlan(CUSTOMER);
110+
expect(team.customerEligibility?.status, "Team is now the active plan").toBe("active");
111+
expect(team.customerEligibility?.trialing, "on a trial").toBe(true);
112+
expect(team.customerEligibility?.attachAction, "nothing to attach").toBe("none");
113+
expect(free.customerEligibility?.attachAction, "free is now a downgrade").toBe("downgrade");
114+
115+
const customer = await autumn.customers.getOrCreate({ customerId: CUSTOMER });
116+
const sub = (customer.subscriptions ?? []).find((s) => s.planId === "team");
117+
expect(sub, "customer carries the Team subscription").toBeTruthy();
118+
expect(sub?.status).toBe("trialing");
119+
});
120+
});

packages/@emulators/autumn/src/entities.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import type { Entity } from "@emulators/core";
22

33
export interface AutumnSubscription {
4+
/** Stable subscription id (e.g. `sub_emulate_1`). */
5+
id?: string;
46
plan_id: string;
7+
/** `active` | `trialing` | `scheduled` | `canceled`. */
58
status: string;
9+
started_at?: number;
10+
current_period_start?: number | null;
11+
current_period_end?: number | null;
12+
trial_ends_at?: number | null;
13+
canceled_at?: number | null;
14+
quantity?: number;
615
[key: string]: unknown;
716
}
817

@@ -11,10 +20,47 @@ export interface AutumnCustomer extends Entity {
1120
name: string | null;
1221
email: string | null;
1322
subscriptions: AutumnSubscription[];
23+
/** Plan ids whose free trial this customer has already consumed. Once used,
24+
* the plan's `trial_available` flips to false (Autumn offers a trial once). */
25+
trials_used?: string[];
1426
}
1527

1628
export interface AutumnTrackEvent extends Entity {
1729
customer_id: string;
1830
feature_id: string;
1931
value: number;
2032
}
33+
34+
export interface AutumnPlanItem {
35+
feature_id: string;
36+
included?: number;
37+
unlimited?: boolean;
38+
price?: unknown;
39+
}
40+
41+
export interface AutumnPlan extends Entity {
42+
plan_id: string;
43+
name: string;
44+
add_on: boolean;
45+
auto_enable: boolean;
46+
price: { amount: number; interval: string } | null;
47+
free_trial: { duration_length: number; duration_type: string; card_required: boolean } | null;
48+
items: AutumnPlanItem[];
49+
/** Rank used to classify an attach as upgrade vs downgrade (low to high). */
50+
order: number;
51+
}
52+
53+
/** A checkout session opened by `billing.attach` for a plan that needs payment
54+
* (a price, or a card-required trial). Mirrors the real flow: the browser is
55+
* redirected to a hosted checkout page; completing it sends the browser back
56+
* to `success_url`, but the subscription only activates once the asynchronous
57+
* Stripe webhook is processed, modelled here by `settle`. */
58+
export interface AutumnCheckout extends Entity {
59+
session_id: string;
60+
customer_id: string;
61+
plan_id: string;
62+
success_url: string;
63+
/** `pending` (checkout open) to `completed` (browser paid, webhook in flight)
64+
* to `settled` (webhook processed, subscription active). */
65+
status: "pending" | "completed" | "settled";
66+
}

packages/@emulators/autumn/src/index.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,61 @@ import type { Hono, Store, WebhookDispatcher, TokenMap, AppEnv, RouteContext, Se
22

33
import { getAutumnStore, type AutumnStore } from "./store.js";
44
import { autumnApiRoutes } from "./routes/api.js";
5+
import { checkoutRoutes } from "./routes/checkout.js";
56
import { openapiRoutes } from "./routes/openapi.js";
6-
import { manifest } from "./manifest.js";
7-
import type { AutumnSubscription } from "./entities.js";
7+
import type { AutumnSubscription, AutumnPlan, AutumnPlanItem } from "./entities.js";
88

99
export { getAutumnStore, type AutumnStore } from "./store.js";
1010
export * from "./entities.js";
1111
export { manifest } from "./manifest.js";
1212

13+
export interface AutumnSeedPlan {
14+
id: string;
15+
name?: string;
16+
add_on?: boolean;
17+
auto_enable?: boolean;
18+
price?: { amount: number; interval: string } | null;
19+
free_trial?: { duration_length: number; duration_type: string; card_required: boolean } | null;
20+
items?: AutumnPlanItem[];
21+
}
22+
1323
export interface AutumnSeedConfig {
1424
customers?: Array<{
1525
id: string;
1626
name?: string;
1727
email?: string;
1828
subscriptions?: AutumnSubscription[];
1929
}>;
30+
/** Plan catalog the emulator advertises via `plans.list` and attaches via
31+
* `billing.attach`. In production these are synced from `autumn.config.ts`;
32+
* the emulator has no such sync, so the application under test seeds them. */
33+
plans?: AutumnSeedPlan[];
34+
}
35+
36+
function seedPlans(as: AutumnStore, plans: AutumnSeedPlan[]): void {
37+
plans.forEach((plan, index) => {
38+
const fields: Omit<AutumnPlan, "id" | "created_at" | "updated_at"> = {
39+
plan_id: plan.id,
40+
name: plan.name ?? plan.id,
41+
add_on: plan.add_on ?? false,
42+
auto_enable: plan.auto_enable ?? false,
43+
price: plan.price ?? null,
44+
free_trial: plan.free_trial ?? null,
45+
items: plan.items ?? [],
46+
order: index,
47+
};
48+
const existing = as.plans.findOneBy("plan_id", plan.id);
49+
if (existing) {
50+
as.plans.update(existing.id, fields);
51+
} else {
52+
as.plans.insert(fields);
53+
}
54+
});
2055
}
2156

2257
export function seedFromConfig(store: Store, _baseUrl: string, config: AutumnSeedConfig): void {
2358
const as = getAutumnStore(store);
59+
if (config.plans) seedPlans(as, config.plans);
2460
for (const customer of config.customers ?? []) {
2561
const existing = as.customers.findOneBy("customer_id", customer.id);
2662
if (existing) {
@@ -45,10 +81,12 @@ export const autumnPlugin: ServicePlugin = {
4581
register(app: Hono<AppEnv>, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void {
4682
const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap };
4783
autumnApiRoutes(ctx);
84+
checkoutRoutes(ctx);
4885
openapiRoutes(ctx);
4986
},
5087
seed(_store: Store, _baseUrl: string): void {
51-
// No default seed; customers are created on first get_or_create.
88+
// No default seed; customers are created on first get_or_create and the
89+
// plan catalog is seeded by the application under test.
5290
},
5391
};
5492

packages/@emulators/autumn/src/manifest.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ export const manifest: ServiceManifest = {
44
id: "autumn",
55
name: "Autumn",
66
description:
7-
"Stateful Autumn billing emulator: customers (with seedable subscriptions), usage tracking, and list endpoints for plans, features, and events.",
7+
"Stateful Autumn billing emulator: customers (with seedable subscriptions and a plan catalog), usage tracking, plan eligibility, and a hosted checkout flow for paid plans and card-required free trials.",
88
docsUrl: "https://docs.emulators.dev/autumn",
9-
surfaces: [{ id: "rest", kind: "rest", title: "Autumn v1 API", status: "partial", basePath: "/v1" }],
9+
surfaces: [
10+
{ id: "rest", kind: "rest", title: "Autumn v1 API", status: "partial", basePath: "/v1" },
11+
{ id: "checkout", kind: "ui", title: "Hosted checkout", status: "partial", basePath: "/checkout" },
12+
],
1013
auth: [{ id: "api-key", title: "Autumn secret key", type: "api-key", status: "supported" }],
1114
specs: [
1215
{
@@ -25,28 +28,55 @@ export const manifest: ServiceManifest = {
2528
{ operationId: "customers.update", method: "POST", path: "/v1/customers.update", status: "hand-authored" },
2629
{ operationId: "balances.track", method: "POST", path: "/v1/balances.track", status: "hand-authored" },
2730
{ operationId: "plans.list", method: "POST", path: "/v1/plans.list", status: "hand-authored" },
31+
{ operationId: "billing.attach", method: "POST", path: "/v1/billing.attach", status: "hand-authored" },
32+
{
33+
operationId: "billing.open_customer_portal",
34+
method: "POST",
35+
path: "/v1/billing.open_customer_portal",
36+
status: "hand-authored",
37+
},
2838
{ operationId: "features.list", method: "POST", path: "/v1/features.list", status: "hand-authored" },
2939
{ operationId: "events.list", method: "POST", path: "/v1/events.list", status: "hand-authored" },
3040
],
3141
},
3242
],
3343
seedSchema: {
34-
description: "Seed customers with subscriptions (e.g. a paid plan).",
44+
description: "Seed the plan catalog and customers (with subscriptions).",
3545
fields: [
46+
{
47+
key: "plans",
48+
title: "Plans",
49+
description:
50+
"Plan catalog advertised by plans.list and attachable via billing.attach. A plan with a price or a card-required free_trial routes attach through hosted checkout.",
51+
example: [
52+
{
53+
id: "team",
54+
name: "Team",
55+
price: { amount: 150, interval: "month" },
56+
free_trial: { duration_length: 14, duration_type: "day", card_required: true },
57+
},
58+
],
59+
},
3660
{
3761
key: "customers",
3862
title: "Customers",
3963
description: "Customers keyed by id, each with optional subscriptions.",
40-
example: [{ id: "org_123", subscriptions: [{ plan_id: "pro", status: "active" }] }],
64+
example: [{ id: "org_123", subscriptions: [{ plan_id: "team", status: "active" }] }],
4165
},
4266
],
4367
example: {
44-
customers: [{ id: "org_123", subscriptions: [{ plan_id: "pro", status: "active" }] }],
68+
plans: [{ id: "free", name: "Free", auto_enable: true }],
69+
customers: [{ id: "org_123", subscriptions: [{ plan_id: "team", status: "active" }] }],
4570
},
4671
},
4772
stateModel: {
4873
description: "Entities mutated by Autumn provider calls.",
49-
collections: [{ name: "autumn.customers" }, { name: "autumn.events" }],
74+
collections: [
75+
{ name: "autumn.customers" },
76+
{ name: "autumn.events" },
77+
{ name: "autumn.plans" },
78+
{ name: "autumn.checkouts" },
79+
],
5080
},
5181
connections: [
5282
{

0 commit comments

Comments
 (0)