Credit workspaces for onboarding invite-team signups (#22309)

https://github.com/user-attachments/assets/6591cbb0-2b60-4f25-8b03-26b0da73f0d8

After the invite has been accepted:
<img width="1606" height="286" alt="CleanShot 2026-06-30 at 11 24 47@2x"
src="https://github.com/user-attachments/assets/7becf8a5-04dc-4512-ac7f-951a77e4c0ac"
/>

Adds a dedicated `ONBOARDING_INVITATION_TOKEN` app-token type so
invitations sent during the onboarding invite-team step are
distinguished from regular invites. When an invited person actually
signs up, the inviting workspace is credited 0.5 credits.

Reward eligibility is derived entirely server-side, with no public API
parameter: an invitation is reward-eligible only while the workspace is
in the onboarding invite-team step (`ONBOARDING_INVITE_TEAM_PENDING`), a
flag set once at workspace creation that no public mutation can re-arm.
Both token types stay valid invitations everywhere via a shared
`INVITATION_APP_TOKEN_TYPES`, so invitees still join normally and appear
in invite lists.

Crediting is a best-effort direct call to
`BillingCreditService.creditWorkspaceBalance` from the sign-in-up flow:
it no-ops when billing is disabled and never blocks signup, and is
bounded by a 10-invite-per-workspace cap. No DB migration needed:
`appToken.type` is a text column.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22309?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:49:25 +02:00
committed by GitHub
parent 3d00dd4066
commit aea6c3832a
21 changed files with 294 additions and 53 deletions
@@ -35,6 +35,7 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain-manager/subdomain-manager.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
@@ -126,6 +127,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
CoreEntityCacheModule,
SecureHttpClientModule,
EnterpriseModule,
BillingModule,
FileModule,
ConnectedAccountTokenEncryptionModule,
EmailAliasManagerModule,
@@ -18,6 +18,7 @@ import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { INVITATION_APP_TOKEN_TYPES } from 'src/engine/core-modules/workspace-invitation/constants/invitation-app-token-types';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { IMPERSONATION_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/impersonation/impersonation';
@@ -796,9 +797,10 @@ export class AuthService {
.where('"appToken"."workspaceId" = :workspaceId', {
workspaceId: params.currentWorkspace.id,
})
.andWhere('"appToken".type = :type', {
type: AppTokenType.InvitationToken,
});
.andWhere('"appToken".type IN (:...types)', {
types: INVITATION_APP_TOKEN_TYPES,
})
.andWhere('"appToken"."deletedAt" IS NULL');
if ('workspacePersonalInviteToken' in params) {
qr.andWhere('"appToken".value = :personalInviteToken', {
@@ -105,6 +105,9 @@ const createSignInUpServiceForTests = () => {
insertWorkspaceEvent: jest.fn(),
}),
} as any,
{
creditWorkspaceBalance: jest.fn(),
} as any,
{
createQueryRunner: jest.fn(() => queryRunnerMock),
} as any,
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
@@ -19,8 +19,12 @@ import { type QueryFailedErrorWithCode } from 'src/engine/api/graphql/workspace-
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { USER_SIGNUP_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/user/user-signup';
import { WORKSPACE_CREATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/workspace/workspace-created';
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import {
type AppTokenEntity,
AppTokenType,
} 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 {
AuthException,
AuthExceptionCode,
@@ -68,6 +72,8 @@ import { isWorkEmail } from 'src/utils/is-work-email';
@Injectable()
// oxlint-disable-next-line twenty/inject-workspace-repository
export class SignInUpService {
private readonly logger = new Logger(SignInUpService.name);
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@@ -86,6 +92,7 @@ export class SignInUpService {
private readonly fileCorePictureService: FileCorePictureService,
private readonly enterprisePlanService: EnterprisePlanService,
private readonly eventLogEmitterService: EventLogEmitterService,
private readonly billingCreditService: BillingCreditService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -231,6 +238,25 @@ export class SignInUpService {
roleId: params.invitation.context?.roleId,
});
if (
params.invitation.type === AppTokenType.OnboardingInvitationToken &&
params.userData.type === 'newUserWithPicture'
) {
try {
await this.billingCreditService.creditWorkspaceBalance({
workspaceId: invitationValidation.workspace.id,
amountMicro: this.twentyConfigService.get(
'ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER',
),
});
} catch (error) {
this.logger.error(
`Failed to credit onboarding invite reward for workspace ${invitationValidation.workspace.id}`,
error,
);
}
}
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
invitationValidation.workspace.id,
email,