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

View File

@@ -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);
}
}
}
}

View File

@@ -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('_');