building plan change utility functions

This commit is contained in:
johnyeo
2026-05-19 18:41:19 +01:00
parent 6ed7f6387c
commit 3d2c4ed72d
21 changed files with 1628 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import {
type AutumnBillingPlan,
type BillingChangeResponse,
BillingChangeResponseSchema,
type FullCustomer,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildPlanChanges } from "./buildPlanChanges";
export const buildBillingChangeResponse = ({
ctx: _ctx,
originalFullCustomer,
autumnBillingPlan,
}: {
ctx: AutumnContext;
originalFullCustomer: FullCustomer;
autumnBillingPlan: AutumnBillingPlan;
}): BillingChangeResponse => {
return BillingChangeResponseSchema.parse({
object: "billing.plans_changed",
customer_id:
originalFullCustomer.id ?? originalFullCustomer.internal_id,
plan_changes: buildPlanChanges({ autumnBillingPlan }),
});
};

View File

@@ -0,0 +1,76 @@
import {
type AutumnBillingPlan,
CusProductStatus,
type CustomerPlanChange,
} from "@autumn/shared";
import { buildPlanItemChanges } from "./buildPlanItemChanges";
import { buildPreviousAttributes } from "./buildPreviousAttributes";
import { toCustomerPlanSnapshot } from "./toCustomerPlanSnapshot";
export const buildPlanChanges = ({
autumnBillingPlan,
}: {
autumnBillingPlan: AutumnBillingPlan;
}): CustomerPlanChange[] => {
const changes: CustomerPlanChange[] = [];
for (const cusProduct of autumnBillingPlan.insertCustomerProducts ?? []) {
const action =
cusProduct.status === CusProductStatus.Scheduled
? "scheduled"
: "activated";
changes.push({
action,
plan: toCustomerPlanSnapshot({ cusProduct }),
previous_attributes: null,
item_changes: [],
});
}
const updates = [
...(autumnBillingPlan.updateCustomerProduct
? [autumnBillingPlan.updateCustomerProduct]
: []),
...(autumnBillingPlan.updateCustomerProducts ?? []),
];
for (const update of updates) {
const originalCusProduct = update.customerProduct;
const previousAttributes = buildPreviousAttributes({
originalCusProduct,
updates: update.updates,
});
const action =
update.updates.status === CusProductStatus.Expired
? "expired"
: "updated";
changes.push({
action,
plan: toCustomerPlanSnapshot({
cusProduct: originalCusProduct,
overrides: {
status: update.updates.status,
canceled_at: update.updates.canceled_at,
ended_at: update.updates.ended_at,
},
}),
previous_attributes: previousAttributes,
item_changes: [],
});
}
for (const patch of autumnBillingPlan.patchCustomerProducts ?? []) {
changes.push({
action: "updated",
plan: toCustomerPlanSnapshot({ cusProduct: patch.customerProduct }),
previous_attributes: {},
item_changes: buildPlanItemChanges({
insertCustomerEntitlements: patch.insertCustomerEntitlements,
deleteCustomerEntitlements: patch.deleteCustomerEntitlements,
}),
});
}
return changes;
};

View File

@@ -0,0 +1,23 @@
import type {
CustomerPlanItemChange,
FullCustomerEntitlement,
} from "@autumn/shared";
export const buildPlanItemChanges = ({
insertCustomerEntitlements,
deleteCustomerEntitlements,
}: {
insertCustomerEntitlements?: FullCustomerEntitlement[];
deleteCustomerEntitlements?: FullCustomerEntitlement[];
}): CustomerPlanItemChange[] => {
const changes: CustomerPlanItemChange[] = [];
for (const ent of insertCustomerEntitlements ?? []) {
changes.push({ action: "created", feature_id: ent.feature_id });
}
for (const ent of deleteCustomerEntitlements ?? []) {
changes.push({ action: "deleted", feature_id: ent.feature_id });
}
return changes;
};

View File

@@ -0,0 +1,39 @@
import type {
CustomerProductUpdateSchema,
FullCusProduct,
} from "@autumn/shared";
import type { z } from "zod/v4";
type CustomerProductUpdate = z.infer<typeof CustomerProductUpdateSchema>;
export const buildPreviousAttributes = ({
originalCusProduct,
updates,
}: {
originalCusProduct: FullCusProduct;
updates: CustomerProductUpdate["updates"];
}): Record<string, unknown> => {
const previous: Record<string, unknown> = {};
if (
updates.status !== undefined &&
updates.status !== originalCusProduct.status
) {
previous.status = originalCusProduct.status;
}
const originalCanceledAt = originalCusProduct.canceled_at ?? null;
if (
updates.canceled_at !== undefined &&
updates.canceled_at !== originalCanceledAt
) {
previous.canceled_at = originalCanceledAt;
}
const originalEndedAt = originalCusProduct.ended_at ?? null;
if (updates.ended_at !== undefined && updates.ended_at !== originalEndedAt) {
previous.expires_at = originalEndedAt;
}
return previous;
};

View File

@@ -0,0 +1,5 @@
export * from "./buildBillingChangeResponse";
export * from "./buildPlanChanges";
export * from "./buildPlanItemChanges";
export * from "./buildPreviousAttributes";
export * from "./toCustomerPlanSnapshot";

View File

@@ -0,0 +1,61 @@
import {
type CustomerPlanSnapshot,
type CustomerPlanStatus,
CusProductStatus,
type FullCusProduct,
} from "@autumn/shared";
const cusProductStatusToPlanStatus = (
status: CusProductStatus,
): CustomerPlanStatus => {
switch (status) {
case CusProductStatus.Active:
return "active";
case CusProductStatus.Trialing:
return "trialing";
case CusProductStatus.PastDue:
return "past_due";
case CusProductStatus.Scheduled:
return "scheduled";
case CusProductStatus.Expired:
return "expired";
case CusProductStatus.Paused:
return "paused";
default:
return "active";
}
};
export type CustomerPlanSnapshotOverrides = Partial<{
status: CusProductStatus;
canceled_at: number | null;
ended_at: number | null;
}>;
export const toCustomerPlanSnapshot = ({
cusProduct,
overrides,
}: {
cusProduct: FullCusProduct;
overrides?: CustomerPlanSnapshotOverrides;
}): CustomerPlanSnapshot => {
const status = overrides?.status ?? cusProduct.status;
const canceledAt =
overrides?.canceled_at !== undefined
? overrides.canceled_at
: (cusProduct.canceled_at ?? null);
const endedAt =
overrides?.ended_at !== undefined
? overrides.ended_at
: (cusProduct.ended_at ?? null);
return {
plan_id: cusProduct.product_id,
status: cusProductStatusToPlanStatus(status),
started_at: cusProduct.starts_at ?? null,
canceled_at: canceledAt,
expires_at: endedAt,
current_period_start: null,
current_period_end: null,
};
};

View File

@@ -0,0 +1,248 @@
import { describe, expect, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse";
import {
expectBillingChangeResponse,
expectPlanChange,
findPlanChange,
} from "./helpers/expectBillingChange";
import { logChangeResponse } from "./helpers/logChangeResponse";
import {
makeAutumnBillingPlan,
makeUpdate,
} from "./helpers/makeAutumnBillingPlan";
import { makeFullCusProduct } from "./helpers/makeFullCusProduct";
import { makeFullCustomer } from "./helpers/makeFullCustomer";
const NOW = 1_710_000_000_000;
const PERIOD_END = NOW + 30 * 24 * 60 * 60 * 1000;
const ctx = {} as AutumnContext;
describe("buildBillingChangeResponse — attach", () => {
test("new customer, free plan attach", () => {
const free = makeFullCusProduct({ planId: "free", startedAt: NOW });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer(),
autumnBillingPlan: makeAutumnBillingPlan({ inserts: [free] }),
});
logChangeResponse("attach / new customer, free plan", response);
expectBillingChangeResponse(response, {
customerId: "cus_test",
activated: ["free"],
});
expectPlanChange(findPlanChange(response, { action: "activated", planId: "free" }), {
action: "activated",
planId: "free",
previousAttributes: null,
itemChanges: [],
});
});
test("new customer, paid plan attach", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer(),
autumnBillingPlan: makeAutumnBillingPlan({ inserts: [pro] }),
});
logChangeResponse("attach / new customer, paid plan", response);
expectBillingChangeResponse(response, { activated: ["pro"] });
expectPlanChange(findPlanChange(response, { action: "activated", planId: "pro" }), {
action: "activated",
planId: "pro",
previousAttributes: null,
});
});
test("immediate upgrade (free → pro)", () => {
const free = makeFullCusProduct({ planId: "free", startedAt: NOW - 1000 });
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [free] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [pro],
update: makeUpdate({
customerProduct: free,
updates: {
status: CusProductStatus.Expired,
ended_at: NOW,
canceled: true,
canceled_at: NOW,
},
}),
}),
});
logChangeResponse("attach / immediate upgrade (free → pro)", response);
expectBillingChangeResponse(response, {
expired: ["free"],
activated: ["pro"],
});
expectPlanChange(findPlanChange(response, { action: "expired", planId: "free" }), {
action: "expired",
planId: "free",
previousAttributes: {
status: CusProductStatus.Active,
canceled_at: null,
expires_at: null,
},
});
expectPlanChange(findPlanChange(response, { action: "activated", planId: "pro" }), {
action: "activated",
planId: "pro",
previousAttributes: null,
});
});
test("upgrade that also clears an existing scheduled downgrade", () => {
const business = makeFullCusProduct({
planId: "business",
startedAt: NOW - 1000,
});
const scheduledPro = makeFullCusProduct({
planId: "pro",
status: CusProductStatus.Scheduled,
startedAt: PERIOD_END,
id: "cp_pro_scheduled",
});
const premium = makeFullCusProduct({ planId: "premium", startedAt: NOW });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({
customerProducts: [business, scheduledPro],
}),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [premium],
update: makeUpdate({
customerProduct: business,
updates: {
status: CusProductStatus.Expired,
ended_at: NOW,
canceled: true,
canceled_at: NOW,
},
}),
deleteOne: scheduledPro,
}),
});
logChangeResponse(
"attach / upgrade clears existing scheduled downgrade",
response,
);
// Deleted (scheduled) products are intentionally ignored — they never
// went live and aren't a customer-facing lifecycle event.
expectBillingChangeResponse(response, {
expired: ["business"],
activated: ["premium"],
});
expect(
findPlanChange(response, { action: "expired", planId: "pro" }),
).toBeUndefined();
});
test("scheduled downgrade via starts_at", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 });
const scheduledFree = makeFullCusProduct({
planId: "free",
status: CusProductStatus.Scheduled,
startedAt: PERIOD_END,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [scheduledFree],
update: makeUpdate({
customerProduct: pro,
updates: {
canceled: true,
canceled_at: NOW,
ended_at: PERIOD_END,
},
}),
}),
});
logChangeResponse("attach / scheduled downgrade (starts_at)", response);
expectBillingChangeResponse(response, {
updated: ["pro"],
scheduled: ["free"],
});
expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), {
action: "updated",
planId: "pro",
previousAttributes: { canceled_at: null, expires_at: null },
});
const scheduled = findPlanChange(response, {
action: "scheduled",
planId: "free",
});
expect(scheduled?.plan.status).toBe("scheduled");
});
test("attach addon (no current product mutated)", () => {
const base = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 });
const addon = makeFullCusProduct({ planId: "seats_addon", startedAt: NOW });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [base] }),
autumnBillingPlan: makeAutumnBillingPlan({ inserts: [addon] }),
});
logChangeResponse("attach / addon", response);
expectBillingChangeResponse(response, { activated: ["seats_addon"] });
});
test("trial revert: pause current and attach trial", () => {
const base = makeFullCusProduct({
planId: "pro",
status: CusProductStatus.Active,
startedAt: NOW - 1000,
});
const trialProduct = makeFullCusProduct({
planId: "premium",
status: CusProductStatus.Trialing,
startedAt: NOW,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [base] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [trialProduct],
update: makeUpdate({
customerProduct: base,
updates: { status: CusProductStatus.Paused },
}),
}),
});
logChangeResponse("attach / trial revert (pause current)", response);
expectBillingChangeResponse(response, {
updated: ["pro"],
activated: ["premium"],
});
const updated = findPlanChange(response, {
action: "updated",
planId: "pro",
});
expectPlanChange(updated, {
action: "updated",
planId: "pro",
previousAttributes: { status: CusProductStatus.Active },
});
expect(updated?.plan.status).toBe("paused");
});
});

View File

@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse";
import {
expectBillingChangeResponse,
findPlanChange,
} from "./helpers/expectBillingChange";
import { logChangeResponse } from "./helpers/logChangeResponse";
import {
makeAutumnBillingPlan,
makeUpdate,
} from "./helpers/makeAutumnBillingPlan";
import { makeFullCusProduct } from "./helpers/makeFullCusProduct";
import { makeFullCustomer } from "./helpers/makeFullCustomer";
const NOW = 1_710_000_000_000;
const PHASE_TWO = NOW + 30 * 24 * 60 * 60 * 1000;
const PHASE_THREE = NOW + 60 * 24 * 60 * 60 * 1000;
const ctx = {} as AutumnContext;
describe("buildBillingChangeResponse — createSchedule", () => {
test("multi-phase schedule replacing current product", () => {
const free = makeFullCusProduct({ planId: "free", startedAt: NOW - 1000 });
const pro = makeFullCusProduct({
planId: "pro",
status: CusProductStatus.Active,
startedAt: NOW,
});
const premiumPhase = makeFullCusProduct({
planId: "premium",
status: CusProductStatus.Scheduled,
startedAt: PHASE_TWO,
});
const enterprisePhase = makeFullCusProduct({
planId: "enterprise",
status: CusProductStatus.Scheduled,
startedAt: PHASE_THREE,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [free] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [pro, premiumPhase, enterprisePhase],
update: makeUpdate({
customerProduct: free,
updates: {
status: CusProductStatus.Expired,
ended_at: NOW,
canceled: true,
canceled_at: NOW,
},
}),
}),
});
logChangeResponse("createSchedule / multi-phase replacing current", response);
expectBillingChangeResponse(response, {
expired: ["free"],
activated: ["pro"],
scheduled: ["premium", "enterprise"],
});
expect(
findPlanChange(response, { action: "activated", planId: "pro" })?.plan
.status,
).toBe("active");
expect(
findPlanChange(response, { action: "scheduled", planId: "premium" })?.plan
.status,
).toBe("scheduled");
expect(
findPlanChange(response, { action: "scheduled", planId: "enterprise" })
?.plan.status,
).toBe("scheduled");
});
test("schedule overrides existing scheduled products", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 });
const oldScheduled = makeFullCusProduct({
planId: "free",
status: CusProductStatus.Scheduled,
startedAt: PHASE_TWO,
});
const newScheduledPremium = makeFullCusProduct({
planId: "premium",
status: CusProductStatus.Scheduled,
startedAt: PHASE_TWO,
});
const newScheduledEnterprise = makeFullCusProduct({
planId: "enterprise",
status: CusProductStatus.Scheduled,
startedAt: PHASE_THREE,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({
customerProducts: [pro, oldScheduled],
}),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [newScheduledPremium, newScheduledEnterprise],
deletes: [oldScheduled],
}),
});
logChangeResponse(
"createSchedule / schedule overrides existing scheduled",
response,
);
// The old scheduled product is deleted, not expired — deletes are
// intentionally skipped in v1.
expectBillingChangeResponse(response, {
scheduled: ["premium", "enterprise"],
});
});
});

View File

@@ -0,0 +1,92 @@
import type {
BillingChangeResponse,
CustomerPlanChange,
PlanChangeAction,
} from "@autumn/shared";
import { expect } from "bun:test";
export const findPlanChange = (
response: BillingChangeResponse,
{ action, planId }: { action: PlanChangeAction; planId: string },
): CustomerPlanChange | undefined =>
response.plan_changes.find(
(change) => change.action === action && change.plan.plan_id === planId,
);
export const expectPlanChange = (
change: CustomerPlanChange | undefined,
{
action,
planId,
previousAttributes,
itemChanges,
}: {
action: PlanChangeAction;
planId: string;
previousAttributes?: Record<string, unknown> | null;
itemChanges?: Array<{ action: "created" | "deleted"; feature_id: string }>;
},
): CustomerPlanChange => {
expect(change, `expected ${action} change for plan ${planId}`).toBeDefined();
const resolved = change as CustomerPlanChange;
expect(resolved.action).toBe(action);
expect(resolved.plan.plan_id).toBe(planId);
if (previousAttributes === null) {
expect(resolved.previous_attributes).toBeNull();
} else if (previousAttributes !== undefined) {
expect(resolved.previous_attributes).not.toBeNull();
for (const [key, value] of Object.entries(previousAttributes)) {
expect(
resolved.previous_attributes,
`previous_attributes.${key} mismatch`,
).toMatchObject({ [key]: value });
}
}
if (itemChanges !== undefined) {
expect(resolved.item_changes).toEqual(itemChanges);
}
return resolved;
};
export const expectBillingChangeResponse = (
response: BillingChangeResponse,
{
customerId,
activated = [],
scheduled = [],
updated = [],
expired = [],
tags,
}: {
customerId?: string;
activated?: string[];
scheduled?: string[];
updated?: string[];
expired?: string[];
tags?: string[];
},
): void => {
if (customerId !== undefined) {
expect(response.customer_id).toBe(customerId);
}
const byAction = (action: PlanChangeAction) =>
response.plan_changes
.filter((change) => change.action === action)
.map((change) => change.plan.plan_id)
.sort();
expect(byAction("activated")).toEqual([...activated].sort());
expect(byAction("scheduled")).toEqual([...scheduled].sort());
expect(byAction("updated")).toEqual([...updated].sort());
expect(byAction("expired")).toEqual([...expired].sort());
if (tags !== undefined) {
expect(
(response as BillingChangeResponse & { tags?: string[] }).tags,
).toEqual(tags);
}
};

View File

@@ -0,0 +1,13 @@
import type { BillingChangeResponse } from "@autumn/shared";
const isEnabled = (): boolean => process.env.PRINT_BILLING_CHANGES === "1";
export const logChangeResponse = (
label: string,
response: BillingChangeResponse,
): void => {
if (!isEnabled()) return;
const divider = "─".repeat(Math.max(8, 60 - label.length));
process.stdout.write(`\n── ${label} ${divider}\n`);
process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
};

View File

@@ -0,0 +1,73 @@
import type {
AutumnBillingPlan,
CustomerProductUpdateSchema,
FullCusProduct,
FullCustomerEntitlement,
PatchCustomerProductSchema,
} from "@autumn/shared";
import type { z } from "zod/v4";
type CustomerProductUpdate = z.infer<typeof CustomerProductUpdateSchema>;
type PatchCustomerProduct = z.infer<typeof PatchCustomerProductSchema>;
export const makeAutumnBillingPlan = ({
inserts = [],
update,
updates,
deleteOne,
deletes,
patches,
}: {
inserts?: FullCusProduct[];
update?: CustomerProductUpdate;
updates?: CustomerProductUpdate[];
deleteOne?: FullCusProduct;
deletes?: FullCusProduct[];
patches?: PatchCustomerProduct[];
} = {}): AutumnBillingPlan => {
return {
customerId: "cus_test",
insertCustomerProducts: inserts,
updateCustomerProduct: update,
updateCustomerProducts: updates,
deleteCustomerProduct: deleteOne,
deleteCustomerProducts: deletes,
patchCustomerProducts: patches,
} as AutumnBillingPlan;
};
export const makeUpdate = ({
customerProduct,
updates,
}: {
customerProduct: FullCusProduct;
updates: CustomerProductUpdate["updates"];
}): CustomerProductUpdate => ({ customerProduct, updates });
export const makePatch = ({
customerProduct,
insertEntitlements = [],
deleteEntitlements = [],
}: {
customerProduct: FullCusProduct;
insertEntitlements?: FullCustomerEntitlement[];
deleteEntitlements?: FullCustomerEntitlement[];
}): PatchCustomerProduct =>
({
customerProduct,
insertCustomerEntitlements: insertEntitlements,
deleteCustomerEntitlements: deleteEntitlements,
insertCustomerPrices: [],
deleteCustomerPrices: [],
}) as PatchCustomerProduct;
export const makeCustomerEntitlement = ({
featureId,
}: {
featureId: string;
}): FullCustomerEntitlement =>
({
id: `cusEnt_${featureId}`,
feature_id: featureId,
internal_feature_id: `internal_${featureId}`,
}) as unknown as FullCustomerEntitlement;

View File

@@ -0,0 +1,45 @@
import {
CollectionMethod,
CusProductStatus,
type FullCusProduct,
} from "@autumn/shared";
export const makeFullCusProduct = ({
planId,
status = CusProductStatus.Active,
startedAt,
canceledAt = null,
endedAt = null,
id,
}: {
planId: string;
status?: CusProductStatus;
startedAt?: number;
canceledAt?: number | null;
endedAt?: number | null;
id?: string;
}): FullCusProduct => {
return {
id: id ?? `cp_${planId}`,
internal_product_id: `internal_${planId}`,
product_id: planId,
internal_customer_id: "internal_cus_test",
customer_id: "cus_test",
created_at: 1_700_000_000_000,
updated_at: null,
status,
canceled: canceledAt !== null,
starts_at: startedAt ?? 1_700_000_000_000,
canceled_at: canceledAt,
ended_at: endedAt,
options: [],
collection_method: CollectionMethod.ChargeAutomatically,
quantity: 1,
api_semver: null,
is_custom: false,
external_id: null,
customer_prices: [],
customer_entitlements: [],
product: { id: planId, name: planId } as FullCusProduct["product"],
} as unknown as FullCusProduct;
};

View File

@@ -0,0 +1,20 @@
import { AppEnv, type FullCusProduct, type FullCustomer } from "@autumn/shared";
export const makeFullCustomer = ({
id = "cus_test",
customerProducts = [],
}: {
id?: string;
customerProducts?: FullCusProduct[];
} = {}): FullCustomer => {
return {
id,
internal_id: `internal_${id}`,
org_id: "org_test",
created_at: 1_700_000_000_000,
env: AppEnv.Sandbox,
processor: null,
customer_products: customerProducts,
entities: [],
} as unknown as FullCustomer;
};

View File

@@ -0,0 +1,89 @@
import { describe, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse";
import {
expectBillingChangeResponse,
expectPlanChange,
findPlanChange,
} from "./helpers/expectBillingChange";
import { logChangeResponse } from "./helpers/logChangeResponse";
import {
makeAutumnBillingPlan,
makeCustomerEntitlement,
makePatch,
makeUpdate,
} from "./helpers/makeAutumnBillingPlan";
import { makeFullCusProduct } from "./helpers/makeFullCusProduct";
import { makeFullCustomer } from "./helpers/makeFullCustomer";
const NOW = 1_710_000_000_000;
const ctx = {} as AutumnContext;
describe("buildBillingChangeResponse — migrate", () => {
test("migrate via update plan path (replace product)", () => {
const oldPro = makeFullCusProduct({
planId: "pro",
startedAt: NOW - 1000,
});
const newPro = makeFullCusProduct({ planId: "pro_v2", startedAt: NOW });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [oldPro] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [newPro],
update: makeUpdate({
customerProduct: oldPro,
updates: { status: CusProductStatus.Expired },
}),
}),
});
logChangeResponse("migrate / update plan path", response);
expectBillingChangeResponse(response, {
expired: ["pro"],
activated: ["pro_v2"],
});
expectPlanChange(findPlanChange(response, { action: "expired", planId: "pro" }), {
action: "expired",
planId: "pro",
previousAttributes: { status: CusProductStatus.Active },
});
});
test("migrate via patch items (carry rollover)", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }),
autumnBillingPlan: makeAutumnBillingPlan({
patches: [
makePatch({
customerProduct: pro,
insertEntitlements: [
makeCustomerEntitlement({ featureId: "new_feature_x" }),
makeCustomerEntitlement({ featureId: "new_feature_y" }),
],
deleteEntitlements: [
makeCustomerEntitlement({ featureId: "old_feature" }),
],
}),
],
}),
});
logChangeResponse("migrate / patch items (carry rollover)", response);
expectBillingChangeResponse(response, { updated: ["pro"] });
expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), {
action: "updated",
planId: "pro",
itemChanges: [
{ action: "created", feature_id: "new_feature_x" },
{ action: "created", feature_id: "new_feature_y" },
{ action: "deleted", feature_id: "old_feature" },
],
});
});
});

View File

@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse";
import {
expectBillingChangeResponse,
findPlanChange,
} from "./helpers/expectBillingChange";
import { logChangeResponse } from "./helpers/logChangeResponse";
import {
makeAutumnBillingPlan,
makeUpdate,
} from "./helpers/makeAutumnBillingPlan";
import { makeFullCusProduct } from "./helpers/makeFullCusProduct";
import { makeFullCustomer } from "./helpers/makeFullCustomer";
const NOW = 1_710_000_000_000;
const PERIOD_END = NOW + 30 * 24 * 60 * 60 * 1000;
const ctx = {} as AutumnContext;
describe("buildBillingChangeResponse — multiAttach", () => {
test("multiple inserts, no current products", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW });
const seatsAddon = makeFullCusProduct({
planId: "seats_addon",
startedAt: NOW,
});
const storageAddon = makeFullCusProduct({
planId: "storage_addon",
startedAt: NOW,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer(),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [pro, seatsAddon, storageAddon],
}),
});
logChangeResponse("multiAttach / multiple inserts no current", response);
expectBillingChangeResponse(response, {
activated: ["pro", "seats_addon", "storage_addon"],
});
});
test("multi-insert with one transitioning current product", () => {
const free = makeFullCusProduct({ planId: "free", startedAt: NOW - 1000 });
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW });
const addon = makeFullCusProduct({
planId: "seats_addon",
startedAt: NOW,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [free] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [pro, addon],
update: makeUpdate({
customerProduct: free,
updates: {
status: CusProductStatus.Expired,
ended_at: NOW,
canceled: true,
canceled_at: NOW,
},
}),
}),
});
logChangeResponse(
"multiAttach / multi-insert with one transition",
response,
);
expectBillingChangeResponse(response, {
expired: ["free"],
activated: ["pro", "seats_addon"],
});
});
test("mixed statuses across inserts (active + scheduled)", () => {
const pro = makeFullCusProduct({
planId: "pro",
status: CusProductStatus.Active,
startedAt: NOW,
});
const scheduledAddon = makeFullCusProduct({
planId: "seats_addon",
status: CusProductStatus.Scheduled,
startedAt: PERIOD_END,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer(),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [pro, scheduledAddon],
}),
});
logChangeResponse("multiAttach / mixed statuses", response);
expectBillingChangeResponse(response, {
activated: ["pro"],
scheduled: ["seats_addon"],
});
expect(
findPlanChange(response, { action: "activated", planId: "pro" })?.plan
.status,
).toBe("active");
expect(
findPlanChange(response, {
action: "scheduled",
planId: "seats_addon",
})?.plan.status,
).toBe("scheduled");
});
});

View File

@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse";
import {
expectBillingChangeResponse,
expectPlanChange,
findPlanChange,
} from "./helpers/expectBillingChange";
import { logChangeResponse } from "./helpers/logChangeResponse";
import {
makeAutumnBillingPlan,
makeUpdate,
} from "./helpers/makeAutumnBillingPlan";
import { makeFullCusProduct } from "./helpers/makeFullCusProduct";
import { makeFullCustomer } from "./helpers/makeFullCustomer";
const NOW = 1_710_000_000_000;
const PERIOD_END = NOW + 30 * 24 * 60 * 60 * 1000;
const ctx = {} as AutumnContext;
describe("buildBillingChangeResponse — restore", () => {
test("restore single canceled product", () => {
const canceledPro = makeFullCusProduct({
planId: "pro",
startedAt: NOW - 1000,
canceledAt: NOW - 500,
endedAt: PERIOD_END,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({
customerProducts: [canceledPro],
}),
autumnBillingPlan: makeAutumnBillingPlan({
update: makeUpdate({
customerProduct: canceledPro,
updates: {
canceled: false,
canceled_at: null,
ended_at: null,
},
}),
}),
});
logChangeResponse("restore / single canceled product", response);
expectBillingChangeResponse(response, { updated: ["pro"] });
const updated = findPlanChange(response, {
action: "updated",
planId: "pro",
});
expectPlanChange(updated, {
action: "updated",
planId: "pro",
previousAttributes: {
canceled_at: NOW - 500,
expires_at: PERIOD_END,
},
});
expect(updated?.plan.canceled_at).toBeNull();
expect(updated?.plan.expires_at).toBeNull();
});
test("restore multiple via updateCustomerProducts array", () => {
const proBase = makeFullCusProduct({
planId: "pro",
startedAt: NOW - 1000,
canceledAt: NOW - 500,
endedAt: PERIOD_END,
});
const addon = makeFullCusProduct({
planId: "seats_addon",
startedAt: NOW - 1000,
canceledAt: NOW - 500,
endedAt: PERIOD_END,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({
customerProducts: [proBase, addon],
}),
autumnBillingPlan: makeAutumnBillingPlan({
updates: [
makeUpdate({
customerProduct: proBase,
updates: { canceled: false, canceled_at: null, ended_at: null },
}),
makeUpdate({
customerProduct: addon,
updates: { canceled: false, canceled_at: null, ended_at: null },
}),
],
}),
});
logChangeResponse("restore / multiple via array", response);
expectBillingChangeResponse(response, {
updated: ["pro", "seats_addon"],
});
for (const planId of ["pro", "seats_addon"]) {
const change = findPlanChange(response, {
action: "updated",
planId,
});
expectPlanChange(change, {
action: "updated",
planId,
previousAttributes: {
canceled_at: NOW - 500,
expires_at: PERIOD_END,
},
});
}
});
});

View File

@@ -0,0 +1,82 @@
import { describe, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse";
import {
expectBillingChangeResponse,
expectPlanChange,
findPlanChange,
} from "./helpers/expectBillingChange";
import { logChangeResponse } from "./helpers/logChangeResponse";
import {
makeAutumnBillingPlan,
makeUpdate,
} from "./helpers/makeAutumnBillingPlan";
import { makeFullCusProduct } from "./helpers/makeFullCusProduct";
import { makeFullCustomer } from "./helpers/makeFullCustomer";
const NOW = 1_710_000_000_000;
const ctx = {} as AutumnContext;
describe("buildBillingChangeResponse — sync (from Stripe)", () => {
test("sync with expire_previous=true", () => {
const oldPro = makeFullCusProduct({
planId: "pro",
startedAt: NOW - 1000,
});
const newPro = makeFullCusProduct({ planId: "pro_v2", startedAt: NOW });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [oldPro] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [newPro],
update: makeUpdate({
customerProduct: oldPro,
updates: {
status: CusProductStatus.Expired,
ended_at: NOW,
canceled: true,
canceled_at: NOW,
},
}),
}),
});
logChangeResponse("sync / expire_previous=true", response);
expectBillingChangeResponse(response, {
expired: ["pro"],
activated: ["pro_v2"],
});
expectPlanChange(
findPlanChange(response, { action: "expired", planId: "pro" }),
{
action: "expired",
planId: "pro",
previousAttributes: { status: CusProductStatus.Active },
},
);
});
test("sync with expire_previous=false (both products active)", () => {
const baseAddon = makeFullCusProduct({
planId: "pro",
startedAt: NOW - 1000,
});
const newAddon = makeFullCusProduct({
planId: "seats_addon",
startedAt: NOW,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({
customerProducts: [baseAddon],
}),
autumnBillingPlan: makeAutumnBillingPlan({ inserts: [newAddon] }),
});
logChangeResponse("sync / expire_previous=false", response);
expectBillingChangeResponse(response, { activated: ["seats_addon"] });
});
});

View File

@@ -0,0 +1,277 @@
import { describe, expect, test } from "bun:test";
import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildBillingChangeResponse } from "@/internal/billing/v2/utils/billingChangeResponse";
import {
expectBillingChangeResponse,
expectPlanChange,
findPlanChange,
} from "./helpers/expectBillingChange";
import { logChangeResponse } from "./helpers/logChangeResponse";
import {
makeAutumnBillingPlan,
makeCustomerEntitlement,
makePatch,
makeUpdate,
} from "./helpers/makeAutumnBillingPlan";
import { makeFullCusProduct } from "./helpers/makeFullCusProduct";
import { makeFullCustomer } from "./helpers/makeFullCustomer";
const NOW = 1_710_000_000_000;
const PERIOD_END = NOW + 30 * 24 * 60 * 60 * 1000;
const ctx = {} as AutumnContext;
describe("buildBillingChangeResponse — updateSubscription", () => {
test("cancel at end of cycle", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 });
const defaultFree = makeFullCusProduct({
planId: "free",
status: CusProductStatus.Scheduled,
startedAt: PERIOD_END,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [defaultFree],
update: makeUpdate({
customerProduct: pro,
updates: {
canceled: true,
canceled_at: NOW,
ended_at: PERIOD_END,
},
}),
}),
});
logChangeResponse("update / cancel end of cycle", response);
expectBillingChangeResponse(response, {
updated: ["pro"],
scheduled: ["free"],
});
expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), {
action: "updated",
planId: "pro",
previousAttributes: { canceled_at: null, expires_at: null },
});
const scheduled = findPlanChange(response, {
action: "scheduled",
planId: "free",
});
expect(scheduled?.plan.status).toBe("scheduled");
});
test("cancel immediately", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 });
const defaultFree = makeFullCusProduct({
planId: "free",
status: CusProductStatus.Active,
startedAt: NOW,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [defaultFree],
update: makeUpdate({
customerProduct: pro,
updates: {
canceled: true,
canceled_at: NOW,
ended_at: NOW,
status: CusProductStatus.Expired,
},
}),
}),
});
logChangeResponse("update / cancel immediately", response);
expectBillingChangeResponse(response, {
expired: ["pro"],
activated: ["free"],
});
expectPlanChange(findPlanChange(response, { action: "expired", planId: "pro" }), {
action: "expired",
planId: "pro",
previousAttributes: {
status: CusProductStatus.Active,
canceled_at: null,
expires_at: null,
},
});
});
test("uncancel", () => {
const pro = makeFullCusProduct({
planId: "pro",
startedAt: NOW - 1000,
canceledAt: NOW - 500,
endedAt: PERIOD_END,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }),
autumnBillingPlan: makeAutumnBillingPlan({
update: makeUpdate({
customerProduct: pro,
updates: {
canceled: false,
canceled_at: null,
ended_at: null,
},
}),
}),
});
logChangeResponse("update / uncancel", response);
expectBillingChangeResponse(response, { updated: ["pro"] });
expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), {
action: "updated",
planId: "pro",
previousAttributes: {
canceled_at: NOW - 500,
expires_at: PERIOD_END,
},
});
const updated = findPlanChange(response, {
action: "updated",
planId: "pro",
});
expect(updated?.plan.canceled_at).toBeNull();
expect(updated?.plan.expires_at).toBeNull();
});
test("update plan via custom plan (replace product)", () => {
const oldPro = makeFullCusProduct({
planId: "pro",
startedAt: NOW - 1000,
});
const newPro = makeFullCusProduct({ planId: "pro_v2", startedAt: NOW });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [oldPro] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [newPro],
update: makeUpdate({
customerProduct: oldPro,
updates: { status: CusProductStatus.Expired },
}),
}),
});
logChangeResponse("update / custom plan replacement", response);
expectBillingChangeResponse(response, {
expired: ["pro"],
activated: ["pro_v2"],
});
expectPlanChange(findPlanChange(response, { action: "expired", planId: "pro" }), {
action: "expired",
planId: "pro",
previousAttributes: { status: CusProductStatus.Active },
});
});
test("delete a scheduled product emits nothing (deletes ignored for now)", () => {
const scheduledFree = makeFullCusProduct({
planId: "free",
status: CusProductStatus.Scheduled,
startedAt: PERIOD_END,
});
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({
customerProducts: [scheduledFree],
}),
autumnBillingPlan: makeAutumnBillingPlan({ deleteOne: scheduledFree }),
});
logChangeResponse("update / delete scheduled product (ignored)", response);
expectBillingChangeResponse(response, {});
expect(response.plan_changes).toEqual([]);
});
test("patch items (inline mode) — add and remove features", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }),
autumnBillingPlan: makeAutumnBillingPlan({
patches: [
makePatch({
customerProduct: pro,
insertEntitlements: [
makeCustomerEntitlement({ featureId: "api_calls" }),
],
deleteEntitlements: [
makeCustomerEntitlement({ featureId: "legacy_feature" }),
],
}),
],
}),
});
logChangeResponse("update / patch items", response);
expectBillingChangeResponse(response, { updated: ["pro"] });
expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), {
action: "updated",
planId: "pro",
itemChanges: [
{ action: "created", feature_id: "api_calls" },
{ action: "deleted", feature_id: "legacy_feature" },
],
});
});
test("quantity-only update — empty previous_attributes (v1 limitation)", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }),
autumnBillingPlan: makeAutumnBillingPlan({
update: makeUpdate({
customerProduct: pro,
updates: {
options: [{ feature_id: "seats", quantity: 10 }],
},
}),
}),
});
logChangeResponse("update / quantity only", response);
expectBillingChangeResponse(response, { updated: ["pro"] });
const updated = findPlanChange(response, {
action: "updated",
planId: "pro",
});
expect(updated?.previous_attributes).toEqual({});
expect(updated?.item_changes).toEqual([]);
});
test("anchor reset — empty updates object", () => {
const pro = makeFullCusProduct({ planId: "pro", startedAt: NOW - 1000 });
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [pro] }),
autumnBillingPlan: makeAutumnBillingPlan({
update: makeUpdate({ customerProduct: pro, updates: {} }),
}),
});
logChangeResponse("update / empty updates (anchor reset)", response);
expectBillingChangeResponse(response, { updated: ["pro"] });
const updated = findPlanChange(response, {
action: "updated",
planId: "pro",
});
expect(updated?.previous_attributes).toEqual({});
});
});

View File

@@ -0,0 +1,23 @@
import { z } from "zod/v4";
import { CustomerPlanChangeSchema } from "./customerPlanChange";
export const BillingChangeResponseSchema = z.object({
object: z.literal("billing.plans_changed"),
customer_id: z.string().meta({
description: "The ID of the customer whose plans changed.",
}),
entity_id: z
.string()
.nullable()
.optional()
.meta({
description:
"The ID of the entity, if the changes are scoped to a specific entity.",
}),
plan_changes: z.array(CustomerPlanChangeSchema).meta({
description:
"The plans that were activated, scheduled, updated, or expired.",
}),
});
export type BillingChangeResponse = z.infer<typeof BillingChangeResponseSchema>;

View File

@@ -0,0 +1,82 @@
import { z } from "zod/v4";
export const PlanChangeActionEnum = z.enum([
"activated",
"scheduled",
"updated",
"expired",
]);
export const CustomerPlanStatusEnum = z.enum([
"active",
"trialing",
"past_due",
"scheduled",
"expired",
"paused",
]);
export const CustomerPlanSnapshotSchema = z.object({
plan_id: z.string().meta({
description: "The ID of the customer plan.",
}),
status: CustomerPlanStatusEnum.meta({
description: "The current status of the plan on the customer.",
}),
started_at: z.number().nullable().meta({
description: "When the plan started, in milliseconds since the Unix epoch.",
}),
canceled_at: z.number().nullable().meta({
description:
"When the plan was canceled, in milliseconds since the Unix epoch, or null if not canceled.",
}),
expires_at: z.number().nullable().meta({
description:
"When the plan ends, in milliseconds since the Unix epoch, or null if no expiry is set.",
}),
current_period_start: z.number().nullable().meta({
description: "Start of the current billing period, or null if not applicable.",
}),
current_period_end: z.number().nullable().meta({
description: "End of the current billing period, or null if not applicable.",
}),
});
export const CustomerPlanItemChangeSchema = z.object({
action: z.enum(["created", "deleted"]).meta({
description: "Whether the feature was added to or removed from the plan.",
}),
feature_id: z.string().meta({
description: "The ID of the feature that was added or removed.",
}),
});
export const CustomerPlanChangeSchema = z.object({
action: PlanChangeActionEnum.meta({
description:
"The lifecycle action applied to this plan: activated (newly active on the customer), scheduled (queued for a future start), updated (mutated in place), or expired (ended).",
}),
plan: CustomerPlanSnapshotSchema.meta({
description: "The plan as it stands after this change.",
}),
previous_attributes: z
.record(z.string(), z.unknown())
.nullable()
.meta({
description:
"Sparse map of scalar fields whose values changed, holding their previous values. Null when the plan is newly activated or scheduled.",
}),
item_changes: z
.array(CustomerPlanItemChangeSchema)
.default([])
.meta({
description:
"Features that were added to or removed from this plan. Only populated for updated plans.",
}),
});
export type PlanChangeAction = z.infer<typeof PlanChangeActionEnum>;
export type CustomerPlanStatus = z.infer<typeof CustomerPlanStatusEnum>;
export type CustomerPlanSnapshot = z.infer<typeof CustomerPlanSnapshotSchema>;
export type CustomerPlanItemChange = z.infer<typeof CustomerPlanItemChangeSchema>;
export type CustomerPlanChange = z.infer<typeof CustomerPlanChangeSchema>;

View File

@@ -1,10 +1,12 @@
export * from "./attachPreviewResponse";
export * from "./billingBehavior";
export * from "./billingChangeResponse";
export * from "./billingParamsBase/billingParamsBaseV0";
export * from "./billingParamsBase/billingParamsBaseV1";
export * from "./billingPreviewChange";
export * from "./billingPreviewResponse";
export * from "./billingResponse";
export * from "./customerPlanChange";
export * from "./cancelAction";
export * from "./customizePlan/customizePlanV0";
export * from "./customizePlan/customizePlanV1";