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
This commit is contained in:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
461
docs/billing/billing-api.mdoc
Normal file
461
docs/billing/billing-api.mdoc
Normal file
@@ -0,0 +1,461 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Billing API"
|
||||
title: "Billing API Reference for Next.js Supabase SaaS Kit"
|
||||
order: 10
|
||||
description: "Complete API reference for Makerkit's billing service. Create checkouts, manage subscriptions, report usage, and handle billing operations programmatically."
|
||||
---
|
||||
|
||||
The Billing Gateway Service provides a unified API for all billing operations, regardless of which payment provider you use (Stripe, Lemon Squeezy, or Paddle). This abstraction lets you switch providers without changing your application code.
|
||||
|
||||
## Getting the Billing Service
|
||||
|
||||
```tsx
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
|
||||
// Get service for the configured provider
|
||||
const service = createBillingGatewayService(
|
||||
process.env.NEXT_PUBLIC_BILLING_PROVIDER
|
||||
);
|
||||
|
||||
// Or specify a provider explicitly
|
||||
const stripeService = createBillingGatewayService('stripe');
|
||||
```
|
||||
|
||||
For most operations, get the provider from the user's subscription record:
|
||||
|
||||
```tsx
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
|
||||
const accountsApi = createAccountsApi(supabaseClient);
|
||||
const subscription = await accountsApi.getSubscription(accountId);
|
||||
const provider = subscription?.billing_provider ?? 'stripe';
|
||||
|
||||
const service = createBillingGatewayService(provider);
|
||||
```
|
||||
|
||||
## Create Checkout Session
|
||||
|
||||
Start a new subscription or one-off purchase.
|
||||
|
||||
```tsx
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
accountId: 'uuid-of-account',
|
||||
plan: billingConfig.products[0].plans[0], // From billing.config.ts
|
||||
returnUrl: 'https://yourapp.com/billing/return',
|
||||
customerEmail: 'user@example.com', // Optional
|
||||
customerId: 'cus_xxx', // Optional, if customer already exists
|
||||
enableDiscountField: true, // Optional, show coupon input
|
||||
variantQuantities: [ // Optional, for per-seat billing
|
||||
{ variantId: 'price_xxx', quantity: 5 }
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `accountId` | `string` | Yes | UUID of the account making the purchase |
|
||||
| `plan` | `Plan` | Yes | Plan object from your billing config |
|
||||
| `returnUrl` | `string` | Yes | URL to redirect after checkout |
|
||||
| `customerEmail` | `string` | No | Pre-fill customer email |
|
||||
| `customerId` | `string` | No | Existing customer ID (skips customer creation) |
|
||||
| `enableDiscountField` | `boolean` | No | Show coupon/discount input |
|
||||
| `variantQuantities` | `array` | No | Override quantities for line items |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
checkoutToken: string // Token to open checkout UI
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Server Action**
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
export async function createCheckout(planId: string, accountId: string) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const plan = billingConfig.products
|
||||
.flatMap(p => p.plans)
|
||||
.find(p => p.id === planId);
|
||||
|
||||
if (!plan) {
|
||||
throw new Error('Plan not found');
|
||||
}
|
||||
|
||||
const service = createBillingGatewayService(billingConfig.provider);
|
||||
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
accountId,
|
||||
plan,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/billing/return`,
|
||||
customerEmail: user.email,
|
||||
});
|
||||
|
||||
return { checkoutToken };
|
||||
}
|
||||
```
|
||||
|
||||
## Retrieve Checkout Session
|
||||
|
||||
Check the status of a checkout session after redirect.
|
||||
|
||||
```tsx
|
||||
const session = await service.retrieveCheckoutSession({
|
||||
sessionId: 'cs_xxx', // From URL params after redirect
|
||||
});
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
checkoutToken: string | null,
|
||||
status: 'complete' | 'expired' | 'open',
|
||||
isSessionOpen: boolean,
|
||||
customer: {
|
||||
email: string | null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Return page handler**
|
||||
|
||||
```tsx
|
||||
// app/[locale]/home/[account]/billing/return/page.tsx
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
|
||||
export default async function BillingReturnPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ session_id?: string }>
|
||||
}) {
|
||||
const { session_id } = await searchParams;
|
||||
|
||||
if (!session_id) {
|
||||
return <div>Invalid session</div>;
|
||||
}
|
||||
|
||||
const service = createBillingGatewayService('stripe');
|
||||
const session = await service.retrieveCheckoutSession({
|
||||
sessionId: session_id,
|
||||
});
|
||||
|
||||
if (session.status === 'complete') {
|
||||
return <div>Payment successful!</div>;
|
||||
}
|
||||
|
||||
return <div>Payment pending or failed</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Create Billing Portal Session
|
||||
|
||||
Open the customer portal for subscription management.
|
||||
|
||||
```tsx
|
||||
const { url } = await service.createBillingPortalSession({
|
||||
customerId: 'cus_xxx', // From billing_customers table
|
||||
returnUrl: 'https://yourapp.com/billing',
|
||||
});
|
||||
|
||||
// Redirect user to the portal URL
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `customerId` | `string` | Yes | Customer ID from billing provider |
|
||||
| `returnUrl` | `string` | Yes | URL to redirect after portal session |
|
||||
|
||||
**Example: Server Action**
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function openBillingPortal(accountId: string) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const api = createAccountsApi(supabase);
|
||||
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('No billing customer found');
|
||||
}
|
||||
|
||||
const service = createBillingGatewayService('stripe');
|
||||
|
||||
const { url } = await service.createBillingPortalSession({
|
||||
customerId,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`,
|
||||
});
|
||||
|
||||
redirect(url);
|
||||
}
|
||||
```
|
||||
|
||||
## Cancel Subscription
|
||||
|
||||
Cancel a subscription immediately or at period end.
|
||||
|
||||
```tsx
|
||||
const { success } = await service.cancelSubscription({
|
||||
subscriptionId: 'sub_xxx',
|
||||
invoiceNow: false, // Optional: charge immediately for usage
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `subscriptionId` | `string` | Yes | Subscription ID from provider |
|
||||
| `invoiceNow` | `boolean` | No | Invoice outstanding usage immediately |
|
||||
|
||||
**Example: Cancel at period end**
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function cancelSubscription(accountId: string) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const api = createAccountsApi(supabase);
|
||||
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error('No subscription found');
|
||||
}
|
||||
|
||||
const service = createBillingGatewayService(subscription.billing_provider);
|
||||
|
||||
await service.cancelSubscription({
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
## Report Usage (Metered Billing)
|
||||
|
||||
Report usage for metered billing subscriptions.
|
||||
|
||||
### Stripe
|
||||
|
||||
Stripe uses customer ID and a meter event name:
|
||||
|
||||
```tsx
|
||||
await service.reportUsage({
|
||||
id: 'cus_xxx', // Customer ID
|
||||
eventName: 'api_requests', // Meter name in Stripe
|
||||
usage: {
|
||||
quantity: 100,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Lemon Squeezy
|
||||
|
||||
Lemon Squeezy uses subscription item ID:
|
||||
|
||||
```tsx
|
||||
await service.reportUsage({
|
||||
id: 'sub_item_xxx', // Subscription item ID
|
||||
usage: {
|
||||
quantity: 100,
|
||||
action: 'increment', // or 'set'
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | `string` | Yes | Customer ID (Stripe) or subscription item ID (LS) |
|
||||
| `eventName` | `string` | Stripe only | Meter event name |
|
||||
| `usage.quantity` | `number` | Yes | Usage amount |
|
||||
| `usage.action` | `'increment' \| 'set'` | No | How to apply usage (LS only) |
|
||||
|
||||
**Example: Track API usage**
|
||||
|
||||
```tsx
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function trackApiUsage(accountId: string, requestCount: number) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const api = createAccountsApi(supabase);
|
||||
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
return; // No active subscription
|
||||
}
|
||||
|
||||
const service = createBillingGatewayService(subscription.billing_provider);
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
|
||||
if (subscription.billing_provider === 'stripe') {
|
||||
await service.reportUsage({
|
||||
id: customerId!,
|
||||
eventName: 'api_requests',
|
||||
usage: { quantity: requestCount },
|
||||
});
|
||||
} else {
|
||||
// Lemon Squeezy: need subscription item ID
|
||||
const { data: item } = await supabase
|
||||
.from('subscription_items')
|
||||
.select('id')
|
||||
.eq('subscription_id', subscription.id)
|
||||
.eq('type', 'metered')
|
||||
.single();
|
||||
|
||||
if (item) {
|
||||
await service.reportUsage({
|
||||
id: item.id,
|
||||
usage: { quantity: requestCount, action: 'increment' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Query Usage
|
||||
|
||||
Retrieve usage data for a metered subscription.
|
||||
|
||||
### Stripe
|
||||
|
||||
```tsx
|
||||
const usage = await service.queryUsage({
|
||||
id: 'meter_xxx', // Stripe Meter ID
|
||||
customerId: 'cus_xxx',
|
||||
filter: {
|
||||
startTime: Math.floor(Date.now() / 1000) - 86400 * 30, // 30 days ago
|
||||
endTime: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Lemon Squeezy
|
||||
|
||||
```tsx
|
||||
const usage = await service.queryUsage({
|
||||
id: 'sub_item_xxx', // Subscription item ID
|
||||
customerId: 'cus_xxx',
|
||||
filter: {
|
||||
page: 1,
|
||||
size: 100,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
```tsx
|
||||
{
|
||||
value: number // Total usage in period
|
||||
}
|
||||
```
|
||||
|
||||
## Update Subscription Item
|
||||
|
||||
Update the quantity of a subscription item (e.g., seat count).
|
||||
|
||||
```tsx
|
||||
const { success } = await service.updateSubscriptionItem({
|
||||
subscriptionId: 'sub_xxx',
|
||||
subscriptionItemId: 'si_xxx',
|
||||
quantity: 10,
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `subscriptionId` | `string` | Yes | Subscription ID |
|
||||
| `subscriptionItemId` | `string` | Yes | Line item ID within subscription |
|
||||
| `quantity` | `number` | Yes | New quantity (minimum 1) |
|
||||
|
||||
{% alert type="default" title="Automatic seat updates" %}
|
||||
For per-seat billing, Makerkit automatically updates seat counts when team members are added or removed. You typically don't need to call this directly.
|
||||
{% /alert %}
|
||||
|
||||
## Get Subscription Details
|
||||
|
||||
Retrieve subscription details from the provider.
|
||||
|
||||
```tsx
|
||||
const subscription = await service.getSubscription('sub_xxx');
|
||||
```
|
||||
|
||||
**Returns:** Provider-specific subscription object.
|
||||
|
||||
## Get Plan Details
|
||||
|
||||
Retrieve plan/price details from the provider.
|
||||
|
||||
```tsx
|
||||
const plan = await service.getPlanById('price_xxx');
|
||||
```
|
||||
|
||||
**Returns:** Provider-specific plan/price object.
|
||||
|
||||
## Error Handling
|
||||
|
||||
All methods can throw errors. Wrap calls in try-catch:
|
||||
|
||||
```tsx
|
||||
try {
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
// ...
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error('Billing error:', error.message);
|
||||
}
|
||||
// Handle error appropriately
|
||||
}
|
||||
```
|
||||
|
||||
Common errors:
|
||||
- Invalid API keys
|
||||
- Invalid price/plan IDs
|
||||
- Customer not found
|
||||
- Subscription not found
|
||||
- Network/provider errors
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts
|
||||
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Handle billing events
|
||||
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based billing guide
|
||||
- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing
|
||||
632
docs/billing/billing-schema.mdoc
Normal file
632
docs/billing/billing-schema.mdoc
Normal file
@@ -0,0 +1,632 @@
|
||||
---
|
||||
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
|
||||
467
docs/billing/billing-webhooks.mdoc
Normal file
467
docs/billing/billing-webhooks.mdoc
Normal file
@@ -0,0 +1,467 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Handling Webhooks"
|
||||
title: "Handle Billing Webhooks in Next.js Supabase SaaS Kit"
|
||||
order: 9
|
||||
description: "Learn how to handle billing webhooks from Stripe, Lemon Squeezy, and Paddle. Extend the default webhook handler with custom logic for payment events, subscription changes, and more."
|
||||
---
|
||||
|
||||
Webhooks let your billing provider notify your application about events like successful payments, subscription changes, and cancellations. Makerkit handles the core webhook processing, but you can extend it with custom logic.
|
||||
|
||||
## Default Webhook Behavior
|
||||
|
||||
Makerkit's webhook handler automatically:
|
||||
|
||||
1. Verifies the webhook signature
|
||||
2. Processes the event based on type
|
||||
3. Updates the database (`subscriptions`, `subscription_items`, `orders`, `order_items`)
|
||||
4. Returns appropriate HTTP responses
|
||||
|
||||
The webhook endpoint is: `/api/billing/webhook`
|
||||
|
||||
## Extending the Webhook Handler
|
||||
|
||||
Add custom logic by providing callbacks to `handleWebhookEvent`:
|
||||
|
||||
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
|
||||
import { getBillingEventHandlerService } from '@kit/billing-gateway';
|
||||
import { getPlanTypesMap } from '@kit/billing';
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async ({ request }) => {
|
||||
const provider = billingConfig.provider;
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = { name: 'billing.webhook', provider };
|
||||
logger.info(ctx, 'Received billing webhook');
|
||||
|
||||
const supabaseClientProvider = () => getSupabaseServerAdminClient();
|
||||
|
||||
const service = await getBillingEventHandlerService(
|
||||
supabaseClientProvider,
|
||||
provider,
|
||||
getPlanTypesMap(billingConfig),
|
||||
);
|
||||
|
||||
try {
|
||||
await service.handleWebhookEvent(request, {
|
||||
// Add your custom callbacks here
|
||||
onCheckoutSessionCompleted: async (subscription, customerId) => {
|
||||
logger.info({ customerId }, 'Checkout completed');
|
||||
// Send welcome email, provision resources, etc.
|
||||
},
|
||||
|
||||
onSubscriptionUpdated: async (subscription) => {
|
||||
logger.info({ subscriptionId: subscription.id }, 'Subscription updated');
|
||||
// Handle plan changes, sync with external systems
|
||||
},
|
||||
|
||||
onSubscriptionDeleted: async (subscriptionId) => {
|
||||
logger.info({ subscriptionId }, 'Subscription deleted');
|
||||
// Clean up resources, send cancellation email
|
||||
},
|
||||
|
||||
onPaymentSucceeded: async (sessionId) => {
|
||||
logger.info({ sessionId }, 'Payment succeeded');
|
||||
// Send receipt, update analytics
|
||||
},
|
||||
|
||||
onPaymentFailed: async (sessionId) => {
|
||||
logger.info({ sessionId }, 'Payment failed');
|
||||
// Send payment failure notification
|
||||
},
|
||||
|
||||
onInvoicePaid: async (data) => {
|
||||
logger.info({ accountId: data.target_account_id }, 'Invoice paid');
|
||||
// Recharge credits, send invoice email
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(ctx, 'Successfully processed billing webhook');
|
||||
return new Response('OK', { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to process billing webhook');
|
||||
return new Response('Failed to process webhook', { status: 500 });
|
||||
}
|
||||
},
|
||||
{ auth: false } // Webhooks don't require authentication
|
||||
);
|
||||
```
|
||||
|
||||
## Available Callbacks
|
||||
|
||||
### onCheckoutSessionCompleted
|
||||
|
||||
Called when a checkout is successfully completed (new subscription or order).
|
||||
|
||||
```tsx
|
||||
onCheckoutSessionCompleted: async (subscription, customerId) => {
|
||||
// subscription: UpsertSubscriptionParams | UpsertOrderParams
|
||||
// customerId: string
|
||||
|
||||
const accountId = subscription.target_account_id;
|
||||
|
||||
// Send welcome email
|
||||
await sendEmail({
|
||||
to: subscription.target_customer_email,
|
||||
template: 'welcome',
|
||||
data: { planName: subscription.line_items[0]?.product_id },
|
||||
});
|
||||
|
||||
// Provision resources
|
||||
await provisionResources(accountId);
|
||||
|
||||
// Track analytics
|
||||
await analytics.track('subscription_created', {
|
||||
accountId,
|
||||
plan: subscription.line_items[0]?.variant_id,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### onSubscriptionUpdated
|
||||
|
||||
Called when a subscription is updated (plan change, renewal, etc.).
|
||||
|
||||
```tsx
|
||||
onSubscriptionUpdated: async (subscription) => {
|
||||
// subscription: UpsertSubscriptionParams
|
||||
|
||||
const accountId = subscription.target_account_id;
|
||||
const status = subscription.status;
|
||||
|
||||
// Handle plan changes
|
||||
if (subscription.line_items) {
|
||||
await syncPlanFeatures(accountId, subscription.line_items);
|
||||
}
|
||||
|
||||
// Handle status changes
|
||||
if (status === 'past_due') {
|
||||
await sendPaymentReminder(accountId);
|
||||
}
|
||||
|
||||
if (status === 'canceled') {
|
||||
await scheduleResourceCleanup(accountId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### onSubscriptionDeleted
|
||||
|
||||
Called when a subscription is fully deleted/expired.
|
||||
|
||||
```tsx
|
||||
onSubscriptionDeleted: async (subscriptionId) => {
|
||||
// subscriptionId: string
|
||||
|
||||
// Look up the subscription in your database
|
||||
const { data: subscription } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('account_id')
|
||||
.eq('id', subscriptionId)
|
||||
.single();
|
||||
|
||||
if (subscription) {
|
||||
// Clean up resources
|
||||
await cleanupResources(subscription.account_id);
|
||||
|
||||
// Send cancellation email
|
||||
await sendCancellationEmail(subscription.account_id);
|
||||
|
||||
// Update analytics
|
||||
await analytics.track('subscription_canceled', {
|
||||
accountId: subscription.account_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### onPaymentSucceeded
|
||||
|
||||
Called when a payment succeeds (for async payment methods like bank transfers).
|
||||
|
||||
```tsx
|
||||
onPaymentSucceeded: async (sessionId) => {
|
||||
// sessionId: string (checkout session ID)
|
||||
|
||||
// Look up the session details
|
||||
const session = await billingService.retrieveCheckoutSession({ sessionId });
|
||||
|
||||
// Send receipt
|
||||
await sendReceipt(session.customer.email);
|
||||
}
|
||||
```
|
||||
|
||||
### onPaymentFailed
|
||||
|
||||
Called when a payment fails.
|
||||
|
||||
```tsx
|
||||
onPaymentFailed: async (sessionId) => {
|
||||
// sessionId: string
|
||||
|
||||
// Notify the customer
|
||||
await sendPaymentFailedEmail(sessionId);
|
||||
|
||||
// Log for monitoring
|
||||
logger.warn({ sessionId }, 'Payment failed');
|
||||
}
|
||||
```
|
||||
|
||||
### onInvoicePaid
|
||||
|
||||
Called when an invoice is paid (subscriptions only, useful for credit recharges).
|
||||
|
||||
```tsx
|
||||
onInvoicePaid: async (data) => {
|
||||
// data: {
|
||||
// target_account_id: string,
|
||||
// target_customer_id: string,
|
||||
// target_customer_email: string,
|
||||
// line_items: SubscriptionLineItem[],
|
||||
// }
|
||||
|
||||
const accountId = data.target_account_id;
|
||||
const variantId = data.line_items[0]?.variant_id;
|
||||
|
||||
// Recharge credits based on plan
|
||||
await rechargeCredits(accountId, variantId);
|
||||
|
||||
// Send invoice email
|
||||
await sendInvoiceEmail(data.target_customer_email);
|
||||
}
|
||||
```
|
||||
|
||||
### onEvent (Catch-All)
|
||||
|
||||
Handle any event not covered by the specific callbacks.
|
||||
|
||||
```tsx
|
||||
onEvent: async (event) => {
|
||||
// event: unknown (provider-specific event object)
|
||||
|
||||
// Example: Handle Stripe-specific events
|
||||
if (event.type === 'invoice.payment_succeeded') {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
// Custom handling
|
||||
}
|
||||
|
||||
// Example: Handle Lemon Squeezy events
|
||||
if (event.event_name === 'license_key_created') {
|
||||
// Handle license key creation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Provider-Specific Events
|
||||
|
||||
### Stripe Events
|
||||
|
||||
| Event | Callback | Description |
|
||||
|-------|----------|-------------|
|
||||
| `checkout.session.completed` | `onCheckoutSessionCompleted` | Checkout completed |
|
||||
| `customer.subscription.created` | `onSubscriptionUpdated` | New subscription |
|
||||
| `customer.subscription.updated` | `onSubscriptionUpdated` | Subscription changed |
|
||||
| `customer.subscription.deleted` | `onSubscriptionDeleted` | Subscription ended |
|
||||
| `checkout.session.async_payment_succeeded` | `onPaymentSucceeded` | Async payment succeeded |
|
||||
| `checkout.session.async_payment_failed` | `onPaymentFailed` | Async payment failed |
|
||||
| `invoice.paid` | `onInvoicePaid` | Invoice paid |
|
||||
|
||||
### Lemon Squeezy Events
|
||||
|
||||
| Event | Callback | Description |
|
||||
|-------|----------|-------------|
|
||||
| `order_created` | `onCheckoutSessionCompleted` | Order created |
|
||||
| `subscription_created` | `onCheckoutSessionCompleted` | Subscription created |
|
||||
| `subscription_updated` | `onSubscriptionUpdated` | Subscription updated |
|
||||
| `subscription_expired` | `onSubscriptionDeleted` | Subscription expired |
|
||||
|
||||
### Paddle Events
|
||||
|
||||
| Event | Callback | Description |
|
||||
|-------|----------|-------------|
|
||||
| `transaction.completed` | `onCheckoutSessionCompleted` | Transaction completed |
|
||||
| `subscription.activated` | `onSubscriptionUpdated` | Subscription activated |
|
||||
| `subscription.updated` | `onSubscriptionUpdated` | Subscription updated |
|
||||
| `subscription.canceled` | `onSubscriptionDeleted` | Subscription canceled |
|
||||
|
||||
## Example: Credit Recharge System
|
||||
|
||||
Here's a complete example of recharging credits when an invoice is paid:
|
||||
|
||||
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
|
||||
import { getBillingEventHandlerService } from '@kit/billing-gateway';
|
||||
import { getPlanTypesMap } from '@kit/billing';
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async ({ request }) => {
|
||||
const provider = billingConfig.provider;
|
||||
const logger = await getLogger();
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
const service = await getBillingEventHandlerService(
|
||||
() => adminClient,
|
||||
provider,
|
||||
getPlanTypesMap(billingConfig),
|
||||
);
|
||||
|
||||
try {
|
||||
await service.handleWebhookEvent(request, {
|
||||
onInvoicePaid: async (data) => {
|
||||
const accountId = data.target_account_id;
|
||||
const variantId = data.line_items[0]?.variant_id;
|
||||
|
||||
if (!variantId) {
|
||||
logger.error({ accountId }, 'No variant ID in invoice');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get credits for this plan from your plans table
|
||||
const { data: plan } = await adminClient
|
||||
.from('plans')
|
||||
.select('tokens')
|
||||
.eq('variant_id', variantId)
|
||||
.single();
|
||||
|
||||
if (!plan) {
|
||||
logger.error({ variantId }, 'Plan not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset credits for the account
|
||||
const { error } = await adminClient
|
||||
.from('credits')
|
||||
.upsert({
|
||||
account_id: accountId,
|
||||
tokens: plan.tokens,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error({ accountId, error }, 'Failed to update credits');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info({ accountId, tokens: plan.tokens }, 'Credits recharged');
|
||||
},
|
||||
});
|
||||
|
||||
return new Response('OK', { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Webhook processing failed');
|
||||
return new Response('Failed', { status: 500 });
|
||||
}
|
||||
},
|
||||
{ auth: false }
|
||||
);
|
||||
```
|
||||
|
||||
## Webhook Security
|
||||
|
||||
### Signature Verification
|
||||
|
||||
Makerkit automatically verifies webhook signatures. Never disable this in production.
|
||||
|
||||
The verification uses:
|
||||
- **Stripe:** `STRIPE_WEBHOOK_SECRET`
|
||||
- **Lemon Squeezy:** `LEMON_SQUEEZY_SIGNING_SECRET`
|
||||
- **Paddle:** `PADDLE_WEBHOOK_SECRET_KEY`
|
||||
|
||||
### Idempotency
|
||||
|
||||
Webhooks can be delivered multiple times. Make your handlers idempotent:
|
||||
|
||||
```tsx
|
||||
onCheckoutSessionCompleted: async (subscription) => {
|
||||
// Check if already processed
|
||||
const { data: existing } = await supabase
|
||||
.from('processed_webhooks')
|
||||
.select('id')
|
||||
.eq('subscription_id', subscription.id)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
logger.info({ id: subscription.id }, 'Already processed, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the webhook
|
||||
await processSubscription(subscription);
|
||||
|
||||
// Mark as processed
|
||||
await supabase
|
||||
.from('processed_webhooks')
|
||||
.insert({ subscription_id: subscription.id });
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Return appropriate HTTP status codes:
|
||||
|
||||
- **200:** Success (even if you skip processing)
|
||||
- **500:** Temporary failure (provider will retry)
|
||||
- **400:** Invalid request (provider won't retry)
|
||||
|
||||
```tsx
|
||||
try {
|
||||
await service.handleWebhookEvent(request, callbacks);
|
||||
return new Response('OK', { status: 200 });
|
||||
} catch (error) {
|
||||
if (isTemporaryError(error)) {
|
||||
// Provider will retry
|
||||
return new Response('Temporary failure', { status: 500 });
|
||||
}
|
||||
// Don't retry invalid requests
|
||||
return new Response('Invalid request', { status: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging Webhooks
|
||||
|
||||
### Local Development
|
||||
|
||||
Use the Stripe CLI or ngrok to test webhooks locally:
|
||||
|
||||
```bash
|
||||
# Stripe CLI
|
||||
stripe listen --forward-to localhost:3000/api/billing/webhook
|
||||
|
||||
# ngrok (for Lemon Squeezy/Paddle)
|
||||
ngrok http 3000
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Add detailed logging to track webhook processing:
|
||||
|
||||
```tsx
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ eventType: event.type }, 'Processing webhook');
|
||||
logger.debug({ payload: event }, 'Webhook payload');
|
||||
logger.error({ error }, 'Webhook failed');
|
||||
```
|
||||
|
||||
### Webhook Logs in Provider Dashboards
|
||||
|
||||
Check webhook delivery status:
|
||||
- **Stripe:** Dashboard → Developers → Webhooks → Recent events
|
||||
- **Lemon Squeezy:** Settings → Webhooks → View logs
|
||||
- **Paddle:** Developer Tools → Notifications → View logs
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts
|
||||
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Configure Stripe webhooks
|
||||
- [Lemon Squeezy Setup](/docs/next-supabase-turbo/billing/lemon-squeezy) - Configure LS webhooks
|
||||
- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Recharge credits on payment
|
||||
487
docs/billing/credit-based-billing.mdoc
Normal file
487
docs/billing/credit-based-billing.mdoc
Normal file
@@ -0,0 +1,487 @@
|
||||
---
|
||||
status: "published"
|
||||
label: 'Credits Based Billing'
|
||||
title: 'Implement Credit-Based Billing for AI SaaS Apps'
|
||||
order: 7
|
||||
description: 'Build a credit/token system for your AI SaaS. Learn how to add credits tables, consumption tracking, and automatic recharge on subscription renewal in Makerkit.'
|
||||
---
|
||||
|
||||
Credit-based billing charges users based on tokens or credits consumed rather than time. This model is common in AI SaaS applications where users pay for API calls, generated content, or compute time.
|
||||
|
||||
Makerkit doesn't include credit-based billing out of the box, but you can implement it using subscriptions plus custom database tables. This guide shows you how.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
User subscribes → Credits allocated → User consumes credits → Invoice paid → Credits recharged
|
||||
```
|
||||
|
||||
Components:
|
||||
1. **`plans` table**: Maps subscription variants to credit amounts
|
||||
2. **`credits` table**: Tracks available credits per account
|
||||
3. **Database functions**: Check and consume credits
|
||||
4. **Webhook handler**: Recharge credits on subscription renewal
|
||||
|
||||
## Step 1: Create the Plans Table
|
||||
|
||||
Store the credit allocation for each plan variant:
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
variant_id TEXT NOT NULL UNIQUE,
|
||||
tokens INTEGER NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Allow authenticated users to read plans
|
||||
CREATE POLICY read_plans ON public.plans
|
||||
FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- Insert your plans
|
||||
INSERT INTO public.plans (name, variant_id, tokens) VALUES
|
||||
('Starter', 'price_starter_monthly', 1000),
|
||||
('Pro', 'price_pro_monthly', 10000),
|
||||
('Enterprise', 'price_enterprise_monthly', 100000);
|
||||
```
|
||||
|
||||
The `variant_id` should match the line item ID in your billing schema (e.g., Stripe Price ID).
|
||||
|
||||
## Step 2: Create the Credits Table
|
||||
|
||||
Track available credits per account:
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.credits (
|
||||
account_id UUID PRIMARY KEY REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
tokens INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE public.credits ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can read their own credits
|
||||
CREATE POLICY read_credits ON public.credits
|
||||
FOR SELECT TO authenticated
|
||||
USING (account_id = (SELECT auth.uid()));
|
||||
|
||||
-- Only service role can modify credits
|
||||
-- No INSERT/UPDATE/DELETE policies for authenticated users
|
||||
```
|
||||
|
||||
{% alert type="warning" title="Security: Restrict credit modifications" %}
|
||||
Users should only read their credits. All modifications should go through the service role (admin client) to prevent manipulation.
|
||||
{% /alert %}
|
||||
|
||||
## Step 3: Create Helper Functions
|
||||
|
||||
### Check if account has enough credits
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.has_credits(
|
||||
p_account_id UUID,
|
||||
p_tokens INTEGER
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT tokens >= p_tokens
|
||||
FROM public.credits
|
||||
WHERE account_id = p_account_id
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.has_credits TO authenticated, service_role;
|
||||
```
|
||||
|
||||
### Consume credits
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.consume_credits(
|
||||
p_account_id UUID,
|
||||
p_tokens INTEGER
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
v_current_tokens INTEGER;
|
||||
BEGIN
|
||||
-- Get current balance with row lock
|
||||
SELECT tokens INTO v_current_tokens
|
||||
FROM public.credits
|
||||
WHERE account_id = p_account_id
|
||||
FOR UPDATE;
|
||||
|
||||
-- Check if enough credits
|
||||
IF v_current_tokens IS NULL OR v_current_tokens < p_tokens THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- Deduct credits
|
||||
UPDATE public.credits
|
||||
SET tokens = tokens - p_tokens,
|
||||
updated_at = NOW()
|
||||
WHERE account_id = p_account_id;
|
||||
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.consume_credits TO service_role;
|
||||
```
|
||||
|
||||
### Add credits (for recharges)
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.add_credits(
|
||||
p_account_id UUID,
|
||||
p_tokens INTEGER
|
||||
)
|
||||
RETURNS VOID
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.credits (account_id, tokens)
|
||||
VALUES (p_account_id, p_tokens)
|
||||
ON CONFLICT (account_id)
|
||||
DO UPDATE SET
|
||||
tokens = public.credits.tokens + p_tokens,
|
||||
updated_at = NOW();
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.add_credits TO service_role;
|
||||
```
|
||||
|
||||
### Reset credits (for subscription renewal)
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.reset_credits(
|
||||
p_account_id UUID,
|
||||
p_tokens INTEGER
|
||||
)
|
||||
RETURNS VOID
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.credits (account_id, tokens)
|
||||
VALUES (p_account_id, p_tokens)
|
||||
ON CONFLICT (account_id)
|
||||
DO UPDATE SET
|
||||
tokens = p_tokens,
|
||||
updated_at = NOW();
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.reset_credits TO service_role;
|
||||
```
|
||||
|
||||
## Step 4: Consume Credits in Your Application
|
||||
|
||||
When a user performs an action that costs credits:
|
||||
|
||||
```tsx
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
export async function consumeApiCredits(
|
||||
accountId: string,
|
||||
tokensRequired: number
|
||||
) {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// Consume credits atomically
|
||||
const { data: success, error } = await adminClient.rpc('consume_credits', {
|
||||
p_account_id: accountId,
|
||||
p_tokens: tokensRequired,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to consume credits: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Insufficient credits');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: AI API Route
|
||||
|
||||
```tsx
|
||||
// app/api/ai/generate/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
const TOKENS_PER_REQUEST = 10;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const client = getSupabaseServerClient();
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// Get current user's account
|
||||
const { data: { user } } = await client.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check credits before processing
|
||||
const { data: hasCredits } = await client.rpc('has_credits', {
|
||||
p_account_id: user.id,
|
||||
p_tokens: TOKENS_PER_REQUEST,
|
||||
});
|
||||
|
||||
if (!hasCredits) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Insufficient credits', code: 'INSUFFICIENT_CREDITS' },
|
||||
{ status: 402 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Call AI API
|
||||
const { prompt } = await request.json();
|
||||
const result = await callAIService(prompt);
|
||||
|
||||
// Consume credits after successful response
|
||||
await adminClient.rpc('consume_credits', {
|
||||
p_account_id: user.id,
|
||||
p_tokens: TOKENS_PER_REQUEST,
|
||||
});
|
||||
|
||||
return NextResponse.json({ result });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Generation failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Display Credits in UI
|
||||
|
||||
Create a component to show remaining credits:
|
||||
|
||||
```tsx
|
||||
// components/credits-display.tsx
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
export function CreditsDisplay({ accountId }: { accountId: string }) {
|
||||
const client = useSupabase();
|
||||
|
||||
const { data: credits, isLoading } = useQuery({
|
||||
queryKey: ['credits', accountId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await client
|
||||
.from('credits')
|
||||
.select('tokens')
|
||||
.eq('account_id', accountId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data?.tokens ?? 0;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <span>Loading...</span>;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Credits:</span>
|
||||
<span className="font-medium">{credits?.toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Recharge Credits on Subscription Renewal
|
||||
|
||||
Extend the webhook handler to recharge credits when an invoice is paid:
|
||||
|
||||
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
|
||||
import { getBillingEventHandlerService } from '@kit/billing-gateway';
|
||||
import { getPlanTypesMap } from '@kit/billing';
|
||||
import { enhanceRouteHandler } from '@kit/next/routes';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
export const POST = enhanceRouteHandler(
|
||||
async ({ request }) => {
|
||||
const provider = billingConfig.provider;
|
||||
const logger = await getLogger();
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
const service = await getBillingEventHandlerService(
|
||||
() => adminClient,
|
||||
provider,
|
||||
getPlanTypesMap(billingConfig),
|
||||
);
|
||||
|
||||
try {
|
||||
await service.handleWebhookEvent(request, {
|
||||
onInvoicePaid: async (data) => {
|
||||
const accountId = data.target_account_id;
|
||||
const variantId = data.line_items[0]?.variant_id;
|
||||
|
||||
if (!variantId) {
|
||||
logger.warn({ accountId }, 'No variant ID in invoice');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get token allocation for this plan
|
||||
const { data: plan, error: planError } = await adminClient
|
||||
.from('plans')
|
||||
.select('tokens')
|
||||
.eq('variant_id', variantId)
|
||||
.single();
|
||||
|
||||
if (planError || !plan) {
|
||||
logger.error({ variantId, planError }, 'Plan not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset credits to plan allocation
|
||||
const { error: creditError } = await adminClient.rpc('reset_credits', {
|
||||
p_account_id: accountId,
|
||||
p_tokens: plan.tokens,
|
||||
});
|
||||
|
||||
if (creditError) {
|
||||
logger.error({ accountId, creditError }, 'Failed to reset credits');
|
||||
throw creditError;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ accountId, tokens: plan.tokens },
|
||||
'Credits recharged on invoice payment'
|
||||
);
|
||||
},
|
||||
|
||||
onCheckoutSessionCompleted: async (subscription) => {
|
||||
// Also allocate credits on initial subscription
|
||||
const accountId = subscription.target_account_id;
|
||||
const variantId = subscription.line_items[0]?.variant_id;
|
||||
|
||||
if (!variantId) return;
|
||||
|
||||
const { data: plan } = await adminClient
|
||||
.from('plans')
|
||||
.select('tokens')
|
||||
.eq('variant_id', variantId)
|
||||
.single();
|
||||
|
||||
if (plan) {
|
||||
await adminClient.rpc('reset_credits', {
|
||||
p_account_id: accountId,
|
||||
p_tokens: plan.tokens,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ accountId, tokens: plan.tokens },
|
||||
'Initial credits allocated'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response('OK', { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Webhook failed');
|
||||
return new Response('Failed', { status: 500 });
|
||||
}
|
||||
},
|
||||
{ auth: false }
|
||||
);
|
||||
```
|
||||
|
||||
## Step 7: Use Credits in RLS Policies (Optional)
|
||||
|
||||
Gate features based on credit balance:
|
||||
|
||||
```sql
|
||||
-- Only allow creating tasks if user has credits
|
||||
CREATE POLICY tasks_insert_with_credits ON public.tasks
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
public.has_credits((SELECT auth.uid()), 1)
|
||||
);
|
||||
|
||||
-- Only allow API calls if user has credits
|
||||
CREATE POLICY api_calls_with_credits ON public.api_logs
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
public.has_credits(account_id, 1)
|
||||
);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
1. Create a subscription in test mode
|
||||
2. Verify initial credits are allocated
|
||||
3. Consume some credits via your API
|
||||
4. Trigger a subscription renewal (Stripe: `stripe trigger invoice.paid`)
|
||||
5. Verify credits are recharged
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Rollover Credits
|
||||
|
||||
To allow unused credits to roll over:
|
||||
|
||||
```sql
|
||||
-- In onInvoicePaid, add instead of reset:
|
||||
await adminClient.rpc('add_credits', {
|
||||
p_account_id: accountId,
|
||||
p_tokens: plan.tokens,
|
||||
});
|
||||
```
|
||||
|
||||
### Credit Expiration
|
||||
|
||||
Add an expiration date to credits:
|
||||
|
||||
```sql
|
||||
ALTER TABLE public.credits ADD COLUMN expires_at TIMESTAMPTZ;
|
||||
|
||||
-- Check expiration in has_credits function
|
||||
CREATE OR REPLACE FUNCTION public.has_credits(...)
|
||||
-- Add: AND (expires_at IS NULL OR expires_at > NOW())
|
||||
```
|
||||
|
||||
### Usage Tracking
|
||||
|
||||
Track credit consumption for analytics:
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.credit_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
account_id UUID REFERENCES accounts(id),
|
||||
amount INTEGER NOT NULL,
|
||||
type TEXT NOT NULL, -- 'consume', 'recharge', 'bonus'
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Billing architecture
|
||||
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Webhook event handling
|
||||
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Alternative usage-based billing
|
||||
- [Database Functions](/docs/next-supabase-turbo/development/database-functions) - Creating Postgres functions
|
||||
638
docs/billing/custom-integration.mdoc
Normal file
638
docs/billing/custom-integration.mdoc
Normal file
@@ -0,0 +1,638 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Custom Integration"
|
||||
title: "How to create a custom billing integration in Makerkit"
|
||||
order: 11
|
||||
description: "Learn how to create a custom billing integration in Makerkit"
|
||||
---
|
||||
|
||||
This guide explains how to create billing integration plugins for the Makerkit SaaS platform to allow you to use a custom billing provider.
|
||||
|
||||
{% sequence title="How to create a custom billing integration in Makerkit" description="Learn how to create a custom billing integration in Makerkit" %}
|
||||
|
||||
[Architecture Overview](#architecture-overview)
|
||||
|
||||
[Package Structure](#package-structure)
|
||||
|
||||
[Core Interface Implementation](#core-interface-implementation)
|
||||
|
||||
[Environment Configuration](#environment-configuration)
|
||||
|
||||
[Billing Strategy Service](#billing-strategy-service)
|
||||
|
||||
[Webhook Handler Service](#webhook-handler-service)
|
||||
|
||||
[Client-Side Components](#client-side-components)
|
||||
|
||||
[Registration and Integration](#registration-and-integration)
|
||||
|
||||
[Testing Strategy](#testing-strategy)
|
||||
|
||||
[Security Best Practices](#security-best-practices)
|
||||
|
||||
[Example Implementation](#example-implementation)
|
||||
{% /sequence %}
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The Makerkit billing system uses a plugin-based architecture that allows multiple billing providers to coexist. The system consists of:
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Billing Strategy Provider Service** - Abstract interface for billing operations
|
||||
2. **Billing Webhook Handler Service** - Abstract interface for webhook processing
|
||||
3. **Registry System** - Dynamic loading and management of providers
|
||||
4. **Schema Validation** - Type-safe configuration and data validation
|
||||
|
||||
### Provider Structure
|
||||
|
||||
Each billing provider is implemented as a separate package under `packages/{provider-name}/` with:
|
||||
|
||||
- **Server-side services** - Billing operations and webhook handling
|
||||
- **Client-side components** - Checkout flows and UI integration
|
||||
- **Configuration schemas** - Environment variable validation
|
||||
- **SDK abstractions** - Provider-specific API integrations
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Client Request → Registry → Provider Service → External API → Webhook → Handler → Database
|
||||
```
|
||||
|
||||
## Creating a package
|
||||
|
||||
You can create a new package for your billing provider by running the following command:
|
||||
|
||||
```bash
|
||||
pnpm turbo gen package
|
||||
```
|
||||
|
||||
This will create a new package in the packages directory, ready to use. You can move this anywhere in the `packages` directory, but we recommend keeping it in the `packages/billing` directory.
|
||||
|
||||
## Package Structure
|
||||
|
||||
Once we finalize the package structure, your structure should look like this:
|
||||
|
||||
```
|
||||
packages/{provider-name}/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── index.ts
|
||||
└── src/
|
||||
├── index.ts
|
||||
├── components/
|
||||
│ ├── index.ts
|
||||
│ └── {provider}-checkout.tsx
|
||||
├── constants/
|
||||
│ └── {provider}-events.ts
|
||||
├── schema/
|
||||
│ ├── {provider}-client-env.schema.ts
|
||||
│ └── {provider}-server-env.schema.ts
|
||||
└── services/
|
||||
├── {provider}-billing-strategy.service.ts
|
||||
├── {provider}-webhook-handler.service.ts
|
||||
├── {provider}-sdk.ts
|
||||
└── create-{provider}-billing-portal-session.ts
|
||||
```
|
||||
|
||||
### package.json Template
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@kit/{provider-name}",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"{provider-sdk}": "^x.x.x"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/billing": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/react": "19.1.13",
|
||||
"next": "16.0.0",
|
||||
"react": "19.1.1",
|
||||
"zod": "^3.25.74"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core Interface Implementation
|
||||
|
||||
### BillingStrategyProviderService
|
||||
|
||||
This abstract class defines the contract for all billing operations:
|
||||
|
||||
```typescript {% title="packages/{provider}/src/services/{provider}-billing-strategy.service.ts" %}
|
||||
import { BillingStrategyProviderService } from '@kit/billing';
|
||||
|
||||
export class YourProviderBillingStrategyService
|
||||
implements BillingStrategyProviderService
|
||||
{
|
||||
private readonly namespace = 'billing.{provider}';
|
||||
|
||||
async createCheckoutSession(params) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
async createBillingPortalSession(params) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
async cancelSubscription(params) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
async retrieveCheckoutSession(params) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
async reportUsage(params) {
|
||||
// Implementation (if supported)
|
||||
}
|
||||
|
||||
async queryUsage(params) {
|
||||
// Implementation (if supported)
|
||||
}
|
||||
|
||||
async updateSubscriptionItem(params) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
async getPlanById(planId: string) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
async getSubscription(subscriptionId: string) {
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### BillingWebhookHandlerService
|
||||
|
||||
This abstract class handles webhook events from the billing provider:
|
||||
|
||||
```typescript {% title="packages/{provider}/src/services/{provider}-webhook-handler.service.ts" %}
|
||||
import { BillingWebhookHandlerService } from '@kit/billing';
|
||||
|
||||
export class YourProviderWebhookHandlerService
|
||||
implements BillingWebhookHandlerService
|
||||
{
|
||||
private readonly provider = '{provider}' as const;
|
||||
private readonly namespace = 'billing.{provider}';
|
||||
|
||||
async verifyWebhookSignature(request: Request) {
|
||||
// Verify signature using provider's SDK
|
||||
// Throw error if invalid
|
||||
}
|
||||
|
||||
async handleWebhookEvent(event: unknown, params) {
|
||||
// Route events to appropriate handlers
|
||||
switch (event.type) {
|
||||
case 'subscription.created':
|
||||
return this.handleSubscriptionCreated(event, params);
|
||||
|
||||
case 'subscription.updated':
|
||||
return this.handleSubscriptionUpdated(event, params);
|
||||
// ... other events
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Server Environment Schema
|
||||
|
||||
Create schemas for server-side configuration:
|
||||
|
||||
```typescript {% title="packages/{provider}/src/schema/{provider}-server-env.schema.ts" %}
|
||||
// src/schema/{provider}-server-env.schema.ts
|
||||
import * as z from 'zod';
|
||||
|
||||
export const YourProviderServerEnvSchema = z.object({
|
||||
apiKey: z.string({
|
||||
description: '{Provider} API key for server-side operations',
|
||||
required_error: '{PROVIDER}_API_KEY is required',
|
||||
}),
|
||||
webhooksSecret: z.string({
|
||||
description: '{Provider} webhook secret for verifying signatures',
|
||||
required_error: '{PROVIDER}_WEBHOOK_SECRET is required',
|
||||
}),
|
||||
});
|
||||
|
||||
export type YourProviderServerEnv = z.infer<typeof YourProviderServerEnvSchema>;
|
||||
```
|
||||
|
||||
### Client Environment Schema
|
||||
|
||||
Create schemas for client-side configuration:
|
||||
|
||||
```typescript {% title="packages/{provider}/src/schema/{provider}-client-env.schema.ts" %}
|
||||
// src/schema/{provider}-client-env.schema.ts
|
||||
import * as z from 'zod';
|
||||
|
||||
export const YourProviderClientEnvSchema = z.object({
|
||||
publicKey: z.string({
|
||||
description: '{Provider} public key for client-side operations',
|
||||
required_error: 'NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY is required',
|
||||
}),
|
||||
});
|
||||
|
||||
export type YourProviderClientEnv = z.infer<typeof YourProviderClientEnvSchema>;
|
||||
```
|
||||
|
||||
## Billing Strategy Service
|
||||
|
||||
### Implementation Example
|
||||
|
||||
{% alert type="warning" title="This is an abstract example" %}
|
||||
The "client" class in the example below is not a real class, it's just an example of how to implement the BillingStrategyProviderService interface. You should refer to the SDK of your billing provider to implement the actual methods.
|
||||
{% /alert %}
|
||||
|
||||
Here's a detailed implementation pattern based on the Paddle service:
|
||||
|
||||
```typescript
|
||||
import 'server-only';
|
||||
import * as z from 'zod';
|
||||
import { BillingStrategyProviderService } from '@kit/billing';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { createYourProviderClient } from './your-provider-sdk';
|
||||
|
||||
export class YourProviderBillingStrategyService
|
||||
implements BillingStrategyProviderService
|
||||
{
|
||||
private readonly namespace = 'billing.{provider}';
|
||||
|
||||
async createCheckoutSession(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
const client = await createYourProviderClient();
|
||||
|
||||
const ctx = {
|
||||
name: this.namespace,
|
||||
customerId: params.customerId,
|
||||
accountId: params.accountId,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Creating checkout session...');
|
||||
|
||||
try {
|
||||
const response = await client.checkout.create({
|
||||
customer: {
|
||||
id: params.customerId,
|
||||
email: params.customerEmail,
|
||||
},
|
||||
lineItems: params.plan.lineItems.map((item) => ({
|
||||
priceId: item.id,
|
||||
quantity: 1,
|
||||
})),
|
||||
successUrl: params.returnUrl,
|
||||
metadata: {
|
||||
accountId: params.accountId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(ctx, 'Checkout session created successfully');
|
||||
|
||||
return {
|
||||
checkoutToken: response.id,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to create checkout session');
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSubscription(
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
const client = await createYourProviderClient();
|
||||
|
||||
const ctx = {
|
||||
name: this.namespace,
|
||||
subscriptionId: params.subscriptionId,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Cancelling subscription...');
|
||||
|
||||
try {
|
||||
await client.subscriptions.cancel(params.subscriptionId, {
|
||||
immediate: params.invoiceNow ?? true,
|
||||
});
|
||||
|
||||
logger.info(ctx, 'Subscription cancelled successfully');
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to cancel subscription');
|
||||
throw new Error('Failed to cancel subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Implement other required methods...
|
||||
}
|
||||
```
|
||||
|
||||
### SDK Client Wrapper
|
||||
|
||||
Create a reusable SDK client:
|
||||
|
||||
```typescript
|
||||
// src/services/{provider}-sdk.ts
|
||||
import 'server-only';
|
||||
import { YourProviderServerEnvSchema } from '../schema/{provider}-server-env.schema';
|
||||
|
||||
export async function createYourProviderClient() {
|
||||
// parse the environment variables
|
||||
const config = YourProviderServerEnvSchema.parse({
|
||||
apiKey: process.env.{PROVIDER}_API_KEY,
|
||||
webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET,
|
||||
});
|
||||
|
||||
return new YourProviderSDK({
|
||||
apiKey: config.apiKey,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook Handler Service
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
```typescript
|
||||
import { BillingWebhookHandlerService, PlanTypeMap } from '@kit/billing';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { createYourProviderClient } from './your-provider-sdk';
|
||||
|
||||
export class YourProviderWebhookHandlerService
|
||||
implements BillingWebhookHandlerService
|
||||
{
|
||||
constructor(private readonly planTypesMap: PlanTypeMap) {}
|
||||
|
||||
private readonly provider = '{provider}' as const;
|
||||
private readonly namespace = 'billing.{provider}';
|
||||
|
||||
async verifyWebhookSignature(request: Request) {
|
||||
const body = await request.clone().text();
|
||||
const signature = request.headers.get('{provider}-signature');
|
||||
|
||||
if (!signature) {
|
||||
throw new Error('Missing {provider} signature');
|
||||
}
|
||||
|
||||
const { webhooksSecret } = YourProviderServerEnvSchema.parse({
|
||||
apiKey: process.env.{PROVIDER}_API_KEY,
|
||||
webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET,
|
||||
environment: process.env.{PROVIDER}_ENVIRONMENT || 'sandbox',
|
||||
});
|
||||
|
||||
const client = await createYourProviderClient();
|
||||
|
||||
try {
|
||||
const eventData = await client.webhooks.verify(body, signature, webhooksSecret);
|
||||
|
||||
if (!eventData) {
|
||||
throw new Error('Invalid signature');
|
||||
}
|
||||
|
||||
return eventData;
|
||||
} catch (error) {
|
||||
throw new Error(`Webhook signature verification failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebhookEvent(event: unknown, params) {
|
||||
const logger = await getLogger();
|
||||
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
return this.handleCheckoutCompleted(event, params.onCheckoutSessionCompleted);
|
||||
}
|
||||
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated': {
|
||||
return this.handleSubscriptionUpdated(event, params.onSubscriptionUpdated);
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
return this.handleSubscriptionDeleted(event, params.onSubscriptionDeleted);
|
||||
}
|
||||
|
||||
default: {
|
||||
logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
eventType: event.type,
|
||||
},
|
||||
'Unhandled webhook event type',
|
||||
);
|
||||
|
||||
if (params.onEvent) {
|
||||
await params.onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutCompleted(event, onCheckoutSessionCompleted) {
|
||||
// Extract subscription/order data from event
|
||||
// Transform to standard format
|
||||
// Call onCheckoutSessionCompleted with normalized data
|
||||
}
|
||||
|
||||
// Implement other event handlers...
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Components
|
||||
|
||||
### Checkout Component
|
||||
|
||||
Create a React component for the checkout flow:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { YourProviderClientEnvSchema } from '../schema/{provider}-client-env.schema';
|
||||
|
||||
interface YourProviderCheckoutProps {
|
||||
onClose?: () => void;
|
||||
checkoutToken: string;
|
||||
}
|
||||
|
||||
const config = YourProviderClientEnvSchema.parse({
|
||||
publicKey: process.env.NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY,
|
||||
environment: process.env.NEXT_PUBLIC_{PROVIDER}_ENVIRONMENT || 'sandbox',
|
||||
});
|
||||
|
||||
export function YourProviderCheckout({
|
||||
onClose,
|
||||
checkoutToken,
|
||||
}: YourProviderCheckoutProps) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function initializeCheckout() {
|
||||
try {
|
||||
// Initialize provider's JavaScript SDK
|
||||
const { YourProviderSDK } = await import('{provider}-js-sdk');
|
||||
|
||||
const sdk = new YourProviderSDK({
|
||||
publicKey: config.publicKey,
|
||||
environment: config.environment,
|
||||
});
|
||||
|
||||
// Open checkout
|
||||
await sdk.redirectToCheckout({
|
||||
sessionId: checkoutToken,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Checkout failed';
|
||||
setError(errorMessage);
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
void initializeCheckout();
|
||||
}, [checkoutToken, onClose]);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
return null; // Provider handles the UI
|
||||
}
|
||||
```
|
||||
|
||||
## Registration and Integration
|
||||
|
||||
### Register Billing Strategy
|
||||
|
||||
Add your provider to the billing strategy registry:
|
||||
|
||||
```typescript
|
||||
// packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts
|
||||
|
||||
// Register {Provider} billing strategy
|
||||
billingStrategyRegistry.register('{provider}', async () => {
|
||||
const { YourProviderBillingStrategyService } = await import('@kit/{provider}');
|
||||
return new YourProviderBillingStrategyService();
|
||||
});
|
||||
```
|
||||
|
||||
### Register Webhook Handler
|
||||
|
||||
Add your provider to the webhook handler factory:
|
||||
|
||||
```typescript
|
||||
// packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts
|
||||
|
||||
// Register {Provider} webhook handler
|
||||
billingWebhookHandlerRegistry.register('{provider}', async () => {
|
||||
const { YourProviderWebhookHandlerService } = await import('@kit/{provider}');
|
||||
return new YourProviderWebhookHandlerService(planTypesMap);
|
||||
});
|
||||
```
|
||||
|
||||
### Update Package Exports
|
||||
|
||||
Export your services from the main index file:
|
||||
|
||||
```typescript
|
||||
// packages/{provider}/src/index.ts
|
||||
export { YourProviderBillingStrategyService } from './services/{provider}-billing-strategy.service';
|
||||
export { YourProviderWebhookHandlerService } from './services/{provider}-webhook-handler.service';
|
||||
export * from './components';
|
||||
export * from './constants/{provider}-events';
|
||||
export {
|
||||
YourProviderClientEnvSchema,
|
||||
type YourProviderClientEnv,
|
||||
} from './schema/{provider}-client-env.schema';
|
||||
export {
|
||||
YourProviderServerEnvSchema,
|
||||
type YourProviderServerEnv,
|
||||
} from './schema/{provider}-server-env.schema';
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Environment Variables
|
||||
|
||||
1. **Never expose secrets in client-side code**
|
||||
2. **Use different credentials for sandbox and production**
|
||||
3. **Validate all environment variables with Zod schemas**
|
||||
4. **Store secrets securely (e.g., in environment variables or secret managers)**
|
||||
|
||||
### Webhook Security
|
||||
|
||||
1. **Always verify webhook signatures**
|
||||
2. **Use HTTPS endpoints for webhooks**
|
||||
3. **Log security events for monitoring**
|
||||
|
||||
### Data Handling
|
||||
|
||||
1. **Validate all incoming data with Zod schemas**
|
||||
2. **Sanitize user inputs**
|
||||
3. **Never log sensitive information (API keys, customer data)**
|
||||
4. **Use structured logging with appropriate log levels**
|
||||
|
||||
### Error Handling
|
||||
|
||||
1. **Don't expose internal errors to users**
|
||||
2. **Log errors with sufficient context for debugging**
|
||||
3. **Implement proper error boundaries in React components**
|
||||
4. **Handle rate limiting and API errors gracefully**
|
||||
|
||||
## Example Implementation
|
||||
|
||||
For a complete reference implementation, see the Stripe integration at `packages/billing/stripe/`. Key files to study:
|
||||
|
||||
- `src/services/stripe-billing-strategy.service.ts` - Complete billing strategy implementation
|
||||
- `src/services/stripe-webhook-handler.service.ts` - Webhook handling patterns
|
||||
- `src/components/stripe-embedded-checkout.tsx` - Client-side checkout component
|
||||
- `src/schema/` - Environment configuration schemas
|
||||
|
||||
Also take a look at the Lemon Squeezy integration at `packages/billing/lemon-squeezy/` or the Paddle integration at `packages/plugins/paddle/` (in the plugins repository)
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Important:** Different providers have different APIs, so the implementation will be different for each provider.
|
||||
|
||||
Following this guide, you should be able to create a robust billing integration that:
|
||||
|
||||
- Implements all required interfaces correctly
|
||||
- Handles errors gracefully and securely
|
||||
- Provides a good user experience
|
||||
- Follows established patterns and best practices
|
||||
- Integrates seamlessly with the existing billing system
|
||||
|
||||
Remember to:
|
||||
|
||||
1. Test thoroughly with the provider's sandbox environment
|
||||
2. Follow security best practices throughout development
|
||||
3. Document any provider-specific requirements or limitations
|
||||
4. Consider edge cases and error scenarios
|
||||
5. Validate your implementation against the existing test suite
|
||||
266
docs/billing/lemon-squeezy.mdoc
Normal file
266
docs/billing/lemon-squeezy.mdoc
Normal file
@@ -0,0 +1,266 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Lemon Squeezy"
|
||||
title: "Configure Lemon Squeezy Billing for Your Next.js SaaS"
|
||||
order: 3
|
||||
description: "Complete guide to setting up Lemon Squeezy payments in Makerkit. Lemon Squeezy is a Merchant of Record that handles global tax compliance, billing, and payments for your SaaS."
|
||||
---
|
||||
|
||||
Lemon Squeezy is a Merchant of Record (MoR), meaning they handle all billing complexity for you: VAT, sales tax, invoicing, and compliance across 100+ countries. You receive payouts minus their fees.
|
||||
|
||||
## Why Choose Lemon Squeezy?
|
||||
|
||||
**Pros:**
|
||||
- Automatic global tax compliance (VAT, GST, sales tax)
|
||||
- No need to register for tax collection in different countries
|
||||
- Simpler setup than Stripe for international sales
|
||||
- Built-in license key generation (great for desktop apps)
|
||||
- Lower complexity for solo founders
|
||||
|
||||
**Cons:**
|
||||
- One line item per plan (no mixing flat + metered + per-seat)
|
||||
- Less flexibility than Stripe
|
||||
- Higher fees than Stripe in some regions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Create a [Lemon Squeezy account](https://lemonsqueezy.com)
|
||||
2. Create a Store in your Lemon Squeezy dashboard
|
||||
3. Create Products and Variants for your pricing plans
|
||||
4. Set up a webhook endpoint
|
||||
|
||||
## Step 1: Environment Variables
|
||||
|
||||
Add these variables to your `.env.local`:
|
||||
|
||||
```bash
|
||||
LEMON_SQUEEZY_SECRET_KEY=your_api_key_here
|
||||
LEMON_SQUEEZY_SIGNING_SECRET=your_webhook_signing_secret
|
||||
LEMON_SQUEEZY_STORE_ID=your_store_id
|
||||
```
|
||||
|
||||
| Variable | Description | Where to Find |
|
||||
|----------|-------------|---------------|
|
||||
| `LEMON_SQUEEZY_SECRET_KEY` | API key for server-side calls | Settings → API |
|
||||
| `LEMON_SQUEEZY_SIGNING_SECRET` | Webhook signature verification | Settings → Webhooks |
|
||||
| `LEMON_SQUEEZY_STORE_ID` | Your store's numeric ID | Settings → Stores |
|
||||
|
||||
{% alert type="error" title="Keep secrets secure" %}
|
||||
Add these to `.env.local` only. Never commit them to your repository or add them to `.env`.
|
||||
{% /alert %}
|
||||
|
||||
## Step 2: Configure Billing Provider
|
||||
|
||||
Set Lemon Squeezy as your billing provider in the environment:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_BILLING_PROVIDER=lemon-squeezy
|
||||
```
|
||||
|
||||
And update the database:
|
||||
|
||||
```sql
|
||||
UPDATE public.config SET billing_provider = 'lemon-squeezy';
|
||||
```
|
||||
|
||||
## Step 3: Create Products in Lemon Squeezy
|
||||
|
||||
1. Go to your Lemon Squeezy Dashboard → Products
|
||||
2. Click **New Product**
|
||||
3. Configure your product:
|
||||
- **Name**: "Pro Plan", "Starter Plan", etc.
|
||||
- **Pricing**: Choose subscription or one-time
|
||||
- **Variant**: Create variants for different billing intervals
|
||||
|
||||
4. Copy the **Variant ID** (not Product ID) for your billing schema
|
||||
|
||||
The Variant ID looks like `123456` (numeric). This goes in your line item's `id` field.
|
||||
|
||||
## Step 4: Update Billing Schema
|
||||
|
||||
Lemon Squeezy has a key limitation: **one line item per plan**. You cannot mix flat, per-seat, and metered billing in a single plan.
|
||||
|
||||
```tsx
|
||||
import { createBillingSchema } from '@kit/billing';
|
||||
|
||||
export default createBillingSchema({
|
||||
provider: 'lemon-squeezy',
|
||||
products: [
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
description: 'For professionals',
|
||||
currency: 'USD',
|
||||
plans: [
|
||||
{
|
||||
id: 'pro-monthly',
|
||||
name: 'Pro Monthly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: '123456', // Lemon Squeezy Variant ID
|
||||
name: 'Pro Plan',
|
||||
cost: 29,
|
||||
type: 'flat',
|
||||
},
|
||||
// Cannot add more line items with Lemon Squeezy!
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
{% alert type="warning" title="Single line item only" %}
|
||||
The schema validation will fail if you add multiple line items with Lemon Squeezy. This is a platform limitation.
|
||||
{% /alert %}
|
||||
|
||||
## Step 5: Configure Webhooks
|
||||
|
||||
### Local Development
|
||||
|
||||
Lemon Squeezy requires a public URL for webhooks. Use a tunneling service like ngrok:
|
||||
|
||||
```bash
|
||||
# Install ngrok
|
||||
npm install -g ngrok
|
||||
|
||||
# Expose your local server
|
||||
ngrok http 3000
|
||||
```
|
||||
|
||||
Copy the ngrok URL (e.g., `https://abc123.ngrok.io`).
|
||||
|
||||
### Create Webhook in Lemon Squeezy
|
||||
|
||||
1. Go to Settings → Webhooks
|
||||
2. Click **Add Webhook**
|
||||
3. Configure:
|
||||
- **URL**: `https://your-ngrok-url.ngrok.io/api/billing/webhook` (dev) or `https://yourdomain.com/api/billing/webhook` (prod)
|
||||
- **Secret**: Generate a secure secret and save it as `LEMON_SQUEEZY_SIGNING_SECRET`
|
||||
|
||||
4. Select these events:
|
||||
- `order_created`
|
||||
- `subscription_created`
|
||||
- `subscription_updated`
|
||||
- `subscription_expired`
|
||||
|
||||
5. Click **Save**
|
||||
|
||||
### Production Webhooks
|
||||
|
||||
For production, replace the ngrok URL with your actual domain:
|
||||
|
||||
```
|
||||
https://yourdomain.com/api/billing/webhook
|
||||
```
|
||||
|
||||
## Metered Usage with Lemon Squeezy
|
||||
|
||||
Lemon Squeezy handles metered billing differently than Stripe. Usage applies to the entire subscription, not individual line items.
|
||||
|
||||
### Setup Fee + Metered Usage
|
||||
|
||||
Use the `setupFee` property for a flat base charge plus usage-based pricing:
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'api-monthly',
|
||||
name: 'API Monthly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: '123456',
|
||||
name: 'API Access',
|
||||
cost: 0,
|
||||
type: 'metered',
|
||||
unit: 'requests',
|
||||
setupFee: 10, // $10 base fee
|
||||
tiers: [
|
||||
{ upTo: 1000, cost: 0 },
|
||||
{ upTo: 'unlimited', cost: 0.001 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
The setup fee is charged once when the subscription is created.
|
||||
|
||||
### Reporting Usage
|
||||
|
||||
Report usage using the billing API:
|
||||
|
||||
```tsx
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
|
||||
async function reportUsage(subscriptionItemId: string, quantity: number) {
|
||||
const service = createBillingGatewayService('lemon-squeezy');
|
||||
|
||||
return service.reportUsage({
|
||||
id: subscriptionItemId, // From subscription_items table
|
||||
usage: {
|
||||
quantity,
|
||||
action: 'increment',
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
See the [metered usage guide](/docs/next-supabase-turbo/billing/metered-usage) for complete implementation details.
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Mode
|
||||
|
||||
Lemon Squeezy has a test mode. Enable it in your dashboard under Settings → Test Mode.
|
||||
|
||||
Test mode uses separate products and variants, so create test versions of your products.
|
||||
|
||||
### Test Cards
|
||||
|
||||
In test mode, use these card numbers:
|
||||
- **Success**: `4242 4242 4242 4242`
|
||||
- **Decline**: `4000 0000 0000 0002`
|
||||
|
||||
Any future expiry date and any 3-digit CVC will work.
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Webhook signature verification failed
|
||||
|
||||
1. Check that `LEMON_SQUEEZY_SIGNING_SECRET` matches the secret in your Lemon Squeezy webhook settings
|
||||
2. Ensure the raw request body is used for verification (not parsed JSON)
|
||||
3. Verify the webhook URL is correct
|
||||
|
||||
### Subscription not created
|
||||
|
||||
1. Check webhook logs in Lemon Squeezy dashboard
|
||||
2. Verify the `order_created` event is enabled
|
||||
3. Check your application logs for errors
|
||||
|
||||
### Multiple line items error
|
||||
|
||||
Lemon Squeezy only supports one line item per plan. Restructure your pricing to use a single line item, or use Stripe for more complex pricing models.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
Before going live:
|
||||
|
||||
- [ ] Create test products in Lemon Squeezy test mode
|
||||
- [ ] Test subscription checkout with test card
|
||||
- [ ] Verify subscription appears in user's billing section
|
||||
- [ ] Test subscription cancellation
|
||||
- [ ] Verify webhook events are processed correctly
|
||||
- [ ] Test with failing card to verify error handling
|
||||
- [ ] Switch to production products and webhook URL
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and provider comparison
|
||||
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure your pricing
|
||||
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Custom webhook handling
|
||||
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Usage-based billing implementation
|
||||
399
docs/billing/metered-usage.mdoc
Normal file
399
docs/billing/metered-usage.mdoc
Normal file
@@ -0,0 +1,399 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Metered Usage"
|
||||
title: "Implement Metered Usage Billing for APIs and SaaS"
|
||||
order: 5
|
||||
description: "Charge customers based on actual usage with metered billing. Learn how to configure usage-based pricing and report usage to Stripe or Lemon Squeezy in your Next.js SaaS."
|
||||
---
|
||||
|
||||
Metered usage billing charges customers based on consumption (API calls, storage, compute time, etc.). You report usage throughout the billing period, and the provider calculates charges at invoice time.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Customer subscribes to a metered plan
|
||||
2. Your application tracks usage and reports it to the billing provider
|
||||
3. At the end of each billing period, the provider invoices based on total usage
|
||||
4. Makerkit stores usage data in `subscription_items` for reference
|
||||
|
||||
## Schema Configuration
|
||||
|
||||
Define a metered line item in your billing schema:
|
||||
|
||||
```tsx {% title="apps/web/config/billing.config.ts" %}
|
||||
{
|
||||
id: 'api-plan',
|
||||
name: 'API Plan',
|
||||
description: 'Pay only for what you use',
|
||||
currency: 'USD',
|
||||
plans: [
|
||||
{
|
||||
id: 'api-monthly',
|
||||
name: 'API Monthly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_api_requests', // Provider Price ID
|
||||
name: 'API Requests',
|
||||
cost: 0,
|
||||
type: 'metered',
|
||||
unit: 'requests',
|
||||
tiers: [
|
||||
{ upTo: 1000, cost: 0 }, // First 1000 free
|
||||
{ upTo: 10000, cost: 0.001 }, // $0.001/request
|
||||
{ upTo: 'unlimited', cost: 0.0005 }, // Volume discount
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
The `tiers` define progressive pricing. The last tier should always have `upTo: 'unlimited'`.
|
||||
|
||||
## Provider Differences
|
||||
|
||||
Stripe and Lemon Squeezy handle metered billing differently:
|
||||
|
||||
| Feature | Stripe | Lemon Squeezy |
|
||||
|---------|--------|---------------|
|
||||
| Report to | Customer ID + meter name | Subscription item ID |
|
||||
| Usage action | Implicit increment | Explicit `increment` or `set` |
|
||||
| Multiple meters | Yes (per customer) | No (per subscription) |
|
||||
| Real-time usage | Yes (Billing Meter) | Limited |
|
||||
|
||||
## Stripe Implementation
|
||||
|
||||
Stripe uses [Billing Meters](https://docs.stripe.com/billing/subscriptions/usage-based/implementation-guide) for metered billing.
|
||||
|
||||
### 1. Create a Meter in Stripe
|
||||
|
||||
1. Go to Stripe Dashboard → Billing → Meters
|
||||
2. Click **Create meter**
|
||||
3. Configure:
|
||||
- **Event name**: `api_requests` (you'll use this in your code)
|
||||
- **Aggregation**: Sum (most common)
|
||||
- **Value key**: `value` (default)
|
||||
|
||||
### 2. Create a Metered Price
|
||||
|
||||
1. Go to Products → Your Product
|
||||
2. Add a price with **Usage-based** pricing
|
||||
3. Select your meter
|
||||
4. Configure tier pricing
|
||||
|
||||
### 3. Report Usage
|
||||
|
||||
```tsx
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function reportApiUsage(accountId: string, requestCount: number) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
const api = createAccountsApi(supabase);
|
||||
|
||||
// Get customer ID for this account
|
||||
const customerId = await api.getCustomerId(accountId);
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('No billing customer found');
|
||||
}
|
||||
|
||||
const service = createBillingGatewayService('stripe');
|
||||
|
||||
await service.reportUsage({
|
||||
id: customerId,
|
||||
eventName: 'api_requests', // Matches your Stripe meter
|
||||
usage: {
|
||||
quantity: requestCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Integrate with Your API
|
||||
|
||||
```tsx
|
||||
// app/api/data/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { reportApiUsage } from '~/lib/billing';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const accountId = getAccountIdFromRequest(request);
|
||||
|
||||
// Process the request
|
||||
const data = await fetchData();
|
||||
|
||||
// Report usage (fire and forget or await)
|
||||
reportApiUsage(accountId, 1).catch(console.error);
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
```
|
||||
|
||||
For high-volume APIs, batch usage reports:
|
||||
|
||||
```tsx
|
||||
// lib/usage-buffer.ts
|
||||
const usageBuffer = new Map<string, number>();
|
||||
|
||||
export function bufferUsage(accountId: string, quantity: number) {
|
||||
const current = usageBuffer.get(accountId) ?? 0;
|
||||
usageBuffer.set(accountId, current + quantity);
|
||||
}
|
||||
|
||||
// Flush every minute
|
||||
setInterval(async () => {
|
||||
for (const [accountId, quantity] of usageBuffer.entries()) {
|
||||
if (quantity > 0) {
|
||||
await reportApiUsage(accountId, quantity);
|
||||
usageBuffer.set(accountId, 0);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
```
|
||||
|
||||
## Lemon Squeezy Implementation
|
||||
|
||||
Lemon Squeezy requires reporting to a subscription item ID.
|
||||
|
||||
### 1. Create a Usage-Based Product
|
||||
|
||||
1. Go to Products → New Product
|
||||
2. Select **Usage-based** pricing
|
||||
3. Configure your pricing tiers
|
||||
|
||||
### 2. Get the Subscription Item ID
|
||||
|
||||
After a customer subscribes, find their subscription item:
|
||||
|
||||
```tsx
|
||||
const { data: subscriptionItem } = await supabase
|
||||
.from('subscription_items')
|
||||
.select('id')
|
||||
.eq('subscription_id', subscriptionId)
|
||||
.eq('type', 'metered')
|
||||
.single();
|
||||
```
|
||||
|
||||
### 3. Report Usage
|
||||
|
||||
```tsx
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function reportUsageLS(
|
||||
accountId: string,
|
||||
quantity: number
|
||||
) {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
// Get subscription and item
|
||||
const { data: subscription } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.single();
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error('No active subscription');
|
||||
}
|
||||
|
||||
const { data: item } = await supabase
|
||||
.from('subscription_items')
|
||||
.select('id')
|
||||
.eq('subscription_id', subscription.id)
|
||||
.eq('type', 'metered')
|
||||
.single();
|
||||
|
||||
if (!item) {
|
||||
throw new Error('No metered item found');
|
||||
}
|
||||
|
||||
const service = createBillingGatewayService('lemon-squeezy');
|
||||
|
||||
await service.reportUsage({
|
||||
id: item.id,
|
||||
usage: {
|
||||
quantity,
|
||||
action: 'increment', // or 'set' to replace
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Lemon Squeezy Usage Actions
|
||||
|
||||
- **`increment`**: Add to existing usage (default)
|
||||
- **`set`**: Replace the current usage value
|
||||
|
||||
```tsx
|
||||
// Increment by 100
|
||||
await service.reportUsage({
|
||||
id: itemId,
|
||||
usage: { quantity: 100, action: 'increment' },
|
||||
});
|
||||
|
||||
// Set total to 500 (overwrites previous)
|
||||
await service.reportUsage({
|
||||
id: itemId,
|
||||
usage: { quantity: 500, action: 'set' },
|
||||
});
|
||||
```
|
||||
|
||||
## Querying Usage
|
||||
|
||||
### Stripe
|
||||
|
||||
```tsx
|
||||
const usage = await service.queryUsage({
|
||||
id: 'meter_xxx', // Stripe Meter ID
|
||||
customerId: 'cus_xxx',
|
||||
filter: {
|
||||
startTime: Math.floor(Date.now() / 1000) - 86400 * 30,
|
||||
endTime: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Total usage: ${usage.value}`);
|
||||
```
|
||||
|
||||
### Lemon Squeezy
|
||||
|
||||
```tsx
|
||||
const usage = await service.queryUsage({
|
||||
id: 'sub_item_xxx',
|
||||
customerId: 'cus_xxx',
|
||||
filter: {
|
||||
page: 1,
|
||||
size: 100,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Combining Metered + Flat Pricing (Stripe Only)
|
||||
|
||||
Charge a base fee plus usage:
|
||||
|
||||
```tsx
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_base',
|
||||
name: 'Platform Access',
|
||||
cost: 29,
|
||||
type: 'flat',
|
||||
},
|
||||
{
|
||||
id: 'price_api',
|
||||
name: 'API Calls',
|
||||
cost: 0,
|
||||
type: 'metered',
|
||||
unit: 'calls',
|
||||
tiers: [
|
||||
{ upTo: 10000, cost: 0 }, // Included in base
|
||||
{ upTo: 'unlimited', cost: 0.001 },
|
||||
],
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
## Setup Fee with Metered Usage (Lemon Squeezy)
|
||||
|
||||
Lemon Squeezy supports a one-time setup fee:
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: '123456',
|
||||
name: 'API Access',
|
||||
cost: 0,
|
||||
type: 'metered',
|
||||
unit: 'requests',
|
||||
setupFee: 49, // One-time charge on subscription creation
|
||||
tiers: [
|
||||
{ upTo: 1000, cost: 0 },
|
||||
{ upTo: 'unlimited', cost: 0.001 },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Displaying Usage to Users
|
||||
|
||||
Show customers their current usage:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export function UsageDisplay({ accountId }: { accountId: string }) {
|
||||
const { data: usage, isLoading } = useQuery({
|
||||
queryKey: ['usage', accountId],
|
||||
queryFn: () => fetch(`/api/usage/${accountId}`).then(r => r.json()),
|
||||
refetchInterval: 60000, // Update every minute
|
||||
});
|
||||
|
||||
if (isLoading) return <span>Loading usage...</span>;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>API Requests</span>
|
||||
<span>{usage?.requests?.toLocaleString() ?? 0}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded">
|
||||
<div
|
||||
className="h-full bg-primary rounded"
|
||||
style={{ width: `${Math.min(100, (usage?.requests / 10000) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{usage?.requests > 10000
|
||||
? `${((usage.requests - 10000) * 0.001).toFixed(2)} overage`
|
||||
: `${10000 - usage?.requests} free requests remaining`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Metered Billing
|
||||
|
||||
1. **Create a metered subscription**
|
||||
2. **Report some usage:**
|
||||
```bash
|
||||
# Stripe CLI
|
||||
stripe billing_meters create_event \
|
||||
--event-name api_requests \
|
||||
--payload customer=cus_xxx,value=100
|
||||
```
|
||||
3. **Check usage in dashboard**
|
||||
4. **Create an invoice to see charges:**
|
||||
```bash
|
||||
stripe invoices create --customer cus_xxx
|
||||
stripe invoices finalize inv_xxx
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Usage not appearing
|
||||
|
||||
1. Verify the meter event name matches
|
||||
2. Check that customer ID is correct
|
||||
3. Look for errors in your application logs
|
||||
4. Check Stripe Dashboard → Billing → Meters → Events
|
||||
|
||||
### Incorrect charges
|
||||
|
||||
1. Verify your tier configuration in Stripe matches your schema
|
||||
2. Check if using graduated vs. volume pricing
|
||||
3. Review the invoice line items in Stripe Dashboard
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure pricing
|
||||
- [Billing API](/docs/next-supabase-turbo/billing/billing-api) - Full API reference
|
||||
- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Alternative usage model
|
||||
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Provider configuration
|
||||
387
docs/billing/one-off-payments.mdoc
Normal file
387
docs/billing/one-off-payments.mdoc
Normal file
@@ -0,0 +1,387 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "One-Off Payments"
|
||||
title: "Configure One-Off Payments for Lifetime Deals and Add-Ons"
|
||||
order: 8
|
||||
description: "Implement one-time purchases in your SaaS for lifetime access, add-ons, or credits. Learn how to configure one-off payments with Stripe, Lemon Squeezy, or Paddle in Makerkit."
|
||||
---
|
||||
|
||||
One-off payments are single charges for non-recurring products: lifetime access, add-ons, credit packs, or physical goods. Unlike subscriptions, one-off purchases are stored in the `orders` table.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Lifetime access**: One-time purchase for perpetual access
|
||||
- **Add-ons**: Additional features or capacity
|
||||
- **Credit packs**: Buy credits/tokens in bulk
|
||||
- **Digital products**: Templates, courses, ebooks
|
||||
- **One-time services**: Setup fees, consulting
|
||||
|
||||
## Schema Configuration
|
||||
|
||||
Define a one-time payment plan:
|
||||
|
||||
```tsx {% title="apps/web/config/billing.config.ts" %}
|
||||
{
|
||||
id: 'lifetime',
|
||||
name: 'Lifetime Access',
|
||||
description: 'Pay once, access forever',
|
||||
currency: 'USD',
|
||||
badge: 'Best Value',
|
||||
features: [
|
||||
'All Pro features',
|
||||
'Lifetime updates',
|
||||
'Priority support',
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
id: 'lifetime-deal',
|
||||
name: 'Lifetime Access',
|
||||
paymentType: 'one-time', // Not recurring
|
||||
// No interval for one-time
|
||||
lineItems: [
|
||||
{
|
||||
id: 'price_lifetime_xxx', // Provider Price ID
|
||||
name: 'Lifetime Access',
|
||||
cost: 299,
|
||||
type: 'flat', // Only flat is supported for one-time
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Key differences from subscriptions:**
|
||||
|
||||
- `paymentType` is `'one-time'` instead of `'recurring'`
|
||||
- No `interval` field
|
||||
- Line items must be `type: 'flat'` (no metered or per-seat)
|
||||
|
||||
## Provider Setup
|
||||
|
||||
### Stripe
|
||||
|
||||
1. Create a product in Stripe Dashboard
|
||||
2. Add a **One-time** price
|
||||
3. Copy the Price ID to your billing schema
|
||||
|
||||
### Lemon Squeezy
|
||||
|
||||
1. Create a product with **Single payment** pricing
|
||||
2. Copy the Variant ID to your billing schema
|
||||
|
||||
### Paddle
|
||||
|
||||
1. Create a product with one-time pricing
|
||||
2. Copy the Price ID to your billing schema
|
||||
|
||||
## Database Storage
|
||||
|
||||
One-off purchases are stored differently than subscriptions:
|
||||
|
||||
| Entity | Table | Description |
|
||||
|--------|-------|-------------|
|
||||
| Subscriptions | `subscriptions`, `subscription_items` | Recurring payments |
|
||||
| One-off | `orders`, `order_items` | Single payments |
|
||||
|
||||
### Orders Table Schema
|
||||
|
||||
```sql
|
||||
orders
|
||||
├── id (text) - Order ID from provider
|
||||
├── account_id (uuid) - Purchasing account
|
||||
├── billing_customer_id (int) - Customer reference
|
||||
├── status (payment_status) - 'pending', 'succeeded', 'failed'
|
||||
├── billing_provider (enum) - 'stripe', 'lemon-squeezy', 'paddle'
|
||||
├── total_amount (numeric) - Total charge
|
||||
├── currency (varchar)
|
||||
└── created_at, updated_at
|
||||
|
||||
order_items
|
||||
├── id (text) - Item ID
|
||||
├── order_id (text) - Reference to order
|
||||
├── product_id (text)
|
||||
├── variant_id (text)
|
||||
├── price_amount (numeric)
|
||||
└── quantity (integer)
|
||||
```
|
||||
|
||||
## Checking Order Status
|
||||
|
||||
Query orders to check if a user has purchased a product:
|
||||
|
||||
```tsx
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
export async function hasLifetimeAccess(accountId: string): Promise<boolean> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
const { data: order } = await supabase
|
||||
.from('orders')
|
||||
.select('id, status')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'succeeded')
|
||||
.single();
|
||||
|
||||
return !!order;
|
||||
}
|
||||
|
||||
// Check for specific product
|
||||
export async function hasPurchasedProduct(
|
||||
accountId: string,
|
||||
productId: string
|
||||
): Promise<boolean> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
const { data: order } = await supabase
|
||||
.from('orders')
|
||||
.select(`
|
||||
id,
|
||||
order_items!inner(product_id)
|
||||
`)
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'succeeded')
|
||||
.eq('order_items.product_id', productId)
|
||||
.single();
|
||||
|
||||
return !!order;
|
||||
}
|
||||
```
|
||||
|
||||
## Gating Features
|
||||
|
||||
Use order status to control access:
|
||||
|
||||
```tsx
|
||||
// Server Component
|
||||
import { hasLifetimeAccess } from '~/lib/orders';
|
||||
|
||||
export default async function PremiumFeature({
|
||||
accountId,
|
||||
}: {
|
||||
accountId: string;
|
||||
}) {
|
||||
const hasAccess = await hasLifetimeAccess(accountId);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <UpgradePrompt />;
|
||||
}
|
||||
|
||||
return <PremiumContent />;
|
||||
}
|
||||
```
|
||||
|
||||
### RLS Policy Example
|
||||
|
||||
Gate database access based on orders:
|
||||
|
||||
```sql
|
||||
-- Function to check if account has a successful order
|
||||
CREATE OR REPLACE FUNCTION public.has_lifetime_access(p_account_id UUID)
|
||||
RETURNS BOOLEAN
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1
|
||||
FROM public.orders
|
||||
WHERE account_id = p_account_id
|
||||
AND status = 'succeeded'
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Example policy
|
||||
CREATE POLICY premium_content_access ON public.premium_content
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
public.has_lifetime_access(account_id)
|
||||
);
|
||||
```
|
||||
|
||||
## Handling Webhooks
|
||||
|
||||
One-off payment webhooks work similarly to subscriptions:
|
||||
|
||||
```tsx {% title="apps/web/app/api/billing/webhook/route.ts" %}
|
||||
await service.handleWebhookEvent(request, {
|
||||
onCheckoutSessionCompleted: async (orderOrSubscription, customerId) => {
|
||||
// Check if this is an order (one-time) or subscription
|
||||
if ('order_id' in orderOrSubscription) {
|
||||
// One-time payment
|
||||
logger.info({ orderId: orderOrSubscription.order_id }, 'Order completed');
|
||||
|
||||
// Provision access, send receipt, etc.
|
||||
await provisionLifetimeAccess(orderOrSubscription.target_account_id);
|
||||
await sendOrderReceipt(orderOrSubscription);
|
||||
} else {
|
||||
// Subscription
|
||||
logger.info('Subscription created');
|
||||
}
|
||||
},
|
||||
|
||||
onPaymentFailed: async (sessionId) => {
|
||||
// Handle failed one-time payments
|
||||
await notifyPaymentFailed(sessionId);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Stripe-Specific Events
|
||||
|
||||
For one-off payments, add these webhook events in Stripe:
|
||||
|
||||
- `checkout.session.completed`
|
||||
- `checkout.session.async_payment_failed`
|
||||
- `checkout.session.async_payment_succeeded`
|
||||
|
||||
{% alert type="default" title="Async payment methods" %}
|
||||
Some payment methods (bank transfers, certain local methods) are asynchronous. Listen for `async_payment_succeeded` to confirm these payments.
|
||||
{% /alert %}
|
||||
|
||||
## Mixing Orders and Subscriptions
|
||||
|
||||
You can offer both one-time and recurring products:
|
||||
|
||||
```tsx
|
||||
products: [
|
||||
// Subscription product
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
plans: [
|
||||
{
|
||||
id: 'pro-monthly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [{ id: 'price_monthly', cost: 29, type: 'flat' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
// One-time product
|
||||
{
|
||||
id: 'lifetime',
|
||||
name: 'Lifetime',
|
||||
plans: [
|
||||
{
|
||||
id: 'lifetime-deal',
|
||||
paymentType: 'one-time',
|
||||
lineItems: [{ id: 'price_lifetime', cost: 299, type: 'flat' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
Check for either type of access:
|
||||
|
||||
```tsx
|
||||
export async function hasAccess(accountId: string): Promise<boolean> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
// Check subscription
|
||||
const { data: subscription } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.single();
|
||||
|
||||
if (subscription) return true;
|
||||
|
||||
// Check lifetime order
|
||||
const { data: order } = await supabase
|
||||
.from('orders')
|
||||
.select('id')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'succeeded')
|
||||
.single();
|
||||
|
||||
return !!order;
|
||||
}
|
||||
```
|
||||
|
||||
## Billing Mode Configuration
|
||||
|
||||
By default, Makerkit checks subscriptions for billing status. To use orders as the primary billing mechanism (versions before 2.12.0):
|
||||
|
||||
```bash
|
||||
BILLING_MODE=one-time
|
||||
```
|
||||
|
||||
When set, the billing section will display orders instead of subscriptions.
|
||||
|
||||
{% alert type="default" title="Version 2.12.0+" %}
|
||||
From version 2.12.0 onwards, orders and subscriptions can coexist. The `BILLING_MODE` setting is only needed if you want to exclusively use one-time payments.
|
||||
{% /alert %}
|
||||
|
||||
## Add-On Purchases
|
||||
|
||||
Sell additional items to existing subscribers:
|
||||
|
||||
```tsx
|
||||
// Add-on product
|
||||
{
|
||||
id: 'addon-storage',
|
||||
name: 'Extra Storage',
|
||||
plans: [
|
||||
{
|
||||
id: 'storage-10gb',
|
||||
name: '10GB Storage',
|
||||
paymentType: 'one-time',
|
||||
lineItems: [
|
||||
{ id: 'price_storage_10gb', name: '10GB Storage', cost: 19, type: 'flat' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Track purchased add-ons:
|
||||
|
||||
```tsx
|
||||
export async function getStorageLimit(accountId: string): Promise<number> {
|
||||
const supabase = getSupabaseServerClient();
|
||||
|
||||
// Base storage from subscription
|
||||
const baseStorage = 5; // GB
|
||||
|
||||
// Additional storage from orders
|
||||
const { data: orders } = await supabase
|
||||
.from('orders')
|
||||
.select('order_items(product_id)')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'succeeded');
|
||||
|
||||
const additionalStorage = orders?.reduce((total, order) => {
|
||||
const hasStorage = order.order_items.some(
|
||||
item => item.product_id === 'storage-10gb'
|
||||
);
|
||||
return hasStorage ? total + 10 : total;
|
||||
}, 0) ?? 0;
|
||||
|
||||
return baseStorage + additionalStorage;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing One-Off Payments
|
||||
|
||||
1. **Test checkout:**
|
||||
- Navigate to your pricing page
|
||||
- Select the one-time product
|
||||
- Complete checkout with test card `4242 4242 4242 4242`
|
||||
2. **Verify database:**
|
||||
```sql
|
||||
SELECT * FROM orders WHERE account_id = 'your-account-id';
|
||||
SELECT * FROM order_items WHERE order_id = 'order-id';
|
||||
```
|
||||
3. **Test access gating:**
|
||||
- Verify features are unlocked after purchase
|
||||
- Test with accounts that haven't purchased
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure pricing
|
||||
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Handle payment events
|
||||
- [Stripe Setup](/docs/next-supabase-turbo/billing/stripe) - Provider configuration
|
||||
- [Credit-Based Billing](/docs/next-supabase-turbo/billing/credit-based-billing) - Token/credit systems
|
||||
268
docs/billing/overview.mdoc
Normal file
268
docs/billing/overview.mdoc
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "How Billing Works"
|
||||
title: "Billing in Next.js Supabase Turbo"
|
||||
description: "Complete guide to implementing billing in your Next.js Supabase SaaS. Configure subscriptions, one-off payments, metered usage, and per-seat pricing with Stripe, Lemon Squeezy, or Paddle."
|
||||
order: 0
|
||||
---
|
||||
|
||||
Makerkit's billing system lets you accept payments through Stripe, Lemon Squeezy, or Paddle with a unified API. You define your pricing once in a schema, and the gateway routes requests to your chosen provider. Switching providers requires changing one environment variable.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Set your billing provider:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_BILLING_PROVIDER=stripe # or lemon-squeezy, paddle
|
||||
```
|
||||
|
||||
Update the database configuration to match:
|
||||
|
||||
```sql
|
||||
UPDATE public.config SET billing_provider = 'stripe';
|
||||
```
|
||||
|
||||
Then [configure your billing schema](/docs/next-supabase-turbo/billing/billing-schema) with your products and pricing.
|
||||
|
||||
## Choose Your Provider
|
||||
|
||||
| Provider | Best For | Tax Handling | Multi-line Items |
|
||||
|----------|----------|--------------|------------------|
|
||||
| [Stripe](/docs/next-supabase-turbo/billing/stripe) | Maximum flexibility, global reach | You handle (or use Stripe Tax) | Yes |
|
||||
| [Lemon Squeezy](/docs/next-supabase-turbo/billing/lemon-squeezy) | Simplicity, automatic tax compliance | Merchant of Record | No (1 per plan) |
|
||||
| [Paddle](/docs/next-supabase-turbo/billing/paddle) | B2B SaaS, automatic tax compliance | Merchant of Record | No (flat + per-seat only) |
|
||||
|
||||
**Merchant of Record** means Lemon Squeezy and Paddle handle VAT, sales tax, and compliance globally. With Stripe, you're responsible for tax collection (though Stripe Tax can help).
|
||||
|
||||
## Supported Pricing Models
|
||||
|
||||
Makerkit supports four billing models out of the box:
|
||||
|
||||
### Flat Subscriptions
|
||||
|
||||
Fixed monthly or annual pricing. The most common SaaS model.
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'price_xxx',
|
||||
name: 'Pro Plan',
|
||||
cost: 29,
|
||||
type: 'flat',
|
||||
}
|
||||
```
|
||||
|
||||
[Learn more about configuring flat subscriptions →](/docs/next-supabase-turbo/billing/billing-schema#flat-subscriptions)
|
||||
|
||||
### Per-Seat Billing
|
||||
|
||||
Charge based on team size. Makerkit automatically updates seat counts when members join or leave.
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'price_xxx',
|
||||
name: 'Team',
|
||||
cost: 0,
|
||||
type: 'per_seat',
|
||||
tiers: [
|
||||
{ upTo: 3, cost: 0 }, // First 3 seats free
|
||||
{ upTo: 10, cost: 12 }, // $12/seat up to 10
|
||||
{ upTo: 'unlimited', cost: 10 },
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[Configure per-seat billing →](/docs/next-supabase-turbo/billing/per-seat-billing)
|
||||
|
||||
### Metered Usage
|
||||
|
||||
Charge based on consumption (API calls, storage, tokens). Report usage through the billing API.
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'price_xxx',
|
||||
name: 'API Requests',
|
||||
cost: 0,
|
||||
type: 'metered',
|
||||
unit: 'requests',
|
||||
tiers: [
|
||||
{ upTo: 1000, cost: 0 },
|
||||
{ upTo: 'unlimited', cost: 0.001 },
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
[Set up metered billing →](/docs/next-supabase-turbo/billing/metered-usage)
|
||||
|
||||
### One-Off Payments
|
||||
|
||||
Lifetime deals, add-ons, or credits. Stored in the `orders` table instead of `subscriptions`.
|
||||
|
||||
```tsx
|
||||
{
|
||||
paymentType: 'one-time',
|
||||
lineItems: [{
|
||||
id: 'price_xxx',
|
||||
name: 'Lifetime Access',
|
||||
cost: 299,
|
||||
type: 'flat',
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
[Configure one-off payments →](/docs/next-supabase-turbo/billing/one-off-payments)
|
||||
|
||||
### Credit-Based Billing
|
||||
|
||||
For AI SaaS and token-based systems. Combine subscriptions with a credits table for consumption tracking.
|
||||
|
||||
[Implement credit-based billing →](/docs/next-supabase-turbo/billing/credit-based-billing)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The billing system uses a provider-agnostic architecture:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Your App │────▶│ Gateway │────▶│ Provider │
|
||||
│ (billing.config) │ (routes requests) │ (Stripe/LS/Paddle)
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Database │◀────│ Webhook Handler│◀────│ Webhook │
|
||||
│ (subscriptions) │ (processes events) │ (payment events)
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
**Package structure:**
|
||||
|
||||
- `@kit/billing` (core): Schema validation, interfaces, types
|
||||
- `@kit/billing-gateway`: Provider routing, unified API
|
||||
- `@kit/stripe`: Stripe-specific implementation
|
||||
- `@kit/lemon-squeezy`: Lemon Squeezy-specific implementation
|
||||
- `@kit/paddle`: Paddle-specific implementation (plugin)
|
||||
|
||||
This abstraction means your application code stays the same regardless of provider. The billing schema defines what you sell, and each provider package handles the API specifics.
|
||||
|
||||
## Database Schema
|
||||
|
||||
Billing data is stored in four main tables:
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `billing_customers` | Links accounts to provider customer IDs |
|
||||
| `subscriptions` | Active and historical subscription records |
|
||||
| `subscription_items` | Line items within subscriptions (for per-seat, metered) |
|
||||
| `orders` | One-off payment records |
|
||||
| `order_items` | Items within one-off orders |
|
||||
|
||||
All tables have Row Level Security (RLS) enabled. Users can only read their own billing data.
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### billing.config.ts
|
||||
|
||||
Your pricing schema lives at `apps/web/config/billing.config.ts`:
|
||||
|
||||
```tsx
|
||||
import { createBillingSchema } from '@kit/billing';
|
||||
|
||||
export default createBillingSchema({
|
||||
provider: process.env.NEXT_PUBLIC_BILLING_PROVIDER,
|
||||
products: [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
description: 'For individuals',
|
||||
currency: 'USD',
|
||||
plans: [/* ... */],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
[Full billing schema documentation →](/docs/next-supabase-turbo/billing/billing-schema)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Each provider requires specific environment variables:
|
||||
|
||||
**Stripe:**
|
||||
```bash
|
||||
STRIPE_SECRET_KEY=sk_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_...
|
||||
```
|
||||
|
||||
**Lemon Squeezy:**
|
||||
```bash
|
||||
LEMON_SQUEEZY_SECRET_KEY=...
|
||||
LEMON_SQUEEZY_SIGNING_SECRET=...
|
||||
LEMON_SQUEEZY_STORE_ID=...
|
||||
```
|
||||
|
||||
**Paddle:**
|
||||
```bash
|
||||
PADDLE_API_KEY=...
|
||||
PADDLE_WEBHOOK_SECRET_KEY=...
|
||||
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=...
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Check if an account has a subscription
|
||||
|
||||
```tsx
|
||||
import { createAccountsApi } from '@kit/accounts/api';
|
||||
|
||||
const api = createAccountsApi(supabaseClient);
|
||||
const subscription = await api.getSubscription(accountId);
|
||||
|
||||
if (subscription?.status === 'active') {
|
||||
// User has active subscription
|
||||
}
|
||||
```
|
||||
|
||||
### Create a checkout session
|
||||
|
||||
```tsx
|
||||
import { createBillingGatewayService } from '@kit/billing-gateway';
|
||||
|
||||
const service = createBillingGatewayService(provider);
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
accountId,
|
||||
plan,
|
||||
returnUrl: `${origin}/billing/return`,
|
||||
customerEmail: user.email,
|
||||
});
|
||||
```
|
||||
|
||||
### Handle billing webhooks
|
||||
|
||||
Webhooks are processed at `/api/billing/webhook`. Extend the handler for custom logic:
|
||||
|
||||
```tsx
|
||||
await service.handleWebhookEvent(request, {
|
||||
onCheckoutSessionCompleted: async (subscription) => {
|
||||
// Send welcome email, provision resources, etc.
|
||||
},
|
||||
onSubscriptionDeleted: async (subscriptionId) => {
|
||||
// Clean up, send cancellation email, etc.
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
[Full webhook documentation →](/docs/next-supabase-turbo/billing/billing-webhooks)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **[Configure your billing schema](/docs/next-supabase-turbo/billing/billing-schema)** to define your products and pricing
|
||||
2. **Set up your payment provider:** [Stripe](/docs/next-supabase-turbo/billing/stripe), [Lemon Squeezy](/docs/next-supabase-turbo/billing/lemon-squeezy), or [Paddle](/docs/next-supabase-turbo/billing/paddle)
|
||||
3. **[Handle webhooks](/docs/next-supabase-turbo/billing/billing-webhooks)** for payment events
|
||||
4. **[Use the billing API](/docs/next-supabase-turbo/billing/billing-api)** to manage subscriptions programmatically
|
||||
|
||||
For advanced use cases:
|
||||
- [Per-seat billing](/docs/next-supabase-turbo/billing/per-seat-billing) for team-based pricing
|
||||
- [Metered usage](/docs/next-supabase-turbo/billing/metered-usage) for consumption-based billing
|
||||
- [Credit-based billing](/docs/next-supabase-turbo/billing/credit-based-billing) for AI/token systems
|
||||
- [Custom integrations](/docs/next-supabase-turbo/billing/custom-integration) for other payment providers
|
||||
475
docs/billing/paddle.mdoc
Normal file
475
docs/billing/paddle.mdoc
Normal file
@@ -0,0 +1,475 @@
|
||||
---
|
||||
status: "published"
|
||||
label: 'Paddle'
|
||||
title: 'Configuring Paddle Billing | Next.js Supabase SaaS Kit Turbo'
|
||||
order: 4
|
||||
description: 'Complete guide to integrating Paddle billing with your Next.js Supabase SaaS application. Learn how to set up payment processing, webhooks, and subscription management with Paddle as your Merchant of Record.'
|
||||
---
|
||||
|
||||
Paddle is a comprehensive billing solution that acts as a Merchant of Record (MoR), handling all payment processing, tax calculations, compliance, and regulatory requirements for your SaaS business.
|
||||
|
||||
This integration eliminates the complexity of managing global tax compliance, PCI requirements, and payment processing infrastructure.
|
||||
|
||||
## Overview
|
||||
|
||||
This guide will walk you through:
|
||||
- Setting up Paddle for development and production
|
||||
- Configuring webhooks for real-time billing events
|
||||
- Creating and managing subscription products
|
||||
- Testing the complete billing flow
|
||||
- Deploying to production
|
||||
|
||||
## Limitations
|
||||
|
||||
Paddle currently supports flat and per-seat plans. Metered subscriptions are not supported with Paddle.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, ensure you have:
|
||||
- A Paddle account (sandbox for development, live for production)
|
||||
- Access to your application's environment configuration
|
||||
- A method to expose your local development server (ngrok, LocalTunnel, Localcan, etc.)
|
||||
|
||||
## Step 0: Fetch the Paddle package from the plugins repository
|
||||
|
||||
The Paddle package is released as a plugin in the Plugins repository. You can fetch it by running the following command:
|
||||
|
||||
```bash
|
||||
npx @makerkit/cli@latest plugins install
|
||||
```
|
||||
|
||||
Please choose the Paddle plugin from the list of available plugins.
|
||||
|
||||
## Step 1: Registering Paddle
|
||||
|
||||
Now we need to register the services from the Paddle plugin.
|
||||
|
||||
### Install the Paddle package
|
||||
|
||||
Run the following command to add the Paddle package to our billing package:
|
||||
|
||||
```bash
|
||||
pnpm --filter @kit/billing-gateway add "@kit/paddle@workspace:*"
|
||||
```
|
||||
|
||||
### Registering the Checkout component
|
||||
|
||||
Update the function `loadCheckoutComponent` to include the `paddle` block,
|
||||
which will dynamically import the Paddle checkout component:
|
||||
|
||||
```tsx {% title="packages/billing/gateway/src/components/embedded-checkout.tsx" %}
|
||||
import { Suspense, lazy } from 'react';
|
||||
|
||||
import { Enums } from '@kit/supabase/database';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
|
||||
type BillingProvider = Enums<'billing_provider'>;
|
||||
|
||||
// Create lazy components at module level (not during render)
|
||||
const StripeCheckoutLazy = lazy(async () => {
|
||||
const { StripeCheckout } = await import('@kit/stripe/components');
|
||||
return { default: StripeCheckout };
|
||||
});
|
||||
|
||||
const LemonSqueezyCheckoutLazy = lazy(async () => {
|
||||
const { LemonSqueezyEmbeddedCheckout } =
|
||||
await import('@kit/lemon-squeezy/components');
|
||||
return { default: LemonSqueezyEmbeddedCheckout };
|
||||
});
|
||||
|
||||
const PaddleCheckoutLazy = lazy(async () => {
|
||||
const { PaddleCheckout } = await import(
|
||||
'@kit/paddle/components'
|
||||
);
|
||||
return { default: PaddleCheckout };
|
||||
});
|
||||
|
||||
|
||||
type CheckoutProps = {
|
||||
onClose: (() => unknown) | undefined;
|
||||
checkoutToken: string;
|
||||
};
|
||||
|
||||
export function EmbeddedCheckout(
|
||||
props: React.PropsWithChildren<{
|
||||
checkoutToken: string;
|
||||
provider: BillingProvider;
|
||||
onClose?: () => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<LoadingOverlay fullPage={false} />}>
|
||||
<CheckoutSelector
|
||||
provider={props.provider}
|
||||
onClose={props.onClose}
|
||||
checkoutToken={props.checkoutToken}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<BlurryBackdrop />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutSelector(
|
||||
props: CheckoutProps & { provider: BillingProvider },
|
||||
) {
|
||||
switch (props.provider) {
|
||||
case 'stripe':
|
||||
return (
|
||||
<StripeCheckoutLazy
|
||||
onClose={props.onClose}
|
||||
checkoutToken={props.checkoutToken}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'lemon-squeezy':
|
||||
return (
|
||||
<LemonSqueezyCheckoutLazy
|
||||
onClose={props.onClose}
|
||||
checkoutToken={props.checkoutToken}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'paddle':
|
||||
return (
|
||||
<PaddleCheckoutLazy
|
||||
onClose={props.onClose}
|
||||
checkoutToken={props.checkoutToken}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${props.provider as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
function BlurryBackdrop() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'bg-background/30 fixed top-0 left-0 w-full backdrop-blur-sm' +
|
||||
' !m-0 h-full'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Registering the Webhook handler
|
||||
|
||||
At `packages/billing/gateway/src/server/services/billing-event-handler
|
||||
/billing-event-handler-factory.service.ts`, add the snippet below at the
|
||||
bottom of the file:
|
||||
|
||||
```tsx {% title="packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts" %}
|
||||
// Register Paddle webhook handler
|
||||
billingWebhookHandlerRegistry.register('paddle', async () => {
|
||||
const { PaddleWebhookHandlerService } = await import('@kit/paddle');
|
||||
|
||||
return new PaddleWebhookHandlerService(planTypesMap);
|
||||
});
|
||||
```
|
||||
|
||||
### Registering the Billing service
|
||||
|
||||
Finally, at `packages/billing/gateway/src/server/services/billing-event-handler
|
||||
/billing-gateway-registry.ts`, add the snippet below at the
|
||||
bottom of the file:
|
||||
|
||||
```tsx {% title="packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts" %}
|
||||
// Register Paddle billing strategy
|
||||
billingStrategyRegistry.register('paddle', async () => {
|
||||
const { PaddleBillingStrategyService } = await import('@kit/paddle');
|
||||
|
||||
return new PaddleBillingStrategyService();
|
||||
});
|
||||
```
|
||||
|
||||
## Step 2: Create Paddle Account
|
||||
|
||||
### Development Account (Sandbox)
|
||||
1. Visit [Paddle Developer Console](https://sandbox-vendors.paddle.com/signup)
|
||||
2. Complete the registration process
|
||||
3. Verify your email address
|
||||
4. Navigate to your sandbox dashboard
|
||||
|
||||
### Important Notes
|
||||
- The sandbox environment allows unlimited testing without processing real payments
|
||||
- All transactions in sandbox mode use test card numbers
|
||||
- Webhooks and API calls work identically to production
|
||||
- The Paddle payment provider currently only supports flat and per-seat plans (metered subscriptions are not supported)
|
||||
|
||||
## Step 3: Configure Billing Provider
|
||||
|
||||
### Database Configuration
|
||||
|
||||
Set Paddle as your billing provider in the database:
|
||||
|
||||
```sql
|
||||
-- Update the billing provider in your configuration table
|
||||
UPDATE public.config
|
||||
set billing_provider = 'paddle';
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
Add the following to your `.env.local` file:
|
||||
|
||||
```bash
|
||||
# Set Paddle as the active billing provider
|
||||
NEXT_PUBLIC_BILLING_PROVIDER=paddle
|
||||
```
|
||||
|
||||
This environment variable tells your application to use Paddle-specific components and API endpoints for billing operations.
|
||||
|
||||
## Step 4: API Key Configuration
|
||||
|
||||
Paddle requires two types of API keys for complete integration:
|
||||
|
||||
### Server-Side API Key (Required)
|
||||
|
||||
1. In your Paddle dashboard, navigate to **Developer Tools** → **Authentication**
|
||||
2. Click **Generate New API Key**
|
||||
3. Give it a descriptive name (e.g., "Production API Key" or "Development API Key")
|
||||
4. **Configure the required permissions** for your API key:
|
||||
- **Write Customer Portal Sessions** - For managing customer billing portals
|
||||
- **Read Customers** - For retrieving customer information
|
||||
- **Read Prices** - For displaying pricing information
|
||||
- **Read Products** - For product catalog access
|
||||
- **Read/Write Subscriptions** - For subscription management
|
||||
- **Read Transactions** - For payment and transaction tracking
|
||||
5. Copy the generated key immediately (it won't be shown again)
|
||||
6. Add to your `.env.local`:
|
||||
|
||||
```bash
|
||||
PADDLE_API_KEY=your_server_api_key_here
|
||||
```
|
||||
|
||||
**Security Note**: This key has access to the specified Paddle account permissions and should never be exposed to the client-side code. Only grant the minimum permissions required for your integration.
|
||||
|
||||
### Client-Side Token (Required)
|
||||
|
||||
1. In the same **Authentication** section, look for **Client-side tokens**
|
||||
2. Click **New Client-Side Token**
|
||||
3. Copy the client token
|
||||
4. Add to your `.env.local`:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=your_client_token_here
|
||||
```
|
||||
|
||||
**Important**: This token is safe to expose in client-side code but should be restricted to your specific domains.
|
||||
|
||||
## Step 5: Webhook Configuration
|
||||
|
||||
Webhooks enable real-time synchronization between Paddle and your application for events like successful payments, subscription changes, and cancellations.
|
||||
|
||||
### Set Up Local Development Tunnel
|
||||
|
||||
First, expose your local development server to the internet:
|
||||
|
||||
#### Using ngrok (Recommended)
|
||||
```bash
|
||||
# Install ngrok if not already installed
|
||||
npm install -g ngrok
|
||||
|
||||
# Expose port 3000 (default Next.js port)
|
||||
ngrok http 3000
|
||||
```
|
||||
|
||||
#### Using LocalTunnel
|
||||
```bash
|
||||
# Install localtunnel
|
||||
npm install -g localtunnel
|
||||
|
||||
# Expose port 3000
|
||||
lt --port 3000
|
||||
```
|
||||
|
||||
### Configure Webhook Destination
|
||||
|
||||
1. In Paddle dashboard, go to **Developer Tools** → **Notifications**
|
||||
2. Click **New Destination**
|
||||
3. Configure the destination:
|
||||
- **Destination URL**: `https://your-tunnel-url.ngrok.io/api/billing/webhook`
|
||||
- **Description**: "Local Development Webhook"
|
||||
- **Active**: ✅ Checked
|
||||
|
||||
### Select Webhook Events
|
||||
|
||||
Enable these essential events for proper billing integration:
|
||||
|
||||
### Retrieve Webhook Secret
|
||||
|
||||
1. After creating the destination, click on it to view details
|
||||
2. Copy the **Endpoint Secret** (used to verify webhook authenticity)
|
||||
3. Add to your `.env.local`:
|
||||
|
||||
```bash
|
||||
PADDLE_WEBHOOK_SECRET_KEY=your_webhook_secret_here
|
||||
```
|
||||
|
||||
### Test Webhook Connection
|
||||
|
||||
You can test the webhook endpoint by making a GET request to verify it's accessible:
|
||||
|
||||
```bash
|
||||
curl https://your-tunnel-url.ngrok.io/api/billing/webhook
|
||||
```
|
||||
|
||||
Expected response: `200 OK` with a message indicating the webhook endpoint is active.
|
||||
|
||||
## Step 6: Product and Pricing Configuration
|
||||
|
||||
### Create Products in Paddle
|
||||
|
||||
1. Navigate to **Catalog** → **Products** in your Paddle dashboard
|
||||
2. Click **Create Product**
|
||||
3. Configure your product:
|
||||
|
||||
**Basic Information:**
|
||||
- **Product Name**: "Starter Plan", "Pro Plan", etc.
|
||||
- **Description**: Detailed description of the plan features
|
||||
- **Tax Category**: Select appropriate category (usually "Software")
|
||||
|
||||
**Pricing Configuration:**
|
||||
- **Billing Interval**: Monthly, Yearly, or Custom
|
||||
- **Price**: Set in your primary currency
|
||||
- **Trial Period**: Optional free trial duration
|
||||
|
||||
### Configure Billing Settings
|
||||
|
||||
Update your billing configuration file with the Paddle product IDs:
|
||||
|
||||
```typescript
|
||||
// apps/web/config/billing.config.ts
|
||||
export const billingConfig = {
|
||||
provider: 'paddle',
|
||||
products: [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter Plan',
|
||||
description: 'Perfect for individuals and small teams',
|
||||
badge: 'Most Popular',
|
||||
features: [
|
||||
'Up to 5 projects',
|
||||
'Basic support',
|
||||
'1GB storage'
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
name: 'Starter Monthly',
|
||||
id: 'starter-monthly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
lineItems: [
|
||||
{
|
||||
id: 'pri_starter_monthly_001', // Paddle Price ID
|
||||
name: 'Starter',
|
||||
cost: 9.99,
|
||||
type: 'flat' as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
// Add more products...
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
## Step 7: Checkout Configuration
|
||||
|
||||
### Default Payment Link Configuration
|
||||
|
||||
1. Go to **Checkout** → **Checkout Settings** in Paddle dashboard
|
||||
2. Configure **Default Payment Link**: use`http://localhost:3000` - but when deploying to production, you should use your production domain.
|
||||
3. Save the configuration
|
||||
|
||||
## Step 8: Testing the Integration
|
||||
|
||||
### Development Testing Checklist
|
||||
|
||||
**Environment Verification:**
|
||||
- [ ] All environment variables are set correctly
|
||||
- [ ] Webhook tunnel is active and accessible
|
||||
- [ ] Destination was defined using the correct URL
|
||||
- [ ] Database billing provider is set to 'paddle' in both DB and ENV
|
||||
|
||||
**Subscription Flow Testing:**
|
||||
1. Navigate to your billing/pricing page (`/home/billing` or equivalent)
|
||||
2. Click on a subscription plan
|
||||
3. Complete the checkout flow using Paddle test cards
|
||||
4. Verify successful redirect to success page
|
||||
5. Check that subscription appears in user dashboard
|
||||
6. Verify webhook events are received in your application logs
|
||||
|
||||
You can also test cancellation flows:
|
||||
- cancel the subscription from the billing portal
|
||||
- delete the account and verify the subscription is cancelled as well
|
||||
|
||||
### Test Card Numbers
|
||||
|
||||
[Follow this link to get the test card numbers](https://developer.paddle.com/concepts/payment-methods/credit-debit-card#test-payment-method)
|
||||
|
||||
### Webhook Testing
|
||||
|
||||
Monitor webhook delivery in your application logs:
|
||||
|
||||
```bash
|
||||
# Watch your development logs
|
||||
pnpm dev
|
||||
|
||||
# In another terminal, monitor webhook requests
|
||||
tail -f logs/webhook.log
|
||||
```
|
||||
|
||||
## Step 9: Production Deployment
|
||||
|
||||
### Apply for Live Paddle Account
|
||||
|
||||
1. In your Paddle dashboard, click **Go Live**
|
||||
2. Complete the application process:
|
||||
- Business information and verification
|
||||
- Tax information and documentation
|
||||
- Banking details for payouts
|
||||
- Identity verification for key personnel
|
||||
|
||||
**Timeline**: Live account approval typically takes 1-3 business days.
|
||||
|
||||
### Production Environment Setup
|
||||
|
||||
Create production-specific configuration:
|
||||
|
||||
```bash
|
||||
# Production environment variables
|
||||
NEXT_PUBLIC_BILLING_PROVIDER=paddle
|
||||
PADDLE_API_KEY=your_production_api_key
|
||||
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=your_production_client_token
|
||||
PADDLE_WEBHOOK_SECRET_KEY=your_production_webhook_secret
|
||||
```
|
||||
|
||||
### Production Webhook Configuration
|
||||
|
||||
1. Create a new webhook destination for production
|
||||
2. Set the destination URL to your production domain:
|
||||
```
|
||||
https://yourdomain.com/api/billing/webhook
|
||||
```
|
||||
3. Enable the same events as configured for development
|
||||
4. Update your production environment with the new webhook secret
|
||||
|
||||
### Production Products and Pricing
|
||||
|
||||
1. Create production versions of your products in the live environment
|
||||
2. Update your production billing configuration with live Price IDs
|
||||
3. Test the complete flow on production with small-amount transactions
|
||||
|
||||
### Support Resources
|
||||
|
||||
Refer to the [Paddle Documentation](https://developer.paddle.com) for more information:
|
||||
|
||||
- **Paddle Documentation**: [https://developer.paddle.com](https://developer.paddle.com)
|
||||
- **Status Page**: [https://status.paddle.com](https://status.paddle.com)
|
||||
293
docs/billing/per-seat-billing.mdoc
Normal file
293
docs/billing/per-seat-billing.mdoc
Normal file
@@ -0,0 +1,293 @@
|
||||
---
|
||||
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
|
||||
292
docs/billing/stripe.mdoc
Normal file
292
docs/billing/stripe.mdoc
Normal file
@@ -0,0 +1,292 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Stripe"
|
||||
title: "Configure Stripe Billing for Your Next.js SaaS"
|
||||
description: "Complete guide to setting up Stripe payments in Makerkit. Configure subscriptions, one-off payments, webhooks, and the Customer Portal for your Next.js Supabase application."
|
||||
order: 2
|
||||
---
|
||||
|
||||
Stripe is the default billing provider in Makerkit. It offers the most flexibility with support for multiple line items, metered billing, and advanced subscription management.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start:
|
||||
1. Create a [Stripe account](https://dashboard.stripe.com/register)
|
||||
2. Have your Stripe API keys ready (Dashboard → Developers → API keys)
|
||||
3. Install the Stripe CLI for local webhook testing
|
||||
|
||||
## Step 1: Environment Variables
|
||||
|
||||
Add these variables to your `.env.local` file:
|
||||
|
||||
```bash
|
||||
# Stripe API Keys
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
```
|
||||
|
||||
| Variable | Description | Where to Find |
|
||||
|----------|-------------|---------------|
|
||||
| `STRIPE_SECRET_KEY` | Server-side API key | Dashboard → Developers → API keys |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Webhook signature verification | Generated by Stripe CLI or Dashboard |
|
||||
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Client-side key (safe to expose) | Dashboard → Developers → API keys |
|
||||
|
||||
{% alert type="error" title="Never commit secret keys" %}
|
||||
Add `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` to `.env.local` only. Never add them to `.env` or commit them to your repository.
|
||||
{% /alert %}
|
||||
|
||||
## Step 2: Configure Billing Provider
|
||||
|
||||
Ensure Stripe is set as your billing provider:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_BILLING_PROVIDER=stripe
|
||||
```
|
||||
|
||||
And in the database:
|
||||
|
||||
```sql
|
||||
UPDATE public.config SET billing_provider = 'stripe';
|
||||
```
|
||||
|
||||
## Step 3: Create Products in Stripe
|
||||
|
||||
1. Go to Stripe Dashboard → Products
|
||||
2. Click **Add product**
|
||||
3. Configure your product:
|
||||
- **Name**: "Pro Plan", "Starter Plan", etc.
|
||||
- **Pricing**: Add prices for monthly and yearly intervals
|
||||
- **Price ID**: Copy the `price_xxx` ID for your billing schema
|
||||
|
||||
**Important:** The Price ID (e.g., `price_1NNwYHI1i3VnbZTqI2UzaHIe`) must match the `id` field in your billing schema's line items.
|
||||
|
||||
## Step 4: Set Up Local Webhooks with Stripe CLI
|
||||
|
||||
The Stripe CLI forwards webhook events from Stripe to your local development server.
|
||||
|
||||
### Using Docker (Recommended)
|
||||
|
||||
First, log in to Stripe:
|
||||
|
||||
```bash
|
||||
docker run --rm -it --name=stripe \
|
||||
-v ~/.config/stripe:/root/.config/stripe \
|
||||
stripe/stripe-cli:latest login
|
||||
```
|
||||
|
||||
This opens a browser window to authenticate. Complete the login process.
|
||||
|
||||
Then start listening for webhooks:
|
||||
|
||||
```bash
|
||||
pnpm run stripe:listen
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
docker run --rm -it --name=stripe \
|
||||
-v ~/.config/stripe:/root/.config/stripe \
|
||||
stripe/stripe-cli:latest listen \
|
||||
--forward-to http://host.docker.internal:3000/api/billing/webhook
|
||||
```
|
||||
|
||||
### Using Stripe CLI Directly
|
||||
|
||||
If you prefer installing Stripe CLI globally:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install stripe/stripe-cli/stripe
|
||||
|
||||
# Login
|
||||
stripe login
|
||||
|
||||
# Listen for webhooks
|
||||
stripe listen --forward-to localhost:3000/api/billing/webhook
|
||||
```
|
||||
|
||||
### Copy the Webhook Secret
|
||||
|
||||
When you start listening, the CLI displays a webhook signing secret:
|
||||
|
||||
```
|
||||
> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Copy this value and add it to your `.env.local`:
|
||||
|
||||
```bash
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
{% alert type="default" title="Re-run after restart" %}
|
||||
The webhook secret changes each time you restart the Stripe CLI. Update your `.env.local` accordingly.
|
||||
{% /alert %}
|
||||
|
||||
### Linux Troubleshooting
|
||||
|
||||
If webhooks aren't reaching your app on Linux, try adding `--network=host`:
|
||||
|
||||
```bash
|
||||
docker run --rm -it --name=stripe \
|
||||
-v ~/.config/stripe:/root/.config/stripe \
|
||||
stripe/stripe-cli:latest listen \
|
||||
--network=host \
|
||||
--forward-to http://localhost:3000/api/billing/webhook
|
||||
```
|
||||
|
||||
## Step 5: Configure Customer Portal
|
||||
|
||||
The Stripe Customer Portal lets users manage their subscriptions, payment methods, and invoices.
|
||||
|
||||
1. Go to Stripe Dashboard → Settings → Billing → Customer portal
|
||||
2. Configure these settings:
|
||||
|
||||
**Payment methods:**
|
||||
- Allow customers to update payment methods: ✅
|
||||
|
||||
**Subscriptions:**
|
||||
- Allow customers to switch plans: ✅
|
||||
- Choose products customers can switch between
|
||||
- Configure proration behavior
|
||||
|
||||
**Cancellations:**
|
||||
- Allow customers to cancel subscriptions: ✅
|
||||
- Configure cancellation behavior (immediate vs. end of period)
|
||||
|
||||
**Invoices:**
|
||||
- Allow customers to view invoice history: ✅
|
||||
|
||||
{% img src="/assets/images/docs/stripe-customer-portal.webp" width="2712" height="1870" /%}
|
||||
|
||||
## Step 6: Production Webhooks
|
||||
|
||||
When deploying to production, configure webhooks in the Stripe Dashboard:
|
||||
|
||||
1. Go to Stripe Dashboard → Developers → Webhooks
|
||||
2. Click **Add endpoint**
|
||||
3. Enter your webhook URL: `https://yourdomain.com/api/billing/webhook`
|
||||
4. Select events to listen for:
|
||||
|
||||
**Required events:**
|
||||
- `checkout.session.completed`
|
||||
- `customer.subscription.created`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
|
||||
**For one-off payments (optional):**
|
||||
- `checkout.session.async_payment_failed`
|
||||
- `checkout.session.async_payment_succeeded`
|
||||
|
||||
5. Click **Add endpoint**
|
||||
6. Copy the signing secret and add it to your production environment variables
|
||||
|
||||
{% alert type="warning" title="Use a public URL" %}
|
||||
Webhook URLs must be publicly accessible. Vercel preview deployments with authentication enabled won't work. Test by visiting the URL in an incognito browser window.
|
||||
{% /alert %}
|
||||
|
||||
## Free Trials Without Credit Card
|
||||
|
||||
Allow users to start a trial without entering payment information:
|
||||
|
||||
```bash
|
||||
STRIPE_ENABLE_TRIAL_WITHOUT_CC=true
|
||||
```
|
||||
|
||||
When enabled, users can start a subscription with a trial period and won't be charged until the trial ends. They'll need to add a payment method before the trial expires.
|
||||
|
||||
You must also set `trialDays` in your billing schema:
|
||||
|
||||
```tsx
|
||||
{
|
||||
id: 'pro-monthly',
|
||||
name: 'Pro Monthly',
|
||||
paymentType: 'recurring',
|
||||
interval: 'month',
|
||||
trialDays: 14, // 14-day free trial
|
||||
lineItems: [/* ... */],
|
||||
}
|
||||
```
|
||||
|
||||
## Migrating Existing Subscriptions
|
||||
|
||||
If you're migrating to Makerkit with existing Stripe subscriptions, you need to add metadata to each subscription.
|
||||
|
||||
Makerkit expects this metadata on subscriptions:
|
||||
|
||||
```json
|
||||
{
|
||||
"accountId": "uuid-of-the-account"
|
||||
}
|
||||
```
|
||||
|
||||
**Option 1: Add metadata manually**
|
||||
|
||||
Use the Stripe Dashboard or a migration script to add the `accountId` metadata to existing subscriptions.
|
||||
|
||||
**Option 2: Modify the webhook handler**
|
||||
|
||||
If you can't update metadata, modify the webhook handler to look up accounts by customer ID:
|
||||
|
||||
```tsx {% title="packages/billing/stripe/src/services/stripe-webhook-handler.service.ts" %}
|
||||
// Instead of:
|
||||
const accountId = subscription.metadata.accountId as string;
|
||||
|
||||
// Query your database:
|
||||
const { data: customer } = await supabase
|
||||
.from('billing_customers')
|
||||
.select('account_id')
|
||||
.eq('customer_id', subscription.customer)
|
||||
.single();
|
||||
|
||||
const accountId = customer?.account_id;
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Webhooks not received
|
||||
|
||||
1. **Check the CLI is running:** `pnpm run stripe:listen` should show "Ready!"
|
||||
2. **Verify the secret:** Copy the new webhook secret after each CLI restart
|
||||
3. **Check the account:** Ensure you're logged into the correct Stripe account
|
||||
4. **Check the URL:** The webhook endpoint is `/api/billing/webhook`
|
||||
|
||||
### "No such price" error
|
||||
|
||||
The Price ID in your billing schema doesn't exist in Stripe. Verify:
|
||||
1. You're using test mode keys with test mode prices (or live with live)
|
||||
2. The Price ID is copied correctly from Stripe Dashboard
|
||||
|
||||
### Subscription not appearing in database
|
||||
|
||||
1. Check webhook logs in Stripe Dashboard → Developers → Webhooks
|
||||
2. Look for errors in your application logs
|
||||
3. Verify the `accountId` is correctly passed in checkout metadata
|
||||
|
||||
### Customer Portal not loading
|
||||
|
||||
1. Ensure the Customer Portal is configured in Stripe Dashboard
|
||||
2. Check that the customer has a valid subscription
|
||||
3. Verify the `customerId` is correct
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
Before going live:
|
||||
|
||||
- [ ] Test subscription checkout with test card `4242 4242 4242 4242`
|
||||
- [ ] Verify subscription appears in user's billing section
|
||||
- [ ] Test subscription upgrade/downgrade via Customer Portal
|
||||
- [ ] Test subscription cancellation
|
||||
- [ ] Verify webhook events are processed correctly
|
||||
- [ ] Test with failing card `4000 0000 0000 0002` to verify error handling
|
||||
- [ ] For trials: test trial expiration and conversion to paid
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Billing Overview](/docs/next-supabase-turbo/billing/overview) - Architecture and concepts
|
||||
- [Billing Schema](/docs/next-supabase-turbo/billing/billing-schema) - Configure your pricing
|
||||
- [Webhooks](/docs/next-supabase-turbo/billing/billing-webhooks) - Custom webhook handling
|
||||
- [Metered Usage](/docs/next-supabase-turbo/billing/metered-usage) - Report usage to Stripe
|
||||
- [Per-Seat Billing](/docs/next-supabase-turbo/billing/per-seat-billing) - Team-based pricing
|
||||
Reference in New Issue
Block a user