mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 03:56:23 +08:00
feat(server): auto init stripe products when development (#9034)
This commit is contained in:
@@ -15,6 +15,8 @@ runs:
|
|||||||
|
|
||||||
- name: Run init-db script
|
- name: Run init-db script
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
run: |
|
run: |
|
||||||
yarn workspace @affine/server exec prisma generate
|
yarn workspace @affine/server exec prisma generate
|
||||||
yarn workspace @affine/server exec prisma db push
|
yarn workspace @affine/server exec prisma db push
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import type { User, UserStripeCustomer } from '@prisma/client';
|
import type { User, UserStripeCustomer } from '@prisma/client';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
InternalServerError,
|
InternalServerError,
|
||||||
InvalidCheckoutParameters,
|
InvalidCheckoutParameters,
|
||||||
InvalidSubscriptionParameters,
|
InvalidSubscriptionParameters,
|
||||||
|
Mutex,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
SameSubscriptionRecurring,
|
SameSubscriptionRecurring,
|
||||||
SubscriptionExpired,
|
SubscriptionExpired,
|
||||||
@@ -39,6 +40,8 @@ import {
|
|||||||
} from './manager';
|
} from './manager';
|
||||||
import { ScheduleManager } from './schedule';
|
import { ScheduleManager } from './schedule';
|
||||||
import {
|
import {
|
||||||
|
decodeLookupKey,
|
||||||
|
DEFAULT_PRICES,
|
||||||
encodeLookupKey,
|
encodeLookupKey,
|
||||||
KnownStripeInvoice,
|
KnownStripeInvoice,
|
||||||
KnownStripePrice,
|
KnownStripePrice,
|
||||||
@@ -49,6 +52,7 @@ import {
|
|||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
|
SubscriptionVariant,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const CheckoutExtraArgs = z.union([
|
export const CheckoutExtraArgs = z.union([
|
||||||
@@ -64,7 +68,7 @@ export const SubscriptionIdentity = z.union([
|
|||||||
export { CheckoutParams };
|
export { CheckoutParams };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionService {
|
export class SubscriptionService implements OnApplicationBootstrap {
|
||||||
private readonly logger = new Logger(SubscriptionService.name);
|
private readonly logger = new Logger(SubscriptionService.name);
|
||||||
private readonly scheduleManager = new ScheduleManager(this.stripe);
|
private readonly scheduleManager = new ScheduleManager(this.stripe);
|
||||||
|
|
||||||
@@ -75,9 +79,14 @@ export class SubscriptionService {
|
|||||||
private readonly feature: FeatureManagementService,
|
private readonly feature: FeatureManagementService,
|
||||||
private readonly user: UserService,
|
private readonly user: UserService,
|
||||||
private readonly userManager: UserSubscriptionManager,
|
private readonly userManager: UserSubscriptionManager,
|
||||||
private readonly workspaceManager: WorkspaceSubscriptionManager
|
private readonly workspaceManager: WorkspaceSubscriptionManager,
|
||||||
|
private readonly mutex: Mutex
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap() {
|
||||||
|
await this.initStripeProducts();
|
||||||
|
}
|
||||||
|
|
||||||
private select(plan: SubscriptionPlan): SubscriptionManager {
|
private select(plan: SubscriptionPlan): SubscriptionManager {
|
||||||
switch (plan) {
|
switch (plan) {
|
||||||
case SubscriptionPlan.Team:
|
case SubscriptionPlan.Team:
|
||||||
@@ -561,4 +570,72 @@ export class SubscriptionService {
|
|||||||
throw new InvalidSubscriptionParameters();
|
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.lock('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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,25 +152,67 @@ export interface KnownStripePrice {
|
|||||||
price: Stripe.Price;
|
price: Stripe.Price;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_LOOKUP_KEYS = new Set([
|
export const DEFAULT_PRICES = new Map([
|
||||||
// pro
|
// pro
|
||||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`,
|
[
|
||||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`,
|
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`,
|
||||||
|
{
|
||||||
|
product: 'AFFiNE Pro',
|
||||||
|
price: 799,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`,
|
||||||
|
{
|
||||||
|
product: 'AFFiNE Pro',
|
||||||
|
price: 8100,
|
||||||
|
},
|
||||||
|
],
|
||||||
// only EA for yearly pro
|
// only EA for yearly pro
|
||||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`,
|
[
|
||||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`,
|
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`,
|
||||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`,
|
{ product: 'AFFiNE Pro', price: 5000 },
|
||||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`,
|
],
|
||||||
|
[
|
||||||
|
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`,
|
||||||
|
{
|
||||||
|
product: 'AFFiNE Pro Believer',
|
||||||
|
price: 49900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`,
|
||||||
|
{ product: 'AFFiNE Pro - One Month', price: 799 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`,
|
||||||
|
{ product: 'AFFiNE Pro - One Year', price: 8100 },
|
||||||
|
],
|
||||||
|
|
||||||
// ai
|
// ai
|
||||||
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`,
|
[
|
||||||
|
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`,
|
||||||
|
{ product: 'AFFiNE AI', price: 10680 },
|
||||||
|
],
|
||||||
// only EA for yearly AI
|
// only EA for yearly AI
|
||||||
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`,
|
[
|
||||||
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`,
|
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`,
|
||||||
|
{ product: 'AFFiNE AI', price: 9900 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`,
|
||||||
|
{ product: 'AFFiNE AI - One Year', price: 10680 },
|
||||||
|
],
|
||||||
|
|
||||||
// team
|
// team
|
||||||
`${SubscriptionPlan.Team}_${SubscriptionRecurring.Monthly}`,
|
[
|
||||||
`${SubscriptionPlan.Team}_${SubscriptionRecurring.Yearly}`,
|
`${SubscriptionPlan.Team}_${SubscriptionRecurring.Monthly}`,
|
||||||
|
{ product: 'AFFiNE Team(per seat)', price: 1500 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`${SubscriptionPlan.Team}_${SubscriptionRecurring.Yearly}`,
|
||||||
|
{ product: 'AFFiNE 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
|
||||||
@@ -181,19 +223,19 @@ export function encodeLookupKey({
|
|||||||
}: LookupKey): string {
|
}: LookupKey): string {
|
||||||
const key = `${plan}_${recurring}` + (variant ? `_${variant}` : '');
|
const key = `${plan}_${recurring}` + (variant ? `_${variant}` : '');
|
||||||
|
|
||||||
if (!VALID_LOOKUP_KEYS.has(key)) {
|
if (!DEFAULT_PRICES.has(key)) {
|
||||||
throw new Error(`Invalid price: ${key}`);
|
throw new Error(`Invalid price: ${key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeLookupKey(key: string): LookupKey | null {
|
export function decodeLookupKey(key: string): LookupKey {
|
||||||
// NOTE(@forehalo):
|
// NOTE(@forehalo):
|
||||||
// we have some legacy prices in stripe still in used,
|
// we have some legacy prices in stripe still in used,
|
||||||
// so we give it `pro_monthly_xxx` variant to make it invisible but valid,
|
// so we give it `pro_monthly_xxx` variant to make it invisible but valid,
|
||||||
// and those variant won't be listed in [SubscriptionVariant]
|
// and those variant won't be listed in [SubscriptionVariant]
|
||||||
// if (!VALID_LOOKUP_KEYS.has(key)) {
|
// if (!DEFAULT_PRICES.has(key)) {
|
||||||
// return null;
|
// return null;
|
||||||
// }
|
// }
|
||||||
const [plan, recurring, variant] = key.split('_');
|
const [plan, recurring, variant] = key.split('_');
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const config: PlaywrightTestConfig = {
|
|||||||
DATABASE_URL:
|
DATABASE_URL:
|
||||||
process.env.DATABASE_URL ??
|
process.env.DATABASE_URL ??
|
||||||
'postgresql://affine:affine@localhost:5432/affine',
|
'postgresql://affine:affine@localhost:5432/affine',
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'test',
|
||||||
AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev',
|
AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev',
|
||||||
DEBUG: 'affine:*',
|
DEBUG: 'affine:*',
|
||||||
FORCE_COLOR: 'true',
|
FORCE_COLOR: 'true',
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const config: PlaywrightTestConfig = {
|
|||||||
DATABASE_URL:
|
DATABASE_URL:
|
||||||
process.env.DATABASE_URL ??
|
process.env.DATABASE_URL ??
|
||||||
'postgresql://affine:affine@localhost:5432/affine',
|
'postgresql://affine:affine@localhost:5432/affine',
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'test',
|
||||||
AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev',
|
AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev',
|
||||||
DEBUG: 'affine:*',
|
DEBUG: 'affine:*',
|
||||||
FORCE_COLOR: 'true',
|
FORCE_COLOR: 'true',
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const config: PlaywrightTestConfig = {
|
|||||||
DATABASE_URL:
|
DATABASE_URL:
|
||||||
process.env.DATABASE_URL ??
|
process.env.DATABASE_URL ??
|
||||||
'postgresql://affine:affine@localhost:5432/affine',
|
'postgresql://affine:affine@localhost:5432/affine',
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'test',
|
||||||
AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev',
|
AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev',
|
||||||
DEBUG: 'affine:*',
|
DEBUG: 'affine:*',
|
||||||
FORCE_COLOR: 'true',
|
FORCE_COLOR: 'true',
|
||||||
|
|||||||
Reference in New Issue
Block a user