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
@@ -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';
@@ -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,
};
}
}