fix(billing): adapt current phase to current period (#15321)
Fix https://github.com/twentyhq/core-team-issues/issues/1748
This commit is contained in:
@@ -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) => {
|
||||
|
||||
+19
-31
@@ -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,
|
||||
|
||||
-51
@@ -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);
|
||||
});
|
||||
});
|
||||
-7
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user