From 866b9888f7beaf114e0acaf9baa4da3c5cdb69bd Mon Sep 17 00:00:00 2001 From: giancarlo Date: Mon, 22 Apr 2024 01:35:15 +0800 Subject: [PATCH] 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. --- .../home-sidebar-account-selector.tsx | 0 .../{ => (user)}/_components/home-sidebar.tsx | 4 +- .../_lib/server}/load-user-workspace.ts | 40 +---- .../personal-account-billing-page.loader.ts | 74 +-------- .../_lib/server/user-billing.service.ts | 51 ++----- .../app/(dashboard)/home/(user)/layout.tsx | 3 +- .../_components/account-layout-sidebar.tsx | 2 +- .../team-account-billing-page.loader.ts | 79 +--------- .../server/team-account-workspace.loader.ts | 46 ++---- .../_lib/server/team-billing.service.ts | 92 ++++-------- .../app/admin/_components/admin-sidebar.tsx | 2 +- .../personal-account-dropdown-container.tsx | 0 packages/features/accounts/package.json | 3 +- packages/features/accounts/src/server/api.ts | 107 +++++++++++++ packages/features/team-accounts/package.json | 1 + .../features/team-accounts/src/server/api.ts | 142 ++++++++++++++++++ packages/supabase/package.json | 1 - 17 files changed, 324 insertions(+), 323 deletions(-) rename apps/web/app/(dashboard)/home/{ => (user)}/_components/home-sidebar-account-selector.tsx (100%) rename apps/web/app/(dashboard)/home/{ => (user)}/_components/home-sidebar.tsx (89%) rename apps/web/app/(dashboard)/home/{_lib => (user)/_lib/server}/load-user-workspace.ts (55%) rename apps/web/{app/(dashboard)/home/_components => components}/personal-account-dropdown-container.tsx (100%) create mode 100644 packages/features/accounts/src/server/api.ts create mode 100644 packages/features/team-accounts/src/server/api.ts diff --git a/apps/web/app/(dashboard)/home/_components/home-sidebar-account-selector.tsx b/apps/web/app/(dashboard)/home/(user)/_components/home-sidebar-account-selector.tsx similarity index 100% rename from apps/web/app/(dashboard)/home/_components/home-sidebar-account-selector.tsx rename to apps/web/app/(dashboard)/home/(user)/_components/home-sidebar-account-selector.tsx diff --git a/apps/web/app/(dashboard)/home/_components/home-sidebar.tsx b/apps/web/app/(dashboard)/home/(user)/_components/home-sidebar.tsx similarity index 89% rename from apps/web/app/(dashboard)/home/_components/home-sidebar.tsx rename to apps/web/app/(dashboard)/home/(user)/_components/home-sidebar.tsx index 1114e4f23..acf6901fb 100644 --- a/apps/web/app/(dashboard)/home/_components/home-sidebar.tsx +++ b/apps/web/app/(dashboard)/home/(user)/_components/home-sidebar.tsx @@ -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(); diff --git a/apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts b/apps/web/app/(dashboard)/home/(user)/_lib/server/load-user-workspace.ts similarity index 55% rename from apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts rename to apps/web/app/(dashboard)/home/(user)/_lib/server/load-user-workspace.ts index 447ebd508..00c13f912 100644 --- a/apps/web/app/(dashboard)/home/_lib/load-user-workspace.ts +++ b/apps/web/app/(dashboard)/home/(user)/_lib/server/load-user-workspace.ts @@ -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) { - const { data, error } = await client - .from('user_account_workspace') - .select(`*`) - .single(); - - if (error) { - throw error; - } - - return data; -} - -async function loadUserAccounts(client: SupabaseClient) { - 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, - }; - }); -} diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts index 82958a77a..15bb17c23 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts +++ b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts @@ -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, 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, 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, - 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; - }); -} diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts index 9f04525dd..ec52af453 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts +++ b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts @@ -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) {} + /** + * @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, diff --git a/apps/web/app/(dashboard)/home/(user)/layout.tsx b/apps/web/app/(dashboard)/home/(user)/layout.tsx index b659d19f7..5c50d9374 100644 --- a/apps/web/app/(dashboard)/home/(user)/layout.tsx +++ b/apps/web/app/(dashboard)/home/(user)/layout.tsx @@ -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 }>{children}; } diff --git a/apps/web/app/(dashboard)/home/[account]/_components/account-layout-sidebar.tsx b/apps/web/app/(dashboard)/home/[account]/_components/account-layout-sidebar.tsx index d7599f94a..52f3fe51c 100644 --- a/apps/web/app/(dashboard)/home/[account]/_components/account-layout-sidebar.tsx +++ b/apps/web/app/(dashboard)/home/[account]/_components/account-layout-sidebar.tsx @@ -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'; diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-billing-page.loader.ts b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-billing-page.loader.ts index 8955dfe07..8f691d25c 100644 --- a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-billing-page.loader.ts +++ b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-billing-page.loader.ts @@ -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, - 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, 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, - 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; - }); -} diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-workspace.loader.ts b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-workspace.loader.ts index 47902d92c..6a0dd71cd 100644 --- a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-workspace.loader.ts +++ b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-workspace.loader.ts @@ -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; }); diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts index 25c318733..3f89d77e3 100644 --- a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts +++ b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts @@ -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[], 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, - 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, diff --git a/apps/web/app/admin/_components/admin-sidebar.tsx b/apps/web/app/admin/_components/admin-sidebar.tsx index a7984006e..69cdc4654 100644 --- a/apps/web/app/admin/_components/admin-sidebar.tsx +++ b/apps/web/app/admin/_components/admin-sidebar.tsx @@ -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(); diff --git a/apps/web/app/(dashboard)/home/_components/personal-account-dropdown-container.tsx b/apps/web/components/personal-account-dropdown-container.tsx similarity index 100% rename from apps/web/app/(dashboard)/home/_components/personal-account-dropdown-container.tsx rename to apps/web/components/personal-account-dropdown-container.tsx diff --git a/packages/features/accounts/package.json b/packages/features/accounts/package.json index 0ad7072dc..aab422ac8 100644 --- a/packages/features/accounts/package.json +++ b/packages/features/accounts/package.json @@ -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", diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts new file mode 100644 index 000000000..4d32f6d73 --- /dev/null +++ b/packages/features/accounts/src/server/api.ts @@ -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} client - The Supabase client instance. + */ +class AccountsApi { + constructor(private readonly client: SupabaseClient) {} + + 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) { + return new AccountsApi(client); +} diff --git a/packages/features/team-accounts/package.json b/packages/features/team-accounts/package.json index e2db4a595..88b9f826f 100644 --- a/packages/features/team-accounts/package.json +++ b/packages/features/team-accounts/package.json @@ -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" }, diff --git a/packages/features/team-accounts/src/server/api.ts b/packages/features/team-accounts/src/server/api.ts new file mode 100644 index 000000000..e611d9d69 --- /dev/null +++ b/packages/features/team-accounts/src/server/api.ts @@ -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} client - The Supabase client instance. + */ +export class TeamAccountsApi { + constructor(private readonly client: SupabaseClient) {} + + /** + * @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) { + return new TeamAccountsApi(client); +} diff --git a/packages/supabase/package.json b/packages/supabase/package.json index 76d44df78..59bd8b7bf 100644 --- a/packages/supabase/package.json +++ b/packages/supabase/package.json @@ -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",