feat(server): combine plan and recurring as stripe lookup key

This commit is contained in:
forehalo
2023-10-24 11:22:27 +08:00
parent 9b43380b05
commit 2e4f6ef2ed
2 changed files with 87 additions and 41 deletions

View File

@@ -16,12 +16,14 @@ import {
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import type { User, UserInvoice, UserSubscription } from '@prisma/client'; import type { User, UserInvoice, UserSubscription } from '@prisma/client';
import { groupBy } from 'lodash-es';
import { Config } from '../../config'; import { Config } from '../../config';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { Auth, CurrentUser, Public } from '../auth'; import { Auth, CurrentUser, Public } from '../auth';
import { UserType } from '../users'; import { UserType } from '../users';
import { import {
decodeLookupKey,
InvoiceStatus, InvoiceStatus,
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
@@ -140,26 +142,45 @@ export class SubscriptionResolver {
async prices(): Promise<SubscriptionPrice[]> { async prices(): Promise<SubscriptionPrice[]> {
const prices = await this.service.listPrices(); const prices = await this.service.listPrices();
const yearly = prices.data.find( const group = groupBy(
price => price.lookup_key === SubscriptionRecurring.Yearly prices.data.filter(price => !!price.lookup_key),
); price => {
const monthly = prices.data.find( // @ts-expect-error empty lookup key is filtered out
price => price.lookup_key === SubscriptionRecurring.Monthly const [plan] = decodeLookupKey(price.lookup_key);
return plan;
}
); );
if (!yearly || !monthly) { return Object.entries(group).map(([plan, prices]) => {
throw new BadGatewayException('The prices are not configured correctly'); const yearly = prices.find(
} price =>
decodeLookupKey(
// @ts-expect-error empty lookup key is filtered out
price.lookup_key
)[1] === SubscriptionRecurring.Yearly
);
const monthly = prices.find(
price =>
decodeLookupKey(
// @ts-expect-error empty lookup key is filtered out
price.lookup_key
)[1] === SubscriptionRecurring.Monthly
);
return [ if (!yearly || !monthly) {
{ throw new BadGatewayException(
'The prices are not configured correctly'
);
}
return {
type: 'fixed', type: 'fixed',
plan: SubscriptionPlan.Pro, plan: plan as SubscriptionPlan,
currency: monthly.currency, currency: monthly.currency,
amount: monthly.unit_amount ?? 0, amount: monthly.unit_amount ?? 0,
yearlyAmount: yearly.unit_amount ?? 0, yearlyAmount: yearly.unit_amount ?? 0,
}, };
]; });
} }
@Mutation(() => String, { @Mutation(() => String, {

View File

@@ -17,7 +17,7 @@ const OnEvent = (
opts?: Parameters<typeof RawOnEvent>[1] opts?: Parameters<typeof RawOnEvent>[1]
) => RawOnEvent(event, opts); ) => RawOnEvent(event, opts);
// also used as lookup key for stripe prices // Plan x Recurring make a stripe price lookup key
export enum SubscriptionRecurring { export enum SubscriptionRecurring {
Monthly = 'monthly', Monthly = 'monthly',
Yearly = 'yearly', Yearly = 'yearly',
@@ -30,6 +30,21 @@ export enum SubscriptionPlan {
Enterprise = 'enterprise', Enterprise = 'enterprise',
} }
export function encodeLookupKey(
plan: SubscriptionPlan,
recurring: SubscriptionRecurring
): string {
return plan + '_' + recurring;
}
export function decodeLookupKey(
key: string
): [SubscriptionPlan, SubscriptionRecurring] {
const [plan, recurring] = key.split('_');
return [plan as SubscriptionPlan, recurring as SubscriptionRecurring];
}
// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status // see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
export enum SubscriptionStatus { export enum SubscriptionStatus {
Active = 'active', Active = 'active',
@@ -77,17 +92,17 @@ export class SubscriptionService {
} }
async listPrices() { async listPrices() {
return this.stripe.prices.list({ return this.stripe.prices.list();
lookup_keys: Object.values(SubscriptionRecurring),
});
} }
async createCheckoutSession({ async createCheckoutSession({
user, user,
recurring, recurring,
redirectUrl, redirectUrl,
plan = SubscriptionPlan.Pro,
}: { }: {
user: User; user: User;
plan?: SubscriptionPlan;
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
redirectUrl: string; redirectUrl: string;
}) { }) {
@@ -101,19 +116,13 @@ export class SubscriptionService {
throw new Error('You already have a subscription'); throw new Error('You already have a subscription');
} }
const prices = await this.stripe.prices.list({ const price = await this.getPrice(plan, recurring);
lookup_keys: [recurring],
});
if (!prices.data.length) {
throw new Error(`Unknown subscription recurring: ${recurring}`);
}
const customer = await this.getOrCreateCustomer(user); const customer = await this.getOrCreateCustomer(user);
return await this.stripe.checkout.sessions.create({ return await this.stripe.checkout.sessions.create({
line_items: [ line_items: [
{ {
price: prices.data[0].id, price,
quantity: 1, quantity: 1,
}, },
], ],
@@ -215,7 +224,7 @@ export class SubscriptionService {
async updateSubscriptionRecurring( async updateSubscriptionRecurring(
userId: string, userId: string,
recurring: string recurring: SubscriptionRecurring
): Promise<UserSubscription> { ): Promise<UserSubscription> {
const user = await this.db.user.findUnique({ const user = await this.db.user.findUnique({
where: { where: {
@@ -238,28 +247,23 @@ export class SubscriptionService {
throw new Error('You have already subscribed to this plan'); throw new Error('You have already subscribed to this plan');
} }
const prices = await this.stripe.prices.list({ const price = await this.getPrice(
lookup_keys: [recurring], user.subscription.plan as SubscriptionPlan,
}); recurring
);
if (!prices.data.length) {
throw new Error(`Unknown subscription recurring: ${recurring}`);
}
const newPrice = prices.data[0];
let scheduleId: string | null; let scheduleId: string | null;
// a schedule existing // a schedule existing
if (user.subscription.stripeScheduleId) { if (user.subscription.stripeScheduleId) {
scheduleId = await this.scheduleNewPrice( scheduleId = await this.scheduleNewPrice(
user.subscription.stripeScheduleId, user.subscription.stripeScheduleId,
newPrice.id price
); );
} else { } else {
const schedule = await this.stripe.subscriptionSchedules.create({ const schedule = await this.stripe.subscriptionSchedules.create({
from_subscription: user.subscription.stripeSubscriptionId, from_subscription: user.subscription.stripeSubscriptionId,
}); });
await this.scheduleNewPrice(schedule.id, newPrice.id); await this.scheduleNewPrice(schedule.id, price);
scheduleId = schedule.id; scheduleId = schedule.id;
} }
@@ -426,6 +430,11 @@ export class SubscriptionService {
} }
const price = subscription.items.data[0].price; const price = subscription.items.data[0].price;
if (!price.lookup_key) {
throw new Error('Unexpected subscription with no key');
}
const [plan, recurring] = decodeLookupKey(price.lookup_key);
const commonData = { const commonData = {
start: new Date(subscription.current_period_start * 1000), start: new Date(subscription.current_period_start * 1000),
@@ -441,9 +450,8 @@ export class SubscriptionService {
? new Date(subscription.canceled_at * 1000) ? new Date(subscription.canceled_at * 1000)
: null, : null,
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
recurring: price.lookup_key ?? price.id, plan,
// TODO: dynamic plans recurring,
plan: SubscriptionPlan.Pro,
status: subscription.status, status: subscription.status,
stripeScheduleId: subscription.schedule as string | null, stripeScheduleId: subscription.schedule as string | null,
}; };
@@ -560,6 +568,23 @@ export class SubscriptionService {
return user; return user;
} }
private async getPrice(
plan: SubscriptionPlan,
recurring: SubscriptionRecurring
): Promise<string> {
const prices = await this.stripe.prices.list({
lookup_keys: [encodeLookupKey(plan, recurring)],
});
if (!prices.data.length) {
throw new Error(
`Unknown subscription plan ${plan} with recurring ${recurring}`
);
}
return prices.data[0].id;
}
/** /**
* If a subscription is managed by a schedule, it has a different way to cancel. * If a subscription is managed by a schedule, it has a different way to cancel.
*/ */
@@ -674,7 +699,7 @@ export class SubscriptionService {
/** /**
* we only schedule a new price when user change the recurring plan and there is now upcoming phases. * we only schedule a new price when user change the recurring plan and there is now upcoming phases.
*/ */
async scheduleNewPrice( private async scheduleNewPrice(
scheduleId: string, scheduleId: string,
priceId: string priceId: string
): Promise<string | null> { ): Promise<string | null> {