Improve and update billing flow

This commit updates various components in the billing flow due to a new schema that supports multiple line items per plan. The added flexibility rendered 'line-items-mapper.ts' redundant, which has been removed. Additionally, webhooks have been created for handling account membership insertions and deletions, as well as handling subscription deletions when an account is deleted. This message also introduces a new service to handle sending out invitation emails. Lastly, the validation of the billing provider has been improved for increased security and stability.
This commit is contained in:
giancarlo
2024-03-30 14:51:16 +08:00
parent f93af31009
commit e158ff28d8
30 changed files with 670 additions and 465 deletions

View File

@@ -0,0 +1,8 @@
# Database Webhooks - @kit/database-webhooks
This package is responsible for handling webhooks from database changes.
For example:
1. when an account is deleted, we handle the cleanup of all related data in the third-party services.
2. when a user is invited, we send an email to the user.
3. when an account member is added, we update the subscription in the third-party services

View File

@@ -0,0 +1,50 @@
{
"name": "@kit/database-webhooks",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"@kit/billing-gateway": "workspace:^",
"@kit/team-accounts": "workspace:^",
"@kit/shared": "^0.1.0",
"@kit/supabase": "^0.1.0",
"@supabase/supabase-js": "^2.40.0"
},
"devDependencies": {
"@kit/billing": "workspace:^",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:^",
"@kit/stripe": "workspace:^",
"@kit/supabase": "workspace:^",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:^",
"@supabase/supabase-js": "^2.41.1",
"lucide-react": "^0.363.0",
"zod": "^3.22.4"
},
"eslintConfig": {
"root": true,
"extends": [
"@kit/eslint-config/base",
"@kit/eslint-config/react"
]
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1 @@
export * from './server/services/database-webhook-handler.service';

View File

@@ -0,0 +1,16 @@
import { Database } from '@kit/supabase/database';
export type Tables = Database['public']['Tables'];
export type TableChangeType = 'INSERT' | 'UPDATE' | 'DELETE';
export interface RecordChange<
Table extends keyof Tables,
Row = Tables[Table]['Row'],
> {
type: TableChangeType;
table: Table;
record: Row;
schema: 'public';
old_record: null | Row;
}

View File

@@ -0,0 +1,43 @@
import { Logger } from '@kit/shared/logger';
import { getSupabaseRouteHandlerClient } from '@kit/supabase/route-handler-client';
import { RecordChange, Tables } from '../record-change.type';
import { DatabaseWebhookRouterService } from './database-webhook-router.service';
export class DatabaseWebhookHandlerService {
private readonly namespace = 'database-webhook-handler';
async handleWebhook(request: Request, webhooksSecret: string) {
Logger.info(
{
name: this.namespace,
},
'Received webhook from DB. Processing...',
);
// check if the signature is valid
this.assertSignatureIsAuthentic(request, webhooksSecret);
const json = await request.json();
await this.handleWebhookBody(json);
}
private handleWebhookBody(body: RecordChange<keyof Tables>) {
const client = getSupabaseRouteHandlerClient({
admin: true,
});
const service = new DatabaseWebhookRouterService(client);
return service.handleWebhook(body);
}
private assertSignatureIsAuthentic(request: Request, webhooksSecret: string) {
const header = request.headers.get('X-Supabase-Event-Signature');
if (header !== webhooksSecret) {
throw new Error('Invalid signature');
}
}
}

View File

@@ -0,0 +1,58 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { RecordChange, Tables } from '../record-change.type';
export class DatabaseWebhookRouterService {
constructor(private readonly adminClient: SupabaseClient<Database>) {}
handleWebhook(body: RecordChange<keyof Tables>) {
switch (body.table) {
case 'invitations': {
const payload = body as RecordChange<typeof body.table>;
return this.handleInvitations(payload);
}
case 'subscriptions': {
const payload = body as RecordChange<typeof body.table>;
return this.handleSubscriptions(payload);
}
case 'accounts_memberships': {
const payload = body as RecordChange<typeof body.table>;
return this.handleAccountsMemberships(payload);
}
default:
throw new Error('No handler for this table');
}
}
private async handleInvitations(body: RecordChange<'invitations'>) {
const { AccountInvitationsWebhookService } = await import(
'@kit/team-accounts/webhooks'
);
const service = new AccountInvitationsWebhookService(this.adminClient);
return service.handleInvitationWebhook(body.record);
}
private async handleSubscriptions(body: RecordChange<'subscriptions'>) {
const { BillingWebhooksService } = await import('@kit/billing-gateway');
const service = new BillingWebhooksService();
return service.handleSubscriptionDeletedWebhook(body.record);
}
private handleAccountsMemberships(
payload: RecordChange<'accounts_memberships'>,
) {
// no-op
return Promise.resolve(undefined);
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}