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
388 lines
9.6 KiB
Plaintext
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
|