feat(server): support team workspace subscription (#8919)

close AF-1724, AF-1722
This commit is contained in:
forehalo
2024-12-05 08:31:01 +00:00
parent 4055e3aa67
commit 5bf8ed1095
26 changed files with 2208 additions and 785 deletions

View File

@@ -1,13 +1,23 @@
import { UserStripeCustomer } from '@prisma/client';
import { PrismaClient, UserStripeCustomer } from '@prisma/client';
import Stripe from 'stripe';
import { z } from 'zod';
import { UserNotFound } from '../../../fundamentals';
import { ScheduleManager } from '../schedule';
import {
encodeLookupKey,
KnownStripeInvoice,
KnownStripePrice,
KnownStripeSubscription,
LookupKey,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from '../types';
export interface Subscription {
stripeSubscriptionId: string | null;
stripeScheduleId: string | null;
status: string;
plan: string;
recurring: string;
@@ -21,36 +31,225 @@ export interface Subscription {
}
export interface Invoice {
stripeInvoiceId: string;
currency: string;
amount: number;
status: string;
createdAt: Date;
reason: string | null;
lastPaymentError: string | null;
link: string | null;
}
export interface SubscriptionManager {
filterPrices(
export const SubscriptionIdentity = z.object({
plan: z.nativeEnum(SubscriptionPlan),
});
export const CheckoutParams = z.object({
plan: z.nativeEnum(SubscriptionPlan),
recurring: z.nativeEnum(SubscriptionRecurring),
variant: z.nativeEnum(SubscriptionVariant).nullable().optional(),
coupon: z.string().nullable().optional(),
quantity: z.number().min(1).nullable().optional(),
successCallbackLink: z.string(),
});
export abstract class SubscriptionManager {
protected readonly scheduleManager = new ScheduleManager(this.stripe);
constructor(
protected readonly stripe: Stripe,
protected readonly db: PrismaClient
) {}
abstract filterPrices(
prices: KnownStripePrice[],
customer?: UserStripeCustomer
): Promise<KnownStripePrice[]>;
): KnownStripePrice[] | Promise<KnownStripePrice[]>;
saveSubscription(
abstract checkout(
price: KnownStripePrice,
params: z.infer<typeof CheckoutParams>,
args: any
): Promise<Stripe.Checkout.Session>;
abstract saveStripeSubscription(
subscription: KnownStripeSubscription
): Promise<Subscription>;
deleteSubscription(subscription: KnownStripeSubscription): Promise<void>;
abstract deleteStripeSubscription(
subscription: KnownStripeSubscription
): Promise<void>;
getSubscription(
id: string,
plan: SubscriptionPlan
abstract getSubscription(
identity: z.infer<typeof SubscriptionIdentity>
): Promise<Subscription | null>;
abstract cancelSubscription(
subscription: Subscription
): Promise<Subscription>;
cancelSubscription(subscription: Subscription): Promise<Subscription>;
abstract resumeSubscription(
subscription: Subscription
): Promise<Subscription>;
resumeSubscription(subscription: Subscription): Promise<Subscription>;
updateSubscriptionRecurring(
abstract updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
): Promise<Subscription>;
abstract saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice>;
transformSubscription({
lookupKey,
stripeSubscription: subscription,
}: KnownStripeSubscription): Subscription {
return {
...lookupKey,
stripeScheduleId: subscription.schedule as string | null,
stripeSubscriptionId: subscription.id,
status: subscription.status,
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,
nextBillAt: !subscription.canceled_at
? new Date(subscription.current_period_end * 1000)
: null,
canceledAt: subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null,
};
}
async transformInvoice({
stripeInvoice,
}: KnownStripeInvoice): Promise<Invoice> {
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.';
}
return {
stripeInvoiceId: stripeInvoice.id,
status,
link: stripeInvoice.hosted_invoice_url || null,
reason: stripeInvoice.billing_reason,
amount: stripeInvoice.total,
currency: stripeInvoice.currency,
lastPaymentError: error,
};
}
async getOrCreateCustomer(userId: string): Promise<UserStripeCustomer> {
const user = await this.db.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
userStripeCustomer: true,
},
});
if (!user) {
throw new UserNotFound();
}
let customer = user.userStripeCustomer;
if (!customer) {
const stripeCustomersList = await this.stripe.customers.list({
email: user.email,
limit: 1,
});
let stripeCustomer: Stripe.Customer | undefined;
if (stripeCustomersList.data.length) {
stripeCustomer = stripeCustomersList.data[0];
} else {
stripeCustomer = await this.stripe.customers.create({
email: user.email,
});
}
customer = await this.db.userStripeCustomer.create({
data: {
userId,
stripeCustomerId: stripeCustomer.id,
},
});
}
return customer;
}
protected async getPrice(
lookupKey: LookupKey
): Promise<KnownStripePrice | null> {
const prices = await this.stripe.prices.list({
lookup_keys: [encodeLookupKey(lookupKey)],
limit: 1,
});
const price = prices.data[0];
return price
? {
lookupKey,
price,
}
: null;
}
protected async getCouponFromPromotionCode(
userFacingPromotionCode: string,
customer: UserStripeCustomer
) {
const list = await this.stripe.promotionCodes.list({
code: userFacingPromotionCode,
active: true,
limit: 1,
});
const code = list.data[0];
if (!code) {
return null;
}
// the coupons are always bound to products, we need to check it first
// but the logic would be too complicated, and stripe will complain if the code is not applicable when checking out
// It's safe to skip the check here
// code.coupon.applies_to.products.forEach()
// check if the code is bound to a specific customer
return !code.customer ||
(typeof code.customer === 'string'
? code.customer === customer.stripeCustomerId
: code.customer.id === customer.stripeCustomerId)
? code.coupon.id
: null;
}
}