feat(pricing/ai): improve billing metered pricing + add pricing on ai chat (#14092)

## TODO:

- [x] display "yearly" or "monthly" wording everywhere it's needed
- [ ] Add button with "downgrade" or "upgrade" to save the change of
credits price + modal to validate
- [x] Add renewal date 
- [ ] Implement
https://docs.stripe.com/billing/subscriptions/subscription-schedules for
`switchFromYearlyToMonthly` and decrease number of credits
This commit is contained in:
Antoine Moreaux
2025-08-29 18:23:07 +02:00
committed by GitHub
parent 7df9094939
commit 1c4568c8b1
45 changed files with 2048 additions and 327 deletions
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiController } from './ai.controller';
@@ -9,16 +10,22 @@ describe('AiController', () => {
let controller: AiController;
let aiService: jest.Mocked<AiService>;
let featureFlagService: jest.Mocked<FeatureFlagService>;
let aiBillingService: jest.Mocked<AIBillingService>;
beforeEach(async () => {
const mockAiService = {
streamText: jest.fn(),
getModel: jest.fn(),
};
const mockFeatureFlagService = {
isFeatureEnabled: jest.fn().mockResolvedValue(true),
};
const mockAIBillingService = {
calculateAndBillUsage: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AiController],
providers: [
@@ -30,12 +37,17 @@ describe('AiController', () => {
provide: FeatureFlagService,
useValue: mockFeatureFlagService,
},
{
provide: AIBillingService,
useValue: mockAIBillingService,
},
],
}).compile();
controller = module.get<AiController>(AiController);
aiService = module.get(AiService);
featureFlagService = module.get(FeatureFlagService);
aiBillingService = module.get(AIBillingService);
});
it('should be defined', () => {
@@ -45,7 +57,7 @@ describe('AiController', () => {
describe('chat', () => {
const mockWorkspace = { id: 'workspace-1' } as any;
it('should handle valid chat request', async () => {
it('should handle valid chat request and bill usage', async () => {
const mockRequest = {
messages: [{ role: 'user' as const, content: 'Hello' }],
temperature: 0.7,
@@ -58,22 +70,44 @@ describe('AiController', () => {
end: jest.fn(),
} as any;
const mockModel = { modelId: 'gpt-4o' } as any;
aiService.getModel.mockReturnValue(mockModel);
const mockUsage = {
promptTokens: 10,
completionTokens: 20,
totalTokens: 30,
};
const mockStreamTextResult = {
usage: Promise.resolve(mockUsage),
pipeDataStreamToResponse: jest.fn(),
};
aiService.streamText.mockReturnValue(mockStreamTextResult as any);
await controller.chat(mockRequest, mockWorkspace, mockRes);
// Wait a microtask so the usage.then billing call fires
await Promise.resolve();
expect(featureFlagService.isFeatureEnabled).toHaveBeenCalled();
expect(aiService.streamText).toHaveBeenCalledWith(mockRequest.messages, {
temperature: 0.7,
maxTokens: 100,
expect(aiService.streamText).toHaveBeenCalledWith({
messages: mockRequest.messages,
options: {
temperature: 0.7,
maxTokens: 100,
model: mockModel,
},
});
expect(
mockStreamTextResult.pipeDataStreamToResponse,
).toHaveBeenCalledWith(mockRes);
expect(aiBillingService.calculateAndBillUsage).toHaveBeenCalledWith(
mockModel.modelId,
mockUsage,
mockWorkspace.id,
);
});
it('should throw error for empty messages', async () => {
@@ -86,6 +120,8 @@ describe('AiController', () => {
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow('Messages array is required and cannot be empty');
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
it('should handle service errors', async () => {
@@ -95,6 +131,7 @@ describe('AiController', () => {
const mockRes = {} as any;
aiService.getModel.mockReturnValue({ modelId: 'gpt-4o' } as any);
aiService.streamText.mockImplementation(() => {
throw new Error('Service error');
});
@@ -104,6 +141,8 @@ describe('AiController', () => {
).rejects.toThrow(
'An error occurred while processing your request: Service error',
);
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
it('should throw error when AI feature is disabled', async () => {
@@ -118,6 +157,8 @@ describe('AiController', () => {
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow('AI feature is not enabled for this workspace');
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
});
});
@@ -17,6 +17,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
export interface ChatRequest {
messages: CoreMessage[];
@@ -30,6 +31,7 @@ export class AiController {
constructor(
private readonly aiService: AiService,
private readonly featureFlagService: FeatureFlagService,
private readonly aiBillingService: AIBillingService,
) {}
@Post()
@@ -60,9 +62,24 @@ export class AiController {
}
try {
const result = this.aiService.streamText(messages, {
temperature,
maxTokens,
// TODO: Add support for custom models
const model = this.aiService.getModel(undefined);
const result = this.aiService.streamText({
messages,
options: {
temperature,
maxTokens,
model,
},
});
result.usage.then((usage) => {
this.aiBillingService.calculateAndBillUsage(
model.modelId,
usage,
workspace.id,
);
});
result.pipeDataStreamToResponse(res);
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type CoreMessage, type StreamTextResult, streamText } from 'ai';
import { type CoreMessage, streamText, LanguageModelV1 } from 'ai';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
@@ -8,15 +8,7 @@ import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-m
export class AiService {
constructor(private aiModelRegistryService: AiModelRegistryService) {}
streamText(
messages: CoreMessage[],
options?: {
temperature?: number;
maxTokens?: number;
modelId?: string; // Optional model override
},
): StreamTextResult<Record<string, never>, undefined> {
const modelId = options?.modelId;
getModel(modelId: string | undefined) {
const registeredModel = modelId
? this.aiModelRegistryService.getModel(modelId)
: this.aiModelRegistryService.getDefaultModel();
@@ -29,19 +21,25 @@ export class AiService {
);
}
return registeredModel.model;
}
streamText({
messages,
options,
}: {
messages: CoreMessage[];
options: {
temperature?: number;
maxTokens?: number;
model: LanguageModelV1;
};
}) {
return streamText({
model: registeredModel.model,
model: options.model,
messages,
temperature: options?.temperature,
maxTokens: options?.maxTokens,
});
}
getAvailableModels() {
return this.aiModelRegistryService.getAvailableModels();
}
getDefaultModel() {
return this.aiModelRegistryService.getDefaultModel();
}
}
@@ -21,10 +21,9 @@ export const getDeletedStripeSubscriptionItemIdsFromStripeSubscriptionEvent = (
const subscriptionItemIds =
event.data.object.items.data.map((item) => item.id) ?? [];
const deletedSubscriptionItemIds =
return (
event.data.previous_attributes?.items?.data
.filter((item) => !subscriptionItemIds.includes(item.id))
.map((item) => item.id) ?? [];
return deletedSubscriptionItemIds;
.map((item) => item.id) ?? []
);
};
@@ -22,4 +22,6 @@ export enum BillingExceptionCode {
BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD = 'BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD',
BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE',
BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE',
BILLING_PRICE_INVALID_TIERS = 'BILLING_PRICE_INVALID_TIERS',
BILLING_PRICE_UPDATE_REQUIRES_INCREASE = 'BILLING_PRICE_UPDATE_REQUIRES_INCREASE',
}
@@ -4,6 +4,7 @@ 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';
@@ -39,6 +40,8 @@ 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()
@UsePipes(ResolverValidationPipe)
@@ -157,6 +160,23 @@ export class BillingResolver {
return { success: true };
}
@Mutation(() => BillingUpdateOutput)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
)
async updateSubscriptionItemPrice(
@AuthWorkspace() workspace: Workspace,
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
) {
await this.billingService.updateMeteredSubscriptionPrice(
workspace.id,
priceId,
);
return { success: true };
}
@Query(() => [BillingPlanOutput])
@UseGuards(WorkspaceAuthGuard)
async plans(): Promise<BillingPlanOutput[]> {
@@ -187,6 +207,34 @@ export class BillingResolver {
return await this.billingUsageService.getMeteredProductsUsage(workspace);
}
@Query(() => [BillingPriceOutput])
@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[],
);
}
private async validateCanCheckoutSessionPermissionOrThrow({
workspaceId,
userWorkspaceId,
@@ -0,0 +1,49 @@
import { isDefined } from 'twenty-shared/utils';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { type MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
const assertIsMeteredTiersSchemaOrThrow = (
tiers: BillingPrice['tiers'] | undefined | null,
): asserts tiers is MeterBillingPriceTiers => {
const error = new BillingException(
'Metered price must have exactly two tiers and only one must have a defined limitation (up_to)',
BillingExceptionCode.BILLING_PRICE_INVALID_TIERS,
);
if (!isMeteredTiersSchema(tiers)) {
throw error;
}
return;
};
const isMeteredTiersSchema = (
tiers: BillingPrice['tiers'] | undefined | null,
): tiers is MeterBillingPriceTiers => {
if (!isDefined(tiers)) {
return false;
}
if (
tiers.length !== 2 ||
typeof tiers[0].up_to !== 'number' ||
tiers[1].up_to !== null
) {
return false;
}
return true;
};
export const billingValidator: {
assertIsMeteredTiersSchemaOrThrow: typeof assertIsMeteredTiersSchemaOrThrow;
isMeteredTiersSchema: typeof isMeteredTiersSchema;
} = {
assertIsMeteredTiersSchemaOrThrow,
isMeteredTiersSchema,
};
@@ -0,0 +1,13 @@
/* @license Enterprise */
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class BillingUpdateSubscriptionItemPriceInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
priceId: string;
}
@@ -14,17 +14,11 @@ export class BillingMeteredProductUsageOutput {
periodEnd: Date;
@Field(() => Number)
usageQuantity: number;
usedCredits: number;
@Field(() => Number)
freeTierQuantity: number;
@Field(() => Number)
freeTrialQuantity: number;
grantedCredits: number;
@Field(() => Number)
unitPriceCents: number;
@Field(() => Number)
totalCostCents: number;
}
@@ -0,0 +1,20 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
@ObjectType()
export class BillingPriceOutput {
@Field(() => String)
nickname: string;
@Field(() => Number)
amount: number;
@Field(() => String)
stripePriceId: string;
@Field(() => SubscriptionInterval)
recurringInterval: SubscriptionInterval;
}
@@ -18,6 +18,9 @@ export class BillingSubscriptionItemDTO {
@Field(() => Number, { nullable: true })
quantity: number | null;
@Field(() => String, { nullable: true })
stripePriceId: string | null;
@Field(() => BillingProductDTO, { nullable: true })
billingProduct: BillingProductDTO;
}
@@ -102,6 +102,7 @@ export class BillingSubscription {
@Column({ nullable: false, default: 'USD' })
currency: string;
@Field(() => Date, { nullable: true })
@Column({
nullable: false,
type: 'timestamptz',
@@ -0,0 +1,443 @@
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,
});
});
});
@@ -27,6 +27,9 @@ 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';
@Injectable()
export class BillingPortalWorkspaceService {
@@ -64,7 +67,7 @@ export class BillingPortalWorkspaceService {
const checkoutSession =
await this.stripeCheckoutService.createCheckoutSession({
user,
workspaceId: workspace.id,
workspace,
stripeSubscriptionLineItems,
successUrl,
cancelUrl,
@@ -98,7 +101,7 @@ export class BillingPortalWorkspaceService {
const subscription =
await this.stripeCheckoutService.createDirectSubscription({
user,
workspaceId: workspace.id,
workspace,
stripeSubscriptionLineItems,
stripeCustomerId: customer?.stripeCustomerId,
plan,
@@ -253,6 +256,44 @@ export class BillingPortalWorkspaceService {
return session.url;
}
private getDefaultMeteredProductPrice(
billingPricesPerPlan: BillingGetPricesPerPlanResult,
): BillingPrice & {
tiers: MeterBillingPriceTiers;
} {
const defaultMeteredProductPrice =
billingPricesPerPlan.meteredProductsPrices.reduce(
(result, billingPrice) => {
if (!result) {
return billingPrice as BillingPrice & {
tiers: MeterBillingPriceTiers;
};
}
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 result;
},
null as (BillingPrice & { tiers: MeterBillingPriceTiers }) | null,
);
if (!isDefined(defaultMeteredProductPrice)) {
throw new BillingException(
'Missing Default Metered price',
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return defaultMeteredProductPrice;
}
private getStripeSubscriptionLineItems({
quantity,
billingPricesPerPlan,
@@ -261,14 +302,17 @@ export class BillingPortalWorkspaceService {
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
if (billingPricesPerPlan) {
const defaultMeteredProductPrice =
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
return [
{
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
quantity,
},
...billingPricesPerPlan.meteredProductsPrices.map((price) => ({
price: price.stripePriceId,
})),
{
price: defaultMeteredProductPrice.stripePriceId,
},
];
}
@@ -3,27 +3,35 @@ import { InjectRepository } from '@nestjs/typeorm';
import { JsonContains, Repository } from 'typeorm';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
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()
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 getMeteredSubscriptionItemDetails(subscriptionId: string) {
const meteredSubscriptionItems =
await this.billingSubscriptionItemRepository.find({
async updateMeteredSubscriptionItemPrice(
subscriptionId: string,
newPriceId: string,
) {
const subscriptionItem =
await this.billingSubscriptionItemRepository.findOne({
where: {
billingSubscriptionId: subscriptionId,
billingProduct: {
@@ -35,27 +43,104 @@ export class BillingSubscriptionItemService {
relations: ['billingProduct', 'billingProduct.billingPrices'],
});
return meteredSubscriptionItems.map((item) => {
const price = this.findMatchingPrice(item);
if (!subscriptionItem) {
throw new BillingException(
`Cannot find subscription item for subscription ${subscriptionId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
const stripeMeterId = price.stripeMeterId;
const currentBillingPrice = subscriptionItem
? this.findMatchingPrice(subscriptionItem)
: null;
if (!stripeMeterId) {
throw new BillingException(
`Stripe meter ID not found for product ${item.billingProduct.metadata.productKey}`,
BillingExceptionCode.BILLING_METER_NOT_FOUND,
);
}
if (!currentBillingPrice) {
throw new BillingException(
`Cannot find price for product ${subscriptionItem.stripeProductId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return {
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: item.billingProduct.metadata.productKey,
stripeMeterId,
freeTierQuantity: this.getFreeTierQuantity(price),
freeTrialQuantity: this.getFreeTrialQuantity(item),
unitPriceCents: this.getUnitPrice(price),
};
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({
where: {
billingSubscriptionId: subscriptionId,
},
relations: ['billingProduct', 'billingProduct.billingPrices'],
});
return meteredSubscriptionItems.reduce(
(acc, item) => {
const price = this.findMatchingPrice(item);
if (!price.stripeMeterId) {
return acc;
}
return acc.concat({
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: item.billingProduct.metadata.productKey,
stripeMeterId: price.stripeMeterId,
tierQuantity: this.getTierQuantity(price),
freeTrialQuantity: this.getFreeTrialQuantity(item),
unitPriceCents: this.getUnitPrice(price),
});
},
[] as Array<{
stripeSubscriptionItemId: string;
productKey: BillingProductKey;
stripeMeterId: string;
tierQuantity: number;
freeTrialQuantity: number;
unitPriceCents: number;
}>,
);
}
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 {
@@ -73,8 +158,10 @@ export class BillingSubscriptionItemService {
return matchingPrice;
}
private getFreeTierQuantity(price: BillingPrice): number {
return price.tiers?.find((tier) => tier.unit_amount === 0)?.up_to || 0;
private getTierQuantity(price: BillingPrice): number {
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return price.tiers[0].up_to;
}
private getFreeTrialQuantity(item: BillingSubscriptionItem): number {
@@ -92,9 +179,8 @@ export class BillingSubscriptionItemService {
}
private getUnitPrice(price: BillingPrice): number {
return Number(
price.tiers?.find((tier) => tier.up_to === null)?.unit_amount_decimal ||
0,
);
billingValidator.assertIsMeteredTiersSchemaOrThrow(price.tiers);
return Number(price.tiers[1].unit_amount_decimal);
}
}
@@ -0,0 +1,446 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
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 { 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 { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingExceptionCode } from 'src/engine/core-modules/billing/billing.exception';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service';
// Helpers to build test objects with only needed fields
const buildWorkspace = (id: string): Workspace =>
({ id }) as unknown as Workspace;
const buildBillingProduct = (
productKey: BillingProductKey,
usageType: BillingUsageType,
) => ({
metadata: {
productKey,
priceUsageBased: usageType,
},
});
const buildSubscriptionItem = (
productKey: BillingProductKey,
usageType: BillingUsageType,
stripeProductId: string,
stripePriceId: string,
overrides: Partial<BillingSubscriptionItem> = {},
): BillingSubscriptionItem =>
({
id: 'subItem-' + stripeProductId,
billingSubscriptionId: 'sub-1',
stripeSubscriptionId: 'stripe-sub-1',
metadata: {},
billingThresholds: null as any,
billingProduct: buildBillingProduct(productKey, usageType) as any,
stripeProductId,
stripePriceId,
stripeSubscriptionItemId: 'ssi-' + stripeProductId,
quantity: null,
hasReachedCurrentPeriodCap: false,
...overrides,
}) as unknown as BillingSubscriptionItem;
const buildSubscription = (
interval: SubscriptionInterval,
items: BillingSubscriptionItem[],
metadata: Record<string, any> = {},
): BillingSubscription =>
({
id: 'sub-1',
workspaceId: 'ws-1',
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'stripe-sub-1',
status: SubscriptionStatus.Active,
interval,
billingSubscriptionItems: items as any,
metadata: metadata as any,
}) as unknown as BillingSubscription;
const buildLicensedYearlyPrice = (
stripeProductId: string,
stripePriceId: string,
): BillingPrice =>
({
id: 'price-licensed',
active: true,
stripeProductId,
stripePriceId,
currency: 'USD',
taxBehavior: undefined as any,
type: undefined as any,
billingScheme: undefined as any,
currencyOptions: null,
tiers: null,
recurring: null,
transformQuantity: null,
tiersMode: null,
unitAmountDecimal: null,
unitAmount: 1000,
stripeMeterId: null,
usageType: BillingUsageType.LICENSED,
interval: SubscriptionInterval.Year,
metadata: { priceUsageBased: BillingUsageType.LICENSED },
billingProduct: buildBillingProduct(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
) as any,
billingMeter: null as any,
}) as unknown as BillingPrice;
const buildMeteredYearlyPrice = (
stripeProductId: string,
stripePriceId: string,
upTo: number,
): BillingPrice =>
({
id: 'price-metered-' + upTo,
active: true,
stripeProductId,
stripePriceId,
currency: 'USD',
taxBehavior: undefined as any,
type: undefined as any,
billingScheme: undefined as any,
currencyOptions: null,
tiers: [
{ up_to: upTo, unit_amount: 1 } as any,
{ up_to: null, unit_amount: 1 } as any,
] as any,
recurring: null,
transformQuantity: null,
tiersMode: null,
unitAmountDecimal: null,
unitAmount: null,
stripeMeterId: null,
usageType: BillingUsageType.METERED,
interval: SubscriptionInterval.Year,
metadata: { priceUsageBased: BillingUsageType.METERED },
billingProduct: buildBillingProduct(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
) as any,
billingMeter: null as any,
}) as unknown as BillingPrice;
describe('BillingSubscriptionService - switching methods', () => {
let service: BillingSubscriptionService;
let billingSubscriptionRepository: Partial<Repository<BillingSubscription>>;
let billingPriceRepository: Partial<Repository<BillingPrice>>;
let stripeSubscriptionService: StripeSubscriptionService;
let billingProductService: BillingProductService;
beforeEach(async () => {
jest.useFakeTimers();
// reset mocks
jest.resetAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingSubscriptionService,
{
provide: StripeSubscriptionService,
useValue: {
updateSubscriptionItems: jest.fn(),
updateSubscription: jest.fn(),
cancelSubscription: jest.fn(),
collectLastInvoice: jest.fn(),
setYearlyThresholds: jest.fn(),
},
},
{
provide: BillingPlanService,
useValue: { getPlanBaseProduct: jest.fn() },
},
{
provide: BillingProductService,
useValue: { getProductPrices: jest.fn() },
},
{
provide: StripeCustomerService,
useValue: { hasPaymentMethod: jest.fn() },
},
{
provide: TwentyConfigService,
useValue: { get: jest.fn() },
},
{
provide: StripeSubscriptionItemService,
useValue: { updateSubscriptionItem: jest.fn() },
},
{
provide: getRepositoryToken(BillingEntitlement),
useValue: {},
},
{
provide: getRepositoryToken(BillingSubscription),
useValue: {
find: jest.fn(),
delete: jest.fn(),
findOneOrFail: jest.fn(),
},
},
{
provide: getRepositoryToken(BillingPrice),
useValue: { findOneByOrFail: jest.fn() },
},
{
provide: getRepositoryToken(BillingSubscriptionItem),
useValue: { update: jest.fn() },
},
],
}).compile();
service = module.get(BillingSubscriptionService);
// Retrieve inline mocks from the module for use in tests
stripeSubscriptionService = module.get(StripeSubscriptionService);
billingProductService = module.get(BillingProductService);
billingSubscriptionRepository = module.get(
getRepositoryToken(BillingSubscription),
);
billingPriceRepository = module.get(getRepositoryToken(BillingPrice));
});
describe('switchToYearlyInterval', () => {
it('throws when already on yearly interval', async () => {
const workspace = buildWorkspace('ws-1');
const licensedItem = buildSubscriptionItem(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
'prod_seats',
'price_month_licensed',
);
const meteredItem = buildSubscriptionItem(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
'prod_workflow',
'price_month_metered',
);
const sub = buildSubscription(
SubscriptionInterval.Year,
[licensedItem, meteredItem],
{ plan: BillingPlanKey.PRO },
);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
sub,
]);
await expect(
service.switchToYearlyInterval(workspace),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE,
});
});
it('updates subscription items with yearly prices when switching from monthly', async () => {
const workspace = buildWorkspace('ws-1');
const licensedItem = buildSubscriptionItem(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
'prod_seats',
'price_month_licensed',
);
const meteredItem = buildSubscriptionItem(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
'prod_workflow',
'price_month_metered',
);
const sub = buildSubscription(
SubscriptionInterval.Month,
[licensedItem, meteredItem],
{ plan: BillingPlanKey.PRO },
);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
sub,
]);
// Return tiers for the current metered monthly price (e.g., up_to = 100 per month)
(billingPriceRepository.findOneByOrFail as jest.Mock).mockResolvedValue({
id: meteredItem.stripePriceId,
tiers: [
{ up_to: 100 }, // monthly cap
{ up_to: null },
],
});
// Candidate yearly prices: metered with various caps and one licensed yearly
const yearlyLicensed = buildLicensedYearlyPrice(
'prod_seats',
'price_year_licensed',
);
const yearlyMeteredBelow = buildMeteredYearlyPrice(
'prod_workflow',
'price_year_metered_1000',
1000,
); // 100*12=1200; pick below 1200
const yearlyMeteredTooHigh = buildMeteredYearlyPrice(
'prod_workflow',
'price_year_metered_2000',
2000,
);
const yearlyMeteredLower = buildMeteredYearlyPrice(
'prod_workflow',
'price_year_metered_600',
600,
);
(billingProductService.getProductPrices as jest.Mock).mockResolvedValue([
yearlyLicensed,
yearlyMeteredTooHigh, // should be ignored (>= current yearly cap)
yearlyMeteredBelow,
yearlyMeteredLower, // lower but should pick highest below cap => 1000
]);
await service.switchToYearlyInterval(workspace);
expect(
stripeSubscriptionService.updateSubscriptionItems,
).toHaveBeenCalledTimes(1);
const [calledSubId, items] = (
stripeSubscriptionService.updateSubscriptionItems as jest.Mock
).mock.calls[0];
expect(calledSubId).toBe(sub.stripeSubscriptionId);
// Ensure both items got mapped to their yearly counterparts
const licensedUpdated = (items as BillingSubscriptionItem[]).find(
(i) => i.stripeProductId === 'prod_seats',
);
const meteredUpdated = (items as BillingSubscriptionItem[]).find(
(i) => i.stripeProductId === 'prod_workflow',
);
expect(licensedUpdated?.stripePriceId).toBe('price_year_licensed');
expect(meteredUpdated?.stripePriceId).toBe('price_year_metered_1000'); // highest below 1200
});
});
describe('switchToEnterprisePlan', () => {
it('throws when already on ENTERPRISE plan', async () => {
const workspace = buildWorkspace('ws-1');
const licensedItem = buildSubscriptionItem(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
'prod_seats',
'price_month_licensed',
);
const meteredItem = buildSubscriptionItem(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
'prod_workflow',
'price_month_metered',
);
const sub = buildSubscription(
SubscriptionInterval.Month,
[licensedItem, meteredItem],
{ plan: BillingPlanKey.ENTERPRISE },
);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
sub,
]);
await expect(
service.switchToEnterprisePlan(workspace),
).rejects.toMatchObject({
code: BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE,
});
});
it('updates items and subscription metadata when switching to ENTERPRISE', async () => {
const workspace = buildWorkspace('ws-1');
const licensedItem = buildSubscriptionItem(
BillingProductKey.BASE_PRODUCT,
BillingUsageType.LICENSED,
'prod_seats',
'price_month_licensed',
);
const meteredItem = buildSubscriptionItem(
BillingProductKey.WORKFLOW_NODE_EXECUTION,
BillingUsageType.METERED,
'prod_workflow',
'price_month_metered',
);
const sub = buildSubscription(
SubscriptionInterval.Month,
[licensedItem, meteredItem],
{ plan: BillingPlanKey.PRO },
);
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
sub,
]);
(billingPriceRepository.findOneByOrFail as jest.Mock).mockResolvedValue({
id: meteredItem.stripePriceId,
tiers: [{ up_to: 50 }, { up_to: null }],
});
const yearlyLicensed = buildLicensedYearlyPrice(
'prod_seats',
'price_year_licensed_ent',
);
const yearlyMetered = buildMeteredYearlyPrice(
'prod_workflow',
'price_year_metered_300',
300,
); // 50*12=600; pick below 600
(billingProductService.getProductPrices as jest.Mock).mockResolvedValue([
yearlyLicensed,
yearlyMetered,
]);
await service.switchToEnterprisePlan(workspace);
expect(
stripeSubscriptionService.updateSubscriptionItems,
).toHaveBeenCalledTimes(1);
const [calledSubId, items] = (
stripeSubscriptionService.updateSubscriptionItems as jest.Mock
).mock.calls[0];
expect(calledSubId).toBe(sub.stripeSubscriptionId);
const licensedUpdated = (items as BillingSubscriptionItem[]).find(
(i) => i.stripeProductId === 'prod_seats',
);
const meteredUpdated = (items as BillingSubscriptionItem[]).find(
(i) => i.stripeProductId === 'prod_workflow',
);
expect(licensedUpdated?.stripePriceId).toBe('price_year_licensed_ent');
expect(meteredUpdated?.stripePriceId).toBe('price_year_metered_300');
expect(stripeSubscriptionService.updateSubscription).toHaveBeenCalledWith(
sub.stripeSubscriptionId,
{ metadata: { ...sub.metadata, plan: BillingPlanKey.ENTERPRISE } },
);
});
});
});
@@ -17,7 +17,7 @@ import {
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.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 { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
@@ -33,6 +33,10 @@ import { StripeSubscriptionService } from 'src/engine/core-modules/billing/strip
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 { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { findOrThrow } from 'src/utils/find-or-throw.util';
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
import type { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
@Injectable()
export class BillingSubscriptionService {
@@ -47,6 +51,8 @@ export class BillingSubscriptionService {
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
private readonly stripeCustomerService: StripeCustomerService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(BillingPrice)
private readonly billingPriceRepository: Repository<BillingPrice>,
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
@InjectRepository(BillingSubscriptionItem)
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@@ -73,6 +79,27 @@ export class BillingSubscriptionService {
return notCanceledSubscriptions?.[0];
}
async getCurrentActiveBillingSubscriptionOrThrow(criteria: {
workspaceId?: string;
stripeCustomerId?: string;
}) {
const subscription =
await this.getCurrentBillingSubscriptionOrThrow(criteria);
if (
![SubscriptionStatus.Active, SubscriptionStatus.Trialing].includes(
subscription.status,
)
) {
throw new BillingException(
'No active billing subscription found',
BillingExceptionCode.BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND,
);
}
return subscription;
}
async getBaseProductCurrentBillingSubscriptionItemOrThrow(
workspaceId: string,
) {
@@ -179,11 +206,15 @@ export class BillingSubscriptionService {
planKey,
});
const subscriptionItemsToUpdate = this.getSubscriptionItemsToUpdate(
const subscriptionItemsToUpdate = await this.getSubscriptionItemsToUpdate(
billingSubscription,
pricesPerPlanArray,
);
await this.stripeSubscriptionService.setYearlyThresholds(
billingSubscription.stripeSubscriptionId,
);
await this.stripeSubscriptionService.updateSubscriptionItems(
billingSubscription.stripeSubscriptionId,
subscriptionItemsToUpdate,
@@ -212,7 +243,7 @@ export class BillingSubscriptionService {
planKey,
});
const subscriptionItemsToUpdate = this.getSubscriptionItemsToUpdate(
const subscriptionItemsToUpdate = await this.getSubscriptionItemsToUpdate(
billingSubscription,
pricesPerPlanArray,
);
@@ -228,34 +259,102 @@ export class BillingSubscriptionService {
);
}
private getSubscriptionItemsToUpdate(
private async getSubscriptionItemsToUpdate(
billingSubscription: BillingSubscription,
billingPricesPerPlanAndIntervalArray: BillingPrice[],
): BillingSubscriptionItem[] {
): Promise<BillingSubscriptionItem[]> {
const currentLicensedBillingSubscriptionItem = findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.priceUsageBased === BillingUsageType.LICENSED,
);
const yearlyLicensedMatchingPrice = findOrThrow(
billingPricesPerPlanAndIntervalArray,
(price) =>
price.billingProduct.metadata.priceUsageBased ===
currentLicensedBillingSubscriptionItem.billingProduct.metadata
.priceUsageBased,
);
const currentMeteredBillingSubscriptionItem = findOrThrow(
billingSubscription.billingSubscriptionItems,
({ billingProduct }) =>
billingProduct.metadata.priceUsageBased === BillingUsageType.METERED,
);
const { tiers: currentMeteredBillingPriceTiers } =
await this.billingPriceRepository.findOneByOrFail({
stripePriceId: currentMeteredBillingSubscriptionItem.stripePriceId,
});
billingValidator.assertIsMeteredTiersSchemaOrThrow(
currentMeteredBillingPriceTiers,
);
const yearlyMeteredMatchingPrice = this.findYearlyMeteredMatchingPrice(
billingPricesPerPlanAndIntervalArray,
currentMeteredBillingPriceTiers,
currentMeteredBillingSubscriptionItem.stripeProductId,
);
return billingSubscription.billingSubscriptionItems.map(
(subscriptionItem) => {
const matchingPrice = billingPricesPerPlanAndIntervalArray.find(
(price) =>
price.billingProduct.metadata.priceUsageBased ===
subscriptionItem.billingProduct.metadata.priceUsageBased,
);
if (!matchingPrice) {
throw new BillingException(
`Cannot find matching price for product ${subscriptionItem.stripeProductId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
const isMetered =
subscriptionItem.billingProduct.metadata.priceUsageBased ===
BillingUsageType.METERED;
return {
...subscriptionItem,
stripePriceId: matchingPrice.stripePriceId,
stripeProductId: matchingPrice.stripeProductId,
stripePriceId: isMetered
? yearlyMeteredMatchingPrice.stripePriceId
: yearlyLicensedMatchingPrice.stripePriceId,
stripeProductId: isMetered
? yearlyMeteredMatchingPrice.stripeProductId
: yearlyLicensedMatchingPrice.stripeProductId,
};
},
);
}
private findYearlyMeteredMatchingPrice(
billingPricesPerPlanAndIntervalArray: BillingPrice[],
currentMeteredBillingPriceTiers: MeterBillingPriceTiers,
currentStripeProductId: string,
): BillingPrice & { tiers: MeterBillingPriceTiers } {
const meteredYearlyCandidates = billingPricesPerPlanAndIntervalArray.filter(
(price) =>
price.billingProduct.metadata.priceUsageBased ===
BillingUsageType.METERED &&
price.interval === SubscriptionInterval.Year,
);
const validCandidates = meteredYearlyCandidates.filter((price) =>
billingValidator.isMeteredTiersSchema(price.tiers),
) as Array<
BillingPrice & {
tiers: MeterBillingPriceTiers;
}
>;
const currentMonthlyCap = currentMeteredBillingPriceTiers[0].up_to;
const currentYearlyCap = currentMonthlyCap * 12;
const match = validCandidates
.filter((price) => price.tiers[0].up_to <= currentYearlyCap)
.sort((a, b) => a.tiers[0].up_to - b.tiers[0].up_to)
.pop();
if (!match) {
throw new BillingException(
`Cannot find matching price for product ${currentStripeProductId}`,
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
);
}
return match;
}
async endTrialPeriod(workspace: Workspace) {
const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
@@ -131,20 +131,16 @@ export class BillingUsageService {
periodEnd,
);
const totalCostCents =
meterEventsSum - item.freeTierQuantity > 0
? (meterEventsSum - item.freeTierQuantity) * item.unitPriceCents
: 0;
return {
productKey: item.productKey,
periodStart,
periodEnd,
usageQuantity: meterEventsSum,
freeTierQuantity: item.freeTierQuantity,
freeTrialQuantity: item.freeTrialQuantity,
usedCredits: meterEventsSum,
grantedCredits:
subscription.status === SubscriptionStatus.Trialing
? item.freeTrialQuantity
: item.tierQuantity,
unitPriceCents: item.unitPriceCents,
totalCostCents,
};
}),
);
@@ -8,12 +8,13 @@ import { Repository } from 'typeorm';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { type BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
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';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
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 {
@@ -22,6 +23,7 @@ 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>,
) {}
@@ -67,6 +69,40 @@ 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,
@@ -77,7 +113,6 @@ export class BillingService {
);
if (
!isDefined(subscription) ||
![SubscriptionStatus.Active, SubscriptionStatus.Trialing].includes(
subscription.status,
)
@@ -35,7 +35,7 @@ export class StripeBillingMeterEventService {
stripeCustomerId: string;
}) {
await this.stripe.billing.meterEvents.create({
event_name: eventName,
event_name: eventName.toLowerCase(),
payload: {
value: value.toString(),
stripe_customer_id: stripeCustomerId,
@@ -11,6 +11,7 @@ import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/se
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 { type User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class StripeCheckoutService {
@@ -32,7 +33,7 @@ export class StripeCheckoutService {
async createCheckoutSession({
user,
workspaceId,
workspace,
stripeSubscriptionLineItems,
successUrl,
cancelUrl,
@@ -42,7 +43,7 @@ export class StripeCheckoutService {
withTrialPeriod,
}: {
user: User;
workspaceId: string;
workspace: Pick<Workspace, 'id' | 'displayName'>;
stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
successUrl?: string;
cancelUrl?: string;
@@ -55,7 +56,8 @@ export class StripeCheckoutService {
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspaceId,
workspace.id,
workspace.displayName,
);
stripeCustomerId = stripeCustomer.id;
@@ -66,7 +68,7 @@ export class StripeCheckoutService {
mode: 'subscription',
subscription_data: {
metadata: {
workspaceId,
workspaceId: workspace.id,
plan,
},
...this.getStripeSubscriptionTrialPeriodConfig(
@@ -88,7 +90,7 @@ export class StripeCheckoutService {
async createDirectSubscription({
user,
workspaceId,
workspace,
stripeSubscriptionLineItems,
stripeCustomerId,
plan = BillingPlanKey.PRO,
@@ -96,7 +98,7 @@ export class StripeCheckoutService {
withTrialPeriod,
}: {
user: User;
workspaceId: string;
workspace: Pick<Workspace, 'id' | 'displayName'>;
stripeSubscriptionLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
stripeCustomerId?: string;
plan?: BillingPlanKey;
@@ -107,7 +109,8 @@ export class StripeCheckoutService {
const stripeCustomer =
await this.stripeCustomerService.createStripeCustomer(
user.email,
workspaceId,
workspace.id,
workspace.displayName,
);
stripeCustomerId = stripeCustomer.id;
@@ -124,7 +127,7 @@ export class StripeCheckoutService {
customer: stripeCustomerId,
items: subscriptionItems,
metadata: {
workspaceId,
workspaceId: workspace.id,
plan,
},
...this.getStripeSubscriptionTrialPeriodConfig(
@@ -46,8 +46,13 @@ export class StripeCustomerService {
return paymentMethods.length > 0;
}
async createStripeCustomer(userEmail: string, workspaceId: string) {
async createStripeCustomer(
userEmail: string,
workspaceId: string,
customerName: string | undefined,
) {
const customer = await this.stripe.customers.create({
name: customerName,
email: userEmail,
metadata: {
workspaceId,
@@ -83,4 +83,16 @@ export class StripeSubscriptionService {
): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.update(stripeSubscriptionId, updateData);
}
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,
},
});
}
}
@@ -0,0 +1,16 @@
export type MeterBillingPriceTiers = [
{
up_to: number;
flat_amount: number;
unit_amount: number;
flat_amount_decimal: string;
unit_amount_decimal: string;
},
{
up_to: null;
flat_amount: null;
unit_amount: null;
flat_amount_decimal: null;
unit_amount_decimal: string;
},
];
@@ -40,6 +40,7 @@ export const transformStripePriceToDatabasePrice = (data: Stripe.Price) => {
? getBillingPriceTiersMode(data.tiers_mode)
: undefined,
recurring: data.recurring === null ? undefined : data.recurring,
metadata: data.metadata,
};
};