# 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