mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
refactor(server): plugin modules (#5630)
- [x] separates modules into `fundamental`, `core`, `plugins`
- [x] optional modules with `@OptionalModule` decorator to install modules with requirements met(`requires`, `if`)
- [x] `module.contributesTo` defines optional features that will be enabled if module registered
- [x] `AFFiNE.plugins.use('payment', {})` to enable a optional/plugin module
- [x] `PaymentModule` is the first plugin module
- [x] GraphQLSchema will not be generated for non-included modules
- [x] Frontend can use `ServerConfigType` query to detect which features are enabled
- [x] override existing provider globally
This commit is contained in:
332
packages/backend/server/src/plugins/payment/resolver.ts
Normal file
332
packages/backend/server/src/plugins/payment/resolver.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Field,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { groupBy } from 'lodash-es';
|
||||
|
||||
import { Auth, CurrentUser, Public } from '../../core/auth';
|
||||
import { UserType } from '../../core/users';
|
||||
import { Config, PrismaService } from '../../fundamentals';
|
||||
import { decodeLookupKey, SubscriptionService } from './service';
|
||||
import {
|
||||
InvoiceStatus,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from './types';
|
||||
|
||||
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
|
||||
registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' });
|
||||
registerEnumType(SubscriptionPlan, { name: 'SubscriptionPlan' });
|
||||
registerEnumType(InvoiceStatus, { name: 'InvoiceStatus' });
|
||||
|
||||
@ObjectType()
|
||||
class SubscriptionPrice {
|
||||
@Field(() => String)
|
||||
type!: 'fixed';
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field()
|
||||
currency!: string;
|
||||
|
||||
@Field()
|
||||
amount!: number;
|
||||
|
||||
@Field()
|
||||
yearlyAmount!: number;
|
||||
}
|
||||
|
||||
@ObjectType('UserSubscription')
|
||||
export class UserSubscriptionType implements Partial<UserSubscription> {
|
||||
@Field({ name: 'id' })
|
||||
stripeSubscriptionId!: string;
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field(() => SubscriptionRecurring)
|
||||
recurring!: SubscriptionRecurring;
|
||||
|
||||
@Field(() => SubscriptionStatus)
|
||||
status!: SubscriptionStatus;
|
||||
|
||||
@Field(() => Date)
|
||||
start!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
end!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
trialStart?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
trialEnd?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
nextBillAt?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
canceledAt?: Date | null;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@ObjectType('UserInvoice')
|
||||
class UserInvoiceType implements Partial<UserInvoice> {
|
||||
@Field({ name: 'id' })
|
||||
stripeInvoiceId!: string;
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field(() => SubscriptionRecurring)
|
||||
recurring!: SubscriptionRecurring;
|
||||
|
||||
@Field()
|
||||
currency!: string;
|
||||
|
||||
@Field()
|
||||
amount!: number;
|
||||
|
||||
@Field(() => InvoiceStatus)
|
||||
status!: InvoiceStatus;
|
||||
|
||||
@Field()
|
||||
reason!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
lastPaymentError?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
link?: string | null;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@Resolver(() => UserSubscriptionType)
|
||||
export class SubscriptionResolver {
|
||||
constructor(
|
||||
private readonly service: SubscriptionService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Query(() => [SubscriptionPrice])
|
||||
async prices(): Promise<SubscriptionPrice[]> {
|
||||
const prices = await this.service.listPrices();
|
||||
|
||||
const group = groupBy(
|
||||
prices.data.filter(price => !!price.lookup_key),
|
||||
price => {
|
||||
// @ts-expect-error empty lookup key is filtered out
|
||||
const [plan] = decodeLookupKey(price.lookup_key);
|
||||
return plan;
|
||||
}
|
||||
);
|
||||
|
||||
return Object.entries(group).map(([plan, prices]) => {
|
||||
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
|
||||
);
|
||||
|
||||
if (!yearly || !monthly) {
|
||||
throw new GraphQLError('The prices are not configured correctly', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'fixed',
|
||||
plan: plan as SubscriptionPlan,
|
||||
currency: monthly.currency,
|
||||
amount: monthly.unit_amount ?? 0,
|
||||
yearlyAmount: yearly.unit_amount ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
description: 'Create a subscription checkout link of stripe',
|
||||
})
|
||||
async checkout(
|
||||
@CurrentUser() user: User,
|
||||
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
|
||||
recurring: SubscriptionRecurring,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
const session = await this.service.createCheckoutSession({
|
||||
user,
|
||||
recurring,
|
||||
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new GraphQLError('Failed to create checkout session', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return session.url;
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
description: 'Create a stripe customer portal to manage payment methods',
|
||||
})
|
||||
async createCustomerPortal(@CurrentUser() user: User) {
|
||||
return this.service.createCustomerPortal(user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
async cancelSubscription(
|
||||
@CurrentUser() user: User,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
return this.service.cancelSubscription(idempotencyKey, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
async resumeSubscription(
|
||||
@CurrentUser() user: User,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
return this.service.resumeCanceledSubscription(idempotencyKey, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
async updateSubscriptionRecurring(
|
||||
@CurrentUser() user: User,
|
||||
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
|
||||
recurring: SubscriptionRecurring,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
return this.service.updateSubscriptionRecurring(
|
||||
idempotencyKey,
|
||||
user.id,
|
||||
recurring
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly db: PrismaService
|
||||
) {}
|
||||
|
||||
@ResolveField(() => UserSubscriptionType, { nullable: true })
|
||||
async subscription(
|
||||
@Context() ctx: { isAdminQuery: boolean },
|
||||
@CurrentUser() me: User,
|
||||
@Parent() user: User
|
||||
) {
|
||||
// allow admin to query other user's subscription
|
||||
if (!ctx.isAdminQuery && me.id !== user.id) {
|
||||
throw new GraphQLError(
|
||||
'You are not allowed to access this subscription',
|
||||
{
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// @FIXME(@forehalo): should not mock any api for selfhosted server
|
||||
// the frontend should avoid calling such api if feature is not enabled
|
||||
if (this.config.flavor.selfhosted) {
|
||||
const start = new Date();
|
||||
const end = new Date();
|
||||
end.setFullYear(start.getFullYear() + 1);
|
||||
|
||||
return {
|
||||
stripeSubscriptionId: 'dummy',
|
||||
plan: SubscriptionPlan.SelfHosted,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start,
|
||||
end,
|
||||
createdAt: start,
|
||||
updatedAt: start,
|
||||
};
|
||||
}
|
||||
|
||||
return this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: SubscriptionStatus.Active,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [UserInvoiceType])
|
||||
async invoices(
|
||||
@CurrentUser() me: User,
|
||||
@Parent() user: User,
|
||||
@Args('take', { type: () => Int, nullable: true, defaultValue: 8 })
|
||||
take: number,
|
||||
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
||||
) {
|
||||
if (me.id !== user.id) {
|
||||
throw new GraphQLError('You are not allowed to access this invoices', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.db.userInvoice.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
take,
|
||||
skip,
|
||||
orderBy: {
|
||||
id: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user