# How to Build a Donation System with Lovable

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

## TL;DR

Build a Stripe-powered donation system in Lovable supporting one-time and recurring donations, campaign progress with a shadcn/ui Progress bar, and automated PDF receipt generation via a Supabase Edge Function using pdf-lib and Resend. Atomic SQL increments keep campaign totals accurate under concurrent donations.

## Before you start

- Lovable Pro account for Edge Function generation
- Stripe account with STRIPE_SECRET_KEY, VITE_STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets
- Resend account with RESEND_API_KEY in Secrets (for receipt emails)
- Supabase project with service role key in Secrets
- Deployed Lovable app URL (Stripe payment flows do not work in preview mode)

## Step-by-step guide

### 1. Set up the donation schema

Create the Supabase tables for campaigns, donations, and donors, plus the atomic increment RPC function that the webhook will use.

```
Create a donation system schema in Supabase:

Tables:
- campaigns: id (uuid pk), title (text), description (text), goal_cents (int), amount_raised_cents (int default 0), donor_count (int default 0), currency (text default 'usd'), start_date (date), end_date (date nullable), is_active (bool default true), image_url (text), created_at
- donations: id (uuid pk), campaign_id (uuid references campaigns), donor_name (text), donor_email (text), amount_cents (int), currency (text default 'usd'), type (text: one_time|recurring), stripe_payment_intent_id (text), stripe_subscription_id (text), status (text: pending|succeeded|failed), is_anonymous (bool default false), message (text nullable), receipt_sent (bool default false), created_at
- recurring_donors: id (uuid pk), donation_id (uuid references donations), stripe_subscription_id (text unique), stripe_customer_id (text), monthly_amount_cents (int), status (text: active|canceled), next_billing_date (timestamptz), created_at

RLS:
- campaigns: public SELECT, admin-only INSERT/UPDATE
- donations: public INSERT (anyone can donate), SELECT own donations only (by email match), service role full access
- recurring_donors: service role only

Create a Supabase RPC function increment_campaign(p_campaign_id uuid, p_amount_cents int):
UPDATE campaigns SET amount_raised_cents = amount_raised_cents + p_amount_cents, donor_count = donor_count + 1 WHERE id = p_campaign_id;
Grant execute to service role.
```

> Pro tip: Create an index on donations.campaign_id and donations.created_at so the donor wall and recent donations queries are fast even with thousands of donation rows.

**Expected result:** All three tables are created. The increment_campaign RPC function is ready. Campaigns can have public read access. TypeScript types are generated.

### 2. Build the donation form and campaign page

Ask Lovable to create the main campaign page with progress tracking and the donation form with preset amounts and a one-time/recurring toggle.

```
Build a campaign donation page at src/pages/Campaign.tsx.

Top section - Campaign hero:
- Campaign title, description, and image
- Progress section: amount raised formatted as currency, goal amount, percentage (amount/goal * 100)
- shadcn/ui Progress bar showing fill percentage (cap at 100%)
- Two stats below the bar: '{donor_count} donors' and 'Goal: {goal_amount}'

Donation form (right column on desktop, below hero on mobile):
- One-time / Monthly toggle using shadcn/ui Tabs
- Preset amount Buttons: $10, $25, $50, $100, $250, Custom
- When Custom is selected, show an Input field for the amount
- Donor name Input (required)
- Donor email Input (required)
- Optional message Textarea
- Anonymous Checkbox: 'Donate anonymously (your name won't appear on the donor wall)'
- Donate Button: 'Donate ${amount} {one-time|monthly}'
- Small text below: 'Secure payment via Stripe. You'll receive a PDF receipt by email.'

Donor wall section below:
- 'Recent Donors' heading
- List of last 10 non-anonymous donations: donor name, amount, time ago, optional message in a Card
- Anonymous donors show as 'Anonymous donor'
- Real-time updates using Supabase Realtime channel subscription on the donations table
```

> Pro tip: Cache the campaign data (title, goal, raised amount) locally and update the Progress bar optimistically when the user submits their donation — before the webhook confirms. Roll back if the payment fails. This makes the form feel instantly responsive.

**Expected result:** The campaign page shows progress bar, preset amount buttons, and the donation form. Selecting One-time or Monthly switches the Tabs. The donor wall shows existing donations and updates in real time.

### 3. Create the donation processing Edge Function

Build the Edge Function that creates either a Stripe PaymentIntent or Subscription based on donation type, and saves a pending donation to Supabase.

```
Create a Supabase Edge Function at supabase/functions/process-donation/index.ts.

Accept POST body: { campaignId, donorName, donorEmail, amountCents, type ('one_time'|'recurring'), message?, isAnonymous? }

For type='one_time':
1. Create a Stripe PaymentIntent via POST to /v1/payment_intents with amount, currency='usd', receipt_email=donorEmail, metadata={campaignId, donorName}
2. Insert a pending donation row into Supabase donations table
3. Return { clientSecret: pi.client_secret }

For type='recurring':
1. Create or find a Stripe customer with donorEmail
2. Do NOT create the subscription yet — return a SetupIntent to collect the payment method first
3. Create a Stripe SetupIntent via POST to /v1/setup_intents with customer, usage='off_session', metadata={campaignId, donorName, amountCents}
4. Insert a pending donation row
5. Return { setupClientSecret: si.client_secret, customerId: customer.id }

After the frontend confirms the SetupIntent, it calls a second Edge Function create-recurring-donation that creates the actual Stripe Subscription using the confirmed payment method.

Include CORS headers and OPTIONS handling.
```

> Pro tip: For recurring donations, create a Stripe Price on the fly using the API (POST to /v1/prices with unit_amount, currency, and recurring.interval=month) rather than pre-creating prices for every possible donation amount. This keeps your Stripe Dashboard clean.

**Expected result:** The process-donation Edge Function returns a clientSecret for one-time donations and a setupClientSecret for recurring. The Stripe Elements form can be loaded with either secret type.

### 4. Build the webhook handler with atomic increment

Create the webhook Edge Function that verifies Stripe events, atomically updates campaign totals, and triggers PDF receipt generation.

```
// supabase/functions/donation-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') ?? '')

  if (event.type === 'payment_intent.succeeded') {
    const pi = event.data.object as Stripe.PaymentIntent
    const campaignId = pi.metadata?.campaignId

    const { data: donation } = await supabase
      .from('donations')
      .update({ status: 'succeeded' })
      .eq('stripe_payment_intent_id', pi.id)
      .select()
      .single()

    if (donation && campaignId) {
      await supabase.rpc('increment_campaign', {
        p_campaign_id: campaignId,
        p_amount_cents: donation.amount_cents,
      })

      if (!donation.receipt_sent) {
        await supabase.functions.invoke('send-donation-receipt', {
          body: { donationId: donation.id },
        })
        await supabase.from('donations').update({ receipt_sent: true }).eq('id', donation.id)
      }
    }
  }

  if (event.type === 'invoice.paid') {
    const inv = event.data.object as Stripe.Invoice
    const subId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id
    if (subId) {
      const { data: rd } = await supabase.from('recurring_donors').select('donation_id').eq('stripe_subscription_id', subId).single()
      if (rd) {
        const { data: originalDonation } = await supabase.from('donations').select('campaign_id, amount_cents, donor_email, donor_name').eq('id', rd.donation_id).single()
        if (originalDonation) {
          await supabase.rpc('increment_campaign', { p_campaign_id: originalDonation.campaign_id, p_amount_cents: originalDonation.amount_cents })
        }
      }
    }
  }

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

> Pro tip: The supabase.functions.invoke call within an Edge Function triggers the receipt generation asynchronously. This keeps the webhook handler fast — it returns 200 to Stripe immediately while receipt generation happens in the background.

**Expected result:** A completed test payment triggers the webhook. The campaign's amount_raised_cents increments atomically. The receipt Edge Function is invoked asynchronously. The donor wall updates via Realtime.

### 5. Build the PDF receipt Edge Function

Create an Edge Function that generates a PDF donation receipt using pdf-lib and sends it via Resend email.

```
Create a Supabase Edge Function at supabase/functions/send-donation-receipt/index.ts.

Accept POST body: { donationId: string }

Logic:
1. Fetch the donation joined with campaigns from Supabase using donationId
2. Use pdf-lib from esm.sh to create a simple PDF receipt:
   - Title: 'Donation Receipt'
   - Organization name from an env var ORGANIZATION_NAME
   - Receipt number: donation.id (first 8 chars)
   - Donor name (or 'Anonymous' if is_anonymous)
   - Date: formatted from donation.created_at
   - Amount: formatted as currency
   - Campaign name
   - Statement: 'No goods or services were provided in exchange for this donation.'
   - Footer with organization contact info from env vars
3. Serialize the PDF to bytes
4. Send via Resend API: POST to https://api.resend.com/emails with:
   - from: noreply@yourdomain.com (from RESEND_FROM_EMAIL env var)
   - to: donation.donor_email
   - subject: 'Your donation receipt from {ORGANIZATION_NAME}'
   - html: simple thank-you message
   - attachments: [{ filename: 'receipt.pdf', content: base64-encoded PDF bytes }]
5. Return { success: true }
```

**Expected result:** After a test donation, the receipt Edge Function generates a PDF and sends an email to the donor's address. The PDF contains all required receipt fields.

## Complete code example

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

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

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

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

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

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

  if (event.type === 'payment_intent.succeeded') {
    const pi = event.data.object as Stripe.PaymentIntent
    const campaignId = pi.metadata?.campaignId
    if (!campaignId) return new Response(JSON.stringify({ received: true }), { status: 200 })

    const { data: donation } = await supabase
      .from('donations')
      .update({ status: 'succeeded' })
      .eq('stripe_payment_intent_id', pi.id)
      .select('id, amount_cents, donor_email, receipt_sent')
      .single()

    if (donation) {
      await supabase.rpc('increment_campaign', {
        p_campaign_id: campaignId,
        p_amount_cents: donation.amount_cents,
      })
      if (!donation.receipt_sent) {
        await supabase.functions.invoke('send-donation-receipt', {
          body: { donationId: donation.id },
        })
        await supabase.from('donations').update({ receipt_sent: true }).eq('id', donation.id)
      }
    }
  }

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

## Common mistakes

- **Incrementing campaign totals in application code instead of using atomic SQL** — Reading the current total and adding to it in JavaScript (current + newAmount) is not thread-safe. Two simultaneous donations can both read the same old total and write conflicting updates, causing the total to reflect only one of the donations. Fix: Use a SQL UPDATE with an expression: UPDATE campaigns SET amount_raised_cents = amount_raised_cents + $1. PostgreSQL locks the row during the update, making it safe for concurrent increments. Implement this as a Supabase RPC function called from the webhook handler.
- **Sending receipt emails synchronously inside the webhook handler** — If the email API is slow or fails, your webhook handler returns a non-200 response to Stripe after the timeout. Stripe interprets this as a failed delivery and retries the webhook, potentially sending duplicate receipts. Fix: Invoke the receipt Edge Function asynchronously using supabase.functions.invoke from within the webhook handler. The invocation queues the receipt generation and returns immediately. Track receipt_sent status in the donations table to prevent duplicates on webhook retries.
- **Not validating the donation amount on the server side** — If the frontend sends an amount to the Edge Function and you use it directly to create the PaymentIntent, a user could modify the amount in the browser to donate $0.01 instead of $25. Fix: For preset amounts, validate on the server that the amount matches one of your predefined options. For custom amounts, set a minimum (e.g., 100 cents = $1) and maximum. Return a 400 error for invalid amounts.
- **Making the donor wall show unverified pending donations** — Displaying donations with status='pending' means the donor wall shows entries for payments that have not been confirmed by Stripe and may fail. Fix: Only show donations with status='succeeded' on the donor wall. Subscribe to the donations table via Supabase Realtime with a filter: .eq('status', 'succeeded'). The wall updates within seconds after the webhook confirms payment.

## Best practices

- Use atomic SQL increments (UPDATE ... SET amount = amount + $1) for campaign totals. Never read-then-write totals in application code.
- Track receipt_sent as a boolean column on each donation. Check it in the webhook handler before sending to prevent duplicate receipts on webhook retries.
- Always verify the Stripe webhook signature with constructEventAsync before updating any database records. An unverified webhook could be forged to increment campaign totals without real payments.
- Support anonymous donations from day one. Add is_anonymous to the donation form. Show 'Anonymous donor' instead of names on the donor wall for these entries.
- Create Stripe Prices dynamically for recurring donations using arbitrary amounts instead of pre-creating prices for every possible donation value. Clean up unused prices periodically.
- Add a donor_count column to campaigns that increments alongside amount_raised_cents. This gives you the social proof number without a separate COUNT query on every page load.
- Set a minimum donation amount of at least $1 (100 cents) to prevent Stripe's minimum charge amount error and to filter out accidental zero-amount submissions.
- Store the campaign ID in the Stripe PaymentIntent metadata at creation time. The webhook uses this to find the right campaign to increment — do not pass it in a separate database lookup.

## Frequently asked questions

### Can I accept both one-time and recurring donations in the same form?

Yes, and this guide shows how. The form has a toggle to switch between one-time and monthly donation types. One-time donations use the PaymentIntent flow (collect card → charge once). Recurring donations use the SetupIntent flow (collect card → store payment method → create subscription that bills monthly). The Edge Function handles both flows based on the type parameter.

### How do I prevent someone from donating $0 or a negative amount?

Validate the amount inside the Edge Function before calling the Stripe API. Check that amountCents is a positive integer greater than or equal to 100 (the minimum charge in USD). Return a 400 error with a descriptive message if validation fails. Also add Zod validation on the frontend form to show an error before the request is sent.

### Are donation receipts legally required?

In the US, organizations must provide written acknowledgment for donations over $250. For tax-deductibility, the receipt must include the organization's name, the donation date and amount, and a statement that no goods or services were provided in exchange (if none were). For donations under $250, receipts are best practice but not strictly required. Check local regulations for your specific jurisdiction and organization type.

### How do I handle recurring donors who want to update their donation amount?

Create a portal page where donors authenticate with their email. Show their active recurring donations. Add a Change Amount button that calls the Stripe API to update the subscription's price to a new dynamically created Price object with their chosen amount. Cancel the old subscription item and add the new one in the same subscription update call with proration_behavior set to none.

### What happens if a recurring donor's card expires?

Stripe Smart Retries will attempt the payment several times over the following week. Stripe also automatically updates many card numbers via the Account Updater service when banks issue new cards. If all retries fail, Stripe sends invoice.payment_failed and customer.subscription.deleted events. Your webhook handles these to mark the recurring_donor as canceled and update the campaign's active recurring donor count.

### Can I set a campaign end date after which donations are not accepted?

Yes. Add an end_date check at the start of the process-donation Edge Function. If the campaign's end_date is in the past or is_active is false, return a 400 error. The frontend shows the closed campaign page differently: replace the donation form with a 'Campaign has ended' message and final totals.

### Is there help available for building a larger donation platform?

RapidDev builds production donation platforms in Lovable including multi-campaign management, donor CRM, matching programs, and nonprofit reporting. Contact us if you need a more complete fundraising platform.

### How do I display a real-time donor count that updates as people donate?

Use Supabase Realtime to subscribe to the campaigns table. Listen for UPDATE events on the specific campaign row. When the webhook increments donor_count and amount_raised_cents, Realtime pushes the update to all connected browsers. Update the campaign state in your React component with the new values — the Progress bar and donor count update without a page refresh.

---

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