fix(billing): adapt current phase to current period (#15321)

Fix https://github.com/twentyhq/core-team-issues/issues/1748
This commit is contained in:
Antoine Moreaux
2025-10-24 15:44:08 +02:00
committed by GitHub
parent f65c4e4be9
commit 5442e9fc37
4 changed files with 27 additions and 105 deletions
@@ -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) => {
@@ -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<void> {
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<BillingSubscriptionPhaseService['getDetailsFromPhase']>
>,
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<string, string>;
}) {
const {
@@ -1326,8 +1310,14 @@ export class BillingSubscriptionService {
currentPhaseSnapshot: Stripe.SubscriptionScheduleUpdateParams.Phase;
nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase;
}): Promise<void> {
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,
@@ -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);
});
});
@@ -1,7 +0,0 @@
export function ensureFutureStartDate(
...dates: Array<number | undefined | null>
): number {
const now = Math.floor(Date.now() / 1000);
return Math.max(...dates.map((d) => d ?? 0), now + 1);
}