This commit is contained in:
John Yeo
2026-01-08 12:13:14 +00:00
parent 9d08e165e5
commit 1f6618cc84
39 changed files with 4394 additions and 1412 deletions

View File

@@ -1,6 +1,7 @@
import type {
Entitlement,
FeatureOptions,
FreeTrial,
FullCusProduct,
FullProduct,
Price,
@@ -16,6 +17,12 @@ export const InvoiceModeSchema = z.object({
export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
export interface TrialContext {
freeTrial?: FreeTrial | null;
trialEndsAt?: number;
customFreeTrial?: FreeTrial;
}
export interface BillingContext {
fullCustomer: FullCustomer;
stripeCustomer: Stripe.Customer;
@@ -27,6 +34,7 @@ export interface BillingContext {
// Timestamps...
currentEpochMs: number;
billingCycleAnchorMs: number | "now";
resetCycleAnchorMs: number | "now";
// Stripe context
stripeSubscription?: Stripe.Subscription;
@@ -36,6 +44,9 @@ export interface BillingContext {
// Unforunately, need to add custom prices, custom entitlements and free trial here, because it's determined in the setup step.
customPrices: Price[];
customEnts: Entitlement[];
// Trial context
trialContext?: TrialContext;
}
export interface UpdateSubscriptionBillingContext extends BillingContext {

View File

@@ -1,7 +1,7 @@
import {
cusProductToArrearLineItems,
cusProductToLineItems,
type FullCusProduct,
type LineItem,
} from "@autumn/shared";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
@@ -22,14 +22,14 @@ export const buildAutumnLineItems = ({
const { org, logger } = ctx;
const arrearLineItems = deletedCustomerProduct
? cusProductToArrearLineItems({
cusProduct: deletedCustomerProduct,
billingCycleAnchorMs,
nowMs: currentEpochMs,
org,
})
: [];
// For now, update subscription doesn't charge for existing usage.
const arrearLineItems: LineItem[] = [];
// cusProductToArrearLineItems({
// cusProduct: deletedCustomerProduct,
// billingCycleAnchorMs,
// nowMs: currentEpochMs,
// org,
// })
// Get line items for ongoing cus product
const deletedLineItems = deletedCustomerProduct

View File

@@ -1,7 +1,7 @@
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
import type { AttachContext } from "../../typesOld";
import { cusProductToExistingUsages } from "../../utils/handleExistingUsages/cusProductToExistingUsages";
import { initFullCustomerProduct } from "../../utils/initFullCustomerProduct/initFullCustomerProduct";
import type { AttachContext } from "../../typesOld";
export const buildNewCusProducts = ({
ctx,

View File

@@ -18,6 +18,7 @@ export const executeAutumnBillingPlan = async ({
const {
insertCustomerProducts,
updateCustomerProduct,
deleteCustomerProduct,
customPrices,
customEntitlements,
customFreeTrial,
@@ -58,7 +59,15 @@ export const executeAutumnBillingPlan = async ({
});
}
// 4. Update entitlement balances
// 4. Delete scheduled customer product (e.g., when updating while canceling)
if (deleteCustomerProduct) {
await CusProductService.delete({
db,
cusProductId: deleteCustomerProduct.id,
});
}
// 5. Update entitlement balances
await updateCustomerEntitlements({
ctx,
updates: autumnBillingPlan.updateCustomerEntitlements,

View File

@@ -5,7 +5,6 @@ import { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/
import { buildStripeSubscriptionCreateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction";
import { buildStripeSubscriptionUpdateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction";
import type {
FreeTrialPlan,
StripeSubscriptionAction,
StripeSubscriptionScheduleAction,
} from "@/internal/billing/v2/types/billingPlan";
@@ -15,13 +14,11 @@ export const buildStripeSubscriptionAction = ({
billingContext,
finalCustomerProducts,
stripeSubscriptionScheduleAction,
freeTrialPlan,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
finalCustomerProducts: FullCusProduct[];
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
freeTrialPlan?: FreeTrialPlan;
}): StripeSubscriptionAction | undefined => {
const { stripeSubscription } = billingContext;
@@ -64,7 +61,6 @@ export const buildStripeSubscriptionAction = ({
ctx,
billingContext,
subItemsUpdate,
freeTrialPlan,
stripeSubscriptionScheduleAction,
});
}

View File

@@ -63,7 +63,7 @@ export const evaluateStripeBillingPlan = async ({
ctx,
billingContext,
finalCustomerProducts: finalFullCustomer.customer_products,
trialEndsAt: autumnBillingPlan.freeTrialPlan?.trialEndsAt,
trialEndsAt: billingContext.trialContext?.trialEndsAt,
});
return {

View File

@@ -2,25 +2,22 @@ import { msToSeconds } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan";
export const buildStripeSubscriptionCreateAction = ({
ctx,
billingContext,
freeTrialPlan,
subItemsUpdate,
addInvoiceItems,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
freeTrialPlan?: FreeTrialPlan;
subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[];
addInvoiceItems: Stripe.SubscriptionCreateParams.AddInvoiceItem[];
}) => {
const { stripeCustomer, paymentMethod } = billingContext;
const { stripeCustomer, paymentMethod, trialContext } = billingContext;
const trialEndsAt = freeTrialPlan?.trialEndsAt;
const freeTrial = freeTrialPlan?.freeTrial;
const trialEndsAt = trialContext?.trialEndsAt;
const freeTrial = trialContext?.freeTrial;
const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required);
const isCustomPaymentMethod = paymentMethod?.type === "custom";

View File

@@ -4,7 +4,6 @@ import { isStripeSubscriptionCanceling } from "@/external/stripe/subscriptions/u
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type {
FreeTrialPlan,
StripeSubscriptionAction,
StripeSubscriptionScheduleAction,
} from "@/internal/billing/v2/types/billingPlan";
@@ -13,16 +12,14 @@ export const buildStripeSubscriptionUpdateAction = ({
ctx,
billingContext,
subItemsUpdate,
freeTrialPlan,
stripeSubscriptionScheduleAction,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[];
freeTrialPlan?: FreeTrialPlan;
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
}): StripeSubscriptionAction | undefined => {
const { stripeSubscription } = billingContext;
const { stripeSubscription, trialContext } = billingContext;
if (!stripeSubscription) {
throw new Error(
@@ -30,7 +27,7 @@ export const buildStripeSubscriptionUpdateAction = ({
);
}
const trialEndsAt = freeTrialPlan?.trialEndsAt;
const trialEndsAt = trialContext?.trialEndsAt;
const cancelAtPeriodEnd = isStripeSubscriptionCanceling(stripeSubscription)
? false
: undefined;

View File

@@ -0,0 +1,48 @@
import {
cusProductToProduct,
type FullCusProduct,
type FullProduct,
isFreeProduct,
isOneOffProduct,
} from "@autumn/shared";
/**
* Determine the billing cycle anchor based on product transitions.
*/
export const setupResetCycleAnchor = ({
billingCycleAnchorMs,
customerProduct,
newFullProduct,
}: {
billingCycleAnchorMs: number | "now";
customerProduct?: FullCusProduct;
newFullProduct: FullProduct;
}): number | "now" => {
if (!customerProduct) {
return billingCycleAnchorMs;
}
const currentFullProduct = cusProductToProduct({
cusProduct: customerProduct,
});
const currentIsFree = isFreeProduct({ prices: currentFullProduct.prices });
const newIsFree = isFreeProduct({ prices: newFullProduct.prices });
// Free -> Free: keep original anchor
if (currentIsFree && newIsFree) {
return customerProduct?.created_at ?? "now";
}
const currentIsOneOff = isOneOffProduct({
prices: currentFullProduct.prices,
});
const newIsOneOff = isOneOffProduct({ prices: newFullProduct.prices });
// One-off -> One-off: keep original anchor
if (currentIsOneOff && newIsOneOff) {
return customerProduct?.created_at ?? "now";
}
return billingCycleAnchorMs;
};

View File

@@ -1,5 +1,5 @@
import type {
FreeTrial,
FullCusProduct,
FullProduct,
UpdateSubscriptionV0Params,
} from "@autumn/shared";
@@ -9,34 +9,28 @@ import {
isProductPaidAndRecurring,
secondsToMs,
} from "@autumn/shared";
import type Stripe from "stripe";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { TrialContext } from "@/internal/billing/v2/billingContext";
interface ComputeSubscriptionUpdateTrialDetailsResult {
freeTrialPlan: {
freeTrial?: FreeTrial | null;
trialEndsAt?: number;
};
customFreeTrial?: FreeTrial;
}
export const computeCustomPlanFreeTrial = ({
updateSubscriptionContext,
export const setupTrialContext = ({
stripeSubscription,
customerProduct,
currentEpochMs,
params,
fullProduct,
}: {
updateSubscriptionContext: UpdateSubscriptionBillingContext;
stripeSubscription?: Stripe.Subscription;
customerProduct: FullCusProduct;
currentEpochMs: number;
params: UpdateSubscriptionV0Params;
fullProduct: FullProduct;
}): ComputeSubscriptionUpdateTrialDetailsResult => {
const { stripeSubscription, customerProduct, currentEpochMs } =
updateSubscriptionContext;
}): TrialContext => {
const freeTrialParams = params.free_trial;
// Case 1: If free trial is null (removing free trial)
if (freeTrialParams === null) {
return { freeTrialPlan: { freeTrial: null } };
return { freeTrial: null };
}
// Case 2: If free trial params are passed in
@@ -53,7 +47,8 @@ export const computeCustomPlanFreeTrial = ({
});
return {
freeTrialPlan: { freeTrial: dbFreeTrial, trialEndsAt },
freeTrial: dbFreeTrial,
trialEndsAt,
customFreeTrial: dbFreeTrial,
};
}
@@ -69,20 +64,18 @@ export const computeCustomPlanFreeTrial = ({
);
return {
freeTrialPlan: { freeTrial: null, trialEndsAt },
};
} else {
return {
freeTrialPlan: { freeTrial: null },
freeTrial: null,
trialEndsAt,
};
}
return {
freeTrial: null,
};
}
// Case 4: Return free trial / trial ends at from current customer product
return {
freeTrialPlan: {
freeTrial: customerProduct.free_trial,
trialEndsAt: customerProduct.trial_ends_at ?? undefined,
},
freeTrial: customerProduct.free_trial,
trialEndsAt: customerProduct.trial_ends_at ?? undefined,
};
};

View File

@@ -10,24 +10,16 @@ import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { FullCusProductSchema } from "../../../../../../shared/models/cusProductModels/cusProductModels";
export const FreeTrialPlanSchema = z.object({
freeTrial: FreeTrialSchema.nullable().optional(),
trialEndsAt: z.number().optional(),
});
export type FreeTrialPlan = z.infer<typeof FreeTrialPlanSchema>;
export const UpdateCustomerEntitlementSchema = z.object({
customerEntitlementId: z.string(),
balanceChange: z.number(),
});
export const AutumnBillingPlanSchema = z.object({
freeTrialPlan: FreeTrialPlanSchema.optional(),
insertCustomerProducts: z.array(FullCusProductSchema),
updateCustomerProduct: FullCusProductSchema.optional(),
deleteCustomerProduct: FullCusProductSchema.optional(), // Scheduled product to delete (e.g., when updating while canceling)
customPrices: z.array(PriceSchema), // Custom prices to insert
customEntitlements: z.array(EntitlementSchema), // Custom entitlements to insert

View File

@@ -3,7 +3,6 @@ import {
type AutumnBillingPlan,
AutumnBillingPlanSchema,
type DeferredAutumnBillingPlanData,
type FreeTrialPlan,
type InvoiceMode,
InvoiceModeSchema,
} from "./autumnBillingPlan";
@@ -31,7 +30,6 @@ export {
StripeSubscriptionScheduleActionSchema,
type AutumnBillingPlan,
type DeferredAutumnBillingPlanData,
type FreeTrialPlan,
type InvoiceMode,
type StripeBillingPlan,
type StripeInvoiceAction,

View File

@@ -20,4 +20,3 @@ export const StripeSubscriptionScheduleActionSchema = z.discriminatedUnion(
export type StripeSubscriptionScheduleAction = z.infer<
typeof StripeSubscriptionScheduleActionSchema
>;

View File

@@ -0,0 +1,37 @@
import {
type FullCusProduct,
findMainScheduledCustomerProductByGroup,
isCustomerProductCanceling,
isCustomerProductMain,
} from "@autumn/shared";
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
/**
* Computes the scheduled customer product to delete when updating a subscription.
*
* When a user updates a canceling subscription, the cancellation should be reversed
* and any scheduled replacement product should be deleted.
*
* @returns The scheduled customer product to delete, or undefined if none exists
*/
export const computeDeleteCustomerProduct = ({
fullCustomer,
customerProduct,
}: {
fullCustomer: FullCustomer;
customerProduct: FullCusProduct;
}): FullCusProduct | undefined => {
// Only look for scheduled product if:
// 1. Current product is main (not add-on)
// 2. Current product is being canceled
const isMain = isCustomerProductMain(customerProduct);
const isCanceling = isCustomerProductCanceling(customerProduct);
if (!isMain || !isCanceling) return undefined;
return findMainScheduledCustomerProductByGroup({
fullCustomer,
productGroup: customerProduct.product.group,
});
};

View File

@@ -1,13 +1,13 @@
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import {
computeUpdateSubscriptionIntent,
UpdateSubscriptionIntent,
} from "@/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionIntent";
import { computeCustomPlan } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlan";
import { computeUpdateQuantityPlan } from "@/internal/billing/v2/updateSubscription/compute/updateQuantity/computeUpdateQuantityPlan";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
/**
* Compute the subscription update plan

View File

@@ -6,7 +6,7 @@ import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@server/internal/billing/v2/billingContext";
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { computeCustomPlanFreeTrial } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanFreeTrial";
import { computeDeleteCustomerProduct } from "@/internal/billing/v2/updateSubscription/compute/computeDeleteCustomerProduct";
import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct";
export const computeCustomPlan = async ({
@@ -18,28 +18,21 @@ export const computeCustomPlan = async ({
updateSubscriptionContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
}) => {
const { customerProduct, customPrices, customEnts } =
updateSubscriptionContext;
const {
customerProduct,
customPrices,
customEnts,
trialContext,
fullCustomer,
} = updateSubscriptionContext;
const customFullProduct = updateSubscriptionContext.fullProducts[0];
// 2. Compute the custom trial details
const { freeTrialPlan, customFreeTrial } = computeCustomPlanFreeTrial({
updateSubscriptionContext,
params,
fullProduct: customFullProduct,
});
if (freeTrialPlan.trialEndsAt) {
updateSubscriptionContext.billingCycleAnchorMs = freeTrialPlan.trialEndsAt;
}
// 3. Compute the new customer product
// Compute the new customer product
const newFullCustomerProduct = computeCustomPlanNewCustomerProduct({
ctx,
updateSubscriptionContext,
fullProduct: customFullProduct,
freeTrialPlan,
});
const lineItems = buildAutumnLineItems({
@@ -49,15 +42,22 @@ export const computeCustomPlan = async ({
billingContext: updateSubscriptionContext,
});
// If customer product is canceling, compute the scheduled product to delete
const deleteCustomerProduct = computeDeleteCustomerProduct({
fullCustomer,
customerProduct,
});
return {
insertCustomerProducts: [newFullCustomerProduct],
updateCustomerProduct: {
...customerProduct,
status: CusProductStatus.Expired,
},
customPrices: customPrices,
deleteCustomerProduct,
customPrices,
customEntitlements: customEnts,
customFreeTrial: customFreeTrial,
customFreeTrial: trialContext?.customFreeTrial,
lineItems,
} satisfies AutumnBillingPlan;
};

View File

@@ -1,7 +1,6 @@
import type { FullProduct } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan";
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
@@ -10,21 +9,20 @@ export const computeCustomPlanNewCustomerProduct = ({
ctx,
updateSubscriptionContext,
fullProduct,
freeTrialPlan,
}: {
ctx: AutumnContext;
updateSubscriptionContext: UpdateSubscriptionBillingContext;
fullProduct: FullProduct;
freeTrialPlan: FreeTrialPlan;
}) => {
const {
customerProduct,
fullCustomer,
stripeSubscription,
stripeSubscriptionSchedule,
billingCycleAnchorMs,
resetCycleAnchorMs,
currentEpochMs,
featureQuantities,
trialContext,
} = updateSubscriptionContext;
const existingUsages = cusProductToExistingUsages({
@@ -48,11 +46,11 @@ export const computeCustomPlanNewCustomerProduct = ({
featureQuantities,
existingUsages,
existingRollovers,
resetCycleAnchor: billingCycleAnchorMs ?? "now",
resetCycleAnchor: resetCycleAnchorMs,
now: currentEpochMs,
freeTrial: freeTrialPlan.freeTrial ?? null,
trialEndsAt: freeTrialPlan.trialEndsAt,
freeTrial: trialContext?.freeTrial ?? null,
trialEndsAt: trialContext?.trialEndsAt,
},
initOptions: {

View File

@@ -4,6 +4,8 @@ import { setupStripeBillingContext } from "@/internal/billing/v2/providers/strip
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
import { setupTrialContext } from "@/internal/billing/v2/setup/setupTrialContext";
import { setupUpdateSubscriptionProductContext } from "@/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionProductContext";
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
@@ -52,9 +54,37 @@ export const setupUpdateSubscriptionBillingContext = async ({
});
const currentEpochMs = testClockFrozenTime ?? Date.now();
const billingCycleAnchorMs = secondsToMs(
stripeSubscription?.billing_cycle_anchor,
);
// 1. Setup trial context first
const trialContext = setupTrialContext({
stripeSubscription,
customerProduct,
currentEpochMs,
params,
fullProduct,
});
// 2. Initial billing cycle anchor from Stripe subscription
let billingCycleAnchorMs: number | "now" =
secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now";
// 3. Determine final anchor based on product transitions
billingCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs,
customerProduct,
newFullProduct: fullProduct,
});
// 4. Trial ends at overrides reset cycle anchor
if (trialContext.trialEndsAt) {
billingCycleAnchorMs = trialContext.trialEndsAt;
}
const resetCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs,
customerProduct,
newFullProduct: fullProduct,
});
const invoiceMode = setupInvoiceModeContext({ params });
@@ -68,11 +98,14 @@ export const setupUpdateSubscriptionBillingContext = async ({
paymentMethod,
currentEpochMs,
billingCycleAnchorMs: billingCycleAnchorMs ?? "now",
billingCycleAnchorMs,
resetCycleAnchorMs,
invoiceMode,
featureQuantities,
customPrices,
customEnts,
trialContext,
};
};

View File

@@ -10,6 +10,7 @@ export const autumnBillingPlanToFinalFullCustomer = ({
}) => {
const {
updateCustomerProduct,
deleteCustomerProduct,
insertCustomerProducts,
updateCustomerEntitlements,
} = autumnBillingPlan;
@@ -23,13 +24,20 @@ export const autumnBillingPlanToFinalFullCustomer = ({
];
// 2. Replace updated customer product if applicable
const customerProducts = combinedCustomerProducts.map((customerProduct) =>
let customerProducts = combinedCustomerProducts.map((customerProduct) =>
customerProduct.id === updateCustomerProduct?.id
? updateCustomerProduct
: customerProduct,
);
// 3. Apply entitlement balance updates
// 3. Remove deleted customer product if applicable
if (deleteCustomerProduct) {
customerProducts = customerProducts.filter(
(customerProduct) => customerProduct.id !== deleteCustomerProduct.id,
);
}
// 4. Apply entitlement balance updates
if (updateCustomerEntitlements) {
const entitlementById = new Map(
customerProducts
@@ -45,7 +53,7 @@ export const autumnBillingPlanToFinalFullCustomer = ({
}
}
// 4. Return final full customer
// 5. Return final full customer
return {
...finalFullCustomer,
customer_products: customerProducts,

View File

@@ -1,7 +1,7 @@
import {
type FullCusProduct,
type InitFullCustomerProductContext,
type InitFullCustomerProductOptions,
import type {
FullCusProduct,
InitFullCustomerProductContext,
InitFullCustomerProductOptions,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { generateId } from "@/utils/genUtils";

View File

@@ -8,28 +8,27 @@
## Quick Start
Use `initTestScenario` for the fastest test setup:
Use `initScenario` with the scenario builder (`s.*`) for test setup:
```typescript
import { expect, test } from "bun:test";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
test.concurrent(`${chalk.yellowBright("my-feature: descriptive test name")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initTestScenario({
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "my-unique-test-id",
products: [free],
attachProducts: [free.id], // Pass original IDs - auto-prefixed
customerOptions: {
withTestClock: true,
attachPm: "success",
},
setup: [
s.customer({ paymentMethod: "success" }), // testClock defaults to true
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Your test logic here
@@ -40,7 +39,7 @@ test.concurrent(`${chalk.yellowBright("my-feature: descriptive test name")}`, as
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[0].balance).toBe(400);
expect(customer.features[TestFeature.Messages].balance).toBe(400);
});
```
@@ -91,48 +90,83 @@ const pro = products.pro({ items: [items.monthlyMessages()] });
---
## Test Scenario Initialization
## Scenario Builder (`initScenario`) - Recommended
### `initTestScenario`
Combines customer creation, product creation, and attachment into one call.
```typescript
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
const { customerId, products, autumnV1, autumnV2, testClockId, customer, ctx } =
await initTestScenario({
customerId: "unique-test-id", // Used as customer ID AND product prefix
products: [free, addon], // Products to create
attachProducts: [free.id], // Original IDs (auto-prefixed with customerId_)
customerOptions: {
withTestClock: true, // Default: true
attachPm: "success", // "success" | "fail" | "authenticate"
withDefault: false, // Default: false
customerData: { fingerprint }, // Optional customer data
},
});
```
**Important:** Product IDs are prefixed with `customerId_` for test isolation.
- You pass: `attachProducts: ["base"]`
- Actual product ID becomes: `"my-test-id_base"`
---
## Scenario Builder (`initScenario`)
For complex tests (entities, multiple products), use functional composition:
Use functional composition with `setup` and `actions` arrays for flexible test configuration:
```typescript
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
const { autumnV1, ctx, entities } = await initScenario({
const { customerId, autumnV1, autumnV2, ctx, testClockId, entities } = await initScenario({
customerId: "my-test",
options: [
setup: [
s.customer({ paymentMethod: "success" }), // testClock is true by default
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }), // optional
],
actions: [
s.attach({ productId: "pro", entityIndex: 0 }),
s.attach({ productId: "free", entityIndex: 1 }),
s.advanceTestClock({ days: 15 }), // optional
],
});
// entities[0].id = "ent-1", entities[1].id = "ent-2"
```
### Setup Methods (`s.*`)
| Method | Purpose |
|--------|---------|
| `s.customer({ paymentMethod?, data?, withDefault?, testClock? })` | Customer options. **`testClock` defaults to `true`** - don't pass it unless disabling |
| `s.products({ list })` | Products to create |
| `s.entities({ count, featureId })` | Auto-generate entities (ids: "ent-1", "ent-2", ...) |
> **Note:** `testClock` defaults to `true` - you don't need to pass `testClock: true` in most tests.
### Action Methods (`s.*`)
| Method | Purpose |
|--------|---------|
| `s.attach({ productId, entityIndex? })` | Attach product (omit entityIndex for customer-level) |
| `s.cancel({ productId, entityIndex? })` | Cancel product subscription |
| `s.advanceTestClock({ days?, weeks?, hours?, months?, toNextInvoice? })` | Advance test clock after attachments |
### Examples
**Simple test (no entities):**
```typescript
const { customerId, autumnV1 } = await initScenario({
customerId: "simple-test",
setup: [
s.customer({}), // testClock defaults to true
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
```
**With payment method:**
```typescript
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "paid-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
```
**With entities:**
```typescript
const { customerId, autumnV1, entities } = await initScenario({
customerId: "entity-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: "pro", entityIndex: 0 }),
s.attach({ productId: "free", entityIndex: 1 }),
],
@@ -140,13 +174,46 @@ const { autumnV1, ctx, entities } = await initScenario({
// entities[0].id = "ent-1", entities[1].id = "ent-2"
```
| Method | Purpose |
|--------|---------|
| `s.customer({ paymentMethod?, testClock?, data?, withDefault? })` | Customer options (testClock defaults to `true`) |
| `s.products({ list })` | Products to create |
| `s.entities({ count, featureId })` | Auto-generate entities (ids: "ent-1", "ent-2", ...) |
| `s.attach({ productId, entityIndex? })` | Attach product (omit entityIndex for customer-level) |
| `s.advanceTestClock({ days?, weeks?, hours?, months?, toNextInvoice? })` | Advance test clock after attachments |
**With clock advancement:**
```typescript
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "clock-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: "pro" }),
s.advanceTestClock({ days: 15 }),
],
});
```
---
## Product ID Prefixing
**Important:** Product IDs are automatically prefixed with `customerId_` for test isolation.
- In `s.attach()`: Use the **unprefixed** product ID (e.g., `"base"`, `"pro"`)
- In API calls after setup: Use `product.id` which includes the prefix
```typescript
const free = products.base({ items: [messagesItem] }); // id = "base"
const { customerId, autumnV1 } = await initScenario({
customerId: "my-test",
setup: [s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })], // Use "base" (unprefixed)
});
// For subsequent API calls, use free.id which is prefixed
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id, // "my-test_base" (prefixed)
items: [newItem],
});
```
---
@@ -176,6 +243,26 @@ await autumnV1.subscriptions.update({
---
## Legacy: `initTestScenario`
For simpler cases without entities, `initTestScenario` is still available but `initScenario` is preferred:
```typescript
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
const { customerId, autumnV1, ctx } = await initTestScenario({
customerId: "unique-test-id",
products: [free, addon],
attachProducts: [free.id], // Original IDs (auto-prefixed)
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
```
---
## Manual Setup (when initScenario doesn't fit)
### Customer Initialization
@@ -219,3 +306,39 @@ Place cursor inside a `test.concurrent()` block and press `Cmd+T`.
```bash
bun test path/to/file.test.ts
```
---
## Code Style
### Avoid Parameter Duplication
When calling similar methods (like `previewUpdate` + `update`), define params once and reuse:
```typescript
// ❌ BAD - Duplicated params
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
});
// ✅ GOOD - Define once, reuse
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
await autumnV1.subscriptions.update(updateParams);
```

View File

@@ -0,0 +1,113 @@
# Subscription Update Billing Guide
## Proration & Charges
When updating a subscription via `subscriptions.update` (custom plan), charges/credits are calculated based on the billing model:
### Billing Models
| Model | On Update Behavior |
|-------|-------------------|
| **Base Price** | Prorated charge/credit for price difference |
| **Consumable** | No immediate overage charge (billed in arrears at cycle end) |
| **Allocated** | Prorated charge for current overage above new included amount |
| **Prepaid** | Full refund of previous prepaid, full charge for new prepaid |
### Detailed Behavior
#### 1. Base Price Changes
- **Increase**: Charge prorated difference for remaining cycle
- **Decrease**: Credit prorated difference for remaining cycle
- **Remove**: Credit full remaining prorated amount
```typescript
// $20/mo -> $30/mo at start of cycle = charge $10
expect(preview.total).toBe(10);
// $30/mo -> $20/mo at start of cycle = credit $10
expect(preview.total).toBe(-10);
// Mid-cycle (15 days): $20/mo -> $30/mo = charge ~$5 (prorated)
expect(preview.total).toBe(5);
```
#### 2. Consumable Features
- **Never** charge overage on update
- Overage is billed at end of billing cycle
- Even if usage exceeds new included amount, preview.total = 0 for the consumable portion
```typescript
// 80 used, 50 included = 30 overage, but...
expect(preview.total).toBe(0); // Consumable overage NOT charged on update
```
#### 3. Allocated Features (Seat-Based)
- Charge prorated amount for overage seats above new included amount
- Based on current usage vs new included allowance
```typescript
// Using 5 seats, decrease included from 5 to 3
// Overage = 5 - 3 = 2 seats @ $10/seat = $20
expect(preview.total).toBe(20);
// Using 2 seats, increase included from 2 to 5
// No overage, no charge
expect(preview.total).toBe(0);
```
#### 4. Prepaid Features
- Refund full previous prepaid amount
- Charge full new prepaid amount
```typescript
// Had 100 units prepaid @ $10, now buying 200 @ $10
// = -$10 (refund) + $20 (new) = $10
expect(preview.total).toBe(10);
// Switching from prepaid to non-prepaid
// = -$10 (full refund)
expect(preview.total).toBe(-10);
```
### Preview vs Invoice Matching
Always verify that `preview.total` matches the actual invoice:
```typescript
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toBe(expectedAmount);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: expectedInvoiceCount,
latestTotal: preview.total,
});
```
### Invoice Count Guidelines
| Transition | Expected Count |
|------------|---------------|
| Free-to-Free | 0 |
| Free-to-Paid | 1 |
| Paid-to-Paid (upgrade/downgrade) | Initial (1) + Update (1) = 2 |
| Paid-to-Paid (allocated to prepaid) | Initial (1) + Arrear Settlement (1) + Prepaid (1) = 3 |
### No-Charge Updates
These updates should have `preview.total = 0`:
- Adding/removing boolean features (no price impact)
- Changing included usage (no billing attached)
- Changing feature intervals (month → week)
- Updating consumable features (overage not charged on update)
- Increasing allocated seats when within included amount

View File

@@ -0,0 +1,636 @@
import { expect, test } from "bun:test";
import { ProductItemInterval } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem";
// ═══════════════════════════════════════════════════════════════════════════════
// FREE-TO-FREE TESTS
// ═══════════════════════════════════════════════════════════════════════════════
// 1. Adding a boolean feature to existing free product (usage should stay)
test.concurrent(`${chalk.yellowBright("free-to-free: add boolean feature")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "f2f-add-bool",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track some usage before update
const messagesUsage = 40;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Add boolean dashboard feature
const dashboardItem = items.dashboard();
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [messagesItem, dashboardItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No charge for free-to-free
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Messages usage should stay the same
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// Dashboard should be accessible (boolean feature)
expect(customer.features[TestFeature.Dashboard]).toBeDefined();
// No invoice for free-to-free
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// 2. Adding unlimited feature to existing free product (usage should stay)
test.concurrent(`${chalk.yellowBright("free-to-free: add unlimited feature")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "f2f-add-unlimited",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track some usage before update
const messagesUsage = 50;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Replace messages with unlimited
const unlimitedMessagesItem = items.unlimitedMessages();
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [unlimitedMessagesItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Messages should now be unlimited
expect(customer.features[TestFeature.Messages].unlimited).toBe(true);
// No invoice for free-to-free
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// 3. Adding additional included feature (usage should stay for existing)
test.concurrent(`${chalk.yellowBright("free-to-free: add included feature")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "f2f-add-included",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track some usage before update
const messagesUsage = 35;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Add words feature
const wordsItem = items.monthlyWords({ includedUsage: 200 });
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [messagesItem, wordsItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Messages usage should stay the same
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// Words should have full balance
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: wordsItem.included_usage,
balance: wordsItem.included_usage,
usage: 0,
});
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// 4. Removing a feature (usage for remaining should stay)
test.concurrent(`${chalk.yellowBright("free-to-free: remove feature")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const wordsItem = items.monthlyWords({ includedUsage: 200 });
const free = products.base({ items: [messagesItem, wordsItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "f2f-remove-feat",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track usage on both features
const messagesUsage = 25;
const wordsUsage = 75;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Words,
value: wordsUsage,
},
{ timeout: 2000 },
);
// Remove words feature, keep only messages
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [messagesItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Messages usage should stay the same
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// Words should no longer exist
expect(customer.features[TestFeature.Words]).toBeUndefined();
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// 5. Update included usage on existing feature (increase)
test.concurrent(`${chalk.yellowBright("free-to-free: increase included usage")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "f2f-inc-included",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track some usage
const messagesUsage = 60;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Increase included usage from 100 to 200
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [updatedMessagesItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should stay, balance should increase
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// 6. Update included usage on existing feature (decrease)
test.concurrent(`${chalk.yellowBright("free-to-free: decrease included usage")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 200 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "f2f-dec-included",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track some usage
const messagesUsage = 50;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Decrease included usage from 200 to 100
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 100 });
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [updatedMessagesItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should stay, balance should decrease
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// 7. Update interval on existing feature (month -> week)
test.concurrent(`${chalk.yellowBright("free-to-free: change interval month to week")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "f2f-int-m2w",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track some usage
const messagesUsage = 30;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Change interval from monthly to weekly
const weeklyMessagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
interval: ProductItemInterval.Week,
});
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [weeklyMessagesItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should stay
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100 - messagesUsage,
usage: messagesUsage,
});
// Verify interval changed
expect(customer.features[TestFeature.Messages].interval).toEqual(
ProductItemInterval.Week,
);
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// 8. Update interval on existing feature (month -> year)
test.concurrent(`${chalk.yellowBright("free-to-free: change interval month to year")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "f2f-int-m2y",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track some usage
const messagesUsage = 45;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Change interval from monthly to yearly
const yearlyMessagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
interval: ProductItemInterval.Year,
});
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [yearlyMessagesItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should stay
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100 - messagesUsage,
usage: messagesUsage,
});
// Verify interval changed
expect(customer.features[TestFeature.Messages].interval).toEqual(
ProductItemInterval.Year,
);
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// FREE-TO-FREE: RESET CYCLE ANCHOR PRESERVATION TESTS
// ═══════════════════════════════════════════════════════════════════════════════
// 9. Reset cycle anchor stays same after advancing clock 5 days
test.concurrent(`${chalk.yellowBright("free-to-free: anchor stays same after 5 days")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "f2f-anchor-5d",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track some usage
const messagesUsage = 20;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Get the original reset time
const customerBefore = await autumnV1.customers.get(customerId);
const originalResetAt =
customerBefore.features[TestFeature.Messages].next_reset_at;
expect(originalResetAt).toBeDefined();
// Advance test clock by 5 days
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 5,
});
// Update with slightly more included usage
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 150 });
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [updatedMessagesItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No charge for free-to-free
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should stay the same, reset anchor should stay approximately the same
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage - messagesUsage,
usage: messagesUsage,
resetsAt: originalResetAt!,
});
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});
// 11. Reset cycle anchor stays same after advancing clock 2 weeks (weekly feature)
test.concurrent(`${chalk.yellowBright("free-to-free: weekly anchor stays same after 2 weeks")}`, async () => {
const weeklyMessagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 50,
interval: ProductItemInterval.Week,
});
const free = products.base({ items: [weeklyMessagesItem] });
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "f2f-anchor-2w",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: "base" })],
});
// Track some usage
const messagesUsage = 15;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Get the original reset time
const customerBefore = await autumnV1.customers.get(customerId);
const originalResetAt =
customerBefore.features[TestFeature.Messages].next_reset_at;
expect(originalResetAt).toBeDefined();
// Advance test clock by 10 days (note: this will trigger resets)
// We're testing that after update, the anchor day remains the same
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 10,
});
// Update with more included usage
const updatedWeeklyMessagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
interval: ProductItemInterval.Week,
});
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [updatedWeeklyMessagesItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No charge for free-to-free
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// After 2 weeks, balance would have reset, but anchor day should be preserved
// The next_reset_at should be aligned to the same day of week as original
const newResetAt = customer.features[TestFeature.Messages].next_reset_at;
expect(newResetAt).toBeDefined();
// Calculate day of week from both timestamps (should be same day)
const originalDay = new Date(originalResetAt!).getDay();
const newDay = new Date(newResetAt!).getDay();
expect(newDay).toEqual(originalDay);
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
});

View File

@@ -0,0 +1,398 @@
import { expect, test } from "bun:test";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// FREE-TO-PAID TESTS
// ═══════════════════════════════════════════════════════════════════════════════
// 1. Adding a monthly base price to free product
test.concurrent(`${chalk.yellowBright("free-to-paid: add monthly base price")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 300 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "f2p-add-base",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
},
{ timeout: 2000 },
);
const priceItem = items.monthlyPrice();
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [messagesItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $20 for monthly base price
expect(preview.total).toEqual(20);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - 100,
usage: 100,
});
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 20,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 2. Adding monthly base price + consumable to free product
test.concurrent(`${chalk.yellowBright("free-to-paid: add monthly base + consumable")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "f2p-add-base-cons",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Track some usage before update
const messagesUsage = 30;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
const priceItem = items.monthlyPrice();
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [consumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $20 for monthly base price (consumable overage not charged on update)
expect(preview.total).toEqual(20);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: consumableItem.included_usage,
balance: consumableItem.included_usage - messagesUsage,
usage: messagesUsage,
});
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 20,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 3. Adding annual base price + monthly consumable to free product
test.concurrent(`${chalk.yellowBright("free-to-paid: add annual base + monthly consumable")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "f2p-add-annual-cons",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
const priceItem = items.annualPrice();
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [consumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $200 for annual base price
expect(preview.total).toEqual(200);
await autumnV1.subscriptions.update(updateParams, { timeout: 2000 });
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: consumableItem.included_usage,
balance: consumableItem.included_usage,
usage: 0,
});
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 200,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4. Updating free feature item to consumable
test.concurrent(`${chalk.yellowBright("free-to-paid: update free item to consumable")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "f2p-update-to-cons",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Track some usage first
const messagesUsage = 50;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Update to consumable (pay-per-use after included usage)
const consumableItem = items.consumableMessages({ includedUsage: 100 });
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [consumableItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No immediate charge - consumable bills in arrears
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: consumableItem.included_usage,
balance: consumableItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// No invoice - consumable bills in arrears, no immediate charge
expectCustomerInvoiceCorrect({
customer,
count: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 5. Updating free feature item to prepaid
test.concurrent(`${chalk.yellowBright("free-to-paid: update free item to prepaid")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "f2p-update-to-prepaid",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Track some usage first
const messagesUsage = 30;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Update to prepaid (purchase units upfront)
const prepaidItem = items.prepaidMessages();
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [prepaidItem],
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Prepaid charges upfront - $10 for 100 units
expect(preview.total).toEqual(10);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100 - messagesUsage,
usage: messagesUsage,
});
// Verify invoice matches preview
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6. Updating free users to allocated users
test.concurrent(`${chalk.yellowBright("free-to-paid: update free users to allocated")}`, async () => {
const usersItem = items.monthlyUsers({ includedUsage: 5 });
const free = products.base({ items: [usersItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "f2p-free-to-allocated",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Use some users (continuous use feature)
const usersUsed = 3;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Users,
value: usersUsed,
},
{ timeout: 2000 },
);
// Verify initial state
const initialCustomer = await autumnV1.customers.get(customerId);
expect(initialCustomer.features[TestFeature.Users].balance).toEqual(
usersItem.included_usage - usersUsed,
);
// Update to allocated users ($10/seat prorated billing)
const allocatedUsersItem = items.allocatedUsers({ includedUsage: 2 });
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [allocatedUsersItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge for (usersUsed - includedUsage) extra seats = (3 - 2) = 1 seat @ $10
expect(preview.total).toEqual(10);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: allocatedUsersItem.included_usage,
balance: allocatedUsersItem.included_usage - usersUsed,
usage: usersUsed,
});
// Invoice for the extra seat
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 10,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,494 @@
import { expect, test } from "bun:test";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-PAID: BILLING MODEL TRANSITIONS
// ═══════════════════════════════════════════════════════════════════════════════
// 4.1 Consumable to prepaid (with overage usage)
test.concurrent(`${chalk.yellowBright("p2p: consumable to prepaid with overage")}`, async () => {
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [consumableItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-cons-to-prepaid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track usage into overage (80 used with 50 included = 30 overage @ $0.10)
const messagesUsage = 80;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Verify customer is in overage
const customerBefore = await autumnV1.customers.get(customerId);
expect(customerBefore.features[TestFeature.Messages].balance).toBe(-30);
// Change to prepaid with 100 units
const prepaidItem = items.prepaidMessages({ includedUsage: 0 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
};
3;
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Preview should only include prepaid charge ($10 for 100 units), NOT overage
expect(preview.total).toBe(10);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should be preserved - was 80, now has 100 prepaid = 20 remaining
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100 - messagesUsage, // 100 - 80 = 20
usage: messagesUsage,
});
// Verify invoice count and that latest total matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + upgrade
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4.2 Prepaid to consumable
test.concurrent(`${chalk.yellowBright("p2p: prepaid to consumable")}`, async () => {
const prepaidItem = items.prepaidMessages({ includedUsage: 0 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [prepaidItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-prepaid-to-cons",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
// Track some usage (40 of 100 prepaid = 60 unused)
const messagesUsage = 40;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Change to consumable
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [consumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should refund full prepaid amount (usage carried over to new plan)
expect(preview.total).toBe(-10);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should be preserved
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: consumableItem.included_usage,
balance: consumableItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// Verify invoice count and that latest total matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + downgrade
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4.3 Included to consumable with overage
test.concurrent(`${chalk.yellowBright("p2p: included to consumable with overage")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-inc-to-cons",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track some usage
const messagesUsage = 60;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Change to consumable with overage (50 included + $0.10/unit overage)
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [consumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Billing model change only - no immediate charge
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage preserved - now over the included amount
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: consumableItem.included_usage,
balance: consumableItem.included_usage - messagesUsage, // Will be negative
usage: messagesUsage,
});
// Verify invoice count and that latest total matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + billing model change
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4.4 Consumable to included (remove overage)
test.concurrent(`${chalk.yellowBright("p2p: consumable to included (remove overage)")}`, async () => {
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [consumableItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-cons-to-inc",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track some usage
const messagesUsage = 35;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Change to pure included (no overage)
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Billing model change only - no immediate charge
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage preserved
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// Verify invoice count and that latest total matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + billing model change
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4.5 Allocated to prepaid
test.concurrent(`${chalk.yellowBright("p2p: allocated to prepaid")}`, async () => {
const allocatedItem = items.allocatedUsers({ includedUsage: 2 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [allocatedItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-alloc-to-prepaid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track some usage (5 users, 2 included, 3 overage)
const usersUsage = 5;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Users,
value: usersUsage,
},
{ timeout: 2000 },
);
// Change to prepaid model with 10 users
const prepaidItem = items.prepaidUsers({ includedUsage: 0 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage preserved, now with prepaid model
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 10,
balance: 10 - usersUsage,
usage: usersUsage,
});
// Verify invoice count and that latest total matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 3, // Initial attach + arrear settlement + prepaid charge
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4.6 Prepaid to allocated
test.concurrent(`${chalk.yellowBright("p2p: prepaid to allocated")}`, async () => {
const prepaidItem = items.prepaidUsers({ includedUsage: 0 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [prepaidItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-prepaid-to-alloc",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
}),
],
});
// Track some usage
const usersUsage = 6;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Users,
value: usersUsage,
},
{ timeout: 2000 },
);
// Change to allocated model with 3 included
const allocatedItem = items.allocatedUsers({ includedUsage: 3 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [allocatedItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage preserved, now using 6 with 3 included = 3 overage @ $10 each
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: allocatedItem.included_usage,
balance: allocatedItem.included_usage - usersUsage, // -3
usage: usersUsage,
});
// Verify invoice count and that latest total matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial attach + billing model change
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4.7 Allocated with entity-based usage
test.concurrent(`${chalk.yellowBright("p2p: allocated with entity-based usage")}`, async () => {
const allocatedItem = items.allocatedUsers({ includedUsage: 2 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [allocatedItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-alloc-entities",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 3, featureId: TestFeature.Users }),
],
actions: [s.attach({ productId: "pro" })],
});
// Entity count = 3, included = 2, so 1 overage seat @ $10
const customer = await autumnV1.customers.get(customerId);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: allocatedItem.included_usage,
balance: allocatedItem.included_usage - 3, // 2 - 3 = -1
usage: 3,
});
// Increase included to cover all entities
const newAllocatedItem = items.allocatedUsers({ includedUsage: 5 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newAllocatedItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
await autumnV1.subscriptions.update(updateParams);
const updatedCustomer = await autumnV1.customers.get(customerId);
// Now 3 entities with 5 included = 2 remaining
expectCustomerFeatureCorrect({
customer: updatedCustomer,
featureId: TestFeature.Users,
includedUsage: newAllocatedItem.included_usage,
balance: newAllocatedItem.included_usage - 3, // 5 - 3 = 2
usage: 3,
});
// Verify invoice count and that latest total matches preview
await expectCustomerInvoiceCorrect({
customer: updatedCustomer,
count: 2, // Initial attach + billing model change
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,840 @@
import { expect, test } from "bun:test";
import { ProductItemInterval } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem";
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-PAID: INCLUDED USAGE SHIFTS
// ═══════════════════════════════════════════════════════════════════════════════
// 4.8 Shift included usage up (50 -> 200)
test.concurrent(`${chalk.yellowBright("p2p: shift included usage up")}`, async () => {
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [consumableItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-shift-inc-up",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track usage that puts us in overage (80 used, 50 included = 30 overage)
const messagesUsage = 80;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Shift included up to 200 - should cover existing usage
const newConsumableItem = items.consumableMessages({ includedUsage: 200 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newConsumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No price change, just included usage shift
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage preserved, now within included
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: newConsumableItem.included_usage,
balance: newConsumableItem.included_usage - messagesUsage, // 200 - 80 = 120
usage: messagesUsage,
});
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4.9 Shift included usage down (200 -> 50) into overage
test.concurrent(`${chalk.yellowBright("p2p: shift included usage down into overage")}`, async () => {
const consumableItem = items.consumableMessages({ includedUsage: 200 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [consumableItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-shift-inc-down",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track usage within included (80 used, 200 included)
const messagesUsage = 80;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Shift included down to 50 - puts existing usage into overage
const newConsumableItem = items.consumableMessages({ includedUsage: 50 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newConsumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No price change, just included usage shift (consumable overage not charged on update)
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage preserved, now in overage (80 used, 50 included = 30 overage)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: newConsumableItem.included_usage,
balance: newConsumableItem.included_usage - messagesUsage, // 50 - 80 = -30
usage: messagesUsage,
});
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-PAID: OVERAGE BILLING MID-CYCLE
// ═══════════════════════════════════════════════════════════════════════════════
// 5.1 Mid-cycle update with pending overage
test.concurrent(`${chalk.yellowBright("p2p: mid-cycle update with pending overage")}`, async () => {
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [consumableItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-pending-overage",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track usage that goes over included (50 included, use 80 = 30 overage @ $0.10 = $3)
const messagesUsage = 80;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Update to different consumable config
const newConsumableItem = items.consumableMessages({ includedUsage: 100 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newConsumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Preview should NOT include overage charge (consumable overage not charged on update)
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage preserved, now within included
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: newConsumableItem.included_usage,
balance: newConsumableItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 5.2 Update consumable to more included covers overage
test.concurrent(`${chalk.yellowBright("p2p: update to more included covers overage")}`, async () => {
const consumableItem = items.consumableMessages({ includedUsage: 50 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [consumableItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-cover-overage",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track usage that goes over (80 used with 50 included = 30 overage)
const messagesUsage = 80;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Verify customer is over their limit
const customerBefore = await autumnV1.customers.get(customerId);
expect(customerBefore.features[TestFeature.Messages].balance).toBeLessThan(0);
// Update to 100 included - should now cover the usage
const newConsumableItem = items.consumableMessages({ includedUsage: 100 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newConsumableItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No price change (consumable overage not charged on update)
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Now usage is within included
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: newConsumableItem.included_usage,
balance: newConsumableItem.included_usage - messagesUsage, // 100 - 80 = 20
usage: messagesUsage,
});
// Balance should now be positive
expect(customer.features[TestFeature.Messages].balance).toBeGreaterThan(0);
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-PAID: INTERVAL CHANGES
// ═══════════════════════════════════════════════════════════════════════════════
// 6.1 Monthly to annual
test.concurrent(`${chalk.yellowBright("p2p: monthly to annual")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const monthlyPriceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, monthlyPriceItem],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-month-to-annual",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track some usage
const messagesUsage = 50;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Change to annual pricing
const annualPriceItem = items.annualPrice({ price: 200 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem, annualPriceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $180 ($200 annual - $20 monthly credit)
expect(preview.total).toBe(180);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should be preserved
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6.2 Annual to monthly
test.concurrent(`${chalk.yellowBright("p2p: annual to monthly")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const annualPriceItem = items.annualPrice({ price: 200 });
const pro = products.base({
id: "pro",
items: [messagesItem, annualPriceItem],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-annual-to-month",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track some usage
const messagesUsage = 30;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Change to monthly pricing
const monthlyPriceItem = items.monthlyPrice({ price: 20 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem, monthlyPriceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should credit $180 ($20 monthly - $200 annual credit)
expect(preview.total).toBe(-180);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should be preserved
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6.3 Change feature reset interval (month to week)
test.concurrent(`${chalk.yellowBright("p2p: change feature reset interval")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-reset-interval",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track some usage
const messagesUsage = 40;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Change to weekly reset
const weeklyMessagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
interval: ProductItemInterval.Week,
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [weeklyMessagesItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No price change, just interval change
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should stay
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100 - messagesUsage,
usage: messagesUsage,
});
// Verify interval changed
expect(customer.features[TestFeature.Messages].interval).toEqual(
ProductItemInterval.Week,
);
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-PAID: TIME-ADVANCED UPDATES (TEST CLOCK)
// ═══════════════════════════════════════════════════════════════════════════════
// 7.4 Reset cycle anchor preserved after 5 days
test.concurrent(`${chalk.yellowBright("p2p: reset anchor preserved after 5 days")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "p2p-anchor-5d",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track some usage
const messagesUsage = 25;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Get the original reset time
const customerBefore = await autumnV1.customers.get(customerId);
const originalResetAt =
customerBefore.features[TestFeature.Messages].next_reset_at;
expect(originalResetAt).toBeDefined();
// Advance test clock by 5 days
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 5,
});
// Update with more included usage (keep same price)
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 150 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [updatedMessagesItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No price change, just included usage change
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage should stay the same, reset anchor should stay approximately the same
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage - messagesUsage,
usage: messagesUsage,
resetsAt: originalResetAt!,
});
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-PAID: ALLOCATED/SEAT-BASED UPDATES
// ═══════════════════════════════════════════════════════════════════════════════
// 8.1 Increase seat allowance
test.concurrent(`${chalk.yellowBright("p2p: increase seat allowance")}`, async () => {
const allocatedUsersItem = items.allocatedUsers({ includedUsage: 2 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [allocatedUsersItem, priceItem],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-inc-seats",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Use 2 seats (at the limit)
const seatsUsed = 2;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Users,
value: seatsUsed,
},
{ timeout: 2000 },
);
// Increase to 5 included seats
const newAllocatedUsersItem = items.allocatedUsers({ includedUsage: 5 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newAllocatedUsersItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// No price change, just seat increase
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Usage preserved, more capacity available
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: newAllocatedUsersItem.included_usage,
balance: newAllocatedUsersItem.included_usage - seatsUsed,
usage: seatsUsed,
});
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 8.2 Decrease seat allowance below usage
test.concurrent(`${chalk.yellowBright("p2p: decrease seat allowance below usage")}`, async () => {
const allocatedUsersItem = items.allocatedUsers({ includedUsage: 5 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [allocatedUsersItem, priceItem],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-dec-seats",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Use 5 seats
const seatsUsed = 5;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Users,
value: seatsUsed,
},
{ timeout: 2000 },
);
// Decrease to 3 included seats (using 5, so 2 extra)
const newAllocatedUsersItem = items.allocatedUsers({ includedUsage: 3 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [newAllocatedUsersItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $20 for 2 extra seats @ $10 each
expect(preview.total).toBe(20);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Using 5 with 3 included = -2 balance
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: newAllocatedUsersItem.included_usage,
balance: newAllocatedUsersItem.included_usage - seatsUsed,
usage: seatsUsed,
});
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-PAID: COMBINATION UPDATES
// ═══════════════════════════════════════════════════════════════════════════════
// 9.3 Change interval + add feature + change usage
test.concurrent(`${chalk.yellowBright("p2p: interval + feature + usage change")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const monthlyPriceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, monthlyPriceItem],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-combo-complex",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track some usage
const messagesUsage = 50;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Complex update: monthly -> annual + add words + increase messages to 200
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const annualPriceItem = items.annualPrice({ price: 200 });
const wordsItem = items.monthlyWords({ includedUsage: 500 });
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [updatedMessagesItem, annualPriceItem, wordsItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $180 ($200 annual - $20 monthly credit)
expect(preview.total).toBe(180);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Messages usage preserved, higher limit
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage - messagesUsage,
usage: messagesUsage,
});
// Words added
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: wordsItem.included_usage,
balance: wordsItem.included_usage,
usage: 0,
});
// Verify invoice matches preview
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,377 @@
import { expect, test } from "bun:test";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductActive,
expectProductCanceled,
expectProductScheduled,
} from "@tests/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts";
/**
* Update While Canceling Tests
*
* Tests for updating a subscription that is in the process of being canceled
* or is scheduled to downgrade.
*/
// ═══════════════════════════════════════════════════════════════════════════════
// TEST CASE 1: Update Pro product while it is canceling (free default scheduled)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Pro (paid product)
* - Free default product exists
* - User cancels Pro → free default is scheduled
* - User updates Pro product items
*
* Expected Result:
* - Pro should remain canceling (canceling state preserved)
* - Scheduled free product should remain scheduled
* - Stripe subscription is correct (still set to cancel at period end)
*/
test.concurrent(`${chalk.yellowBright("update-while-canceling: update pro items while canceling to free")}`, async () => {
const customerId = "cancel-update-pro-to-free";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
// Free is the default product
const free = constructProduct({
id: "free",
items: [messagesItem],
type: "free",
isDefault: true,
});
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [
s.attach({ productId: "pro" }),
s.cancel({ productId: "pro" }), // Cancel pro → free scheduled
],
});
// Verify pro is canceled and free is scheduled
const customerAfterCancel = await autumnV1.customers.get(customerId);
await expectProductCanceled({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductScheduled({
customer: customerAfterCancel,
productId: free.id,
});
// Now update pro's items while it's canceling
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const newPriceItem = items.monthlyPrice({ price: 30 }); // $30/mo instead of $20
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
items: [updatedMessagesItem, newPriceItem],
});
// Should charge prorated difference for price increase ($10 prorated)
console.log("Preview total (update while canceling):", preview.total);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
items: [updatedMessagesItem, newPriceItem],
});
// Verify state after update
const customerAfterUpdate = await autumnV1.customers.get(customerId);
// Pro should remain canceling (canceling state preserved)
await expectProductCanceled({
customer: customerAfterUpdate,
productId: pro.id,
});
// Scheduled free product should remain scheduled
await expectProductScheduled({
customer: customerAfterUpdate,
productId: free.id,
});
// Verify Stripe subscription is correct (still set to cancel at period end)
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: true,
});
// Verify invoices
expectCustomerInvoiceCorrect({
customer: customerAfterUpdate,
count: 2, // Initial pro attach + update
latestTotal: preview.total,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST CASE 2: Update scheduled Pro while downgrading from Premium
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Premium
* - User downgrades from Premium → Pro (Pro is scheduled)
* - User updates the scheduled Pro product
*
* Expected Result:
* - Scheduled Pro product should remain scheduled (with updated items)
* - Premium product should remain canceling
*/
test.concurrent(`${chalk.yellowBright("update-while-canceling: update scheduled pro during downgrade")}`, async () => {
const customerId = "downgrade-update-scheduled";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const consumableItem = items.consumableMessages({ includedUsage: 50 });
// Premium product ($50/mo)
const premium = constructProduct({
id: "premium",
items: [consumableItem],
type: "premium",
isDefault: false,
});
// Pro product ($20/mo)
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [s.attach({ productId: "premium" })],
});
// Verify premium is active
const customerAfterAttach = await autumnV1.customers.get(customerId);
await expectProductActive({
customer: customerAfterAttach,
productId: premium.id,
});
// User downgrades from premium to pro (pro is scheduled)
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
// Verify premium is canceled and pro is scheduled
const customerAfterDowngrade = await autumnV1.customers.get(customerId);
await expectProductCanceled({
customer: customerAfterDowngrade,
productId: premium.id,
});
await expectProductScheduled({
customer: customerAfterDowngrade,
productId: pro.id,
});
console.log("Products after downgrade:", customerAfterDowngrade.products);
// Now update the scheduled pro's items
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const newPriceItem = items.monthlyPrice({ price: 40 }); // $40/mo instead of $20
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
items: [updatedMessagesItem, newPriceItem],
});
console.log("Preview total (update scheduled product):", preview.total);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
items: [updatedMessagesItem, newPriceItem],
});
// Verify state after update
const customerAfterUpdate = await autumnV1.customers.get(customerId);
console.log("Products after update:", customerAfterUpdate.products);
// Scheduled pro product should remain scheduled (with updated items)
await expectProductScheduled({
customer: customerAfterUpdate,
productId: pro.id,
});
// Premium product should remain canceling
await expectProductCanceled({
customer: customerAfterUpdate,
productId: premium.id,
});
// Verify Stripe subscription is correct (still set to cancel at period end)
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
subCount: 1,
shouldBeCanceled: true,
});
// Verify invoices
expectCustomerInvoiceCorrect({
customer: customerAfterUpdate,
count: 1, // Only initial premium attach
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// ADDITIONAL TEST: Update while canceling with usage tracked
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Pro with some usage tracked
* - User cancels Pro → free default is scheduled
* - User updates Pro product items (increases included usage)
*
* Expected Result:
* - Pro should remain canceling (canceling state preserved)
* - Scheduled free product should remain scheduled
* - Usage should be preserved
* - Stripe subscription is correct (still set to cancel at period end)
*/
test.concurrent(`${chalk.yellowBright("update-while-canceling: update pro with usage while canceling")}`, async () => {
const customerId = "cancel-update-pro-usage";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
// Free is the default product
const free = constructProduct({
id: "free",
items: [messagesItem],
type: "free",
isDefault: true,
});
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success", withDefault: true }),
s.products({ list: [free, pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Track some usage
const messagesUsage = 50;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Verify usage tracked
const customerWithUsage = await autumnV1.customers.get(customerId);
expect(customerWithUsage.features[TestFeature.Messages].usage).toBe(
messagesUsage,
);
// Cancel pro → free scheduled
await autumnV1.cancel({
customer_id: customerId,
product_id: pro.id,
});
// Verify pro is canceled and free is scheduled
const customerAfterCancel = await autumnV1.customers.get(customerId);
await expectProductCanceled({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductScheduled({
customer: customerAfterCancel,
productId: free.id,
});
// Now update pro's items while it's canceling
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
items: [updatedMessagesItem, items.monthlyPrice()],
});
console.log(
"Preview total (update while canceling with usage):",
preview.total,
);
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
items: [updatedMessagesItem, items.monthlyPrice()],
});
// Verify state after update
const customerAfterUpdate = await autumnV1.customers.get(customerId);
// Pro should remain canceling (canceling state preserved)
await expectProductCanceled({
customer: customerAfterUpdate,
productId: pro.id,
});
// Scheduled free product should remain scheduled
await expectProductScheduled({
customer: customerAfterUpdate,
productId: free.id,
});
// Usage should be preserved
expect(customerAfterUpdate.features[TestFeature.Messages].usage).toBe(
messagesUsage,
);
// Verify Stripe subscription is correct (still set to cancel at period end)
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: true,
});
});

View File

@@ -27,10 +27,12 @@ test.concurrent(`${chalk.yellowBright("multi-entity-from-free: update free items
const { autumnV1, ctx, entities } = await initScenario({
customerId,
options: [
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: "pro", entityIndex: 0 }),
s.attach({ productId: "free", entityIndex: 1 }),
],

View File

@@ -23,7 +23,7 @@ export const expectCustomerFeatureExists = async ({
expect(feature).toBeDefined();
};
const ONE_HOUR_MS = 60 * 60 * 1000;
const TEN_MINUTES_MS = 10 * 60 * 1000;
export const expectCustomerFeatureCorrect = async ({
customerId,
@@ -57,7 +57,7 @@ export const expectCustomerFeatureCorrect = async ({
const actualResetsAt = feature.next_reset_at ?? 0;
expect(actualResetsAt).toBeDefined();
expect(Math.abs(actualResetsAt - resetsAt)).toBeLessThanOrEqual(
ONE_HOUR_MS,
TEN_MINUTES_MS,
);
}
};

View File

@@ -45,9 +45,17 @@ export const expectCustomerProductCorrect = async ({
if (state === "active") {
expect(String(product.status)).toBe("active");
expect(product.canceled_at).toBeUndefined();
// canceled_at can be undefined or null when not canceled
expect(
product.canceled_at == null,
`Product ${productId} should not be canceled (canceled_at: ${product.canceled_at})`,
).toBe(true);
} else if (state === "canceled") {
expect(product.canceled_at).toBeDefined();
expect(String(product.status)).toBe("active");
expect(
product.canceled_at != null,
`Product ${productId} should be canceled`,
).toBe(true);
} else if (state === "scheduled") {
expect(String(product.status)).toBe("scheduled");
}

View File

@@ -112,8 +112,8 @@ const lifetimeMessages = ({
// ═══════════════════════════════════════════════════════════════════
/**
* Prepaid messages - purchase units upfront ($10/unit)
* @param includedUsage - Free units before purchase required (default: 0), billing units are 100
* Prepaid messages - purchase units upfront ($10 per 100 units)
* @param includedUsage - Free units before purchase required (default: 0)
*/
const prepaidMessages = ({
includedUsage = 0,
@@ -129,6 +129,24 @@ const prepaidMessages = ({
includedUsage,
}) as LimitedItem;
/**
* Prepaid users/seats - purchase seats upfront ($10/seat)
* @param includedUsage - Free seats before purchase required (default: 0)
*/
const prepaidUsers = ({
includedUsage = 0,
billingUnits = 1,
}: {
includedUsage?: number;
billingUnits?: number;
} = {}): LimitedItem =>
constructPrepaidItem({
featureId: TestFeature.Users,
price: 10,
billingUnits,
includedUsage,
}) as LimitedItem;
// ═══════════════════════════════════════════════════════════════════
// CONSUMABLE / PAY-PER-USE (overage pricing)
// ═══════════════════════════════════════════════════════════════════
@@ -210,6 +228,7 @@ export const items = {
// Prepaid
prepaidMessages,
prepaidUsers,
// Consumable
consumableMessages,

View File

@@ -37,9 +37,15 @@ type EntityConfig = {
featureId: string;
};
type FeatureOption = {
feature_id: string;
quantity: number;
};
type AttachmentDef = {
productId: string;
entityIndex?: number;
options?: FeatureOption[];
};
type CancelDef = {
@@ -136,20 +142,24 @@ const entities = ({
* Product ID is auto-prefixed with customerId.
* @param productId - The product ID (without prefix)
* @param entityIndex - Optional entity index (0-based) to attach to (omit for customer-level)
* @param options - Optional feature options (e.g., prepaid quantity)
* @example s.attach({ productId: "pro" }) // customer-level
* @example s.attach({ productId: "pro", entityIndex: 0 }) // attach to first entity (ent-1)
* @example s.attach({ productId: "free", entityIndex: 1 }) // attach to second entity (ent-2)
* @example s.attach({ productId: "pro", options: [{ feature_id: "messages", quantity: 100 }] })
*/
const attach = ({
productId,
entityIndex,
options,
}: {
productId: string;
entityIndex?: number;
options?: FeatureOption[];
}): ConfigFn => {
return (config) => ({
...config,
attachments: [...config.attachments, { productId, entityIndex }],
attachments: [...config.attachments, { productId, entityIndex, options }],
});
};
@@ -357,6 +367,7 @@ export const initScenario = async ({
customer_id: customerId,
product_id: prefixedProductId,
entity_id: entityId,
options: attachment.options,
});
}

View File

@@ -7,6 +7,12 @@ import { notNullish, nullish } from "../utils";
import { cusProductToPrices } from "./convertCusProduct";
import { ACTIVE_STATUSES } from "./cusProductConstants";
export const isCustomerProductMain = (cusProduct?: FullCusProduct) => {
if (!cusProduct) return false;
return !cusProduct.product.is_add_on;
};
export const isCustomerProductOneOff = (cusProduct?: FullCusProduct) => {
if (!cusProduct) return false;

View File

@@ -0,0 +1,35 @@
import type { FullCustomer } from "../../../models/cusModels/fullCusModel";
import { CusProductStatus } from "../../../models/cusProductModels/cusProductEnums";
import {
isCusProductOnEntity,
isCustomerProductMain,
} from "../classifyCusProduct";
/**
* Finds the scheduled customer product in a given group for a customer.
* Filters by product group, scheduled status, and entity.
*/
export const findMainScheduledCustomerProductByGroup = ({
fullCustomer,
productGroup,
}: {
fullCustomer: FullCustomer;
productGroup: string;
}) => {
return fullCustomer.customer_products.find((customerProduct) => {
const productGroupMatches = customerProduct.product.group === productGroup;
const statusMatches = customerProduct.status === CusProductStatus.Scheduled;
const entityMatches = isCusProductOnEntity({
cusProduct: customerProduct,
internalEntityId: fullCustomer.entity?.internal_id,
});
const isMainProduct = isCustomerProductMain(customerProduct);
return (
productGroupMatches && statusMatches && entityMatches && isMainProduct
);
});
};

View File

@@ -1,6 +1,5 @@
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels";
import type { FullCustomer } from "../../models/cusModels/fullCusModel";
import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums";
import {
cusProductHasSubscription,
customerProductHasSubscriptionSchedule,
@@ -77,35 +76,6 @@ export const getOngoingCusProductById = ({
return activeCusProduct;
};
/**
* Finds the scheduled customer product in a given group for a customer.
* Filters by product group, scheduled status, and entity.
*/
export const getScheduledMainCusProductByGroup = ({
fullCus,
productGroup,
}: {
fullCus: FullCustomer;
productGroup: string;
}) => {
return fullCus.customer_products.find((cp) => {
const productGroupMatches = cp.product.group === productGroup;
const statusMatches = cp.status === CusProductStatus.Scheduled;
const entityMatches = isCusProductOnEntity({
cusProduct: cp,
internalEntityId: fullCus.entity?.internal_id,
});
const isMainProduct = !cp.product.is_add_on;
return (
productGroupMatches && statusMatches && entityMatches && isMainProduct
);
});
};
const sortCustomerProductsForBilling = ({
customerProducts,
productId,

View File

@@ -0,0 +1,13 @@
export * from "./classifyCusProduct.js";
export * from "./convertCusProduct/cusProductToConvertedFeatureOptions.js";
export * from "./convertCusProduct/cusProductToFeatureOptions.js";
export * from "./convertCusProduct.js";
export * from "./cusProductConstants.js";
export * from "./cusProductUtils.js";
export * from "./featureOptionUtils/findFeatureOptions.js";
export * from "./filterCusProductUtils.js";
export * from "./filterCustomerProducts/filterCustomerProductsByActiveStatuses.js";
export * from "./filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js";
export * from "./findCustomerProduct/findScheduledCustomerProduct.js";
export * from "./getCusProductFromCustomer.js";
export * from "./productIdToCusProduct.js";

View File

@@ -30,19 +30,7 @@ export * from "./cusEntUtils/getStartingBalance.js";
export * from "./cusEntUtils/sortCusEntsForDeduction.js";
export * from "./cusPriceUtils/index.js";
// Cus product utils
export * from "./cusProductUtils/classifyCusProduct.js";
export * from "./cusProductUtils/convertCusProduct/cusProductToConvertedFeatureOptions.js";
export * from "./cusProductUtils/convertCusProduct/cusProductToFeatureOptions.js";
export * from "./cusProductUtils/convertCusProduct.js";
export * from "./cusProductUtils/cusProductConstants.js";
export * from "./cusProductUtils/cusProductUtils.js";
export * from "./cusProductUtils/featureOptionUtils/findFeatureOptions.js";
export * from "./cusProductUtils/filterCusProductUtils.js";
export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByActiveStatuses.js";
export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js";
export * from "./cusProductUtils/getCusProductFromCustomer.js";
export * from "./cusProductUtils/productIdToCusProduct.js";
export * from "./cusProductUtils/index.js";
// Cus utils
export * from "./cusUtils/cusPlanUtils/cusPlanUtils.js";
export * from "./cusUtils/fullCusUtils/getCusStripeSubCount.js";