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:
+1
-2
@@ -112,10 +112,9 @@ export class GoogleAPIsAuthController {
|
||||
});
|
||||
|
||||
if (userId) {
|
||||
await this.onboardingService.setOnboardingConnectAccountPending({
|
||||
await this.onboardingService.completeOnboardingConnectAccountStep({
|
||||
userId,
|
||||
workspaceId,
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -119,10 +119,9 @@ export class MicrosoftAPIsAuthController {
|
||||
});
|
||||
|
||||
if (userId) {
|
||||
await this.onboardingService.setOnboardingConnectAccountPending({
|
||||
await this.onboardingService.completeOnboardingConnectAccountStep({
|
||||
userId,
|
||||
workspaceId,
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user