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.
What you're building
The donation system handles two Stripe flows: one-time donations use PaymentIntents (amount collected once) and recurring donations use Subscriptions (amount collected monthly until canceled). A single donation form with a toggle switches between these modes, and the Edge Function determines which Stripe API to call based on the selected type.
Campaign totals must be accurate even when multiple donations arrive at the same moment. Rather than reading the current total and incrementing in application code (which has a race condition), the webhook handler uses a Supabase RPC function with UPDATE campaigns SET amount_raised = amount_raised + $1 WHERE id = $2. PostgreSQL's row-level lock during UPDATE makes this atomic — no two concurrent increments can conflict.
PDF receipts use the IRS-friendly format for charitable donations: organization name, donor name, date, amount, and a statement that no goods or services were provided in exchange. An Edge Function generates the PDF using pdf-lib from esm.sh and attaches it to an email sent via Resend.
Final result
A fully functional donation platform with campaign progress tracking, one-time and recurring donations, atomic totals, and automatic PDF receipt delivery.
Tech stack
Prerequisites
- 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)
Build steps
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.
1Create a donation system schema in Supabase:23Tables:4- 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_at5- 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_at6- 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_at78RLS:9- campaigns: public SELECT, admin-only INSERT/UPDATE10- donations: public INSERT (anyone can donate), SELECT own donations only (by email match), service role full access11- recurring_donors: service role only1213Create a Supabase RPC function increment_campaign(p_campaign_id uuid, p_amount_cents int):14UPDATE campaigns SET amount_raised_cents = amount_raised_cents + p_amount_cents, donor_count = donor_count + 1 WHERE id = p_campaign_id;15Grant 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.
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.
1Build a campaign donation page at src/pages/Campaign.tsx.23Top section - Campaign hero:4- Campaign title, description, and image5- Progress section: amount raised formatted as currency, goal amount, percentage (amount/goal * 100)6- shadcn/ui Progress bar showing fill percentage (cap at 100%)7- Two stats below the bar: '{donor_count} donors' and 'Goal: {goal_amount}'89Donation form (right column on desktop, below hero on mobile):10- One-time / Monthly toggle using shadcn/ui Tabs11- Preset amount Buttons: $10, $25, $50, $100, $250, Custom12- When Custom is selected, show an Input field for the amount13- Donor name Input (required)14- Donor email Input (required)15- Optional message Textarea16- Anonymous Checkbox: 'Donate anonymously (your name won't appear on the donor wall)'17- Donate Button: 'Donate ${amount} {one-time|monthly}'18- Small text below: 'Secure payment via Stripe. You'll receive a PDF receipt by email.'1920Donor wall section below:21- 'Recent Donors' heading22- List of last 10 non-anonymous donations: donor name, amount, time ago, optional message in a Card23- Anonymous donors show as 'Anonymous donor'24- Real-time updates using Supabase Realtime channel subscription on the donations tablePro 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.
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.
1Create a Supabase Edge Function at supabase/functions/process-donation/index.ts.23Accept POST body: { campaignId, donorName, donorEmail, amountCents, type ('one_time'|'recurring'), message?, isAnonymous? }45For type='one_time':61. Create a Stripe PaymentIntent via POST to /v1/payment_intents with amount, currency='usd', receipt_email=donorEmail, metadata={campaignId, donorName}72. Insert a pending donation row into Supabase donations table83. Return { clientSecret: pi.client_secret }910For type='recurring':111. Create or find a Stripe customer with donorEmail122. Do NOT create the subscription yet — return a SetupIntent to collect the payment method first133. Create a Stripe SetupIntent via POST to /v1/setup_intents with customer, usage='off_session', metadata={campaignId, donorName, amountCents}144. Insert a pending donation row155. Return { setupClientSecret: si.client_secret, customerId: customer.id }1617After 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.1819Include 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.
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.
1// supabase/functions/donation-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 if (event.type === 'payment_intent.succeeded') {25 const pi = event.data.object as Stripe.PaymentIntent26 const campaignId = pi.metadata?.campaignId2728 const { data: donation } = await supabase29 .from('donations')30 .update({ status: 'succeeded' })31 .eq('stripe_payment_intent_id', pi.id)32 .select()33 .single()3435 if (donation && campaignId) {36 await supabase.rpc('increment_campaign', {37 p_campaign_id: campaignId,38 p_amount_cents: donation.amount_cents,39 })4041 if (!donation.receipt_sent) {42 await supabase.functions.invoke('send-donation-receipt', {43 body: { donationId: donation.id },44 })45 await supabase.from('donations').update({ receipt_sent: true }).eq('id', donation.id)46 }47 }48 }4950 if (event.type === 'invoice.paid') {51 const inv = event.data.object as Stripe.Invoice52 const subId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id53 if (subId) {54 const { data: rd } = await supabase.from('recurring_donors').select('donation_id').eq('stripe_subscription_id', subId).single()55 if (rd) {56 const { data: originalDonation } = await supabase.from('donations').select('campaign_id, amount_cents, donor_email, donor_name').eq('id', rd.donation_id).single()57 if (originalDonation) {58 await supabase.rpc('increment_campaign', { p_campaign_id: originalDonation.campaign_id, p_amount_cents: originalDonation.amount_cents })59 }60 }61 }62 }6364 return new Response(JSON.stringify({ received: true }), { status: 200 })65})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.
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.
1Create a Supabase Edge Function at supabase/functions/send-donation-receipt/index.ts.23Accept POST body: { donationId: string }45Logic:61. Fetch the donation joined with campaigns from Supabase using donationId72. Use pdf-lib from esm.sh to create a simple PDF receipt:8 - Title: 'Donation Receipt'9 - Organization name from an env var ORGANIZATION_NAME10 - Receipt number: donation.id (first 8 chars)11 - Donor name (or 'Anonymous' if is_anonymous)12 - Date: formatted from donation.created_at13 - Amount: formatted as currency14 - Campaign name15 - Statement: 'No goods or services were provided in exchange for this donation.'16 - Footer with organization contact info from env vars173. Serialize the PDF to bytes184. Send via Resend API: POST to https://api.resend.com/emails with:19 - from: noreply@yourdomain.com (from RESEND_FROM_EMAIL env var)20 - to: donation.donor_email21 - subject: 'Your donation receipt from {ORGANIZATION_NAME}'22 - html: simple thank-you message23 - attachments: [{ filename: 'receipt.pdf', content: base64-encoded PDF bytes }]245. 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
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') ?? ''14 let event: Stripe.Event1516 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 failed', { status: 400 })23 }2425 const supabase = createClient(26 Deno.env.get('SUPABASE_URL') ?? '',27 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''28 )2930 if (event.type === 'payment_intent.succeeded') {31 const pi = event.data.object as Stripe.PaymentIntent32 const campaignId = pi.metadata?.campaignId33 if (!campaignId) return new Response(JSON.stringify({ received: true }), { status: 200 })3435 const { data: donation } = await supabase36 .from('donations')37 .update({ status: 'succeeded' })38 .eq('stripe_payment_intent_id', pi.id)39 .select('id, amount_cents, donor_email, receipt_sent')40 .single()4142 if (donation) {43 await supabase.rpc('increment_campaign', {44 p_campaign_id: campaignId,45 p_amount_cents: donation.amount_cents,46 })47 if (!donation.receipt_sent) {48 await supabase.functions.invoke('send-donation-receipt', {49 body: { donationId: donation.id },50 })51 await supabase.from('donations').update({ receipt_sent: true }).eq('id', donation.id)52 }53 }54 }5556 return new Response(JSON.stringify({ received: true }), { status: 200 })57})Customization ideas
Multiple campaigns with campaign management dashboard
Build an admin dashboard at /admin/campaigns with a DataTable listing all campaigns, their progress bars, and Edit/Archive buttons. The Edit button opens a Dialog with a react-hook-form for updating the campaign goal, end date, and description. Add a create campaign flow that sets up a new campaign and generates a shareable donation URL.
Donation matching multiplier
Add a matching_multiplier column to campaigns (e.g., 2 for double-match, 3 for triple-match) and a matching_donor column. When a donation succeeds, display 'Your $50 donation was matched to $100 by Acme Corp' on the success screen. Apply the multiplier in the increment_campaign RPC to update the amount_raised.
Social sharing with dynamic OG images
Add a share Button on the campaign page that generates a pre-filled tweet or LinkedIn post with the campaign progress. Create a Supabase Edge Function that generates a dynamic OG image showing the campaign title, progress percentage, and a CTA. Use this as the og:image meta tag so shared links show a live progress card.
Recurring donation management portal
Add a donor portal at /my-donations where recurring donors can log in with their email, see their recurring donations, change amounts, or cancel. Authentication uses Supabase magic link (email OTP) so donors do not need to create a full account — just verify their email. The portal uses Stripe's Customer Portal for payment method updates.
Donation certificate generator
For donations above a threshold (e.g., $100), generate a personalized certificate using a Supabase Edge Function with pdf-lib. The certificate includes the donor's name, a custom message from the organization, and a unique certificate number stored in the donations table. Donors can download it from their receipt email.
Common pitfalls
Pitfall: Incrementing campaign totals in application code instead of using atomic SQL
How to avoid: 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.
Pitfall: Sending receipt emails synchronously inside the webhook handler
How to avoid: 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.
Pitfall: Not validating the donation amount on the server side
How to avoid: 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.
Pitfall: Making the donor wall show unverified pending donations
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a donation system in a Lovable app with Supabase. I need campaign totals to be accurate under concurrent donations. Explain why read-then-write increment patterns fail under concurrency and show me the SQL UPDATE statement that safely increments a counter atomically. Also show how to create this as a Supabase RPC function and call it from a Deno Edge Function using the Supabase JavaScript client.
Add a donation success page at /donate/success. Read the payment_intent query param from the URL. Fetch the donation from Supabase where stripe_payment_intent_id matches. Show: a large animated checkmark (CSS keyframe animation), 'Thank you, {donor_name}!', donation amount, campaign name, and message: 'A PDF receipt has been sent to {donor_email}'. Add a Progress bar showing the updated campaign total. Add share buttons for Twitter and Facebook pre-filled with the message: 'I just donated ${amount} to {campaign_name}! Join me.' Show a Skeleton while loading.
In Supabase, create a view campaign_stats that returns for each campaign: id, title, goal_cents, amount_raised_cents, donor_count, a percentage column ((amount_raised_cents::float / goal_cents) * 100), days_remaining (DATE_PART days between now and end_date), and is_goal_reached (amount_raised_cents >= goal_cents). Grant SELECT to anon role so it can be fetched without authentication from the campaign page.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation