refactor(server): payment service (#8906)

This commit is contained in:
forehalo
2024-12-05 08:31:00 +00:00
parent 4fe1b5ba93
commit 4055e3aa67
26 changed files with 1934 additions and 1537 deletions

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "user_invoices" ALTER COLUMN "plan" DROP NOT NULL,
ALTER COLUMN "recurring" DROP NOT NULL,
ALTER COLUMN "reason" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "user_invoices_user_id_idx" ON "user_invoices"("user_id");

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -335,7 +335,7 @@ model UserSubscription {
// yearly/monthly/lifetime
recurring String @db.VarChar(20)
// onetime subscription or anything else
variant String? @db.VarChar(20)
variant String? @db.VarChar(20)
// subscription.id, null for linefetime payment or one time payment subscription
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
// subscription.status, active/past_due/canceled/unpaid...
@@ -370,18 +370,22 @@ model UserInvoice {
// CNY 12.50 stored as 1250
amount Int @db.Integer
status String @db.VarChar(20)
plan String @db.VarChar(20)
recurring String @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
// billing reason
reason String @db.VarChar
reason String? @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text
// stripe hosted invoice link
link String? @db.Text
// @deprecated
plan String? @db.VarChar(20)
// @deprecated
recurring String? @db.VarChar(20)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("user_invoices")
}

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert';
import type { RawBodyRequest } from '@nestjs/common';
import { Controller, Logger, Post, Req } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { Request } from 'express';
import Stripe from 'stripe';
import { Public } from '../../core/auth';
import { Config, InternalServerError } from '../../fundamentals';
@Controller('/api/stripe')
export class StripeWebhookController {
private readonly webhookKey: string;
private readonly logger = new Logger(StripeWebhookController.name);
constructor(
config: Config,
private readonly stripe: Stripe,
private readonly event: EventEmitter2
) {
assert(config.plugins.payment.stripe);
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
}
@Public()
@Post('/webhook')
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
// Retrieve the event by verifying the signature using the raw body and secret.
const signature = req.headers['stripe-signature'];
try {
const event = this.stripe.webhooks.constructEvent(
req.rawBody ?? '',
signature ?? '',
this.webhookKey
);
this.logger.debug(
`[${event.id}] Stripe Webhook {${event.type}} received.`
);
// Stripe requires responseing webhook immediately and handle event asynchronously.
setImmediate(() => {
this.event.emitAsync(`stripe:${event.type}`, event).catch(e => {
this.logger.error('Failed to handle Stripe Webhook event.', e);
});
});
} catch (err: any) {
throw new InternalServerError(err.message);
}
}
}

View File

@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import { EventEmitter, type EventPayload } from '../../fundamentals';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from './types';
@Injectable()
export class SubscriptionCronJobs {
constructor(
private readonly db: PrismaClient,
private readonly event: EventEmitter
) {}
@Cron(CronExpression.EVERY_HOUR)
async cleanExpiredOnetimeSubscriptions() {
const subscriptions = await this.db.userSubscription.findMany({
where: {
variant: SubscriptionVariant.Onetime,
end: {
lte: new Date(),
},
},
});
for (const subscription of subscriptions) {
this.event.emit('user.subscription.canceled', {
userId: subscription.userId,
plan: subscription.plan as SubscriptionPlan,
recurring: subscription.variant as SubscriptionRecurring,
});
}
}
@OnEvent('user.subscription.canceled')
async handleUserSubscriptionCanceled({
userId,
plan,
}: EventPayload<'user.subscription.canceled'>) {
await this.db.userSubscription.delete({
where: {
userId_plan: {
userId,
plan,
},
},
});
}
}

View File

@@ -2,24 +2,29 @@ import './config';
import { ServerFeature } from '../../core/config';
import { FeatureModule } from '../../core/features';
import { UserModule } from '../../core/user';
import { Plugin } from '../registry';
import { StripeWebhookController } from './controller';
import { SubscriptionCronJobs } from './cron';
import { UserSubscriptionManager } from './manager';
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
import { ScheduleManager } from './schedule';
import { SubscriptionService } from './service';
import { StripeProvider } from './stripe';
import { StripeWebhook } from './webhook';
@Plugin({
name: 'payment',
imports: [FeatureModule],
imports: [FeatureModule, UserModule],
providers: [
ScheduleManager,
StripeProvider,
SubscriptionService,
SubscriptionResolver,
UserSubscriptionResolver,
StripeWebhook,
UserSubscriptionManager,
SubscriptionCronJobs,
],
controllers: [StripeWebhook],
controllers: [StripeWebhookController],
requires: [
'plugins.payment.stripe.keys.APIKey',
'plugins.payment.stripe.keys.webhookKey',

View File

@@ -0,0 +1,56 @@
import { UserStripeCustomer } from '@prisma/client';
import {
KnownStripePrice,
KnownStripeSubscription,
SubscriptionPlan,
SubscriptionRecurring,
} from '../types';
export interface Subscription {
status: string;
plan: string;
recurring: string;
variant: string | null;
start: Date;
end: Date | null;
trialStart: Date | null;
trialEnd: Date | null;
nextBillAt: Date | null;
canceledAt: Date | null;
}
export interface Invoice {
currency: string;
amount: number;
status: string;
createdAt: Date;
lastPaymentError: string | null;
link: string | null;
}
export interface SubscriptionManager {
filterPrices(
prices: KnownStripePrice[],
customer?: UserStripeCustomer
): Promise<KnownStripePrice[]>;
saveSubscription(
subscription: KnownStripeSubscription
): Promise<Subscription>;
deleteSubscription(subscription: KnownStripeSubscription): Promise<void>;
getSubscription(
id: string,
plan: SubscriptionPlan
): Promise<Subscription | null>;
cancelSubscription(subscription: Subscription): Promise<Subscription>;
resumeSubscription(subscription: Subscription): Promise<Subscription>;
updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
): Promise<Subscription>;
}

View File

@@ -0,0 +1,2 @@
export * from './common';
export * from './user';

View File

@@ -0,0 +1,534 @@
import { Injectable } from '@nestjs/common';
import {
PrismaClient,
UserStripeCustomer,
UserSubscription,
} from '@prisma/client';
import Stripe from 'stripe';
import {
EarlyAccessType,
FeatureManagementService,
} from '../../../core/features';
import {
Config,
EventEmitter,
InternalServerError,
} from '../../../fundamentals';
import {
CouponType,
KnownStripeInvoice,
KnownStripePrice,
KnownStripeSubscription,
retriveLookupKeyFromStripeSubscription,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
SubscriptionVariant,
} from '../types';
import { SubscriptionManager } from './common';
interface PriceStrategyStatus {
proEarlyAccess: boolean;
aiEarlyAccess: boolean;
proSubscribed: boolean;
aiSubscribed: boolean;
onetime: boolean;
}
@Injectable()
export class UserSubscriptionManager implements SubscriptionManager {
constructor(
private readonly db: PrismaClient,
private readonly config: Config,
private readonly stripe: Stripe,
private readonly feature: FeatureManagementService,
private readonly event: EventEmitter
) {}
async filterPrices(
prices: KnownStripePrice[],
customer?: UserStripeCustomer
) {
const strategyStatus = customer
? await this.strategyStatus(customer)
: {
proEarlyAccess: false,
aiEarlyAccess: false,
proSubscribed: false,
aiSubscribed: false,
onetime: false,
};
const availablePrices: KnownStripePrice[] = [];
for (const price of prices) {
if (await this.isPriceAvailable(price, strategyStatus)) {
availablePrices.push(price);
}
}
return availablePrices;
}
async getSubscription(userId: string, plan: SubscriptionPlan) {
return this.db.userSubscription.findFirst({
where: {
userId,
plan,
status: {
in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing],
},
},
});
}
async saveSubscription({
userId,
lookupKey,
stripeSubscription: subscription,
}: KnownStripeSubscription) {
// update features first, features modify are idempotent
// so there is no need to skip if a subscription already exists.
// TODO(@forehalo):
// we should move the subscription feature updating logic back to payment module,
// because quota or feature module themself should not be aware of what payment or subscription is.
this.event.emit('user.subscription.activated', {
userId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
const commonData = {
status: subscription.status,
stripeScheduleId: subscription.schedule as string | null,
nextBillAt: !subscription.canceled_at
? new Date(subscription.current_period_end * 1000)
: null,
canceledAt: subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null,
};
return await this.db.userSubscription.upsert({
where: {
stripeSubscriptionId: subscription.id,
},
update: commonData,
create: {
userId,
...lookupKey,
stripeSubscriptionId: subscription.id,
start: new Date(subscription.current_period_start * 1000),
end: new Date(subscription.current_period_end * 1000),
trialStart: subscription.trial_start
? new Date(subscription.trial_start * 1000)
: null,
trialEnd: subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null,
...commonData,
},
});
}
async cancelSubscription(subscription: UserSubscription) {
return this.db.userSubscription.update({
where: {
id: subscription.id,
},
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
}
async resumeSubscription(subscription: UserSubscription) {
return this.db.userSubscription.update({
where: { id: subscription.id },
data: {
canceledAt: null,
nextBillAt: subscription.end,
},
});
}
async updateSubscriptionRecurring(
subscription: UserSubscription,
recurring: SubscriptionRecurring
) {
return this.db.userSubscription.update({
where: { id: subscription.id },
data: { recurring },
});
}
async deleteSubscription({
userId,
lookupKey,
stripeSubscription,
}: KnownStripeSubscription) {
await this.db.userSubscription.delete({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
});
this.event.emit('user.subscription.canceled', {
userId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
}
async validatePrice(price: KnownStripePrice, customer: UserStripeCustomer) {
const strategyStatus = await this.strategyStatus(customer);
// onetime price is allowed for checkout
strategyStatus.onetime = true;
if (!(await this.isPriceAvailable(price, strategyStatus))) {
return null;
}
let coupon: CouponType | null = null;
if (price.lookupKey.variant === SubscriptionVariant.EA) {
if (price.lookupKey.plan === SubscriptionPlan.Pro) {
coupon = CouponType.ProEarlyAccessOneYearFree;
} else if (price.lookupKey.plan === SubscriptionPlan.AI) {
coupon = CouponType.AIEarlyAccessOneYearFree;
}
} else if (price.lookupKey.plan === SubscriptionPlan.AI) {
const { proEarlyAccess, aiSubscribed } = strategyStatus;
if (proEarlyAccess && !aiSubscribed) {
coupon = CouponType.ProEarlyAccessAIOneYearFree;
}
}
return {
price,
coupon,
};
}
async saveInvoice(knownInvoice: KnownStripeInvoice) {
const { userId, lookupKey, stripeInvoice } = knownInvoice;
const status = stripeInvoice.status ?? 'void';
let error: string | boolean | null = null;
if (status !== 'paid') {
if (stripeInvoice.last_finalization_error) {
error = stripeInvoice.last_finalization_error.message ?? true;
} else if (
stripeInvoice.attempt_count > 1 &&
stripeInvoice.payment_intent
) {
const paymentIntent =
typeof stripeInvoice.payment_intent === 'string'
? await this.stripe.paymentIntents.retrieve(
stripeInvoice.payment_intent
)
: stripeInvoice.payment_intent;
if (paymentIntent.last_payment_error) {
error = paymentIntent.last_payment_error.message ?? true;
}
}
}
// fallback to generic error message
if (error === true) {
error = 'Payment Error. Please contact support.';
}
const invoice = this.db.userInvoice.upsert({
where: {
stripeInvoiceId: stripeInvoice.id,
},
update: {
status,
link: stripeInvoice.hosted_invoice_url,
amount: stripeInvoice.total,
currency: stripeInvoice.currency,
lastPaymentError: error,
},
create: {
userId,
stripeInvoiceId: stripeInvoice.id,
status,
link: stripeInvoice.hosted_invoice_url,
reason: stripeInvoice.billing_reason,
amount: stripeInvoice.total,
currency: stripeInvoice.currency,
lastPaymentError: error,
},
});
// onetime and lifetime subscription is a special "subscription" that doesn't get involved with stripe subscription system
// we track the deals by invoice only.
if (status === 'paid') {
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
await this.saveLifetimeSubscription(knownInvoice);
} else if (lookupKey.variant === SubscriptionVariant.Onetime) {
await this.saveOnetimePaymentSubscription(knownInvoice);
}
}
return invoice;
}
async saveLifetimeSubscription(
knownInvoice: KnownStripeInvoice
): Promise<UserSubscription> {
// cancel previous non-lifetime subscription
const prevSubscription = await this.db.userSubscription.findUnique({
where: {
userId_plan: {
userId: knownInvoice.userId,
plan: SubscriptionPlan.Pro,
},
},
});
let subscription: UserSubscription;
if (prevSubscription && prevSubscription.stripeSubscriptionId) {
subscription = await this.db.userSubscription.update({
where: {
id: prevSubscription.id,
},
data: {
stripeScheduleId: null,
stripeSubscriptionId: null,
plan: knownInvoice.lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
start: new Date(),
end: null,
status: SubscriptionStatus.Active,
nextBillAt: null,
},
});
await this.stripe.subscriptions.cancel(
prevSubscription.stripeSubscriptionId,
{
prorate: true,
}
);
} else {
subscription = await this.db.userSubscription.create({
data: {
userId: knownInvoice.userId,
stripeSubscriptionId: null,
plan: knownInvoice.lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
start: new Date(),
end: null,
status: SubscriptionStatus.Active,
nextBillAt: null,
},
});
}
this.event.emit('user.subscription.activated', {
userId: knownInvoice.userId,
plan: knownInvoice.lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
});
return subscription;
}
async saveOnetimePaymentSubscription(
knownInvoice: KnownStripeInvoice
): Promise<UserSubscription> {
const { userId, lookupKey } = knownInvoice;
const existingSubscription = await this.db.userSubscription.findUnique({
where: {
userId_plan: {
userId,
plan: lookupKey.plan,
},
},
});
// TODO(@forehalo): time helper
const subscriptionTime =
(lookupKey.recurring === SubscriptionRecurring.Monthly ? 30 : 365) *
24 *
60 *
60 *
1000;
let subscription: UserSubscription;
// extends the subscription time if exists
if (existingSubscription) {
if (!existingSubscription.end) {
throw new InternalServerError(
'Unexpected onetime subscription with no end date'
);
}
const period =
// expired, reset the period
existingSubscription.end <= new Date()
? {
start: new Date(),
end: new Date(Date.now() + subscriptionTime),
}
: {
end: new Date(
existingSubscription.end.getTime() + subscriptionTime
),
};
subscription = await this.db.userSubscription.update({
where: {
id: existingSubscription.id,
},
data: period,
});
} else {
subscription = await this.db.userSubscription.create({
data: {
userId,
stripeSubscriptionId: null,
...lookupKey,
start: new Date(),
end: new Date(Date.now() + subscriptionTime),
status: SubscriptionStatus.Active,
nextBillAt: null,
},
});
}
this.event.emit('user.subscription.activated', {
userId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
return subscription;
}
private async isPriceAvailable(
price: KnownStripePrice,
strategy: PriceStrategyStatus
) {
if (price.lookupKey.plan === SubscriptionPlan.Pro) {
return this.isProPriceAvailable(price, strategy);
}
if (price.lookupKey.plan === SubscriptionPlan.AI) {
return this.isAIPriceAvailable(price, strategy);
}
return false;
}
private async isProPriceAvailable(
{ lookupKey }: KnownStripePrice,
{ proEarlyAccess, proSubscribed, onetime }: PriceStrategyStatus
) {
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
return this.config.runtime.fetch('plugins.payment/showLifetimePrice');
}
if (lookupKey.variant === SubscriptionVariant.Onetime) {
return onetime;
}
// no special price for monthly plan
if (lookupKey.recurring === SubscriptionRecurring.Monthly) {
return true;
}
// show EA price instead of normal price if early access is available
return proEarlyAccess && !proSubscribed
? lookupKey.variant === SubscriptionVariant.EA
: lookupKey.variant !== SubscriptionVariant.EA;
}
private async isAIPriceAvailable(
{ lookupKey }: KnownStripePrice,
{ aiEarlyAccess, aiSubscribed, onetime }: PriceStrategyStatus
) {
// no lifetime price for AI
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
return false;
}
// never show onetime prices
if (lookupKey.variant === SubscriptionVariant.Onetime) {
return onetime;
}
// show EA price instead of normal price if early access is available
return aiEarlyAccess && !aiSubscribed
? lookupKey.variant === SubscriptionVariant.EA
: lookupKey.variant !== SubscriptionVariant.EA;
}
private async strategyStatus(
customer: UserStripeCustomer
): Promise<PriceStrategyStatus> {
const proEarlyAccess = await this.feature.isEarlyAccessUser(
customer.userId,
EarlyAccessType.App
);
const aiEarlyAccess = await this.feature.isEarlyAccessUser(
customer.userId,
EarlyAccessType.AI
);
// fast pass if the user is not early access for any plan
if (!proEarlyAccess && !aiEarlyAccess) {
return {
proEarlyAccess,
aiEarlyAccess,
proSubscribed: false,
aiSubscribed: false,
onetime: false,
};
}
let proSubscribed = false;
let aiSubscribed = false;
const subscriptions = await this.stripe.subscriptions.list({
customer: customer.stripeCustomerId,
status: 'all',
});
// if the early access user had early access subscription in the past, but it got canceled or past due,
// the user will lose the early access privilege
for (const sub of subscriptions.data) {
const lookupKey = retriveLookupKeyFromStripeSubscription(sub);
if (!lookupKey) {
continue;
}
if (sub.status === 'past_due' || sub.status === 'canceled') {
if (lookupKey.plan === SubscriptionPlan.Pro) {
proSubscribed = true;
}
if (lookupKey.plan === SubscriptionPlan.AI) {
aiSubscribed = true;
}
}
}
return {
proEarlyAccess,
aiEarlyAccess,
proSubscribed,
aiSubscribed,
onetime: false,
};
}
}

View File

@@ -1,6 +1,6 @@
import { Headers } from '@nestjs/common';
import {
Args,
Context,
Field,
InputType,
Int,
@@ -12,19 +12,15 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
import type { User, UserSubscription } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { groupBy } from 'lodash-es';
import { CurrentUser, Public } from '../../core/auth';
import { UserType } from '../../core/user';
import {
AccessDenied,
Config,
FailedToCheckout,
URLHelper,
} from '../../fundamentals';
import { decodeLookupKey, SubscriptionService } from './service';
import { AccessDenied, FailedToCheckout, URLHelper } from '../../fundamentals';
import { Invoice, Subscription } from './manager';
import { SubscriptionService } from './service';
import {
InvoiceStatus,
SubscriptionPlan,
@@ -60,11 +56,8 @@ class SubscriptionPrice {
lifetimeAmount?: number | null;
}
@ObjectType('UserSubscription')
export class UserSubscriptionType implements Partial<UserSubscription> {
@Field(() => String, { name: 'id', nullable: true })
stripeSubscriptionId!: string | null;
@ObjectType()
export class SubscriptionType implements Subscription {
@Field(() => SubscriptionPlan, {
description:
"The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.\nThere won't actually be a subscription with plan 'Free'",
@@ -75,7 +68,7 @@ export class UserSubscriptionType implements Partial<UserSubscription> {
recurring!: SubscriptionRecurring;
@Field(() => SubscriptionVariant, { nullable: true })
variant?: SubscriptionVariant | null;
variant!: SubscriptionVariant | null;
@Field(() => SubscriptionStatus)
status!: SubscriptionStatus;
@@ -87,35 +80,34 @@ export class UserSubscriptionType implements Partial<UserSubscription> {
end!: Date | null;
@Field(() => Date, { nullable: true })
trialStart?: Date | null;
trialStart!: Date | null;
@Field(() => Date, { nullable: true })
trialEnd?: Date | null;
trialEnd!: Date | null;
@Field(() => Date, { nullable: true })
nextBillAt?: Date | null;
nextBillAt!: Date | null;
@Field(() => Date, { nullable: true })
canceledAt?: Date | null;
canceledAt!: Date | null;
@Field(() => Date)
createdAt!: Date;
@Field(() => Date)
updatedAt!: Date;
// deprecated fields
@Field(() => String, {
name: 'id',
nullable: true,
deprecationReason: 'removed',
})
stripeSubscriptionId!: string;
}
@ObjectType('UserInvoice')
class UserInvoiceType implements Partial<UserInvoice> {
@Field({ name: 'id' })
stripeInvoiceId!: string;
@Field(() => SubscriptionPlan)
plan!: SubscriptionPlan;
@Field(() => SubscriptionRecurring)
recurring!: SubscriptionRecurring;
@ObjectType()
export class InvoiceType implements Invoice {
@Field()
currency!: string;
@@ -129,16 +121,36 @@ class UserInvoiceType implements Partial<UserInvoice> {
reason!: string;
@Field(() => String, { nullable: true })
lastPaymentError?: string | null;
lastPaymentError!: string | null;
@Field(() => String, { nullable: true })
link?: string | null;
link!: string | null;
@Field(() => Date)
createdAt!: Date;
@Field(() => Date)
updatedAt!: Date;
// deprecated fields
@Field(() => String, {
name: 'id',
nullable: true,
deprecationReason: 'removed',
})
stripeInvoiceId!: string | null;
@Field(() => SubscriptionPlan, {
nullable: true,
deprecationReason: 'removed',
})
plan!: SubscriptionPlan | null;
@Field(() => SubscriptionRecurring, {
nullable: true,
deprecationReason: 'removed',
})
recurring!: SubscriptionRecurring | null;
}
@InputType()
@@ -166,12 +178,14 @@ class CreateCheckoutSessionInput {
@Field(() => String)
successCallbackLink!: string;
// @FIXME(forehalo): we should put this field in the header instead of as a explicity args
@Field(() => String)
idempotencyKey!: string;
@Field(() => String, {
nullable: true,
deprecationReason: 'use header `Idempotency-Key`',
})
idempotencyKey?: string;
}
@Resolver(() => UserSubscriptionType)
@Resolver(() => SubscriptionType)
export class SubscriptionResolver {
constructor(
private readonly service: SubscriptionService,
@@ -186,9 +200,7 @@ export class SubscriptionResolver {
const prices = await this.service.listPrices(user);
const group = groupBy(prices, price => {
// @ts-expect-error empty lookup key is filtered out
const [plan] = decodeLookupKey(price.lookup_key);
return plan;
return price.lookupKey.plan;
});
function findPrice(plan: SubscriptionPlan) {
@@ -198,21 +210,24 @@ export class SubscriptionResolver {
return null;
}
const monthlyPrice = prices.find(p => p.recurring?.interval === 'month');
const yearlyPrice = prices.find(p => p.recurring?.interval === 'year');
const lifetimePrice = prices.find(
p =>
// asserted before
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
decodeLookupKey(p.lookup_key!)[1] === SubscriptionRecurring.Lifetime
const monthlyPrice = prices.find(
p => p.lookupKey.recurring === SubscriptionRecurring.Monthly
);
const currency = monthlyPrice?.currency ?? yearlyPrice?.currency ?? 'usd';
const yearlyPrice = prices.find(
p => p.lookupKey.recurring === SubscriptionRecurring.Yearly
);
const lifetimePrice = prices.find(
p => p.lookupKey.recurring === SubscriptionRecurring.Lifetime
);
const currency =
monthlyPrice?.price.currency ?? yearlyPrice?.price.currency ?? 'usd';
return {
currency,
amount: monthlyPrice?.unit_amount,
yearlyAmount: yearlyPrice?.unit_amount,
lifetimeAmount: lifetimePrice?.unit_amount,
amount: monthlyPrice?.price.unit_amount,
yearlyAmount: yearlyPrice?.price.unit_amount,
lifetimeAmount: lifetimePrice?.price.unit_amount,
};
}
@@ -240,16 +255,19 @@ export class SubscriptionResolver {
async createCheckoutSession(
@CurrentUser() user: CurrentUser,
@Args({ name: 'input', type: () => CreateCheckoutSessionInput })
input: CreateCheckoutSessionInput
input: CreateCheckoutSessionInput,
@Headers('idempotency-key') idempotencyKey?: string
) {
const session = await this.service.createCheckoutSession({
const session = await this.service.checkout({
user,
plan: input.plan,
recurring: input.recurring,
variant: input.variant,
lookupKey: {
plan: input.plan,
recurring: input.recurring,
variant: input.variant,
},
promotionCode: input.coupon,
redirectUrl: this.url.link(input.successCallbackLink),
idempotencyKey: input.idempotencyKey,
idempotencyKey,
});
if (!session.url) {
@@ -266,7 +284,7 @@ export class SubscriptionResolver {
return this.service.createCustomerPortal(user.id);
}
@Mutation(() => UserSubscriptionType)
@Mutation(() => SubscriptionType)
async cancelSubscription(
@CurrentUser() user: CurrentUser,
@Args({
@@ -276,12 +294,18 @@ export class SubscriptionResolver {
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan,
@Args('idempotencyKey') idempotencyKey: string
@Headers('idempotency-key') idempotencyKey?: string,
@Args('idempotencyKey', {
type: () => String,
nullable: true,
deprecationReason: 'use header `Idempotency-Key`',
})
_?: string
) {
return this.service.cancelSubscription(idempotencyKey, user.id, plan);
return this.service.cancelSubscription(user.id, plan, idempotencyKey);
}
@Mutation(() => UserSubscriptionType)
@Mutation(() => SubscriptionType)
async resumeSubscription(
@CurrentUser() user: CurrentUser,
@Args({
@@ -291,16 +315,18 @@ export class SubscriptionResolver {
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan,
@Args('idempotencyKey') idempotencyKey: string
@Headers('idempotency-key') idempotencyKey?: string,
@Args('idempotencyKey', {
type: () => String,
nullable: true,
deprecationReason: 'use header `Idempotency-Key`',
})
_?: string
) {
return this.service.resumeCanceledSubscription(
idempotencyKey,
user.id,
plan
);
return this.service.resumeSubscription(user.id, plan, idempotencyKey);
}
@Mutation(() => UserSubscriptionType)
@Mutation(() => SubscriptionType)
async updateSubscriptionRecurring(
@CurrentUser() user: CurrentUser,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
@@ -312,88 +338,28 @@ export class SubscriptionResolver {
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan,
@Args('idempotencyKey') idempotencyKey: string
@Headers('idempotency-key') idempotencyKey?: string,
@Args('idempotencyKey', {
type: () => String,
nullable: true,
deprecationReason: 'use header `Idempotency-Key`',
})
_?: string
) {
return this.service.updateSubscriptionRecurring(
idempotencyKey,
user.id,
plan,
recurring
recurring,
idempotencyKey
);
}
}
@Resolver(() => UserType)
export class UserSubscriptionResolver {
constructor(
private readonly config: Config,
private readonly db: PrismaClient
) {}
constructor(private readonly db: PrismaClient) {}
@ResolveField(() => UserSubscriptionType, {
nullable: true,
deprecationReason: 'use `UserType.subscriptions`',
})
async subscription(
@Context() ctx: { isAdminQuery: boolean },
@CurrentUser() me: User,
@Parent() user: User,
@Args({
name: 'plan',
type: () => SubscriptionPlan,
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan
) {
// allow admin to query other user's subscription
if (!ctx.isAdminQuery && me.id !== user.id) {
throw new AccessDenied();
}
// @FIXME(@forehalo): should not mock any api for selfhosted server
// the frontend should avoid calling such api if feature is not enabled
if (this.config.isSelfhosted) {
const start = new Date();
const end = new Date();
end.setFullYear(start.getFullYear() + 1);
return {
stripeSubscriptionId: 'dummy',
plan: SubscriptionPlan.SelfHosted,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start,
end,
createdAt: start,
updatedAt: start,
};
}
const subscription = await this.db.userSubscription.findUnique({
where: {
userId_plan: {
userId: user.id,
plan,
},
status: SubscriptionStatus.Active,
},
});
if (
subscription &&
subscription.variant &&
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
subscription.variant as SubscriptionVariant
)
) {
subscription.variant = null;
}
return subscription;
}
@ResolveField(() => [UserSubscriptionType])
@ResolveField(() => [SubscriptionType])
async subscriptions(
@CurrentUser() me: User,
@Parent() user: User
@@ -423,7 +389,7 @@ export class UserSubscriptionResolver {
return subscriptions;
}
@ResolveField(() => [UserInvoiceType])
@ResolveField(() => [InvoiceType])
async invoices(
@CurrentUser() me: User,
@Parent() user: User,

View File

@@ -63,8 +63,8 @@ export class ScheduleManager {
}
async fromSubscription(
idempotencyKey: string,
subscription: string | Stripe.Subscription
subscription: string | Stripe.Subscription,
idempotencyKey?: string
) {
if (typeof subscription === 'string') {
subscription = await this.stripe.subscriptions.retrieve(subscription, {
@@ -88,7 +88,7 @@ export class ScheduleManager {
* Cancel a subscription by marking schedule's end behavior to `cancel`.
* At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription.
*/
async cancel(idempotencyKey: string) {
async cancel(idempotencyKey?: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
@@ -129,7 +129,7 @@ export class ScheduleManager {
);
}
async resume(idempotencyKey: string) {
async resume(idempotencyKey?: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
@@ -188,7 +188,7 @@ export class ScheduleManager {
});
}
async update(idempotencyKey: string, price: string) {
async update(price: string, idempotencyKey?: string) {
if (!this._schedule) {
throw new Error('No schedule');
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import type { User } from '@prisma/client';
import Stripe from 'stripe';
import type { Payload } from '../../fundamentals/event/def';
@@ -42,6 +43,12 @@ export enum InvoiceStatus {
Uncollectible = 'uncollectible',
}
export enum CouponType {
ProEarlyAccessOneYearFree = 'pro_ea_one_year_free',
AIEarlyAccessOneYearFree = 'ai_ea_one_year_free',
ProEarlyAccessAIOneYearFree = 'ai_pro_ea_one_year_free',
}
declare module '../../fundamentals/event/def' {
interface UserEvents {
subscription: {
@@ -58,3 +65,125 @@ declare module '../../fundamentals/event/def' {
};
}
}
export interface LookupKey {
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
variant?: SubscriptionVariant;
}
export interface KnownStripeInvoice {
/**
* User in AFFiNE system.
*/
userId: string;
/**
* The lookup key of the price that the invoice is for.
*/
lookupKey: LookupKey;
/**
* The invoice object from Stripe.
*/
stripeInvoice: Stripe.Invoice;
}
export interface KnownStripeSubscription {
/**
* User in AFFiNE system.
*/
userId: string;
/**
* The lookup key of the price that the invoice is for.
*/
lookupKey: LookupKey;
/**
* The subscription object from Stripe.
*/
stripeSubscription: Stripe.Subscription;
}
export interface KnownStripePrice {
/**
* The lookup key of the price.
*/
lookupKey: LookupKey;
/**
* The price object from Stripe.
*/
price: Stripe.Price;
}
const VALID_LOOKUP_KEYS = new Set([
// pro
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`,
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`,
// 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}`,
// ai
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`,
// only EA for yearly AI
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`,
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`,
// team
`${SubscriptionPlan.Team}_${SubscriptionRecurring.Monthly}`,
`${SubscriptionPlan.Team}_${SubscriptionRecurring.Yearly}`,
]);
// [Plan x Recurring x Variant] make a stripe price lookup key
export function encodeLookupKey({
plan,
recurring,
variant,
}: LookupKey): string {
const key = `${plan}_${recurring}` + (variant ? `_${variant}` : '');
if (!VALID_LOOKUP_KEYS.has(key)) {
throw new Error(`Invalid price: ${key}`);
}
return key;
}
export function decodeLookupKey(key: string): LookupKey | null {
// 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)) {
// return null;
// }
const [plan, recurring, variant] = key.split('_');
return {
plan: plan as SubscriptionPlan,
recurring: recurring as SubscriptionRecurring,
variant: variant as SubscriptionVariant | undefined,
};
}
export function retriveLookupKeyFromStripePrice(price: Stripe.Price) {
return price.lookup_key ? decodeLookupKey(price.lookup_key) : null;
}
export function retriveLookupKeyFromStripeSubscription(
subscription: Stripe.Subscription
) {
const price = subscription.items.data[0]?.price;
// there should be and only one item in the subscription
if (!price) {
return null;
}
return retriveLookupKeyFromStripePrice(price);
}

View File

@@ -1,63 +1,63 @@
import assert from 'node:assert';
import type { RawBodyRequest } from '@nestjs/common';
import { Controller, Logger, Post, Req } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { Request } from 'express';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import Stripe from 'stripe';
import { Public } from '../../core/auth';
import { Config, InternalServerError } from '../../fundamentals';
import { SubscriptionService } from './service';
@Controller('/api/stripe')
const OnStripeEvent = (
event: Stripe.Event.Type,
opts?: Parameters<typeof OnEvent>[1]
) => OnEvent(`stripe:${event}`, opts);
/**
* Stripe webhook events sent in random order, and may be even sent more than once.
*
* A good way to avoid events sequence issue is fetch the latest object data regarding that event,
* and all following operations only depend on the latest state instead of the one in event data.
*/
@Injectable()
export class StripeWebhook {
private readonly webhookKey: string;
private readonly logger = new Logger(StripeWebhook.name);
constructor(
config: Config,
private readonly stripe: Stripe,
private readonly event: EventEmitter2
private readonly service: SubscriptionService,
private readonly stripe: Stripe
) {}
@OnStripeEvent('invoice.created')
@OnStripeEvent('invoice.updated')
@OnStripeEvent('invoice.finalization_failed')
@OnStripeEvent('invoice.payment_failed')
@OnStripeEvent('invoice.payment_succeeded')
async onInvoiceUpdated(
event:
| Stripe.InvoiceCreatedEvent
| Stripe.InvoiceUpdatedEvent
| Stripe.InvoiceFinalizationFailedEvent
| Stripe.InvoicePaymentFailedEvent
| Stripe.InvoicePaymentSucceededEvent
) {
assert(config.plugins.payment.stripe);
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
const invoice = await this.stripe.invoices.retrieve(event.data.object.id);
await this.service.saveStripeInvoice(invoice);
}
@Public()
@Post('/webhook')
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
// Check if webhook signing is configured.
@OnStripeEvent('customer.subscription.created')
@OnStripeEvent('customer.subscription.updated')
async onSubscriptionChanges(
event:
| Stripe.CustomerSubscriptionUpdatedEvent
| Stripe.CustomerSubscriptionCreatedEvent
) {
const subscription = await this.stripe.subscriptions.retrieve(
event.data.object.id,
{
expand: ['customer'],
}
);
// Retrieve the event by verifying the signature using the raw body and secret.
const signature = req.headers['stripe-signature'];
try {
const event = this.stripe.webhooks.constructEvent(
req.rawBody ?? '',
signature ?? '',
this.webhookKey
);
await this.service.saveStripeSubscription(subscription);
}
this.logger.debug(
`[${event.id}] Stripe Webhook {${event.type}} received.`
);
// Stripe requires responseing webhook immediately and handle event asynchronously.
setImmediate(() => {
// handle duplicated events?
// see https://stripe.com/docs/webhooks#handle-duplicate-events
this.event
.emitAsync(
event.type,
event.data.object,
// here to let event listeners know what exactly the event is if a handler can handle multiple events
event.type
)
.catch(e => {
this.logger.error('Failed to handle Stripe Webhook event.', e);
});
});
} catch (err: any) {
throw new InternalServerError(err.message);
}
@OnStripeEvent('customer.subscription.deleted')
async onSubscriptionDeleted(event: Stripe.CustomerSubscriptionDeletedEvent) {
await this.service.deleteStripeSubscription(event.data.object);
}
}

View File

@@ -141,7 +141,7 @@ input CreateChatSessionInput {
input CreateCheckoutSessionInput {
coupon: String
idempotencyKey: String!
idempotencyKey: String
plan: SubscriptionPlan = Pro
recurring: SubscriptionRecurring = Yearly
successCallbackLink: String!
@@ -392,6 +392,20 @@ enum InvoiceStatus {
Void
}
type InvoiceType {
amount: Int!
createdAt: DateTime!
currency: String!
id: String @deprecated(reason: "removed")
lastPaymentError: String
link: String
plan: SubscriptionPlan @deprecated(reason: "removed")
reason: String!
recurring: SubscriptionRecurring @deprecated(reason: "removed")
status: InvoiceStatus!
updatedAt: DateTime!
}
"""
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
"""
@@ -430,7 +444,7 @@ type MissingOauthQueryParameterDataType {
type Mutation {
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro): SubscriptionType!
changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!, userId: String): Boolean!
@@ -477,7 +491,7 @@ type Mutation {
"""Remove user avatar"""
removeAvatar: RemoveAvatar!
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro): SubscriptionType!
revoke(userId: String!, workspaceId: String!): Boolean!
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
@@ -499,7 +513,7 @@ type Mutation {
"""update multiple server runtime configurable settings"""
updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]!
updateSubscriptionRecurring(idempotencyKey: String!, plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): UserSubscription!
updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): SubscriptionType!
"""Update a user"""
updateUser(id: String!, input: ManageUserInput!): UserType!
@@ -770,6 +784,27 @@ enum SubscriptionStatus {
Unpaid
}
type SubscriptionType {
canceledAt: DateTime
createdAt: DateTime!
end: DateTime
id: String @deprecated(reason: "removed")
nextBillAt: DateTime
"""
The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.
There won't actually be a subscription with plan 'Free'
"""
plan: SubscriptionPlan!
recurring: SubscriptionRecurring!
start: DateTime!
status: SubscriptionStatus!
trialEnd: DateTime
trialStart: DateTime
updatedAt: DateTime!
variant: SubscriptionVariant
}
enum SubscriptionVariant {
EA
Onetime
@@ -796,20 +831,6 @@ input UpdateWorkspaceInput {
"""The `Upload` scalar type represents a file upload."""
scalar Upload
type UserInvoice {
amount: Int!
createdAt: DateTime!
currency: String!
id: String!
lastPaymentError: String
link: String
plan: SubscriptionPlan!
reason: String!
recurring: SubscriptionRecurring!
status: InvoiceStatus!
updatedAt: DateTime!
}
union UserOrLimitedUser = LimitedUserType | UserType
type UserQuota {
@@ -829,27 +850,6 @@ type UserQuotaHumanReadable {
storageQuota: String!
}
type UserSubscription {
canceledAt: DateTime
createdAt: DateTime!
end: DateTime
id: String
nextBillAt: DateTime
"""
The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.
There won't actually be a subscription with plan 'Free'
"""
plan: SubscriptionPlan!
recurring: SubscriptionRecurring!
start: DateTime!
status: SubscriptionStatus!
trialEnd: DateTime
trialStart: DateTime
updatedAt: DateTime!
variant: SubscriptionVariant
}
type UserType {
"""User avatar url"""
avatarUrl: String
@@ -873,13 +873,12 @@ type UserType {
"""Get user invoice count"""
invoiceCount: Int!
invoices(skip: Int, take: Int = 8): [UserInvoice!]!
invoices(skip: Int, take: Int = 8): [InvoiceType!]!
"""User name"""
name: String!
quota: UserQuota
subscription(plan: SubscriptionPlan = Pro): UserSubscription @deprecated(reason: "use `UserType.subscriptions`")
subscriptions: [UserSubscription!]!
subscriptions: [SubscriptionType!]!
token: tokenType! @deprecated(reason: "use [/api/auth/sign-in?native=true] instead")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
# Snapshot report for `tests/payment/service.spec.ts`
The actual snapshot is saved in `service.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should list normal price for unauthenticated user
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'ai_yearly',
]
## should list normal prices for authenticated user
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'ai_yearly',
]
## should list early access prices for pro ea user
> Snapshot 1
[
'pro_monthly',
'pro_yearly_earlyaccess',
'ai_yearly',
]
## should list normal prices for pro ea user with old subscriptions
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'ai_yearly',
]
## should list early access prices for ai ea user
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'ai_yearly_earlyaccess',
]
## should list early access prices for pro and ai ea user
> Snapshot 1
[
'pro_monthly',
'pro_yearly_earlyaccess',
'ai_yearly_earlyaccess',
]
## should list normal prices for ai ea user with old subscriptions
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'ai_yearly',
]