From e158ff28d8298b46fd68879ce18d24839d8f2558 Mon Sep 17 00:00:00 2001 From: giancarlo Date: Sat, 30 Mar 2024 14:51:16 +0800 Subject: [PATCH] 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. --- apps/web/.env.development | 4 +- .../personal-account-checkout-form.tsx | 2 +- .../(dashboard)/home/(user)/billing/page.tsx | 36 +- .../home/[account]/billing/server-actions.ts | 1 - apps/web/app/api/database/webhook.ts | 3 - apps/web/app/api/database/webhook/route.ts | 20 + apps/web/app/layout.tsx | 10 +- apps/web/next.config.mjs | 1 + apps/web/package.json | 1 + .../src/components/current-plan-card.tsx | 22 +- .../src/components/plan-picker.tsx | 363 ++++++++++-------- packages/billing-gateway/src/index.ts | 2 +- .../services/account-billing.service.ts | 68 ---- .../billing-gateway-factory.service.ts | 7 +- .../billing-webhooks.service.ts | 15 + packages/database-webhooks/README.md | 8 + packages/database-webhooks/package.json | 50 +++ packages/database-webhooks/src/index.ts | 1 + .../src/server/record-change.type.ts | 16 + .../database-webhook-handler.service.ts | 43 +++ .../database-webhook-router.service.ts | 58 +++ packages/database-webhooks/tsconfig.json | 8 + .../delete-personal-account.service.ts | 9 - packages/features/team-accounts/package.json | 3 +- .../account-invitations-webhook.service.ts | 105 +++++ .../services/account-invitations.service.ts | 108 ------ .../services/delete-team-account.service.ts | 17 +- pnpm-lock.yaml | 113 +++--- supabase/migrations/20221215192558_schema.sql | 4 + supabase/seed.sql | 37 ++ 30 files changed, 670 insertions(+), 465 deletions(-) delete mode 100644 apps/web/app/api/database/webhook.ts create mode 100644 apps/web/app/api/database/webhook/route.ts delete mode 100644 packages/billing-gateway/src/server/services/account-billing.service.ts create mode 100644 packages/billing-gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts create mode 100644 packages/database-webhooks/README.md create mode 100644 packages/database-webhooks/package.json create mode 100644 packages/database-webhooks/src/index.ts create mode 100644 packages/database-webhooks/src/server/record-change.type.ts create mode 100644 packages/database-webhooks/src/server/services/database-webhook-handler.service.ts create mode 100644 packages/database-webhooks/src/server/services/database-webhook-router.service.ts create mode 100644 packages/database-webhooks/tsconfig.json create mode 100644 packages/features/team-accounts/src/server/services/account-invitations-webhook.service.ts diff --git a/apps/web/.env.development b/apps/web/.env.development index 83f2d963a..8ee84a2c1 100644 --- a/apps/web/.env.development +++ b/apps/web/.env.development @@ -17,9 +17,11 @@ NEXT_PUBLIC_BILLING_PROVIDER=stripe # SUPABASE NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 - NEXT_PUBLIC_REQUIRE_EMAIL_CONFIRMATION=true +## THIS IS FOR DEVELOPMENT ONLY - DO NOT USE IN PRODUCTION +SUPABASE_WEBHOOK_SECRET=WEBHOOKSECRET + EMAIL_SENDER=test@makerkit.dev EMAIL_PORT=54325 EMAIL_HOST=localhost diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx index b6494fcc1..6c10c6a7a 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -37,7 +37,7 @@ export function PersonalAccountCheckoutForm() { // Otherwise, render the plan picker component return ( -
+
Manage your Plan diff --git a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx index dba87c227..de7bf2e28 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx @@ -40,26 +40,24 @@ async function PersonalAccountBillingPage() { /> -
-
- } - > - {(subscription) => ( - - )} - +
+ } + > + {(subscription) => ( + + )} + - -
- - -
-
+ +
+ + +
diff --git a/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts b/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts index 766d96cea..b30443992 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts +++ b/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts @@ -4,7 +4,6 @@ import { redirect } from 'next/navigation'; import { z } from 'zod'; -import { getLineItemsFromPlanId } from '@kit/billing'; import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { requireUser } from '@kit/supabase/require-user'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; diff --git a/apps/web/app/api/database/webhook.ts b/apps/web/app/api/database/webhook.ts deleted file mode 100644 index efe1e2ecd..000000000 --- a/apps/web/app/api/database/webhook.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function POST(request: Request) { - console.log(request); -} diff --git a/apps/web/app/api/database/webhook/route.ts b/apps/web/app/api/database/webhook/route.ts new file mode 100644 index 000000000..0144f3e73 --- /dev/null +++ b/apps/web/app/api/database/webhook/route.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { DatabaseWebhookHandlerService } from '@kit/database-webhooks'; + +const webhooksSecret = z + .string({ + description: `The secret used to verify the webhook signature`, + }) + .min(1) + .parse(process.env.SUPABASE_DB_WEBHOOK_SECRET); + +export async function POST(request: Request) { + const service = new DatabaseWebhookHandlerService(); + + await service.handleWebhook(request, webhooksSecret); + + return new Response(null, { + status: 200, + }); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 2c54f38b7..7c200e984 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,5 @@ import { Inter as SansFont } from 'next/font/google'; -import { cookies } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { Toaster } from '@kit/ui/sonner'; import { cn } from '@kit/ui/utils'; @@ -27,6 +27,8 @@ export default async function RootLayout({ return ( + + {children} @@ -70,3 +72,9 @@ export const metadata = { }, }, }; + +function CsrfTokenMeta() { + const csrf = headers().get('x-csrf-token') ?? ''; + + return ; +} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 1e23658b5..0710cda7b 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -16,6 +16,7 @@ const INTERNAL_PACKAGES = [ '@kit/billing-gateway', '@kit/stripe', '@kit/email-templates', + '@kit/database-webhooks' ]; /** @type {import('next').NextConfig} */ diff --git a/apps/web/package.json b/apps/web/package.json index e70326a32..98b398fc4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@kit/auth": "workspace:^", "@kit/billing": "workspace:^", "@kit/billing-gateway": "workspace:^", + "@kit/database-webhooks": "workspace:^", "@kit/email-templates": "workspace:^", "@kit/i18n": "workspace:^", "@kit/mailers": "workspace:^", diff --git a/packages/billing-gateway/src/components/current-plan-card.tsx b/packages/billing-gateway/src/components/current-plan-card.tsx index a829726f6..a1384f3b6 100644 --- a/packages/billing-gateway/src/components/current-plan-card.tsx +++ b/packages/billing-gateway/src/components/current-plan-card.tsx @@ -1,7 +1,11 @@ import { formatDate } from 'date-fns'; import { BadgeCheck, CheckCircle2 } from 'lucide-react'; -import { BillingConfig, getProductPlanPair } from '@kit/billing'; +import { + BillingConfig, + getBaseLineItem, + getProductPlanPair, +} from '@kit/billing'; import { formatCurrency } from '@kit/shared/utils'; import { Database } from '@kit/supabase/database'; import { @@ -31,6 +35,7 @@ export function CurrentPlanCard({ config: BillingConfig; }>) { const { plan, product } = getProductPlanPair(config, subscription.variant_id); + const baseLineItem = getBaseLineItem(config, plan.id); return ( @@ -62,7 +67,7 @@ export function CurrentPlanCard({ i18nKey="billing:planRenewal" values={{ interval: subscription.interval, - price: formatCurrency(product.currency, plan.price), + price: formatCurrency(product.currency, baseLineItem.price), }} />
@@ -111,19 +116,6 @@ export function CurrentPlanCard({
- -
- Your next bill - -
- Your next bill is for {product.currency} {plan.price} on{' '} - - {formatDate(subscription.period_ends_at ?? '', 'P')} - {' '} -
-
-
-
Features diff --git a/packages/billing-gateway/src/components/plan-picker.tsx b/packages/billing-gateway/src/components/plan-picker.tsx index a602ac126..ffd94aea0 100644 --- a/packages/billing-gateway/src/components/plan-picker.tsx +++ b/packages/billing-gateway/src/components/plan-picker.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ArrowRight } from 'lucide-react'; +import { ArrowRight, CheckCircle } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -24,6 +24,7 @@ import { FormLabel, FormMessage, } from '@kit/ui/form'; +import { Heading } from '@kit/ui/heading'; import { If } from '@kit/ui/if'; import { Label } from '@kit/ui/label'; import { @@ -31,6 +32,7 @@ import { RadioGroupItem, RadioGroupItemLabel, } from '@kit/ui/radio-group'; +import { Separator } from '@kit/ui/separator'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; @@ -81,189 +83,240 @@ export function PlanPicker( const { interval: selectedInterval } = form.watch(); const planId = form.getValues('planId'); - const selectedPlan = useMemo(() => { + const { plan: selectedPlan, product: selectedProduct } = useMemo(() => { try { - return getProductPlanPair(props.config, planId).plan; + return getProductPlanPair(props.config, planId); } catch { - return; + return { + plan: null, + product: null, + }; } - }, [form, props.config, planId]); + }, [props.config, planId]); return (
- - { - return ( - - - Choose your billing interval - +
+ + { + return ( + + + Choose your billing interval + - - -
- {intervals.map((interval) => { - const selected = field.value === interval; + + +
+ {intervals.map((interval) => { + const selected = field.value === interval; - return ( -
-
-
- - - ); - }} - /> - ( - - Pick your preferred plan + + + + + ); + })} +
+
+
+ +
+ ); + }} + /> - - - {props.config.products.map((product) => { - const plan = product.plans.find( - (item) => item.interval === selectedInterval, - ); + ( + + Pick your preferred plan - if (!plan) { - return null; - } + + + {props.config.products.map((product) => { + const plan = product.plans.find( + (item) => item.interval === selectedInterval, + ); - const baseLineItem = getBaseLineItem(props.config, plan.id); + if (!plan) { + return null; + } - return ( - - { - form.setValue('planId', plan.id, { - shouldValidate: true, - }); + const baseLineItem = getBaseLineItem( + props.config, + plan.id, + ); - form.setValue('productId', product.id, { - shouldValidate: true, - }); - }} - /> - -
- + form.setValue('productId', product.id, { + shouldValidate: true, + }); + }} + />
- -
- - {plan.trialPeriod} day trial - -
-
+
- - ); - })} - - + + ); + })} + + - - - )} - /> - -
- -
- + /> + +
+ +
+ + + +
+
+ + {selectedProduct?.name} + + +

+ + {selectedProduct?.description} + +

+
+ +
+ {selectedProduct?.features.map((item) => { + return ( +
+ + + {item} +
+ ); + })} +
+ + +
+
+
); } diff --git a/packages/billing-gateway/src/index.ts b/packages/billing-gateway/src/index.ts index abb2865b6..38ecdabfd 100644 --- a/packages/billing-gateway/src/index.ts +++ b/packages/billing-gateway/src/index.ts @@ -1,4 +1,4 @@ export * from './server/services/billing-gateway/billing-gateway.service'; export * from './server/services/billing-gateway/billing-gateway-provider-factory'; export * from './server/services/billing-event-handler/billing-gateway-provider-factory'; -export * from './server/services/account-billing.service'; +export * from './server/services/billing-webhooks/billing-webhooks.service'; diff --git a/packages/billing-gateway/src/server/services/account-billing.service.ts b/packages/billing-gateway/src/server/services/account-billing.service.ts deleted file mode 100644 index 727e04e97..000000000 --- a/packages/billing-gateway/src/server/services/account-billing.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { SupabaseClient } from '@supabase/supabase-js'; - -import { Logger } from '@kit/shared/logger'; -import { Database } from '@kit/supabase/database'; - -import { BillingGatewayService } from './billing-gateway/billing-gateway.service'; - -export class AccountBillingService { - private readonly namespace = 'accounts.billing'; - - constructor(private readonly client: SupabaseClient) {} - - async cancelAllAccountSubscriptions({ - accountId, - userId, - }: { - accountId: string; - userId: string; - }) { - Logger.info( - { - userId, - accountId, - name: this.namespace, - }, - 'Cancelling all subscriptions for account...', - ); - - const { data: subscriptions } = await this.client - .from('subscriptions') - .select('*') - .eq('account_id', accountId); - - const cancellationRequests = []; - - Logger.info( - { - userId, - subscriptions: subscriptions?.length ?? 0, - name: this.namespace, - }, - 'Cancelling all account subscriptions...', - ); - - for (const subscription of subscriptions ?? []) { - const gateway = new BillingGatewayService(subscription.billing_provider); - - cancellationRequests.push( - gateway.cancelSubscription({ - subscriptionId: subscription.id, - invoiceNow: true, - }), - ); - } - - // execute all cancellation requests - await Promise.all(cancellationRequests); - - Logger.info( - { - userId, - subscriptions: subscriptions?.length ?? 0, - name: this.namespace, - }, - 'Subscriptions cancelled successfully', - ); - } -} diff --git a/packages/billing-gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts b/packages/billing-gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts index 258616b99..e065d94f0 100644 --- a/packages/billing-gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts +++ b/packages/billing-gateway/src/server/services/billing-event-handler/billing-gateway-factory.service.ts @@ -1,10 +1,13 @@ import { z } from 'zod'; -import { BillingProvider, BillingWebhookHandlerService } from '@kit/billing'; +import { + BillingProviderSchema, + BillingWebhookHandlerService, +} from '@kit/billing'; export class BillingEventHandlerFactoryService { static async GetProviderStrategy( - provider: z.infer, + provider: z.infer, ): Promise { switch (provider) { case 'stripe': { diff --git a/packages/billing-gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts b/packages/billing-gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts new file mode 100644 index 000000000..976ff5c92 --- /dev/null +++ b/packages/billing-gateway/src/server/services/billing-webhooks/billing-webhooks.service.ts @@ -0,0 +1,15 @@ +import { Database } from '@kit/supabase/database'; + +import { BillingGatewayService } from '../billing-gateway/billing-gateway.service'; + +type Subscription = Database['public']['Tables']['subscriptions']['Row']; + +export class BillingWebhooksService { + async handleSubscriptionDeletedWebhook(subscription: Subscription) { + const gateway = new BillingGatewayService(subscription.billing_provider); + + await gateway.cancelSubscription({ + subscriptionId: subscription.id, + }); + } +} diff --git a/packages/database-webhooks/README.md b/packages/database-webhooks/README.md new file mode 100644 index 000000000..bdf32bb5b --- /dev/null +++ b/packages/database-webhooks/README.md @@ -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 \ No newline at end of file diff --git a/packages/database-webhooks/package.json b/packages/database-webhooks/package.json new file mode 100644 index 000000000..bae9cb87e --- /dev/null +++ b/packages/database-webhooks/package.json @@ -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/*" + ] + } + } +} \ No newline at end of file diff --git a/packages/database-webhooks/src/index.ts b/packages/database-webhooks/src/index.ts new file mode 100644 index 000000000..072c8917a --- /dev/null +++ b/packages/database-webhooks/src/index.ts @@ -0,0 +1 @@ +export * from './server/services/database-webhook-handler.service'; diff --git a/packages/database-webhooks/src/server/record-change.type.ts b/packages/database-webhooks/src/server/record-change.type.ts new file mode 100644 index 000000000..fd1565f22 --- /dev/null +++ b/packages/database-webhooks/src/server/record-change.type.ts @@ -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; +} diff --git a/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts b/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts new file mode 100644 index 000000000..f437be6f1 --- /dev/null +++ b/packages/database-webhooks/src/server/services/database-webhook-handler.service.ts @@ -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) { + 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'); + } + } +} diff --git a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts new file mode 100644 index 000000000..50ce0a9d6 --- /dev/null +++ b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts @@ -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) {} + + handleWebhook(body: RecordChange) { + switch (body.table) { + case 'invitations': { + const payload = body as RecordChange; + + return this.handleInvitations(payload); + } + + case 'subscriptions': { + const payload = body as RecordChange; + + return this.handleSubscriptions(payload); + } + + case 'accounts_memberships': { + const payload = body as RecordChange; + + 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); + } +} diff --git a/packages/database-webhooks/tsconfig.json b/packages/database-webhooks/tsconfig.json new file mode 100644 index 000000000..c4697e934 --- /dev/null +++ b/packages/database-webhooks/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@kit/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/features/accounts/src/server/services/delete-personal-account.service.ts b/packages/features/accounts/src/server/services/delete-personal-account.service.ts index 5a43f387a..139f300e1 100644 --- a/packages/features/accounts/src/server/services/delete-personal-account.service.ts +++ b/packages/features/accounts/src/server/services/delete-personal-account.service.ts @@ -1,6 +1,5 @@ import { SupabaseClient } from '@supabase/supabase-js'; -import { AccountBillingService } from '@kit/billing-gateway'; import { Mailer } from '@kit/mailers'; import { Logger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -42,14 +41,6 @@ export class DeletePersonalAccountService { 'User requested deletion. Processing...', ); - // Cancel all user subscriptions - const billingService = new AccountBillingService(params.adminClient); - - await billingService.cancelAllAccountSubscriptions({ - userId, - accountId: userId, - }); - // execute the deletion of the user try { await params.adminClient.auth.admin.deleteUser(userId); diff --git a/packages/features/team-accounts/package.json b/packages/features/team-accounts/package.json index db4a501c9..1ad3484d4 100644 --- a/packages/features/team-accounts/package.json +++ b/packages/features/team-accounts/package.json @@ -9,7 +9,8 @@ "typecheck": "tsc --noEmit" }, "exports": { - "./components": "./src/components/index.ts" + "./components": "./src/components/index.ts", + "./webhooks": "./src/server/services/account-invitations-webhook.service.ts" }, "devDependencies": { "@hookform/resolvers": "^3.3.4", diff --git a/packages/features/team-accounts/src/server/services/account-invitations-webhook.service.ts b/packages/features/team-accounts/src/server/services/account-invitations-webhook.service.ts new file mode 100644 index 000000000..9efa414ce --- /dev/null +++ b/packages/features/team-accounts/src/server/services/account-invitations-webhook.service.ts @@ -0,0 +1,105 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +import { z } from 'zod'; + +import { Mailer } from '@kit/mailers'; +import { Logger } from '@kit/shared/logger'; +import { Database } from '@kit/supabase/database'; + +type Invitation = Database['public']['Tables']['invitations']['Row']; + +const invitePath = process.env.INVITATION_PAGE_PATH; +const siteURL = process.env.NEXT_PUBLIC_SITE_URL; +const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? ''; +const emailSender = process.env.EMAIL_SENDER; + +const env = z + .object({ + invitePath: z.string().min(1), + siteURL: z.string().min(1), + productName: z.string(), + emailSender: z.string().email(), + }) + .parse({ + invitePath, + siteURL, + productName, + emailSender, + }); + +export class AccountInvitationsWebhookService { + private namespace = 'accounts.invitations.webhook'; + + constructor(private readonly client: SupabaseClient) {} + + async handleInvitationWebhook(invitation: Invitation) { + return this.dispatchInvitationEmail(invitation); + } + + private async dispatchInvitationEmail(invitation: Invitation) { + const mailer = new Mailer(); + + const inviter = await this.client + .from('accounts') + .select('email, name') + .eq('id', invitation.invited_by) + .single(); + + if (inviter.error) { + throw inviter.error; + } + + const team = await this.client + .from('accounts') + .select('name') + .eq('id', invitation.account_id) + .single(); + + if (team.error) { + throw team.error; + } + + try { + const { renderInviteEmail } = await import('@kit/email-templates'); + + const html = renderInviteEmail({ + link: this.getInvitationLink(invitation.invite_token), + invitedUserEmail: invitation.email, + inviter: inviter.data.name ?? inviter.data.email ?? '', + productName: env.productName, + teamName: team.data.name, + }); + + await mailer.sendEmail({ + from: env.emailSender, + to: invitation.email, + subject: 'You have been invited to join a team', + html, + }); + + Logger.info('Invitation email sent', { + email: invitation.email, + account: invitation.account_id, + name: this.namespace, + }); + + return { + success: true, + }; + } catch (error) { + Logger.warn( + { error, name: this.namespace }, + 'Failed to send invitation email', + ); + + return { + error, + success: false, + }; + } + } + + private getInvitationLink(token: string) { + return new URL(env.invitePath, env.siteURL).href + `?invite_token=${token}`; + } +} diff --git a/packages/features/team-accounts/src/server/services/account-invitations.service.ts b/packages/features/team-accounts/src/server/services/account-invitations.service.ts index bcc691f61..8ec335c9c 100644 --- a/packages/features/team-accounts/src/server/services/account-invitations.service.ts +++ b/packages/features/team-accounts/src/server/services/account-invitations.service.ts @@ -4,34 +4,13 @@ import { addDays, formatISO } from 'date-fns'; import 'server-only'; import { z } from 'zod'; -import { Mailer } from '@kit/mailers'; import { Logger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; -import { requireUser } from '@kit/supabase/require-user'; import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema'; import { InviteMembersSchema } from '../../schema/invite-members.schema'; import { UpdateInvitationSchema } from '../../schema/update-invitation.schema'; -const invitePath = process.env.INVITATION_PAGE_PATH; -const siteURL = process.env.NEXT_PUBLIC_SITE_URL; -const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? ''; -const emailSender = process.env.EMAIL_SENDER; - -const env = z - .object({ - invitePath: z.string().min(1), - siteURL: z.string().min(1), - productName: z.string(), - emailSender: z.string().email(), - }) - .parse({ - invitePath, - siteURL, - productName, - emailSender, - }); - export class AccountInvitationsService { private namespace = 'accounts.invitations'; @@ -101,9 +80,6 @@ export class AccountInvitationsService { 'Storing invitations', ); - const mailer = new Mailer(); - const user = await this.getUser(); - const accountResponse = await this.client .from('accounts') .select('name') @@ -123,8 +99,6 @@ export class AccountInvitationsService { throw response.error; } - const promises = []; - const responseInvitations = Array.isArray(response.data) ? response.data : [response.data]; @@ -137,74 +111,6 @@ export class AccountInvitationsService { }, 'Invitations added to account', ); - - Logger.info( - { - account, - count: responseInvitations.length, - name: this.namespace, - }, - 'Sending invitation emails...', - ); - - for (const invitation of responseInvitations) { - const promise = async () => { - try { - const { renderInviteEmail } = await import('@kit/email-templates'); - - const html = renderInviteEmail({ - link: this.getInvitationLink(invitation.invite_token), - invitedUserEmail: invitation.email, - inviter: user.email, - productName: env.productName, - teamName: accountResponse.data.name, - }); - - await mailer.sendEmail({ - from: env.emailSender, - to: invitation.email, - subject: 'You have been invited to join a team', - html, - }); - - Logger.info('Invitation email sent', { - email: invitation.email, - account, - name: this.namespace, - }); - - return { - success: true, - }; - } catch (error) { - console.error(error); - Logger.warn( - { account, error, name: this.namespace }, - 'Failed to send invitation email', - ); - - return { - error, - success: false, - }; - } - }; - - promises.push(promise); - } - - const responses = await Promise.all(promises.map((promise) => promise())); - const success = responses.filter((response) => response.success).length; - - Logger.info( - { - name: this.namespace, - account, - success, - failed: responses.length - success, - }, - `Invitations processed`, - ); } /** @@ -255,18 +161,4 @@ export class AccountInvitationsService { return data; } - - private async getUser() { - const { data, error } = await requireUser(this.client); - - if (error ?? !data) { - throw new Error('Authentication required'); - } - - return data; - } - - private getInvitationLink(token: string) { - return new URL(env.invitePath, env.siteURL).href + `?invite_token=${token}`; - } } diff --git a/packages/features/team-accounts/src/server/services/delete-team-account.service.ts b/packages/features/team-accounts/src/server/services/delete-team-account.service.ts index d42f95e03..bf527c447 100644 --- a/packages/features/team-accounts/src/server/services/delete-team-account.service.ts +++ b/packages/features/team-accounts/src/server/services/delete-team-account.service.ts @@ -2,7 +2,6 @@ import { SupabaseClient } from '@supabase/supabase-js'; import 'server-only'; -import { AccountBillingService } from '@kit/billing-gateway'; import { Logger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -34,21 +33,7 @@ export class DeleteTeamAccountService { `Requested team account deletion. Processing...`, ); - Logger.info( - { - name: this.namespace, - accountId: params.accountId, - userId: params.userId, - }, - `Deleting all account subscriptions...`, - ); - - // First - we want to cancel all Stripe active subscriptions - const billingService = new AccountBillingService(adminClient); - - await billingService.cancelAllAccountSubscriptions(params); - - // now we can use the admin client to delete the account. + // we can use the admin client to delete the account. const { error } = await adminClient .from('accounts') .delete() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05adf289c..16a1d07b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@kit/billing-gateway': specifier: workspace:^ version: link:../../packages/billing-gateway + '@kit/database-webhooks': + specifier: workspace:^ + version: link:../../packages/database-webhooks '@kit/email-templates': specifier: workspace:^ version: link:../../packages/email-templates @@ -265,6 +268,52 @@ importers: specifier: ^3.22.4 version: 3.22.4 + packages/database-webhooks: + dependencies: + '@kit/billing-gateway': + specifier: workspace:^ + version: link:../billing-gateway + '@kit/team-accounts': + specifier: workspace:^ + version: link:../features/team-accounts + devDependencies: + '@kit/billing': + specifier: workspace:^ + version: link:../billing + '@kit/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@kit/prettier-config': + specifier: workspace:* + version: link:../../tooling/prettier + '@kit/shared': + specifier: workspace:^ + version: link:../shared + '@kit/stripe': + specifier: workspace:^ + version: link:../stripe + '@kit/supabase': + specifier: workspace:^ + version: link:../supabase + '@kit/tailwind-config': + specifier: workspace:* + version: link:../../tooling/tailwind + '@kit/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@kit/ui': + specifier: workspace:^ + version: link:../ui + '@supabase/supabase-js': + specifier: ^2.41.1 + version: 2.41.1 + lucide-react: + specifier: ^0.363.0 + version: 0.363.0(react@18.2.0) + zod: + specifier: ^3.22.4 + version: 3.22.4 + packages/email-templates: dependencies: '@react-email/components': @@ -767,9 +816,6 @@ importers: eslint-plugin-import: specifier: ^2.29.1 version: 2.29.1(@typescript-eslint/parser@7.4.0)(eslint@8.57.0) - eslint-plugin-jsx-a11y: - specifier: ^6.8.0 - version: 6.8.0(eslint@8.57.0) eslint-plugin-react: specifier: ^7.34.1 version: 7.34.1(eslint@8.57.0) @@ -4918,12 +4964,6 @@ packages: tslib: 2.6.2 dev: false - /aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - dependencies: - dequal: 2.0.3 - dev: false - /array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -5030,10 +5070,6 @@ packages: is-shared-array-buffer: 1.0.3 dev: false - /ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - dev: false - /ast-types@0.13.4: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} @@ -5089,17 +5125,6 @@ packages: possible-typed-array-names: 1.0.0 dev: false - /axe-core@4.7.0: - resolution: {integrity: sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==} - engines: {node: '>=4'} - dev: false - - /axobject-query@3.2.1: - resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} - dependencies: - dequal: 2.0.3 - dev: false - /babel-walk@3.0.0: resolution: {integrity: sha512-fdRxJkQ9MUSEi4jH2DcV3FAPFktk0wefilxrwNyUuWpoWawQGN7G7cB+fOYTtFfI6XNkFgwqJ/D3G18BoJJ/jg==} engines: {node: '>= 10.0.0'} @@ -5695,10 +5720,6 @@ packages: engines: {node: '>=12'} dev: false - /damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - dev: false - /data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -6376,31 +6397,6 @@ packages: - supports-color dev: false - /eslint-plugin-jsx-a11y@6.8.0(eslint@8.57.0): - resolution: {integrity: sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - dependencies: - '@babel/runtime': 7.24.1 - aria-query: 5.3.0 - array-includes: 3.1.8 - array.prototype.flatmap: 1.3.2 - ast-types-flow: 0.0.8 - axe-core: 4.7.0 - axobject-query: 3.2.1 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - es-iterator-helpers: 1.0.18 - eslint: 8.57.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.2 - object.entries: 1.1.8 - object.fromentries: 2.0.8 - dev: false - /eslint-plugin-react-hooks@4.6.0(eslint@8.57.0): resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} engines: {node: '>=10'} @@ -7905,17 +7901,6 @@ packages: engines: {node: '>=6'} dev: false - /language-subtag-registry@0.3.22: - resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} - dev: false - - /language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - dependencies: - language-subtag-registry: 0.3.22 - dev: false - /leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} dev: false diff --git a/supabase/migrations/20221215192558_schema.sql b/supabase/migrations/20221215192558_schema.sql index cd1e4b2c5..d012c9626 100644 --- a/supabase/migrations/20221215192558_schema.sql +++ b/supabase/migrations/20221215192558_schema.sql @@ -437,11 +437,15 @@ create table if not exists public.roles ( primary key (name) ); +grant select on table public.roles to authenticated, service_role; + -- Seed the roles table with default roles 'owner' and 'member' insert into public.roles (name, hierarchy_level) values ('owner', 1); insert into public.roles (name, hierarchy_level) values ('member', 2); -- RLS +alter table public.roles enable row level security; + -- SELECT: authenticated users can query roles create policy roles_read on public.roles for select diff --git a/supabase/seed.sql b/supabase/seed.sql index e69de29bb..b44a50283 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -0,0 +1,37 @@ +-- These webhooks are only for development purposes. +-- In production, you should manually create webhooks in the Supabase dashboard (or create a migration to do so). +-- We don't do it because you'll need to manually add your webhook URL and secret key. + +-- this webhook will be triggered after every insert on the accounts_memberships table +create trigger "accounts_memberships_insert" after insert +on "public"."accounts_memberships" for each row +execute function "supabase_functions"."http_request"( + 'http://localhost:3000/api/database/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', + '{}', + '1000' +); + +-- this webhook will be triggered after every insert on the accounts_memberships table +create trigger "account_membership_delete" after insert +on "public"."accounts_memberships" for each row +execute function "supabase_functions"."http_request"( + 'http://localhost:3000/api/database/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', + '{}', + '1000' +); + +-- this webhook will be triggered after a delete on the subscriptions table +-- which should happen when a user deletes their account (and all their subscriptions) +create trigger "account_delete" after delete +on "public"."subscriptions" for each row +execute function "supabase_functions"."http_request"( + 'http://localhost:3000/api/database/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', + '{}', + '1000' +); \ No newline at end of file