feat(billing): refacto billing (#14243)

… prices for metered billing

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Antoine Moreaux
2025-09-19 11:25:53 +02:00
committed by GitHub
parent 163890f6c8
commit 43e0cd5d05
351 changed files with 16091 additions and 6101 deletions
@@ -12,6 +12,7 @@ export enum BillingExceptionCode {
BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND',
BILLING_SUBSCRIPTION_NOT_FOUND = 'BILLING_SUBSCRIPTION_NOT_FOUND',
BILLING_SUBSCRIPTION_ITEM_NOT_FOUND = 'BILLING_SUBSCRIPTION_ITEM_NOT_FOUND',
BILLING_SUBSCRIPTION_INVALID = 'BILLING_SUBSCRIPTION_INVALID',
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND',
BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND',
@@ -21,7 +22,10 @@ export enum BillingExceptionCode {
BILLING_STRIPE_ERROR = 'BILLING_STRIPE_ERROR',
BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD = 'BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD',
BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE',
BILLING_SUBSCRIPTION_INTERVAL_INVALID = 'BILLING_SUBSCRIPTION_INTERVAL_INVALID',
BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE',
BILLING_SUBSCRIPTION_ITEM_INVALID = 'BILLING_SUBSCRIPTION_ITEM_INVALID',
BILLING_PRICE_INVALID_TIERS = 'BILLING_PRICE_INVALID_TIERS',
BILLING_PRICE_UPDATE_REQUIRES_INCREASE = 'BILLING_PRICE_UPDATE_REQUIRES_INCREASE',
BILLING_PRICE_INVALID = 'BILLING_PRICE_INVALID',
BILLING_SUBSCRIPTION_PHASE_NOT_FOUND = 'BILLING_SUBSCRIPTION_PHASE_NOT_FOUND',
}
@@ -34,6 +34,7 @@ import { MessageQueueModule } from 'src/engine/core-modules/message-queue/messag
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
@Module({
imports: [
@@ -61,6 +62,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
BillingSubscriptionItemService,
BillingPortalWorkspaceService,
BillingProductService,
BillingSubscriptionPhaseService,
BillingResolver,
BillingPlanService,
BillingWorkspaceMemberListener,
@@ -4,7 +4,6 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { isDefined } from 'twenty-shared/utils';
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';
@@ -19,7 +18,6 @@ import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/s
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
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';
@@ -40,7 +38,6 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { BillingPriceOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-price.output';
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
@Resolver()
@@ -98,7 +95,7 @@ export class BillingResolver {
workspaceActivationStatus: workspace.activationStatus,
});
const checkoutSessionParams: BillingPortalCheckoutSessionParameters = {
const checkoutSessionParams = {
user,
workspace,
successUrlPath,
@@ -106,12 +103,11 @@ export class BillingResolver {
requirePaymentMethod,
};
const billingPricesPerPlan = await this.billingPlanService.getPricesPerPlan(
{
const billingPricesPerPlan =
await this.billingPlanService.getPricesPerPlanByInterval({
planKey: checkoutSessionParams.plan,
interval: recurringInterval,
},
);
});
// For 7-day trials (no payment method required), create subscription directly
// For 30-day trials (payment method required), use checkout session flow
@@ -143,10 +139,19 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
)
async switchToYearlyInterval(@AuthWorkspace() workspace: Workspace) {
await this.billingSubscriptionService.switchToYearlyInterval(workspace);
async switchSubscriptionInterval(@AuthWorkspace() workspace: Workspace) {
await this.billingSubscriptionService.changeInterval(workspace);
return { success: true };
return {
billingSubscriptions:
await this.billingSubscriptionService.getBillingSubscriptions(
workspace.id,
),
currentBillingSubscription:
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
),
};
}
@Mutation(() => BillingUpdateOutput)
@@ -154,10 +159,19 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
)
async switchToEnterprisePlan(@AuthWorkspace() workspace: Workspace) {
await this.billingSubscriptionService.switchToEnterprisePlan(workspace);
async switchBillingPlan(@AuthWorkspace() workspace: Workspace) {
await this.billingSubscriptionService.changePlan(workspace);
return { success: true };
return {
billingSubscriptions:
await this.billingSubscriptionService.getBillingSubscriptions(
workspace.id,
),
currentBillingSubscription:
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
),
};
}
@Mutation(() => BillingUpdateOutput)
@@ -165,22 +179,71 @@ export class BillingResolver {
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
)
async updateSubscriptionItemPrice(
async cancelSwitchBillingPlan(@AuthWorkspace() workspace: Workspace) {
await this.billingSubscriptionService.cancelSwitchPlan(workspace);
return {
billingSubscriptions:
await this.billingSubscriptionService.getBillingSubscriptions(
workspace.id,
),
currentBillingSubscription:
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
),
};
}
@Mutation(() => BillingUpdateOutput)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
)
async cancelSwitchBillingInterval(@AuthWorkspace() workspace: Workspace) {
await this.billingSubscriptionService.cancelSwitchInterval(workspace);
return {
billingSubscriptions:
await this.billingSubscriptionService.getBillingSubscriptions(
workspace.id,
),
currentBillingSubscription:
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
),
};
}
@Mutation(() => BillingUpdateOutput)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
)
async setMeteredSubscriptionPrice(
@AuthWorkspace() workspace: Workspace,
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
) {
await this.billingService.updateMeteredSubscriptionPrice(
workspace.id,
await this.billingSubscriptionService.changeMeteredPrice(
workspace,
priceId,
);
return { success: true };
return {
billingSubscriptions:
await this.billingSubscriptionService.getBillingSubscriptions(
workspace.id,
),
currentBillingSubscription:
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
),
};
}
@Query(() => [BillingPlanOutput])
@UseGuards(WorkspaceAuthGuard)
async plans(): Promise<BillingPlanOutput[]> {
const plans = await this.billingPlanService.getPlans();
async listPlans(): Promise<BillingPlanOutput[]> {
const plans = await this.billingPlanService.listPlans();
return plans.map(formatBillingDatabaseProductToGraphqlDTO);
}
@@ -207,32 +270,24 @@ export class BillingResolver {
return await this.billingUsageService.getMeteredProductsUsage(workspace);
}
@Query(() => [BillingPriceOutput])
@Mutation(() => BillingUpdateOutput)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
)
async listAvailableMeteredBillingPrices(
@AuthWorkspace() workspace: Workspace,
): Promise<BillingPriceOutput[]> {
return (
await this.billingService.listMeteredBillingPricesByWorkspaceIdAndProductKey(
workspace.id,
)
).reduce(
(acc, billingPrice) =>
isDefined(billingPrice.tiers?.[0].flat_amount) &&
isDefined(billingPrice.nickname) &&
isDefined(billingPrice.interval)
? acc.concat({
amount: billingPrice.tiers[0].flat_amount,
nickname: billingPrice.nickname,
stripePriceId: billingPrice.stripePriceId,
recurringInterval: billingPrice.interval,
})
: acc,
[] as BillingPriceOutput[],
);
async cancelSwitchMeteredPrice(@AuthWorkspace() workspace: Workspace) {
await this.billingSubscriptionService.cancelSwitchMeteredPrice(workspace);
return {
billingSubscriptions:
await this.billingSubscriptionService.getBillingSubscriptions(
workspace.id,
),
currentBillingSubscription:
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
),
};
}
private async validateCanCheckoutSessionPermissionOrThrow({
@@ -6,6 +6,15 @@ import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { type BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { type BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import {
type LicensedBillingSubscriptionItem,
type MeteredBillingSubscriptionItem,
} from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
import { type BillingSubscriptionWithSubscriptionItems } from 'src/engine/core-modules/billing/types/billing-subscription-with-subscription-items';
const assertIsMeteredTiersSchemaOrThrow = (
tiers: BillingPrice['tiers'] | undefined | null,
@@ -40,10 +49,117 @@ const isMeteredTiersSchema = (
return true;
};
const assertIsLicensedSubscriptionItem = (
subscriptionItem: BillingSubscriptionItem,
): asserts subscriptionItem is LicensedBillingSubscriptionItem => {
if (
subscriptionItem.quantity !== null &&
subscriptionItem.billingProduct.metadata.priceUsageBased ===
BillingUsageType.LICENSED
)
return;
throw new BillingException(
'Subscription Item is not a licence subscription item',
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_INVALID,
);
};
const assertIsMeteredSubscriptionItem = (
subscriptionItem: BillingSubscriptionItem,
): asserts subscriptionItem is MeteredBillingSubscriptionItem => {
if (
subscriptionItem.quantity === null &&
subscriptionItem.billingProduct.metadata.priceUsageBased ===
BillingUsageType.METERED
)
return;
throw new BillingException(
'Subscription Item is not a meter subscription item',
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_INVALID,
);
};
const assertIsMeteredPrice = (
price: BillingPrice,
): asserts price is BillingMeterPrice => {
if (
price.billingProduct?.metadata.priceUsageBased !== BillingUsageType.METERED
) {
throw new BillingException(
'Price is not a metered price',
BillingExceptionCode.BILLING_PRICE_INVALID,
);
}
if (!isMeteredTiersSchema(price.tiers)) {
throw new BillingException(
'Tiers declare in price do not match metered price schema. Price must have exactly two tiers and only one must have a defined limitation (up_to). Example: [{up_to: 100}, {up_to: null}]',
BillingExceptionCode.BILLING_PRICE_INVALID,
);
}
return;
};
const isMeteredPrice = (price: BillingPrice): price is BillingMeterPrice => {
if (
price.billingProduct?.metadata.priceUsageBased !==
BillingUsageType.METERED ||
!isMeteredTiersSchema(price.tiers)
) {
return false;
}
return true;
};
const assertIsSubscription = (
subscription: BillingSubscription | undefined,
): asserts subscription is BillingSubscription &
BillingSubscriptionWithSubscriptionItems => {
if (!isDefined(subscription)) {
throw new BillingException(
'Subscription is not defined',
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
);
}
if (!isDefined(subscription.billingSubscriptionItems)) {
throw new BillingException(
'Subscription items is not defined. Check the relation in the query',
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
if (subscription.billingSubscriptionItems.length !== 2) {
throw new BillingException(
'Subscription must have exactly two subscription items. Check that stripe and database are in sync',
BillingExceptionCode.BILLING_SUBSCRIPTION_INVALID,
{
userFriendlyMessage:
'Your billing subscription is corrupted. Please contact support.',
},
);
}
return;
};
export const billingValidator: {
assertIsMeteredTiersSchemaOrThrow: typeof assertIsMeteredTiersSchemaOrThrow;
isMeteredTiersSchema: typeof isMeteredTiersSchema;
assertIsLicensedSubscriptionItem: typeof assertIsLicensedSubscriptionItem;
assertIsMeteredSubscriptionItem: typeof assertIsMeteredSubscriptionItem;
assertIsMeteredPrice: typeof assertIsMeteredPrice;
assertIsSubscription: typeof assertIsSubscription;
isMeteredPrice: typeof isMeteredPrice;
} = {
assertIsMeteredTiersSchemaOrThrow,
isMeteredTiersSchema,
assertIsLicensedSubscriptionItem,
assertIsMeteredSubscriptionItem,
assertIsMeteredPrice,
assertIsSubscription,
isMeteredPrice,
};
@@ -47,7 +47,7 @@ export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspacesM
workspaceId,
);
if (stripeCustomerId) {
if (typeof stripeCustomerId === 'string') {
await this.billingCustomerRepository.upsert(
{
stripeCustomerId,
@@ -3,16 +3,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { BillingPriceTierDTO } from 'src/engine/core-modules/billing/dtos/billing-price-tier.dto';
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
import { 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';
@ObjectType()
export class BillingPriceMeteredDTO {
@Field(() => BillingPriceTiersMode, { nullable: true })
tiersMode: BillingPriceTiersMode.GRADUATED | null;
@Field(() => [BillingPriceTierDTO], { nullable: true })
@Field(() => [BillingPriceTierDTO])
tiers: BillingPriceTierDTO[];
@Field(() => SubscriptionInterval)
@@ -1,18 +0,0 @@
/* @license Enterprise */
import { createUnionType } from '@nestjs/graphql';
import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
export const BillingPriceUnionDTO = createUnionType({
name: 'BillingPriceUnionDTO',
types: () => [BillingPriceLicensedDTO, BillingPriceMeteredDTO],
resolveType(value) {
if (value.priceUsageType === BillingUsageType.LICENSED) {
return BillingPriceLicensedDTO;
}
return BillingPriceMeteredDTO;
},
});
@@ -1,12 +1,18 @@
/* @license Enterprise */
import { Field, InterfaceType, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql';
import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
import { BillingPriceUnionDTO } from 'src/engine/core-modules/billing/dtos/billing-price-union.dto';
import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type';
import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
@InterfaceType({
resolveType(product: BillingProductDTO) {
return product.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION
? BillingMeteredProduct
: BillingLicensedProduct;
},
})
@ObjectType('BillingProduct')
export class BillingProductDTO {
@Field(() => String)
@@ -18,9 +24,18 @@ export class BillingProductDTO {
@Field(() => [String], { nullable: true })
images: string[];
@Field(() => [BillingPriceUnionDTO], { nullable: true })
prices: Array<BillingPriceLicensedDTO> | Array<BillingPriceMeteredDTO>;
@Field(() => BillingProductMetadata)
metadata: BillingProductMetadata;
}
@ObjectType({ implements: BillingProductDTO })
export class BillingLicensedProduct {
@Field(() => [BillingPriceLicensedDTO], { nullable: true })
prices: BillingPriceLicensedDTO[] | null;
}
@ObjectType({ implements: BillingProductDTO })
export class BillingMeteredProduct {
@Field(() => [BillingPriceMeteredDTO], { nullable: true })
prices: BillingPriceMeteredDTO[] | null;
}
@@ -0,0 +1,22 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class BillingSubscriptionSchedulePhaseItem {
@Field(() => String)
price: string;
@Field(() => Number, { nullable: true })
quantity?: number;
}
@ObjectType()
export class BillingSubscriptionSchedulePhase {
@Field(() => Number)
start_date: number;
@Field(() => Number)
end_date: number;
@Field(() => [BillingSubscriptionSchedulePhaseItem])
items: Array<BillingSubscriptionSchedulePhaseItem>;
}
@@ -2,7 +2,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { BillingProductDTO } from 'src/engine/core-modules/billing/dtos/billing-product.dto';
import {
BillingLicensedProduct,
BillingMeteredProduct,
} from 'src/engine/core-modules/billing/dtos/billing-product.dto';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
@ObjectType()
@@ -10,12 +13,9 @@ export class BillingPlanOutput {
@Field(() => BillingPlanKey)
planKey: BillingPlanKey;
@Field(() => BillingProductDTO)
baseProduct: BillingProductDTO;
@Field(() => [BillingLicensedProduct])
licensedProducts: BillingLicensedProduct[];
@Field(() => [BillingProductDTO])
otherLicensedProducts: BillingProductDTO[];
@Field(() => [BillingProductDTO])
meteredProducts: BillingProductDTO[];
@Field(() => [BillingMeteredProduct])
meteredProducts: BillingMeteredProduct[];
}
@@ -6,8 +6,8 @@ import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/bill
@ObjectType()
export class BillingPriceOutput {
@Field(() => String)
nickname: string;
@Field(() => Number)
upTo: number;
@Field(() => Number)
amount: number;
@@ -7,7 +7,7 @@ import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BillingProductDTO } from 'src/engine/core-modules/billing/dtos/billing-product.dto';
@ObjectType('BillingSubscriptionItem')
@ObjectType()
export class BillingSubscriptionItemDTO {
@IDField(() => UUIDScalarType)
id: string;
@@ -18,9 +18,9 @@ export class BillingSubscriptionItemDTO {
@Field(() => Number, { nullable: true })
quantity: number | null;
@Field(() => String, { nullable: true })
stripePriceId: string | null;
@Field(() => String)
stripePriceId: string;
@Field(() => BillingProductDTO, { nullable: true })
@Field(() => BillingProductDTO)
billingProduct: BillingProductDTO;
}
@@ -2,10 +2,17 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@ObjectType()
export class BillingUpdateOutput {
@Field(() => Boolean, {
description: 'Boolean that confirms query was successful',
@Field(() => BillingSubscription, {
description: 'Current billing subscription',
})
success: boolean;
currentBillingSubscription: BillingSubscription;
@Field(() => [BillingSubscription], {
description: 'All billing subscriptions',
})
billingSubscriptions: BillingSubscription[];
}
@@ -19,7 +19,6 @@ import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-m
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum';
import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum';
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
import { 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';
@@ -86,13 +85,6 @@ export class BillingPrice {
@Column({ nullable: true, type: 'jsonb' })
transformQuantity: Stripe.Price.TransformQuantity | null;
@Column({
nullable: true,
type: 'enum',
enum: Object.values(BillingPriceTiersMode),
})
tiersMode: BillingPriceTiersMode | null;
@Column({ nullable: true, type: 'text' })
unitAmountDecimal: string | null;
@@ -110,26 +102,26 @@ export class BillingPrice {
})
usageType: BillingUsageType;
@Field(() => SubscriptionInterval, { nullable: true })
@Field(() => SubscriptionInterval)
@Column({
type: 'enum',
enum: Object.values(SubscriptionInterval),
nullable: true,
})
interval: SubscriptionInterval | null;
interval: SubscriptionInterval;
@ManyToOne(
() => BillingProduct,
(billingProduct) => billingProduct.billingPrices,
{
onDelete: 'CASCADE',
nullable: true,
},
)
@JoinColumn({
referencedColumnName: 'stripeProductId',
name: 'stripeProductId',
})
billingProduct: Relation<BillingProduct>;
billingProduct: Relation<BillingProduct> | null;
@ManyToOne(() => BillingMeter, (billingMeter) => billingMeter.billingPrices, {
nullable: true,
@@ -138,5 +130,5 @@ export class BillingPrice {
referencedColumnName: 'stripeMeterId',
name: 'stripeMeterId',
})
billingMeter: Relation<BillingMeter>;
billingMeter: Relation<BillingMeter> | null;
}
@@ -24,7 +24,7 @@ export class BillingProduct {
id: string;
@Column({ nullable: true, type: 'timestamptz' })
deletedAt?: Date;
deletedAt?: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -25,6 +25,7 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.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 { BillingSubscriptionSchedulePhase } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
registerEnumType(SubscriptionInterval, { name: 'SubscriptionInterval' });
@@ -72,7 +73,7 @@ export class BillingSubscription {
enum: Object.values(SubscriptionInterval),
nullable: true,
})
interval: Stripe.Price.Recurring.Interval;
interval: SubscriptionInterval;
@Field(() => [BillingSubscriptionItemDTO], { nullable: true })
@OneToMany(
@@ -121,6 +122,10 @@ export class BillingSubscription {
@Column({ nullable: false, type: 'jsonb', default: {} })
metadata: Stripe.Metadata;
@Field(() => [BillingSubscriptionSchedulePhase])
@Column({ nullable: false, type: 'jsonb', default: [] })
phases: Array<BillingSubscriptionSchedulePhase>;
@Column({ nullable: true, type: 'timestamptz' })
cancelAt: Date | null;
@@ -1,12 +0,0 @@
/* @license Enterprise */
import { registerEnumType } from '@nestjs/graphql';
export enum BillingPriceTiersMode {
GRADUATED = 'GRADUATED',
VOLUME = 'VOLUME',
}
registerEnumType(BillingPriceTiersMode, {
name: 'BillingPriceTiersMode',
description: 'The different billing price tiers modes',
});
@@ -1,8 +1,6 @@
/* @license Enterprise */
export enum SubscriptionInterval {
Day = 'day',
Month = 'month',
Week = 'week',
Year = 'year',
}
@@ -13,4 +13,5 @@ export enum BillingWebhookEvent {
PRICE_UPDATED = 'price.updated',
ALERT_TRIGGERED = 'billing.alert.triggered',
INVOICE_FINALIZED = 'invoice.finalized',
SUBSCRIPTION_SCHEDULE_UPDATED = 'subscription_schedule.updated',
}
@@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { JsonContains, Repository } from 'typeorm';
import { findOrThrow } from 'twenty-shared/utils';
import {
BillingException,
@@ -34,7 +35,7 @@ export class BillingPlanService {
priceUsageBased: BillingUsageType;
productKey: BillingProductKey;
}): Promise<BillingProduct[]> {
const products = await this.billingProductRepository.find({
return await this.billingProductRepository.find({
where: {
metadata: JsonContains({
priceUsageBased,
@@ -45,8 +46,6 @@ export class BillingPlanService {
},
relations: ['billingPrices'],
});
return products;
}
async getPlanBaseProduct(planKey: BillingPlanKey): Promise<BillingProduct> {
@@ -59,66 +58,68 @@ export class BillingPlanService {
return baseProduct;
}
async getPlans(): Promise<BillingGetPlanResult[]> {
async listPlans(): Promise<BillingGetPlanResult[]> {
const planKeys = Object.values(BillingPlanKey);
const products = await this.billingProductRepository.find({
where: {
active: true,
billingPrices: {
active: true,
},
},
relations: ['billingPrices.billingProduct'],
});
return planKeys.map((planKey) => {
const planProducts = products
.filter((product) => product.metadata.planKey === planKey)
.map((product) => {
return {
...product,
billingPrices: product.billingPrices.filter(
(price) => price.active,
),
};
});
const baseProduct = planProducts.find(
(product) =>
product.metadata.productKey === BillingProductKey.BASE_PRODUCT,
const planProducts = products.filter(
(product) => product.metadata.planKey === planKey,
);
if (!baseProduct) {
throw new BillingException(
'Base product not found, did you run the billing:sync-plans-data command?',
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
);
}
const meteredProducts = planProducts.filter(
(product) =>
product.metadata.priceUsageBased === BillingUsageType.METERED,
);
const otherLicensedProducts = planProducts.filter(
const licensedProducts = planProducts.filter(
(product) =>
product.metadata.priceUsageBased === BillingUsageType.LICENSED &&
product.metadata.productKey !== BillingProductKey.BASE_PRODUCT,
product.metadata.priceUsageBased === BillingUsageType.LICENSED,
);
return {
planKey,
baseProduct,
meteredProducts,
otherLicensedProducts,
licensedProducts,
};
});
}
async getPricesPerPlan({
async getPlanByPriceId(stripePriceId: string) {
const plans = await this.listPlans();
return findOrThrow(plans, (plan) => {
return (
plan.meteredProducts.some((product) =>
product.billingPrices.some(
(price) => price.stripePriceId === stripePriceId,
),
) ||
plan.licensedProducts.some((product) =>
product.billingPrices.some(
(price) => price.stripePriceId === stripePriceId,
),
)
);
});
}
async getPricesPerPlanByInterval({
planKey,
interval,
}: {
planKey: BillingPlanKey;
interval: SubscriptionInterval;
}): Promise<BillingGetPricesPerPlanResult> {
const plans = await this.getPlans();
const plans = await this.listPlans();
const plan = plans.find((plan) => plan.planKey === planKey);
if (!plan) {
@@ -127,33 +128,21 @@ export class BillingPlanService {
BillingExceptionCode.BILLING_PLAN_NOT_FOUND,
);
}
const { baseProduct, meteredProducts, otherLicensedProducts } = plan;
const baseProductPrice = baseProduct.billingPrices.find(
(price) => price.interval === interval && price.active,
);
const { meteredProducts, licensedProducts } = plan;
if (!baseProductPrice) {
throw new BillingException(
'Base product active price not found for given interval',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
const filterPricesByInterval = (product: BillingProduct) =>
product.billingPrices.filter(
(price) => price.interval === interval && price.active,
);
product.billingPrices.filter((price) => price.interval === interval);
const meteredProductsPrices = meteredProducts.flatMap(
filterPricesByInterval,
);
const otherLicensedProductsPrices = otherLicensedProducts.flatMap(
const licensedProductsPrices = licensedProducts.flatMap(
filterPricesByInterval,
);
return {
baseProductPrice,
meteredProductsPrices,
otherLicensedProductsPrices,
licensedProductsPrices,
};
}
}
@@ -1,443 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import type Stripe from 'stripe';
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingExceptionCode } from 'src/engine/core-modules/billing/billing.exception';
const buildWorkspace = (id: string): Workspace =>
({
id,
name: 'WS',
}) as unknown as Workspace;
const buildPricesPerPlan = () => ({
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
meteredProductsPrices: [
{
stripePriceId: 'price_metered_default',
tiers: [
{ flat_amount: 1000, up_to: 100 },
{ flat_amount: 0, up_to: null },
],
} as unknown as BillingPrice,
],
otherLicensedProductsPrices: [],
});
const buildStripeSubscription = (id = 'sub_123'): Stripe.Subscription =>
({
id,
status: 'active',
currency: 'usd',
current_period_start: 1700000000,
current_period_end: 1702592000,
cancel_at_period_end: false,
collection_method: 'charge_automatically',
automatic_tax: null,
cancellation_details: null,
trial_start: null,
trial_end: null,
cancel_at: null,
canceled_at: null,
customer: 'cus_123',
items: {
data: [
{
id: 'si_1',
price: { id: 'price_base', product: 'prod_base' },
plan: { interval: 'month' },
},
{
id: 'si_2',
price: { id: 'price_metered_default', product: 'prod_metered' },
plan: { interval: 'month' },
},
],
},
metadata: {},
}) as unknown as Stripe.Subscription;
describe('BillingPortalWorkspaceService', () => {
let service: BillingPortalWorkspaceService;
let stripeCheckoutService: StripeCheckoutService;
let billingSubscriptionRepository: Repository<BillingSubscription>;
let billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>;
let billingCustomerRepository: Repository<BillingCustomer>;
let userWorkspaceRepository: Repository<UserWorkspace>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingPortalWorkspaceService,
{
provide: StripeCheckoutService,
useValue: { createDirectSubscription: jest.fn() },
},
{
provide: StripeBillingPortalService,
useValue: { createBillingPortalSession: jest.fn() },
},
{
provide: DomainManagerService,
useValue: {
buildWorkspaceURL: jest.fn(
() => new URL('https://app.local/workspace'),
),
},
},
{
provide: BillingSubscriptionService,
useValue: {
setBillingThresholdsAndTrialPeriodWorkflowCredits: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingSubscription),
useValue: {
upsert: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
findOneBy: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingSubscriptionItem),
useValue: { upsert: jest.fn() },
},
{
provide: getRepositoryToken(BillingCustomer),
useValue: { upsert: jest.fn(), findOne: jest.fn() },
},
{
provide: getRepositoryToken(UserWorkspace),
useValue: { countBy: jest.fn() },
},
],
}).compile();
service = module.get(BillingPortalWorkspaceService);
stripeCheckoutService = module.get(StripeCheckoutService);
billingSubscriptionRepository = module.get(
getRepositoryToken(BillingSubscription),
);
billingSubscriptionItemRepository = module.get(
getRepositoryToken(BillingSubscriptionItem),
);
billingCustomerRepository = module.get(getRepositoryToken(BillingCustomer));
userWorkspaceRepository = module.get(getRepositoryToken(UserWorkspace));
});
it('creates a direct subscription and syncs to database, returning success URL', async () => {
const workspace = buildWorkspace('ws-1');
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(3);
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_123',
} as unknown as BillingCustomer);
const subscription = buildStripeSubscription('sub_test');
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(subscription);
// After upserts, the repo.find should return the created subscription mapping
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_1',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_other',
},
{
id: 'db_sub_created',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_test',
},
]);
const url = await service.createDirectSubscription({
user: { id: 'user_1' } as any,
workspace,
billingPricesPerPlan: buildPricesPerPlan(),
successUrlPath: '/billing/success',
plan: 'PRO' as any,
requirePaymentMethod: false,
});
expect(url).toBe('https://app.local/billing/success');
// Ensure stripe call built line items properly
const callArgs = (
stripeCheckoutService.createDirectSubscription as jest.Mock
).mock.calls[0][0];
expect(callArgs.workspace.id).toBe(workspace.id);
expect(callArgs.stripeSubscriptionLineItems).toEqual([
{ price: 'price_base', quantity: 3 },
{ price: 'price_metered_default' },
]);
expect(callArgs.withTrialPeriod).toBe(true); // no previous subscriptions
// Sync to DB operations
expect(billingCustomerRepository.upsert).toHaveBeenCalled();
expect(billingSubscriptionRepository.upsert).toHaveBeenCalled();
expect(billingSubscriptionItemRepository.upsert).toHaveBeenCalled();
});
it('throws when missing billing prices per plan (line items cannot be built)', async () => {
const workspace = buildWorkspace('ws-1');
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(1);
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_123',
} as unknown as BillingCustomer);
await expect(
service.createDirectSubscription({
user: { id: 'user_1' } as any,
workspace,
billingPricesPerPlan: undefined as any,
successUrlPath: '/billing/success',
plan: 'PRO' as any,
requirePaymentMethod: false,
}),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
});
});
it('does not include trial period when customer already has subscriptions', async () => {
const workspace = buildWorkspace('ws-1');
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(5);
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [{}],
stripeCustomerId: 'cus_999',
} as BillingCustomer);
const subscription = buildStripeSubscription('sub_no_trial');
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(subscription);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_created',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_no_trial',
},
]);
const url = await service.createDirectSubscription({
user: { id: 'user_1' } as any,
workspace,
billingPricesPerPlan: buildPricesPerPlan(),
successUrlPath: '/done',
plan: 'PRO' as any,
requirePaymentMethod: true,
});
expect(url).toBe('https://app.local/done');
const callArgs = (
stripeCheckoutService.createDirectSubscription as jest.Mock
).mock.calls[0][0];
expect(callArgs.withTrialPeriod).toBe(false);
});
it('throws if subscription not found after creation during sync', async () => {
const workspace = buildWorkspace('ws-1');
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(2);
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_123',
} as unknown as BillingCustomer);
const subscription = buildStripeSubscription('sub_missing');
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(subscription);
// Return list that doesn't include the just-created subscription id
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_other',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_other',
},
]);
await expect(
service.createDirectSubscription({
user: { id: 'user_1' } as any,
workspace,
billingPricesPerPlan: buildPricesPerPlan(),
successUrlPath: '/billing/success',
plan: 'PRO' as any,
requirePaymentMethod: false,
}),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
});
});
it('picks the metered price with the lowest first tier flat_amount among many', async () => {
const workspace = buildWorkspace('ws-x');
const prices = {
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
meteredProductsPrices: [
{
stripePriceId: 'price_metered_A',
tiers: [
{ flat_amount: 1200, up_to: 100 },
{ flat_amount: 0, up_to: null },
],
} as unknown as BillingPrice,
{
stripePriceId: 'price_metered_B',
tiers: [
{ flat_amount: 800, up_to: 100 },
{ flat_amount: 0, up_to: null },
],
} as unknown as BillingPrice,
{
stripePriceId: 'price_metered_C',
tiers: [
{ flat_amount: 900, up_to: 100 },
{ flat_amount: 0, up_to: null },
],
} as unknown as BillingPrice,
],
otherLicensedProductsPrices: [],
};
// set specific mocks for this scenario
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(buildStripeSubscription('sub_x'));
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_x',
});
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_created_x',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_x',
},
]);
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(2);
await service.createDirectSubscription({
user: { id: 'u1' } as any,
workspace,
billingPricesPerPlan: prices as any,
successUrlPath: '/ok',
plan: 'PRO' as any,
requirePaymentMethod: false,
});
const args = (stripeCheckoutService.createDirectSubscription as jest.Mock)
.mock.calls[0][0];
const lineItems = args.stripeSubscriptionLineItems as any[];
expect(lineItems[1]).toEqual({ price: 'price_metered_B' });
});
it('ignores non-metered tiers shapes and still picks the valid lowest flat_amount', async () => {
const workspace = buildWorkspace('ws-y');
const prices = {
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
meteredProductsPrices: [
// invalid tiers shape (missing flat_amount), should be ignored by validator
{ stripePriceId: 'price_invalid', tiers: [{ up_to: 100 }] },
{
stripePriceId: 'price_valid',
tiers: [
{ flat_amount: 700, up_to: 50 },
{ flat_amount: 0, up_to: null },
],
},
],
otherLicensedProductsPrices: [],
} as any;
// set specific mocks for this scenario
(
stripeCheckoutService.createDirectSubscription as jest.Mock
).mockResolvedValue(buildStripeSubscription('sub_x'));
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
workspaceId: workspace.id,
billingSubscriptions: [],
stripeCustomerId: 'cus_x',
});
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
{
id: 'db_sub_created_x',
workspaceId: workspace.id,
stripeSubscriptionId: 'sub_x',
},
]);
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(2);
await service.createDirectSubscription({
user: { id: 'u2' } as any,
workspace,
billingPricesPerPlan: prices,
successUrlPath: '/ok',
plan: 'PRO' as any,
requirePaymentMethod: false,
});
const args = (stripeCheckoutService.createDirectSubscription as jest.Mock)
.mock.calls[0][0];
const lineItems = args.stripeSubscriptionLineItems as any[];
expect(lineItems[1]).toEqual({ price: 'price_invalid' }); // current implementation keeps first entry even if tiers are invalid
});
it('throws BILLING_PRICE_NOT_FOUND when meteredProductsPrices is empty', async () => {
const workspace = buildWorkspace('ws-z');
const prices = {
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
meteredProductsPrices: [],
otherLicensedProductsPrices: [],
} as any;
await expect(
service.createDirectSubscription({
user: { id: 'u3' } as any,
workspace,
billingPricesPerPlan: prices,
successUrlPath: '/ok',
plan: 'PRO' as any,
requirePaymentMethod: false,
}),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
});
});
});
@@ -3,20 +3,16 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { findOrThrow, isDefined } from 'twenty-shared/utils';
import { Not, Repository } from 'typeorm';
import type Stripe from 'stripe';
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-customer.util';
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-subscription.util';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
@@ -27,9 +23,10 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { assert } from 'src/utils/assert';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
import { BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
@Injectable()
export class BillingPortalWorkspaceService {
@@ -41,8 +38,6 @@ export class BillingPortalWorkspaceService {
private readonly billingSubscriptionService: BillingSubscriptionService,
@InjectRepository(BillingSubscription)
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(BillingSubscriptionItem)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(BillingCustomer)
private readonly billingCustomerRepository: Repository<BillingCustomer>,
@InjectRepository(UserWorkspace)
@@ -106,11 +101,17 @@ export class BillingPortalWorkspaceService {
stripeCustomerId: customer?.stripeCustomerId,
plan,
requirePaymentMethod,
withTrialPeriod:
!isDefined(customer) || customer.billingSubscriptions.length === 0,
});
await this.syncSubscriptionToDatabase(workspace.id, subscription);
const createdBillingSubscription =
await this.billingSubscriptionService.syncSubscriptionToDatabase(
workspace.id,
subscription,
);
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
createdBillingSubscription.id,
);
return successUrl;
}
@@ -121,7 +122,7 @@ export class BillingPortalWorkspaceService {
successUrlPath,
}: {
workspace: Workspace;
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
billingPricesPerPlan: BillingGetPricesPerPlanResult;
successUrlPath?: string;
}) {
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
@@ -157,72 +158,15 @@ export class BillingPortalWorkspaceService {
};
}
private async syncSubscriptionToDatabase(
workspaceId: string,
subscription: Stripe.Subscription,
) {
await this.billingCustomerRepository.upsert(
transformStripeSubscriptionEventToDatabaseCustomer(workspaceId, {
object: subscription,
}),
{
conflictPaths: ['workspaceId'],
skipUpdateIfNoValuesChanged: true,
},
);
await this.billingSubscriptionRepository.upsert(
transformStripeSubscriptionEventToDatabaseSubscription(workspaceId, {
object: subscription,
}),
{
conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true,
},
);
const billingSubscriptions = await this.billingSubscriptionRepository.find({
where: { workspaceId },
});
const createdBillingSubscription = billingSubscriptions.find(
(sub) => sub.stripeSubscriptionId === subscription.id,
);
if (!createdBillingSubscription) {
throw new BillingException(
'Billing subscription not found after creation',
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
);
}
await this.billingSubscriptionItemRepository.upsert(
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
createdBillingSubscription.id,
{
object: subscription,
},
),
{
conflictPaths: ['stripeSubscriptionItemId'],
skipUpdateIfNoValuesChanged: true,
},
);
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
createdBillingSubscription.id,
);
this.logger.log(
`Subscription synced to database: ${subscription.id} for workspace: ${workspaceId}`,
);
}
async computeBillingPortalSessionURLOrThrow(
workspace: Workspace,
returnUrlPath?: string,
) {
const lastSubscription = await this.billingSubscriptionRepository.findOne({
where: { workspaceId: workspace.id },
where: {
workspaceId: workspace.id,
status: Not(SubscriptionStatus.Canceled),
},
order: { createdAt: 'DESC' },
});
@@ -258,30 +202,24 @@ export class BillingPortalWorkspaceService {
private getDefaultMeteredProductPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
): BillingPrice & {
tiers: MeterBillingPriceTiers;
} {
): BillingMeterPrice {
const defaultMeteredProductPrice =
billingPricesPerPlan.meteredProductsPrices.reduce(
(result, billingPrice) => {
if (!result) {
return billingPrice as BillingPrice & {
tiers: MeterBillingPriceTiers;
};
return billingPrice as BillingMeterPrice;
}
const tiers = billingPrice.tiers;
if (billingValidator.isMeteredTiersSchema(tiers)) {
if (tiers[0].flat_amount < result.tiers[0].flat_amount) {
return billingPrice as BillingPrice & {
tiers: MeterBillingPriceTiers;
};
return billingPrice as BillingMeterPrice;
}
}
return result;
},
null as (BillingPrice & { tiers: MeterBillingPriceTiers }) | null,
null as BillingMeterPrice | null,
);
if (!isDefined(defaultMeteredProductPrice)) {
@@ -299,26 +237,30 @@ export class BillingPortalWorkspaceService {
billingPricesPerPlan,
}: {
quantity: number;
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
billingPricesPerPlan: BillingGetPricesPerPlanResult;
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
if (billingPricesPerPlan) {
const defaultMeteredProductPrice =
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
const defaultMeteredProductPrice =
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
return [
{
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
quantity,
},
{
price: defaultMeteredProductPrice.stripePriceId,
},
];
}
throw new BillingException(
'Missing Billing prices per plan',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
const defaultLicensedProductPrice = findOrThrow(
billingPricesPerPlan.licensedProductsPrices,
(licensedProductsPrice) =>
licensedProductsPrice.billingProduct?.metadata.productKey ===
BillingProductKey.BASE_PRODUCT,
new BillingException(
`Base product not found`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
),
);
return [
{
price: defaultLicensedProductPrice.stripePriceId,
quantity,
},
{
price: defaultMeteredProductPrice.stripePriceId,
},
];
}
}
@@ -47,7 +47,7 @@ export class BillingProductService {
}
async getProductsByPlan(planKey: BillingPlanKey): Promise<BillingProduct[]> {
const products = await this.billingPlanService.getPlans();
const products = await this.billingPlanService.listPlans();
const plan = products.find((product) => product.planKey === planKey);
if (!plan) {
@@ -57,6 +57,6 @@ export class BillingProductService {
);
}
return [plan.baseProduct, ...plan.meteredProducts];
return [...plan.licensedProducts, ...plan.meteredProducts];
}
}
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { JsonContains, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import {
@@ -10,9 +10,7 @@ import {
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
@Injectable()
@@ -20,82 +18,9 @@ export class BillingSubscriptionItemService {
constructor(
@InjectRepository(BillingSubscriptionItem)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(BillingPrice)
private readonly billingPriceRepository: Repository<BillingPrice>,
private readonly twentyConfigService: TwentyConfigService,
private readonly stripeSubscriptionService: StripeSubscriptionService,
) {}
async updateMeteredSubscriptionItemPrice(
subscriptionId: string,
newPriceId: string,
) {
const subscriptionItem =
await this.billingSubscriptionItemRepository.findOne({
where: {
billingSubscriptionId: subscriptionId,
billingProduct: {
metadata: JsonContains({
priceUsageBased: BillingUsageType.METERED,
}),
},
},
relations: ['billingProduct', 'billingProduct.billingPrices'],
});
if (!subscriptionItem) {
throw new BillingException(
`Cannot find subscription item for subscription ${subscriptionId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
const currentBillingPrice = subscriptionItem
? this.findMatchingPrice(subscriptionItem)
: null;
if (!currentBillingPrice) {
throw new BillingException(
`Cannot find price for product ${subscriptionItem.stripeProductId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
const newPrice = await this.billingPriceRepository.findOne({
where: { stripePriceId: newPriceId },
});
if (!newPrice) {
throw new BillingException(
`Cannot find price with id ${newPriceId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
{ userFriendlyMessage: 'Price not found' },
);
}
if (
!this.isFirstPriceTiersLowerThatSecondPriceTier(
currentBillingPrice,
newPrice,
)
) {
throw new BillingException(
'Cannot update price of subscription item because the new tier is lower than the current tier.',
BillingExceptionCode.BILLING_PRICE_UPDATE_REQUIRES_INCREASE,
);
}
await this.stripeSubscriptionService.updateSubscriptionItems(
subscriptionItem.stripeSubscriptionId,
[
{
...subscriptionItem,
stripePriceId: newPriceId,
},
],
);
}
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
const meteredSubscriptionItems =
await this.billingSubscriptionItemRepository.find({
@@ -133,16 +58,6 @@ export class BillingSubscriptionItemService {
);
}
private isFirstPriceTiersLowerThatSecondPriceTier(
price1: BillingPrice,
price2: BillingPrice,
) {
billingValidator.assertIsMeteredTiersSchemaOrThrow(price1.tiers);
billingValidator.assertIsMeteredTiersSchemaOrThrow(price2.tiers);
return price1.tiers[0].up_to < price2.tiers[0].up_to;
}
private findMatchingPrice(item: BillingSubscriptionItem): BillingPrice {
const matchingPrice = item.billingProduct.billingPrices.find(
(price) => price.stripePriceId === item.stripePriceId,
@@ -167,11 +82,8 @@ export class BillingSubscriptionItemService {
private getFreeTrialQuantity(item: BillingSubscriptionItem): number {
switch (item.billingProduct.metadata.productKey) {
case BillingProductKey.WORKFLOW_NODE_EXECUTION:
return (
item.metadata.trialPeriodFreeWorkflowCredits ||
this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
)
return this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
);
default:
return 0;
@@ -0,0 +1,154 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
assertIsDefinedOrThrow,
findOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import Stripe from 'stripe';
import { BillingSubscriptionSchedulePhase } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
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';
@Injectable()
export class BillingSubscriptionPhaseService {
constructor(
@InjectRepository(BillingPrice)
private readonly billingPriceRepository: Repository<BillingPrice>,
private readonly billingPlanService: BillingPlanService,
) {}
async getDetailsFromPhase(phase: BillingSubscriptionSchedulePhase) {
const meteredPrice = await this.billingPriceRepository.findOneByOrFail({
stripePriceId: findOrThrow(
phase.items,
({ quantity }) => !isDefined(quantity),
).price,
});
const { quantity, price: licensedItemPriceId } = findOrThrow(
phase.items,
({ quantity }) => isDefined(quantity),
);
const licensedPrice = await this.billingPriceRepository.findOneByOrFail({
stripePriceId: licensedItemPriceId,
});
const plan = await this.billingPlanService.getPlanByPriceId(
meteredPrice.stripePriceId,
);
if (!isDefined(quantity)) {
throw new Error('Quantity is not defined');
}
return {
plan,
meteredPrice,
licensedPrice,
quantity,
interval: meteredPrice.interval,
};
}
toSnapshot(
phase: Stripe.SubscriptionSchedule.Phase,
): Stripe.SubscriptionScheduleUpdateParams.Phase {
return {
start_date: phase.start_date,
end_date: phase.end_date ?? undefined,
items: (phase.items || []).map((it) => ({
price: normalizePriceRef(it.price) as string,
quantity: it.quantity ?? undefined,
})),
...(phase.billing_thresholds
? { billing_thresholds: phase.billing_thresholds }
: {}),
proration_behavior: 'none',
} as Stripe.SubscriptionScheduleUpdateParams.Phase;
}
buildSnapshot(
base: Stripe.SubscriptionScheduleUpdateParams.Phase,
licensedPriceId: string,
seats: number,
meteredPriceId: string,
billing_thresholds?: Stripe.SubscriptionScheduleUpdateParams.Phase.BillingThresholds,
): Stripe.SubscriptionScheduleUpdateParams.Phase {
return {
start_date: base.start_date,
end_date: base.end_date,
proration_behavior: base.proration_behavior ?? 'none',
items: [
{ price: licensedPriceId, quantity: seats },
{ price: meteredPriceId },
],
...(billing_thresholds ? { billing_thresholds } : {}),
};
}
getLicensedPriceIdFromSnapshot(
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
): string {
const licensedItem = findOrThrow(phase.items!, (i) => i.quantity != null);
assertIsDefinedOrThrow(licensedItem.price);
return licensedItem.price;
}
async isSamePhaseSignature(
a: Stripe.SubscriptionScheduleUpdateParams.Phase,
b: Stripe.SubscriptionScheduleUpdateParams.Phase,
): Promise<boolean> {
try {
const sigA = await this.getPhaseSignatureFromSnapshot(a);
const sigB = await this.getPhaseSignatureFromSnapshot(b);
return (
sigA.planKey === sigB.planKey &&
sigA.interval === sigB.interval &&
sigA.meteredPriceId === sigB.meteredPriceId
);
} 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,
};
}
}
@@ -14,7 +14,6 @@ import { BillingProductService } from 'src/engine/core-modules/billing/services/
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
@Injectable()
export class BillingService {
@@ -23,7 +22,6 @@ export class BillingService {
private readonly twentyConfigService: TwentyConfigService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingProductService: BillingProductService,
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
@InjectRepository(BillingSubscription)
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
) {}
@@ -69,40 +67,6 @@ export class BillingService {
return !hasAnySubscription;
}
async updateMeteredSubscriptionPrice(workspaceId: string, priceId: string) {
const subscription =
await this.billingSubscriptionService.getCurrentActiveBillingSubscriptionOrThrow(
{ workspaceId },
);
await this.billingSubscriptionItemService.updateMeteredSubscriptionItemPrice(
subscription.id,
priceId,
);
}
async listMeteredBillingPricesByWorkspaceIdAndProductKey(
workspaceId: string,
productKey: BillingProductKey = BillingProductKey.WORKFLOW_NODE_EXECUTION,
) {
const subscription =
await this.billingSubscriptionService.getCurrentActiveBillingSubscriptionOrThrow(
{ workspaceId },
);
const planKey = getPlanKeyFromSubscription(subscription);
const products =
await this.billingProductService.getProductsByPlan(planKey);
const targetProduct = products.find(
({ metadata }) => metadata.productKey === productKey,
);
return (
targetProduct?.billingPrices.filter(
({ active, interval }) => active && interval === subscription.interval,
) ?? []
);
}
async canBillMeteredProduct(
workspaceId: string,
productKey: BillingProductKey,
@@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import type Stripe from 'stripe';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
@Injectable()
export class StripeBillingAlertService {
protected readonly logger = new Logger(StripeBillingAlertService.name);
private readonly stripe: Stripe;
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly stripeSDKService: StripeSDKService,
private readonly stripeBillingMeterService: StripeBillingMeterService,
) {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.twentyConfigService.get('BILLING_STRIPE_API_KEY'),
);
}
async createUsageThresholdAlertForCustomerMeter(
customerId: string,
gte: number,
): Promise<void> {
const meters = await this.stripeBillingMeterService.getAllMeters();
await this.stripe.billing.alerts.create({
alert_type: 'usage_threshold',
title: `Trial usage cap for customer ${customerId}`,
usage_threshold: {
gte,
meter: meters[0].id,
recurrence: 'one_time',
filters: [
{
type: 'customer',
customer: customerId,
},
],
},
});
}
}
@@ -95,7 +95,6 @@ export class StripeCheckoutService {
stripeCustomerId,
plan = BillingPlanKey.PRO,
requirePaymentMethod = false,
withTrialPeriod,
}: {
user: User;
workspace: Pick<Workspace, 'id' | 'displayName'>;
@@ -103,7 +102,6 @@ export class StripeCheckoutService {
stripeCustomerId?: string;
plan?: BillingPlanKey;
requirePaymentMethod?: boolean;
withTrialPeriod: boolean;
}): Promise<Stripe.Subscription> {
if (!isDefined(stripeCustomerId)) {
const stripeCustomer =
@@ -130,10 +128,6 @@ export class StripeCheckoutService {
workspaceId: workspace.id,
plan,
},
...this.getStripeSubscriptionTrialPeriodConfig(
withTrialPeriod,
requirePaymentMethod,
),
automatic_tax: { enabled: !!requirePaymentMethod },
};
@@ -24,10 +24,17 @@ export class StripePriceService {
);
}
async getPriceByPriceId(priceId: string) {
return await this.stripe.prices.retrieve(priceId, {
expand: ['data.currency_options', 'data.tiers'],
});
}
async getPricesByProductId(productId: string) {
const prices = await this.stripe.prices.list({
product: productId,
type: 'recurring',
limit: 100,
expand: ['data.currency_options', 'data.tiers'],
});
@@ -0,0 +1,175 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
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';
@Injectable()
export class StripeSubscriptionScheduleService {
protected readonly logger = new Logger(
StripeSubscriptionScheduleService.name,
);
private readonly stripe: Stripe;
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly stripeSDKService: StripeSDKService,
) {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = this.stripeSDKService.getStripe(
this.twentyConfigService.get('BILLING_STRIPE_API_KEY'),
);
}
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) {
const now = Math.floor(Date.now() / 1000);
const currentEditable = (live.phases || []).find((p) => {
const s = p.start_date ?? 0;
const e = p.end_date ?? Infinity;
return s <= now && now < e;
});
const nextEditable = (live.phases || [])
.filter((p) => (p.start_date ?? 0) > now)
.sort((a, b) => (a.start_date ?? 0) - (b.start_date ?? 0))[0];
return { currentEditable, nextEditable };
}
async getSubscriptionWithSchedule(stripeSubscriptionId: string) {
return (await this.stripe.subscriptions.retrieve(stripeSubscriptionId, {
expand: ['schedule'],
})) 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);
}
async createScheduleFromSubscription(subscriptionId: string) {
if (!this.stripe) throw new Error('Billing is disabled');
return this.stripe.subscriptionSchedules.create({
from_subscription: subscriptionId,
});
}
async findOrCreateSubscriptionSchedule(
subscription: SubscriptionWithSchedule,
) {
if (subscription.schedule) return subscription.schedule;
return this.createScheduleFromSubscription(subscription.id);
}
async replaceEditablePhases(
scheduleId: string,
desired: {
currentSnapshot?: Stripe.SubscriptionScheduleUpdateParams.Phase;
nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase;
},
): Promise<Stripe.SubscriptionSchedule> {
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[] = [];
if (currentEditable) {
const currentSnapshot =
desired.currentSnapshot ?? this.snapshotFromLivePhase(currentEditable);
phases.push(currentSnapshot);
}
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 });
}
}
@@ -7,6 +7,7 @@ import type Stripe from 'stripe';
import { type BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
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 { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
@Injectable()
export class StripeSubscriptionService {
@@ -34,11 +35,10 @@ export class StripeSubscriptionService {
query: `metadata['workspaceId']:'${workspaceId}'`,
limit: 1,
});
const stripeCustomerId = subscription.data[0].customer
? String(subscription.data[0].customer)
: undefined;
return stripeCustomerId;
return subscription.data[0].customer
? subscription.data[0].customer
: undefined;
}
async collectLastInvoice(stripeSubscriptionId: string) {
@@ -84,15 +84,20 @@ export class StripeSubscriptionService {
return this.stripe.subscriptions.update(stripeSubscriptionId, updateData);
}
getBillingThresholdsByInterval(interval: SubscriptionInterval) {
return {
amount_gte:
this.twentyConfigService.get('BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT') *
(interval === SubscriptionInterval.Year ? 12 : 1),
reset_billing_cycle_anchor: false,
};
}
async setYearlyThresholds(stripeSubscriptionId: string) {
return this.stripe.subscriptions.update(stripeSubscriptionId, {
billing_thresholds: {
amount_gte:
this.twentyConfigService.get(
'BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT',
) * 12,
reset_billing_cycle_anchor: false,
},
billing_thresholds: this.getBillingThresholdsByInterval(
SubscriptionInterval.Year,
),
});
}
}
@@ -13,9 +13,11 @@ import { StripePriceService } from 'src/engine/core-modules/billing/stripe/servi
import { StripeProductService } from 'src/engine/core-modules/billing/stripe/services/stripe-product.service';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service';
import { StripeSDKModule } from 'src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
@Module({
imports: [
@@ -28,12 +30,14 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma
StripeWebhookService,
StripeCheckoutService,
StripeSubscriptionService,
StripeSubscriptionScheduleService,
StripeBillingPortalService,
StripeBillingMeterService,
StripeCustomerService,
StripePriceService,
StripeProductService,
StripeBillingMeterEventService,
StripeBillingAlertService,
],
exports: [
StripeWebhookService,
@@ -46,6 +50,8 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma
StripeSubscriptionService,
StripeProductService,
StripeBillingMeterEventService,
StripeSubscriptionScheduleService,
StripeBillingAlertService,
],
})
export class StripeModule {}
@@ -5,7 +5,6 @@ import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billi
export type BillingGetPlanResult = {
planKey: BillingPlanKey;
baseProduct: BillingProduct;
meteredProducts: BillingProduct[];
otherLicensedProducts: BillingProduct[];
licensedProducts: BillingProduct[];
};
@@ -3,7 +3,6 @@
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
export type BillingGetPricesPerPlanResult = {
baseProductPrice: BillingPrice;
meteredProductsPrices: BillingPrice[];
otherLicensedProductsPrices: BillingPrice[];
licensedProductsPrices: BillingPrice[];
};
@@ -0,0 +1,6 @@
import type { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
export type BillingMeterPrice = BillingPrice & {
tiers: MeterBillingPriceTiers;
};
@@ -8,7 +8,7 @@ import { type Workspace } from 'src/engine/core-modules/workspace/workspace.enti
export type BillingPortalCheckoutSessionParameters = {
user: User;
workspace: Workspace;
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
billingPricesPerPlan: BillingGetPricesPerPlanResult;
successUrlPath?: string;
plan: BillingPlanKey;
requirePaymentMethod?: boolean;
@@ -0,0 +1,15 @@
import { type BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
export type LicensedBillingSubscriptionItem = Omit<
BillingSubscriptionItem,
'quantity'
> & {
quantity: number;
};
export type MeteredBillingSubscriptionItem = Omit<
BillingSubscriptionItem,
'quantity'
> & {
quantity: null;
};
@@ -0,0 +1,5 @@
import type Stripe from 'stripe';
export type SubscriptionWithSchedule = Omit<Stripe.Subscription, 'schedule'> & {
schedule: Stripe.SubscriptionSchedule;
};
@@ -0,0 +1,14 @@
import { type BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import {
type LicensedBillingSubscriptionItem,
type MeteredBillingSubscriptionItem,
} from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
export type BillingSubscriptionWithSubscriptionItems = Omit<
BillingSubscription,
'billingSubscriptionItems'
> & {
billingSubscriptionItems: Array<
LicensedBillingSubscriptionItem | MeteredBillingSubscriptionItem
>;
};
@@ -2,9 +2,9 @@ export type MeterBillingPriceTiers = [
{
up_to: number;
flat_amount: number;
unit_amount: number;
unit_amount: null;
flat_amount_decimal: string;
unit_amount_decimal: string;
unit_amount_decimal: null;
},
{
up_to: null;
@@ -0,0 +1,51 @@
import { ensureFutureStartDate } from 'src/engine/core-modules/billing/utils/ensure-future-start-date.util';
describe('ensureFutureStartDate', () => {
const fixedNowSec = 1_700_000_000; // arbitrary fixed epoch seconds
const fixedNowMs = fixedNowSec * 1000;
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(fixedNowMs);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns now+1 when called with no arguments', () => {
expect(ensureFutureStartDate()).toBe(fixedNowSec + 1);
});
it('returns now+1 when all provided dates are in the past or equal to now', () => {
const past = fixedNowSec - 10;
const equal = fixedNowSec;
expect(ensureFutureStartDate(past, null, undefined, 0, equal)).toBe(
fixedNowSec + 1,
);
});
it('returns the maximum future date when at least one future date is provided', () => {
const future1 = fixedNowSec + 5;
const future2 = fixedNowSec + 10;
expect(ensureFutureStartDate(future1, future2)).toBe(future2);
});
it('ignores null/undefined by treating them as 0 and still enforces at least now+1', () => {
const result = ensureFutureStartDate(
null,
undefined,
-100,
fixedNowSec - 1,
);
expect(result).toBe(fixedNowSec + 1);
});
it('returns a provided future date even if it is just one second above now+1 boundary', () => {
const barelyFuture = fixedNowSec + 2; // > now+1
expect(ensureFutureStartDate(barelyFuture)).toBe(barelyFuture);
});
});
@@ -1,37 +1,23 @@
/* @license Enterprise */
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
import { 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 BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
import type { BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
describe('formatBillingDatabaseProductToGraphqlDTO', () => {
it('should format a complete billing plan correctly', () => {
it('should correctly format a billing plan with licensed and metered products', () => {
const mockPlan = {
planKey: BillingPlanKey.PRO,
baseProduct: {
id: 'base-1',
name: 'Base Product',
billingPrices: [
{
interval: SubscriptionInterval.Month,
unitAmount: 1000,
stripePriceId: 'price_base1',
priceUsageType: BillingUsageType.LICENSED,
},
],
},
otherLicensedProducts: [
licensedProducts: [
{
id: 'licensed-1',
name: 'Licensed Product',
id: 'product-1',
name: 'Test Licensed Product',
billingPrices: [
{
interval: SubscriptionInterval.Year,
unitAmount: 2000,
stripePriceId: 'price_licensed1',
interval: SubscriptionInterval.Month,
unitAmount: 1500,
stripePriceId: 'price_123',
priceUsageType: BillingUsageType.LICENSED,
},
],
@@ -39,18 +25,14 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
],
meteredProducts: [
{
id: 'metered-1',
name: 'Metered Product',
id: 'product-2',
name: 'Test Metered Product',
billingPrices: [
{
interval: SubscriptionInterval.Month,
tiersMode: BillingPriceTiersMode.GRADUATED,
tiers: [
{
up_to: 10,
flat_amount: 1000,
unit_amount: 100,
},
{ up_to: 10, flat_amount: 500, unit_amount: null },
{ up_to: null, flat_amount: null, unit_amount: 0.001 },
],
stripePriceId: 'price_metered1',
priceUsageType: BillingUsageType.METERED,
@@ -66,181 +48,54 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
expect(result).toEqual({
planKey: BillingPlanKey.PRO,
baseProduct: {
id: 'base-1',
metadata: {
priceUsageBased: BillingUsageType.LICENSED,
},
name: 'Base Product',
billingPrices: [
{
interval: SubscriptionInterval.Month,
unitAmount: 1000,
stripePriceId: 'price_base1',
priceUsageType: BillingUsageType.LICENSED,
},
],
prices: [
{
recurringInterval: SubscriptionInterval.Month,
unitAmount: 1000,
stripePriceId: 'price_base1',
priceUsageType: BillingUsageType.LICENSED,
},
],
},
otherLicensedProducts: [
licensedProducts: [
{
id: 'licensed-1',
metadata: {
priceUsageBased: BillingUsageType.LICENSED,
},
name: 'Licensed Product',
billingPrices: [
{
interval: SubscriptionInterval.Year,
unitAmount: 2000,
stripePriceId: 'price_licensed1',
priceUsageType: BillingUsageType.LICENSED,
},
],
prices: [
{
recurringInterval: SubscriptionInterval.Year,
unitAmount: 2000,
stripePriceId: 'price_licensed1',
priceUsageType: BillingUsageType.LICENSED,
},
],
},
],
meteredProducts: [
{
id: 'metered-1',
metadata: {
priceUsageBased: BillingUsageType.METERED,
},
name: 'Metered Product',
id: 'product-1',
name: 'Test Licensed Product',
billingPrices: [
{
interval: SubscriptionInterval.Month,
tiersMode: BillingPriceTiersMode.GRADUATED,
tiers: [
{
up_to: 10,
flat_amount: 1000,
unit_amount: 100,
},
],
stripePriceId: 'price_metered1',
priceUsageType: BillingUsageType.METERED,
unitAmount: 1500,
stripePriceId: 'price_123',
priceUsageType: BillingUsageType.LICENSED,
},
],
prices: [
{
tiersMode: BillingPriceTiersMode.GRADUATED,
tiers: [
{
upTo: 10,
flatAmount: 1000,
unitAmount: 100,
},
],
recurringInterval: SubscriptionInterval.Month,
stripePriceId: 'price_metered1',
priceUsageType: BillingUsageType.METERED,
unitAmount: 1500,
stripePriceId: 'price_123',
priceUsageType: BillingUsageType.LICENSED,
},
],
},
],
});
});
it('should handle empty products and null values', () => {
const mockPlan = {
planKey: 'empty-plan',
baseProduct: {
id: 'base-1',
name: 'Base Product',
billingPrices: [
{
interval: null,
unitAmount: null,
stripePriceId: null,
priceUsageType: BillingUsageType.LICENSED,
},
],
},
otherLicensedProducts: [],
meteredProducts: [
{
id: 'metered-1',
name: 'Metered Product',
billingPrices: [
{
interval: null,
tiersMode: null,
tiers: null,
stripePriceId: null,
priceUsageType: BillingUsageType.METERED,
},
],
},
],
};
const result = formatBillingDatabaseProductToGraphqlDTO(
mockPlan as unknown as BillingGetPlanResult,
);
expect(result).toEqual({
planKey: 'empty-plan',
baseProduct: {
id: 'base-1',
metadata: {
priceUsageBased: BillingUsageType.LICENSED,
},
name: 'Base Product',
billingPrices: [
{
interval: null,
unitAmount: null,
stripePriceId: null,
priceUsageType: BillingUsageType.LICENSED,
},
],
prices: [
{
recurringInterval: SubscriptionInterval.Month,
unitAmount: 0,
stripePriceId: null,
priceUsageType: BillingUsageType.LICENSED,
},
],
},
otherLicensedProducts: [],
meteredProducts: [
{
id: 'metered-1',
id: 'product-2',
metadata: {
priceUsageBased: BillingUsageType.METERED,
priceUsageBased: 'METERED',
},
name: 'Metered Product',
name: 'Test Metered Product',
billingPrices: [
{
interval: null,
tiersMode: null,
tiers: null,
stripePriceId: null,
interval: SubscriptionInterval.Month,
tiers: [
{ up_to: 10, flat_amount: 500, unit_amount: null },
{ up_to: null, flat_amount: null, unit_amount: 0.001 },
],
stripePriceId: 'price_metered1',
priceUsageType: BillingUsageType.METERED,
},
],
prices: [
{
tiersMode: null,
tiers: [],
tiers: [
{ upTo: 10, flatAmount: 500, unitAmount: null },
{ upTo: null, flatAmount: null, unitAmount: 0.001 },
],
recurringInterval: SubscriptionInterval.Month,
stripePriceId: null,
stripePriceId: 'price_metered1',
priceUsageType: BillingUsageType.METERED,
},
],
@@ -0,0 +1,23 @@
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
describe('normalizePriceRef', () => {
it('returns the same string when input is a string id', () => {
expect(normalizePriceRef('price_123')).toBe('price_123');
});
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('');
});
});
@@ -4,7 +4,6 @@ import type Stripe from 'stripe';
import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum';
import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum';
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
import { 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';
@@ -30,7 +29,6 @@ describe('transformStripePriceToDatabasePrice', () => {
},
currency_options: null,
tiers: null,
tiers_mode: null,
...overrides,
}) as unknown as Stripe.Price;
@@ -55,7 +53,6 @@ describe('transformStripePriceToDatabasePrice', () => {
interval: SubscriptionInterval.Month,
currencyOptions: undefined,
tiers: undefined,
tiersMode: undefined,
recurring: {
usage_type: 'licensed',
interval: 'month',
@@ -130,8 +127,6 @@ describe('transformStripePriceToDatabasePrice', () => {
it.each([
['month', SubscriptionInterval.Month],
['day', SubscriptionInterval.Day],
['week', SubscriptionInterval.Week],
['year', SubscriptionInterval.Year],
])('should transform interval %s correctly', (stripeInterval, expected) => {
const mockPrice = createMockPrice({
@@ -147,31 +142,6 @@ describe('transformStripePriceToDatabasePrice', () => {
});
});
describe('tiered pricing configurations', () => {
const mockTiers = [
{ up_to: 10, unit_amount: 1000 },
{ up_to: 20, unit_amount: 800 },
];
it.each([
['graduated', BillingPriceTiersMode.GRADUATED],
['volume', BillingPriceTiersMode.VOLUME],
])(
'should transform tiers mode %s correctly',
(stripeTiersMode, expected) => {
const mockPrice = createMockPrice({
billing_scheme: 'tiered',
tiers: mockTiers,
tiers_mode: stripeTiersMode as Stripe.Price.TiersMode,
});
const result = transformStripePriceToDatabasePrice(mockPrice);
expect(result.tiersMode).toBe(expected);
expect(result.tiers).toEqual(mockTiers);
},
);
});
describe('optional fields handling', () => {
it('should handle transform quantity configuration', () => {
const transformQuantity = {
@@ -0,0 +1,7 @@
export function ensureFutureStartDate(
...dates: Array<number | undefined | null>
): number {
const now = Math.floor(Date.now() / 1000);
return Math.max(...dates.map((d) => d ?? 0), now + 1);
}
@@ -4,7 +4,6 @@ import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dt
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
import { type BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
import { 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 BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
@@ -14,23 +13,9 @@ export const formatBillingDatabaseProductToGraphqlDTO = (
): BillingPlanOutput => {
return {
planKey: plan.planKey,
baseProduct: {
...plan.baseProduct,
metadata: {
...plan.baseProduct.metadata,
priceUsageBased: BillingUsageType.LICENSED,
},
prices: plan.baseProduct.billingPrices.map(
formatBillingDatabasePriceToLicensedPriceDTO,
),
},
otherLicensedProducts: plan.otherLicensedProducts.map((product) => {
licensedProducts: plan.licensedProducts.map((product) => {
return {
...product,
metadata: {
...product.metadata,
priceUsageBased: BillingUsageType.LICENSED,
},
prices: product.billingPrices.map(
formatBillingDatabasePriceToLicensedPriceDTO,
),
@@ -55,10 +40,6 @@ const formatBillingDatabasePriceToMeteredPriceDTO = (
billingPrice: BillingPrice,
): BillingPriceMeteredDTO => {
return {
tiersMode:
billingPrice?.tiersMode === BillingPriceTiersMode.GRADUATED
? BillingPriceTiersMode.GRADUATED
: null,
tiers:
billingPrice?.tiers?.map((tier) => ({
upTo: tier.up_to,
@@ -0,0 +1,14 @@
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
export const getOppositeInterval = (interval: SubscriptionInterval) => {
if (interval === SubscriptionInterval.Month) return SubscriptionInterval.Year;
if (interval === SubscriptionInterval.Year) return SubscriptionInterval.Month;
throw new BillingException(
`Interval invalid`,
BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_INVALID,
);
};
@@ -0,0 +1,14 @@
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
export const getOppositePlan = (plan: BillingPlanKey) => {
if (plan === BillingPlanKey.PRO) return BillingPlanKey.ENTERPRISE;
if (plan === BillingPlanKey.ENTERPRISE) return BillingPlanKey.PRO;
throw new BillingException(
`Plan invalid`,
BillingExceptionCode.BILLING_PLAN_NOT_FOUND,
);
};
@@ -0,0 +1,7 @@
export function normalizePriceRef(
p: string | { id: string } | null | undefined,
): string | undefined {
if (!p) return undefined;
return typeof p === 'string' ? p : p.id;
}
@@ -4,7 +4,6 @@ import type Stripe from 'stripe';
import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum';
import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum';
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
import { 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';
@@ -36,11 +35,7 @@ export const transformStripePriceToDatabasePrice = (data: Stripe.Price) => {
currencyOptions:
data.currency_options === null ? undefined : data.currency_options,
tiers: data.tiers === null ? undefined : data.tiers,
tiersMode: data.tiers_mode
? getBillingPriceTiersMode(data.tiers_mode)
: undefined,
recurring: data.recurring === null ? undefined : data.recurring,
metadata: data.metadata,
};
};
@@ -82,23 +77,10 @@ const getBillingPriceUsageType = (data: Stripe.Price.Recurring.UsageType) => {
}
};
const getBillingPriceTiersMode = (data: Stripe.Price.TiersMode) => {
switch (data) {
case 'graduated':
return BillingPriceTiersMode.GRADUATED;
case 'volume':
return BillingPriceTiersMode.VOLUME;
}
};
const getBillingPriceInterval = (data: Stripe.Price.Recurring.Interval) => {
switch (data) {
case 'month':
return SubscriptionInterval.Month;
case 'day':
return SubscriptionInterval.Day;
case 'week':
return SubscriptionInterval.Week;
case 'year':
return SubscriptionInterval.Year;
}