From 82516d65e4a0daf0ad12852eeee8ca4e912ab8ff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Tue, 30 Jun 2026 14:48:15 +0200
Subject: [PATCH] Credit the import-contacts onboarding reward on account
connection (#22354)
Onboarding V2 shows a credit reward for connecting an email account, but
the reward was only ever a frontend localStorage counter, never granted
server-side. This applies it for real.
When the connect-account step is actually completed via a Google or
Microsoft connection, the workspace is credited
`ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD`. Eligibility is derived
server-side from the `ONBOARDING_CONNECT_ACCOUNT_PENDING` flag (set once
at workspace creation), so the reward is one-time and is not granted
when the step is skipped. Crediting is best-effort: it never blocks the
OAuth flow and no-ops when billing is disabled.
The invite-team reward is handled separately in #22309. The upgrade
reward needs no grant: it is applied structurally through the trial
resource-usage cap, so an explicit grant would double-count it.
---
.../google-apis-auth.controller.ts | 3 +-
.../microsoft-apis-auth.controller.ts | 3 +-
.../key-value-pair/key-value-pair.service.ts | 14 +-
.../onboarding/onboarding.service.spec.ts | 139 ++++++++++++++++++
.../onboarding/onboarding.service.ts | 59 +++++++-
5 files changed, 206 insertions(+), 12 deletions(-)
create mode 100644 packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts
diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts
index 853b5ab960..befcfa78aa 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/google-apis-auth.controller.ts
@@ -112,10 +112,9 @@ export class GoogleAPIsAuthController {
});
if (userId) {
- await this.onboardingService.setOnboardingConnectAccountPending({
+ await this.onboardingService.completeOnboardingConnectAccountStep({
userId,
workspaceId,
- value: false,
});
}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts
index 827666d0dc..fdd9842258 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller.ts
@@ -119,10 +119,9 @@ export class MicrosoftAPIsAuthController {
});
if (userId) {
- await this.onboardingService.setOnboardingConnectAccountPending({
+ await this.onboardingService.completeOnboardingConnectAccountStep({
userId,
workspaceId,
- value: false,
});
}
diff --git a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts
index 04092ab028..e3ab5adb1c 100644
--- a/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/key-value-pair/key-value-pair.service.ts
@@ -132,12 +132,12 @@ export class KeyValuePairService<
key,
};
- if (queryRunner) {
- await queryRunner.manager
- .getRepository(KeyValuePairEntity)
- .delete(deleteConditions);
- } else {
- await this.keyValuePairRepository.delete(deleteConditions);
- }
+ const { affected } = queryRunner
+ ? await queryRunner.manager
+ .getRepository(KeyValuePairEntity)
+ .delete(deleteConditions)
+ : await this.keyValuePairRepository.delete(deleteConditions);
+
+ return affected;
}
}
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts
new file mode 100644
index 0000000000..9f61719a7c
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.spec.ts
@@ -0,0 +1,139 @@
+import { Test, type TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+
+import { Repository } from 'typeorm';
+
+import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service';
+import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
+import {
+ OnboardingService,
+ OnboardingStepKeys,
+} from 'src/engine/core-modules/onboarding/onboarding.service';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
+import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+
+describe('OnboardingService', () => {
+ let service: OnboardingService;
+ let userVarsService: UserVarsService;
+ let billingCreditService: BillingCreditService;
+ let twentyConfigService: TwentyConfigService;
+
+ const userId = 'user-id';
+ const workspaceId = 'workspace-id';
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ OnboardingService,
+ {
+ provide: BillingService,
+ useValue: {
+ isSubscriptionIncompleteOnboardingStatus: jest.fn(),
+ },
+ },
+ {
+ provide: BillingCreditService,
+ useValue: {
+ creditWorkspaceBalance: jest.fn(),
+ },
+ },
+ {
+ provide: UserVarsService,
+ useValue: {
+ get: jest.fn(),
+ set: jest.fn(),
+ delete: jest.fn(),
+ },
+ },
+ {
+ provide: TwentyConfigService,
+ useValue: {
+ get: jest.fn(),
+ },
+ },
+ {
+ provide: getRepositoryToken(WorkspaceEntity),
+ useClass: Repository,
+ },
+ ],
+ }).compile();
+
+ service = module.get(OnboardingService);
+ userVarsService = module.get(UserVarsService);
+ billingCreditService =
+ module.get(BillingCreditService);
+ twentyConfigService = module.get(TwentyConfigService);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('completeOnboardingConnectAccountStep', () => {
+ it('should credit the import-contacts reward when the step was claimed', async () => {
+ jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
+ jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000);
+
+ await service.completeOnboardingConnectAccountStep({
+ userId,
+ workspaceId,
+ });
+
+ expect(userVarsService.delete).toHaveBeenCalledWith({
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING,
+ });
+ expect(billingCreditService.creditWorkspaceBalance).toHaveBeenCalledWith({
+ workspaceId,
+ amountMicro: 2_000_000,
+ });
+ });
+
+ it('should not credit anything when the step was already consumed', async () => {
+ jest.spyOn(userVarsService, 'delete').mockResolvedValue(0);
+
+ await service.completeOnboardingConnectAccountStep({
+ userId,
+ workspaceId,
+ });
+
+ expect(
+ billingCreditService.creditWorkspaceBalance,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('should credit only once when two completions race for the same step', async () => {
+ jest
+ .spyOn(userVarsService, 'delete')
+ .mockResolvedValueOnce(1)
+ .mockResolvedValueOnce(0);
+ jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000);
+
+ await Promise.all([
+ service.completeOnboardingConnectAccountStep({ userId, workspaceId }),
+ service.completeOnboardingConnectAccountStep({ userId, workspaceId }),
+ ]);
+
+ expect(billingCreditService.creditWorkspaceBalance).toHaveBeenCalledTimes(
+ 1,
+ );
+ });
+
+ it('should not throw when crediting fails', async () => {
+ jest.spyOn(userVarsService, 'delete').mockResolvedValue(1);
+ jest.spyOn(twentyConfigService, 'get').mockReturnValue(2_000_000);
+ jest
+ .spyOn(billingCreditService, 'creditWorkspaceBalance')
+ .mockRejectedValue(new Error('billing failure'));
+
+ await expect(
+ service.completeOnboardingConnectAccountStep({
+ userId,
+ workspaceId,
+ }),
+ ).resolves.not.toThrow();
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts
index c751e46aa7..da6ac48c1e 100644
--- a/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/onboarding/onboarding.service.ts
@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type QueryRunner, Repository } from 'typeorm';
+import { BillingCreditService } from 'src/engine/core-modules/billing/services/billing-credit.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -29,8 +30,11 @@ export type OnboardingKeyValueTypeMap = {
@Injectable()
export class OnboardingService {
+ private readonly logger = new Logger(OnboardingService.name);
+
constructor(
private readonly billingService: BillingService,
+ private readonly billingCreditService: BillingCreditService,
private readonly userVarsService: UserVarsService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(WorkspaceEntity)
@@ -167,6 +171,59 @@ export class OnboardingService {
);
}
+ async completeOnboardingConnectAccountStep({
+ userId,
+ workspaceId,
+ }: {
+ userId: string;
+ workspaceId: string;
+ }) {
+ const hasClaimedConnectAccountStep =
+ await this.claimOnboardingConnectAccountStep({ userId, workspaceId });
+
+ if (!hasClaimedConnectAccountStep) {
+ return;
+ }
+
+ await this.creditImportContactsReward({ workspaceId });
+ }
+
+ private async claimOnboardingConnectAccountStep({
+ userId,
+ workspaceId,
+ }: {
+ userId: string;
+ workspaceId: string;
+ }): Promise {
+ const affectedRows = await this.userVarsService.delete({
+ userId,
+ workspaceId,
+ key: OnboardingStepKeys.ONBOARDING_CONNECT_ACCOUNT_PENDING,
+ });
+
+ return isDefined(affectedRows) && affectedRows > 0;
+ }
+
+ private async creditImportContactsReward({
+ workspaceId,
+ }: {
+ workspaceId: string;
+ }) {
+ try {
+ await this.billingCreditService.creditWorkspaceBalance({
+ workspaceId,
+ amountMicro: this.twentyConfigService.get(
+ 'ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD',
+ ),
+ });
+ } catch (error) {
+ this.logger.error(
+ `Failed to credit onboarding import-contacts reward for workspace ${workspaceId}`,
+ error,
+ );
+ }
+ }
+
async setOnboardingInviteTeamPending(
{
workspaceId,