Stripe allows us to add multiple line items to a single subscription. This is useful when you want to offer additional features or services to your customers.
This feature is not supported by default in Makerkit. However, in this guide, I will show you how to create a subscription with addons using Stripe Billing, and how to customize Makerkit to support this feature.
Let's get started!
1. Personal Account Checkout Form
File: apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx
Update your PersonalAccountCheckoutForm
component to pass addon data to the checkout session creation process:
<CheckoutForm
// ...existing props
onSubmit={({ planId, productId, addons }) => {
startTransition(async () => {
try {
const { checkoutToken } = await createPersonalAccountCheckoutSession({
planId,
productId,
addons, // Add this line
});
setCheckoutToken(checkoutToken);
} catch {
setError(true);
}
});
}}
/>
This change allows the checkout form to handle addon selections and pass them to the checkout session creation process.
2. Personal Account Checkout Schema
Let's add addon support to the personal account checkout schema. The addons
is an array of objects, each containing a productId
and planId
. By default, the addons
array is empty.
Update your PersonalAccountCheckoutSchema
:
export const PersonalAccountCheckoutSchema = z.object({
planId: z.string().min(1),
productId: z.string().min(1),
addons: z
.array(
z.object({
productId: z.string().min(1),
planId: z.string().min(1),
}),
)
.default([]),
});
This schema update ensures that the addon data is properly validated before being processed.
3. User Billing Service
Update your createCheckoutSession
method. This method is responsible for creating a checkout session with the billing gateway. We need to pass the addon data to the billing gateway:
async createCheckoutSession({
planId,
productId,
addons,
}: z.infer<typeof PersonalAccountCheckoutSchema>) {
// ...existing code
const checkoutToken = await this.billingGateway.createCheckoutSession({
// ...existing props
addons,
});
// ...rest of the method
}
This change ensures that the addon information is passed to the billing gateway when creating a checkout session.
4. Team Account Checkout Form
File: apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx
Make similar changes to the TeamAccountCheckoutForm
as we did for the personal account form.
5. Team Billing Schema
File: apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts
Update your TeamCheckoutSchema
similar to the personal account schema.
6. Team Billing Service
File: apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts
Update the createCheckoutSession
method similar to the user billing service.
7. Billing Configuration
We can now add addons to our billing configuration. Update your billing configuration file to include addons:
plans: [
{
// ...existing plan config
addons: [
{
id: 'price_1J4J9zL2c7J1J4J9zL2c7J1',
name: 'Extra Feature',
cost: 9.99,
type: 'flat' as const,
},
],
},
],
NB: the ID
of the addon should match the planId
in your Stripe account.
8. Localization
Add a new translation key for translating the term "Add-ons" in your billing locale file:
{
// ...existing translations
"addons": "Add-ons"
}
9. Billing Schema
File: packages/billing/core/src/create-billing-schema.ts
The billing schema has been updated to include addons. You don't need to change this file, but be aware that the schema now supports addons.
10. Create Billing Checkout Schema
File: packages/billing/core/src/schema/create-billing-checkout.schema.ts
The checkout schema now includes addons. Again, you don't need to change this file, but your checkout process will now support addons.
11. Plan Picker Component
File: packages/billing/gateway/src/components/plan-picker.tsx
This component has been significantly updated to handle addons. It now displays addons as checkboxes and manages their state.
Here's the updated Plan Picker component:
'use client';
import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRight, CheckCircle } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import {
BillingConfig,
type LineItemSchema,
getPlanIntervals,
getPrimaryLineItem,
getProductPlanPair,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Checkbox } from '@kit/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Label } from '@kit/ui/label';
import {
RadioGroup,
RadioGroupItem,
RadioGroupItemLabel,
} from '@kit/ui/radio-group';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details';
const AddonSchema = z.object({
name: z.string(),
id: z.string(),
productId: z.string(),
planId: z.string(),
cost: z.number(),
});
type OnSubmitData = {
planId: string;
productId: string;
addons: z.infer<typeof AddonSchema>[];
};
export function PlanPicker(
props: React.PropsWithChildren<{
config: BillingConfig;
onSubmit: (data: OnSubmitData) => void;
canStartTrial?: boolean;
pending?: boolean;
}>,
) {
const { t } = useTranslation(`billing`);
const intervals = useMemo(
() => getPlanIntervals(props.config),
[props.config],
) as string[];
const form = useForm({
reValidateMode: 'onChange',
mode: 'onChange',
resolver: zodResolver(
z
.object({
planId: z.string(),
productId: z.string(),
interval: z.string().optional(),
addons: z.array(AddonSchema).optional(),
})
.refine(
(data) => {
try {
const { product, plan } = getProductPlanPair(
props.config,
data.planId,
);
return product && plan;
} catch {
return false;
}
},
{ message: t('noPlanChosen'), path: ['planId'] },
),
),
defaultValues: {
interval: intervals[0],
planId: '',
productId: '',
addons: [] as z.infer<typeof AddonSchema>[],
},
});
const { interval: selectedInterval } = form.watch();
const planId = form.getValues('planId');
const { plan: selectedPlan, product: selectedProduct } = useMemo(() => {
try {
return getProductPlanPair(props.config, planId);
} catch {
return {
plan: null,
product: null,
};
}
}, [props.config, planId]);
const addons = form.watch('addons');
const onAddonAdded = (data: z.infer<typeof AddonSchema>) => {
form.setValue('addons', [...addons, data], { shouldValidate: true });
};
const onAddonRemoved = (id: string) => {
form.setValue(
'addons',
addons.filter((item) => item.id !== id),
{ shouldValidate: true },
);
};
// display the period picker if the selected plan is recurring or if no plan is selected
const isRecurringPlan =
selectedPlan?.paymentType === 'recurring' || !selectedPlan;
const locale = useTranslation().i18n.language;
return (
<Form {...form}>
<div
className={
'flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0'
}
>
<form
className={'flex w-full max-w-xl flex-col space-y-6'}
onSubmit={form.handleSubmit(props.onSubmit)}
>
<If condition={intervals.length}>
<div
className={cn('transition-all', {
['pointer-events-none opacity-50']: !isRecurringPlan,
})}
>
<FormField
name={'interval'}
render={({ field }) => {
return (
<FormItem className={'rounded-md border p-4'}>
<FormLabel htmlFor={'plan-picker-id'}>
<Trans i18nKey={'common:billingInterval.label'} />
</FormLabel>
<FormControl id={'plan-picker-id'}>
<RadioGroup name={field.name} value={field.value}>
<div className={'flex space-x-2.5'}>
{intervals.map((interval) => {
const selected = field.value === interval;
return (
<label
htmlFor={interval}
key={interval}
className={cn(
'flex items-center space-x-2 rounded-md border border-transparent px-4 py-2 transition-colors',
{
['border-primary']: selected,
['hover:border-primary']: !selected,
},
)}
>
<RadioGroupItem
id={interval}
value={interval}
onClick={() => {
form.setValue('interval', interval, {
shouldValidate: true,
});
form.setValue('addons', [], {
shouldValidate: true,
});
if (selectedProduct) {
const plan = selectedProduct.plans.find(
(item) => item.interval === interval,
);
form.setValue(
'planId',
plan?.id ?? '',
{
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
},
);
}
}}
/>
<span
className={cn('text-sm', {
['cursor-pointer']: !selected,
})}
>
<Trans
i18nKey={`billing:billingInterval.${interval}`}
/>
</span>
</label>
);
})}
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
</If>
<FormField
name={'planId'}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:planPickerLabel'} />
</FormLabel>
<FormControl>
<RadioGroup value={field.value} name={field.name}>
{props.config.products.map((product) => {
const plan = product.plans.find((item) => {
if (item.paymentType === 'one-time') {
return true;
}
return item.interval === selectedInterval;
});
if (!plan || plan.custom) {
return null;
}
const planId = plan.id;
const selected = field.value === planId;
const primaryLineItem = getPrimaryLineItem(
props.config,
planId,
);
if (!primaryLineItem) {
throw new Error(`Base line item was not found`);
}
return (
<RadioGroupItemLabel
selected={selected}
key={primaryLineItem.id}
>
<RadioGroupItem
data-test-plan={plan.id}
key={plan.id + selected}
id={plan.id}
value={plan.id}
onClick={() => {
if (selected) {
return;
}
form.setValue('planId', planId, {
shouldValidate: true,
});
form.setValue('productId', product.id, {
shouldValidate: true,
});
form.setValue('addons', [], {
shouldValidate: true,
});
}}
/>
<div
className={
'flex w-full flex-col content-center space-y-2 lg:flex-row lg:items-center lg:justify-between lg:space-y-0'
}
>
<Label
htmlFor={plan.id}
className={
'flex flex-col justify-center space-y-2'
}
>
<div className={'flex items-center space-x-2.5'}>
<span className="font-semibold">
<Trans
i18nKey={`billing:plans.${product.id}.name`}
defaults={product.name}
/>
</span>
<If
condition={
plan.trialDays && props.canStartTrial
}
>
<div>
<Badge
className={'px-1 py-0.5 text-xs'}
variant={'success'}
>
<Trans
i18nKey={`billing:trialPeriod`}
values={{
period: plan.trialDays,
}}
/>
</Badge>
</div>
</If>
</div>
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:plans.${product.id}.description`}
defaults={product.description}
/>
</span>
</Label>
<div
className={
'flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-x-4 lg:space-y-0 lg:text-right'
}
>
<div>
<Price key={plan.id}>
<span>
{formatCurrency({
currencyCode:
product.currency.toLowerCase(),
value: primaryLineItem.cost,
locale,
})}
</span>
</Price>
<div>
<span className={'text-muted-foreground'}>
<If
condition={
plan.paymentType === 'recurring'
}
fallback={
<Trans i18nKey={`billing:lifetime`} />
}
>
<Trans
i18nKey={`billing:perPeriod`}
values={{
period: selectedInterval,
}}
/>
</If>
</span>
</div>
</div>
</div>
</div>
</RadioGroupItemLabel>
);
})}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<If condition={selectedPlan?.addons}>
<div className={'flex flex-col space-y-2.5'}>
<span className={'text-sm font-medium'}>Addons</span>
<div className={'flex flex-col space-y-2'}>
{selectedPlan?.addons?.map((addon) => {
return (
<div
className={'flex items-center space-x-2 text-sm'}
key={addon.id}
>
<Checkbox
value={addon.id}
onCheckedChange={() => {
if (addons.some((item) => item.id === addon.id)) {
onAddonRemoved(addon.id);
} else {
onAddonAdded({
productId: selectedProduct.id,
planId: selectedPlan.id,
id: addon.id,
name: addon.name,
cost: addon.cost,
});
}
}}
/>
<span>{addon.name}</span>
</div>
);
})}
</div>
</div>
</If>
<div>
<Button
data-test="checkout-submit-button"
disabled={props.pending ?? !form.formState.isValid}
>
{props.pending ? (
t('redirectingToPayment')
) : (
<>
<If
condition={selectedPlan?.trialDays && props.canStartTrial}
fallback={t(`proceedToPayment`)}
>
<span>{t(`startTrial`)}</span>
</If>
<ArrowRight className={'ml-2 h-4 w-4'} />
</>
)}
</Button>
</div>
</form>
{selectedPlan && selectedInterval && selectedProduct ? (
<PlanDetails
selectedInterval={selectedInterval}
selectedPlan={selectedPlan}
selectedProduct={selectedProduct}
addons={addons}
/>
) : null}
</div>
</Form>
);
}
function PlanDetails({
selectedProduct,
selectedInterval,
selectedPlan,
addons = [],
}: {
selectedProduct: {
id: string;
name: string;
description: string;
currency: string;
features: string[];
};
selectedInterval: string;
selectedPlan: {
lineItems: z.infer<typeof LineItemSchema>[];
paymentType: string;
};
addons: z.infer<typeof AddonSchema>[];
}) {
const isRecurring = selectedPlan.paymentType === 'recurring';
const { i18n } = useTranslation(`billing`);
// trick to force animation on re-render
const key = Math.random();
return (
<div
key={key}
className={
'fade-in animate-in zoom-in-95 flex w-full flex-col space-y-4 py-2 lg:px-8'
}
>
<div className={'flex flex-col space-y-0.5'}>
<span className={'text-sm font-medium'}>
<b>
<Trans
i18nKey={`billing:plans.${selectedProduct.id}.name`}
defaults={selectedProduct.name}
/>
</b>{' '}
<If condition={isRecurring}>
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
</If>
</span>
<p>
<span className={'text-muted-foreground text-sm'}>
<Trans
i18nKey={`billing:plans.${selectedProduct.id}.description`}
defaults={selectedProduct.description}
/>
</span>
</p>
</div>
<If condition={selectedPlan.lineItems.length > 0}>
<Separator />
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
</span>
<LineItemDetails
lineItems={selectedPlan.lineItems ?? []}
selectedInterval={isRecurring ? selectedInterval : undefined}
currency={selectedProduct.currency}
/>
</div>
</If>
<Separator />
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-semibold'}>
<Trans i18nKey={'billing:featuresLabel'} />
</span>
{selectedProduct.features.map((item) => {
return (
<div key={item} className={'flex items-center space-x-1 text-sm'}>
<CheckCircle className={'h-4 text-green-500'} />
<span className={'text-secondary-foreground'}>
<Trans i18nKey={item} defaults={item} />
</span>
</div>
);
})}
</div>
<If condition={addons.length > 0}>
<div className={'flex flex-col space-y-2'}>
<span className={'text-sm font-semibold'}>
<Trans i18nKey={'billing:addons'} />
</span>
{addons.map((addon) => {
return (
<div
key={addon.id}
className={'flex items-center space-x-1 text-sm'}
>
<CheckCircle className={'h-4 text-green-500'} />
<span className={'text-secondary-foreground'}>
<Trans i18nKey={addon.name} defaults={addon.name} />
</span>
<span>-</span>
<span className={'text-xs font-semibold'}>
{formatCurrency({
currencyCode: selectedProduct.currency.toLowerCase(),
value: addon.cost,
locale: i18n.language,
})}
</span>
</div>
);
})}
</div>
</If>
</div>
);
}
function Price(props: React.PropsWithChildren) {
return (
<span
className={
'animate-in slide-in-from-left-4 fade-in text-xl font-semibold tracking-tight duration-500'
}
>
{props.children}
</span>
);
}
12. Stripe Checkout Creation
File: packages/billing/stripe/src/services/create-stripe-checkout.ts
The Stripe checkout creation process now includes addons:
if (params.addons.length > 0) {
lineItems.push(
...params.addons.map((addon) => ({
price: addon.planId,
quantity: 1,
})),
);
}
This change ensures that selected addons are included in the Stripe checkout session.
Conclusion
These changes introduce a flexible addon system to Makerkit. By implementing these updates, you'll be able to offer additional features or services alongside your main subscription plans.
Remember, while adding addons to the checkout process is now straightforward, managing them post-purchase (like allowing users to add or remove addons from an active subscription) will require additional custom development. Consider your specific use case and user needs when implementing this feature.