# Billing & Subscriptions
PayPal REST API integration for Next.js 14 — one-time orders, monthly/yearly subscriptions, refunds, webhook verification, usage tracking, and invoice generation, all wired to Supabase.
## What's included
**Plan config**
- `BILLING_PLANS` — array of `BillingPlan` objects for `pro_monthly` ($9) and `pro_yearly` ($79); includes limits, features, INR equivalents, and PayPal plan IDs read from env vars
- `FREE_LIMITS` — baseline limits for unauthenticated/free users
- `USAGE_LIMITS` — combined export of both
- `BillingPlan` — TypeScript interface
**PayPal — one-time orders**
- `createOneTimeOrder(amountUSD, description, returnBase?)` — creates a PayPal order with `CAPTURE` intent; returns the order object including the approval URL
- `captureOneTimeOrder(orderId)` — captures a previously approved order
- `getOrder(orderId)` — fetches order details by ID
**PayPal — subscriptions**
- `createSubscription(planId, subscriberEmail, returnBase?)` — creates a PayPal subscription for a plan from `BILLING_PLANS`; returns the subscription object with approval URL
- `cancelSubscription(paypalSubId, reason?)` — cancels a subscription
- `suspendSubscription(paypalSubId, reason?)` — pauses billing without cancelling
- `reactivateSubscription(paypalSubId, reason?)` — reactivates a suspended subscription
- `getSubscriptionDetails(paypalSubId)` — fetches live subscription state from PayPal
**PayPal — refunds & webhooks**
- `issueRefund(captureId, amountUSD?, reason?)` — full or partial refund against a capture ID
- `verifyPayPalWebhook(headers, rawBody)` — verifies webhook signature via PayPal's API; returns boolean
**Supabase — subscriptions & invoices**
- `getUserSubscription(userId)` — fetches the active subscription row for a user
- `upsertSubscription(data)` — inserts or updates a subscription row by `paypal_sub_id`
- `getUserInvoices(userId, limit?)` — returns invoice history newest-first
- `createInvoiceRecord(data)` — inserts an invoice row with optional line items (JSONB)
**Supabase — usage tracking**
- `trackUsage(userId, feature, quantity?, metadata?)` — appends a usage event row
- `getUsageThisPeriod(userId, feature)` — sums usage for the current calendar month
- `checkUsageLimit(userId, feature, hasPro)` — returns `{ allowed, used, limit }` against free or pro limits
**Invoice & display**
- `generateInvoiceHTML(opts)` — returns a complete HTML invoice string; accepts line items array; ready to send via email or serve as a download
- `usdToInr(usd)` — converts at hardcoded 84x rate
- `formatInr(usd)` — returns formatted string e.g. `₹756`
- `formatUsd(usd)` — returns `$9.00`
## Setup
### 1. Install dependencies
```bash
npm install @supabase/supabase-js
# No extra PayPal SDK needed — uses fetch against PayPal REST API directly
```
### 2. Environment variables
```
PAYPAL_CLIENT_ID=your PayPal app client ID
PAYPAL_CLIENT_SECRET=your PayPal app client secret
PAYPAL_MODE=sandbox # or 'live'
PAYPAL_WEBHOOK_ID=your webhook ID from PayPal dashboard
PAYPAL_PRO_MONTHLY_PLAN_ID=P-xxxx # from PayPal billing plans
PAYPAL_PRO_YEARLY_PLAN_ID=P-xxxx
NEXT_PUBLIC_APP_URL=https://yourapp.com
NEXT_PUBLIC_SUPABASE_URL=your Supabase project URL
SUPABASE_SERVICE_ROLE_KEY=service role key (server-only)
```
### 3. Database
```sql
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
paypal_sub_id TEXT UNIQUE NOT NULL,
plan_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','paused','cancelled','expired')),
current_period_start TIMESTAMPTZ NOT NULL DEFAULT NOW(),
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "sub_own" ON subscriptions FOR SELECT USING (user_id::text = auth.uid()::text);
CREATE POLICY "sub_admin" ON subscriptions FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id::text = auth.uid()::text AND role IN ('admin','super_admin'))
);
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
amount DECIMAL(10,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
description TEXT,
paypal_id TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'paid'
CHECK (status IN ('paid','refunded','failed')),
line_items JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY "inv_own" ON invoices FOR SELECT USING (user_id::text = auth.uid()::text);
CREATE POLICY "inv_admin" ON invoices FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id::text = auth.uid()::text AND role IN ('admin','super_admin'))
);
CREATE TABLE usage_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
feature TEXT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
## Usage examples
```ts
// One-time purchase flow (API route)
import { createOneTimeOrder, captureOneTimeOrder, createInvoiceRecord } from '@/blocks/billing'
// Step 1 — create order, return approval URL to client
export const POST = async (req) => {
const { amountUSD, description } = await req.json()
const order = await createOneTimeOrder(amountUSD, description)
const approvalUrl = order.links.find(l => l.rel === 'approve').href
return Response.json({ orderId: order.id, approvalUrl })
}
// Step 2 — capture after user approves (webhook or return URL handler)
const captured = await captureOneTimeOrder(orderId)
await createInvoiceRecord({ userId, amount: 19, description: 'Auth System block', paypalId: orderId })
```
```ts
// Subscription creation
import { createSubscription, upsertSubscription } from '@/blocks/billing'
const sub = await createSubscription('pro_monthly', user.email)
const approvalUrl = sub.links.find(l => l.rel === 'approve').href
// redirect user to approvalUrl, then handle BILLING.SUBSCRIPTION.ACTIVATED webhook
```
```ts
// Usage gate before an AI call
import { checkUsageLimit, trackUsage } from '@/blocks/billing'
const sub = await getUserSubscription(session.user.id)
const { allowed, used, limit } = await checkUsageLimit(session.user.id, 'ai_customizations', !!sub)
if (!allowed) return Response.json({ error: `Limit reached (${used}/${limit})` }, { status: 403 })
await trackUsage(session.user.id, 'ai_customizations')
// proceed with AI call
```
## Notes
- `BILLING_PLANS` reads `PAYPAL_PRO_MONTHLY_PLAN_ID` and `PAYPAL_PRO_YEARLY_PLAN_ID` at module load time — these must exist in `.env` before the module is imported or `paypalPlanId` will be an empty string and PayPal will return a 400
- `verifyPayPalWebhook` makes an outbound API call to PayPal to verify the signature — it is not HMAC-local like Razorpay/Stripe; this adds ~100–200ms latency to your webhook handler
- `getUsageThisPeriod` counts from the first of the current calendar month, not from the subscription start date — if a user subscribes on the 25th they get a near-full month free; adjust the `startOfMonth` logic if billing-period alignment matters
- `generateInvoiceHTML` has `marrowstack.dev` and `support@marrowstack.dev` hardcoded in the footer — update before shipping