feat(server): support lifetime subscription (#7405)

closes CLOUD-48

- [x] lifetime subscription quota
- [ ] tests
This commit is contained in:
forehalo
2024-07-08 07:41:26 +00:00
parent 7235779b02
commit de91027852
17 changed files with 447 additions and 165 deletions

View File

@@ -53,12 +53,15 @@ class SubscriptionPrice {
@Field(() => Int, { nullable: true })
yearlyAmount?: number | null;
@Field(() => Int, { nullable: true })
lifetimeAmount?: number | null;
}
@ObjectType('UserSubscription')
export class UserSubscriptionType implements Partial<UserSubscription> {
@Field({ name: 'id' })
stripeSubscriptionId!: string;
@Field(() => String, { name: 'id', nullable: true })
stripeSubscriptionId!: string | null;
@Field(() => SubscriptionPlan, {
description:
@@ -75,8 +78,8 @@ export class UserSubscriptionType implements Partial<UserSubscription> {
@Field(() => Date)
start!: Date;
@Field(() => Date)
end!: Date;
@Field(() => Date, { nullable: true })
end!: Date | null;
@Field(() => Date, { nullable: true })
trialStart?: Date | null;
@@ -187,11 +190,19 @@ export class SubscriptionResolver {
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 currency = monthlyPrice?.currency ?? yearlyPrice?.currency ?? 'usd';
return {
currency,
amount: monthlyPrice?.unit_amount,
yearlyAmount: yearlyPrice?.unit_amount,
lifetimeAmount: lifetimePrice?.unit_amount,
};
}

View File

@@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
import type {
Prisma,
User,
UserInvoice,
UserStripeCustomer,
@@ -16,6 +15,7 @@ import { CurrentUser } from '../../core/auth';
import { EarlyAccessType, FeatureManagementService } from '../../core/features';
import {
ActionForbidden,
CantUpdateLifetimeSubscription,
Config,
CustomerPortalCreateFailed,
EventEmitter,
@@ -131,7 +131,11 @@ export class SubscriptionService {
}
const [plan, recurring, variant] = decodeLookupKey(price.lookup_key);
if (recurring === SubscriptionRecurring.Monthly) {
// no variant price should be used for monthly or lifetime subscription
if (
recurring === SubscriptionRecurring.Monthly ||
recurring === SubscriptionRecurring.Lifetime
) {
return !variant;
}
@@ -184,7 +188,12 @@ export class SubscriptionService {
},
});
if (currentSubscription) {
if (
currentSubscription &&
// do not allow to re-subscribe unless the new recurring is `Lifetime`
(currentSubscription.recurring === recurring ||
recurring !== SubscriptionRecurring.Lifetime)
) {
throw new SubscriptionAlreadyExists({ plan });
}
@@ -224,8 +233,19 @@ export class SubscriptionService {
tax_id_collection: {
enabled: true,
},
// discount
...(discounts.length ? { discounts } : { allow_promotion_codes: true }),
mode: 'subscription',
// 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: {
@@ -264,6 +284,12 @@ export class SubscriptionService {
throw new SubscriptionNotExists({ plan });
}
if (!subscriptionInDB.stripeSubscriptionId) {
throw new CantUpdateLifetimeSubscription(
'Lifetime subscription cannot be canceled.'
);
}
if (subscriptionInDB.canceledAt) {
throw new SubscriptionHasBeenCanceled();
}
@@ -315,6 +341,12 @@ export class SubscriptionService {
throw new SubscriptionNotExists({ plan });
}
if (!subscriptionInDB.stripeSubscriptionId || !subscriptionInDB.end) {
throw new CantUpdateLifetimeSubscription(
'Lifetime subscription cannot be resumed.'
);
}
if (!subscriptionInDB.canceledAt) {
throw new SubscriptionHasBeenCanceled();
}
@@ -368,6 +400,12 @@ export class SubscriptionService {
throw new SubscriptionNotExists({ plan });
}
if (!subscriptionInDB.stripeSubscriptionId) {
throw new CantUpdateLifetimeSubscription(
'Can not update lifetime subscription.'
);
}
if (subscriptionInDB.canceledAt) {
throw new SubscriptionHasBeenCanceled();
}
@@ -422,60 +460,12 @@ export class SubscriptionService {
}
}
@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] = this.decodePlanFromSubscription(subscription);
this.event.emit('user.subscription.canceled', {
userId: user.id,
plan,
});
await this.db.userSubscription.deleteMany({
where: {
stripeSubscriptionId: subscription.id,
},
});
}
@OnStripeEvent('invoice.paid')
async onInvoicePaid(stripeInvoice: Stripe.Invoice) {
stripeInvoice = await this.stripe.invoices.retrieve(stripeInvoice.id);
await this.saveInvoice(stripeInvoice);
const line = stripeInvoice.lines.data[0];
if (!line.price || line.price.type !== 'recurring') {
throw new Error('Unknown invoice with no recurring price');
}
}
@OnStripeEvent('invoice.created')
@OnStripeEvent('invoice.updated')
@OnStripeEvent('invoice.finalization_failed')
@OnStripeEvent('invoice.payment_failed')
async saveInvoice(stripeInvoice: Stripe.Invoice) {
@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');
@@ -487,12 +477,6 @@ export class SubscriptionService {
: stripeInvoice.customer.id
);
const invoice = await this.db.userInvoice.findUnique({
where: {
stripeInvoiceId: stripeInvoice.id,
},
});
const data: Partial<UserInvoice> = {
currency: stripeInvoice.currency,
amount: stripeInvoice.total,
@@ -524,39 +508,135 @@ export class SubscriptionService {
}
}
// update invoice
if (invoice) {
await this.db.userInvoice.update({
where: {
stripeInvoiceId: stripeInvoice.id,
// 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,
end: null,
},
data,
});
await this.stripe.subscriptions.cancel(
savedSubscription.stripeSubscriptionId,
{
prorate: true,
}
);
} else {
// create invoice
const price = stripeInvoice.lines.data[0].price;
if (!price || price.type !== 'recurring') {
throw new Error('Unexpected invoice with no recurring price');
}
if (!price.lookup_key) {
throw new Error('Unexpected subscription with no key');
}
const [plan, recurring] = decodeLookupKey(price.lookup_key);
await this.db.userInvoice.create({
await this.db.userSubscription.create({
data: {
userId: user.id,
stripeInvoiceId: stripeInvoice.id,
plan,
recurring,
reason: stripeInvoice.billing_reason ?? 'contact support',
...(data as any),
stripeSubscriptionId: null,
plan: invoice.plan,
recurring: invoice.recurring,
end: null,
start: new Date(),
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(
@@ -576,6 +656,7 @@ export class SubscriptionService {
this.event.emit('user.subscription.activated', {
userId: user.id,
plan,
recurring,
});
let nextBillAt: Date | null = null;
@@ -600,44 +681,21 @@ export class SubscriptionService {
: null,
stripeSubscriptionId: subscription.id,
plan,
recurring,
status: subscription.status,
stripeScheduleId: subscription.schedule as string | null,
};
const currentSubscription = await this.db.userSubscription.findUnique({
return await this.db.userSubscription.upsert({
where: {
userId_plan: {
userId: user.id,
plan,
},
stripeSubscriptionId: subscription.id,
},
update: commonData,
create: {
userId: user.id,
recurring,
...commonData,
},
});
if (currentSubscription) {
const update: Prisma.UserSubscriptionUpdateInput = {
...commonData,
};
// a schedule exists, update the recurring to scheduled one
if (update.stripeScheduleId) {
delete update.recurring;
}
return await this.db.userSubscription.update({
where: {
id: currentSubscription.id,
},
data: update,
});
} else {
return await this.db.userSubscription.create({
data: {
userId: user.id,
...commonData,
},
});
}
}
private async getOrCreateCustomer(

View File

@@ -5,6 +5,7 @@ import type { Payload } from '../../fundamentals/event/def';
export enum SubscriptionRecurring {
Monthly = 'monthly',
Yearly = 'yearly',
Lifetime = 'lifetime',
}
export enum SubscriptionPlan {
@@ -46,10 +47,12 @@ declare module '../../fundamentals/event/def' {
activated: Payload<{
userId: User['id'];
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
}>;
canceled: Payload<{
userId: User['id'];
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
}>;
};
}

View File

@@ -45,9 +45,16 @@ export class StripeWebhook {
setImmediate(() => {
// handle duplicated events?
// see https://stripe.com/docs/webhooks#handle-duplicate-events
this.event.emitAsync(event.type, event.data.object).catch(e => {
this.logger.error('Failed to handle Stripe Webhook event.', e);
});
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);