diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildBillingChangeResponse.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildBillingChangeResponse.ts new file mode 100644 index 000000000..6d558d29c --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildBillingChangeResponse.ts @@ -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 }), + }); +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts new file mode 100644 index 000000000..3059f52bf --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.ts @@ -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; +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts new file mode 100644 index 000000000..38776119a --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPlanItemChanges.ts @@ -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; +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/buildPreviousAttributes.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPreviousAttributes.ts new file mode 100644 index 000000000..7c3b589c1 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/buildPreviousAttributes.ts @@ -0,0 +1,39 @@ +import type { + CustomerProductUpdateSchema, + FullCusProduct, +} from "@autumn/shared"; +import type { z } from "zod/v4"; + +type CustomerProductUpdate = z.infer; + +export const buildPreviousAttributes = ({ + originalCusProduct, + updates, +}: { + originalCusProduct: FullCusProduct; + updates: CustomerProductUpdate["updates"]; +}): Record => { + const previous: Record = {}; + + 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; +}; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/index.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/index.ts new file mode 100644 index 000000000..5f4e6331c --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/index.ts @@ -0,0 +1,5 @@ +export * from "./buildBillingChangeResponse"; +export * from "./buildPlanChanges"; +export * from "./buildPlanItemChanges"; +export * from "./buildPreviousAttributes"; +export * from "./toCustomerPlanSnapshot"; diff --git a/server/src/internal/billing/v2/utils/billingChangeResponse/toCustomerPlanSnapshot.ts b/server/src/internal/billing/v2/utils/billingChangeResponse/toCustomerPlanSnapshot.ts new file mode 100644 index 000000000..c24012852 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingChangeResponse/toCustomerPlanSnapshot.ts @@ -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, + }; +}; diff --git a/server/tests/unit/billing/billing-change-response/attach.test.ts b/server/tests/unit/billing/billing-change-response/attach.test.ts new file mode 100644 index 000000000..b4bfdb008 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/attach.test.ts @@ -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"); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/create-schedule.test.ts b/server/tests/unit/billing/billing-change-response/create-schedule.test.ts new file mode 100644 index 000000000..861c78224 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/create-schedule.test.ts @@ -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"], + }); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts b/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts new file mode 100644 index 000000000..4662c8407 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/expectBillingChange.ts @@ -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 | 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); + } +}; diff --git a/server/tests/unit/billing/billing-change-response/helpers/logChangeResponse.ts b/server/tests/unit/billing/billing-change-response/helpers/logChangeResponse.ts new file mode 100644 index 000000000..137d676fa --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/logChangeResponse.ts @@ -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`); +}; diff --git a/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts b/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts new file mode 100644 index 000000000..be450cf93 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/makeAutumnBillingPlan.ts @@ -0,0 +1,73 @@ +import type { + AutumnBillingPlan, + CustomerProductUpdateSchema, + FullCusProduct, + FullCustomerEntitlement, + PatchCustomerProductSchema, +} from "@autumn/shared"; +import type { z } from "zod/v4"; + +type CustomerProductUpdate = z.infer; +type PatchCustomerProduct = z.infer; + +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; diff --git a/server/tests/unit/billing/billing-change-response/helpers/makeFullCusProduct.ts b/server/tests/unit/billing/billing-change-response/helpers/makeFullCusProduct.ts new file mode 100644 index 000000000..7cbfa6d0f --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/makeFullCusProduct.ts @@ -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; +}; diff --git a/server/tests/unit/billing/billing-change-response/helpers/makeFullCustomer.ts b/server/tests/unit/billing/billing-change-response/helpers/makeFullCustomer.ts new file mode 100644 index 000000000..f6831b731 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/helpers/makeFullCustomer.ts @@ -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; +}; diff --git a/server/tests/unit/billing/billing-change-response/migrate.test.ts b/server/tests/unit/billing/billing-change-response/migrate.test.ts new file mode 100644 index 000000000..1f7f172d5 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/migrate.test.ts @@ -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" }, + ], + }); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/multi-attach.test.ts b/server/tests/unit/billing/billing-change-response/multi-attach.test.ts new file mode 100644 index 000000000..0d1ca6cfc --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/multi-attach.test.ts @@ -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"); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/restore.test.ts b/server/tests/unit/billing/billing-change-response/restore.test.ts new file mode 100644 index 000000000..00d267394 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/restore.test.ts @@ -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, + }, + }); + } + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/sync.test.ts b/server/tests/unit/billing/billing-change-response/sync.test.ts new file mode 100644 index 000000000..bc692f7f5 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/sync.test.ts @@ -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"] }); + }); +}); diff --git a/server/tests/unit/billing/billing-change-response/update-subscription.test.ts b/server/tests/unit/billing/billing-change-response/update-subscription.test.ts new file mode 100644 index 000000000..cc6adb866 --- /dev/null +++ b/server/tests/unit/billing/billing-change-response/update-subscription.test.ts @@ -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({}); + }); +}); diff --git a/shared/api/billing/common/billingChangeResponse.ts b/shared/api/billing/common/billingChangeResponse.ts new file mode 100644 index 000000000..13d6666be --- /dev/null +++ b/shared/api/billing/common/billingChangeResponse.ts @@ -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; diff --git a/shared/api/billing/common/customerPlanChange.ts b/shared/api/billing/common/customerPlanChange.ts new file mode 100644 index 000000000..e1f95bd50 --- /dev/null +++ b/shared/api/billing/common/customerPlanChange.ts @@ -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; +export type CustomerPlanStatus = z.infer; +export type CustomerPlanSnapshot = z.infer; +export type CustomerPlanItemChange = z.infer; +export type CustomerPlanChange = z.infer; diff --git a/shared/api/billing/common/index.ts b/shared/api/billing/common/index.ts index 9cfc436a8..b469221ba 100644 --- a/shared/api/billing/common/index.ts +++ b/shared/api/billing/common/index.ts @@ -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";