feat(server): auto init stripe products when development (#9034)

This commit is contained in:
forehalo
2024-12-05 14:21:59 +00:00
parent 4c39b89b98
commit 0436e59b6a
6 changed files with 142 additions and 21 deletions
@@ -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',
+1 -1
View File
@@ -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',