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:
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+4
-9
@@ -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(
|
||||
|
||||
+1789
File diff suppressed because it is too large
Load Diff
+66
@@ -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;
|
||||
+180
@@ -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;
|
||||
+21
@@ -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';
|
||||
+4
-4
@@ -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(
|
||||
|
||||
+99
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+54
-54
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
-2792
File diff suppressed because it is too large
Load Diff
+560
-1139
File diff suppressed because it is too large
Load Diff
+48
-113
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -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;
|
||||
};
|
||||
+68
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+97
@@ -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();
|
||||
});
|
||||
});
|
||||
+56
@@ -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();
|
||||
});
|
||||
});
|
||||
+69
@@ -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();
|
||||
});
|
||||
});
|
||||
-12
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
+45
@@ -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',
|
||||
);
|
||||
}
|
||||
};
|
||||
+15
@@ -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;
|
||||
};
|
||||
+15
@@ -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;
|
||||
};
|
||||
+22
@@ -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,
|
||||
};
|
||||
};
|
||||
+5
-7
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user