From 5442e9fc372e06fb80cdeaccb8e7a0aa0a13ee75 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Fri, 24 Oct 2025 15:44:08 +0200 Subject: [PATCH] fix(billing): adapt current phase to current period (#15321) Fix https://github.com/twentyhq/core-team-issues/issues/1748 --- .../billing/hooks/useBillingWording.ts | 24 +++------ .../services/billing-subscription.service.ts | 50 +++++++----------- .../ensure-future-start-date.util.spec.ts | 51 ------------------- .../utils/ensure-future-start-date.util.ts | 7 --- 4 files changed, 27 insertions(+), 105 deletions(-) delete mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/ensure-future-start-date.util.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/ensure-future-start-date.util.ts diff --git a/packages/twenty-front/src/modules/billing/hooks/useBillingWording.ts b/packages/twenty-front/src/modules/billing/hooks/useBillingWording.ts index c72630403da..6cd13a3d180 100644 --- a/packages/twenty-front/src/modules/billing/hooks/useBillingWording.ts +++ b/packages/twenty-front/src/modules/billing/hooks/useBillingWording.ts @@ -4,11 +4,7 @@ import { SubscriptionInterval, SubscriptionStatus, } from '~/generated-metadata/graphql'; -import { - assertIsDefinedOrThrow, - capitalize, - isDefined, -} from 'twenty-shared/utils'; +import { assertIsDefinedOrThrow, capitalize } from 'twenty-shared/utils'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; import { useLingui } from '@lingui/react/macro'; @@ -52,18 +48,14 @@ export const useBillingWording = () => { : t`year`; const getBeautifiedRenewDate = () => { - const endDateFromPhase = currentBillingSubscription.phases?.[0]?.end_date; - const renewData = endDateFromPhase - ? endDateFromPhase * 1000 - : currentBillingSubscription.currentPeriodEnd - ? new Date(currentBillingSubscription.currentPeriodEnd) - : undefined; + assertIsDefinedOrThrow( + currentBillingSubscription.currentPeriodEnd, + new Error(`No renew date defined for current subscription.`), + ); - if (!isDefined(renewData)) { - throw new Error(`No renew date defined for current subscription.`); - } - - return beautifyExactDate(renewData); + return beautifyExactDate( + new Date(currentBillingSubscription.currentPeriodEnd), + ); }; const getIntervalLabelAsAdjectiveCapitalize = (isMonthlyPlan: boolean) => { diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index 2f0d46eda83..8c2fd2a4882 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -47,7 +47,6 @@ import { BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing import { LicensedBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type'; import { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type'; import { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type'; -import { ensureFutureStartDate } from 'src/engine/core-modules/billing/utils/ensure-future-start-date.util'; import { getOppositeInterval } from 'src/engine/core-modules/billing/utils/get-opposite-interval'; import { getOppositePlan } from 'src/engine/core-modules/billing/utils/get-opposite-plan'; import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util'; @@ -232,7 +231,6 @@ export class BillingSubscriptionService { nextEditable: updatedNextEditable, } = (await this.maybeUpgradeNowIfHigherTier( billingSubscription, - currentPhaseDetails, targetCap, currentCap, mappedCurrentMeteredId, @@ -258,10 +256,14 @@ export class BillingSubscriptionService { nextMutated, ); + const currentPhaseSnapshotForUpdate = currentMutated + ? { ...currentMutated, end_date: updatedSubscription.current_period_end } + : undefined; + await this.stripeSubscriptionScheduleService.replaceEditablePhases( updatedSchedule.id, { - currentPhaseSnapshot: currentMutated ?? undefined, + currentPhaseSnapshot: currentPhaseSnapshotForUpdate, nextPhase: nextForUpdate, }, ); @@ -572,7 +574,6 @@ export class BillingSubscriptionService { private async replaceCurrentMeteredItem( billingSubscription: BillingSubscriptionEntity, newMeteredPriceId: string, - licensedPriceIdForThresholds: string, ): Promise { const licensedItem = this.getCurrentLicensedBillingSubscriptionItemOrThrow( @@ -589,7 +590,6 @@ export class BillingSubscriptionService { meteredPriceId: newMeteredPriceId, seats: licensedItem.quantity, proration: 'none', - thresholdsPriceId: licensedPriceIdForThresholds, }); await this.syncSubscriptionToDatabase( @@ -675,9 +675,6 @@ export class BillingSubscriptionService { private async maybeUpgradeNowIfHigherTier( billingSubscription: BillingSubscriptionEntity, - currentPhaseDetails: Awaited< - ReturnType - >, targetCap: number, currentCap: number, mappedCurrentMeteredId: string, @@ -691,12 +688,9 @@ export class BillingSubscriptionService { | undefined > { if (targetCap > currentCap) { - const currentLicensedId = currentPhaseDetails.licensedPrice.stripePriceId; - await this.replaceCurrentMeteredItem( billingSubscription, mappedCurrentMeteredId, - currentLicensedId, ); const { subscription, schedule, currentEditable, nextEditable } = await this.loadScheduleEditable( @@ -717,7 +711,7 @@ export class BillingSubscriptionService { >, mappedCurrentMeteredId: string, mappedNextMeteredId: string, - subscriptionCurrentPeriodEnd: number | undefined, + subscriptionCurrentPeriodEnd: number, mutateCurrentNow: boolean, ): Promise<{ currentSnap: Stripe.SubscriptionScheduleUpdateParams.Phase | undefined; @@ -773,10 +767,7 @@ export class BillingSubscriptionService { } const nextPhaseBase: Stripe.SubscriptionScheduleUpdateParams.Phase = { - start_date: ensureFutureStartDate( - (currentSnap?.end_date as number | undefined) ?? - subscriptionCurrentPeriodEnd, - ), + start_date: subscriptionCurrentPeriodEnd, items: baseItems, proration_behavior: 'none', }; @@ -1018,10 +1009,7 @@ export class BillingSubscriptionService { const nextPhaseForYear = await this.billingSubscriptionPhaseService.buildSnapshot( { - start_date: ensureFutureStartDate( - (currentSnap?.end_date as number | undefined) ?? - subscription.current_period_end, - ), + start_date: subscription.current_period_end, items: currentSnap.items, proration_behavior: 'none', } as Stripe.SubscriptionScheduleUpdateParams.Phase, @@ -1203,10 +1191,7 @@ export class BillingSubscriptionService { const nextPhase = await this.billingSubscriptionPhaseService.buildSnapshot( { - start_date: ensureFutureStartDate( - (currentPhaseSnapshot?.end_date as number | undefined) ?? - subscription.current_period_end, - ), + start_date: subscription.current_period_end, items: currentPhaseSnapshot.items, proration_behavior: 'none', } as Stripe.SubscriptionScheduleUpdateParams.Phase, @@ -1287,7 +1272,6 @@ export class BillingSubscriptionService { seats: number; anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor; proration?: Stripe.SubscriptionUpdateParams.ProrationBehavior; - thresholdsPriceId?: string; metadata?: Record; }) { const { @@ -1326,8 +1310,14 @@ export class BillingSubscriptionService { currentPhaseSnapshot: Stripe.SubscriptionScheduleUpdateParams.Phase; nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase; }): Promise { - const { scheduleId, currentPhaseSnapshot, subscription } = params; - let { nextPhase } = params; + const { scheduleId, subscription } = params; + let { nextPhase, currentPhaseSnapshot } = params; + + const currentPhaseToPersist: Stripe.SubscriptionScheduleUpdateParams.Phase = + { + ...currentPhaseSnapshot, + end_date: subscription.current_period_end, + }; if ( nextPhase && @@ -1342,7 +1332,7 @@ export class BillingSubscriptionService { await this.stripeSubscriptionScheduleService.replaceEditablePhases( scheduleId, { - currentPhaseSnapshot, + currentPhaseSnapshot: currentPhaseToPersist, nextPhase, }, ); @@ -1433,7 +1423,6 @@ export class BillingSubscriptionService { seats: prices.seats, anchor: 'now', proration: 'create_prorations', - thresholdsPriceId: prices.licensedPriceId, }); await this.syncSubscriptionToDatabase(sub.workspaceId, updatedSubscription); @@ -1472,8 +1461,7 @@ export class BillingSubscriptionService { ); const next = await this.billingSubscriptionPhaseService.buildSnapshot( { - start_date: - currentPhaseSnapshot.end_date ?? subscription.current_period_end, + start_date: subscription.current_period_end, items: currentPhaseSnapshot.items, proration_behavior: 'none', } as Stripe.SubscriptionScheduleUpdateParams.Phase, diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/ensure-future-start-date.util.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/ensure-future-start-date.util.spec.ts deleted file mode 100644 index 025f8746efe..00000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/__tests__/ensure-future-start-date.util.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ensureFutureStartDate } from 'src/engine/core-modules/billing/utils/ensure-future-start-date.util'; - -describe('ensureFutureStartDate', () => { - const fixedNowSec = 1_700_000_000; // arbitrary fixed epoch seconds - const fixedNowMs = fixedNowSec * 1000; - - beforeEach(() => { - jest.spyOn(Date, 'now').mockReturnValue(fixedNowMs); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('returns now+1 when called with no arguments', () => { - expect(ensureFutureStartDate()).toBe(fixedNowSec + 1); - }); - - it('returns now+1 when all provided dates are in the past or equal to now', () => { - const past = fixedNowSec - 10; - const equal = fixedNowSec; - - expect(ensureFutureStartDate(past, null, undefined, 0, equal)).toBe( - fixedNowSec + 1, - ); - }); - - it('returns the maximum future date when at least one future date is provided', () => { - const future1 = fixedNowSec + 5; - const future2 = fixedNowSec + 10; - - expect(ensureFutureStartDate(future1, future2)).toBe(future2); - }); - - it('ignores null/undefined by treating them as 0 and still enforces at least now+1', () => { - const result = ensureFutureStartDate( - null, - undefined, - -100, - fixedNowSec - 1, - ); - - expect(result).toBe(fixedNowSec + 1); - }); - - it('returns a provided future date even if it is just one second above now+1 boundary', () => { - const barelyFuture = fixedNowSec + 2; // > now+1 - - expect(ensureFutureStartDate(barelyFuture)).toBe(barelyFuture); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/ensure-future-start-date.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/ensure-future-start-date.util.ts deleted file mode 100644 index 4a61d07b7f1..00000000000 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/ensure-future-start-date.util.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function ensureFutureStartDate( - ...dates: Array -): number { - const now = Math.floor(Date.now() / 1000); - - return Math.max(...dates.map((d) => d ?? 0), now + 1); -}