feat(server): support selfhost licenses (#8947)

This commit is contained in:
forehalo
2025-01-22 10:21:07 +00:00
parent 22e424d7de
commit 994d758c07
31 changed files with 1653 additions and 127 deletions
@@ -22,6 +22,7 @@ export interface Subscription {
plan: string;
recurring: string;
variant: string | null;
quantity: number;
start: Date;
end: Date | null;
trialStart: Date | null;
@@ -99,11 +100,13 @@ export abstract class SubscriptionManager {
transformSubscription({
lookupKey,
stripeSubscription: subscription,
quantity,
}: KnownStripeSubscription): Subscription {
return {
...lookupKey,
stripeScheduleId: subscription.schedule as string | null,
stripeSubscriptionId: subscription.id,
quantity,
status: subscription.status,
start: new Date(subscription.current_period_start * 1000),
end: new Date(subscription.current_period_end * 1000),
@@ -224,7 +227,7 @@ export abstract class SubscriptionManager {
protected async getCouponFromPromotionCode(
userFacingPromotionCode: string,
customer: UserStripeCustomer
customer?: UserStripeCustomer
) {
const list = await this.stripe.promotionCodes.list({
code: userFacingPromotionCode,
@@ -243,11 +246,20 @@ export abstract class SubscriptionManager {
// 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;
if (code.customer) {
if (!customer) {
return null;
}
return (
typeof code.customer === 'string'
? code.customer === customer.stripeCustomerId
: code.customer.id === customer.stripeCustomerId
)
? code.coupon.id
: null;
}
return code.coupon.id;
}
}
@@ -1,3 +1,4 @@
export * from './common';
export * from './selfhost';
export * from './user';
export * from './workspace';
@@ -0,0 +1,231 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { PrismaClient, UserStripeCustomer } from '@prisma/client';
import { pick } from 'lodash-es';
import Stripe from 'stripe';
import { z } from 'zod';
import {
MailService,
SubscriptionPlanNotFound,
URLHelper,
} from '../../../base';
import {
KnownStripeInvoice,
KnownStripePrice,
KnownStripeSubscription,
LookupKey,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../types';
import {
CheckoutParams,
Invoice,
Subscription,
SubscriptionManager,
} from './common';
export const SelfhostTeamCheckoutArgs = z.object({
quantity: z.number(),
});
export const SelfhostTeamSubscriptionIdentity = z.object({
plan: z.literal(SubscriptionPlan.SelfHostedTeam),
key: z.string(),
});
@Injectable()
export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
constructor(
stripe: Stripe,
db: PrismaClient,
private readonly url: URLHelper,
private readonly mailer: MailService
) {
super(stripe, db);
}
filterPrices(
prices: KnownStripePrice[],
_customer?: UserStripeCustomer
): KnownStripePrice[] {
return prices.filter(
price => price.lookupKey.plan === SubscriptionPlan.SelfHostedTeam
);
}
async checkout(
lookupKey: LookupKey,
params: z.infer<typeof CheckoutParams>,
args: z.infer<typeof SelfhostTeamCheckoutArgs>
) {
const { quantity } = args;
const price = await this.getPrice(lookupKey);
if (!price) {
throw new SubscriptionPlanNotFound({
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
}
const discounts = await (async () => {
if (params.coupon) {
const couponId = await this.getCouponFromPromotionCode(params.coupon);
if (couponId) {
return { discounts: [{ coupon: couponId }] };
}
}
return { allow_promotion_codes: true };
})();
let successUrl = this.url.link(params.successCallbackLink);
// stripe only accept unescaped '{CHECKOUT_SESSION_ID}' as query
successUrl = this.url.addSimpleQuery(
successUrl,
'session_id',
'{CHECKOUT_SESSION_ID}',
false
);
return this.stripe.checkout.sessions.create({
line_items: [
{
price: price.price.id,
quantity,
},
],
tax_id_collection: {
enabled: true,
},
...discounts,
mode: 'subscription',
success_url: successUrl,
});
}
async saveStripeSubscription(subscription: KnownStripeSubscription) {
const { stripeSubscription, userEmail } = subscription;
const subscriptionData = this.transformSubscription(subscription);
const existingSubscription = await this.db.subscription.findFirst({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
});
if (!existingSubscription) {
const key = randomUUID();
const [subscription] = await this.db.$transaction([
this.db.subscription.create({
data: {
targetId: key,
...subscriptionData,
},
}),
this.db.license.create({
data: { key },
}),
]);
await this.mailer.sendTeamLicenseMail(userEmail, { license: key });
return subscription;
} else {
return this.db.subscription.update({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
data: pick(subscriptionData, [
'status',
'stripeScheduleId',
'nextBillAt',
'canceledAt',
]),
});
}
}
async deleteStripeSubscription({
stripeSubscription,
}: KnownStripeSubscription) {
const subscription = await this.db.subscription.findFirst({
where: { stripeSubscriptionId: stripeSubscription.id },
});
if (!subscription) {
return;
}
await this.db.$transaction([
this.db.subscription.deleteMany({
where: { stripeSubscriptionId: stripeSubscription.id },
}),
this.db.license.deleteMany({
where: { key: subscription.targetId },
}),
]);
}
getSubscription(identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>) {
return this.db.subscription.findFirst({
where: {
targetId: identity.key,
plan: identity.plan,
status: {
in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing],
},
},
});
}
async cancelSubscription(subscription: Subscription) {
return await this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
}
resumeSubscription(subscription: Subscription): Promise<Subscription> {
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: {
canceledAt: null,
nextBillAt: subscription.end,
},
});
}
updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
): Promise<Subscription> {
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: { recurring },
});
}
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
const invoiceData = await this.transformInvoice(knownInvoice);
return invoiceData;
}
}
@@ -209,6 +209,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveStripeSubscription(subscription: KnownStripeSubscription) {
const { userId, lookupKey, stripeSubscription } = subscription;
this.assertUserIdExists(userId);
// update features first, features modify are idempotent
// so there is no need to skip if a subscription already exists.
// TODO(@forehalo):
@@ -235,7 +237,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
]),
create: {
userId,
...subscriptionData,
...omit(subscriptionData, 'quantity'),
},
});
@@ -261,6 +263,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
lookupKey,
stripeSubscription,
}: KnownStripeSubscription) {
this.assertUserIdExists(userId);
const deleted = await this.db.subscription.deleteMany({
where: {
stripeSubscriptionId: stripeSubscription.id,
@@ -385,6 +389,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveInvoice(knownInvoice: KnownStripeInvoice) {
const { userId, lookupKey, stripeInvoice } = knownInvoice;
this.assertUserIdExists(userId);
const invoiceData = await this.transformInvoice(knownInvoice);
@@ -427,6 +432,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveLifetimeSubscription(
knownInvoice: KnownStripeInvoice
): Promise<Subscription> {
this.assertUserIdExists(knownInvoice.userId);
// cancel previous non-lifetime subscription
const prevSubscription = await this.db.subscription.findUnique({
where: {
@@ -492,6 +499,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveOnetimePaymentSubscription(
knownInvoice: KnownStripeInvoice
): Promise<Subscription> {
this.assertUserIdExists(knownInvoice.userId);
// TODO(@forehalo): identify whether the invoice has already been redeemed.
const { userId, lookupKey } = knownInvoice;
const existingSubscription = await this.db.subscription.findUnique({
@@ -714,4 +723,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
onetime: false,
};
}
private assertUserIdExists(
userId: string | undefined
): asserts userId is string {
if (!userId) {
throw new Error('user should exists for stripe subscription or invoice.');
}
}
}
@@ -128,7 +128,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
}
async saveStripeSubscription(subscription: KnownStripeSubscription) {
const { lookupKey, quantity, stripeSubscription } = subscription;
const { lookupKey, stripeSubscription } = subscription;
const workspaceId = stripeSubscription.metadata.workspaceId;
@@ -138,31 +138,30 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
);
}
const subscriptionData = this.transformSubscription(subscription);
this.event.emit('workspace.subscription.activated', {
workspaceId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
quantity,
quantity: subscriptionData.quantity,
});
const subscriptionData = this.transformSubscription(subscription);
return this.db.subscription.upsert({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
update: {
quantity,
...pick(subscriptionData, [
'status',
'stripeScheduleId',
'nextBillAt',
'canceledAt',
'quantity',
]),
},
create: {
targetId: workspaceId,
quantity,
...subscriptionData,
},
});