# How to Build a Subscription System with Lovable

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

## TL;DR

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.

## Before you start

- 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)

## Step-by-step guide

### 1. 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.

```
Create a subscription system schema in Supabase:

Tables:
- 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_at

- 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_at

- 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)

RLS: users can SELECT their own subscription and subscription_events. Service role full access.

Seed 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.

### 2. 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.

```
Build a pricing page at src/pages/Pricing.tsx.

Requirements:
- Fetch all is_active billing_plans ordered by sort_order
- Show a monthly/annual Toggle at the top (shadcn/ui Switch with label)
- Display each plan as a shadcn/ui Card with:
  - Plan name and description
  - Price (format as $X/month or $X/year based on toggle)
  - If is_recommended=true, add a 'Most Popular' Badge above the card with accent border
  - Feature list: iterate the features array, each item with a CheckCircle icon
  - A 'Get Started' Button (primary for recommended, outline for others)
- If the user already has an active subscription:
  - The current plan's button shows 'Current Plan' and is disabled
  - Other plans show 'Upgrade' or 'Downgrade' based on price comparison
  - Clicking Upgrade/Downgrade opens a Confirmation Dialog explaining proration
- If no subscription, 'Get Started' opens a payment method collection Dialog
- Below the plans, add a FAQ Accordion with 4 questions about billing, cancellation, and upgrades
```

> Pro 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.

### 3. Create the subscription lifecycle Edge Functions

Build Edge Functions for creating subscriptions, upgrading/downgrading plans, canceling, and creating Customer Portal sessions.

```
Create four Supabase Edge Functions:

1. supabase/functions/create-subscription/index.ts
- Accept: { planId: string, paymentMethodId: string, trialDays?: number }
- Create Stripe customer if not exists, attach payment method, set as default
- Create Stripe subscription with price ID from billing_plans, optional trial_end
- Insert into subscriptions table with all Stripe IDs and status
- Return: { subscriptionId, status, clientSecret? } (clientSecret if requires payment action)

2. supabase/functions/update-subscription/index.ts
- Accept: { newPlanId: string }
- Get user's current subscription from Supabase
- Call Stripe API: PATCH /v1/subscriptions/{id} with items[0][id]=existing_item_id, items[0][price]=new_price_id, proration_behavior='create_prorations'
- Update subscriptions.current_plan_id in Supabase
- Insert a subscription_events row with event_type='plan_changed', from_plan_id, to_plan_id
- Return: { success: true }

3. supabase/functions/cancel-subscription/index.ts
- Accept: { immediately?: boolean }
- If immediately=false: PATCH /v1/subscriptions/{id} with cancel_at_period_end=true
- If immediately=true: DELETE /v1/subscriptions/{id}
- Update Supabase subscriptions row accordingly
- Return: { canceledAt }

4. supabase/functions/create-portal-session/index.ts
- No request body needed (reads user from JWT)
- Call POST /v1/billing_portal/sessions with customer_id and return_url
- Return: { url } — frontend redirects to this URL
```

> Pro 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.

### 4. 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.

```
// supabase/functions/subscription-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('Signature failed', { status: 400 })
  }

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

  const { error: dupErr } = await supabase.from('subscription_events').insert({ stripe_event_id: event.id, event_type: event.type, occurred_at: new Date().toISOString() })
  if (dupErr?.code === '23505') return new Response(JSON.stringify({ received: true }))

  if (event.type.startsWith('customer.subscription.')) {
    const stripeSub = event.data.object as Stripe.Subscription
    const { data: sub } = await supabase.from('subscriptions').select('id, user_id, status, current_plan_id').eq('stripe_subscription_id', stripeSub.id).single()

    if (sub) {
      const newStatus = stripeSub.status
      await supabase.from('subscriptions').update({
        status: newStatus,
        cancel_at_period_end: stripeSub.cancel_at_period_end,
        current_period_start: new Date(stripeSub.current_period_start * 1000).toISOString(),
        current_period_end: new Date(stripeSub.current_period_end * 1000).toISOString(),
        canceled_at: stripeSub.canceled_at ? new Date(stripeSub.canceled_at * 1000).toISOString() : null,
        updated_at: new Date().toISOString(),
      }).eq('id', sub.id)

      await supabase.from('subscription_events').update({
        subscription_id: sub.id,
        user_id: sub.user_id,
        from_status: sub.status,
        to_status: newStatus,
        metadata: { stripe_subscription_id: stripeSub.id },
      }).eq('stripe_event_id', event.id)
    }
  }

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

> 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.

### 5. 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.

```
Build a subscription management page at src/pages/Subscription.tsx.

Requirements:
- Fetch the current user's subscription joined with billing_plans (for plan name and features)
- If no subscription, show a full-width Card: 'You are not subscribed' with a 'View Plans' Button linking to /pricing
- If subscribed, show:
  - A status Banner: 'Active' (green), 'Trial ends in X days' (blue), 'Payment past due' (red), 'Cancels on {date}' (yellow)
  - Current plan Card with: plan name, features list, amount, billing interval
  - Next renewal date formatted as 'Next billing date: March 1, 2026'
  - Three Buttons: 'Change Plan' (links to /pricing), 'Manage Billing' (calls create-portal-session Edge Function, redirects), 'Cancel Subscription' (opens Confirmation Dialog)
  - Cancel Dialog explains: 'Your plan stays active until {current_period_end}. You can reactivate before then.'
- 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 badges
- For trialing users, show a countdown Card: 'X days left in your trial' with a progress bar
```

**Expected 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 example

File: `supabase/functions/update-subscription/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'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  try {
    const authHeader = req.headers.get('Authorization') ?? ''
    const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')
    const { data: { user } } = await createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {
      global: { headers: { Authorization: authHeader } }
    }).auth.getUser()

    if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })

    const { newPlanId } = await req.json()

    const { data: newPlan } = await supabase.from('billing_plans').select('stripe_price_id').eq('id', newPlanId).single()
    if (!newPlan) return new Response(JSON.stringify({ error: 'Plan not found' }), { status: 404, headers: corsHeaders })

    const { data: sub } = await supabase.from('subscriptions').select('stripe_subscription_id, current_plan_id').eq('user_id', user.id).single()
    if (!sub) return new Response(JSON.stringify({ error: 'No active subscription' }), { status: 404, headers: corsHeaders })

    const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
    const getSubRes = await fetch(`https://api.stripe.com/v1/subscriptions/${sub.stripe_subscription_id}`, {
      headers: { Authorization: `Basic ${btoa(stripeKey + ':')}` },
    })
    const stripeSub = await getSubRes.json()
    const itemId = stripeSub.items.data[0].id

    const body = new URLSearchParams({
      'items[0][id]': itemId,
      'items[0][price]': newPlan.stripe_price_id,
      proration_behavior: 'create_prorations',
    })

    const updateRes = await fetch(`https://api.stripe.com/v1/subscriptions/${sub.stripe_subscription_id}`, {
      method: 'POST',
      headers: {
        Authorization: `Basic ${btoa(stripeKey + ':')}`,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body,
    })
    const updatedSub = await updateRes.json()

    await supabase.from('subscriptions').update({ current_plan_id: newPlanId, status: updatedSub.status, updated_at: new Date().toISOString() }).eq('user_id', user.id)
    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() })

    return new Response(JSON.stringify({ success: true, status: updatedSub.status }), { headers: corsHeaders })
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Internal error'
    return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })
  }
})
```

## Common mistakes

- **Not passing the subscription item ID when updating a plan** — When calling the Stripe subscription update API, omitting items[0][id] creates a new subscription item alongside the existing one. The customer ends up billed for two plans simultaneously. Fix: 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.
- **Canceling subscriptions from the frontend directly** — Cancellation logic requires the Stripe secret key, which must never be exposed to the browser. Running Stripe API calls from client-side JavaScript exposes your secret key in network requests. Fix: 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.
- **Not handling the trialing subscription status** — New subscriptions in trial have status trialing, not active. Showing 'No active plan' to users in trial, or restricting premium features from them, creates a confusing and broken experience. Fix: Treat trialing as equivalent to active for feature access purposes. In your subscription status checks, use: const hasAccess = ['active', 'trialing'].includes(subscription.status).
- **Redirecting users away from the app after the Customer Portal** — If the Customer Portal return_url points to a generic homepage, users lose context after managing their billing settings. Fix: 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.

## 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.

---

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