wip
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const evaluateStripeBillingPlan = async ({
|
||||
ctx,
|
||||
billingContext,
|
||||
finalCustomerProducts: finalFullCustomer.customer_products,
|
||||
trialEndsAt: autumnBillingPlan.freeTrialPlan?.trialEndsAt,
|
||||
trialEndsAt: billingContext.trialContext?.trialEndsAt,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,4 +20,3 @@ export const StripeSubscriptionScheduleActionSchema = z.discriminatedUnion(
|
||||
export type StripeSubscriptionScheduleAction = z.infer<
|
||||
typeof StripeSubscriptionScheduleActionSchema
|
||||
>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
113
server/tests/billing/update-subscription/BILLING_GUIDE.md
Normal file
113
server/tests/billing/update-subscription/BILLING_GUIDE.md
Normal 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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
13
shared/utils/cusProductUtils/index.ts
Normal file
13
shared/utils/cusProductUtils/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user