diff --git a/packages/backend/server/migrations/20241213092654_licenses/migration.sql b/packages/backend/server/migrations/20241213092654_licenses/migration.sql new file mode 100644 index 0000000000000..86cdd3711b591 --- /dev/null +++ b/packages/backend/server/migrations/20241213092654_licenses/migration.sql @@ -0,0 +1,24 @@ +-- 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, + "installed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "validate_key" VARCHAR, + "validated_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/schema.prisma b/packages/backend/server/schema.prisma index ce5315b46ecd3..394cf3a42df64 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -569,15 +569,36 @@ 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 + 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) + + @@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/app.module.ts b/packages/backend/server/src/app.module.ts index d6764ab9c203d..c12870f0a4e8c 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -37,6 +37,7 @@ import { SyncModule } from './core/sync'; import { UserModule } from './core/user'; import { WorkspaceModule } from './core/workspaces'; import { REGISTERED_PLUGINS } from './plugins'; +import { LicenseModule } from './plugins/license'; import { ENABLED_PLUGINS } from './plugins/registry'; export const FunctionalityModules = [ @@ -174,7 +175,7 @@ export function buildAppModule() { ) // self hosted server only - .useIf(config => config.isSelfhosted, SelfhostModule) + .useIf(config => config.isSelfhosted, SelfhostModule, LicenseModule) .useIf(config => config.flavor.renderer, DocRendererModule); // plugin modules diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 21b9e354f3681..c2f455ee0fbf7 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -592,4 +592,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 e87010b0bed99..6ca8d250ce5ca 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -587,6 +587,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, @@ -665,7 +715,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' @@ -674,5 +731,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', types: () => - [WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, 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, 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 cf1f1cde21613..75362809661af 100644 --- a/packages/backend/server/src/base/mailer/mail.service.ts +++ b/packages/backend/server/src/base/mailer/mail.service.ts @@ -311,4 +311,13 @@ export class MailService { }); return this.sendMail({ to, subject: title, html }); } + + async sendLicenseGeneratedEmail(to: string, licenseKey: string) { + const html = emailTemplate({ + title: 'Your license key for AFFiNE self-hosted workspace', + content: `Your license key is:
${licenseKey}`, + }); + + return this.sendMail({ to, subject: 'Your AFFiNE license key', html }); + } } diff --git a/packages/backend/server/src/base/storage/providers/utils.ts b/packages/backend/server/src/base/storage/providers/utils.ts index 4c7e9323f2888..3ae687b06841a 100644 --- a/packages/backend/server/src/base/storage/providers/utils.ts +++ b/packages/backend/server/src/base/storage/providers/utils.ts @@ -1,14 +1,14 @@ import { Readable } from 'node:stream'; import { crc32 } from '@node-rs/crc32'; -import { getStreamAsBuffer } from 'get-stream'; +import getStream from 'get-stream'; import { getMime } from '../../../native'; import { BlobInputType, PutObjectMetadata } from './provider'; export async function toBuffer(input: BlobInputType): Promise { return input instanceof Readable - ? await getStreamAsBuffer(input) + ? await getStream.buffer(input) : input instanceof Buffer ? input : Buffer.from(input); diff --git a/packages/backend/server/src/core/quota/service.ts b/packages/backend/server/src/core/quota/service.ts index e28795c00dcc1..659c071f792d7 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, @@ -314,55 +309,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/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index a32c4765374f2..4b59e780d0f38 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import { getStreamAsBuffer } from 'get-stream'; +import getSteam from 'get-stream'; import { Cache, MailService } from '../../../base'; import { DocContentService } from '../../doc-renderer'; @@ -67,7 +67,7 @@ export class WorkspaceService { ); if (avatarBlob.body) { - avatar = (await getStreamAsBuffer(avatarBlob.body)).toString('base64'); + avatar = (await getSteam.buffer(avatarBlob.body)).toString('base64'); } } 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..b08566e360658 --- /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 { LicenseService } from './service'; + +@OptionalModule({ + if: config => config.isSelfhosted, + imports: [QuotaModule, PermissionModule], + providers: [LicenseService], +}) +export class LicenseModule {} 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..5c59b9ec36343 --- /dev/null +++ b/packages/backend/server/src/plugins/license/service.ts @@ -0,0 +1,309 @@ +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, + WorkspaceMembersExceedLimitToDowngrade, +} 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 activateTeamLicense(workspaceId: string, license: string) { + const isTeam = await this.quota.isTeamWorkspace(workspaceId); + + if (isTeam) { + throw new WorkspaceLicenseAlreadyExists(); + } + + const res = await this.fetch( + `/api/team/licenses/${license}/activate`, + { + method: 'POST', + } + ); + + await this.db.installedLicense.upsert({ + where: { + workspaceId, + }, + update: { + key: license, + validatedAt: new Date(), + validateKey: res.res.headers.get('x-next-validate-key'), + }, + create: { + key: license, + workspaceId, + validatedAt: new Date(), + validateKey: res.res.headers.get('x-next-validate-key'), + }, + }); + + this.event.emit('workspace.subscription.activated', { + workspaceId, + plan: res.plan, + recurring: res.recurring, + quantity: res.quantity, + }); + } + + async deactivateTeamLicense(workspaceId: string) { + const license = await this.db.installedLicense.findUnique({ + where: { + workspaceId, + }, + }); + + if (!license) { + throw new LicenseNotFound(); + } + + const count = await this.db.workspaceUserPermission.count({ + where: { + workspaceId, + }, + }); + + // TODO(@forehalo): we need a way to get the default quota for workspace + if (count > 10) { + throw new WorkspaceMembersExceedLimitToDowngrade({ limit: 10 }); + } + + 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, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + @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); + } + + 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'), + }, + }); + + 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); + + 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 1538843a2ffaf..1276f9e206df6 100644 --- a/packages/backend/server/src/plugins/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -8,11 +8,13 @@ import { UserModule } from '../../core/user'; 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, @@ -33,11 +35,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..ec8e11a01ace6 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/license/controller.ts @@ -0,0 +1,231 @@ +import { randomUUID } from 'node:crypto'; + +import { + Body, + Controller, + Get, + Headers, + HttpStatus, + Param, + Post, + Res, +} from '@nestjs/common'; +import { PrismaClient, Subscription } from '@prisma/client'; +import type { Response } from 'express'; +import { z } from 'zod'; + +import { + 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 { + constructor( + private readonly db: PrismaClient, + private readonly mutex: Mutex, + private readonly subscription: SubscriptionService, + private readonly manager: SelfhostTeamSubscriptionManager + ) {} + + @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 + ); + } + + 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..abebb91a2d71c --- /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.sendLicenseGeneratedEmail(userEmail, 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 683c6cf12b874..f0ed6d2215a13 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): @@ -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 00ae9ce53ac11..6eede37437fdf 100644 --- a/packages/backend/server/src/plugins/payment/quota.ts +++ b/packages/backend/server/src/plugins/payment/quota.ts @@ -2,30 +2,38 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import type { EventPayload } from '../../base'; +import { FeatureManagementService } from '../../core/features'; import { PermissionService } from '../../core/permission'; -import { QuotaManagementService, QuotaType } from '../../core/quota'; +import { + QuotaManagementService, + QuotaService, + QuotaType, +} from '../../core/quota'; +import { SubscriptionPlan } from './types'; @Injectable() -export class TeamQuotaOverride { +export class QuotaOverride { constructor( - private readonly manager: QuotaManagementService, + private readonly quota: QuotaManagementService, + private readonly feature: FeatureManagementService, + private readonly quotaService: QuotaService, private readonly permission: PermissionService ) {} @OnEvent('workspace.subscription.activated') - async onSubscriptionUpdated({ + async onWorkspaceSubscriptionUpdated({ workspaceId, plan, recurring, quantity, }: EventPayload<'workspace.subscription.activated'>) { switch (plan) { - case 'team': - await this.manager.addTeamWorkspace( + case SubscriptionPlan.Team: + await this.quota.addTeamWorkspace( workspaceId, `${recurring} team subscription activated` ); - await this.manager.updateWorkspaceConfig( + await this.quota.updateWorkspaceConfig( workspaceId, QuotaType.TeamPlanV1, { memberLimit: quantity } @@ -38,16 +46,67 @@ export class TeamQuotaOverride { } @OnEvent('workspace.subscription.canceled') - async onSubscriptionCanceled({ + async onWorkspaceSubscriptionCanceled({ workspaceId, plan, }: EventPayload<'workspace.subscription.canceled'>) { switch (plan) { - case 'team': - await this.manager.removeTeamWorkspace(workspaceId); + case SubscriptionPlan.Team: + await this.quota.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 90cceaa800b5f..da2b007db74fd 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 user: UserService, 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,6 +303,129 @@ export class SubscriptionService implements OnApplicationBootstrap { return newSubscription; } + 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(id: string) { const user = await this.db.userStripeCustomer.findUnique({ where: { @@ -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.user.findUserByEmail(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 { @@ -487,14 +621,9 @@ export class SubscriptionService implements OnApplicationBootstrap { const user = await this.user.findUserByEmail(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 ?? {}, @@ -510,14 +639,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 f2179b0222d48..22b3c40901d0c 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 { @@ -95,7 +96,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. @@ -117,7 +120,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. @@ -213,6 +218,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 19763fda7eb0e..599ee47e172b2 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 | 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 | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WrongSignInCredentialsDataType enum ErrorNames { ACCESS_DENIED @@ -251,10 +251,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_QUOTA_EXCEEDED @@ -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! @@ -520,6 +531,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!, sendInviteMail: Boolean, workspaceId: String!): String! inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]! @@ -793,6 +805,7 @@ enum SubscriptionPlan { Free Pro SelfHosted + SelfHostedTeam Team } @@ -963,6 +976,10 @@ enum WorkspaceMemberStatus { UnderReview } +type WorkspaceMembersExceedLimitToDowngradeDataType { + limit: Int! +} + type WorkspacePage { id: String! mode: PublicPageMode! diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 4baeb351ad7e8..5d1f810aa0f08 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -271,6 +271,7 @@ export type ErrorDataUnion = | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType + | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType @@ -286,6 +287,7 @@ export type ErrorDataUnion = | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType + | WorkspaceMembersExceedLimitToDowngradeDataType | WrongSignInCredentialsDataType; export enum ErrorNames { @@ -328,10 +330,13 @@ export enum ErrorNames { INVALID_EMAIL = 'INVALID_EMAIL', INVALID_EMAIL_TOKEN = 'INVALID_EMAIL_TOKEN', INVALID_HISTORY_TIMESTAMP = 'INVALID_HISTORY_TIMESTAMP', + 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', LINK_EXPIRED = 'LINK_EXPIRED', MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED', MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED', @@ -365,6 +370,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', } @@ -407,6 +414,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']; @@ -1133,6 +1145,7 @@ export enum SubscriptionPlan { Free = 'Free', Pro = 'Pro', SelfHosted = 'SelfHosted', + SelfHostedTeam = 'SelfHostedTeam', Team = 'Team', } @@ -1315,6 +1328,11 @@ export enum WorkspaceMemberStatus { UnderReview = 'UnderReview', } +export interface WorkspaceMembersExceedLimitToDowngradeDataType { + __typename?: 'WorkspaceMembersExceedLimitToDowngradeDataType'; + limit: Scalars['Int']['output']; +} + export interface WorkspacePage { __typename?: 'WorkspacePage'; id: Scalars['String']['output'];