refactor(server): config system (#11081)

This commit is contained in:
forehalo
2025-03-27 12:32:28 +00:00
parent 7091111f85
commit 0ea38680fa
274 changed files with 7583 additions and 5841 deletions
@@ -1,10 +1,6 @@
import type { Stripe } from 'stripe';
import {
defineRuntimeConfig,
defineStartupConfig,
ModuleConfig,
} from '../../base/config';
import { defineModuleConfig } from '../../base';
export interface PaymentStartupConfig {
stripe?: {
@@ -19,16 +15,40 @@ export interface PaymentRuntimeConfig {
showLifetimePrice: boolean;
}
declare module '../config' {
interface PluginsConfig {
payment: ModuleConfig<PaymentStartupConfig, PaymentRuntimeConfig>;
declare global {
interface AppConfigSchema {
payment: {
enabled: boolean;
showLifetimePrice: boolean;
apiKey: string;
webhookKey: string;
stripe: ConfigItem<{} & Stripe.StripeConfig>;
};
}
}
defineStartupConfig('plugins.payment', {});
defineRuntimeConfig('plugins.payment', {
defineModuleConfig('payment', {
enabled: {
desc: 'Whether enable payment plugin',
default: false,
},
showLifetimePrice: {
desc: 'Whether enable lifetime price and allow user to pay for it.',
default: true,
},
apiKey: {
desc: 'Stripe API key to enable payment service.',
default: '',
env: 'STRIPE_API_KEY',
},
webhookKey: {
desc: 'Stripe webhook key to enable payment service.',
default: '',
env: 'STRIPE_WEBHOOK_KEY',
},
stripe: {
desc: 'Stripe API keys',
default: {},
link: 'https://docs.stripe.com/api',
},
});
@@ -1,37 +1,32 @@
import assert from 'node:assert';
import type { RawBodyRequest } from '@nestjs/common';
import { Controller, Logger, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import Stripe from 'stripe';
import { Config, EventBus, InternalServerError } from '../../base';
import { Public } from '../../core/auth';
import { StripeFactory } from './stripe';
@Controller('/api/stripe')
export class StripeWebhookController {
private readonly webhookKey: string;
private readonly logger = new Logger(StripeWebhookController.name);
constructor(
config: Config,
private readonly stripe: Stripe,
private readonly config: Config,
private readonly stripeProvider: StripeFactory,
private readonly event: EventBus
) {
assert(config.plugins.payment.stripe);
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
}
) {}
@Public()
@Post('/webhook')
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
const webhookKey = this.config.payment.webhookKey;
// Retrieve the event by verifying the signature using the raw body and secret.
const signature = req.headers['stripe-signature'];
try {
const event = this.stripe.webhooks.constructEvent(
const event = this.stripeProvider.stripe.webhooks.constructEvent(
req.rawBody ?? '',
signature ?? '',
this.webhookKey
webhookKey
);
this.logger.debug(
@@ -1,13 +1,14 @@
import './config';
import { ServerFeature } from '../../core/config';
import { Module } from '@nestjs/common';
import { ServerConfigModule } from '../../core';
import { FeatureModule } from '../../core/features';
import { MailModule } from '../../core/mail';
import { PermissionModule } from '../../core/permission';
import { QuotaModule } from '../../core/quota';
import { UserModule } from '../../core/user';
import { WorkspaceModule } from '../../core/workspaces';
import { Plugin } from '../registry';
import { StripeWebhookController } from './controller';
import { SubscriptionCronJobs } from './cron';
import { LicenseController } from './license/controller';
@@ -23,11 +24,10 @@ import {
WorkspaceSubscriptionResolver,
} from './resolver';
import { SubscriptionService } from './service';
import { StripeProvider } from './stripe';
import { StripeFactory, StripeProvider } from './stripe';
import { StripeWebhook } from './webhook';
@Plugin({
name: 'payment',
@Module({
imports: [
FeatureModule,
QuotaModule,
@@ -35,8 +35,10 @@ import { StripeWebhook } from './webhook';
PermissionModule,
WorkspaceModule,
MailModule,
ServerConfigModule,
],
providers: [
StripeFactory,
StripeProvider,
SubscriptionService,
SubscriptionResolver,
@@ -50,11 +52,5 @@ import { StripeWebhook } from './webhook';
QuotaOverride,
],
controllers: [StripeWebhookController, LicenseController],
requires: [
'plugins.payment.stripe.keys.APIKey',
'plugins.payment.stripe.keys.webhookKey',
],
contributesTo: ServerFeature.Payment,
if: config => config.flavor.graphql,
})
export class PaymentModule {}
@@ -26,6 +26,7 @@ import {
import { Public } from '../../../core/auth';
import { SelfhostTeamSubscriptionManager } from '../manager/selfhost';
import { SubscriptionService } from '../service';
import { StripeFactory } from '../stripe';
import {
SubscriptionPlan,
SubscriptionRecurring,
@@ -53,7 +54,7 @@ export class LicenseController {
private readonly mutex: Mutex,
private readonly subscription: SubscriptionService,
private readonly manager: SelfhostTeamSubscriptionManager,
private readonly stripe: Stripe
private readonly stripeProvider: StripeFactory
) {}
@Post('/:license/activate')
@@ -238,18 +239,20 @@ export class LicenseController {
throw new LicenseNotFound();
}
const subscriptionData = await this.stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId,
{
expand: ['customer'],
}
);
const subscriptionData =
await this.stripeProvider.stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId,
{
expand: ['customer'],
}
);
const customer = subscriptionData.customer as Stripe.Customer;
try {
const portal = await this.stripe.billingPortal.sessions.create({
customer: customer.id,
});
const portal =
await this.stripeProvider.stripe.billingPortal.sessions.create({
customer: customer.id,
});
return { url: portal.url };
} catch (e) {
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { UserNotFound } from '../../../base';
import { ScheduleManager } from '../schedule';
import { StripeFactory } from '../stripe';
import {
encodeLookupKey,
KnownStripeInvoice,
@@ -55,12 +56,16 @@ export const CheckoutParams = z.object({
});
export abstract class SubscriptionManager {
protected readonly scheduleManager = new ScheduleManager(this.stripe);
protected readonly scheduleManager = new ScheduleManager(this.stripeProvider);
constructor(
protected readonly stripe: Stripe,
protected readonly stripeProvider: StripeFactory,
protected readonly db: PrismaClient
) {}
get stripe() {
return this.stripeProvider.stripe;
}
abstract filterPrices(
prices: KnownStripePrice[],
customer?: UserStripeCustomer
@@ -3,11 +3,11 @@ 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 { SubscriptionPlanNotFound, URLHelper } from '../../../base';
import { Mailer } from '../../../core/mail';
import { StripeFactory } from '../stripe';
import {
KnownStripeInvoice,
KnownStripePrice,
@@ -43,12 +43,12 @@ export const SelfhostTeamSubscriptionIdentity = z.object({
@Injectable()
export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
constructor(
stripe: Stripe,
stripeProvider: StripeFactory,
db: PrismaClient,
private readonly url: URLHelper,
private readonly mailer: Mailer
) {
super(stripe, db);
super(stripeProvider, db);
}
filterPrices(
@@ -5,17 +5,18 @@ import Stripe from 'stripe';
import { z } from 'zod';
import {
Config,
EventBus,
InternalServerError,
InvalidCheckoutParameters,
Mutex,
Runtime,
SubscriptionAlreadyExists,
SubscriptionPlanNotFound,
TooManyRequest,
URLHelper,
} from '../../../base';
import { EarlyAccessType, FeatureService } from '../../../core/features';
import { StripeFactory } from '../stripe';
import {
CouponType,
KnownStripeInvoice,
@@ -53,15 +54,15 @@ export const UserSubscriptionCheckoutArgs = z.object({
@Injectable()
export class UserSubscriptionManager extends SubscriptionManager {
constructor(
stripe: Stripe,
stripeProvider: StripeFactory,
db: PrismaClient,
private readonly runtime: Runtime,
private readonly config: Config,
private readonly feature: FeatureService,
private readonly event: EventBus,
private readonly url: URLHelper,
private readonly mutex: Mutex
) {
super(stripe, db);
super(stripeProvider, db);
}
async filterPrices(
@@ -588,7 +589,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
{ proEarlyAccess, proSubscribed, onetime }: PriceStrategyStatus
) {
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
return this.runtime.fetch('plugins.payment/showLifetimePrice');
return this.config.payment.showLifetimePrice;
}
if (lookupKey.variant === SubscriptionVariant.Onetime) {
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient, UserStripeCustomer } from '@prisma/client';
import { omit, pick } from 'lodash-es';
import Stripe from 'stripe';
import { z } from 'zod';
import {
@@ -12,6 +11,7 @@ import {
URLHelper,
} from '../../../base';
import { Models } from '../../../models';
import { StripeFactory } from '../stripe';
import {
KnownStripeInvoice,
KnownStripePrice,
@@ -46,13 +46,13 @@ export const WorkspaceSubscriptionCheckoutArgs = z.object({
@Injectable()
export class WorkspaceSubscriptionManager extends SubscriptionManager {
constructor(
stripe: Stripe,
stripeProvider: StripeFactory,
db: PrismaClient,
private readonly url: URLHelper,
private readonly event: EventBus,
private readonly models: Models
) {
super(stripe, db);
super(stripeProvider, db);
}
filterPrices(
@@ -1,15 +1,24 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { StripeFactory } from './stripe';
@Injectable()
export class ScheduleManager {
private _schedule: Stripe.SubscriptionSchedule | null = null;
private readonly logger = new Logger(ScheduleManager.name);
constructor(private readonly stripe: Stripe) {}
constructor(private readonly stripeProvider: StripeFactory) {}
static create(stripe: Stripe, schedule?: Stripe.SubscriptionSchedule) {
const manager = new ScheduleManager(stripe);
get stripe() {
return this.stripeProvider.stripe;
}
static create(
stripeProvider: StripeFactory,
schedule?: Stripe.SubscriptionSchedule
) {
const manager = new ScheduleManager(stripeProvider);
if (schedule) {
manager._schedule = schedule;
}
@@ -56,9 +65,9 @@ export class ScheduleManager {
return undefined;
});
return ScheduleManager.create(this.stripe, s);
return ScheduleManager.create(this.stripeProvider, s);
} else {
return ScheduleManager.create(this.stripe, schedule);
return ScheduleManager.create(this.stripeProvider, schedule);
}
}
@@ -1,4 +1,4 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import type { User, UserStripeCustomer } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import Stripe from 'stripe';
@@ -7,14 +7,12 @@ import { z } from 'zod';
import {
ActionForbidden,
CantUpdateOnetimePaymentSubscription,
Config,
CustomerPortalCreateFailed,
InternalServerError,
InvalidCheckoutParameters,
InvalidLicenseSessionId,
InvalidSubscriptionParameters,
LicenseRevealed,
Mutex,
OnEvent,
SameSubscriptionRecurring,
SubscriptionExpired,
@@ -46,9 +44,8 @@ import {
SelfhostTeamSubscriptionManager,
} from './manager/selfhost';
import { ScheduleManager } from './schedule';
import { StripeFactory } from './stripe';
import {
decodeLookupKey,
DEFAULT_PRICES,
KnownStripeInvoice,
KnownStripePrice,
KnownStripeSubscription,
@@ -57,7 +54,6 @@ import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
SubscriptionVariant,
} from './types';
export const CheckoutExtraArgs = z.union([
@@ -75,24 +71,22 @@ export const SubscriptionIdentity = z.union([
export { CheckoutParams };
@Injectable()
export class SubscriptionService implements OnApplicationBootstrap {
export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name);
private readonly scheduleManager = new ScheduleManager(this.stripe);
private readonly scheduleManager = new ScheduleManager(this.stripeProvider);
constructor(
private readonly config: Config,
private readonly stripe: Stripe,
private readonly stripeProvider: StripeFactory,
private readonly db: PrismaClient,
private readonly feature: FeatureService,
private readonly models: Models,
private readonly userManager: UserSubscriptionManager,
private readonly workspaceManager: WorkspaceSubscriptionManager,
private readonly selfhostManager: SelfhostTeamSubscriptionManager,
private readonly mutex: Mutex
private readonly selfhostManager: SelfhostTeamSubscriptionManager
) {}
async onApplicationBootstrap() {
await this.initStripeProducts();
get stripe() {
return this.stripeProvider.stripe;
}
private select(plan: SubscriptionPlan): SubscriptionManager {
@@ -132,8 +126,8 @@ export class SubscriptionService implements OnApplicationBootstrap {
const { plan, recurring, variant } = params;
if (
this.config.deploy &&
this.config.affine.canary &&
env.namespaces.canary &&
env.prod &&
args.user &&
!this.feature.isStaff(args.user.email)
) {
@@ -683,72 +677,4 @@ export class SubscriptionService implements OnApplicationBootstrap {
throw new InvalidSubscriptionParameters();
}
}
private async initStripeProducts() {
// only init stripe products in dev mode or canary deployment
if (
(this.config.deploy && !this.config.affine.canary) ||
!this.config.node.dev
) {
return;
}
await using lock = await this.mutex.acquire('init stripe prices');
if (!lock) {
return;
}
const keys = new Set<string>();
try {
await this.stripe.prices
.list({
active: true,
limit: 100,
})
.autoPagingEach(item => {
if (item.lookup_key) {
keys.add(item.lookup_key);
}
});
} catch {
this.logger.warn('Failed to list stripe prices, skip auto init.');
return;
}
for (const [key, setting] of DEFAULT_PRICES) {
if (keys.has(key)) {
continue;
}
const lookupKey = decodeLookupKey(key);
try {
await this.stripe.prices.create({
product_data: {
name: setting.product,
},
billing_scheme: 'per_unit',
unit_amount: setting.price,
currency: 'usd',
lookup_key: key,
tax_behavior: 'inclusive',
recurring:
lookupKey.recurring === SubscriptionRecurring.Lifetime ||
lookupKey.variant === SubscriptionVariant.Onetime
? undefined
: {
interval:
lookupKey.recurring === SubscriptionRecurring.Monthly
? 'month'
: 'year',
interval_count: 1,
usage_type: 'licensed',
},
});
} catch (e) {
this.logger.error('Failed to create stripe price.', e);
}
}
}
}
@@ -1,18 +1,130 @@
import assert from 'node:assert';
import { FactoryProvider } from '@nestjs/common';
import { omit } from 'lodash-es';
import { FactoryProvider, Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
import { Config } from '../../base';
import { Config, Mutex, OnEvent } from '../../base';
import { ServerFeature, ServerService } from '../../core';
import {
decodeLookupKey,
DEFAULT_PRICES,
SubscriptionRecurring,
SubscriptionVariant,
} from './types';
@Injectable()
export class StripeFactory {
#stripe!: Stripe;
readonly #logger = new Logger(StripeFactory.name);
constructor(
private readonly config: Config,
private readonly mutex: Mutex,
private readonly server: ServerService
) {}
get stripe() {
return this.#stripe;
}
@OnEvent('config.init')
async onConfigInit() {
this.setup();
await this.initStripeProducts();
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if ('payment' in event.updates) {
this.setup();
}
}
setup() {
// TODO@(@forehalo): use per-requests api key injection
this.#stripe = new Stripe(
this.config.payment.apiKey ||
// NOTE(@forehalo):
// we always fake a key if not set because `new Stripe` will complain if it's empty string
// this will make code cleaner than providing `Stripe` instance as optional one.
'stripe-api-key',
this.config.payment.stripe
);
if (this.config.payment.enabled) {
this.server.enableFeature(ServerFeature.Payment);
} else {
this.server.disableFeature(ServerFeature.Payment);
}
}
private async initStripeProducts() {
// only init stripe products in dev mode or canary deployment
if (!this.config.payment.enabled && !env.namespaces.canary) {
return;
}
await using lock = await this.mutex.acquire('init stripe prices');
if (!lock) {
return;
}
const keys = new Set<string>();
try {
await this.stripe.prices
.list({
active: true,
limit: 100,
})
.autoPagingEach(item => {
if (item.lookup_key) {
keys.add(item.lookup_key);
}
});
} catch {
this.#logger.warn('Failed to list stripe prices, skip auto init.');
return;
}
for (const [key, setting] of DEFAULT_PRICES) {
if (keys.has(key)) {
continue;
}
const lookupKey = decodeLookupKey(key);
try {
await this.stripe.prices.create({
product_data: {
name: setting.product,
},
billing_scheme: 'per_unit',
unit_amount: setting.price,
currency: 'usd',
lookup_key: key,
tax_behavior: 'inclusive',
recurring:
lookupKey.recurring === SubscriptionRecurring.Lifetime ||
lookupKey.variant === SubscriptionVariant.Onetime
? undefined
: {
interval:
lookupKey.recurring === SubscriptionRecurring.Monthly
? 'month'
: 'year',
interval_count: 1,
usage_type: 'licensed',
},
});
} catch (e) {
this.#logger.error('Failed to create stripe price.', e);
}
}
}
}
export const StripeProvider: FactoryProvider = {
provide: Stripe,
useFactory: (config: Config) => {
const stripeConfig = config.plugins.payment.stripe;
assert(stripeConfig, 'Stripe configuration is missing');
return new Stripe(stripeConfig.keys.APIKey, omit(stripeConfig, 'keys'));
useFactory: (provider: StripeFactory) => {
return provider.stripe;
},
inject: [Config],
inject: [StripeFactory],
};
@@ -3,6 +3,7 @@ import Stripe from 'stripe';
import { OnEvent } from '../../base';
import { SubscriptionService } from './service';
import { StripeFactory } from './stripe';
/**
* Stripe webhook events sent in random order, and may be even sent more than once.
@@ -14,9 +15,13 @@ import { SubscriptionService } from './service';
export class StripeWebhook {
constructor(
private readonly service: SubscriptionService,
private readonly stripe: Stripe
private readonly stripeProvider: StripeFactory
) {}
get stripe() {
return this.stripeProvider.stripe;
}
@OnEvent('stripe.invoice.created')
@OnEvent('stripe.invoice.updated')
@OnEvent('stripe.invoice.finalization_failed')