feat(server): support selfhost licenses (#8947)

This commit is contained in:
forehalo
2025-01-22 10:21:07 +00:00
parent 22e424d7de
commit 994d758c07
31 changed files with 1653 additions and 127 deletions

View File

@@ -1,4 +1,3 @@
DATABASE_LOCATION=./postgres
DB_PASSWORD=affine DB_PASSWORD=affine
DB_USERNAME=affine DB_USERNAME=affine
DB_DATABASE_NAME=affine DB_DATABASE_NAME=affine

View File

@@ -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");

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (i.e. Git)
provider = "postgresql" provider = "postgresql"

View File

@@ -569,15 +569,39 @@ model Invoice {
@@index([targetId]) @@index([targetId])
@@map("invoices") @@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. // Blob table only exists for fast non-data queries.
// like, total size of blobs in a workspace, or blob list for sync service. // 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 // it should only be a map of metadata of blobs stored anywhere else
model Blob { model Blob {
workspaceId String @map("workspace_id") @db.VarChar workspaceId String @map("workspace_id") @db.VarChar
key String @db.VarChar key String @db.VarChar
size Int @db.Integer size Int @db.Integer
mime String @db.VarChar mime String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)

View File

@@ -1635,6 +1635,87 @@ Generated by [AVA](https://avajs.dev).
<!--/$-->␊ <!--/$-->␊
` `
> Your AFFiNE Self-Hosted Team Workspace license is ready
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊
<!--$-->␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody>␊
<tr>␊
<td>␊
<p␊
style="font-size:20px;line-height:28px;margin:24px 0 0;font-weight:600;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
Here is your license key.␊
</p>␊
</td>␊
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody>␊
<tr>␊
<td>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<pre␊
style="font-size:15px;font-weight:400;line-height:24px;font-family:Inter, Arial, Helvetica, sans-serif;margin:24px 0 0;color:#141414;white-space:nowrap;border:1px solid rgba(0,0,0,.1);padding:8px 10px;border-radius:4px;background-color:#F5F5F5"␊
>␊
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</pre␊
>␊
</tr>␊
</tbody>␊
</table>␊
<table␊
align="center"␊
width="100%"␊
border="0"␊
cellpadding="0"␊
cellspacing="0"␊
role="presentation"␊
>␊
<tbody style="width:100%">␊
<tr style="width:100%">␊
<p␊
style="font-size:15px;line-height:24px;margin:24px 0 0;font-weight:400;font-family:Inter, Arial, Helvetica, sans-serif;color:#141414"␊
>␊
You can use this key to upgrade your selfhost workspace in<!-- -->␊
<span style="font-weight:600"␊
>Settings &gt; Workspace &gt; License</span␊
>.␊
</p>␊
</tr>␊
</tbody>␊
</table>␊
</td>␊
</tr>␊
</tbody>␊
</table>␊
<!--/$-->␊
`
> Your workspace Test Workspace has been deleted > Your workspace Test Workspace has been deleted
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊ `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">␊

View File

@@ -46,6 +46,7 @@ import { UserModule } from './core/user';
import { WorkspaceModule } from './core/workspaces'; import { WorkspaceModule } from './core/workspaces';
import { ModelsModule } from './models'; import { ModelsModule } from './models';
import { REGISTERED_PLUGINS } from './plugins'; import { REGISTERED_PLUGINS } from './plugins';
import { LicenseModule } from './plugins/license';
import { ENABLED_PLUGINS } from './plugins/registry'; import { ENABLED_PLUGINS } from './plugins/registry';
export const FunctionalityModules = [ export const FunctionalityModules = [
@@ -203,7 +204,8 @@ export function buildAppModule() {
GqlModule, GqlModule,
StorageModule, StorageModule,
ServerConfigModule, ServerConfigModule,
WorkspaceModule WorkspaceModule,
LicenseModule
) )
// self hosted server only // self hosted server only
@@ -214,7 +216,8 @@ export function buildAppModule() {
ENABLED_PLUGINS.forEach(name => { ENABLED_PLUGINS.forEach(name => {
const plugin = REGISTERED_PLUGINS.get(name); const plugin = REGISTERED_PLUGINS.get(name);
if (!plugin) { if (!plugin) {
throw new Error(`Unknown plugin ${name}`); new Logger('AppBuilder').warn(`Unknown plugin ${name}`);
return;
} }
factor.use(plugin); factor.use(plugin);

View File

@@ -607,4 +607,38 @@ export const USER_FRIENDLY_ERRORS = {
type: 'bad_request', type: 'bad_request',
message: 'Captcha verification failed.', 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<string, UserFriendlyErrorOptions>; } satisfies Record<string, UserFriendlyErrorOptions>;

View File

@@ -591,6 +591,56 @@ export class CaptchaVerificationFailed extends UserFriendlyError {
super('bad_request', 'captcha_verification_failed', message); 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 { export enum ErrorNames {
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST, TOO_MANY_REQUEST,
@@ -669,7 +719,14 @@ export enum ErrorNames {
MAILER_SERVICE_IS_NOT_CONFIGURED, MAILER_SERVICE_IS_NOT_CONFIGURED,
CANNOT_DELETE_ALL_ADMIN_ACCOUNT, CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
CANNOT_DELETE_OWN_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, { registerEnumType(ErrorNames, {
name: 'ErrorNames' name: 'ErrorNames'
@@ -678,5 +735,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({ export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion', name: 'ErrorDataUnion',
types: () => 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,
}); });

View File

@@ -46,6 +46,24 @@ export class URLHelper {
return new URLSearchParams(query).toString(); 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<string, any> = {}) { url(path: string, query: Record<string, any> = {}) {
const url = new URL(path, this.origin); const url = new URL(path, this.origin);

View File

@@ -22,6 +22,7 @@ import {
renderTeamBecomeCollaboratorMail, renderTeamBecomeCollaboratorMail,
renderTeamDeleteIn1MonthMail, renderTeamDeleteIn1MonthMail,
renderTeamDeleteIn24HoursMail, renderTeamDeleteIn24HoursMail,
renderTeamLicenseMail,
renderTeamWorkspaceDeletedMail, renderTeamWorkspaceDeletedMail,
renderTeamWorkspaceExpiredMail, renderTeamWorkspaceExpiredMail,
renderTeamWorkspaceExpireSoonMail, renderTeamWorkspaceExpireSoonMail,
@@ -188,4 +189,5 @@ export class MailService {
renderTeamWorkspaceExpireSoonMail renderTeamWorkspaceExpireSoonMail
); );
sendTeamExpiredMail = this.makeWorkspace(renderTeamWorkspaceExpiredMail); sendTeamExpiredMail = this.makeWorkspace(renderTeamWorkspaceExpiredMail);
sendTeamLicenseMail = this.make(renderTeamLicenseMail);
} }

View File

@@ -1,19 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import type { EventPayload } from '../../base'; import { PrismaTransaction } from '../../base';
import { OnEvent, PrismaTransaction } from '../../base';
import { FeatureManagementService } from '../features/management';
import { FeatureKind } from '../features/types'; import { FeatureKind } from '../features/types';
import { QuotaConfig } from './quota'; import { QuotaConfig } from './quota';
import { QuotaType } from './types'; import { QuotaType } from './types';
@Injectable() @Injectable()
export class QuotaService { export class QuotaService {
constructor( constructor(private readonly prisma: PrismaClient) {}
private readonly prisma: PrismaClient,
private readonly feature: FeatureManagementService
) {}
async getQuota<Q extends QuotaType>( async getQuota<Q extends QuotaType>(
quota: Q, quota: Q,
@@ -331,55 +326,4 @@ export class QuotaService {
}); });
return r.count; 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;
}
}
} }

View File

@@ -7,6 +7,7 @@ import {
TeamDeleteInOneMonth, TeamDeleteInOneMonth,
TeamExpired, TeamExpired,
TeamExpireSoon, TeamExpireSoon,
TeamLicense,
TeamWorkspaceDeleted, TeamWorkspaceDeleted,
TeamWorkspaceUpgraded, TeamWorkspaceUpgraded,
} from './teams'; } from './teams';
@@ -175,3 +176,8 @@ export const renderTeamWorkspaceExpiredMail = make(
TeamExpired, TeamExpired,
props => `Your ${props.workspace.name} team workspace has expired` props => `Your ${props.workspace.name} team workspace has expired`
); );
export const renderTeamLicenseMail = make(
TeamLicense,
'Your AFFiNE Self-Hosted Team Workspace license is ready'
);

View File

@@ -23,6 +23,7 @@ export {
type TeamExpireSoonProps, type TeamExpireSoonProps,
} from './expire-soon'; } from './expire-soon';
export { default as TeamExpired, type TeamExpiredProps } from './expired'; export { default as TeamExpired, type TeamExpiredProps } from './expired';
export { default as TeamLicense, type TeamLicenseProps } from './license';
export { export {
default as TeamWorkspaceUpgraded, default as TeamWorkspaceUpgraded,
type TeamWorkspaceUpgradedProps, type TeamWorkspaceUpgradedProps,

View File

@@ -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 (
<Template>
<Title>Here is your license key.</Title>
<Content>
<OnelineCodeBlock>{license}</OnelineCodeBlock>
<P>
You can use this key to upgrade your selfhost workspace in{' '}
<Bold>Settings &gt; Workspace &gt; License</Bold>.
</P>
</Content>
</Template>
);
}
TeamLicense.PreviewProps = {
license: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
};

View File

@@ -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 {}

View File

@@ -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<License | null> {
// 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;
}
}

View File

@@ -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<License>(
`/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<License>(
`/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<T = any>(
path: string,
init?: RequestInit
): Promise<T & { res: Response }> {
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;
}
}
}

View File

@@ -9,11 +9,13 @@ import { WorkspaceModule } from '../../core/workspaces';
import { Plugin } from '../registry'; import { Plugin } from '../registry';
import { StripeWebhookController } from './controller'; import { StripeWebhookController } from './controller';
import { SubscriptionCronJobs } from './cron'; import { SubscriptionCronJobs } from './cron';
import { LicenseController } from './license/controller';
import { import {
SelfhostTeamSubscriptionManager,
UserSubscriptionManager, UserSubscriptionManager,
WorkspaceSubscriptionManager, WorkspaceSubscriptionManager,
} from './manager'; } from './manager';
import { TeamQuotaOverride } from './quota'; import { QuotaOverride } from './quota';
import { import {
SubscriptionResolver, SubscriptionResolver,
UserSubscriptionResolver, UserSubscriptionResolver,
@@ -40,11 +42,12 @@ import { StripeWebhook } from './webhook';
StripeWebhook, StripeWebhook,
UserSubscriptionManager, UserSubscriptionManager,
WorkspaceSubscriptionManager, WorkspaceSubscriptionManager,
SelfhostTeamSubscriptionManager,
SubscriptionCronJobs, SubscriptionCronJobs,
WorkspaceSubscriptionResolver, WorkspaceSubscriptionResolver,
TeamQuotaOverride, QuotaOverride,
], ],
controllers: [StripeWebhookController], controllers: [StripeWebhookController, LicenseController],
requires: [ requires: [
'plugins.payment.stripe.keys.APIKey', 'plugins.payment.stripe.keys.APIKey',
'plugins.payment.stripe.keys.webhookKey', 'plugins.payment.stripe.keys.webhookKey',

View File

@@ -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<typeof UpdateSeatsParams>
) {
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<typeof UpdateRecurringParams>
) {
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(),
};
}
}

View File

@@ -22,6 +22,7 @@ export interface Subscription {
plan: string; plan: string;
recurring: string; recurring: string;
variant: string | null; variant: string | null;
quantity: number;
start: Date; start: Date;
end: Date | null; end: Date | null;
trialStart: Date | null; trialStart: Date | null;
@@ -99,11 +100,13 @@ export abstract class SubscriptionManager {
transformSubscription({ transformSubscription({
lookupKey, lookupKey,
stripeSubscription: subscription, stripeSubscription: subscription,
quantity,
}: KnownStripeSubscription): Subscription { }: KnownStripeSubscription): Subscription {
return { return {
...lookupKey, ...lookupKey,
stripeScheduleId: subscription.schedule as string | null, stripeScheduleId: subscription.schedule as string | null,
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
quantity,
status: subscription.status, status: subscription.status,
start: new Date(subscription.current_period_start * 1000), start: new Date(subscription.current_period_start * 1000),
end: new Date(subscription.current_period_end * 1000), end: new Date(subscription.current_period_end * 1000),
@@ -224,7 +227,7 @@ export abstract class SubscriptionManager {
protected async getCouponFromPromotionCode( protected async getCouponFromPromotionCode(
userFacingPromotionCode: string, userFacingPromotionCode: string,
customer: UserStripeCustomer customer?: UserStripeCustomer
) { ) {
const list = await this.stripe.promotionCodes.list({ const list = await this.stripe.promotionCodes.list({
code: userFacingPromotionCode, code: userFacingPromotionCode,
@@ -243,11 +246,20 @@ export abstract class SubscriptionManager {
// code.coupon.applies_to.products.forEach() // code.coupon.applies_to.products.forEach()
// check if the code is bound to a specific customer // check if the code is bound to a specific customer
return !code.customer || if (code.customer) {
(typeof code.customer === 'string' if (!customer) {
? code.customer === customer.stripeCustomerId return null;
: code.customer.id === customer.stripeCustomerId) }
? code.coupon.id
: null; return (
typeof code.customer === 'string'
? code.customer === customer.stripeCustomerId
: code.customer.id === customer.stripeCustomerId
)
? code.coupon.id
: null;
}
return code.coupon.id;
} }
} }

View File

@@ -1,3 +1,4 @@
export * from './common'; export * from './common';
export * from './selfhost';
export * from './user'; export * from './user';
export * from './workspace'; export * from './workspace';

View File

@@ -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<typeof CheckoutParams>,
args: z.infer<typeof SelfhostTeamCheckoutArgs>
) {
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<typeof SelfhostTeamSubscriptionIdentity>) {
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<Subscription> {
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<Subscription> {
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: { recurring },
});
}
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
const invoiceData = await this.transformInvoice(knownInvoice);
return invoiceData;
}
}

View File

@@ -209,6 +209,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveStripeSubscription(subscription: KnownStripeSubscription) { async saveStripeSubscription(subscription: KnownStripeSubscription) {
const { userId, lookupKey, stripeSubscription } = subscription; const { userId, lookupKey, stripeSubscription } = subscription;
this.assertUserIdExists(userId);
// update features first, features modify are idempotent // update features first, features modify are idempotent
// so there is no need to skip if a subscription already exists. // so there is no need to skip if a subscription already exists.
// TODO(@forehalo): // TODO(@forehalo):
@@ -235,7 +237,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
]), ]),
create: { create: {
userId, userId,
...subscriptionData, ...omit(subscriptionData, 'quantity'),
}, },
}); });
@@ -261,6 +263,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
lookupKey, lookupKey,
stripeSubscription, stripeSubscription,
}: KnownStripeSubscription) { }: KnownStripeSubscription) {
this.assertUserIdExists(userId);
const deleted = await this.db.subscription.deleteMany({ const deleted = await this.db.subscription.deleteMany({
where: { where: {
stripeSubscriptionId: stripeSubscription.id, stripeSubscriptionId: stripeSubscription.id,
@@ -385,6 +389,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveInvoice(knownInvoice: KnownStripeInvoice) { async saveInvoice(knownInvoice: KnownStripeInvoice) {
const { userId, lookupKey, stripeInvoice } = knownInvoice; const { userId, lookupKey, stripeInvoice } = knownInvoice;
this.assertUserIdExists(userId);
const invoiceData = await this.transformInvoice(knownInvoice); const invoiceData = await this.transformInvoice(knownInvoice);
@@ -427,6 +432,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveLifetimeSubscription( async saveLifetimeSubscription(
knownInvoice: KnownStripeInvoice knownInvoice: KnownStripeInvoice
): Promise<Subscription> { ): Promise<Subscription> {
this.assertUserIdExists(knownInvoice.userId);
// cancel previous non-lifetime subscription // cancel previous non-lifetime subscription
const prevSubscription = await this.db.subscription.findUnique({ const prevSubscription = await this.db.subscription.findUnique({
where: { where: {
@@ -492,6 +499,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveOnetimePaymentSubscription( async saveOnetimePaymentSubscription(
knownInvoice: KnownStripeInvoice knownInvoice: KnownStripeInvoice
): Promise<Subscription> { ): Promise<Subscription> {
this.assertUserIdExists(knownInvoice.userId);
// TODO(@forehalo): identify whether the invoice has already been redeemed. // TODO(@forehalo): identify whether the invoice has already been redeemed.
const { userId, lookupKey } = knownInvoice; const { userId, lookupKey } = knownInvoice;
const existingSubscription = await this.db.subscription.findUnique({ const existingSubscription = await this.db.subscription.findUnique({
@@ -714,4 +723,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
onetime: false, onetime: false,
}; };
} }
private assertUserIdExists(
userId: string | undefined
): asserts userId is string {
if (!userId) {
throw new Error('user should exists for stripe subscription or invoice.');
}
}
} }

View File

@@ -128,7 +128,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
} }
async saveStripeSubscription(subscription: KnownStripeSubscription) { async saveStripeSubscription(subscription: KnownStripeSubscription) {
const { lookupKey, quantity, stripeSubscription } = subscription; const { lookupKey, stripeSubscription } = subscription;
const workspaceId = stripeSubscription.metadata.workspaceId; 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', { this.event.emit('workspace.subscription.activated', {
workspaceId, workspaceId,
plan: lookupKey.plan, plan: lookupKey.plan,
recurring: lookupKey.recurring, recurring: lookupKey.recurring,
quantity, quantity: subscriptionData.quantity,
}); });
const subscriptionData = this.transformSubscription(subscription);
return this.db.subscription.upsert({ return this.db.subscription.upsert({
where: { where: {
stripeSubscriptionId: stripeSubscription.id, stripeSubscriptionId: stripeSubscription.id,
}, },
update: { update: {
quantity,
...pick(subscriptionData, [ ...pick(subscriptionData, [
'status', 'status',
'stripeScheduleId', 'stripeScheduleId',
'nextBillAt', 'nextBillAt',
'canceledAt', 'canceledAt',
'quantity',
]), ]),
}, },
create: { create: {
targetId: workspaceId, targetId: workspaceId,
quantity,
...subscriptionData, ...subscriptionData,
}, },
}); });

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; 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 { PermissionService } from '../../core/permission';
import { import {
QuotaManagementService, QuotaManagementService,
@@ -9,18 +10,21 @@ import {
QuotaType, QuotaType,
} from '../../core/quota'; } from '../../core/quota';
import { WorkspaceService } from '../../core/workspaces/resolvers'; import { WorkspaceService } from '../../core/workspaces/resolvers';
import { SubscriptionPlan } from './types';
@Injectable() @Injectable()
export class TeamQuotaOverride { export class QuotaOverride {
constructor( constructor(
private readonly quota: QuotaService, private readonly quota: QuotaService,
private readonly manager: QuotaManagementService, private readonly manager: QuotaManagementService,
private readonly permission: PermissionService, private readonly permission: PermissionService,
private readonly workspace: WorkspaceService private readonly workspace: WorkspaceService,
private readonly feature: FeatureManagementService,
private readonly quotaService: QuotaService
) {} ) {}
@OnEvent('workspace.subscription.activated') @OnEvent('workspace.subscription.activated')
async onSubscriptionUpdated({ async onWorkspaceSubscriptionUpdated({
workspaceId, workspaceId,
plan, plan,
recurring, recurring,
@@ -36,7 +40,7 @@ export class TeamQuotaOverride {
workspaceId, workspaceId,
`${recurring} team subscription activated` `${recurring} team subscription activated`
); );
await this.manager.updateWorkspaceConfig( await this.quota.updateWorkspaceConfig(
workspaceId, workspaceId,
QuotaType.TeamPlanV1, QuotaType.TeamPlanV1,
{ memberLimit: quantity } { memberLimit: quantity }
@@ -55,16 +59,67 @@ export class TeamQuotaOverride {
} }
@OnEvent('workspace.subscription.canceled') @OnEvent('workspace.subscription.canceled')
async onSubscriptionCanceled({ async onWorkspaceSubscriptionCanceled({
workspaceId, workspaceId,
plan, plan,
}: EventPayload<'workspace.subscription.canceled'>) { }: EventPayload<'workspace.subscription.canceled'>) {
switch (plan) { switch (plan) {
case 'team': case SubscriptionPlan.Team:
await this.manager.removeTeamWorkspace(workspaceId); await this.manager.removeTeamWorkspace(workspaceId);
break; break;
default: default:
break; 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;
}
}
} }

View File

@@ -16,11 +16,14 @@ import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { GraphQLJSONObject } from 'graphql-scalars'; import { GraphQLJSONObject } from 'graphql-scalars';
import { groupBy } from 'lodash-es'; import { groupBy } from 'lodash-es';
import Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
import { import {
AccessDenied, AccessDenied,
AuthenticationRequired,
FailedToCheckout, FailedToCheckout,
Throttle,
WorkspaceIdRequiredToUpdateTeamSubscription, WorkspaceIdRequiredToUpdateTeamSubscription,
} from '../../base'; } from '../../base';
import { CurrentUser, Public } from '../../core/auth'; import { CurrentUser, Public } from '../../core/auth';
@@ -193,7 +196,7 @@ class CreateCheckoutSessionInput implements z.infer<typeof CheckoutParams> {
idempotencyKey?: string; idempotencyKey?: string;
@Field(() => GraphQLJSONObject, { nullable: true }) @Field(() => GraphQLJSONObject, { nullable: true })
args!: { workspaceId?: string }; args!: { workspaceId?: string; quantity?: number };
} }
@Resolver(() => SubscriptionType) @Resolver(() => SubscriptionType)
@@ -261,19 +264,33 @@ export class SubscriptionResolver {
}, [] as SubscriptionPrice[]); }, [] as SubscriptionPrice[]);
} }
@Public()
@Mutation(() => String, { @Mutation(() => String, {
description: 'Create a subscription checkout link of stripe', description: 'Create a subscription checkout link of stripe',
}) })
async createCheckoutSession( async createCheckoutSession(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser | null,
@Args({ name: 'input', type: () => CreateCheckoutSessionInput }) @Args({ name: 'input', type: () => CreateCheckoutSessionInput })
input: CreateCheckoutSessionInput input: CreateCheckoutSessionInput
) { ) {
const session = await this.service.checkout(input, { let session: Stripe.Checkout.Session;
plan: input.plan as any,
user, if (input.plan === SubscriptionPlan.SelfHostedTeam) {
workspaceId: input.args?.workspaceId, 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) { if (!session.url) {
throw new FailedToCheckout(); throw new FailedToCheckout();
@@ -415,6 +432,15 @@ export class SubscriptionResolver {
idempotencyKey idempotencyKey
); );
} }
@Public()
@Throttle('strict')
@Mutation(() => String)
async generateLicenseKey(
@Args('sessionId', { type: () => String }) sessionId: string
) {
return this.service.generateLicenseKey(sessionId);
}
} }
@Resolver(() => UserType) @Resolver(() => UserType)

View File

@@ -11,7 +11,9 @@ import {
CustomerPortalCreateFailed, CustomerPortalCreateFailed,
InternalServerError, InternalServerError,
InvalidCheckoutParameters, InvalidCheckoutParameters,
InvalidLicenseSessionId,
InvalidSubscriptionParameters, InvalidSubscriptionParameters,
LicenseRevealed,
Mutex, Mutex,
OnEvent, OnEvent,
SameSubscriptionRecurring, SameSubscriptionRecurring,
@@ -38,6 +40,11 @@ import {
WorkspaceSubscriptionIdentity, WorkspaceSubscriptionIdentity,
WorkspaceSubscriptionManager, WorkspaceSubscriptionManager,
} from './manager'; } from './manager';
import {
SelfhostTeamCheckoutArgs,
SelfhostTeamSubscriptionIdentity,
SelfhostTeamSubscriptionManager,
} from './manager/selfhost';
import { ScheduleManager } from './schedule'; import { ScheduleManager } from './schedule';
import { import {
decodeLookupKey, decodeLookupKey,
@@ -56,11 +63,13 @@ import {
export const CheckoutExtraArgs = z.union([ export const CheckoutExtraArgs = z.union([
UserSubscriptionCheckoutArgs, UserSubscriptionCheckoutArgs,
WorkspaceSubscriptionCheckoutArgs, WorkspaceSubscriptionCheckoutArgs,
SelfhostTeamCheckoutArgs,
]); ]);
export const SubscriptionIdentity = z.union([ export const SubscriptionIdentity = z.union([
UserSubscriptionIdentity, UserSubscriptionIdentity,
WorkspaceSubscriptionIdentity, WorkspaceSubscriptionIdentity,
SelfhostTeamSubscriptionIdentity,
]); ]);
export { CheckoutParams }; export { CheckoutParams };
@@ -78,6 +87,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
private readonly models: Models, private readonly models: Models,
private readonly userManager: UserSubscriptionManager, private readonly userManager: UserSubscriptionManager,
private readonly workspaceManager: WorkspaceSubscriptionManager, private readonly workspaceManager: WorkspaceSubscriptionManager,
private readonly selfhostManager: SelfhostTeamSubscriptionManager,
private readonly mutex: Mutex private readonly mutex: Mutex
) {} ) {}
@@ -92,6 +102,8 @@ export class SubscriptionService implements OnApplicationBootstrap {
case SubscriptionPlan.Pro: case SubscriptionPlan.Pro:
case SubscriptionPlan.AI: case SubscriptionPlan.AI:
return this.userManager; return this.userManager;
case SubscriptionPlan.SelfHostedTeam:
return this.selfhostManager;
default: default:
throw new UnsupportedSubscriptionPlan({ plan }); throw new UnsupportedSubscriptionPlan({ plan });
} }
@@ -122,7 +134,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
if ( if (
this.config.deploy && this.config.deploy &&
this.config.affine.canary && this.config.affine.canary &&
!this.feature.isStaff(args.user.email) (!('user' in args) || !this.feature.isStaff(args.user.email))
) { ) {
throw new ActionForbidden(); throw new ActionForbidden();
} }
@@ -291,10 +303,133 @@ export class SubscriptionService implements OnApplicationBootstrap {
return newSubscription; return newSubscription;
} }
async createCustomerPortal(id: string) { async updateSubscriptionQuantity(
identity: z.infer<typeof SubscriptionIdentity>,
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({ const user = await this.db.userStripeCustomer.findUnique({
where: { where: {
userId: id, userId: userId,
}, },
}); });
@@ -416,15 +551,18 @@ export class SubscriptionService implements OnApplicationBootstrap {
private async retrieveUserFromCustomer( private async retrieveUserFromCustomer(
customer: string | Stripe.Customer | Stripe.DeletedCustomer customer: string | Stripe.Customer | Stripe.DeletedCustomer
) { ): Promise<{ id?: string; email: string } | null> {
const userStripeCustomer = await this.db.userStripeCustomer.findUnique({ const userStripeCustomer = await this.db.userStripeCustomer.findUnique({
where: { where: {
stripeCustomerId: typeof customer === 'string' ? customer : customer.id, stripeCustomerId: typeof customer === 'string' ? customer : customer.id,
}, },
select: {
user: true,
},
}); });
if (userStripeCustomer) { if (userStripeCustomer) {
return userStripeCustomer.userId; return userStripeCustomer.user;
} }
if (typeof customer === 'string') { if (typeof customer === 'string') {
@@ -438,17 +576,13 @@ export class SubscriptionService implements OnApplicationBootstrap {
const user = await this.models.user.getPublicUserByEmail(customer.email); const user = await this.models.user.getPublicUserByEmail(customer.email);
if (!user) { if (!user) {
return null; return {
id: undefined,
email: customer.email,
};
} }
await this.db.userStripeCustomer.create({ return user;
data: {
userId: user.id,
stripeCustomerId: customer.id,
},
});
return user.id;
} }
private async listStripePrices(): Promise<KnownStripePrice[]> { private async listStripePrices(): Promise<KnownStripePrice[]> {
@@ -489,14 +623,9 @@ export class SubscriptionService implements OnApplicationBootstrap {
invoice.customer_email 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 { return {
userId: user.id, userId: user?.id,
userEmail: invoice.customer_email,
stripeInvoice: invoice, stripeInvoice: invoice,
lookupKey, lookupKey,
metadata: invoice.subscription_details?.metadata ?? {}, metadata: invoice.subscription_details?.metadata ?? {},
@@ -512,14 +641,18 @@ export class SubscriptionService implements OnApplicationBootstrap {
return null; 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 null;
} }
return { return {
userId, userId: user.id,
userEmail: user.email,
lookupKey, lookupKey,
stripeSubscription: subscription, stripeSubscription: subscription,
quantity: subscription.items.data[0]?.quantity ?? 1, quantity: subscription.items.data[0]?.quantity ?? 1,

View File

@@ -16,6 +16,7 @@ export enum SubscriptionPlan {
Team = 'team', Team = 'team',
Enterprise = 'enterprise', Enterprise = 'enterprise',
SelfHosted = 'selfhosted', SelfHosted = 'selfhosted',
SelfHostedTeam = 'selfhostedteam',
} }
export enum SubscriptionVariant { export enum SubscriptionVariant {
@@ -97,7 +98,9 @@ export interface KnownStripeInvoice {
/** /**
* User in AFFiNE system. * User in AFFiNE system.
*/ */
userId: string; userId?: string;
userEmail: string;
/** /**
* The lookup key of the price that the invoice is for. * The lookup key of the price that the invoice is for.
@@ -119,7 +122,9 @@ export interface KnownStripeSubscription {
/** /**
* User in AFFiNE system. * User in AFFiNE system.
*/ */
userId: string; userId?: string;
userEmail: string;
/** /**
* The lookup key of the price that the invoice is for. * 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}`, `${SubscriptionPlan.Team}_${SubscriptionRecurring.Yearly}`,
{ product: 'AFFiNE Team(per seat)', price: 14400 }, { 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 // [Plan x Recurring x Variant] make a stripe price lookup key

View File

@@ -209,7 +209,7 @@ type EditorType {
name: String! 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 { enum ErrorNames {
ACCESS_DENIED ACCESS_DENIED
@@ -250,10 +250,15 @@ enum ErrorNames {
INVALID_EMAIL INVALID_EMAIL
INVALID_EMAIL_TOKEN INVALID_EMAIL_TOKEN
INVALID_HISTORY_TIMESTAMP INVALID_HISTORY_TIMESTAMP
INVALID_LICENSE_SESSION_ID
INVALID_LICENSE_TO_ACTIVATE
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_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
@@ -288,6 +293,8 @@ 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_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE
WRONG_SIGN_IN_CREDENTIALS WRONG_SIGN_IN_CREDENTIALS
WRONG_SIGN_IN_METHOD WRONG_SIGN_IN_METHOD
} }
@@ -330,6 +337,10 @@ type InvalidHistoryTimestampDataType {
timestamp: String! timestamp: String!
} }
type InvalidLicenseUpdateParamsDataType {
reason: String!
}
type InvalidPasswordLengthDataType { type InvalidPasswordLengthDataType {
max: Int! max: Int!
min: 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") 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 { type LimitedUserType {
"""User email""" """User email"""
email: String! email: String!
@@ -482,6 +501,7 @@ type MissingOauthQueryParameterDataType {
type Mutation { type Mutation {
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
activateLicense(license: String!, workspaceId: String!): License!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
approveMember(userId: String!, workspaceId: String!): String! approveMember(userId: String!, workspaceId: String!): String!
cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! 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""" """Create a stripe customer portal to manage payment methods"""
createCustomerPortal: String! createCustomerPortal: String!
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink! createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink!
createSelfhostWorkspaceCustomerPortal(workspaceId: String!): String!
"""Create a new user""" """Create a new user"""
createUser(input: CreateUserInput!): UserType! createUser(input: CreateUserInput!): UserType!
"""Create a new workspace""" """Create a new workspace"""
createWorkspace(init: Upload): WorkspaceType! createWorkspace(init: Upload): WorkspaceType!
deactivateLicense(workspaceId: String!): Boolean!
deleteAccount: DeleteAccount! deleteAccount: DeleteAccount!
deleteBlob(hash: String @deprecated(reason: "use parameter [key]"), key: String, permanently: Boolean! = false, workspaceId: String!): Boolean! 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""" """Create a chat session"""
forkCopilotSession(options: ForkChatSessionInput!): String! forkCopilotSession(options: ForkChatSessionInput!): String!
generateLicenseKey(sessionId: String!): String!
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String! grantMember(permission: Permission!, userId: String!, workspaceId: String!): String!
invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, 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!]! inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
@@ -800,6 +823,7 @@ enum SubscriptionPlan {
Free Free
Pro Pro
SelfHosted SelfHosted
SelfHostedTeam
Team Team
} }
@@ -976,6 +1000,10 @@ enum WorkspaceMemberStatus {
UnderReview UnderReview
} }
type WorkspaceMembersExceedLimitToDowngradeDataType {
limit: Int!
}
type WorkspacePage { type WorkspacePage {
id: String! id: String!
mode: PublicPageMode! mode: PublicPageMode!
@@ -1024,6 +1052,9 @@ type WorkspaceType {
invoiceCount: Int! invoiceCount: Int!
invoices(skip: Int, take: Int = 8): [InvoiceType!]! invoices(skip: Int, take: Int = 8): [InvoiceType!]!
"""The selfhost license of the workspace"""
license: License
"""member count of workspace""" """member count of workspace"""
memberCount: Int! memberCount: Int!

View File

@@ -276,6 +276,7 @@ export type ErrorDataUnion =
| DocNotFoundDataType | DocNotFoundDataType
| InvalidEmailDataType | InvalidEmailDataType
| InvalidHistoryTimestampDataType | InvalidHistoryTimestampDataType
| InvalidLicenseUpdateParamsDataType
| InvalidPasswordLengthDataType | InvalidPasswordLengthDataType
| InvalidRuntimeConfigTypeDataType | InvalidRuntimeConfigTypeDataType
| MemberNotFoundInSpaceDataType | MemberNotFoundInSpaceDataType
@@ -292,6 +293,7 @@ export type ErrorDataUnion =
| UnknownOauthProviderDataType | UnknownOauthProviderDataType
| UnsupportedSubscriptionPlanDataType | UnsupportedSubscriptionPlanDataType
| VersionRejectedDataType | VersionRejectedDataType
| WorkspaceMembersExceedLimitToDowngradeDataType
| WrongSignInCredentialsDataType; | WrongSignInCredentialsDataType;
export enum ErrorNames { export enum ErrorNames {
@@ -333,10 +335,15 @@ export enum ErrorNames {
INVALID_EMAIL = 'INVALID_EMAIL', INVALID_EMAIL = 'INVALID_EMAIL',
INVALID_EMAIL_TOKEN = 'INVALID_EMAIL_TOKEN', INVALID_EMAIL_TOKEN = 'INVALID_EMAIL_TOKEN',
INVALID_HISTORY_TIMESTAMP = 'INVALID_HISTORY_TIMESTAMP', 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_OAUTH_CALLBACK_STATE = 'INVALID_OAUTH_CALLBACK_STATE',
INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH', INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH',
INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE', INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE',
INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS', INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS',
LICENSE_NOT_FOUND = 'LICENSE_NOT_FOUND',
LICENSE_REVEALED = 'LICENSE_REVEALED',
LINK_EXPIRED = 'LINK_EXPIRED', LINK_EXPIRED = 'LINK_EXPIRED',
MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED', MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED',
MEMBER_NOT_FOUND_IN_SPACE = 'MEMBER_NOT_FOUND_IN_SPACE', MEMBER_NOT_FOUND_IN_SPACE = 'MEMBER_NOT_FOUND_IN_SPACE',
@@ -371,6 +378,8 @@ export enum ErrorNames {
VERSION_REJECTED = 'VERSION_REJECTED', VERSION_REJECTED = 'VERSION_REJECTED',
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION', 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_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_CREDENTIALS = 'WRONG_SIGN_IN_CREDENTIALS',
WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD', WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD',
} }
@@ -413,6 +422,11 @@ export interface InvalidHistoryTimestampDataType {
timestamp: Scalars['String']['output']; timestamp: Scalars['String']['output'];
} }
export interface InvalidLicenseUpdateParamsDataType {
__typename?: 'InvalidLicenseUpdateParamsDataType';
reason: Scalars['String']['output'];
}
export interface InvalidPasswordLengthDataType { export interface InvalidPasswordLengthDataType {
__typename?: 'InvalidPasswordLengthDataType'; __typename?: 'InvalidPasswordLengthDataType';
max: Scalars['Int']['output']; max: Scalars['Int']['output'];
@@ -591,6 +605,7 @@ export interface Mutation {
deleteWorkspace: Scalars['Boolean']['output']; deleteWorkspace: Scalars['Boolean']['output'];
/** Create a chat session */ /** Create a chat session */
forkCopilotSession: Scalars['String']['output']; forkCopilotSession: Scalars['String']['output'];
generateLicenseKey: Scalars['String']['output'];
grantMember: Scalars['String']['output']; grantMember: Scalars['String']['output'];
invite: Scalars['String']['output']; invite: Scalars['String']['output'];
inviteBatch: Array<InviteResult>; inviteBatch: Array<InviteResult>;
@@ -729,6 +744,10 @@ export interface MutationForkCopilotSessionArgs {
options: ForkChatSessionInput; options: ForkChatSessionInput;
} }
export interface MutationGenerateLicenseKeyArgs {
sessionId: Scalars['String']['input'];
}
export interface MutationGrantMemberArgs { export interface MutationGrantMemberArgs {
permission: Permission; permission: Permission;
userId: Scalars['String']['input']; userId: Scalars['String']['input'];
@@ -1148,6 +1167,7 @@ export enum SubscriptionPlan {
Free = 'Free', Free = 'Free',
Pro = 'Pro', Pro = 'Pro',
SelfHosted = 'SelfHosted', SelfHosted = 'SelfHosted',
SelfHostedTeam = 'SelfHostedTeam',
Team = 'Team', Team = 'Team',
} }
@@ -1330,6 +1350,11 @@ export enum WorkspaceMemberStatus {
UnderReview = 'UnderReview', UnderReview = 'UnderReview',
} }
export interface WorkspaceMembersExceedLimitToDowngradeDataType {
__typename?: 'WorkspaceMembersExceedLimitToDowngradeDataType';
limit: Scalars['Int']['output'];
}
export interface WorkspacePage { export interface WorkspacePage {
__typename?: 'WorkspacePage'; __typename?: 'WorkspacePage';
id: Scalars['String']['output']; id: Scalars['String']['output'];