From c3a4a05b2269654a1abf4a3cc284b408c344f0cd Mon Sep 17 00:00:00 2001 From: giancarlo Date: Wed, 27 Mar 2024 21:06:34 +0800 Subject: [PATCH] Refactor and improve billing module The billing module has been refined and enhanced to include deeper validation and detailing of billing plans and products. The checkout session creation process was revised to handle more complex scenarios, incorporating better parsing and validation. Additional validations were added for the plan and product schemas, improving product details extraction, and rearranging of module exports was made for better organization. The code refactor allows easier future modifications and upgrades for recurring and one-time payments with nuanced product configurations. --- .../personal-account-checkout-form.tsx | 3 +- .../home/(user)/billing/server-actions.ts | 31 +- .../team-account-checkout-form.tsx | 5 +- .../home/[account]/billing/server-actions.ts | 22 +- apps/web/config/billing.config.ts | 60 ++-- .../src/components/embedded-checkout.tsx | 2 +- .../src/components/plan-picker.tsx | 97 +++--- .../src/components/pricing-table.tsx | 53 ++-- .../billing-event-handler.service.ts | 2 +- packages/billing/src/create-billing-schema.ts | 277 +++++++++++++----- packages/billing/src/index.ts | 1 + packages/billing/src/line-items-mapper.ts | 51 ++++ .../schema/create-billing-checkout.schema.ts | 38 ++- packages/features/accounts/package.json | 1 + .../personal-accounts-server-actions.ts | 21 +- .../services/personal-accounts.service.ts | 87 +++++- .../delete-team-account-server-actions.ts | 4 +- ...vice.ts => delete-team-account.service.ts} | 2 +- packages/mailers/src/impl/cloudflare/index.ts | 1 + .../src/services/create-stripe-checkout.ts | 40 ++- packages/ui/src/makerkit/mdx/mdx-renderer.tsx | 2 +- pnpm-lock.yaml | 3 + 22 files changed, 578 insertions(+), 225 deletions(-) create mode 100644 packages/billing/src/line-items-mapper.ts rename packages/features/team-accounts/src/services/{delete-account.service.ts => delete-team-account.service.ts} (88%) diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx index cd22afa92..06cc3f904 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -54,12 +54,13 @@ export function PersonalAccountCheckoutForm() { { + onSubmit={({ planId, productId }) => { startTransition(async () => { try { const { checkoutToken } = await createPersonalAccountCheckoutSession({ planId, + productId, }); setCheckoutToken(checkoutToken); 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 b0c81ca13..ee203ddd5 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts +++ b/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; import { z } from 'zod'; -import { getProductPlanPairFromId } from '@kit/billing'; +import { getLineItemsFromPlanId } from '@kit/billing'; import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { Logger } from '@kit/shared/logger'; import { requireAuth } from '@kit/supabase/require-auth'; @@ -22,6 +22,7 @@ import pathsConfig from '~/config/paths.config'; */ export async function createPersonalAccountCheckoutSession(params: { planId: string; + productId: string; }) { const client = getSupabaseServerActionClient(); const { data, error } = await requireAuth(client); @@ -30,21 +31,22 @@ export async function createPersonalAccountCheckoutSession(params: { throw new Error('Authentication required'); } - const planId = z.string().min(1).parse(params.planId); + const { planId, productId } = z + .object({ + planId: z.string().min(1), + productId: z.string().min(1), + }) + .parse(params); Logger.info( { planId, + productId, }, `Creating checkout session for plan ID`, ); const service = await getBillingGatewayProvider(client); - const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId); - - if (!productPlanPairFromId) { - throw new Error('Product not found'); - } // in the case of personal accounts // the account ID is the same as the user ID @@ -57,16 +59,21 @@ export async function createPersonalAccountCheckoutSession(params: { // (eg. if the account has been billed before) const customerId = await getCustomerIdFromAccountId(accountId); - // retrieve the product and plan from the billing configuration - const { product, plan } = productPlanPairFromId; + const product = billingConfig.products.find((item) => item.id === productId); + + if (!product) { + throw new Error('Product not found'); + } + + const { lineItems, trialDays } = getLineItemsFromPlanId(product, planId); // call the payment gateway to create the checkout session const { checkoutToken } = await service.createCheckoutSession({ - paymentType: product.paymentType, + lineItems, returnUrl, accountId, - planId, - trialPeriodDays: plan.trialPeriodDays, + trialDays, + paymentType: product.paymentType, customerEmail: data.user.email, customerId, }); diff --git a/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx index dc39b3cce..a6eaf695a 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx @@ -45,14 +45,15 @@ export function TeamAccountCheckoutForm(params: { accountId: string }) { { + onSubmit={({ planId, productId }) => { startTransition(async () => { const slug = routeParams.account as string; const { checkoutToken } = await createTeamAccountCheckoutSession({ planId, - accountId: params.accountId, + productId, slug, + accountId: params.accountId, }); setCheckoutToken(checkoutToken); 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 3c1701e11..238d9af88 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts +++ b/apps/web/app/(dashboard)/home/[account]/billing/server-actions.ts @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; import { z } from 'zod'; -import { getProductPlanPairFromId } from '@kit/billing'; +import { getLineItemsFromPlanId } from '@kit/billing'; import { getBillingGatewayProvider } from '@kit/billing-gateway'; import { requireAuth } from '@kit/supabase/require-auth'; import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client'; @@ -20,6 +20,7 @@ import pathsConfig from '~/config/paths.config'; * @param {string} params.planId - The ID of the plan to be associated with the account. */ export async function createTeamAccountCheckoutSession(params: { + productId: string; planId: string; accountId: string; slug: string; @@ -29,6 +30,7 @@ export async function createTeamAccountCheckoutSession(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); // we require the user to be authenticated const { data: session } = await requireAuth(client); @@ -51,32 +53,34 @@ export async function createTeamAccountCheckoutSession(params: { // 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 productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId); - if (!productPlanPairFromId) { + const product = billingConfig.products.find( + (product) => product.id === productId, + ); + + if (!product) { throw new Error('Product not found'); } - // the return URL for the checkout session - const returnUrl = getCheckoutSessionReturnUrl(params.slug); + const { lineItems, trialDays } = getLineItemsFromPlanId(product, planId); // 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 = session.user.email; - // retrieve the product and plan from the billing configuration - const { product, plan } = productPlanPairFromId; + // 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, + lineItems, returnUrl, - planId, customerEmail, customerId, + trialDays, paymentType: product.paymentType, - trialPeriodDays: plan.trialPeriodDays, }); // return the checkout token to the client diff --git a/apps/web/config/billing.config.ts b/apps/web/config/billing.config.ts index ba86c99a0..5c1151052 100644 --- a/apps/web/config/billing.config.ts +++ b/apps/web/config/billing.config.ts @@ -8,22 +8,24 @@ export default createBillingSchema({ name: 'Starter', description: 'The perfect plan to get started', currency: 'USD', - paymentType: 'recurring', badge: `Value`, + paymentType: 'recurring', plans: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', name: 'Starter Monthly', - price: '9.99', - interval: 'month', - perSeat: false, + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', + price: 9.99, + recurring: { + interval: 'month', + }, }, { - id: 'starter-yearly', name: 'Starter Yearly', - price: '99.99', - interval: 'year', - perSeat: false, + id: 'starter-yearly', + price: 99.99, + recurring: { + interval: 'year', + }, }, ], features: ['Feature 1', 'Feature 2', 'Feature 3'], @@ -34,22 +36,24 @@ export default createBillingSchema({ badge: `Popular`, highlighted: true, description: 'The perfect plan for professionals', - paymentType: 'recurring', currency: 'USD', + paymentType: 'recurring', plans: [ { - id: 'pro-monthly', name: 'Pro Monthly', - price: '19.99', - interval: 'month', - perSeat: false, + id: 'pro-monthly', + price: 19.99, + recurring: { + interval: 'month', + }, }, { - id: 'pro-yearly', name: 'Pro Yearly', - price: '199.99', - interval: 'year', - perSeat: false, + id: 'pro-yearly', + price: 199.99, + recurring: { + interval: 'year', + }, }, ], features: [ @@ -64,22 +68,24 @@ export default createBillingSchema({ id: 'enterprise', name: 'Enterprise', description: 'The perfect plan for enterprises', - paymentType: 'recurring', currency: 'USD', + paymentType: 'recurring', plans: [ { - id: 'enterprise-monthly', name: 'Enterprise Monthly', - price: '99.99', - interval: 'month', - perSeat: false, + id: 'enterprise-monthly', + price: 99.99, + recurring: { + interval: 'month', + }, }, { - id: 'enterprise-yearly', name: 'Enterprise Yearly', - price: '999.99', - interval: 'year', - perSeat: false, + id: 'enterprise-yearly', + price: 999.99, + recurring: { + interval: 'year', + }, }, ], features: [ diff --git a/packages/billing-gateway/src/components/embedded-checkout.tsx b/packages/billing-gateway/src/components/embedded-checkout.tsx index f5288d7c8..68672c93c 100644 --- a/packages/billing-gateway/src/components/embedded-checkout.tsx +++ b/packages/billing-gateway/src/components/embedded-checkout.tsx @@ -78,7 +78,7 @@ function buildLazyComponent< return ( - {/* @ts-ignore */} + {/* @ts-expect-error */} ; - onSubmit: (data: { planId: string }) => void; + onSubmit: (data: { planId: string; productId: string }) => void; pending?: boolean; }>, ) { - const intervals = props.config.products.reduce((acc, item) => { - return Array.from( - new Set([...acc, ...item.plans.map((plan) => plan.interval)]), - ); - }, []); + const intervals = useMemo( + () => getPlanIntervals(props.config), + [props.config], + ); const form = useForm({ reValidateMode: 'onChange', @@ -44,20 +50,17 @@ export function PlanPicker( resolver: zodResolver( z .object({ - planId: z.string(), - interval: z.string(), + planId: z.string().min(1), + interval: z.string().min(1), }) .refine( (data) => { - const planFound = props.config.products - .flatMap((item) => item.plans) - .some((plan) => plan.id === data.planId); + const { product, plan } = getProductPlanPairFromId( + props.config, + data.planId, + ); - if (!planFound) { - return false; - } - - return intervals.includes(data.interval); + return product && plan; }, { message: `Please pick a plan to continue`, path: ['planId'] }, ), @@ -65,6 +68,7 @@ export function PlanPicker( defaultValues: { interval: intervals[0], planId: '', + productId: '', }, }); @@ -81,9 +85,11 @@ export function PlanPicker( render={({ field }) => { return ( - Choose your billing interval + + Choose your billing interval + - +
{intervals.map((interval) => { @@ -91,6 +97,7 @@ export function PlanPicker( return (