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:
@@ -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
|
||||
|
||||
@@ -37,7 +37,7 @@ export function PersonalAccountCheckoutForm() {
|
||||
|
||||
// Otherwise, render the plan picker component
|
||||
return (
|
||||
<div className={'mx-auto w-full max-w-2xl'}>
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Manage your Plan</CardTitle>
|
||||
|
||||
@@ -40,7 +40,6 @@ async function PersonalAccountBillingPage() {
|
||||
/>
|
||||
|
||||
<PageBody>
|
||||
<div className={'mx-auto w-full max-w-2xl'}>
|
||||
<div className={'flex flex-col space-y-8'}>
|
||||
<If
|
||||
condition={subscription}
|
||||
@@ -60,7 +59,6 @@ async function PersonalAccountBillingPage() {
|
||||
</form>
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function POST(request: Request) {
|
||||
console.log(request);
|
||||
}
|
||||
20
apps/web/app/api/database/webhook/route.ts
Normal file
20
apps/web/app/api/database/webhook/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang={language} className={getClassName()}>
|
||||
<CsrfTokenMeta />
|
||||
|
||||
<body>
|
||||
<RootProviders lang={language}>{children}</RootProviders>
|
||||
<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" />;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const INTERNAL_PACKAGES = [
|
||||
'@kit/billing-gateway',
|
||||
'@kit/stripe',
|
||||
'@kit/email-templates',
|
||||
'@kit/database-webhooks'
|
||||
];
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -111,19 +116,6 @@ export function CurrentPlanCard({
|
||||
</div>
|
||||
</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">
|
||||
<span className="font-medium">Features</span>
|
||||
|
||||
|
||||
@@ -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,18 +83,22 @@ 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 (
|
||||
<Form {...form}>
|
||||
<div className={'flex space-x-4'}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4'}
|
||||
className={'flex w-full max-w-xl flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit(props.onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
@@ -170,7 +176,10 @@ export function PlanPicker(
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseLineItem = getBaseLineItem(props.config, plan.id);
|
||||
const baseLineItem = getBaseLineItem(
|
||||
props.config,
|
||||
plan.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel
|
||||
@@ -192,11 +201,15 @@ export function PlanPicker(
|
||||
/>
|
||||
|
||||
<div
|
||||
className={'flex w-full items-center justify-between'}
|
||||
className={
|
||||
'flex w-full items-center justify-between'
|
||||
}
|
||||
>
|
||||
<Label
|
||||
htmlFor={plan.id}
|
||||
className={'flex flex-col justify-center space-y-2'}
|
||||
className={
|
||||
'flex flex-col justify-center space-y-2'
|
||||
}
|
||||
>
|
||||
<span className="font-bold">{product.name}</span>
|
||||
|
||||
@@ -206,7 +219,9 @@ export function PlanPicker(
|
||||
</Label>
|
||||
|
||||
<div
|
||||
className={'flex items-center space-x-4 text-right'}
|
||||
className={
|
||||
'flex items-center space-x-4 text-right'
|
||||
}
|
||||
>
|
||||
<If condition={plan.trialPeriod}>
|
||||
<div>
|
||||
@@ -264,6 +279,44 @@ export function PlanPicker(
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof BillingProvider>,
|
||||
provider: z.infer<typeof BillingProviderSchema>,
|
||||
): Promise<BillingWebhookHandlerService> {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
packages/database-webhooks/README.md
Normal file
8
packages/database-webhooks/README.md
Normal 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
|
||||
50
packages/database-webhooks/package.json
Normal file
50
packages/database-webhooks/package.json
Normal 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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/database-webhooks/src/index.ts
Normal file
1
packages/database-webhooks/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './server/services/database-webhook-handler.service';
|
||||
16
packages/database-webhooks/src/server/record-change.type.ts
Normal file
16
packages/database-webhooks/src/server/record-change.type.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
8
packages/database-webhooks/tsconfig.json
Normal file
8
packages/database-webhooks/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
113
pnpm-lock.yaml
generated
113
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
Reference in New Issue
Block a user