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:
+3
@@ -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 =
|
||||
|
||||
+94
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user