mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 10:06:17 +08:00
feat(server): support selfhost licenses (#8947)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user