Create billing customer at signup so onboarding rewards are credited (#22633)

## Problem

Onboarding credit rewards (install apps, import contacts, invite team)
were silently dropped. They credit the workspace balance via
`billingCustomer.increment(...)`, but no `billingCustomer` row exists
until the plan step (it's created lazily when the first subscription is
set up, which is after those steps). So the increment affected 0 rows
and the credit was lost. A user installing 3 apps saw only the trial
grant, not the expected +1.5 credits.

## Fix

Create the Stripe customer + `billingCustomer` row eagerly at signup via
a new `BillingCreditService.ensureBillingCustomer`, called from
`signUpOnNewWorkspace` after the workspace transaction commits. It is
idempotent, guarded by `IS_BILLING_ENABLED`, and non-blocking (failures
are logged, not thrown). The later subscription flow reuses this
customer (no duplicate Stripe customer), and trial eligibility is
unchanged since the customer has no subscriptions yet.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22633?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-07-07 16:51:53 +02:00
committed by GitHub
parent 35d3f9b89d
commit 2c0e0b2eac
4 changed files with 135 additions and 0 deletions
@@ -108,6 +108,9 @@ const createSignInUpServiceForTests = () => {
{
creditWorkspaceBalance: jest.fn(),
} as any,
{
isBillingEnabled: jest.fn(),
} as any,
{
createQueryRunner: jest.fn(() => queryRunnerMock),
} as any,
@@ -25,6 +25,7 @@ import {
} from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import {
AuthException,
AuthExceptionCode,
@@ -93,6 +94,7 @@ export class SignInUpService {
private readonly enterprisePlanService: EnterprisePlanService,
private readonly eventLogEmitterService: EventLogEmitterService,
private readonly billingCreditService: BillingCreditService,
private readonly billingService: BillingService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -708,6 +710,14 @@ export class SignInUpService {
.createContext({ workspaceId })
.insertWorkspaceEvent(WORKSPACE_CREATED_EVENT, {});
if (this.billingService.isBillingEnabled()) {
await this.billingService.ensureBillingCustomer({
userEmail: email,
workspaceId: workspace.id,
workspaceDisplayName: workspace.displayName,
});
}
return { user, workspace };
} catch (error) {
const isSubdomainConflict =
@@ -0,0 +1,94 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
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 { BillingService } from 'src/engine/core-modules/billing/services/billing.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 { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
describe('BillingService', () => {
let service: BillingService;
let stripeCustomerService: jest.Mocked<
Pick<StripeCustomerService, 'createStripeCustomer'>
>;
let billingCustomerRepository: jest.Mocked<{ findOne: jest.Mock }>;
const ensureParams = {
userEmail: 'user@example.com',
workspaceId: 'ws_123',
workspaceDisplayName: 'Acme',
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingService,
{
provide: TwentyConfigService,
useValue: { get: jest.fn() },
},
{
provide: BillingSubscriptionService,
useValue: {},
},
{
provide: BillingProductService,
useValue: {},
},
{
provide: StripeCustomerService,
useValue: {
createStripeCustomer: jest
.fn()
.mockResolvedValue({ id: 'cus_123' }),
},
},
{
provide: getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity),
useValue: { findOne: jest.fn() },
},
{
provide: getWorkspaceScopedRepositoryToken(BillingCustomerEntity),
useValue: { findOne: jest.fn().mockResolvedValue(null) },
},
],
}).compile();
service = module.get<BillingService>(BillingService);
stripeCustomerService = module.get(StripeCustomerService);
billingCustomerRepository = module.get(
getWorkspaceScopedRepositoryToken(BillingCustomerEntity),
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('ensureBillingCustomer', () => {
it('creates a stripe customer when none exists', async () => {
billingCustomerRepository.findOne.mockResolvedValue(null);
await service.ensureBillingCustomer(ensureParams);
expect(stripeCustomerService.createStripeCustomer).toHaveBeenCalledWith(
'user@example.com',
'ws_123',
'Acme',
);
});
it('is idempotent when a billing customer already exists', async () => {
billingCustomerRepository.findOne.mockResolvedValue({ id: 'bc_1' });
await service.ensureBillingCustomer(ensureParams);
expect(stripeCustomerService.createStripeCustomer).not.toHaveBeenCalled();
});
});
});
@@ -4,10 +4,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
import { BillingSubscriptionEntity } 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 { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.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 { 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';
@@ -18,14 +20,40 @@ export class BillingService {
private readonly twentyConfigService: TwentyConfigService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingProductService: BillingProductService,
private readonly stripeCustomerService: StripeCustomerService,
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
) {}
isBillingEnabled() {
return this.twentyConfigService.get('IS_BILLING_ENABLED');
}
async ensureBillingCustomer({
userEmail,
workspaceId,
workspaceDisplayName,
}: {
userEmail: string;
workspaceId: string;
workspaceDisplayName: string | undefined;
}): Promise<void> {
const existingBillingCustomer =
await this.billingCustomerRepository.findOne(workspaceId, { where: {} });
if (isDefined(existingBillingCustomer)) {
return;
}
await this.stripeCustomerService.createStripeCustomer(
userEmail,
workspaceId,
workspaceDisplayName,
);
}
async hasWorkspaceAnySubscription(workspaceId: string) {
const isBillingEnabled = this.isBillingEnabled();