From c441ee6567e8323fbf976af08de6f639ea36e8cb Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 5 May 2026 11:08:41 +0100 Subject: [PATCH] chore: resolve comments --- .../computeAttachNewCustomerProduct.ts | 6 +- .../attach/compute/computeAttachPlan.ts | 18 +- .../attach/errors/handleStartDateErrors.ts | 64 +- .../attach/setup/setupAttachBillingContext.ts | 7 +- .../buildStripeSubscriptionAction.ts | 24 +- .../buildStripeSubscriptionScheduleAction.ts | 10 +- .../evaluateStripeBillingPlan.ts | 2 + .../billing/v2/utils/startDateUtils.ts | 19 - .../params/start-date/scheduling.test.ts | 219 ------- .../start-date/starts_at_scheduling.test.ts | 565 ++++++++++++++++++ .../start-date/starts_at_validation.test.ts | 333 +++++++++++ .../start-date/starts_at_webhook.test.ts | 246 ++++++++ .../billing/attach/params/start-date/utils.ts | 13 +- .../params/start-date/validation.test.ts | 403 ------------- .../attach/params/start-date/webhook.test.ts | 208 ------- .../attach/attach-start-date-scenario.test.ts | 15 +- .../unit/billing/attach-params-schema.test.ts | 20 +- shared/api/billing/attachV2/attachParamsV0.ts | 66 +- shared/api/billing/attachV2/attachParamsV1.ts | 2 +- shared/utils/common/unixUtils.ts | 21 + .../attach-v2/hooks/useAttachRequestBody.ts | 2 +- .../hooks/build-attach-request-body.test.ts | 16 +- 22 files changed, 1289 insertions(+), 990 deletions(-) delete mode 100644 server/src/internal/billing/v2/utils/startDateUtils.ts delete mode 100644 server/tests/integration/billing/attach/params/start-date/scheduling.test.ts create mode 100644 server/tests/integration/billing/attach/params/start-date/starts_at_scheduling.test.ts create mode 100644 server/tests/integration/billing/attach/params/start-date/starts_at_validation.test.ts create mode 100644 server/tests/integration/billing/attach/params/start-date/starts_at_webhook.test.ts delete mode 100644 server/tests/integration/billing/attach/params/start-date/validation.test.ts delete mode 100644 server/tests/integration/billing/attach/params/start-date/webhook.test.ts diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts index 00f6ee91b..e2b11f93d 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts @@ -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 = diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts index 0e999e311..fa42d9792 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts @@ -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 diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleStartDateErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleStartDateErrors.ts index ba13cf6e1..a89ecd349 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleStartDateErrors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleStartDateErrors.ts @@ -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, }); diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 2d31e3a44..b411ed0a3 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -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, diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts index 70980d963..516614fb8 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts @@ -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) { diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts index ac201c5b1..cada0ef67 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction.ts @@ -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, }); }; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts index cceed9c20..bdd75f2b7 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts @@ -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({ diff --git a/server/src/internal/billing/v2/utils/startDateUtils.ts b/server/src/internal/billing/v2/utils/startDateUtils.ts deleted file mode 100644 index ab786b548..000000000 --- a/server/src/internal/billing/v2/utils/startDateUtils.ts +++ /dev/null @@ -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); diff --git a/server/tests/integration/billing/attach/params/start-date/scheduling.test.ts b/server/tests/integration/billing/attach/params/start-date/scheduling.test.ts deleted file mode 100644 index 4bfda9d45..000000000 --- a/server/tests/integration/billing/attach/params/start-date/scheduling.test.ts +++ /dev/null @@ -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({ - customer_id: customerId, - plan_id: pro.id, - start_date: startDate, - }); - expect(preview.total).toBe(0); - - await autumnV2_2.billing.attach({ - customer_id: customerId, - plan_id: pro.id, - start_date: startDate, - }); - - const customer = await autumnV2_2.customers.get(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({ - customer_id: customerId, - product_id: pro.id, - start_date: startDate, - }); - expect(preview.total).toBe(0); - - await autumnV1Beta.billing.attach({ - customer_id: customerId, - product_id: pro.id, - start_date: startDate, - }); - - const customer = await autumnV2_2.customers.get(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({ - customer_id: customerId, - plan_id: pro.id, - start_date: advancedTo, - }); - - const customer = await autumnV2_2.customers.get(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({ - 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]); -}); diff --git a/server/tests/integration/billing/attach/params/start-date/starts_at_scheduling.test.ts b/server/tests/integration/billing/attach/params/start-date/starts_at_scheduling.test.ts new file mode 100644 index 000000000..7c1f4d963 --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/starts_at_scheduling.test.ts @@ -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( + { + customer_id: customerId, + plan_id: pro.id, + starts_at: startDate, + }, + ); + expect(preview.total).toBe(0); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startDate, + }); + + const customer = await autumnV2_2.customers.get(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({ + customer_id: customerId, + product_id: pro.id, + starts_at: startDate, + }); + expect(preview.total).toBe(0); + + await autumnV1Beta.billing.attach({ + customer_id: customerId, + product_id: pro.id, + starts_at: startDate, + }); + + const customer = await autumnV2_2.customers.get(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({ + customer_id: customerId, + plan_id: pro.id, + starts_at: advancedTo, + }); + + const customer = await autumnV2_2.customers.get(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({ + customer_id: customerId, + product_id: pro.id, + entity_id: entityAId, + }); + + const startsAt = addDays(advancedTo, 10).getTime(); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + entity_id: entityBId, + starts_at: startsAt, + }); + + const entityA = await autumnV1.entities.get( + customerId, + entityAId, + ); + const entityB = await autumnV1.entities.get( + 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({ + customer_id: customerId, + product_id: pro.id, + entity_id: entityAId, + }); + + const startsAt = addDays(advancedTo, 10).getTime(); + await autumnV1.billing.attach({ + 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({ + customer_id: customerId, + plan_id: pro.id, + starts_at: addDays(advancedTo, 7).getTime(), + }); + const customerWithScheduledPro = + await autumnV2_2.customers.get(customerId); + await expectProductScheduled({ + customer: customerWithScheduledPro, + productId: pro.id, + }); + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + }); + + const customer = await autumnV2_2.customers.get(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({ + customer_id: customerId, + plan_id: pro.id, + starts_at: startsAt, + }); + + const customer = await autumnV2_2.customers.get(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({ + customer_id: customerId, + plan_id: addon.id, + starts_at: startsAt, + }); + + const customer = await autumnV2_2.customers.get(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({ + customer_id: customerId, + plan_id: addon.id, + starts_at: addDays(advancedTo, 7).getTime(), + }); + const customerWithScheduledAddon = + await autumnV2_2.customers.get(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(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({ + 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]); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/starts_at_validation.test.ts b/server/tests/integration/billing/attach/params/start-date/starts_at_validation.test.ts new file mode 100644 index 000000000..ed61e6ded --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/starts_at_validation.test.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + customer_id: customerId, + product_id: pro.id, + starts_at: addDays(advancedTo, 1).getTime(), + free_trial: { + length: 7, + duration: FreeTrialDuration.Day, + card_required: true, + }, + }), + }); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/starts_at_webhook.test.ts b/server/tests/integration/billing/attach/params/start-date/starts_at_webhook.test.ts new file mode 100644 index 000000000..01606a83d --- /dev/null +++ b/server/tests/integration/billing/attach/params/start-date/starts_at_webhook.test.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ + 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([]); + }, +); diff --git a/server/tests/integration/billing/attach/params/start-date/utils.ts b/server/tests/integration/billing/attach/params/start-date/utils.ts index 8b4f505bc..681acaca3 100644 --- a/server/tests/integration/billing/attach/params/start-date/utils.ts +++ b/server/tests/integration/billing/attach/params/start-date/utils.ts @@ -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[0]["ctx"]; customerId: string; productId: string; + entityId?: string; }): Promise => { 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, diff --git a/server/tests/integration/billing/attach/params/start-date/validation.test.ts b/server/tests/integration/billing/attach/params/start-date/validation.test.ts deleted file mode 100644 index 1819ec140..000000000 --- a/server/tests/integration/billing/attach/params/start-date/validation.test.ts +++ /dev/null @@ -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({ - 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({ - 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({ - 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({ - 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({ - 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({ - 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({ - 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({ - 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({ - 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({ - 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({ - 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({ - 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({ - customer_id: customerId, - product_id: pro.id, - start_date: addDays(advancedTo, 1).getTime(), - free_trial: { - length: 7, - duration: FreeTrialDuration.Day, - card_required: true, - }, - }), - }); -}); diff --git a/server/tests/integration/billing/attach/params/start-date/webhook.test.ts b/server/tests/integration/billing/attach/params/start-date/webhook.test.ts deleted file mode 100644 index e9f4d7756..000000000 --- a/server/tests/integration/billing/attach/params/start-date/webhook.test.ts +++ /dev/null @@ -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({ - 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({ - 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({ - 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({ - 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({ - 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([]); -}); diff --git a/server/tests/scenarios/attach/attach-start-date-scenario.test.ts b/server/tests/scenarios/attach/attach-start-date-scenario.test.ts index b0d96bacf..655c76268 100644 --- a/server/tests/scenarios/attach/attach-start-date-scenario.test.ts +++ b/server/tests/scenarios/attach/attach-start-date-scenario.test.ts @@ -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({ customer_id: customerId, plan_id: pro.id, - start_date: startDate, + starts_at: startDate, }); const customer = await autumnV2_2.customers.get(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] }); diff --git a/server/tests/unit/billing/attach-params-schema.test.ts b/server/tests/unit/billing/attach-params-schema.test.ts index e369914b7..7ac18d6d8 100644 --- a/server/tests/unit/billing/attach-params-schema.test.ts +++ b/server/tests/unit/billing/attach-params-schema.test.ts @@ -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); } diff --git a/shared/api/billing/attachV2/attachParamsV0.ts b/shared/api/billing/attachV2/attachParamsV0.ts index 1278990e1..e60c43b98 100644 --- a/shared/api/billing/attachV2/attachParamsV0.ts +++ b/shared/api/billing/attachV2/attachParamsV0.ts @@ -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; diff --git a/shared/api/billing/attachV2/attachParamsV1.ts b/shared/api/billing/attachV2/attachParamsV1.ts index d4ee8e9cd..d2e7d462f 100644 --- a/shared/api/billing/attachV2/attachParamsV1.ts +++ b/shared/api/billing/attachV2/attachParamsV1.ts @@ -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.", }), diff --git a/shared/utils/common/unixUtils.ts b/shared/utils/common/unixUtils.ts index ada9e71ae..95cb5c02f 100644 --- a/shared/utils/common/unixUtils.ts +++ b/shared/utils/common/unixUtils.ts @@ -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); diff --git a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts index 4b0120872..ec0b05c04 100644 --- a/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts +++ b/vite/src/components/forms/attach-v2/hooks/useAttachRequestBody.ts @@ -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; } diff --git a/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts b/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts index d23fd6511..c4cdf4c62 100644 --- a/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts +++ b/vite/tests/components/forms/attach-v2/hooks/build-attach-request-body.test.ts @@ -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(); }); });