From 994d758c076c84f20f9327e1c7b7e37cbb172145 Mon Sep 17 00:00:00 2001 From: forehalo Date: Wed, 22 Jan 2025 10:21:07 +0000 Subject: [PATCH] feat(server): support selfhost licenses (#8947) --- .docker/dev/.env.example | 1 - .../20250110034441_licenses/migration.sql | 27 ++ .../server/migrations/migration_lock.toml | 2 +- packages/backend/server/schema.prisma | 34 +- .../__tests__/__snapshots__/mailer.spec.ts.md | 81 +++++ .../__snapshots__/mailer.spec.ts.snap | Bin 4004 -> 4131 bytes packages/backend/server/src/app.module.ts | 7 +- packages/backend/server/src/base/error/def.ts | 34 ++ .../server/src/base/error/errors.gen.ts | 61 +++- .../backend/server/src/base/helpers/url.ts | 18 + .../server/src/base/mailer/mail.service.ts | 2 + .../backend/server/src/core/quota/service.ts | 60 +-- packages/backend/server/src/mails/index.tsx | 6 + .../backend/server/src/mails/teams/index.ts | 1 + .../server/src/mails/teams/license.tsx | 33 ++ .../server/src/plugins/license/index.ts | 11 + .../server/src/plugins/license/resolver.ts | 126 +++++++ .../server/src/plugins/license/service.ts | 343 ++++++++++++++++++ .../server/src/plugins/payment/index.ts | 9 +- .../src/plugins/payment/license/controller.ts | 269 ++++++++++++++ .../src/plugins/payment/manager/common.ts | 26 +- .../src/plugins/payment/manager/index.ts | 1 + .../src/plugins/payment/manager/selfhost.ts | 231 ++++++++++++ .../src/plugins/payment/manager/user.ts | 19 +- .../src/plugins/payment/manager/workspace.ts | 11 +- .../server/src/plugins/payment/quota.ts | 69 +++- .../server/src/plugins/payment/resolver.ts | 40 +- .../server/src/plugins/payment/service.ts | 181 +++++++-- .../server/src/plugins/payment/types.ts | 19 +- packages/backend/server/src/schema.gql | 33 +- packages/frontend/graphql/src/schema.ts | 25 ++ 31 files changed, 1653 insertions(+), 127 deletions(-) create mode 100644 packages/backend/server/migrations/20250110034441_licenses/migration.sql create mode 100644 packages/backend/server/src/mails/teams/license.tsx create mode 100644 packages/backend/server/src/plugins/license/index.ts create mode 100644 packages/backend/server/src/plugins/license/resolver.ts create mode 100644 packages/backend/server/src/plugins/license/service.ts create mode 100644 packages/backend/server/src/plugins/payment/license/controller.ts create mode 100644 packages/backend/server/src/plugins/payment/manager/selfhost.ts diff --git a/.docker/dev/.env.example b/.docker/dev/.env.example index b6beb2deb4db3..c8c53b42f9946 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 0000000000000..75d5b78678e55 --- /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 99e4f20090794..fbffa92c2bb7c 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 ce5315b46ecd3..47d8edde1c8d5 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 f34bd485cc64a..ad0c8b89c6007 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 4c90e4f6a7010283a9ad9b429c9436e181be09fd..6408e7fdf3220eda0e7a4a123c0583f4115bdfcc 100644 GIT binary patch literal 4131 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 diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index 32ec5fe373c19..86a7ff034e35d 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 d02c5b7c5c288..3efa8d6bb8f43 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 a17feb5b84ac5..99fb8a2f1a4e2 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 48bf54fe18016..9a2bbf05a50a2 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 25736b9c6dac4..5dc85332168b2 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 c8609f7c7eda7..e782603de4e7b 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 df0c702001d72..3d91806573fc6 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 659068e0d98a7..9a58a5f472340 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 0000000000000..04447c1ead5c8 --- /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 0000000000000..854ee67a9eb77 --- /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 0000000000000..463c759e9cd09 --- /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 0000000000000..5d1491224b228 --- /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 8d01b152ae3a8..4504b7f233851 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 0000000000000..0b9e587be3bb7 --- /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 f8aede9e451f8..c7661e4a5c5d1 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 21d7a26a20784..8a03bb3f398fb 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 0000000000000..0af3c109075c5 --- /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 a42e842845f10..75b699a91b003 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 c64be38f979ab..d40183136b33d 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 7b0dbe3487092..a7ba9b745a468 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 010bfc0a729ec..45c7344e027c1 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 f711fc3ee555a..dbbcf727b3a02 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 6ea473646236a..faf0dcc5caa8c 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 a5c168609aa98..ff9f512984bbe 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 346dc1793a9ff..b6eac30be6fc4 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'];