wip: trial tests

This commit is contained in:
John Yeo
2026-01-11 11:01:19 +00:00
parent 4d1c0e5176
commit d78cdc9d5d
45 changed files with 1819 additions and 704 deletions

View File

@@ -1,14 +1,23 @@
import { notNullish } from "@autumn/shared";
import type Stripe from "stripe";
/** Stripe subscription that is trialing with guaranteed trial_end */
export type TrialingStripeSubscription = Stripe.Subscription & {
status: "trialing";
trial_end: number;
};
/**
* Checks if a Stripe subscription is in the trialing status.
* @param stripeSubscription - The Stripe subscription to check.
* @returns True if the subscription is in the trialing status, false otherwise.
* Type guard that narrows to TrialingStripeSubscription with defined trial_end.
*/
export const isStripeSubscriptionTrialing = (
stripeSubscription: Stripe.Subscription,
) => {
stripeSubscription?: Stripe.Subscription,
): stripeSubscription is TrialingStripeSubscription => {
if (!stripeSubscription) {
return false;
}
return stripeSubscription.status === "trialing";
};

View File

@@ -4,7 +4,7 @@ import {
type FreeTrial,
type FullCusProduct,
findCusPriceByFeature,
isCusProductTrialing,
isCustomerProductTrialing,
type ProductItem,
priceToInvoiceAmount,
UsageModel,
@@ -64,7 +64,8 @@ export const getOptions = ({
if (
(freeTrial ||
(cusProduct && isCusProductTrialing({ cusProduct, now }))) &&
(cusProduct &&
isCustomerProductTrialing(cusProduct, { nowMs: now }))) &&
notNullish(i.interval)
) {
priceData = {

View File

@@ -19,7 +19,7 @@ export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
export interface TrialContext {
freeTrial?: FreeTrial | null;
trialEndsAt?: number;
trialEndsAt: number | null;
customFreeTrial?: FreeTrial;
}

View File

@@ -1,10 +1,12 @@
import {
cp,
cusProductToLineItems,
type FullCusProduct,
filterUnchangedPricesFromLineItems,
type LineItem,
} from "@autumn/shared";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { billingContextHasTrial } from "@/internal/billing/v2/utils/billingContext/billingContextHasTrial";
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
export const buildAutumnLineItems = ({
@@ -33,7 +35,13 @@ export const buildAutumnLineItems = ({
// })
// Get line items for ongoing cus product
const deletedLineItems = deletedCustomerProduct
const { valid: isTrialing } = cp(deletedCustomerProduct).trialing({
nowMs: currentEpochMs,
});
const shouldRefundLineItems = deletedCustomerProduct && !isTrialing;
const deletedLineItems = shouldRefundLineItems
? cusProductToLineItems({
cusProduct: deletedCustomerProduct,
nowMs: currentEpochMs,
@@ -64,11 +72,29 @@ export const buildAutumnLineItems = ({
});
// All items
const allLineItems = [
let allLineItems = [
...filteredDeletedLineItems,
...arrearLineItems,
...filteredNewLineItems,
];
// If trialing, don't apply free trial?
if (billingContextHasTrial({ billingContext })) {
allLineItems = [
...filteredDeletedLineItems,
...arrearLineItems,
...filteredNewLineItems,
].map((item) => ({ ...item, amount: 0, finalAmount: 0 }));
}
console.log(
"All line items: ",
allLineItems.map((item) => ({
description: item.description,
amount: item.amount,
finalAmount: item.finalAmount,
})),
);
return allLineItems;
};

View File

@@ -0,0 +1,70 @@
import {
type FullCusProduct,
type FullProduct,
isCustomerProductFree,
isCustomerProductOneOff,
isCustomerProductTrialing,
isFreeProduct,
isOneOffProduct,
secondsToMs,
} from "@autumn/shared";
import type Stripe from "stripe";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { TrialContext } from "@/internal/billing/v2/billingContext";
/**
* Determine the billing cycle anchor based on product transitions.
*/
export const setupBillingCycleAnchor = ({
stripeSubscription,
customerProduct,
newFullProduct,
trialContext,
currentEpochMs,
}: {
stripeSubscription?: Stripe.Subscription;
customerProduct?: FullCusProduct;
newFullProduct: FullProduct;
trialContext?: TrialContext;
currentEpochMs: number;
}): number | "now" => {
const currentIsFree = isCustomerProductFree(customerProduct);
const newIsFree = isFreeProduct({ prices: newFullProduct.prices });
// Free -> Free: keep original anchor
if (currentIsFree && newIsFree) {
return customerProduct?.created_at ?? "now";
}
const currentIsOneOff = isCustomerProductOneOff(customerProduct);
const newIsOneOff = isOneOffProduct({ prices: newFullProduct.prices });
// One-off -> One-off: keep original anchor
if (currentIsOneOff && newIsOneOff) {
return customerProduct?.created_at ?? "now";
}
// If trialing:
const stripeTrialEndsAtMs = isStripeSubscriptionTrialing(stripeSubscription)
? secondsToMs(stripeSubscription?.trial_end)
: undefined;
const currentCustomerProductTrialEndsAtMs = isCustomerProductTrialing(
customerProduct,
{ nowMs: currentEpochMs },
)
? customerProduct?.trial_ends_at
: undefined;
const currentTrialEndsAt =
stripeTrialEndsAtMs ?? currentCustomerProductTrialEndsAtMs;
const newIsTrialing =
(trialContext?.trialEndsAt && trialContext.trialEndsAt > currentEpochMs) ??
stripeTrialEndsAtMs;
// Billing cycle anchor = trial ends at if exists
if (newIsTrialing) return trialContext?.trialEndsAt ?? "now";
return secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now";
};

View File

@@ -6,6 +6,7 @@ import type {
import {
addDuration,
initFreeTrial,
isCustomerProductTrialing,
isProductPaidAndRecurring,
secondsToMs,
} from "@autumn/shared";
@@ -25,12 +26,12 @@ export const setupTrialContext = ({
currentEpochMs: number;
params: UpdateSubscriptionV0Params;
fullProduct: FullProduct;
}): TrialContext => {
}): TrialContext | undefined => {
const freeTrialParams = params.free_trial;
// Case 1: If free trial is null (removing free trial)
if (freeTrialParams === null) {
return { freeTrial: null };
return { freeTrial: null, trialEndsAt: null };
}
// Case 2: If free trial params are passed in
@@ -65,17 +66,20 @@ export const setupTrialContext = ({
return {
freeTrial: null,
trialEndsAt,
trialEndsAt: trialEndsAt,
};
} else {
return undefined;
}
return {
freeTrial: null,
};
}
// Case 4: Return free trial / trial ends at from current customer product
if (isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs })) {
return {
freeTrial: customerProduct.free_trial,
trialEndsAt: customerProduct.trial_ends_at ?? undefined,
freeTrial: customerProduct.free_trial, // can be undefined...
trialEndsAt: customerProduct.trial_ends_at ?? null,
};
}
return undefined;
};

View File

@@ -1,4 +1,8 @@
import type { FullCusProduct, FullProduct } from "@autumn/shared";
import {
type FullCusProduct,
type FullProduct,
formatMs,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
@@ -36,7 +40,7 @@ export const computeCustomPlanNewCustomerProduct = ({
cusProduct: customerProduct,
});
console.log("Existing usages", existingUsages);
console.log("Reset cycle anchor: ", formatMs(resetCycleAnchorMs));
// Compute the new full customer product
const newFullCustomerProduct = initFullCustomerProduct({

View File

@@ -1,6 +1,7 @@
import { secondsToMs, type UpdateSubscriptionV0Params } from "@autumn/shared";
import { formatMs, type UpdateSubscriptionV0Params } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
@@ -64,19 +65,17 @@ export const setupUpdateSubscriptionBillingContext = async ({
fullProduct,
});
// 2. Initial billing cycle anchor from Stripe subscription
let billingCycleAnchorMs: number | "now" =
secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now";
// 3. Determine final anchor based on product transitions
billingCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs,
let billingCycleAnchorMs = setupBillingCycleAnchor({
stripeSubscription,
customerProduct,
newFullProduct: fullProduct,
trialContext,
currentEpochMs,
});
// 4. Trial ends at overrides reset cycle anchor
if (trialContext.trialEndsAt) {
if (trialContext?.trialEndsAt) {
billingCycleAnchorMs = trialContext.trialEndsAt;
}
@@ -86,6 +85,10 @@ export const setupUpdateSubscriptionBillingContext = async ({
newFullProduct: fullProduct,
});
console.log("Billing cycle anchor: ", formatMs(billingCycleAnchorMs));
console.log("Trial ends at: ", formatMs(trialContext?.trialEndsAt));
console.log("Reset cycle anchor: ", formatMs(resetCycleAnchorMs));
const invoiceMode = setupInvoiceModeContext({ params });
return {

View File

@@ -0,0 +1,20 @@
import type { BillingContext } from "@/internal/billing/v2/billingContext";
/**
* Check if the billing context will create a trial that ends later than the current epoch.
* @param billingContext - The billing context.
* @returns True if the billing context has a trial, false otherwise.
*/
export const billingContextHasTrial = ({
billingContext,
}: {
billingContext: BillingContext;
}) => {
const { currentEpochMs, trialContext } = billingContext;
if (trialContext?.trialEndsAt && trialContext.trialEndsAt > currentEpochMs) {
return true;
}
return false;
};

View File

@@ -0,0 +1,63 @@
import {
type BillingPreviewResponse,
cp,
cusProductsToPrices,
cusProductToLineItems,
getCycleEnd,
getSmallestInterval,
sumValues,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
export const billingPlanToNextCyclePreview = ({
ctx,
billingContext,
billingPlan,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
billingPlan: BillingPlan;
}): BillingPreviewResponse["next_cycle"] => {
// 1. Return undefined if billing cycle anchor is now
const { billingCycleAnchorMs } = billingContext;
if (billingCycleAnchorMs === "now") return undefined;
const { insertCustomerProducts } = billingPlan.autumn;
// 2. Get cycle end and if none, return undefined
const customerProducts = insertCustomerProducts.filter(
(customerProduct) => cp(customerProduct).paid().recurring().valid,
);
const prices = cusProductsToPrices({ cusProducts: customerProducts });
const smallestInterval = getSmallestInterval({ prices });
if (!smallestInterval) return undefined;
const nextCycleStart = getCycleEnd({
anchor: billingCycleAnchorMs,
interval: smallestInterval.interval,
intervalCount: smallestInterval.intervalCount,
now: billingContext.currentEpochMs,
});
const autumnLineItems = customerProducts.flatMap((customerProduct) =>
cusProductToLineItems({
cusProduct: customerProduct,
nowMs: nextCycleStart,
billingCycleAnchorMs,
direction: "charge",
org: ctx.org,
logger: ctx.logger,
}),
);
const total = sumValues(autumnLineItems.map((line) => line.finalAmount));
return {
starts_at: nextCycleStart,
total,
};
};

View File

@@ -7,6 +7,7 @@ import { Decimal } from "decimal.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { billingPlanToNextCyclePreview } from "./billingPlan/billingPlanToNextCyclePreview";
export const billingPlanToPreviewResponse = ({
ctx,
@@ -20,7 +21,9 @@ export const billingPlanToPreviewResponse = ({
const { fullCustomer } = billingContext;
const autumnBillingPlan = billingPlan.autumn;
const previewImmediateLineItems = autumnBillingPlan.lineItems.filter((line) => line.chargeImmediately).map((line) => ({
const previewImmediateLineItems = autumnBillingPlan.lineItems
.filter((line) => line.chargeImmediately)
.map((line) => ({
description: line.description,
amount: line.finalAmount,
}));
@@ -33,10 +36,18 @@ export const billingPlanToPreviewResponse = ({
const currency = orgToCurrency({ org: ctx.org });
// Get next cycle object
const nextCycle = billingPlanToNextCyclePreview({
ctx,
billingContext,
billingPlan,
});
return {
customer_id: fullCustomer.id || "",
line_items: previewImmediateLineItems,
total,
currency,
next_cycle: nextCycle,
} satisfies BillingPreviewResponse;
};

View File

@@ -4,7 +4,7 @@ import {
type AttachFunctionResponse,
AttachFunctionResponseSchema,
AttachScenario,
isCusProductTrialing,
isCustomerProductTrialing,
MetadataType,
SuccessCode,
} from "@autumn/shared";
@@ -86,9 +86,8 @@ export const handlePaidProduct = async ({
if (mergeSub && !config.disableMerge) {
if (mergeCusProduct?.free_trial) {
trialEndsAt = isCusProductTrialing({
cusProduct: mergeCusProduct,
now: attachParams.now,
trialEndsAt = isCustomerProductTrialing(mergeCusProduct, {
nowMs: attachParams.now,
})
? mergeCusProduct.trial_ends_at
: undefined;

View File

@@ -5,7 +5,7 @@ import {
AttachFunctionResponseSchema,
AttachScenario,
CusProductStatus,
isCusProductTrialing,
isCustomerProductTrialing,
SuccessCode,
} from "@autumn/shared";
import type Stripe from "stripe";
@@ -188,8 +188,7 @@ export const handleMultiAttachFlow = async ({
logger,
productOptions,
trialEndsAt:
mergeCusProduct &&
isCusProductTrialing({ cusProduct: mergeCusProduct })
mergeCusProduct && isCustomerProductTrialing(mergeCusProduct)
? mergeCusProduct?.trial_ends_at || undefined
: undefined,
}),

View File

@@ -4,7 +4,7 @@ import {
type FullCustomer,
formatAmount,
getTotalCusProdQuantity,
isCusProductTrialing,
isCustomerProductTrialing,
isFixedPrice,
type Organization,
type Price,
@@ -66,7 +66,7 @@ export const priceToUnusedPreviewItem = ({
anchor?: number;
}) => {
now = now || Date.now();
const onTrial = isCusProductTrialing({ cusProduct, now });
const onTrial = isCustomerProductTrialing(cusProduct, { nowMs: now });
const subItem = findStripeItemForPrice({
price,

View File

@@ -1,4 +1,4 @@
import { type FullCusProduct, isCusProductTrialing } from "@autumn/shared";
import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared";
import type Stripe from "stripe";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { subToAutumnInterval } from "@/external/stripe/utils.js";
@@ -66,9 +66,7 @@ export const isMainTrialBranch = ({
attachParams: AttachParams;
}) => {
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
if (
!isCusProductTrialing({ cusProduct: curCusProduct!, now: attachParams.now })
)
if (!isCustomerProductTrialing(curCusProduct!, { nowMs: attachParams.now }))
return false;
const subId = curCusProduct?.subscription_ids?.[0];

View File

@@ -3,7 +3,7 @@ import {
type AttachConfig,
BillingInterval,
type FullProduct,
isCusProductTrialing,
isCustomerProductTrialing,
} from "@autumn/shared";
import { getOptions } from "@/internal/api/check/checkUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
@@ -114,9 +114,8 @@ export const getNewProductPreview = async ({
if (mergeSub && !config.disableMerge) {
if (mergeCusProduct?.free_trial) {
if (
isCusProductTrialing({
cusProduct: mergeCusProduct,
now: attachParams.now,
isCustomerProductTrialing(mergeCusProduct, {
nowMs: attachParams.now,
})
) {
trialEnds = mergeCusProduct.trial_ends_at || undefined;

View File

@@ -5,7 +5,7 @@ import {
cusProductToProduct,
type FreeTrial,
type FullCusProduct,
isCusProductTrialing,
isCustomerProductTrialing,
isPrepaidPrice,
OnDecrease,
OnIncrease,
@@ -53,7 +53,7 @@ const getNextCycleAt = ({
if (
branch === AttachBranch.NewVersion &&
curCusProduct &&
isCusProductTrialing({ cusProduct: curCusProduct, now })
isCustomerProductTrialing(curCusProduct, { nowMs: now })
) {
return curCusProduct.trial_ends_at;
}
@@ -170,7 +170,7 @@ export const getUpgradeProductPreview = async ({
if (
config?.carryTrial &&
curCusProduct?.free_trial &&
isCusProductTrialing({ cusProduct: curCusProduct, now })
isCustomerProductTrialing(curCusProduct, { nowMs: now })
) {
freeTrial = curCusProduct.free_trial;
}

View File

@@ -8,7 +8,7 @@ import {
expandIncludes,
type FullCusProduct,
type FullCustomer,
isCusProductTrialing,
isCustomerProductTrialing,
type Subscription,
} from "@autumn/shared";
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
@@ -93,7 +93,7 @@ export const getApiSubscription = async ({
canceled_at: cusProduct.canceled_at || null,
expires_at: cusProduct.ended_at || null,
trial_ends_at: isCusProductTrialing({ cusProduct })
trial_ends_at: isCustomerProductTrialing(cusProduct)
? cusProduct.trial_ends_at
: null,
started_at: cusProduct.starts_at,

View File

@@ -408,6 +408,158 @@ await expectProductActive({ customer, productId: pro.id });
**Note:** "Canceling" means the product is still active and usable, but is scheduled to end at the next billing cycle.
## Trial Testing Utilities
### Checking Product Trial State
Use `expectProductTrialing` and `expectProductNotTrialing` to verify trial state:
```typescript
import {
expectProductTrialing,
expectProductNotTrialing,
expectFeatureResetAlignedWithTrialEnd,
} from "@tests/billing/utils/expectCustomerProductTrialing";
// Verify product is trialing and get trial end time
// Verify product is trialing with expected trial end (10 min tolerance)
const trialEndsAt = await expectProductTrialing({
customer,
productId: product.id,
trialEndsAt: Date.now() + ms.days(7), // Expected trial end
});
// Or check against a previously captured timestamp
await expectProductTrialing({
customer,
productId: product.id,
trialEndsAt: initialTrialEnd,
});
// Verify product is NOT trialing
await expectProductNotTrialing({
customer,
productId: product.id,
});
// Verify feature reset aligns with trial end
await expectFeatureResetAlignedWithTrialEnd({
customer,
featureId: TestFeature.Messages,
trialEndsAt: trialEndsAt!,
});
```
### Checking Preview next_cycle Field
Use `expectPreviewNextCycleCorrect` to verify the `next_cycle` field in subscription update previews:
```typescript
import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect";
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// For paid products: check next_cycle is set with expected values
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(7), // Expected offset from now (1 day tolerance)
total: priceItem.price!, // Expected total in dollars
});
// For free-to-free updates: next_cycle should NOT be defined
expectPreviewNextCycleCorrect({
preview,
expectDefined: false,
});
```
**Note:** Free-to-free updates don't have `next_cycle` since there's no billing cycle.
### Feature Assertions with Reset Time
Use `resetsAt` in `expectCustomerFeatureCorrect` to verify the reset cycle anchor:
```typescript
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 200,
usage: 0,
resetsAt: initialResetAt, // Verify reset time hasn't changed (10 min tolerance)
});
```
### Common Trial Test Patterns
```typescript
// 1. Get initial state before update
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const initialTrialEnd = await expectProductTrialing({
customer: customerBefore,
productId: product.id,
});
const initialResetAt = customerBefore.features[TestFeature.Messages].next_reset_at;
// 2. Advance time mid-trial
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 5,
});
// 3. Perform update and verify preview
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(9), // 14 - 5 = 9 days remaining
total: priceItem.price!,
});
// 4. Execute update
await autumnV1.subscriptions.update(updateParams);
// 5. Verify trial preserved/extended/removed
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const newTrialEnd = await expectProductTrialing({
customer,
productId: product.id,
});
expect(Math.abs(newTrialEnd! - initialTrialEnd!)).toBeLessThan(ms.minutes(5));
```
## Free-to-Free Tests Don't Need Subscription Checks
When testing free-to-free product updates, **skip `expectSubToBeCorrect`** since there's no Stripe subscription for free products:
```typescript
// ✅ GOOD - Free-to-free test, no subscription check needed
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 200,
usage: 0,
});
// No expectSubToBeCorrect needed for free products
// ✅ GOOD - Free-to-paid test, subscription check needed
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
```
**When to use `expectSubToBeCorrect`:**
- Free-to-paid upgrades
- Paid-to-paid updates
- Any scenario involving Stripe subscriptions
**When to skip:**
- Free-to-free updates (no Stripe subscription exists)
## Common Pitfalls
### Wait for Sync Before Attach (after Track)

View File

@@ -1,266 +0,0 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/billing/utils/expectCustomerProductTrialing";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
/**
* Free-to-Paid with Trial Tests
*
* Tests for scenarios starting from free products and upgrading to paid with trial.
* Uses `status === "trialing"` and `current_period_end` to verify trial state.
*/
// 1. Free to paid with `free_trial` param
test.concurrent(`${chalk.yellowBright("f2p-trial: add paid with free_trial param")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "f2p-trial-param",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Track some usage before update
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 30,
},
{ timeout: 2000 },
);
const priceItem = items.monthlyPrice();
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [messagesItem, priceItem],
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Preview total should be 0 during trial
expect(preview.total).toEqual(0);
// Verify preview has trial info
expect(preview.autumn?.freeTrialPlan?.trialEndsAt).toBeDefined();
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is trialing (status = "trialing", current_period_end is trial end)
await expectProductTrialing({
customer,
productId: free.id,
trialEndsAfter: ms.days(6), // At least 6 days from now
trialEndsBefore: ms.days(8), // At most 8 days from now
});
// Usage should be preserved
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - 30,
usage: 30,
});
// No immediate charge during trial
expectCustomerInvoiceCorrect({
customer,
count: 1, // Just the $0 trial invoice
latestTotal: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 2. Free to paid, product has trial config in plan, update while trial ongoing
test.concurrent(`${chalk.yellowBright("f2p-trial: product with trial config, update mid-trial")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const proTrial = products.proWithTrial({
items: [messagesItem],
id: "pro-trial",
trialDays: 14,
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "f2p-trial-mid-update",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
// Verify initially trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: proTrial.id,
});
// Get initial trial end from current_period_end
const initialTrialEnd = customerBefore.products?.find(
(p) => p.id === proTrial.id,
)?.current_period_end;
expect(initialTrialEnd).toBeDefined();
// Update mid-trial - change included usage (no free_trial param = keep existing trial)
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const updateParams = {
customer_id: customerId,
product_id: proTrial.id,
items: [updatedMessagesItem, items.monthlyPrice()],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 during trial
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Trial should be preserved
await expectProductTrialing({
customer,
productId: proTrial.id,
});
// Verify current_period_end (trial end) is approximately the same (allow some variance)
const newTrialEnd = customer.products?.find(
(p) => p.id === proTrial.id,
)?.current_period_end;
expect(newTrialEnd).toBeDefined();
expect(Math.abs(newTrialEnd! - initialTrialEnd!)).toBeLessThan(60000); // Within 1 minute
// Feature updated
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage,
usage: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 3. Free to paid with trial, merging with existing subscription
test.concurrent(`${chalk.yellowBright("f2p-trial: merge with existing subscription")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const free = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1, ctx, entities } = await initScenario({
customerId: "f2p-trial-merge",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: pro.id, entityIndex: 0 }),
s.attach({ productId: free.id, entityIndex: 1 }),
],
});
// Verify entity 0 is on paid pro (not trialing)
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductActive({ customer: entity1, productId: pro.id });
await expectProductNotTrialing({ customer: entity1, productId: pro.id });
// Now upgrade entity 1 from free to paid with trial
const priceItem = items.monthlyPrice();
const updateParams = {
customer_id: customerId,
entity_id: entities[1].id,
product_id: free.id,
items: [messagesItem, priceItem],
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 during trial
expect(preview.total).toEqual(0);
// Verify preview has trial info
expect(preview.autumn?.freeTrialPlan?.trialEndsAt).toBeDefined();
await autumnV1.subscriptions.update(updateParams);
// Verify entity 1 is now trialing
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
customer: entity2,
productId: free.id,
trialEndsAfter: ms.days(6),
trialEndsBefore: ms.days(8),
});
// Entity 0 should still not be trialing
const entity1After = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductNotTrialing({
customer: entity1After,
productId: pro.id,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,495 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
/**
* Free Product with Trial Tests
*
* Tests for scenarios starting from free products with trials.
* Covers free-to-free updates (preserving/extending/removing trials) and free-to-paid upgrades.
* Uses `status === "trialing"` and `current_period_end` to verify trial state.
*/
// 1. Free to paid with `free_trial` param
test.concurrent(`${chalk.yellowBright("f2p-trial: add paid with free_trial param")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "f2p-trial-param",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Track some usage before update
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 30,
},
{ timeout: 2000 },
);
const priceItem = items.monthlyPrice();
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [messagesItem, priceItem],
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Preview total should be 0 during trial
expect(preview.total).toEqual(0);
// next_cycle should show when trial ends and what the charge will be
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(7),
total: priceItem.price!,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is trialing (status = "trialing", current_period_end is trial end)
await expectProductTrialing({
customer,
productId: free.id,
trialEndsAt: Date.now() + ms.days(7),
});
// Usage should be preserved
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - 30,
usage: 30,
});
// No immediate charge during trial
expectCustomerInvoiceCorrect({
customer,
count: 1, // Just the $0 trial invoice
latestTotal: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 2. Free product with trial, update mid-trial (no free_trial param) - trial preserved
test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial -> free, update mid-trial preserves trial")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
items: [messagesItem],
id: "free-trial",
trialDays: 14,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "f2p-trial-mid-update-preserve",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [freeWithTrial] }),
],
actions: [s.attach({ productId: freeWithTrial.id })],
});
// Verify initially trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const initialTrialEnd = await expectProductTrialing({
customer: customerBefore,
productId: freeWithTrial.id,
});
// Advance 5 days (mid-trial)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 5,
});
// Update mid-trial - change included usage (no free_trial param = keep existing trial)
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const updateParams = {
customer_id: customerId,
product_id: freeWithTrial.id,
items: [updatedMessagesItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 (still free product)
expect(preview.total).toEqual(0);
// Free-to-free updates don't have next_cycle
expectPreviewNextCycleCorrect({
preview,
expectDefined: false,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Trial should be preserved with same end date
await expectProductTrialing({
customer,
productId: freeWithTrial.id,
trialEndsAt: initialTrialEnd!,
});
// Feature updated, reset should align with trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage,
usage: 0,
});
// Note: Free-to-free tests don't need expectSubToBeCorrect (no Stripe subscription)
});
// 4. Free product with trial, update mid-trial WITH new free_trial param - trial extended
test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-trial extends trial")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
items: [messagesItem],
id: "free-trial",
trialDays: 14,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "f2p-trial-mid-update-extend",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [freeWithTrial] }),
],
actions: [s.attach({ productId: freeWithTrial.id })],
});
// Verify initially trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const initialTrialEnd = await expectProductTrialing({
customer: customerBefore,
productId: freeWithTrial.id,
});
// Advance 5 days (mid-trial)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 5,
});
// Update mid-trial WITH new free_trial param - extend to 30 days from now
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const updateParams = {
customer_id: customerId,
product_id: freeWithTrial.id,
items: [updatedMessagesItem],
free_trial: {
length: 30,
duration: FreeTrialDuration.Day,
card_required: false,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 (still free product)
expect(preview.total).toEqual(0);
// Free-to-free updates don't have next_cycle
expectPreviewNextCycleCorrect({
preview,
expectDefined: false,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Trial should be extended to 30 days from now
const newTrialEnd = await expectProductTrialing({
customer,
productId: freeWithTrial.id,
trialEndsAt: Date.now() + ms.days(35), // 5 days advanced + 30 day new trial
});
// New trial end should be later than original
expect(newTrialEnd!).toBeGreaterThan(initialTrialEnd!);
// Feature updated
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage,
usage: 0,
});
// Note: Free-to-free tests don't need expectSubToBeCorrect (no Stripe subscription)
});
// 5. Free product with trial, update mid-trial to PAID product
test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-trial to paid")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
items: [messagesItem],
id: "free-trial",
trialDays: 14,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "f2p-trial-mid-update-to-paid",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [freeWithTrial] }),
],
actions: [s.attach({ productId: freeWithTrial.id })],
});
// Verify initially trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: freeWithTrial.id,
});
// Advance 5 days (mid-trial)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 5,
});
// Update mid-trial to PAID product (add price item)
const priceItem = items.monthlyPrice();
const updateParams = {
customer_id: customerId,
product_id: freeWithTrial.id,
items: [messagesItem, priceItem],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge full price since trial doesn't carry over
expect(preview.total).toEqual(priceItem.price!);
// next_cycle should be ~1 month from now (regular billing cycle)
expectPreviewNextCycleCorrect({
preview,
expectDefined: false,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Trial should NOT carry over - product should no longer be trialing
await expectProductNotTrialing({
customer,
productId: freeWithTrial.id,
});
// Product should be active
await expectProductActive({
customer,
productId: freeWithTrial.id,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6. Free product (no trial) → Free product with trial, items undefined
test.concurrent(`${chalk.yellowBright("f2p-trial: free no trial -> free with trial, items undefined")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
items: [messagesItem],
id: "free-no-trial",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "f2p-trial-items-undefined",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: free.id })],
});
// Verify initially NOT trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer: customerBefore,
productId: free.id,
});
// Add trial without passing items (items undefined)
const updateParams = {
customer_id: customerId,
product_id: free.id,
// items is NOT specified (undefined)
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
card_required: false,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 (free product)
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Product should now be trialing
await expectProductTrialing({
customer,
productId: free.id,
trialEndsAt: Date.now() + ms.days(14),
});
// Feature should still have correct values (unchanged since items undefined)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage,
usage: 0,
});
// Note: Free-to-free tests don't need expectSubToBeCorrect (no Stripe subscription)
});
// 7. Free product with trial, update mid-trial WITH free_trial: null - trial removed
test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-trial removes trial")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeWithTrial = products.baseWithTrial({
items: [messagesItem],
id: "free-trial",
trialDays: 14,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "f2p-trial-mid-update-remove",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [freeWithTrial] }),
],
actions: [s.attach({ productId: freeWithTrial.id })],
});
// Verify initially trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: freeWithTrial.id,
});
// Advance 5 days (mid-trial)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 5,
});
// Update mid-trial WITH free_trial: null - remove trial
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const updateParams = {
customer_id: customerId,
product_id: freeWithTrial.id,
items: [updatedMessagesItem],
free_trial: null,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 (still free product)
expect(preview.total).toEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Product should no longer be trialing
await expectProductNotTrialing({
customer,
productId: freeWithTrial.id,
});
// Product should now be active
await expectProductActive({
customer,
productId: freeWithTrial.id,
});
// Feature updated
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage,
usage: 0,
});
// Note: Free-to-free tests don't need expectSubToBeCorrect (no Stripe subscription)
});

View File

@@ -7,6 +7,7 @@ import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
@@ -32,7 +33,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}`
trialDays: 14,
});
const { customerId, autumnV1, ctx } = await initScenario({
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "p2p-remove-trial-active",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
@@ -49,6 +50,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}`
productId: proTrial.id,
});
// Advance to mid-trial (7 days into 14-day trial)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 7,
});
// Remove the trial by passing free_trial: null
const updateParams = {
customer_id: customerId,
@@ -62,6 +70,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}`
// Should charge full price since trial is being removed
expect(preview.total).toEqual(20);
// When trial is removed, next_cycle should start in ~1 month (regular billing)
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(30),
total: items.monthlyPrice().price!,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
@@ -211,6 +226,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: trial carries over when undefi
// Should be 0 during trial
expect(preview.total).toEqual(0);
// next_cycle should align with existing trial (~14 days)
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
total: items.monthlyPrice().price!,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
@@ -269,8 +291,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial")
await expectProductTrialing({
customer: customerBefore,
productId: proTrial.id,
trialEndsAfter: ms.days(6),
trialEndsBefore: ms.days(8),
trialEndsAt: Date.now() + ms.days(7),
});
// Replace with a new 30-day trial
@@ -282,6 +303,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial")
length: 30,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
@@ -290,6 +312,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial")
// Should be 0 during trial
expect(preview.total).toEqual(0);
// next_cycle should show new 30-day trial end
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(30),
total: items.monthlyPrice().price!,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
@@ -298,8 +327,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial")
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAfter: ms.days(29), // At least 29 days from now
trialEndsBefore: ms.days(31), // At most 31 days from now
trialEndsAt: Date.now() + ms.days(30),
});
await expectSubToBeCorrect({
@@ -310,7 +338,87 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial")
});
});
// 5. New trial after old expired
// 5. Paid product (no trial) → Paid product with trial, items undefined
test.concurrent(`${chalk.yellowBright("p2p-trial: paid no trial -> paid with trial, items undefined")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice();
const pro = products.pro({
items: [messagesItem, priceItem],
id: "pro-no-trial",
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-trial-items-undefined",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Verify initially NOT trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer: customerBefore,
productId: pro.id,
});
// Add trial without passing items (items undefined)
const updateParams = {
customer_id: customerId,
product_id: pro.id,
// items is NOT specified (undefined)
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 during trial (trial being added)
expect(preview.total).toEqual(0);
// next_cycle should show when trial ends
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
total: priceItem.price!,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Product should now be trialing
await expectProductTrialing({
customer,
productId: pro.id,
trialEndsAt: Date.now() + ms.days(14),
});
// Feature should still have correct values (unchanged since items undefined)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage,
usage: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6. New trial after old expired
test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
@@ -357,6 +465,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")}
length: 14,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
@@ -365,6 +474,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")}
// Should be 0 during new trial
expect(preview.total).toEqual(0);
// next_cycle should show new 14-day trial end
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
total: items.monthlyPrice().price!,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
@@ -373,8 +489,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")}
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAfter: ms.days(13),
trialEndsBefore: ms.days(15),
trialEndsAt: Date.now() + ms.days(14),
});
await expectSubToBeCorrect({

View File

@@ -1,11 +1,12 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import {
expectFeatureResetAlignedWithTrialEnd,
expectPeriodEndsAlignedWithTrialEnd,
expectProductTrialing,
} from "@tests/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
@@ -62,10 +63,13 @@ test.concurrent(`${chalk.yellowBright("trial-qty: update prepaid quantity while
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
};
const _preview = await autumnV1.subscriptions.previewUpdate(updateParams);
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be some charge for additional prepaid (depends on trial behavior)
// But trial should be preserved
// next_cycle should align with existing 14-day trial
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
});
await autumnV1.subscriptions.update(updateParams);
@@ -142,6 +146,12 @@ test.concurrent(`${chalk.yellowBright("trial-qty: update allocated seats while t
// During trial, no proration should occur
expect(preview.total).toEqual(0);
// next_cycle should align with existing 14-day trial
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);

View File

@@ -6,11 +6,16 @@ import {
expectProductCanceling,
expectProductScheduled,
} from "@tests/billing/utils/expectCustomerProductCorrect";
import { expectProductTrialing } from "@tests/billing/utils/expectCustomerProductTrialing";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/billing/utils/expectPreviewNextCycleCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
@@ -55,15 +60,15 @@ test.concurrent(`${chalk.yellowBright("trial-multi: separate entities have separ
await expectProductTrialing({
customer: entity0,
productId: proTrial.id,
trialEndsAfter: ms.days(13),
trialEndsBefore: ms.days(15),
trialEndsAt: Date.now() + ms.days(14),
});
// Verify entity 1 is NOT trialing (free product)
// Verify entity 1 is also trialing (merged with entity 0's trial subscription)
const entity1 = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductActive({
await expectProductTrialing({
customer: entity1,
productId: free.id,
trialEndsAt: Date.now() + ms.days(14),
});
// Upgrade entity 1 to paid with a different trial length
@@ -88,8 +93,7 @@ test.concurrent(`${chalk.yellowBright("trial-multi: separate entities have separ
await expectProductTrialing({
customer: entity1After,
productId: free.id,
trialEndsAfter: ms.days(6),
trialEndsBefore: ms.days(8),
trialEndsAt: Date.now() + ms.days(7),
});
// Entity 0 should still have its original 14-day trial
@@ -97,8 +101,7 @@ test.concurrent(`${chalk.yellowBright("trial-multi: separate entities have separ
await expectProductTrialing({
customer: entity0After,
productId: proTrial.id,
trialEndsAfter: ms.days(13),
trialEndsBefore: ms.days(15),
trialEndsAt: Date.now() + ms.days(14),
});
// Verify the trial end dates are different
@@ -237,6 +240,15 @@ test.concurrent(`${chalk.yellowBright("trial-multi: trial preserved when schedul
// No free_trial param - should preserve existing trial
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// next_cycle should align with existing 14-day trial
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
total: items.monthlyPrice().price!,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
@@ -269,3 +281,209 @@ test.concurrent(`${chalk.yellowBright("trial-multi: trial preserved when schedul
env: ctx.env,
});
});
// 4. Free to paid with trial, merging with existing subscription
test.concurrent(`${chalk.yellowBright("trial-multi: free to paid with trial merges with existing subscription")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const free = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1, ctx, entities } = await initScenario({
customerId: "trial-multi-merge",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: pro.id, entityIndex: 0 }),
s.attach({ productId: free.id, entityIndex: 1 }),
],
});
// Verify entity 0 is on paid pro (not trialing)
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductActive({ customer: entity1, productId: pro.id });
await expectProductNotTrialing({ customer: entity1, productId: pro.id });
// Now upgrade entity 1 from free to paid with trial
const priceItem = items.monthlyPrice();
const updateParams = {
customer_id: customerId,
entity_id: entities[1].id,
product_id: free.id,
items: [messagesItem, priceItem],
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 during trial
expect(preview.total).toEqual(0);
// next_cycle should show when 7-day trial ends
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(7),
total: priceItem.price!,
});
await autumnV1.subscriptions.update(updateParams);
// Verify entity 1 is now trialing
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
customer: entity2,
productId: free.id,
trialEndsAt: Date.now() + ms.days(7),
});
// Entity 0 should still not be trialing
const entity1After = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductNotTrialing({
customer: entity1After,
productId: pro.id,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 5. Free customer -> entities subscribe to trial product -> advance cycle -> update free to paid (merges with existing)
test.concurrent(`${chalk.yellowBright("trial-multi: free to paid after trial cycle merges with subscription")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice();
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem, priceItem],
trialDays: 7,
});
const free = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1, ctx, entities, testClockId } =
await initScenario({
customerId: "trial-multi-after-cycle",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: proTrial.id, entityIndex: 0 }), // Entity 0 gets trial product
s.attach({ productId: free.id, entityIndex: 1 }), // Entity 1 gets free product
],
});
// Verify entity 0 is trialing
const entity0Before = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductTrialing({
customer: entity0Before,
productId: proTrial.id,
trialEndsAt: Date.now() + ms.days(7),
});
// Verify entity 1 is NOT trialing (free product)
const entity1Before = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductActive({
customer: entity1Before,
productId: free.id,
});
// Advance past trial period (10 days to be safe)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 10,
});
// Verify entity 0 is no longer trialing (trial ended, now active)
const entity0AfterAdvance = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectProductNotTrialing({
customer: entity0AfterAdvance,
productId: proTrial.id,
});
await expectProductActive({
customer: entity0AfterAdvance,
productId: proTrial.id,
});
// Now upgrade entity 1 from free to paid with trial - should merge with existing subscription
const updateParams = {
customer_id: customerId,
entity_id: entities[1].id,
product_id: free.id,
items: [messagesItem, priceItem],
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 during trial
expect(preview.total).toEqual(0);
// next_cycle should show when 14-day trial ends
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
total: priceItem.price!,
});
await autumnV1.subscriptions.update(updateParams);
// Verify entity 1 is now trialing with 14-day trial
const entity1After = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
customer: entity1After,
productId: free.id,
trialEndsAt: Date.now() + ms.days(14),
});
// Entity 0 should still be active (not trialing)
const entity0After = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductNotTrialing({
customer: entity0After,
productId: proTrial.id,
});
await expectProductActive({
customer: entity0After,
productId: proTrial.id,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -1,8 +1,8 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect.js";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectEntityFeatureCorrect } from "@tests/billing/utils/expectEntityFeatureCorrect.js";
import { expectEntityProductActive } from "@tests/billing/utils/expectEntityProductCorrect.js";
import { expectProductActive } from "@tests/billing/utils/expectCustomerProductCorrect.js";
import { expectLatestInvoiceCorrect } from "@tests/billing/utils/expectLatestInvoiceCorrect.js";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
@@ -97,16 +97,16 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 1 increases
// Verify entity 1 has new balance
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
await expectEntityFeatureCorrect({
entity: entity1,
await expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: newQuantity1,
});
// Verify entity 2 is unchanged
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
await expectEntityFeatureCorrect({
entity: entity2,
await expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: initialQuantity2,
});
@@ -196,16 +196,16 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 2 decreases
// Verify entity 2 has new balance
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
await expectEntityFeatureCorrect({
entity: entity2,
await expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: newQuantity2,
});
// Verify entity 1 is unchanged
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
await expectEntityFeatureCorrect({
entity: entity1,
await expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: initialQuantity1,
});
@@ -302,13 +302,13 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: mixed changes acro
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
await expectEntityFeatureCorrect({
entity: entity1,
await expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: newQuantity1,
});
await expectEntityFeatureCorrect({
entity: entity2,
await expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: newQuantity2,
});
@@ -398,8 +398,8 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: OnDecrease.None cr
// Balance is updated immediately with OnDecrease.None
const entity1After = await autumnV1.entities.get(customerId, entities[0].id);
await expectEntityFeatureCorrect({
entity: entity1After,
await expectCustomerFeatureCorrect({
customer: entity1After,
featureId: TestFeature.Messages,
balance: newQuantity1,
});
@@ -413,8 +413,8 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: OnDecrease.None cr
// Entity 2 should be unchanged
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
await expectEntityFeatureCorrect({
entity: entity2,
await expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: initialQuantity2,
});
@@ -497,24 +497,24 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products
// Verify entity 2 (pro) has new balance
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
await expectEntityProductActive({
entity: entity2,
await expectProductActive({
customer: entity2,
productId: proProduct.id,
});
await expectEntityFeatureCorrect({
entity: entity2,
await expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: newQuantityPro,
});
// Verify entity 1 (base) is unchanged
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
await expectEntityProductActive({
entity: entity1,
await expectProductActive({
customer: entity1,
productId: baseProduct.id,
});
await expectEntityFeatureCorrect({
entity: entity1,
await expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: initialQuantityBase,
});
@@ -618,26 +618,26 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features
// Verify entity 1 features
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
await expectEntityFeatureCorrect({
entity: entity1,
await expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: 100,
});
await expectEntityFeatureCorrect({
entity: entity1,
await expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Words,
balance: 100,
});
// Verify entity 2 is unchanged
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
await expectEntityFeatureCorrect({
entity: entity2,
await expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Messages,
balance: 100,
});
await expectEntityFeatureCorrect({
entity: entity2,
await expectCustomerFeatureCorrect({
customer: entity2,
featureId: TestFeature.Words,
balance: 500,
});

View File

@@ -1,18 +1,21 @@
import { expect } from "bun:test";
import { type ApiCustomerV3, ApiVersion } from "@autumn/shared";
import {
type ApiCustomerV3,
type ApiEntityV0,
ApiVersion,
} from "@autumn/shared";
import type { Customer } from "autumn-js";
import { AutumnInt } from "@/external/autumn/autumnCli";
const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 });
export const expectCustomerFeatureExists = async ({
customerId,
customer: providedCustomer,
featureId,
}: {
customerId?: string;
customer?: Customer;
customer?: Customer | ApiEntityV0;
featureId: string;
}) => {
const customer = providedCustomer
@@ -26,7 +29,7 @@ export const expectCustomerFeatureExists = async ({
const TEN_MINUTES_MS = 10 * 60 * 1000;
export const expectCustomerFeatureCorrect = async ({
export const expectCustomerFeatureCorrect = ({
customerId,
customer: providedCustomer,
featureId,
@@ -36,23 +39,31 @@ export const expectCustomerFeatureCorrect = async ({
resetsAt,
}: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
featureId: string;
includedUsage?: number;
balance?: number;
usage?: number;
resetsAt?: number;
}) => {
const customer = providedCustomer
? providedCustomer
: await defaultAutumn.customers.get(customerId!);
const feature = customer.features?.[featureId];
if (!providedCustomer && !customerId) {
throw new Error("Either customer or customerId must be provided");
}
expect(feature).toMatchObject({
included_usage: includedUsage,
balance,
usage,
});
const feature = providedCustomer?.features?.[featureId];
expect(feature, `Feature ${featureId} not found`).toBeDefined();
if (includedUsage !== undefined) {
expect(feature?.included_usage).toBe(includedUsage);
}
if (balance !== undefined) {
expect(feature?.balance).toBe(balance);
}
if (usage !== undefined) {
expect(feature?.usage).toBe(usage);
}
if (resetsAt !== undefined) {
const actualResetsAt = feature?.next_reset_at ?? 0;

View File

@@ -1,5 +1,5 @@
import { expect } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import { ApiVersion } from "@autumn/shared";
import { AutumnInt } from "@/external/autumn/autumnCli";
@@ -21,7 +21,7 @@ export const expectCustomerProductCorrect = async ({
state,
}: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
state: ProductState;
}) => {
@@ -66,7 +66,7 @@ export const expectCustomerProductCorrect = async ({
*/
export const expectProductActive = async (params: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
}) => expectCustomerProductCorrect({ ...params, state: "active" });
@@ -77,7 +77,7 @@ export const expectProductActive = async (params: {
*/
export const expectProductCanceling = async (params: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
}) => expectCustomerProductCorrect({ ...params, state: "canceled" });
@@ -86,7 +86,7 @@ export const expectProductCanceling = async (params: {
*/
export const expectProductScheduled = async (params: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
}) => expectCustomerProductCorrect({ ...params, state: "scheduled" });
@@ -95,6 +95,6 @@ export const expectProductScheduled = async (params: {
*/
export const expectProductNotPresent = async (params: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
}) => expectCustomerProductCorrect({ ...params, state: "undefined" });

View File

@@ -1,5 +1,5 @@
import { expect } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import { ApiVersion } from "@autumn/shared";
import { AutumnInt } from "@/external/autumn/autumnCli";
@@ -8,6 +8,8 @@ const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 });
const ONE_HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
const TEN_MINUTES_MS = 10 * 60 * 1000;
/**
* Verify a customer product is currently trialing with the expected trial end time.
* Uses `status === "trialing"` and `current_period_end` to determine trial state.
@@ -16,16 +18,13 @@ export const expectProductTrialing = async ({
customerId,
customer: providedCustomer,
productId,
trialEndsAfter,
trialEndsBefore,
trialEndsAt: expectedTrialEndsAt,
}: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
/** Lower bound - current_period_end should be after this (ms from now) */
trialEndsAfter?: number;
/** Upper bound - current_period_end should be before this (ms from now) */
trialEndsBefore?: number;
/** Expected trial end timestamp (10 min tolerance) */
trialEndsAt?: number;
}) => {
const customer = providedCustomer
? providedCustomer
@@ -52,20 +51,11 @@ export const expectProductTrialing = async ({
`Product ${productId} should have current_period_end defined when trialing`,
).toBeDefined();
const now = Date.now();
// Verify trial_ends_at is within expected range
if (trialEndsAfter !== undefined) {
// Verify trial_ends_at matches expected timestamp (with tolerance)
if (expectedTrialEndsAt !== undefined) {
expect(
trialEndsAt! > now + trialEndsAfter - ONE_HOUR_MS,
`Product ${productId} current_period_end (${trialEndsAt}) should be after ${trialEndsAfter}ms from now`,
).toBe(true);
}
if (trialEndsBefore !== undefined) {
expect(
trialEndsAt! < now + trialEndsBefore + ONE_HOUR_MS,
`Product ${productId} current_period_end (${trialEndsAt}) should be before ${trialEndsBefore}ms from now`,
Math.abs(trialEndsAt! - expectedTrialEndsAt) < TEN_MINUTES_MS,
`Product ${productId} current_period_end (${trialEndsAt}) should be within 10 min of ${expectedTrialEndsAt}`,
).toBe(true);
}
@@ -81,7 +71,7 @@ export const expectProductNotTrialing = async ({
productId,
}: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
}) => {
const customer = providedCustomer
@@ -112,7 +102,7 @@ export const expectFeatureResetAlignedWithTrialEnd = async ({
trialEndsAt,
}: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
featureId: string;
trialEndsAt: number;
}) => {
@@ -120,7 +110,12 @@ export const expectFeatureResetAlignedWithTrialEnd = async ({
? providedCustomer
: await defaultAutumn.customers.get(customerId!);
const feature = customer.features[featureId];
expect(
customer.features,
"Customer features not found for reset alignment check",
).toBeDefined();
const feature = customer.features![featureId];
expect(
feature,
`Feature ${featureId} not found for reset alignment check`,
@@ -148,7 +143,7 @@ export const expectPeriodEndsAlignedWithTrialEnd = async ({
trialEndsAt,
}: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
trialEndsAt: number;
}) => {
@@ -196,7 +191,7 @@ export const getTrialEndsAt = async ({
productId,
}: {
customerId?: string;
customer?: ApiCustomerV3;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
}): Promise<number | null> => {
const customer = providedCustomer

View File

@@ -1,73 +0,0 @@
import { expect } from "bun:test";
import { type ApiEntityV0, ApiVersion } from "@autumn/shared";
import { AutumnInt } from "@/external/autumn/autumnCli";
const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 });
const ONE_HOUR_MS = 60 * 60 * 1000;
/**
* Verify an entity has the expected feature with correct balance/usage values.
* Uses ApiEntityV0 which has `features` with `balance` property (V1.2 format).
*/
export const expectEntityFeatureCorrect = async ({
customerId,
entityId,
entity: providedEntity,
featureId,
balance,
usage,
resetsAt,
}: {
customerId?: string;
entityId?: string;
entity?: ApiEntityV0;
featureId: string;
balance?: number;
usage?: number;
resetsAt?: number;
}) => {
const entity = providedEntity
? providedEntity
: await defaultAutumn.entities.get(customerId!, entityId!);
const feature = entity.features?.[featureId];
if (balance !== undefined) {
expect(feature?.balance).toBe(balance);
}
if (usage !== undefined) {
expect(feature?.usage).toBe(usage);
}
if (resetsAt !== undefined) {
const actualResetsAt = feature?.next_reset_at ?? 0;
expect(actualResetsAt).toBeDefined();
expect(Math.abs(actualResetsAt - resetsAt)).toBeLessThanOrEqual(
ONE_HOUR_MS,
);
}
};
/**
* Verify an entity has a specific feature defined.
*/
export const expectEntityFeatureExists = async ({
customerId,
entityId,
entity: providedEntity,
featureId,
}: {
customerId?: string;
entityId?: string;
entity?: ApiEntityV0;
featureId: string;
}) => {
const entity = providedEntity
? providedEntity
: await defaultAutumn.entities.get(customerId!, entityId!);
const feature = entity.features?.[featureId];
expect(feature).toBeDefined();
};

View File

@@ -1,91 +0,0 @@
import { expect } from "bun:test";
import { type ApiEntityV0, ApiVersion } from "@autumn/shared";
import { AutumnInt } from "@/external/autumn/autumnCli";
const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 });
type ProductState = "active" | "canceled" | "scheduled" | "undefined";
/**
* Verify an entity has the expected product in the expected state.
*/
export const expectEntityProductCorrect = async ({
customerId,
entityId,
entity: providedEntity,
productId,
state,
}: {
customerId?: string;
entityId?: string;
entity?: ApiEntityV0;
productId: string;
state: ProductState;
}) => {
const entity = providedEntity
? providedEntity
: await defaultAutumn.entities.get(customerId!, entityId!);
const products = entity.products ?? [];
const product = products.find((p) => p.id === productId);
if (state === "undefined") {
expect(product, `Product ${productId} should not exist`).toBeUndefined();
return;
}
if (!product) {
throw new Error(
`Product ${productId} not found on entity but expected state: ${state}`,
);
}
if (state === "active") {
expect(String(product.status)).toBe("active");
expect(product.canceled_at == null).toBe(true);
} else if (state === "canceled") {
expect(product.canceled_at).toBeDefined();
} else if (state === "scheduled") {
expect(String(product.status)).toBe("scheduled");
}
};
/**
* Shorthand for checking entity product is active
*/
export const expectEntityProductActive = async (params: {
customerId?: string;
entityId?: string;
entity?: ApiEntityV0;
productId: string;
}) => expectEntityProductCorrect({ ...params, state: "active" });
/**
* Shorthand for checking entity product is canceled
*/
export const expectEntityProductCanceled = async (params: {
customerId?: string;
entityId?: string;
entity?: ApiEntityV0;
productId: string;
}) => expectEntityProductCorrect({ ...params, state: "canceled" });
/**
* Shorthand for checking entity product is scheduled
*/
export const expectEntityProductScheduled = async (params: {
customerId?: string;
entityId?: string;
entity?: ApiEntityV0;
productId: string;
}) => expectEntityProductCorrect({ ...params, state: "scheduled" });
/**
* Shorthand for checking entity product does not exist
*/
export const expectEntityProductNotPresent = async (params: {
customerId?: string;
entityId?: string;
entity?: ApiEntityV0;
productId: string;
}) => expectEntityProductCorrect({ ...params, state: "undefined" });

View File

@@ -0,0 +1,61 @@
import { expect } from "bun:test";
import type { BillingPreviewResponse } from "@autumn/shared";
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/**
* Verify a billing preview's next_cycle field has the expected values.
* Used to check when trial ends and what charge will be.
*/
export const expectPreviewNextCycleCorrect = ({
preview,
expectDefined = true,
startsAt,
total,
toleranceMs = ONE_DAY_MS,
}: {
preview: BillingPreviewResponse;
/** Whether next_cycle should be defined (default: true) */
expectDefined?: boolean;
/** Expected starts_at offset from now (ms from now) */
startsAt?: number;
/** Expected total amount (in dollars) */
total?: number;
/** Tolerance in ms (default: 1 day) */
toleranceMs?: number;
}) => {
if (!expectDefined) {
expect(
preview.next_cycle,
"Preview next_cycle should not be defined",
).toBeUndefined();
return;
}
expect(
preview.next_cycle,
"Preview next_cycle should be defined",
).toBeDefined();
const nextCycle = preview.next_cycle!;
if (startsAt !== undefined) {
const now = Date.now();
const expectedStartsAt = now + startsAt;
const diff = Math.abs(nextCycle.starts_at - expectedStartsAt);
expect(
diff < toleranceMs,
`Preview next_cycle.starts_at (${nextCycle.starts_at}) should be within ${toleranceMs}ms of ${expectedStartsAt}, but diff is ${diff}ms`,
).toBe(true);
}
if (total !== undefined) {
expect(
nextCycle.total,
`Preview next_cycle.total should be ${total}`,
).toEqual(total);
}
return nextCycle;
};

View File

@@ -97,8 +97,37 @@ const proWithTrial = ({
},
});
/**
* Base (free) product with free trial - no base price, with configurable trial
* @param items - Product items (features)
* @param id - Product ID (default: "base-trial")
* @param trialDays - Number of trial days (default: 7)
* @param cardRequired - Whether card is required for trial (default: false)
*/
const baseWithTrial = ({
items,
id = "base-trial",
trialDays = 7,
cardRequired = false,
}: {
items: ProductItem[];
id?: string;
trialDays?: number;
cardRequired?: boolean;
}): ProductV2 => ({
...constructRawProduct({ id, items }),
is_default: false,
free_trial: {
length: trialDays,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
card_required: cardRequired,
},
});
export const products = {
base,
baseWithTrial,
pro,
proAnnual,
proWithTrial,

View File

@@ -11,6 +11,13 @@ export const BillingPreviewResponseSchema = z.object({
total: z.number(),
currency: z.string(),
next_cycle: z
.object({
starts_at: z.number(),
total: z.number(),
})
.optional(),
});
export type BillingPreviewResponse = z.infer<

View File

@@ -32,9 +32,10 @@ export const isValidMsTimestamp = (unixTimestamp: number): boolean => {
* Validates that a timestamp is in seconds, then converts to milliseconds.
* Returns undefined if input is undefined or not a valid seconds timestamp.
*/
export const secondsToMs = (
seconds: number | undefined,
): number | undefined => {
export function secondsToMs(seconds: number): number;
export function secondsToMs(seconds: undefined): undefined;
export function secondsToMs(seconds: number | undefined): number | undefined;
export function secondsToMs(seconds: number | undefined): number | undefined {
if (seconds === undefined) {
return undefined;
}
@@ -49,7 +50,7 @@ export const secondsToMs = (
}
return seconds * 1000;
};
}
export const msToSeconds = (ms: number): number => {
return Math.floor(ms / 1000);

View File

@@ -79,18 +79,14 @@ export const isCustomerProductExpired = (
);
};
export const isCusProductTrialing = ({
cusProduct,
now,
}: {
cusProduct?: FullCusProduct;
now?: number;
}) => {
if (!cusProduct) return false;
export const isCustomerProductTrialing = (
customerProduct?: FullCusProduct,
params?: { nowMs?: number },
) => {
if (!customerProduct) return false;
return (
cusProduct.trial_ends_at && cusProduct.trial_ends_at > (now || Date.now())
);
const nowMs = params?.nowMs ?? Date.now();
return customerProduct.trial_ends_at && customerProduct.trial_ends_at > nowMs;
};
export const customerProductHasRelevantStatus = (cp?: FullCusProduct) => {

View File

@@ -5,7 +5,6 @@ import {
customerProductHasRelevantStatus,
customerProductHasSubscriptionSchedule,
isCusProductOnEntity,
isCusProductTrialing,
isCustomerProductAddOn,
isCustomerProductCanceling,
isCustomerProductFree,
@@ -16,6 +15,7 @@ import {
isCustomerProductPaid,
isCustomerProductRecurring,
isCustomerProductScheduled,
isCustomerProductTrialing,
} from "./classifyCustomerProduct";
type Predicate = (cp: FullCusProduct) => boolean;
@@ -120,10 +120,8 @@ class CustomerProductChecker {
}
/** Product is trialing */
trialing({ now }: { now?: number } = {}) {
this.predicates.push(
(cp) => !!isCusProductTrialing({ cusProduct: cp, now }),
);
trialing({ nowMs }: { nowMs?: number } = {}) {
this.predicates.push((cp) => !!isCustomerProductTrialing(cp, { nowMs }));
return this;
}

View File

@@ -81,8 +81,6 @@ export const getSmallestInterval = ({
ents?: Entitlement[];
excludeOneOff?: boolean;
}) => {
// let sortedPrices = structuredClone(prices);
// sortPricesByInterval(sortedPrices);
let allPriceIntervals = prices.map((p) => {
return {
interval: p.config.interval,

View File

@@ -58,7 +58,7 @@ export function useAttachPreview(params: AttachPreviewParams = {}) {
}
const response = await axiosInstance.post<CheckoutResponseV0>(
"/v1/checkout",
"/v1/subscriptions/preview_update",
attachBody,
);

View File

@@ -0,0 +1,78 @@
import type {
CreateFreeTrial,
FeatureOptions,
ProductV2,
} from "@autumn/shared";
export const getUpdateSubscriptionBody = ({
customerId,
product,
entityId,
optionsInput,
useInvoice,
enableProductImmediately = true,
successUrl,
version,
isCustom = false,
freeTrial,
}: {
customerId: string;
product: ProductV2;
entityId?: string;
optionsInput?: FeatureOptions[];
useInvoice?: boolean;
enableProductImmediately?: boolean;
successUrl?: string;
version?: number;
isCustom?: boolean;
// Free trial param - null removes trial, undefined preserves existing
freeTrial?: CreateFreeTrial | null;
}) => {
const customData = isCustom
? {
items: product.items,
free_trial: product.free_trial,
}
: {};
// Determine free_trial value:
// 1. If freeTrial is explicitly set (including null), use it
// 2. If isCustom, use product.free_trial
// 3. Otherwise, undefined (preserve existing)
const getFreeTrialValue = () => {
if (freeTrial !== undefined) {
return freeTrial;
}
if (isCustom) {
return product.free_trial || undefined;
}
return undefined;
};
return {
customer_id: customerId,
product_id: product.id,
entity_id: entityId || undefined,
options: optionsInput
? optionsInput.map((option) => ({
feature_id: option.feature_id,
quantity: option.quantity || 0,
}))
: undefined,
is_custom: isCustom,
...customData,
free_trial: getFreeTrialValue(),
invoice: useInvoice,
enable_product_immediately: useInvoice
? enableProductImmediately
: undefined,
finalize_invoice: useInvoice ? false : undefined,
force_checkout:
useInvoice && enableProductImmediately === false ? true : undefined,
success_url: successUrl,
version: version ? Number(version) : undefined,
};
};

View File

@@ -0,0 +1,123 @@
import {
AppEnv,
type CreateFreeTrial,
type ProductV2,
UsageModel,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { useMemo } from "react";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useEnv } from "@/utils/envUtils";
import { getRedirectUrl } from "@/utils/genUtils";
import { getUpdateSubscriptionBody } from "./get-update-subscription-body";
interface UpdateSubscriptionBodyBuilderParams {
customerId?: string;
productId?: string;
product?: ProductV2;
entityId?: string;
prepaidOptions?: Record<string, number>;
version?: number;
useInvoice?: boolean;
enableProductImmediately?: boolean;
// Free trial param - null removes trial, undefined preserves existing
freeTrial?: CreateFreeTrial | null;
}
/**
* Shared hook to build update subscription body from explicit params.
* Similar to useAttachBodyBuilder but includes free_trial support.
*/
export function useUpdateSubscriptionBodyBuilder(
params: UpdateSubscriptionBodyBuilderParams = {},
) {
const { products } = useProductsQuery();
const hasChanges = useHasChanges();
const storeProduct = useProductStore((s) => s.product);
const { entityId: storeEntityId } = useEntity();
const env = useEnv();
// Memoized builder function that can be called with runtime params
const buildUpdateSubscriptionBody = useMemo(
() => (runtimeParams?: UpdateSubscriptionBodyBuilderParams) => {
const mergedParams = { ...params, ...runtimeParams };
const redirectUrl = getRedirectUrl(
`/customers/${mergedParams.customerId}`,
env,
);
// Resolve the product: use provided product or find by ID
const product =
mergedParams.product ||
products.find((p) => p.id === mergedParams.productId);
if (!product || !mergedParams.customerId) {
return null;
}
// Determine if this is a custom product (from store with changes)
const isCustom =
hasChanges && !!storeProduct?.id && product === storeProduct
? true
: undefined;
const version = storeProduct?.id ? storeProduct.version : undefined;
// Convert prepaidOptions to options array
const options = mergedParams.prepaidOptions
? Object.entries(mergedParams.prepaidOptions).map(
([featureId, quantity]) => {
const prepaidItem = product?.items.find(
(item) =>
item.feature_id === featureId &&
item.usage_model === UsageModel.Prepaid,
);
if (!prepaidItem) {
return {
feature_id: featureId,
quantity: quantity,
};
}
return {
feature_id: featureId,
quantity: new Decimal(quantity || 0)
.mul(prepaidItem.billing_units || 1)
.toNumber(),
};
},
)
: [];
// Build the body using getUpdateSubscriptionBody (includes freeTrial support)
return getUpdateSubscriptionBody({
customerId: mergedParams.customerId,
product,
entityId: mergedParams.entityId ?? storeEntityId ?? undefined,
optionsInput: options.length > 0 ? options : undefined,
isCustom,
version,
useInvoice: mergedParams.useInvoice,
enableProductImmediately: mergedParams.enableProductImmediately,
successUrl:
env === AppEnv.Sandbox
? `${import.meta.env.VITE_FRONTEND_URL}${redirectUrl}`
: undefined,
freeTrial: mergedParams.freeTrial,
});
},
[products, hasChanges, storeProduct, storeEntityId, params, env],
);
// For simple usage, return the built body with current params
const updateSubscriptionBody = useMemo(
() => buildUpdateSubscriptionBody(),
[buildUpdateSubscriptionBody],
);
return { updateSubscriptionBody, buildUpdateSubscriptionBody };
}

View File

@@ -0,0 +1,90 @@
import type {
CheckoutResponseV0,
CreateFreeTrial,
ProductV2,
} from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useUpdateSubscriptionBodyBuilder } from "./use-update-subscription-body-builder";
interface UpdateSubscriptionPreviewParams {
// Required params - no fallbacks
customerId?: string;
product?: ProductV2;
entityId?: string;
prepaidOptions?: Record<string, number>;
version?: number;
// Free trial param - null removes trial, undefined preserves existing
freeTrial?: CreateFreeTrial | null;
// Control behavior
enabled?: boolean;
}
export function useUpdateSubscriptionPreview(
params: UpdateSubscriptionPreviewParams = {},
) {
const axiosInstance = useAxiosInstance();
// Build update subscription body using shared hook with explicit params
const { updateSubscriptionBody } = useUpdateSubscriptionBodyBuilder({
customerId: params.customerId,
product: params.product,
entityId: params.entityId,
prepaidOptions: params.prepaidOptions,
version: params.version,
freeTrial: params.freeTrial,
});
// Auto-enable if not explicitly set and all required data is present
const shouldEnable =
params.enabled !== undefined
? params.enabled
: !!(params.customerId && params.product && updateSubscriptionBody);
// Create a stable serialized key from updateSubscriptionBody (which already captures all dependencies)
const queryKeyDeps = useMemo(
() => JSON.stringify(updateSubscriptionBody),
[updateSubscriptionBody],
);
// Debounce the query key to delay API calls by 150ms
const [debouncedQueryKey, setDebouncedQueryKey] = useState(queryKeyDeps);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQueryKey(queryKeyDeps);
}, 300);
return () => clearTimeout(timer);
}, [queryKeyDeps]);
// Track if we're in a debouncing state (query key has changed but debounce hasn't completed)
const isDebouncing = queryKeyDeps !== debouncedQueryKey;
const query = useQuery({
queryKey: ["update-subscription-preview", debouncedQueryKey],
queryFn: async () => {
if (!updateSubscriptionBody || !params.customerId) {
return null;
}
const response = await axiosInstance.post<CheckoutResponseV0>(
"/v1/subscriptions/preview_update",
updateSubscriptionBody,
);
return response.data;
},
enabled: shouldEnable,
staleTime: 0, // Always fetch fresh pricing
});
// Override isLoading to include debouncing state
// This prevents showing stale data during the transition between diff plans in the selector
return {
...query,
isLoading: query.isLoading || isDebouncing,
};
}

View File

@@ -2,7 +2,7 @@ import {
CusProductStatus,
type Entity,
featureToOptions,
isCusProductTrialing,
isCustomerProductTrialing,
isOneOffProductV2,
type ProductItem,
UsageModel,
@@ -290,9 +290,8 @@ export function SubscriptionDetailSheet() {
status={cusProduct.status}
canceled={cusProduct.canceled}
trialing={
isCusProductTrialing({
cusProduct,
now: Date.now(),
isCustomerProductTrialing(cusProduct, {
nowMs: Date.now(),
}) || false
}
trial_ends_at={cusProduct.trial_ends_at ?? undefined}

View File

@@ -6,7 +6,7 @@ import {
type FullCusProduct,
type FullCustomer,
getProductItemDisplay,
isCusProductTrialing,
isCustomerProductTrialing,
type ProductItem,
type ProductV2,
stripeToAtmnAmount,
@@ -17,7 +17,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { DateInputUnix } from "@/components/general/DateInputUnix";
import { AttachProductLineItems } from "@/components/forms/attach-product/attach-product-line-items";
import { AttachProductTotals } from "@/components/forms/attach-product/attach-product-totals";
import { useUpdateSubscriptionPreview } from "@/components/forms/update-subscription/use-update-subscription-preview";
import {
Popover,
PopoverContent,
@@ -25,6 +27,7 @@ import {
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText";
import { SheetHeader } from "@/components/v2/sheets/InlineSheet";
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
@@ -126,7 +129,7 @@ function FreeTrialEditor({
onTrialCardRequiredChange,
onRemoveTrialChange,
}: FreeTrialEditorProps) {
const isCurrentlyTrialing = isCusProductTrialing({ cusProduct });
const isCurrentlyTrialing = isCustomerProductTrialing(cusProduct);
return (
<div className="border-b border-border">
@@ -1145,13 +1148,13 @@ function SheetContent({
initialPrepaidOptions,
);
const [planCustomStartDate, setPlanCustomStartDate] = useState<number | null>(
const [planCustomStartDate, _setPlanCustomStartDate] = useState<
number | null
>(null);
const [planCustomEndDate, _setPlanCustomEndDate] = useState<number | null>(
null,
);
const [planCustomEndDate, setPlanCustomEndDate] = useState<number | null>(
null,
);
const [billingCycleAnchor, setBillingCycleAnchor] = useState<number | null>(
const [billingCycleAnchor, _setBillingCycleAnchor] = useState<number | null>(
null,
);
@@ -1269,6 +1272,32 @@ function SheetContent({
enabled: !!requestBody,
});
// Compute freeTrial value for preview
const previewFreeTrial = useMemo(() => {
if (removeTrial) {
return null;
}
if (trialLength) {
return {
length: trialLength,
duration: trialDuration,
card_required: trialCardRequired,
unique_fingerprint: false,
};
}
return undefined;
}, [removeTrial, trialLength, trialDuration, trialCardRequired]);
// Checkout preview query with free trial support
const checkoutPreviewQuery = useUpdateSubscriptionPreview({
customerId,
product,
entityId,
prepaidOptions: prepaidOptions ?? undefined,
version: product?.version,
freeTrial: previewFreeTrial,
});
// Update mutation with invoice handling
const updateMutation = useSubscriptionUpdate({
customerId,
@@ -1471,82 +1500,22 @@ function SheetContent({
onRemoveTrialChange={setRemoveTrial}
/>
{/* Custom Plan Dates */}
{/* Checkout Preview Response (same as SubscriptionUpdateSheet) */}
<div className="border-b border-border">
<div className="px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium">Custom Plan Dates</h3>
<h3 className="text-sm font-medium">Checkout Preview Response</h3>
</div>
<div className="px-4 py-3 space-y-3">
<div className="flex items-center gap-3">
<label
htmlFor="plan-start-date"
className="text-sm text-t-secondary w-32"
>
Start Date
</label>
<div className="flex-1">
<DateInputUnix
unixDate={planCustomStartDate}
setUnixDate={setPlanCustomStartDate}
{checkoutPreviewQuery.isLoading ? (
<LoadingShimmerText
text="Calculating totals"
className="py-4 px-6"
/>
) : (
<div className="py-4">
<AttachProductLineItems previewData={checkoutPreviewQuery.data} />
<AttachProductTotals previewData={checkoutPreviewQuery.data} />
</div>
{planCustomStartDate ? (
<button
type="button"
onClick={() => setPlanCustomStartDate(null)}
className="text-xs text-t-secondary hover:text-t-primary"
>
Clear
</button>
) : null}
</div>
<div className="flex items-center gap-3">
<label
htmlFor="plan-end-date"
className="text-sm text-t-secondary w-32"
>
End Date
</label>
<div className="flex-1">
<DateInputUnix
unixDate={planCustomEndDate}
setUnixDate={setPlanCustomEndDate}
/>
</div>
{planCustomEndDate ? (
<button
type="button"
onClick={() => setPlanCustomEndDate(null)}
className="text-xs text-t-secondary hover:text-t-primary"
>
Clear
</button>
) : null}
</div>
<div className="flex items-center gap-3">
<label
htmlFor="billing-cycle-anchor"
className="text-sm text-t-secondary w-32"
>
Billing Anchor
</label>
<div className="flex-1">
<DateInputUnix
unixDate={billingCycleAnchor}
setUnixDate={setBillingCycleAnchor}
/>
</div>
{billingCycleAnchor ? (
<button
type="button"
onClick={() => setBillingCycleAnchor(null)}
className="text-xs text-t-secondary hover:text-t-primary"
>
Clear
</button>
) : null}
</div>
</div>
)}
</div>
{/* Preview Result */}

View File

@@ -2,7 +2,7 @@ import {
CusProductStatus,
type CustomerSchema,
type FullCusProduct,
isCusProductTrialing,
isCustomerProductTrialing,
} from "@autumn/shared";
import type { ColumnDef, Row } from "@tanstack/react-table";
import type { z } from "zod/v4";
@@ -93,9 +93,8 @@ const getCusProductsInfo = ({
}
tooltip={true}
trialing={
isCusProductTrialing({
cusProduct: cusProduct as FullCusProduct,
now: Date.now(),
isCustomerProductTrialing(cusProduct as FullCusProduct, {
nowMs: Date.now(),
}) || false
}
trial_ends_at={

View File

@@ -1,4 +1,4 @@
import { type FullCusProduct, isCusProductTrialing } from "@autumn/shared";
import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared";
import type { Row, Table } from "@tanstack/react-table";
import { ArrowRightLeft, Delete } from "lucide-react";
import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell";
@@ -49,12 +49,7 @@ export const CustomerProductsColumns = [
status={row.original.status}
starts_at={row.original.starts_at ?? undefined}
canceled={row.original.canceled}
trialing={
isCusProductTrialing({
cusProduct: row.original,
now: Date.now(),
}) || false
}
trialing={isCustomerProductTrialing(row.original) || false}
trial_ends_at={row.original.trial_ends_at ?? undefined}
/>
);