mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
refactor(server): payment service (#8906)
This commit is contained in:
@@ -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");
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
52
packages/backend/server/src/plugins/payment/controller.ts
Normal file
52
packages/backend/server/src/plugins/payment/controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
packages/backend/server/src/plugins/payment/cron.ts
Normal file
54
packages/backend/server/src/plugins/payment/cron.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './common';
|
||||
export * from './user';
|
||||
534
packages/backend/server/src/plugins/payment/manager/user.ts
Normal file
534
packages/backend/server/src/plugins/payment/manager/user.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
]
|
||||
Binary file not shown.
Reference in New Issue
Block a user