Files
myeasycms-v2/docs/billing/per-seat-billing.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

294 lines
8.0 KiB
Plaintext

---
status: "published"
label: "Per Seat Billing"
title: "Configure Per-Seat Billing for Team Subscriptions"
order: 6
description: "Implement per-seat pricing for your SaaS. Makerkit automatically tracks team members and updates seat counts with your billing provider when members join or leave."
---
Per-seat billing charges customers based on the number of users (seats) in their team. Makerkit handles this automatically: when team members are added or removed, the subscription is updated with the new seat count.
## How It Works
1. You define a `per_seat` line item in your billing schema
2. When a team subscribes, Makerkit counts current members and sets the initial quantity
3. When members join or leave, Makerkit updates the subscription quantity
4. Your billing provider (Stripe, Lemon Squeezy, Paddle) handles proration
No custom code required for basic per-seat billing.
## Schema Configuration
Define a per-seat line item in your billing schema:
```tsx {% title="apps/web/config/billing.config.ts" %}
import { createBillingSchema } from '@kit/billing';
export default createBillingSchema({
provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER,
products: [
{
id: 'team',
name: 'Team',
description: 'For growing teams',
currency: 'USD',
features: [
'Unlimited projects',
'Team collaboration',
'Priority support',
],
plans: [
{
id: 'team-monthly',
name: 'Team Monthly',
paymentType: 'recurring',
interval: 'month',
lineItems: [
{
id: 'price_team_monthly', // Your Stripe Price ID
name: 'Team Seats',
cost: 0, // Base cost (calculated from tiers)
type: 'per_seat',
tiers: [
{ upTo: 3, cost: 0 }, // First 3 seats free
{ upTo: 10, cost: 15 }, // $15/seat for seats 4-10
{ upTo: 'unlimited', cost: 12 }, // Volume discount
],
},
],
},
],
},
],
});
```
## Pricing Tier Patterns
### Free Tier + Per-Seat
Include free seats for small teams:
```tsx
tiers: [
{ upTo: 5, cost: 0 }, // 5 free seats
{ upTo: 'unlimited', cost: 10 }, // $10/seat after
]
```
### Flat Per-Seat (No Tiers)
Simple per-seat pricing:
```tsx
tiers: [
{ upTo: 'unlimited', cost: 15 }, // $15/seat for all seats
]
```
### Volume Discounts
Reward larger teams:
```tsx
tiers: [
{ upTo: 10, cost: 20 }, // $20/seat for 1-10
{ upTo: 50, cost: 15 }, // $15/seat for 11-50
{ upTo: 100, cost: 12 }, // $12/seat for 51-100
{ upTo: 'unlimited', cost: 10 }, // $10/seat for 100+
]
```
### Base Fee + Per-Seat (Stripe Only)
Combine a flat fee with per-seat pricing:
```tsx
lineItems: [
{
id: 'price_base_fee',
name: 'Platform Fee',
cost: 49,
type: 'flat',
},
{
id: 'price_seats',
name: 'Team Seats',
cost: 0,
type: 'per_seat',
tiers: [
{ upTo: 5, cost: 0 },
{ upTo: 'unlimited', cost: 10 },
],
},
]
```
{% alert type="warning" title="Stripe only" %}
Multiple line items (flat + per-seat) only work with Stripe. Lemon Squeezy and Paddle support one line item per plan.
{% /alert %}
## Provider Setup
### Stripe
1. Create a product in Stripe Dashboard
2. Add a price with **Graduated pricing** or **Volume pricing**
3. Set the pricing tiers to match your schema
4. Copy the Price ID (e.g., `price_xxx`) to your line item `id`
**Stripe pricing types:**
- **Graduated**: Each tier applies to that range only (e.g., seats 1-5 at $0, seats 6-10 at $15)
- **Volume**: The price for all units is determined by the total quantity
### Lemon Squeezy
1. Create a product with **Usage-based pricing**
2. Configure the pricing tiers
3. Copy the Variant ID to your line item `id`
### Paddle
1. Create a product with quantity-based pricing
2. Configure as needed (Paddle handles proration automatically)
3. Copy the Price ID to your line item `id`
{% alert type="default" title="Paddle trial limitation" %}
Paddle doesn't support updating subscription quantities during a trial period. If using per-seat billing with Paddle trials, consider using Feature Policies to restrict invitations during trials.
{% /alert %}
## Automatic Seat Updates
Makerkit automatically updates seat counts when:
| Action | Effect |
|--------|--------|
| Team member accepts invitation | Seat count increases |
| Team member is removed | Seat count decreases |
| Team member leaves | Seat count decreases |
| Account is deleted | Subscription is canceled |
The billing provider handles proration based on your settings.
## Testing Per-Seat Billing
1. **Create a team subscription:**
- Sign up and create a team account
- Subscribe to a per-seat plan
- Verify the initial seat count matches team size
2. **Add a member:**
- Invite a new member to the team
- Have them accept the invitation
- Check Stripe/LS/Paddle: subscription quantity should increase
3. **Remove a member:**
- Remove a member from the team
- Check: subscription quantity should decrease
4. **Verify proration:**
- Check the upcoming invoice in your provider dashboard
- Confirm proration is calculated correctly
## Manual Seat Updates (Advanced)
In rare cases, you might need to manually update seat counts:
```tsx
import { createBillingGatewayService } from '@kit/billing-gateway';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function updateSeatCount(
subscriptionId: string,
subscriptionItemId: string,
newQuantity: number
) {
const supabase = getSupabaseServerClient();
// Get subscription to find the provider
const { data: subscription } = await supabase
.from('subscriptions')
.select('billing_provider')
.eq('id', subscriptionId)
.single();
if (!subscription) {
throw new Error('Subscription not found');
}
const service = createBillingGatewayService(
subscription.billing_provider
);
return service.updateSubscriptionItem({
subscriptionId,
subscriptionItemId,
quantity: newQuantity,
});
}
```
## Checking Seat Limits
To enforce seat limits in your application:
```tsx
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export async function canAddMember(accountId: string): Promise<boolean> {
const supabase = getSupabaseServerClient();
const api = createAccountsApi(supabase);
// Get current subscription
const subscription = await api.getSubscription(accountId);
if (!subscription) {
return false; // No subscription
}
// Get per-seat item
const { data: seatItem } = await supabase
.from('subscription_items')
.select('quantity')
.eq('subscription_id', subscription.id)
.eq('type', 'per_seat')
.single();
// Get current member count
const { count: memberCount } = await supabase
.from('accounts_memberships')
.select('*', { count: 'exact', head: true })
.eq('account_id', accountId);
// Check if under limit (if you have a max seats limit)
const maxSeats = 100; // Your limit
return (memberCount ?? 0) < maxSeats;
}
```
## Common Issues
### Seat count not updating
1. Check that the line item has `type: 'per_seat'`
2. Verify the subscription is active
3. Check webhook logs for errors
4. Ensure the subscription item ID is correct in the database
### Proration not working as expected
Configure proration behavior in your billing provider:
- **Stripe:** Customer Portal settings or API parameters
- **Lemon Squeezy:** Product settings
- **Paddle:** Automatic proration
### "Minimum quantity" errors
Some plans require at least 1 seat. Ensure your tiers start at a valid minimum.
## Related Documentation
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Define pricing plans
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe
- [Billing API](/docs/next-supabase-turbo/billing/billing-api) - Manual subscription updates
- [Team Accounts](/docs/next-supabase-turbo/api/team-account-api) - Team management