Refactor billing services with new AccountsApi

The billing services have been refactored to use the new AccountsApi and TeamAccountsApi. All methods that were previously defined in each billing service, including getting customer ID, getting permissions, etc., have been transferred to these APIs. This change improves the modularity and organization of the code.
This commit is contained in:
giancarlo
2024-04-22 01:35:15 +08:00
parent 75c438a5f2
commit 866b9888f7
17 changed files with 324 additions and 323 deletions

View File

@@ -5,14 +5,14 @@ import { cookies } from 'next/headers';
import { If } from '@kit/ui/if';
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
import { loadUserWorkspace } from '~/(dashboard)/home/(user)/_lib/server/load-user-workspace';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import featuresFlagConfig from '~/config/feature-flags.config';
import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config';
// home imports
import { HomeSidebarAccountSelector } from '../_components/home-sidebar-account-selector';
import { ProfileAccountDropdownContainer } from '../_components/personal-account-dropdown-container';
import { loadUserWorkspace } from '../_lib/load-user-workspace';
export function HomeSidebar() {
const collapsed = getSidebarCollapsed();

View File

@@ -1,11 +1,9 @@
import { cache } from 'react';
import { SupabaseClient } from '@supabase/supabase-js';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import featureFlagsConfig from '~/config/feature-flags.config';
import { Database } from '~/lib/database.types';
const shouldLoadAccounts = featureFlagsConfig.enableTeamAccounts;
@@ -17,12 +15,13 @@ const shouldLoadAccounts = featureFlagsConfig.enableTeamAccounts;
*/
export const loadUserWorkspace = cache(async () => {
const client = getSupabaseServerComponentClient();
const api = createAccountsApi(client);
const accountsPromise = shouldLoadAccounts
? () => loadUserAccounts(client)
? () => api.loadUserAccounts()
: () => Promise.resolve([]);
const workspacePromise = loadUserAccountWorkspace(client);
const workspacePromise = api.getAccountWorkspace();
const userPromise = client.auth.getUser();
const [accounts, workspace, userResult] = await Promise.all([
@@ -43,34 +42,3 @@ export const loadUserWorkspace = cache(async () => {
user,
};
});
async function loadUserAccountWorkspace(client: SupabaseClient<Database>) {
const { data, error } = await client
.from('user_account_workspace')
.select(`*`)
.single();
if (error) {
throw error;
}
return data;
}
async function loadUserAccounts(client: SupabaseClient<Database>) {
const { data: accounts, error } = await client
.from('user_accounts')
.select(`name, slug, picture_url`);
if (error) {
throw error;
}
return accounts.map(({ name, slug, picture_url }) => {
return {
label: name,
value: slug,
image: picture_url,
};
});
}

View File

@@ -6,10 +6,9 @@ import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Database } from '~/lib/database.types';
/**
* The variable BILLING_MODE represents the billing mode for a service. It can
* have either the value 'subscription' or 'one-time'. If not provided, the default
@@ -33,77 +32,14 @@ const BILLING_MODE = z
*/
export const loadPersonalAccountBillingPageData = cache((userId: string) => {
const client = getSupabaseServerComponentClient();
const api = createAccountsApi(client);
const data =
BILLING_MODE === 'subscription'
? getSubscriptionData(client, userId)
: getOrdersData(client, userId);
? api.getSubscriptionData(userId)
: api.getOrdersData(userId);
const customerId = getBillingCustomerId(client, userId);
const customerId = api.getBillingCustomerId(userId);
return Promise.all([data, customerId]);
});
/**
* Get the subscription data for the given user.
* @param client
* @param userId
*/
function getSubscriptionData(client: SupabaseClient<Database>, userId: string) {
return client
.from('subscriptions')
.select('*, items: subscription_items !inner (*)')
.eq('account_id', userId)
.maybeSingle()
.then((response) => {
if (response.error) {
throw response.error;
}
return response.data;
});
}
/**
* Get the orders data for the given user.
* @param client
* @param userId
*/
function getOrdersData(client: SupabaseClient<Database>, userId: string) {
return client
.from('orders')
.select('*, items: order_items !inner (*)')
.eq('account_id', userId)
.maybeSingle()
.then((response) => {
if (response.error) {
throw response.error;
}
return response.data;
});
}
/**
* Get the billing customer ID for the given user.
* If the user does not have a billing customer ID, it will return null.
* @param client
* @param userId
*/
function getBillingCustomerId(
client: SupabaseClient<Database>,
userId: string,
) {
return client
.from('billing_customers')
.select('customer_id')
.eq('account_id', userId)
.maybeSingle()
.then((response) => {
if (response.error) {
throw response.error;
}
return response.data?.customer_id;
});
}

View File

@@ -4,11 +4,11 @@ import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { getProductPlanPair } from '@kit/billing';
import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { getLogger } from '@kit/shared/logger';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import appConfig from '~/config/app.config';
import billingConfig from '~/config/billing.config';
@@ -22,6 +22,12 @@ export class UserBillingService {
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name createCheckoutSession
* @description Create a checkout session for the user
* @param planId
* @param productId
*/
async createCheckoutSession({
planId,
productId,
@@ -44,7 +50,8 @@ export class UserBillingService {
// find the customer ID for the account if it exists
// (eg. if the account has been billed before)
const customerId = await getCustomerIdFromAccountId(accountId);
const api = createAccountsApi(this.client);
const customerId = await api.getBillingCustomerId(accountId);
const product = billingConfig.products.find(
(item) => item.id === productId,
@@ -107,6 +114,11 @@ export class UserBillingService {
}
}
/**
* @name createBillingPortalSession
* @description Create a billing portal session for the user
* @returns The URL to redirect the user to the billing portal
*/
async createBillingPortalSession() {
const { data, error } = await requireUser(this.client);
@@ -118,7 +130,8 @@ export class UserBillingService {
const logger = await getLogger();
const accountId = data.id;
const customerId = await getCustomerIdFromAccountId(accountId);
const api = createAccountsApi(this.client);
const customerId = await api.getBillingCustomerId(accountId);
const returnUrl = getBillingPortalReturnUrl();
if (!customerId) {
@@ -166,38 +179,6 @@ export class UserBillingService {
}
}
async function getCustomerIdFromAccountId(accountId: string) {
const client = getSupabaseServerActionClient();
const logger = await getLogger();
logger.info(
{
accountId,
},
`Getting customer ID for account ${accountId}...`,
);
const { data, error } = await client
.from('billing_customers')
.select('customer_id')
.eq('account_id', accountId)
.maybeSingle();
if (error) {
logger.error(
{
accountId,
error,
},
`Failed to get customer ID`,
);
throw error;
}
return data?.customer_id;
}
function getCheckoutSessionReturnUrl() {
return new URL(
pathsConfig.app.personalAccountBillingReturn,

View File

@@ -1,8 +1,9 @@
import { Page } from '@kit/ui/page';
import { HomeSidebar } from '~/(dashboard)/home/_components/home-sidebar';
import { withI18n } from '~/lib/i18n/with-i18n';
import { HomeSidebar } from './_components/home-sidebar';
function UserHomeLayout({ children }: React.PropsWithChildren) {
return <Page sidebar={<HomeSidebar />}>{children}</Page>;
}

View File

@@ -18,7 +18,7 @@ import {
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { ProfileAccountDropdownContainer } from '~/(dashboard)/home/_components/personal-account-dropdown-container';
import { ProfileAccountDropdownContainer } from '~/components//personal-account-dropdown-container';
import featureFlagsConfig from '~/config/feature-flags.config';
import pathsConfig from '~/config/paths.config';

View File

@@ -2,14 +2,11 @@ import 'server-only';
import { cache } from 'react';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { Database } from '~/lib/database.types';
/**
* The variable BILLING_MODE represents the billing mode for a service. It can
* have either the value 'subscription' or 'one-time'. If not provided, the default
@@ -27,80 +24,14 @@ const BILLING_MODE = z
export const loadTeamAccountBillingPage = cache((accountId: string) => {
const client = getSupabaseServerComponentClient();
const api = createAccountsApi(client);
const data =
BILLING_MODE === 'subscription'
? getSubscriptionData(client, accountId)
: getOrdersData(client, accountId);
? api.getSubscriptionData(accountId)
: api.getOrdersData(accountId);
const customerId = getBillingCustomerId(client, accountId);
const customerId = api.getBillingCustomerId(accountId);
return Promise.all([data, customerId]);
});
/**
* Get the subscription data for the given user.
* @param client
* @param accountId
*/
function getSubscriptionData(
client: SupabaseClient<Database>,
accountId: string,
) {
return client
.from('subscriptions')
.select('*, items: subscription_items !inner (*)')
.eq('account_id', accountId)
.maybeSingle()
.then((response) => {
if (response.error) {
throw response.error;
}
return response.data;
});
}
/**
* Get the orders data for the given user.
* @param client
* @param accountId
*/
function getOrdersData(client: SupabaseClient<Database>, accountId: string) {
return client
.from('orders')
.select('*, items: order_items !inner (*)')
.eq('account_id', accountId)
.maybeSingle()
.then((response) => {
if (response.error) {
throw response.error;
}
return response.data;
});
}
/**
* Get the billing customer ID for the given user.
* If the user does not have a billing customer ID, it will return null.
* @param client
* @param accountId
*/
function getBillingCustomerId(
client: SupabaseClient<Database>,
accountId: string,
) {
return client
.from('billing_customers')
.select('customer_id')
.eq('account_id', accountId)
.maybeSingle()
.then((response) => {
if (response.error) {
throw response.error;
}
return response.data?.customer_id;
});
}

View File

@@ -5,6 +5,7 @@ import { cache } from 'react';
import { redirect } from 'next/navigation';
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import pathsConfig from '~/config/paths.config';
@@ -19,50 +20,21 @@ import pathsConfig from '~/config/paths.config';
*/
export const loadTeamWorkspace = cache(async (accountSlug: string) => {
const client = getSupabaseServerComponentClient();
const api = createTeamAccountsApi(client);
const accountPromise = client.rpc('team_account_workspace', {
account_slug: accountSlug,
});
const workspace = await api.getAccountWorkspace(accountSlug);
const accountsPromise = client.from('user_accounts').select('*');
const [
accountResult,
accountsResult,
{
data: { user },
},
] = await Promise.all([
accountPromise,
accountsPromise,
client.auth.getUser(),
]);
if (accountResult.error) {
throw accountResult.error;
if (workspace.error) {
throw workspace.error;
}
const account = workspace.data.account;
// we cannot find any record for the selected account
// so we redirect the user to the home page
if (!accountResult.data.length) {
if (!account) {
return redirect(pathsConfig.app.home);
}
const accountData = accountResult.data[0];
// we cannot find any record for the selected account
// so we redirect the user to the home page
if (!accountData) {
return redirect(pathsConfig.app.home);
}
if (accountsResult.error) {
throw accountsResult.error;
}
return {
account: accountData,
accounts: accountsResult.data,
user,
};
return workspace.data;
});

View File

@@ -9,6 +9,7 @@ import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { getLogger } from '@kit/shared/logger';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
import { createTeamAccountsApi } from '@kit/team-accounts/api';
import appConfig from '~/config/app.config';
import billingConfig from '~/config/billing.config';
@@ -46,11 +47,14 @@ export class TeamBillingService {
logger.info(ctx, `Requested checkout session. Processing...`);
const api = createTeamAccountsApi(this.client);
// verify permissions to manage billing
const hasPermission = await getBillingPermissionsForAccountId(
const hasPermission = await api.hasPermission({
userId,
accountId,
);
permission: 'billing.manage',
});
// if the user does not have permission to manage billing for the account
// then we should not proceed
@@ -73,7 +77,7 @@ export class TeamBillingService {
// find the customer ID for the account if it exists
// (eg. if the account has been billed before)
const customerId = await getCustomerIdFromAccountId(this.client, accountId);
const customerId = await api.getCustomerId(accountId);
const customerEmail = user.email;
// the return URL for the checkout session
@@ -157,11 +161,14 @@ export class TeamBillingService {
const userId = user.id;
const api = createTeamAccountsApi(client);
// we require the user to have permissions to manage billing for the account
const hasPermission = await getBillingPermissionsForAccountId(
const hasPermission = await api.hasPermission({
userId,
accountId,
);
permission: 'billing.manage',
});
// if the user does not have permission to manage billing for the account
// then we should not proceed
@@ -178,8 +185,7 @@ export class TeamBillingService {
throw new Error('Permission denied');
}
const service = await getBillingGatewayProvider(client);
const customerId = await getCustomerIdFromAccountId(client, accountId);
const customerId = await api.getCustomerId(accountId);
if (!customerId) {
throw new Error('Customer not found');
@@ -195,6 +201,9 @@ export class TeamBillingService {
`Creating billing portal session...`,
);
// get the billing gateway provider
const service = await getBillingGatewayProvider(client);
try {
const returnUrl = getBillingPortalReturnUrl(slug);
@@ -227,7 +236,10 @@ export class TeamBillingService {
lineItems: z.infer<typeof LineItemSchema>[],
accountId: string,
) {
const variantQuantities = [];
const variantQuantities: Array<{
quantity: number;
variantId: string;
}> = [];
for (const lineItem of lineItems) {
if (lineItem.type === 'per-seat') {
@@ -246,17 +258,14 @@ export class TeamBillingService {
}
private async getCurrentMembersCount(accountId: string) {
const { count, error } = await this.client
.from('accounts_memberships')
.select('*', {
head: true,
count: 'exact',
})
.eq('account_id', accountId);
const api = createTeamAccountsApi(this.client);
const logger = await getLogger();
if (error) {
const logger = await getLogger();
try {
const count = await api.getMembersCount(accountId);
return count ?? 1;
} catch (error) {
logger.error(
{
accountId,
@@ -266,10 +275,8 @@ export class TeamBillingService {
`Encountered an error while fetching the number of existing seats`,
);
throw new Error();
return Promise.reject(error);
}
return count ?? 1;
}
}
@@ -285,51 +292,6 @@ function getAccountUrl(path: string, slug: string) {
return new URL(path, appConfig.url).toString().replace('[account]', slug);
}
/**
* @name getBillingPermissionsForAccountId
* @description Retrieves the permissions for a user on an account for managing billing.
*/
async function getBillingPermissionsForAccountId(
userId: string,
accountId: string,
) {
const client = getSupabaseServerActionClient();
const { data, error } = await client.rpc('has_permission', {
account_id: accountId,
user_id: userId,
permission_name: 'billing.manage',
});
if (error) {
throw error;
}
return data;
}
/**
* Retrieves the customer ID based on the provided account ID.
* If it exists we need to pass it to the provider so we can bill the same
* customer ID for the provided account ID
*/
async function getCustomerIdFromAccountId(
client: SupabaseClient<Database>,
accountId: string,
) {
const { data, error } = await client
.from('billing_customers')
.select('customer_id')
.eq('account_id', accountId)
.maybeSingle();
if (error) {
throw error;
}
return data?.customer_id;
}
function getPlanDetails(productId: string, planId: string) {
const product = billingConfig.products.find(
(product) => product.id === productId,

View File

@@ -11,8 +11,8 @@ import {
SidebarItem,
} from '@kit/ui/sidebar';
import { ProfileAccountDropdownContainer } from '~/(dashboard)/home/_components/personal-account-dropdown-container';
import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
export async function AdminSidebar() {
const client = getSupabaseServerActionClient();

View File

@@ -12,7 +12,8 @@
"./personal-account-dropdown": "./src/components/personal-account-dropdown.tsx",
"./account-selector": "./src/components/account-selector.tsx",
"./personal-account-settings": "./src/components/personal-account-settings/index.ts",
"./hooks/*": "./src/hooks/*.ts"
"./hooks/*": "./src/hooks/*.ts",
"./api": "./src/server/api.ts"
},
"dependencies": {
"@tanstack/react-table": "^8.16.0",

View File

@@ -0,0 +1,107 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
/**
* Class representing an API for interacting with user accounts.
* @constructor
* @param {SupabaseClient<Database>} client - The Supabase client instance.
*/
class AccountsApi {
constructor(private readonly client: SupabaseClient<Database>) {}
async getAccountWorkspace() {
const { data, error } = await this.client
.from('user_account_workspace')
.select(`*`)
.single();
if (error) {
throw error;
}
return data;
}
async loadUserAccounts() {
const { data: accounts, error } = await this.client
.from('user_accounts')
.select(`name, slug, picture_url`);
if (error) {
throw error;
}
return accounts.map(({ name, slug, picture_url }) => {
return {
label: name,
value: slug,
image: picture_url,
};
});
}
/**
* @name getSubscriptionData
* Get the subscription data for the given user.
* @param accountId
*/
getSubscriptionData(accountId: string) {
return this.client
.from('subscriptions')
.select('*, items: subscription_items !inner (*)')
.eq('account_id', accountId)
.maybeSingle()
.then((response) => {
if (response.error) {
throw response.error;
}
return response.data;
});
}
/**
* Get the orders data for the given account.
* @param accountId
*/
getOrdersData(accountId: string) {
return this.client
.from('orders')
.select('*, items: order_items !inner (*)')
.eq('account_id', accountId)
.maybeSingle()
.then((response) => {
if (response.error) {
throw response.error;
}
return response.data;
});
}
/**
* @name getBillingCustomerId
* Get the billing customer ID for the given user.
* If the user does not have a billing customer ID, it will return null.
* @param accountId
*/
getBillingCustomerId(accountId: string) {
return this.client
.from('billing_customers')
.select('customer_id')
.eq('account_id', accountId)
.maybeSingle()
.then((response) => {
if (response.error) {
throw response.error;
}
return response.data?.customer_id;
});
}
}
export function createAccountsApi(client: SupabaseClient<Database>) {
return new AccountsApi(client);
}

View File

@@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit"
},
"exports": {
"./api": "./src/server/api.ts",
"./components": "./src/components/index.ts",
"./webhooks": "./src/server/services/webhooks/index.ts"
},

View File

@@ -0,0 +1,142 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
/**
* Class representing an API for interacting with team accounts.
* @constructor
* @param {SupabaseClient<Database>} client - The Supabase client instance.
*/
export class TeamAccountsApi {
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name getAccountWorkspace
* @description Get the account workspace data.
* @param slug
*/
async getAccountWorkspace(slug: string) {
const accountPromise = this.client.rpc('team_account_workspace', {
account_slug: slug,
});
const accountsPromise = this.client.from('user_accounts').select('*');
const [
accountResult,
accountsResult,
{
data: { user },
},
] = await Promise.all([
accountPromise,
accountsPromise,
this.client.auth.getUser(),
]);
if (accountResult.error) {
return {
error: accountResult.error,
data: null,
};
}
if (accountsResult.error) {
return {
error: accountsResult.error,
data: null,
};
}
if (!user) {
return {
error: new Error('User is not logged in'),
data: null,
};
}
const accountData = accountResult.data[0];
if (!accountData) {
return {
error: new Error('Account data not found'),
data: null,
};
}
return {
data: {
account: accountData,
accounts: accountsResult.data,
user,
},
error: null,
};
}
/**
* @name hasPermission
* @description Check if the user has permission to manage billing for the account.
*/
async hasPermission(params: {
accountId: string;
userId: string;
permission: Database['public']['Enums']['app_permissions'];
}) {
const { data, error } = await this.client.rpc('has_permission', {
account_id: params.accountId,
user_id: params.userId,
permission_name: params.permission,
});
if (error) {
throw error;
}
return data;
}
/**
* @name getMembersCount
* @description Get the number of members in the account.
* @param accountId
*/
async getMembersCount(accountId: string) {
const { count, error } = await this.client
.from('accounts_memberships')
.select('*', {
head: true,
count: 'exact',
})
.eq('account_id', accountId);
if (error) {
throw error;
}
return count;
}
/**
* @name getCustomerId
* @description Get the billing customer ID for the given account.
* @param accountId
*/
async getCustomerId(accountId: string) {
const { data, error } = await this.client
.from('billing_customers')
.select('customer_id')
.eq('account_id', accountId)
.maybeSingle();
if (error) {
throw error;
}
return data?.customer_id;
}
}
export function createTeamAccountsApi(client: SupabaseClient<Database>) {
return new TeamAccountsApi(client);
}

View File

@@ -10,7 +10,6 @@
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./middleware-client": "./src/clients/middleware.client.ts",
"./server-actions-client": "./src/clients/server-actions.client.ts",
"./route-handler-client": "./src/clients/route-handler.client.ts",