From 6b460da6221ec099f620d3ce3bc888e9e6499c57 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Wed, 24 Jun 2026 17:28:24 +0200
Subject: [PATCH] Add backend primitive to credit a workspace's billing balance
(#22094)
Adds `BillingCreditService.creditWorkspaceBalance({ workspaceId,
amountMicro })`, an internal, server-side primitive to grant spendable
resource credits to a workspace. This is the backend foundation for
awarding free credits during the new onboarding steps; there was no
existing way to add credits to a workspace.
What it does:
- Increments `billingCustomer.creditBalanceMicro` atomically
(workspace-scoped), then flushes the Redis available-credits cache so
the credit is immediately spendable, not just shown in the gauge.
- No-ops when billing is disabled or no billing customer exists; rejects
non-positive/non-finite amounts.
- Pure primitive with no GraphQL/REST surface; the caller owns
idempotency.
Notes for reviewers:
- Credits use the existing `RESOURCE_CREDIT` currency (micro units, 1
display credit = 1,000,000 micro).
- The credited balance is overwritten by the rollover job at the next
billing-period renewal, so it is not guaranteed to persist across
periods (intentional for onboarding bonuses).
---
.../core-modules/billing/billing.exception.ts | 3 +
.../core-modules/billing/billing.module.ts | 3 +
.../__test__/billing-credit.service.spec.ts | 123 ++++++++++++++++++
.../services/billing-credit.service.ts | 63 +++++++++
.../get-billing-exception-status-code.util.ts | 1 +
5 files changed, 193 insertions(+)
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-credit.service.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/services/billing-credit.service.ts
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;