diff --git a/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts b/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts index 0345e43c5..2604c29b4 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts +++ b/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts @@ -75,6 +75,7 @@ export async function createPersonalAccountCheckoutSession( customerEmail: user.email, customerId, plan, + variantQuantities: [], }); Logger.info( diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/schema/team-billing-portal.schema.ts b/apps/web/app/(dashboard)/home/[account]/_lib/schema/team-billing-portal.schema.ts new file mode 100644 index 000000000..570b69e4e --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/_lib/schema/team-billing-portal.schema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const TeamBillingPortalSchema = z.object({ + accountId: z.string().uuid(), + slug: z.string().min(1), +}); diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/schema/team-checkout.schema.ts b/apps/web/app/(dashboard)/home/[account]/_lib/schema/team-checkout.schema.ts new file mode 100644 index 000000000..12c1127ec --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/_lib/schema/team-checkout.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const TeamCheckoutSchema = z.object({ + slug: z.string().min(1), + productId: z.string().min(1), + planId: z.string().min(1), + accountId: z.string().uuid(), +}); diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/load-team-account-workspace.ts b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-workspace.loader.ts similarity index 100% rename from apps/web/app/(dashboard)/home/[account]/_lib/load-team-account-workspace.ts rename to apps/web/app/(dashboard)/home/[account]/_lib/server/team-account-workspace.loader.ts 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 new file mode 100644 index 000000000..6d2e80152 --- /dev/null +++ b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts @@ -0,0 +1,254 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +import { z } from 'zod'; + +import { LineItemSchema } from '@kit/billing'; +import { getBillingGatewayProvider } from '@kit/billing-gateway'; +import { Logger } from '@kit/shared/logger'; +import { Database } from '@kit/supabase/database'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; + +import { TeamCheckoutSchema } from '~/(dashboard)/home/[account]/_lib/schema/team-checkout.schema'; +import appConfig from '~/config/app.config'; +import billingConfig from '~/config/billing.config'; +import pathsConfig from '~/config/paths.config'; + +export class TeamBillingService { + constructor(private readonly client: SupabaseClient) {} + + async createCheckout(params: z.infer) { + // we require the user to be authenticated + const { data: user } = await requireUser(this.client); + + if (!user) { + throw new Error('Authentication required'); + } + + const userId = user.id; + const accountId = params.accountId; + + // verify permissions to manage billing + const hasPermission = await getBillingPermissionsForAccountId( + userId, + accountId, + ); + + // if the user does not have permission to manage billing for the account + // then we should not proceed + if (!hasPermission) { + throw new Error('Permission denied'); + } + + // here we have confirmed that the user has permission to manage billing for the account + // so we go on and create a checkout session + const service = await getBillingGatewayProvider(this.client); + + // retrieve the plan from the configuration + // so we can assign the correct checkout data + const plan = getPlan(params.productId, params.planId); + + // 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 customerEmail = user.email; + + // the return URL for the checkout session + const returnUrl = getCheckoutSessionReturnUrl(params.slug); + + // get variant quantities + // useful for setting an initial quantity value for certain line items + // such as per seat + const variantQuantities = await this.getVariantQuantities( + plan.lineItems, + accountId, + ); + + // call the payment gateway to create the checkout session + const { checkoutToken } = await service.createCheckoutSession({ + accountId, + plan, + returnUrl, + customerEmail, + customerId, + variantQuantities, + }); + + // return the checkout token to the client + // so we can call the payment gateway to complete the checkout + return { + checkoutToken, + }; + } + + async createBillingPortalSession({ + accountId, + slug, + }: { + accountId: string; + slug: string; + }) { + const client = getSupabaseServerActionClient(); + + const { data: user, error } = await requireUser(client); + + if (error ?? !user) { + throw new Error('Authentication required'); + } + + const userId = user.id; + + // we require the user to have permissions to manage billing for the account + const hasPermission = await getBillingPermissionsForAccountId( + userId, + accountId, + ); + + // if the user does not have permission to manage billing for the account + // then we should not proceed + if (!hasPermission) { + throw new Error('Permission denied'); + } + + const service = await getBillingGatewayProvider(client); + const customerId = await getCustomerIdFromAccountId(client, accountId); + const returnUrl = getBillingPortalReturnUrl(slug); + + if (!customerId) { + throw new Error('Customer not found'); + } + + const { url } = await service.createBillingPortalSession({ + customerId, + returnUrl, + }); + + // redirect the user to the billing portal + return url; + } + + /** + * Retrieves variant quantities for line items. + */ + private async getVariantQuantities( + lineItems: z.infer[], + accountId: string, + ) { + const variantQuantities = []; + + for (const lineItem of lineItems) { + if (lineItem.type === 'per-seat') { + const quantity = await this.getCurrentMembersCount(accountId); + + const item = { + quantity, + variantId: lineItem.id, + }; + + variantQuantities.push(item); + } + } + + return variantQuantities; + } + + private async getCurrentMembersCount(accountId: string) { + const { count, error } = await this.client + .from('accounts_memberships') + .select('*', { + head: true, + count: 'exact', + }) + .eq('account_id', accountId); + + if (error) { + Logger.error( + { + accountId, + error, + name: `billing.checkout`, + }, + `Encountered an error while fetching the number of existing seats`, + ); + + throw new Error(); + } + + return count ?? 1; + } +} + +function getCheckoutSessionReturnUrl(accountSlug: string) { + return getAccountUrl(pathsConfig.app.accountBillingReturn, accountSlug); +} + +function getBillingPortalReturnUrl(accountSlug: string) { + return getAccountUrl(pathsConfig.app.accountBilling, accountSlug); +} + +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: ReturnType, + 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 getPlan(productId: string, planId: string) { + const product = billingConfig.products.find( + (product) => product.id === productId, + ); + + if (!product) { + throw new Error('Product not found'); + } + + const plan = product?.plans.find((plan) => plan.id === planId); + + if (!plan) { + throw new Error('Plan not found'); + } + + return plan; +} diff --git a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx index e33b7f0c4..7e44a835d 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx @@ -10,12 +10,12 @@ import { If } from '@kit/ui/if'; import { PageBody, PageHeader } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; -import { loadTeamWorkspace } from '~/(dashboard)/home/[account]/_lib/load-team-account-workspace'; import { createBillingPortalSession } from '~/(dashboard)/home/[account]/billing/server-actions'; import billingConfig from '~/config/billing.config'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; +import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader'; import { TeamAccountCheckoutForm } from './_components/team-account-checkout-form'; interface Params { diff --git a/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts b/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts index b30443992..676324ee8 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts +++ b/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts @@ -4,183 +4,39 @@ import { redirect } from 'next/navigation'; import { z } from 'zod'; -import { getBillingGatewayProvider } from '@kit/billing-gateway'; -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'; -import pathsConfig from '~/config/paths.config'; +import { TeamBillingPortalSchema } from '~/(dashboard)/home/[account]/_lib/schema/team-billing-portal.schema'; + +import { TeamCheckoutSchema } from '../_lib/schema/team-checkout.schema'; +import { TeamBillingService } from '../_lib/server/team-billing.service'; /** - * Creates a checkout session for a team account. - * - * @param {object} params - The parameters for creating the checkout session. - * @param {string} params.planId - The ID of the plan to be associated with the account. + * @name createTeamAccountCheckoutSession + * @description Creates a checkout session for a team account. */ -export async function createTeamAccountCheckoutSession(params: { - productId: string; - planId: string; - accountId: string; - slug: string; -}) { - const client = getSupabaseServerActionClient(); +export async function createTeamAccountCheckoutSession( + params: z.infer, +) { + const data = TeamCheckoutSchema.parse(params); - // we parse the plan ID from the parameters - // no need in continuing if the plan ID is not valid - const planId = z.string().min(1).parse(params.planId); - const productId = z.string().min(1).parse(params.productId); + const service = new TeamBillingService(getSupabaseServerActionClient()); - // we require the user to be authenticated - const { data: user } = await requireUser(client); - - if (!user) { - throw new Error('Authentication required'); - } - - const userId = user.id; - const accountId = params.accountId; - - const hasPermission = await getPermissionsForAccountId(userId, accountId); - - // if the user does not have permission to manage billing for the account - // then we should not proceed - if (!hasPermission) { - throw new Error('Permission denied'); - } - - // here we have confirmed that the user has permission to manage billing for the account - // so we go on and create a checkout session - const service = await getBillingGatewayProvider(client); - - const product = billingConfig.products.find( - (product) => product.id === productId, - ); - - if (!product) { - throw new Error('Product not found'); - } - - const plan = product?.plans.find((plan) => plan.id === planId); - - if (!plan) { - throw new Error('Plan not found'); - } - - // find the customer ID for the account if it exists - // (eg. if the account has been billed before) - const customerId = await getCustomerIdFromAccountId(client, accountId); - const customerEmail = user.email; - - // the return URL for the checkout session - const returnUrl = getCheckoutSessionReturnUrl(params.slug); - - // call the payment gateway to create the checkout session - const { checkoutToken } = await service.createCheckoutSession({ - accountId, - plan, - returnUrl, - customerEmail, - customerId, - }); - - // return the checkout token to the client - // so we can call the payment gateway to complete the checkout - return { - checkoutToken, - }; + return service.createCheckout(data); } +/** + * @name createBillingPortalSession + * @description Creates a Billing Session Portal and redirects the user to the + * provider's hosted instance + */ export async function createBillingPortalSession(formData: FormData) { - const client = getSupabaseServerActionClient(); + const params = TeamBillingPortalSchema.parse(Object.fromEntries(formData)); - const { accountId, slug } = z - .object({ - accountId: z.string().min(1), - slug: z.string().min(1), - }) - .parse(Object.fromEntries(formData)); + const service = new TeamBillingService(getSupabaseServerActionClient()); - const { data: user, error } = await requireUser(client); + // get url to billing portal + const url = await service.createBillingPortalSession(params); - if (error ?? !user) { - throw new Error('Authentication required'); - } - - const userId = user.id; - - // we require the user to have permissions to manage billing for the account - const hasPermission = await getPermissionsForAccountId(userId, accountId); - - // if the user does not have permission to manage billing for the account - // then we should not proceed - if (!hasPermission) { - throw new Error('Permission denied'); - } - - const service = await getBillingGatewayProvider(client); - const customerId = await getCustomerIdFromAccountId(client, accountId); - const returnUrl = getBillingPortalReturnUrl(slug); - - if (!customerId) { - throw new Error('Customer not found'); - } - - const { url } = await service.createBillingPortalSession({ - customerId, - returnUrl, - }); - - // redirect the user to the billing portal return redirect(url); } - -function getCheckoutSessionReturnUrl(accountSlug: string) { - return new URL(pathsConfig.app.accountBillingReturn, appConfig.url) - .toString() - .replace('[account]', accountSlug); -} - -function getBillingPortalReturnUrl(accountSlug: string) { - return new URL(pathsConfig.app.accountBilling, appConfig.url) - .toString() - .replace('[account]', accountSlug); -} - -/** - * Retrieves the permissions for a user on an account for managing billing. - * @param userId - * @param accountId - */ -async function getPermissionsForAccountId(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; -} - -async function getCustomerIdFromAccountId( - client: ReturnType, - 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; -} diff --git a/apps/web/app/(dashboard)/home/[account]/layout.tsx b/apps/web/app/(dashboard)/home/[account]/layout.tsx index b41ba1d85..9732a9801 100644 --- a/apps/web/app/(dashboard)/home/[account]/layout.tsx +++ b/apps/web/app/(dashboard)/home/[account]/layout.tsx @@ -5,7 +5,7 @@ import { Page } from '@kit/ui/page'; import { withI18n } from '~/lib/i18n/with-i18n'; import { AccountLayoutSidebar } from './_components/account-layout-sidebar'; -import { loadTeamWorkspace } from './_lib/load-team-account-workspace'; +import { loadTeamWorkspace } from './_lib/server/team-account-workspace.loader'; interface Params { account: string; diff --git a/apps/web/app/(dashboard)/home/[account]/members/page.tsx b/apps/web/app/(dashboard)/home/[account]/members/page.tsx index 6a0160faa..7f892fd8b 100644 --- a/apps/web/app/(dashboard)/home/[account]/members/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/members/page.tsx @@ -21,10 +21,11 @@ import { If } from '@kit/ui/if'; import { PageBody, PageHeader } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; -import { loadTeamWorkspace } from '~/(dashboard)/home/[account]/_lib/load-team-account-workspace'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; +import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader'; + interface Params { params: { account: string; diff --git a/apps/web/app/(dashboard)/home/[account]/settings/page.tsx b/apps/web/app/(dashboard)/home/[account]/settings/page.tsx index 5879d11f6..4df0570bc 100644 --- a/apps/web/app/(dashboard)/home/[account]/settings/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/settings/page.tsx @@ -5,7 +5,7 @@ import { Trans } from '@kit/ui/trans'; import pathsConfig from '~/config/paths.config'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { loadTeamWorkspace } from '../_lib/load-team-account-workspace'; +import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader'; export const generateMetadata = async () => { const i18n = await createI18nServerInstance(); diff --git a/packages/billing/core/src/schema/create-billing-checkout.schema.ts b/packages/billing/core/src/schema/create-billing-checkout.schema.ts index 3e18cbf34..2fca5c856 100644 --- a/packages/billing/core/src/schema/create-billing-checkout.schema.ts +++ b/packages/billing/core/src/schema/create-billing-checkout.schema.ts @@ -9,4 +9,10 @@ export const CreateBillingCheckoutSchema = z.object({ trialDays: z.number().optional(), customerId: z.string().optional(), customerEmail: z.string().email().optional(), + variantQuantities: z.array( + z.object({ + variantId: z.string().min(1), + quantity: z.number(), + }), + ), }); diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts index 62b32c24c..9d4c3ce4b 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts @@ -55,6 +55,12 @@ export async function createLemonSqueezyCheckout( }, checkoutData: { email: customerEmail, + variantQuantities: params.variantQuantities.map((item) => { + return { + quantity: item.quantity, + variantId: Number(item.variantId), + }; + }), custom: { account_id: params.accountId, }, diff --git a/packages/billing/stripe/src/services/create-stripe-checkout.ts b/packages/billing/stripe/src/services/create-stripe-checkout.ts index 25a51bfa7..aed1d4e12 100644 --- a/packages/billing/stripe/src/services/create-stripe-checkout.ts +++ b/packages/billing/stripe/src/services/create-stripe-checkout.ts @@ -61,9 +61,16 @@ export async function createStripeCheckout( }; } + // if we pass a custom quantity for the item ID + // we use that - otherwise we set it to 1 by default + const quantity = + params.variantQuantities.find((variant) => { + return variant.variantId === item.id; + })?.quantity ?? 1; + return { price: item.id, - quantity: 1, + quantity, }; });