mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(server): support selfhost licenses (#8947)
This commit is contained in:
@@ -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
|
||||||
@@ -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");
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 > Workspace > 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">␊
|
||||||
|
|||||||
Binary file not shown.
@@ -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);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
33
packages/backend/server/src/mails/teams/license.tsx
Normal file
33
packages/backend/server/src/mails/teams/license.tsx
Normal 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 > Workspace > License</Bold>.
|
||||||
|
</P>
|
||||||
|
</Content>
|
||||||
|
</Template>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TeamLicense.PreviewProps = {
|
||||||
|
license: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
||||||
|
};
|
||||||
11
packages/backend/server/src/plugins/license/index.ts
Normal file
11
packages/backend/server/src/plugins/license/index.ts
Normal 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 {}
|
||||||
126
packages/backend/server/src/plugins/license/resolver.ts
Normal file
126
packages/backend/server/src/plugins/license/resolver.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
343
packages/backend/server/src/plugins/license/service.ts
Normal file
343
packages/backend/server/src/plugins/license/service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
231
packages/backend/server/src/plugins/payment/manager/selfhost.ts
Normal file
231
packages/backend/server/src/plugins/payment/manager/selfhost.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
Reference in New Issue
Block a user