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

388 lines
9.6 KiB
Plaintext

---
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