Files
myeasycms-v2/docs/billing/billing-schema.mdoc
Giancarlo Buomprisco 7ebff31475 Next.js Supabase V3 (#463)
Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
2026-03-24 13:40:38 +08:00

632 lines
17 KiB
Plaintext

---
status: "published"
label: "Billing Schema"
title: "Configure SaaS Pricing Plans with the Billing Schema"
order: 1
description: "Define your SaaS pricing with Makerkit's billing schema. Configure products, plans, flat subscriptions, per-seat pricing, metered usage, and one-off payments for Stripe, Lemon Squeezy, or Paddle."
---
The billing schema defines your products and pricing in a single configuration file. This schema drives the pricing table UI, checkout sessions, and subscription management across all supported providers (Stripe, Lemon Squeezy, Paddle).
## Schema Structure
The schema has three levels:
```
Products (what you sell)
└── Plans (pricing options: monthly, yearly)
└── Line Items (how you charge: flat, per-seat, metered)
```
**Example:** A "Pro" product might have "Pro Monthly" and "Pro Yearly" plans. Each plan has line items defining the actual charges.
## Quick Start
Create or edit `apps/web/config/billing.config.ts`:
```tsx
import { createBillingSchema } from '@kit/billing';
const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe';
export default createBillingSchema({
provider,
products: [
{
id: 'pro',
name: 'Pro',
description: 'For growing teams',
currency: 'USD',
badge: 'Popular',
plans: [
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_xxxxxxxxxxxxx', // Your Stripe Price ID
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
],
},
{
id: 'pro-yearly',
name: 'Pro Yearly',
paymentType: 'recurring',
interval: 'year',
lineItems: [
{
id: 'price_yyyyyyyyyyyyy', // Your Stripe Price ID
name: 'Pro Plan',
cost: 290,
type: 'flat',
},
],
},
],
},
],
});
```
{% alert type="warning" title="Match IDs exactly" %}
Line item `id` values **must match** the Price IDs in your billing provider (Stripe, Lemon Squeezy, or Paddle). The schema validates this format but cannot verify the IDs exist in your provider account.
{% /alert %}
## Setting the Billing Provider
Set the provider via environment variable:
```bash
NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle
```
Also update the database configuration:
```sql
UPDATE public.config SET billing_provider = 'stripe';
```
The provider determines which API is called when creating checkouts, managing subscriptions, and processing webhooks.
## Products
Products represent what you're selling (e.g., "Starter", "Pro", "Enterprise"). Each product can have multiple plans with different billing intervals.
```tsx
{
id: 'pro',
name: 'Pro',
description: 'For growing teams',
currency: 'USD',
badge: 'Popular',
highlighted: true,
enableDiscountField: true,
features: [
'Unlimited projects',
'Priority support',
'Advanced analytics',
],
plans: [/* ... */],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique identifier (your choice, not the provider's ID) |
| `name` | Yes | Display name in pricing table |
| `description` | Yes | Short description shown to users |
| `currency` | Yes | ISO currency code (e.g., "USD", "EUR") |
| `plans` | Yes | Array of pricing plans |
| `badge` | No | Badge text (e.g., "Popular", "Best Value") |
| `highlighted` | No | Visually highlight this product |
| `enableDiscountField` | No | Show coupon/discount input at checkout |
| `features` | No | Feature list for pricing table |
| `hidden` | No | Hide from pricing table (for legacy plans) |
The `id` is your internal identifier. It doesn't need to match anything in Stripe or your payment provider.
## Plans
Plans define pricing options within a product. Typically, you'll have monthly and yearly variants.
```tsx
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
trialDays: 14,
lineItems: [/* ... */],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique identifier (your choice) |
| `name` | Yes | Display name |
| `paymentType` | Yes | `'recurring'` or `'one-time'` |
| `interval` | Recurring only | `'month'` or `'year'` |
| `lineItems` | Yes | Array of line items (charges) |
| `trialDays` | No | Free trial period in days |
| `custom` | No | Mark as custom/enterprise plan (see below) |
| `href` | Custom only | Link for custom plans |
| `label` | Custom only | Button label for custom plans |
| `buttonLabel` | No | Custom checkout button text |
**Plan ID validation:** The schema validates that plan IDs are unique across all products.
## Line Items
Line items define how you charge for a plan. Makerkit supports three types:
| Type | Use Case | Example |
|------|----------|---------|
| `flat` | Fixed recurring price | $29/month |
| `per_seat` | Per-user pricing | $10/seat/month |
| `metered` | Usage-based pricing | $0.01 per API call |
**Provider limitations:**
- **Stripe:** Supports multiple line items per plan (mix flat + per-seat + metered)
- **Lemon Squeezy:** One line item per plan only
- **Paddle:** Flat and per-seat only (no metered billing)
### Flat Subscriptions
The most common pricing model. A fixed amount charged at each billing interval.
```tsx
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', // Stripe Price ID
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | **Must match** your provider's Price ID |
| `name` | Yes | Display name |
| `cost` | Yes | Price (for UI display only) |
| `type` | Yes | `'flat'` |
{% alert type="default" title="Cost is for display only" %}
The `cost` field is used for the pricing table UI. The actual charge comes from your billing provider. Make sure they match to avoid confusing users.
{% /alert %}
### Metered Billing
Charge based on usage (API calls, storage, tokens). You report usage through the billing API, and the provider calculates charges at the end of each billing period.
```tsx
{
id: 'api-monthly',
name: 'API Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'API Requests',
cost: 0,
type: 'metered',
unit: 'requests',
tiers: [
{ upTo: 1000, cost: 0 }, // First 1000 free
{ upTo: 10000, cost: 0.001 }, // $0.001 per request
{ upTo: 'unlimited', cost: 0.0005 }, // Volume discount
],
},
],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | **Must match** your provider's Price ID |
| `name` | Yes | Display name |
| `cost` | Yes | Base cost (usually 0 for metered) |
| `type` | Yes | `'metered'` |
| `unit` | Yes | Unit label (e.g., "requests", "GBs", "tokens") |
| `tiers` | Yes | Array of pricing tiers |
**Tier structure:**
```tsx
{
upTo: number | 'unlimited', // Usage threshold
cost: number, // Cost per unit in this tier
}
```
The last tier should always have `upTo: 'unlimited'`.
{% alert type="warning" title="Provider-specific metered billing" %}
Stripe and Lemon Squeezy handle metered billing differently. See the [metered usage guide](/docs/next-supabase-turbo/billing/metered-usage) for provider-specific implementation details.
{% /alert %}
### Per-Seat Billing
Charge based on team size. Makerkit automatically updates seat counts when members are added or removed from a team account.
```tsx
{
id: 'team-monthly',
name: 'Team Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 3, cost: 0 }, // First 3 seats free
{ upTo: 10, cost: 12 }, // $12/seat for 4-10
{ upTo: 'unlimited', cost: 10 }, // Volume discount
],
},
],
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | **Must match** your provider's Price ID |
| `name` | Yes | Display name |
| `cost` | Yes | Base cost (usually 0 for tiered) |
| `type` | Yes | `'per_seat'` |
| `tiers` | Yes | Array of pricing tiers |
**Common patterns:**
```tsx
// Free tier + flat per-seat
tiers: [
{ upTo: 5, cost: 0 }, // 5 free seats
{ upTo: 'unlimited', cost: 15 },
]
// Volume discounts
tiers: [
{ upTo: 10, cost: 20 }, // $20/seat for 1-10
{ upTo: 50, cost: 15 }, // $15/seat for 11-50
{ upTo: 'unlimited', cost: 10 },
]
// Flat price (no tiers)
tiers: [
{ upTo: 'unlimited', cost: 10 },
]
```
Makerkit handles seat count updates automatically when:
- A new member joins the team
- A member is removed from the team
- A member invitation is accepted
[Full per-seat billing guide →](/docs/next-supabase-turbo/billing/per-seat-billing)
### One-Off Payments
Single charges for lifetime access, add-ons, or credits. One-off payments are stored in the `orders` table instead of `subscriptions`.
```tsx
{
id: 'lifetime',
name: 'Lifetime Access',
paymentType: 'one-time',
// No interval for one-time payments
lineItems: [
{
id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe',
name: 'Lifetime Access',
cost: 299,
type: 'flat',
},
],
}
```
**Key differences from subscriptions:**
- `paymentType` must be `'one-time'`
- No `interval` field
- Line items can only be `type: 'flat'`
- Data is stored in `orders` and `order_items` tables
[Full one-off payments guide →](/docs/next-supabase-turbo/billing/one-off-payments)
## Combining Line Items (Stripe Only)
With Stripe, you can combine multiple line items in a single plan. This is useful for hybrid pricing models:
```tsx
{
id: 'growth-monthly',
name: 'Growth Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
// Base platform fee
{
id: 'price_base_fee',
name: 'Platform Fee',
cost: 49,
type: 'flat',
},
// Per-seat charges
{
id: 'price_seats',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 5, cost: 0 },
{ upTo: 'unlimited', cost: 10 },
],
},
// Usage-based charges
{
id: 'price_api',
name: 'API Calls',
cost: 0,
type: 'metered',
unit: 'calls',
tiers: [
{ upTo: 10000, cost: 0 },
{ upTo: 'unlimited', cost: 0.001 },
],
},
],
}
```
{% alert type="warning" title="Lemon Squeezy and Paddle limitations" %}
Lemon Squeezy and Paddle only support one line item per plan. The schema validation will fail if you add multiple line items with these providers.
{% /alert %}
## Custom Plans (Enterprise/Contact Us)
Display a plan in the pricing table without checkout functionality. Useful for enterprise tiers or "Contact Us" options.
```tsx
{
id: 'enterprise',
name: 'Enterprise',
paymentType: 'recurring',
interval: 'month',
custom: true,
label: '$5,000+', // or 'common.contactUs' for i18n
href: '/contact',
buttonLabel: 'Contact Sales',
lineItems: [], // Must be empty array
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `custom` | Yes | Set to `true` |
| `label` | Yes | Price label (e.g., "Custom pricing", "$5,000+") |
| `href` | Yes | Link destination (e.g., "/contact", "mailto:sales@...") |
| `buttonLabel` | No | Custom CTA text |
| `lineItems` | Yes | Must be empty array `[]` |
Custom plans appear in the pricing table but clicking them navigates to `href` instead of opening checkout.
## Legacy Plans
When you discontinue a plan but have existing subscribers, use the `hidden` flag to keep the plan in your schema without showing it in the pricing table:
```tsx
{
id: 'old-pro',
name: 'Pro (Legacy)',
description: 'This plan is no longer available',
currency: 'USD',
hidden: true, // Won't appear in pricing table
plans: [
{
id: 'old-pro-monthly',
name: 'Pro Monthly (Legacy)',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_legacy_xxx',
name: 'Pro Plan',
cost: 19,
type: 'flat',
},
],
},
],
}
```
Hidden plans:
- Don't appear in the pricing table
- Still display correctly in the user's billing section
- Allow existing subscribers to continue without issues
**If you remove a plan entirely:** Makerkit will attempt to fetch plan details from the billing provider. This works for `flat` line items only. For complex plans, keep them in your schema with `hidden: true`.
## Schema Validation
The `createBillingSchema` function validates your configuration and throws errors for common mistakes:
| Validation | Rule |
|------------|------|
| Unique Plan IDs | Plan IDs must be unique across all products |
| Unique Line Item IDs | Line item IDs must be unique across all plans |
| Provider constraints | Lemon Squeezy: max 1 line item per plan |
| Required tiers | Metered and per-seat items require `tiers` array |
| One-time payments | Must have `type: 'flat'` line items only |
| Recurring payments | Must specify `interval: 'month'` or `'year'` |
## Complete Example
Here's a full billing schema with multiple products and pricing models:
```tsx
import { createBillingSchema } from '@kit/billing';
const provider = process.env.NEXT_PUBLIC_BILLING_PROVIDER ?? 'stripe';
export default createBillingSchema({
provider,
products: [
// Free tier (custom plan, no billing)
{
id: 'free',
name: 'Free',
description: 'Get started for free',
currency: 'USD',
plans: [
{
id: 'free',
name: 'Free',
paymentType: 'recurring',
interval: 'month',
custom: true,
label: '$0',
href: '/auth/sign-up',
buttonLabel: 'Get Started',
lineItems: [],
},
],
},
// Pro tier with monthly/yearly
{
id: 'pro',
name: 'Pro',
description: 'For professionals and small teams',
currency: 'USD',
badge: 'Popular',
highlighted: true,
features: [
'Unlimited projects',
'Priority support',
'Advanced analytics',
'Custom integrations',
],
plans: [
{
id: 'pro-monthly',
name: 'Pro Monthly',
paymentType: 'recurring',
interval: 'month',
trialDays: 14,
lineItems: [
{
id: 'price_pro_monthly',
name: 'Pro Plan',
cost: 29,
type: 'flat',
},
],
},
{
id: 'pro-yearly',
name: 'Pro Yearly',
paymentType: 'recurring',
interval: 'year',
trialDays: 14,
lineItems: [
{
id: 'price_pro_yearly',
name: 'Pro Plan',
cost: 290,
type: 'flat',
},
],
},
],
},
// Team tier with per-seat pricing
{
id: 'team',
name: 'Team',
description: 'For growing teams',
currency: 'USD',
features: [
'Everything in Pro',
'Team management',
'SSO authentication',
'Audit logs',
],
plans: [
{
id: 'team-monthly',
name: 'Team Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_team_monthly',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 5, cost: 0 },
{ upTo: 'unlimited', cost: 15 },
],
},
],
},
],
},
// Enterprise tier
{
id: 'enterprise',
name: 'Enterprise',
description: 'For large organizations',
currency: 'USD',
features: [
'Everything in Team',
'Dedicated support',
'Custom contracts',
'SLA guarantees',
],
plans: [
{
id: 'enterprise',
name: 'Enterprise',
paymentType: 'recurring',
interval: 'month',
custom: true,
label: 'Custom',
href: '/contact',
buttonLabel: 'Contact Sales',
lineItems: [],
},
],
},
],
});
```
## Related Documentation
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and provider comparison
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe billing
- [Lemon Squeezy Setup](/docs/next-supabase-turbo/billing/lemon-squeezy) - Configure Lemon Squeezy
- [Paddle Setup](/docs/next-supabase-turbo/billing/paddle) - Configure Paddle
- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based pricing
- [One-Off Payments](/docs/next-supabase-turbo/billing/one-off-payments) - Lifetime deals and add-ons