wip: trial tests
This commit is contained in:
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -19,7 +19,7 @@ export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
|
||||
|
||||
export interface TrialContext {
|
||||
freeTrial?: FreeTrial | null;
|
||||
trialEndsAt?: number;
|
||||
trialEndsAt: number | null;
|
||||
customFreeTrial?: FreeTrial;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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" });
|
||||
61
server/tests/billing/utils/expectPreviewNextCycleCorrect.ts
Normal file
61
server/tests/billing/utils/expectPreviewNextCycleCorrect.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useAttachPreview(params: AttachPreviewParams = {}) {
|
||||
}
|
||||
|
||||
const response = await axiosInstance.post<CheckoutResponseV0>(
|
||||
"/v1/checkout",
|
||||
"/v1/subscriptions/preview_update",
|
||||
attachBody,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user