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.
This commit is contained in:
@@ -54,12 +54,13 @@ export function PersonalAccountCheckoutForm() {
|
|||||||
<PlanPicker
|
<PlanPicker
|
||||||
pending={pending}
|
pending={pending}
|
||||||
config={billingConfig}
|
config={billingConfig}
|
||||||
onSubmit={({ planId }) => {
|
onSubmit={({ planId, productId }) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const { checkoutToken } =
|
const { checkoutToken } =
|
||||||
await createPersonalAccountCheckoutSession({
|
await createPersonalAccountCheckoutSession({
|
||||||
planId,
|
planId,
|
||||||
|
productId,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCheckoutToken(checkoutToken);
|
setCheckoutToken(checkoutToken);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getProductPlanPairFromId } from '@kit/billing';
|
import { getLineItemsFromPlanId } from '@kit/billing';
|
||||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||||
import { Logger } from '@kit/shared/logger';
|
import { Logger } from '@kit/shared/logger';
|
||||||
import { requireAuth } from '@kit/supabase/require-auth';
|
import { requireAuth } from '@kit/supabase/require-auth';
|
||||||
@@ -22,6 +22,7 @@ import pathsConfig from '~/config/paths.config';
|
|||||||
*/
|
*/
|
||||||
export async function createPersonalAccountCheckoutSession(params: {
|
export async function createPersonalAccountCheckoutSession(params: {
|
||||||
planId: string;
|
planId: string;
|
||||||
|
productId: string;
|
||||||
}) {
|
}) {
|
||||||
const client = getSupabaseServerActionClient();
|
const client = getSupabaseServerActionClient();
|
||||||
const { data, error } = await requireAuth(client);
|
const { data, error } = await requireAuth(client);
|
||||||
@@ -30,21 +31,22 @@ export async function createPersonalAccountCheckoutSession(params: {
|
|||||||
throw new Error('Authentication required');
|
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(
|
Logger.info(
|
||||||
{
|
{
|
||||||
planId,
|
planId,
|
||||||
|
productId,
|
||||||
},
|
},
|
||||||
`Creating checkout session for plan ID`,
|
`Creating checkout session for plan ID`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const service = await getBillingGatewayProvider(client);
|
const service = await getBillingGatewayProvider(client);
|
||||||
const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
|
|
||||||
|
|
||||||
if (!productPlanPairFromId) {
|
|
||||||
throw new Error('Product not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// in the case of personal accounts
|
// in the case of personal accounts
|
||||||
// the account ID is the same as the user ID
|
// 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)
|
// (eg. if the account has been billed before)
|
||||||
const customerId = await getCustomerIdFromAccountId(accountId);
|
const customerId = await getCustomerIdFromAccountId(accountId);
|
||||||
|
|
||||||
// retrieve the product and plan from the billing configuration
|
const product = billingConfig.products.find((item) => item.id === productId);
|
||||||
const { product, plan } = productPlanPairFromId;
|
|
||||||
|
if (!product) {
|
||||||
|
throw new Error('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lineItems, trialDays } = getLineItemsFromPlanId(product, planId);
|
||||||
|
|
||||||
// call the payment gateway to create the checkout session
|
// call the payment gateway to create the checkout session
|
||||||
const { checkoutToken } = await service.createCheckoutSession({
|
const { checkoutToken } = await service.createCheckoutSession({
|
||||||
paymentType: product.paymentType,
|
lineItems,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
accountId,
|
accountId,
|
||||||
planId,
|
trialDays,
|
||||||
trialPeriodDays: plan.trialPeriodDays,
|
paymentType: product.paymentType,
|
||||||
customerEmail: data.user.email,
|
customerEmail: data.user.email,
|
||||||
customerId,
|
customerId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,14 +45,15 @@ export function TeamAccountCheckoutForm(params: { accountId: string }) {
|
|||||||
<PlanPicker
|
<PlanPicker
|
||||||
pending={pending}
|
pending={pending}
|
||||||
config={billingConfig}
|
config={billingConfig}
|
||||||
onSubmit={({ planId }) => {
|
onSubmit={({ planId, productId }) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const slug = routeParams.account as string;
|
const slug = routeParams.account as string;
|
||||||
|
|
||||||
const { checkoutToken } = await createTeamAccountCheckoutSession({
|
const { checkoutToken } = await createTeamAccountCheckoutSession({
|
||||||
planId,
|
planId,
|
||||||
accountId: params.accountId,
|
productId,
|
||||||
slug,
|
slug,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCheckoutToken(checkoutToken);
|
setCheckoutToken(checkoutToken);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getProductPlanPairFromId } from '@kit/billing';
|
import { getLineItemsFromPlanId } from '@kit/billing';
|
||||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||||
import { requireAuth } from '@kit/supabase/require-auth';
|
import { requireAuth } from '@kit/supabase/require-auth';
|
||||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
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.
|
* @param {string} params.planId - The ID of the plan to be associated with the account.
|
||||||
*/
|
*/
|
||||||
export async function createTeamAccountCheckoutSession(params: {
|
export async function createTeamAccountCheckoutSession(params: {
|
||||||
|
productId: string;
|
||||||
planId: string;
|
planId: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -29,6 +30,7 @@ export async function createTeamAccountCheckoutSession(params: {
|
|||||||
// we parse the plan ID from the parameters
|
// we parse the plan ID from the parameters
|
||||||
// no need in continuing if the plan ID is not valid
|
// no need in continuing if the plan ID is not valid
|
||||||
const planId = z.string().min(1).parse(params.planId);
|
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
|
// we require the user to be authenticated
|
||||||
const { data: session } = await requireAuth(client);
|
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
|
// here we have confirmed that the user has permission to manage billing for the account
|
||||||
// so we go on and create a checkout session
|
// so we go on and create a checkout session
|
||||||
const service = await getBillingGatewayProvider(client);
|
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');
|
throw new Error('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// the return URL for the checkout session
|
const { lineItems, trialDays } = getLineItemsFromPlanId(product, planId);
|
||||||
const returnUrl = getCheckoutSessionReturnUrl(params.slug);
|
|
||||||
|
|
||||||
// find the customer ID for the account if it exists
|
// find the customer ID for the account if it exists
|
||||||
// (eg. if the account has been billed before)
|
// (eg. if the account has been billed before)
|
||||||
const customerId = await getCustomerIdFromAccountId(client, accountId);
|
const customerId = await getCustomerIdFromAccountId(client, accountId);
|
||||||
const customerEmail = session.user.email;
|
const customerEmail = session.user.email;
|
||||||
|
|
||||||
// retrieve the product and plan from the billing configuration
|
// the return URL for the checkout session
|
||||||
const { product, plan } = productPlanPairFromId;
|
const returnUrl = getCheckoutSessionReturnUrl(params.slug);
|
||||||
|
|
||||||
// call the payment gateway to create the checkout session
|
// call the payment gateway to create the checkout session
|
||||||
const { checkoutToken } = await service.createCheckoutSession({
|
const { checkoutToken } = await service.createCheckoutSession({
|
||||||
accountId,
|
accountId,
|
||||||
|
lineItems,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
planId,
|
|
||||||
customerEmail,
|
customerEmail,
|
||||||
customerId,
|
customerId,
|
||||||
|
trialDays,
|
||||||
paymentType: product.paymentType,
|
paymentType: product.paymentType,
|
||||||
trialPeriodDays: plan.trialPeriodDays,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// return the checkout token to the client
|
// return the checkout token to the client
|
||||||
|
|||||||
@@ -8,22 +8,24 @@ export default createBillingSchema({
|
|||||||
name: 'Starter',
|
name: 'Starter',
|
||||||
description: 'The perfect plan to get started',
|
description: 'The perfect plan to get started',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
paymentType: 'recurring',
|
|
||||||
badge: `Value`,
|
badge: `Value`,
|
||||||
|
paymentType: 'recurring',
|
||||||
plans: [
|
plans: [
|
||||||
{
|
{
|
||||||
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
|
|
||||||
name: 'Starter Monthly',
|
name: 'Starter Monthly',
|
||||||
price: '9.99',
|
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
|
||||||
interval: 'month',
|
price: 9.99,
|
||||||
perSeat: false,
|
recurring: {
|
||||||
|
interval: 'month',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'starter-yearly',
|
|
||||||
name: 'Starter Yearly',
|
name: 'Starter Yearly',
|
||||||
price: '99.99',
|
id: 'starter-yearly',
|
||||||
interval: 'year',
|
price: 99.99,
|
||||||
perSeat: false,
|
recurring: {
|
||||||
|
interval: 'year',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
features: ['Feature 1', 'Feature 2', 'Feature 3'],
|
features: ['Feature 1', 'Feature 2', 'Feature 3'],
|
||||||
@@ -34,22 +36,24 @@ export default createBillingSchema({
|
|||||||
badge: `Popular`,
|
badge: `Popular`,
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
description: 'The perfect plan for professionals',
|
description: 'The perfect plan for professionals',
|
||||||
paymentType: 'recurring',
|
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
|
paymentType: 'recurring',
|
||||||
plans: [
|
plans: [
|
||||||
{
|
{
|
||||||
id: 'pro-monthly',
|
|
||||||
name: 'Pro Monthly',
|
name: 'Pro Monthly',
|
||||||
price: '19.99',
|
id: 'pro-monthly',
|
||||||
interval: 'month',
|
price: 19.99,
|
||||||
perSeat: false,
|
recurring: {
|
||||||
|
interval: 'month',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pro-yearly',
|
|
||||||
name: 'Pro Yearly',
|
name: 'Pro Yearly',
|
||||||
price: '199.99',
|
id: 'pro-yearly',
|
||||||
interval: 'year',
|
price: 199.99,
|
||||||
perSeat: false,
|
recurring: {
|
||||||
|
interval: 'year',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
features: [
|
features: [
|
||||||
@@ -64,22 +68,24 @@ export default createBillingSchema({
|
|||||||
id: 'enterprise',
|
id: 'enterprise',
|
||||||
name: 'Enterprise',
|
name: 'Enterprise',
|
||||||
description: 'The perfect plan for enterprises',
|
description: 'The perfect plan for enterprises',
|
||||||
paymentType: 'recurring',
|
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
|
paymentType: 'recurring',
|
||||||
plans: [
|
plans: [
|
||||||
{
|
{
|
||||||
id: 'enterprise-monthly',
|
|
||||||
name: 'Enterprise Monthly',
|
name: 'Enterprise Monthly',
|
||||||
price: '99.99',
|
id: 'enterprise-monthly',
|
||||||
interval: 'month',
|
price: 99.99,
|
||||||
perSeat: false,
|
recurring: {
|
||||||
|
interval: 'month',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'enterprise-yearly',
|
|
||||||
name: 'Enterprise Yearly',
|
name: 'Enterprise Yearly',
|
||||||
price: '999.99',
|
id: 'enterprise-yearly',
|
||||||
interval: 'year',
|
price: 999.99,
|
||||||
perSeat: false,
|
recurring: {
|
||||||
|
interval: 'year',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
features: [
|
features: [
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function buildLazyComponent<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={fallback}>
|
<Suspense fallback={fallback}>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-expect-error */}
|
||||||
<LoadedComponent
|
<LoadedComponent
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
checkoutToken={props.checkoutToken}
|
checkoutToken={props.checkoutToken}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { BillingSchema } from '@kit/billing';
|
import {
|
||||||
|
BillingSchema,
|
||||||
|
RecurringPlanSchema,
|
||||||
|
getPlanIntervals,
|
||||||
|
getProductPlanPairFromId,
|
||||||
|
} from '@kit/billing';
|
||||||
import { formatCurrency } from '@kit/shared/utils';
|
import { formatCurrency } from '@kit/shared/utils';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -28,15 +35,14 @@ import { cn } from '@kit/ui/utils';
|
|||||||
export function PlanPicker(
|
export function PlanPicker(
|
||||||
props: React.PropsWithChildren<{
|
props: React.PropsWithChildren<{
|
||||||
config: z.infer<typeof BillingSchema>;
|
config: z.infer<typeof BillingSchema>;
|
||||||
onSubmit: (data: { planId: string }) => void;
|
onSubmit: (data: { planId: string; productId: string }) => void;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
const intervals = props.config.products.reduce<string[]>((acc, item) => {
|
const intervals = useMemo(
|
||||||
return Array.from(
|
() => getPlanIntervals(props.config),
|
||||||
new Set([...acc, ...item.plans.map((plan) => plan.interval)]),
|
[props.config],
|
||||||
);
|
);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
@@ -44,20 +50,17 @@ export function PlanPicker(
|
|||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
planId: z.string(),
|
planId: z.string().min(1),
|
||||||
interval: z.string(),
|
interval: z.string().min(1),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
const planFound = props.config.products
|
const { product, plan } = getProductPlanPairFromId(
|
||||||
.flatMap((item) => item.plans)
|
props.config,
|
||||||
.some((plan) => plan.id === data.planId);
|
data.planId,
|
||||||
|
);
|
||||||
|
|
||||||
if (!planFound) {
|
return product && plan;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return intervals.includes(data.interval);
|
|
||||||
},
|
},
|
||||||
{ message: `Please pick a plan to continue`, path: ['planId'] },
|
{ message: `Please pick a plan to continue`, path: ['planId'] },
|
||||||
),
|
),
|
||||||
@@ -65,6 +68,7 @@ export function PlanPicker(
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
interval: intervals[0],
|
interval: intervals[0],
|
||||||
planId: '',
|
planId: '',
|
||||||
|
productId: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,9 +85,11 @@ export function PlanPicker(
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem className={'rounded-md border p-4'}>
|
<FormItem className={'rounded-md border p-4'}>
|
||||||
<FormLabel>Choose your billing interval</FormLabel>
|
<FormLabel htmlFor={'plan-picker-id'}>
|
||||||
|
Choose your billing interval
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl id={'plan-picker-id'}>
|
||||||
<RadioGroup name={field.name} value={field.value}>
|
<RadioGroup name={field.name} value={field.value}>
|
||||||
<div className={'flex space-x-2.5'}>
|
<div className={'flex space-x-2.5'}>
|
||||||
{intervals.map((interval) => {
|
{intervals.map((interval) => {
|
||||||
@@ -91,6 +97,7 @@ export function PlanPicker(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
|
htmlFor={interval}
|
||||||
key={interval}
|
key={interval}
|
||||||
className={cn(
|
className={cn(
|
||||||
'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2',
|
'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2',
|
||||||
@@ -107,6 +114,7 @@ export function PlanPicker(
|
|||||||
form.setValue('planId', '', {
|
form.setValue('planId', '', {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
form.setValue('interval', interval, {
|
form.setValue('interval', interval, {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
});
|
});
|
||||||
@@ -138,25 +146,38 @@ export function PlanPicker(
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RadioGroup name={field.name}>
|
<RadioGroup name={field.name}>
|
||||||
{props.config.products.map((item) => {
|
{props.config.products.map((product) => {
|
||||||
const variant = item.plans.find(
|
const plan =
|
||||||
(plan) => plan.interval === selectedInterval,
|
product.paymentType === 'one-time'
|
||||||
);
|
? product.plans[0]
|
||||||
|
: product.plans.find((item) => {
|
||||||
|
if (
|
||||||
|
'recurring' in item &&
|
||||||
|
(item as z.infer<typeof RecurringPlanSchema>)
|
||||||
|
.recurring.interval === selectedInterval
|
||||||
|
) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!variant) {
|
if (!plan) {
|
||||||
throw new Error('No plan found');
|
throw new Error('Plan not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RadioGroupItemLabel
|
<RadioGroupItemLabel
|
||||||
selected={field.value === variant.id}
|
selected={field.value === plan.id}
|
||||||
key={variant.id}
|
key={plan.id}
|
||||||
>
|
>
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
id={variant.id}
|
id={plan.id}
|
||||||
value={variant.id}
|
value={plan.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
form.setValue('planId', variant.id, {
|
form.setValue('planId', plan.id, {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
form.setValue('productId', product.id, {
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -166,23 +187,23 @@ export function PlanPicker(
|
|||||||
className={'flex w-full items-center justify-between'}
|
className={'flex w-full items-center justify-between'}
|
||||||
>
|
>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={variant.id}
|
htmlFor={plan.id}
|
||||||
className={'flex flex-col justify-center space-y-2'}
|
className={'flex flex-col justify-center space-y-2'}
|
||||||
>
|
>
|
||||||
<span className="font-bold">{item.name}</span>
|
<span className="font-bold">{product.name}</span>
|
||||||
|
|
||||||
<span className={'text-muted-foreground'}>
|
<span className={'text-muted-foreground'}>
|
||||||
{item.description}
|
{product.description}
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div className={'text-right'}>
|
<div className={'text-right'}>
|
||||||
<div>
|
<div>
|
||||||
<Price key={variant.id}>
|
<Price key={plan.id}>
|
||||||
<span>
|
<span>
|
||||||
{formatCurrency(
|
{formatCurrency(
|
||||||
item.currency.toLowerCase(),
|
product.currency.toLowerCase(),
|
||||||
variant.price,
|
plan.price,
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Price>
|
</Price>
|
||||||
@@ -190,7 +211,7 @@ export function PlanPicker(
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className={'text-muted-foreground'}>
|
<span className={'text-muted-foreground'}>
|
||||||
per {variant.interval}
|
per {selectedInterval}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +228,7 @@ export function PlanPicker(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button disabled={props.pending || !form.formState.isValid}>
|
<Button disabled={props.pending ?? !form.formState.isValid}>
|
||||||
{props.pending ? (
|
{props.pending ? (
|
||||||
'Processing...'
|
'Processing...'
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import Link from 'next/link';
|
|||||||
import { CheckCircle, Sparkles } from 'lucide-react';
|
import { CheckCircle, Sparkles } from 'lucide-react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { BillingSchema, getPlanIntervals } from '@kit/billing';
|
import {
|
||||||
|
BillingSchema,
|
||||||
|
RecurringPlanInterval,
|
||||||
|
RecurringPlanSchema,
|
||||||
|
getPlanIntervals,
|
||||||
|
} from '@kit/billing';
|
||||||
|
import { formatCurrency } from '@kit/shared/utils';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Heading } from '@kit/ui/heading';
|
import { Heading } from '@kit/ui/heading';
|
||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
@@ -15,6 +21,7 @@ import { Trans } from '@kit/ui/trans';
|
|||||||
import { cn } from '@kit/ui/utils';
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
type Config = z.infer<typeof BillingSchema>;
|
type Config = z.infer<typeof BillingSchema>;
|
||||||
|
type Interval = z.infer<typeof RecurringPlanInterval>;
|
||||||
|
|
||||||
interface Paths {
|
interface Paths {
|
||||||
signUp: string;
|
signUp: string;
|
||||||
@@ -33,20 +40,20 @@ export function PricingTable({
|
|||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const intervals = getPlanIntervals(config);
|
const intervals = getPlanIntervals(config).filter(Boolean);
|
||||||
|
|
||||||
const [planVariant, setPlanVariant] = useState<string>(
|
const [interval, setInterval] = useState<Interval>(intervals[0]!);
|
||||||
intervals[0] as string,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex flex-col space-y-12'}>
|
<div className={'flex flex-col space-y-12'}>
|
||||||
<div className={'flex justify-center'}>
|
<div className={'flex justify-center'}>
|
||||||
<PlanIntervalSwitcher
|
{intervals.length ? (
|
||||||
intervals={intervals}
|
<PlanIntervalSwitcher
|
||||||
interval={planVariant}
|
intervals={intervals}
|
||||||
setInterval={setPlanVariant}
|
interval={interval}
|
||||||
/>
|
setInterval={setInterval}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -56,21 +63,28 @@ export function PricingTable({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{config.products.map((product) => {
|
{config.products.map((product) => {
|
||||||
const plan = product.plans.find(
|
const plan = product.plans.find((item) =>
|
||||||
(item) => item.interval === planVariant,
|
'recurring' in item
|
||||||
|
? (item as z.infer<typeof RecurringPlanSchema>).recurring
|
||||||
|
.interval === interval
|
||||||
|
: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!plan || product.hidden) {
|
if (!plan) {
|
||||||
console.warn(`No plan found for ${product.name}`);
|
console.warn(`No plan found for ${product.name}`);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (product.hidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PricingItem
|
<PricingItem
|
||||||
selectable
|
selectable
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
plan={plan}
|
plan={{ ...plan, interval }}
|
||||||
product={product}
|
product={product}
|
||||||
paths={paths}
|
paths={paths}
|
||||||
CheckoutButton={CheckoutButtonRenderer}
|
CheckoutButton={CheckoutButtonRenderer}
|
||||||
@@ -92,7 +106,7 @@ function PricingItem(
|
|||||||
|
|
||||||
plan: {
|
plan: {
|
||||||
id: string;
|
id: string;
|
||||||
price: string;
|
price: number;
|
||||||
interval: string;
|
interval: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
@@ -158,8 +172,7 @@ function PricingItem(
|
|||||||
|
|
||||||
<div className={'flex items-center space-x-1'}>
|
<div className={'flex items-center space-x-1'}>
|
||||||
<Price>
|
<Price>
|
||||||
<span className={'text-base'}>{props.product.currency}</span>
|
{formatCurrency(props.product.currency, props.plan.price)}
|
||||||
{props.plan.price}
|
|
||||||
</Price>
|
</Price>
|
||||||
|
|
||||||
<If condition={props.plan.name}>
|
<If condition={props.plan.name}>
|
||||||
@@ -249,9 +262,9 @@ function ListItem({ children }: React.PropsWithChildren) {
|
|||||||
|
|
||||||
function PlanIntervalSwitcher(
|
function PlanIntervalSwitcher(
|
||||||
props: React.PropsWithChildren<{
|
props: React.PropsWithChildren<{
|
||||||
intervals: string[];
|
intervals: Interval[];
|
||||||
interval: string;
|
interval: Interval;
|
||||||
setInterval: (interval: string) => void;
|
setInterval: (interval: Interval) => void;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export class BillingEventHandlerService {
|
|||||||
|
|
||||||
Logger.info(ctx, 'Processing checkout session completed event...');
|
Logger.info(ctx, 'Processing checkout session completed event...');
|
||||||
|
|
||||||
const { id, ...data } = subscription;
|
const { id: _, ...data } = subscription;
|
||||||
|
|
||||||
const { error } = await client.rpc('add_subscription', {
|
const { error } = await client.rpc('add_subscription', {
|
||||||
...data,
|
...data,
|
||||||
|
|||||||
@@ -1,96 +1,221 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const Interval = z.enum(['month', 'year']);
|
export const RecurringPlanInterval = z.enum(['month', 'year']);
|
||||||
const PaymentType = z.enum(['recurring', 'one-time']);
|
|
||||||
|
|
||||||
export const BillingProvider = z.enum(['stripe', 'paddle', 'lemon-squeezy']);
|
export const BillingProvider = z.enum(['stripe', 'paddle', 'lemon-squeezy']);
|
||||||
|
|
||||||
const PlanSchema = z.object({
|
export const PaymentType = z.enum(['recurring', 'one-time']);
|
||||||
id: z.string().min(1),
|
|
||||||
|
export const LineItemUsageType = z.enum(['licensed', 'metered']);
|
||||||
|
|
||||||
|
const RecurringLineItemSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
interval: RecurringPlanInterval,
|
||||||
|
metered: z.boolean().optional().default(false),
|
||||||
|
costPerUnit: z.number().positive().optional(),
|
||||||
|
perSeat: z.boolean().default(false).optional().default(false),
|
||||||
|
usageType: LineItemUsageType.optional().default('licensed'),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
if (!schema.metered && schema.perSeat) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Line item must be either metered or a member seat',
|
||||||
|
path: ['metered', 'perSeat'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
if (!schema.metered && !schema.usageType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Line item must have a usage type',
|
||||||
|
path: ['usageType'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const RecurringSchema = z
|
||||||
|
.object({
|
||||||
|
interval: RecurringPlanInterval,
|
||||||
|
metered: z.boolean().optional(),
|
||||||
|
costPerUnit: z.number().positive().optional(),
|
||||||
|
perSeat: z.boolean().optional(),
|
||||||
|
usageType: LineItemUsageType.optional(),
|
||||||
|
addOns: z.array(RecurringLineItemSchema).default([]).optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
if (schema.metered) {
|
||||||
|
return schema.costPerUnit !== undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Metered plans must have a cost per unit',
|
||||||
|
path: ['costPerUnit'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
if (schema.perSeat && !schema.metered) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Per seat plans must be metered',
|
||||||
|
path: ['perSeat'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
return schema.metered && schema.usageType;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Metered plans must have a usage type',
|
||||||
|
path: ['usageType'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RecurringPlanSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
price: z.string().min(1).max(100),
|
id: z.string().min(1),
|
||||||
trialPeriodDays: z.number().optional(),
|
price: z.number().positive(),
|
||||||
interval: Interval,
|
trialDays: z.number().positive().optional(),
|
||||||
perSeat: z.boolean().optional().default(false),
|
recurring: RecurringSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ProductSchema = z.object({
|
export const OneTimePlanSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string().min(1),
|
||||||
name: z.string(),
|
name: z.string().min(1).max(100),
|
||||||
description: z.string(),
|
price: z.number().positive(),
|
||||||
currency: z.string().optional().default('USD'),
|
|
||||||
plans: z.array(PlanSchema),
|
|
||||||
features: z.array(z.string()),
|
|
||||||
badge: z.string().optional(),
|
|
||||||
highlighted: z.boolean().optional(),
|
|
||||||
hidden: z.boolean().optional(),
|
|
||||||
paymentType: PaymentType.optional().default('recurring'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProductSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
currency: z.string().optional().default('USD'),
|
||||||
|
plans: z.union([
|
||||||
|
RecurringPlanSchema.array().nonempty(),
|
||||||
|
OneTimePlanSchema.array().nonempty(),
|
||||||
|
]),
|
||||||
|
paymentType: PaymentType,
|
||||||
|
features: z.array(z.string()),
|
||||||
|
badge: z.string().min(1).optional(),
|
||||||
|
highlighted: z.boolean().default(false).optional(),
|
||||||
|
hidden: z.boolean().default(false).optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
console.log(schema);
|
||||||
|
|
||||||
|
const recurringPlans = schema.plans.filter((plan) => 'recurring' in plan);
|
||||||
|
|
||||||
|
if (recurringPlans.length && schema.paymentType === 'one-time') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'One-time products cannot have recurring plans',
|
||||||
|
path: ['paymentType'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
const recurringPlans = schema.plans.filter((plan) => 'recurring' in plan);
|
||||||
|
|
||||||
|
if (recurringPlans.length === 0 && schema.paymentType === 'recurring') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'The product must have at least one recurring plan if the payment type is recurring',
|
||||||
|
path: ['paymentType'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
return !(schema.paymentType === 'one-time' && schema.plans.length > 1);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'One-time products can only have one plan',
|
||||||
|
path: ['plans'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const BillingSchema = z
|
export const BillingSchema = z
|
||||||
.object({
|
.object({
|
||||||
products: z.array(ProductSchema),
|
products: z.array(ProductSchema).nonempty(),
|
||||||
provider: BillingProvider,
|
provider: BillingProvider,
|
||||||
})
|
})
|
||||||
.refine((schema) => {
|
.refine(
|
||||||
// verify dupe product ids
|
(schema) => {
|
||||||
const ids = schema.products.map((product) => product.id);
|
const ids = schema.products.map((product) => product.id);
|
||||||
|
|
||||||
if (new Set(ids).size !== ids.length) {
|
return new Set(ids).size === ids.length;
|
||||||
return {
|
},
|
||||||
message: 'Duplicate product IDs',
|
{
|
||||||
path: ['products'],
|
message: 'Duplicate product IDs',
|
||||||
};
|
path: ['products'],
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
const planIds = getAllPlanIds(schema);
|
||||||
|
|
||||||
return true;
|
return new Set(planIds).size === planIds.length;
|
||||||
})
|
},
|
||||||
.refine((schema) => {
|
{
|
||||||
// verify dupe plan ids
|
message: 'Duplicate plan IDs',
|
||||||
const planIds = schema.products.flatMap((product) =>
|
path: ['products'],
|
||||||
product.plans.map((plan) => plan.id),
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (new Set(planIds).size !== planIds.length) {
|
|
||||||
return {
|
|
||||||
message: 'Duplicate plan IDs',
|
|
||||||
path: ['products'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create and validate the billing schema
|
* Create and validate the billing schema
|
||||||
* @param config
|
* @param config The billing configuration
|
||||||
*/
|
*/
|
||||||
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||||
return BillingSchema.parse(config);
|
return BillingSchema.parse(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an array of billing plans based on the provided configuration.
|
|
||||||
*
|
|
||||||
* @param {Object} config - The configuration object containing product and plan information.
|
|
||||||
*/
|
|
||||||
export function getBillingPlans(config: z.infer<typeof BillingSchema>) {
|
|
||||||
return config.products.flatMap((product) => product.plans);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the intervals of all plans specified in the given configuration.
|
* Retrieves the intervals of all plans specified in the given configuration.
|
||||||
*
|
* @param config The billing configuration containing products and plans.
|
||||||
* @param {Object} config - The billing configuration containing products and plans.
|
|
||||||
*/
|
*/
|
||||||
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
config.products.flatMap((product) =>
|
config.products.flatMap((product) => {
|
||||||
product.plans.map((plan) => plan.interval),
|
const isRecurring = product.paymentType === 'recurring';
|
||||||
),
|
|
||||||
|
if (isRecurring) {
|
||||||
|
const plans = product.plans as z.infer<typeof RecurringPlanSchema>[];
|
||||||
|
|
||||||
|
return plans.map((plan) => plan.recurring.interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProductPlanPairFromId(
|
export function getProductPlanPairFromId(
|
||||||
@@ -98,12 +223,30 @@ export function getProductPlanPairFromId(
|
|||||||
planId: string,
|
planId: string,
|
||||||
) {
|
) {
|
||||||
for (const product of config.products) {
|
for (const product of config.products) {
|
||||||
const plan = product.plans.find((plan) => plan.id === planId);
|
for (const plan of product.plans) {
|
||||||
|
if (plan.id === planId) {
|
||||||
if (plan) {
|
return { product, plan };
|
||||||
return { product, plan };
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Plan with ID ${planId} not found`);
|
throw new Error('Plan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllPlanIds(config: z.infer<typeof BillingSchema>) {
|
||||||
|
const ids: string[] = [];
|
||||||
|
|
||||||
|
for (const product of config.products) {
|
||||||
|
for (const plan of product.plans) {
|
||||||
|
ids.push(plan.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecurringPlan(
|
||||||
|
plan: z.infer<typeof RecurringPlanSchema | typeof OneTimePlanSchema>,
|
||||||
|
): plan is z.infer<typeof RecurringPlanSchema> {
|
||||||
|
return 'recurring' in plan;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './create-billing-schema';
|
export * from './create-billing-schema';
|
||||||
export * from './services/billing-strategy-provider.service';
|
export * from './services/billing-strategy-provider.service';
|
||||||
export * from './services/billing-webhook-handler.service';
|
export * from './services/billing-webhook-handler.service';
|
||||||
|
export * from './line-items-mapper';
|
||||||
|
|||||||
51
packages/billing/src/line-items-mapper.ts
Normal file
51
packages/billing/src/line-items-mapper.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ProductSchema, isRecurringPlan } from './create-billing-schema';
|
||||||
|
|
||||||
|
export function getLineItemsFromPlanId(
|
||||||
|
product: z.infer<typeof ProductSchema>,
|
||||||
|
planId: string,
|
||||||
|
) {
|
||||||
|
const plan = product.plans.find((plan) => plan.id === planId);
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
throw new Error('Plan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineItems = [];
|
||||||
|
|
||||||
|
let trialDays = undefined;
|
||||||
|
|
||||||
|
if (isRecurringPlan(plan)) {
|
||||||
|
const lineItem: {
|
||||||
|
id: string;
|
||||||
|
quantity: number;
|
||||||
|
usageType?: 'metered' | 'licensed';
|
||||||
|
} = {
|
||||||
|
id: plan.id,
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
trialDays = plan.trialDays;
|
||||||
|
|
||||||
|
if (plan.recurring.usageType) {
|
||||||
|
lineItem.usageType = plan.recurring.usageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
lineItems.push(lineItem);
|
||||||
|
|
||||||
|
if (plan.recurring.addOns) {
|
||||||
|
for (const addOn of plan.recurring.addOns) {
|
||||||
|
lineItems.push({
|
||||||
|
id: addOn.id,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineItems,
|
||||||
|
trialDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,31 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const CreateBillingCheckoutSchema = z.object({
|
import { LineItemUsageType, PaymentType } from '../create-billing-schema';
|
||||||
returnUrl: z.string().url(),
|
|
||||||
accountId: z.string(),
|
|
||||||
planId: z.string(),
|
|
||||||
paymentType: z.enum(['recurring', 'one-time']),
|
|
||||||
|
|
||||||
trialPeriodDays: z.number().optional(),
|
export const CreateBillingCheckoutSchema = z
|
||||||
|
.object({
|
||||||
customerId: z.string().optional(),
|
returnUrl: z.string().url(),
|
||||||
customerEmail: z.string().optional(),
|
accountId: z.string(),
|
||||||
});
|
paymentType: PaymentType,
|
||||||
|
lineItems: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
quantity: z.number().int().positive(),
|
||||||
|
usageType: LineItemUsageType.optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
trialDays: z.number().optional(),
|
||||||
|
customerId: z.string().optional(),
|
||||||
|
customerEmail: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
if (schema.paymentType === 'one-time' && schema.trialDays) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Trial days are only allowed for recurring payments',
|
||||||
|
path: ['trialDays'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"./hooks/*": "./src/hooks/*.ts"
|
"./hooks/*": "./src/hooks/*.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@kit/billing-gateway": "*",
|
||||||
"@kit/eslint-config": "0.2.0",
|
"@kit/eslint-config": "0.2.0",
|
||||||
"@kit/prettier-config": "0.1.0",
|
"@kit/prettier-config": "0.1.0",
|
||||||
"@kit/shared": "*",
|
"@kit/shared": "*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { RedirectType, redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { Logger } from '@kit/shared/logger';
|
import { Logger } from '@kit/shared/logger';
|
||||||
import { requireAuth } from '@kit/supabase/require-auth';
|
import { requireAuth } from '@kit/supabase/require-auth';
|
||||||
@@ -33,28 +33,13 @@ export async function deletePersonalAccountAction(formData: FormData) {
|
|||||||
`Deleting personal account...`,
|
`Deleting personal account...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAccountResponse = await service.deletePersonalAccount(
|
await service.deletePersonalAccount(
|
||||||
getSupabaseServerActionClient({ admin: true }),
|
getSupabaseServerActionClient({ admin: true }),
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
//
|
|
||||||
// also delete any associated data and subscriptions
|
|
||||||
|
|
||||||
if (deleteAccountResponse.error) {
|
|
||||||
Logger.error(
|
|
||||||
{
|
|
||||||
error: deleteAccountResponse.error,
|
|
||||||
name: 'accounts',
|
|
||||||
},
|
|
||||||
`Error deleting personal account`,
|
|
||||||
);
|
|
||||||
|
|
||||||
throw new Error('Error deleting personal account');
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info(
|
Logger.info(
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
@@ -65,5 +50,5 @@ export async function deletePersonalAccountAction(formData: FormData) {
|
|||||||
|
|
||||||
await client.auth.signOut();
|
await client.auth.signOut();
|
||||||
|
|
||||||
redirect('/');
|
redirect('/', RedirectType.replace);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { SupabaseClient } from '@supabase/supabase-js';
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { BillingGatewayService } from '@kit/billing-gateway';
|
||||||
|
import { Logger } from '@kit/shared/logger';
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,12 +13,95 @@ import { Database } from '@kit/supabase/database';
|
|||||||
* const accountsService = new AccountsService(client);
|
* const accountsService = new AccountsService(client);
|
||||||
*/
|
*/
|
||||||
export class PersonalAccountsService {
|
export class PersonalAccountsService {
|
||||||
|
private namespace = 'account';
|
||||||
|
|
||||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name deletePersonalAccount
|
||||||
|
* Delete personal account of a user.
|
||||||
|
* This will delete the user from the authentication provider and cancel all subscriptions.
|
||||||
|
*/
|
||||||
async deletePersonalAccount(
|
async deletePersonalAccount(
|
||||||
adminClient: SupabaseClient<Database>,
|
adminClient: SupabaseClient<Database>,
|
||||||
params: { userId: string },
|
params: { userId: string },
|
||||||
) {
|
) {
|
||||||
return adminClient.auth.admin.deleteUser(params.userId);
|
Logger.info(
|
||||||
|
{ userId: params.userId, name: this.namespace },
|
||||||
|
'User requested deletion. Processing...',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminClient.auth.admin.deleteUser(params.userId);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
{
|
||||||
|
userId: params.userId,
|
||||||
|
error,
|
||||||
|
name: this.namespace,
|
||||||
|
},
|
||||||
|
'Error deleting user',
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error('Error deleting user');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.cancelAllUserSubscriptions(params.userId);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error({
|
||||||
|
userId: params.userId,
|
||||||
|
error,
|
||||||
|
name: this.namespace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cancelAllUserSubscriptions(userId: string) {
|
||||||
|
Logger.info(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
name: this.namespace,
|
||||||
|
},
|
||||||
|
'Cancelling all subscriptions for user...',
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: subscriptions } = await this.client
|
||||||
|
.from('subscriptions')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', userId);
|
||||||
|
|
||||||
|
const cancellationRequests = [];
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
subscriptions: subscriptions?.length ?? 0,
|
||||||
|
name: this.namespace,
|
||||||
|
},
|
||||||
|
'Cancelling subscriptions...',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const subscription of subscriptions ?? []) {
|
||||||
|
const gateway = new BillingGatewayService(subscription.billing_provider);
|
||||||
|
|
||||||
|
cancellationRequests.push(
|
||||||
|
gateway.cancelSubscription({
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
invoiceNow: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(cancellationRequests);
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
subscriptions: subscriptions?.length ?? 0,
|
||||||
|
name: this.namespace,
|
||||||
|
},
|
||||||
|
'Subscriptions cancelled successfully',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||||
|
|
||||||
import { DeleteTeamAccountSchema } from '../schema/delete-team-account.schema';
|
import { DeleteTeamAccountSchema } from '../schema/delete-team-account.schema';
|
||||||
import { DeleteAccountService } from '../services/delete-account.service';
|
import { DeleteTeamAccountService } from '../services/delete-team-account.service';
|
||||||
|
|
||||||
export async function deleteTeamAccountAction(formData: FormData) {
|
export async function deleteTeamAccountAction(formData: FormData) {
|
||||||
const body = Object.fromEntries(formData.entries());
|
const body = Object.fromEntries(formData.entries());
|
||||||
const params = DeleteTeamAccountSchema.parse(body);
|
const params = DeleteTeamAccountSchema.parse(body);
|
||||||
const client = getSupabaseServerActionClient();
|
const client = getSupabaseServerActionClient();
|
||||||
const service = new DeleteAccountService(client);
|
const service = new DeleteTeamAccountService(client);
|
||||||
|
|
||||||
await service.deleteTeamAccount(params);
|
await service.deleteTeamAccount(params);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'server-only';
|
|||||||
|
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
export class DeleteAccountService {
|
export class DeleteTeamAccountService {
|
||||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
async deleteTeamAccount(params: { accountId: string }) {
|
async deleteTeamAccount(params: { accountId: string }) {
|
||||||
@@ -12,6 +12,7 @@ type Config = z.infer<typeof MailerSchema>;
|
|||||||
*/
|
*/
|
||||||
export class CloudflareMailer implements Mailer {
|
export class CloudflareMailer implements Mailer {
|
||||||
async sendEmail(config: Config) {
|
async sendEmail(config: Config) {
|
||||||
|
console.log('Sending email with Cloudflare Workers', config);
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,19 +26,18 @@ export async function createStripeCheckout(
|
|||||||
const mode: Stripe.Checkout.SessionCreateParams.Mode =
|
const mode: Stripe.Checkout.SessionCreateParams.Mode =
|
||||||
params.paymentType === 'recurring' ? 'subscription' : 'payment';
|
params.paymentType === 'recurring' ? 'subscription' : 'payment';
|
||||||
|
|
||||||
// TODO: support multiple line items and per-seat pricing
|
// this should only be set if the mode is 'subscription'
|
||||||
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
|
const subscriptionData:
|
||||||
quantity: 1,
|
| Stripe.Checkout.SessionCreateParams.SubscriptionData
|
||||||
price: params.planId,
|
| undefined =
|
||||||
};
|
mode === 'subscription'
|
||||||
|
? {
|
||||||
const subscriptionData: Stripe.Checkout.SessionCreateParams.SubscriptionData =
|
trial_period_days: params.trialDays,
|
||||||
{
|
metadata: {
|
||||||
trial_period_days: params.trialPeriodDays,
|
accountId: params.accountId,
|
||||||
metadata: {
|
},
|
||||||
accountId: params.accountId,
|
}
|
||||||
},
|
: undefined;
|
||||||
};
|
|
||||||
|
|
||||||
const urls = getUrls({
|
const urls = getUrls({
|
||||||
returnUrl: params.returnUrl,
|
returnUrl: params.returnUrl,
|
||||||
@@ -55,10 +54,23 @@ export async function createStripeCheckout(
|
|||||||
customer_email: params.customerEmail,
|
customer_email: params.customerEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lineItems = params.lineItems.map((item) => {
|
||||||
|
if (item.usageType === 'metered') {
|
||||||
|
return {
|
||||||
|
price: item.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: item.id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return stripe.checkout.sessions.create({
|
return stripe.checkout.sessions.create({
|
||||||
mode,
|
mode,
|
||||||
ui_mode: uiMode,
|
ui_mode: uiMode,
|
||||||
line_items: [lineItem],
|
line_items: lineItems,
|
||||||
client_reference_id: clientReferenceId,
|
client_reference_id: clientReferenceId,
|
||||||
subscription_data: subscriptionData,
|
subscription_data: subscriptionData,
|
||||||
...customerData,
|
...customerData,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { MDXComponents } from 'mdx/types';
|
|||||||
import { getMDXComponent } from 'next-contentlayer/hooks';
|
import { getMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
import Components from './mdx-components';
|
import Components from './mdx-components';
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
import styles from './mdx-renderer.module.css';
|
import styles from './mdx-renderer.module.css';
|
||||||
|
|
||||||
export function Mdx({
|
export function Mdx({
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -283,6 +283,9 @@ importers:
|
|||||||
|
|
||||||
packages/features/accounts:
|
packages/features/accounts:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@kit/billing-gateway':
|
||||||
|
specifier: '*'
|
||||||
|
version: link:../../billing-gateway
|
||||||
'@kit/eslint-config':
|
'@kit/eslint-config':
|
||||||
specifier: 0.2.0
|
specifier: 0.2.0
|
||||||
version: link:../../../tooling/eslint
|
version: link:../../../tooling/eslint
|
||||||
|
|||||||
Reference in New Issue
Block a user