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 Nov 25, 2024
1 parent b369ee0 commit d310992
Show file tree
Hide file tree
Showing 23 changed files with 1,772 additions and 1,345 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);
}
}
}
11 changes: 7 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,27 @@ 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 { 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,
],
controllers: [StripeWebhook],
controllers: [StripeWebhookController],
requires: [
'plugins.payment.stripe.keys.APIKey',
'plugins.payment.stripe.keys.webhookKey',
Expand Down
47 changes: 47 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,47 @@
import { UserStripeCustomer } from '@prisma/client';

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

export interface BaseSubscription {
status: string;
}

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

export interface SubscriptionManager<Subscription extends BaseSubscription> {
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,
price: KnownStripePrice
): Promise<Subscription>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './user';
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ export class ScheduleManager {
}

async fromSubscription(
idempotencyKey: string,
subscription: string | Stripe.Subscription
subscription: string | Stripe.Subscription,
idempotencyKey?: string
) {
if (typeof subscription === 'string') {
subscription = await this.stripe.subscriptions.retrieve(subscription, {
Expand All @@ -88,7 +88,7 @@ export class ScheduleManager {
* Cancel a subscription by marking schedule's end behavior to `cancel`.
* At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription.
*/
async cancel(idempotencyKey: string) {
async cancel(idempotencyKey?: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
Expand Down Expand Up @@ -129,7 +129,7 @@ export class ScheduleManager {
);
}

async resume(idempotencyKey: string) {
async resume(idempotencyKey?: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
Expand Down Expand Up @@ -188,7 +188,7 @@ export class ScheduleManager {
});
}

async update(idempotencyKey: string, price: string) {
async update(price: string, idempotencyKey?: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
Expand Down
Loading

0 comments on commit d310992

Please sign in to comment.