diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.ts index 0815728ba..0a80f749a 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges.ts @@ -34,6 +34,9 @@ export const handleSchedulePhaseChanges = async ({ return; } + // Step 1: Activate scheduled products; checkout trial-end updates have no schedule phase change. + await activateScheduledCustomerProducts({ ctx, eventContext }); + // Check if phase possibly changed (items changed and schedule exists) const phasePossiblyChanged = notNullish(previousAttributes?.items) && @@ -52,10 +55,7 @@ export const handleSchedulePhaseChanges = async ({ `[handleSchedulePhaseChanges] sub: ${stripeSubscription.id}, now: ${formatMs(nowMs)}, currentPhase: ${currentPhaseIndex + 1}/${stripeSubscriptionSchedule.phases.length}`, ); - // Step 1: Activate scheduled customer products - await activateScheduledCustomerProducts({ ctx, eventContext }); - - // Step 2: Expire ended customer products (uses updated customerProducts from step 1) + // Step 2: Expire ended customer products (uses updated customerProducts) await expireEndedCustomerProducts({ ctx, eventContext }); // Step 3: Release schedule if at last phase diff --git a/server/src/internal/billing/v2/actions/attach/compute/applyAttachStartDates.ts b/server/src/internal/billing/v2/actions/attach/compute/applyAttachStartDates.ts deleted file mode 100644 index 2217880fa..000000000 --- a/server/src/internal/billing/v2/actions/attach/compute/applyAttachStartDates.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - type AttachBillingContext, - EntInterval, - type FullCusProduct, - getCycleEnd, -} from "@autumn/shared"; -import type { AttachStartTiming } from "./getAttachStartTiming"; - -export const applyAttachStartDates = ({ - newFullCustomerProduct, - attachBillingContext, - attachStartTiming, -}: { - newFullCustomerProduct: FullCusProduct; - attachBillingContext: AttachBillingContext; - attachStartTiming: AttachStartTiming; -}): void => { - const { billingStartsAt, currentEpochMs } = attachBillingContext; - const { accessStartsAt, billingAnchorStartsAt } = attachStartTiming; - - if (billingStartsAt !== undefined) { - for (const customerEntitlement of newFullCustomerProduct.customer_entitlements) { - if (customerEntitlement.next_reset_at === null) continue; - customerEntitlement.next_reset_at = getCycleEnd({ - anchor: billingStartsAt, - interval: customerEntitlement.entitlement.interval ?? EntInterval.Month, - intervalCount: customerEntitlement.entitlement.interval_count, - now: billingStartsAt, - }); - } - } - if (accessStartsAt === billingAnchorStartsAt) return; - newFullCustomerProduct.starts_at = accessStartsAt ?? currentEpochMs; -}; 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 5f775775c..80cf15cea 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts @@ -7,8 +7,6 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { carryOverUsagesToExistingUsagesConfig } from "@/internal/billing/v2/utils/handleCarryOvers/carryOverUtils"; import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; -import { applyAttachStartDates } from "./applyAttachStartDates"; -import { getAttachStartTiming } from "./getAttachStartTiming"; const getScheduledBillingCycleAnchorResetAt = ({ requestedBillingCycleAnchor, @@ -47,6 +45,7 @@ export const computeAttachNewCustomerProduct = ({ fullCustomer, currentCustomerProduct, planTiming, + endOfCycleMs, stripeSubscription, stripeSubscriptionSchedule, currentEpochMs, @@ -57,6 +56,8 @@ export const computeAttachNewCustomerProduct = ({ transitionConfig, externalId, requestedBillingCycleAnchor, + resetCycleAnchorMs, + accessStartsAt, } = attachBillingContext; const currentCustomerEntitlements = @@ -72,12 +73,8 @@ export const computeAttachNewCustomerProduct = ({ .map((ce) => ce.entitlement.feature.id), ); - const attachStartTiming = getAttachStartTiming({ - attachBillingContext, - params, - }); - const { billingAnchorStartsAt, resetCycleAnchor, status } = attachStartTiming; const isScheduled = planTiming === "end_of_cycle"; + const startsAt = params.starts_at ?? (isScheduled ? endOfCycleMs : undefined); let existingUsagesConfig: ExistingUsagesConfig | undefined = !isScheduled && currentCustomerProduct @@ -110,7 +107,7 @@ export const computeAttachNewCustomerProduct = ({ featureQuantities, // existingUsages: isScheduled ? undefined : existingUsages, // existingRollovers, - resetCycleAnchor, + resetCycleAnchor: resetCycleAnchorMs, now: currentEpochMs, freeTrial: trialContext?.freeTrial ?? null, trialEndsAt: trialContext?.trialEndsAt ?? undefined, @@ -125,8 +122,8 @@ export const computeAttachNewCustomerProduct = ({ // subscriptionId: isScheduled ? undefined : stripeSubscription?.id, subscriptionId: stripeSubscription?.id, subscriptionScheduleId: stripeSubscriptionSchedule?.id, - status, - startsAt: billingAnchorStartsAt, + startsAt, + accessStartsAt, externalId, billingCycleAnchorResetsAt: getScheduledBillingCycleAnchorResetAt({ requestedBillingCycleAnchor, @@ -135,11 +132,5 @@ export const computeAttachNewCustomerProduct = ({ }, }); - applyAttachStartDates({ - newFullCustomerProduct, - attachBillingContext, - attachStartTiming, - }); - return newFullCustomerProduct; }; 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 232228988..fa5dcc18e 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts @@ -61,7 +61,7 @@ export const computeAttachPlan = ({ const shouldBuildLineItems = shouldBuildImmediateLineItems({ planTiming, customerProductStatus: newCustomerProduct.status, - billingStartsAt: attachBillingContext.billingStartsAt, + accessStartsAt: attachBillingContext.accessStartsAt, }); const { allLineItems: lineItems, updateCustomerEntitlements } = diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachTransitionUpdates.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachTransitionUpdates.ts index 6129d7643..b8951b98d 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachTransitionUpdates.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachTransitionUpdates.ts @@ -41,7 +41,7 @@ export const computeAttachTransitionUpdates = ({ } const startsAt = params.starts_at; - const transitionEndMs = isFutureStartDate(startsAt, currentEpochMs) + const transitionAtMs = isFutureStartDate(startsAt, currentEpochMs) ? startsAt : endOfCycleMs; @@ -54,7 +54,7 @@ export const computeAttachTransitionUpdates = ({ : undefined, canceled: true, canceled_at: currentEpochMs, - ended_at: transitionEndMs, + ended_at: transitionAtMs, }, }; }; diff --git a/server/src/internal/billing/v2/actions/attach/compute/getAttachStartTiming.ts b/server/src/internal/billing/v2/actions/attach/compute/getAttachStartTiming.ts deleted file mode 100644 index d8ca8f808..000000000 --- a/server/src/internal/billing/v2/actions/attach/compute/getAttachStartTiming.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { AttachBillingContext, AttachParamsV1 } from "@autumn/shared"; -import { CusProductStatus } from "@autumn/shared"; - -export type AttachStartTiming = { - accessStartsAt?: number; - billingAnchorStartsAt?: number; - resetCycleAnchor: number | "now"; - status?: CusProductStatus; -}; - -const resolveResetCycleAnchor = ({ - billingStartsAt, - billingAnchorStartsAt, - resetCycleAnchorMs, -}: { - billingStartsAt?: number; - billingAnchorStartsAt?: number; - resetCycleAnchorMs: number | "now"; -}): number | "now" => { - if (billingStartsAt !== undefined) return billingStartsAt; - if (resetCycleAnchorMs !== "now") return resetCycleAnchorMs; - return billingAnchorStartsAt ?? resetCycleAnchorMs; -}; - -const resolveCustomerProductStatus = ({ - billingStartsAt, - isScheduled, -}: { - billingStartsAt?: number; - isScheduled: boolean; -}): CusProductStatus | undefined => { - if (billingStartsAt !== undefined) return CusProductStatus.Active; - if (isScheduled) return CusProductStatus.Scheduled; - return undefined; -}; - -export const getAttachStartTiming = ({ - attachBillingContext, - params, -}: { - attachBillingContext: AttachBillingContext; - params: AttachParamsV1; -}): AttachStartTiming => { - const { - planTiming, - endOfCycleMs, - resetCycleAnchorMs, - currentEpochMs, - billingStartsAt, - } = attachBillingContext; - const isScheduled = planTiming === "end_of_cycle"; - const requestedStartsAt = - params.starts_at ?? (isScheduled ? endOfCycleMs : undefined); - const billingAnchorStartsAt = billingStartsAt ?? requestedStartsAt; - const accessStartsAt = - billingStartsAt !== undefined ? currentEpochMs : requestedStartsAt; - const resetCycleAnchor = resolveResetCycleAnchor({ - billingStartsAt, - billingAnchorStartsAt, - resetCycleAnchorMs, - }); - const status = resolveCustomerProductStatus({ - billingStartsAt, - isScheduled, - }); - - return { - accessStartsAt, - billingAnchorStartsAt, - resetCycleAnchor, - status, - }; -}; diff --git a/server/src/internal/billing/v2/actions/attach/compute/shouldBuildImmediateLineItems.ts b/server/src/internal/billing/v2/actions/attach/compute/shouldBuildImmediateLineItems.ts index 9c931b75c..ac3470755 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/shouldBuildImmediateLineItems.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/shouldBuildImmediateLineItems.ts @@ -3,13 +3,13 @@ import { type AttachBillingContext, CusProductStatus } from "@autumn/shared"; export const shouldBuildImmediateLineItems = ({ planTiming, customerProductStatus, - billingStartsAt, + accessStartsAt, }: { planTiming: AttachBillingContext["planTiming"]; customerProductStatus: CusProductStatus; - billingStartsAt?: number; + accessStartsAt?: number; }): boolean => { - if (billingStartsAt !== undefined) return false; + if (accessStartsAt !== undefined) return false; if (planTiming !== "immediate") return false; return customerProductStatus !== CusProductStatus.Scheduled; }; diff --git a/server/src/internal/billing/v2/actions/attach/setup/getAttachBillingStartsAt.ts b/server/src/internal/billing/v2/actions/attach/setup/getAttachAccessStartsAt.ts similarity index 83% rename from server/src/internal/billing/v2/actions/attach/setup/getAttachBillingStartsAt.ts rename to server/src/internal/billing/v2/actions/attach/setup/getAttachAccessStartsAt.ts index abca2ddde..490d0ee1b 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/getAttachBillingStartsAt.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/getAttachAccessStartsAt.ts @@ -1,6 +1,6 @@ import { type AttachParamsV1, isFutureStartDate } from "@autumn/shared"; -export const getAttachBillingStartsAt = ({ +export const getAttachAccessStartsAt = ({ params, currentEpochMs, }: { @@ -12,9 +12,9 @@ export const getAttachBillingStartsAt = ({ params.enable_plan_immediately !== true || startsAt === undefined || !isFutureStartDate(startsAt, currentEpochMs) - ){ + ) { return undefined; } - return startsAt; + return currentEpochMs; }; 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 9cd6b7fd2..b65607060 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -25,7 +25,7 @@ import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCyc import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs"; import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities"; import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund"; -import { getAttachBillingStartsAt } from "./getAttachBillingStartsAt"; +import { getAttachAccessStartsAt } from "./getAttachAccessStartsAt"; import { setupAttachCheckoutMode } from "./setupAttachCheckoutMode"; import { setupAttachEndOfCycleMs } from "./setupAttachEndOfCycleMs"; import { setupAttachProductContext } from "./setupAttachProductContext"; @@ -183,12 +183,6 @@ export const setupAttachBillingContext = async ({ billingCycleAnchorMs = trialContext.trialEndsAt; } - const resetCycleAnchorMs = setupResetCycleAnchor({ - billingCycleAnchorMs, - customerProduct: undefined, // don't pass in current customer product here (paid products should have the reset cycle anchor correctly...) - newFullProduct: attachProduct, - }); - const endOfCycleMs = contextOverride.endOfCycleMsOverride ?? setupAttachEndOfCycleMs({ @@ -199,15 +193,24 @@ export const setupAttachBillingContext = async ({ currentEpochMs, }); + const attachStartsAt = + params.starts_at ?? (planTiming === "end_of_cycle" ? endOfCycleMs : undefined); const hasFutureStartDate = isFutureStartDate( params.starts_at, currentEpochMs, ); - const billingStartsAt = getAttachBillingStartsAt({ + const accessStartsAt = getAttachAccessStartsAt({ params, currentEpochMs, }); + const resetCycleAnchorMs = setupResetCycleAnchor({ + billingCycleAnchorMs, + customerProduct: undefined, // don't pass in current customer product here (paid products should have the reset cycle anchor correctly...) + newFullProduct: attachProduct, + startsAt: attachStartsAt, + }); + const checkoutMode = setupAttachCheckoutMode({ paymentMethod, redirectMode: params.redirect_mode, @@ -255,7 +258,7 @@ export const setupAttachBillingContext = async ({ invoiceMode, enablePlanImmediately: params.enable_plan_immediately ?? false, - billingStartsAt, + accessStartsAt, customPrices, customEnts, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan.ts index c4f35994e..e648f0773 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan.ts @@ -18,6 +18,7 @@ const computeScheduledAddOnsToDelete = ({ }: { billingContext: UpdateSubscriptionBillingContext; }): FullCusProduct[] => { + // Immediate main-plan cancellation invalidates future add-on phases in the same scope. const { cancelAction, customerProduct, fullCustomer } = billingContext; if (cancelAction !== "cancel_immediately") return []; if (!cp(customerProduct).main().recurring().valid) return []; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildCustomerProductsForStripe.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildCustomerProductsForStripe.ts index eae1c8f6e..5a8db0fec 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildCustomerProductsForStripe.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildCustomerProductsForStripe.ts @@ -17,14 +17,21 @@ export const buildCustomerProductsForStripe = ({ autumnBillingPlan: AutumnBillingPlan; finalCustomerProducts: FullCusProduct[]; }): FullCusProduct[] => { - const { billingStartsAt } = billingContext; - if (billingStartsAt === undefined) return finalCustomerProducts; + if (billingContext.accessStartsAt === undefined) return finalCustomerProducts; const insertedCustomerProductIds = new Set( autumnBillingPlan.insertCustomerProducts.map( (customerProduct) => customerProduct.id, ), ); + const billingStartMs = autumnBillingPlan.insertCustomerProducts.find( + (customerProduct) => + customerProduct.access_starts_at !== undefined && + customerProduct.access_starts_at !== null, + )?.starts_at; + + if (billingStartMs === undefined) return finalCustomerProducts; + const outgoingCustomerProduct = autumnBillingPlan.updateCustomerProduct?.customerProduct; @@ -33,7 +40,7 @@ export const buildCustomerProductsForStripe = ({ return { ...customerProduct, status: CusProductStatus.Scheduled, - starts_at: billingStartsAt, + starts_at: billingStartMs, }; } @@ -41,7 +48,7 @@ export const buildCustomerProductsForStripe = ({ return { ...outgoingCustomerProduct, status: CusProductStatus.Active, - ended_at: billingStartsAt, + ended_at: billingStartMs, canceled: true, canceled_at: billingContext.currentEpochMs, }; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/getCheckoutSubscriptionTrialEnd.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/getCheckoutSubscriptionTrialEnd.ts index 7043e957b..d6318b83e 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/getCheckoutSubscriptionTrialEnd.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/getCheckoutSubscriptionTrialEnd.ts @@ -12,9 +12,6 @@ export const getCheckoutSubscriptionTrialEnd = ({ deferredStartsAt?: number; }): number | undefined => { if (mode !== "subscription") return undefined; - if (billingContext.billingStartsAt) { - return msToSeconds(billingContext.billingStartsAt); - } if (deferredStartsAt) return msToSeconds(deferredStartsAt); if (!billingContext.trialContext?.trialEndsAt) return undefined; diff --git a/server/src/internal/billing/v2/setup/setupResetCycleAnchor.ts b/server/src/internal/billing/v2/setup/setupResetCycleAnchor.ts index 60b2a1f8e..483a5a662 100644 --- a/server/src/internal/billing/v2/setup/setupResetCycleAnchor.ts +++ b/server/src/internal/billing/v2/setup/setupResetCycleAnchor.ts @@ -8,16 +8,28 @@ import { /** * Determine the billing cycle anchor based on product transitions. + * + * For future starts, feature resets anchor to the billing start (`startsAt`). */ export const setupResetCycleAnchor = ({ billingCycleAnchorMs, customerProduct, newFullProduct, + startsAt, }: { billingCycleAnchorMs: number | "now"; customerProduct?: FullCusProduct; newFullProduct: FullProduct; + startsAt?: number; }): number | "now" => { + const hasFutureBillingStart = startsAt !== undefined; + const shouldAnchorToBillingStart = + hasFutureBillingStart && !customerProduct; + + if (shouldAnchorToBillingStart) { + return startsAt; + } + if (!customerProduct) { return billingCycleAnchorMs; } diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts index 90250dfb6..2eac34ab0 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -35,6 +35,7 @@ export const initCustomerProduct = ({ apiSemver, externalId, billingCycleAnchorResetsAt, + accessStartsAt, } = initOptions ?? {}; const internalEntityId = fullCustomer.entity?.internal_id; @@ -48,7 +49,11 @@ export const initCustomerProduct = ({ // 1 minute tolerance to determine if customer product should be scheduled. (for test clock time frozen issues) const TOLERANCE_MS = ms.minutes(1); - if (startsAt && startsAt > now + TOLERANCE_MS) { + const effectiveAccessStartsAt = accessStartsAt ?? startsAt; + if ( + effectiveAccessStartsAt && + effectiveAccessStartsAt > now + TOLERANCE_MS + ) { return CusProductStatus.Scheduled; } @@ -85,6 +90,7 @@ export const initCustomerProduct = ({ // processor: null, starts_at: startsAt, + access_starts_at: accessStartsAt ?? null, ended_at: endedAt, trial_ends_at: trialEndsAt, diff --git a/server/tests/integration/billing/attach/params/start-date/starts-at-enable-plan-immediately.test.ts b/server/tests/integration/billing/attach/params/start-date/starts-at-enable-plan-immediately.test.ts index 2db39896d..34c1b7f5d 100644 --- a/server/tests/integration/billing/attach/params/start-date/starts-at-enable-plan-immediately.test.ts +++ b/server/tests/integration/billing/attach/params/start-date/starts-at-enable-plan-immediately.test.ts @@ -63,7 +63,8 @@ test.concurrent(`${chalk.yellowBright("starts_at: enable_plan_immediately activa expect(cusProduct.status).toBe(CusProductStatus.Active); expect(cusProduct.subscription_ids ?? []).toEqual([]); expect(cusProduct.scheduled_ids).toHaveLength(1); - expect(Math.abs(cusProduct.starts_at - advancedTo)).toBeLessThan( + expect(cusProduct.starts_at).toBe(startDate); + expect(Math.abs(cusProduct.access_starts_at! - advancedTo)).toBeLessThan( ms.minutes(10), ); expectResetAnchoredTo({ @@ -123,9 +124,10 @@ test.concurrent(`${chalk.yellowBright("starts_at: upgrade access can start befor }); expect(premiumCustomerProduct.status).toBe(CusProductStatus.Active); expect(premiumCustomerProduct.scheduled_ids).toHaveLength(1); - expect(Math.abs(premiumCustomerProduct.starts_at - advancedTo)).toBeLessThan( - ms.minutes(10), - ); + expect(premiumCustomerProduct.starts_at).toBe(startsAt); + expect( + Math.abs(premiumCustomerProduct.access_starts_at! - advancedTo), + ).toBeLessThan(ms.minutes(10)); expectResetAnchoredTo({ cusProduct: premiumCustomerProduct, featureId: TestFeature.Messages, @@ -181,9 +183,10 @@ test.concurrent(`${chalk.yellowBright("starts_at: add-on access can start before }); expect(addonCustomerProduct.status).toBe(CusProductStatus.Active); expect(addonCustomerProduct.scheduled_ids).toHaveLength(1); - expect(Math.abs(addonCustomerProduct.starts_at - advancedTo)).toBeLessThan( - ms.minutes(10), - ); + expect(addonCustomerProduct.starts_at).toBe(startsAt); + expect( + Math.abs(addonCustomerProduct.access_starts_at! - advancedTo), + ).toBeLessThan(ms.minutes(10)); expectResetAnchoredTo({ cusProduct: addonCustomerProduct, featureId: TestFeature.Words, 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 index b6ea9f1de..82964a0ce 100644 --- 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 @@ -77,6 +77,7 @@ test.concurrent(`${chalk.yellowBright("starts_at: future attach creates schedule productId: pro.id, }); expect(cusProduct.status).toBe(CusProductStatus.Scheduled); + expect(cusProduct.access_starts_at).toBeNull(); expect(cusProduct.subscription_ids ?? []).toEqual([]); expect(cusProduct.scheduled_ids).toHaveLength(1); expectResetAnchoredTo({ 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 index 8974f786a..94a9421d4 100644 --- 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 @@ -3,10 +3,49 @@ 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 type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; import chalk from "chalk"; +import { handleSchedulePhaseChanges } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges"; +import type { StripeSubscriptionUpdatedContext } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext"; +import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; import { CusService } from "@/internal/customers/CusService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { getCustomerProduct, triggerSubscriptionCreated } from "./utils"; +const triggerSubscriptionUpdated = async ({ + ctx, + stripeSubId, + fullCustomer, + nowMs, +}: { + ctx: TestContext; + stripeSubId: string; + fullCustomer: StripeSubscriptionUpdatedContext["fullCustomer"]; + nowMs: number; +}) => { + await handleSchedulePhaseChanges({ + ctx: { + ...ctx, + stripeEvent: {} as StripeWebhookContext["stripeEvent"], + }, + eventContext: { + stripeSubscription: { + id: stripeSubId, + schedule: null, + }, + previousAttributes: { + status: "trialing", + }, + fullCustomer, + customerProducts: [...fullCustomer.customer_products], + nowMs, + updatedCustomerProducts: [], + deletedCustomerProducts: [], + insertedCustomerProducts: [], + } as unknown as StripeSubscriptionUpdatedContext, + }); +}; + test.concurrent(`${chalk.yellowBright("starts_at: subscription.created links scheduled product")}`, async () => { const customerId = "attach-start-date-webhook"; const pro = products.pro({ @@ -172,6 +211,63 @@ test.concurrent(`${chalk.yellowBright("starts_at: subscription.created retry can expect(activatedProduct.status).toBe(CusProductStatus.Active); }); +test.concurrent(`${chalk.yellowBright("starts_at: subscription.updated activates checkout trial-start product")}`, async () => { + const customerId = "attach-start-date-webhook-sub-updated"; + 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 scheduledProduct = await getCustomerProduct({ + ctx, + customerId, + productId: pro.id, + }); + const stripeSubId = "sub_attach_start_date_updated"; + await CusProductService.update({ + ctx, + cusProductId: scheduledProduct.id, + updates: { + subscription_ids: [stripeSubId], + }, + }); + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + await triggerSubscriptionUpdated({ + ctx, + stripeSubId, + fullCustomer, + nowMs: startDate + ms.minutes(5), + }); + + const activatedProduct = await getCustomerProduct({ + ctx, + customerId, + productId: pro.id, + }); + expect(activatedProduct.status).toBe(CusProductStatus.Active); + expect(activatedProduct.subscription_ids).toEqual([stripeSubId]); +}); + test.concurrent(`${chalk.yellowBright("starts_at: subscription.created ignores missing schedule")}`, async () => { const customerId = "attach-start-date-webhook-no-schedule"; const pro = products.pro({ diff --git a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts index 96de09cee..bd53751bd 100644 --- a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts +++ b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts @@ -257,6 +257,7 @@ const buildCustomerProduct = ({ canceled_at: null, ended_at: null, starts_at: now, + access_starts_at: null, options: [], product_id: product.id, free_trial_id: null, diff --git a/shared/models/billingModels/context/billingContext.ts b/shared/models/billingModels/context/billingContext.ts index df538297e..511f9c4a7 100644 --- a/shared/models/billingModels/context/billingContext.ts +++ b/shared/models/billingModels/context/billingContext.ts @@ -92,8 +92,8 @@ export interface BillingContext { // session is required. Mirrors invoice-mode enable_plan_immediately for the // stripe_checkout flow. enablePlanImmediately?: boolean; - // When set, Autumn access starts now while Stripe billing starts at this time. - billingStartsAt?: number; + // When set, Autumn access starts at this time while billing may start later. + accessStartsAt?: number; /** Identifies the Autumn action driving this billing context. Stamped onto Stripe * subscription metadata so downstream webhook handlers can recognise Autumn-driven * subscription mutations and skip auto-sync. */ diff --git a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts index f39c75af9..dc8e92b92 100644 --- a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts +++ b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts @@ -65,6 +65,7 @@ export interface InitFullCustomerProductOptions { canceledAt?: number; status?: CusProductStatus; // Used for scheduling product startsAt?: number; // Used for scheduling product + accessStartsAt?: number; endedAt?: number; // Used for scheduling product // Optional + random diff --git a/shared/models/cusProductModels/cusProductModels.ts b/shared/models/cusProductModels/cusProductModels.ts index 82dd320e1..2f3138332 100644 --- a/shared/models/cusProductModels/cusProductModels.ts +++ b/shared/models/cusProductModels/cusProductModels.ts @@ -41,6 +41,7 @@ export const CusProductSchema = z.object({ canceled: z.boolean().default(false), starts_at: z.number().default(Date.now()), + access_starts_at: z.number().optional().nullable(), trial_ends_at: z.number().optional().nullable(), billing_cycle_anchor_resets_at: z.number().optional().nullable(), canceled_at: z.number().optional().nullable(), diff --git a/shared/models/cusProductModels/cusProductTable.ts b/shared/models/cusProductModels/cusProductTable.ts index 195962c74..f8661b195 100644 --- a/shared/models/cusProductModels/cusProductTable.ts +++ b/shared/models/cusProductModels/cusProductTable.ts @@ -36,6 +36,7 @@ export const customerProducts = pgTable( canceled_at: numeric({ mode: "number" }), ended_at: numeric({ mode: "number" }), starts_at: numeric({ mode: "number" }), + access_starts_at: numeric({ mode: "number" }), options: jsonb().array(), product_id: text("product_id"), free_trial_id: text("free_trial_id"), diff --git a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx index 633f7e3da..a2992c914 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx @@ -107,7 +107,7 @@ function SubscriptionDetailItems({ } export function SubscriptionDetailSheet() { - const { customer } = useCusQuery(); + const { customer, testClockFrozenTimeMs } = useCusQuery(); const { stripeAccount } = useOrgStripeQuery(); const env = useEnv(); const itemId = useSheetStore((s) => s.itemId); @@ -144,6 +144,7 @@ export function SubscriptionDetailSheet() { const isScheduled = cusProduct.status === CusProductStatus.Scheduled; const canCancel = !isExpired; const canUpdate = !isExpired && !isScheduled; + const nowMs = testClockFrozenTimeMs ?? Date.now(); const prepaidDisplayQuantities = backendToDisplayQuantity({ backendOptions: cusProduct.options, prepaidItems, @@ -302,10 +303,11 @@ export function SubscriptionDetailSheet() { canceled_at={cusProduct.canceled_at ?? undefined} trialing={ isCustomerProductTrialing(cusProduct, { - nowMs: Date.now(), + nowMs, }) || false } trial_ends_at={cusProduct.trial_ends_at ?? undefined} + nowMs={nowMs} /> } /> diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx index 834fdd6d0..ec282a396 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx @@ -44,15 +44,24 @@ export const CustomerProductsColumns = [ { header: "Status", accessorKey: "status", - cell: ({ row }: { row: Row }) => { + cell: ({ + row, + table, + }: { + row: Row; + table: Table; + }) => { + const nowMs = (table.options.meta as { nowMs?: number })?.nowMs; + return ( ); }, diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsStatus.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsStatus.tsx index 44ba60777..cb5a5eab6 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsStatus.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsStatus.tsx @@ -1,6 +1,6 @@ import { CusProductStatus, formatMsToDate } from "@autumn/shared"; import { DotIcon, ExclamationMarkIcon, XIcon } from "@phosphor-icons/react"; -import { formatDistanceToNow } from "date-fns"; +import { formatDistance } from "date-fns"; import { BanIcon, CalendarIcon, CheckIcon, ClockIcon } from "lucide-react"; import { Tooltip, @@ -15,6 +15,7 @@ const StatusItem = ({ text, trial_ends_at, canceled_at, + nowMs, tooltip, className, }: { @@ -22,15 +23,16 @@ const StatusItem = ({ text: string; trial_ends_at?: number; canceled_at?: number; + nowMs?: number; tooltip?: boolean; className?: string; }) => { const getSubtext = () => { if (trial_ends_at) { - return `${formatDistanceToNow(trial_ends_at)} left`; + return `${formatDistance(trial_ends_at, nowMs ?? Date.now())} left`; } if (canceled_at) { - return `${formatDistanceToNow(canceled_at)} ago`; + return `${formatDistance(canceled_at, nowMs ?? Date.now())} ago`; } return null; }; @@ -75,6 +77,7 @@ export const CustomerProductsStatus = ({ trialing, trial_ends_at, starts_at, + nowMs, }: { status?: CusProductStatus; tooltip?: boolean; @@ -83,7 +86,10 @@ export const CustomerProductsStatus = ({ trialing?: boolean; trial_ends_at?: number; starts_at?: number; + nowMs?: number; }) => { + const effectiveNowMs = nowMs ?? Date.now(); + // Expired status takes priority over canceled if (status === CusProductStatus.Expired) { return ( @@ -115,7 +121,12 @@ export const CustomerProductsStatus = ({ // If product is canceled, show that status if (canceled) { return ( - + +