Build a full Stripe Subscriptions system in Lovable with tiered plans, upgrade and downgrade flows, a subscription events audit trail, and a self-service Customer Portal. Users pick plans from comparison Cards, Supabase tracks every lifecycle event, and Edge Functions handle all Stripe interactions securely.
What you're building
Stripe Subscriptions are built around three objects: Products (what you sell), Prices (how much and how often), and Subscriptions (a customer's active recurring purchase). You create Products and Prices once in the Stripe Dashboard or via API, then reference their IDs in your billing_plans table in Supabase. When a user selects a plan, your Edge Function creates a Stripe Subscription using the price ID.
The subscription lifecycle generates events that Stripe sends to your webhook: trial starts, trial ends, payment succeeds, payment fails, subscription is canceled, subscription is paused. Your subscription_events audit table records each event with the old status, new status, and a timestamp — giving you a complete history of every subscription change.
Upgrade and downgrade flows use Stripe's subscription update API with proration_behavior: create_prorations. When a user upgrades mid-cycle, Stripe charges the prorated difference immediately. When they downgrade, Stripe applies a credit to the next invoice. Your webhook handler captures the customer.subscription.updated event and syncs the new plan to Supabase.
Final result
A complete subscription management system with plan selection, lifecycle tracking, self-service upgrades and downgrades, and a full audit trail.
Tech stack
Prerequisites
- Lovable Pro account for Edge Function generation
- Stripe account with at least two Products and Prices created in the Stripe Dashboard
- STRIPE_SECRET_KEY, VITE_STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets
- Supabase project with service role key in Secrets
- Stripe Customer Portal enabled in Stripe Dashboard → Settings → Billing → Customer portal
- Deployed Lovable app URL for setting up webhook endpoints (Stripe does not work in preview)
Build steps
Set up the subscription schema and seed plans
Create the Supabase tables for plans, subscriptions, and the audit trail. Then ask Lovable to seed the billing_plans table with your Stripe Price IDs.
1Create a subscription system schema in Supabase:23Tables:4- billing_plans: id (uuid pk), name (text), description (text), stripe_price_id (text unique), stripe_product_id (text), amount_cents (int), currency (text default 'usd'), interval (text: month|year), features (jsonb array of strings), is_recommended (bool default false), is_active (bool default true), sort_order (int), created_at56- subscriptions: id (uuid pk), user_id (uuid references auth.users unique), stripe_subscription_id (text unique), stripe_customer_id (text), current_plan_id (uuid references billing_plans), status (text: active|trialing|past_due|canceled|unpaid|paused), trial_end (timestamptz), current_period_start (timestamptz), current_period_end (timestamptz), cancel_at_period_end (bool default false), canceled_at (timestamptz), created_at, updated_at78- subscription_events: id (uuid pk), subscription_id (uuid references subscriptions), user_id (uuid references auth.users), event_type (text), from_plan_id (uuid references billing_plans nullable), to_plan_id (uuid references billing_plans nullable), from_status (text), to_status (text), stripe_event_id (text unique), metadata (jsonb default '{}'), occurred_at (timestamptz)910RLS: users can SELECT their own subscription and subscription_events. Service role full access.1112Seed three billing plans with realistic feature sets: Starter ($9/mo), Pro ($29/mo, is_recommended=true), Business ($79/mo).Pro tip: Add a unique constraint on subscriptions.user_id so each user can only have one active subscription. Add a partial unique index for non-canceled subscriptions: CREATE UNIQUE INDEX one_active_sub_per_user ON subscriptions(user_id) WHERE status != 'canceled'.
Expected result: All three tables are created. Three billing_plans rows are seeded. TypeScript types are generated. The subscription table has the unique constraint on user_id.
Build the plan comparison page
Ask Lovable to build the pricing page with plan Cards, feature lists, a recommended badge, and a monthly/annual toggle that updates prices.
1Build a pricing page at src/pages/Pricing.tsx.23Requirements:4- Fetch all is_active billing_plans ordered by sort_order5- Show a monthly/annual Toggle at the top (shadcn/ui Switch with label)6- Display each plan as a shadcn/ui Card with:7 - Plan name and description8 - Price (format as $X/month or $X/year based on toggle)9 - If is_recommended=true, add a 'Most Popular' Badge above the card with accent border10 - Feature list: iterate the features array, each item with a CheckCircle icon11 - A 'Get Started' Button (primary for recommended, outline for others)12- If the user already has an active subscription:13 - The current plan's button shows 'Current Plan' and is disabled14 - Other plans show 'Upgrade' or 'Downgrade' based on price comparison15 - Clicking Upgrade/Downgrade opens a Confirmation Dialog explaining proration16- If no subscription, 'Get Started' opens a payment method collection Dialog17- Below the plans, add a FAQ Accordion with 4 questions about billing, cancellation, and upgradesPro tip: For the annual toggle, show the monthly price with a strikethrough and the discounted annual price. Calculate the annual savings percentage and show it as a Badge: 'Save 20%'. This increases annual plan conversion.
Expected result: The pricing page renders three plan cards with feature lists. The recommended plan has a highlighted border. The monthly/annual toggle updates displayed prices. Users with an existing subscription see their current plan labeled.
Create the subscription lifecycle Edge Functions
Build Edge Functions for creating subscriptions, upgrading/downgrading plans, canceling, and creating Customer Portal sessions.
1Create four Supabase Edge Functions:231. supabase/functions/create-subscription/index.ts4- Accept: { planId: string, paymentMethodId: string, trialDays?: number }5- Create Stripe customer if not exists, attach payment method, set as default6- Create Stripe subscription with price ID from billing_plans, optional trial_end7- Insert into subscriptions table with all Stripe IDs and status8- Return: { subscriptionId, status, clientSecret? } (clientSecret if requires payment action)9102. supabase/functions/update-subscription/index.ts11- Accept: { newPlanId: string }12- Get user's current subscription from Supabase13- Call Stripe API: PATCH /v1/subscriptions/{id} with items[0][id]=existing_item_id, items[0][price]=new_price_id, proration_behavior='create_prorations'14- Update subscriptions.current_plan_id in Supabase15- Insert a subscription_events row with event_type='plan_changed', from_plan_id, to_plan_id16- Return: { success: true }17183. supabase/functions/cancel-subscription/index.ts19- Accept: { immediately?: boolean }20- If immediately=false: PATCH /v1/subscriptions/{id} with cancel_at_period_end=true21- If immediately=true: DELETE /v1/subscriptions/{id}22- Update Supabase subscriptions row accordingly23- Return: { canceledAt }24254. supabase/functions/create-portal-session/index.ts26- No request body needed (reads user from JWT)27- Call POST /v1/billing_portal/sessions with customer_id and return_url28- Return: { url } — frontend redirects to this URLPro tip: When calling the Stripe subscription update API, always pass the items array with the existing subscription item ID to replace the price. Omitting the item ID creates a new subscription item instead of replacing the old one, resulting in two active prices on one subscription.
Expected result: All four Edge Functions deploy. Creating a subscription in test mode works. The update function changes the plan and creates an audit event. The portal session function returns a Stripe Customer Portal URL.
Build the subscription webhook handler
Create the comprehensive webhook Edge Function that processes all subscription lifecycle events and maintains the audit trail in subscription_events.
1// supabase/functions/subscription-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') ?? ''15 let event: Stripe.Event16 try {17 event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '', undefined, cryptoProvider)18 } catch {19 return new Response('Signature failed', { status: 400 })20 }2122 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')2324 const { error: dupErr } = await supabase.from('subscription_events').insert({ stripe_event_id: event.id, event_type: event.type, occurred_at: new Date().toISOString() })25 if (dupErr?.code === '23505') return new Response(JSON.stringify({ received: true }))2627 if (event.type.startsWith('customer.subscription.')) {28 const stripeSub = event.data.object as Stripe.Subscription29 const { data: sub } = await supabase.from('subscriptions').select('id, user_id, status, current_plan_id').eq('stripe_subscription_id', stripeSub.id).single()3031 if (sub) {32 const newStatus = stripeSub.status33 await supabase.from('subscriptions').update({34 status: newStatus,35 cancel_at_period_end: stripeSub.cancel_at_period_end,36 current_period_start: new Date(stripeSub.current_period_start * 1000).toISOString(),37 current_period_end: new Date(stripeSub.current_period_end * 1000).toISOString(),38 canceled_at: stripeSub.canceled_at ? new Date(stripeSub.canceled_at * 1000).toISOString() : null,39 updated_at: new Date().toISOString(),40 }).eq('id', sub.id)4142 await supabase.from('subscription_events').update({43 subscription_id: sub.id,44 user_id: sub.user_id,45 from_status: sub.status,46 to_status: newStatus,47 metadata: { stripe_subscription_id: stripeSub.id },48 }).eq('stripe_event_id', event.id)49 }50 }5152 return new Response(JSON.stringify({ received: true }), { status: 200 })53})Pro tip: Register the subscription-webhook endpoint in Stripe for these events: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, customer.subscription.trial_will_end. Add invoice.paid and invoice.payment_failed if you want invoice sync in the same handler.
Expected result: Stripe test events flow through the webhook and update the subscriptions table. The subscription_events table has a growing audit trail of each event with before and after status.
Build the subscription management dashboard
Ask Lovable to build the subscription status page where users see their current plan, next renewal date, manage billing via the Customer Portal, and view their event history.
1Build a subscription management page at src/pages/Subscription.tsx.23Requirements:4- Fetch the current user's subscription joined with billing_plans (for plan name and features)5- If no subscription, show a full-width Card: 'You are not subscribed' with a 'View Plans' Button linking to /pricing6- If subscribed, show:7 - A status Banner: 'Active' (green), 'Trial ends in X days' (blue), 'Payment past due' (red), 'Cancels on {date}' (yellow)8 - Current plan Card with: plan name, features list, amount, billing interval9 - Next renewal date formatted as 'Next billing date: March 1, 2026'10 - Three Buttons: 'Change Plan' (links to /pricing), 'Manage Billing' (calls create-portal-session Edge Function, redirects), 'Cancel Subscription' (opens Confirmation Dialog)11 - Cancel Dialog explains: 'Your plan stays active until {current_period_end}. You can reactivate before then.'12- Below the status section, show an Accordion: 'Billing History' — fetch last 10 subscription_events and display as a timeline list with event type, date, and from/to status badges13- For trialing users, show a countdown Card: 'X days left in your trial' with a progress barExpected result: The subscription page shows the user's current plan and status. The Manage Billing button redirects to the Stripe Customer Portal. The event history accordion shows lifecycle events.
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'34const corsHeaders = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1213 try {14 const authHeader = req.headers.get('Authorization') ?? ''15 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')16 const { data: { user } } = await createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {17 global: { headers: { Authorization: authHeader } }18 }).auth.getUser()1920 if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })2122 const { newPlanId } = await req.json()2324 const { data: newPlan } = await supabase.from('billing_plans').select('stripe_price_id').eq('id', newPlanId).single()25 if (!newPlan) return new Response(JSON.stringify({ error: 'Plan not found' }), { status: 404, headers: corsHeaders })2627 const { data: sub } = await supabase.from('subscriptions').select('stripe_subscription_id, current_plan_id').eq('user_id', user.id).single()28 if (!sub) return new Response(JSON.stringify({ error: 'No active subscription' }), { status: 404, headers: corsHeaders })2930 const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''31 const getSubRes = await fetch(`https://api.stripe.com/v1/subscriptions/${sub.stripe_subscription_id}`, {32 headers: { Authorization: `Basic ${btoa(stripeKey + ':')}` },33 })34 const stripeSub = await getSubRes.json()35 const itemId = stripeSub.items.data[0].id3637 const body = new URLSearchParams({38 'items[0][id]': itemId,39 'items[0][price]': newPlan.stripe_price_id,40 proration_behavior: 'create_prorations',41 })4243 const updateRes = await fetch(`https://api.stripe.com/v1/subscriptions/${sub.stripe_subscription_id}`, {44 method: 'POST',45 headers: {46 Authorization: `Basic ${btoa(stripeKey + ':')}`,47 'Content-Type': 'application/x-www-form-urlencoded',48 },49 body,50 })51 const updatedSub = await updateRes.json()5253 await supabase.from('subscriptions').update({ current_plan_id: newPlanId, status: updatedSub.status, updated_at: new Date().toISOString() }).eq('user_id', user.id)54 await supabase.from('subscription_events').insert({ user_id: user.id, event_type: 'plan_changed', from_plan_id: sub.current_plan_id, to_plan_id: newPlanId, from_status: 'active', to_status: 'active', occurred_at: new Date().toISOString() })5556 return new Response(JSON.stringify({ success: true, status: updatedSub.status }), { headers: corsHeaders })57 } catch (err) {58 const message = err instanceof Error ? err.message : 'Internal error'59 return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })60 }61})Customization ideas
Subscription pause and resume
Add a Pause Subscription button that uses Stripe's subscription pause collection feature (pause_collection: { behavior: 'keep_as_draft' }). This stops billing without canceling the subscription. Add a Resume button that removes the pause. Display a paused status Badge and estimated resume date on the subscription management page.
Trial extension for at-risk users
When a trial is nearing expiration and the user has not added a payment method, trigger an Edge Function via cron to send an email offering a 7-day trial extension. Include a one-click extension link that calls an Edge Function to update the Stripe trial_end date. Track extension usage in a trial_extensions table to limit abuse.
Seat-based team subscriptions
Add a team_members table and a seat_count column to subscriptions. When a user invites a team member, increment the Stripe subscription quantity. When they remove a member, decrement. The billing amount scales automatically. Add a team management page showing current seats used versus allowed per plan.
Subscription analytics for founders
Build an admin analytics page with MRR, churn rate, trial conversion rate, and average subscription length metrics. Calculate churn as (canceled_this_month / active_last_month) * 100. Pull all data from your Supabase subscriptions and subscription_events tables — no Stripe API calls needed for the dashboard.
Reactivation flow for canceled subscriptions
When a user with a canceled subscription returns to the pricing page, show a personalized banner: 'Welcome back. Reactivate your plan to regain access.' When they click, skip the payment method step (use their saved Stripe payment method) and create a new subscription with their previous plan pre-selected.
Common pitfalls
Pitfall: Not passing the subscription item ID when updating a plan
How to avoid: Always fetch the current subscription from Stripe first to get the items.data[0].id, then pass it as items[0][id] in the update request along with the new price ID.
Pitfall: Canceling subscriptions from the frontend directly
How to avoid: Always route cancellation through a Supabase Edge Function. The frontend calls the Edge Function endpoint, which uses STRIPE_SECRET_KEY from Deno.env to call the Stripe API.
Pitfall: Not handling the trialing subscription status
How to avoid: Treat trialing as equivalent to active for feature access purposes. In your subscription status checks, use: const hasAccess = ['active', 'trialing'].includes(subscription.status).
Pitfall: Redirecting users away from the app after the Customer Portal
How to avoid: Set the return_url to your subscription management page: https://yourapp.com/subscription. This returns users directly to their subscription status after managing billing.
Best practices
- Store all Stripe subscription data locally in Supabase. Sync via webhooks and treat your local database as a read cache. Never query Stripe directly from the frontend.
- Build an idempotency guard in your webhook handler using the subscription_events table. Insert the stripe_event_id first and skip processing if it already exists.
- Always cancel with cancel_at_period_end: true by default. Only cancel immediately if the user explicitly chooses immediate cancellation — most users expect access until period end.
- Use Stripe's Customer Portal for payment method updates and plan changes whenever possible. Stripe handles the complex 3D Secure and SCA flows for you.
- Record every subscription state change in the subscription_events audit table. This gives you a complete history for customer support, debugging, and revenue analytics.
- Handle the customer.subscription.trial_will_end event to send reminder emails 3 days before trial expiration. This is one of the highest-impact actions for trial-to-paid conversion.
- Add a UNIQUE constraint on subscriptions.user_id with a partial index (WHERE status != 'canceled') to prevent duplicate active subscriptions for the same user.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a Stripe Subscriptions integration in a Lovable app using Supabase Edge Functions. Explain how proration works when a customer upgrades mid-billing-cycle. Show me the exact Stripe API call (using fetch with Basic Auth) to update a subscription's price with create_prorations, and explain what the proration_behavior options create_prorations, none, and always_invoice do differently.
Add a subscription upgrade confirmation Dialog to the pricing page. When a user clicks Upgrade on a higher-tier plan, show a Dialog with: current plan name and price, new plan name and price, estimated proration charge for the remaining days in the billing cycle (calculate as: (new_price - old_price) * remaining_days / 30), a warning that the charge happens immediately, and a 'Confirm Upgrade' Button that calls the update-subscription Edge Function. Show a loading Spinner on the button while waiting for the response.
In Supabase, create a view subscription_summary that joins subscriptions with billing_plans and auth.users. Return: user email, plan name, status, current_period_end, cancel_at_period_end, and trial_end. Create a second view subscription_event_timeline that joins subscription_events with billing_plans twice (for from_plan and to_plan names) and orders by occurred_at DESC. Grant SELECT on both views to the authenticated role with RLS filtering by user_id.
Frequently asked questions
How do I give users access to premium features based on their subscription plan?
Fetch the user's subscription from Supabase on the frontend using a React hook. Check subscription.status is active or trialing and subscription.current_plan_id matches a paid plan. Use a context provider to share subscription state across the app. On the backend, in Edge Functions, query the subscriptions table with the service role to verify access before returning premium data.
What is proration and how does it work for mid-cycle plan changes?
Proration is a credit or charge calculated for the unused portion of a billing period when a plan changes. If a customer upgrades from $10/month to $30/month halfway through their cycle, Stripe charges $10 immediately (half the $20 difference) and starts billing $30/month next cycle. For downgrades, Stripe applies a credit to the next invoice. Setting proration_behavior to create_prorations handles this automatically.
Can users have multiple subscriptions simultaneously?
By default in this architecture, each user has one active subscription. Add a unique partial index on subscriptions.user_id where status not in canceled to enforce this. If your product requires multiple subscriptions per user (like separate add-ons), remove the unique constraint and adjust your feature access logic to check any of the user's active subscriptions.
How does the Stripe Customer Portal work and what can users do in it?
The Customer Portal is a Stripe-hosted page where users manage their own billing. You configure what they can do in Stripe Dashboard → Settings → Billing → Customer portal: update payment methods, view invoice history, cancel subscriptions, or change plans. Your app redirects to the portal URL, the user makes changes, and Stripe sends webhook events back to your app reflecting the changes.
How do I handle 3D Secure authentication for subscription payments?
When creating a subscription, expand latest_invoice.payment_intent in the Stripe API response. If the payment intent status is requires_action, the card requires 3D Secure authentication. Return the client_secret to the frontend and use stripe.confirmCardPayment(clientSecret) to trigger the 3D Secure flow. After confirmation, Stripe sends a payment_intent.succeeded webhook to complete the subscription activation.
Can I offer annual subscriptions at a discounted rate?
Yes. Create separate Stripe Prices for monthly and annual billing intervals (interval: year). Add both stripe_price_id values to your billing_plans table with an interval column. Show the monthly/annual toggle on the pricing page. When the user selects a plan, pass the appropriate price ID based on the toggle. Annual pricing typically saves 15-20% compared to monthly to incentivize the commitment.
Is there help available for building a more complex subscription system?
RapidDev builds production subscription systems in Lovable, including usage-based billing, team seat management, and complex trial flows. Contact us if your subscription architecture requires more than this guide covers.
How do I reactivate a subscription after cancellation?
If the subscription is set to cancel at period end (cancel_at_period_end: true) but has not expired yet, you can reactivate it by updating the subscription to set cancel_at_period_end: false. This is a PATCH request to the Stripe subscriptions API. If the subscription has already fully canceled, you must create a new subscription — the old one cannot be reactivated.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation