diff --git a/.docker/dev/.env.example b/.docker/dev/.env.example index b6beb2deb4..c8c53b42f9 100644 --- a/.docker/dev/.env.example +++ b/.docker/dev/.env.example @@ -1,4 +1,3 @@ -DATABASE_LOCATION=./postgres DB_PASSWORD=affine DB_USERNAME=affine DB_DATABASE_NAME=affine \ No newline at end of file diff --git a/packages/backend/server/migrations/20250110034441_licenses/migration.sql b/packages/backend/server/migrations/20250110034441_licenses/migration.sql new file mode 100644 index 0000000000..75d5b78678 --- /dev/null +++ b/packages/backend/server/migrations/20250110034441_licenses/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "licenses" ( + "key" VARCHAR NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revealed_at" TIMESTAMPTZ(3), + "installed_at" TIMESTAMPTZ(3), + "validate_key" VARCHAR, + + CONSTRAINT "licenses_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "installed_licenses" ( + "key" VARCHAR NOT NULL, + "workspace_id" VARCHAR NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + "recurring" VARCHAR NOT NULL, + "installed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "validate_key" VARCHAR NOT NULL, + "validated_at" TIMESTAMPTZ(3) NOT NULL, + "expired_at" TIMESTAMPTZ(3), + + CONSTRAINT "installed_licenses_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "installed_licenses_workspace_id_key" ON "installed_licenses"("workspace_id"); diff --git a/packages/backend/server/migrations/migration_lock.toml b/packages/backend/server/migrations/migration_lock.toml index 99e4f20090..fbffa92c2b 100644 --- a/packages/backend/server/migrations/migration_lock.toml +++ b/packages/backend/server/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (i.e. Git) -provider = "postgresql" +provider = "postgresql" \ No newline at end of file diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index ce5315b46e..47d8edde1c 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -569,15 +569,39 @@ model Invoice { @@index([targetId]) @@map("invoices") } + +model License { + key String @id @map("key") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + revealedAt DateTime? @map("revealed_at") @db.Timestamptz(3) + installedAt DateTime? @map("installed_at") @db.Timestamptz(3) + validateKey String? @map("validate_key") @db.VarChar + + @@map("licenses") +} + +model InstalledLicense { + key String @id @map("key") @db.VarChar + workspaceId String @unique @map("workspace_id") @db.VarChar + quantity Int @default(1) @db.Integer + recurring String @db.VarChar + installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3) + validateKey String @map("validate_key") @db.VarChar + validatedAt DateTime @map("validated_at") @db.Timestamptz(3) + expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) + + @@map("installed_licenses") +} + // Blob table only exists for fast non-data queries. // like, total size of blobs in a workspace, or blob list for sync service. // it should only be a map of metadata of blobs stored anywhere else model Blob { - workspaceId String @map("workspace_id") @db.VarChar - key String @db.VarChar - size Int @db.Integer - mime String @db.VarChar - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + workspaceId String @map("workspace_id") @db.VarChar + key String @db.VarChar + size Int @db.Integer + mime String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) diff --git a/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.md b/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.md index f34bd485cc..ad0c8b89c6 100644 --- a/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.md +++ b/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.md @@ -1635,6 +1635,87 @@ Generated by [AVA](https://avajs.dev). ␊ ` +> Your AFFiNE Self-Hosted Team Workspace license is ready + + `␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + Here is your license key.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + You can use this key to upgrade your selfhost workspace in␊ + Settings > Workspace > License.␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ` + > Your workspace Test Workspace has been deleted `␊ diff --git a/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.snap index 4c90e4f6a7..6408e7fdf3 100644 Binary files a/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.snap and b/packages/backend/server/src/__tests__/__snapshots__/mailer.spec.ts.snap differ diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index 32ec5fe373..86a7ff034e 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -46,6 +46,7 @@ import { UserModule } from './core/user'; import { WorkspaceModule } from './core/workspaces'; import { ModelsModule } from './models'; import { REGISTERED_PLUGINS } from './plugins'; +import { LicenseModule } from './plugins/license'; import { ENABLED_PLUGINS } from './plugins/registry'; export const FunctionalityModules = [ @@ -203,7 +204,8 @@ export function buildAppModule() { GqlModule, StorageModule, ServerConfigModule, - WorkspaceModule + WorkspaceModule, + LicenseModule ) // self hosted server only @@ -214,7 +216,8 @@ export function buildAppModule() { ENABLED_PLUGINS.forEach(name => { const plugin = REGISTERED_PLUGINS.get(name); if (!plugin) { - throw new Error(`Unknown plugin ${name}`); + new Logger('AppBuilder').warn(`Unknown plugin ${name}`); + return; } factor.use(plugin); diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index d02c5b7c5c..3efa8d6bb8 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -607,4 +607,38 @@ export const USER_FRIENDLY_ERRORS = { type: 'bad_request', message: 'Captcha verification failed.', }, + + // license errors + invalid_license_session_id: { + type: 'invalid_input', + message: 'Invalid session id to generate license key.', + }, + license_revealed: { + type: 'action_forbidden', + message: + 'License key has been revealed. Please check your mail box of the one provided during checkout.', + }, + workspace_license_already_exists: { + type: 'action_forbidden', + message: 'Workspace already has a license applied.', + }, + license_not_found: { + type: 'resource_not_found', + message: 'License not found.', + }, + invalid_license_to_activate: { + type: 'bad_request', + message: 'Invalid license to activate.', + }, + invalid_license_update_params: { + type: 'invalid_input', + args: { reason: 'string' }, + message: ({ reason }) => `Invalid license update params. ${reason}`, + }, + workspace_members_exceed_limit_to_downgrade: { + type: 'bad_request', + args: { limit: 'number' }, + message: ({ limit }) => + `You cannot downgrade the workspace from team workspace because there are more than ${limit} members that are currently active.`, + }, } satisfies Record; diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index a17feb5b84..99fb8a2f1a 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -591,6 +591,56 @@ export class CaptchaVerificationFailed extends UserFriendlyError { super('bad_request', 'captcha_verification_failed', message); } } + +export class InvalidLicenseSessionId extends UserFriendlyError { + constructor(message?: string) { + super('invalid_input', 'invalid_license_session_id', message); + } +} + +export class LicenseRevealed extends UserFriendlyError { + constructor(message?: string) { + super('action_forbidden', 'license_revealed', message); + } +} + +export class WorkspaceLicenseAlreadyExists extends UserFriendlyError { + constructor(message?: string) { + super('action_forbidden', 'workspace_license_already_exists', message); + } +} + +export class LicenseNotFound extends UserFriendlyError { + constructor(message?: string) { + super('resource_not_found', 'license_not_found', message); + } +} + +export class InvalidLicenseToActivate extends UserFriendlyError { + constructor(message?: string) { + super('bad_request', 'invalid_license_to_activate', message); + } +} +@ObjectType() +class InvalidLicenseUpdateParamsDataType { + @Field() reason!: string +} + +export class InvalidLicenseUpdateParams extends UserFriendlyError { + constructor(args: InvalidLicenseUpdateParamsDataType, message?: string | ((args: InvalidLicenseUpdateParamsDataType) => string)) { + super('invalid_input', 'invalid_license_update_params', message, args); + } +} +@ObjectType() +class WorkspaceMembersExceedLimitToDowngradeDataType { + @Field() limit!: number +} + +export class WorkspaceMembersExceedLimitToDowngrade extends UserFriendlyError { + constructor(args: WorkspaceMembersExceedLimitToDowngradeDataType, message?: string | ((args: WorkspaceMembersExceedLimitToDowngradeDataType) => string)) { + super('bad_request', 'workspace_members_exceed_limit_to_downgrade', message, args); + } +} export enum ErrorNames { INTERNAL_SERVER_ERROR, TOO_MANY_REQUEST, @@ -669,7 +719,14 @@ export enum ErrorNames { MAILER_SERVICE_IS_NOT_CONFIGURED, CANNOT_DELETE_ALL_ADMIN_ACCOUNT, CANNOT_DELETE_OWN_ACCOUNT, - CAPTCHA_VERIFICATION_FAILED + CAPTCHA_VERIFICATION_FAILED, + INVALID_LICENSE_SESSION_ID, + LICENSE_REVEALED, + WORKSPACE_LICENSE_ALREADY_EXISTS, + LICENSE_NOT_FOUND, + INVALID_LICENSE_TO_ACTIVATE, + INVALID_LICENSE_UPDATE_PARAMS, + WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE } registerEnumType(ErrorNames, { name: 'ErrorNames' @@ -678,5 +735,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', types: () => - [WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const, + [WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType] as const, }); diff --git a/packages/backend/server/src/base/helpers/url.ts b/packages/backend/server/src/base/helpers/url.ts index 48bf54fe18..9a2bbf05a5 100644 --- a/packages/backend/server/src/base/helpers/url.ts +++ b/packages/backend/server/src/base/helpers/url.ts @@ -46,6 +46,24 @@ export class URLHelper { return new URLSearchParams(query).toString(); } + addSimpleQuery( + url: string, + key: string, + value: string | number | boolean, + escape = true + ) { + const urlObj = new URL(url); + if (escape) { + urlObj.searchParams.set(key, encodeURIComponent(value)); + return urlObj.toString(); + } else { + const query = + (urlObj.search ? urlObj.search + '&' : '?') + `${key}=${value}`; + + return urlObj.origin + urlObj.pathname + query; + } + } + url(path: string, query: Record = {}) { const url = new URL(path, this.origin); diff --git a/packages/backend/server/src/base/mailer/mail.service.ts b/packages/backend/server/src/base/mailer/mail.service.ts index 25736b9c6d..5dc8533216 100644 --- a/packages/backend/server/src/base/mailer/mail.service.ts +++ b/packages/backend/server/src/base/mailer/mail.service.ts @@ -22,6 +22,7 @@ import { renderTeamBecomeCollaboratorMail, renderTeamDeleteIn1MonthMail, renderTeamDeleteIn24HoursMail, + renderTeamLicenseMail, renderTeamWorkspaceDeletedMail, renderTeamWorkspaceExpiredMail, renderTeamWorkspaceExpireSoonMail, @@ -188,4 +189,5 @@ export class MailService { renderTeamWorkspaceExpireSoonMail ); sendTeamExpiredMail = this.makeWorkspace(renderTeamWorkspaceExpiredMail); + sendTeamLicenseMail = this.make(renderTeamLicenseMail); } diff --git a/packages/backend/server/src/core/quota/service.ts b/packages/backend/server/src/core/quota/service.ts index c8609f7c7e..e782603de4 100644 --- a/packages/backend/server/src/core/quota/service.ts +++ b/packages/backend/server/src/core/quota/service.ts @@ -1,19 +1,14 @@ import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import type { EventPayload } from '../../base'; -import { OnEvent, PrismaTransaction } from '../../base'; -import { FeatureManagementService } from '../features/management'; +import { PrismaTransaction } from '../../base'; import { FeatureKind } from '../features/types'; import { QuotaConfig } from './quota'; import { QuotaType } from './types'; @Injectable() export class QuotaService { - constructor( - private readonly prisma: PrismaClient, - private readonly feature: FeatureManagementService - ) {} + constructor(private readonly prisma: PrismaClient) {} async getQuota( quota: Q, @@ -331,55 +326,4 @@ export class QuotaService { }); return r.count; } - - @OnEvent('user.subscription.activated') - async onSubscriptionUpdated({ - userId, - plan, - recurring, - }: EventPayload<'user.subscription.activated'>) { - switch (plan) { - case 'ai': - await this.feature.addCopilot(userId, 'subscription activated'); - break; - case 'pro': - await this.switchUserQuota( - userId, - recurring === 'lifetime' - ? QuotaType.LifetimeProPlanV1 - : QuotaType.ProPlanV1, - 'subscription activated' - ); - break; - default: - break; - } - } - - @OnEvent('user.subscription.canceled') - async onSubscriptionCanceled({ - userId, - plan, - }: EventPayload<'user.subscription.canceled'>) { - switch (plan) { - case 'ai': - await this.feature.removeCopilot(userId); - break; - case 'pro': { - // edge case: when user switch from recurring Pro plan to `Lifetime` plan, - // a subscription canceled event will be triggered because `Lifetime` plan is not subscription based - const quota = await this.getUserQuota(userId); - if (quota.feature.name !== QuotaType.LifetimeProPlanV1) { - await this.switchUserQuota( - userId, - QuotaType.FreePlanV1, - 'subscription canceled' - ); - } - break; - } - default: - break; - } - } } diff --git a/packages/backend/server/src/mails/index.tsx b/packages/backend/server/src/mails/index.tsx index df0c702001..3d91806573 100644 --- a/packages/backend/server/src/mails/index.tsx +++ b/packages/backend/server/src/mails/index.tsx @@ -7,6 +7,7 @@ import { TeamDeleteInOneMonth, TeamExpired, TeamExpireSoon, + TeamLicense, TeamWorkspaceDeleted, TeamWorkspaceUpgraded, } from './teams'; @@ -175,3 +176,8 @@ export const renderTeamWorkspaceExpiredMail = make( TeamExpired, props => `Your ${props.workspace.name} team workspace has expired` ); + +export const renderTeamLicenseMail = make( + TeamLicense, + 'Your AFFiNE Self-Hosted Team Workspace license is ready' +); diff --git a/packages/backend/server/src/mails/teams/index.ts b/packages/backend/server/src/mails/teams/index.ts index 659068e0d9..9a58a5f472 100644 --- a/packages/backend/server/src/mails/teams/index.ts +++ b/packages/backend/server/src/mails/teams/index.ts @@ -23,6 +23,7 @@ export { type TeamExpireSoonProps, } from './expire-soon'; export { default as TeamExpired, type TeamExpiredProps } from './expired'; +export { default as TeamLicense, type TeamLicenseProps } from './license'; export { default as TeamWorkspaceUpgraded, type TeamWorkspaceUpgradedProps, diff --git a/packages/backend/server/src/mails/teams/license.tsx b/packages/backend/server/src/mails/teams/license.tsx new file mode 100644 index 0000000000..04447c1ead --- /dev/null +++ b/packages/backend/server/src/mails/teams/license.tsx @@ -0,0 +1,33 @@ +import { + Bold, + Content, + OnelineCodeBlock, + P, + Template, + Title, +} from '../components'; + +export interface TeamLicenseProps { + license: string; +} + +export default function TeamLicense(props: TeamLicenseProps) { + const { license } = props; + + return ( + + ); +} + +TeamLicense.PreviewProps = { + license: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', +}; diff --git a/packages/backend/server/src/plugins/license/index.ts b/packages/backend/server/src/plugins/license/index.ts new file mode 100644 index 0000000000..854ee67a9e --- /dev/null +++ b/packages/backend/server/src/plugins/license/index.ts @@ -0,0 +1,11 @@ +import { OptionalModule } from '../../base'; +import { PermissionModule } from '../../core/permission'; +import { QuotaModule } from '../../core/quota'; +import { LicenseResolver } from './resolver'; +import { LicenseService } from './service'; + +@OptionalModule({ + imports: [QuotaModule, PermissionModule], + providers: [LicenseService, LicenseResolver], +}) +export class LicenseModule {} diff --git a/packages/backend/server/src/plugins/license/resolver.ts b/packages/backend/server/src/plugins/license/resolver.ts new file mode 100644 index 0000000000..463c759e9c --- /dev/null +++ b/packages/backend/server/src/plugins/license/resolver.ts @@ -0,0 +1,126 @@ +import { + Args, + Field, + Int, + Mutation, + ObjectType, + Parent, + ResolveField, + Resolver, +} from '@nestjs/graphql'; + +import { ActionForbidden, Config } from '../../base'; +import { CurrentUser } from '../../core/auth'; +import { Permission, PermissionService } from '../../core/permission'; +import { WorkspaceType } from '../../core/workspaces'; +import { SubscriptionRecurring } from '../payment/types'; +import { LicenseService } from './service'; + +@ObjectType() +export class License { + @Field(() => Int) + quantity!: number; + + @Field(() => SubscriptionRecurring) + recurring!: string; + + @Field(() => Date) + installedAt!: Date; + + @Field(() => Date) + validatedAt!: Date; + + @Field(() => Date, { nullable: true }) + expiredAt!: Date | null; +} + +@Resolver(() => WorkspaceType) +export class LicenseResolver { + constructor( + private readonly config: Config, + private readonly service: LicenseService, + private readonly permission: PermissionService + ) {} + + @ResolveField(() => License, { + complexity: 2, + description: 'The selfhost license of the workspace', + nullable: true, + }) + async license( + @CurrentUser() user: CurrentUser, + @Parent() workspace: WorkspaceType + ): Promise { + // NOTE(@forehalo): + // we can't simply disable license resolver for non-selfhosted server + // it will make the gql codegen messed up. + if (!this.config.isSelfhosted) { + return null; + } + + await this.permission.checkWorkspaceIs( + workspace.id, + user.id, + Permission.Owner + ); + + return this.service.getLicense(workspace.id); + } + + @Mutation(() => License) + async activateLicense( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string, + @Args('license') license: string + ) { + if (!this.config.isSelfhosted) { + throw new ActionForbidden(); + } + + await this.permission.checkWorkspaceIs( + workspaceId, + user.id, + Permission.Owner + ); + + return this.service.activateTeamLicense(workspaceId, license); + } + + @Mutation(() => Boolean) + async deactivateLicense( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string + ) { + if (!this.config.isSelfhosted) { + throw new ActionForbidden(); + } + + await this.permission.checkWorkspaceIs( + workspaceId, + user.id, + Permission.Owner + ); + + return this.service.deactivateTeamLicense(workspaceId); + } + + @Mutation(() => String) + async createSelfhostWorkspaceCustomerPortal( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string + ) { + if (!this.config.isSelfhosted) { + throw new ActionForbidden(); + } + + await this.permission.checkWorkspaceIs( + workspaceId, + user.id, + Permission.Owner + ); + + const { url } = await this.service.createCustomerPortal(workspaceId); + + return url; + } +} diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts new file mode 100644 index 0000000000..5d1491224b --- /dev/null +++ b/packages/backend/server/src/plugins/license/service.ts @@ -0,0 +1,343 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InstalledLicense, PrismaClient } from '@prisma/client'; + +import { + EventEmitter, + type EventPayload, + InternalServerError, + LicenseNotFound, + OnEvent, + UserFriendlyError, + WorkspaceLicenseAlreadyExists, +} from '../../base'; +import { PermissionService } from '../../core/permission'; +import { QuotaManagementService, QuotaType } from '../../core/quota'; +import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types'; + +interface License { + plan: SubscriptionPlan; + recurring: SubscriptionRecurring; + quantity: number; + endAt: number; +} + +@Injectable() +export class LicenseService { + private readonly logger = new Logger(LicenseService.name); + + constructor( + private readonly db: PrismaClient, + private readonly quota: QuotaManagementService, + private readonly event: EventEmitter, + private readonly permission: PermissionService + ) {} + + async getLicense(workspaceId: string) { + return this.db.installedLicense.findUnique({ + select: { + installedAt: true, + validatedAt: true, + expiredAt: true, + quantity: true, + recurring: true, + }, + where: { + workspaceId, + }, + }); + } + + async activateTeamLicense(workspaceId: string, licenseKey: string) { + const installedLicense = await this.getLicense(workspaceId); + + if (installedLicense) { + throw new WorkspaceLicenseAlreadyExists(); + } + + const data = await this.fetch( + `/api/team/licenses/${licenseKey}/activate`, + { + method: 'POST', + } + ); + + const license = await this.db.installedLicense.upsert({ + where: { + workspaceId, + }, + update: { + key: licenseKey, + validatedAt: new Date(), + validateKey: data.res.headers.get('x-next-validate-key') ?? '', + expiredAt: new Date(data.endAt), + recurring: data.recurring, + quantity: data.quantity, + }, + create: { + workspaceId, + key: licenseKey, + expiredAt: new Date(data.endAt), + validatedAt: new Date(), + validateKey: data.res.headers.get('x-next-validate-key') ?? '', + recurring: data.recurring, + quantity: data.quantity, + }, + }); + + this.event.emit('workspace.subscription.activated', { + workspaceId, + plan: data.plan, + recurring: data.recurring, + quantity: data.quantity, + }); + return license; + } + + async deactivateTeamLicense(workspaceId: string) { + const license = await this.db.installedLicense.findUnique({ + where: { + workspaceId, + }, + }); + + if (!license) { + throw new LicenseNotFound(); + } + + await this.fetch(`/api/team/licenses/${license.key}/deactivate`, { + method: 'POST', + }); + + await this.db.installedLicense.deleteMany({ + where: { + workspaceId, + }, + }); + + this.event.emit('workspace.subscription.canceled', { + workspaceId, + plan: SubscriptionPlan.SelfHostedTeam, + recurring: SubscriptionRecurring.Monthly, + }); + } + + async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) { + await this.fetch(`/api/team/licenses/${key}/recurring`, { + method: 'POST', + body: JSON.stringify({ + recurring, + }), + }); + } + + async createCustomerPortal(workspaceId: string) { + const license = await this.db.installedLicense.findUnique({ + where: { + workspaceId, + }, + }); + + if (!license) { + throw new LicenseNotFound(); + } + + return this.fetch<{ url: string }>( + `/api/team/licenses/${license.key}/create-customer-portal`, + { + method: 'POST', + } + ); + } + + @OnEvent('workspace.members.updated') + async updateTeamSeats(payload: EventPayload<'workspace.members.updated'>) { + const { workspaceId, count } = payload; + + const license = await this.db.installedLicense.findUnique({ + where: { + workspaceId, + }, + }); + + if (!license) { + return; + } + + await this.fetch(`/api/team/licenses/${license.key}/seats`, { + method: 'POST', + body: JSON.stringify({ + quantity: count, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + + // stripe payment is async, we can't directly the charge result in update calling + await this.waitUntilLicenseUpdated(license, count); + } + + private async waitUntilLicenseUpdated( + license: InstalledLicense, + memberRequired: number + ) { + let tried = 0; + while (tried++ < 10) { + try { + const res = await this.revalidateLicense(license); + + if (res?.quantity === memberRequired) { + break; + } + } catch (e) { + this.logger.error('Failed to check license health', e); + } + + await new Promise(resolve => setTimeout(resolve, tried * 2000)); + } + + // fallback to health check if we can't get the upgrade result immediately + throw new Error('Timeout checking seat update result.'); + } + + @Cron(CronExpression.EVERY_10_MINUTES) + async licensesHealthCheck() { + const licenses = await this.db.installedLicense.findMany({ + where: { + validatedAt: { + lte: new Date(Date.now() - 1000 * 60 * 60), + }, + }, + }); + + for (const license of licenses) { + await this.revalidateLicense(license); + } + } + + private async revalidateLicense(license: InstalledLicense) { + try { + const res = await this.fetch( + `/api/team/licenses/${license.key}/health` + ); + + await this.db.installedLicense.update({ + where: { + key: license.key, + }, + data: { + validatedAt: new Date(), + validateKey: res.res.headers.get('x-next-validate-key') ?? '', + quantity: res.quantity, + recurring: res.recurring, + expiredAt: new Date(res.endAt), + }, + }); + + this.event.emit('workspace.subscription.activated', { + workspaceId: license.workspaceId, + plan: res.plan, + recurring: res.recurring, + quantity: res.quantity, + }); + + return res; + } catch (e) { + this.logger.error('Failed to revalidate license', e); + + // only treat known error as invalid license response + if ( + e instanceof UserFriendlyError && + e.name !== 'internal_server_error' + ) { + this.event.emit('workspace.subscription.canceled', { + workspaceId: license.workspaceId, + plan: SubscriptionPlan.SelfHostedTeam, + recurring: SubscriptionRecurring.Monthly, + }); + } + + return null; + } + } + + private async fetch( + path: string, + init?: RequestInit + ): Promise { + try { + const res = await fetch('https://app.affine.pro' + path, { + ...init, + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + const body = (await res.json()) as UserFriendlyError; + throw new UserFriendlyError( + body.type as any, + body.name as any, + body.message, + body.data + ); + } + + const data = (await res.json()) as T; + return { + ...data, + res, + }; + } catch (e) { + if (e instanceof UserFriendlyError) { + throw e; + } + + throw new InternalServerError( + e instanceof Error + ? e.message + : 'Failed to contact with https://app.affine.pro' + ); + } + } + + @OnEvent('workspace.subscription.activated') + async onWorkspaceSubscriptionUpdated({ + workspaceId, + plan, + recurring, + quantity, + }: EventPayload<'workspace.subscription.activated'>) { + switch (plan) { + case SubscriptionPlan.SelfHostedTeam: + await this.quota.addTeamWorkspace( + workspaceId, + `${recurring} team subscription activated` + ); + await this.quota.updateWorkspaceConfig( + workspaceId, + QuotaType.TeamPlanV1, + { memberLimit: quantity } + ); + await this.permission.refreshSeatStatus(workspaceId, quantity); + break; + default: + break; + } + } + + @OnEvent('workspace.subscription.canceled') + async onWorkspaceSubscriptionCanceled({ + workspaceId, + plan, + }: EventPayload<'workspace.subscription.canceled'>) { + switch (plan) { + case SubscriptionPlan.SelfHostedTeam: + await this.quota.removeTeamWorkspace(workspaceId); + break; + default: + break; + } + } +} diff --git a/packages/backend/server/src/plugins/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts index 8d01b152ae..4504b7f233 100644 --- a/packages/backend/server/src/plugins/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -9,11 +9,13 @@ import { WorkspaceModule } from '../../core/workspaces'; import { Plugin } from '../registry'; import { StripeWebhookController } from './controller'; import { SubscriptionCronJobs } from './cron'; +import { LicenseController } from './license/controller'; import { + SelfhostTeamSubscriptionManager, UserSubscriptionManager, WorkspaceSubscriptionManager, } from './manager'; -import { TeamQuotaOverride } from './quota'; +import { QuotaOverride } from './quota'; import { SubscriptionResolver, UserSubscriptionResolver, @@ -40,11 +42,12 @@ import { StripeWebhook } from './webhook'; StripeWebhook, UserSubscriptionManager, WorkspaceSubscriptionManager, + SelfhostTeamSubscriptionManager, SubscriptionCronJobs, WorkspaceSubscriptionResolver, - TeamQuotaOverride, + QuotaOverride, ], - controllers: [StripeWebhookController], + controllers: [StripeWebhookController, LicenseController], requires: [ 'plugins.payment.stripe.keys.APIKey', 'plugins.payment.stripe.keys.webhookKey', diff --git a/packages/backend/server/src/plugins/payment/license/controller.ts b/packages/backend/server/src/plugins/payment/license/controller.ts new file mode 100644 index 0000000000..0b9e587be3 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/license/controller.ts @@ -0,0 +1,269 @@ +import { randomUUID } from 'node:crypto'; + +import { + Body, + Controller, + Get, + Headers, + HttpStatus, + Logger, + Param, + Post, + Res, +} from '@nestjs/common'; +import { PrismaClient, Subscription } from '@prisma/client'; +import type { Response } from 'express'; +import Stripe from 'stripe'; +import { z } from 'zod'; + +import { + CustomerPortalCreateFailed, + InvalidLicenseToActivate, + InvalidLicenseUpdateParams, + LicenseNotFound, + Mutex, +} from '../../../base'; +import { Public } from '../../../core/auth'; +import { SelfhostTeamSubscriptionManager } from '../manager/selfhost'; +import { SubscriptionService } from '../service'; +import { + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionStatus, +} from '../types'; + +const UpdateSeatsParams = z.object({ + seats: z.number().min(1), +}); + +const UpdateRecurringParams = z.object({ + recurring: z.enum([ + SubscriptionRecurring.Monthly, + SubscriptionRecurring.Yearly, + ]), +}); + +@Public() +@Controller('/api/team/licenses') +export class LicenseController { + private readonly logger = new Logger(LicenseController.name); + + constructor( + private readonly db: PrismaClient, + private readonly mutex: Mutex, + private readonly subscription: SubscriptionService, + private readonly manager: SelfhostTeamSubscriptionManager, + private readonly stripe: Stripe + ) {} + + @Post('/:license/activate') + async activate(@Res() res: Response, @Param('license') key: string) { + await using lock = await this.mutex.acquire(`license-activation:${key}`); + + if (!lock) { + throw new InvalidLicenseToActivate(); + } + + const license = await this.db.license.findUnique({ + where: { + key, + }, + }); + + if (!license) { + throw new InvalidLicenseToActivate(); + } + + const subscription = await this.manager.getSubscription({ + key: license.key, + plan: SubscriptionPlan.SelfHostedTeam, + }); + + if ( + !subscription || + license.installedAt || + subscription.status !== SubscriptionStatus.Active + ) { + throw new InvalidLicenseToActivate(); + } + + const validateKey = randomUUID(); + await this.db.license.update({ + where: { + key, + }, + data: { + installedAt: new Date(), + validateKey, + }, + }); + + res + .status(HttpStatus.OK) + .header('x-next-validate-key', validateKey) + .json(this.license(subscription)); + } + + @Post('/:license/deactivate') + async deactivate(@Param('license') key: string) { + await this.db.license.update({ + where: { + key, + }, + data: { + installedAt: null, + validateKey: null, + }, + }); + + return { + success: true, + }; + } + + @Get('/:license/health') + async health( + @Res() res: Response, + @Param('license') key: string, + @Headers('x-validate-key') revalidateKey: string + ) { + const license = await this.db.license.findUnique({ + where: { + key, + }, + }); + + const subscription = await this.manager.getSubscription({ + key, + plan: SubscriptionPlan.SelfHostedTeam, + }); + + if (!license || !subscription) { + throw new LicenseNotFound(); + } + + if (license.validateKey && license.validateKey !== revalidateKey) { + throw new InvalidLicenseToActivate(); + } + + const validateKey = randomUUID(); + await this.db.license.update({ + where: { + key, + }, + data: { + validateKey, + }, + }); + + res + .status(HttpStatus.OK) + .header('x-next-validate-key', validateKey) + .json(this.license(subscription)); + } + + @Post('/:license/seats') + async updateSeats( + @Param('license') key: string, + @Body() body: z.infer + ) { + const parseResult = UpdateSeatsParams.safeParse(body); + + if (parseResult.error) { + throw new InvalidLicenseUpdateParams({ + reason: parseResult.error.message, + }); + } + + const license = await this.db.license.findUnique({ + where: { + key, + }, + }); + + if (!license) { + throw new LicenseNotFound(); + } + + await this.subscription.updateSubscriptionQuantity( + { + key: license.key, + plan: SubscriptionPlan.SelfHostedTeam, + }, + parseResult.data.seats + ); + } + + @Post('/:license/recurring') + async updateRecurring( + @Param('license') key: string, + @Body() body: z.infer + ) { + const parseResult = UpdateRecurringParams.safeParse(body); + + if (parseResult.error) { + throw new InvalidLicenseUpdateParams({ + reason: parseResult.error.message, + }); + } + + const license = await this.db.license.findUnique({ + where: { + key, + }, + }); + + if (!license) { + throw new LicenseNotFound(); + } + + await this.subscription.updateSubscriptionRecurring( + { + key: license.key, + plan: SubscriptionPlan.SelfHostedTeam, + }, + parseResult.data.recurring + ); + } + + @Post('/:license/create-customer-portal') + async createCustomerPortal(@Param('license') key: string) { + const invoice = await this.db.invoice.findFirst({ + where: { + targetId: key, + }, + }); + + if (!invoice) { + throw new LicenseNotFound(); + } + + const invoiceData = await this.stripe.invoices.retrieve( + invoice.stripeInvoiceId, + { + expand: ['customer'], + } + ); + + const customer = invoiceData.customer as Stripe.Customer; + try { + const portal = await this.stripe.billingPortal.sessions.create({ + customer: customer.id, + }); + + return { url: portal.url }; + } catch (e) { + this.logger.error('Failed to create customer portal.', e); + throw new CustomerPortalCreateFailed(); + } + } + + license(subscription: Subscription) { + return { + plan: subscription.plan, + recurring: subscription.recurring, + quantity: subscription.quantity, + endAt: subscription.end?.getTime(), + }; + } +} diff --git a/packages/backend/server/src/plugins/payment/manager/common.ts b/packages/backend/server/src/plugins/payment/manager/common.ts index f8aede9e45..c7661e4a5c 100644 --- a/packages/backend/server/src/plugins/payment/manager/common.ts +++ b/packages/backend/server/src/plugins/payment/manager/common.ts @@ -22,6 +22,7 @@ export interface Subscription { plan: string; recurring: string; variant: string | null; + quantity: number; start: Date; end: Date | null; trialStart: Date | null; @@ -99,11 +100,13 @@ export abstract class SubscriptionManager { transformSubscription({ lookupKey, stripeSubscription: subscription, + quantity, }: KnownStripeSubscription): Subscription { return { ...lookupKey, stripeScheduleId: subscription.schedule as string | null, stripeSubscriptionId: subscription.id, + quantity, status: subscription.status, start: new Date(subscription.current_period_start * 1000), end: new Date(subscription.current_period_end * 1000), @@ -224,7 +227,7 @@ export abstract class SubscriptionManager { protected async getCouponFromPromotionCode( userFacingPromotionCode: string, - customer: UserStripeCustomer + customer?: UserStripeCustomer ) { const list = await this.stripe.promotionCodes.list({ code: userFacingPromotionCode, @@ -243,11 +246,20 @@ export abstract class SubscriptionManager { // code.coupon.applies_to.products.forEach() // check if the code is bound to a specific customer - return !code.customer || - (typeof code.customer === 'string' - ? code.customer === customer.stripeCustomerId - : code.customer.id === customer.stripeCustomerId) - ? code.coupon.id - : null; + if (code.customer) { + if (!customer) { + return null; + } + + return ( + typeof code.customer === 'string' + ? code.customer === customer.stripeCustomerId + : code.customer.id === customer.stripeCustomerId + ) + ? code.coupon.id + : null; + } + + return code.coupon.id; } } diff --git a/packages/backend/server/src/plugins/payment/manager/index.ts b/packages/backend/server/src/plugins/payment/manager/index.ts index 21d7a26a20..8a03bb3f39 100644 --- a/packages/backend/server/src/plugins/payment/manager/index.ts +++ b/packages/backend/server/src/plugins/payment/manager/index.ts @@ -1,3 +1,4 @@ export * from './common'; +export * from './selfhost'; export * from './user'; export * from './workspace'; diff --git a/packages/backend/server/src/plugins/payment/manager/selfhost.ts b/packages/backend/server/src/plugins/payment/manager/selfhost.ts new file mode 100644 index 0000000000..0af3c10907 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/manager/selfhost.ts @@ -0,0 +1,231 @@ +import { randomUUID } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { PrismaClient, UserStripeCustomer } from '@prisma/client'; +import { pick } from 'lodash-es'; +import Stripe from 'stripe'; +import { z } from 'zod'; + +import { + MailService, + SubscriptionPlanNotFound, + URLHelper, +} from '../../../base'; +import { + KnownStripeInvoice, + KnownStripePrice, + KnownStripeSubscription, + LookupKey, + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionStatus, +} from '../types'; +import { + CheckoutParams, + Invoice, + Subscription, + SubscriptionManager, +} from './common'; + +export const SelfhostTeamCheckoutArgs = z.object({ + quantity: z.number(), +}); + +export const SelfhostTeamSubscriptionIdentity = z.object({ + plan: z.literal(SubscriptionPlan.SelfHostedTeam), + key: z.string(), +}); + +@Injectable() +export class SelfhostTeamSubscriptionManager extends SubscriptionManager { + constructor( + stripe: Stripe, + db: PrismaClient, + private readonly url: URLHelper, + private readonly mailer: MailService + ) { + super(stripe, db); + } + + filterPrices( + prices: KnownStripePrice[], + _customer?: UserStripeCustomer + ): KnownStripePrice[] { + return prices.filter( + price => price.lookupKey.plan === SubscriptionPlan.SelfHostedTeam + ); + } + + async checkout( + lookupKey: LookupKey, + params: z.infer, + args: z.infer + ) { + const { quantity } = args; + + const price = await this.getPrice(lookupKey); + + if (!price) { + throw new SubscriptionPlanNotFound({ + plan: lookupKey.plan, + recurring: lookupKey.recurring, + }); + } + + const discounts = await (async () => { + if (params.coupon) { + const couponId = await this.getCouponFromPromotionCode(params.coupon); + if (couponId) { + return { discounts: [{ coupon: couponId }] }; + } + } + + return { allow_promotion_codes: true }; + })(); + + let successUrl = this.url.link(params.successCallbackLink); + // stripe only accept unescaped '{CHECKOUT_SESSION_ID}' as query + successUrl = this.url.addSimpleQuery( + successUrl, + 'session_id', + '{CHECKOUT_SESSION_ID}', + false + ); + + return this.stripe.checkout.sessions.create({ + line_items: [ + { + price: price.price.id, + quantity, + }, + ], + tax_id_collection: { + enabled: true, + }, + ...discounts, + mode: 'subscription', + success_url: successUrl, + }); + } + + async saveStripeSubscription(subscription: KnownStripeSubscription) { + const { stripeSubscription, userEmail } = subscription; + + const subscriptionData = this.transformSubscription(subscription); + + const existingSubscription = await this.db.subscription.findFirst({ + where: { + stripeSubscriptionId: stripeSubscription.id, + }, + }); + + if (!existingSubscription) { + const key = randomUUID(); + const [subscription] = await this.db.$transaction([ + this.db.subscription.create({ + data: { + targetId: key, + ...subscriptionData, + }, + }), + this.db.license.create({ + data: { key }, + }), + ]); + + await this.mailer.sendTeamLicenseMail(userEmail, { license: key }); + + return subscription; + } else { + return this.db.subscription.update({ + where: { + stripeSubscriptionId: stripeSubscription.id, + }, + data: pick(subscriptionData, [ + 'status', + 'stripeScheduleId', + 'nextBillAt', + 'canceledAt', + ]), + }); + } + } + + async deleteStripeSubscription({ + stripeSubscription, + }: KnownStripeSubscription) { + const subscription = await this.db.subscription.findFirst({ + where: { stripeSubscriptionId: stripeSubscription.id }, + }); + + if (!subscription) { + return; + } + + await this.db.$transaction([ + this.db.subscription.deleteMany({ + where: { stripeSubscriptionId: stripeSubscription.id }, + }), + this.db.license.deleteMany({ + where: { key: subscription.targetId }, + }), + ]); + } + + getSubscription(identity: z.infer) { + return this.db.subscription.findFirst({ + where: { + targetId: identity.key, + plan: identity.plan, + status: { + in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing], + }, + }, + }); + } + + async cancelSubscription(subscription: Subscription) { + return await this.db.subscription.update({ + where: { + // @ts-expect-error checked outside + stripeSubscriptionId: subscription.stripeSubscriptionId, + }, + data: { + canceledAt: new Date(), + nextBillAt: null, + }, + }); + } + + resumeSubscription(subscription: Subscription): Promise { + return this.db.subscription.update({ + where: { + // @ts-expect-error checked outside + stripeSubscriptionId: subscription.stripeSubscriptionId, + }, + data: { + canceledAt: null, + nextBillAt: subscription.end, + }, + }); + } + + updateSubscriptionRecurring( + subscription: Subscription, + recurring: SubscriptionRecurring + ): Promise { + return this.db.subscription.update({ + where: { + // @ts-expect-error checked outside + stripeSubscriptionId: subscription.stripeSubscriptionId, + }, + data: { recurring }, + }); + } + + async saveInvoice(knownInvoice: KnownStripeInvoice): Promise { + const invoiceData = await this.transformInvoice(knownInvoice); + + return invoiceData; + } +} diff --git a/packages/backend/server/src/plugins/payment/manager/user.ts b/packages/backend/server/src/plugins/payment/manager/user.ts index a42e842845..75b699a91b 100644 --- a/packages/backend/server/src/plugins/payment/manager/user.ts +++ b/packages/backend/server/src/plugins/payment/manager/user.ts @@ -209,6 +209,8 @@ export class UserSubscriptionManager extends SubscriptionManager { async saveStripeSubscription(subscription: KnownStripeSubscription) { const { userId, lookupKey, stripeSubscription } = subscription; + this.assertUserIdExists(userId); + // update features first, features modify are idempotent // so there is no need to skip if a subscription already exists. // TODO(@forehalo): @@ -235,7 +237,7 @@ export class UserSubscriptionManager extends SubscriptionManager { ]), create: { userId, - ...subscriptionData, + ...omit(subscriptionData, 'quantity'), }, }); @@ -261,6 +263,8 @@ export class UserSubscriptionManager extends SubscriptionManager { lookupKey, stripeSubscription, }: KnownStripeSubscription) { + this.assertUserIdExists(userId); + const deleted = await this.db.subscription.deleteMany({ where: { stripeSubscriptionId: stripeSubscription.id, @@ -385,6 +389,7 @@ export class UserSubscriptionManager extends SubscriptionManager { async saveInvoice(knownInvoice: KnownStripeInvoice) { const { userId, lookupKey, stripeInvoice } = knownInvoice; + this.assertUserIdExists(userId); const invoiceData = await this.transformInvoice(knownInvoice); @@ -427,6 +432,8 @@ export class UserSubscriptionManager extends SubscriptionManager { async saveLifetimeSubscription( knownInvoice: KnownStripeInvoice ): Promise { + this.assertUserIdExists(knownInvoice.userId); + // cancel previous non-lifetime subscription const prevSubscription = await this.db.subscription.findUnique({ where: { @@ -492,6 +499,8 @@ export class UserSubscriptionManager extends SubscriptionManager { async saveOnetimePaymentSubscription( knownInvoice: KnownStripeInvoice ): Promise { + this.assertUserIdExists(knownInvoice.userId); + // TODO(@forehalo): identify whether the invoice has already been redeemed. const { userId, lookupKey } = knownInvoice; const existingSubscription = await this.db.subscription.findUnique({ @@ -714,4 +723,12 @@ export class UserSubscriptionManager extends SubscriptionManager { onetime: false, }; } + + private assertUserIdExists( + userId: string | undefined + ): asserts userId is string { + if (!userId) { + throw new Error('user should exists for stripe subscription or invoice.'); + } + } } diff --git a/packages/backend/server/src/plugins/payment/manager/workspace.ts b/packages/backend/server/src/plugins/payment/manager/workspace.ts index c64be38f97..d40183136b 100644 --- a/packages/backend/server/src/plugins/payment/manager/workspace.ts +++ b/packages/backend/server/src/plugins/payment/manager/workspace.ts @@ -128,7 +128,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager { } async saveStripeSubscription(subscription: KnownStripeSubscription) { - const { lookupKey, quantity, stripeSubscription } = subscription; + const { lookupKey, stripeSubscription } = subscription; const workspaceId = stripeSubscription.metadata.workspaceId; @@ -138,31 +138,30 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager { ); } + const subscriptionData = this.transformSubscription(subscription); + this.event.emit('workspace.subscription.activated', { workspaceId, plan: lookupKey.plan, recurring: lookupKey.recurring, - quantity, + quantity: subscriptionData.quantity, }); - const subscriptionData = this.transformSubscription(subscription); - return this.db.subscription.upsert({ where: { stripeSubscriptionId: stripeSubscription.id, }, update: { - quantity, ...pick(subscriptionData, [ 'status', 'stripeScheduleId', 'nextBillAt', 'canceledAt', + 'quantity', ]), }, create: { targetId: workspaceId, - quantity, ...subscriptionData, }, }); diff --git a/packages/backend/server/src/plugins/payment/quota.ts b/packages/backend/server/src/plugins/payment/quota.ts index 7b0dbe3487..a7ba9b745a 100644 --- a/packages/backend/server/src/plugins/payment/quota.ts +++ b/packages/backend/server/src/plugins/payment/quota.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; -import { type EventPayload } from '../../base'; +import type { EventPayload } from '../../base'; +import { FeatureManagementService } from '../../core/features'; import { PermissionService } from '../../core/permission'; import { QuotaManagementService, @@ -9,18 +10,21 @@ import { QuotaType, } from '../../core/quota'; import { WorkspaceService } from '../../core/workspaces/resolvers'; +import { SubscriptionPlan } from './types'; @Injectable() -export class TeamQuotaOverride { +export class QuotaOverride { constructor( private readonly quota: QuotaService, private readonly manager: QuotaManagementService, private readonly permission: PermissionService, - private readonly workspace: WorkspaceService + private readonly workspace: WorkspaceService, + private readonly feature: FeatureManagementService, + private readonly quotaService: QuotaService ) {} @OnEvent('workspace.subscription.activated') - async onSubscriptionUpdated({ + async onWorkspaceSubscriptionUpdated({ workspaceId, plan, recurring, @@ -36,7 +40,7 @@ export class TeamQuotaOverride { workspaceId, `${recurring} team subscription activated` ); - await this.manager.updateWorkspaceConfig( + await this.quota.updateWorkspaceConfig( workspaceId, QuotaType.TeamPlanV1, { memberLimit: quantity } @@ -55,16 +59,67 @@ export class TeamQuotaOverride { } @OnEvent('workspace.subscription.canceled') - async onSubscriptionCanceled({ + async onWorkspaceSubscriptionCanceled({ workspaceId, plan, }: EventPayload<'workspace.subscription.canceled'>) { switch (plan) { - case 'team': + case SubscriptionPlan.Team: await this.manager.removeTeamWorkspace(workspaceId); break; default: break; } } + + @OnEvent('user.subscription.activated') + async onUserSubscriptionUpdated({ + userId, + plan, + recurring, + }: EventPayload<'user.subscription.activated'>) { + switch (plan) { + case SubscriptionPlan.AI: + await this.feature.addCopilot(userId, 'subscription activated'); + break; + case SubscriptionPlan.Pro: + await this.quotaService.switchUserQuota( + userId, + recurring === 'lifetime' + ? QuotaType.LifetimeProPlanV1 + : QuotaType.ProPlanV1, + 'subscription activated' + ); + break; + default: + break; + } + } + + @OnEvent('user.subscription.canceled') + async onUserSubscriptionCanceled({ + userId, + plan, + }: EventPayload<'user.subscription.canceled'>) { + switch (plan) { + case SubscriptionPlan.AI: + await this.feature.removeCopilot(userId); + break; + case SubscriptionPlan.Pro: { + // edge case: when user switch from recurring Pro plan to `Lifetime` plan, + // a subscription canceled event will be triggered because `Lifetime` plan is not subscription based + const quota = await this.quotaService.getUserQuota(userId); + if (quota.feature.name !== QuotaType.LifetimeProPlanV1) { + await this.quotaService.switchUserQuota( + userId, + QuotaType.FreePlanV1, + 'subscription canceled' + ); + } + break; + } + default: + break; + } + } } diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index 010bfc0a72..45c7344e02 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -16,11 +16,14 @@ import type { User } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; import { GraphQLJSONObject } from 'graphql-scalars'; import { groupBy } from 'lodash-es'; +import Stripe from 'stripe'; import { z } from 'zod'; import { AccessDenied, + AuthenticationRequired, FailedToCheckout, + Throttle, WorkspaceIdRequiredToUpdateTeamSubscription, } from '../../base'; import { CurrentUser, Public } from '../../core/auth'; @@ -193,7 +196,7 @@ class CreateCheckoutSessionInput implements z.infer { idempotencyKey?: string; @Field(() => GraphQLJSONObject, { nullable: true }) - args!: { workspaceId?: string }; + args!: { workspaceId?: string; quantity?: number }; } @Resolver(() => SubscriptionType) @@ -261,19 +264,33 @@ export class SubscriptionResolver { }, [] as SubscriptionPrice[]); } + @Public() @Mutation(() => String, { description: 'Create a subscription checkout link of stripe', }) async createCheckoutSession( - @CurrentUser() user: CurrentUser, + @CurrentUser() user: CurrentUser | null, @Args({ name: 'input', type: () => CreateCheckoutSessionInput }) input: CreateCheckoutSessionInput ) { - const session = await this.service.checkout(input, { - plan: input.plan as any, - user, - workspaceId: input.args?.workspaceId, - }); + let session: Stripe.Checkout.Session; + + if (input.plan === SubscriptionPlan.SelfHostedTeam) { + session = await this.service.checkout(input, { + plan: input.plan as any, + quantity: input.args.quantity ?? 10, + }); + } else { + if (!user) { + throw new AuthenticationRequired(); + } + + session = await this.service.checkout(input, { + plan: input.plan as any, + user, + workspaceId: input.args?.workspaceId, + }); + } if (!session.url) { throw new FailedToCheckout(); @@ -415,6 +432,15 @@ export class SubscriptionResolver { idempotencyKey ); } + + @Public() + @Throttle('strict') + @Mutation(() => String) + async generateLicenseKey( + @Args('sessionId', { type: () => String }) sessionId: string + ) { + return this.service.generateLicenseKey(sessionId); + } } @Resolver(() => UserType) diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index f711fc3ee5..dbbcf727b3 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -11,7 +11,9 @@ import { CustomerPortalCreateFailed, InternalServerError, InvalidCheckoutParameters, + InvalidLicenseSessionId, InvalidSubscriptionParameters, + LicenseRevealed, Mutex, OnEvent, SameSubscriptionRecurring, @@ -38,6 +40,11 @@ import { WorkspaceSubscriptionIdentity, WorkspaceSubscriptionManager, } from './manager'; +import { + SelfhostTeamCheckoutArgs, + SelfhostTeamSubscriptionIdentity, + SelfhostTeamSubscriptionManager, +} from './manager/selfhost'; import { ScheduleManager } from './schedule'; import { decodeLookupKey, @@ -56,11 +63,13 @@ import { export const CheckoutExtraArgs = z.union([ UserSubscriptionCheckoutArgs, WorkspaceSubscriptionCheckoutArgs, + SelfhostTeamCheckoutArgs, ]); export const SubscriptionIdentity = z.union([ UserSubscriptionIdentity, WorkspaceSubscriptionIdentity, + SelfhostTeamSubscriptionIdentity, ]); export { CheckoutParams }; @@ -78,6 +87,7 @@ export class SubscriptionService implements OnApplicationBootstrap { private readonly models: Models, private readonly userManager: UserSubscriptionManager, private readonly workspaceManager: WorkspaceSubscriptionManager, + private readonly selfhostManager: SelfhostTeamSubscriptionManager, private readonly mutex: Mutex ) {} @@ -92,6 +102,8 @@ export class SubscriptionService implements OnApplicationBootstrap { case SubscriptionPlan.Pro: case SubscriptionPlan.AI: return this.userManager; + case SubscriptionPlan.SelfHostedTeam: + return this.selfhostManager; default: throw new UnsupportedSubscriptionPlan({ plan }); } @@ -122,7 +134,7 @@ export class SubscriptionService implements OnApplicationBootstrap { if ( this.config.deploy && this.config.affine.canary && - !this.feature.isStaff(args.user.email) + (!('user' in args) || !this.feature.isStaff(args.user.email)) ) { throw new ActionForbidden(); } @@ -291,10 +303,133 @@ export class SubscriptionService implements OnApplicationBootstrap { return newSubscription; } - async createCustomerPortal(id: string) { + async updateSubscriptionQuantity( + identity: z.infer, + count: number + ) { + this.assertSubscriptionIdentity(identity); + + const subscription = await this.select(identity.plan).getSubscription( + identity + ); + + if (!subscription) { + throw new SubscriptionNotExists({ plan: identity.plan }); + } + + if (!subscription.stripeSubscriptionId) { + throw new CantUpdateOnetimePaymentSubscription(); + } + + const stripeSubscription = await this.stripe.subscriptions.retrieve( + subscription.stripeSubscriptionId + ); + + const lookupKey = + retriveLookupKeyFromStripeSubscription(stripeSubscription); + + await this.stripe.subscriptions.update(stripeSubscription.id, { + items: [ + { + id: stripeSubscription.items.data[0].id, + quantity: count, + }, + ], + payment_behavior: 'pending_if_incomplete', + proration_behavior: + lookupKey?.recurring === SubscriptionRecurring.Yearly + ? 'always_invoice' + : 'none', + }); + + if (subscription.stripeScheduleId) { + const schedule = await this.scheduleManager.fromSchedule( + subscription.stripeScheduleId + ); + await schedule.updateQuantity(count); + } + } + + async generateLicenseKey(stripeCheckoutSessionId: string) { + if (!stripeCheckoutSessionId) { + throw new InvalidLicenseSessionId(); + } + + let session: Stripe.Checkout.Session; + try { + session = await this.stripe.checkout.sessions.retrieve( + stripeCheckoutSessionId + ); + } catch { + throw new InvalidLicenseSessionId(); + } + + // session should be complete and have a subscription + if (session.status !== 'complete' || !session.subscription) { + throw new InvalidLicenseSessionId(); + } + + const subscription = + typeof session.subscription === 'string' + ? await this.stripe.subscriptions.retrieve(session.subscription) + : session.subscription; + + const knownSubscription = await this.parseStripeSubscription(subscription); + + // invalid subscription triple + if ( + !knownSubscription || + knownSubscription.lookupKey.plan !== SubscriptionPlan.SelfHostedTeam + ) { + throw new InvalidLicenseSessionId(); + } + + let subInDB = await this.db.subscription.findUnique({ + where: { + stripeSubscriptionId: subscription.id, + }, + }); + + // subscription not found in db + if (!subInDB) { + subInDB = + await this.selfhostManager.saveStripeSubscription(knownSubscription); + } + + const license = await this.db.license.findUnique({ + where: { + key: subInDB.targetId, + }, + }); + + // subscription and license are created in a transaction + // there is no way a sub exist but the license is not created + if (!license) { + throw new Error( + 'unaccessible path. if you see this error, there must be a bug in the codebase.' + ); + } + + if (!license.revealedAt) { + await this.db.license.update({ + where: { + key: license.key, + }, + data: { + revealedAt: new Date(), + }, + }); + + return license.key; + } + + throw new LicenseRevealed(); + } + + async createCustomerPortal(userId: string) { const user = await this.db.userStripeCustomer.findUnique({ where: { - userId: id, + userId: userId, }, }); @@ -416,15 +551,18 @@ export class SubscriptionService implements OnApplicationBootstrap { private async retrieveUserFromCustomer( customer: string | Stripe.Customer | Stripe.DeletedCustomer - ) { + ): Promise<{ id?: string; email: string } | null> { const userStripeCustomer = await this.db.userStripeCustomer.findUnique({ where: { stripeCustomerId: typeof customer === 'string' ? customer : customer.id, }, + select: { + user: true, + }, }); if (userStripeCustomer) { - return userStripeCustomer.userId; + return userStripeCustomer.user; } if (typeof customer === 'string') { @@ -438,17 +576,13 @@ export class SubscriptionService implements OnApplicationBootstrap { const user = await this.models.user.getPublicUserByEmail(customer.email); if (!user) { - return null; + return { + id: undefined, + email: customer.email, + }; } - await this.db.userStripeCustomer.create({ - data: { - userId: user.id, - stripeCustomerId: customer.id, - }, - }); - - return user.id; + return user; } private async listStripePrices(): Promise { @@ -489,14 +623,9 @@ export class SubscriptionService implements OnApplicationBootstrap { invoice.customer_email ); - // TODO(@forehalo): the email may actually not appear to be AFFiNE user - // There is coming feature that allow anonymous user with only email provided to buy selfhost licenses - if (!user) { - return null; - } - return { - userId: user.id, + userId: user?.id, + userEmail: invoice.customer_email, stripeInvoice: invoice, lookupKey, metadata: invoice.subscription_details?.metadata ?? {}, @@ -512,14 +641,18 @@ export class SubscriptionService implements OnApplicationBootstrap { return null; } - const userId = await this.retrieveUserFromCustomer(subscription.customer); + const user = await this.retrieveUserFromCustomer(subscription.customer); - if (!userId) { + // stripe customer got deleted or customer email is null + // it's an invalid status + // maybe we need to check stripe dashboard + if (!user) { return null; } return { - userId, + userId: user.id, + userEmail: user.email, lookupKey, stripeSubscription: subscription, quantity: subscription.items.data[0]?.quantity ?? 1, diff --git a/packages/backend/server/src/plugins/payment/types.ts b/packages/backend/server/src/plugins/payment/types.ts index 6ea4736462..faf0dcc5ca 100644 --- a/packages/backend/server/src/plugins/payment/types.ts +++ b/packages/backend/server/src/plugins/payment/types.ts @@ -16,6 +16,7 @@ export enum SubscriptionPlan { Team = 'team', Enterprise = 'enterprise', SelfHosted = 'selfhosted', + SelfHostedTeam = 'selfhostedteam', } export enum SubscriptionVariant { @@ -97,7 +98,9 @@ export interface KnownStripeInvoice { /** * User in AFFiNE system. */ - userId: string; + userId?: string; + + userEmail: string; /** * The lookup key of the price that the invoice is for. @@ -119,7 +122,9 @@ export interface KnownStripeSubscription { /** * User in AFFiNE system. */ - userId: string; + userId?: string; + + userEmail: string; /** * The lookup key of the price that the invoice is for. @@ -215,6 +220,16 @@ export const DEFAULT_PRICES = new Map([ `${SubscriptionPlan.Team}_${SubscriptionRecurring.Yearly}`, { product: 'AFFiNE Team(per seat)', price: 14400 }, ], + + // selfhost team + [ + `${SubscriptionPlan.SelfHostedTeam}_${SubscriptionRecurring.Monthly}`, + { product: 'AFFiNE Self-hosted Team(per seat)', price: 1500 }, + ], + [ + `${SubscriptionPlan.SelfHostedTeam}_${SubscriptionRecurring.Yearly}`, + { product: 'AFFiNE Self-hosted Team(per seat)', price: 14400 }, + ], ]); // [Plan x Recurring x Variant] make a stripe price lookup key diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index a5c168609a..ff9f512984 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -209,7 +209,7 @@ type EditorType { name: String! } -union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WrongSignInCredentialsDataType +union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WrongSignInCredentialsDataType enum ErrorNames { ACCESS_DENIED @@ -250,10 +250,15 @@ enum ErrorNames { INVALID_EMAIL INVALID_EMAIL_TOKEN INVALID_HISTORY_TIMESTAMP + INVALID_LICENSE_SESSION_ID + INVALID_LICENSE_TO_ACTIVATE + INVALID_LICENSE_UPDATE_PARAMS INVALID_OAUTH_CALLBACK_STATE INVALID_PASSWORD_LENGTH INVALID_RUNTIME_CONFIG_TYPE INVALID_SUBSCRIPTION_PARAMETERS + LICENSE_NOT_FOUND + LICENSE_REVEALED LINK_EXPIRED MAILER_SERVICE_IS_NOT_CONFIGURED MEMBER_NOT_FOUND_IN_SPACE @@ -288,6 +293,8 @@ enum ErrorNames { VERSION_REJECTED WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION + WORKSPACE_LICENSE_ALREADY_EXISTS + WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE WRONG_SIGN_IN_CREDENTIALS WRONG_SIGN_IN_METHOD } @@ -330,6 +337,10 @@ type InvalidHistoryTimestampDataType { timestamp: String! } +type InvalidLicenseUpdateParamsDataType { + reason: String! +} + type InvalidPasswordLengthDataType { max: Int! min: Int! @@ -444,6 +455,14 @@ The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404]( """ scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") +type License { + expiredAt: DateTime + installedAt: DateTime! + quantity: Int! + recurring: SubscriptionRecurring! + validatedAt: DateTime! +} + type LimitedUserType { """User email""" email: String! @@ -482,6 +501,7 @@ type MissingOauthQueryParameterDataType { type Mutation { acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! + activateLicense(license: String!, workspaceId: String!): License! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! approveMember(userId: String!, workspaceId: String!): String! cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! @@ -509,12 +529,14 @@ type Mutation { """Create a stripe customer portal to manage payment methods""" createCustomerPortal: String! createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink! + createSelfhostWorkspaceCustomerPortal(workspaceId: String!): String! """Create a new user""" createUser(input: CreateUserInput!): UserType! """Create a new workspace""" createWorkspace(init: Upload): WorkspaceType! + deactivateLicense(workspaceId: String!): Boolean! deleteAccount: DeleteAccount! deleteBlob(hash: String @deprecated(reason: "use parameter [key]"), key: String, permanently: Boolean! = false, workspaceId: String!): Boolean! @@ -524,6 +546,7 @@ type Mutation { """Create a chat session""" forkCopilotSession(options: ForkChatSessionInput!): String! + generateLicenseKey(sessionId: String!): String! grantMember(permission: Permission!, userId: String!, workspaceId: String!): String! invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String! inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]! @@ -800,6 +823,7 @@ enum SubscriptionPlan { Free Pro SelfHosted + SelfHostedTeam Team } @@ -976,6 +1000,10 @@ enum WorkspaceMemberStatus { UnderReview } +type WorkspaceMembersExceedLimitToDowngradeDataType { + limit: Int! +} + type WorkspacePage { id: String! mode: PublicPageMode! @@ -1024,6 +1052,9 @@ type WorkspaceType { invoiceCount: Int! invoices(skip: Int, take: Int = 8): [InvoiceType!]! + """The selfhost license of the workspace""" + license: License + """member count of workspace""" memberCount: Int! diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 346dc1793a..b6eac30be6 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -276,6 +276,7 @@ export type ErrorDataUnion = | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType + | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType @@ -292,6 +293,7 @@ export type ErrorDataUnion = | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType + | WorkspaceMembersExceedLimitToDowngradeDataType | WrongSignInCredentialsDataType; export enum ErrorNames { @@ -333,10 +335,15 @@ export enum ErrorNames { INVALID_EMAIL = 'INVALID_EMAIL', INVALID_EMAIL_TOKEN = 'INVALID_EMAIL_TOKEN', INVALID_HISTORY_TIMESTAMP = 'INVALID_HISTORY_TIMESTAMP', + INVALID_LICENSE_SESSION_ID = 'INVALID_LICENSE_SESSION_ID', + INVALID_LICENSE_TO_ACTIVATE = 'INVALID_LICENSE_TO_ACTIVATE', + INVALID_LICENSE_UPDATE_PARAMS = 'INVALID_LICENSE_UPDATE_PARAMS', INVALID_OAUTH_CALLBACK_STATE = 'INVALID_OAUTH_CALLBACK_STATE', INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH', INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE', INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS', + LICENSE_NOT_FOUND = 'LICENSE_NOT_FOUND', + LICENSE_REVEALED = 'LICENSE_REVEALED', LINK_EXPIRED = 'LINK_EXPIRED', MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED', MEMBER_NOT_FOUND_IN_SPACE = 'MEMBER_NOT_FOUND_IN_SPACE', @@ -371,6 +378,8 @@ export enum ErrorNames { VERSION_REJECTED = 'VERSION_REJECTED', WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION', WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION', + WORKSPACE_LICENSE_ALREADY_EXISTS = 'WORKSPACE_LICENSE_ALREADY_EXISTS', + WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE = 'WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE', WRONG_SIGN_IN_CREDENTIALS = 'WRONG_SIGN_IN_CREDENTIALS', WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD', } @@ -413,6 +422,11 @@ export interface InvalidHistoryTimestampDataType { timestamp: Scalars['String']['output']; } +export interface InvalidLicenseUpdateParamsDataType { + __typename?: 'InvalidLicenseUpdateParamsDataType'; + reason: Scalars['String']['output']; +} + export interface InvalidPasswordLengthDataType { __typename?: 'InvalidPasswordLengthDataType'; max: Scalars['Int']['output']; @@ -591,6 +605,7 @@ export interface Mutation { deleteWorkspace: Scalars['Boolean']['output']; /** Create a chat session */ forkCopilotSession: Scalars['String']['output']; + generateLicenseKey: Scalars['String']['output']; grantMember: Scalars['String']['output']; invite: Scalars['String']['output']; inviteBatch: Array; @@ -729,6 +744,10 @@ export interface MutationForkCopilotSessionArgs { options: ForkChatSessionInput; } +export interface MutationGenerateLicenseKeyArgs { + sessionId: Scalars['String']['input']; +} + export interface MutationGrantMemberArgs { permission: Permission; userId: Scalars['String']['input']; @@ -1148,6 +1167,7 @@ export enum SubscriptionPlan { Free = 'Free', Pro = 'Pro', SelfHosted = 'SelfHosted', + SelfHostedTeam = 'SelfHostedTeam', Team = 'Team', } @@ -1330,6 +1350,11 @@ export enum WorkspaceMemberStatus { UnderReview = 'UnderReview', } +export interface WorkspaceMembersExceedLimitToDowngradeDataType { + __typename?: 'WorkspaceMembersExceedLimitToDowngradeDataType'; + limit: Scalars['Int']['output']; +} + export interface WorkspacePage { __typename?: 'WorkspacePage'; id: Scalars['String']['output'];