mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
feat(server): auto init stripe products when development (#9034)
This commit is contained in:
2
.github/actions/server-test-env/action.yml
vendored
2
.github/actions/server-test-env/action.yml
vendored
@@ -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
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
const VALID_LOOKUP_KEYS = new Set([
|
||||
export const DEFAULT_PRICES = new Map([
|
||||
// pro
|
||||
[
|
||||
`${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}`,
|
||||
{ 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}`,
|
||||
{ product: 'AFFiNE AI', price: 10680 },
|
||||
],
|
||||
// only EA for yearly AI
|
||||
[
|
||||
`${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}`,
|
||||
{ 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('_');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user