chore: resolve comments

This commit is contained in:
Charlie Lamb
2026-05-05 11:08:41 +01:00
parent 49d31fa27d
commit c441ee6567
22 changed files with 1289 additions and 990 deletions

View File

@@ -74,10 +74,10 @@ export const computeAttachNewCustomerProduct = ({
);
const isScheduled = planTiming === "end_of_cycle";
const startsAt = params.start_date ?? (isScheduled ? endOfCycleMs : undefined);
const startsAt = params.starts_at ?? (isScheduled ? endOfCycleMs : undefined);
const resetCycleAnchor =
resetCycleAnchorMs === "now" && params.start_date !== undefined
? params.start_date
resetCycleAnchorMs === "now" && params.starts_at !== undefined
? params.starts_at
: resetCycleAnchorMs;
let existingUsagesConfig: ExistingUsagesConfig | undefined =

View File

@@ -11,6 +11,17 @@ import { computeAttachNewCustomerProduct } from "./computeAttachNewCustomerProdu
import { computeAttachTransitionUpdates } from "./computeAttachTransitionUpdates";
import { finalizeAttachPlan } from "./finalizeAttachPlan";
const shouldBuildImmediateLineItems = ({
planTiming,
customerProductStatus,
}: {
planTiming: AttachBillingContext["planTiming"];
customerProductStatus: CusProductStatus;
}): boolean => {
if (planTiming !== "immediate") return false;
return customerProductStatus !== CusProductStatus.Scheduled;
};
/**
* Computes the billing plan for attaching a product.
*
@@ -57,9 +68,10 @@ export const computeAttachPlan = ({
});
const includeArrearLineItems = !params.carry_over_usages?.enabled;
const shouldBuildLineItems =
planTiming === "immediate" &&
newCustomerProduct.status !== CusProductStatus.Scheduled;
const shouldBuildLineItems = shouldBuildImmediateLineItems({
planTiming,
customerProductStatus: newCustomerProduct.status,
});
const { allLineItems: lineItems, updateCustomerEntitlements } =
shouldBuildLineItems

View File

@@ -1,36 +1,14 @@
import {
ACTIVE_STATUSES,
type AttachBillingContext,
type AttachParamsV1,
CusProductStatus,
cusProductToPrices,
ErrCode,
isFreeProduct,
isFutureStartDate,
isOneOffProduct,
isPastStartDate,
RecaseError,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import {
isFutureStartDate,
isPastStartDate,
} from "@/internal/billing/v2/utils/startDateUtils";
const hasActivePaidRecurringSubscription = ({
billingContext,
}: {
billingContext: AttachBillingContext;
}) =>
billingContext.fullCustomer.customer_products.some((customerProduct) => {
const hasActiveOrTrialingStatus =
ACTIVE_STATUSES.includes(customerProduct.status) ||
customerProduct.status === CusProductStatus.Trialing;
if (!hasActiveOrTrialingStatus) return false;
if (!customerProduct.subscription_ids?.length) return false;
const prices = cusProductToPrices({ cusProduct: customerProduct });
return !isFreeProduct({ prices }) && !isOneOffProduct({ prices });
});
export const handleStartDateErrors = ({
billingContext,
@@ -39,12 +17,12 @@ export const handleStartDateErrors = ({
billingContext: AttachBillingContext;
params: AttachParamsV1;
}) => {
if (params.start_date === undefined) return;
if (params.starts_at === undefined) return;
if (isPastStartDate(params.start_date, billingContext.currentEpochMs)) {
if (isPastStartDate(params.starts_at, billingContext.currentEpochMs)) {
throw new RecaseError({
message:
"start_date cannot be set to a past timestamp. Use now or a future Unix timestamp in milliseconds.",
"starts_at cannot be set to a past timestamp. Use now or a future Unix timestamp in milliseconds.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
@@ -53,39 +31,19 @@ export const handleStartDateErrors = ({
if (params.plan_schedule === "end_of_cycle") {
throw new RecaseError({
message:
"start_date cannot be used together with plan_schedule: end_of_cycle.",
"starts_at cannot be used together with plan_schedule: end_of_cycle.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
if (billingContext.currentCustomerProduct) {
throw new RecaseError({
message:
"start_date is only supported when attaching a new subscription, not when switching an existing one.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
if (
!isFutureStartDate(params.start_date, billingContext.currentEpochMs)
) {
if (!isFutureStartDate(params.starts_at, billingContext.currentEpochMs)) {
return;
}
if (params.invoice_mode?.enabled) {
throw new RecaseError({
message: "Future start_date cannot be used together with invoice mode.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
if (hasActivePaidRecurringSubscription({ billingContext })) {
throw new RecaseError({
message:
"Future start_date is only supported when the customer has no active paid subscription.",
message: "Future starts_at cannot be used together with invoice mode.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
@@ -96,7 +54,7 @@ export const handleStartDateErrors = ({
!isFreeProduct({ prices }) && !isOneOffProduct({ prices });
if (!isPaidRecurring) {
throw new RecaseError({
message: "Future start_date is only supported for paid recurring plans.",
message: "Future starts_at is only supported for paid recurring plans.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
@@ -104,7 +62,7 @@ export const handleStartDateErrors = ({
if (!billingContext.paymentMethod) {
throw new RecaseError({
message: "Future start_date requires a saved payment method.",
message: "Future starts_at requires a saved payment method.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
@@ -112,7 +70,7 @@ export const handleStartDateErrors = ({
if (billingContext.trialContext?.trialEndsAt) {
throw new RecaseError({
message: "Future start_date cannot be used together with a free trial.",
message: "Future starts_at cannot be used together with a free trial.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});

View File

@@ -8,6 +8,7 @@ import {
cusProductToPrices,
hasCustomItems,
isFreeProduct,
isFutureStartDate,
isOneOffProduct,
notNullish,
orgDisableStripeWrites,
@@ -22,7 +23,6 @@ import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullC
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCycleAnchor";
import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs";
import { isFutureStartDate } from "@/internal/billing/v2/utils/startDateUtils";
import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities";
import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund";
import { setupAttachCheckoutMode } from "./setupAttachCheckoutMode";
@@ -198,7 +198,10 @@ export const setupAttachBillingContext = async ({
currentEpochMs,
});
const hasFutureStartDate = isFutureStartDate(params.start_date, currentEpochMs);
const hasFutureStartDate = isFutureStartDate(
params.starts_at,
currentEpochMs,
);
const checkoutMode = setupAttachCheckoutMode({
paymentMethod,

View File

@@ -5,27 +5,24 @@ import type {
StripeSubscriptionAction,
StripeSubscriptionScheduleAction,
} from "@autumn/shared";
import { stripePhaseStartsInFuture } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate";
import { buildStripeSubscriptionCreateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction";
import { buildStripeSubscriptionUpdateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction";
import { stripePhaseStartsInFuture } from "@server/internal/billing/v2/utils/startDateUtils";
import { billingPlanToOneOffStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/billingPlanToOneOffStripeItemSpecs";
const scheduleStartsInFuture = ({
const subscriptionStartsInFuture = ({
billingContext,
stripeSubscriptionScheduleAction,
subscriptionStartsAt,
}: {
billingContext: BillingContext;
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
}) => {
if (stripeSubscriptionScheduleAction?.type !== "create") return false;
return stripePhaseStartsInFuture(
stripeSubscriptionScheduleAction.params.phases?.[0]?.start_date,
subscriptionStartsAt?: number | "now";
}): boolean =>
stripePhaseStartsInFuture(
subscriptionStartsAt,
billingContext.currentEpochMs,
);
};
export const buildStripeSubscriptionAction = ({
ctx,
@@ -34,6 +31,7 @@ export const buildStripeSubscriptionAction = ({
finalCustomerProducts,
stripeSubscriptionScheduleAction,
subscriptionCancelAt,
subscriptionStartsAt,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
@@ -41,6 +39,7 @@ export const buildStripeSubscriptionAction = ({
finalCustomerProducts: FullCusProduct[];
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
subscriptionCancelAt?: number | null;
subscriptionStartsAt?: number | "now";
}): StripeSubscriptionAction | undefined => {
const { stripeSubscription } = billingContext;
@@ -70,7 +69,10 @@ export const buildStripeSubscriptionAction = ({
const shouldCreateScheduleOnly =
!stripeSubscription &&
scheduleStartsInFuture({ billingContext, stripeSubscriptionScheduleAction });
subscriptionStartsInFuture({
billingContext,
subscriptionStartsAt,
});
// Case 2: No subscription and future schedule exists -> schedule creates subscription later
if (shouldCreateScheduleOnly) {

View File

@@ -8,10 +8,10 @@ import {
cp,
isCustomerProductOnStripeSubscription,
isCustomerProductOnStripeSubscriptionSchedule,
stripePhaseStartsInFuture,
} from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { buildStripePhasesUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate";
import { stripePhaseStartsInFuture } from "@server/internal/billing/v2/utils/startDateUtils";
import type Stripe from "stripe";
// ═══════════════════════════════════════════════════════════════════════════════
@@ -22,6 +22,7 @@ type StripeSubscriptionScheduleResult = {
scheduleAction?: StripeSubscriptionScheduleAction;
/** number = set cancel_at, null = clear cancel_at, undefined = don't touch */
subscriptionCancelAt?: number | null;
subscriptionStartsAt?: number | "now";
};
/**
@@ -98,6 +99,7 @@ const buildActionForScenario = ({
scheduledPhases,
cancelAtSeconds,
endsWithEmptyPhase,
subscriptionStartsAt,
}: {
scenario: ScheduleScenario;
hasSchedule: boolean;
@@ -105,6 +107,7 @@ const buildActionForScenario = ({
scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[];
cancelAtSeconds: number | undefined;
endsWithEmptyPhase: boolean;
subscriptionStartsAt?: number | "now";
}): StripeSubscriptionScheduleResult => {
switch (scenario) {
case "no_phases":
@@ -148,6 +151,7 @@ const buildActionForScenario = ({
end_behavior: endBehavior,
},
},
subscriptionStartsAt,
}
: {
scheduleAction: {
@@ -157,6 +161,7 @@ const buildActionForScenario = ({
end_behavior: endBehavior,
},
},
subscriptionStartsAt,
};
}
}
@@ -281,5 +286,8 @@ export const buildStripeSubscriptionScheduleAction = ({
scheduledPhases,
cancelAtSeconds,
endsWithEmptyPhase,
subscriptionStartsAt: isFutureSchedule
? scheduledPhases[0]?.start_date
: undefined,
});
};

View File

@@ -49,6 +49,7 @@ export const evaluateStripeBillingPlan = async ({
const {
scheduleAction: stripeSubscriptionScheduleAction,
subscriptionCancelAt,
subscriptionStartsAt,
} = buildStripeSubscriptionScheduleAction({
ctx,
billingContext,
@@ -64,6 +65,7 @@ export const evaluateStripeBillingPlan = async ({
finalCustomerProducts: finalFullCustomer.customer_products,
stripeSubscriptionScheduleAction,
subscriptionCancelAt,
subscriptionStartsAt,
});
const stripeRefundAction = await buildStripeRefundAction({

View File

@@ -1,19 +0,0 @@
import { ms, secondsToMs } from "@autumn/shared";
const START_DATE_TOLERANCE_MS = ms.minutes(1);
export const isFutureStartDate = (
startDate: number | undefined,
currentEpochMs: number,
toleranceMs = START_DATE_TOLERANCE_MS,
) => startDate !== undefined && startDate > currentEpochMs + toleranceMs;
export const isPastStartDate = (startDate: number, currentEpochMs: number) =>
startDate < currentEpochMs - START_DATE_TOLERANCE_MS;
export const stripePhaseStartsInFuture = (
startDate: number | "now" | undefined,
currentEpochMs: number,
) =>
typeof startDate === "number" &&
isFutureStartDate(secondsToMs(startDate), currentEpochMs, 0);

View File

@@ -1,219 +0,0 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV5,
type AttachParamsV0Input,
type AttachParamsV1Input,
CusProductStatus,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductActive,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addDays, addHours, addMinutes } from "date-fns";
import type Stripe from "stripe";
import { CusService } from "@/internal/customers/CusService";
import {
expectResetAnchoredTo,
getCustomerProduct,
triggerSubscriptionCreated,
} from "./utils";
const getScheduleSubscriptionId = (schedule: Stripe.SubscriptionSchedule) =>
typeof schedule.subscription === "string"
? schedule.subscription
: schedule.subscription?.id;
test.concurrent(`${chalk.yellowBright("start_date: future attach creates scheduled subscription")}`, async () => {
const customerId = "attach-start-date-future";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const preview = await autumnV2_2.billing.previewAttach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: startDate,
});
expect(preview.total).toBe(0);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: startDate,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({ customer, productId: pro.id, startsAt: startDate });
const cusProduct = await getCustomerProduct({ ctx, customerId, productId: pro.id });
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
expectResetAnchoredTo({
cusProduct,
featureId: TestFeature.Messages,
startDate,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(Math.floor(startDate / 1000));
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("start_date: beta attach creates scheduled subscription")}`, async () => {
const customerId = "attach-start-date-beta";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const preview = await autumnV1Beta.billing.previewAttach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
start_date: startDate,
});
expect(preview.total).toBe(0);
await autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
start_date: startDate,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({ customer, productId: pro.id, startsAt: startDate });
const cusProduct = await getCustomerProduct({ ctx, customerId, productId: pro.id });
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(Math.floor(startDate / 1000));
});
test.concurrent(`${chalk.yellowBright("start_date: now attaches immediately")}`, async () => {
const customerId = "attach-start-date-now";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: advancedTo,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
const cusProduct = await getCustomerProduct({ ctx, customerId, productId: pro.id });
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids?.length).toBe(1);
expect(cusProduct.scheduled_ids ?? []).toEqual([]);
expectResetAnchoredTo({
cusProduct,
featureId: TestFeature.Messages,
startDate: advancedTo,
});
});
test.concurrent(`${chalk.yellowBright("start_date: test clock start links and activates subscription")}`, async () => {
const customerId = "attach-start-date-clock";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
expect(testClockId).toBeDefined();
const startDate = addDays(advancedTo, 1).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: startDate,
});
const scheduledProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = scheduledProduct.scheduled_ids?.[0];
expect(scheduleId).toBeDefined();
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
advanceTo: addHours(startDate, 1).getTime(),
waitForSeconds: 30,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
scheduleId!,
)) as Stripe.SubscriptionSchedule;
const stripeSubId = getScheduleSubscriptionId(stripeSchedule);
if (!stripeSubId) throw new Error("Expected schedule to have a subscription");
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId,
subscriptionCreatedAtMs: addMinutes(startDate, 5).getTime(),
fullCustomer: await CusService.getFull({ ctx, idOrInternalId: customerId }),
});
const cusProduct = await getCustomerProduct({ ctx, customerId, productId: pro.id });
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids).toEqual([stripeSubId]);
});

View File

@@ -0,0 +1,565 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV5,
type ApiEntityV0,
type AttachParamsV0Input,
type AttachParamsV1Input,
CusProductStatus,
ms,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addDays, addHours, addMinutes, addMonths } from "date-fns";
import type Stripe from "stripe";
import { CusService } from "@/internal/customers/CusService";
import {
expectResetAnchoredTo,
getCustomerProduct,
triggerSubscriptionCreated,
} from "./utils";
const getScheduleSubscriptionId = (schedule: Stripe.SubscriptionSchedule) =>
typeof schedule.subscription === "string"
? schedule.subscription
: schedule.subscription?.id;
test.concurrent(
`${chalk.yellowBright("starts_at: future attach creates scheduled subscription")}`,
async () => {
const customerId = "attach-start-date-future";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const preview = await autumnV2_2.billing.previewAttach<AttachParamsV1Input>(
{
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
},
);
expect(preview.total).toBe(0);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer,
productId: pro.id,
startsAt: startDate,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
expectResetAnchoredTo({
cusProduct,
featureId: TestFeature.Messages,
startDate,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startDate / 1000),
);
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: beta attach creates scheduled subscription")}`,
async () => {
const customerId = "attach-start-date-beta";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const preview =
await autumnV1Beta.billing.previewAttach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
starts_at: startDate,
});
expect(preview.total).toBe(0);
await autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
starts_at: startDate,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer,
productId: pro.id,
startsAt: startDate,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startDate / 1000),
);
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: now attaches immediately")}`,
async () => {
const customerId = "attach-start-date-now";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: advancedTo,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids?.length).toBe(1);
expect(cusProduct.scheduled_ids ?? []).toEqual([]);
expectResetAnchoredTo({
cusProduct,
featureId: TestFeature.Messages,
startDate: advancedTo,
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: entity attach with existing scheduled switch")}`,
async () => {
const customerId = "attach-starts-at-entity-existing-schedule";
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, ctx, entities, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: premium.id, entityIndex: 0 })],
});
const entityAId = entities[0].id;
const entityBId = entities[1].id;
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityAId,
});
const startsAt = addDays(advancedTo, 10).getTime();
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityBId,
starts_at: startsAt,
});
const entityA = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entityAId,
);
const entityB = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entityBId,
);
await expectProductCanceling({ customer: entityA, productId: premium.id });
await expectProductScheduled({ customer: entityA, productId: pro.id });
await expectProductScheduled({
customer: entityB,
productId: pro.id,
startsAt,
});
const entityBCusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
entityId: entityBId,
});
expect(entityBCusProduct.status).toBe(CusProductStatus.Scheduled);
expect(entityBCusProduct.scheduled_ids?.length).toBeGreaterThan(0);
expect(Math.abs(entityBCusProduct.starts_at - startsAt)).toBeLessThan(
ms.minutes(10),
);
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: entity attach creates new billing subscription beside existing schedule")}`,
async () => {
const customerId = "attach-starts-at-entity-new-sub-existing-schedule";
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, ctx, entities, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: premium.id, entityIndex: 0 })],
});
const entityAId = entities[0].id;
const entityBId = entities[1].id;
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityAId,
});
const startsAt = addDays(advancedTo, 10).getTime();
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityBId,
starts_at: startsAt,
new_billing_subscription: true,
});
const entityBCusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
entityId: entityBId,
});
expect(entityBCusProduct.status).toBe(CusProductStatus.Scheduled);
expect(entityBCusProduct.subscription_ids ?? []).toEqual([]);
expect(entityBCusProduct.scheduled_ids).toHaveLength(1);
expect(Math.abs(entityBCusProduct.starts_at - startsAt)).toBeLessThan(
ms.minutes(10),
);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
entityBCusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startsAt / 1000),
);
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: immediate attach replaces scheduled plan")}`,
async () => {
const customerId = "attach-starts-at-future-then-immediate";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: addDays(advancedTo, 7).getTime(),
});
const customerWithScheduledPro =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer: customerWithScheduledPro,
productId: pro.id,
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: premium.id,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: custom switch date schedules plan change")}`,
async () => {
const customerId = "attach-starts-at-custom-switch";
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
const startsAt = addMonths(advancedTo, 2).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startsAt,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductCanceling({ customer, productId: premium.id });
await expectProductScheduled({ customer, productId: pro.id, startsAt });
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: add-on starts in the future")}`,
async () => {
const customerId = "attach-starts-at-addon";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "addon",
items: [items.monthlyUsers({ includedUsage: 5 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const startsAt = addDays(advancedTo, 7).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
starts_at: startsAt,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
await expectProductScheduled({ customer, productId: addon.id, startsAt });
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: scheduled add-on is removed when base plan is canceled immediately")}`,
async () => {
const customerId = "attach-starts-at-addon-cancel-base";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "addon",
items: [items.monthlyUsers({ includedUsage: 5 })],
});
const { autumnV1, autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
starts_at: addDays(advancedTo, 7).getTime(),
});
const customerWithScheduledAddon =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer: customerWithScheduledAddon,
productId: addon.id,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
notPresent: [pro.id, addon.id],
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: test clock start links and activates subscription")}`,
async () => {
const customerId = "attach-start-date-clock";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
expect(testClockId).toBeDefined();
const startDate = addDays(advancedTo, 1).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const scheduledProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = scheduledProduct.scheduled_ids?.[0];
expect(scheduleId).toBeDefined();
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
advanceTo: addHours(startDate, 1).getTime(),
waitForSeconds: 30,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
scheduleId!,
)) as Stripe.SubscriptionSchedule;
const stripeSubId = getScheduleSubscriptionId(stripeSchedule);
if (!stripeSubId)
throw new Error("Expected schedule to have a subscription");
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId,
subscriptionCreatedAtMs: addMinutes(startDate, 5).getTime(),
fullCustomer: await CusService.getFull({
ctx,
idOrInternalId: customerId,
}),
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids).toEqual([stripeSubId]);
},
);

View File

@@ -0,0 +1,333 @@
import { test } from "bun:test";
import {
type AttachParamsV0Input,
type AttachParamsV1Input,
ErrCode,
FreeTrialDuration,
} from "@autumn/shared";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addDays, subDays } from "date-fns";
test.concurrent(
`${chalk.yellowBright("starts_at: past dates are rejected")}`,
async () => {
const customerId = "attach-start-date-past";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "starts_at cannot be set to a past timestamp",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: subDays(advancedTo, 1).getTime(),
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: future date rejects free plans")}`,
async () => {
const customerId = "attach-start-date-free";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [s.customer({}), s.products({ list: [free] })],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future starts_at is only supported for paid recurring plans",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: free.id,
starts_at: addDays(advancedTo, 1).getTime(),
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: future date rejects one-off plans")}`,
async () => {
const customerId = "attach-start-date-one-off";
const oneOff = products.oneOff({
id: "one-off",
items: [items.oneOffMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOff] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future starts_at is only supported for paid recurring plans",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: oneOff.id,
starts_at: addDays(advancedTo, 1).getTime(),
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: future date rejects customers without payment method")}`,
async () => {
const customerId = "attach-start-date-no-payment-method";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [s.customer({}), s.products({ list: [pro] })],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future starts_at requires a saved payment method",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: addDays(advancedTo, 1).getTime(),
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: future date rejects end of cycle plan schedule")}`,
async () => {
const customerId = "attach-start-date-end-of-cycle";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage:
"starts_at cannot be used together with plan_schedule: end_of_cycle",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: addDays(advancedTo, 1).getTime(),
plan_schedule: "end_of_cycle",
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: beta future date rejects end of cycle plan schedule")}`,
async () => {
const customerId = "attach-start-date-beta-end-of-cycle";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage:
"starts_at cannot be used together with plan_schedule: end_of_cycle",
func: () =>
autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
starts_at: addDays(advancedTo, 1).getTime(),
plan_schedule: "end_of_cycle",
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: future date rejects invoice mode")}`,
async () => {
const customerId = "attach-start-date-invoice-mode";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future starts_at cannot be used together with invoice mode",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: addDays(advancedTo, 1).getTime(),
invoice_mode: {
enabled: true,
enable_plan_immediately: true,
finalize: false,
},
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: beta future date rejects invoice mode")}`,
async () => {
const customerId = "attach-start-date-beta-invoice-mode";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future starts_at cannot be used together with invoice mode",
func: () =>
autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
starts_at: addDays(advancedTo, 1).getTime(),
invoice: true,
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: future date rejects free trials")}`,
async () => {
const customerId = "attach-start-date-trial";
const proTrial = products.proWithTrial({
id: "pro-trial",
trialDays: 7,
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future starts_at cannot be used together with a free trial",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: proTrial.id,
starts_at: addDays(advancedTo, 1).getTime(),
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: beta future date rejects custom free trial")}`,
async () => {
const customerId = "attach-start-date-beta-trial";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future starts_at cannot be used together with a free trial",
func: () =>
autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
starts_at: addDays(advancedTo, 1).getTime(),
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
card_required: true,
},
}),
});
},
);

View File

@@ -0,0 +1,246 @@
import { expect, test } from "bun:test";
import { type AttachParamsV1Input, CusProductStatus, ms } from "@autumn/shared";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { CusService } from "@/internal/customers/CusService";
import { getCustomerProduct, triggerSubscriptionCreated } from "./utils";
test.concurrent(
`${chalk.yellowBright("starts_at: subscription.created links scheduled product")}`,
async () => {
const customerId = "attach-start-date-webhook";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: advancedTo + ms.days(1),
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = cusProduct.scheduled_ids?.[0];
expect(scheduleId).toBeDefined();
const stripeSubId = "sub_attach_start_date_webhook";
await triggerSubscriptionCreated({ ctx, stripeSubId, scheduleId });
const updatedProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(updatedProduct.subscription_ids).toEqual([stripeSubId]);
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: subscription.created link is idempotent")}`,
async () => {
const customerId = "attach-start-date-webhook-idempotent";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: advancedTo + ms.days(1),
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const stripeSubId = "sub_attach_start_date_idempotent";
for (let i = 0; i < 2; i++) {
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId: cusProduct.scheduled_ids?.[0],
});
}
const updatedProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(updatedProduct.subscription_ids).toEqual([stripeSubId]);
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: subscription.created retry can activate after missing fullCustomer")}`,
async () => {
const customerId = "attach-start-date-webhook-retry-activation";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = advancedTo + ms.days(1);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = cusProduct.scheduled_ids?.[0];
const stripeSubId = "sub_attach_start_date_retry_activation";
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId,
subscriptionCreatedAtMs: startDate + ms.minutes(5),
});
const skippedProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(skippedProduct.subscription_ids ?? []).toEqual([]);
expect(skippedProduct.status).toBe(CusProductStatus.Scheduled);
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId,
subscriptionCreatedAtMs: startDate + ms.minutes(5),
fullCustomer: await CusService.getFull({
ctx,
idOrInternalId: customerId,
}),
});
const activatedProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(activatedProduct.subscription_ids).toEqual([stripeSubId]);
expect(activatedProduct.status).toBe(CusProductStatus.Active);
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: subscription.created ignores missing schedule")}`,
async () => {
const customerId = "attach-start-date-webhook-no-schedule";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: advancedTo + ms.days(1),
});
await triggerSubscriptionCreated({
ctx,
stripeSubId: "sub_attach_start_date_no_schedule",
scheduleId: null,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.subscription_ids ?? []).toEqual([]);
},
);
test.concurrent(
`${chalk.yellowBright("starts_at: subscription.created ignores unknown schedule")}`,
async () => {
const customerId = "attach-start-date-webhook-unknown-schedule";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: advancedTo + ms.days(1),
});
await triggerSubscriptionCreated({
ctx,
stripeSubId: "sub_attach_start_date_unknown_schedule",
scheduleId: "sub_sched_unknown",
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.subscription_ids ?? []).toEqual([]);
},
);

View File

@@ -1,27 +1,31 @@
import { expect } from "bun:test";
import { type FullCusProduct, ms } from "@autumn/shared";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
import { addMonths, getUnixTime } from "date-fns";
import type Stripe from "stripe";
import { handleSubCreated } from "@/external/stripe/webhookHandlers/handleSubCreated";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { CusService } from "@/internal/customers/CusService";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
export const getCustomerProduct = async ({
ctx,
customerId,
productId,
entityId,
}: {
ctx: Parameters<typeof CusService.getFull>[0]["ctx"];
customerId: string;
productId: string;
entityId?: string;
}): Promise<FullCusProduct> => {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const cusProduct = fullCustomer.customer_products.find(
(cp) => cp.product_id === productId,
(cp) =>
cp.product_id === productId &&
(entityId === undefined || cp.entity_id === entityId),
);
expect(cusProduct).toBeDefined();
return cusProduct!;
@@ -80,8 +84,9 @@ export const triggerSubscriptionCreated = async ({
created: getUnixTime(subscriptionCreatedAtMs ?? Date.now()),
schedule: scheduleId ?? null,
} as Stripe.Subscription;
const retrieveSubscription: Stripe.SubscriptionsResource["retrieve"] = async () =>
stripeResponse({ object: subscription, requestId: `req_${stripeSubId}` });
const retrieveSubscription: Stripe.SubscriptionsResource["retrieve"] =
async () =>
stripeResponse({ object: subscription, requestId: `req_${stripeSubId}` });
const stripeCli = {
...ctx.stripeCli,

View File

@@ -1,403 +0,0 @@
import { test } from "bun:test";
import {
type AttachParamsV0Input,
type AttachParamsV1Input,
ErrCode,
FreeTrialDuration,
} from "@autumn/shared";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addDays, subDays } from "date-fns";
test.concurrent(`${chalk.yellowBright("start_date: past dates are rejected")}`, async () => {
const customerId = "attach-start-date-past";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "start_date cannot be set to a past timestamp",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: subDays(advancedTo, 1).getTime(),
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: future date rejects free plans")}`, async () => {
const customerId = "attach-start-date-free";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [s.customer({}), s.products({ list: [free] })],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future start_date is only supported for paid recurring plans",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: free.id,
start_date: addDays(advancedTo, 1).getTime(),
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: future date rejects one-off plans")}`, async () => {
const customerId = "attach-start-date-one-off";
const oneOff = products.oneOff({
id: "one-off",
items: [items.oneOffMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOff] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future start_date is only supported for paid recurring plans",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: oneOff.id,
start_date: addDays(advancedTo, 1).getTime(),
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: future date rejects customers without payment method")}`, async () => {
const customerId = "attach-start-date-no-payment-method";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [s.customer({}), s.products({ list: [pro] })],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future start_date requires a saved payment method",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: addDays(advancedTo, 1).getTime(),
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: future date rejects end of cycle plan schedule")}`, async () => {
const customerId = "attach-start-date-end-of-cycle";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage:
"start_date cannot be used together with plan_schedule: end_of_cycle",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: addDays(advancedTo, 1).getTime(),
plan_schedule: "end_of_cycle",
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: beta future date rejects end of cycle plan schedule")}`, async () => {
const customerId = "attach-start-date-beta-end-of-cycle";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage:
"start_date cannot be used together with plan_schedule: end_of_cycle",
func: () =>
autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
start_date: addDays(advancedTo, 1).getTime(),
plan_schedule: "end_of_cycle",
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: future date rejects invoice mode")}`, async () => {
const customerId = "attach-start-date-invoice-mode";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future start_date cannot be used together with invoice mode",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: addDays(advancedTo, 1).getTime(),
invoice_mode: {
enabled: true,
enable_plan_immediately: true,
finalize: false,
},
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: beta future date rejects invoice mode")}`, async () => {
const customerId = "attach-start-date-beta-invoice-mode";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future start_date cannot be used together with invoice mode",
func: () =>
autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
start_date: addDays(advancedTo, 1).getTime(),
invoice: true,
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: future date rejects active subscription add-ons")}`, async () => {
const customerId = "attach-start-date-active-sub-addon";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "addon",
items: [items.monthlyUsers({ includedUsage: 5 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage:
"Future start_date is only supported when the customer has no active paid subscription",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
start_date: addDays(advancedTo, 1).getTime(),
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: future date rejects new billing subscription with active subscription")}`, async () => {
const customerId = "attach-start-date-active-sub-new-billing";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "addon",
items: [items.monthlyUsers({ includedUsage: 5 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage:
"Future start_date is only supported when the customer has no active paid subscription",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
start_date: addDays(advancedTo, 1).getTime(),
new_billing_subscription: true,
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: future date rejects switches")}`, async () => {
const customerId = "attach-start-date-switch";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage:
"start_date is only supported when attaching a new subscription",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: premium.id,
start_date: addDays(advancedTo, 1).getTime(),
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: future date rejects free trials")}`, async () => {
const customerId = "attach-start-date-trial";
const proTrial = products.proWithTrial({
id: "pro-trial",
trialDays: 7,
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future start_date cannot be used together with a free trial",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: proTrial.id,
start_date: addDays(advancedTo, 1).getTime(),
}),
});
});
test.concurrent(`${chalk.yellowBright("start_date: beta future date rejects custom free trial")}`, async () => {
const customerId = "attach-start-date-beta-trial";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "Future start_date cannot be used together with a free trial",
func: () =>
autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
start_date: addDays(advancedTo, 1).getTime(),
free_trial: {
length: 7,
duration: FreeTrialDuration.Day,
card_required: true,
},
}),
});
});

View File

@@ -1,208 +0,0 @@
import { expect, test } from "bun:test";
import { type AttachParamsV1Input, CusProductStatus, ms } from "@autumn/shared";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { CusService } from "@/internal/customers/CusService";
import { getCustomerProduct, triggerSubscriptionCreated } from "./utils";
test.concurrent(`${chalk.yellowBright("start_date: subscription.created links scheduled product")}`, async () => {
const customerId = "attach-start-date-webhook";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: advancedTo + ms.days(1),
});
const cusProduct = await getCustomerProduct({ ctx, customerId, productId: pro.id });
const scheduleId = cusProduct.scheduled_ids?.[0];
expect(scheduleId).toBeDefined();
const stripeSubId = "sub_attach_start_date_webhook";
await triggerSubscriptionCreated({ ctx, stripeSubId, scheduleId });
const updatedProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(updatedProduct.subscription_ids).toEqual([stripeSubId]);
});
test.concurrent(`${chalk.yellowBright("start_date: subscription.created link is idempotent")}`, async () => {
const customerId = "attach-start-date-webhook-idempotent";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: advancedTo + ms.days(1),
});
const cusProduct = await getCustomerProduct({ ctx, customerId, productId: pro.id });
const stripeSubId = "sub_attach_start_date_idempotent";
for (let i = 0; i < 2; i++) {
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId: cusProduct.scheduled_ids?.[0],
});
}
const updatedProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(updatedProduct.subscription_ids).toEqual([stripeSubId]);
});
test.concurrent(`${chalk.yellowBright("start_date: subscription.created retry can activate after missing fullCustomer")}`, async () => {
const customerId = "attach-start-date-webhook-retry-activation";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = advancedTo + ms.days(1);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: startDate,
});
const cusProduct = await getCustomerProduct({ ctx, customerId, productId: pro.id });
const scheduleId = cusProduct.scheduled_ids?.[0];
const stripeSubId = "sub_attach_start_date_retry_activation";
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId,
subscriptionCreatedAtMs: startDate + ms.minutes(5),
});
const skippedProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(skippedProduct.subscription_ids ?? []).toEqual([]);
expect(skippedProduct.status).toBe(CusProductStatus.Scheduled);
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId,
subscriptionCreatedAtMs: startDate + ms.minutes(5),
fullCustomer: await CusService.getFull({ ctx, idOrInternalId: customerId }),
});
const activatedProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(activatedProduct.subscription_ids).toEqual([stripeSubId]);
expect(activatedProduct.status).toBe(CusProductStatus.Active);
});
test.concurrent(`${chalk.yellowBright("start_date: subscription.created ignores missing schedule")}`, async () => {
const customerId = "attach-start-date-webhook-no-schedule";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: advancedTo + ms.days(1),
});
await triggerSubscriptionCreated({
ctx,
stripeSubId: "sub_attach_start_date_no_schedule",
scheduleId: null,
});
const cusProduct = await getCustomerProduct({ ctx, customerId, productId: pro.id });
expect(cusProduct.subscription_ids ?? []).toEqual([]);
});
test.concurrent(`${chalk.yellowBright("start_date: subscription.created ignores unknown schedule")}`, async () => {
const customerId = "attach-start-date-webhook-unknown-schedule";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: advancedTo + ms.days(1),
});
await triggerSubscriptionCreated({
ctx,
stripeSubId: "sub_attach_start_date_unknown_schedule",
scheduleId: "sub_sched_unknown",
});
const cusProduct = await getCustomerProduct({ ctx, customerId, productId: pro.id });
expect(cusProduct.subscription_ids ?? []).toEqual([]);
});

View File

@@ -1,8 +1,5 @@
import { test } from "bun:test";
import {
type ApiCustomerV5,
type AttachParamsV1Input,
} from "@autumn/shared";
import type { ApiCustomerV5, AttachParamsV1Input } from "@autumn/shared";
import { expectProductScheduled } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
@@ -64,14 +61,18 @@ test(`${chalk.yellowBright("attach-start-date: already scheduled paid recurring
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
start_date: startDate,
starts_at: startDate,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({ customer, productId: pro.id, startsAt: startDate });
await expectProductScheduled({
customer,
productId: pro.id,
startsAt: startDate,
});
});
test(`${chalk.yellowBright("attach-start-date: active subscription hides start date option")}`, async () => {
test(`${chalk.yellowBright("attach-start-date: active subscription hides starts_at option")}`, async () => {
const customerId = "attach-start-date-active-sub";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] });

View File

@@ -3,24 +3,16 @@ import { AttachParamsV0Schema } from "@api/billing/attachV2/attachParamsV0";
import { AttachParamsV1Schema } from "@api/billing/attachV2/attachParamsV1";
const schemas = [
[
"V0",
AttachParamsV0Schema,
{ customer_id: "cus", product_id: "pro" },
],
[
"V1",
AttachParamsV1Schema,
{ customer_id: "cus", plan_id: "pro" },
],
["V0", AttachParamsV0Schema, { customer_id: "cus", product_id: "pro" }],
["V1", AttachParamsV1Schema, { customer_id: "cus", plan_id: "pro" }],
] as const;
describe("attach params start_date", () => {
describe("attach params starts_at", () => {
test.each(schemas)("%s accepts Unix-ms integers", (_, schema, params) => {
expect(
schema.safeParse({
...params,
start_date: 1_775_123_200_000,
starts_at: 1_775_123_200_000,
}).success,
).toBe(true);
});
@@ -28,7 +20,7 @@ describe("attach params start_date", () => {
test.each(schemas)(
"%s rejects malformed numeric timestamps",
(_, schema, params) => {
for (const start_date of [
for (const starts_at of [
1_775_123_200_000.5,
Number.NaN,
Infinity,
@@ -38,7 +30,7 @@ describe("attach params start_date", () => {
expect(
schema.safeParse({
...params,
start_date,
starts_at,
}).success,
).toBe(false);
}

View File

@@ -8,53 +8,53 @@ import { UnixMsTimestampSchema } from "../common/unixMsTimestamp";
import { AttachDiscountSchema } from "./attachDiscount";
export const ExtAttachParamsV0Schema = BillingParamsBaseV0Schema.extend({
// Product identification
product_id: z.string(),
// Product identification
product_id: z.string(),
// Invoice mode
invoice: z.boolean().optional(),
enable_product_immediately: z.boolean().optional(),
finalize_invoice: z.boolean().optional(),
// Invoice mode
invoice: z.boolean().optional(),
enable_product_immediately: z.boolean().optional(),
finalize_invoice: z.boolean().optional(),
success_url: z.string().optional(),
success_url: z.string().optional(),
new_billing_subscription: z.boolean().optional(),
billing_cycle_anchor: BillingCycleAnchorSchema.optional(),
plan_schedule: PlanTimingSchema.optional(),
start_date: UnixMsTimestampSchema.optional(),
plan_schedule: PlanTimingSchema.optional(),
starts_at: UnixMsTimestampSchema.optional(),
// Discounts to apply (Stripe coupon IDs or human-readable promo code strings)
discounts: z.array(AttachDiscountSchema).optional(),
// Billing behavior for attach operations (product transitions):
// - 'prorate_immediately' (default): Invoice line items are charged immediately
// - 'next_cycle_only': Do NOT create any charges due to the attach
billing_behavior: BillingBehaviorSchema.optional(),
// Discounts to apply (Stripe coupon IDs or human-readable promo code strings)
discounts: z.array(AttachDiscountSchema).optional(),
// Billing behavior for attach operations (product transitions):
// - 'prorate_immediately' (default): Invoice line items are charged immediately
// - 'next_cycle_only': Do NOT create any charges due to the attach
billing_behavior: BillingBehaviorSchema.optional(),
// For importing an existing subscription...?
processor_subscription_id: z.string().optional(),
no_billing_changes: z.boolean().optional(),
// For importing an existing subscription...?
processor_subscription_id: z.string().optional(),
no_billing_changes: z.boolean().optional(),
});
export const AttachParamsV0Schema = ExtAttachParamsV0Schema.extend({
// Custom product configuration
items: z.array(ProductItemSchema).optional(),
// Custom product configuration
items: z.array(ProductItemSchema).optional(),
checkout_session_params: z.record(z.string(), z.unknown()).optional(),
checkout_session_params: z.record(z.string(), z.unknown()).optional(),
carry_over_balances: z
.object({
enabled: z.boolean(),
feature_ids: z.array(z.string()).optional(),
})
.optional(),
carry_over_balances: z
.object({
enabled: z.boolean(),
feature_ids: z.array(z.string()).optional(),
})
.optional(),
carry_over_usages: z
.object({
enabled: z.boolean(),
feature_ids: z.array(z.string()).optional(),
})
.optional(),
carry_over_usages: z
.object({
enabled: z.boolean(),
feature_ids: z.array(z.string()).optional(),
})
.optional(),
});
export type ExtAttachParamsV0 = z.input<typeof ExtAttachParamsV0Schema>;

View File

@@ -27,7 +27,7 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({
description:
"When the plan change should take effect. 'immediate' applies now, 'end_of_cycle' schedules for the end of the current billing cycle. By default, upgrades are immediate and downgrades are scheduled.",
}),
start_date: UnixMsTimestampSchema.optional().meta({
starts_at: UnixMsTimestampSchema.optional().meta({
description:
"Unix timestamp in milliseconds for when the attached plan should start. Future dates create a scheduled subscription.",
}),

View File

@@ -80,3 +80,24 @@ export const timestampsMatch = (
b: number,
toleranceMs = ms.seconds(1),
): boolean => Math.abs(a - b) <= toleranceMs;
const START_DATE_TOLERANCE_MS = ms.minutes(1);
export const isFutureStartDate = (
startDate: number | undefined,
currentEpochMs: number,
toleranceMs = START_DATE_TOLERANCE_MS,
): boolean =>
startDate !== undefined && startDate > currentEpochMs + toleranceMs;
export const isPastStartDate = (
startDate: number,
currentEpochMs: number,
): boolean => startDate < currentEpochMs - START_DATE_TOLERANCE_MS;
export const stripePhaseStartsInFuture = (
startDate: number | "now" | undefined,
currentEpochMs: number,
): boolean =>
typeof startDate === "number" &&
isFutureStartDate(secondsToMs(startDate), currentEpochMs, 0);

View File

@@ -122,7 +122,7 @@ export function buildAttachRequestBody({
}
if (startDate && !trialEnabled) {
body.start_date = startDate;
body.starts_at = startDate;
} else if (planSchedule) {
body.plan_schedule = planSchedule;
}

View File

@@ -11,7 +11,7 @@ function makeProduct({ items }: { items: ProductV2["items"] }): ProductV2 {
is_default: false,
version: 1,
group: null,
env: "sandbox" as any,
env: "sandbox" as ProductV2["env"],
items,
created_at: Date.now(),
};
@@ -172,7 +172,7 @@ describe("buildAttachRequestBody — billing_units handling", () => {
});
});
describe("buildAttachRequestBody — start_date handling", () => {
describe("buildAttachRequestBody — starts_at handling", () => {
const product = makeProduct({
items: [
{
@@ -182,7 +182,7 @@ describe("buildAttachRequestBody — start_date handling", () => {
],
});
test("sends start_date instead of silently falling back to plan_schedule", () => {
test("sends starts_at instead of silently falling back to plan_schedule", () => {
const startDate = addDays(Date.now(), 1).getTime();
const result = buildAttachRequestBody({
@@ -193,11 +193,11 @@ describe("buildAttachRequestBody — start_date handling", () => {
startDate,
});
expect(result?.start_date).toBe(startDate);
expect(result?.starts_at).toBe(startDate);
expect(result?.plan_schedule).toBeUndefined();
});
test("keeps plan_schedule when no start_date is selected", () => {
test("keeps plan_schedule when no starts_at is selected", () => {
const result = buildAttachRequestBody({
...baseParams,
product,
@@ -206,10 +206,10 @@ describe("buildAttachRequestBody — start_date handling", () => {
});
expect(result?.plan_schedule).toBe("end_of_cycle");
expect(result?.start_date).toBeUndefined();
expect(result?.starts_at).toBeUndefined();
});
test("does not send start_date with a trial", () => {
test("does not send starts_at with a trial", () => {
const result = buildAttachRequestBody({
...baseParams,
product,
@@ -219,6 +219,6 @@ describe("buildAttachRequestBody — start_date handling", () => {
trialLength: 7,
});
expect(result?.start_date).toBeUndefined();
expect(result?.starts_at).toBeUndefined();
});
});