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 (