chore: merge with update subscription branch
This commit is contained in:
@@ -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);
|
||||
|
||||
21
server/src/internal/admin/handleGetMasterStripeAccount.ts
Normal file
21
server/src/internal/admin/handleGetMasterStripeAccount.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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("=============================================");
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -42,6 +42,7 @@ export const computeUpdateSubscriptionPlan = async ({
|
||||
}
|
||||
|
||||
plan = finalizeUpdateSubscriptionPlan({
|
||||
ctx,
|
||||
plan,
|
||||
billingContext,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -105,6 +105,7 @@ export const computeUpdateQuantityDetails = ({
|
||||
|
||||
const lineItems = computeUpdateQuantityLineItems({
|
||||
ctx,
|
||||
billingContext: updateSubscriptionContext,
|
||||
customerProduct,
|
||||
feature,
|
||||
billingPeriod,
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,9 @@ export const ADMIN_USER_IDs = [
|
||||
"user_2sB3tBXsnVVLlTKliQIqvvM2xfB", // j
|
||||
"ZsDswIXyOGMP9y1V1At4dAZNaiggClSs", // t
|
||||
"NqNuL3MtS7MR2WYoqx2b28iY9vmfAs8h", // c
|
||||
|
||||
// Sandbox:
|
||||
"user_2rypooIKyMQx81vMS8FFGx24UHU", // john
|
||||
];
|
||||
|
||||
export const dashboardOrigins = [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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}"`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
33
vite/src/views/admin/hooks/useMasterStripeAccount.tsx
Normal file
33
vite/src/views/admin/hooks/useMasterStripeAccount.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user