# How to Build a Billing System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a recurring billing system in Lovable using Stripe Billing for subscription invoices, automatic payment collection, and PDF invoice generation via an Edge Function. You'll track the full subscription lifecycle, send invoice emails, and display revenue trends with Recharts — all backed by Supabase and secured with RLS.

## Before you start

- Lovable Pro account for multi-step Edge Function generation
- Stripe account with Billing module enabled (available on all plans)
- Stripe secret key, publishable key, and webhook secret stored in Cloud tab → Secrets
- Completed payment-gateway-integration setup or familiarity with PaymentIntents and Stripe webhooks
- Supabase project with service role key available in Secrets
- Understanding that Stripe billing flows only work in deployed mode, not Lovable preview

## Step-by-step guide

### 1. Set up the billing schema in Supabase

Create the tables that track subscriptions, invoices, and billing events. These mirror Stripe's data model so you always have a local copy of billing state for fast queries without hitting the Stripe API on every page load.

```
Create a billing system schema in Supabase:

Tables:
- billing_plans: id (uuid pk), name (text), stripe_price_id (text unique), stripe_product_id (text), amount_cents (int), currency (text default 'usd'), interval (text: month|year), features (text array), is_active (bool default true), created_at
- subscriptions: id (uuid pk), user_id (references auth.users), stripe_subscription_id (text unique), stripe_customer_id (text), plan_id (uuid references billing_plans), status (text: active|past_due|canceled|trialing|unpaid), current_period_start (timestamptz), current_period_end (timestamptz), cancel_at_period_end (bool default false), created_at, updated_at
- invoices: id (uuid pk), user_id (references auth.users), subscription_id (uuid references subscriptions), stripe_invoice_id (text unique), amount_due (int), amount_paid (int), currency (text), status (text: draft|open|paid|void|uncollectible), due_date (timestamptz), paid_at (timestamptz), hosted_invoice_url (text), invoice_pdf_url (text), period_start (timestamptz), period_end (timestamptz), created_at
- billing_events: id (uuid pk), user_id (references auth.users), stripe_event_id (text unique), event_type (text), payload (jsonb), processed_at (timestamptz)

RLS: users can SELECT their own rows across all tables. Service role has full access.
```

> Pro tip: The billing_events table with a unique stripe_event_id gives you idempotency for free. Before processing any webhook, try to INSERT the event ID. If it fails (duplicate), skip processing and return 200.

**Expected result:** All four billing tables are created with RLS. The schema mirrors Stripe's subscription and invoice model. TypeScript types are generated.

### 2. Build the subscription creation Edge Function

Create an Edge Function that creates a Stripe customer, attaches a payment method, and starts a subscription. This Edge Function is called when a user picks a billing plan.

```
Create a Supabase Edge Function at supabase/functions/create-subscription/index.ts.

Accept POST body: { planId: string, paymentMethodId: string }

Logic:
1. Get the authenticated user from the Authorization header (Supabase JWT)
2. Look up billing_plans by planId to get stripe_price_id
3. Check if the user already has a stripe_customer_id in the subscriptions table
4. If not: create a Stripe customer via POST to https://api.stripe.com/v1/customers with the user's email
5. Attach the payment method to the customer: POST to /v1/payment_methods/{paymentMethodId}/attach with customer ID
6. Set it as the default payment method: POST to /v1/customers/{customerId} with invoice_settings[default_payment_method]
7. Create the subscription: POST to /v1/subscriptions with customer, items[0][price] = stripe_price_id, expand[]=latest_invoice.payment_intent
8. If the subscription's latest_invoice.payment_intent.status is 'requires_action', return { requiresAction: true, clientSecret: pi.client_secret }
9. Insert into subscriptions table with status from Stripe response
10. Return { subscriptionId, status }
```

> Pro tip: Expand latest_invoice.payment_intent in the Stripe subscription create call so you can immediately handle 3D Secure authentication if the card requires it, without needing a separate API call.

**Expected result:** The Edge Function creates a Stripe subscription and inserts a row in the subscriptions table. Calling with a test card 4242 4242 4242 4242 returns status: active.

### 3. Build the comprehensive webhook handler

Create the webhook Edge Function that handles the full subscription lifecycle: invoice creation, payment success, payment failure, and subscription cancellation.

```
// supabase/functions/billing-webhook/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import Stripe from 'https://esm.sh/stripe@14?target=deno'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {
  apiVersion: '2023-10-16',
  httpClient: Stripe.createFetchHttpClient(),
})
const cryptoProvider = Stripe.createSubtleCryptoProvider()

serve(async (req: Request) => {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature') ?? ''

  let event: Stripe.Event
  try {
    event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '', undefined, cryptoProvider)
  } catch {
    return new Response('Webhook signature failed', { status: 400 })
  }

  const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')

  const { error: dupError } = await supabase.from('billing_events').insert({ stripe_event_id: event.id, event_type: event.type, payload: event.data.object, processed_at: new Date().toISOString() })
  if (dupError?.code === '23505') return new Response(JSON.stringify({ received: true }), { status: 200 })

  switch (event.type) {
    case 'invoice.finalized':
    case 'invoice.paid': {
      const inv = event.data.object as Stripe.Invoice
      const subId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id
      const { data: sub } = await supabase.from('subscriptions').select('id, user_id').eq('stripe_subscription_id', subId).single()
      if (sub) {
        await supabase.from('invoices').upsert({
          user_id: sub.user_id,
          subscription_id: sub.id,
          stripe_invoice_id: inv.id,
          amount_due: inv.amount_due,
          amount_paid: inv.amount_paid,
          currency: inv.currency,
          status: inv.status ?? 'open',
          due_date: inv.due_date ? new Date(inv.due_date * 1000).toISOString() : null,
          paid_at: inv.status_transitions?.paid_at ? new Date(inv.status_transitions.paid_at * 1000).toISOString() : null,
          hosted_invoice_url: inv.hosted_invoice_url,
          invoice_pdf_url: inv.invoice_pdf,
          period_start: new Date(inv.period_start * 1000).toISOString(),
          period_end: new Date(inv.period_end * 1000).toISOString(),
        }, { onConflict: 'stripe_invoice_id' })
      }
      break
    }
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription
      await supabase.from('subscriptions').update({
        status: sub.status,
        cancel_at_period_end: sub.cancel_at_period_end,
        current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
        updated_at: new Date().toISOString(),
      }).eq('stripe_subscription_id', sub.id)
      break
    }
  }

  return new Response(JSON.stringify({ received: true }), { status: 200 })
})
```

> Pro tip: Register the billing-webhook endpoint in Stripe with these events: invoice.finalized, invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted. Subscribing only to needed events keeps your webhook logs clean.

**Expected result:** Test events sent from the Stripe Dashboard update the invoices and subscriptions tables in Supabase. The billing_events table records every processed event for audit purposes.

### 4. Build the invoice list and PDF download page

Ask Lovable to create the invoice management page where users see all their invoices, download PDFs, and pay any open invoices.

```
Build an invoice management page at src/pages/Invoices.tsx.

Requirements:
- Fetch all invoices for the current user from Supabase, ordered by created_at DESC
- Render as a shadcn/ui DataTable with columns:
  - Invoice period (formatted as 'Jan 2025 – Feb 2025' from period_start/period_end)
  - Amount Due (formatted as currency)
  - Amount Paid (formatted as currency)
  - Status Badge (paid=green, open=blue, uncollectible=red, void=gray, draft=gray)
  - Due Date (formatted date)
  - Actions (two Buttons: 'Download PDF' and 'View Invoice')
- 'Download PDF' opens invoice_pdf_url in a new tab
- 'View Invoice' opens hosted_invoice_url in a new tab
- For open invoices with a past due_date, show an Alert banner at the top: 'You have {count} overdue invoice(s). Pay now to avoid service interruption.' with a Button that opens the hosted_invoice_url
- Add a summary row at the bottom showing total paid this year
- Show a Skeleton loading state while fetching
```

**Expected result:** The invoice page shows all billing history. Overdue invoices trigger the Alert banner. Download PDF opens Stripe's hosted PDF in a new tab.

### 5. Build the revenue analytics dashboard

Ask Lovable to create a revenue analytics page showing MRR trends, subscription counts, and churn rate using Recharts charts.

```
Build a revenue analytics page at src/pages/BillingAnalytics.tsx. This page is visible only to admin users.

Requirements:
- Fetch all invoices from Supabase using the service role (call a Supabase RPC function get_billing_analytics that returns aggregated data)
- The RPC function should return:
  - monthly_revenue: array of { month: string, revenue_cents: int } for last 12 months
  - active_subscriptions: int count of subscriptions with status='active'
  - past_due_subscriptions: int count with status='past_due'
  - churned_this_month: int count of subscriptions canceled in current calendar month
  - total_revenue_ytd: int sum of amount_paid for current year
- Display four metric Cards at the top: MRR (latest month), Active Subs, Past Due, YTD Revenue
- Below cards, render a Recharts AreaChart showing monthly_revenue over 12 months with a gradient fill
- Show a second BarChart comparing monthly new subscriptions vs cancellations (requires two more data points in the RPC)
- Use shadcn/ui Tabs to switch between 'Overview', 'Invoices', and 'Subscriptions' views on the same page
```

**Expected result:** The analytics page shows revenue trends in a chart. The four metric Cards update from real Supabase data. The Tabs switch between different analytics views.

## Complete code example

File: `supabase/functions/billing-webhook/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import Stripe from 'https://esm.sh/stripe@14?target=deno'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {
  apiVersion: '2023-10-16',
  httpClient: Stripe.createFetchHttpClient(),
})
const cryptoProvider = Stripe.createSubtleCryptoProvider()

serve(async (req: Request) => {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature') ?? ''

  let event: Stripe.Event
  try {
    event = await stripe.webhooks.constructEventAsync(
      body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '',
      undefined, cryptoProvider
    )
  } catch {
    return new Response('Signature verification failed', { status: 400 })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { error: dupErr } = await supabase
    .from('billing_events')
    .insert({ stripe_event_id: event.id, event_type: event.type, payload: event.data.object, processed_at: new Date().toISOString() })
  if (dupErr?.code === '23505') {
    return new Response(JSON.stringify({ received: true }), { status: 200 })
  }

  if (event.type === 'invoice.paid' || event.type === 'invoice.finalized') {
    const inv = event.data.object as Stripe.Invoice
    const stripeSubId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id
    if (stripeSubId) {
      const { data: sub } = await supabase
        .from('subscriptions')
        .select('id, user_id')
        .eq('stripe_subscription_id', stripeSubId)
        .single()
      if (sub) {
        await supabase.from('invoices').upsert({
          user_id: sub.user_id,
          subscription_id: sub.id,
          stripe_invoice_id: inv.id,
          amount_due: inv.amount_due,
          amount_paid: inv.amount_paid,
          currency: inv.currency,
          status: inv.status ?? 'open',
          hosted_invoice_url: inv.hosted_invoice_url ?? null,
          invoice_pdf_url: inv.invoice_pdf ?? null,
          period_start: new Date(inv.period_start * 1000).toISOString(),
          period_end: new Date(inv.period_end * 1000).toISOString(),
          paid_at: inv.status === 'paid' ? new Date().toISOString() : null,
        }, { onConflict: 'stripe_invoice_id' })
      }
    }
  }

  if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted') {
    const sub = event.data.object as Stripe.Subscription
    await supabase.from('subscriptions').update({
      status: sub.status,
      cancel_at_period_end: sub.cancel_at_period_end,
      current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
      updated_at: new Date().toISOString(),
    }).eq('stripe_subscription_id', sub.id)
  }

  return new Response(JSON.stringify({ received: true }), { status: 200 })
})
```

## Common mistakes

- **Not storing Stripe invoice data locally in Supabase** — Fetching invoice history directly from the Stripe API on every page load is slow and counts toward your Stripe API rate limits. For billing dashboards with many users, this quickly becomes a bottleneck. Fix: Use webhooks to sync every invoice state change into your Supabase invoices table. Your frontend always reads from Supabase, which is fast and unlimited. Treat Stripe as the source of truth and Supabase as a read-optimized cache.
- **Missing the invoice.finalized event in webhook subscriptions** — If you only listen for invoice.paid, you miss the moment Stripe finalizes the invoice (making it immutable). The hosted_invoice_url and invoice_pdf URL are set at finalization time, before payment. Fix: Subscribe to both invoice.finalized and invoice.paid. Use upsert with stripe_invoice_id as the conflict key so both events update the same row without creating duplicates.
- **Canceling subscriptions immediately instead of at period end** — Canceling a subscription immediately stops billing and revokes access even if the customer has paid for the rest of the month, creating a poor user experience and potential refund requests. Fix: When a user clicks Cancel, use the Stripe API to set cancel_at_period_end: true rather than deleting the subscription. The subscription remains active until current_period_end, then cancels automatically.
- **Displaying raw Stripe timestamps without timezone conversion** — Stripe returns all timestamps as Unix epoch seconds in UTC. Displaying them without formatting shows confusing numbers like 1704067200. Fix: Always convert Stripe timestamps using new Date(timestamp * 1000) before storing in Supabase as ISO strings. Display them to users using JavaScript Intl.DateTimeFormat with their local timezone.

## Best practices

- Always use idempotency keys when creating Stripe subscriptions and invoices. Pass an Idempotency-Key header to Stripe API requests so retried requests do not create duplicate billing records.
- Mirror all Stripe subscription and invoice state in Supabase via webhooks. Never query the Stripe API directly from the frontend — always read from your local database cache.
- Subscribe to the minimum set of webhook events you actually handle. Extra events add noise to your billing_events table and make debugging webhook flows harder.
- Test the full subscription lifecycle in Stripe test mode using test clock advancement. Stripe's test clocks let you simulate time passing and trigger renewal, dunning, and cancellation events without waiting real days.
- Implement cancel_at_period_end instead of immediate cancellation. Users who paid for the current billing period deserve access until it expires.
- Add a billing_events table with a unique stripe_event_id column as your first line of idempotency defense. INSERT the event before processing and skip if it already exists.
- Use Stripe's Customer Portal for self-service plan changes and payment method updates instead of building your own UI for these operations.
- Set up Stripe Radar rules to automatically block high-risk payment attempts. This reduces failed payment attempts that count toward your webhook load.

## Frequently asked questions

### What is the difference between Stripe Billing and a regular PaymentIntent?

A PaymentIntent handles a single one-time charge. Stripe Billing manages the full recurring subscription lifecycle: it automatically creates invoices at renewal, retries failed payments (dunning), sends payment failure notifications to customers, and handles proration when plans change. Use PaymentIntents for one-time purchases and Stripe Billing for anything that repeats monthly or annually.

### Do I need to store invoice PDFs in Supabase Storage?

No. Stripe generates and hosts PDF invoices automatically at a URL stored in the invoice_pdf field. You can link directly to this URL from your app — it is a permanent, publicly accessible link tied to the invoice. Only build custom PDF generation if you need branded invoices with your own design that Stripe's default layout does not provide.

### How does Stripe handle failed recurring payments?

Stripe's Smart Retries automatically retries failed payments using machine learning to pick optimal retry times (typically 3–4 retries over 8 days). After retries are exhausted, Stripe sends a final invoice.payment_failed event and marks the subscription as unpaid. You configure the number of retries and whether to cancel the subscription after failure in your Stripe Dashboard → Settings → Billing.

### How do I handle subscription upgrades and downgrades?

Use the Stripe API to update the subscription's price ID. Set proration_behavior to create_prorations to automatically calculate and charge or credit the difference. Stripe creates a prorated invoice immediately. In your webhook handler, listen for customer.subscription.updated and update your subscriptions table with the new plan_id and status.

### Can I offer a free trial before billing starts?

Yes. When creating the subscription, pass trial_end as a Unix timestamp (e.g. 14 days from now). Stripe creates the subscription with status trialing and does not charge until the trial ends. A customer.subscription.trial_will_end event fires 3 days before the trial expires — use this to send a reminder email via your webhook handler.

### How do I set up the Stripe Customer Portal?

Enable the Customer Portal in your Stripe Dashboard → Settings → Billing → Customer portal. Configure which features you allow (plan changes, cancellation, payment method updates). Then create a portal session from an Edge Function using the Stripe API: POST to /v1/billing_portal/sessions with customer and return_url. Redirect the user to the session URL.

### Is there help available for building a more complex billing system?

RapidDev builds production billing systems in Lovable including multi-tier plans, usage-based billing, and enterprise invoicing workflows. Reach out if you need custom billing architecture beyond what this guide covers.

### How do I test the full subscription lifecycle without charging a real card?

Use Stripe test mode with the test card 4242 4242 4242 4242 and any future expiry date. For simulating subscription renewals and invoice generation without waiting a month, use Stripe's Test Clocks feature in the Dashboard. Create a test clock, attach your test subscription to it, and advance time to trigger renewal events instantly.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/billing-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/billing-system
