diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts index c170c35be4..ff8f3ace02 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts @@ -33,6 +33,7 @@ export enum BillingExceptionCode { BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND = 'BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND', BILLING_CREDITS_EXHAUSTED = 'BILLING_CREDITS_EXHAUSTED', BILLING_SUBSCRIPTION_NOT_CANCELED = 'BILLING_SUBSCRIPTION_NOT_CANCELED', + BILLING_CREDIT_AMOUNT_INVALID = 'BILLING_CREDIT_AMOUNT_INVALID', } const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => { @@ -89,6 +90,8 @@ const getBillingExceptionUserFriendlyMessage = (code: BillingExceptionCode) => { return msg`You have exhausted your credits. Please upgrade your plan to continue.`; case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_CANCELED: return msg`Workspace cannot be deleted: subscription is not yet canceled.`; + case BillingExceptionCode.BILLING_CREDIT_AMOUNT_INVALID: + return msg`Invalid credit amount.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index b20aa44522..427bb6b4f3 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -21,6 +21,7 @@ import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entit import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter'; import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener'; import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service'; +import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service'; import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; import { BillingPriceService } from 'src/engine/core-modules/billing/services/billing-price.service'; @@ -94,6 +95,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache BillingUsageCapService, BillingPriceService, BillingCreditRolloverService, + BillingCreditService, ResourceCreditService, BillingGaugeService, WorkspaceCurrentBillingSubscriptionCacheService, @@ -112,6 +114,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache BillingUsageCacheService, BillingUsageCapService, BillingCreditRolloverService, + BillingCreditService, ResourceCreditService, ], }) diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-credit.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-credit.service.spec.ts new file mode 100644 index 0000000000..7178454c9c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-credit.service.spec.ts @@ -0,0 +1,123 @@ +/* @license Enterprise */ + +import { Test, type TestingModule } from '@nestjs/testing'; + +import { BillingException } from 'src/engine/core-modules/billing/billing.exception'; +import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; +import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service'; +import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service'; +import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; + +describe('BillingCreditService', () => { + let service: BillingCreditService; + let billingService: jest.Mocked>; + let billingUsageCacheService: jest.Mocked< + Pick + >; + let billingCustomerRepository: jest.Mocked<{ increment: jest.Mock }>; + + const workspaceId = 'ws_123'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BillingCreditService, + { + provide: BillingService, + useValue: { + isBillingEnabled: jest.fn().mockReturnValue(true), + }, + }, + { + provide: BillingUsageCacheService, + useValue: { + flushAvailableCredits: jest.fn().mockResolvedValue(undefined), + }, + }, + { + provide: getWorkspaceScopedRepositoryToken(BillingCustomerEntity), + useValue: { + increment: jest.fn().mockResolvedValue({ affected: 1 }), + }, + }, + ], + }).compile(); + + service = module.get(BillingCreditService); + billingService = module.get(BillingService); + billingUsageCacheService = module.get(BillingUsageCacheService); + billingCustomerRepository = module.get( + getWorkspaceScopedRepositoryToken(BillingCustomerEntity), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('creditWorkspaceBalance', () => { + it('increments the credit balance and flushes the usage cache', async () => { + await service.creditWorkspaceBalance({ + workspaceId, + amountMicro: 2_000_000, + }); + + expect(billingCustomerRepository.increment).toHaveBeenCalledWith( + workspaceId, + {}, + 'creditBalanceMicro', + 2_000_000, + ); + expect( + billingUsageCacheService.flushAvailableCredits, + ).toHaveBeenCalledWith(workspaceId); + }); + + it('no-ops when billing is disabled', async () => { + billingService.isBillingEnabled.mockReturnValue(false); + + await service.creditWorkspaceBalance({ + workspaceId, + amountMicro: 2_000_000, + }); + + expect(billingCustomerRepository.increment).not.toHaveBeenCalled(); + expect( + billingUsageCacheService.flushAvailableCredits, + ).not.toHaveBeenCalled(); + }); + + it('does not flush the cache when no billing customer exists', async () => { + billingCustomerRepository.increment.mockResolvedValue({ affected: 0 }); + + await service.creditWorkspaceBalance({ + workspaceId, + amountMicro: 2_000_000, + }); + + expect(billingCustomerRepository.increment).toHaveBeenCalled(); + expect( + billingUsageCacheService.flushAvailableCredits, + ).not.toHaveBeenCalled(); + }); + + it.each([ + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ])( + 'throws on a non-positive, non-integer or unsafe-integer amount (%p)', + async (amountMicro) => { + await expect( + service.creditWorkspaceBalance({ workspaceId, amountMicro }), + ).rejects.toThrow(BillingException); + + expect(billingCustomerRepository.increment).not.toHaveBeenCalled(); + }, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-credit.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-credit.service.ts new file mode 100644 index 0000000000..c7818f70b0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-credit.service.ts @@ -0,0 +1,63 @@ +/* @license Enterprise */ + +import { Injectable, Logger } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; +import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; +import { BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service'; +import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; + +@Injectable() +export class BillingCreditService { + private readonly logger = new Logger(BillingCreditService.name); + + constructor( + private readonly billingService: BillingService, + private readonly billingUsageCacheService: BillingUsageCacheService, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, + ) {} + + async creditWorkspaceBalance({ + workspaceId, + amountMicro, + }: { + workspaceId: string; + amountMicro: number; + }): Promise { + if (!this.billingService.isBillingEnabled()) { + return; + } + + if (!Number.isSafeInteger(amountMicro) || amountMicro <= 0) { + throw new BillingException( + `Cannot credit an amount (${amountMicro}) that is not a positive safe integer to workspace ${workspaceId}`, + BillingExceptionCode.BILLING_CREDIT_AMOUNT_INVALID, + ); + } + + const { affected } = await this.billingCustomerRepository.increment( + workspaceId, + {}, + 'creditBalanceMicro', + amountMicro, + ); + + if (!isDefined(affected) || affected === 0) { + this.logger.warn( + `Skipped crediting ${amountMicro} credits: no billing customer for workspace ${workspaceId}`, + ); + + return; + } + + await this.billingUsageCacheService.flushAvailableCredits(workspaceId); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts index 718dcba78f..b0afe13dbe 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/get-billing-exception-status-code.util.ts @@ -24,6 +24,7 @@ export const getBillingExceptionStatusCode = ( case BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE: case BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE: case BillingExceptionCode.BILLING_MISSING_REQUEST_BODY: + case BillingExceptionCode.BILLING_CREDIT_AMOUNT_INVALID: return 400; case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED: return 402;