chore: merge with update subscription branch

This commit is contained in:
Charlie Lamb
2026-01-13 19:25:33 +00:00
61 changed files with 2395 additions and 1108 deletions

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv";
import { handleGetMasterStripeAccount } from "./handleGetMasterStripeAccount";
import { handleGetOrgMember } from "./handleGetOrgMember";
import { handleListAdminOrgs } from "./handleListAdminOrgs";
import { handleListAdminUsers } from "./handleListAdminUsers";
@@ -9,3 +10,4 @@ export const honoAdminRouter = new Hono<HonoEnv>();
honoAdminRouter.get("/users", ...handleListAdminUsers);
honoAdminRouter.get("/orgs", ...handleListAdminOrgs);
honoAdminRouter.get("/org-member", ...handleGetOrgMember);
honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount);

View File

@@ -0,0 +1,21 @@
import { initMasterStripe } from "@/external/connect/initStripeCli";
import { createRoute } from "../../honoMiddlewares/routeHandler";
export const handleGetMasterStripeAccount = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { env, logger } = ctx;
try {
const masterStripe = initMasterStripe({ env });
const account = await masterStripe.accounts.retrieve();
return c.json({
id: account.id,
});
} catch (error) {
logger.warn(`Failed to get master Stripe account: ${error}`);
return c.json(null);
}
},
});

View File

@@ -1,13 +1,12 @@
import {
cp,
cusProductToLineItems,
type FullCusProduct,
filterUnchangedPricesFromLineItems,
type LineItem,
secondsToMs,
} 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";
import { logBuildAutumnLineItems } from "./logBuildAutumnLineItems";
export const buildAutumnLineItems = ({
ctx,
@@ -21,7 +20,8 @@ export const buildAutumnLineItems = ({
billingContext: BillingContext;
}) => {
// billingCycleAnchor = billingCycleAnchor ?? now;
const { billingCycleAnchorMs, currentEpochMs } = billingContext;
const { billingCycleAnchorMs, currentEpochMs, stripeSubscription } =
billingContext;
const { org, logger } = ctx;
@@ -35,17 +35,14 @@ export const buildAutumnLineItems = ({
// })
// Get line items for ongoing cus product
const { valid: isTrialing } = cp(deletedCustomerProduct).trialing({
nowMs: currentEpochMs,
});
const shouldRefundLineItems = deletedCustomerProduct && !isTrialing;
const deletedLineItems = shouldRefundLineItems
const originalBillingCycleAnchorMs = stripeSubscription?.billing_cycle_anchor
? secondsToMs(stripeSubscription.billing_cycle_anchor)
: "now";
const deletedLineItems = deletedCustomerProduct
? cusProductToLineItems({
cusProduct: deletedCustomerProduct,
nowMs: currentEpochMs,
billingCycleAnchorMs,
billingCycleAnchorMs: originalBillingCycleAnchorMs,
direction: "refund",
org,
logger,
@@ -63,38 +60,15 @@ export const buildAutumnLineItems = ({
}),
);
const {
deletedLineItems: filteredDeletedLineItems,
newLineItems: filteredNewLineItems,
} = filterUnchangedPricesFromLineItems({
// Combine all line items - trial filtering and unchanged price filtering
// will be handled in finalizeUpdateSubscriptionPlan
const allLineItems = [...deletedLineItems, ...newLineItems];
logBuildAutumnLineItems({
logger,
deletedLineItems,
newLineItems,
});
// All items
let allLineItems = [
...filteredDeletedLineItems,
...arrearLineItems,
...filteredNewLineItems,
];
// If trialing, don't apply free trial?
if (billingContextHasTrial({ billingContext })) {
allLineItems = [
...filteredDeletedLineItems,
...arrearLineItems,
...filteredNewLineItems,
].map((item) => ({ ...item, amount: 0, finalAmount: 0 }));
}
console.log(
"All line items: ",
allLineItems.map((item) => ({
description: item.description,
amount: item.amount,
finalAmount: item.finalAmount,
})),
);
return allLineItems;
};

View File

@@ -0,0 +1,164 @@
import {
cp,
cusProductToLineItems,
type FullCusProduct,
type LineItem,
secondsToMs,
} from "@autumn/shared";
import chalk from "chalk";
import type { Logger } from "@/external/logtail/logtailUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
const formatLineItem = (item: LineItem) => ({
description: item.description,
amount: item.amount,
finalAmount: item.finalAmount,
});
const logSharedSubscriptionTrialLineItems = ({
logger,
direction,
siblingCustomerProducts,
lineItems,
}: {
logger: Logger;
direction: "charge" | "refund";
siblingCustomerProducts: FullCusProduct[];
lineItems: LineItem[];
}) => {
const formatLineItemCompact = (item: LineItem) =>
` ${item.description}: ${chalk.yellow(item.finalAmount.toFixed(2))}`;
// Structured info log
logger.info(`buildSharedSubscriptionTrialLineItems data`, {
data: {
direction,
sibilingCustomerProducts: siblingCustomerProducts.map(
(customerProduct) => ({
id: customerProduct.id,
productId: customerProduct.product?.id,
entityId: customerProduct.entity_id,
}),
),
lineItems: lineItems.map(formatLineItem),
},
});
// Debug output
logger.debug("========== [buildSharedSubscriptionTrialLineItems] ==========");
logger.debug("");
logger.debug(`direction: ${direction}`);
logger.debug(
`siblingCustomerProducts: ${siblingCustomerProducts.map((cp) => cp.id).join(", ")}`,
);
logger.debug("");
logger.debug("lineItems:");
if (lineItems.length === 0) logger.debug(" (none)");
else for (const item of lineItems) logger.debug(formatLineItemCompact(item));
logger.debug("");
logger.debug(
"==============================================================",
);
};
/** Filter for sibling customer products on the same Stripe subscription. */
const getSiblingCustomerProducts = ({
customerProducts,
autumnBillingPlan,
stripeSubscriptionId,
}: {
customerProducts: FullCusProduct[];
autumnBillingPlan: AutumnBillingPlan;
stripeSubscriptionId: string;
}): FullCusProduct[] => {
const handledIds = new Set([
...autumnBillingPlan.insertCustomerProducts.map((cp) => cp.id),
autumnBillingPlan.updateCustomerProduct?.id,
autumnBillingPlan.deleteCustomerProduct?.id,
]);
return customerProducts.filter((customerProduct) => {
if (handledIds.has(customerProduct.id)) return false;
return cp(customerProduct)
.paid()
.recurring()
.onStripeSubscription({ stripeSubscriptionId }).valid;
});
};
/**
* Builds line items for sibling customer products on a shared Stripe subscription
* when trial state changes (trialing → no trial or no trial → trialing).
*/
export const buildSharedSubscriptionTrialLineItems = ({
ctx,
billingContext,
autumnBillingPlan,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
}): LineItem[] => {
const { org, logger } = ctx;
const { fullCustomer, stripeSubscription, currentEpochMs } = billingContext;
if (!stripeSubscription) return [];
const { isTrialing, willBeTrialing } = getTrialStateTransition({
billingContext,
});
// Determine direction based on trial state change
let direction: "charge" | "refund" | null = null;
if (isTrialing && !willBeTrialing) {
direction = "charge"; // Ending trial → charge for sibling products
} else if (!isTrialing && willBeTrialing) {
direction = "refund"; // Starting trial → refund sibling products
}
if (!direction) return [];
const siblingCustomerProducts = getSiblingCustomerProducts({
customerProducts: fullCustomer.customer_products,
autumnBillingPlan,
stripeSubscriptionId: stripeSubscription.id,
});
if (siblingCustomerProducts.length === 0) return [];
const originalBillingCycleAnchorMs = stripeSubscription.billing_cycle_anchor
? secondsToMs(stripeSubscription.billing_cycle_anchor)
: currentEpochMs;
const billingCycleAnchorMs =
direction === "charge"
? billingContext.billingCycleAnchorMs
: originalBillingCycleAnchorMs;
const lineItems: LineItem[] = [];
for (const customerProduct of siblingCustomerProducts) {
lineItems.push(
...cusProductToLineItems({
cusProduct: customerProduct,
nowMs: currentEpochMs,
billingCycleAnchorMs,
direction,
org,
logger,
}),
);
}
logSharedSubscriptionTrialLineItems({
logger,
direction,
siblingCustomerProducts,
lineItems,
});
return lineItems;
};

View File

@@ -0,0 +1,63 @@
import type { LineItem } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition";
/**
* Filters line items based on trial state transitions.
*
* Ending trial (isTrialing → !willBeTrialing):
* - Excludes refund items (no refund for trial period)
* - Excludes in_arrear positive items (no arrear charges for trial usage)
*
* Starting trial (!isTrialing → willBeTrialing):
* - Excludes in_advance positive items (no charge for upcoming trial period)
*/
export const filterLineItemsForTrialTransition = ({
ctx,
lineItems,
billingContext,
}: {
ctx: AutumnContext;
lineItems: LineItem[];
billingContext: BillingContext;
}): LineItem[] => {
const { isTrialing, willBeTrialing } = getTrialStateTransition({
billingContext,
});
// No filtering needed if no trial transition
if (!isTrialing && !willBeTrialing) {
return lineItems;
}
if (isTrialing && !willBeTrialing) {
ctx.logger.info(
`Trial transition: ending trial, clearing refund line items`,
);
} else if (!isTrialing && willBeTrialing) {
ctx.logger.info(
`Trial transition: starting trial, clearing charge line items`,
);
}
return lineItems.filter((lineItem) => {
const { billingTiming, direction } = lineItem.context;
const isPositive = lineItem.amount > 0;
// Ending trial (isTrialing → !willBeTrialing):
// Filter out refunds and in_arrear positive items (no refund for trial period, no arrear charges)
if (isTrialing) {
if (direction === "refund") return false;
if (billingTiming === "in_arrear" && isPositive) return false;
}
// Starting trial (!isTrialing → willBeTrialing):
// Filter out in_advance positive items (no charge for upcoming trial period)
if (willBeTrialing) {
if (billingTiming === "in_advance" && isPositive) return false;
}
return true;
});
};

View File

@@ -0,0 +1,45 @@
import type { LineItem } from "@autumn/shared";
import chalk from "chalk";
import type { Logger } from "@/external/logtail/logtailUtils";
const formatLineItem = (item: LineItem) => ({
description: item.description,
amount: item.amount,
finalAmount: item.finalAmount,
});
export const logBuildAutumnLineItems = ({
logger,
deletedLineItems,
newLineItems,
}: {
logger: Logger;
deletedLineItems: LineItem[];
newLineItems: LineItem[];
}) => {
logger.info(`buildAutumnLineItems data`, {
data: {
deletedLineItems: deletedLineItems.map(formatLineItem),
newLineItems: newLineItems.map(formatLineItem),
},
});
// Debug output (compact table format)
const formatLineItemCompact = (item: LineItem) =>
` ${item.description}: ${chalk.yellow(item.finalAmount.toFixed(2))}`;
logger.debug("========== [buildAutumnLineItems] ==========");
logger.debug("deletedLineItems:");
if (deletedLineItems.length === 0) logger.debug(" (none)");
else
for (const item of deletedLineItems)
logger.debug(formatLineItemCompact(item));
logger.debug("newLineItems:");
if (newLineItems.length === 0) logger.debug(" (none)");
else
for (const item of newLineItems) logger.debug(formatLineItemCompact(item));
logger.debug("=============================================");
};

View File

@@ -4,7 +4,7 @@ import type {
Price,
UpdateSubscriptionV0Params,
} from "@autumn/shared";
import { roundUsageToNearestBillingUnit } from "@autumn/shared";
import { notNullish, roundUsageToNearestBillingUnit } from "@autumn/shared";
import { Decimal } from "decimal.js";
export const paramsToFeatureOptions = ({
@@ -22,7 +22,7 @@ export const paramsToFeatureOptions = ({
const billingUnits = price.config.billing_units ?? 1;
if (options?.quantity) {
if (notNullish(options?.quantity)) {
// 1. Round options quantity to nearest billing units:
const roundedQuantity = roundUsageToNearestBillingUnit({
usage: options.quantity,

View File

@@ -1,5 +1,6 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction";
import { shouldCreateManualStripeInvoice } from "@/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice";
import { autumnBillingPlanToFinalFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer";
import type { BillingContext } from "../../../billingContext";
import { buildStripeInvoiceAction } from "../../../providers/stripe/actionBuilders/buildStripeInvoiceAction";
@@ -41,12 +42,14 @@ export const evaluateStripeBillingPlan = async ({
const { lineItems } = autumnBillingPlan;
const subscriptionActionIsCreate =
stripeSubscriptionAction?.type === "create";
const createManualInvoice = shouldCreateManualStripeInvoice({
billingContext,
stripeSubscriptionAction,
});
let stripeInvoiceAction: StripeInvoiceAction | undefined;
let stripeInvoiceItemsAction: StripeInvoiceItemsAction | undefined;
if (!subscriptionActionIsCreate) {
if (createManualInvoice) {
stripeInvoiceAction = buildStripeInvoiceAction({
lineItems,
});
@@ -63,7 +66,7 @@ export const evaluateStripeBillingPlan = async ({
ctx,
billingContext,
finalCustomerProducts: finalFullCustomer.customer_products,
trialEndsAt: billingContext.trialContext?.trialEndsAt,
trialEndsAt: billingContext.trialContext?.trialEndsAt ?? undefined,
});
return {

View File

@@ -0,0 +1,26 @@
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan";
export const shouldCreateManualStripeInvoice = ({
billingContext,
stripeSubscriptionAction,
}: {
billingContext: BillingContext;
stripeSubscriptionAction?: StripeSubscriptionAction;
}): boolean => {
const isCreateAction = stripeSubscriptionAction?.type === "create";
if (isCreateAction) return false;
const { stripeSubscription, trialContext } = billingContext;
if (!stripeSubscription) return false;
const isTrialing = isStripeSubscriptionTrialing(stripeSubscription);
const endingTrial = trialContext?.trialEndsAt === null;
const updateWillCharge =
stripeSubscriptionAction?.type === "update" && isTrialing && endingTrial;
if (updateWillCharge) return false;
return true;
};

View File

@@ -23,11 +23,6 @@ export const buildTransitionPoints = ({
}): (number | undefined)[] => {
const timestamps = new Set<number>();
// Add trial end as a transition point
if (trialEndsAt && trialEndsAt > nowMs) {
timestamps.add(trialEndsAt);
}
// Add new billing cycle anchor as a transition point
if (newBillingCycleAnchorMs && newBillingCycleAnchorMs > nowMs) {
timestamps.add(newBillingCycleAnchorMs);
@@ -51,5 +46,11 @@ export const buildTransitionPoints = ({
}
}
// Add trial end as a transition point only if schedule is required
// (i.e., there's at least one other transition point)
if (trialEndsAt && trialEndsAt > nowMs && timestamps.size > 0) {
timestamps.add(trialEndsAt);
}
return [...Array.from(timestamps).sort((a, b) => a - b), undefined];
};

View File

@@ -8,6 +8,7 @@ import type {
} from "@/internal/billing/v2/types/billingPlan";
export const buildStripeSubscriptionUpdateAction = ({
// biome-ignore lint/correctness/noUnusedFunctionParameters: might be used in the future
ctx,
billingContext,
subItemsUpdate,
@@ -31,11 +32,25 @@ export const buildStripeSubscriptionUpdateAction = ({
// When a schedule manages the subscription, don't set trial_end or cancel_at_period_end
// The schedule controls these via phase-level settings
const scheduleManagesSubscription = !!stripeSubscriptionScheduleAction;
const shouldSetTrialEnd = !scheduleManagesSubscription && trialEndsAt;
const shouldSetTrialEnd =
!scheduleManagesSubscription &&
trialEndsAt &&
msToSeconds(trialEndsAt) !== stripeSubscription?.trial_end;
console.log("shouldSetTrialEnd", shouldSetTrialEnd);
console.log("trialEndsAt", trialEndsAt);
console.log("stripeSubscription?.trial_end", stripeSubscription?.trial_end);
const shouldUnsetTrialEnd =
!scheduleManagesSubscription && trialEndsAt === null;
const params: Stripe.SubscriptionUpdateParams = {
items: subItemsUpdate.length > 0 ? subItemsUpdate : undefined,
trial_end: shouldSetTrialEnd ? msToSeconds(trialEndsAt) : undefined,
trial_end: shouldSetTrialEnd
? msToSeconds(trialEndsAt)
: shouldUnsetTrialEnd
? "now"
: undefined,
proration_behavior: "none",
};

View File

@@ -3,7 +3,6 @@ import {
type FullProduct,
isCustomerProductFree,
isCustomerProductOneOff,
isCustomerProductTrialing,
isFreeProduct,
isOneOffProduct,
secondsToMs,
@@ -49,16 +48,6 @@ export const setupBillingCycleAnchor = ({
? 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;

View File

@@ -31,7 +31,17 @@ export const setupTrialContext = ({
// Case 1: If free trial is null (removing free trial)
if (freeTrialParams === null) {
return { freeTrial: null, trialEndsAt: null };
// If currently trialing, then return this object, if not don't return anything
if (
isStripeSubscriptionTrialing(stripeSubscription) ||
isCustomerProductTrialing(customerProduct, { nowMs: currentEpochMs })
) {
return { freeTrial: null, trialEndsAt: null };
} else {
return undefined;
}
// return { freeTrial: null, trialEndsAt: null };
}
// Case 2: If free trial params are passed in

View File

@@ -12,8 +12,7 @@ export const computeUpdateSubscriptionIntent = (
params: UpdateSubscriptionV0Params,
): UpdateSubscriptionIntent => {
// Version change = plan update (takes priority)
if (params.version !== undefined)
return UpdateSubscriptionIntent.UpdatePlan;
if (params.version !== undefined) return UpdateSubscriptionIntent.UpdatePlan;
if (params.options?.length && !params.items?.length)
return UpdateSubscriptionIntent.UpdateQuantity;

View File

@@ -42,6 +42,7 @@ export const computeUpdateSubscriptionPlan = async ({
}
plan = finalizeUpdateSubscriptionPlan({
ctx,
plan,
billingContext,
});

View File

@@ -40,7 +40,10 @@ export const computeCustomPlanNewCustomerProduct = ({
cusProduct: customerProduct,
});
console.log("Reset cycle anchor: ", formatMs(resetCycleAnchorMs));
console.log("Trial context: ", {
trialEndsAt: formatMs(trialContext?.trialEndsAt),
freeTrial: trialContext?.freeTrial,
});
// Compute the new full customer product
const newFullCustomerProduct = initFullCustomerProduct({
@@ -56,7 +59,7 @@ export const computeCustomPlanNewCustomerProduct = ({
now: currentEpochMs,
freeTrial: trialContext?.freeTrial ?? null,
trialEndsAt: trialContext?.trialEndsAt,
trialEndsAt: trialContext?.trialEndsAt ?? undefined,
},
initOptions: {

View File

@@ -1,20 +1,47 @@
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { filterUnchangedPricesFromLineItems } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import { buildSharedSubscriptionTrialLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildSharedSubscriptionTrialLineItems";
import { filterLineItemsForTrialTransition } from "@/internal/billing/v2/compute/computeAutumnUtils/filterLineItemsForTrialTransition";
import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
export const finalizeUpdateSubscriptionPlan = ({
ctx,
plan,
billingContext,
billingContext,
}: {
ctx: AutumnContext;
plan: AutumnBillingPlan;
billingContext: BillingContext;
billingContext: UpdateSubscriptionBillingContext;
}): AutumnBillingPlan => {
// Filter line items based on trial state transitions
plan.lineItems = filterLineItemsForTrialTransition({
ctx,
lineItems: plan.lineItems,
billingContext,
});
// Filter out unchanged prices (refund + charge pairs that cancel out)
plan.lineItems = filterUnchangedPricesFromLineItems({
lineItems: plan.lineItems,
});
// Add line items for sibling products affected by trial state changes
const sharedTrialLineItems = buildSharedSubscriptionTrialLineItems({
ctx,
billingContext,
autumnBillingPlan: plan,
});
plan.lineItems = [...plan.lineItems, ...sharedTrialLineItems];
// Apply discounts
if (billingContext.stripeDiscounts?.length) {
plan.lineItems = applyStripeDiscountsToLineItems({
lineItems: plan.lineItems,
discounts: billingContext.stripeDiscounts,
});
}
return plan;
};
};

View File

@@ -105,6 +105,7 @@ export const computeUpdateQuantityDetails = ({
const lineItems = computeUpdateQuantityLineItems({
ctx,
billingContext: updateSubscriptionContext,
customerProduct,
feature,
billingPeriod,

View File

@@ -14,9 +14,11 @@ import {
usagePriceToLineItem,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
export const computeUpdateQuantityLineItems = ({
ctx,
billingContext,
customerProduct,
feature,
billingPeriod,
@@ -24,6 +26,7 @@ export const computeUpdateQuantityLineItems = ({
currentEpochMs,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
customerProduct: FullCusProduct;
feature: Feature;
billingPeriod?: BillingPeriod;
@@ -103,7 +106,10 @@ export const computeUpdateQuantityLineItems = ({
// Don't return line items if they sum to 0
if (
sumValues([refundLineItem.finalAmount, chargeLineItem.finalAmount]) === 0
sumValues([
refundLineItem?.finalAmount ?? 0,
chargeLineItem?.finalAmount ?? 0,
]) === 0
) {
return [];
}

View File

@@ -0,0 +1,60 @@
import {
cusProductToPrices,
ErrCode,
isPrepaidPrice,
RecaseError,
type UsagePriceConfig,
} from "@autumn/shared";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
export const handleFeatureQuantityErrors = ({
// biome-ignore lint/correctness/noUnusedFunctionParameters: consistent signature with other error handlers
billingContext,
autumnBillingPlan,
}: {
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0];
if (!newCustomerProduct) {
return;
}
const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct });
const prepaidPrices = newPrices.filter(isPrepaidPrice);
if (prepaidPrices.length === 0) {
return;
}
const options = newCustomerProduct.options || [];
const missingFeatures: string[] = [];
for (const price of prepaidPrices) {
const config = price.config as UsagePriceConfig;
const internalFeatureId = config.internal_feature_id;
// Check if there's an option for this prepaid price
const hasOption = options.some(
(opt) => opt.internal_feature_id === internalFeatureId,
);
if (!hasOption) {
// Try to find the feature_id from customer_entitlements
const cusEnt = newCustomerProduct.customer_entitlements?.find(
(ce) => ce.entitlement.internal_feature_id === internalFeatureId,
);
const featureId = cusEnt?.entitlement.feature_id || internalFeatureId;
missingFeatures.push(featureId);
}
}
if (missingFeatures.length > 0) {
throw new RecaseError({
message: `Missing quantity options for prepaid features: ${missingFeatures.join(", ")}`,
code: ErrCode.InvalidOptions,
statusCode: 400,
});
}
};

View File

@@ -0,0 +1,45 @@
import { cusProductToPrices, ErrCode, RecaseError } from "@autumn/shared";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
import { isOneOff } from "@/internal/products/productUtils";
export const handleProductTypeTransitionErrors = ({
billingContext,
autumnBillingPlan,
}: {
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0];
if (!newCustomerProduct) {
return;
}
const currentCustomerProduct = billingContext.customerProduct;
const currentPrices = cusProductToPrices({
cusProduct: currentCustomerProduct,
});
const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct });
const currentIsOneOff = isOneOff(currentPrices);
const newIsOneOff = isOneOff(newPrices);
if (!currentIsOneOff && newIsOneOff) {
throw new RecaseError({
message:
"Cannot update a subscription from a recurring product to a one-off product",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (currentIsOneOff && !newIsOneOff) {
throw new RecaseError({
message:
"Cannot update a subscription from a one-off product to a recurring product",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
};

View File

@@ -0,0 +1,31 @@
import { ProcessorType, RecaseError } from "@autumn/shared";
import { cusProductToProcessorType } from "@shared/utils/cusProductUtils/convertCusProduct";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan";
import { handleFeatureQuantityErrors } from "./handleFeatureQuantityErrors";
import { handleProductTypeTransitionErrors } from "./handleProductTypeTransitionErrors";
export const handleUpdateSubscriptionErrors = async ({
billingContext,
autumnBillingPlan,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const { customerProduct } = billingContext;
// 1. RevenueCat error
if (cusProductToProcessorType(customerProduct) === ProcessorType.RevenueCat) {
throw new RecaseError({
message: `Cannot update '${customerProduct.product.name}' because it is managed by RevenueCat.`,
});
}
// 2. Product type transition errors
handleProductTypeTransitionErrors({ billingContext, autumnBillingPlan });
// 3. Feature quantity errors (prepaid prices must have options)
handleFeatureQuantityErrors({ billingContext, autumnBillingPlan });
};

View File

@@ -1,5 +1,6 @@
import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared";
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan";
import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors";
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
import { executeBillingPlan } from "../execute/executeBillingPlan";
import { evaluateStripeBillingPlan } from "../providers/stripe/actionBuilders/evaluateStripeBillingPlan";
@@ -11,6 +12,9 @@ export const handleUpdateSubscription = createRoute({
const ctx = c.get("ctx");
const body = c.req.valid("json");
ctx.logger.info(`===============================================`);
ctx.logger.info(`UPDATE SUBSCRIPTION RUNNING FOR ${body.customer_id}`);
const billingContext = await setupUpdateSubscriptionBillingContext({
ctx,
params: body,
@@ -22,6 +26,12 @@ export const handleUpdateSubscription = createRoute({
params: body,
});
await handleUpdateSubscriptionErrors({
ctx,
billingContext,
autumnBillingPlan,
});
const stripeBillingPlan = await evaluateStripeBillingPlan({
ctx,
billingContext,

View File

@@ -1,4 +1,4 @@
import { formatMs, type UpdateSubscriptionV0Params } from "@autumn/shared";
import 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";
@@ -86,10 +86,6 @@ export const setupUpdateSubscriptionBillingContext = async ({
newFullProduct: fullProduct,
});
console.log("Billing cycle anchor: ", formatMs(billingCycleAnchorMs));
console.log("Trial ends at: ", formatMs(trialContext?.trialEndsAt));
console.log("Reset cycle anchor: ", formatMs(resetCycleAnchorMs));
const invoiceMode = setupInvoiceModeContext({ params });
return {

View File

@@ -0,0 +1,17 @@
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { BillingContext } from "@/internal/billing/v2/billingContext";
import { billingContextHasTrial } from "./billingContextHasTrial";
/** Gets the trial state transition for a billing context. */
export const getTrialStateTransition = ({
billingContext,
}: {
billingContext: BillingContext;
}) => {
const isTrialing = isStripeSubscriptionTrialing(
billingContext.stripeSubscription,
);
const willBeTrialing = billingContextHasTrial({ billingContext });
return { isTrialing, willBeTrialing };
};

View File

@@ -23,15 +23,25 @@ export const billingPlanToNextCyclePreview = ({
// 1. Return undefined if billing cycle anchor is now
const { billingCycleAnchorMs } = billingContext;
ctx.logger.info(`billingCycleAnchorMs: ${billingCycleAnchorMs}`);
ctx.logger.info(`billingCycleAnchorMs: ${billingCycleAnchorMs}`);
if (billingCycleAnchorMs === "now") return undefined;
const { insertCustomerProducts } = billingPlan.autumn;
const { insertCustomerProducts, updateCustomerProduct } = billingPlan.autumn;
// 2. Get cycle end and if none, return undefined
const customerProducts = insertCustomerProducts.filter(
(customerProduct) => cp(customerProduct).paid().recurring().valid,
const allCustomerProducts = [
...insertCustomerProducts,
...(updateCustomerProduct ? [updateCustomerProduct] : []),
];
const customerProducts = allCustomerProducts.filter(
(customerProduct) =>
cp(customerProduct).paid().recurring().hasActiveStatus().valid,
);
const prices = cusProductsToPrices({ cusProducts: customerProducts });
const smallestInterval = getSmallestInterval({ prices });
if (!smallestInterval) return undefined;

View File

@@ -1,7 +1,5 @@
import { CACHE_CUSTOMER_VERSIONS } from "../../../../_luaScripts/cacheConfig.js";
import {
getConfiguredRegions,
getRegionalRedis,
redis,
} from "../../../../external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
@@ -40,40 +38,40 @@ export const deleteCachedApiCustomer = async ({
try {
await deleteCachedFullCustomer({ ctx, customerId, source });
// Delete from all regions in parallel to avoid race conditions
const deletePromises = regions.map(async (region) => {
const regionalRedis = getRegionalRedis(region);
// const deletePromises = regions.map(async (region) => {
// const regionalRedis = getRegionalRedis(region);
// Check if this regional instance is ready
if (regionalRedis.status !== "ready") {
logger?.warn(`Redis not ready for region ${region}, skipping`, {
data: { status: regionalRedis.status, customerId, region },
});
return { region, deletedCount: 0, skipped: true };
}
// // Check if this regional instance is ready
// if (regionalRedis.status !== "ready") {
// logger?.warn(`Redis not ready for region ${region}, skipping`, {
// data: { status: regionalRedis.status, customerId, region },
// });
// return { region, deletedCount: 0, skipped: true };
// }
const deletedCount = await regionalRedis.deleteCustomer(
CACHE_CUSTOMER_VERSIONS.LATEST,
org.id,
env,
customerId,
);
// const deletedCount = await regionalRedis.deleteCustomer(
// CACHE_CUSTOMER_VERSIONS.LATEST,
// org.id,
// env,
// customerId,
// );
return { region, deletedCount, skipped: false };
});
// return { region, deletedCount, skipped: false };
// });
const results = await Promise.all(deletePromises);
// const results = await Promise.all(deletePromises);
const totalDeleted = results.reduce(
(sum, r) => sum + (r.deletedCount || 0),
0,
);
const regionsSummary = results
.map((r) => `${r.region}: ${r.skipped ? "skipped" : r.deletedCount}`)
.join(", ");
// const totalDeleted = results.reduce(
// (sum, r) => sum + (r.deletedCount || 0),
// 0,
// );
// const regionsSummary = results
// .map((r) => `${r.region}: ${r.skipped ? "skipped" : r.deletedCount}`)
// .join(", ");
logger.info(
`Deleted cache keys for customer ${customerId}. Source: ${source}, keys: ${totalDeleted}, regions: ${regions.length} (${regionsSummary})`,
);
// logger.info(
// `Deleted cache keys for customer ${customerId}. Source: ${source}, keys: ${totalDeleted}, regions: ${regions.length} (${regionsSummary})`,
// );
} catch (error) {
logger.error(`Error deleting customer with entities: ${error}`);
throw error;

View File

@@ -13,6 +13,9 @@ export const ADMIN_USER_IDs = [
"user_2sB3tBXsnVVLlTKliQIqvvM2xfB", // j
"ZsDswIXyOGMP9y1V1At4dAZNaiggClSs", // t
"NqNuL3MtS7MR2WYoqx2b28iY9vmfAs8h", // c
// Sandbox:
"user_2rypooIKyMQx81vMS8FFGx24UHU", // john
];
export const dashboardOrigins = [

View File

@@ -191,6 +191,29 @@ const { customerId, autumnV1, advancedTo } = await initScenario({
---
## Test Clocks
**Critical:** `Date.now()` does NOT change when using `s.advanceTestClock`. Always use `advancedTo` from `initScenario`.
```typescript
const { advancedTo } = await initScenario({
actions: [
s.attach({ productId: pro.id }),
s.advanceTestClock({ days: 3 }),
],
});
// ❌ WRONG - Date.now() is still real time
expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(4));
// ✅ CORRECT - Use advancedTo (Stripe test clock's current time)
expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(4));
```
`advancedTo` is the Unix timestamp (ms) of the Stripe test clock after all `s.advanceTestClock` actions complete.
---
## Product ID in `s.attach()`
**Important:** Always use the product variable's `.id` property in `s.attach()`, never a string literal.

View File

@@ -950,3 +950,82 @@ test.concurrent(`${chalk.yellowBright("prepaid: zero usage, change item config")
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// FREE TO PAID: PREPAID ITEM WITH ZERO QUANTITY
// ═══════════════════════════════════════════════════════════════════════════════
// Update from free product to paid with prepaid item, passing 0 quantity
test.concurrent(`${chalk.yellowBright("prepaid: free to paid with zero quantity")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem], id: "free" });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "free-to-prepaid-zero-qty",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: free.id })],
});
// Track some usage on the free product
const messagesUsed = 30;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsed,
},
{ timeout: 2000 },
);
// Update to add prepaid messages item with 0 quantity
const billingUnits = 100;
const pricePerPack = 10;
const prepaidItem = items.prepaidMessages({
includedUsage: 0,
billingUnits,
price: pricePerPack,
});
const priceItem = items.monthlyPrice({ price: 20 });
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// With 0 quantity, no packs are purchased
// Total = base price only = $20
expect(preview.total).toBe(priceItem.price);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// With 0 quantity: includedUsage = 0, balance = 0 - messagesUsed (goes negative)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 0, // 0 + 0 = 0
balance: Math.max(0, 0 - messagesUsed), // 0 - 30 = -30 (negative balance)
usage: 0,
});
await expectCustomerInvoiceCorrect({
customer,
count: 1, // Initial free invoice + update invoice
latestTotal: preview.total,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -0,0 +1,171 @@
import { test } from "bun:test";
import { ErrCode } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.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";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
// ═══════════════════════════════════════════════════════════════════════════════
// PRODUCT TYPE TRANSITION ERRORS
// ═══════════════════════════════════════════════════════════════════════════════
// 1. Cannot update from recurring product to one-off product
test.concurrent(`${chalk.yellowBright("error: recurring to one-off transition")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const recurring = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "err-rec-to-oneoff",
setup: [s.customer({}), s.products({ list: [recurring] })],
actions: [s.attach({ productId: "base" })],
});
// Try to update to one-off items (price with null interval)
const oneOffPriceItem = constructPriceItem({
price: 50,
interval: null, // One-off
});
const updateParams = {
customer_id: customerId,
product_id: recurring.id,
items: [oneOffPriceItem],
};
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 2. Cannot update from one-off product to recurring product
test.concurrent(`${chalk.yellowBright("error: one-off to recurring transition")}`, async () => {
// Create a one-off prepaid product
const oneOffPrepaidItem = constructPrepaidItem({
featureId: TestFeature.Messages,
price: 50,
billingUnits: 100,
includedUsage: 0,
isOneOff: true,
});
const oneOff = products.base({ items: [oneOffPrepaidItem], id: "oneoff" });
const { customerId, autumnV1 } = await initScenario({
customerId: "err-oneoff-to-rec",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOff] }),
],
actions: [
s.attach({
productId: "oneoff",
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
}),
],
});
// Try to update to recurring items
const recurringMessagesItem = items.monthlyMessages({ includedUsage: 100 });
const updateParams = {
customer_id: customerId,
product_id: oneOff.id,
items: [recurringMessagesItem],
};
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 3. Cannot update from paid recurring (pro) to one-off
test.concurrent(`${chalk.yellowBright("error: paid recurring (pro) to one-off transition")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "err-pro-to-oneoff",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Try to update to one-off items
const oneOffPriceItem = constructPriceItem({
price: 50,
interval: null, // One-off
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [oneOffPriceItem],
};
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 4. Cannot update from one-off to paid recurring (with monthly price)
test.concurrent(`${chalk.yellowBright("error: one-off to paid recurring transition")}`, async () => {
// Create a one-off prepaid product
const oneOffPrepaidItem = constructPrepaidItem({
featureId: TestFeature.Messages,
price: 50,
billingUnits: 100,
includedUsage: 0,
isOneOff: true,
});
const oneOff = products.base({
items: [oneOffPrepaidItem],
id: "oneoff-paid",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-oneoff-to-paid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOff] }),
],
actions: [
s.attach({
productId: "oneoff-paid",
options: [{ feature_id: TestFeature.Messages, quantity: 1 }],
}),
],
});
// Try to update to paid recurring items (monthly price + feature)
const monthlyPriceItem = items.monthlyPrice({ price: 20 });
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const updateParams = {
customer_id: customerId,
product_id: oneOff.id,
items: [monthlyPriceItem, messagesItem],
};
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});

View File

@@ -0,0 +1,147 @@
import { test } from "bun:test";
import { ErrCode } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.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";
// ═══════════════════════════════════════════════════════════════════════════════
// FEATURE QUANTITY ERRORS (PREPAID PRICES REQUIRE OPTIONS)
// ═══════════════════════════════════════════════════════════════════════════════
// 1. Free product → update with prepaid messages but no options → error
test.concurrent(`${chalk.yellowBright("error: update with prepaid but missing options")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "err-prepaid-no-opts",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Try to update with prepaid messages item but no options
const prepaidMessagesItem = items.prepaidMessages({
includedUsage: 0,
price: 10,
billingUnits: 100,
});
const updateParams = {
customer_id: customerId,
product_id: free.id,
items: [prepaidMessagesItem],
// Missing options!
};
await expectAutumnError({
errCode: ErrCode.InvalidOptions,
errMessage: "Missing quantity options for prepaid features",
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 2. Pro with prepaidMessages → update to add prepaidWords but missing options for words
test.concurrent(`${chalk.yellowBright("error: add prepaid feature without options")}`, async () => {
const prepaidMessagesItem = items.prepaidMessages({
includedUsage: 0,
price: 10,
billingUnits: 100,
});
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
items: [priceItem, prepaidMessagesItem],
id: "pro",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-add-prepaid-no-opts",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity: 5 }],
}),
],
});
// Try to add prepaidWords but don't provide options for it
const prepaidWordsItem = items.prepaid({
featureId: TestFeature.Words,
price: 15,
billingUnits: 100,
includedUsage: 0,
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [priceItem, prepaidMessagesItem, prepaidWordsItem],
options: [
{ feature_id: TestFeature.Messages, quantity: 5 }, // Only messages, missing words
],
};
await expectAutumnError({
errCode: ErrCode.InvalidOptions,
errMessage: "Missing quantity options for prepaid features",
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});
// 3. Pro with prepaidMessages → update with negative quantity → error (from zod validation)
test.concurrent(`${chalk.yellowBright("error: negative quantity for prepaid feature")}`, async () => {
const prepaidMessagesItem = items.prepaidMessages({
includedUsage: 0,
price: 10,
billingUnits: 100,
});
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
items: [priceItem, prepaidMessagesItem],
id: "pro-neg",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "err-negative-qty",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: "pro-neg",
options: [{ feature_id: TestFeature.Messages, quantity: 5 }],
}),
],
});
// Try to update with negative quantity
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [priceItem, prepaidMessagesItem],
options: [
{ feature_id: TestFeature.Messages, quantity: -1 }, // Negative quantity
],
};
await expectAutumnError({
errMessage: "Options quantity must be >= 0",
func: async () => {
await autumnV1.subscriptions.update(updateParams);
},
});
});

View File

@@ -12,7 +12,6 @@ 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";
@@ -29,7 +28,7 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: add paid with free_trial param
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "f2p-trial-param",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
@@ -70,7 +69,7 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: add paid with free_trial param
// next_cycle should show when trial ends and what the charge will be
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(7),
startsAt: advancedTo + ms.days(7),
total: priceItem.price!,
});
@@ -82,7 +81,7 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: add paid with free_trial param
await expectProductTrialing({
customer,
productId: free.id,
trialEndsAt: Date.now() + ms.days(7),
trialEndsAt: advancedTo + ms.days(7),
});
// Usage should be preserved
@@ -119,29 +118,24 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial -> free, updat
trialDays: 14,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
const { customerId, autumnV1 } = 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 })],
actions: [
s.attach({ productId: freeWithTrial.id }),
s.advanceTestClock({ days: 5 }), // Advance 5 days (mid-trial)
],
});
// Verify initially trialing
// Verify initially trialing (get the trial end time before update)
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,
});
const initialTrialEnd = customerBefore.products?.find(
(p) => p.id === freeWithTrial.id,
)?.current_period_end;
// Update mid-trial - change included usage (no free_trial param = keep existing trial)
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
@@ -196,29 +190,24 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-tr
trialDays: 14,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
const { customerId, autumnV1, advancedTo } = 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 })],
actions: [
s.attach({ productId: freeWithTrial.id }),
s.advanceTestClock({ days: 5 }), // Advance 5 days (mid-trial)
],
});
// Verify initially trialing
// Get the initial trial end time before update
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,
});
const initialTrialEnd = customerBefore.products?.find(
(p) => p.id === freeWithTrial.id,
)?.current_period_end;
// Update mid-trial WITH new free_trial param - extend to 30 days from now
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
@@ -250,11 +239,11 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-tr
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Trial should be extended to 30 days from now
// Trial should be extended to 30 days from advancedTo (test clock time)
const newTrialEnd = await expectProductTrialing({
customer,
productId: freeWithTrial.id,
trialEndsAt: Date.now() + ms.days(35), // 5 days advanced + 30 day new trial
trialEndsAt: advancedTo! + ms.days(30), // advancedTo + 30 day new trial
});
// New trial end should be later than original
@@ -282,28 +271,16 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-tr
trialDays: 14,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
const { customerId, autumnV1, ctx, advancedTo } = 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,
actions: [
s.attach({ productId: freeWithTrial.id }),
s.advanceTestClock({ days: 5 }), // Advance 5 days (mid-trial)
],
});
// Update mid-trial to PAID product (add price item)
@@ -334,6 +311,7 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-tr
await expectProductNotTrialing({
customer,
productId: freeWithTrial.id,
nowMs: advancedTo,
});
// Product should be active
@@ -359,7 +337,7 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free no trial -> free with tri
id: "free-no-trial",
});
const { customerId, autumnV1 } = await initScenario({
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "f2p-trial-items-undefined",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
@@ -402,7 +380,7 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free no trial -> free with tri
await expectProductTrialing({
customer,
productId: free.id,
trialEndsAt: Date.now() + ms.days(14),
trialEndsAt: advancedTo + ms.days(14),
});
// Feature should still have correct values (unchanged since items undefined)
@@ -427,28 +405,16 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-tr
trialDays: 14,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
const { customerId, autumnV1, advancedTo } = 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,
actions: [
s.attach({ productId: freeWithTrial.id }),
s.advanceTestClock({ days: 5 }), // Advance 5 days (mid-trial)
],
});
// Update mid-trial WITH free_trial: null - remove trial
@@ -474,6 +440,7 @@ test.concurrent(`${chalk.yellowBright("f2p-trial: free with trial, update mid-tr
await expectProductNotTrialing({
customer,
productId: freeWithTrial.id,
nowMs: advancedTo,
});
// Product should now be active

View File

@@ -12,7 +12,6 @@ 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";
@@ -33,28 +32,16 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}`
trialDays: 14,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "p2p-remove-trial-active",
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,
});
// Advance to mid-trial (7 days into 14-day trial)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 7,
actions: [
s.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 7 }),
],
});
// Remove the trial by passing free_trial: null
@@ -73,18 +60,18 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}`
// When trial is removed, next_cycle should start in ~1 month (regular billing)
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(30),
total: items.monthlyPrice().price!,
expectDefined: false,
});
await autumnV1.subscriptions.update(updateParams);
await autumnV1.subscriptions.update(updateParams, { timeout: 5000 });
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Product should no longer be trialing
// Product should no longer be trialing (use advancedTo for test clock time)
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedTo,
});
// Should now be active (not trialing)
@@ -105,6 +92,9 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial while running")}`
customerId,
org: ctx.org,
env: ctx.env,
flags: {
checkNotTrialing: true,
},
});
});
@@ -118,28 +108,16 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial after ended (no-o
trialDays: 7,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "p2p-remove-trial-ended",
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,
});
// Advance past trial period (10 days to be safe)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 10,
actions: [
s.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 14 }), // Advance to trial end
],
});
// After advancing, product should no longer be trialing
@@ -148,13 +126,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial after ended (no-o
await expectProductNotTrialing({
customer: customerAfterAdvance,
productId: proTrial.id,
nowMs: advancedTo,
});
// Now try to remove trial (should be no-op since trial already ended)
const updateParams = {
customer_id: customerId,
product_id: proTrial.id,
items: [messagesItem, items.monthlyPrice()],
free_trial: null,
};
@@ -171,6 +149,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial after ended (no-o
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedTo,
});
await expectSubToBeCorrect({
@@ -178,6 +157,14 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: remove trial after ended (no-o
customerId,
org: ctx.org,
env: ctx.env,
flags: {
checkNotTrialing: true,
},
});
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial $0 trial invoice + $20 charge for trial ending
});
});
@@ -191,13 +178,16 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: trial carries over when undefi
trialDays: 14,
});
const { customerId, autumnV1, ctx } = await initScenario({
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "p2p-trial-carryover",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
actions: [
s.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 7 }),
],
});
// Verify initially trialing
@@ -226,10 +216,10 @@ 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)
// next_cycle should align with existing trial (~7 days remaining from 14-day trial after 7 days advanced)
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
startsAt: advancedTo + ms.days(7),
total: items.monthlyPrice().price!,
});
@@ -276,22 +266,25 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial")
trialDays: 7,
});
const { customerId, autumnV1, ctx } = await initScenario({
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "p2p-replace-trial",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
actions: [
s.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 3 }), // Advance 3 days into 7-day trial
],
});
// Verify initially trialing (7 days)
// Verify still trialing (4 days remaining from advancedTo)
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerBefore,
productId: proTrial.id,
trialEndsAt: Date.now() + ms.days(7),
trialEndsAt: advancedTo + ms.days(4), // 7 - 3 = 4 days remaining
});
// Replace with a new 30-day trial
@@ -315,7 +308,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial")
// next_cycle should show new 30-day trial end
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(30),
startsAt: advancedTo + ms.days(30),
total: items.monthlyPrice().price!,
});
@@ -327,7 +320,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial")
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: Date.now() + ms.days(30),
trialEndsAt: advancedTo + ms.days(30),
});
await expectSubToBeCorrect({
@@ -339,17 +332,17 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: replace trial with new trial")
});
// 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 () => {
test.concurrent(`${chalk.yellowBright("p2p-trial: paid no trial -> paid with trial")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice();
const pro = products.pro({
const pro = products.base({
items: [messagesItem, priceItem],
id: "pro-no-trial",
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "p2p-trial-items-undefined",
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "p2p-no-trial-to-trial",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
@@ -357,6 +350,17 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: paid no trial -> paid with tri
actions: [s.attach({ productId: pro.id })],
});
// Track some usage before update
const messagesUsage = 35;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Verify initially NOT trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
@@ -380,13 +384,13 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: paid no trial -> paid with tri
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 during trial (trial being added)
expect(preview.total).toEqual(0);
// Should be refunded for unused time (-$20)
expect(preview.total).toEqual(-20);
// next_cycle should show when trial ends
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
startsAt: advancedTo + ms.days(14),
total: priceItem.price!,
});
@@ -398,16 +402,17 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: paid no trial -> paid with tri
await expectProductTrialing({
customer,
productId: pro.id,
trialEndsAt: Date.now() + ms.days(14),
trialEndsAt: advancedTo + ms.days(14),
});
// Feature should still have correct values (unchanged since items undefined)
// Feature should still have correct values with usage preserved
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage,
usage: 0,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
resetsAt: advancedTo + ms.days(14),
});
await expectSubToBeCorrect({
@@ -428,32 +433,34 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")}
trialDays: 7,
});
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "p2p-new-trial-after-expired",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
actions: [
s.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 10 }), // Advance past 7-day trial
],
});
// Verify initially trialing
await expectProductTrialing({
customerId,
productId: proTrial.id,
});
// Track some usage before update
const messagesUsage = 45;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Advance past trial period
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 10,
});
// Verify no longer trialing
// Verify no longer trialing (trial has ended)
await expectProductNotTrialing({
customerId,
productId: proTrial.id,
nowMs: advancedTo,
});
// Add a new 14-day trial
@@ -477,7 +484,7 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")}
// next_cycle should show new 14-day trial end
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
startsAt: advancedTo + ms.days(14),
total: items.monthlyPrice().price!,
});
@@ -489,7 +496,17 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")}
await expectProductTrialing({
customer,
productId: proTrial.id,
trialEndsAt: Date.now() + ms.days(14),
trialEndsAt: advancedTo + ms.days(14),
});
// Usage should be preserved, reset should follow new trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
resetsAt: advancedTo + ms.days(14),
});
await expectSubToBeCorrect({
@@ -499,3 +516,161 @@ test.concurrent(`${chalk.yellowBright("p2p-trial: new trial after old expired")}
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// PAID-TO-FREE WITH TRIAL
// ═══════════════════════════════════════════════════════════════════════════════
// 7. Paid (no trial) -> Free with trial
test.concurrent(`${chalk.yellowBright("p2f-trial: paid no trial -> free with trial")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice();
const pro = products.base({
items: [messagesItem, priceItem],
id: "pro-no-trial",
});
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "p2f-no-trial-to-trial",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Track some usage before update
const messagesUsage = 40;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Verify initially NOT trialing
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer: customerBefore,
productId: pro.id,
});
// Update to free (remove price) but add trial
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [messagesItem], // No price item = free
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
card_required: false,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be refunded for the removed price (-$20)
expect(preview.total).toEqual(-20);
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: advancedTo + ms.days(14),
});
// Usage should be preserved, reset should follow new trial end
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
resetsAt: advancedTo + ms.days(14),
});
});
// 8. Paid with trial (mid-cycle after trial ended) -> Free (no trial)
test.concurrent(`${chalk.yellowBright("p2f-trial: paid with trial -> free no trial")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const proTrial = products.proWithTrial({
items: [messagesItem],
id: "pro-trial",
trialDays: 7,
});
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "p2f-trial-to-no-trial",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [
s.attach({ productId: proTrial.id }),
s.advanceTestClock({ days: 10 }), // Advance past 7-day trial to mid-cycle
],
});
// Track some usage before update
const messagesUsage = 30;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 2000 },
);
// Verify no longer trialing (trial has ended, now paying)
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({
customer: customerBefore,
productId: proTrial.id,
nowMs: advancedTo,
});
// Update to free (remove price), no trial specified
const updateParams = {
customer_id: customerId,
product_id: proTrial.id,
items: [messagesItem], // No price item = free
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should credit for the removed price
expect(preview.total).toBeLessThanOrEqual(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Product should NOT be trialing
await expectProductNotTrialing({
customer,
productId: proTrial.id,
nowMs: advancedTo,
});
// Usage should be preserved, reset should be from advancedTo + 1 month
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: messagesItem.included_usage,
balance: messagesItem.included_usage - messagesUsage,
usage: messagesUsage,
// resetsAt: advancedTo + ms.days(30),
});
});

View File

@@ -1,11 +1,7 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/billing/utils/expectCustomerFeatureCorrect";
import {
expectFeatureResetAlignedWithTrialEnd,
expectPeriodEndsAlignedWithTrialEnd,
expectProductTrialing,
} from "@tests/billing/utils/expectCustomerProductTrialing";
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
import { 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";
@@ -31,7 +27,7 @@ test.concurrent(`${chalk.yellowBright("trial-qty: update prepaid quantity while
trialDays: 14,
});
const { customerId, autumnV1, ctx } = await initScenario({
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "trial-qty-prepaid",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
@@ -65,10 +61,12 @@ test.concurrent(`${chalk.yellowBright("trial-qty: update prepaid quantity while
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(0);
// next_cycle should align with existing 14-day trial
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
startsAt: advancedTo + ms.days(14),
});
await autumnV1.subscriptions.update(updateParams);
@@ -90,176 +88,10 @@ test.concurrent(`${chalk.yellowBright("trial-qty: update prepaid quantity while
org: ctx.org,
env: ctx.env,
});
});
// 2. Update allocated seats while trialing
test.concurrent(`${chalk.yellowBright("trial-qty: update allocated seats while trialing")}`, async () => {
const allocatedItem = items.allocatedUsers({ includedUsage: 2 });
const proTrial = products.proWithTrial({
items: [allocatedItem],
id: "pro-trial",
trialDays: 14,
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "trial-qty-allocated",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
// Verify initially trialing
await expectProductTrialing({
customerId,
productId: proTrial.id,
});
// Track 5 users (beyond included 2)
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Users,
value: 5,
},
{ timeout: 2000 },
);
// Verify users tracked
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerBefore.features[TestFeature.Users].usage).toEqual(5);
// Update to increase included users to 10
const updatedAllocatedItem = items.allocatedUsers({ includedUsage: 10 });
const updateParams = {
customer_id: customerId,
product_id: proTrial.id,
items: [updatedAllocatedItem, items.monthlyPrice()],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// 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);
// Trial should still be active
await expectProductTrialing({
await expectCustomerInvoiceCorrect({
customer,
productId: proTrial.id,
});
// Usage preserved, but now within included
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: updatedAllocatedItem.included_usage,
balance: updatedAllocatedItem.included_usage - 5, // 10 - 5 = 5
usage: 5,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 3. Verify next_reset_at aligns with trial end
test.concurrent(`${chalk.yellowBright("trial-qty: next_reset_at aligns with trial end")}`, 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: "trial-qty-reset-align",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify trialing
const trialEndsAt = await expectProductTrialing({
customer,
productId: proTrial.id,
});
// Verify next_reset_at aligns with trial end
await expectFeatureResetAlignedWithTrialEnd({
customer,
featureId: TestFeature.Messages,
trialEndsAt: trialEndsAt!,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4. Verify period_ends aligns with trial end
test.concurrent(`${chalk.yellowBright("trial-qty: period_ends aligns with trial end")}`, 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: "trial-qty-period-align",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify trialing
const trialEndsAt = await expectProductTrialing({
customer,
productId: proTrial.id,
});
// For a trialing product, current_period_end IS the trial end
// So this verifies the period_ends field equals trial end
await expectPeriodEndsAlignedWithTrialEnd({
customer,
productId: proTrial.id,
trialEndsAt: trialEndsAt!,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
count: 1,
latestTotal: preview.total,
});
});

View File

@@ -1,21 +1,18 @@
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,
expectProductCanceling,
expectProductScheduled,
} from "@tests/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/billing/utils/expectCustomerProductTrialing";
import { 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";
@@ -28,7 +25,7 @@ import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
*/
// 1. Separate entities, separate trials
test.concurrent(`${chalk.yellowBright("trial-multi: separate entities have separate trials")}`, async () => {
test.concurrent(`${chalk.yellowBright("trial-multi: free to paid with trial merges with existing subscription")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const proTrial = products.proWithTrial({
@@ -42,33 +39,33 @@ test.concurrent(`${chalk.yellowBright("trial-multi: separate entities have separ
items: [messagesItem],
});
const { customerId, autumnV1, ctx, entities } = await initScenario({
customerId: "trial-multi-separate",
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
],
});
const { customerId, autumnV1, ctx, entities, advancedTo } =
await initScenario({
customerId: "trial-multi-separate",
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 entity0 = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductTrialing({
customer: entity0,
productId: proTrial.id,
trialEndsAt: Date.now() + ms.days(14),
trialEndsAt: advancedTo + ms.days(14),
});
// Verify entity 1 is also trialing (merged with entity 0's trial subscription)
const entity1 = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
await expectProductActive({
customer: entity1,
productId: free.id,
trialEndsAt: Date.now() + ms.days(14),
});
// Upgrade entity 1 to paid with a different trial length
@@ -79,43 +76,360 @@ test.concurrent(`${chalk.yellowBright("trial-multi: separate entities have separ
entity_id: entities[1].id,
product_id: free.id,
items: [messagesItem, priceItem],
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
card_required: true,
},
};
await autumnV1.subscriptions.update(updateParams);
// Verify entity 1 is now trialing with 7-day trial
const entity1After = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
customer: entity1After,
productId: free.id,
trialEndsAt: Date.now() + ms.days(7),
});
// Entity 0 should still have its original 14-day trial
const entity0After = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductTrialing({
customer: entity0After,
productId: proTrial.id,
trialEndsAt: Date.now() + ms.days(14),
trialEndsAt: advancedTo + ms.days(14),
});
// Verify the trial end dates are different
const entity0TrialEnd = entity0After.products?.find(
// 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: advancedTo + ms.days(14),
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 2. Entity 1 Pro, Entity 2 Free -> Entity 2 updates to paid with trial -> Both get trial
test.concurrent(`${chalk.yellowBright("trial-multi: free to paid with trial applies trial to all entities")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice();
const pro = products.base({
items: [messagesItem, priceItem],
id: "pro",
});
const free = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1, ctx, entities, advancedTo } =
await initScenario({
customerId: "trial-multi-free-to-trial",
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 }), // Entity 0 gets paid pro
s.attach({ productId: free.id, entityIndex: 1 }), // Entity 1 gets free product
],
});
// Verify entity 0 is active (not trialing)
const entity0 = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductActive({
customer: entity0,
productId: pro.id,
});
// Verify entity 1 is active (free)
const entity1 = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductActive({
customer: entity1,
productId: free.id,
});
// Update entity 1 to paid with trial
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,
},
};
// Preview should show -$20 refund (entity 0's paid pro gets refunded)
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(-20);
await autumnV1.subscriptions.update(updateParams, { timeout: 4000 });
// Both entities should now be trialing
const entity0After = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductTrialing({
customer: entity0After,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
});
const entity1After = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
customer: entity1After,
productId: free.id,
trialEndsAt: advancedTo + ms.days(14),
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 3. Entity 1 Pro, Entity 2 Pro -> Entity 2 sets trial with undefined items -> Both get trial
test.concurrent(`${chalk.yellowBright("trial-multi: setting trial on one entity applies to all")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice();
const pro = products.base({
items: [messagesItem, priceItem],
id: "pro",
});
const { customerId, autumnV1, ctx, entities, advancedTo } =
await initScenario({
customerId: "trial-multi-set-trial",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: pro.id, entityIndex: 0 }), // Entity 0 gets paid pro
s.attach({ productId: pro.id, entityIndex: 1 }), // Entity 1 gets paid pro (merges)
],
});
// Verify both entities are active (not trialing)
const entity0 = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductActive({
customer: entity0,
productId: pro.id,
});
const entity1 = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductActive({
customer: entity1,
productId: pro.id,
});
// Entity 1 sets trial with items undefined
const updateParams = {
customer_id: customerId,
entity_id: entities[1].id,
product_id: pro.id,
// items is undefined
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
// Preview should show -$40 refund (both entities refunded $20 each)
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(-40);
await autumnV1.subscriptions.update(updateParams, { timeout: 4000 });
// Both entities should now be trialing
const entity0After = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductTrialing({
customer: entity0After,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
});
const entity1After = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
customer: entity1After,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 4. Entity 1 Pro Trial, Entity 2 Pro Trial -> Entity 2 removes trial -> Both active without trial
test.concurrent(`${chalk.yellowBright("trial-multi: removing trial on one entity removes from all")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const proTrial = products.proWithTrial({
items: [messagesItem],
id: "pro-trial",
trialDays: 14,
});
const { customerId, autumnV1, ctx, entities, advancedTo } =
await initScenario({
customerId: "trial-multi-remove-trial",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: proTrial.id, entityIndex: 0 }), // Entity 0 gets trial product
s.attach({ productId: proTrial.id, entityIndex: 1 }), // Entity 1 gets trial product (merges)
],
});
// Verify both entities are trialing
const entity0 = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductTrialing({
customer: entity0,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
const entity1 = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
customer: entity1,
productId: proTrial.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Entity 1 removes trial by passing free_trial: null
const updateParams = {
customer_id: customerId,
entity_id: entities[1].id,
product_id: proTrial.id,
items: [messagesItem, items.monthlyPrice()],
free_trial: null,
};
// Preview should show $40 charge (both entities charged $20 each)
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expect(preview.total).toEqual(40);
await autumnV1.subscriptions.update(updateParams);
// Both entities should now be active (not trialing)
const entity0After = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductActive({
customer: entity0After,
productId: proTrial.id,
});
const entity1After = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductActive({
customer: entity1After,
productId: proTrial.id,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: {
checkNotTrialing: true,
},
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 3, // Initial $0 trial invoice + $20 charge for trial ending
latestTotal: preview.total,
});
});
// SCHEDULES
// 3. Trial carry-over across schedule phases
test.concurrent(`${chalk.yellowBright("trial-multi: trial preserved when schedule exists")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const proTrial = products.proWithTrial({
items: [messagesItem],
id: "pro-trial",
trialDays: 14,
});
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "trial-multi-schedule-preserve",
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,
});
const initialTrialEnd = customerBefore.products?.find(
(p) => p.id === proTrial.id,
)?.current_period_end;
const entity1TrialEnd = entity1After.products?.find(
(p) => p.id === free.id,
)?.current_period_end;
expect(entity0TrialEnd).toBeDefined();
expect(entity1TrialEnd).toBeDefined();
// Entity 0 trial should be ~7 days longer than entity 1
expect(entity0TrialEnd! - entity1TrialEnd!).toBeGreaterThan(ms.days(5));
// Update the subscription (this might create a schedule in some cases)
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const updateParams = {
customer_id: customerId,
product_id: proTrial.id,
items: [updatedMessagesItem, items.monthlyPrice()],
// 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: advancedTo + ms.days(14),
total: items.monthlyPrice().price!,
});
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Trial should still be preserved
await expectProductTrialing({
customer,
productId: proTrial.id,
});
// Verify trial end is approximately the same
const newTrialEnd = customer.products?.find(
(p) => p.id === proTrial.id,
)?.current_period_end;
expect(Math.abs(newTrialEnd! - initialTrialEnd!)).toBeLessThan(60000);
// Feature updated
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: updatedMessagesItem.included_usage,
balance: updatedMessagesItem.included_usage,
usage: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
@@ -199,291 +513,3 @@ test.concurrent(`${chalk.yellowBright("trial-multi: trial with scheduled downgra
env: ctx.env,
});
});
// 3. Trial carry-over across schedule phases
test.concurrent(`${chalk.yellowBright("trial-multi: trial preserved when schedule exists")}`, 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: "trial-multi-schedule-preserve",
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,
});
const initialTrialEnd = customerBefore.products?.find(
(p) => p.id === proTrial.id,
)?.current_period_end;
// Update the subscription (this might create a schedule in some cases)
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const updateParams = {
customer_id: customerId,
product_id: proTrial.id,
items: [updatedMessagesItem, items.monthlyPrice()],
// 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);
// Trial should still be preserved
await expectProductTrialing({
customer,
productId: proTrial.id,
});
// Verify trial end is approximately the same
const newTrialEnd = customer.products?.find(
(p) => p.id === proTrial.id,
)?.current_period_end;
expect(Math.abs(newTrialEnd! - initialTrialEnd!)).toBeLessThan(60000);
// 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,
});
});
// 4. Free to paid with trial, merging with existing subscription
test.concurrent(`${chalk.yellowBright("trial-multi: free to paid with trial merges with existing subscription")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const free = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1, ctx, entities } = await initScenario({
customerId: "trial-multi-merge",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: pro.id, entityIndex: 0 }),
s.attach({ productId: free.id, entityIndex: 1 }),
],
});
// Verify entity 0 is on paid pro (not trialing)
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductActive({ customer: entity1, productId: pro.id });
await expectProductNotTrialing({ customer: entity1, productId: pro.id });
// Now upgrade entity 1 from free to paid with trial
const priceItem = items.monthlyPrice();
const updateParams = {
customer_id: customerId,
entity_id: entities[1].id,
product_id: free.id,
items: [messagesItem, priceItem],
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 during trial
expect(preview.total).toEqual(0);
// next_cycle should show when 7-day trial ends
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(7),
total: priceItem.price!,
});
await autumnV1.subscriptions.update(updateParams);
// Verify entity 1 is now trialing
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
customer: entity2,
productId: free.id,
trialEndsAt: Date.now() + ms.days(7),
});
// Entity 0 should still not be trialing
const entity1After = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductNotTrialing({
customer: entity1After,
productId: pro.id,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 5. Free customer -> entities subscribe to trial product -> advance cycle -> update free to paid (merges with existing)
test.concurrent(`${chalk.yellowBright("trial-multi: free to paid after trial cycle merges with subscription")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice();
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem, priceItem],
trialDays: 7,
});
const free = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV1, ctx, entities, testClockId } =
await initScenario({
customerId: "trial-multi-after-cycle",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: proTrial.id, entityIndex: 0 }), // Entity 0 gets trial product
s.attach({ productId: free.id, entityIndex: 1 }), // Entity 1 gets free product
],
});
// Verify entity 0 is trialing
const entity0Before = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductTrialing({
customer: entity0Before,
productId: proTrial.id,
trialEndsAt: Date.now() + ms.days(7),
});
// Verify entity 1 is NOT trialing (free product)
const entity1Before = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductActive({
customer: entity1Before,
productId: free.id,
});
// Advance past trial period (10 days to be safe)
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 10,
});
// Verify entity 0 is no longer trialing (trial ended, now active)
const entity0AfterAdvance = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectProductNotTrialing({
customer: entity0AfterAdvance,
productId: proTrial.id,
});
await expectProductActive({
customer: entity0AfterAdvance,
productId: proTrial.id,
});
// Now upgrade entity 1 from free to paid with trial - should merge with existing subscription
const updateParams = {
customer_id: customerId,
entity_id: entities[1].id,
product_id: free.id,
items: [messagesItem, priceItem],
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should be 0 during trial
expect(preview.total).toEqual(0);
// next_cycle should show when 14-day trial ends
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(14),
total: priceItem.price!,
});
await autumnV1.subscriptions.update(updateParams);
// Verify entity 1 is now trialing with 14-day trial
const entity1After = await autumnV1.entities.get(customerId, entities[1].id);
await expectProductTrialing({
customer: entity1After,
productId: free.id,
trialEndsAt: Date.now() + ms.days(14),
});
// Entity 0 should still be active (not trialing)
const entity0After = await autumnV1.entities.get(customerId, entities[0].id);
await expectProductNotTrialing({
customer: entity0After,
productId: proTrial.id,
});
await expectProductActive({
customer: entity0After,
productId: proTrial.id,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});

View File

@@ -41,6 +41,7 @@ test.concurrent(`${chalk.yellowBright("version-billing: add prepaid users")}`, a
customer_id: customerId,
product_id: pro.id,
version: 2,
options: [{ feature_id: TestFeature.Users, quantity: 0 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
@@ -183,7 +184,11 @@ test.concurrent(`${chalk.yellowBright("version-billing: metered to prepaid")}`,
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
actions: [
s.attach({
productId: "pro",
}),
],
});
// Verify messages is metered (limited) before update
@@ -191,7 +196,7 @@ test.concurrent(`${chalk.yellowBright("version-billing: metered to prepaid")}`,
expect(customerBefore.features[TestFeature.Messages]).toBeDefined();
// Create v2 with prepaid messages instead of metered
const prepaidMessagesItem = items.prepaidMessages({ includedUsage: 50 });
const prepaidMessagesItem = items.prepaidMessages({ includedUsage: 0 });
await autumnV1.products.update(pro.id, {
items: [prepaidMessagesItem, priceItem],
});
@@ -200,6 +205,7 @@ test.concurrent(`${chalk.yellowBright("version-billing: metered to prepaid")}`,
customer_id: customerId,
product_id: pro.id,
version: 2,
options: [{ feature_id: TestFeature.Messages, quantity: 300 }],
};
await autumnV1.subscriptions.update(updateParams);
@@ -207,11 +213,12 @@ test.concurrent(`${chalk.yellowBright("version-billing: metered to prepaid")}`,
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Messages should now have prepaid model with 50 included
// Options are ignored when version is passed in
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 50,
balance: 50,
includedUsage: 300,
balance: 300,
usage: 0,
});
@@ -252,6 +259,7 @@ test.concurrent(`${chalk.yellowBright("version-billing: mixed billing models")}`
customer_id: customerId,
product_id: pro.id,
version: 2,
options: [{ feature_id: TestFeature.Users, quantity: 0 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);

View File

@@ -44,14 +44,22 @@ export const expectCustomerProductCorrect = async ({
}
if (state === "active") {
expect(String(product.status)).toBe("active");
// Product can be "active" or "trialing" - both are considered active states
expect(
product.status === "active" || product.status === "trialing",
`Product ${productId} should be "active" or "trialing" but got "${product.status}"`,
).toBe(true);
// canceled_at can be undefined or null when not canceled
expect(
product.canceled_at == null,
`Product ${productId} should not be canceled (canceled_at: ${product.canceled_at})`,
).toBe(true);
} else if (state === "canceled") {
expect(String(product.status)).toBe("active");
// Product can be "active" or "trialing" when canceling (scheduled to end)
expect(
product.status === "active" || product.status === "trialing",
`Product ${productId} should be "active" or "trialing" but got "${product.status}"`,
).toBe(true);
expect(
product.canceled_at != null,
`Product ${productId} should be canceled`,

View File

@@ -1,6 +1,6 @@
import { expect } from "bun:test";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import { ApiVersion } from "@autumn/shared";
import { ApiVersion, formatMs } from "@autumn/shared";
import { AutumnInt } from "@/external/autumn/autumnCli";
const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 });
@@ -63,16 +63,21 @@ export const expectProductTrialing = async ({
};
/**
* Verify a customer product is NOT trialing (status is not "trialing").
* Verify a customer product is NOT trialing.
* If nowMs is provided, checks if product is actually trialing based on test clock time
* (status may be "trialing" but if nowMs >= current_period_end, trial has ended).
*/
export const expectProductNotTrialing = async ({
customerId,
customer: providedCustomer,
productId,
nowMs,
}: {
customerId?: string;
customer?: ApiCustomerV3 | ApiEntityV0;
productId: string;
/** Current time in ms (e.g., advancedTo from test clock). If provided, checks if trial is actually active. */
nowMs?: number;
}) => {
const customer = providedCustomer
? providedCustomer
@@ -86,6 +91,25 @@ export const expectProductNotTrialing = async ({
`Product ${productId} not found for not-trialing check`,
).toBeDefined();
// If nowMs is provided, check if product is actually trialing based on test clock time
if (nowMs !== undefined && product!.status === "trialing") {
const currentPeriodStart = product!.current_period_start;
const currentPeriodEnd = product!.current_period_end;
if (!currentPeriodStart || !currentPeriodEnd) {
throw new Error(
`Product ${productId} has no current_period_start or current_period_end`,
);
}
// If status is "trialing" but nowMs >= current_period_end, trial has ended
// Only fail if nowMs < current_period_end (trial is actually still active)
expect(
nowMs >= currentPeriodStart || nowMs >= currentPeriodEnd,
`Product ${productId} is still trialing (status: "trialing", current_period_end: ${formatMs(currentPeriodEnd)}, nowMs: ${formatMs(nowMs)}). Trial has not ended yet.`,
).toBe(true);
return;
}
// Without nowMs, simply check status is not "trialing"
expect(
product!.status,
`Product ${productId} should not have status "trialing" but got "${product!.status}"`,

View File

@@ -1,11 +1,14 @@
import { expect } from "bun:test";
import type { BillingPreviewResponse } from "@autumn/shared";
import { type BillingPreviewResponse, formatMs } 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.
*
* @param startsAt - Expected starts_at as absolute Unix timestamp (ms).
* Use `advancedTo + ms.days(X)` for test clock scenarios.
*/
export const expectPreviewNextCycleCorrect = ({
preview,
@@ -17,7 +20,7 @@ export const expectPreviewNextCycleCorrect = ({
preview: BillingPreviewResponse;
/** Whether next_cycle should be defined (default: true) */
expectDefined?: boolean;
/** Expected starts_at offset from now (ms from now) */
/** Expected starts_at as absolute Unix timestamp (ms) */
startsAt?: number;
/** Expected total amount (in dollars) */
total?: number;
@@ -40,13 +43,11 @@ export const expectPreviewNextCycleCorrect = ({
const nextCycle = preview.next_cycle!;
if (startsAt !== undefined) {
const now = Date.now();
const expectedStartsAt = now + startsAt;
const diff = Math.abs(nextCycle.starts_at - expectedStartsAt);
const diff = Math.abs(nextCycle.starts_at - startsAt);
expect(
diff < toleranceMs,
`Preview next_cycle.starts_at (${nextCycle.starts_at}) should be within ${toleranceMs}ms of ${expectedStartsAt}, but diff is ${diff}ms`,
`Preview next_cycle.starts_at (${formatMs(nextCycle.starts_at)}) should be within ${toleranceMs}ms of ${formatMs(startsAt)}, but diff is ${diff}ms`,
).toBe(true);
}

View File

@@ -0,0 +1,171 @@
/**
* Tests for buildStripePhasesUpdate with free trial scenarios.
*
* Key behavior: trialEndsAt only creates a transition point when a schedule is required
* (i.e., when there's at least one scheduled product).
*/
import { describe, expect, test } from "bun:test";
import { CusProductStatus, msToSeconds } from "@autumn/shared";
import { createMockCtx } from "@tests/utils/mockUtils/contextMocks";
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
import chalk from "chalk";
import { buildStripePhasesUpdate } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate";
import { createMockBillingContext } from "../billingContextMocks";
import {
createCustomerPricesForProduct,
createProductWithAllPriceTypes,
expectPhaseItems,
getStripePriceIds,
HALF_MONTH_MS,
ONE_MONTH_MS,
} from "../stripeSubscriptionTestHelpers";
describe(
chalk.yellowBright("buildStripePhasesUpdate - Free Trial Scenarios"),
() => {
describe(
chalk.cyan("Single Product with Trial (No Schedule Required)"),
() => {
test("Single active product with trial - should have 1 phase (trial does not create transition)", () => {
const nowMs = Date.now();
const trialEndsAt = nowMs + HALF_MONTH_MS;
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
});
const premiumCustomerProduct = createMockCustomerProduct({
id: "cus_prod_premium",
productId: "premium",
product: premium.product,
customerPrices: createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium",
}),
customerEntitlements: premium.allEntitlements,
options: premium.allOptions,
status: CusProductStatus.Active,
startsAt: nowMs,
});
const ctx = createMockCtx({ features: [] });
const billingContext = createMockBillingContext({
customerProducts: [premiumCustomerProduct],
fullProducts: [premium.product],
currentEpochMs: nowMs,
});
const phases = buildStripePhasesUpdate({
ctx,
billingContext,
customerProducts: [premiumCustomerProduct],
trialEndsAt,
});
// Should have 1 phase - trial alone does NOT create a transition point
// when no schedule is required
expect(phases).toHaveLength(1);
expect(phases[0].start_date).toBe(msToSeconds(nowMs));
expect(phases[0].end_date).toBeUndefined();
expect(phases[0].trial_end).toBe(msToSeconds(trialEndsAt));
expectPhaseItems(phases[0].items!, getStripePriceIds(premium));
});
},
);
describe(
chalk.cyan("Product Transition with Trial (Schedule Required)"),
() => {
test("Premium → Pro with trial ending mid-Premium phase - trial creates transition point", () => {
const nowMs = Date.now();
const trialEndsAt = nowMs + HALF_MONTH_MS;
const proStartMs = nowMs + ONE_MONTH_MS;
const premium = createProductWithAllPriceTypes({
productId: "premium",
productName: "Premium",
customerProductId: "cus_prod_premium",
});
const pro = createProductWithAllPriceTypes({
productId: "pro",
productName: "Pro",
customerProductId: "cus_prod_pro",
});
// Premium is ACTIVE now, scheduled to end when Pro starts
const premiumCustomerProduct = createMockCustomerProduct({
id: "cus_prod_premium",
productId: "premium",
product: premium.product,
customerPrices: createCustomerPricesForProduct({
prices: premium.allPrices,
customerProductId: "cus_prod_premium",
}),
customerEntitlements: premium.allEntitlements,
options: premium.allOptions,
status: CusProductStatus.Active,
startsAt: nowMs,
endedAt: proStartMs,
});
// Pro is SCHEDULED to start in the future
const proCustomerProduct = createMockCustomerProduct({
id: "cus_prod_pro",
productId: "pro",
product: pro.product,
customerPrices: createCustomerPricesForProduct({
prices: pro.allPrices,
customerProductId: "cus_prod_pro",
}),
customerEntitlements: pro.allEntitlements,
options: pro.allOptions,
status: CusProductStatus.Scheduled,
startsAt: proStartMs,
});
const ctx = createMockCtx({ features: [] });
const billingContext = createMockBillingContext({
customerProducts: [premiumCustomerProduct, proCustomerProduct],
fullProducts: [premium.product, pro.product],
currentEpochMs: nowMs,
});
const phases = buildStripePhasesUpdate({
ctx,
billingContext,
customerProducts: [premiumCustomerProduct, proCustomerProduct],
trialEndsAt,
});
// Should have 3 phases:
// 1. Premium (trial) from now → trialEndsAt
// 2. Premium (paid) from trialEndsAt → proStartMs
// 3. Pro from proStartMs → undefined
expect(phases).toHaveLength(3);
// Phase 1: Premium with trial
expect(phases[0].start_date).toBe(msToSeconds(nowMs));
expect(phases[0].end_date).toBe(msToSeconds(trialEndsAt));
expect(phases[0].trial_end).toBe(msToSeconds(trialEndsAt));
expectPhaseItems(phases[0].items!, getStripePriceIds(premium));
// Phase 2: Premium (no trial)
expect(phases[1].start_date).toBe(msToSeconds(trialEndsAt));
expect(phases[1].end_date).toBe(msToSeconds(proStartMs));
expect(phases[1].trial_end).toBeUndefined();
expectPhaseItems(phases[1].items!, getStripePriceIds(premium));
// Phase 3: Pro
expect(phases[2].start_date).toBe(msToSeconds(proStartMs));
expect(phases[2].end_date).toBeUndefined();
expect(phases[2].trial_end).toBeUndefined();
expectPhaseItems(phases[2].items!, getStripePriceIds(pro));
});
},
);
},
);

View File

@@ -46,6 +46,7 @@ export const createProduct = async ({
await Promise.all(batchDelete);
} catch (error) {
console.error("Error deleting product", error);
// Ignore deletion errors (might have customers attached)
}

View File

@@ -12,7 +12,39 @@ import ctx from "./createTestContext.js";
// TYPES
// ═══════════════════════════════════════════════════════════════════
type AdvanceClockConfig = {
type FeatureOption = {
feature_id: string;
quantity: number;
};
type EntityConfig = {
count: number;
featureId: string;
};
type GeneratedEntity = {
id: string;
name: string;
featureId: string;
};
// Discriminated union for all action types
type AttachAction = {
type: "attach";
productId: string;
entityIndex?: number;
options?: FeatureOption[];
newBillingSubscription?: boolean;
};
type CancelAction = {
type: "cancel";
productId: string;
entityIndex?: number;
};
type AdvanceClockAction = {
type: "advanceClock";
days?: number;
weeks?: number;
hours?: number;
@@ -20,6 +52,8 @@ type AdvanceClockConfig = {
toNextInvoice?: boolean;
};
type ScenarioAction = AttachAction | CancelAction | AdvanceClockAction;
type ScenarioConfig = {
testClock: boolean;
attachPm?: "success" | "fail" | "authenticate";
@@ -27,38 +61,8 @@ type ScenarioConfig = {
withDefault: boolean;
products: ProductV2[];
entityConfig?: EntityConfig;
attachments: AttachmentDef[];
cancellations: CancelDef[];
advanceClock?: AdvanceClockConfig;
customerIds?: string[];
};
type EntityConfig = {
count: number;
featureId: string;
};
type FeatureOption = {
feature_id: string;
quantity: number;
};
type AttachmentDef = {
productId: string;
entityIndex?: number;
options?: Array<{ feature_id: string; quantity: number }>;
newBillingSubscription?: boolean;
};
type CancelDef = {
productId: string;
entityIndex?: number;
};
type GeneratedEntity = {
id: string;
name: string;
featureId: string;
actions: ScenarioAction[];
};
type ConfigFn = (config: ScenarioConfig) => ScenarioConfig;
@@ -153,6 +157,7 @@ const entities = ({
/**
* Attach a product to the customer or a specific entity.
* Product ID is auto-prefixed with customerId.
* Actions are executed in the order they appear in the actions array.
* @param productId - The product ID (without prefix)
* @param entityIndex - Optional entity index (0-based) to attach to (omit for customer-level)
* @param options - Optional feature options (e.g., prepaid quantity)
@@ -176,16 +181,22 @@ const attach = ({
}): ConfigFn => {
return (config) => ({
...config,
attachments: [
...config.attachments,
{ productId, entityIndex, options, newBillingSubscription },
actions: [
...config.actions,
{
type: "attach" as const,
productId,
entityIndex,
options,
newBillingSubscription,
},
],
});
};
/**
* Cancel a product subscription for the customer or a specific entity.
* Runs after all attachments.
* Actions are executed in the order they appear in the actions array.
* @param productId - The product ID (without prefix)
* @param entityIndex - Optional entity index (0-based) to cancel for (omit for customer-level)
* @example s.cancel({ productId: "pro" }) // customer-level
@@ -200,12 +211,17 @@ const cancel = ({
}): ConfigFn => {
return (config) => ({
...config,
cancellations: [...config.cancellations, { productId, entityIndex }],
actions: [
...config.actions,
{ type: "cancel" as const, productId, entityIndex },
],
});
};
/**
* Advance the Stripe test clock after all attachments.
* Advance the Stripe test clock.
* Actions are executed in the order they appear in the actions array.
* Multiple advanceTestClock calls are executed sequentially, each starting from where the previous one ended.
* @param days - Number of days to advance
* @param weeks - Number of weeks to advance
* @param hours - Number of hours to advance
@@ -214,6 +230,12 @@ const cancel = ({
* @example s.advanceTestClock({ days: 15 }) // advance 15 days
* @example s.advanceTestClock({ months: 1 }) // advance 1 month
* @example s.advanceTestClock({ toNextInvoice: true }) // advance to next invoice
* @example
* // Interleaved actions:
* s.attach({ productId: "pro" }),
* s.advanceTestClock({ days: 7 }),
* s.cancel({ productId: "pro" }),
* s.advanceTestClock({ days: 3 }),
*/
const advanceTestClock = ({
days,
@@ -230,7 +252,17 @@ const advanceTestClock = ({
}): ConfigFn => {
return (config) => ({
...config,
advanceClock: { days, weeks, hours, months, toNextInvoice },
actions: [
...config.actions,
{
type: "advanceClock" as const,
days,
weeks,
hours,
months,
toNextInvoice,
},
],
});
};
@@ -268,18 +300,18 @@ const defaultConfig: ScenarioConfig = {
testClock: false,
withDefault: false,
products: [],
attachments: [],
cancellations: [],
actions: [],
};
/**
* Initialize a complete test scenario with customer, products, entities, and attachments.
* Uses functional composition for flexible configuration.
* Actions are executed in the exact order they appear in the actions array.
*
* @param customerId - Unique identifier used as customer ID and product prefix
* @param setup - Configuration functions (customer, products, entities)
* @param actions - Action functions (attach, cancel, advanceTestClock)
* @returns autumnV1, autumnV2, ctx, testClockId, customer, entities
* @param actions - Action functions (attach, cancel, advanceTestClock) - executed in order
* @returns autumnV1, autumnV2, ctx, testClockId, customer, entities, advancedTo
*
* @example
* ```typescript
@@ -295,20 +327,20 @@ const defaultConfig: ScenarioConfig = {
* ],
* });
*
* // With entities
* const { autumnV1, ctx, entities } = await initScenario({
* customerId: "entity-test",
* // Interleaved actions - executed in order
* const { autumnV1, ctx, advancedTo } = await initScenario({
* customerId: "interleaved-test",
* setup: [
* s.customer({ paymentMethod: "success" }),
* s.products({ list: [pro, free] }),
* s.entities({ count: 2, featureId: TestFeature.Users }),
* s.customer({ testClock: true, paymentMethod: "success" }),
* s.products({ list: [pro] }),
* ],
* actions: [
* s.attach({ productId: "pro", entityIndex: 0 }),
* s.attach({ productId: "free", entityIndex: 1 }),
* s.attach({ productId: "pro" }),
* s.advanceTestClock({ days: 7 }), // Advance 7 days
* s.cancel({ productId: "pro" }),
* s.advanceTestClock({ days: 3 }), // Advance another 3 days (10 total)
* ],
* });
* // entities[0].id = "ent-1", entities[1].id = "ent-2"
* ```
*/
export const initScenario = async ({
@@ -334,7 +366,7 @@ export const initScenario = async ({
ctx,
products: config.products,
prefix: customerId,
customerIds: config.customerIds,
customerIds: config.customerIds ?? [customerId],
});
}
@@ -369,75 +401,82 @@ export const initScenario = async ({
await autumnV1.entities.create(customerId, entityDefs);
}
// 5. Attach products
for (const attachment of config.attachments) {
const prefixedProductId = `${attachment.productId}_${customerId}`;
// 5. Execute actions in order (attach, cancel, advanceClock)
let advancedTo: number = Date.now();
// Resolve entityIndex to entityId
let entityId: string | undefined;
if (attachment.entityIndex !== undefined) {
if (attachment.entityIndex >= generatedEntities.length) {
for (const action of config.actions) {
if (action.type === "attach") {
const prefixedProductId = `${action.productId}_${customerId}`;
// Resolve entityIndex to entityId
let entityId: string | undefined;
if (action.entityIndex !== undefined) {
if (action.entityIndex >= generatedEntities.length) {
throw new Error(
`entityIndex ${action.entityIndex} is out of bounds. Only ${generatedEntities.length} entities configured.`,
);
}
entityId = generatedEntities[action.entityIndex].id;
}
await autumnV1.attach({
customer_id: customerId,
product_id: prefixedProductId,
entity_id: entityId,
options: action.options,
new_billing_subscription: action.newBillingSubscription,
});
} else if (action.type === "cancel") {
const prefixedProductId = `${action.productId}_${customerId}`;
// Resolve entityIndex to entityId
let entityId: string | undefined;
if (action.entityIndex !== undefined) {
if (action.entityIndex >= generatedEntities.length) {
throw new Error(
`entityIndex ${action.entityIndex} is out of bounds. Only ${generatedEntities.length} entities configured.`,
);
}
entityId = generatedEntities[action.entityIndex].id;
}
await autumnV1.cancel({
customer_id: customerId,
product_id: prefixedProductId,
entity_id: entityId,
});
} else if (action.type === "advanceClock") {
if (!testClockId) {
throw new Error(
`entityIndex ${attachment.entityIndex} is out of bounds. Only ${generatedEntities.length} entities configured.`,
"Cannot advance test clock: testClock not enabled in customer config",
);
}
entityId = generatedEntities[attachment.entityIndex].id;
}
await autumnV1.attach({
customer_id: customerId,
product_id: prefixedProductId,
entity_id: entityId,
options: attachment.options,
new_billing_subscription: attachment.newBillingSubscription,
});
}
const startingFrom = new Date(advancedTo);
// 6. Cancel products if configured
for (const cancellation of config.cancellations) {
const prefixedProductId = `${cancellation.productId}_${customerId}`;
// Resolve entityIndex to entityId
let entityId: string | undefined;
if (cancellation.entityIndex !== undefined) {
if (cancellation.entityIndex >= generatedEntities.length) {
throw new Error(
`entityIndex ${cancellation.entityIndex} is out of bounds. Only ${generatedEntities.length} entities configured.`,
);
if (action.toNextInvoice) {
// Advance to next month + hours to finalize invoice
const baseDate = startingFrom ?? new Date();
advancedTo = await advanceTestClockFn({
stripeCli: ctx.stripeCli,
testClockId,
advanceTo: addHours(
addMonths(baseDate, 1),
hoursToFinalizeInvoice,
).getTime(),
waitForSeconds: 30,
});
} else {
advancedTo = await advanceTestClockFn({
stripeCli: ctx.stripeCli,
testClockId,
startingFrom,
numberOfDays: action.days,
numberOfWeeks: action.weeks,
numberOfHours: action.hours,
numberOfMonths: action.months,
});
}
entityId = generatedEntities[cancellation.entityIndex].id;
}
await autumnV1.cancel({
customer_id: customerId,
product_id: prefixedProductId,
entity_id: entityId,
});
}
// 7. Advance test clock if configured
let advancedTo: number | undefined;
if (config.advanceClock && testClockId) {
if (config.advanceClock.toNextInvoice) {
// Advance to next month + hours to finalize invoice
advancedTo = await advanceTestClockFn({
stripeCli: ctx.stripeCli,
testClockId,
advanceTo: addHours(
addMonths(new Date(), 1),
hoursToFinalizeInvoice,
).getTime(),
waitForSeconds: 30,
});
} else {
advancedTo = await advanceTestClockFn({
stripeCli: ctx.stripeCli,
testClockId,
numberOfDays: config.advanceClock.days,
numberOfWeeks: config.advanceClock.weeks,
numberOfHours: config.advanceClock.hours,
numberOfMonths: config.advanceClock.months,
});
}
}

View File

@@ -1,86 +0,0 @@
import type { z } from "zod/v4";
import { ApiVersion } from "../../../versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "../../../versionUtils/versionChangeUtils/VersionChange.js";
import { AttachBodyV1Schema } from "../attachBodyV1.js";
import { AttachBodyV0Schema } from "../prevVersions/attachBodyV0.js";
/**
* V2_1_AttachBodyChange: Transforms attach request body from V2.0 to V2.1 format
*
* Applied when: sourceVersion <= V2.0
*
* Breaking changes introduced in V2.1:
*
* 1. Removed field: `customer_id`
*
* Input: AttachBodyV2 (V2.0 format)
* Output: AttachBodyV2.1 (V2.1 format)
*/
export const V2_0_AttachBodyChange = defineVersionChange({
name: "V2.1 Attach Body Change",
newVersion: ApiVersion.V2_1,
oldVersion: ApiVersion.V2_0,
description: ["Transforms attach body from V2.0 to V2.1 format"],
affectedResources: [AffectedResource.Attach],
newSchema: AttachBodyV1Schema,
oldSchema: AttachBodyV0Schema,
affectsRequest: true,
affectsResponse: false,
// Request: V0 (AttachBodyV0) → V1 (AttachBodyV1)
transformRequest: ({
input,
}: {
input: z.infer<typeof AttachBodyV0Schema>;
}): z.infer<typeof AttachBodyV1Schema> => {
// Get plan_id from product_id or first product_ids entry
const planId = input.product_id ?? input.product_ids?.[0];
if (!planId) {
throw new Error("product_id or product_ids is required");
}
// Transform options to feature_quantities
const featureQuantities = input.options?.map((opt) => ({
feature_id: opt.feature_id,
quantity: opt.quantity,
}));
// Build invoice_settings from legacy fields
const invoiceSettings = {
enable_immediately: input.enable_product_immediately ?? false,
finalize_immediately: input.finalize_invoice ?? false,
};
// const items = input.items?.map((item) => ({
// product_id: item.product_id,
// quantity: item.quantity,
// }));
return {
customer_id: input.customer_id,
plan_id: planId,
version: input.version,
entity_id: input.entity_id ?? undefined,
customer_data: input.customer_data ?? undefined,
entity_data: input.entity_data,
feature_quantities: featureQuantities,
success_url: input.success_url,
checkout_session_params: input.checkout_session_params,
reward: input.reward,
invoice: input.invoice,
invoice_settings: invoiceSettings,
setup_payment: input.setup_payment,
force_checkout: input.force_checkout ?? false,
};
},
});

View File

@@ -1,5 +1,5 @@
import { BillingPreviewResponseSchema } from "@api/billing/common/billingPreviewResponse";
import type { z } from "zod/v4";
import { BillingPreviewResponseSchema } from "../common/billingPreviewResponse";
export const PreviewUpdateSubscriptionResponseSchema =
BillingPreviewResponseSchema;

View File

@@ -1,4 +1,5 @@
import { CreateFreeTrialSchema } from "@models/productModels/freeTrialModels/freeTrialModels";
import { nullish } from "@utils/utils";
import { z } from "zod/v4";
import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels";
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels";
@@ -21,10 +22,6 @@ export const ExtUpdateSubscriptionV0ParamsSchema = z.object({
enable_product_immediately: z.boolean().optional(),
finalize_invoice: z.boolean().optional(),
// Schedules (epoch milliseconds)
// plan_custom_start_date: z.number().optional(),
// billing_cycle_anchor: z.number().optional(),
// New
items: z.array(ProductItemSchema).optional(), // used for custom configuration of a plan (in api - plan_override)
free_trial: CreateFreeTrialSchema.nullable().optional(),
@@ -37,6 +34,20 @@ export const ExtUpdateSubscriptionV0ParamsSchema = z.object({
export const UpdateSubscriptionV0ParamsSchema =
ExtUpdateSubscriptionV0ParamsSchema.extend({
customer_product_id: z.string().optional(),
}).check((ctx) => {
if (ctx.value.options && ctx.value.options.length > 0) {
const invalidFeatures = ctx.value.options
.filter((opt) => nullish(opt.quantity) || opt.quantity < 0)
.map((opt) => opt.feature_id);
if (invalidFeatures.length > 0) {
ctx.issues.push({
code: "custom",
message: `Options quantity must be >= 0 for features: ${invalidFeatures.join(", ")}`,
input: ctx.value,
});
}
}
});
export type ExtUpdateSubscriptionV0Params = z.infer<
@@ -47,6 +58,10 @@ export type UpdateSubscriptionV0Params = z.infer<
typeof UpdateSubscriptionV0ParamsSchema
>;
// Schedules (epoch milliseconds)
// plan_custom_start_date: z.number().optional(),
// billing_cycle_anchor: z.number().optional(),
// keep_existing_plan: true, //disable_plan_switch
// prorate_billing: true,
// invoice_only: true,

View File

@@ -9,7 +9,6 @@ import { V1_2_TrialsUsedChange } from "@api/customers/components/apiTrialsUsed/c
// Import customer product changes
import { V2_0_AttachBodyChange } from "@api/billing/attach/changes/V2.0_AttachBodyChange.js";
import { V1_2_CustomerChange } from "@api/customers/changes/V1.2_CustomerChange.js";
import { V1_2_CustomerQueryChange } from "@api/customers/requestChanges/V1.2_CustomerQueryChange.js";
// Import entity changes
@@ -32,10 +31,6 @@ import { ApiVersion } from "../ApiVersion.js";
import type { VersionChangeConstructor } from "./VersionChange.js";
import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js";
export const V2_1_CHANGES: VersionChangeConstructor[] = [
V2_0_AttachBodyChange, // Transforms Attach Body TO V2.0 format from V2.1 format
];
export const V2_CHANGES: VersionChangeConstructor[] = [
V1_2_CustomerChange, // Transforms Customer TO V1.2 format from V2 format
V1_2_CustomerQueryChange, // Transforms Customer Query TO V2.0 format (adds expand options)
@@ -74,11 +69,6 @@ export const V0_2_CHANGES: VersionChangeConstructor[] = [
export const V0_1_CHANGES: VersionChangeConstructor[] = [];
export function registerAllVersionChanges() {
VersionChangeRegistryClass.register({
version: ApiVersion.V2_1,
changes: V2_1_CHANGES,
});
VersionChangeRegistryClass.register({
version: ApiVersion.V2_0,
changes: V2_CHANGES,

View File

@@ -1,4 +1,5 @@
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import { ProcessorType } from "@models/genModels/genEnums.js";
import { z } from "zod/v4";
import { CustomerSchema } from "../cusModels/cusModels.js";
import { FreeTrialSchema } from "../productModels/freeTrialModels/freeTrialModels.js";
@@ -50,14 +51,14 @@ export const CusProductSchema = z.object({
// Fixed-cycle configuration
subscription_ids: z.array(z.string()).nullish(),
scheduled_ids: z.array(z.string()).nullish(),
// processor: z
// .object({
// type: z.enum(ProcessorType),
// subscription_id: z.string().optional().nullable(),
// subscription_schedule_id: z.string().optional().nullable(),
// last_invoice_id: z.string().optional().nullable(),
// })
// .optional(),
processor: z
.object({
type: z.enum(ProcessorType),
// subscription_id: z.string().optional().nullable(),
// subscription_schedule_id: z.string().optional().nullable(),
// last_invoice_id: z.string().optional().nullable(),
})
.optional(),
quantity: z.number().default(1),
api_semver: z.enum(ApiVersion).nullable(),

View File

@@ -1,51 +1,54 @@
import type { LineItem } from "@models/billingModels/invoicingModels/lineItem";
/**
* Filters out line item pairs where a deleted and new item have the same price ID
* Filters out line item pairs where a refund and charge item have the same price ID
* and their amounts cancel out (sum to 0).
*/
export const filterUnchangedPricesFromLineItems = ({
deletedLineItems,
newLineItems,
lineItems,
}: {
deletedLineItems: LineItem[];
newLineItems: LineItem[];
}): { deletedLineItems: LineItem[]; newLineItems: LineItem[] } => {
const remainingDeletedLineItems: LineItem[] = [];
const matchedNewLineItemIndices = new Set<number>();
lineItems: LineItem[];
}): LineItem[] => {
// Split by direction
const refundItems = lineItems.filter(
(item) => item.context.direction === "refund",
);
const chargeItems = lineItems.filter(
(item) => item.context.direction === "charge",
);
for (const deletedItem of deletedLineItems) {
const deletedPriceId = deletedItem.context.price.id;
const remainingRefundItems: LineItem[] = [];
const matchedChargeIndices = new Set<number>();
// Find a matching new line item with the same price ID
const matchingNewIndex = newLineItems.findIndex(
(newItem, index) =>
!matchedNewLineItemIndices.has(index) &&
newItem.context.price.id === deletedPriceId,
for (const refundItem of refundItems) {
const refundPriceId = refundItem.context.price.id;
// Find a matching charge item with the same price ID
const matchingChargeIndex = chargeItems.findIndex(
(chargeItem, index) =>
!matchedChargeIndices.has(index) &&
chargeItem.context.price.id === refundPriceId,
);
if (matchingNewIndex !== -1) {
const matchingNewItem = newLineItems[matchingNewIndex];
const total = deletedItem.amount + matchingNewItem.amount;
if (matchingChargeIndex !== -1) {
const matchingChargeItem = chargeItems[matchingChargeIndex];
const total = refundItem.amount + matchingChargeItem.amount;
if (total === 0) {
// Amounts cancel out - mark new item as matched (both will be removed)
matchedNewLineItemIndices.add(matchingNewIndex);
// Amounts cancel out - mark charge item as matched (both will be removed)
matchedChargeIndices.add(matchingChargeIndex);
continue;
}
}
// No canceling match found - keep this deleted item
remainingDeletedLineItems.push(deletedItem);
// No canceling match found - keep this refund item
remainingRefundItems.push(refundItem);
}
// Filter out matched new line items
const remainingNewLineItems = newLineItems.filter(
(_, index) => !matchedNewLineItemIndices.has(index),
// Filter out matched charge items
const remainingChargeItems = chargeItems.filter(
(_, index) => !matchedChargeIndices.has(index),
);
return {
deletedLineItems: remainingDeletedLineItems,
newLineItems: remainingNewLineItems,
};
return [...remainingRefundItems, ...remainingChargeItems];
};

View File

@@ -5,6 +5,7 @@ export const ms = {
hours: (n: number) => n * 60 * 60 * 1000,
days: (n: number) => n * 24 * 60 * 60 * 1000,
weeks: (n: number) => n * 7 * 24 * 60 * 60 * 1000,
months: (n: number) => n * 30 * 24 * 60 * 60 * 1000,
};
/**

View File

@@ -1,8 +1,11 @@
import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared";
import type {
PreviewUpdateSubscriptionResponse,
ProductV2,
} from "@autumn/shared";
import { LoadingShimmerText } from "@/components/v2/LoadingShimmerText";
import { UpdateConfirmationInfo } from "../update-subscription/update-confirmation-info";
import { AttachProductLineItems } from "./attach-product-line-items";
import { AttachProductTotals } from "./attach-product-totals";
import { UpdateConfirmationInfo } from "./update-confirmation-info";
import type { UseAttachProductForm } from "./use-attach-product-form";
export function UpdateProductSummary({
@@ -12,7 +15,7 @@ export function UpdateProductSummary({
form,
}: {
product?: ProductV2;
previewData?: CheckoutResponseV0 | null;
previewData?: PreviewUpdateSubscriptionResponse | null;
isLoading?: boolean;
form: UseAttachProductForm;
}) {

View File

@@ -1,29 +1,27 @@
import type { CheckoutResponseV0, ProductV2 } from "@autumn/shared";
import type {
PreviewUpdateSubscriptionResponse,
ProductV2,
} from "@autumn/shared";
import type { ReactNode } from "react";
import { useMemo } from "react";
import {
useHasBillingChanges,
useHasChanges,
usePrepaidItems,
} from "@/hooks/stores/useProductStore";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { useHasChanges, usePrepaidItems } from "@/hooks/stores/useProductStore";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
import type { UseAttachProductForm } from "./use-attach-product-form";
import type { UseUpdateSubscriptionForm } from "./use-update-subscription-form";
export const UpdateConfirmationInfo = ({
previewData,
product,
form,
}: {
previewData?: CheckoutResponseV0 | null;
previewData?: PreviewUpdateSubscriptionResponse | null;
product?: ProductV2;
form: UseAttachProductForm;
form: UseUpdateSubscriptionForm;
}) => {
const hasChanges = useHasChanges();
const hasBillingChanges = useHasBillingChanges({
baseProduct: previewData?.current_product,
newProduct: previewData?.product,
});
// const hasBillingChanges = useHasBillingChanges({
// baseProduct: previewData?.current_product,
// newProduct: previewData?.product,
// });
const hasPrepaidQuantityChanges = useHasPrepaidQuantityChanges(product, form);
@@ -44,14 +42,14 @@ export const UpdateConfirmationInfo = ({
}
// Version change notice
if (previewData.current_product?.version !== previewData.product.version) {
boxes.push(
<InfoBox key="version-change" variant="info">
You're switching from v{previewData.current_product?.version} to v
{previewData.product.version} of this plan
</InfoBox>,
);
}
// if (previewData.current_product?.version !== previewData.product.version) {
// boxes.push(
// <InfoBox key="version-change" variant="info">
// You're switching from v{previewData.current_product?.version} to v
// {previewData.product.version} of this plan
// </InfoBox>,
// );
// }
// Prepaid quantity changes notice
if (hasPrepaidQuantityChanges) {
@@ -63,32 +61,32 @@ export const UpdateConfirmationInfo = ({
}
// No billing changes notice
if (!hasBillingChanges && !hasPrepaidQuantityChanges) {
boxes.push(
<InfoBox key="no-billing-changes" variant="success">
No changes to billing will be made
</InfoBox>,
);
}
// if (!hasBillingChanges && !hasPrepaidQuantityChanges) {
// boxes.push(
// <InfoBox key="no-billing-changes" variant="success">
// No changes to billing will be made
// </InfoBox>,
// );
// }
// Free trial updated
if (previewData.product.free_trial) {
const trialEndDate = previewData.next_cycle?.starts_at
? formatUnixToDate(previewData.next_cycle.starts_at)
: null;
// if (previewData.product.free_trial) {
// const trialEndDate = previewData.next_cycle?.starts_at
// ? formatUnixToDate(previewData.next_cycle.starts_at)
// : null;
boxes.push(
<InfoBox key="free-trial-updated" variant="info">
Free trial updated
{trialEndDate && (
<>
{" "}
- trial ends <span className="font-semibold">{trialEndDate}</span>
</>
)}
</InfoBox>,
);
}
// boxes.push(
// <InfoBox key="free-trial-updated" variant="info">
// Free trial updated
// {trialEndDate && (
// <>
// {" "}
// - trial ends <span className="font-semibold">{trialEndDate}</span>
// </>
// )}
// </InfoBox>,
// );
// }
return boxes;
};
@@ -110,7 +108,7 @@ export const UpdateConfirmationInfo = ({
const useHasPrepaidQuantityChanges = (
product: ProductV2 | undefined,
form: UseAttachProductForm,
form: UseUpdateSubscriptionForm,
) => {
const { prepaidItems } = usePrepaidItems({ product });
const currentPrepaidOptions = form.state.values.prepaidOptions;

View File

@@ -0,0 +1,55 @@
import { useEffect, useRef } from "react";
import { useAppForm } from "@/hooks/form/form";
import {
type AttachProductForm,
AttachProductFormSchema,
} from "../attach-product/attach-product-form-schema";
export function useUpdateSubscriptionForm({
initialProductId,
initialPrepaidOptions,
}: {
initialProductId?: string;
initialPrepaidOptions?: Record<string, number>;
} = {}) {
return useAppForm({
defaultValues: {
productId: initialProductId || "",
prepaidOptions: initialPrepaidOptions ?? {},
} as AttachProductForm,
validators: {
onChange: AttachProductFormSchema,
onSubmit: AttachProductFormSchema,
},
});
}
// Subscribe to form changes and clear prepaid options when productId changes
// Prevents stale prepaid options from causing "no prepaid price found" in the `checkout` call
export function useResetPrepaidOnProductChange({
form,
}: {
form: UseUpdateSubscriptionForm;
}) {
const previousProductIdRef = useRef<string | undefined>();
useEffect(() => {
const subscription = form.store.subscribe(() => {
const currentProductId = form.store.state.values.productId;
if (
previousProductIdRef.current !== undefined &&
previousProductIdRef.current !== currentProductId
) {
form.setFieldValue("prepaidOptions", {});
}
previousProductIdRef.current = currentProductId;
});
return () => subscription();
}, [form.store, form.setFieldValue]);
}
export type UseUpdateSubscriptionForm = ReturnType<
typeof useUpdateSubscriptionForm
>;

View File

@@ -1,6 +1,6 @@
import type {
CheckoutResponseV0,
CreateFreeTrial,
PreviewUpdateSubscriptionResponse,
ProductV2,
} from "@autumn/shared";
import { useQuery } from "@tanstack/react-query";
@@ -70,10 +70,11 @@ export function useUpdateSubscriptionPreview(
return null;
}
const response = await axiosInstance.post<CheckoutResponseV0>(
"/v1/subscriptions/preview_update",
updateSubscriptionBody,
);
const response =
await axiosInstance.post<PreviewUpdateSubscriptionResponse>(
"/v1/subscriptions/preview_update",
updateSubscriptionBody,
);
return response.data;
},

View File

@@ -84,3 +84,19 @@ export const getStripeDashboardLink = ({
const withTest = env === AppEnv.Live ? "" : "/test";
return `${baseUrl}${accountPath}${withTest}/dashboard`;
};
export const getStripeConnectViewAsLink = ({
masterAccountId,
connectedAccountId,
env,
path = "payments",
}: {
masterAccountId: string;
connectedAccountId: string;
env: AppEnv;
path?: string;
}) => {
const baseUrl = `https://dashboard.stripe.com`;
const withTest = env === AppEnv.Live ? "" : "/test";
return `${baseUrl}/${masterAccountId}/connect/view-as/${connectedAccountId}${withTest}/${path}`;
};

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useAdmin } from "./useAdmin";
interface MasterStripeAccount {
id: string;
}
export const useMasterStripeAccount = () => {
const axiosInstance = useAxiosInstance();
const { isAdmin } = useAdmin();
const fetchMasterStripeAccount = async () => {
const { data } = await axiosInstance.get<MasterStripeAccount>(
"/admin/master-stripe-account",
);
return data;
};
const { data, isLoading, error } = useQuery<MasterStripeAccount | null>({
queryKey: ["admin", "master-stripe-account"],
queryFn: fetchMasterStripeAccount,
retry: false,
enabled: isAdmin,
});
return {
masterStripeAccount: data || null,
isLoading,
error,
};
};

View File

@@ -8,7 +8,7 @@ import { useMemo } from "react";
import { UpdateProductActions } from "@/components/forms/attach-product/update-product-actions";
import { UpdateProductPrepaidOptions } from "@/components/forms/attach-product/update-product-prepaid-options";
import { UpdateProductSummary } from "@/components/forms/attach-product/update-product-summary";
import { useAttachPreview } from "@/components/forms/attach-product/use-attach-preview";
import { useUpdateSubscriptionPreview } from "@/components/forms/update-subscription/use-update-subscription-preview";
import {
type UseAttachProductForm,
useAttachProductForm,
@@ -43,7 +43,7 @@ const FormContent = ({
const initialPrepaidOptions =
form.options.defaultValues?.prepaidOptions ?? {};
const previewQuery = useAttachPreview({
const previewQuery = useUpdateSubscriptionPreview({
customerId,
product,
entityId,

View File

@@ -12,6 +12,7 @@ import {
stripeToAtmnAmount,
type UpdateSubscriptionV0Params,
} from "@autumn/shared";
import type { AxiosError } from "axios";
import { Check, Copy, PencilSimple } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
@@ -289,7 +290,7 @@ interface BillingPlanData {
quantity?: number;
deleted?: boolean;
}>;
trial_end?: number;
trial_end?: number | "now";
proration_behavior?: string;
cancel_at_period_end?: boolean;
};
@@ -613,10 +614,13 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
<div className="mt-1 text-xs text-t-secondary">
<span>Trial ends: </span>
<span className="text-green-400">
{new Date(
billingPlan.stripe.subscriptionAction.params.trial_end *
1000,
).toLocaleDateString()}
{billingPlan.stripe.subscriptionAction.params.trial_end ===
"now"
? "now"
: new Date(
(billingPlan.stripe.subscriptionAction.params
.trial_end as number) * 1000,
).toLocaleDateString()}
</span>
</div>
) : null}
@@ -1077,6 +1081,10 @@ function useSubscriptionUpdate({
}
},
onError: (error) => {
toast.error(
(error as AxiosError<{ message: string }>)?.response?.data?.message ??
"Failed to update subscription",
);
console.error("Update failed:", error);
},
});
@@ -1134,15 +1142,24 @@ function SheetContent({
};
// Get initial prepaid values from the current subscription
// Divide by billing_units to show values in "billing units" (e.g., 200 instead of 20000)
const initialPrepaidOptions = useMemo(() => {
return cusProduct.options.reduce(
(acc, option) => {
acc[option.feature_id] = option.quantity;
// Find the corresponding prepaid item to get billing_units
const prepaidItem = prepaidItems.find(
(item) =>
(item.feature_id ?? item.feature?.internal_id) ===
option.feature_id,
);
const billingUnits = prepaidItem?.billing_units ?? 1;
// Divide by billing_units so input shows "200" not "20000"
acc[option.feature_id] = Math.round(option.quantity / billingUnits);
return acc;
},
{} as Record<string, number>,
);
}, [cusProduct.options]);
}, [cusProduct.options, prepaidItems]);
const [prepaidOptions, setPrepaidOptions] = useState<Record<string, number>>(
initialPrepaidOptions,
@@ -1185,20 +1202,26 @@ function SheetContent({
};
// Add options only if they have changed from initial values
// Multiply by billing_units to match what useUpdateSubscriptionBodyBuilder does
if (prepaidItems.length > 0) {
const options = prepaidItems
.map((item) => {
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
const quantity = prepaidOptions[featureId];
const inputQuantity = prepaidOptions[featureId];
const initialQuantity = initialPrepaidOptions[featureId];
const billingUnits = item.billing_units ?? 1;
// Only include if changed from initial value
if (
quantity !== undefined &&
quantity !== null &&
inputQuantity !== undefined &&
inputQuantity !== null &&
featureId &&
quantity !== initialQuantity
inputQuantity !== initialQuantity
) {
return { feature_id: featureId, quantity };
// Multiply by billing_units - input is in "billing units", API expects total quantity
return {
feature_id: featureId,
quantity: inputQuantity * billingUnits,
};
}
return null;
})
@@ -1230,8 +1253,6 @@ function SheetContent({
duration: trialDuration,
card_required: trialCardRequired,
};
} else if (customizedProduct?.free_trial) {
body.free_trial = customizedProduct.free_trial;
}
// Add custom plan dates if set (epoch milliseconds)

View File

@@ -1,4 +1,5 @@
import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared";
import { FlaskIcon } from "@phosphor-icons/react";
import type { Row, Table } from "@tanstack/react-table";
import { ArrowRightLeft, Delete } from "lucide-react";
import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell";
@@ -74,6 +75,7 @@ export const CustomerProductsColumns = [
const meta = table.options.meta as {
onCancelClick?: (product: FullCusProduct) => void;
onTransferClick?: (product: FullCusProduct) => void;
onTestSheetClick?: (product: FullCusProduct) => void;
hasEntities?: boolean;
};
@@ -81,6 +83,17 @@ export const CustomerProductsColumns = [
return (
<TableDropdownMenuCell>
{meta.onTestSheetClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onTestSheetClick?.(row.original);
}}
>
<FlaskIcon size={16} /> Test Sheet
</DropdownMenuItem>
)}
{meta.hasEntities && meta.onTransferClick && (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"

View File

@@ -4,6 +4,7 @@ import {
ArrowSquareOutIcon,
BracketsSquareIcon,
CaretDownIcon,
LinkIcon,
PencilSimpleIcon,
SubtractIcon,
TicketIcon,
@@ -30,7 +31,13 @@ import { CusService } from "@/services/customers/CusService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { getBackendErr } from "@/utils/genUtils";
import { getRevenueCatCusLink, getStripeCusLink } from "@/utils/linkUtils";
import {
getRevenueCatCusLink,
getStripeConnectViewAsLink,
getStripeCusLink,
} from "@/utils/linkUtils";
import { useAdmin } from "@/views/admin/hooks/useAdmin";
import { useMasterStripeAccount } from "@/views/admin/hooks/useMasterStripeAccount";
import { DeleteCustomerDialog } from "@/views/customers/customer/components/DeleteCustomerDialog";
import UpdateCustomerDialog from "@/views/customers/customer/components/UpdateCustomerDialog";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
@@ -50,6 +57,8 @@ export function CustomerActions() {
const { features } = useFeaturesQuery();
const { org } = useOrg();
const { stripeAccount } = useOrgStripeQuery();
const { isAdmin } = useAdmin();
const { masterStripeAccount } = useMasterStripeAccount();
const env = useEnv();
const axiosInstance = useAxiosInstance();
@@ -177,6 +186,28 @@ export function CustomerActions() {
Open in Stripe
</DropdownMenuItem>
)}
{isAdmin &&
masterStripeAccount?.id &&
stripeAccount?.id &&
customer?.processor?.type === ProcessorType.Stripe && (
<DropdownMenuItem
onClick={() => {
window.open(
getStripeConnectViewAsLink({
masterAccountId: masterStripeAccount.id,
connectedAccountId: stripeAccount.id,
env,
path: `customers/${stripeCustomerId}`,
}),
"_blank",
);
}}
className="flex gap-2"
>
<LinkIcon className="size-3.5" />
View in Stripe Connect
</DropdownMenuItem>
)}
{((customer?.processor?.id &&
customer.processor.type === ProcessorType.RevenueCat) ||
customer?.customer_products?.some(