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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user