Skip to content

Commit

Permalink
refactor(server): payment service
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Dec 2, 2024
1 parent 9b4cd83 commit 36a8ed5
Show file tree
Hide file tree
Showing 26 changed files with 1,910 additions and 1,513 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "user_invoices" ALTER COLUMN "plan" DROP NOT NULL,
ALTER COLUMN "recurring" DROP NOT NULL,
ALTER COLUMN "reason" DROP NOT NULL;

-- CreateIndex
CREATE INDEX "user_invoices_user_id_idx" ON "user_invoices"("user_id");
2 changes: 1 addition & 1 deletion packages/backend/server/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
provider = "postgresql"
12 changes: 8 additions & 4 deletions packages/backend/server/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ model UserSubscription {
// yearly/monthly/lifetime
recurring String @db.VarChar(20)
// onetime subscription or anything else
variant String? @db.VarChar(20)
variant String? @db.VarChar(20)
// subscription.id, null for linefetime payment or one time payment subscription
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
// subscription.status, active/past_due/canceled/unpaid...
Expand Down Expand Up @@ -370,18 +370,22 @@ model UserInvoice {
// CNY 12.50 stored as 1250
amount Int @db.Integer
status String @db.VarChar(20)
plan String @db.VarChar(20)
recurring String @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
// billing reason
reason String @db.VarChar
reason String? @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text
// stripe hosted invoice link
link String? @db.Text
// @deprecated
plan String? @db.VarChar(20)
// @deprecated
recurring String? @db.VarChar(20)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("user_invoices")
}

Expand Down
52 changes: 52 additions & 0 deletions packages/backend/server/src/plugins/payment/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from 'node:assert';

import type { RawBodyRequest } from '@nestjs/common';
import { Controller, Logger, Post, Req } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { Request } from 'express';
import Stripe from 'stripe';

import { Public } from '../../core/auth';
import { Config, InternalServerError } from '../../fundamentals';

@Controller('/api/stripe')
export class StripeWebhookController {
private readonly webhookKey: string;
private readonly logger = new Logger(StripeWebhookController.name);

constructor(
config: Config,
private readonly stripe: Stripe,
private readonly event: EventEmitter2
) {
assert(config.plugins.payment.stripe);
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
}

@Public()
@Post('/webhook')
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
// Retrieve the event by verifying the signature using the raw body and secret.
const signature = req.headers['stripe-signature'];
try {
const event = this.stripe.webhooks.constructEvent(
req.rawBody ?? '',
signature ?? '',
this.webhookKey
);

this.logger.debug(
`[${event.id}] Stripe Webhook {${event.type}} received.`
);

// Stripe requires responseing webhook immediately and handle event asynchronously.
setImmediate(() => {
this.event.emitAsync(`stripe:${event.type}`, event).catch(e => {
this.logger.error('Failed to handle Stripe Webhook event.', e);
});
});
} catch (err: any) {
throw new InternalServerError(err.message);
}
}
}
54 changes: 54 additions & 0 deletions packages/backend/server/src/plugins/payment/cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';

import { EventEmitter, type EventPayload } from '../../fundamentals';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionVariant,
} from './types';

@Injectable()
export class SubscriptionCronJobs {
constructor(
private readonly db: PrismaClient,
private readonly event: EventEmitter
) {}

@Cron(CronExpression.EVERY_HOUR)
async cleanExpiredOnetimeSubscriptions() {
const subscriptions = await this.db.userSubscription.findMany({
where: {
variant: SubscriptionVariant.Onetime,
end: {
lte: new Date(),
},
},
});

for (const subscription of subscriptions) {
this.event.emit('user.subscription.canceled', {
userId: subscription.userId,
plan: subscription.plan as SubscriptionPlan,
recurring: subscription.variant as SubscriptionRecurring,
});
}
}

@OnEvent('user.subscription.canceled')
async handleUserSubscriptionCanceled({
userId,
plan,
}: EventPayload<'user.subscription.canceled'>) {
await this.db.userSubscription.delete({
where: {
userId_plan: {
userId,
plan,
},
},
});
}
}
13 changes: 9 additions & 4 deletions packages/backend/server/src/plugins/payment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,29 @@ import './config';

import { ServerFeature } from '../../core/config';
import { FeatureModule } from '../../core/features';
import { UserModule } from '../../core/user';
import { Plugin } from '../registry';
import { StripeWebhookController } from './controller';
import { SubscriptionCronJobs } from './cron';
import { UserSubscriptionManager } from './manager';
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
import { ScheduleManager } from './schedule';
import { SubscriptionService } from './service';
import { StripeProvider } from './stripe';
import { StripeWebhook } from './webhook';

@Plugin({
name: 'payment',
imports: [FeatureModule],
imports: [FeatureModule, UserModule],
providers: [
ScheduleManager,
StripeProvider,
SubscriptionService,
SubscriptionResolver,
UserSubscriptionResolver,
StripeWebhook,
UserSubscriptionManager,
SubscriptionCronJobs,
],
controllers: [StripeWebhook],
controllers: [StripeWebhookController],
requires: [
'plugins.payment.stripe.keys.APIKey',
'plugins.payment.stripe.keys.webhookKey',
Expand Down
56 changes: 56 additions & 0 deletions packages/backend/server/src/plugins/payment/manager/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { UserStripeCustomer } from '@prisma/client';

import {
KnownStripePrice,
KnownStripeSubscription,
SubscriptionPlan,
SubscriptionRecurring,
} from '../types';

export interface Subscription {
status: string;
plan: string;
recurring: string;
variant: string | null;
start: Date;
end: Date | null;
trialStart: Date | null;
trialEnd: Date | null;
nextBillAt: Date | null;
canceledAt: Date | null;
}

export interface Invoice {
currency: string;
amount: number;
status: string;
createdAt: Date;
lastPaymentError: string | null;
link: string | null;
}

export interface SubscriptionManager {
filterPrices(
prices: KnownStripePrice[],
customer?: UserStripeCustomer
): Promise<KnownStripePrice[]>;

saveSubscription(
subscription: KnownStripeSubscription
): Promise<Subscription>;
deleteSubscription(subscription: KnownStripeSubscription): Promise<void>;

getSubscription(
id: string,
plan: SubscriptionPlan
): Promise<Subscription | null>;

cancelSubscription(subscription: Subscription): Promise<Subscription>;

resumeSubscription(subscription: Subscription): Promise<Subscription>;

updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
): Promise<Subscription>;
}
2 changes: 2 additions & 0 deletions packages/backend/server/src/plugins/payment/manager/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './common';
export * from './user';
Loading

0 comments on commit 36a8ed5

Please sign in to comment.