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
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
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.
1Create a billing system schema in Supabase:23Tables: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_at5- 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_at6- 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_at7- billing_events: id (uuid pk), user_id (references auth.users), stripe_event_id (text unique), event_type (text), payload (jsonb), processed_at (timestamptz)89RLS: 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.
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.
1Create a Supabase Edge Function at supabase/functions/create-subscription/index.ts.23Accept POST body: { planId: string, paymentMethodId: string }45Logic:61. Get the authenticated user from the Authorization header (Supabase JWT)72. Look up billing_plans by planId to get stripe_price_id83. Check if the user already has a stripe_customer_id in the subscriptions table94. If not: create a Stripe customer via POST to https://api.stripe.com/v1/customers with the user's email105. Attach the payment method to the customer: POST to /v1/payment_methods/{paymentMethodId}/attach with customer ID116. 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_intent138. 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 response1510. 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.
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.
1// supabase/functions/billing-webhook/index.ts2import { 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'56const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {7 apiVersion: '2023-10-16',8 httpClient: Stripe.createFetchHttpClient(),9})10const cryptoProvider = Stripe.createSubtleCryptoProvider()1112serve(async (req: Request) => {13 const body = await req.text()14 const sig = req.headers.get('stripe-signature') ?? ''1516 let event: Stripe.Event17 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 }2223 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')2425 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 })2728 switch (event.type) {29 case 'invoice.finalized':30 case 'invoice.paid': {31 const inv = event.data.object as Stripe.Invoice32 const subId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id33 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 break52 }53 case 'customer.subscription.updated':54 case 'customer.subscription.deleted': {55 const sub = event.data.object as Stripe.Subscription56 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 break63 }64 }6566 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.
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.
1Build an invoice management page at src/pages/Invoices.tsx.23Requirements:4- Fetch all invoices for the current user from Supabase, ordered by created_at DESC5- 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 tab13- 'View Invoice' opens hosted_invoice_url in a new tab14- 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_url15- Add a summary row at the bottom showing total paid this year16- Show a Skeleton loading state while fetchingExpected 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.
Build the revenue analytics dashboard
Ask Lovable to create a revenue analytics page showing MRR trends, subscription counts, and churn rate using Recharts charts.
1Build a revenue analytics page at src/pages/BillingAnalytics.tsx. This page is visible only to admin users.23Requirements: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 months7 - 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 month10 - total_revenue_ytd: int sum of amount_paid for current year11- Display four metric Cards at the top: MRR (latest month), Active Subs, Past Due, YTD Revenue12- Below cards, render a Recharts AreaChart showing monthly_revenue over 12 months with a gradient fill13- 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 pageExpected 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
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'45const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {6 apiVersion: '2023-10-16',7 httpClient: Stripe.createFetchHttpClient(),8})9const cryptoProvider = Stripe.createSubtleCryptoProvider()1011serve(async (req: Request) => {12 const body = await req.text()13 const sig = req.headers.get('stripe-signature') ?? ''1415 let event: Stripe.Event16 try {17 event = await stripe.webhooks.constructEventAsync(18 body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '',19 undefined, cryptoProvider20 )21 } catch {22 return new Response('Signature verification failed', { status: 400 })23 }2425 const supabase = createClient(26 Deno.env.get('SUPABASE_URL') ?? '',27 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''28 )2930 const { error: dupErr } = await supabase31 .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 }3637 if (event.type === 'invoice.paid' || event.type === 'invoice.finalized') {38 const inv = event.data.object as Stripe.Invoice39 const stripeSubId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id40 if (stripeSubId) {41 const { data: sub } = await supabase42 .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 }6465 if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted') {66 const sub = event.data.object as Stripe.Subscription67 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 }7475 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation