Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
208 changes: 208 additions & 0 deletions packages/@emulators/autumn/src/__tests__/autumn-carry-over.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof serve>;

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<string, unknown> = {}) => ({
customer_id: customerId,
plan_id: "next-team",
remove_plan_ids: ["team"],
proration_behavior: "none",
redirect_mode: "never",
...extra,
});
const subscribed = (sub: Record<string, unknown>) => ({
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<void>((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);
});
});
26 changes: 26 additions & 0 deletions packages/@emulators/autumn/src/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions packages/@emulators/autumn/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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,
});
});
Expand Down
10 changes: 9 additions & 1 deletion packages/@emulators/autumn/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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",
Expand Down
Loading
Loading