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:
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
+123
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user