Billing - fixes and updates (#16796)

Improvements :
- phase 2 date calculation issue
- quantity update issue
- unnecessary phase creation - schedule should be created only when a
next phase is planned
This commit is contained in:
Etienne
2025-12-24 15:18:06 +01:00
committed by GitHub
parent 37e59d0cf1
commit bc0ffc98bb
23 changed files with 3252 additions and 4140 deletions
@@ -6,7 +6,7 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
@@ -24,8 +24,8 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
@@ -144,7 +144,7 @@ export class BillingResolver {
async switchSubscriptionInterval(
@AuthWorkspace() workspace: WorkspaceEntity,
) {
await this.billingSubscriptionService.changeInterval(workspace);
await this.billingSubscriptionService.changeInterval(workspace.id);
return {
billingSubscriptions:
@@ -164,7 +164,7 @@ export class BillingResolver {
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
async switchBillingPlan(@AuthWorkspace() workspace: WorkspaceEntity) {
await this.billingSubscriptionService.changePlan(workspace);
await this.billingSubscriptionService.changePlan(workspace.id);
return {
billingSubscriptions:
@@ -184,7 +184,7 @@ export class BillingResolver {
SettingsPermissionGuard(PermissionFlagType.BILLING),
)
async cancelSwitchBillingPlan(@AuthWorkspace() workspace: WorkspaceEntity) {
await this.billingSubscriptionService.cancelSwitchPlan(workspace);
await this.billingSubscriptionService.cancelSwitchPlan(workspace.id);
return {
billingSubscriptions:
@@ -206,7 +206,7 @@ export class BillingResolver {
async cancelSwitchBillingInterval(
@AuthWorkspace() workspace: WorkspaceEntity,
) {
await this.billingSubscriptionService.cancelSwitchInterval(workspace);
await this.billingSubscriptionService.cancelSwitchInterval(workspace.id);
return {
billingSubscriptions:
@@ -230,7 +230,7 @@ export class BillingResolver {
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
) {
await this.billingSubscriptionService.changeMeteredPrice(
workspace,
workspace.id,
priceId,
);
@@ -9,7 +9,7 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export type UpdateSubscriptionQuantityJobData = { workspaceId: string };
@Processor({
@@ -45,14 +45,9 @@ export class UpdateSubscriptionQuantityJob {
}
try {
const billingBaseProductSubscriptionItem =
await this.billingSubscriptionService.getBaseProductCurrentBillingSubscriptionItemOrThrow(
data.workspaceId,
);
await this.stripeSubscriptionItemService.updateSubscriptionItem(
billingBaseProductSubscriptionItem.stripeSubscriptionItemId,
{ quantity: workspaceMembersCount },
await this.billingSubscriptionService.changeSeats(
data.workspaceId,
workspaceMembersCount,
);
this.logger.log(
@@ -0,0 +1,66 @@
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import {
LICENSE_PRICE_PRO_MONTH_ID,
METER_PRICE_PRO_MONTH_ID,
} from './price.constants';
export const buildSubscription = ({
planKey = BillingPlanKey.PRO,
interval = SubscriptionInterval.Month,
licensedPriceId = LICENSE_PRICE_PRO_MONTH_ID,
meteredPriceId = METER_PRICE_PRO_MONTH_ID,
seats = 1,
workspaceId = 'ws_1',
stripeSubscriptionId = 'sub_1',
currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
}: {
planKey?: BillingPlanKey;
interval?: SubscriptionInterval;
licensedPriceId?: string;
meteredPriceId?: string;
seats?: number;
workspaceId?: string;
stripeSubscriptionId?: string;
currentPeriodEnd?: Date;
} = {}): BillingSubscriptionEntity =>
({
id: 'sub_db_1',
workspaceId,
stripeSubscriptionId,
status: SubscriptionStatus.Active,
interval,
currentPeriodEnd,
billingSubscriptionItems: [
{
stripeSubscriptionItemId: 'si_licensed',
stripeProductId: 'prod_base',
stripePriceId: licensedPriceId,
quantity: seats,
billingProduct: {
metadata: {
planKey,
productKey: BillingProductKey.BASE_PRODUCT,
priceUsageBased: BillingUsageType.LICENSED,
},
},
},
{
stripeSubscriptionItemId: 'si_metered',
stripeProductId: 'prod_metered',
stripePriceId: meteredPriceId,
billingProduct: {
metadata: {
planKey,
productKey: BillingProductKey.WORKFLOW_NODE_EXECUTION,
priceUsageBased: BillingUsageType.METERED,
},
},
},
],
}) as BillingSubscriptionEntity;
@@ -0,0 +1,180 @@
import { type ObjectLiteral, type Repository } from 'typeorm';
import type Stripe from 'stripe';
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { type BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { type BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
import { type StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { buildSubscription } from './build-subscription.util';
export const repoMock = <T extends ObjectLiteral>() =>
({
find: jest.fn(),
findOne: jest.fn(),
findOneOrFail: jest.fn(),
upsert: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
}) as unknown as jest.Mocked<Repository<T>>;
export const buildBillingPriceEntity = ({
stripePriceId,
planKey,
interval,
isMetered,
tiers,
}: {
stripePriceId: string;
planKey: BillingPlanKey;
interval: SubscriptionInterval;
isMetered: boolean;
tiers?: Stripe.Price.Tier[];
}): BillingPriceEntity | BillingMeterPrice =>
({
stripePriceId,
interval,
billingProduct: {
metadata: {
planKey,
productKey: isMetered
? BillingProductKey.WORKFLOW_NODE_EXECUTION
: BillingProductKey.BASE_PRODUCT,
priceUsageBased: isMetered
? BillingUsageType.METERED
: BillingUsageType.LICENSED,
},
},
...(isMetered && tiers
? {
tiers,
}
: {}),
}) as BillingPriceEntity | BillingMeterPrice;
export const buildDefaultMeteredTiers = (
upTo: number = 1000,
): Stripe.Price.Tier[] => [
{
up_to: upTo,
flat_amount: upTo,
unit_amount: null,
flat_amount_decimal: String(upTo * 100),
unit_amount_decimal: null,
},
{
up_to: null,
flat_amount: null,
unit_amount: null,
flat_amount_decimal: null,
unit_amount_decimal: '100',
},
];
export const arrangeBillingSubscriptionRepositoryFindOneOrFail = (
billingSubscriptionRepository: jest.Mocked<
Repository<BillingSubscriptionEntity>
>,
params: {
planKey?: BillingPlanKey;
interval?: SubscriptionInterval;
licensedPriceId?: string;
meteredPriceId?: string;
seats?: number;
workspaceId?: string;
stripeSubscriptionId?: string;
currentPeriodEnd?: Date;
} = {},
) =>
jest
.spyOn(billingSubscriptionRepository, 'findOneOrFail')
.mockResolvedValue(buildSubscription(params));
export const arrangeBillingPriceRepositoryFindOneOrFail = (
billingPriceRepository: jest.Mocked<Repository<BillingPriceEntity>>,
priceIdToPriceMap: Record<string, BillingPriceEntity | BillingMeterPrice>,
) =>
jest
.spyOn(billingPriceRepository, 'findOneOrFail')
.mockImplementation(async (criteria: unknown) => {
const where = (criteria as { where?: { stripePriceId?: string } })?.where;
const priceId = where?.stripePriceId;
if (priceId && priceIdToPriceMap[priceId]) {
return priceIdToPriceMap[priceId] as BillingPriceEntity;
}
return {} as BillingPriceEntity;
});
export const arrangeStripeSubscriptionScheduleServiceLoadSubscriptionSchedule =
(
stripeSubscriptionScheduleService: jest.Mocked<StripeSubscriptionScheduleService>,
result: {
schedule?: Stripe.SubscriptionSchedule;
currentPhase?: Stripe.SubscriptionSchedule.Phase;
nextPhase?: Stripe.SubscriptionSchedule.Phase;
} = {},
) =>
jest
.spyOn(stripeSubscriptionScheduleService, 'loadSubscriptionSchedule')
.mockResolvedValue(result as never);
export const arrangeStripeSubscriptionScheduleServiceCreateSubscriptionSchedule =
(
stripeSubscriptionScheduleService: jest.Mocked<StripeSubscriptionScheduleService>,
currentPhase: Stripe.SubscriptionSchedule.Phase = {} as Stripe.SubscriptionSchedule.Phase,
) =>
jest
.spyOn(stripeSubscriptionScheduleService, 'createSubscriptionSchedule')
.mockResolvedValue({
schedule: {
id: 'schedule_1',
} as unknown as Stripe.Response<Stripe.SubscriptionSchedule>,
currentPhase,
});
export const arrangeBillingProductServiceGetProductPrices = (
billingProductService: jest.Mocked<BillingProductService>,
prices: BillingPriceEntity[],
) =>
jest
.spyOn(billingProductService, 'getProductPrices')
.mockResolvedValue(prices);
export const arrangeBillingSubscriptionPhaseServiceToPhaseUpdateParams = (
billingSubscriptionPhaseService: jest.Mocked<BillingSubscriptionPhaseService>,
result: Stripe.SubscriptionScheduleUpdateParams.Phase = {} as Stripe.SubscriptionScheduleUpdateParams.Phase,
) =>
jest
.spyOn(billingSubscriptionPhaseService, 'toPhaseUpdateParams')
.mockReturnValue(result);
export const buildSchedulePhase = ({
licensedPriceId,
meteredPriceId,
seats = 1,
startDate = Math.floor(Date.now() / 1000),
endDate = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
}: {
licensedPriceId: string;
meteredPriceId: string;
seats?: number;
startDate?: number;
endDate?: number;
}): Stripe.SubscriptionSchedule.Phase =>
({
start_date: startDate,
end_date: endDate,
items: [
{ price: licensedPriceId, quantity: seats },
{ price: meteredPriceId },
],
}) as Stripe.SubscriptionSchedule.Phase;
@@ -0,0 +1,21 @@
export const LICENSE_PRICE_ENTERPRISE_MONTH_ID =
'LICENSE_PRICE_ENTERPRISE_MONTH_ID';
export const LICENSE_PRICE_PRO_MONTH_ID = 'LICENSE_PRICE_PRO_MONTH_ID';
export const LICENSE_PRICE_ENTERPRISE_YEAR_ID =
'LICENSE_PRICE_ENTERPRISE_YEAR_ID';
export const LICENSE_PRICE_PRO_YEAR_ID = 'LICENSE_PRICE_PRO_YEAR_ID';
export const METER_PRICE_ENTERPRISE_MONTH_ID =
'METER_PRICE_ENTERPRISE_MONTH_ID';
export const METER_PRICE_PRO_MONTH_ID = 'METER_PRICE_PRO_MONTH_ID';
export const METER_PRICE_ENTERPRISE_YEAR_ID = 'METER_PRICE_ENTERPRISE_YEAR_ID';
export const METER_PRICE_PRO_YEAR_ID = 'METER_PRICE_PRO_YEAR_ID';
export const METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID =
'METER_PRICE_ENTERPRISE_MONTH_TIER_LOW_ID';
export const METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID =
'METER_PRICE_ENTERPRISE_MONTH_TIER_HIGH_ID';
export const METER_PRICE_PRO_MONTH_TIER_LOW_ID =
'METER_PRICE_PRO_MONTH_TIER_LOW_ID';
export const METER_PRICE_PRO_MONTH_TIER_HIGH_ID =
'METER_PRICE_PRO_MONTH_TIER_HIGH_ID';
@@ -21,11 +21,11 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
import { type BillingGetPricesPerPlanResult } from 'src/engine/core-modules/billing/types/billing-get-prices-per-plan-result.type';
import { BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { assert } from 'src/utils/assert';
@Injectable()
@@ -105,7 +105,7 @@ export class BillingPortalWorkspaceService {
);
}
const subscription =
const stripeSubscription =
await this.stripeCheckoutService.createDirectSubscription({
user,
workspace,
@@ -120,7 +120,7 @@ export class BillingPortalWorkspaceService {
const createdBillingSubscription =
await this.billingSubscriptionService.syncSubscriptionToDatabase(
workspace.id,
subscription,
stripeSubscription.id,
);
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
@@ -3,11 +3,19 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { type Repository } from 'typeorm';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
@Injectable()
export class BillingPriceService {
@@ -16,6 +24,7 @@ export class BillingPriceService {
private readonly stripeSubscriptionService: StripeSubscriptionService,
@InjectRepository(BillingPriceEntity)
private readonly billingPriceRepository: Repository<BillingPriceEntity>,
private readonly billingProductService: BillingProductService,
) {}
async getBillingThresholdsByMeterPriceId(meterPriceId: string) {
@@ -32,4 +41,92 @@ export class BillingPriceService {
price.tiers[0].flat_amount,
);
}
async findEquivalentMeteredPrice({
meteredPrice,
targetInterval,
targetPlanKey,
hasSameInterval,
hasSamePlanKey,
}: {
meteredPrice: BillingMeterPrice;
targetInterval: SubscriptionInterval;
targetPlanKey: BillingPlanKey;
hasSameInterval: boolean;
hasSamePlanKey: boolean;
}) {
if (hasSameInterval && hasSamePlanKey) {
return meteredPrice;
}
const billingPricesPerPlanAndIntervalArray =
await this.billingProductService.getProductPrices({
interval: targetInterval,
planKey: targetPlanKey,
});
const targetMeteredPrice = await this.findMeteredMatchFloor(
billingPricesPerPlanAndIntervalArray,
meteredPrice,
targetInterval !== meteredPrice.interval ? targetInterval : undefined,
);
return targetMeteredPrice;
}
private async findMeteredMatchFloor(
catalog: BillingPriceEntity[],
reference: BillingMeterPrice,
targetInterval: SubscriptionInterval | undefined,
): Promise<BillingMeterPrice> {
const refCap = targetInterval
? this.scaleCap(
reference.tiers[0].up_to,
reference.interval,
targetInterval,
)
: reference.tiers[0].up_to;
const candidates = this.filterMeteredCandidates(catalog, targetInterval);
if (!candidates.length) {
throw new BillingException(
'No metered candidates found for mapping',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return (
candidates.filter((p) => p.tiers[0].up_to <= refCap).pop() ??
candidates[0]
);
}
private scaleCap(
cap: number,
from: SubscriptionInterval,
to: SubscriptionInterval,
) {
if (from === to) return cap;
return from === SubscriptionInterval.Month &&
to === SubscriptionInterval.Year
? cap * 12
: cap / 12;
}
private filterMeteredCandidates(
catalog: BillingPriceEntity[],
interval?: SubscriptionInterval,
) {
const pool = interval
? catalog.filter((p) => p.interval === interval)
: catalog;
return (
pool.filter((p) =>
billingValidator.isMeteredPrice(p),
) as BillingMeterPrice[]
).sort((a, b) => a.tiers[0].up_to - b.tiers[0].up_to);
}
}
@@ -3,18 +3,17 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
import {
assertIsDefinedOrThrow,
findOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type Repository } from 'typeorm';
import { BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
import type Stripe from 'stripe';
import { type BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
@@ -66,7 +65,7 @@ export class BillingSubscriptionPhaseService {
};
}
toSnapshot(
toPhaseUpdateParams(
phase: Stripe.SubscriptionSchedule.Phase,
): Stripe.SubscriptionScheduleUpdateParams.Phase {
return {
@@ -83,35 +82,56 @@ export class BillingSubscriptionPhaseService {
} as Stripe.SubscriptionScheduleUpdateParams.Phase;
}
async buildSnapshot(
base: Stripe.SubscriptionScheduleUpdateParams.Phase,
licensedPriceId: string,
seats: number,
meteredPriceId: string,
): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
async buildPhaseUpdateParams({
licensedStripePriceId,
seats,
meteredStripePriceId,
startDate,
endDate,
}: {
licensedStripePriceId: string;
seats: number;
meteredStripePriceId: string;
startDate: Stripe.SubscriptionScheduleUpdateParams.Phase['start_date'];
endDate: number | undefined;
}): Promise<Stripe.SubscriptionScheduleUpdateParams.Phase> {
return {
start_date: base.start_date,
end_date: base.end_date,
proration_behavior: base.proration_behavior ?? 'none',
start_date: startDate,
...(endDate ? { end_date: endDate } : {}),
proration_behavior: 'none',
items: [
{ price: licensedPriceId, quantity: seats },
{ price: meteredPriceId },
{ price: licensedStripePriceId, quantity: seats },
{ price: meteredStripePriceId },
],
billing_thresholds:
await this.billingPriceService.getBillingThresholdsByMeterPriceId(
meteredPriceId,
meteredStripePriceId,
),
};
}
getLicensedPriceIdFromSnapshot(
getLicensedPriceIdAndQuantityFromPhaseUpdateParams(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): string {
): { price: string; quantity: number } {
const licensedItem = findOrThrow(phase.items!, (i) => i.quantity != null);
assertIsDefinedOrThrow(licensedItem.price);
assertIsDefinedOrThrow(licensedItem.quantity);
return licensedItem.price;
return {
price: licensedItem.price,
quantity: licensedItem.quantity,
};
}
getMeteredPriceIdFromPhaseUpdateParams(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): string {
const meteredItem = findOrThrow(phase.items!, (i) => i.quantity == null);
assertIsDefinedOrThrow(meteredItem.price);
return meteredItem.price;
}
async isSamePhaseSignature(
@@ -119,44 +139,24 @@ export class BillingSubscriptionPhaseService {
b: Stripe.SubscriptionScheduleUpdateParams.Phase,
): Promise<boolean> {
try {
const sigA = await this.getPhaseSignatureFromSnapshot(a);
const sigB = await this.getPhaseSignatureFromSnapshot(b);
const phaseALicensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(a);
const phaseBLicensedPriceIdAndQuantity =
this.getLicensedPriceIdAndQuantityFromPhaseUpdateParams(b);
const phaseAMeteredPriceId =
this.getMeteredPriceIdFromPhaseUpdateParams(a);
const phaseBMeteredPriceId =
this.getMeteredPriceIdFromPhaseUpdateParams(b);
return (
sigA.planKey === sigB.planKey &&
sigA.interval === sigB.interval &&
sigA.meteredPriceId === sigB.meteredPriceId
phaseALicensedPriceIdAndQuantity.price ===
phaseBLicensedPriceIdAndQuantity.price &&
phaseALicensedPriceIdAndQuantity.quantity ===
phaseBLicensedPriceIdAndQuantity.quantity &&
phaseAMeteredPriceId === phaseBMeteredPriceId
);
} catch {
return false;
}
}
private async getPhaseSignatureFromSnapshot(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): Promise<{
planKey: BillingPlanKey;
interval: SubscriptionInterval;
meteredPriceId: string;
}> {
const metered = findOrThrow(phase.items!, (i) => i.quantity == null);
const meteredPriceId = metered.price;
assertIsDefinedOrThrow(meteredPriceId);
const meteredPrice = await this.billingPriceRepository.findOneOrFail({
where: {
stripePriceId: meteredPriceId,
},
relations: ['billingProduct'],
});
const plan = await this.billingPlanService.getPlanByPriceId(meteredPriceId);
return {
planKey: plan.planKey,
interval: meteredPrice.interval,
meteredPriceId,
};
}
}
@@ -2,18 +2,17 @@
import { Injectable, Logger } from '@nestjs/common';
import { findOrThrow } from 'twenty-shared/utils';
import { findOrThrow, isDefined } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { type SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class StripeSubscriptionScheduleService {
@@ -34,40 +33,11 @@ export class StripeSubscriptionScheduleService {
);
}
private snapshotFromLivePhase(phase: Stripe.SubscriptionSchedule.Phase) {
return {
start_date: phase.start_date,
end_date: phase.end_date ?? undefined,
items: (phase.items || []).map((i) => ({
price: normalizePriceRef(i.price) as string,
quantity: i.quantity ?? undefined,
})),
proration_behavior: 'none',
...(phase.billing_thresholds
? { billing_thresholds: phase.billing_thresholds }
: {}),
} as Stripe.SubscriptionScheduleUpdateParams.Phase;
}
private computeBaseStart(
currentEditable: Stripe.SubscriptionSchedule.Phase | undefined,
nextEditable: Stripe.SubscriptionSchedule.Phase | undefined,
live: Stripe.SubscriptionSchedule,
now: number,
): number {
const cEnd = (currentEditable?.end_date as number | undefined) ?? 0;
const curPhaseEnd =
(live.current_phase?.end_date as number | undefined) ?? 0;
const nStart = (nextEditable?.start_date as number | undefined) ?? 0;
return Math.max(cEnd, curPhaseEnd, nStart, now + 1);
}
getEditablePhases(live: Stripe.SubscriptionSchedule) {
getPhases(subscriptionSchedule: Stripe.SubscriptionSchedule) {
const now = Math.floor(Date.now() / 1000);
const currentEditable = findOrThrow(
live.phases,
const currentPhase = findOrThrow(
subscriptionSchedule.phases,
(p) => {
const s = p.start_date ?? 0;
const e = p.end_date ?? Infinity;
@@ -75,20 +45,20 @@ export class StripeSubscriptionScheduleService {
return s <= now && now < e;
},
new BillingException(
`Subscription must have at least 1 phase to be editable`,
`Subscription must have at least 1 current phase`,
BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND,
),
);
const nextEditable = (live.phases || [])
const nextPhase = (subscriptionSchedule.phases || [])
.filter((p) => (p.start_date ?? 0) > now)
.sort((a, b) => (a.start_date ?? 0) - (b.start_date ?? 0))[0] as
| Stripe.SubscriptionSchedule.Phase
| undefined;
return {
currentEditable,
nextEditable,
currentPhase,
nextPhase,
};
}
@@ -98,95 +68,60 @@ export class StripeSubscriptionScheduleService {
})) as SubscriptionWithSchedule;
}
async retrieveSchedule(scheduleId: string) {
if (!this.stripe) throw new Error('Billing is disabled');
return this.stripe.subscriptionSchedules.retrieve(scheduleId, {
expand: ['subscription'],
});
}
async updateSchedule(
scheduleId: string,
params: Stripe.SubscriptionScheduleUpdateParams,
) {
if (!this.stripe) throw new Error('Billing is disabled');
return this.stripe.subscriptionSchedules.update(scheduleId, params);
return await this.stripe.subscriptionSchedules.update(scheduleId, params);
}
async createScheduleFromSubscription(subscriptionId: string) {
async createSubscriptionSchedule(stripeSubscriptionId: string) {
if (!this.stripe) throw new Error('Billing is disabled');
return this.stripe.subscriptionSchedules.create({
from_subscription: subscriptionId,
const schedule = await this.stripe.subscriptionSchedules.create({
from_subscription: stripeSubscriptionId,
});
const currentPhase = this.getPhases(schedule).currentPhase;
return {
schedule,
currentPhase,
};
}
async findOrCreateSubscriptionSchedule(
subscription: SubscriptionWithSchedule,
) {
if (subscription.schedule) return subscription.schedule;
async loadSubscriptionSchedule(stripeSubscriptionId: string) {
const subscriptionWithSchedule =
await this.getSubscriptionWithSchedule(stripeSubscriptionId);
return this.createScheduleFromSubscription(subscription.id);
if (!isDefined(subscriptionWithSchedule.schedule)) {
return {};
}
const { currentPhase, nextPhase } = this.getPhases(
subscriptionWithSchedule.schedule,
);
if (!isDefined(nextPhase)) {
await this.releaseSubscriptionSchedule(
subscriptionWithSchedule.schedule.id,
);
return {};
}
return {
schedule: subscriptionWithSchedule.schedule,
currentPhase,
nextPhase,
};
}
async replaceEditablePhases(
scheduleId: string,
desired: {
currentPhaseSnapshot?: Stripe.SubscriptionScheduleUpdateParams.Phase;
nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase;
},
): Promise<Stripe.SubscriptionSchedule> {
releaseSubscriptionSchedule(scheduleId: string) {
if (!this.stripe) throw new Error('Billing is disabled');
const live = await this.retrieveSchedule(scheduleId);
const { currentEditable, nextEditable } = this.getEditablePhases(live);
const now = Math.floor(Date.now() / 1000);
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [];
const currentPhaseSnapshot =
desired.currentPhaseSnapshot ??
this.snapshotFromLivePhase(currentEditable);
phases.push(currentPhaseSnapshot);
const hasNextKey = 'nextPhase' in desired;
const wantsNext = hasNextKey && !!desired.nextPhase;
const wantsDeleteNext = hasNextKey && !desired.nextPhase;
const preserveExistingNext = !hasNextKey && !!nextEditable;
if (wantsNext) {
phases.push({
...desired.nextPhase!,
start_date: this.computeBaseStart(
currentEditable,
nextEditable,
live,
now,
),
proration_behavior: 'none',
});
}
if (!wantsNext && !wantsDeleteNext && preserveExistingNext) {
phases.push(this.snapshotFromLivePhase(nextEditable!));
}
if (phases.length === 0 && wantsNext) {
phases.push({
...desired.nextPhase!,
start_date: Math.max(
(live.current_phase?.end_date as number | undefined) ?? 0,
now + 1,
),
proration_behavior: 'none',
});
}
if (phases.length === 0) return live;
return this.updateSchedule(scheduleId, { phases });
return this.stripe.subscriptionSchedules.release(scheduleId);
}
}
@@ -0,0 +1,27 @@
import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { type SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
export enum SubscriptionUpdateType {
PLAN = 'PLAN',
METERED_PRICE = 'METERED_PRICE',
SEATS = 'SEATS',
INTERVAL = 'INTERVAL',
}
export type SubscriptionUpdate =
| {
type: SubscriptionUpdateType.PLAN;
newPlan: BillingPlanKey;
}
| {
type: SubscriptionUpdateType.METERED_PRICE;
newMeteredPriceId: string;
}
| {
type: SubscriptionUpdateType.SEATS;
newSeats: number;
}
| {
type: SubscriptionUpdateType.INTERVAL;
newInterval: SubscriptionInterval;
};
@@ -0,0 +1,68 @@
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
import { computeSubscriptionUpdateOptions } from 'src/engine/core-modules/billing/utils/compute-subscription-update-options.util';
describe('computeSubscriptionUpdateOptions', () => {
it('returns proration and plan metadata for PLAN update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.PLAN,
newPlan: BillingPlanKey.PRO,
});
expect(result).toEqual({
proration: 'create_prorations',
metadata: {
plan: BillingPlanKey.PRO,
},
});
});
it('returns proration and enterprise plan metadata for PLAN update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.PLAN,
newPlan: BillingPlanKey.ENTERPRISE,
});
expect(result).toEqual({
proration: 'create_prorations',
metadata: {
plan: BillingPlanKey.ENTERPRISE,
},
});
});
it('returns only proration for METERED_PRICE update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.METERED_PRICE,
newMeteredPriceId: 'price_123',
});
expect(result).toEqual({
proration: 'create_prorations',
});
});
it('returns proration and anchor for INTERVAL update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.INTERVAL,
newInterval: SubscriptionInterval.Month,
});
expect(result).toEqual({
proration: 'create_prorations',
anchor: 'now',
});
});
it('returns only proration for SEATS update type', () => {
const result = computeSubscriptionUpdateOptions({
type: SubscriptionUpdateType.SEATS,
newSeats: 10,
});
expect(result).toEqual({
proration: 'create_prorations',
});
});
});
@@ -0,0 +1,97 @@
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { getCurrentLicensedBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-licensed-billing-subscription-item-or-throw.util';
describe('getCurrentLicensedBillingSubscriptionItemOrThrow', () => {
it('returns the licensed billing subscription item when found', () => {
const licensedItem = {
id: 'item_licensed',
quantity: 5,
billingProduct: {
metadata: {
priceUsageBased: BillingUsageType.LICENSED,
},
},
};
const meteredItem = {
id: 'item_metered',
quantity: null,
billingProduct: {
metadata: {
priceUsageBased: BillingUsageType.METERED,
},
},
};
const billingSubscription = {
billingSubscriptionItems: [meteredItem, licensedItem],
} as unknown as BillingSubscriptionEntity;
const result =
getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription);
expect(result).toBe(licensedItem);
});
it('returns the first licensed item when multiple licensed items exist', () => {
const firstLicensedItem = {
id: 'item_licensed_1',
quantity: 5,
billingProduct: {
metadata: {
priceUsageBased: BillingUsageType.LICENSED,
},
},
};
const secondLicensedItem = {
id: 'item_licensed_2',
quantity: 10,
billingProduct: {
metadata: {
priceUsageBased: BillingUsageType.LICENSED,
},
},
};
const billingSubscription = {
billingSubscriptionItems: [firstLicensedItem, secondLicensedItem],
} as unknown as BillingSubscriptionEntity;
const result =
getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription);
expect(result).toBe(firstLicensedItem);
});
it('throws when no licensed billing subscription item is found', () => {
const meteredItem = {
id: 'item_metered',
quantity: null,
billingProduct: {
metadata: {
priceUsageBased: BillingUsageType.METERED,
},
},
};
const billingSubscription = {
billingSubscriptionItems: [meteredItem],
} as unknown as BillingSubscriptionEntity;
expect(() =>
getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription),
).toThrow();
});
it('throws when subscription has no items', () => {
const billingSubscription = {
billingSubscriptionItems: [],
} as unknown as BillingSubscriptionEntity;
expect(() =>
getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription),
).toThrow();
});
});
@@ -0,0 +1,56 @@
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { getCurrentMeteredBillingSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-metered-billing-subscription-item-or-throw.util';
describe('getCurrentMeteredBillingSubscriptionItemOrThrow', () => {
it('returns the metered billing subscription item when found', () => {
const licensedItem = {
id: 'item_licensed',
quantity: 5,
billingProduct: {
metadata: {
priceUsageBased: BillingUsageType.LICENSED,
},
},
};
const meteredItem = {
id: 'item_metered',
quantity: null,
billingProduct: {
metadata: {
priceUsageBased: BillingUsageType.METERED,
},
},
};
const billingSubscription = {
billingSubscriptionItems: [licensedItem, meteredItem],
} as unknown as BillingSubscriptionEntity;
const result =
getCurrentMeteredBillingSubscriptionItemOrThrow(billingSubscription);
expect(result).toBe(meteredItem);
});
it('throws when no metered billing subscription item is found', () => {
const licensedItem = {
id: 'item_licensed',
quantity: 5,
billingProduct: {
metadata: {
priceUsageBased: BillingUsageType.LICENSED,
},
},
};
const billingSubscription = {
billingSubscriptionItems: [licensedItem],
} as unknown as BillingSubscriptionEntity;
expect(() =>
getCurrentMeteredBillingSubscriptionItemOrThrow(billingSubscription),
).toThrow();
});
});
@@ -0,0 +1,69 @@
import { type Stripe } from 'stripe';
import { getSubscriptionPricesFromSchedulePhase } from 'src/engine/core-modules/billing/utils/get-subscription-prices-from-schedule-phase.util';
describe('getSubscriptionPricesFromSchedulePhase', () => {
it('returns licensed price id, metered price id, and seats from phase with string prices', () => {
const phase = {
items: [
{ price: 'price_licensed_123', quantity: 10 },
{ price: 'price_metered_456', quantity: undefined },
],
} as unknown as Stripe.SubscriptionSchedule.Phase;
const result = getSubscriptionPricesFromSchedulePhase(phase);
expect(result).toEqual({
licensedPriceId: 'price_licensed_123',
meteredPriceId: 'price_metered_456',
seats: 10,
});
});
it('returns licensed price id, metered price id, and seats from phase with object prices', () => {
const phase = {
items: [
{ price: { id: 'price_licensed_abc' }, quantity: 5 },
{ price: { id: 'price_metered_def' }, quantity: undefined },
],
} as unknown as Stripe.SubscriptionSchedule.Phase;
const result = getSubscriptionPricesFromSchedulePhase(phase);
expect(result).toEqual({
licensedPriceId: 'price_licensed_abc',
meteredPriceId: 'price_metered_def',
seats: 5,
});
});
it('throws when no licensed item is found', () => {
const phase = {
items: [
{ price: 'price_metered_1', quantity: undefined },
{ price: 'price_metered_2', quantity: undefined },
],
} as unknown as Stripe.SubscriptionSchedule.Phase;
expect(() => getSubscriptionPricesFromSchedulePhase(phase)).toThrow();
});
it('throws when no metered item is found', () => {
const phase = {
items: [
{ price: 'price_licensed_1', quantity: 5 },
{ price: 'price_licensed_2', quantity: 10 },
],
} as unknown as Stripe.SubscriptionSchedule.Phase;
expect(() => getSubscriptionPricesFromSchedulePhase(phase)).toThrow();
});
it('throws when phase has no items', () => {
const phase = {
items: [],
} as unknown as Stripe.SubscriptionSchedule.Phase;
expect(() => getSubscriptionPricesFromSchedulePhase(phase)).toThrow();
});
});
@@ -8,16 +8,4 @@ describe('normalizePriceRef', () => {
it('returns the id field when input is an object with id', () => {
expect(normalizePriceRef({ id: 'price_abc' })).toBe('price_abc');
});
it('returns undefined for null input', () => {
expect(normalizePriceRef(null)).toBeUndefined();
});
it('returns undefined for undefined input', () => {
expect(normalizePriceRef(undefined)).toBeUndefined();
});
it('preserves empty string ids', () => {
expect(normalizePriceRef({ id: '' })).toBe('');
});
});
@@ -0,0 +1,45 @@
import { assertUnreachable } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import {
SubscriptionUpdateType,
type SubscriptionUpdate,
} from 'src/engine/core-modules/billing/types/billing-subscription-update.type';
export const computeSubscriptionUpdateOptions = (
subscriptionUpdate: SubscriptionUpdate,
): {
proration: Stripe.SubscriptionUpdateParams.ProrationBehavior;
metadata?: Record<string, string>;
anchor?: Stripe.SubscriptionUpdateParams.BillingCycleAnchor;
} => {
switch (subscriptionUpdate.type) {
case SubscriptionUpdateType.PLAN:
return {
proration: 'create_prorations',
metadata: {
plan: subscriptionUpdate.newPlan,
},
};
case SubscriptionUpdateType.METERED_PRICE:
return {
proration: 'create_prorations',
};
case SubscriptionUpdateType.INTERVAL:
return {
proration: 'create_prorations',
anchor: 'now',
};
case SubscriptionUpdateType.SEATS:
return {
proration: 'create_prorations',
};
default:
return assertUnreachable(
subscriptionUpdate,
'Should never occur, add validator for new subscription update type',
);
}
};
@@ -0,0 +1,15 @@
import { findOrThrow } from 'twenty-shared/utils';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { type LicensedBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
export const getCurrentLicensedBillingSubscriptionItemOrThrow = (
billingSubscription: BillingSubscriptionEntity,
) => {
return findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.priceUsageBased === BillingUsageType.LICENSED,
) as LicensedBillingSubscriptionItem;
};
@@ -0,0 +1,15 @@
import { findOrThrow } from 'twenty-shared/utils';
import { type BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { type MeteredBillingSubscriptionItem } from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
export const getCurrentMeteredBillingSubscriptionItemOrThrow = (
billingSubscription: BillingSubscriptionEntity,
) => {
return findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.priceUsageBased === BillingUsageType.METERED,
) as MeteredBillingSubscriptionItem;
};
@@ -0,0 +1,22 @@
import { type Stripe } from 'stripe';
import { assertIsDefinedOrThrow, findOrThrow } from 'twenty-shared/utils';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
export const getSubscriptionPricesFromSchedulePhase = (
phase: Stripe.SubscriptionSchedule.Phase,
) => {
const licensedItem = findOrThrow(
phase.items,
(item) => item.quantity != null,
);
assertIsDefinedOrThrow(licensedItem.quantity);
const meteredItem = findOrThrow(phase.items, (item) => item.quantity == null);
return {
licensedPriceId: normalizePriceRef(licensedItem.price),
meteredPriceId: normalizePriceRef(meteredItem.price),
seats: licensedItem.quantity,
};
};
@@ -1,7 +1,5 @@
export function normalizePriceRef(
p: string | { id: string } | null | undefined,
): string | undefined {
if (!p) return undefined;
return typeof p === 'string' ? p : p.id;
}
export const normalizePriceRef = (
priceRef: string | { id: string },
): string => {
return typeof priceRef === 'string' ? priceRef : priceRef.id;
};