Skip to main content
RapidDev - Software Development Agency

How to Build a Billing System with Lovable

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.

What you'll build

  • Stripe Billing subscriptions with monthly and annual pricing tiers stored in Supabase
  • Invoice list page with shadcn/ui DataTable showing due date, amount, and status Badges
  • PDF invoice generation via Supabase Edge Function using the Deno PDF library or Stripe's hosted invoice URL
  • Webhook handler processing invoice.paid, invoice.payment_failed, and customer.subscription.deleted events
  • Revenue dashboard with Recharts AreaChart for monthly recurring revenue and churn rate Cards
  • Overdue invoice Alert banner with retry payment Button linked to Stripe's hosted invoice page
  • Subscription management page letting users view their current plan and navigate to Customer Portal
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced14 min read3–4 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
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.

What you're building

Stripe Billing automates the recurring invoice lifecycle. When a subscription renews, Stripe creates a draft Invoice, finalizes it, attempts payment on the customer's saved card, and either marks it paid or triggers a payment_failed event. Your webhook handler listens to these events and keeps your Supabase invoices table synchronized with Stripe's state.

PDF invoice generation works two ways: for simplicity, Stripe provides a hosted_invoice_url and invoice_pdf URL on every finalized invoice. You can redirect users to the Stripe-hosted PDF without generating your own. For custom-branded PDFs, an Edge Function can fetch the invoice data from Supabase and render a PDF using the pdf-lib library available via esm.sh.

The revenue dashboard aggregates your invoices table: sum of amount_paid grouped by month gives MRR trends, count of canceled subscriptions divided by active subscriptions gives churn rate. These are straightforward SQL aggregations that Recharts turns into clear AreaCharts and metric Cards.

Final result

A complete recurring billing system with automated Stripe invoicing, PDF download, revenue analytics, and real-time subscription status tracking.

Tech stack

LovableFrontend app builder
Stripe BillingRecurring invoices and subscriptions
SupabaseDatabase and Edge Functions
shadcn/uiUI components
RechartsRevenue analytics charts
React Hook Form + ZodInvoice and plan forms

Prerequisites

  • 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

Build steps

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.

prompt.txt
1Create a billing system schema in Supabase:
2
3Tables:
4- 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
5- 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
6- 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
7- billing_events: id (uuid pk), user_id (references auth.users), stripe_event_id (text unique), event_type (text), payload (jsonb), processed_at (timestamptz)
8
9RLS: 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.

prompt.txt
1Create a Supabase Edge Function at supabase/functions/create-subscription/index.ts.
2
3Accept POST body: { planId: string, paymentMethodId: string }
4
5Logic:
61. Get the authenticated user from the Authorization header (Supabase JWT)
72. Look up billing_plans by planId to get stripe_price_id
83. Check if the user already has a stripe_customer_id in the subscriptions table
94. If not: create a Stripe customer via POST to https://api.stripe.com/v1/customers with the user's email
105. Attach the payment method to the customer: POST to /v1/payment_methods/{paymentMethodId}/attach with customer ID
116. Set it as the default payment method: POST to /v1/customers/{customerId} with invoice_settings[default_payment_method]
127. Create the subscription: POST to /v1/subscriptions with customer, items[0][price] = stripe_price_id, expand[]=latest_invoice.payment_intent
138. If the subscription's latest_invoice.payment_intent.status is 'requires_action', return { requiresAction: true, clientSecret: pi.client_secret }
149. Insert into subscriptions table with status from Stripe response
1510. 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
1// supabase/functions/billing-webhook/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4import Stripe from 'https://esm.sh/stripe@14?target=deno'
5
6const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {
7 apiVersion: '2023-10-16',
8 httpClient: Stripe.createFetchHttpClient(),
9})
10const cryptoProvider = Stripe.createSubtleCryptoProvider()
11
12serve(async (req: Request) => {
13 const body = await req.text()
14 const sig = req.headers.get('stripe-signature') ?? ''
15
16 let event: Stripe.Event
17 try {
18 event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '', undefined, cryptoProvider)
19 } catch {
20 return new Response('Webhook signature failed', { status: 400 })
21 }
22
23 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')
24
25 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() })
26 if (dupError?.code === '23505') return new Response(JSON.stringify({ received: true }), { status: 200 })
27
28 switch (event.type) {
29 case 'invoice.finalized':
30 case 'invoice.paid': {
31 const inv = event.data.object as Stripe.Invoice
32 const subId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id
33 const { data: sub } = await supabase.from('subscriptions').select('id, user_id').eq('stripe_subscription_id', subId).single()
34 if (sub) {
35 await supabase.from('invoices').upsert({
36 user_id: sub.user_id,
37 subscription_id: sub.id,
38 stripe_invoice_id: inv.id,
39 amount_due: inv.amount_due,
40 amount_paid: inv.amount_paid,
41 currency: inv.currency,
42 status: inv.status ?? 'open',
43 due_date: inv.due_date ? new Date(inv.due_date * 1000).toISOString() : null,
44 paid_at: inv.status_transitions?.paid_at ? new Date(inv.status_transitions.paid_at * 1000).toISOString() : null,
45 hosted_invoice_url: inv.hosted_invoice_url,
46 invoice_pdf_url: inv.invoice_pdf,
47 period_start: new Date(inv.period_start * 1000).toISOString(),
48 period_end: new Date(inv.period_end * 1000).toISOString(),
49 }, { onConflict: 'stripe_invoice_id' })
50 }
51 break
52 }
53 case 'customer.subscription.updated':
54 case 'customer.subscription.deleted': {
55 const sub = event.data.object as Stripe.Subscription
56 await supabase.from('subscriptions').update({
57 status: sub.status,
58 cancel_at_period_end: sub.cancel_at_period_end,
59 current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
60 updated_at: new Date().toISOString(),
61 }).eq('stripe_subscription_id', sub.id)
62 break
63 }
64 }
65
66 return new Response(JSON.stringify({ received: true }), { status: 200 })
67})

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.

prompt.txt
1Build an invoice management page at src/pages/Invoices.tsx.
2
3Requirements:
4- Fetch all invoices for the current user from Supabase, ordered by created_at DESC
5- Render as a shadcn/ui DataTable with columns:
6 - Invoice period (formatted as 'Jan 2025 – Feb 2025' from period_start/period_end)
7 - Amount Due (formatted as currency)
8 - Amount Paid (formatted as currency)
9 - Status Badge (paid=green, open=blue, uncollectible=red, void=gray, draft=gray)
10 - Due Date (formatted date)
11 - Actions (two Buttons: 'Download PDF' and 'View Invoice')
12- 'Download PDF' opens invoice_pdf_url in a new tab
13- 'View Invoice' opens hosted_invoice_url in a new tab
14- 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
15- Add a summary row at the bottom showing total paid this year
16- 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.

prompt.txt
1Build a revenue analytics page at src/pages/BillingAnalytics.tsx. This page is visible only to admin users.
2
3Requirements:
4- Fetch all invoices from Supabase using the service role (call a Supabase RPC function get_billing_analytics that returns aggregated data)
5- The RPC function should return:
6 - monthly_revenue: array of { month: string, revenue_cents: int } for last 12 months
7 - active_subscriptions: int count of subscriptions with status='active'
8 - past_due_subscriptions: int count with status='past_due'
9 - churned_this_month: int count of subscriptions canceled in current calendar month
10 - total_revenue_ytd: int sum of amount_paid for current year
11- Display four metric Cards at the top: MRR (latest month), Active Subs, Past Due, YTD Revenue
12- Below cards, render a Recharts AreaChart showing monthly_revenue over 12 months with a gradient fill
13- Show a second BarChart comparing monthly new subscriptions vs cancellations (requires two more data points in the RPC)
14- 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

supabase/functions/billing-webhook/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3import Stripe from 'https://esm.sh/stripe@14?target=deno'
4
5const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {
6 apiVersion: '2023-10-16',
7 httpClient: Stripe.createFetchHttpClient(),
8})
9const cryptoProvider = Stripe.createSubtleCryptoProvider()
10
11serve(async (req: Request) => {
12 const body = await req.text()
13 const sig = req.headers.get('stripe-signature') ?? ''
14
15 let event: Stripe.Event
16 try {
17 event = await stripe.webhooks.constructEventAsync(
18 body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '',
19 undefined, cryptoProvider
20 )
21 } catch {
22 return new Response('Signature verification failed', { status: 400 })
23 }
24
25 const supabase = createClient(
26 Deno.env.get('SUPABASE_URL') ?? '',
27 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
28 )
29
30 const { error: dupErr } = await supabase
31 .from('billing_events')
32 .insert({ stripe_event_id: event.id, event_type: event.type, payload: event.data.object, processed_at: new Date().toISOString() })
33 if (dupErr?.code === '23505') {
34 return new Response(JSON.stringify({ received: true }), { status: 200 })
35 }
36
37 if (event.type === 'invoice.paid' || event.type === 'invoice.finalized') {
38 const inv = event.data.object as Stripe.Invoice
39 const stripeSubId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id
40 if (stripeSubId) {
41 const { data: sub } = await supabase
42 .from('subscriptions')
43 .select('id, user_id')
44 .eq('stripe_subscription_id', stripeSubId)
45 .single()
46 if (sub) {
47 await supabase.from('invoices').upsert({
48 user_id: sub.user_id,
49 subscription_id: sub.id,
50 stripe_invoice_id: inv.id,
51 amount_due: inv.amount_due,
52 amount_paid: inv.amount_paid,
53 currency: inv.currency,
54 status: inv.status ?? 'open',
55 hosted_invoice_url: inv.hosted_invoice_url ?? null,
56 invoice_pdf_url: inv.invoice_pdf ?? null,
57 period_start: new Date(inv.period_start * 1000).toISOString(),
58 period_end: new Date(inv.period_end * 1000).toISOString(),
59 paid_at: inv.status === 'paid' ? new Date().toISOString() : null,
60 }, { onConflict: 'stripe_invoice_id' })
61 }
62 }
63 }
64
65 if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted') {
66 const sub = event.data.object as Stripe.Subscription
67 await supabase.from('subscriptions').update({
68 status: sub.status,
69 cancel_at_period_end: sub.cancel_at_period_end,
70 current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
71 updated_at: new Date().toISOString(),
72 }).eq('stripe_subscription_id', sub.id)
73 }
74
75 return new Response(JSON.stringify({ received: true }), { status: 200 })
76})

Customization ideas

Custom branded PDF invoices

Instead of using Stripe's hosted PDF, generate custom-branded invoices using pdf-lib from esm.sh in an Edge Function. Pull your logo from Supabase Storage, format a structured layout with your company name, the customer's billing address, line items, and totals. Return the PDF as a binary response with Content-Disposition: attachment.

Automated dunning emails

When invoice.payment_failed fires in the webhook, call Resend from the Edge Function to send a payment failure email to the customer. Include the invoice amount, a link to update their payment method (Customer Portal URL), and a direct link to pay the invoice. Implement a retry schedule: day 1, day 3, day 7 before marking the subscription as unpaid.

Tax calculation with Stripe Tax

Enable Stripe Tax in your Stripe Dashboard and add automatic_tax: { enabled: true } to subscription and invoice objects. Stripe calculates tax based on the customer's location automatically. Display the tax breakdown in the invoice detail view by reading the invoice's total_tax_amounts array.

Usage-based billing

For metered billing (charge per API call, per user seat, or per GB), add a usage_records table in Supabase. Create an Edge Function that reports usage to Stripe's usage records API each month. Stripe calculates the invoice amount based on reported usage multiplied by your unit price.

Multi-currency support

Add a preferred_currency column to your customers table. When creating subscriptions, pass the customer's preferred currency to Stripe. Display invoice amounts in the customer's currency throughout the UI. Stripe handles currency conversion and displays the original and converted amounts on the hosted invoice.

Common pitfalls

Pitfall: Not storing Stripe invoice data locally in Supabase

How to avoid: 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.

Pitfall: Missing the invoice.finalized event in webhook subscriptions

How to avoid: 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.

Pitfall: Canceling subscriptions immediately instead of at period end

How to avoid: 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.

Pitfall: Displaying raw Stripe timestamps without timezone conversion

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a Stripe Billing integration in a Lovable app with Supabase. Explain the difference between Stripe Products, Prices, Subscriptions, and Invoices and how they relate to each other. I need to design a Supabase schema that mirrors this hierarchy. Show me the SQL CREATE TABLE statements for subscriptions and invoices tables that capture all the important Stripe fields I'll need for a billing dashboard, including status, amounts, period dates, and hosted invoice URLs.

Lovable Prompt

Add a billing settings page at /settings/billing. Show the user's current subscription plan name, status Badge, and next billing date. Add a 'Manage Billing' Button that calls a Supabase Edge Function to create a Stripe Customer Portal session and redirects to the returned URL. Below that, show the last 3 invoices as compact rows with status and download link. Add a 'Upgrade Plan' Button that opens a Dialog showing the available billing_plans as Cards with feature lists and a Select Plan button per card.

Build Prompt

In Supabase, create a SQL function get_billing_analytics() that returns a single JSON object with: monthly_revenue (array of month+sum for last 12 months from the invoices table where status=paid), active_subscriptions (count where status=active), past_due_subscriptions (count where status=past_due), total_revenue_ytd (sum of amount_paid for current year), and churn_this_month (count of subscriptions where status=canceled and updated_at >= date_trunc month). Grant execute to authenticated role.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.