From 2c0e0b2eac196f0a746864fffb29dfc555107d65 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Tue, 7 Jul 2026 16:51:53 +0200
Subject: [PATCH] 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.
---
.../auth/services/sign-in-up.service.spec.ts | 3 +
.../auth/services/sign-in-up.service.ts | 10 ++
.../services/__test__/billing.service.spec.ts | 94 +++++++++++++++++++
.../billing/services/billing.service.ts | 28 ++++++
4 files changed, 135 insertions(+)
create mode 100644 packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing.service.spec.ts
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts
index 658e67d1bc..6f6019632f 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts
@@ -108,6 +108,9 @@ const createSignInUpServiceForTests = () => {
{
creditWorkspaceBalance: jest.fn(),
} as any,
+ {
+ isBillingEnabled: jest.fn(),
+ } as any,
{
createQueryRunner: jest.fn(() => queryRunnerMock),
} as any,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts
index ec4932d725..85028e7673 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts
@@ -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 =
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing.service.spec.ts
new file mode 100644
index 0000000000..7265bc8894
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing.service.spec.ts
@@ -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
+ >;
+ 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);
+ 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();
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts
index f509f99e9e..3df408794f 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts
@@ -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,
+ @InjectWorkspaceScopedRepository(BillingCustomerEntity)
+ private readonly billingCustomerRepository: WorkspaceScopedRepository,
) {}
isBillingEnabled() {
return this.twentyConfigService.get('IS_BILLING_ENABLED');
}
+ async ensureBillingCustomer({
+ userEmail,
+ workspaceId,
+ workspaceDisplayName,
+ }: {
+ userEmail: string;
+ workspaceId: string;
+ workspaceDisplayName: string | undefined;
+ }): Promise {
+ 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();