From 0436e59b6a78e5f0020249c958385c5fdb1dff6a Mon Sep 17 00:00:00 2001 From: forehalo Date: Thu, 5 Dec 2024 14:21:59 +0000 Subject: [PATCH] feat(server): auto init stripe products when development (#9034) --- .github/actions/server-test-env/action.yml | 2 + .../server/src/plugins/payment/service.ts | 83 ++++++++++++++++++- .../server/src/plugins/payment/types.ts | 72 ++++++++++++---- .../affine-cloud-copilot/playwright.config.ts | 2 +- tests/affine-cloud/playwright.config.ts | 2 +- .../affine-desktop-cloud/playwright.config.ts | 2 +- 6 files changed, 142 insertions(+), 21 deletions(-) diff --git a/.github/actions/server-test-env/action.yml b/.github/actions/server-test-env/action.yml index b296b98abf..22bfe52172 100644 --- a/.github/actions/server-test-env/action.yml +++ b/.github/actions/server-test-env/action.yml @@ -15,6 +15,8 @@ runs: - name: Run init-db script shell: bash + env: + NODE_ENV: test run: | yarn workspace @affine/server exec prisma generate yarn workspace @affine/server exec prisma db push diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index facdb19df1..391f75b0be 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -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 { PrismaClient } from '@prisma/client'; import Stripe from 'stripe'; @@ -15,6 +15,7 @@ import { InternalServerError, InvalidCheckoutParameters, InvalidSubscriptionParameters, + Mutex, OnEvent, SameSubscriptionRecurring, SubscriptionExpired, @@ -39,6 +40,8 @@ import { } from './manager'; import { ScheduleManager } from './schedule'; import { + decodeLookupKey, + DEFAULT_PRICES, encodeLookupKey, KnownStripeInvoice, KnownStripePrice, @@ -49,6 +52,7 @@ import { SubscriptionPlan, SubscriptionRecurring, SubscriptionStatus, + SubscriptionVariant, } from './types'; export const CheckoutExtraArgs = z.union([ @@ -64,7 +68,7 @@ export const SubscriptionIdentity = z.union([ export { CheckoutParams }; @Injectable() -export class SubscriptionService { +export class SubscriptionService implements OnApplicationBootstrap { private readonly logger = new Logger(SubscriptionService.name); private readonly scheduleManager = new ScheduleManager(this.stripe); @@ -75,9 +79,14 @@ export class SubscriptionService { private readonly feature: FeatureManagementService, private readonly user: UserService, 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 { switch (plan) { case SubscriptionPlan.Team: @@ -561,4 +570,72 @@ export class SubscriptionService { 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(); + 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); + } + } + } } diff --git a/packages/backend/server/src/plugins/payment/types.ts b/packages/backend/server/src/plugins/payment/types.ts index efb09089b9..8a241fbf13 100644 --- a/packages/backend/server/src/plugins/payment/types.ts +++ b/packages/backend/server/src/plugins/payment/types.ts @@ -152,25 +152,67 @@ export interface KnownStripePrice { price: Stripe.Price; } -const VALID_LOOKUP_KEYS = new Set([ +export const DEFAULT_PRICES = new Map([ // 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 - `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`, - `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`, - `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`, - `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`, + [ + `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`, + { product: 'AFFiNE Pro', price: 5000 }, + ], + [ + `${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 - `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`, + [ + `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`, + { product: 'AFFiNE AI', price: 10680 }, + ], // 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 - `${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 @@ -181,19 +223,19 @@ export function encodeLookupKey({ }: LookupKey): string { const key = `${plan}_${recurring}` + (variant ? `_${variant}` : ''); - if (!VALID_LOOKUP_KEYS.has(key)) { + if (!DEFAULT_PRICES.has(key)) { throw new Error(`Invalid price: ${key}`); } return key; } -export function decodeLookupKey(key: string): LookupKey | null { +export function decodeLookupKey(key: string): LookupKey { // NOTE(@forehalo): // we have some legacy prices in stripe still in used, // so we give it `pro_monthly_xxx` variant to make it invisible but valid, // and those variant won't be listed in [SubscriptionVariant] - // if (!VALID_LOOKUP_KEYS.has(key)) { + // if (!DEFAULT_PRICES.has(key)) { // return null; // } const [plan, recurring, variant] = key.split('_'); diff --git a/tests/affine-cloud-copilot/playwright.config.ts b/tests/affine-cloud-copilot/playwright.config.ts index 712274c68b..30e083ce7f 100644 --- a/tests/affine-cloud-copilot/playwright.config.ts +++ b/tests/affine-cloud-copilot/playwright.config.ts @@ -57,7 +57,7 @@ const config: PlaywrightTestConfig = { DATABASE_URL: process.env.DATABASE_URL ?? 'postgresql://affine:affine@localhost:5432/affine', - NODE_ENV: 'development', + NODE_ENV: 'test', AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev', DEBUG: 'affine:*', FORCE_COLOR: 'true', diff --git a/tests/affine-cloud/playwright.config.ts b/tests/affine-cloud/playwright.config.ts index e0f7ad44a3..18175a67ce 100644 --- a/tests/affine-cloud/playwright.config.ts +++ b/tests/affine-cloud/playwright.config.ts @@ -57,7 +57,7 @@ const config: PlaywrightTestConfig = { DATABASE_URL: process.env.DATABASE_URL ?? 'postgresql://affine:affine@localhost:5432/affine', - NODE_ENV: 'development', + NODE_ENV: 'test', AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev', DEBUG: 'affine:*', FORCE_COLOR: 'true', diff --git a/tests/affine-desktop-cloud/playwright.config.ts b/tests/affine-desktop-cloud/playwright.config.ts index cb6ae08bfc..3432035187 100644 --- a/tests/affine-desktop-cloud/playwright.config.ts +++ b/tests/affine-desktop-cloud/playwright.config.ts @@ -44,7 +44,7 @@ const config: PlaywrightTestConfig = { DATABASE_URL: process.env.DATABASE_URL ?? 'postgresql://affine:affine@localhost:5432/affine', - NODE_ENV: 'development', + NODE_ENV: 'test', AFFINE_ENV: process.env.AFFINE_ENV ?? 'dev', DEBUG: 'affine:*', FORCE_COLOR: 'true',