finished trial tests and implementation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -8,5 +8,7 @@ set -e
|
||||
|
||||
BUN_PARALLEL_V2 \
|
||||
'attach/immediate-switch' \
|
||||
'attach/scheduled-switch' \
|
||||
'attach/free-trial' \
|
||||
# 'attach/new-plan'
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
3
server/src/external/autumn/autumnCli.ts
vendored
3
server/src/external/autumn/autumnCli.ts
vendored
@@ -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,
|
||||
|
||||
@@ -55,7 +55,6 @@ export async function attach({
|
||||
ctx,
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
params,
|
||||
});
|
||||
|
||||
// 4. Evaluate Stripe billing plan (handles checkout mode internally)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { applyProductTrialConfig } from "./applyProductTrialConfig";
|
||||
export { handleFreeTrialParam } from "./handleFreeTrialParam";
|
||||
export { inheritTrialFromCustomerProduct } from "./inheritTrialFromCustomerProduct";
|
||||
export { inheritTrialFromSubscription } from "./inheritTrialFromSubscription";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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** |
|
||||
@@ -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()`
|
||||
@@ -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** |
|
||||
@@ -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,
|
||||
});
|
||||
```
|
||||
@@ -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!)) {
|
||||
|
||||
@@ -168,7 +168,7 @@ export const advanceToNextInvoice = async ({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
advanceTo: addMonths(baseTime, 1).getTime(),
|
||||
waitForSeconds: 45,
|
||||
waitForSeconds: 30,
|
||||
});
|
||||
|
||||
await advanceTestClock({
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
10
shared/api/billing/common/freeTrial/freeTrialParamsV0.ts
Normal file
10
shared/api/billing/common/freeTrial/freeTrialParamsV0.ts
Normal 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>;
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user