chore: resolve comments
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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]);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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] });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user