From f2be4b5710202d934aa1299c529919d29005a88d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:44:48 -0700 Subject: [PATCH] Autumn: move a paid customer to another plan on its Stripe subscription --- .../src/__tests__/autumn-carry-over.test.ts | 208 +++++++++++++++++ packages/@emulators/autumn/src/entities.ts | 26 +++ packages/@emulators/autumn/src/index.ts | 15 +- packages/@emulators/autumn/src/manifest.ts | 10 +- packages/@emulators/autumn/src/routes/api.ts | 215 ++++++++++++++++++ .../@emulators/autumn/src/routes/openapi.ts | 52 ++++- packages/@emulators/autumn/src/serialize.ts | 88 +++++-- skills/autumn/SKILL.md | 42 +++- 8 files changed, 634 insertions(+), 22 deletions(-) create mode 100644 packages/@emulators/autumn/src/__tests__/autumn-carry-over.test.ts diff --git a/packages/@emulators/autumn/src/__tests__/autumn-carry-over.test.ts b/packages/@emulators/autumn/src/__tests__/autumn-carry-over.test.ts new file mode 100644 index 00000000..e4db6bdc --- /dev/null +++ b/packages/@emulators/autumn/src/__tests__/autumn-carry-over.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createServer, serve } from "@emulators/core"; + +import { autumnPlugin, seedFromConfig } from "../index.js"; +import { manifest } from "../manifest.js"; + +// Moving a paid customer between two catalogs in one Autumn account, as the +// real sandbox does it: rename the customer, then attach the new catalog's plan +// with `remove_plan_ids` and `proration_behavior: "none"`, which takes over the +// old plan's Stripe subscription. Raw fetch, since these routes postdate the +// pinned SDK. + +const PORT = 41941; +const BASE = `http://localhost:${PORT}`; +const PERIOD_END = Date.UTC(2030, 0, 15); +const TRIAL_END = Date.UTC(2030, 0, 8); + +let httpServer: ReturnType; + +const post = async (path: string, body: unknown) => { + const response = await fetch(`${BASE}/v1/${path}`, { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer am_sk_test_emulate" }, + body: JSON.stringify(body), + }); + return { status: response.status, body: (await response.json()) as any }; +}; + +const seatsItem = { + feature_id: "members", + included: 0, + price: { amount: 15, interval: "month", billing_units: 1, billing_method: "usage_based" }, +}; +const flatTeam = { + price: { amount: 49, interval: "month" }, + items: [ + { + feature_id: "executions", + included: 250000, + price: { amount: 0.2, interval: "month", billing_units: 1000, billing_method: "usage_based" }, + }, + ], +}; +const carry = (customerId: string, extra: Record = {}) => ({ + customer_id: customerId, + plan_id: "next-team", + remove_plan_ids: ["team"], + proration_behavior: "none", + redirect_mode: "never", + ...extra, +}); +const subscribed = (sub: Record) => ({ + plan_id: "team", + status: "active", + current_period_start: PERIOD_END - 30 * 86_400_000, + current_period_end: PERIOD_END, + ...sub, +}); + +beforeAll(() => { + const { app, store } = createServer(autumnPlugin, { + port: PORT, + baseUrl: BASE, + manifest, + fallbackUser: { login: "am_emulate_admin", id: 1, scopes: [] }, + }); + seedFromConfig(store, BASE, { + plans: [ + { id: "free", name: "Free", auto_enable: true, items: [{ feature_id: "executions", included: 10000 }] }, + // v3 bills per member; v1 was a flat price with metered executions. + { id: "team", name: "Team", version: 3, items: [seatsItem], versions: [{ version: 1, ...flatTeam }] }, + { id: "next-free", name: "Next Free", group: "next", items: [{ feature_id: "next-members", included: 3 }] }, + { + id: "next-team", + name: "Next Team", + group: "next", + free_trial: { duration_length: 14, duration_type: "day", card_required: true }, + items: [{ ...seatsItem, feature_id: "next-members" }], + }, + ], + customers: [ + { id: "org_seats", subscriptions: [subscribed({})] }, + { id: "org_flat", subscriptions: [subscribed({ version: 1 })] }, + { + id: "org_trial", + subscriptions: [subscribed({ status: "trialing", trial_ends_at: TRIAL_END, current_period_end: TRIAL_END })], + }, + { + id: "org_cancelled", + subscriptions: [ + subscribed({ canceled_at: PERIOD_END - 86_400_000, expires_at: PERIOD_END }), + { plan_id: "free", status: "scheduled" }, + ], + }, + { id: "org_taken", subscriptions: [{ plan_id: "free", status: "active" }] }, + ], + }); + httpServer = serve({ fetch: app.fetch, port: PORT }); +}); + +afterAll(async () => { + await new Promise((resolve) => httpServer.close(() => resolve())); +}); + +describe("autumn emulator: carrying a subscription over to another catalog", () => { + it("expands each subscription's plan at the version it bills, with a Stripe subscription", async () => { + const flat = await post("customers.get", { customer_id: "org_flat", expand: ["subscriptions.plan"] }); + const [sub] = flat.body.subscriptions; + expect(sub.plan).toMatchObject({ id: "team", version: 1, price: { amount: 49, interval: "month" } }); + expect(sub.plan.items[0]).toMatchObject({ feature_id: "executions", price: { billing_method: "usage_based" } }); + const seats = await post("customers.get", { customer_id: "org_seats" }); + expect(seats.body.subscriptions[0].plan).toBeUndefined(); + const verified = await post("billing.verify", { customer_id: "org_seats" }); + expect(verified.body.subscriptions).toEqual([ + { stripe_subscription_id: "sub_emulate_stripe_org_seats_team", status: "correct", mismatches: [] }, + ]); + }); + + it("renames a customer, keeping its subscription and usage, and refuses an id that is taken", async () => { + await post("balances.update", { customer_id: "org_seats", feature_id: "members", usage: 4 }); + const renamed = await post("customers.update", { customer_id: "org_seats", new_customer_id: "next:org_seats" }); + expect(renamed.status).toBe(200); + expect(renamed.body.id).toBe("next:org_seats"); + expect((await post("customers.get", { customer_id: "org_seats" })).status).toBe(404); + const moved = await post("customers.get", { customer_id: "next:org_seats" }); + expect(moved.body.subscriptions[0].plan_id).toBe("team"); + expect(moved.body.balances.members.usage).toBe(4); + const taken = await post("customers.update", { customer_id: "next:org_seats", new_customer_id: "org_taken" }); + expect(taken.status).toBe(409); + expect(taken.body.code).toBe("customer_already_exists"); + }); + + it("previews and attaches onto the same Stripe subscription with no charge and the same renewal", async () => { + const preview = await post("billing.preview_attach", carry("next:org_seats", { customize: { free_trial: null } })); + expect(preview.body).toMatchObject({ + total: 0, + redirect_to_checkout: false, + next_cycle: { starts_at: PERIOD_END }, + incoming: [{ plan_id: "next-team" }], + outgoing: [{ plan_id: "team" }], + }); + const attached = await post("billing.attach", carry("next:org_seats", { customize: { free_trial: null } })); + expect(attached.body).toEqual({ customer_id: "next:org_seats", payment_url: null }); + const after = await post("customers.get", { customer_id: "next:org_seats" }); + expect(after.body.subscriptions).toMatchObject([ + { plan_id: "next-team", status: "active", current_period_end: PERIOD_END, trial_ends_at: null }, + ]); + const verified = await post("billing.verify", { customer_id: "next:org_seats" }); + expect(verified.body.subscriptions.map((s: any) => s.stripe_subscription_id)).toEqual([ + "sub_emulate_stripe_org_seats_team", + ]); + // A second attach finds nothing to remove. + const again = await post("billing.attach", carry("next:org_seats", { customize: { free_trial: null } })); + expect(again.status).toBe(409); + }); + + it("keeps a flat price through customize and attaches an earlier version back", async () => { + const custom = { + price: { amount: 49, interval: "month" }, + items: [{ feature_id: "next-members", unlimited: true }], + free_trial: null, + }; + const preview = await post("billing.preview_attach", carry("org_flat", { customize: custom })); + expect(preview.body.next_cycle).toMatchObject({ starts_at: PERIOD_END, total: 49 }); + await post("billing.attach", carry("org_flat", { customize: custom })); + const after = await post("customers.get", { customer_id: "org_flat", expand: ["subscriptions.plan"] }); + expect(after.body.subscriptions[0].plan).toMatchObject({ id: "next-team", price: { amount: 49 } }); + expect(after.body.balances["next-members"].unlimited).toBe(true); + const back = await post("billing.attach", { + customer_id: "org_flat", + plan_id: "team", + version: 1, + remove_plan_ids: ["next-team"], + proration_behavior: "none", + redirect_mode: "never", + customize: { free_trial: null }, + }); + expect(back.status).toBe(200); + const restored = await post("customers.get", { customer_id: "org_flat", expand: ["subscriptions.plan"] }); + expect(restored.body.subscriptions[0].plan).toMatchObject({ id: "team", version: 1, price: { amount: 49 } }); + }); + + it("keeps a running trial, and refuses to remove it without proration", async () => { + const refused = await post("billing.preview_attach", carry("org_trial", { customize: { free_trial: null } })); + expect(refused.status).toBe(400); + expect(refused.body.message).toBe("Cannot set proration_behavior to 'none' when removing a free trial"); + await post("billing.attach", carry("org_trial")); + const after = await post("customers.get", { customer_id: "org_trial" }); + expect(after.body.subscriptions[0]).toMatchObject({ plan_id: "next-team", trial_ends_at: TRIAL_END }); + }); + + it("drops a cancellation at the end of the cycle and keeps the plan scheduled after it", async () => { + await post("billing.attach", carry("org_cancelled", { customize: { free_trial: null } })); + const after = await post("customers.get", { customer_id: "org_cancelled" }); + expect(after.body.subscriptions).toMatchObject([ + { plan_id: "free", status: "scheduled" }, + { plan_id: "next-team", canceled_at: null, expires_at: null }, + ]); + }); + + it("refuses to remove a plan the customer does not have, and deletes a customer", async () => { + const missing = await post("billing.attach", carry("org_taken")); + expect(missing.status).toBe(404); + expect(missing.body.code).toBe("product_not_found"); + expect((await post("customers.delete", { customer_id: "org_taken" })).body).toEqual({ success: true }); + expect((await post("customers.delete", { customer_id: "org_taken" })).status).toBe(404); + }); +}); diff --git a/packages/@emulators/autumn/src/entities.ts b/packages/@emulators/autumn/src/entities.ts index caa6345d..11cceedd 100644 --- a/packages/@emulators/autumn/src/entities.ts +++ b/packages/@emulators/autumn/src/entities.ts @@ -19,9 +19,25 @@ export interface AutumnSubscription { * plan is attached, so a re-attach after a cancel starts from zero usage * rather than inheriting the previous subscription's consumption. */ usage_epoch?: number; + /** The Stripe subscription that bills this plan. Paid plans attached by + * checkout get one; a plan that replaces another through `remove_plan_ids` + * takes over the removed plan's, as Autumn does. Free plans have none. */ + stripe_subscription_id?: string | null; + /** The plan version this subscription bills. Defaults to the plan's current + * version. Older versions come from the plan's `versions`. */ + version?: number; + /** Per-subscription overrides from an attach's `customize`: a flat base + * price and replaced items, as Autumn records a custom plan. */ + customize?: AutumnCustomize | null; [key: string]: unknown; } +/** An attach's `customize`, as far as the emulator models it. */ +export interface AutumnCustomize { + price?: { amount: number; interval: string } | null; + items?: AutumnPlanItem[]; +} + export interface AutumnCustomer extends Entity { customer_id: string; name: string | null; @@ -84,6 +100,16 @@ export interface AutumnPlan extends Entity { items: AutumnPlanItem[]; /** Rank used to classify an attach as upgrade vs downgrade (low to high). */ order: number; + /** Earlier versions of the plan that existing subscriptions may still bill. + * The plan's own fields are its current `version`. */ + versions?: AutumnPlanVersion[]; +} + +/** One earlier version of a plan: its own price and items. */ +export interface AutumnPlanVersion { + version: number; + price: { amount: number; interval: string } | null; + items: AutumnPlanItem[]; } /** A catalog feature. Autumn resolves a `balances.check` feature id against diff --git a/packages/@emulators/autumn/src/index.ts b/packages/@emulators/autumn/src/index.ts index 6f17f00f..7e8aef78 100644 --- a/packages/@emulators/autumn/src/index.ts +++ b/packages/@emulators/autumn/src/index.ts @@ -6,6 +6,7 @@ import { checkoutRoutes } from "./routes/checkout.js"; import { portalRoutes } from "./routes/portal.js"; import { openapiRoutes } from "./routes/openapi.js"; import type { AutumnSubscription, AutumnFeature, AutumnPaymentMethod, AutumnPlan, AutumnPlanItem } from "./entities.js"; +import { withStripeSubscription } from "./serialize.js"; export { getAutumnStore, type AutumnStore } from "./store.js"; export * from "./entities.js"; @@ -40,6 +41,9 @@ export interface AutumnSeedPlan { price?: { amount: number; interval: string } | null; free_trial?: { duration_length: number; duration_type: string; card_required: boolean } | null; items?: AutumnPlanItem[]; + /** Earlier versions existing subscriptions still bill. A seeded subscription + * names one with `version`; `billing.attach` can attach one with `version`. */ + versions?: Array<{ version: number; price?: { amount: number; interval: string } | null; items?: AutumnPlanItem[] }>; } export interface AutumnSeedConfig { @@ -96,6 +100,11 @@ function seedPlans(as: AutumnStore, plans: AutumnSeedPlan[]): void { // Keep a re-seeded plan in its catalog position so seeding twice cannot // silently turn an upgrade into a downgrade. order: existing?.order ?? index, + versions: (plan.versions ?? []).map((version) => ({ + version: version.version, + price: version.price ?? null, + items: version.items ?? [], + })), }; if (existing) { as.plans.update(existing.id, fields); @@ -124,7 +133,9 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: AutumnSee as.customers.update(existing.id, { name: customer.name ?? existing.name, email: customer.email ?? existing.email, - subscriptions: customer.subscriptions ?? existing.subscriptions, + subscriptions: customer.subscriptions + ? customer.subscriptions.map((sub) => withStripeSubscription(as, customer.id, sub)) + : existing.subscriptions, payment_method: paymentMethod ?? existing.payment_method, }); return; @@ -133,7 +144,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: AutumnSee customer_id: customer.id, name: customer.name ?? null, email: customer.email ?? null, - subscriptions: customer.subscriptions ?? [], + subscriptions: (customer.subscriptions ?? []).map((sub) => withStripeSubscription(as, customer.id, sub)), payment_method: paymentMethod, }); }); diff --git a/packages/@emulators/autumn/src/manifest.ts b/packages/@emulators/autumn/src/manifest.ts index 3c130aae..be69b5a9 100644 --- a/packages/@emulators/autumn/src/manifest.ts +++ b/packages/@emulators/autumn/src/manifest.ts @@ -4,7 +4,7 @@ export const manifest: ServiceManifest = { id: "autumn", name: "Autumn", description: - "Stateful Autumn billing emulator: customers (with auto_enable_plan_id, seedable subscriptions and a seedable feature and plan catalog), usage tracking, atomic check-and-consume, subscription cancellation, plan eligibility, a hosted checkout flow for paid plans and card-required free trials, a hosted setup flow for putting a card on file, and a hosted billing portal for changing it.", + "Stateful Autumn billing emulator: customers (with auto_enable_plan_id, seedable subscriptions and a seedable feature and plan catalog), usage tracking, atomic check-and-consume, subscription cancellation, moving a paid customer to another plan on its Stripe subscription (rename, preview and attach with remove_plan_ids), plan eligibility, a hosted checkout flow for paid plans and card-required free trials, a hosted setup flow for putting a card on file, and a hosted billing portal for changing it.", docsUrl: "https://docs.emulators.dev/autumn", surfaces: [ { id: "rest", kind: "rest", title: "Autumn v1 API", status: "partial", basePath: "/v1" }, @@ -29,11 +29,19 @@ export const manifest: ServiceManifest = { { operationId: "customers.get", method: "POST", path: "/v1/customers.get", status: "hand-authored" }, { operationId: "customers.list", method: "POST", path: "/v1/customers.list", status: "hand-authored" }, { operationId: "customers.update", method: "POST", path: "/v1/customers.update", status: "hand-authored" }, + { operationId: "customers.delete", method: "POST", path: "/v1/customers.delete", status: "hand-authored" }, { operationId: "balances.track", method: "POST", path: "/v1/balances.track", status: "hand-authored" }, { operationId: "balances.check", method: "POST", path: "/v1/balances.check", status: "hand-authored" }, { operationId: "balances.update", method: "POST", path: "/v1/balances.update", status: "hand-authored" }, { operationId: "plans.list", method: "POST", path: "/v1/plans.list", status: "hand-authored" }, { operationId: "billing.attach", method: "POST", path: "/v1/billing.attach", status: "hand-authored" }, + { + operationId: "billing.preview_attach", + method: "POST", + path: "/v1/billing.preview_attach", + status: "partial", + }, + { operationId: "billing.verify", method: "POST", path: "/v1/billing.verify", status: "partial" }, { operationId: "billing.update", method: "POST", path: "/v1/billing.update", status: "partial" }, { operationId: "billing.setup_payment", diff --git a/packages/@emulators/autumn/src/routes/api.ts b/packages/@emulators/autumn/src/routes/api.ts index bfab5bde..b98ee387 100644 --- a/packages/@emulators/autumn/src/routes/api.ts +++ b/packages/@emulators/autumn/src/routes/api.ts @@ -14,8 +14,11 @@ import { compactUsage, hasBillingCycle, knownFeature, + billedPlan, type CancelAction, } from "../serialize.js"; +import type { AutumnCustomer, AutumnCustomize, AutumnPlan, AutumnSubscription } from "../entities.js"; +import type { AutumnStore } from "../store.js"; /** Autumn's `expand` request param: an array of field names, and (for hand-rolled * HTTP callers) a comma-separated string. Anything else expands nothing. */ @@ -32,6 +35,89 @@ function parseExpand(value: unknown): string[] { const CANCEL_ACTIONS = new Set(["cancel_immediately", "cancel_end_of_cycle", "uncancel"]); +/** A subscription that bills now. Autumn reports a running trial as active; + * the emulator marks it `trialing`. */ +const isLive = (sub: AutumnSubscription) => sub.status === "active" || sub.status === "trialing"; + +type Refusal = { status: 400 | 404 | 409; message: string; code: string }; + +/** What an attach with `remove_plan_ids` does: the new plan takes over the + * Stripe subscription of the first paid plan it removes, keeping its billing + * period and, unless the attach removes it, its trial. With + * `proration_behavior: "none"` nothing is charged now. A cancellation at the + * end of the cycle does not carry over: the new plan renews. Plans scheduled to + * follow stay scheduled. Returns null when the attach takes over no paid + * subscription or prorates, which this emulator routes through checkout. */ +function replacement( + store: AutumnStore, + customer: AutumnCustomer, + plan: AutumnPlan, + body: Record, +): Refusal | { next: AutumnSubscription[]; sub: AutumnSubscription; removed: AutumnSubscription[] } | null { + const removeIds = body.remove_plan_ids; + if (!Array.isArray(removeIds) || removeIds.length === 0) return null; + const subs = customer.subscriptions ?? []; + if (subs.some((sub) => isLive(sub) && sub.plan_id === plan.plan_id)) { + return { + status: 409, + message: `Cannot attach because the customer's current product '${plan.name}' is the same as the product being attached`, + code: "plan_already_attached", + }; + } + const removed: AutumnSubscription[] = []; + for (const id of removeIds) { + const sub = subs.find((candidate) => isLive(candidate) && candidate.plan_id === id); + if (!sub) { + return { + status: 404, + message: `No active plan '${String(id)}' found on the customer to remove`, + code: "product_not_found", + }; + } + removed.push(sub); + } + const taken = removed.find((sub) => sub.stripe_subscription_id); + if (!taken || body.proration_behavior !== "none") return null; + const customize = (body.customize ?? {}) as Record; + const removesTrial = + ("free_trial" in customize && customize.free_trial === null) || ("free_trial" in body && body.free_trial === null); + const now = Date.now(); + const trialing = taken.status === "trialing" || (taken.trial_ends_at ?? 0) > now; + if (trialing && removesTrial) { + return { + status: 400, + message: "Cannot set proration_behavior to 'none' when removing a free trial", + code: "invalid_request", + }; + } + const override: AutumnCustomize = { + ...("price" in customize ? { price: (customize.price as AutumnCustomize["price"]) ?? null } : {}), + ...(Array.isArray(customize.items) ? { items: customize.items as AutumnCustomize["items"] } : {}), + }; + const sub: AutumnSubscription = { + id: `sub_emulate_${customer.id}_${plan.plan_id}`, + plan_id: plan.plan_id, + status: trialing ? "trialing" : "active", + started_at: now, + current_period_start: taken.current_period_start ?? null, + current_period_end: taken.current_period_end ?? null, + trial_ends_at: trialing ? (taken.trial_ends_at ?? null) : null, + canceled_at: null, + expires_at: null, + quantity: 1, + usage_epoch: store.events.all().reduce((max, event) => Math.max(max, event.id), 0), + stripe_subscription_id: taken.stripe_subscription_id, + ...(typeof body.version === "number" ? { version: body.version } : {}), + ...(Object.keys(override).length > 0 ? { customize: override } : {}), + }; + const next = [...subs.filter((existing) => !removed.includes(existing)), sub]; + return { next, sub, removed }; +} + +function refuse(c: { json: (body: unknown, status: number) => Response }, refusal: Refusal) { + return c.json({ message: refusal.message, code: refusal.code }, refusal.status); +} + /** Autumn v1 RPC-style API (paths mirror autumn-js: /v1/.). */ export function autumnApiRoutes(ctx: RouteContext): void { const { app, store, baseUrl } = ctx; @@ -98,13 +184,136 @@ export function autumnApiRoutes(ctx: RouteContext): void { const store = as(); const customer = store.customers.findOneBy("customer_id", id); if (!customer) return c.json({ message: "Customer not found", code: "not_found" }, 404); + // `new_customer_id` gives the customer a new id. Its Stripe customer, + // subscriptions and usage stay; an id another customer has is refused. + const newId = body.new_customer_id ?? body.newCustomerId; + if (typeof newId === "string" && newId !== "" && newId !== id) { + if (store.customers.findOneBy("customer_id", newId)) { + return c.json( + { + message: `Customer with ID ${newId} already exists, can't change to this ID`, + code: "customer_already_exists", + }, + 409, + ); + } + for (const collection of [store.events, store.checkouts, store.setups, store.portals] as const) { + for (const row of collection.findBy("customer_id", id)) { + (collection as typeof store.events).update(row.id, { customer_id: newId }); + } + } + } const updated = store.customers.update(customer.id, { name: typeof body.name === "string" ? body.name : customer.name, email: typeof body.email === "string" ? body.email : customer.email, + ...(typeof newId === "string" && newId !== "" ? { customer_id: newId } : {}), })!; return c.json(serializeCustomer(store, updated)); }); + // Delete a customer. Its Stripe customer is left alone unless + // `delete_in_stripe`, which the emulator has no Stripe to apply to. + app.post("/v1/customers.delete", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Record; + const id = String(body.customer_id ?? body.customerId ?? ""); + const store = as(); + const customer = store.customers.findOneBy("customer_id", id); + if (!customer) return c.json({ message: `Customer ${id} not found`, code: "customer_not_found" }, 404); + store.customers.delete(customer.id); + return c.json({ success: true }); + }); + + // Compare each paid plan's Stripe subscription with Autumn's record. The + // emulator's Stripe is its own record, so every subscription is correct. + app.post("/v1/billing.verify", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Record; + const id = String(body.customer_id ?? body.customerId ?? ""); + const store = as(); + const customer = store.customers.findOneBy("customer_id", id); + if (!customer) return c.json({ message: `Customer ${id} not found`, code: "customer_not_found" }, 404); + const ids = Array.from( + new Set( + (customer.subscriptions ?? []) + .filter((sub) => isLive(sub) && sub.stripe_subscription_id) + .map((sub) => sub.stripe_subscription_id as string), + ), + ); + return c.json({ + customer_id: id, + customer_mismatches: [], + subscriptions: ids.map((stripeId) => ({ stripe_subscription_id: stripeId, status: "correct", mismatches: [] })), + }); + }); + + // What an attach would charge and change, changing nothing. + app.post("/v1/billing.preview_attach", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as Record; + const customerId = String(body.customer_id ?? body.customerId ?? ""); + const planId = String(body.plan_id ?? body.planId ?? ""); + const store = as(); + const plan = store.plans.findOneBy("plan_id", planId); + if (!plan) return c.json({ message: `Product ${planId} not found`, code: "product_not_found" }, 404); + const customer = store.customers.findOneBy("customer_id", customerId); + if (!customer) return c.json({ message: `Customer ${customerId} not found`, code: "customer_not_found" }, 404); + const change = replacement(store, customer, plan, body); + if (change && "status" in change) return refuse(c, change); + if (change) { + const billed = billedPlan(store, change.sub); + return c.json({ + customer_id: customerId, + line_items: [], + subtotal: 0, + total: 0, + currency: "usd", + next_cycle: change.sub.current_period_end + ? { + starts_at: change.sub.current_period_end, + subtotal: billed.price?.amount ?? 0, + total: billed.price?.amount ?? 0, + line_items: [], + } + : null, + incoming: [ + { plan_id: plan.plan_id, feature_quantities: [], effective_at: null, canceled_at: null, expires_at: null }, + ], + outgoing: change.removed.map((sub) => ({ + plan_id: sub.plan_id, + feature_quantities: [], + effective_at: Date.now(), + canceled_at: sub.canceled_at ?? null, + expires_at: sub.expires_at ?? null, + })), + redirect_to_checkout: false, + checkout_type: null, + }); + } + const requiresPayment = plan.price != null || plan.free_trial?.card_required === true; + const outgoing = (customer.subscriptions ?? []).filter((sub) => { + const other = store.plans.findOneBy("plan_id", sub.plan_id); + return isLive(sub) && !plan.add_on && !other?.add_on && (other?.group ?? null) === plan.group; + }); + return c.json({ + customer_id: customerId, + line_items: [], + subtotal: plan.price?.amount ?? 0, + total: plan.price?.amount ?? 0, + currency: "usd", + next_cycle: null, + incoming: [ + { plan_id: plan.plan_id, feature_quantities: [], effective_at: null, canceled_at: null, expires_at: null }, + ], + outgoing: outgoing.map((sub) => ({ + plan_id: sub.plan_id, + feature_quantities: [], + effective_at: Date.now(), + canceled_at: sub.canceled_at ?? null, + expires_at: sub.expires_at ?? null, + })), + redirect_to_checkout: requiresPayment, + checkout_type: requiresPayment ? "stripe_checkout" : null, + }); + }); + app.post("/v1/balances.track", async (c) => { const body = (await c.req.json().catch(() => ({}))) as Record; const customerId = String(body.customer_id ?? body.customerId ?? ""); @@ -259,6 +468,12 @@ export function autumnApiRoutes(ctx: RouteContext): void { const plan = store.plans.findOneBy("plan_id", planId); if (!plan) return c.json({ message: `Product ${planId} not found`, code: "product_not_found" }, 404); const customer = ensureCustomer(store, customerId, body); + const change = replacement(store, customer, plan, body); + if (change && "status" in change) return refuse(c, change); + if (change) { + store.customers.update(customer.id, { subscriptions: change.next }); + return c.json({ customer_id: customerId, payment_url: null }); + } const requiresPayment = plan.price != null || plan.free_trial?.card_required === true; if (requiresPayment) { diff --git a/packages/@emulators/autumn/src/routes/openapi.ts b/packages/@emulators/autumn/src/routes/openapi.ts index b072942b..08af8665 100644 --- a/packages/@emulators/autumn/src/routes/openapi.ts +++ b/packages/@emulators/autumn/src/routes/openapi.ts @@ -117,11 +117,61 @@ function buildSpec(baseUrl: string): Record { customer_id: { type: "string" }, name: { type: "string" }, email: { type: "string" }, + new_customer_id: { + type: "string", + description: "A new id for the customer. Its subscriptions, Stripe subscription and usage stay.", + }, }, ["customer_id"], "The customer fields to update.", ), - responses: { "200": ok("The updated customer."), "404": ok("Not found.") }, + responses: { + "200": ok("The updated customer."), + "404": ok("Not found."), + "409": ok("`new_customer_id` belongs to another customer."), + }, + }, + }, + "/v1/customers.delete": { + post: { + operationId: "customers.delete", + tags: ["customers"], + summary: "Delete a customer", + requestBody: jsonBody({ customer_id: { type: "string" } }, ["customer_id"], "The customer to delete."), + responses: { "200": ok("`{ success: true }`."), "404": ok("Not found.") }, + }, + }, + "/v1/billing.preview_attach": { + post: { + operationId: "billing.preview_attach", + tags: ["billing"], + summary: "Preview an attach without changing anything", + requestBody: jsonBody( + { + customer_id: { type: "string" }, + plan_id: { type: "string" }, + remove_plan_ids: { type: "array", items: { type: "string" } }, + proration_behavior: { type: "string" }, + redirect_mode: { type: "string" }, + version: { type: "number" }, + customize: { type: "object" }, + }, + ["customer_id", "plan_id"], + "The same body as `billing.attach`. With `remove_plan_ids` and `proration_behavior: none`, the new plan takes over the removed paid plan's Stripe subscription: `total` is 0 and `next_cycle.starts_at` is that subscription's period end.", + ), + responses: { + "200": ok("The charge now, the next cycle and the plans that change."), + "404": ok("Unknown customer or plan."), + }, + }, + }, + "/v1/billing.verify": { + post: { + operationId: "billing.verify", + tags: ["billing"], + summary: "Compare a customer's Stripe subscriptions with its plans", + requestBody: jsonBody({ customer_id: { type: "string" } }, ["customer_id"], "The customer to verify."), + responses: { "200": ok("Each paid plan's Stripe subscription, always `correct`."), "404": ok("Not found.") }, }, }, "/v1/balances.track": { diff --git a/packages/@emulators/autumn/src/serialize.ts b/packages/@emulators/autumn/src/serialize.ts index f5997fba..25326981 100644 --- a/packages/@emulators/autumn/src/serialize.ts +++ b/packages/@emulators/autumn/src/serialize.ts @@ -62,10 +62,45 @@ export function ensureCustomer(as: AutumnStore, id: string, data: { name?: unkno * usage-based item price. A plan that bills nothing has no cycle, which is * what makes it the free tier of its group and what makes an end-of-cycle * cancellation meaningless for it. */ -export function hasBillingCycle(plan: AutumnPlan): boolean { +export function hasBillingCycle(plan: Pick): boolean { return plan.price != null || (plan.items ?? []).some((item) => item.price != null); } +/** What a subscription bills: its plan at the subscription's version (an + * earlier one from the plan's `versions`), with the subscription's own + * `customize` laid over it. Autumn expands this as `subscriptions.plan`. */ +export function billedPlan( + as: AutumnStore, + sub: AutumnSubscription, +): { plan: AutumnPlan | undefined; version: number; price: AutumnPlan["price"]; items: AutumnPlanItem[] } { + const plan = as.plans.findOneBy("plan_id", sub.plan_id); + const version = sub.version ?? plan?.version ?? 1; + const earlier = plan?.versions?.find((candidate) => candidate.version === version); + const base = earlier ?? { price: plan?.price ?? null, items: plan?.items ?? [] }; + const customize = sub.customize ?? undefined; + return { + plan, + version, + price: customize && "price" in customize ? (customize.price ?? null) : base.price, + items: customize?.items ?? base.items, + }; +} + +/** A paid subscription bills on a Stripe subscription; give a seeded one a + * stable id when it names none. Free plans have none. */ +export function withStripeSubscription( + as: AutumnStore, + customerId: string, + sub: AutumnSubscription, +): AutumnSubscription { + if (sub.stripe_subscription_id !== undefined) return sub; + const billed = billedPlan(as, sub); + return { + ...sub, + stripe_subscription_id: hasBillingCycle(billed) ? `sub_emulate_stripe_${customerId}_${sub.plan_id}` : null, + }; +} + /** The highest event id issued so far. A subscription records this at * activation so its grants only see usage tracked afterwards. */ function usageWatermark(as: AutumnStore): number { @@ -108,6 +143,14 @@ export function attachPlan( if (plan.add_on) return true; return (other?.group ?? null) !== plan.group; }); + // A paid plan merges into the Stripe subscription of the paid plan it + // replaces, as Autumn does by default; otherwise checkout opened a new one. + const replaced = (customer.subscriptions ?? []).find( + (existing) => !kept.includes(existing) && existing.stripe_subscription_id, + ); + sub.stripe_subscription_id = paid + ? (replaced?.stripe_subscription_id ?? `sub_emulate_stripe_${customer.id}_${plan.plan_id}_${now}`) + : null; const trialsUsed = trialing ? Array.from(new Set([...(customer.trials_used ?? []), plan.plan_id])) : (customer.trials_used ?? []); @@ -259,8 +302,22 @@ function serializeBalanceFeature(as: AutumnStore, featureId: string): Record { +function serializeSubscription(as: AutumnStore, sub: AutumnSubscription, expandPlan: boolean): Record { + const billed = expandPlan ? billedPlan(as, sub) : undefined; return { + ...(billed?.plan + ? { + plan: { + ...serializePlan(as, undefined, billed.plan), + version: billed.version, + version_slug: `v${billed.version}`, + price: billed.price + ? { amount: billed.price.amount, interval: billed.price.interval, interval_count: 1 } + : null, + items: billed.items.map(serializePlanItem), + }, + } + : {}), id: sub.id ?? `sub_emulate_${sub.plan_id}`, plan_id: sub.plan_id, auto_enable: false, @@ -317,8 +374,7 @@ interface Granting { function balancesFor(as: AutumnStore, customer: AutumnCustomer): Record { const granting = new Map(); for (const sub of activeSubscriptions(customer)) { - const plan = as.plans.findOneBy("plan_id", sub.plan_id); - for (const item of plan?.items ?? []) { + for (const item of billedPlan(as, sub).items) { const list = granting.get(item.feature_id) ?? []; list.push({ sub, item, window: resetWindow(sub, item) }); granting.set(item.feature_id, list); @@ -498,7 +554,9 @@ export function serializeCustomer( metadata: {}, send_email_receipts: true, billing_controls: {}, - subscriptions: (customer.subscriptions ?? []).map(serializeSubscription), + subscriptions: (customer.subscriptions ?? []).map((sub) => + serializeSubscription(as, sub, options.expand?.includes("subscriptions.plan") === true), + ), purchases: [], licenses: [], balances: balancesFor(as, customer), @@ -556,6 +614,17 @@ function eligibilityFor(as: AutumnStore, customer: AutumnCustomer, plan: AutumnP }; } +function serializePlanItem(it: AutumnPlanItem): Record { + return { + feature_id: it.feature_id, + included: it.included ?? 0, + unlimited: it.unlimited === true, + pooled: false, + reset: it.reset ?? null, + price: it.price ?? null, + }; +} + export function serializePlan( as: AutumnStore, customer: AutumnCustomer | undefined, @@ -572,14 +641,7 @@ export function serializePlan( add_on: plan.add_on, auto_enable: plan.auto_enable, price: plan.price ? { amount: plan.price.amount, interval: plan.price.interval, interval_count: 1 } : null, - items: (plan.items ?? []).map((it) => ({ - feature_id: it.feature_id, - included: it.included ?? 0, - unlimited: it.unlimited === true, - pooled: false, - reset: it.reset ?? null, - price: it.price ?? null, - })), + items: (plan.items ?? []).map(serializePlanItem), free_trial: plan.free_trial ? { duration_length: plan.free_trial.duration_length, diff --git a/skills/autumn/SKILL.md b/skills/autumn/SKILL.md index f1f05a7a..8cf058ea 100644 --- a/skills/autumn/SKILL.md +++ b/skills/autumn/SKILL.md @@ -6,7 +6,7 @@ allowed-tools: Bash(npx emulate:*), Bash(curl:*) # Autumn Emulator -Stateful Autumn billing emulation: customers (`customers.get_or_create`, including `auto_enable_plan_id`, and the read-only `customers.get`, a 404 `customer_not_found` for an unknown id), a seedable feature and plan catalog with per-customer eligibility (`plans.list`, `features.list`), usage tracking (`balances.track`), balance reconciliation (`balances.update`), feature access checks with atomic check-and-consume (`balances.check`), subscription changes (`billing.update`), `billing.attach` / `billing.setup_payment` / `billing.open_customer_portal`, a hosted checkout flow for paid plans and card-required free trials, a hosted setup flow for putting a card on file, and a hosted billing portal for changing it. +Stateful Autumn billing emulation: customers (`customers.get_or_create`, including `auto_enable_plan_id`, and the read-only `customers.get`, a 404 `customer_not_found` for an unknown id), a seedable feature and plan catalog with per-customer eligibility (`plans.list`, `features.list`), usage tracking (`balances.track`), balance reconciliation (`balances.update`), feature access checks with atomic check-and-consume (`balances.check`), subscription changes (`billing.update`), `billing.attach` / `billing.preview_attach` / `billing.setup_payment` / `billing.open_customer_portal`, moving a paid customer to another plan on its Stripe subscription (`customers.update` with `new_customer_id`, `remove_plan_ids`, `billing.verify`, `customers.delete`), a hosted checkout flow for paid plans and card-required free trials, a hosted setup flow for putting a card on file, and a hosted billing portal for changing it. The request and response shapes, status codes and error codes below were taken from the real Autumn sandbox API at `x-api-version: 2.3.0` rather than from the docs, so an application can point at the emulator with no emulator-specific branches. @@ -95,11 +95,11 @@ Because it consumes, a lost response is not evidence that nothing was spent: do Three outcomes, matching real Autumn: -| Case | Response | -| --- | --- | -| Feature the catalog does not declare | `404 { "code": "feature_not_found" }` | +| Case | Response | +| ---------------------------------------------- | ------------------------------------------- | +| Feature the catalog does not declare | `404 { "code": "feature_not_found" }` | | Declared feature no active subscription grants | `200 { "allowed": false, "balance": null }` | -| Granted feature | `200` with the balance after any deduction | +| Granted feature | `200` with the balance after any deduction | ## Cancel a subscription @@ -121,6 +121,38 @@ Omit `plan_id` to apply the action to every live subscription. A plan the custom Attaching a plan after an immediate cancel issues fresh grants, so usage starts from zero rather than inheriting the cancelled subscription's consumption. +## Move a paid customer to another plan on its Stripe subscription + +Two catalogs in one Autumn account (for example an old product's plans and a new product's) can hand a paying customer from one to the other without a new subscription or a charge, as the real sandbox does it: + +1. `customers.update` with `new_customer_id` renames the customer. Its subscriptions, Stripe subscription and usage stay; an id another customer has is a `409 customer_already_exists`. +2. `billing.preview_attach` takes the same body as `billing.attach` and changes nothing. +3. `billing.attach` with `remove_plan_ids`, `proration_behavior: "none"` and `redirect_mode: "never"` expires the removed plans and puts the new plan on the Stripe subscription of the first paid one it removes: same billing period, nothing charged now (`total: 0`), and a running trial keeps its end. Removing a running trial without proration is a `400`, as in Autumn. A cancellation at the end of the cycle does not carry over, and a plan scheduled to follow stays scheduled. `customize` can set a flat `price` and replace `items` for this subscription; `version` attaches an earlier version of a plan. A plan the customer does not hold 404s with `product_not_found`, and attaching the plan it already has is a `409 plan_already_attached`. + +`customers.get` with `expand: ["subscriptions.plan"]` returns each subscription's plan at the version it bills, with its `customize`. `billing.verify` lists the Stripe subscription of each paid plan (always `correct`: the emulator's Stripe is its own record), and `customers.delete` deletes a customer. + +Seed a customer on an earlier version, and its Stripe subscription, through the seed config: a plan's `versions` holds earlier versions' `price` and `items`, and a subscription names `version` and optionally `stripe_subscription_id` (a paid one gets a stable id otherwise). + +```ts +await fetch(`${base}/v1/customers.update`, { + method: "POST", + headers, + body: JSON.stringify({ customer_id: "org_123", new_customer_id: "next:org_123" }), +}); +await fetch(`${base}/v1/billing.attach`, { + method: "POST", + headers, + body: JSON.stringify({ + customer_id: "next:org_123", + plan_id: "next-team", + remove_plan_ids: ["team"], + proration_behavior: "none", + redirect_mode: "never", + customize: { free_trial: null }, + }), +}); +``` + ## Set a balance directly `balances.update` sets a customer's balance for one feature. Exactly one of `usage`, `remaining`, or `add_to_balance` is required. Use it for continuous-use features (seats, storage) where the app reconciles an absolute count rather than tracking deltas. The update is recorded as an adjustment event, so `events.list` shows the reconciliation and `balances.check` stays consistent. Unknown customers 404 with `customer_not_found`; a feature the customer's plan does not carry 404s with `not_found`.