building plan change utility functions
This commit is contained in:
@@ -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 }),
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./buildBillingChangeResponse";
|
||||
export * from "./buildPlanChanges";
|
||||
export * from "./buildPlanItemChanges";
|
||||
export * from "./buildPreviousAttributes";
|
||||
export * from "./toCustomerPlanSnapshot";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
248
server/tests/unit/billing/billing-change-response/attach.test.ts
Normal file
248
server/tests/unit/billing/billing-change-response/attach.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
23
shared/api/billing/common/billingChangeResponse.ts
Normal file
23
shared/api/billing/common/billingChangeResponse.ts
Normal 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>;
|
||||
82
shared/api/billing/common/customerPlanChange.ts
Normal file
82
shared/api/billing/common/customerPlanChange.ts
Normal 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>;
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user