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).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22094?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-06-24 17:28:24 +02:00
committed by GitHub
parent c71663946e
commit 6b460da622
5 changed files with 193 additions and 0 deletions
@@ -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);
}
@@ -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,
],
})
@@ -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<Pick<BillingService, 'isBillingEnabled'>>;
let billingUsageCacheService: jest.Mocked<
Pick<BillingUsageCacheService, 'flushAvailableCredits'>
>;
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>(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();
},
);
});
});
@@ -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<BillingCustomerEntity>,
) {}
async creditWorkspaceBalance({
workspaceId,
amountMicro,
}: {
workspaceId: string;
amountMicro: number;
}): Promise<void> {
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);
}
}
@@ -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;