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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22354?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-06-30 14:48:15 +02:00
committed by GitHub
parent 96e5d0f3ed
commit 82516d65e4
5 changed files with 206 additions and 12 deletions
@@ -112,10 +112,9 @@ export class GoogleAPIsAuthController {
});
if (userId) {
await this.onboardingService.setOnboardingConnectAccountPending({
await this.onboardingService.completeOnboardingConnectAccountStep({
userId,
workspaceId,
value: false,
});
}
@@ -119,10 +119,9 @@ export class MicrosoftAPIsAuthController {
});
if (userId) {
await this.onboardingService.setOnboardingConnectAccountPending({
await this.onboardingService.completeOnboardingConnectAccountStep({
userId,
workspaceId,
value: false,
});
}
@@ -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;
}
}
@@ -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>(OnboardingService);
userVarsService = module.get<UserVarsService>(UserVarsService);
billingCreditService =
module.get<BillingCreditService>(BillingCreditService);
twentyConfigService = module.get<TwentyConfigService>(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();
});
});
});
@@ -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<OnboardingKeyValueTypeMap>,
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<boolean> {
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,