mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
944 lines
25 KiB
TypeScript
944 lines
25 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
|
|
import type {
|
|
User,
|
|
UserInvoice,
|
|
UserStripeCustomer,
|
|
UserSubscription,
|
|
} from '@prisma/client';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import Stripe from 'stripe';
|
|
|
|
import { CurrentUser } from '../../core/auth';
|
|
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
|
|
import {
|
|
ActionForbidden,
|
|
CantUpdateLifetimeSubscription,
|
|
Config,
|
|
CustomerPortalCreateFailed,
|
|
EventEmitter,
|
|
OnEvent,
|
|
SameSubscriptionRecurring,
|
|
SubscriptionAlreadyExists,
|
|
SubscriptionExpired,
|
|
SubscriptionHasBeenCanceled,
|
|
SubscriptionNotExists,
|
|
SubscriptionPlanNotFound,
|
|
UserNotFound,
|
|
} from '../../fundamentals';
|
|
import { ScheduleManager } from './schedule';
|
|
import {
|
|
InvoiceStatus,
|
|
SubscriptionPlan,
|
|
SubscriptionPriceVariant,
|
|
SubscriptionRecurring,
|
|
SubscriptionStatus,
|
|
} from './types';
|
|
|
|
const OnStripeEvent = (
|
|
event: Stripe.Event.Type,
|
|
opts?: Parameters<typeof RawOnEvent>[1]
|
|
) => RawOnEvent(event, opts);
|
|
|
|
// Plan x Recurring make a stripe price lookup key
|
|
export function encodeLookupKey(
|
|
plan: SubscriptionPlan,
|
|
recurring: SubscriptionRecurring,
|
|
variant?: SubscriptionPriceVariant
|
|
): string {
|
|
return `${plan}_${recurring}` + (variant ? `_${variant}` : '');
|
|
}
|
|
|
|
export function decodeLookupKey(
|
|
key: string
|
|
): [SubscriptionPlan, SubscriptionRecurring, SubscriptionPriceVariant?] {
|
|
const [plan, recurring, variant] = key.split('_');
|
|
|
|
return [
|
|
plan as SubscriptionPlan,
|
|
recurring as SubscriptionRecurring,
|
|
variant as SubscriptionPriceVariant | undefined,
|
|
];
|
|
}
|
|
|
|
const SubscriptionActivated: Stripe.Subscription.Status[] = [
|
|
SubscriptionStatus.Active,
|
|
SubscriptionStatus.Trialing,
|
|
];
|
|
|
|
export enum CouponType {
|
|
ProEarlyAccessOneYearFree = 'pro_ea_one_year_free',
|
|
AIEarlyAccessOneYearFree = 'ai_ea_one_year_free',
|
|
ProEarlyAccessAIOneYearFree = 'ai_pro_ea_one_year_free',
|
|
}
|
|
|
|
@Injectable()
|
|
export class SubscriptionService {
|
|
private readonly logger = new Logger(SubscriptionService.name);
|
|
|
|
constructor(
|
|
private readonly config: Config,
|
|
private readonly stripe: Stripe,
|
|
private readonly db: PrismaClient,
|
|
private readonly scheduleManager: ScheduleManager,
|
|
private readonly event: EventEmitter,
|
|
private readonly feature: FeatureManagementService
|
|
) {}
|
|
|
|
async listPrices(user?: CurrentUser) {
|
|
let canHaveEarlyAccessDiscount = false;
|
|
let canHaveAIEarlyAccessDiscount = false;
|
|
if (user) {
|
|
canHaveEarlyAccessDiscount = await this.feature.isEarlyAccessUser(
|
|
user.id
|
|
);
|
|
canHaveAIEarlyAccessDiscount = await this.feature.isEarlyAccessUser(
|
|
user.id,
|
|
EarlyAccessType.AI
|
|
);
|
|
|
|
const customer = await this.getOrCreateCustomer(
|
|
'list-price:' + randomUUID(),
|
|
user
|
|
);
|
|
const oldSubscriptions = await this.stripe.subscriptions.list({
|
|
customer: customer.stripeCustomerId,
|
|
status: 'all',
|
|
});
|
|
|
|
oldSubscriptions.data.forEach(sub => {
|
|
if (sub.status === 'past_due' || sub.status === 'canceled') {
|
|
const [oldPlan] = this.decodePlanFromSubscription(sub);
|
|
if (oldPlan === SubscriptionPlan.Pro) {
|
|
canHaveEarlyAccessDiscount = false;
|
|
}
|
|
if (oldPlan === SubscriptionPlan.AI) {
|
|
canHaveAIEarlyAccessDiscount = false;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const lifetimePriceEnabled = await this.config.runtime.fetch(
|
|
'plugins.payment/showLifetimePrice'
|
|
);
|
|
|
|
const list = await this.stripe.prices.list({
|
|
active: true,
|
|
// only list recurring prices if lifetime price is not enabled
|
|
...(lifetimePriceEnabled ? {} : { type: 'recurring' }),
|
|
});
|
|
|
|
return list.data.filter(price => {
|
|
if (!price.lookup_key) {
|
|
return false;
|
|
}
|
|
|
|
const [plan, recurring, variant] = decodeLookupKey(price.lookup_key);
|
|
// no variant price should be used for monthly or lifetime subscription
|
|
if (
|
|
recurring === SubscriptionRecurring.Monthly ||
|
|
recurring === SubscriptionRecurring.Lifetime
|
|
) {
|
|
return !variant;
|
|
}
|
|
|
|
if (plan === SubscriptionPlan.Pro) {
|
|
return (
|
|
(canHaveEarlyAccessDiscount && variant) ||
|
|
(!canHaveEarlyAccessDiscount && !variant)
|
|
);
|
|
}
|
|
|
|
if (plan === SubscriptionPlan.AI) {
|
|
return (
|
|
(canHaveAIEarlyAccessDiscount && variant) ||
|
|
(!canHaveAIEarlyAccessDiscount && !variant)
|
|
);
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
async createCheckoutSession({
|
|
user,
|
|
recurring,
|
|
plan,
|
|
promotionCode,
|
|
redirectUrl,
|
|
idempotencyKey,
|
|
}: {
|
|
user: CurrentUser;
|
|
recurring: SubscriptionRecurring;
|
|
plan: SubscriptionPlan;
|
|
promotionCode?: string | null;
|
|
redirectUrl: string;
|
|
idempotencyKey: string;
|
|
}) {
|
|
if (
|
|
this.config.deploy &&
|
|
this.config.affine.canary &&
|
|
!this.feature.isStaff(user.email)
|
|
) {
|
|
throw new ActionForbidden();
|
|
}
|
|
|
|
const currentSubscription = await this.db.userSubscription.findFirst({
|
|
where: {
|
|
userId: user.id,
|
|
plan,
|
|
status: SubscriptionStatus.Active,
|
|
},
|
|
});
|
|
|
|
if (
|
|
currentSubscription &&
|
|
// do not allow to re-subscribe unless the new recurring is `Lifetime`
|
|
(currentSubscription.recurring === recurring ||
|
|
recurring !== SubscriptionRecurring.Lifetime)
|
|
) {
|
|
throw new SubscriptionAlreadyExists({ plan });
|
|
}
|
|
|
|
const customer = await this.getOrCreateCustomer(
|
|
`${idempotencyKey}-getOrCreateCustomer`,
|
|
user
|
|
);
|
|
|
|
const { price, coupon } = await this.getAvailablePrice(
|
|
customer,
|
|
plan,
|
|
recurring
|
|
);
|
|
|
|
let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = [];
|
|
|
|
if (coupon) {
|
|
discounts = [{ coupon }];
|
|
} else if (promotionCode) {
|
|
const code = await this.getAvailablePromotionCode(
|
|
promotionCode,
|
|
customer.stripeCustomerId
|
|
);
|
|
if (code) {
|
|
discounts = [{ promotion_code: code }];
|
|
}
|
|
}
|
|
|
|
return await this.stripe.checkout.sessions.create(
|
|
{
|
|
line_items: [
|
|
{
|
|
price,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
tax_id_collection: {
|
|
enabled: true,
|
|
},
|
|
// discount
|
|
...(discounts.length ? { discounts } : { allow_promotion_codes: true }),
|
|
// mode: 'subscription' or 'payment' for lifetime
|
|
...(recurring === SubscriptionRecurring.Lifetime
|
|
? {
|
|
mode: 'payment',
|
|
invoice_creation: {
|
|
enabled: true,
|
|
},
|
|
}
|
|
: {
|
|
mode: 'subscription',
|
|
}),
|
|
success_url: redirectUrl,
|
|
customer: customer.stripeCustomerId,
|
|
customer_update: {
|
|
address: 'auto',
|
|
name: 'auto',
|
|
},
|
|
},
|
|
{ idempotencyKey: `${idempotencyKey}-checkoutSession` }
|
|
);
|
|
}
|
|
|
|
async cancelSubscription(
|
|
idempotencyKey: string,
|
|
userId: string,
|
|
plan: SubscriptionPlan
|
|
): Promise<UserSubscription> {
|
|
const user = await this.db.user.findUnique({
|
|
where: {
|
|
id: userId,
|
|
},
|
|
include: {
|
|
subscriptions: {
|
|
where: {
|
|
plan,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new UserNotFound();
|
|
}
|
|
|
|
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
|
|
if (!subscriptionInDB) {
|
|
throw new SubscriptionNotExists({ plan });
|
|
}
|
|
|
|
if (!subscriptionInDB.stripeSubscriptionId) {
|
|
throw new CantUpdateLifetimeSubscription(
|
|
'Lifetime subscription cannot be canceled.'
|
|
);
|
|
}
|
|
|
|
if (subscriptionInDB.canceledAt) {
|
|
throw new SubscriptionHasBeenCanceled();
|
|
}
|
|
|
|
// should release the schedule first
|
|
if (subscriptionInDB.stripeScheduleId) {
|
|
const manager = await this.scheduleManager.fromSchedule(
|
|
subscriptionInDB.stripeScheduleId
|
|
);
|
|
await manager.cancel(idempotencyKey);
|
|
return this.saveSubscription(
|
|
user,
|
|
await this.stripe.subscriptions.retrieve(
|
|
subscriptionInDB.stripeSubscriptionId
|
|
)
|
|
);
|
|
} else {
|
|
// let customer contact support if they want to cancel immediately
|
|
// see https://stripe.com/docs/billing/subscriptions/cancel
|
|
const subscription = await this.stripe.subscriptions.update(
|
|
subscriptionInDB.stripeSubscriptionId,
|
|
{ cancel_at_period_end: true },
|
|
{ idempotencyKey }
|
|
);
|
|
return await this.saveSubscription(user, subscription);
|
|
}
|
|
}
|
|
|
|
async resumeCanceledSubscription(
|
|
idempotencyKey: string,
|
|
userId: string,
|
|
plan: SubscriptionPlan
|
|
): Promise<UserSubscription> {
|
|
const user = await this.db.user.findUnique({
|
|
where: {
|
|
id: userId,
|
|
},
|
|
include: {
|
|
subscriptions: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new UserNotFound();
|
|
}
|
|
|
|
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
|
|
if (!subscriptionInDB) {
|
|
throw new SubscriptionNotExists({ plan });
|
|
}
|
|
|
|
if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) {
|
|
throw new CantUpdateLifetimeSubscription(
|
|
'Lifetime subscription cannot be resumed.'
|
|
);
|
|
}
|
|
|
|
if (!subscriptionInDB.canceledAt) {
|
|
throw new SubscriptionHasBeenCanceled();
|
|
}
|
|
|
|
if (subscriptionInDB.end < new Date()) {
|
|
throw new SubscriptionExpired();
|
|
}
|
|
|
|
if (subscriptionInDB.stripeScheduleId) {
|
|
const manager = await this.scheduleManager.fromSchedule(
|
|
subscriptionInDB.stripeScheduleId
|
|
);
|
|
await manager.resume(idempotencyKey);
|
|
return this.saveSubscription(
|
|
user,
|
|
await this.stripe.subscriptions.retrieve(
|
|
subscriptionInDB.stripeSubscriptionId
|
|
)
|
|
);
|
|
} else {
|
|
const subscription = await this.stripe.subscriptions.update(
|
|
subscriptionInDB.stripeSubscriptionId,
|
|
{ cancel_at_period_end: false },
|
|
{ idempotencyKey }
|
|
);
|
|
|
|
return await this.saveSubscription(user, subscription);
|
|
}
|
|
}
|
|
|
|
async updateSubscriptionRecurring(
|
|
idempotencyKey: string,
|
|
userId: string,
|
|
plan: SubscriptionPlan,
|
|
recurring: SubscriptionRecurring
|
|
): Promise<UserSubscription> {
|
|
const user = await this.db.user.findUnique({
|
|
where: {
|
|
id: userId,
|
|
},
|
|
include: {
|
|
subscriptions: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new UserNotFound();
|
|
}
|
|
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
|
|
if (!subscriptionInDB) {
|
|
throw new SubscriptionNotExists({ plan });
|
|
}
|
|
|
|
if (!subscriptionInDB.stripeSubscriptionId) {
|
|
throw new CantUpdateLifetimeSubscription(
|
|
'Can not update lifetime subscription.'
|
|
);
|
|
}
|
|
|
|
if (subscriptionInDB.canceledAt) {
|
|
throw new SubscriptionHasBeenCanceled();
|
|
}
|
|
|
|
if (subscriptionInDB.recurring === recurring) {
|
|
throw new SameSubscriptionRecurring({ recurring });
|
|
}
|
|
|
|
const price = await this.getPrice(
|
|
subscriptionInDB.plan as SubscriptionPlan,
|
|
recurring
|
|
);
|
|
|
|
const manager = await this.scheduleManager.fromSubscription(
|
|
`${idempotencyKey}-fromSubscription`,
|
|
subscriptionInDB.stripeSubscriptionId
|
|
);
|
|
|
|
await manager.update(`${idempotencyKey}-update`, price);
|
|
|
|
return await this.db.userSubscription.update({
|
|
where: {
|
|
id: subscriptionInDB.id,
|
|
},
|
|
data: {
|
|
stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched)
|
|
recurring,
|
|
},
|
|
});
|
|
}
|
|
|
|
async createCustomerPortal(id: string) {
|
|
const user = await this.db.userStripeCustomer.findUnique({
|
|
where: {
|
|
userId: id,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new UserNotFound();
|
|
}
|
|
|
|
try {
|
|
const portal = await this.stripe.billingPortal.sessions.create({
|
|
customer: user.stripeCustomerId,
|
|
});
|
|
|
|
return portal.url;
|
|
} catch (e) {
|
|
this.logger.error('Failed to create customer portal.', e);
|
|
throw new CustomerPortalCreateFailed();
|
|
}
|
|
}
|
|
|
|
@OnStripeEvent('invoice.created')
|
|
@OnStripeEvent('invoice.updated')
|
|
@OnStripeEvent('invoice.finalization_failed')
|
|
@OnStripeEvent('invoice.payment_failed')
|
|
@OnStripeEvent('invoice.payment_succeeded')
|
|
async saveInvoice(stripeInvoice: Stripe.Invoice, event: string) {
|
|
stripeInvoice = await this.stripe.invoices.retrieve(stripeInvoice.id);
|
|
if (!stripeInvoice.customer) {
|
|
throw new Error('Unexpected invoice with no customer');
|
|
}
|
|
|
|
const user = await this.retrieveUserFromCustomer(
|
|
typeof stripeInvoice.customer === 'string'
|
|
? stripeInvoice.customer
|
|
: stripeInvoice.customer.id
|
|
);
|
|
|
|
const data: Partial<UserInvoice> = {
|
|
currency: stripeInvoice.currency,
|
|
amount: stripeInvoice.total,
|
|
status: stripeInvoice.status ?? InvoiceStatus.Void,
|
|
link: stripeInvoice.hosted_invoice_url,
|
|
};
|
|
|
|
// handle payment error
|
|
if (stripeInvoice.attempt_count > 1) {
|
|
const paymentIntent = await this.stripe.paymentIntents.retrieve(
|
|
stripeInvoice.payment_intent as string
|
|
);
|
|
|
|
if (paymentIntent.last_payment_error) {
|
|
if (paymentIntent.last_payment_error.type === 'card_error') {
|
|
data.lastPaymentError =
|
|
paymentIntent.last_payment_error.message ?? 'Failed to pay';
|
|
} else {
|
|
data.lastPaymentError = 'Internal Payment error';
|
|
}
|
|
}
|
|
} else if (stripeInvoice.last_finalization_error) {
|
|
if (stripeInvoice.last_finalization_error.type === 'card_error') {
|
|
data.lastPaymentError =
|
|
stripeInvoice.last_finalization_error.message ??
|
|
'Failed to finalize invoice';
|
|
} else {
|
|
data.lastPaymentError = 'Internal Payment error';
|
|
}
|
|
}
|
|
|
|
// create invoice
|
|
const price = stripeInvoice.lines.data[0].price;
|
|
|
|
if (!price) {
|
|
throw new Error('Unexpected invoice with no price');
|
|
}
|
|
|
|
if (!price.lookup_key) {
|
|
throw new Error('Unexpected subscription with no key');
|
|
}
|
|
|
|
const [plan, recurring] = decodeLookupKey(price.lookup_key);
|
|
|
|
const invoice = await this.db.userInvoice.upsert({
|
|
where: {
|
|
stripeInvoiceId: stripeInvoice.id,
|
|
},
|
|
update: data,
|
|
create: {
|
|
userId: user.id,
|
|
stripeInvoiceId: stripeInvoice.id,
|
|
plan,
|
|
recurring,
|
|
reason: stripeInvoice.billing_reason ?? 'contact support',
|
|
...(data as any),
|
|
},
|
|
});
|
|
|
|
// handle one time payment, no subscription created by stripe
|
|
if (
|
|
event === 'invoice.payment_succeeded' &&
|
|
recurring === SubscriptionRecurring.Lifetime &&
|
|
stripeInvoice.status === 'paid'
|
|
) {
|
|
await this.saveLifetimeSubscription(user, invoice);
|
|
}
|
|
}
|
|
|
|
async saveLifetimeSubscription(user: User, invoice: UserInvoice) {
|
|
// cancel previous non-lifetime subscription
|
|
const savedSubscription = await this.db.userSubscription.findUnique({
|
|
where: {
|
|
userId_plan: {
|
|
userId: user.id,
|
|
plan: SubscriptionPlan.Pro,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (savedSubscription && savedSubscription.stripeSubscriptionId) {
|
|
await this.db.userSubscription.update({
|
|
where: {
|
|
id: savedSubscription.id,
|
|
},
|
|
data: {
|
|
stripeScheduleId: null,
|
|
stripeSubscriptionId: null,
|
|
status: SubscriptionStatus.Active,
|
|
recurring: SubscriptionRecurring.Lifetime,
|
|
start: new Date(),
|
|
end: null,
|
|
nextBillAt: null,
|
|
},
|
|
});
|
|
|
|
await this.stripe.subscriptions.cancel(
|
|
savedSubscription.stripeSubscriptionId,
|
|
{
|
|
prorate: true,
|
|
}
|
|
);
|
|
} else {
|
|
await this.db.userSubscription.create({
|
|
data: {
|
|
userId: user.id,
|
|
stripeSubscriptionId: null,
|
|
plan: invoice.plan,
|
|
recurring: invoice.recurring,
|
|
start: new Date(),
|
|
end: null,
|
|
status: SubscriptionStatus.Active,
|
|
nextBillAt: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
this.event.emit('user.subscription.activated', {
|
|
userId: user.id,
|
|
plan: invoice.plan as SubscriptionPlan,
|
|
recurring: SubscriptionRecurring.Lifetime,
|
|
});
|
|
}
|
|
|
|
@OnStripeEvent('customer.subscription.created')
|
|
@OnStripeEvent('customer.subscription.updated')
|
|
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
|
subscription = await this.stripe.subscriptions.retrieve(subscription.id);
|
|
if (subscription.status === 'active') {
|
|
const user = await this.retrieveUserFromCustomer(
|
|
typeof subscription.customer === 'string'
|
|
? subscription.customer
|
|
: subscription.customer.id
|
|
);
|
|
|
|
await this.saveSubscription(user, subscription);
|
|
} else {
|
|
await this.onSubscriptionDeleted(subscription);
|
|
}
|
|
}
|
|
|
|
@OnStripeEvent('customer.subscription.deleted')
|
|
async onSubscriptionDeleted(subscription: Stripe.Subscription) {
|
|
const user = await this.retrieveUserFromCustomer(
|
|
typeof subscription.customer === 'string'
|
|
? subscription.customer
|
|
: subscription.customer.id
|
|
);
|
|
|
|
const [plan, recurring] = this.decodePlanFromSubscription(subscription);
|
|
|
|
this.event.emit('user.subscription.canceled', {
|
|
userId: user.id,
|
|
plan,
|
|
recurring,
|
|
});
|
|
|
|
await this.db.userSubscription.deleteMany({
|
|
where: {
|
|
stripeSubscriptionId: subscription.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async saveSubscription(
|
|
user: User,
|
|
subscription: Stripe.Subscription
|
|
): Promise<UserSubscription> {
|
|
const price = subscription.items.data[0].price;
|
|
if (!price.lookup_key) {
|
|
throw new Error('Unexpected subscription with no key');
|
|
}
|
|
|
|
const [plan, recurring] = this.decodePlanFromSubscription(subscription);
|
|
const planActivated = SubscriptionActivated.includes(subscription.status);
|
|
|
|
// update features first, features modify are idempotent
|
|
// so there is no need to skip if a subscription already exists.
|
|
this.event.emit('user.subscription.activated', {
|
|
userId: user.id,
|
|
plan,
|
|
recurring,
|
|
});
|
|
|
|
let nextBillAt: Date | null = null;
|
|
if (planActivated && !subscription.canceled_at) {
|
|
// get next bill date from upcoming invoice
|
|
// see https://stripe.com/docs/api/invoices/upcoming
|
|
nextBillAt = new Date(subscription.current_period_end * 1000);
|
|
}
|
|
|
|
const commonData = {
|
|
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,
|
|
canceledAt: subscription.canceled_at
|
|
? new Date(subscription.canceled_at * 1000)
|
|
: null,
|
|
stripeSubscriptionId: subscription.id,
|
|
plan,
|
|
status: subscription.status,
|
|
stripeScheduleId: subscription.schedule as string | null,
|
|
};
|
|
|
|
return await this.db.userSubscription.upsert({
|
|
where: {
|
|
stripeSubscriptionId: subscription.id,
|
|
},
|
|
update: commonData,
|
|
create: {
|
|
userId: user.id,
|
|
recurring,
|
|
...commonData,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async getOrCreateCustomer(
|
|
idempotencyKey: string,
|
|
user: CurrentUser
|
|
): Promise<UserStripeCustomer> {
|
|
let customer = await this.db.userStripeCustomer.findUnique({
|
|
where: {
|
|
userId: user.id,
|
|
},
|
|
});
|
|
|
|
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 },
|
|
{ idempotencyKey }
|
|
);
|
|
}
|
|
|
|
customer = await this.db.userStripeCustomer.create({
|
|
data: {
|
|
userId: user.id,
|
|
stripeCustomerId: stripeCustomer.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
return customer;
|
|
}
|
|
|
|
@OnEvent('user.updated')
|
|
async onUserUpdated(user: User) {
|
|
const customer = await this.db.userStripeCustomer.findUnique({
|
|
where: {
|
|
userId: user.id,
|
|
},
|
|
});
|
|
|
|
if (customer) {
|
|
const stripeCustomer = await this.stripe.customers.retrieve(
|
|
customer.stripeCustomerId
|
|
);
|
|
if (!stripeCustomer.deleted && stripeCustomer.email !== user.email) {
|
|
await this.stripe.customers.update(customer.stripeCustomerId, {
|
|
email: user.email,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private async retrieveUserFromCustomer(customerId: string) {
|
|
const customer = await this.db.userStripeCustomer.findUnique({
|
|
where: {
|
|
stripeCustomerId: customerId,
|
|
},
|
|
include: {
|
|
user: true,
|
|
},
|
|
});
|
|
|
|
if (customer?.user) {
|
|
return customer.user;
|
|
}
|
|
|
|
// customer may not saved is db, check it with stripe
|
|
const stripeCustomer = await this.stripe.customers.retrieve(customerId);
|
|
|
|
if (stripeCustomer.deleted) {
|
|
throw new Error('Unexpected subscription created with deleted customer');
|
|
}
|
|
|
|
if (!stripeCustomer.email) {
|
|
throw new Error('Unexpected subscription created with no email customer');
|
|
}
|
|
|
|
const user = await this.db.user.findUnique({
|
|
where: {
|
|
email: stripeCustomer.email,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new Error(
|
|
`Unexpected subscription created with unknown customer ${stripeCustomer.email}`
|
|
);
|
|
}
|
|
|
|
await this.db.userStripeCustomer.create({
|
|
data: {
|
|
userId: user.id,
|
|
stripeCustomerId: stripeCustomer.id,
|
|
},
|
|
});
|
|
|
|
return user;
|
|
}
|
|
|
|
private async getPrice(
|
|
plan: SubscriptionPlan,
|
|
recurring: SubscriptionRecurring,
|
|
variant?: SubscriptionPriceVariant
|
|
): Promise<string> {
|
|
if (recurring === SubscriptionRecurring.Lifetime) {
|
|
const lifetimePriceEnabled = await this.config.runtime.fetch(
|
|
'plugins.payment/showLifetimePrice'
|
|
);
|
|
|
|
if (!lifetimePriceEnabled) {
|
|
throw new ActionForbidden();
|
|
}
|
|
}
|
|
|
|
const prices = await this.stripe.prices.list({
|
|
lookup_keys: [encodeLookupKey(plan, recurring, variant)],
|
|
});
|
|
|
|
if (!prices.data.length) {
|
|
throw new SubscriptionPlanNotFound({
|
|
plan,
|
|
recurring,
|
|
});
|
|
}
|
|
|
|
return prices.data[0].id;
|
|
}
|
|
|
|
/**
|
|
* Get available for different plans with special early-access price and coupon
|
|
*/
|
|
private async getAvailablePrice(
|
|
customer: UserStripeCustomer,
|
|
plan: SubscriptionPlan,
|
|
recurring: SubscriptionRecurring
|
|
): Promise<{ price: string; coupon?: string }> {
|
|
const isEaUser = await this.feature.isEarlyAccessUser(customer.userId);
|
|
const oldSubscriptions = await this.stripe.subscriptions.list({
|
|
customer: customer.stripeCustomerId,
|
|
status: 'all',
|
|
});
|
|
|
|
const subscribed = oldSubscriptions.data.some(sub => {
|
|
const [oldPlan] = this.decodePlanFromSubscription(sub);
|
|
return (
|
|
oldPlan === plan &&
|
|
(sub.status === 'past_due' || sub.status === 'canceled')
|
|
);
|
|
});
|
|
|
|
if (plan === SubscriptionPlan.Pro) {
|
|
const canHaveEADiscount =
|
|
isEaUser && !subscribed && recurring === SubscriptionRecurring.Yearly;
|
|
const price = await this.getPrice(
|
|
plan,
|
|
recurring,
|
|
canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined
|
|
);
|
|
return {
|
|
price,
|
|
coupon: canHaveEADiscount
|
|
? CouponType.ProEarlyAccessOneYearFree
|
|
: undefined,
|
|
};
|
|
} else {
|
|
const isAIEaUser = await this.feature.isEarlyAccessUser(
|
|
customer.userId,
|
|
EarlyAccessType.AI
|
|
);
|
|
|
|
const canHaveEADiscount =
|
|
isAIEaUser && !subscribed && recurring === SubscriptionRecurring.Yearly;
|
|
const price = await this.getPrice(
|
|
plan,
|
|
recurring,
|
|
canHaveEADiscount ? SubscriptionPriceVariant.EA : undefined
|
|
);
|
|
|
|
return {
|
|
price,
|
|
coupon: !subscribed
|
|
? isAIEaUser
|
|
? CouponType.AIEarlyAccessOneYearFree
|
|
: isEaUser
|
|
? CouponType.ProEarlyAccessAIOneYearFree
|
|
: undefined
|
|
: undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async getAvailablePromotionCode(
|
|
userFacingPromotionCode: string,
|
|
customer?: string
|
|
) {
|
|
const list = await this.stripe.promotionCodes.list({
|
|
code: userFacingPromotionCode,
|
|
active: true,
|
|
limit: 1,
|
|
});
|
|
|
|
const code = list.data[0];
|
|
if (!code) {
|
|
return null;
|
|
}
|
|
|
|
let available = false;
|
|
|
|
if (code.customer) {
|
|
available =
|
|
typeof code.customer === 'string'
|
|
? code.customer === customer
|
|
: code.customer.id === customer;
|
|
} else {
|
|
available = true;
|
|
}
|
|
|
|
return available ? code.id : null;
|
|
}
|
|
|
|
private decodePlanFromSubscription(sub: Stripe.Subscription) {
|
|
const price = sub.items.data[0].price;
|
|
|
|
if (!price.lookup_key) {
|
|
throw new Error('Unexpected subscription with no key');
|
|
}
|
|
|
|
return decodeLookupKey(price.lookup_key);
|
|
}
|
|
}
|