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:
+45
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -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,
|
||||
};
|
||||
+13
@@ -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;
|
||||
}
|
||||
+2
-8
@@ -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;
|
||||
}
|
||||
|
||||
+20
@@ -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;
|
||||
}
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -102,6 +102,7 @@ export class BillingSubscription {
|
||||
@Column({ nullable: false, default: 'USD' })
|
||||
currency: string;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: 'timestamptz',
|
||||
|
||||
+443
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+49
-5
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+113
-27
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+446
@@ -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 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+118
-19
@@ -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 },
|
||||
|
||||
+5
-9
@@ -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,
|
||||
)
|
||||
|
||||
+1
-1
@@ -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
-8
@@ -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(
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+12
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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;
|
||||
},
|
||||
];
|
||||
+1
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user