--- status: "published" title: 'Checkout Addons with Stripe Billing' label: 'Checkout Addons with Stripe Billing' order: 3 description: 'Learn how to create a subscription with addons using Stripe Billing.' --- 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/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx` Update your `PersonalAccountCheckoutForm` component to pass addon data to the checkout session creation process: ```typescript {% title="home/(user)/billing/_components/personal-account-checkout-form.tsx" %} { 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`: ```typescript {% title="home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts" %} 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: ```typescript {% title="home/(user)/billing/_lib/server/user-billing.service.ts" %} async createCheckoutSession({ planId, productId, addons, }: z.infer) { // ...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/[locale]/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/[locale]/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/[locale]/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: ```typescript {% title="apps/web/config/billing.sample.config.ts" %} plans: [ { // ...existing plan config addons: [ { id: 'price_1J4J9zL2c7J1J4J9zL2c7J1', name: 'Extra Feature', cost: 9.99, type: 'flat' as const, }, ], }, ], ``` **Note:** 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: ```json {% title="apps/web/i18n/messages/en/billing.json" %} { // ...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: ```tsx {% title="packages/billing/gateway/src/components/plan-picker.tsx" %} '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 { useLocale, useTranslations } from 'next-intl'; import * as 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[]; }; export function PlanPicker( props: React.PropsWithChildren<{ config: BillingConfig; onSubmit: (data: OnSubmitData) => void; canStartTrial?: boolean; pending?: boolean; }>, ) { const t = useTranslations(`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[], }, }); 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) => { 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 = useLocale(); return (
{ return (
{intervals.map((interval) => { const selected = field.value === interval; return ( ); })}
); }} />
( {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 ( { if (selected) { return; } form.setValue('planId', planId, { shouldValidate: true, }); form.setValue('productId', product.id, { shouldValidate: true, }); form.setValue('addons', [], { shouldValidate: true, }); }} />
{formatCurrency({ currencyCode: product.currency.toLowerCase(), value: primaryLineItem.cost, locale, })}
} >
); })}
)} />
Addons
{selectedPlan?.addons?.map((addon) => { return (
{ 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, }); } }} /> {addon.name}
); })}
{selectedPlan && selectedInterval && selectedProduct ? ( ) : null}
); } function PlanDetails({ selectedProduct, selectedInterval, selectedPlan, addons = [], }: { selectedProduct: { id: string; name: string; description: string; currency: string; features: string[]; }; selectedInterval: string; selectedPlan: { lineItems: z.infer[]; paymentType: string; }; addons: z.infer[]; }) { const isRecurring = selectedPlan.paymentType === 'recurring'; const locale = useLocale(); // trick to force animation on re-render const key = Math.random(); return (
{' '} /

0}>
{selectedProduct.features.map((item) => { return (
); })}
0}>
{addons.map((addon) => { return (
- {formatCurrency({ currencyCode: selectedProduct.currency.toLowerCase(), value: addon.cost, locale, })}
); })}
); } function Price(props: React.PropsWithChildren) { return ( {props.children} ); } ``` ## 12. Stripe Checkout Creation File: `packages/billing/stripe/src/services/create-stripe-checkout.ts` The Stripe checkout creation process now includes addons: ```typescript 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.