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. Review in cubic --- .../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();