finished trial tests and implementation

This commit is contained in:
John Yeo
2026-02-03 20:27:38 +00:00
parent 06d44632c7
commit e56dc0b663
62 changed files with 6887 additions and 861 deletions

View File

@@ -229,6 +229,33 @@ expectCustomerFeatureCorrect({
## Billing & Invoices
### Trial Invoice Count
When a Stripe subscription is created (even with a trial), Stripe generates a $0 invoice:
```typescript
// WRONG - Trial subscription DOES create an invoice
await expectCustomerInvoiceCorrect({
customer,
count: 0, // Wrong! Trial creates $0 invoice
});
// RIGHT - Trial subscription creates 1 invoice with $0 total
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// RIGHT - Free product (no Stripe subscription) has no invoice
await expectCustomerInvoiceCorrect({
customer,
count: 0, // Correct for free products
});
```
Rules:
- **Stripe subscription created (even trialing)**: `count: 1, latestTotal: 0`
- **Subscription updated while trialing**: Invoice count increases by 1 (still `latestTotal: 0`)
- **Free product (no Stripe subscription)**: `count: 0` is correct
### Consumable Overage: Not Charged on Update
```typescript
expect(preview.total).toBe(0); // Even with existing overage

View File

@@ -8,5 +8,7 @@ set -e
BUN_PARALLEL_V2 \
'attach/immediate-switch' \
'attach/scheduled-switch' \
'attach/free-trial' \
# 'attach/new-plan'

View File

@@ -27,7 +27,7 @@
"cm": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMaster.ts",
"ts": "bunx tsgo --build --noEmit",
"test:integration": "ENV_FILE=.env infisical run --env=dev -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts",
"cm": "ENV_FILE=.env infisical run --env=dev -- bun tests/clearMaster.ts"
},
"mocha": {
"node-option": [

View File

@@ -9,7 +9,6 @@ import {
type ApiCusProductV3,
type ApiEntityV0,
type AttachBodyV0,
type AttachParamsV0,
type AttachParamsV0Input,
type BalancesUpdateParams,
type BillingPreviewResponse,
@@ -816,7 +815,7 @@ export class AutumnInt {
billing = {
attach: async (
params: AttachParamsV0,
params: AttachParamsV0Input,
{
skipWebhooks,
idempotencyKey,

View File

@@ -55,7 +55,6 @@ export async function attach({
ctx,
billingContext,
autumnBillingPlan,
params,
});
// 4. Evaluate Stripe billing plan (handles checkout mode internally)

View File

@@ -1,18 +1,12 @@
import { filterUnchangedPricesFromLineItems } from "@autumn/shared";
import type { AttachBillingContext, AutumnBillingPlan } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
import type {
AttachBillingContext,
AutumnBillingPlan,
} from "@autumn/shared";
import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems";
/**
* Finalizes the attach billing plan by:
* 1. Filtering out unchanged prices (refund + charge pairs that cancel out)
* 2. Applying Stripe discounts to line items
* Finalizes the attach billing plan by processing line items.
*/
export const finalizeAttachPlan = ({
ctx: _ctx,
ctx,
plan,
attachBillingContext,
}: {
@@ -20,18 +14,12 @@ export const finalizeAttachPlan = ({
plan: AutumnBillingPlan;
attachBillingContext: AttachBillingContext;
}): AutumnBillingPlan => {
// 1. Filter out unchanged prices (refund + charge pairs that cancel out)
plan.lineItems = filterUnchangedPricesFromLineItems({
plan.lineItems = finalizeLineItems({
ctx,
lineItems: plan.lineItems ?? [],
billingContext: attachBillingContext,
autumnBillingPlan: plan,
});
// 2. Apply Stripe discounts if present
if (attachBillingContext.stripeDiscounts?.length) {
plan.lineItems = applyStripeDiscountsToLineItems({
lineItems: plan.lineItems ?? [],
discounts: attachBillingContext.stripeDiscounts,
});
}
return plan;
};

View File

@@ -25,6 +25,7 @@ export const logAttachContext = ({
isCustom,
billingCycleAnchorMs,
resetCycleAnchorMs,
trialContext,
} = billingContext;
addToExtraLogs({
@@ -58,6 +59,10 @@ export const logAttachContext = ({
.map((fq) => `${fq.feature_id}: ${fq.quantity}`)
.join(", ")
: "none",
trialContext: trialContext
? `trial ends at: ${formatMs(trialContext.trialEndsAt)} | free trial ID: ${trialContext.freeTrial?.id ?? "none"} | appliesToBilling: ${trialContext.appliesToBilling}`
: "none",
},
},
});

View File

@@ -15,6 +15,7 @@ import { setupAttachCheckoutMode } from "./setupAttachCheckoutMode";
import { setupAttachEndOfCycleMs } from "./setupAttachEndOfCycleMs";
import { setupAttachProductContext } from "./setupAttachProductContext";
import { setupAttachTransitionContext } from "./setupAttachTransitionContext";
import { setupAttachTrialContext } from "./setupAttachTrialContext";
/**
* Assembles the full billing context for attaching a product.
@@ -70,22 +71,36 @@ export const setupAttachBillingContext = async ({
// Timestamp context
const currentEpochMs = testClockFrozenTime ?? Date.now();
const billingCycleAnchorMs = setupBillingCycleAnchor({
// Setup trial context
const trialContext = await setupAttachTrialContext({
ctx,
params,
currentContext: {
fullCustomer,
attachProduct,
stripeSubscription,
currentEpochMs,
currentCustomerProduct,
},
});
let billingCycleAnchorMs = setupBillingCycleAnchor({
stripeSubscription,
customerProduct: currentCustomerProduct,
newFullProduct: attachProduct,
trialContext: undefined,
trialContext,
currentEpochMs,
});
// if (trialContext?.trialEndsAt) {
// // 4. Trial ends at overrides reset cycle anchor
// billingCycleAnchorMs = trialContext.trialEndsAt;
// }
// Trial ends at overrides billing cycle anchor
if (trialContext?.trialEndsAt) {
billingCycleAnchorMs = trialContext.trialEndsAt;
}
const resetCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs,
customerProduct: currentCustomerProduct,
customerProduct: undefined, // don't pass in current customer product here (paid products should have the reset cycle anchor correctly...)
newFullProduct: attachProduct,
});
@@ -101,6 +116,7 @@ export const setupAttachBillingContext = async ({
redirectMode: params.redirect_mode,
attachProduct,
stripeSubscription,
trialContext,
});
return {
@@ -131,6 +147,7 @@ export const setupAttachBillingContext = async ({
customPrices,
customEnts,
isCustom,
trialContext,
billingVersion: BillingVersion.V2,
};

View File

@@ -1,4 +1,4 @@
import type { CheckoutMode } from "@autumn/shared";
import type { CheckoutMode, TrialContext } from "@autumn/shared";
import {
type FullCusProduct,
type FullProduct,
@@ -18,12 +18,14 @@ export const setupAttachCheckoutMode = ({
attachProduct,
currentCustomerProduct,
stripeSubscription,
trialContext,
}: {
paymentMethod?: Stripe.PaymentMethod;
currentCustomerProduct?: FullCusProduct;
redirectMode?: RedirectMode;
attachProduct: FullProduct;
stripeSubscription?: Stripe.Subscription;
trialContext?: TrialContext;
}): CheckoutMode => {
const hasPaymentMethod = !!paymentMethod;
const hasExistingSubscription = !!stripeSubscription;
@@ -39,8 +41,13 @@ export const setupAttachCheckoutMode = ({
if (productIsOneOff) return "stripe_checkout";
if (!hasExistingSubscription && productIsPaidRecurring)
if (!hasExistingSubscription && productIsPaidRecurring) {
// If trial no card required, direct billing
if (trialContext?.trialEndsAt && trialContext?.cardRequired === false) {
return null;
}
return "stripe_checkout";
}
return null;
};

View File

@@ -1,3 +1,4 @@
import type { PlanTiming } from "@autumn/shared";
import {
cusProductToPrices,
type FullCustomer,
@@ -7,7 +8,6 @@ import {
isOneOffProduct,
isProductUpgrade,
} from "@autumn/shared";
import type { PlanTiming } from "@autumn/shared";
/**
* Sets up the transition context for attaching a product.

View File

@@ -0,0 +1,79 @@
import type {
AttachParamsV0,
FullCusProduct,
FullCustomer,
FullProduct,
TrialContext,
} from "@autumn/shared";
import { isProductPaidAndRecurring } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import {
applyProductTrialConfig,
handleFreeTrialParam,
inheritTrialFromSubscription,
} from "@/internal/billing/v2/setup/trialContext";
import { isAttachUpgrade } from "../utils/isAttachUpgrade";
/**
* Sets up trial context for attach operations.
*
* Logic:
* 1. If free_trial param passed → Use it (null removes trial, value sets fresh trial)
* 2. If NOT upgrade AND stripeSubscription exists → Inherit from subscription
* 3. Otherwise (upgrade OR fresh attach) → Apply product's trial config with dedup
*/
export const setupAttachTrialContext = async ({
ctx,
params,
currentContext,
}: {
ctx: AutumnContext;
params: AttachParamsV0;
currentContext: {
fullCustomer: FullCustomer;
attachProduct: FullProduct;
stripeSubscription?: Stripe.Subscription;
currentEpochMs: number;
currentCustomerProduct?: FullCusProduct;
};
}): Promise<TrialContext | undefined> => {
const {
fullCustomer,
attachProduct,
stripeSubscription,
currentEpochMs,
currentCustomerProduct,
} = currentContext;
// Handle explicit free_trial param (null or value)
if (params.free_trial !== undefined) {
return handleFreeTrialParam({
freeTrialParams: params.free_trial,
stripeSubscription,
fullProduct: attachProduct,
currentEpochMs,
});
}
const newProductIsPaidRecurring = isProductPaidAndRecurring(attachProduct);
// Determine if this is an upgrade
const isUpgrade = isAttachUpgrade({
currentCustomerProduct,
attachProduct,
});
// Inherit from subscription (merge/downgrade - NOT upgrade)
if (newProductIsPaidRecurring && stripeSubscription && !isUpgrade) {
return inheritTrialFromSubscription({ stripeSubscription });
}
// Apply product's trial config (upgrade or fresh attach) with dedup check
return applyProductTrialConfig({
ctx,
fullProduct: attachProduct,
fullCustomer,
currentEpochMs,
});
};

View File

@@ -0,0 +1,31 @@
import {
cusProductToPrices,
type FullCusProduct,
type FullProduct,
isProductUpgrade,
} from "@autumn/shared";
/**
* Determines if attaching a product is an upgrade from the current product.
* An upgrade occurs when the new product has a higher price or longer billing interval.
*/
export const isAttachUpgrade = ({
currentCustomerProduct,
attachProduct,
}: {
currentCustomerProduct?: FullCusProduct;
attachProduct: FullProduct;
}): boolean => {
if (!currentCustomerProduct) {
return false;
}
const currentPrices = cusProductToPrices({
cusProduct: currentCustomerProduct,
});
return isProductUpgrade({
prices1: currentPrices,
prices2: attachProduct.prices,
});
};

View File

@@ -11,9 +11,9 @@ import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupAttachEndOfCycleMs } from "@/internal/billing/v2/actions/attach/setup/setupAttachEndOfCycleMs";
import { setupUpgradeDowngradeBillingContext } from "@/internal/billing/v2/actions/legacy/setup/setupUpgradeBillingContext";
import { setupUpdateSubscriptionTrialContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext";
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling";
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling";
import { setupTrialContext } from "@/internal/billing/v2/setup/setupTrialContext";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams";
export const attachParamsToAttachBillingContext = async ({
@@ -85,7 +85,7 @@ export const attachParamsToAttachBillingContext = async ({
const paramsFreeTrial = attachParams.freeTrial;
let trialContext: TrialContext | undefined;
if (paramsFreeTrial && !attachParams.config?.disableTrial) {
trialContext = setupTrialContext({
trialContext = setupUpdateSubscriptionTrialContext({
stripeSubscription,
customerProduct: currentCustomerProduct,
currentEpochMs,

View File

@@ -1,15 +1,16 @@
import {
filterUnchangedPricesFromLineItems,
type AutumnBillingPlan,
isCustomerProductOneOff,
type UpdateSubscriptionBillingContext,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import { buildSharedSubscriptionTrialLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems";
import { filterLineItemsForTrialTransition } from "@/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition";
import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
import type { AutumnBillingPlan } from "@autumn/shared";
import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems";
/**
* Finalizes the update subscription billing plan by processing line items
* and applying update-subscription-specific guards.
*/
export const finalizeUpdateSubscriptionPlan = ({
ctx,
plan,
@@ -21,35 +22,15 @@ export const finalizeUpdateSubscriptionPlan = ({
billingContext: UpdateSubscriptionBillingContext;
params: UpdateSubscriptionV0Params;
}): AutumnBillingPlan => {
// Filter line items based on trial state transitions
plan.lineItems = filterLineItemsForTrialTransition({
// Finalize line items (shared logic)
plan.lineItems = finalizeLineItems({
ctx,
lineItems: plan.lineItems ?? [],
billingContext,
});
// Filter out unchanged prices (refund + charge pairs that cancel out)
plan.lineItems = filterUnchangedPricesFromLineItems({
lineItems: plan.lineItems,
});
// Add line items for sibling products affected by trial state changes
const sharedTrialLineItems = buildSharedSubscriptionTrialLineItems({
ctx,
billingContext,
autumnBillingPlan: plan,
});
plan.lineItems = [...plan.lineItems, ...sharedTrialLineItems];
// Apply discounts
if (billingContext.stripeDiscounts?.length) {
plan.lineItems = applyStripeDiscountsToLineItems({
lineItems: plan.lineItems,
discounts: billingContext.stripeDiscounts,
});
}
// Guard: if current customer product is one off, make sure there are no line items.
// Guard: if current customer product is one off, make sure there are no line items
if (isCustomerProductOneOff(billingContext.customerProduct)) {
plan.lineItems = [];
}

View File

@@ -14,7 +14,7 @@ import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setup
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 { setupUpdateSubscriptionTrialContext } from "./setupUpdateSubscriptionTrialContext";
/**
* Fetch the context for updating a subscription
@@ -64,7 +64,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
const currentEpochMs = testClockFrozenTime ?? Date.now();
// 1. Setup trial context first
const trialContext = setupTrialContext({
const trialContext = setupUpdateSubscriptionTrialContext({
stripeSubscription,
customerProduct,
currentEpochMs,

View File

@@ -0,0 +1,59 @@
import type {
BillingParamsBase,
FullCusProduct,
FullProduct,
TrialContext,
} from "@autumn/shared";
import { isProductPaidAndRecurring } from "@autumn/shared";
import type Stripe from "stripe";
import {
handleFreeTrialParam,
inheritTrialFromCustomerProduct,
inheritTrialFromSubscription,
} from "@/internal/billing/v2/setup/trialContext";
/**
* Sets up trial context for update subscription operations.
*
* Logic:
* 1. If free_trial param passed → Use it (null removes trial, value sets fresh trial)
* 2. If paid product with trialing subscription → Inherit from subscription
* 3. If customer product is trialing (free product case) → Inherit from customer product
* 4. Otherwise → No trial context
*/
export const setupUpdateSubscriptionTrialContext = ({
stripeSubscription,
customerProduct,
currentEpochMs,
params,
fullProduct,
}: {
stripeSubscription?: Stripe.Subscription;
customerProduct?: FullCusProduct;
currentEpochMs: number;
fullProduct: FullProduct;
params: BillingParamsBase;
}): TrialContext | undefined => {
// Handle explicit free_trial param (null or value)
if (params.free_trial !== undefined) {
return handleFreeTrialParam({
freeTrialParams: params.free_trial,
stripeSubscription,
customerProduct,
fullProduct,
currentEpochMs,
});
}
// Inherit from stripe subscription (paid product case)
if (isProductPaidAndRecurring(fullProduct) && stripeSubscription) {
return inheritTrialFromSubscription({ stripeSubscription });
}
// Inherit from customer product (free product case)
if (customerProduct) {
return inheritTrialFromCustomerProduct({ customerProduct, currentEpochMs });
}
return undefined;
};

View File

@@ -1,9 +1,13 @@
import { cp, type FullCusProduct, type LineItem } from "@autumn/shared";
import type { AutumnBillingPlan } from "@autumn/shared";
import {
type BillingContext,
cp,
type FullCusProduct,
type LineItem,
} from "@autumn/shared";
import chalk from "chalk";
import type { Logger } from "@/external/logtail/logtailUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import type { AutumnBillingPlan } from "@autumn/shared";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct";
import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems";
@@ -100,11 +104,11 @@ export const buildSharedSubscriptionTrialLineItems = ({
autumnBillingPlan,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
billingContext: BillingContext;
autumnBillingPlan: AutumnBillingPlan;
}): LineItem[] => {
const { logger } = ctx;
const { fullCustomer, stripeSubscription } = billingContext;
const { fullCustomer, stripeSubscription, trialContext } = billingContext;
if (!stripeSubscription) return [];
@@ -120,6 +124,7 @@ export const buildSharedSubscriptionTrialLineItems = ({
direction = "refund"; // Starting trial → refund sibling products
}
if (!trialContext?.appliesToBilling) return [];
if (!direction) return [];
const siblingCustomerProducts = getSiblingCustomerProducts({

View File

@@ -1,8 +1,8 @@
import type {
BillingParamsBase,
EntitlementWithFeature,
FeatureOptions,
Price,
UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { notNullish, roundUsageToNearestBillingUnit } from "@autumn/shared";
import { Decimal } from "decimal.js";
@@ -12,7 +12,7 @@ export const paramsToFeatureOptions = ({
price,
entitlement,
}: {
params: UpdateSubscriptionV0Params;
params: BillingParamsBase;
price: Price;
entitlement: EntitlementWithFeature;
}): FeatureOptions | undefined => {

View File

@@ -0,0 +1,59 @@
import {
type AutumnBillingPlan,
type BillingContext,
filterUnchangedPricesFromLineItems,
type LineItem,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildSharedSubscriptionTrialLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems";
import { filterLineItemsForTrialTransition } from "@/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition";
import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
/**
* Finalizes line items for a billing plan by:
* 1. Filtering line items based on trial state transitions
* 2. Filtering out unchanged prices (refund + charge pairs that cancel out)
* 3. Adding line items for sibling products affected by trial state changes
* 4. Applying Stripe discounts to line items
*/
export const finalizeLineItems = ({
ctx,
lineItems,
billingContext,
autumnBillingPlan,
}: {
ctx: AutumnContext;
lineItems: LineItem[];
billingContext: BillingContext;
autumnBillingPlan: AutumnBillingPlan;
}): LineItem[] => {
// 1. Filter line items based on trial state transitions
let finalizedLineItems = filterLineItemsForTrialTransition({
ctx,
lineItems,
billingContext,
});
// 2. Filter out unchanged prices (refund + charge pairs that cancel out)
finalizedLineItems = filterUnchangedPricesFromLineItems({
lineItems: finalizedLineItems,
});
// 3. Add line items for sibling products affected by trial state changes
const sharedTrialLineItems = buildSharedSubscriptionTrialLineItems({
ctx,
billingContext,
autumnBillingPlan,
});
finalizedLineItems = [...finalizedLineItems, ...sharedTrialLineItems];
// 4. Apply Stripe discounts if present
if (billingContext.stripeDiscounts?.length) {
finalizedLineItems = applyStripeDiscountsToLineItems({
lineItems: finalizedLineItems,
discounts: billingContext.stripeDiscounts,
});
}
return finalizedLineItems;
};

View File

@@ -1,11 +1,11 @@
import {
type BillingParamsBase,
cusProductToConvertedFeatureOptions,
type FeatureOptions,
type FullCusProduct,
type FullProduct,
isPrepaidPrice,
priceToEnt,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
@@ -15,6 +15,7 @@ import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAut
* For each prepaid price, uses new quantity from params or falls back to existing subscription.
*/
export const setupFeatureQuantitiesContext = ({
// biome-ignore lint/correctness/noUnusedFunctionParameters: Might be used in the future
ctx,
featureQuantitiesParams,
fullProduct,
@@ -22,7 +23,7 @@ export const setupFeatureQuantitiesContext = ({
initializeUndefinedQuantities = false,
}: {
ctx: AutumnContext;
featureQuantitiesParams: UpdateSubscriptionV0Params;
featureQuantitiesParams: BillingParamsBase;
fullProduct: FullProduct;
currentCustomerProduct?: FullCusProduct;
initializeUndefinedQuantities?: boolean;

View File

@@ -1,9 +1,12 @@
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import type {
AttachParamsV0,
UpdateSubscriptionV0Params,
} from "@autumn/shared";
export const setupInvoiceModeContext = ({
params,
}: {
params: UpdateSubscriptionV0Params;
params: UpdateSubscriptionV0Params | AttachParamsV0;
}) => {
if (params?.invoice !== true) {
return undefined;

View File

@@ -1,110 +0,0 @@
import type {
FreeTrial,
FullCusProduct,
FullProduct,
TrialContext,
} from "@autumn/shared";
import {
addDuration,
isCustomerProductTrialing,
isProductPaidAndRecurring,
secondsToMs,
} from "@autumn/shared";
import type Stripe from "stripe";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial";
export const setupTrialContext = ({
stripeSubscription,
customerProduct,
currentEpochMs,
params,
fullProduct,
}: {
stripeSubscription?: Stripe.Subscription;
customerProduct?: FullCusProduct;
currentEpochMs: number;
params: { free_trial: FreeTrial | null };
fullProduct: FullProduct;
}): TrialContext | undefined => {
const freeTrialParams = params.free_trial;
const newProductIsPaidRecurring = isProductPaidAndRecurring(fullProduct);
// Case 1: If free trial is null (removing free trial)
if (freeTrialParams === null) {
// If currently trialing, then return this object, if not don't return anything
if (
isStripeSubscriptionTrialing(stripeSubscription) ||
isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs })
) {
return {
freeTrial: null,
trialEndsAt: null,
appliesToBilling: newProductIsPaidRecurring,
cardRequired: true,
};
} else {
return undefined;
}
// return { freeTrial: null, trialEndsAt: null };
}
// Case 2: If free trial params are passed in
if (freeTrialParams) {
const dbFreeTrial = initFreeTrial({
freeTrialParams,
internalProductId: fullProduct.internal_id,
});
const trialEndsAt = addDuration({
now: currentEpochMs,
durationType: dbFreeTrial.duration,
durationLength: dbFreeTrial.length,
});
return {
freeTrial: dbFreeTrial,
trialEndsAt,
customFreeTrial: dbFreeTrial,
appliesToBilling: newProductIsPaidRecurring,
cardRequired: dbFreeTrial.card_required,
};
}
// Case 3: If new product is paid and recurring
if (isProductPaidAndRecurring(fullProduct)) {
if (
stripeSubscription &&
isStripeSubscriptionTrialing(stripeSubscription)
) {
const trialEndsAt = secondsToMs(
stripeSubscription.trial_end ?? undefined,
);
return {
freeTrial: null,
trialEndsAt: trialEndsAt,
appliesToBilling: newProductIsPaidRecurring,
cardRequired: true,
};
} else {
return undefined;
}
}
// Case 4: Return free trial / trial ends at from current customer product
if (
customerProduct &&
isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs })
) {
return {
freeTrial: customerProduct.free_trial, // can be undefined...
trialEndsAt: customerProduct.trial_ends_at ?? null,
appliesToBilling: false,
cardRequired: true,
};
}
return undefined;
};

View File

@@ -0,0 +1,75 @@
import type { FullCustomer, FullProduct, TrialContext } from "@autumn/shared";
import { addDuration, isProductPaidAndRecurring } from "@autumn/shared";
import type Stripe from "stripe";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getFreeTrialAfterFingerprint } from "@/internal/products/free-trials/freeTrialUtils";
/**
* Applies the product's trial configuration with deduplication check.
* Used for upgrades and fresh attaches.
*
* Deduplication is skipped if:
* - org.config.multiple_trials is true
* - Product's free_trial.unique_fingerprint is false
*
* Returns undefined if product has no trial config or dedup check fails.
*/
export const applyProductTrialConfig = async ({
ctx,
fullProduct,
fullCustomer,
stripeSubscription,
currentEpochMs,
}: {
ctx: AutumnContext;
fullProduct: FullProduct;
fullCustomer: FullCustomer;
stripeSubscription?: Stripe.Subscription;
currentEpochMs: number;
}): Promise<TrialContext | undefined> => {
const newProductIsPaidRecurring = isProductPaidAndRecurring(fullProduct);
if (!fullProduct.free_trial) {
const isCurrentlyTrialing =
isStripeSubscriptionTrialing(stripeSubscription);
if (isCurrentlyTrialing) {
return {
freeTrial: null,
trialEndsAt: null,
appliesToBilling: newProductIsPaidRecurring,
cardRequired: false,
};
}
return undefined;
}
const multipleTrialsAllowed = ctx.org.config?.multiple_trials ?? false;
const freeTrial = await getFreeTrialAfterFingerprint({
db: ctx.db,
freeTrial: fullProduct.free_trial,
productId: fullProduct.id,
fingerprint: fullCustomer.fingerprint,
internalCustomerId: fullCustomer.internal_id,
multipleAllowed: multipleTrialsAllowed,
});
if (!freeTrial) {
return undefined;
}
const trialEndsAt = addDuration({
now: currentEpochMs,
durationType: freeTrial.duration,
durationLength: freeTrial.length,
});
return {
freeTrial,
trialEndsAt,
appliesToBilling: newProductIsPaidRecurring,
cardRequired: freeTrial.card_required,
};
};

View File

@@ -0,0 +1,69 @@
import type { FullCusProduct, FullProduct, TrialContext } from "@autumn/shared";
import {
addDuration,
isCustomerProductTrialing,
isProductPaidAndRecurring,
} from "@autumn/shared";
import type { FreeTrialParamsV0 } from "@shared/api/billing/common/freeTrial/freeTrialParamsV0";
import type Stripe from "stripe";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial";
/**
* Handles explicit free_trial parameter passed to attach/update subscription.
*
* - `free_trial: null` → Removes trial if currently trialing
* - `free_trial: { length, duration }` → Starts fresh trial (bypasses dedup)
*/
export const handleFreeTrialParam = ({
freeTrialParams,
stripeSubscription,
customerProduct,
fullProduct,
currentEpochMs,
}: {
freeTrialParams: FreeTrialParamsV0 | null;
stripeSubscription?: Stripe.Subscription;
customerProduct?: FullCusProduct;
fullProduct: FullProduct;
currentEpochMs: number;
}): TrialContext | undefined => {
const newProductIsPaidRecurring = isProductPaidAndRecurring(fullProduct);
// free_trial: null → Remove trial if currently trialing
if (freeTrialParams === null) {
const isCurrentlyTrialing =
isStripeSubscriptionTrialing(stripeSubscription) ||
isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs });
if (isCurrentlyTrialing) {
return {
freeTrial: null,
trialEndsAt: null,
appliesToBilling: newProductIsPaidRecurring,
cardRequired: true,
};
}
return undefined;
}
// free_trial: { length, duration } → Fresh trial
const dbFreeTrial = initFreeTrial({
freeTrialParams,
internalProductId: fullProduct.internal_id,
});
const trialEndsAt = addDuration({
now: currentEpochMs,
durationType: dbFreeTrial.duration,
durationLength: dbFreeTrial.length,
});
return {
freeTrial: dbFreeTrial,
trialEndsAt,
customFreeTrial: dbFreeTrial,
appliesToBilling: newProductIsPaidRecurring,
cardRequired: dbFreeTrial.card_required,
};
};

View File

@@ -0,0 +1,4 @@
export { applyProductTrialConfig } from "./applyProductTrialConfig";
export { handleFreeTrialParam } from "./handleFreeTrialParam";
export { inheritTrialFromCustomerProduct } from "./inheritTrialFromCustomerProduct";
export { inheritTrialFromSubscription } from "./inheritTrialFromSubscription";

View File

@@ -0,0 +1,27 @@
import type { FullCusProduct, TrialContext } from "@autumn/shared";
import { isCustomerProductTrialing } from "@autumn/shared";
/**
* Inherits trial state from an existing customer product.
* Used by update subscription for free products (no Stripe subscription).
*
* Returns undefined if customer product is not trialing.
*/
export const inheritTrialFromCustomerProduct = ({
customerProduct,
currentEpochMs,
}: {
customerProduct: FullCusProduct;
currentEpochMs: number;
}): TrialContext | undefined => {
if (!isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs })) {
return undefined;
}
return {
freeTrial: customerProduct.free_trial,
trialEndsAt: customerProduct.trial_ends_at ?? null,
appliesToBilling: false,
cardRequired: true,
};
};

View File

@@ -0,0 +1,29 @@
import type { TrialContext } from "@autumn/shared";
import { secondsToMs } from "@autumn/shared";
import type Stripe from "stripe";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
/**
* Inherits trial state from an existing Stripe subscription.
* Used when merging into or downgrading from a trialing subscription.
*
* Returns undefined if subscription is not trialing.
*/
export const inheritTrialFromSubscription = ({
stripeSubscription,
}: {
stripeSubscription: Stripe.Subscription;
}): TrialContext | undefined => {
if (!isStripeSubscriptionTrialing(stripeSubscription)) {
return undefined;
}
const trialEndsAt = secondsToMs(stripeSubscription.trial_end ?? undefined);
return {
freeTrial: null,
trialEndsAt,
appliesToBilling: true, // Always true - we only call this for paid recurring products
cardRequired: true,
};
};

View File

@@ -1,4 +1,8 @@
import type { BillingContext, BillingPlan } from "@autumn/shared";
import type {
BillingContext,
BillingPlan,
FullCusProduct,
} from "@autumn/shared";
import {
type BillingPreviewResponse,
cp,
@@ -13,6 +17,20 @@ import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils
import { customerProductToLineItems } from "../lineItems/customerProductToLineItems";
import { lineItemToPreviewLineItem } from "../lineItems/lineItemToPreviewLineItem";
export type NextCyclePreviewDebug = {
allCustomerProducts: FullCusProduct[];
currentCustomerProducts: FullCusProduct[];
smallestInterval: { interval: string; intervalCount: number } | null;
anchorMs: number;
nextCycleStart: number | null;
filteredCustomerProducts: FullCusProduct[];
};
export type NextCyclePreviewResult = {
nextCycle: BillingPreviewResponse["next_cycle"];
debug: NextCyclePreviewDebug;
};
export const billingPlanToNextCyclePreview = ({
ctx,
billingContext,
@@ -21,7 +39,7 @@ export const billingPlanToNextCyclePreview = ({
ctx: AutumnContext;
billingContext: BillingContext;
billingPlan: BillingPlan;
}): BillingPreviewResponse["next_cycle"] => {
}): NextCyclePreviewResult => {
const { billingCycleAnchorMs } = billingContext;
const updatedCustomerProduct = billingPlanToUpdatedCustomerProduct({
@@ -35,7 +53,7 @@ export const billingPlanToNextCyclePreview = ({
...(updatedCustomerProduct ? [updatedCustomerProduct] : []),
];
let customerProducts = allCustomerProducts.filter(
const customerProducts = allCustomerProducts.filter(
(customerProduct) =>
cp(customerProduct).paid().recurring().hasRelevantStatus().valid,
);
@@ -45,11 +63,6 @@ export const billingPlanToNextCyclePreview = ({
cp(customerProduct).paid().recurring().hasActiveStatus().valid,
);
console.log(
"currentCustomerProducts",
currentCustomerProducts.map((cp) => cp.product.name),
);
const currentPrices = cusProductsToPrices({
cusProducts: currentCustomerProducts,
filters: { excludeOneOffPrices: true },
@@ -57,18 +70,28 @@ export const billingPlanToNextCyclePreview = ({
const smallestInterval = getSmallestInterval({ prices: currentPrices });
console.log("smallestInterval", smallestInterval);
// Return undefined if there's no recurring interval (not a subscription)
if (!smallestInterval) return undefined;
// Calculate next cycle start
// If billing cycle anchor is "now" (new subscription), calculate from current time
// Calculate anchor
const anchorMs =
billingCycleAnchorMs === "now"
? billingContext.currentEpochMs
: billingCycleAnchorMs;
const baseDebug = {
allCustomerProducts,
currentCustomerProducts,
smallestInterval,
anchorMs,
};
// Return undefined if there's no recurring interval (not a subscription)
if (!smallestInterval) {
return {
nextCycle: undefined,
debug: { ...baseDebug, nextCycleStart: null, filteredCustomerProducts: [] },
};
}
// Calculate next cycle start
const nextCycleStart = getCycleEnd({
anchor: anchorMs,
interval: smallestInterval.interval,
@@ -77,15 +100,22 @@ export const billingPlanToNextCyclePreview = ({
floor: anchorMs,
});
customerProducts = customerProducts.filter((customerProduct) => {
return !hasCustomerProductEnded(customerProduct, {
nowMs: nextCycleStart,
});
});
const filteredCustomerProducts = customerProducts.filter(
(customerProduct) => {
return !hasCustomerProductEnded(customerProduct, {
nowMs: nextCycleStart,
});
},
);
if (customerProducts.length === 0) return undefined;
if (filteredCustomerProducts.length === 0) {
return {
nextCycle: undefined,
debug: { ...baseDebug, nextCycleStart, filteredCustomerProducts },
};
}
const autumnLineItems = customerProducts.flatMap((customerProduct) =>
const autumnLineItems = filteredCustomerProducts.flatMap((customerProduct) =>
customerProductToLineItems({
ctx,
customerProduct: customerProduct,
@@ -101,8 +131,11 @@ export const billingPlanToNextCyclePreview = ({
const total = sumValues(autumnLineItems.map((line) => line.finalAmount));
return {
starts_at: nextCycleStart,
total,
line_items: previewLineItems,
nextCycle: {
starts_at: nextCycleStart,
total,
line_items: previewLineItems,
},
debug: { ...baseDebug, nextCycleStart, filteredCustomerProducts },
};
};

View File

@@ -8,6 +8,7 @@ import { Decimal } from "decimal.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { billingPlanToNextCyclePreview } from "./billingPlan/billingPlanToNextCyclePreview";
import { lineItemToPreviewLineItem } from "./lineItems/lineItemToPreviewLineItem";
import { logBillingPreview } from "./logs/logBillingPreview";
export const billingPlanToPreviewResponse = ({
ctx,
@@ -21,9 +22,9 @@ export const billingPlanToPreviewResponse = ({
const { fullCustomer } = billingContext;
const autumnBillingPlan = billingPlan.autumn;
const planLineItems = autumnBillingPlan.lineItems ?? [];
const allLineItems = autumnBillingPlan.lineItems ?? [];
const immediateLineItems = planLineItems.filter(
const immediateLineItems = allLineItems.filter(
(line) => line.chargeImmediately,
);
@@ -40,12 +41,22 @@ export const billingPlanToPreviewResponse = ({
const currency = orgToCurrency({ org: ctx.org });
// Get next cycle object
const nextCycle = billingPlanToNextCyclePreview({
const { nextCycle, debug: nextCycleDebug } = billingPlanToNextCyclePreview({
ctx,
billingContext,
billingPlan,
});
logBillingPreview({
ctx,
allLineItems,
immediateLineItems,
total,
currency,
nextCycleDebug,
nextCycle,
});
// Extract billing period from first line item with a billing period
const firstLineWithPeriod = immediateLineItems.find(
(line) => line.context.billingPeriod,

View File

@@ -0,0 +1,76 @@
import type {
BillingPreviewResponse,
FullCusProduct,
LineItem,
} from "@autumn/shared";
import { formatMs } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
import type { NextCyclePreviewDebug } from "../billingPlan/billingPlanToNextCyclePreview";
const formatCustomerProduct = (customerProduct: FullCusProduct) =>
`${customerProduct.product.name} (${customerProduct.product_id}) [${customerProduct.status}]`;
const formatLineItem = (item: LineItem) =>
`${item.description}: ${item.finalAmount} (charge: ${item.chargeImmediately})`;
export const logBillingPreview = ({
ctx,
allLineItems,
immediateLineItems,
total,
currency,
nextCycleDebug,
nextCycle,
}: {
ctx: AutumnContext;
allLineItems: LineItem[];
immediateLineItems: LineItem[];
total: number;
currency: string;
nextCycleDebug: NextCyclePreviewDebug;
nextCycle: BillingPreviewResponse["next_cycle"];
}) => {
const {
allCustomerProducts,
currentCustomerProducts,
smallestInterval,
anchorMs,
nextCycleStart,
filteredCustomerProducts,
} = nextCycleDebug;
addToExtraLogs({
ctx,
extras: {
billingPreview: {
// Immediate charge breakdown
total: `${total} ${currency}`,
allLineItems:
allLineItems.map(formatLineItem).join(" | ") || "none",
immediateLineItems:
immediateLineItems.map(formatLineItem).join(" | ") || "none",
// Next cycle calculation
nextCycle: {
allCustomerProducts:
allCustomerProducts.map(formatCustomerProduct).join(", ") || "none",
currentCustomerProducts:
currentCustomerProducts.map(formatCustomerProduct).join(", ") ||
"none",
smallestInterval: smallestInterval
? `${smallestInterval.intervalCount} ${smallestInterval.interval}`
: "none (not a subscription)",
anchor: formatMs(anchorMs),
nextCycleStart: nextCycleStart ? formatMs(nextCycleStart) : "n/a",
filteredCustomerProducts:
filteredCustomerProducts.map(formatCustomerProduct).join(", ") ||
"none",
result: nextCycle
? `starts: ${formatMs(nextCycle.starts_at)} | total: ${nextCycle.total} | items: ${nextCycle.line_items.length}`
: "undefined",
},
},
},
});
};

View File

@@ -1,5 +1,9 @@
import type { FullCustomer, FullProduct } from "@autumn/shared";
import type { BillingContext, TrialContext } from "@autumn/shared";
import type {
BillingContext,
FullCustomer,
FullProduct,
TrialContext,
} from "@autumn/shared";
export interface CreateCustomerContextFree {
fullCustomer: FullCustomer;

View File

@@ -1,6 +1,6 @@
import { type BillingContext, BillingVersion } from "@autumn/shared";
import { getOrCreateStripeCustomer } from "@/external/stripe/customers/index.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { BillingContext } from "@autumn/shared";
import type { CreateCustomerContext } from "../createCustomerContext.js";
/**
@@ -40,5 +40,6 @@ export const setupCreateCustomerBillingContext = async ({
customPrices: [],
customEnts: [],
isCustom: false,
billingVersion: BillingVersion.V2,
};
};

View File

@@ -1,10 +1,10 @@
import type { TrialContext } from "@autumn/shared";
import {
addDuration,
FreeTrialDuration,
type FullProduct,
InternalError,
} from "@autumn/shared";
import type { TrialContext } from "@autumn/shared";
export const setupCreateCustomerTrialContext = ({
paidProducts,

View File

@@ -10,12 +10,12 @@ import {
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.js";
import { setupUpdateSubscriptionTrialContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext";
import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan.js";
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.js";
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js";
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult.js";
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.js";
import { setupTrialContext } from "@/internal/billing/v2/setup/setupTrialContext.js";
import { billingResultToResponse } from "@/internal/billing/v2/utils/billingResult/billingResultToResponse.js";
import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan.js";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
@@ -65,7 +65,7 @@ export const handleUpdateQuantityFunction = async ({
secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now";
// 1. Setup trial context first
const trialContext = setupTrialContext({
const trialContext = setupUpdateSubscriptionTrialContext({
stripeSubscription,
customerProduct: currentCustomerProduct,
currentEpochMs,

View File

@@ -3,6 +3,7 @@ import {
CreateFreeTrialSchema,
type FreeTrial,
} from "@autumn/shared";
import type { FreeTrialParamsV0 } from "@shared/api/billing/common/freeTrial/freeTrialParamsV0";
import { generateId } from "@/utils/genUtils";
export const initFreeTrial = ({
@@ -10,7 +11,7 @@ export const initFreeTrial = ({
internalProductId,
isCustom = false,
}: {
freeTrialParams: CreateFreeTrial;
freeTrialParams: CreateFreeTrial | FreeTrialParamsV0;
internalProductId: string;
isCustom?: boolean;
}): FreeTrial => {

View File

@@ -30,7 +30,33 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
## Key Gotchas
1. **Always use `product.id`, never string literals**
1. **Trial Invoice Count - Stripe creates $0 invoice on subscription creation**
```typescript
// ❌ WRONG - Trial subscription DOES create an invoice
await expectCustomerInvoiceCorrect({
customer,
count: 0, // Wrong! Trial creates $0 invoice
});
// ✅ RIGHT - Trial subscription creates 1 invoice with $0 total
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// ✅ RIGHT - Free product (no Stripe subscription) has no invoice
await expectCustomerInvoiceCorrect({
customer,
count: 0, // Correct for free products
});
```
Rules:
- **Stripe subscription created (even trialing)**: `count: 1, latestTotal: 0`
- **Subscription updated while trialing**: Invoice count increases by 1 (still `latestTotal: 0`)
- **Free product (no Stripe subscription)**: `count: 0` is correct
2. **Always use `product.id`, never string literals**
```typescript
// ✅ GOOD
s.billing.attach({ productId: pro.id })
@@ -39,26 +65,26 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
s.billing.attach({ productId: "pro" })
```
2. **Multiple products need unique IDs**
3. **Multiple products need unique IDs**
- Without `isAddOn: true`, second product **replaces** the first
```typescript
const prod1 = constructProduct({ type: "free", id: "prod1", items: [...] });
const prod2 = constructProduct({ type: "free", id: "prod2", isAddOn: true, items: [...] });
```
3. **Payment method required for paid features**
4. **Payment method required for paid features**
```typescript
s.customer({ paymentMethod: "success" }) // Required for overage, per-seat, usage-based, base price
```
4. **Wait 2000ms after `track` before `attach`**
5. **Wait 2000ms after `track` before `attach`**
```typescript
await autumnV1.track({ ... });
await new Promise(r => setTimeout(r, 2000)); // track syncs to Postgres async
await autumnV1.attach({ ... });
```
5. **Prepaid items require `options` with `quantity` on attach**
6. **Prepaid items require `options` with `quantity` on attach**
- The `quantity` represents actual units (e.g., 100 messages), NOT number of packs
- If `billingUnits: 100` and you want 1 pack, pass `quantity: 100`
```typescript
@@ -69,7 +95,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
})
```
5b. **Prepaid `includedUsage` must be a multiple of `billingUnits` (or 0)**
6b. **Prepaid `includedUsage` must be a multiple of `billingUnits` (or 0)**
- When Stripe tiered pricing is created, `up_to` = `includedUsage / billingUnits`
- Stripe requires `up_to` to be a positive integer or "inf"
- If this results in a decimal (e.g., 50/100=0.5), Stripe rejects it
@@ -89,20 +115,20 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
});
```
6. **Use `products.base()` for free products** (no base price)
7. **Use `products.base()` for free products** (no base price)
- `products.pro()` already includes $20/mo base price — don't add `monthlyPrice()`
7. **Lifetime interval: `null` vs `"one_off"`**
8. **Lifetime interval: `null` vs `"one_off"`**
- Constructing: use `null` → `constructFeatureItem({ interval: null })`
- In API responses: use `ResetInterval.OneOff`
8. **Canceling/Downgrading is NOT a status**
9. **Canceling/Downgrading is NOT a status**
- Use `expectProductCanceling` helper, not `expect(product.status).toBe("canceling")`
9. **Server logs not visible in tests**
- Console logs in server code don't appear in test output
10. **Server logs not visible in tests**
- Console logs in server code don't appear in test output
10. **ALWAYS verify Stripe subscription state after billing calls**
11. **ALWAYS verify Stripe subscription state after billing calls**
- After EVERY `billing.attach()` call, verify the Stripe subscription state matches Autumn
- For paid products: use `expectSubToBeCorrect`
- For free products (no Stripe subscription): use `expectNoStripeSubscription`
@@ -167,7 +193,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
s.billing.attach({ productId: pro.id, isAddOn: true });
```
19. **Use `s.billing.attach()` and `autumnV1.billing.attach()` - NOT the legacy attach**
13. **Use `s.billing.attach()` and `autumnV1.billing.attach()` - NOT the legacy attach**
- These tests are for the NEW billing.attach endpoint (V2 attach flow)
- Never use `s.attach()` or `autumnV1.attach()` in these test files
```typescript
@@ -180,7 +206,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
await autumnV1.attach({ customer_id: customerId, product_id: pro.id })
```
20. **For scheduled switches (downgrades), always call previewAttach first with exact `startsAt` verification**
14. **For scheduled switches (downgrades), always call previewAttach first with exact `startsAt` verification**
- Preview should return `total: 0` since the change is scheduled, not immediate
- Use `expectPreviewNextCycleCorrect` to verify `next_cycle.starts_at` and `next_cycle.total`
- Pass the EXACT `startsAt` using `addMonths(advancedTo, 1).getTime()` - do NOT use approximates
@@ -215,7 +241,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
});
```
21. **Do NOT create a new initScenario to advance the test clock**
15. **Do NOT create a new initScenario to advance the test clock**
- WRONG: Creating a second `initScenario` with the same customerId to advance time
- RIGHT: Keep downgrade attach OUT of initScenario, call it in test body, then use helpers to advance
```typescript
@@ -249,7 +275,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
// B. Use advanceTestClock helper from the same ctx
```
22. **Prepaid next_cycle.total depends on quantity passed at attach time**
16. **Prepaid next_cycle.total depends on quantity passed at attach time**
- If `options: [{ quantity: 100 }]` passed → `next_cycle.total` = price for 100 units
- If no options passed → inherits current product's quantity (if any), else 0
```typescript
@@ -269,7 +295,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
});
```
13. **Product IDs in expectations - just use `product.id`**
17. **Product IDs in expectations - just use `product.id`**
- `initScenario` already prefixes product IDs with `customerId`
- Don't double-prefix in expectations
```typescript
@@ -280,7 +306,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
expectProductActive({ customer, productId: `${pro.id}_${customerId}` });
```
15. **Use `expectCustomerProducts` batch helper when checking multiple products**
18. **Use `expectCustomerProducts` batch helper when checking multiple products**
- When verifying 2+ product states, use the batch helper instead of individual calls
- More concise and easier to read
```typescript
@@ -297,7 +323,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
await expectProductNotPresent({ customer, productId: free.id });
```
14. **Always pass `redirect_mode: "if_required"` to attach calls**
19. **Always pass `redirect_mode: "if_required"` to attach calls**
- Prevents checkout redirect when customer already has a payment method
- Without this, the endpoint may redirect to Stripe Checkout even when payment method exists
```typescript
@@ -308,12 +334,12 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
});
```
16. **One-time products do NOT replace/expire other products**
20. **One-time products do NOT replace/expire other products**
- Attaching a one-time product will NOT cancel or replace existing main products
- One-time products are always treated as add-ons (they stack with existing products)
- Only recurring products can replace other recurring products
17. **Set up scenario state in `initScenario`, test only the action being tested**
21. **Set up scenario state in `initScenario`, test only the action being tested**
- All prerequisite state (existing products, entities, usage) should be set up in `initScenario` actions
- The test body should only call the single action being tested and verify results
```typescript
@@ -338,7 +364,7 @@ bun test server/tests/integration/billing/attach/immediate-switch/immediate-swit
await autumnV1.billing.attach({ customer_id: customerId, product_id: oneOff.id });
```
18. **Scheduled-switch tests must advance test clock with `advanceToNextInvoice()`**
22. **Scheduled-switch tests must advance test clock with `advanceToNextInvoice()`**
- After scheduling a downgrade, advance the test clock to verify:
- A. Next cycle invoice is correct
- B. Products on customer are correct after cycle

View File

@@ -101,10 +101,11 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: trial card required")}`,
usage: 0,
});
// Verify no invoice yet (trial)
// Verify $0 invoice for trial (Stripe creates invoice for trial subscriptions)
await expectCustomerInvoiceCorrect({
customer,
count: 0,
count: 1,
latestTotal: 0,
});
});
@@ -183,9 +184,10 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: trial subscription_data"
usage: 0,
});
// Verify no invoice yet (trial)
// Verify $0 invoice for trial (Stripe creates invoice for trial subscriptions)
await expectCustomerInvoiceCorrect({
customer,
count: 0,
count: 1,
latestTotal: 0,
});
});

View File

@@ -0,0 +1,336 @@
/**
* Free Trial Override Basic Tests (Attach V2)
*
* Tests for basic free_trial parameter override behaviors.
*
* Key behaviors:
* - free_trial param overrides product's trial config
* - free_trial param bypasses deduplication logic
* - Trial always starts from now + trial_days
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Fresh attach with free_trial override (product has no trial config)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Product has no trial configuration
* - Attach with free_trial: { length: 7, duration: "day" }
*
* Expected Result:
* - Trial starts, ends at now + 7 days
* - No immediate charge
* - Preview next_cycle shows correct trial end and charge
*/
test.concurrent(`${chalk.yellowBright("trial-override-basic 1: fresh attach with free_trial override")}`, async () => {
const customerId = "trial-override-basic-fresh";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [messagesItem, priceItem],
// No trial config
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
// 1. Preview attach with free_trial override
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
},
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7),
total: 20,
});
// 2. Attach with free_trial override
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
},
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active and trialing
await expectProductActive({
customer,
productId: pro.id,
});
await expectProductTrialing({
customer,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify features available with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: advancedTo + ms.days(7), // Reset aligns with trial end
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Override product's trial config
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Product has 14-day trial configuration
* - Attach with free_trial: { length: 30, duration: "day" }
*
* Expected Result:
* - Trial uses override (30 days), NOT product config (14 days)
*/
test.concurrent(`${chalk.yellowBright("trial-override-basic 2: override product's trial config")}`, async () => {
const customerId = "trial-override-basic-override-config";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem],
trialDays: 14, // Product config: 14 days
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [],
});
// 1. Preview attach with override (30 days instead of 14)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
free_trial: {
length: 30,
duration: FreeTrialDuration.Day,
},
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(30), // Override, not product config
total: 20,
});
// 2. Attach with override
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
free_trial: {
length: 30,
duration: FreeTrialDuration.Day,
},
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify trial uses override (30 days), NOT product config (14 days)
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(30),
});
// Verify feature reset aligns with overridden trial (30 days, not 14)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: advancedTo + ms.days(30), // Reset aligns with override, not product config
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Override bypasses deduplication (reattach after cancel)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer previously had trial, used it, then cancelled
* - Reattach with free_trial override
*
* Expected Result:
* - Gets fresh trial (deduplication bypassed)
*/
test.concurrent(`${chalk.yellowBright("trial-override-basic 3: override bypasses deduplication")}`, async () => {
const customerId = "trial-override-basic-bypass-dedup";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [
s.billing.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 3 }), // Mid-trial
s.updateSubscription({
productId: proTrial.id,
cancelAction: "cancel_immediately" as const,
}),
],
});
// Verify product is cancelled
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const hasProduct = customerBefore.products.some(
(p) => p.id === proTrial.id && p.status === "active",
);
expect(hasProduct).toBe(false);
// 1. Preview reattach with free_trial override (bypasses dedup)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14),
total: 20,
});
// 2. Reattach with free_trial override
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify gets fresh 14-day trial (deduplication bypassed)
await expectProductActive({
customer,
productId: proTrial.id,
});
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify feature reset aligns with fresh trial (14 days from now)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: advancedTo + ms.days(14), // Fresh trial, reset at trial end
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});

View File

@@ -0,0 +1,389 @@
/**
* Free Trial Override Entity Tests (Attach V2)
*
* Tests for free_trial parameter override in multi-entity scenarios.
*
* Key behaviors:
* - Entity attach with free_trial affects shared subscription (all entities)
* - Entity attach with free_trial: null ends trial for all entities
* - Upgrade with free_trial override affects all entities on subscription
*/
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type ApiEntityV0,
FreeTrialDuration,
ms,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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 { timeout } from "@tests/utils/genUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Entity attach with free_trial to active subscription
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 has Pro (active, not trialing)
* - Entity-2 attaches Pro with free_trial: { length: 14 }
*
* Expected Result:
* - Both entities move to trial (shared subscription)
*/
test.concurrent(`${chalk.yellowBright("trial-override-entity 1: entity attach with free_trial to active sub")}`, async () => {
const customerId = "trial-override-entity-active";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const { autumnV1, ctx, advancedTo, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - entity-1 has pro, not trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductNotTrialing({
customer: entity1Before,
productId: pro.id,
nowMs: advancedTo,
});
// 1. Preview entity-2 attach with free_trial override
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
entity_id: entity2Id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
});
// Entity-1's pro refunded (-$20), entity-2's pro free during trial = -$20
expect(preview.total).toBe(-20);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14),
total: 20, // Pro ($20) for entity 2 after trial
});
// 2. Attach with free_trial override
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
entity_id: entity2Id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
redirect_mode: "if_required",
});
// Wait for webhook to sync trial status
await timeout(4000);
// Verify entity-2 is trialing
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductTrialing({
customer: entity2,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify entity-1 is also trialing (shared subscription)
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductTrialing({
customer: entity1,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify invoices: pro ($20) + refund (-$20)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 3,
latestTotal: 0,
});
expect(customer.invoices?.[1]?.total).toBe(-20);
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Entity attach with free_trial: null to trialing subscription
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 has Pro (trialing)
* - Entity-2 attaches Pro with free_trial: null
*
* Expected Result:
* - Trial ends for both entities, both charged
*/
test.concurrent(`${chalk.yellowBright("trial-override-entity 2: entity attach with free_trial: null to trialing sub")}`, async () => {
const customerId = "trial-override-entity-null";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: proTrial.id, entityIndex: 0 })],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - entity-1 is trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductTrialing({
customer: entity1Before,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// 1. Preview entity-2 attach with free_trial: null
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
entity_id: entity2Id,
free_trial: null,
});
// Entity-1's pro ($20) + entity-2's pro ($20) = $40
expect(preview.total).toBe(40);
// 2. Attach with free_trial: null
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
entity_id: entity2Id,
free_trial: null,
redirect_mode: "if_required",
});
// Wait for webhook to sync trial status
await timeout(4000);
// Verify entity-2 is NOT trialing
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductNotTrialing({
customer: entity2,
productId: proTrial.id,
nowMs: advancedTo,
});
// Verify entity-1 is also NOT trialing (shared subscription)
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductNotTrialing({
customer: entity1,
productId: proTrial.id,
nowMs: advancedTo,
});
// Verify invoices: $40 charge
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 40,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Entity upgrade with free_trial override
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 & 2 both on Pro (active, not trialing)
* - Entity-1 upgrades to Premium with free_trial: { length: 14 }
*
* Expected Result:
* - Both entities get 14-day trial (shared subscription)
*/
test.concurrent(`${chalk.yellowBright("trial-override-entity 3: entity upgrade with free_trial override")}`, async () => {
const customerId = "trial-override-entity-upgrade";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, ctx, advancedTo, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
s.billing.attach({ productId: pro.id, entityIndex: 1 }),
],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - both entities on pro, not trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductNotTrialing({
customer: entity1Before,
productId: pro.id,
nowMs: advancedTo,
});
// 1. Preview entity-1 upgrade to premium with free_trial override
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
entity_id: entity1Id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
});
// Refund: entity-1 pro ($20) + entity-2 pro ($20) = -$40
expect(preview.total).toBe(-40);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14),
total: 50, // Premium ($50) after trial
});
// 2. Upgrade with free_trial override
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
entity_id: entity1Id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
redirect_mode: "if_required",
});
// Wait for webhook to sync trial status
await timeout(4000);
// Verify entity-1 has premium and is trialing
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductTrialing({
customer: entity1,
productId: premium.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify entity-2 still has pro and is also trialing (shared subscription)
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductTrialing({
customer: entity2,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify invoices: 2x pro ($40) + refund (-$40)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 4,
latestTotal: 0,
});
expect(customer.invoices?.[1]?.total).toBe(-40);
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});

View File

@@ -0,0 +1,356 @@
/**
* Free Trial Override Merge Tests (Attach V2)
*
* Tests for free_trial parameter override with add-ons and merges.
*
* Key behaviors:
* - Add-on with free_trial override moves entire subscription to trial
* - Add-on with free_trial: null ends subscription trial
* - Add-on with free_trial override replaces existing trial
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Add-on with free_trial override to active subscription
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro (active, not trialing)
* - Attaches add-on with free_trial: { length: 7 }
*
* Expected Result:
* - Subscription moves to trial, both products trial
* - Pro is refunded, no charge for add-on
*/
test.concurrent(`${chalk.yellowBright("trial-override-merge 1: add-on with free_trial override to active sub")}`, async () => {
const customerId = "trial-override-merge-addon-active";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const addonItem = items.dashboard();
const addon = products.recurringAddOn({
id: "addon",
items: [addonItem],
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Verify initial state - pro is active, not trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer: customerBefore,
productId: pro.id,
nowMs: advancedTo,
});
// 1. Preview add-on with free_trial override
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: addon.id,
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
},
});
// Pro refunded (-$20), add-on free during trial = -$20
expect(preview.total).toBe(-20);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7),
total: 40, // Pro ($20) + Add-on ($20) after trial
});
// 2. Attach add-on with free_trial override
await autumnV1.billing.attach({
customer_id: customerId,
product_id: addon.id,
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
},
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify both products are active
await expectCustomerProducts({
customer,
active: [pro.id, addon.id],
});
// Verify both products are trialing with same trial end
await expectProductTrialing({
customer,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(7),
});
await expectProductTrialing({
customer,
productId: addon.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify invoices: pro ($20) + refund (-$20)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: -20,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Add-on with free_trial: null to trialing subscription
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro (trialing)
* - Attaches add-on with free_trial: null
*
* Expected Result:
* - Trial ends, both products charged immediately
*/
test.concurrent(`${chalk.yellowBright("trial-override-merge 2: add-on with free_trial: null to trialing sub")}`, async () => {
const customerId = "trial-override-merge-addon-null";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 14,
cardRequired: true,
});
const addonItem = items.dashboard();
const addon = products.recurringAddOn({
id: "addon",
items: [addonItem],
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, addon] }),
],
actions: [s.billing.attach({ productId: proTrial.id })],
});
// Verify initial state - pro is trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// 1. Preview add-on with free_trial: null
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: addon.id,
free_trial: null,
});
// Pro ($20) + Add-on ($20) = $40
expect(preview.total).toBe(40);
// 2. Attach add-on with free_trial: null
await autumnV1.billing.attach({
customer_id: customerId,
product_id: addon.id,
free_trial: null,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify both products are active
await expectCustomerProducts({
customer,
active: [proTrial.id, addon.id],
});
// Verify both products are NOT trialing
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedTo,
});
await expectProductNotTrialing({
customer,
productId: addon.id,
nowMs: advancedTo,
});
// Verify invoices: $40 charge
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 40,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Add-on with free_trial override to trialing subscription (replaces trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro (trialing, 5 days left)
* - Attaches add-on with free_trial: { length: 14 }
*
* Expected Result:
* - Fresh 14-day trial for both (replaces existing trial)
*/
test.concurrent(`${chalk.yellowBright("trial-override-merge 3: add-on with free_trial override replaces existing trial")}`, async () => {
const customerId = "trial-override-merge-addon-replace";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const addonItem = items.dashboard();
const addon = products.recurringAddOn({
id: "addon",
items: [addonItem],
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, addon] }),
],
actions: [
s.billing.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 2 }), // 5 days remaining on trial
],
});
// Verify initial state - pro is trialing with ~5 days remaining
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(5), // 7 - 2 = 5 days remaining
});
// 1. Preview add-on with free_trial override (14 days)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: addon.id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
});
expect(preview.total).toBe(0); // No charge during trial
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14), // Fresh 14-day trial
total: 40, // Pro ($20) + Add-on ($20) after trial
});
// 2. Attach add-on with free_trial override
await autumnV1.billing.attach({
customer_id: customerId,
product_id: addon.id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify both products are active
await expectCustomerProducts({
customer,
active: [proTrial.id, addon.id],
});
// Verify both products have fresh 14-day trial (NOT the remaining 5 days)
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
await expectProductTrialing({
customer,
productId: addon.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
// Count is 2: initial trial invoice ($0) + subscription update invoice ($0)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});

View File

@@ -0,0 +1,393 @@
/**
* Free Trial Override Null Tests (Attach V2)
*
* Tests for free_trial: null parameter behavior.
*
* Key behaviors:
* - free_trial: null prevents trial even if product has trial config
* - free_trial: null on free product works (no subscription, no trial)
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, type ApiEntityV0, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { timeout } from "@/utils/genUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Attach with free_trial: null (product has trial config)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Product has 14-day trial configuration
* - Attach with free_trial: null
*
* Expected Result:
* - No trial, charged immediately
*/
test.concurrent(`${chalk.yellowBright("trial-override-null 1: attach with free_trial: null (product has trial)")}`, async () => {
const customerId = "trial-override-null-has-trial";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [],
});
// 1. Preview attach with free_trial: null
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
free_trial: null,
});
expect(preview.total).toBe(20); // Charged immediately
// 2. Attach with free_trial: null
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
free_trial: null,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active and NOT trialing
await expectProductActive({
customer,
productId: proTrial.id,
});
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedTo,
});
// Verify features available with resetsAt at normal billing cycle (no trial)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: advancedTo + ms.days(30), // No trial, reset at billing cycle end
});
// Verify invoice: $20 charge
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 20,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Free product with free_trial: null (no Stripe sub)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Free product (no price) has trial config
* - Attach with free_trial: null
*
* Expected Result:
* - No trial, product active immediately
* - No Stripe subscription created
*/
test.concurrent(`${chalk.yellowBright("trial-override-null 2: free product with free_trial: null")}`, async () => {
const customerId = "trial-override-null-free-product";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
id: "free-trial",
items: [messagesItem],
trialDays: 7,
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({}), // No payment method needed for free product
s.products({ list: [freeWithTrial] }),
],
actions: [],
});
// 1. Preview attach with free_trial: null
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: freeWithTrial.id,
free_trial: null,
});
expect(preview.total).toBe(0); // Free product
// 2. Attach with free_trial: null
await autumnV1.billing.attach({
customer_id: customerId,
product_id: freeWithTrial.id,
free_trial: null,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active and NOT trialing
await expectProductActive({
customer,
productId: freeWithTrial.id,
});
await expectProductNotTrialing({
customer,
productId: freeWithTrial.id,
});
// Verify features available
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
// Verify no invoice (free product)
await expectCustomerInvoiceCorrect({
customer,
count: 0,
});
// Verify no Stripe subscription
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Entity pro isolated from customer-level attach with free_trial: null
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 and Entity-2 both have Pro products (on Stripe subscription, NOT trialing)
* - Advance test clock (entities have been subscribed for a billing cycle)
* - Customer-level attaches a free product with free_trial: null
*
* Expected Result:
* - Entity-1 and Entity-2's Pro products remain untouched (on subscription, not trialing)
* - Free product is active immediately with no trial (due to free_trial: null)
* - No interference between customer-level free product and entity-level subscriptions
*/
test.concurrent(`${chalk.yellowBright("trial-override-null 3: entity pro isolated from customer-level attach with free_trial: null")}`, async () => {
const customerId = "trial-override-null-entity-isolated";
// Pro product for entities (paid, on subscription)
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
// Free product for customer level (with trial config, but we'll use free_trial: null)
const freeMessagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
id: "free-trial",
items: [freeMessagesItem],
trialDays: 7,
cardRequired: false,
});
const { autumnV1, ctx, advancedTo, entities, testClockId } =
await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, freeWithTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
// Attach pro to both entities (on Stripe subscription, NOT trialing)
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
s.billing.attach({ productId: pro.id, entityIndex: 1 }),
],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - both entities have Pro, NOT trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({ customer: entity1Before, productId: pro.id });
await expectProductNotTrialing({
customer: entity1Before,
productId: pro.id,
nowMs: advancedTo,
});
const entity2Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductActive({ customer: entity2Before, productId: pro.id });
await expectProductNotTrialing({
customer: entity2Before,
productId: pro.id,
nowMs: advancedTo,
});
// Verify initial invoices - 2 Pro attaches
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: customerBefore,
count: 2,
latestTotal: 20,
});
// Advance test clock to next invoice (entities have been subscribed for a billing cycle)
const advancedToAfterCycle = await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
// Verify entities are still active after billing cycle
const entity1AfterCycle = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({ customer: entity1AfterCycle, productId: pro.id });
await expectProductNotTrialing({
customer: entity1AfterCycle,
productId: pro.id,
nowMs: advancedToAfterCycle,
});
const entity2AfterCycle = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductActive({ customer: entity2AfterCycle, productId: pro.id });
await expectProductNotTrialing({
customer: entity2AfterCycle,
productId: pro.id,
nowMs: advancedToAfterCycle,
});
// Attach free product with free_trial: null at CUSTOMER level (not entity level)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: freeWithTrial.id,
free_trial: null,
redirect_mode: "if_required",
});
await timeout(2000);
// Verify customer-level free product is active and NOT trialing (due to free_trial: null)
const customerAfterAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterAttach,
productId: freeWithTrial.id,
});
await expectProductNotTrialing({
customer: customerAfterAttach,
productId: freeWithTrial.id,
nowMs: advancedToAfterCycle,
});
// Verify entities' Pro products are still NOT trialing (unaffected by customer-level attach)
const entity1AfterAttach = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({
customer: entity1AfterAttach,
productId: pro.id,
});
await expectProductNotTrialing({
customer: entity1AfterAttach,
productId: pro.id,
nowMs: advancedToAfterCycle,
});
const entity2AfterAttach = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductActive({
customer: entity2AfterAttach,
productId: pro.id,
});
await expectProductNotTrialing({
customer: entity2AfterAttach,
productId: pro.id,
nowMs: advancedToAfterCycle,
});
// Verify Stripe subscription is correct (entity subs, not trialing)
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: {
checkNotTrialing: true,
},
});
// Verify invoices - should have renewal invoices for entity pro subscriptions
await expectCustomerInvoiceCorrect({
customer: customerAfterAttach,
count: 3, // 2 initial Pro attaches + 1 renewal
latestTotal: 40, // Renewal for both entities ($20 each)
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: {
checkNotTrialing: true,
},
});
});

View File

@@ -0,0 +1,249 @@
/**
* Free Trial Override Upgrade Tests (Attach V2)
*
* Tests for free_trial parameter override during upgrades.
*
* Key behaviors:
* - Upgrade with free_trial override starts fresh trial
* - Upgrade with free_trial: null prevents trial
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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 { addMonths } from "date-fns";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Upgrade with free_trial override (customer is active, not trialing)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on Pro (active, not trialing)
* - Upgrades to Premium with free_trial: { length: 14 }
*
* Expected Result:
* - Premium starts 14-day trial
* - Pro is replaced, customer refunded
*/
test.concurrent(`${chalk.yellowBright("trial-override-upgrade 1: upgrade active customer with free_trial override")}`, async () => {
const customerId = "trial-override-upgrade-active";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Verify initial state - pro is active, not trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerBefore,
productId: pro.id,
});
await expectProductNotTrialing({
customer: customerBefore,
productId: pro.id,
nowMs: advancedTo,
});
// 1. Preview upgrade with free_trial override
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
});
// Should refund Pro ($20) and not charge for Premium during trial
expect(preview.total).toBe(-20);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14),
total: 50, // Premium price after trial
});
// 2. Upgrade with free_trial override
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
},
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// Verify premium is trialing
await expectProductTrialing({
customer,
productId: premium.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify features with resetsAt aligned to new trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
resetsAt: advancedTo + ms.days(14), // Reset changed to new trial end
});
// Verify invoices: initial pro ($20) + refund (-$20)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: -20,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Upgrade with free_trial: null (product has trial config)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on Pro (active)
* - Upgrades to Premium (has 14-day trial config) with free_trial: null
*
* Expected Result:
* - No trial, charged immediately
*/
test.concurrent(`${chalk.yellowBright("trial-override-upgrade 2: upgrade with free_trial: null (no trial)")}`, async () => {
const customerId = "trial-override-upgrade-null";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premiumTrial] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// 1. Preview upgrade with free_trial: null
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premiumTrial.id,
free_trial: null,
});
// Premium ($50) - Pro refund ($20) = $30
expect(preview.total).toBe(30);
// 2. Upgrade with free_trial: null
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premiumTrial.id,
free_trial: null,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premiumTrial.id],
notPresent: [pro.id],
});
// Verify premium is NOT trialing
await expectProductNotTrialing({
customer,
productId: premiumTrial.id,
nowMs: advancedTo,
});
// Verify features with resetsAt at normal billing cycle (no trial)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
resetsAt: addMonths(advancedTo, 1).getTime(),
});
// Verify invoices: pro ($20) + upgrade ($30)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 30,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});

View File

@@ -0,0 +1,229 @@
/**
* Free Trial Basic Tests (Attach V2)
*
* Tests for basic product-level trial behavior when attaching products.
*
* Key behaviors:
* - New subscription with trial product starts in trial
* - Trial end timestamp is calculated from attach time
* - Product without trial attached to new customer has no trial
* - Preview shows $0 total during trial (no immediate charge)
* - Preview next_cycle shows trial end date and first charge amount
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: New subscription with trial product
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has no existing product
* - Attach proWithTrial ($20/mo, 7-day trial)
*
* Expected Result:
* - Product is active and trialing
* - Trial ends at advancedTo + 7 days
* - No immediate charge (preview.total = 0)
* - next_cycle.starts_at = trial end, next_cycle.total = $20
* - Invoice count = 0 (no invoice during trial)
*/
test.concurrent(`${chalk.yellowBright("trial-basic 1: new subscription with trial product")}`, async () => {
const customerId = "trial-basic-new-sub";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [],
});
// 1. Preview attach - should show $0 during trial, next_cycle shows first charge
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7),
total: 20, // Pro base price after trial
});
// 2. Attach product with trial
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active
await expectProductActive({
customer,
productId: proTrial.id,
});
// Verify product is trialing with correct end date
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify feature balance is available during trial with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: advancedTo + ms.days(7),
});
// Verify no invoice during trial
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Free-to-trial (no existing Stripe subscription)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has free product (no Stripe subscription)
* - Attach proWithTrial ($20/mo, 7-day trial)
*
* Expected Result:
* - New Stripe subscription is created in trial
* - Product is trialing
* - Free product is removed
* - next_cycle shows first charge after trial
*/
test.concurrent(`${chalk.yellowBright("trial-basic 5: free to trial product")}`, async () => {
const customerId = "trial-basic-free-to-trial";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, proTrial] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
// 1. Preview upgrade to trial product - should show $0, next_cycle = $20
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7),
total: 20,
});
// 2. Attach trial product
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [proTrial.id],
notPresent: [free.id],
});
// Verify product is trialing
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify feature balance is pro's balance with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: advancedTo + ms.days(7),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});

View File

@@ -0,0 +1,389 @@
/**
* Free Trial Conversion Tests (Attach V2)
*
* Tests for trial end/conversion behaviors.
*
* Key behaviors:
* - Trial end triggers first charge
* - Billing cycle starts from trial end
* - Features continue after conversion
* - Arrears usage billed at trial end
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Trial ends naturally - first charge
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has proWithTrial (7-day trial)
* - Advance past trial end
*
* Expected Result:
* - Product converts to active (not trialing)
* - First charge of $20
* - Features continue working
*/
test.concurrent(`${chalk.yellowBright("trial-conversion 1: trial ends naturally - first charge")}`, async () => {
const customerId = "trial-conv-natural-end";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [
s.billing.attach({ productId: proTrial.id }),
s.advanceTestClock({ toNextInvoice: true }), // Advance past trial
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active and NOT trialing
await expectProductActive({
customer,
productId: proTrial.id,
});
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedTo,
});
// Verify first charge invoice
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20, // Pro base price
latestInvoiceProductId: proTrial.id,
});
// Verify features still available with resetsAt aligned to monthly billing cycle
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500, // Reset after trial end
usage: 0,
resetsAt: advancedTo + ms.days(30),
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Trial ends with usage - reset balances
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has proWithTrial with monthly messages
* - Use some messages during trial
* - Trial ends
*
* Expected Result:
* - Balance resets at trial end (new billing cycle)
* - First charge processed
*/
test.concurrent(`${chalk.yellowBright("trial-conversion 2: trial ends with usage - reset balances")}`, async () => {
const customerId = "trial-conv-usage-reset";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.billing.attach({ productId: proTrial.id })],
});
// Use some messages during trial
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 200,
});
// Wait for track to sync
await new Promise((r) => setTimeout(r, 2000));
// Verify usage during trial
const customerDuringTrial =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerDuringTrial,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 300, // 500 - 200 = 300
usage: 200,
});
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
});
const advancedToAfter = await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is NOT trialing
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedToAfter,
});
// Verify balance reset (new billing cycle)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500, // Reset to full
usage: 0, // Reset to 0
});
// Verify first charge
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20,
latestInvoiceProductId: proTrial.id,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Trial ends with add-on - both products charged
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has proWithTrial (trialing) + add-on (inheriting trial)
* - Trial ends
*
* Expected Result:
* - Both products charged: pro ($20) + add-on ($20) = $40
*/
test.concurrent(`${chalk.yellowBright("trial-conversion 4: trial ends with add-on - both charged")}`, async () => {
const customerId = "trial-conv-with-addon";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const addonItem = items.dashboard();
const addonWords = items.monthlyWords({ includedUsage: 100 });
const addon = products.recurringAddOn({
id: "addon",
items: [addonItem, addonWords],
});
let { autumnV1, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, addon] }),
],
actions: [
s.billing.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 3 }),
s.billing.attach({ productId: addon.id, timeout: 4000 }),
],
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 0,
latestInvoiceProductIds: [addon.id],
});
const customerAfterAddonAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerAfterAddonAttach,
featureId: TestFeature.Words,
includedUsage: 100,
balance: 100,
usage: 0,
resetsAt: advancedTo + ms.days(4),
});
advancedTo = await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 14,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify both products are NOT trialing
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedTo,
});
await expectProductNotTrialing({
customer,
productId: addon.id,
nowMs: advancedTo,
});
// Verify first charge includes both products
await expectCustomerInvoiceCorrect({
customer,
count: 3,
latestTotal: 40, // Pro ($20) + Add-on ($20)
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Scheduled downgrade activates after trial ends
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premiumWithTrial (trialing)
* - Downgrade to pro (scheduled for trial end)
* - Trial ends
*
* Expected Result:
* - Pro becomes active (not premium)
* - Pro price charged ($20, not $50)
*/
test.concurrent(`${chalk.yellowBright("trial-conversion 5: scheduled downgrade activates after trial")}`, async () => {
const customerId = "trial-conv-scheduled-downgrade";
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premiumTrial, pro] }),
],
actions: [
s.billing.attach({ productId: premiumTrial.id }),
s.billing.attach({ productId: pro.id }), // Downgrade - scheduled
s.advanceTestClock({ toNextInvoice: true }),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify pro is active (not premium)
await expectProductActive({
customer,
productId: pro.id,
});
// Verify premium is no longer present
const hasPremium = customer.products.some((p) => p.id === premiumTrial.id);
expect(hasPremium).toBe(false);
// Verify pro is NOT trialing
await expectProductNotTrialing({
customer,
productId: pro.id,
nowMs: advancedTo,
});
// Verify pro price charged (not premium)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20, // Pro price, not premium
latestInvoiceProductId: pro.id,
});
// Verify feature balance is pro's balance with resetsAt aligned to monthly billing cycle
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: Date.now() + ms.days(14) + ms.days(30),
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});

View File

@@ -0,0 +1,750 @@
/**
* Free Trial Downgrade Tests (Attach V2)
*
* Tests for downgrade scenarios (scheduled switches).
*
* Key behaviors:
* - DOWNGRADE: Inherits subscription's current trial state
* - Product's trial config is IGNORED on downgrade
* - Scheduled downgrade activates with NO trial (regardless of product config)
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductActive,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addMonths } from "date-fns";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Downgrade from trialing premium to pro with trial (inherits trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premiumWithTrial (14-day trial, currently trialing)
* - Downgrade to proWithTrial (7-day trial config - IGNORED)
*
* Expected Result:
* - Premium stays active and trialing (canceling at end of trial)
* - Pro is scheduled
* - Trial state is preserved (premium's trial continues)
*/
test.concurrent(`${chalk.yellowBright("trial-downgrade 1: trialing premium to pro with trial")}`, async () => {
const customerId = "trial-downgrade-premium-to-pro-trial";
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premiumTrial, proTrial] }),
],
actions: [s.billing.attach({ productId: premiumTrial.id })],
});
// Verify initial state - premium is trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// 1. Preview downgrade - should show $0 (scheduled, no immediate charge)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
});
expect(preview.total).toBe(0);
// Verify next_cycle info for scheduled downgrade
expectPreviewNextCycleCorrect({
preview,
total: 20, // Pro's price after trial ends
startsAt: advancedTo + ms.days(14), // Trial end = cycle start
});
// 2. Attach pro (downgrade - scheduled)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify premium is canceling (still active, will be removed at trial end)
await expectProductCanceling({
customer,
productId: premiumTrial.id,
});
// Verify pro is scheduled
await expectProductScheduled({
customer,
productId: proTrial.id,
});
// Verify premium is STILL trialing (trial inherited)
await expectProductTrialing({
customer,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify feature balance is still premium's balance (until switch) with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
resetsAt: advancedTo + ms.days(14),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
// Count is 2: initial trial ($0) + scheduled downgrade ($0)
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 20,
waitForSeconds: 30,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20,
latestInvoiceProductId: proTrial.id,
});
await expectProductActive({
customer,
productId: proTrial.id,
});
await expectProductNotTrialing({
customer,
productId: proTrial.id,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Downgrade from trialing premium to pro without trial (inherits trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premiumWithTrial (14-day trial, currently trialing)
* - Downgrade to pro (NO trial config)
*
* Expected Result:
* - Premium stays active and trialing
* - Pro is scheduled
* - Trial state is preserved (product config ignored on downgrade)
*/
test.concurrent(`${chalk.yellowBright("trial-downgrade 2: trialing premium to pro without trial")}`, async () => {
const customerId = "trial-downgrade-premium-to-pro-no-trial";
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premiumTrial, pro] }),
],
actions: [s.billing.attach({ productId: premiumTrial.id })],
});
// Verify initial state - premium is trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// 1. Preview downgrade - should show $0 (scheduled), next_cycle = $20 at trial end
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14), // Trial end
total: 20, // Pro's price after trial ends
});
// 2. Attach pro (downgrade - scheduled)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify premium is canceling
await expectProductCanceling({
customer,
productId: premiumTrial.id,
});
// Verify pro is scheduled
await expectProductScheduled({
customer,
productId: pro.id,
});
// Verify premium is STILL trialing
await expectProductTrialing({
customer,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
// ═══════════════════════════════════════════════════════════════════════════
// ADVANCE PAST TRIAL: Verify scheduled downgrade activates correctly
// ═══════════════════════════════════════════════════════════════════════════
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 20,
waitForSeconds: 30,
});
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify pro is now active (not scheduled)
await expectProductActive({
customer: customerAfter,
productId: pro.id,
});
// Verify pro is NOT trialing (downgrades don't get trial)
await expectProductNotTrialing({
customer: customerAfter,
productId: pro.id,
});
// Verify invoice: $0 trial + $20 for pro after trial ends
await expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 2,
latestTotal: 20,
latestInvoiceProductId: pro.id,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Downgrade from non-trialing premium to pro with trial (no trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo, NOT trialing)
* - Downgrade to proWithTrial (7-day trial config - IGNORED)
*
* Expected Result:
* - Premium stays active (canceling at end of cycle)
* - Pro is scheduled
* - NO trial (inherits non-trialing state)
*/
test.concurrent(`${chalk.yellowBright("trial-downgrade 3: non-trialing premium to pro with trial (no trial)")}`, async () => {
const customerId = "trial-downgrade-notrial-premium-to-pro-trial";
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, proTrial] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// Verify initial state - premium is NOT trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer: customerBefore,
productId: premium.id,
nowMs: advancedTo,
});
// 1. Preview downgrade - should show $0 (scheduled)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
});
expect(preview.total).toBe(0);
// Verify next_cycle shows pro's charge (NO trial on scheduled activation)
expectPreviewNextCycleCorrect({
preview,
total: 20, // Pro's price - no trial on downgrade activation
startsAt: addMonths(advancedTo, 1).getTime(),
});
// 2. Attach pro (downgrade - scheduled)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify premium is canceling
await expectProductCanceling({
customer,
productId: premium.id,
});
// Verify pro is scheduled
await expectProductScheduled({
customer,
productId: proTrial.id,
});
// Verify premium is NOT trialing (no change)
await expectProductNotTrialing({
customer,
productId: premium.id,
nowMs: advancedTo,
});
// Verify invoice for premium
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 50,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20,
latestInvoiceProductId: proTrial.id,
});
await expectProductActive({
customer,
productId: proTrial.id,
});
await expectProductNotTrialing({
customer,
productId: proTrial.id,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Downgrade from premium to free (no trial on scheduled free)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo, NOT trialing)
* - Downgrade to free product
*
* Expected Result:
* - Premium stays active until end of cycle
* - Free is scheduled
* - No trial (free products don't need trials)
*/
test.concurrent(`${chalk.yellowBright("trial-downgrade 4: premium to free (no trial)")}`, async () => {
const customerId = "trial-downgrade-premium-to-free";
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
});
const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, free] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
// 1. Preview downgrade - should show $0 (scheduled)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: free.id,
});
expect(preview.total).toBe(0);
// Verify next_cycle shows $0 for free product
expectPreviewNextCycleCorrect({
preview,
total: 0,
startsAt: addMonths(advancedTo, 1).getTime(),
});
// 2. Attach free (downgrade - scheduled)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: free.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify premium is canceling
await expectProductCanceling({
customer,
productId: premium.id,
});
// Verify free is scheduled
await expectProductScheduled({
customer,
productId: free.id,
});
// Verify invoice for premium
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 50,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
// ═══════════════════════════════════════════════════════════════════════════
// ADVANCE PAST CYCLE: Verify scheduled downgrade activates correctly
// ═══════════════════════════════════════════════════════════════════════════
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
});
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify free is now active (not scheduled)
await expectProductActive({
customer: customerAfter,
productId: free.id,
});
// Verify feature balance is now free's balance
expectCustomerFeatureCorrect({
customer: customerAfter,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
// Verify no additional invoice (free product has no charge)
await expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 1,
latestTotal: 50, // Still the original premium invoice
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Downgrade from trialing premium to free (inherits trial until switch)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premiumWithTrial (14-day trial, currently trialing)
* - Downgrade to free product
*
* Expected Result:
* - Premium stays active and trialing until trial end
* - Free is scheduled
* - Trial continues until trial end, then free activates
*/
test.concurrent(`${chalk.yellowBright("trial-downgrade 5: trialing premium to free (inherits trial)")}`, async () => {
const customerId = "trial-downgrade-trialing-premium-to-free";
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
});
const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premiumTrial, free] }),
],
actions: [s.billing.attach({ productId: premiumTrial.id })],
});
// Verify initial state - premium is trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// 1. Preview downgrade - should show $0, next_cycle = $0 at trial end
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: free.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14), // Trial end
total: 0, // Free product
});
// 2. Attach free (downgrade - scheduled)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: free.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify premium is canceling
await expectProductCanceling({
customer,
productId: premiumTrial.id,
});
// Verify free is scheduled
await expectProductScheduled({
customer,
productId: free.id,
});
// Verify premium is STILL trialing
await expectProductTrialing({
customer,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify feature balance is still premium's balance with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
resetsAt: advancedTo + ms.days(14),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
// ═══════════════════════════════════════════════════════════════════════════
// ADVANCE PAST TRIAL: Verify scheduled downgrade activates correctly
// ═══════════════════════════════════════════════════════════════════════════
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 20,
waitForSeconds: 30,
});
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify free is now active (not scheduled)
await expectProductActive({
customer: customerAfter,
productId: free.id,
});
// Verify feature balance is now free's balance
expectCustomerFeatureCorrect({
customer: customerAfter,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
// Verify no additional paid invoice (free product has no charge)
await expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 1,
latestTotal: 0,
});
});

View File

@@ -0,0 +1,726 @@
/**
* Free Trial Entity Upgrade Tests (Attach V2)
*
* Tests for entity upgrades that affect shared subscription trial state.
*
* Key behaviors:
* - Entity upgrade to product with trial → fresh trial for ALL entities
* - Entity upgrade to product without trial → trial ends for ALL entities
* - All entities share the same subscription/trial state
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, type ApiEntityV0, ms } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductActive,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { timeout } from "@/utils/genUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Entity upgrade to product with trial (fresh trial for ALL)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 has proWithTrial (7-day trial, trialing)
* - Entity-2 has same proWithTrial (sharing trial)
* - Entity-1 upgrades to premiumWithTrial (14-day trial)
*
* Expected Result:
* - Entity-1 gets fresh 14-day trial on premium
* - Entity-2 continues with inherited trial state
*/
test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 1: entity upgrade to product with trial")}`, async () => {
const customerId = "trial-ent-upgrade-with-trial";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, premiumTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: proTrial.id, entityIndex: 0 }),
s.billing.attach({ productId: proTrial.id, entityIndex: 1 }),
],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - both entities trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductTrialing({
customer: entity1Before,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// 1. Preview upgrade entity-1 to premium - should show $0 (fresh trial), next_cycle shows combined charge
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premiumTrial.id,
entity_id: entity1Id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14), // Fresh 14-day trial
total: 50, // Premium ($50)
});
// 2. Upgrade entity-1 to premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premiumTrial.id,
entity_id: entity1Id,
redirect_mode: "if_required",
});
await timeout(4000);
// Verify entity-1 has premium with fresh 14-day trial
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({
customer: entity1,
productId: premiumTrial.id,
});
await expectProductTrialing({
customer: entity1,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify entity-2 still has pro (unchanged)
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductTrialing({
customer: entity2,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14), // Updated to match new subscription trial
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
// Count is 3: entity-1 trial ($0) + entity-2 trial ($0) + upgrade ($0)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 3,
latestTotal: 0,
latestInvoiceProductId: premiumTrial.id,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Entity upgrade to product without trial (trial ends for ALL)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 has proWithTrial (7-day trial, trialing)
* - Entity-2 has same proWithTrial (sharing trial)
* - Entity-1 upgrades to premium (NO trial)
*
* Expected Result:
* - Trial ends for ALL entities
* - Entity-1 charged for premium
* - Entity-2's pro is now charged (trial ended)
*/
test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 2: entity upgrade to product without trial (trial ends)")}`, async () => {
const customerId = "trial-ent-upgrade-no-trial";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, ctx, advancedTo, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, premium] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: proTrial.id, entityIndex: 0 }),
s.billing.attach({ productId: proTrial.id, entityIndex: 1 }),
],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - both entities trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductTrialing({
customer: entity1Before,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// 1. Preview upgrade entity-1 to premium (no trial)
// Should show premium ($50) + pro for entity-2 ($20) = $70
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
entity_id: entity1Id,
});
expect(preview.total).toBe(70); // Premium + Pro (trial ends for both)
// 2. Upgrade entity-1 to premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
entity_id: entity1Id,
redirect_mode: "if_required",
});
// Verify entity-1 has premium and NOT trialing
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({
customer: entity1,
productId: premium.id,
});
await expectProductNotTrialing({
customer: entity1,
productId: premium.id,
nowMs: advancedTo,
});
// Verify entity-2 pro is NOT trialing (trial ended for all)
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductNotTrialing({
customer: entity2,
productId: proTrial.id,
nowMs: advancedTo,
});
// Verify invoice for both products
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 3,
latestTotal: 70,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Entity downgrade during trial (scheduled, inherits trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 and Entity-2 both have premiumWithTrial (14-day trial, trialing)
* - Entity-1 downgrades to proWithTrial (scheduled)
* - Entity-2 stays on premiumWithTrial
*
* Expected Result:
* - Premium on Entity-1 stays trialing (canceling at trial end)
* - Pro on Entity-1 is scheduled
* - Premium on Entity-2 stays trialing
* - After trial ends: Entity-1 has pro (not trialing), Entity-2 has premium (not trialing)
*/
test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 3: entity downgrade during trial (scheduled)")}`, async () => {
const customerId = "trial-ent-downgrade-scheduled";
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
let { autumnV1, ctx, advancedTo, entities, testClockId } = await initScenario(
{
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premiumTrial, proTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: premiumTrial.id, entityIndex: 0 }),
s.billing.attach({ productId: premiumTrial.id, entityIndex: 1 }),
],
},
);
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// 1. Preview downgrade - should show $0 (scheduled), next_cycle shows pro + premium price
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
entity_id: entity1Id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14), // Trial end (pro activates)
total: 20, // Pro ($20)
});
// 2. Downgrade entity-1 to pro (scheduled)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
entity_id: entity1Id,
redirect_mode: "if_required",
});
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
// Verify premium is canceling but still trialing on entity-1
await expectProductCanceling({
customer: entity1,
productId: premiumTrial.id,
});
await expectProductTrialing({
customer: entity1,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify pro is scheduled on entity-1
await expectProductScheduled({
customer: entity1,
productId: proTrial.id,
});
// Verify entity-2 still has premium and is trialing
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductActive({
customer: entity2,
productId: premiumTrial.id,
});
await expectProductTrialing({
customer: entity2,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
// ═══════════════════════════════════════════════════════════════════════════
// ADVANCE PAST TRIAL: Verify scheduled downgrade activates correctly
// ═══════════════════════════════════════════════════════════════════════════
advancedTo = await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
});
// Verify entity-1 now has pro (not scheduled, not trialing)
const entity1After = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({
customer: entity1After,
productId: proTrial.id,
});
await expectProductNotTrialing({
customer: entity1After,
productId: proTrial.id,
nowMs: advancedTo,
});
// Verify entity-2 still has premium and is NOT trialing (trial ended)
const entity2After = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductActive({
customer: entity2After,
productId: premiumTrial.id,
});
await expectProductNotTrialing({
customer: entity2After,
productId: premiumTrial.id,
nowMs: advancedTo,
});
// Verify invoice: $0 trial invoice + $70 renewal (pro $20 + premium $50)
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 3,
latestTotal: 70,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: One entity upgrades, another stays on trial product
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 and Entity-2 both have proWithTrial (7-day trial, trialing)
* - Advance 3 days into trial
* - Entity-1 upgrades to premiumWithTrial (14-day trial)
* - Entity-2 stays on proWithTrial
*
* Expected Result:
* - Both entities share the new trial end (now + 14 days from upgrade time)
* - Entity-1 has premium, Entity-2 has pro
*/
test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 4: mixed products after entity upgrade")}`, async () => {
const customerId = "trial-ent-upgrade-mixed";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo, entities, testClockId } =
await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, premiumTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: proTrial.id, entityIndex: 0 }),
s.billing.attach({ productId: proTrial.id, entityIndex: 1 }),
],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - both entities trialing with 7-day trial
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductTrialing({
customer: entity1Before,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Advance 3 days into trial
const advancedTo3Days = await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
});
// New trial end is 14 days from upgrade time (now + 14 days)
const newTrialEnd = advancedTo3Days + ms.days(14);
// 1. Preview upgrade - should show $0 (fresh trial), next_cycle shows combined charge
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premiumTrial.id,
entity_id: entity1Id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: newTrialEnd, // Fresh 14-day trial from now
total: 70, // Premium ($50) + Pro ($20) after trial
});
// 2. Upgrade entity-1 to premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premiumTrial.id,
entity_id: entity1Id,
redirect_mode: "if_required",
});
// Wait for webhook to sync trial status
await timeout(4000);
// Verify entity-1 has premium with fresh 14-day trial
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({
customer: entity1,
productId: premiumTrial.id,
});
await expectProductTrialing({
customer: entity1,
productId: premiumTrial.id,
trialEndsAt: newTrialEnd,
});
// Verify entity-2 still has pro but now shares premium's 14-day trial end
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductActive({
customer: entity2,
productId: proTrial.id,
});
await expectProductTrialing({
customer: entity2,
productId: proTrial.id,
trialEndsAt: newTrialEnd, // Shares new 14-day trial end
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 3,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Non-trialing entity upgrade to trial product
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 has pro ($20/mo, NOT trialing)
* - Entity-2 has same pro (NOT trialing)
* - Entity-1 upgrades to premiumWithTrial
*
* Expected Result:
* - Fresh trial starts for entity-1's premium
* - Entity-2's pro gets refunded (subscription moved to trial)
*/
test.concurrent(`${chalk.yellowBright("trial-entity-upgrade 5: non-trialing upgrade to trial product")}`, async () => {
const customerId = "trial-ent-notrial-to-trial";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premiumTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
s.billing.attach({ productId: pro.id, entityIndex: 1 }),
],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - neither entity is trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductNotTrialing({
customer: entity1Before,
productId: pro.id,
nowMs: advancedTo,
});
// 1. Preview upgrade to premium with trial
// Should show negative (refund for both pro products)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premiumTrial.id,
entity_id: entity1Id,
});
// Refund for entity-1 pro (-$20) + refund for entity-2 pro (-$20) = -$40
expect(preview.total).toBe(-40);
// 2. Upgrade entity-1 to premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premiumTrial.id,
entity_id: entity1Id,
redirect_mode: "if_required",
});
// Verify invoices: initial charges + refunds
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 3, // entity-1 pro ($20) + entity-2 pro ($20) + refund (-$40)
latestTotal: -40,
});
await timeout(4000);
// Verify entity-1 has premium with fresh 14-day trial
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductTrialing({
customer: entity1,
productId: premiumTrial.id,
});
// Verify entity-2's pro is now trialing (subscription moved to trial)
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductTrialing({
customer: entity2,
productId: pro.id,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});

View File

@@ -0,0 +1,430 @@
/**
* Free Trial Features Tests (Attach V2)
*
* Tests for prepaid, allocated, and consumable features during trials.
*
* Key behaviors:
* - Prepaid items: Balance available during trial, no charge until trial ends
* - Allocated seats: Seats available during trial, prorated charge after trial
* - Consumable (arrears): Usage tracked, billed at trial end
* - Feature balance is available during trial period
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Prepaid messages during trial
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer attaches product with prepaid messages and trial
* - Product includes: 100 included messages, prepaid at $10/100 messages
* - Customer uses some prepaid messages during trial
*
* Expected Result:
* - Balance is available during trial
* - Balance decreases as used
* - No charge during trial
*/
test.concurrent(`${chalk.yellowBright("trial-features 1: prepaid messages during trial")}`, async () => {
const customerId = "trial-feat-prepaid-messages";
const prepaidItem = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const priceItem = items.monthlyPrice({ price: 20 });
const productWithTrial = products.base({
id: "prepaid-trial",
items: [prepaidItem, priceItem],
trialDays: 7,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [productWithTrial] }),
],
actions: [],
});
// 1. Preview attach with quantity - should show $0 during trial, next_cycle shows base + prepaid
// quantity becomes the new includedUsage, so 200 - 100 original = 1 pack to purchase
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: productWithTrial.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7), // Trial end
total: 30, // Base ($20) + 1 pack x $10 = $30 after trial
});
// 2. Attach product with prepaid
// quantity becomes the new includedUsage (200), so 1 pack purchased
await autumnV1.billing.attach({
customer_id: customerId,
product_id: productWithTrial.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
redirect_mode: "if_required",
});
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is trialing
await expectProductTrialing({
customer,
productId: productWithTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify prepaid balance is available
// quantity (200) becomes the new includedUsage and balance
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 200,
usage: 0,
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// 3. Track some usage during trial
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
await new Promise((r) => setTimeout(r, 2000));
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify balance decreased (200 - 50 = 150)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 150,
usage: 50,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Allocated seats during trial
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer attaches product with allocated seats and trial
* - Product includes: 3 included seats, $10/seat overage
* - Customer tracks seat usage during trial (5 seats - 2 over included)
*
* Expected Result:
* - Seats are available during trial
* - Seat usage tracked with overage
* - No charge during trial (prorated charge happens at trial end)
*/
test.concurrent(`${chalk.yellowBright("trial-features 2: allocated seats during trial")}`, async () => {
const customerId = "trial-feat-allocated-seats";
const seatsItem = items.allocatedUsers({ includedUsage: 3 });
const priceItem = items.monthlyPrice({ price: 20 });
const productWithTrial = products.base({
id: "seats-trial",
items: [seatsItem, priceItem],
trialDays: 7,
});
const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [productWithTrial] }),
],
actions: [],
});
// 1. Preview attach - should show $0 during trial, next_cycle shows base price
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: productWithTrial.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7), // Trial end
total: 20, // Base price after trial
});
// 2. Attach product with allocated seats
await autumnV1.billing.attach({
customer_id: customerId,
product_id: productWithTrial.id,
redirect_mode: "if_required",
});
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is trialing
await expectProductTrialing({
customer,
productId: productWithTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify seats are available
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 3,
balance: 3,
usage: 0,
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// 3. Track seat usage (set to 5 seats - 2 over included)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 5,
});
await new Promise((r) => setTimeout(r, 2000));
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify seat usage is tracked
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 3,
balance: -2, // 3 included - 5 used = -2 overage
usage: 5,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
const advancedToAfter = await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is NOT trialing
await expectProductNotTrialing({
customer,
productId: productWithTrial.id,
nowMs: advancedToAfter,
});
await expectCustomerInvoiceCorrect({
customer,
count: 3,
latestTotal: 10 + 2 * 10, // Base price + 2 seats x $10/seat
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 3,
balance: -2, // 3 included - 5 used = -2 overage
usage: 5,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Consumable (arrears) during trial
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer attaches product with consumable messages and trial
* - Product includes: 100 included, $0.10/message overage
* - Customer tracks usage to overage during trial (150 used = 50 overage)
* - Trial ends
*
* Expected Result:
* - Usage is tracked during trial
* - After trial ends: consumable does NOT reset, overage is NOT charged
* - Invoice only contains base price ($20)
*/
test.concurrent(`${chalk.yellowBright("trial-features 3: consumable (arrears) during trial")}`, async () => {
const customerId = "trial-feat-consumable";
const consumableItem = items.consumableMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const productWithTrial = products.base({
id: "consumable-trial",
items: [consumableItem, priceItem],
trialDays: 7,
});
const { autumnV1, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [productWithTrial] }),
],
actions: [],
});
// 1. Preview attach - should show $0 during trial, next_cycle shows base price
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: productWithTrial.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7), // Trial end
total: 20, // Base price after trial (arrears usage billed separately)
});
// 2. Attach product with consumable
await autumnV1.billing.attach({
customer_id: customerId,
product_id: productWithTrial.id,
redirect_mode: "if_required",
});
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is trialing
await expectProductTrialing({
customer,
productId: productWithTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify consumable balance (included usage available) with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
resetsAt: advancedTo + ms.days(7),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// 3. Track usage to overage (150 used = 50 over the 100 included)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 150,
});
await new Promise((r) => setTimeout(r, 2000));
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify usage during trial (150 used, 50 overage)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: -50, // 100 - 150 = -50 overage
usage: 150,
});
// 4. Advance test clock past trial end
const advancedToAfter = await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
currentEpochMs: advancedTo,
});
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is NOT trialing
await expectProductNotTrialing({
customer,
productId: productWithTrial.id,
nowMs: advancedToAfter,
});
// Verify consumable does NOT reset - usage stays at 150, balance stays at -50
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: -50, // Still -50, no reset
usage: 150, // Still 150, no reset
});
// Verify invoice only contains base price ($20), overage is NOT charged
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20, // Only base price, no overage
latestInvoiceProductId: productWithTrial.id,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});

View File

@@ -0,0 +1,588 @@
/**
* Free Trial Free Product Tests (Attach V2)
*
* Tests for free product trials (no Stripe subscription).
*
* Key behaviors:
* - Free products with trial are isolated from Stripe subscription
* - Trial gates features until trial ends
* - Product's trial config always applies for free products
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, type ApiEntityV0, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addMonths } from "date-fns";
import { timeout } from "@/utils/genUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Free product with trial (no Stripe subscription)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer attaches free product with trial (baseWithTrial)
* - No base price, just features
*
* Expected Result:
* - Product is trialing
* - No Stripe subscription created
* - Features available during trial
*/
test.concurrent(`${chalk.yellowBright("trial-free-product 1: free product with trial")}`, async () => {
const customerId = "trial-free-prod-basic";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
id: "free-trial",
items: [messagesItem],
trialDays: 7,
cardRequired: false,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({}), // No payment method needed
s.products({ list: [freeWithTrial] }),
],
actions: [],
});
// 1. Preview attach - should show $0 (free product), next_cycle shows $0 (free continues)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: freeWithTrial.id,
});
expect(preview.total).toBe(0);
// 2. Attach free product with trial
await autumnV1.billing.attach({
customer_id: customerId,
product_id: freeWithTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active
await expectProductActive({
customer,
productId: freeWithTrial.id,
});
// Verify product is trialing
await expectProductTrialing({
customer,
productId: freeWithTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify features available with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
resetsAt: advancedTo + ms.days(7),
});
// Verify no invoice (free product)
await expectCustomerInvoiceCorrect({
customer,
count: 0,
});
// Verify no Stripe subscription
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Free trial product to paid product (creates Stripe subscription)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has free product with trial
* - Upgrade to paid pro product
*
* Expected Result:
* - Stripe subscription created
* - Pro is active, free is removed
*/
test.concurrent(`${chalk.yellowBright("trial-free-product 3: free trial to paid product")}`, async () => {
const customerId = "trial-free-prod-to-paid";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
id: "free-trial",
items: [messagesItem],
trialDays: 7,
cardRequired: false,
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [freeWithTrial, pro] }),
],
actions: [s.billing.attach({ productId: freeWithTrial.id })],
});
// 1. Preview upgrade - should show $20 (pro price)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
});
expect(preview.total).toBe(20);
// 2. Upgrade to pro
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [freeWithTrial.id],
});
// Verify pro is NOT trialing
await expectProductNotTrialing({
customer,
productId: pro.id,
nowMs: advancedTo,
});
// Verify feature balance is pro's balance with resetsAt at billing cycle (no trial)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: addMonths(Date.now(), 1).getTime(),
});
// Verify invoice for pro
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 20,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Free trial product to paid trial product
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has free product with trial
* - Upgrade to proWithTrial
*
* Expected Result:
* - Pro starts fresh trial
* - No immediate charge
*/
test.concurrent(`${chalk.yellowBright("trial-free-product 4: free trial to paid trial product")}`, async () => {
const customerId = "trial-free-prod-to-paid-trial";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
id: "free-trial",
items: [messagesItem],
trialDays: 7,
cardRequired: false,
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [freeWithTrial, proTrial] }),
],
actions: [s.billing.attach({ productId: freeWithTrial.id })],
});
// 1. Preview upgrade - should show $0 (new trial), next_cycle shows pro price
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14), // Fresh 14-day trial
total: 20, // Pro price after trial
});
// 2. Upgrade to proTrial
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [proTrial.id],
notPresent: [freeWithTrial.id],
});
// Verify pro is trialing with fresh 14-day trial
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify feature balance is pro's balance with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: advancedTo + ms.days(14),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Multiple free products with different trial configs
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer attaches free product with 7-day trial
* - Upgrade to different free product with 14-day trial
*
* Expected Result:
* - New product gets fresh 14-day trial
* - Old product removed
*/
test.concurrent(`${chalk.yellowBright("trial-free-product 6: free to different free with trial")}`, async () => {
const customerId = "trial-free-prod-free-to-free";
const messagesItem1 = items.monthlyMessages({ includedUsage: 100 });
const free1 = products.baseWithTrial({
id: "free1-trial",
items: [messagesItem1],
trialDays: 7,
cardRequired: false,
});
const messagesItem2 = items.monthlyMessages({ includedUsage: 200 });
const free2 = products.baseWithTrial({
id: "free2-trial",
items: [messagesItem2],
trialDays: 14,
cardRequired: false,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [s.customer({}), s.products({ list: [free1, free2] })],
actions: [s.billing.attach({ productId: free1.id })],
});
// Verify initial state - free1 is trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: free1.id,
trialEndsAt: advancedTo + ms.days(7),
});
// 1. Preview switch to free2 - should show $0, next_cycle shows $0 (free continues)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: free2.id,
});
expect(preview.total).toBe(0);
// 2. Switch to free2
await autumnV1.billing.attach({
customer_id: customerId,
product_id: free2.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [free2.id],
notPresent: [free1.id],
});
// Verify free2 is trialing with fresh 14-day trial
await expectProductTrialing({
customer,
productId: free2.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify feature balance is free2's balance with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 200,
usage: 0,
resetsAt: advancedTo + ms.days(14),
});
// Verify no invoice
await expectCustomerInvoiceCorrect({
customer,
count: 0,
});
// Verify no Stripe subscription
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7: Entity pro products isolated from customer-level free trial
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Entity-1 and Entity-2 both have Pro products (on Stripe subscription, NOT trialing)
* - Customer-level attaches a free product with trial
* - Advance test clock past the free product's trial end
*
* Expected Result:
* - Entity-1 and Entity-2's Pro products remain untouched (on subscription, not trialing)
* - Free product's trial converts independently at customer level
* - Free product has its own billing cycle separate from the subscription
*/
test.concurrent(`${chalk.yellowBright("trial-free-product 7: entity pro isolated from customer-level free trial")}`, async () => {
const customerId = "trial-free-prod-entity-isolated";
// Pro product for entities (paid, on subscription)
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
// Free product for customer level (with 7-day trial)
const freeMessagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
id: "free-trial",
items: [freeMessagesItem],
trialDays: 7,
cardRequired: false,
});
const { autumnV1, ctx, advancedTo, entities, testClockId } =
await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, freeWithTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
// Attach pro to both entities (on Stripe subscription, NOT trialing)
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
s.billing.attach({ productId: pro.id, entityIndex: 1 }),
],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - both entities have Pro, NOT trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({ customer: entity1Before, productId: pro.id });
await expectProductNotTrialing({
customer: entity1Before,
productId: pro.id,
nowMs: advancedTo,
});
const entity2Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductActive({ customer: entity2Before, productId: pro.id });
await expectProductNotTrialing({
customer: entity2Before,
productId: pro.id,
nowMs: advancedTo,
});
// Attach free product with trial at CUSTOMER level (not entity level)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: freeWithTrial.id,
redirect_mode: "if_required",
});
await timeout(2000);
// Verify customer-level free product is trialing
const customerDuringTrial =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerDuringTrial,
productId: freeWithTrial.id,
});
await expectProductTrialing({
customer: customerDuringTrial,
productId: freeWithTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify entities' Pro products are still NOT trialing (unaffected by customer-level free trial)
const entity1DuringTrial = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductNotTrialing({
customer: entity1DuringTrial,
productId: pro.id,
nowMs: advancedTo,
});
const entity2DuringTrial = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductNotTrialing({
customer: entity2DuringTrial,
productId: pro.id,
nowMs: advancedTo,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: {
checkNotTrialing: true,
},
});
await expectCustomerInvoiceCorrect({
customer: customerDuringTrial,
count: 2,
latestTotal: 20,
});
// Advance test clock past the free product's trial end (7 days)
const advancedToAfterTrial = await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
// Verify free product trial has ended and is now active (no longer trialing)
const customerAfterTrial =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterTrial,
productId: freeWithTrial.id,
});
await expectProductNotTrialing({
customer: customerAfterTrial,
productId: freeWithTrial.id,
nowMs: advancedToAfterTrial,
});
// Verify entities' Pro products are STILL not trialing and remain on subscription billing cycle
const entity1AfterTrial = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({ customer: entity1AfterTrial, productId: pro.id });
await expectProductNotTrialing({
customer: entity1AfterTrial,
productId: pro.id,
nowMs: advancedToAfterTrial,
});
const entity2AfterTrial = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductActive({ customer: entity2AfterTrial, productId: pro.id });
await expectProductNotTrialing({
customer: entity2AfterTrial,
productId: pro.id,
nowMs: advancedToAfterTrial,
});
await expectCustomerInvoiceCorrect({
customer: customerAfterTrial,
count: 3, // Only the 2 Pro attaches
latestTotal: 40, // Each Pro attach is $20
});
});

View File

@@ -0,0 +1,462 @@
/**
* Free Trial Merge Tests (Attach V2)
*
* Tests for add-on and entity scenarios where subscription trial state is inherited.
*
* Key behaviors:
* - ADD-ONS: Inherit subscription's current trial state
* - NEW ENTITIES: Inherit subscription's current trial state
* - Product's trial config is IGNORED for merges
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, type ApiEntityV0, ms } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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 { timeout } from "@/utils/genUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Add-on to trialing subscription (inherits trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has proWithTrial (7-day trial, currently trialing)
* - Attach add-on product ($20/mo)
*
* Expected Result:
* - Add-on inherits subscription's trial state
* - Add-on is trialing with same trial end as main product
* - No charge for add-on during trial
*/
test.concurrent(`${chalk.yellowBright("trial-merge 1: add-on to trialing subscription (inherits trial)")}`, async () => {
const customerId = "trial-merge-addon-trialing";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const addonItem = items.monthlyMessages({ includedUsage: 100 });
const addon = products.recurringAddOn({
id: "addon",
items: [addonItem],
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, addon] }),
],
actions: [s.billing.attach({ productId: proTrial.id })],
});
// Verify initial state - pro is trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// 1. Preview add-on - should show $0 (inherits trial), next_cycle shows combined charge
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: addon.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7),
total: 20, // Add-on ($20) after trial
});
// 2. Attach add-on
await autumnV1.billing.attach({
customer_id: customerId,
product_id: addon.id,
redirect_mode: "if_required",
});
await timeout(4000);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify both products are active
await expectCustomerProducts({
customer,
active: [proTrial.id, addon.id],
});
// Verify pro is still trialing
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify add-on inherits trial state (same trial end)
await expectProductTrialing({
customer,
productId: addon.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
// Count is 2: initial trial ($0) + add addon ($0)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Add-on to non-trialing subscription (no trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro ($20/mo, NOT trialing)
* - Attach add-on with trial config ($20/mo, 7-day trial - IGNORED)
*
* Expected Result:
* - Add-on does NOT get trial (inherits non-trialing state)
* - Charged immediately for add-on ($20)
*/
test.concurrent(`${chalk.yellowBright("trial-merge 2: add-on to non-trialing subscription (no trial)")}`, async () => {
const customerId = "trial-merge-addon-not-trialing";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
// Add-on with trial config - should be IGNORED
const addonItem = items.monthlyMessages({ includedUsage: 100 });
const addonWithTrial = products.base({
id: "addon-trial",
items: [addonItem, items.monthlyPrice({ price: 20 })],
isAddOn: true,
trialDays: 7,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addonWithTrial] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Verify initial state - pro is NOT trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer: customerBefore,
productId: pro.id,
nowMs: advancedTo,
});
// 1. Preview add-on - should show $20 (no trial, product config ignored)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: addonWithTrial.id,
});
expect(preview.total).toBe(20);
// 2. Attach add-on with trial config
await autumnV1.billing.attach({
customer_id: customerId,
product_id: addonWithTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify both products are active
await expectCustomerProducts({
customer,
active: [pro.id, addonWithTrial.id],
});
// Verify pro is NOT trialing
await expectProductNotTrialing({
customer,
productId: pro.id,
nowMs: advancedTo,
});
// Verify add-on is NOT trialing (inherits non-trialing state)
await expectProductNotTrialing({
customer,
productId: addonWithTrial.id,
nowMs: advancedTo,
});
// Verify invoices: pro ($20) + add-on ($20)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Entity attach to trialing subscription (inherits trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has proWithTrial on entity-1 (7-day trial, trialing)
* - Attach same product to entity-2
*
* Expected Result:
* - Entity-2 inherits subscription's trial state
* - Both entities have same trial end
*/
test.concurrent(`${chalk.yellowBright("trial-merge 3: entity attach to trialing subscription (inherits trial)")}`, async () => {
const customerId = "trial-merge-entity-trialing";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: proTrial.id, entityIndex: 0 })],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - entity-1 is trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductTrialing({
customer: entity1Before,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// 1. Preview attach to entity-2 - should show $0 (inherits trial), next_cycle shows charge for both entities
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
entity_id: entity2Id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7), // Trial end
total: 20, // 2 entities x $20 = $40 after trial
});
// 2. Attach to entity-2
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
entity_id: entity2Id,
redirect_mode: "if_required",
});
await timeout(4000);
// Verify entity-1 is still trialing
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductTrialing({
customer: entity1,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify entity-2 inherits trial (same trial end)
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductTrialing({
customer: entity2,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
// Count is 2: initial trial ($0) + add entity ($0)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Entity attach to non-trialing subscription (no trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro on entity-1 (NOT trialing)
* - Attach proWithTrial to entity-2 (trial config IGNORED)
*
* Expected Result:
* - Entity-2 does NOT get trial (inherits non-trialing state)
* - Charged immediately for entity-2
*/
test.concurrent(`${chalk.yellowBright("trial-merge 4: entity attach to non-trialing subscription (no trial)")}`, async () => {
const customerId = "trial-merge-entity-not-trialing";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
// Product with trial config - should be IGNORED on entity add
const proTrialMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proTrialMessagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, entities, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, proTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: pro.id, entityIndex: 0 })],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// Verify initial state - entity-1 is NOT trialing
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductNotTrialing({
customer: entity1Before,
productId: pro.id,
nowMs: advancedTo,
});
// 1. Preview attach trial product to entity-2 - should show $20 (no trial)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
entity_id: entity2Id,
});
expect(preview.total).toBe(20);
// 2. Attach trial product to entity-2
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
entity_id: entity2Id,
redirect_mode: "if_required",
});
// Verify entity-1 is NOT trialing
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductNotTrialing({
customer: entity1,
productId: pro.id,
nowMs: advancedTo,
});
// Verify entity-2 is NOT trialing (trial config ignored)
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
await expectProductNotTrialing({
customer: entity2,
productId: proTrial.id,
nowMs: advancedTo,
});
// Verify invoices: entity-1 pro ($20) + entity-2 pro ($20)
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});

View File

@@ -0,0 +1,124 @@
/**
* Free Trial Payment Method Tests (Attach V2)
*
* Tests for card_required vs card not required trial behavior.
*
* Key behaviors:
* - card_required: true → Must have payment method to start trial
* - card_required: false → Can start trial without payment method
* - Payment method can be added during trial
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Card required - without payment method (should redirect to checkout)
// ═══════════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Card NOT required - without payment method
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Product has card_required: false
* - Customer does NOT have payment method
*
* Expected Result:
* - Trial starts without payment method
* - No checkout redirect
*/
test.concurrent(`${chalk.yellowBright("trial-payment 3: card not required - no payment method")}`, async () => {
const customerId = "trial-payment-no-card-req";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrialNoCard = products.proWithTrial({
id: "pro-trial-nocard",
items: [messagesItem],
trialDays: 7,
cardRequired: false, // Card NOT required
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({}), // No payment method
s.products({ list: [proTrialNoCard] }),
],
actions: [],
});
// 1. Preview attach - should show $0 (trial), next_cycle shows pro price
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrialNoCard.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(7), // Trial end
total: 20, // Pro price after trial
});
// 2. Attach product - should succeed without payment method
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrialNoCard.id,
redirect_mode: "if_required",
});
// Should NOT redirect to checkout
expect(result.checkout_url).toBeUndefined();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active and trialing
await expectProductActive({
customer,
productId: proTrialNoCard.id,
});
await expectProductTrialing({
customer,
productId: proTrialNoCard.id,
trialEndsAt: advancedTo + ms.days(7),
});
// Verify features available with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
resetsAt: advancedTo + ms.days(7),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// Verify Stripe subscription state (trial without card)
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});

View File

@@ -0,0 +1,199 @@
/**
* Free Trial Reattach Tests (Attach V2)
*
* Tests for cancel/reattach scenarios and trial prevention.
*
* Key behaviors:
* - Cancel during trial cancels subscription
* - Reattach same product gets fresh trial (based on unique_fingerprint)
* - Scheduled switches can be cancelled
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductActive,
expectProductNotPresent,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Reattach same product after cancel (fresh trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer had proWithTrial, cancelled during trial
* - Reattach same product (unique_fingerprint: false)
*
* Expected Result:
* - Gets fresh trial (unique_fingerprint not enforced)
*/
test.concurrent(`${chalk.yellowBright("trial-reattach 2: reattach same product (fresh trial)")}`, async () => {
const customerId = "trial-reattach-same-product";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [
s.billing.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 3 }),
s.updateSubscription({
productId: proTrial.id,
cancelAction: "cancel_immediately" as const,
}),
],
});
// Verify product is removed after cancel
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotPresent({
customer: customerAfterCancel,
productId: proTrial.id,
});
// 1. Preview reattach - should show $0 deduplicate trial
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proTrial.id,
});
expect(preview.total).toBe(20);
// 2. Reattach same product
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active
await expectProductActive({
customer,
productId: proTrial.id,
});
// Verify gets fresh 7-day trial (from current time)
await expectProductNotTrialing({
customer,
productId: proTrial.id,
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 20,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Downgrade during trial then cancel scheduled
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premiumWithTrial (trialing)
* - Downgrade to pro (scheduled)
* - Cancel the scheduled pro
*
* Expected Result:
* - Premium remains active and trialing
* - Pro scheduled attachment is cancelled
*/
test.concurrent(`${chalk.yellowBright("trial-reattach 3: cancel scheduled downgrade during trial")}`, async () => {
const customerId = "trial-reattach-cancel-scheduled";
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premiumTrial, pro] }),
],
actions: [
s.billing.attach({ productId: premiumTrial.id }),
s.billing.attach({ productId: pro.id }), // Downgrade - scheduled
],
});
// Cancel the scheduled pro
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: premiumTrial.id,
cancel_action: "uncancel" as const,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify premium is active and still trialing
await expectProductActive({
customer,
productId: premiumTrial.id,
});
await expectProductTrialing({
customer,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify pro scheduled is cancelled (not present)
await expectProductNotPresent({
customer,
productId: pro.id,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});

View File

@@ -0,0 +1,483 @@
/**
* Free Trial Upgrade Tests (Attach V2)
*
* Tests for upgrade scenarios where the new product's trial config applies.
*
* Key behaviors:
* - UPGRADE: New product's trial config applies
* - If new product has trial → Fresh trial starts
* - If new product has NO trial → Trial ends, charge immediately
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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 { addMonths } from "date-fns";
import { timeout } from "@/utils/genUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Upgrade from trialing pro to premium with trial (fresh trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has proWithTrial (7-day trial, currently trialing)
* - Upgrade to premiumWithTrial (14-day trial)
*
* Expected Result:
* - Fresh 14-day trial starts from upgrade time
* - Old trial is replaced with new trial
* - No charge during trial
*/
test.concurrent(`${chalk.yellowBright("trial-upgrade 1: trialing pro to premium with trial (fresh trial)")}`, async () => {
const customerId = "trial-upgrade-pro-to-premium-trial";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, premiumTrial] }),
],
actions: [s.billing.attach({ productId: proTrial.id })],
});
// Verify initial state - pro is trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(7),
});
// 1. Preview upgrade - should show $0 (new trial), next_cycle = $50 at 14 days
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premiumTrial.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14),
total: 50, // Premium base price after fresh trial
});
// 2. Attach premium with trial (upgrade)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premiumTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premiumTrial.id],
notPresent: [proTrial.id],
});
// Verify premium is trialing with FRESH 14-day trial
await expectProductTrialing({
customer,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify feature balance is premium's balance with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
resetsAt: advancedTo + ms.days(14),
});
// Verify $0 invoice during trial (Stripe creates invoice for trial subscriptions)
// Count is 2: initial trial ($0) + upgrade ($0)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Upgrade from trialing pro to premium without trial (trial ends)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has proWithTrial (7-day trial, currently trialing)
* - Upgrade to premium (NO trial)
*
* Expected Result:
* - Trial ends immediately
* - Charged for premium ($50)
*/
test.concurrent(`${chalk.yellowBright("trial-upgrade 2: trialing pro to premium without trial (trial ends)")}`, async () => {
const customerId = "trial-upgrade-pro-to-premium-no-trial";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, premium] }),
],
actions: [s.billing.attach({ productId: proTrial.id })],
});
const now = Date.now();
// Verify initial state - pro is trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: proTrial.id,
trialEndsAt: now + ms.days(7),
});
// 1. Preview upgrade - should show $50 (no trial, full charge)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(50);
// 2. Attach premium without trial (upgrade - ends trial)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [proTrial.id],
});
// Verify premium is NOT trialing
await expectProductNotTrialing({
customer,
productId: premium.id,
nowMs: advancedTo,
});
// Verify feature balance is premium's balance with resetsAt at billing cycle (no trial)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
resetsAt: addMonths(now, 1).getTime(),
});
// Verify invoice for premium
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 50,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Upgrade from non-trialing pro to premium with trial (fresh trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro ($20/mo, NOT trialing)
* - Upgrade to premiumWithTrial (14-day trial)
*
* Expected Result:
* - Fresh 14-day trial starts
* - Existing pro charge is refunded (prorated credit)
* - No new charge during trial
*/
test.concurrent(`${chalk.yellowBright("trial-upgrade 3: non-trialing pro to premium with trial")}`, async () => {
const customerId = "trial-upgrade-notrial-to-trial";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premiumTrial] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Verify initial state - pro is NOT trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer: customerBefore,
productId: pro.id,
nowMs: advancedTo,
});
// 1. Preview upgrade - should show negative (refund for unused pro), next_cycle = $50
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premiumTrial.id,
});
// At start of cycle, full refund of pro: -$20
expect(preview.total).toBe(-20);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14),
total: 50, // Premium price after fresh trial
});
// 2. Attach premium with trial (upgrade)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premiumTrial.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premiumTrial.id],
notPresent: [pro.id],
});
// Verify premium is trialing with fresh 14-day trial
await expectProductTrialing({
customer,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify feature balance is premium's balance with resetsAt aligned to trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
resetsAt: advancedTo + ms.days(14),
});
// Verify invoices: pro charge ($20) + refund (-$20)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: -20,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Mid-trial upgrade from pro to premium with trial (fresh trial)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has proWithTrial (7-day trial)
* - Advance 3 days (mid-trial)
* - Upgrade to premiumWithTrial (14-day trial)
*
* Expected Result:
* - Fresh 14-day trial starts from upgrade time (NOT from original attach)
* - Old partial trial is discarded
* - No invoice generated (both trials are $0)
*/
test.concurrent(`${chalk.yellowBright("trial-upgrade 4: mid-trial upgrade to premium with trial (fresh trial)")}`, async () => {
const customerId = "trial-upgrade-mid-trial-fresh";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [proMessagesItem],
trialDays: 7,
cardRequired: true,
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premiumTrial = products.premiumWithTrial({
id: "premium-trial",
items: [premiumMessagesItem],
trialDays: 14,
cardRequired: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial, premiumTrial] }),
],
actions: [
s.billing.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 3 }), // Mid-trial: 4 days remaining
],
});
// advancedTo is now 3 days after initial attach
// Original pro trial would end at: advancedTo + 4 days (7 - 3 = 4 days remaining)
// Verify pro is still trialing with 4 days remaining
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(4), // 4 days remaining from current time
});
// 1. Preview upgrade - should show $0 (new trial), next_cycle = $50 at 14 days FROM NOW
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premiumTrial.id,
});
expect(preview.total).toBe(0);
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14), // Fresh 14-day trial from upgrade time
total: 50,
});
// 2. Attach premium with trial (upgrade mid-trial)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premiumTrial.id,
redirect_mode: "if_required",
});
await timeout(4000);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premiumTrial.id],
notPresent: [proTrial.id],
});
// Verify premium has FRESH 14-day trial from upgrade time (NOT 4 days remaining)
await expectProductTrialing({
customer,
productId: premiumTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Verify feature balance is premium's balance with resetsAt aligned to new trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
resetsAt: advancedTo + ms.days(14),
});
// Verify NO paid invoice generated - both are $0 trial invoices
// Count is 2: initial trial ($0) + upgrade ($0)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
// Verify Stripe subscription state
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkTrialing: true },
});
});

View File

@@ -1,118 +0,0 @@
# immediate-switch/ Test Cases
Covers **upgrades** — when attaching a higher-tier product that takes effect **immediately**.
---
## immediate-switch-basic.test.ts
Basic upgrade scenarios.
- **immediate-switch: free to pro** — Free product → Pro. Verify pro is active, free is removed, invoice for pro base price.
- **immediate-switch: pro to premium** — Pro ($20/mo) → Premium ($50/mo). Verify prorated charge for price difference.
- **immediate-switch: pro to premium mid-cycle** — Attach pro, advance 15 days, upgrade to premium. Verify prorated charge.
- **immediate-switch: pro to free to premium** — Pro → Free (downgrade, scheduled) → Premium (upgrade). Verify scheduled downgrade is cancelled, premium is active immediately.
- **immediate-switch: premium to pro to ultra** — Premium → Pro (downgrade, scheduled) → Ultra. Verify scheduled downgrade is cancelled, ultra is active immediately.
- **immediate-switch: upgrade with consumable features, verify usage resets** — Pro with consumable + free consumable + prepaid consumable → Premium. Verify all **usage resets**.
- **immediate-switch: upgrade with allocated features, verify usage carries over** — Pro with free allocated + allocated + prepaid allocated → Premium. Verify all **usage carries over**.
Fixtures: `products.pro()`, `products.premium()`, `products.ultra()`, `products.base()`
---
## immediate-switch-consumable.test.ts
Upgrades involving consumable features.
- **immediate-switch: pro with consumable, track usage, to premium** — Pro with consumable messages, track some usage (and also into overage). Upgrade to premium. Verify overage NOT charged on upgrade (billed at cycle end), and **usage resets** after upgrade.
---
## immediate-switch-allocated.test.ts
Upgrades involving allocated (seat-based) features.
### Same included usage (pro → pro-variant)
- **immediate-switch: free with free allocated to pro with allocated** — Free with free allocated users → Pro with allocated users (same included). Verify usage carries over.
- **immediate-switch: pro with allocated, under limit, to pro-variant** — Pro with 3 allocated (using 2) → Pro-variant with 3 allocated. Verify no overage, usage carries over.
- **immediate-switch: pro with allocated, at limit, to pro-variant** — Pro with 3 allocated (using 3) → Pro-variant with 3 allocated. Verify usage carries over.
### Included usage changes (pro → premium with higher limit)
- **immediate-switch: pro with allocated, under limit, to premium with higher limit** — Pro with 3 allocated (using 2) → Premium with 5 allocated. Verify no overage charge, usage carries over.
- **immediate-switch: pro with allocated, over limit, to premium with higher limit** — Pro with 3 allocated (using 5) → Premium with 10 allocated. Verify existing overage handled, usage carries over.
### Replaceable (TBD)
- **immediate-switch: allocated with replaceable entities (track negative), upgrade** — Error: "behavior undefined". Will implement later.
---
## immediate-switch-prepaid.test.ts
Upgrades involving prepaid features.
### No options passed
- **immediate-switch: free to pro with prepaid, no options** — Free → Pro with prepaid, no options passed. Verify quantity defaults to 0, only base price charged.
### Same config (quantity change only)
- **immediate-switch: pro with prepaid, increase quantity** — Pro with prepaid messages (2 packs) → same product with 5 packs. Verify refund old + charge new.
- **immediate-switch: pro with prepaid, decrease quantity** — Pro with prepaid (5 packs) → same with 2 packs. Verify credit issued.
### Billing units change
- **immediate-switch: prepaid billing units change (100 → 50)** — Pro prepaid (100 units/pack) → Premium prepaid (50 units/pack). Verify correct recalculation.
- **immediate-switch: prepaid billing units change (50 → 100)** — Pro prepaid (50 units/pack) → Premium prepaid (100 units/pack). Verify correct recalculation.
### Price change
- **immediate-switch: prepaid price increase** — Pro prepaid ($10/pack) → Premium prepaid ($15/pack). Verify correct charge difference.
- **immediate-switch: prepaid price decrease** — Pro prepaid ($15/pack) → Premium prepaid ($10/pack). Verify credit issued.
### Included usage change
- **immediate-switch: prepaid included usage increase** — Pro prepaid (0 included) → Premium prepaid (100 included). Verify correct handling.
- **immediate-switch: prepaid included usage decrease** — Pro prepaid (100 included) → Premium prepaid (0 included). Verify correct handling.
### Upcoming quantity (proration None)
- **immediate-switch: prepaid with upcoming_quantity populated, upgrade** — Pro with prepaid, decrease quantity with proration `None` (sets `upcoming_quantity`), then upgrade to premium. Verify correct handling of pending quantity change.
---
## immediate-switch-billing-interval.test.ts
Upgrades involving billing interval changes.
- **immediate-switch: monthly to annual** — Pro monthly → Pro annual. Verify correct charge for annual.
- **immediate-switch: monthly to monthly + annual** — Pro monthly → Pro with both monthly and annual components.
---
## immediate-switch-entities.test.ts
Multi-entity upgrade scenarios.
### Basic entity upgrades
- **immediate-switch: entity 1 free, entity 2 free, upgrade entity 2 to pro** — Two entities on free, upgrade one to pro. Verify independent states.
- **immediate-switch: entity 1 pro, entity 2 free, upgrade entity 2 to pro** — Mixed entity states, upgrade the free one.
- **immediate-switch: entity 1 pro, entity 2 pro, upgrade entity 2 to premium** — Both on pro, upgrade one to premium.
- **immediate-switch: entity 1 pro, entity 2 pro, upgrade entity 2 to pro annual** — Both on pro monthly, upgrade one to annual.
### Upgrade with scheduled downgrade
- **immediate-switch: entity 1 premium, entity 2 premium, downgrade entity 1 to pro, then upgrade entity 1 to growth** — Premium on both, downgrade one (scheduled), then upgrade that same entity. Verify scheduled downgrade is cancelled, growth is active.
- **immediate-switch: entity 1 premium, entity 2 premium, downgrade both to pro, upgrade entity 2 to growth** — Both scheduled for downgrade, upgrade one. Verify one still scheduled, one upgraded.
### Upgrade when cancel is scheduled
- **immediate-switch: entity 1 pro, entity 2 pro, cancel entity 1 (to free), upgrade entity 1 to premium** — Pro on both, cancel one (scheduled to free), then upgrade that entity to premium. Verify cancel is overridden, premium is active.
### Track and upgrade
- **immediate-switch: entity 1 pro, entity 2 pro, track usage on both, advance 2 weeks, upgrade entity 1 to premium** — Both on pro with tracked usage, mid-cycle upgrade one. Verify correct invoice at end of cycle (entity 2 overage + base prices).
---
## Summary
| File | Test Count |
|------|------------|
| `immediate-switch-basic.test.ts` | 7 |
| `immediate-switch-consumable.test.ts` | 1 |
| `immediate-switch-allocated.test.ts` | 6 |
| `immediate-switch-prepaid.test.ts` | 10 |
| `immediate-switch-billing-interval.test.ts` | 2 |
| `immediate-switch-entities.test.ts` | 8 |
| **Total** | **34** |

View File

@@ -1,51 +0,0 @@
# new-plan/ Test Cases
Covers attaching products when customer has **no existing product** for that group.
---
## attach-free.test.ts
- **new-plan: attach free product** — Free product with monthly messages. Verify balance, usage, no invoice.
- **new-plan: attach free with multiple features** — Free with messages + words + dashboard + unlimited. Verify all features present with correct balances.
Fixtures: `items.monthlyMessages()`, `items.monthlyWords()`, `items.dashboard()`, `items.unlimitedMessages()`, `products.base()`
---
## attach-paid.test.ts
- **new-plan: attach pro with mixed features** — Pro ($20/mo) with consumable words + prepaid messages + allocated users. Verify invoice = base + prepaid, all features correct.
- **new-plan: attach pro with allocated, create entities** — Pro with allocated users (3 included). Create 5 user entities via track. Verify users usage = 5, overage invoice created.
- **new-plan: attach base with prepaid messages, no options** — Base product with prepaid messages, attach without passing `options`. Expect error: "behavior undefined".
- **new-plan: attach pro with prepaid messages, no options** — Pro with prepaid messages, attach without passing `options`. Expect error: "behavior undefined".
- **new-plan: attach pro with prepaid messages, quantity 0** — Pro with prepaid messages, pass `options` with `quantity: 0`. Verify no prepaid charged, only base price.
Fixtures: `items.consumableMessages()`, `items.prepaidMessages()`, `items.allocatedUsers()`, `products.pro()`, `products.base()`
---
## attach-one-time.test.ts
- **new-plan: attach one-time purchase** — One-time product with prepaid messages. Verify invoice, balance added, no recurring subscription.
- **new-plan: attach one-time purchase twice** — Attach same one-time product twice. Verify balance is cumulative (not replaced).
- **new-plan: attach pro then one-time as main** — Attach pro, then attach one-time **without** `isAddOn`. Should replace pro (user forgot to toggle).
- **new-plan: attach one-time with quantity=0 for one feature** — One-time with messages (qty=100) + words (qty=0). Verify messages added, words not charged.
- **new-plan: attach one-time as add-on to pro** — Attach pro, then attach one-time with `isAddOn: true`. Verify both products exist, balances combined.
- **new-plan: attach one-time with multiple features** — One-time with messages + words + storage (all one-off). Verify all balances correct.
- **new-plan: attach one-time to entity** — Create entity, attach one-time to entity. Verify entity has balance, customer does not.
Fixtures: `items.oneOffMessages()`, `items.oneOffPrice()`, `products.oneOff()`
---
## attach-entities.test.ts
- **new-plan: create entity, attach pro to entity** — Create entity, attach pro to entity (not customer). Verify entity has product, customer does not.
- **new-plan: create 2 entities, attach pro to each** — Create 2 entities, attach pro to each. Verify independent balances, 2 separate subscriptions.
- **new-plan: attach pro to entity 1, advance 2 weeks, attach pro to entity 2** — Mid-cycle attach to second entity. Verify prorated billing for entity 2.
- **new-plan: attach pro annual to entity** — Attach annual product to entity. Verify correct billing interval.
- **new-plan: attach pro to customer, then pro to entity** — Attach pro to customer first, then attach pro to entity. Verify both have product independently.
- **new-plan: attach free to customer, then free to entity** — Attach free to customer first, then attach free to entity. Verify both have product independently.
Fixtures: `s.entities({ count: 2 })`, `products.pro()`, `products.proAnnual()`, `products.base()`

View File

@@ -1,120 +0,0 @@
# scheduled-switch/ Test Cases
Covers **downgrades** — when attaching a lower-tier product that takes effect at **end of billing cycle**.
---
## scheduled-switch-basic.test.ts
Basic downgrade scenarios.
- **scheduled-switch: pro to free** — Pro → Free. Verify pro is "canceling" (active with canceled_at), free is "scheduled". At cycle end, pro removed, free active.
- **scheduled-switch: premium to pro** — Premium ($50/mo) → Pro ($20/mo). Verify premium canceling, pro scheduled. At cycle end, premium removed, pro active.
- **scheduled-switch: premium to pro to free** — Premium → Pro (scheduled) → Free (scheduled). Verify pro scheduled is replaced by free scheduled. At cycle end, premium removed, free active.
- **scheduled-switch: premium to free to pro** — Premium → Free (scheduled) → Pro (upgrade, immediate). Verify scheduled downgrade cancelled, pro active immediately.
- **scheduled-switch: premium to pro, then upgrade to growth** — Premium → Pro (scheduled) → Growth (immediate). Verify scheduled pro is cancelled, growth active.
- **scheduled-switch: premium to free, then upgrade to pro** — Premium → Free (scheduled) → Pro (immediate). Verify scheduled free is cancelled, pro active.
- **scheduled-switch: premium annual + monthly to premium monthly** — Premium with annual + monthly components → Premium monthly only. Verify correct handling of mixed intervals on downgrade.
---
## scheduled-switch-prepaid.test.ts
Downgrades with prepaid quantities.
> **Key behavior:** Total prepaid quantity is preserved (rounded to new billing units).
> Example: 5 packs × 100 units = 500 units → new plan with 50 units/pack = 10 packs.
### Quantity handling
- **scheduled-switch: prepaid 5 packs to 2 packs (explicit options)** — Premium 5 packs (100 units/pack) → Pro 2 packs. Verify 2 packs on next cycle.
- **scheduled-switch: prepaid, no options passed in** — Premium 5 packs → Pro (no options). Verify total units preserved, quantity converted to new billing units.
- **scheduled-switch: prepaid, no options, different billing units** — Premium 5 packs (100 units/pack = 500 units) → Pro (50 units/pack). Verify 10 packs on next cycle.
- **scheduled-switch: prepaid to quantity 0** — Premium 5 packs → Pro with quantity: 0. Verify no prepaid charged on next cycle.
### Feature changes
- **scheduled-switch: prepaid to product without prepaid feature** — Premium with prepaid → Free (no prepaid). Verify balance lost at cycle end.
- **scheduled-switch: prepaid with different price per pack** — Premium ($15/pack) → Pro ($10/pack). Verify next cycle uses new price.
### Included usage change
- **scheduled-switch: prepaid included usage increase** — Premium prepaid (0 included) → Pro prepaid (100 included). Verify included usage changes on next cycle.
- **scheduled-switch: prepaid included usage decrease** — Premium prepaid (100 included) → Pro prepaid (0 included). Verify included usage changes on next cycle.
---
## scheduled-switch-consumable.test.ts
Downgrades with consumable features.
> **Note:** Consumable overage is charged at cycle end via invoice-created webhook. These tests verify the downgrade flow works correctly with consumable usage.
- **scheduled-switch: pro with consumable, usage under limit, to free** — Pro with consumable (used 50/100 included) → Free. Verify scheduled downgrade, no overage charged at cycle end.
- **scheduled-switch: pro with consumable, into overage, to free** — Pro with consumable (used 150/100, 50 overage) → Free. Verify overage charged at cycle end when downgrade completes.
- **scheduled-switch: premium with consumable overage, downgrade to pro** — Premium ($50/mo) with consumable (200 used, 100 overage) → Pro ($20/mo). Advance cycle. Verify overage billed to Premium, Pro active with balance reset. (from invoice-created-consumable-edge-cases.test.ts)
---
## scheduled-switch-allocated.test.ts
Downgrades with allocated (seat-based) features.
> **Note:** These cases have undefined behavior. Tests should throw error "behavior undefined" until we clarify how allocated seats are handled on scheduled downgrade.
- **scheduled-switch: pro with allocated, under limit, to free** — Pro with 5 allocated (using 3) → Free. Error: "behavior undefined". TBD: How are seats handled at cycle end?
- **scheduled-switch: pro with allocated, over limit, to free** — Pro with 5 allocated (using 7) → Free. Error: "behavior undefined". TBD: How is existing overage handled on downgrade?
---
## scheduled-switch-entities.test.ts
Multi-entity downgrade scenarios.
### Basic entity downgrades
- **scheduled-switch: entity 1 pro, entity 2 pro, downgrade entity 1 to free** — Both on pro, downgrade one. Verify entity 1 has pro canceling + free scheduled, entity 2 unchanged.
- **scheduled-switch: entity 1 pro, entity 2 pro, downgrade both to free** — Both on pro, downgrade both. Verify both have free scheduled. Advance cycle, verify both on free.
### Downgrade + upgrade on different entities simultaneously
- **scheduled-switch: entity 1 premium to pro, entity 2 pro to premium** — Premium on entity 1 → Pro (scheduled), Pro on entity 2 → Premium (immediate). Verify independent states.
- **scheduled-switch: entity 1 pro to premium, entity 2 premium to pro** — Pro on entity 1 → Premium (immediate), Premium on entity 2 → Pro (scheduled). Verify independent states.
### Change scheduled product (replace)
- **scheduled-switch: entity 1 & 2 premium, downgrade both to free, entity 2 changes to pro** — Premium on both → Free scheduled on both → Entity 2 changes scheduled to pro. Verify entity 1 has free scheduled, entity 2 has pro scheduled.
### Post-cycle upgrade
- **scheduled-switch: entity 1 premium to free, entity 2 premium to pro, advance cycle, upgrade entity 1 to premium** — After downgrade completes (entity 1 now free, entity 2 now pro), upgrade entity 1 back to premium.
- **scheduled-switch: entity 1 premiumAnnual to pro, entity 2 premium to pro, advance cycle, upgrade entity 2 to premium** — After monthly downgrade completes (entity 2 now pro), upgrade entity 2 back to premium. Entity 1 still has annual + scheduled pro.
### Chained downgrades
- **scheduled-switch: entity 1 premium, entity 2 premium, downgrade both to pro, then downgrade entity 1 to free** — Premium on both → Pro scheduled on both → Free scheduled on entity 1 (replaces pro). Verify entity 1 has free scheduled, entity 2 has pro scheduled.
---
## scheduled-switch-multi-interval.test.ts
Mixed billing interval scenarios (annual + monthly entities).
- **scheduled-switch: entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, advance monthly cycle** — Annual on entity 1, monthly on entity 2 → both scheduled for pro. Advance 1 month. Entity 1 still on premiumAnnual + pro scheduled (annual not ended), entity 2 now on pro.
- **scheduled-switch: entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, re-upgrade both** — Annual on entity 1, monthly on entity 2 → both scheduled for pro → both re-upgrade (premiumAnnual and premium). Verify scheduled downgrades cancelled, both back to original products.
- **scheduled-switch: entity 1 premiumAnnual, entity 2 premium, downgrade both to pro, advance full year** — Same setup but advance a full year to see the annual downgrade complete as well. Verify both entities now on pro.
---
## scheduled-switch-edge-cases.test.ts
Edge cases and complex scenarios.
- **scheduled-switch: multiple scheduled changes on same entity** — Growth → Free (scheduled) → Pro (replaces) → Premium (replaces) → Free (replaces). Verify each change replaces the previous scheduled product.
---
## Summary
| File | Test Count |
|------|------------|
| `scheduled-switch-basic.test.ts` | 7 |
| `scheduled-switch-prepaid.test.ts` | 8 |
| `scheduled-switch-consumable.test.ts` | 3 |
| `scheduled-switch-allocated.test.ts` | 2 |
| `scheduled-switch-entities.test.ts` | 8 |
| `scheduled-switch-multi-interval.test.ts` | 3 |
| `scheduled-switch-edge-cases.test.ts` | 1 |
| **Total** | **32** |

View File

@@ -1,312 +0,0 @@
# Trials Test Plan
Tests for free trial logic in attach operations.
---
## File Structure
| File | Test Count | Description |
|------|------------|-------------|
| `trials-basic.test.ts` | 5 | Basic trial attachment and states |
| `trials-conversion.test.ts` | 5 | Trial to paid conversion |
| `trials-cancel.test.ts` | 5 | Canceling trials (immediately, end-of-cycle) |
| `trials-upgrade.test.ts` | 4 | Upgrading while in trial |
| `trials-entities.test.ts` | 4 | Entity-scoped trials |
| `trials-payment-method.test.ts` | 4 | Card required vs not required |
**Total: 27 tests**
---
## Trial Product Types
| Type | Property | Description |
|------|----------|-------------|
| Card Required | `cardRequired: true` | Customer must have PM before trial starts |
| No Card Required | `cardRequired: false` | Customer can start trial without PM |
```typescript
// Card required trial (default for proWithTrial)
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem],
trialDays: 7,
cardRequired: true,
});
// No card required trial (default for baseWithTrial)
const freeTrial = products.baseWithTrial({
id: "free-trial",
items: [messagesItem],
trialDays: 14,
cardRequired: false,
});
```
---
## Test Details
### `trials-basic.test.ts` (5 tests)
| # | Test Name | Scenario | Key Assertions |
|---|-----------|----------|----------------|
| 1 | trial: attach product with trial | Attach proTrial | Product status = trialing, trialEndsAt correct |
| 2 | trial: features available during trial | Check entitlements during trial | All features accessible |
| 3 | trial: trial end date calculation | 7-day trial attached today | trialEndsAt = now + 7 days |
| 4 | trial: usage tracking during trial | Track usage during trial | Usage recorded, balance updated |
| 5 | trial: trial with prepaid features | Trial product with prepaid credits | Credits available during trial |
**Setup:**
```typescript
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem],
trialDays: 7,
cardRequired: true,
});
const { customerId, autumnV1 } = await initScenario({
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: addDays(new Date(), 7).getTime(),
toleranceMs: 60_000, // 1 minute tolerance
});
```
---
### `trials-conversion.test.ts` (5 tests)
| # | Test Name | Scenario | Key Assertions |
|---|-----------|----------|----------------|
| 1 | conversion: trial ends naturally | Advance clock past trial end | Status = active, invoice generated |
| 2 | conversion: remove trial early | Call remove trial action | Trial ends immediately, payment charged |
| 3 | conversion: trial ends without PM | No card trial ends | Product removed or checkout required |
| 4 | conversion: trial ends with failed PM | PM fails at conversion | Invoice open, action required |
| 5 | conversion: invoice amount after trial | Trial ends, verify invoice | First invoice = full price (no proration) |
**Conversion Pattern:**
```typescript
const { customerId, autumnV1, ctx } = await initScenario({
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
// Advance past trial (7 days + buffer)
const advancedTo = await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: ctx.testClockId,
currentEpochMs: addDays(new Date(), 7).getTime(),
});
// Verify conversion
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductNotTrialing({ customer, productId: proTrial.id, nowMs: advancedTo });
expectProductActive({ customer, productId: proTrial.id });
```
---
### `trials-cancel.test.ts` (5 tests)
| # | Test Name | Scenario | Key Assertions |
|---|-----------|----------|----------------|
| 1 | cancel-trial: immediately | Cancel trial immediately | Product removed, no invoice |
| 2 | cancel-trial: end-of-cycle (trial period) | Cancel during trial | Canceling status, removed at trial end |
| 3 | cancel-trial: uncancel during trial | Cancel then uncancel | Trial restored, same end date |
| 4 | cancel-trial: usage not charged | Cancel trial with usage | No overage charged |
| 5 | cancel-trial: verify no refund | Cancel free trial | No refund invoice (nothing charged) |
**Cancel Pattern:**
```typescript
// Cancel trial immediately
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: proTrial.id,
cancel_action: "cancel_immediately",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductNotPresent({ customer, productId: proTrial.id });
// Cancel at end of trial
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: proTrial.id,
cancel_action: "cancel_end_of_cycle",
});
expectProductCanceling({ customer, productId: proTrial.id });
expectProductTrialing({ customer, productId: proTrial.id }); // Still trialing until end
```
---
### `trials-upgrade.test.ts` (4 tests)
| # | Test Name | Scenario | Key Assertions |
|---|-----------|----------|----------------|
| 1 | upgrade-trial: trial to trial | Pro trial → Premium trial | New trial starts, original trial replaced |
| 2 | upgrade-trial: trial to paid | Trial → paid (no trial) | Trial ends, paid immediately |
| 3 | upgrade-trial: trial to free | Trial → free product | Trial ends, free product attached |
| 4 | upgrade-trial: preserve trial days | Upgrade mid-trial | Remaining trial days preserved (if configured) |
**Upgrade Pattern:**
```typescript
const proTrial = products.proWithTrial({ id: "pro-trial", items: [...], trialDays: 14 });
const premiumTrial = products.premiumWithTrial({ id: "premium-trial", items: [...], trialDays: 14 });
// Attach pro trial
await autumnV1.attach({ customer_id: customerId, product_id: proTrial.id });
// Upgrade to premium trial after 7 days
await advanceTestClock({ ... addDays(7) });
await autumnV1.attach({ customer_id: customerId, product_id: premiumTrial.id });
// Verify new trial
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectProductTrialing({ customer, productId: premiumTrial.id });
expectProductNotPresent({ customer, productId: proTrial.id });
```
---
### `trials-entities.test.ts` (4 tests)
| # | Test Name | Scenario | Key Assertions |
|---|-----------|----------|----------------|
| 1 | entity-trial: new entity starts trial | Entity1 with trial | Entity1 trialing |
| 2 | entity-trial: second entity mid-trial | Entity2 joins while Entity1 in trial | Entity2 starts own trial |
| 3 | entity-trial: entity trial conversion | Advance past entity trial end | Entity-level invoice generated |
| 4 | entity-trial: cancel one entity trial | Cancel Entity1 trial | Entity2 trial unaffected |
**Entity Pattern:**
```typescript
const { customerId, autumnV1 } = await initScenario({
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
s.entities({ ids: ["entity-1", "entity-2"] }),
],
actions: [
s.attach({ productId: proTrial.id, entityId: "entity-1" }),
],
});
// Entity-1 trialing
const entity1 = await autumnV1.entities.get<ApiEntityV0>(customerId, "entity-1");
expectProductTrialing({ customer: entity1, productId: proTrial.id });
// Entity-2 not attached yet
const entity2 = await autumnV1.entities.get<ApiEntityV0>(customerId, "entity-2");
expect(entity2.products.length).toBe(0);
```
---
### `trials-payment-method.test.ts` (4 tests)
| # | Test Name | Scenario | Key Assertions |
|---|-----------|----------|----------------|
| 1 | pm-trial: card required - no PM | Attach cardRequired trial without PM | Error or checkout required |
| 2 | pm-trial: card required - with PM | Attach cardRequired trial with PM | Trial starts successfully |
| 3 | pm-trial: no card required - start | Attach no-card trial without PM | Trial starts without PM |
| 4 | pm-trial: no card required - conversion | No-card trial ends | Checkout required to continue |
**Payment Method Pattern:**
```typescript
// Card required - needs PM
const cardRequiredTrial = products.proWithTrial({
items: [...],
trialDays: 7,
cardRequired: true,
});
// No card required - no PM needed
const noCardTrial = products.baseWithTrial({
items: [...],
trialDays: 7,
cardRequired: false,
});
// Without PM - cardRequired fails, noCard succeeds
const { customerId, autumnV1 } = await initScenario({
setup: [
s.customer({ testClock: true }), // No payment method
s.products({ list: [cardRequiredTrial, noCardTrial] }),
],
});
// This should require checkout or fail
const result1 = await autumnV1.attach({
customer_id: customerId,
product_id: cardRequiredTrial.id,
});
expect(result1.checkout_url).toBeDefined();
// This should succeed
await autumnV1.attach({
customer_id: customerId,
product_id: noCardTrial.id,
});
```
---
## Key Utilities
**Product Fixtures:**
```typescript
products.proWithTrial({ items, trialDays, cardRequired })
products.premiumWithTrial({ items, trialDays, cardRequired })
products.baseWithTrial({ items, trialDays, cardRequired })
products.defaultTrial({ items, trialDays, cardRequired })
```
**Expectation Helpers:**
```typescript
expectProductTrialing({
customer,
productId,
trialEndsAt, // Expected trial end timestamp
toleranceMs, // Tolerance for date comparison (default 60000)
});
expectProductNotTrialing({
customer,
productId,
nowMs, // Current time to compare against
});
```
**Test Clock Advancement:**
```typescript
// Advance to end of trial
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addDays(new Date(), trialDays).getTime(),
waitForSeconds: 30,
});
// Or use advanceToNextInvoice for full cycle
await advanceToNextInvoice({
stripeCli,
testClockId,
currentEpochMs,
});
```

View File

@@ -169,6 +169,7 @@ export const expectSubToBeCorrect = async ({
shouldBeTrialing?: boolean;
flags?: {
checkNotTrialing?: boolean;
checkTrialing?: boolean;
};
subId?: string;
rewards?: string[];
@@ -429,6 +430,10 @@ export const expectSubToBeCorrect = async ({
expect(sub.status).not.toBe("trialing");
}
if (flags?.checkTrialing) {
expect(sub.status).toBe("trialing");
}
// Should be canceled
const cusSubShouldBeCanceled = cusProducts.every((cp) => {
if (cp.subscription_ids?.includes(subId!)) {

View File

@@ -168,7 +168,7 @@ export const advanceToNextInvoice = async ({
stripeCli,
testClockId,
advanceTo: addMonths(baseTime, 1).getTime(),
waitForSeconds: 45,
waitForSeconds: 30,
});
await advanceTestClock({

View File

@@ -1,5 +1,4 @@
import { z } from "zod/v4";
import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels.js";
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels.js";
import { BillingParamsBaseSchema } from "../common/billingParamsBase.js";
@@ -16,8 +15,6 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseSchema.extend({
finalize_invoice: z.boolean().optional(),
// Product config
options: z.array(FeatureOptionsSchema).nullish(),
version: z.number().optional(),
// Checkout behavior
redirect_mode: RedirectModeSchema.default("always"),

View File

@@ -1,3 +1,6 @@
import { FreeTrialParamsV0Schema } from "@api/billing/common/freeTrial/freeTrialParamsV0.js";
import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels.js";
import { ProductItemSchema } from "@models/productV2Models/productItemModels/productItemModels.js";
import { z } from "zod/v4";
import { CustomerDataSchema } from "../../common/customerData.js";
import { EntityDataSchema } from "../../common/entityData.js";
@@ -7,6 +10,12 @@ export const BillingParamsBaseSchema = z.object({
entity_id: z.string().nullish(),
customer_data: CustomerDataSchema.optional(),
entity_data: EntityDataSchema.optional(),
// Used for both update and attach
options: z.array(FeatureOptionsSchema).nullish(),
version: z.number().optional(),
free_trial: FreeTrialParamsV0Schema.nullable().optional(),
items: z.array(ProductItemSchema).optional(),
});
export type BillingParamsBase = z.infer<typeof BillingParamsBaseSchema>;

View File

@@ -0,0 +1,10 @@
import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTrialEnums.js";
import { z } from "zod/v4";
export const FreeTrialParamsV0Schema = z.object({
length: z.number(),
duration: z.enum(FreeTrialDuration),
card_required: z.boolean().default(true),
});
export type FreeTrialParamsV0 = z.infer<typeof FreeTrialParamsV0Schema>;

View File

@@ -1,9 +1,6 @@
import { RefundBehaviorSchema } from "@api/billing/common/refundBehavior";
import { CreateFreeTrialSchema } from "@models/productModels/freeTrialModels/freeTrialModels";
import { nullish } from "@utils/utils";
import { z } from "zod/v4";
import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels";
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels";
import { BillingBehaviorSchema } from "../common/billingBehavior";
import { BillingParamsBaseSchema } from "../common/billingParamsBase";
import { CancelActionSchema } from "../common/cancelAction";
@@ -16,12 +13,8 @@ export const ExtUpdateSubscriptionV0ParamsSchema =
invoice: z.boolean().optional(),
enable_product_immediately: z.boolean().optional(),
finalize_invoice: z.boolean().optional(),
options: z.array(FeatureOptionsSchema).nullish(), // used for update quantity etc (in api - feature_quantities)
// New
version: z.number().optional(),
items: z.array(ProductItemSchema).optional(), // used for custom configuration of a plan (in api - plan_override)
free_trial: CreateFreeTrialSchema.nullable().optional(),
// Cancel action: 'cancel_immediately' | 'cancel_end_of_cycle' | 'uncancel'
cancel_action: CancelActionSchema.optional(),