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

@@ -17,9 +17,11 @@ NEXT_PUBLIC_BILLING_PROVIDER=stripe
# SUPABASE # SUPABASE
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
NEXT_PUBLIC_REQUIRE_EMAIL_CONFIRMATION=true 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_SENDER=test@makerkit.dev
EMAIL_PORT=54325 EMAIL_PORT=54325
EMAIL_HOST=localhost EMAIL_HOST=localhost

View File

@@ -37,7 +37,7 @@ export function PersonalAccountCheckoutForm() {
// Otherwise, render the plan picker component // Otherwise, render the plan picker component
return ( return (
<div className={'mx-auto w-full max-w-2xl'}> <div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Manage your Plan</CardTitle> <CardTitle>Manage your Plan</CardTitle>

View File

@@ -40,26 +40,24 @@ async function PersonalAccountBillingPage() {
/> />
<PageBody> <PageBody>
<div className={'mx-auto w-full max-w-2xl'}> <div className={'flex flex-col space-y-8'}>
<div className={'flex flex-col space-y-8'}> <If
<If condition={subscription}
condition={subscription} fallback={<PersonalAccountCheckoutForm />}
fallback={<PersonalAccountCheckoutForm />} >
> {(subscription) => (
{(subscription) => ( <CurrentPlanCard
<CurrentPlanCard subscription={subscription}
subscription={subscription} config={billingConfig}
config={billingConfig} />
/> )}
)} </If>
</If>
<If condition={customerId}> <If condition={customerId}>
<form action={createPersonalAccountBillingPortalSession}> <form action={createPersonalAccountBillingPortalSession}>
<BillingPortalCard /> <BillingPortalCard />
</form> </form>
</If> </If>
</div>
</div> </div>
</PageBody> </PageBody>
</> </>

View File

@@ -4,7 +4,6 @@ import { redirect } from 'next/navigation';
import { z } from 'zod'; import { z } from 'zod';
import { getLineItemsFromPlanId } from '@kit/billing';
import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { requireUser } from '@kit/supabase/require-user'; import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';

View File

@@ -1,3 +0,0 @@
export function POST(request: Request) {
console.log(request);
}

View File

@@ -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,
});
}

View File

@@ -1,5 +1,5 @@
import { Inter as SansFont } from 'next/font/google'; 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 { Toaster } from '@kit/ui/sonner';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
@@ -27,6 +27,8 @@ export default async function RootLayout({
return ( return (
<html lang={language} className={getClassName()}> <html lang={language} className={getClassName()}>
<CsrfTokenMeta />
<body> <body>
<RootProviders lang={language}>{children}</RootProviders> <RootProviders lang={language}>{children}</RootProviders>
<Toaster richColors={false} /> <Toaster richColors={false} />
@@ -70,3 +72,9 @@ export const metadata = {
}, },
}, },
}; };
function CsrfTokenMeta() {
const csrf = headers().get('x-csrf-token') ?? '';
return <meta content={csrf} name="csrf-token" />;
}

View File

@@ -16,6 +16,7 @@ const INTERNAL_PACKAGES = [
'@kit/billing-gateway', '@kit/billing-gateway',
'@kit/stripe', '@kit/stripe',
'@kit/email-templates', '@kit/email-templates',
'@kit/database-webhooks'
]; ];
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */

View File

@@ -22,6 +22,7 @@
"@kit/auth": "workspace:^", "@kit/auth": "workspace:^",
"@kit/billing": "workspace:^", "@kit/billing": "workspace:^",
"@kit/billing-gateway": "workspace:^", "@kit/billing-gateway": "workspace:^",
"@kit/database-webhooks": "workspace:^",
"@kit/email-templates": "workspace:^", "@kit/email-templates": "workspace:^",
"@kit/i18n": "workspace:^", "@kit/i18n": "workspace:^",
"@kit/mailers": "workspace:^", "@kit/mailers": "workspace:^",

View File

@@ -1,7 +1,11 @@
import { formatDate } from 'date-fns'; import { formatDate } from 'date-fns';
import { BadgeCheck, CheckCircle2 } from 'lucide-react'; 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 { formatCurrency } from '@kit/shared/utils';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { import {
@@ -31,6 +35,7 @@ export function CurrentPlanCard({
config: BillingConfig; config: BillingConfig;
}>) { }>) {
const { plan, product } = getProductPlanPair(config, subscription.variant_id); const { plan, product } = getProductPlanPair(config, subscription.variant_id);
const baseLineItem = getBaseLineItem(config, plan.id);
return ( return (
<Card> <Card>
@@ -62,7 +67,7 @@ export function CurrentPlanCard({
i18nKey="billing:planRenewal" i18nKey="billing:planRenewal"
values={{ values={{
interval: subscription.interval, interval: subscription.interval,
price: formatCurrency(product.currency, plan.price), price: formatCurrency(product.currency, baseLineItem.price),
}} }}
/> />
</div> </div>
@@ -111,19 +116,6 @@ export function CurrentPlanCard({
</div> </div>
</If> </If>
<If condition={!subscription.cancel_at_period_end}>
<div className="flex flex-col space-y-1">
<span className="font-medium">Your next bill</span>
<div className={'text-muted-foreground'}>
Your next bill is for {product.currency} {plan.price} on{' '}
<span>
{formatDate(subscription.period_ends_at ?? '', 'P')}
</span>{' '}
</div>
</div>
</If>
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<span className="font-medium">Features</span> <span className="font-medium">Features</span>

View File

@@ -3,7 +3,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRight } from 'lucide-react'; import { ArrowRight, CheckCircle } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -24,6 +24,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import { import {
@@ -31,6 +32,7 @@ import {
RadioGroupItem, RadioGroupItem,
RadioGroupItemLabel, RadioGroupItemLabel,
} from '@kit/ui/radio-group'; } from '@kit/ui/radio-group';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
@@ -81,189 +83,240 @@ export function PlanPicker(
const { interval: selectedInterval } = form.watch(); const { interval: selectedInterval } = form.watch();
const planId = form.getValues('planId'); const planId = form.getValues('planId');
const selectedPlan = useMemo(() => { const { plan: selectedPlan, product: selectedProduct } = useMemo(() => {
try { try {
return getProductPlanPair(props.config, planId).plan; return getProductPlanPair(props.config, planId);
} catch { } catch {
return; return {
plan: null,
product: null,
};
} }
}, [form, props.config, planId]); }, [props.config, planId]);
return ( return (
<Form {...form}> <Form {...form}>
<form <div className={'flex space-x-4'}>
className={'flex flex-col space-y-4'} <form
onSubmit={form.handleSubmit(props.onSubmit)} className={'flex w-full max-w-xl flex-col space-y-4'}
> onSubmit={form.handleSubmit(props.onSubmit)}
<FormField >
name={'interval'} <FormField
render={({ field }) => { name={'interval'}
return ( render={({ field }) => {
<FormItem className={'rounded-md border p-4'}> return (
<FormLabel htmlFor={'plan-picker-id'}> <FormItem className={'rounded-md border p-4'}>
Choose your billing interval <FormLabel htmlFor={'plan-picker-id'}>
</FormLabel> Choose your billing interval
</FormLabel>
<FormControl id={'plan-picker-id'}> <FormControl id={'plan-picker-id'}>
<RadioGroup name={field.name} value={field.value}> <RadioGroup name={field.name} value={field.value}>
<div className={'flex space-x-2.5'}> <div className={'flex space-x-2.5'}>
{intervals.map((interval) => { {intervals.map((interval) => {
const selected = field.value === interval; const selected = field.value === interval;
return ( return (
<label <label
htmlFor={interval} htmlFor={interval}
key={interval} key={interval}
className={cn( className={cn(
'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2', 'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2',
{ {
['border-border']: selected, ['border-border']: selected,
['hover:bg-muted']: !selected, ['hover:bg-muted']: !selected,
}, },
)} )}
> >
<RadioGroupItem <RadioGroupItem
id={interval} id={interval}
value={interval} value={interval}
onClick={() => { onClick={() => {
form.setValue('planId', '', { form.setValue('planId', '', {
shouldValidate: true, shouldValidate: true,
}); });
form.setValue('interval', interval, { form.setValue('interval', interval, {
shouldValidate: true, shouldValidate: true,
}); });
}} }}
/>
<span className={'text-sm font-bold'}>
<Trans
i18nKey={`common:billingInterval.${interval}`}
/> />
</span>
</label>
);
})}
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField <span className={'text-sm font-bold'}>
name={'planId'} <Trans
render={({ field }) => ( i18nKey={`common:billingInterval.${interval}`}
<FormItem> />
<FormLabel>Pick your preferred plan</FormLabel> </span>
</label>
);
})}
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormControl> <FormField
<RadioGroup name={field.name}> name={'planId'}
{props.config.products.map((product) => { render={({ field }) => (
const plan = product.plans.find( <FormItem>
(item) => item.interval === selectedInterval, <FormLabel>Pick your preferred plan</FormLabel>
);
if (!plan) { <FormControl>
return null; <RadioGroup name={field.name}>
} {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 ( const baseLineItem = getBaseLineItem(
<RadioGroupItemLabel props.config,
selected={field.value === plan.id} plan.id,
key={plan.id} );
>
<RadioGroupItem
id={plan.id}
value={plan.id}
onClick={() => {
form.setValue('planId', plan.id, {
shouldValidate: true,
});
form.setValue('productId', product.id, { return (
shouldValidate: true, <RadioGroupItemLabel
}); selected={field.value === plan.id}
}} key={plan.id}
/>
<div
className={'flex w-full items-center justify-between'}
> >
<Label <RadioGroupItem
htmlFor={plan.id} id={plan.id}
className={'flex flex-col justify-center space-y-2'} value={plan.id}
> onClick={() => {
<span className="font-bold">{product.name}</span> form.setValue('planId', plan.id, {
shouldValidate: true,
});
<span className={'text-muted-foreground'}> form.setValue('productId', product.id, {
{product.description} shouldValidate: true,
</span> });
</Label> }}
/>
<div <div
className={'flex items-center space-x-4 text-right'} className={
'flex w-full items-center justify-between'
}
> >
<If condition={plan.trialPeriod}> <Label
<div> htmlFor={plan.id}
<Badge variant={'success'}> className={
{plan.trialPeriod} day trial 'flex flex-col justify-center space-y-2'
</Badge> }
</div> >
</If> <span className="font-bold">{product.name}</span>
<div> <span className={'text-muted-foreground'}>
<Price key={plan.id}> {product.description}
<span> </span>
{formatCurrency( </Label>
product.currency.toLowerCase(),
baseLineItem.cost, <div
)} className={
</span> 'flex items-center space-x-4 text-right'
</Price> }
>
<If condition={plan.trialPeriod}>
<div>
<Badge variant={'success'}>
{plan.trialPeriod} day trial
</Badge>
</div>
</If>
<div> <div>
<span className={'text-muted-foreground'}> <Price key={plan.id}>
per {selectedInterval} <span>
</span> {formatCurrency(
product.currency.toLowerCase(),
baseLineItem.cost,
)}
</span>
</Price>
<div>
<span className={'text-muted-foreground'}>
per {selectedInterval}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </RadioGroupItemLabel>
</RadioGroupItemLabel> );
); })}
})} </RadioGroup>
</RadioGroup> </FormControl>
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)}
/>
<div>
<Button disabled={props.pending ?? !form.formState.isValid}>
{props.pending ? (
'Processing...'
) : (
<>
<If
condition={selectedPlan?.trialPeriod}
fallback={'Proceed to payment'}
>
<span>Start {selectedPlan?.trialPeriod} day trial</span>
</If>
<ArrowRight className={'ml-2 h-4 w-4'} />
</>
)} )}
</Button> />
</div>
</form> <div>
<Button disabled={props.pending ?? !form.formState.isValid}>
{props.pending ? (
'Processing...'
) : (
<>
<If
condition={selectedPlan?.trialPeriod}
fallback={'Proceed to payment'}
>
<span>Start {selectedPlan?.trialPeriod} day trial</span>
</If>
<ArrowRight className={'ml-2 h-4 w-4'} />
</>
)}
</Button>
</div>
</form>
<If condition={selectedPlan && selectedProduct}>
<div
className={
'fade-in animate-in zoom-in-90 flex w-full flex-col space-y-4 rounded-lg border p-4'
}
>
<div className={'flex flex-col space-y-0.5'}>
<Heading level={5}>
<b>{selectedProduct?.name}</b>
</Heading>
<p>
<span className={'text-muted-foreground'}>
{selectedProduct?.description}
</span>
</p>
</div>
<div className={'flex flex-col'}>
{selectedProduct?.features.map((item) => {
return (
<div
key={item}
className={'flex items-center space-x-2 text-sm'}
>
<CheckCircle className={'h-4 text-green-500'} />
<span className={'text-muted-foreground'}>{item}</span>
</div>
);
})}
</div>
<Separator />
</div>
</If>
</div>
</Form> </Form>
); );
} }

View File

@@ -1,4 +1,4 @@
export * from './server/services/billing-gateway/billing-gateway.service'; export * from './server/services/billing-gateway/billing-gateway.service';
export * from './server/services/billing-gateway/billing-gateway-provider-factory'; 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/billing-event-handler/billing-gateway-provider-factory';
export * from './server/services/account-billing.service'; export * from './server/services/billing-webhooks/billing-webhooks.service';

View File

@@ -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<Database>) {}
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',
);
}
}

View File

@@ -1,10 +1,13 @@
import { z } from 'zod'; import { z } from 'zod';
import { BillingProvider, BillingWebhookHandlerService } from '@kit/billing'; import {
BillingProviderSchema,
BillingWebhookHandlerService,
} from '@kit/billing';
export class BillingEventHandlerFactoryService { export class BillingEventHandlerFactoryService {
static async GetProviderStrategy( static async GetProviderStrategy(
provider: z.infer<typeof BillingProvider>, provider: z.infer<typeof BillingProviderSchema>,
): Promise<BillingWebhookHandlerService> { ): Promise<BillingWebhookHandlerService> {
switch (provider) { switch (provider) {
case 'stripe': { case 'stripe': {

View File

@@ -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,
});
}
}

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"]
}

View File

@@ -1,6 +1,5 @@
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { AccountBillingService } from '@kit/billing-gateway';
import { Mailer } from '@kit/mailers'; import { Mailer } from '@kit/mailers';
import { Logger } from '@kit/shared/logger'; import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
@@ -42,14 +41,6 @@ export class DeletePersonalAccountService {
'User requested deletion. Processing...', '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 // execute the deletion of the user
try { try {
await params.adminClient.auth.admin.deleteUser(userId); await params.adminClient.auth.admin.deleteUser(userId);

View File

@@ -9,7 +9,8 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"exports": { "exports": {
"./components": "./src/components/index.ts" "./components": "./src/components/index.ts",
"./webhooks": "./src/server/services/account-invitations-webhook.service.ts"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",

View File

@@ -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<Database>) {}
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}`;
}
}

View File

@@ -4,34 +4,13 @@ import { addDays, formatISO } from 'date-fns';
import 'server-only'; import 'server-only';
import { z } from 'zod'; import { z } from 'zod';
import { Mailer } from '@kit/mailers';
import { Logger } from '@kit/shared/logger'; import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { requireUser } from '@kit/supabase/require-user';
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema'; import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
import { InviteMembersSchema } from '../../schema/invite-members.schema'; import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { UpdateInvitationSchema } from '../../schema/update-invitation.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 { export class AccountInvitationsService {
private namespace = 'accounts.invitations'; private namespace = 'accounts.invitations';
@@ -101,9 +80,6 @@ export class AccountInvitationsService {
'Storing invitations', 'Storing invitations',
); );
const mailer = new Mailer();
const user = await this.getUser();
const accountResponse = await this.client const accountResponse = await this.client
.from('accounts') .from('accounts')
.select('name') .select('name')
@@ -123,8 +99,6 @@ export class AccountInvitationsService {
throw response.error; throw response.error;
} }
const promises = [];
const responseInvitations = Array.isArray(response.data) const responseInvitations = Array.isArray(response.data)
? response.data ? response.data
: [response.data]; : [response.data];
@@ -137,74 +111,6 @@ export class AccountInvitationsService {
}, },
'Invitations added to account', '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; 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}`;
}
} }

View File

@@ -2,7 +2,6 @@ import { SupabaseClient } from '@supabase/supabase-js';
import 'server-only'; import 'server-only';
import { AccountBillingService } from '@kit/billing-gateway';
import { Logger } from '@kit/shared/logger'; import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
@@ -34,21 +33,7 @@ export class DeleteTeamAccountService {
`Requested team account deletion. Processing...`, `Requested team account deletion. Processing...`,
); );
Logger.info( // we can use the admin client to delete the account.
{
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.
const { error } = await adminClient const { error } = await adminClient
.from('accounts') .from('accounts')
.delete() .delete()

113
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
'@kit/billing-gateway': '@kit/billing-gateway':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/billing-gateway version: link:../../packages/billing-gateway
'@kit/database-webhooks':
specifier: workspace:^
version: link:../../packages/database-webhooks
'@kit/email-templates': '@kit/email-templates':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/email-templates version: link:../../packages/email-templates
@@ -265,6 +268,52 @@ importers:
specifier: ^3.22.4 specifier: ^3.22.4
version: 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: packages/email-templates:
dependencies: dependencies:
'@react-email/components': '@react-email/components':
@@ -767,9 +816,6 @@ importers:
eslint-plugin-import: eslint-plugin-import:
specifier: ^2.29.1 specifier: ^2.29.1
version: 2.29.1(@typescript-eslint/parser@7.4.0)(eslint@8.57.0) 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: eslint-plugin-react:
specifier: ^7.34.1 specifier: ^7.34.1
version: 7.34.1(eslint@8.57.0) version: 7.34.1(eslint@8.57.0)
@@ -4918,12 +4964,6 @@ packages:
tslib: 2.6.2 tslib: 2.6.2
dev: false 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: /array-buffer-byte-length@1.0.1:
resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -5030,10 +5070,6 @@ packages:
is-shared-array-buffer: 1.0.3 is-shared-array-buffer: 1.0.3
dev: false dev: false
/ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
dev: false
/ast-types@0.13.4: /ast-types@0.13.4:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -5089,17 +5125,6 @@ packages:
possible-typed-array-names: 1.0.0 possible-typed-array-names: 1.0.0
dev: false 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: /babel-walk@3.0.0:
resolution: {integrity: sha512-fdRxJkQ9MUSEi4jH2DcV3FAPFktk0wefilxrwNyUuWpoWawQGN7G7cB+fOYTtFfI6XNkFgwqJ/D3G18BoJJ/jg==} resolution: {integrity: sha512-fdRxJkQ9MUSEi4jH2DcV3FAPFktk0wefilxrwNyUuWpoWawQGN7G7cB+fOYTtFfI6XNkFgwqJ/D3G18BoJJ/jg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
@@ -5695,10 +5720,6 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: false dev: false
/damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: false
/data-uri-to-buffer@4.0.1: /data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@@ -6376,31 +6397,6 @@ packages:
- supports-color - supports-color
dev: false 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): /eslint-plugin-react-hooks@4.6.0(eslint@8.57.0):
resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -7905,17 +7901,6 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: false 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: /leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
dev: false dev: false

View File

@@ -437,11 +437,15 @@ create table if not exists public.roles (
primary key (name) primary key (name)
); );
grant select on table public.roles to authenticated, service_role;
-- Seed the roles table with default roles 'owner' and 'member' -- 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 ('owner', 1);
insert into public.roles (name, hierarchy_level) values ('member', 2); insert into public.roles (name, hierarchy_level) values ('member', 2);
-- RLS -- RLS
alter table public.roles enable row level security;
-- SELECT: authenticated users can query roles -- SELECT: authenticated users can query roles
create policy roles_read on public.roles for create policy roles_read on public.roles for
select select

View File

@@ -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'
);