mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(server): support selfhost licenses (#8947)
This commit is contained in:
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 { StripeWebhookController } from './controller';
|
||||
import { SubscriptionCronJobs } from './cron';
|
||||
import { LicenseController } from './license/controller';
|
||||
import {
|
||||
SelfhostTeamSubscriptionManager,
|
||||
UserSubscriptionManager,
|
||||
WorkspaceSubscriptionManager,
|
||||
} from './manager';
|
||||
import { TeamQuotaOverride } from './quota';
|
||||
import { QuotaOverride } from './quota';
|
||||
import {
|
||||
SubscriptionResolver,
|
||||
UserSubscriptionResolver,
|
||||
@@ -40,11 +42,12 @@ import { StripeWebhook } from './webhook';
|
||||
StripeWebhook,
|
||||
UserSubscriptionManager,
|
||||
WorkspaceSubscriptionManager,
|
||||
SelfhostTeamSubscriptionManager,
|
||||
SubscriptionCronJobs,
|
||||
WorkspaceSubscriptionResolver,
|
||||
TeamQuotaOverride,
|
||||
QuotaOverride,
|
||||
],
|
||||
controllers: [StripeWebhookController],
|
||||
controllers: [StripeWebhookController, LicenseController],
|
||||
requires: [
|
||||
'plugins.payment.stripe.keys.APIKey',
|
||||
'plugins.payment.stripe.keys.webhookKey',
|
||||
|
||||
@@ -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;
|
||||
recurring: string;
|
||||
variant: string | null;
|
||||
quantity: number;
|
||||
start: Date;
|
||||
end: Date | null;
|
||||
trialStart: Date | null;
|
||||
@@ -99,11 +100,13 @@ export abstract class SubscriptionManager {
|
||||
transformSubscription({
|
||||
lookupKey,
|
||||
stripeSubscription: subscription,
|
||||
quantity,
|
||||
}: KnownStripeSubscription): Subscription {
|
||||
return {
|
||||
...lookupKey,
|
||||
stripeScheduleId: subscription.schedule as string | null,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
quantity,
|
||||
status: subscription.status,
|
||||
start: new Date(subscription.current_period_start * 1000),
|
||||
end: new Date(subscription.current_period_end * 1000),
|
||||
@@ -224,7 +227,7 @@ export abstract class SubscriptionManager {
|
||||
|
||||
protected async getCouponFromPromotionCode(
|
||||
userFacingPromotionCode: string,
|
||||
customer: UserStripeCustomer
|
||||
customer?: UserStripeCustomer
|
||||
) {
|
||||
const list = await this.stripe.promotionCodes.list({
|
||||
code: userFacingPromotionCode,
|
||||
@@ -243,11 +246,20 @@ export abstract class SubscriptionManager {
|
||||
// code.coupon.applies_to.products.forEach()
|
||||
|
||||
// check if the code is bound to a specific customer
|
||||
return !code.customer ||
|
||||
(typeof code.customer === 'string'
|
||||
? code.customer === customer.stripeCustomerId
|
||||
: code.customer.id === customer.stripeCustomerId)
|
||||
? code.coupon.id
|
||||
: null;
|
||||
if (code.customer) {
|
||||
if (!customer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof code.customer === 'string'
|
||||
? code.customer === customer.stripeCustomerId
|
||||
: code.customer.id === customer.stripeCustomerId
|
||||
)
|
||||
? code.coupon.id
|
||||
: null;
|
||||
}
|
||||
|
||||
return code.coupon.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './common';
|
||||
export * from './selfhost';
|
||||
export * from './user';
|
||||
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) {
|
||||
const { userId, lookupKey, stripeSubscription } = subscription;
|
||||
this.assertUserIdExists(userId);
|
||||
|
||||
// update features first, features modify are idempotent
|
||||
// so there is no need to skip if a subscription already exists.
|
||||
// TODO(@forehalo):
|
||||
@@ -235,7 +237,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
]),
|
||||
create: {
|
||||
userId,
|
||||
...subscriptionData,
|
||||
...omit(subscriptionData, 'quantity'),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -261,6 +263,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
lookupKey,
|
||||
stripeSubscription,
|
||||
}: KnownStripeSubscription) {
|
||||
this.assertUserIdExists(userId);
|
||||
|
||||
const deleted = await this.db.subscription.deleteMany({
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
@@ -385,6 +389,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
async saveInvoice(knownInvoice: KnownStripeInvoice) {
|
||||
const { userId, lookupKey, stripeInvoice } = knownInvoice;
|
||||
this.assertUserIdExists(userId);
|
||||
|
||||
const invoiceData = await this.transformInvoice(knownInvoice);
|
||||
|
||||
@@ -427,6 +432,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
async saveLifetimeSubscription(
|
||||
knownInvoice: KnownStripeInvoice
|
||||
): Promise<Subscription> {
|
||||
this.assertUserIdExists(knownInvoice.userId);
|
||||
|
||||
// cancel previous non-lifetime subscription
|
||||
const prevSubscription = await this.db.subscription.findUnique({
|
||||
where: {
|
||||
@@ -492,6 +499,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
async saveOnetimePaymentSubscription(
|
||||
knownInvoice: KnownStripeInvoice
|
||||
): Promise<Subscription> {
|
||||
this.assertUserIdExists(knownInvoice.userId);
|
||||
|
||||
// TODO(@forehalo): identify whether the invoice has already been redeemed.
|
||||
const { userId, lookupKey } = knownInvoice;
|
||||
const existingSubscription = await this.db.subscription.findUnique({
|
||||
@@ -714,4 +723,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
onetime: false,
|
||||
};
|
||||
}
|
||||
|
||||
private assertUserIdExists(
|
||||
userId: string | undefined
|
||||
): asserts userId is string {
|
||||
if (!userId) {
|
||||
throw new Error('user should exists for stripe subscription or invoice.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
async saveStripeSubscription(subscription: KnownStripeSubscription) {
|
||||
const { lookupKey, quantity, stripeSubscription } = subscription;
|
||||
const { lookupKey, stripeSubscription } = subscription;
|
||||
|
||||
const workspaceId = stripeSubscription.metadata.workspaceId;
|
||||
|
||||
@@ -138,31 +138,30 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionData = this.transformSubscription(subscription);
|
||||
|
||||
this.event.emit('workspace.subscription.activated', {
|
||||
workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
quantity,
|
||||
quantity: subscriptionData.quantity,
|
||||
});
|
||||
|
||||
const subscriptionData = this.transformSubscription(subscription);
|
||||
|
||||
return this.db.subscription.upsert({
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
update: {
|
||||
quantity,
|
||||
...pick(subscriptionData, [
|
||||
'status',
|
||||
'stripeScheduleId',
|
||||
'nextBillAt',
|
||||
'canceledAt',
|
||||
'quantity',
|
||||
]),
|
||||
},
|
||||
create: {
|
||||
targetId: workspaceId,
|
||||
quantity,
|
||||
...subscriptionData,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
|
||||
import { type EventPayload } from '../../base';
|
||||
import type { EventPayload } from '../../base';
|
||||
import { FeatureManagementService } from '../../core/features';
|
||||
import { PermissionService } from '../../core/permission';
|
||||
import {
|
||||
QuotaManagementService,
|
||||
@@ -9,18 +10,21 @@ import {
|
||||
QuotaType,
|
||||
} from '../../core/quota';
|
||||
import { WorkspaceService } from '../../core/workspaces/resolvers';
|
||||
import { SubscriptionPlan } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class TeamQuotaOverride {
|
||||
export class QuotaOverride {
|
||||
constructor(
|
||||
private readonly quota: QuotaService,
|
||||
private readonly manager: QuotaManagementService,
|
||||
private readonly permission: PermissionService,
|
||||
private readonly workspace: WorkspaceService
|
||||
private readonly workspace: WorkspaceService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly quotaService: QuotaService
|
||||
) {}
|
||||
|
||||
@OnEvent('workspace.subscription.activated')
|
||||
async onSubscriptionUpdated({
|
||||
async onWorkspaceSubscriptionUpdated({
|
||||
workspaceId,
|
||||
plan,
|
||||
recurring,
|
||||
@@ -36,7 +40,7 @@ export class TeamQuotaOverride {
|
||||
workspaceId,
|
||||
`${recurring} team subscription activated`
|
||||
);
|
||||
await this.manager.updateWorkspaceConfig(
|
||||
await this.quota.updateWorkspaceConfig(
|
||||
workspaceId,
|
||||
QuotaType.TeamPlanV1,
|
||||
{ memberLimit: quantity }
|
||||
@@ -55,16 +59,67 @@ export class TeamQuotaOverride {
|
||||
}
|
||||
|
||||
@OnEvent('workspace.subscription.canceled')
|
||||
async onSubscriptionCanceled({
|
||||
async onWorkspaceSubscriptionCanceled({
|
||||
workspaceId,
|
||||
plan,
|
||||
}: EventPayload<'workspace.subscription.canceled'>) {
|
||||
switch (plan) {
|
||||
case 'team':
|
||||
case SubscriptionPlan.Team:
|
||||
await this.manager.removeTeamWorkspace(workspaceId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('user.subscription.activated')
|
||||
async onUserSubscriptionUpdated({
|
||||
userId,
|
||||
plan,
|
||||
recurring,
|
||||
}: EventPayload<'user.subscription.activated'>) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
await this.feature.addCopilot(userId, 'subscription activated');
|
||||
break;
|
||||
case SubscriptionPlan.Pro:
|
||||
await this.quotaService.switchUserQuota(
|
||||
userId,
|
||||
recurring === 'lifetime'
|
||||
? QuotaType.LifetimeProPlanV1
|
||||
: QuotaType.ProPlanV1,
|
||||
'subscription activated'
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('user.subscription.canceled')
|
||||
async onUserSubscriptionCanceled({
|
||||
userId,
|
||||
plan,
|
||||
}: EventPayload<'user.subscription.canceled'>) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
await this.feature.removeCopilot(userId);
|
||||
break;
|
||||
case SubscriptionPlan.Pro: {
|
||||
// edge case: when user switch from recurring Pro plan to `Lifetime` plan,
|
||||
// a subscription canceled event will be triggered because `Lifetime` plan is not subscription based
|
||||
const quota = await this.quotaService.getUserQuota(userId);
|
||||
if (quota.feature.name !== QuotaType.LifetimeProPlanV1) {
|
||||
await this.quotaService.switchUserQuota(
|
||||
userId,
|
||||
QuotaType.FreePlanV1,
|
||||
'subscription canceled'
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,14 @@ import type { User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { GraphQLJSONObject } from 'graphql-scalars';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
AccessDenied,
|
||||
AuthenticationRequired,
|
||||
FailedToCheckout,
|
||||
Throttle,
|
||||
WorkspaceIdRequiredToUpdateTeamSubscription,
|
||||
} from '../../base';
|
||||
import { CurrentUser, Public } from '../../core/auth';
|
||||
@@ -193,7 +196,7 @@ class CreateCheckoutSessionInput implements z.infer<typeof CheckoutParams> {
|
||||
idempotencyKey?: string;
|
||||
|
||||
@Field(() => GraphQLJSONObject, { nullable: true })
|
||||
args!: { workspaceId?: string };
|
||||
args!: { workspaceId?: string; quantity?: number };
|
||||
}
|
||||
|
||||
@Resolver(() => SubscriptionType)
|
||||
@@ -261,19 +264,33 @@ export class SubscriptionResolver {
|
||||
}, [] as SubscriptionPrice[]);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Mutation(() => String, {
|
||||
description: 'Create a subscription checkout link of stripe',
|
||||
})
|
||||
async createCheckoutSession(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: CurrentUser | null,
|
||||
@Args({ name: 'input', type: () => CreateCheckoutSessionInput })
|
||||
input: CreateCheckoutSessionInput
|
||||
) {
|
||||
const session = await this.service.checkout(input, {
|
||||
plan: input.plan as any,
|
||||
user,
|
||||
workspaceId: input.args?.workspaceId,
|
||||
});
|
||||
let session: Stripe.Checkout.Session;
|
||||
|
||||
if (input.plan === SubscriptionPlan.SelfHostedTeam) {
|
||||
session = await this.service.checkout(input, {
|
||||
plan: input.plan as any,
|
||||
quantity: input.args.quantity ?? 10,
|
||||
});
|
||||
} else {
|
||||
if (!user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
session = await this.service.checkout(input, {
|
||||
plan: input.plan as any,
|
||||
user,
|
||||
workspaceId: input.args?.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!session.url) {
|
||||
throw new FailedToCheckout();
|
||||
@@ -415,6 +432,15 @@ export class SubscriptionResolver {
|
||||
idempotencyKey
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle('strict')
|
||||
@Mutation(() => String)
|
||||
async generateLicenseKey(
|
||||
@Args('sessionId', { type: () => String }) sessionId: string
|
||||
) {
|
||||
return this.service.generateLicenseKey(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
CustomerPortalCreateFailed,
|
||||
InternalServerError,
|
||||
InvalidCheckoutParameters,
|
||||
InvalidLicenseSessionId,
|
||||
InvalidSubscriptionParameters,
|
||||
LicenseRevealed,
|
||||
Mutex,
|
||||
OnEvent,
|
||||
SameSubscriptionRecurring,
|
||||
@@ -38,6 +40,11 @@ import {
|
||||
WorkspaceSubscriptionIdentity,
|
||||
WorkspaceSubscriptionManager,
|
||||
} from './manager';
|
||||
import {
|
||||
SelfhostTeamCheckoutArgs,
|
||||
SelfhostTeamSubscriptionIdentity,
|
||||
SelfhostTeamSubscriptionManager,
|
||||
} from './manager/selfhost';
|
||||
import { ScheduleManager } from './schedule';
|
||||
import {
|
||||
decodeLookupKey,
|
||||
@@ -56,11 +63,13 @@ import {
|
||||
export const CheckoutExtraArgs = z.union([
|
||||
UserSubscriptionCheckoutArgs,
|
||||
WorkspaceSubscriptionCheckoutArgs,
|
||||
SelfhostTeamCheckoutArgs,
|
||||
]);
|
||||
|
||||
export const SubscriptionIdentity = z.union([
|
||||
UserSubscriptionIdentity,
|
||||
WorkspaceSubscriptionIdentity,
|
||||
SelfhostTeamSubscriptionIdentity,
|
||||
]);
|
||||
|
||||
export { CheckoutParams };
|
||||
@@ -78,6 +87,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
|
||||
private readonly models: Models,
|
||||
private readonly userManager: UserSubscriptionManager,
|
||||
private readonly workspaceManager: WorkspaceSubscriptionManager,
|
||||
private readonly selfhostManager: SelfhostTeamSubscriptionManager,
|
||||
private readonly mutex: Mutex
|
||||
) {}
|
||||
|
||||
@@ -92,6 +102,8 @@ export class SubscriptionService implements OnApplicationBootstrap {
|
||||
case SubscriptionPlan.Pro:
|
||||
case SubscriptionPlan.AI:
|
||||
return this.userManager;
|
||||
case SubscriptionPlan.SelfHostedTeam:
|
||||
return this.selfhostManager;
|
||||
default:
|
||||
throw new UnsupportedSubscriptionPlan({ plan });
|
||||
}
|
||||
@@ -122,7 +134,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
|
||||
if (
|
||||
this.config.deploy &&
|
||||
this.config.affine.canary &&
|
||||
!this.feature.isStaff(args.user.email)
|
||||
(!('user' in args) || !this.feature.isStaff(args.user.email))
|
||||
) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
@@ -291,10 +303,133 @@ export class SubscriptionService implements OnApplicationBootstrap {
|
||||
return newSubscription;
|
||||
}
|
||||
|
||||
async createCustomerPortal(id: string) {
|
||||
async updateSubscriptionQuantity(
|
||||
identity: z.infer<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({
|
||||
where: {
|
||||
userId: id,
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -416,15 +551,18 @@ export class SubscriptionService implements OnApplicationBootstrap {
|
||||
|
||||
private async retrieveUserFromCustomer(
|
||||
customer: string | Stripe.Customer | Stripe.DeletedCustomer
|
||||
) {
|
||||
): Promise<{ id?: string; email: string } | null> {
|
||||
const userStripeCustomer = await this.db.userStripeCustomer.findUnique({
|
||||
where: {
|
||||
stripeCustomerId: typeof customer === 'string' ? customer : customer.id,
|
||||
},
|
||||
select: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (userStripeCustomer) {
|
||||
return userStripeCustomer.userId;
|
||||
return userStripeCustomer.user;
|
||||
}
|
||||
|
||||
if (typeof customer === 'string') {
|
||||
@@ -438,17 +576,13 @@ export class SubscriptionService implements OnApplicationBootstrap {
|
||||
const user = await this.models.user.getPublicUserByEmail(customer.email);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
return {
|
||||
id: undefined,
|
||||
email: customer.email,
|
||||
};
|
||||
}
|
||||
|
||||
await this.db.userStripeCustomer.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
stripeCustomerId: customer.id,
|
||||
},
|
||||
});
|
||||
|
||||
return user.id;
|
||||
return user;
|
||||
}
|
||||
|
||||
private async listStripePrices(): Promise<KnownStripePrice[]> {
|
||||
@@ -489,14 +623,9 @@ export class SubscriptionService implements OnApplicationBootstrap {
|
||||
invoice.customer_email
|
||||
);
|
||||
|
||||
// TODO(@forehalo): the email may actually not appear to be AFFiNE user
|
||||
// There is coming feature that allow anonymous user with only email provided to buy selfhost licenses
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
userId: user?.id,
|
||||
userEmail: invoice.customer_email,
|
||||
stripeInvoice: invoice,
|
||||
lookupKey,
|
||||
metadata: invoice.subscription_details?.metadata ?? {},
|
||||
@@ -512,14 +641,18 @@ export class SubscriptionService implements OnApplicationBootstrap {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = await this.retrieveUserFromCustomer(subscription.customer);
|
||||
const user = await this.retrieveUserFromCustomer(subscription.customer);
|
||||
|
||||
if (!userId) {
|
||||
// stripe customer got deleted or customer email is null
|
||||
// it's an invalid status
|
||||
// maybe we need to check stripe dashboard
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
lookupKey,
|
||||
stripeSubscription: subscription,
|
||||
quantity: subscription.items.data[0]?.quantity ?? 1,
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum SubscriptionPlan {
|
||||
Team = 'team',
|
||||
Enterprise = 'enterprise',
|
||||
SelfHosted = 'selfhosted',
|
||||
SelfHostedTeam = 'selfhostedteam',
|
||||
}
|
||||
|
||||
export enum SubscriptionVariant {
|
||||
@@ -97,7 +98,9 @@ export interface KnownStripeInvoice {
|
||||
/**
|
||||
* User in AFFiNE system.
|
||||
*/
|
||||
userId: string;
|
||||
userId?: string;
|
||||
|
||||
userEmail: string;
|
||||
|
||||
/**
|
||||
* The lookup key of the price that the invoice is for.
|
||||
@@ -119,7 +122,9 @@ export interface KnownStripeSubscription {
|
||||
/**
|
||||
* User in AFFiNE system.
|
||||
*/
|
||||
userId: string;
|
||||
userId?: string;
|
||||
|
||||
userEmail: string;
|
||||
|
||||
/**
|
||||
* The lookup key of the price that the invoice is for.
|
||||
@@ -215,6 +220,16 @@ export const DEFAULT_PRICES = new Map([
|
||||
`${SubscriptionPlan.Team}_${SubscriptionRecurring.Yearly}`,
|
||||
{ product: 'AFFiNE Team(per seat)', price: 14400 },
|
||||
],
|
||||
|
||||
// selfhost team
|
||||
[
|
||||
`${SubscriptionPlan.SelfHostedTeam}_${SubscriptionRecurring.Monthly}`,
|
||||
{ product: 'AFFiNE Self-hosted Team(per seat)', price: 1500 },
|
||||
],
|
||||
[
|
||||
`${SubscriptionPlan.SelfHostedTeam}_${SubscriptionRecurring.Yearly}`,
|
||||
{ product: 'AFFiNE Self-hosted Team(per seat)', price: 14400 },
|
||||
],
|
||||
]);
|
||||
|
||||
// [Plan x Recurring x Variant] make a stripe price lookup key
|
||||
|
||||
Reference in New Issue
Block a user