Skip to main content
RapidDev - Software Development Agency
Lovable PromptsB2B SaaSAdvanced

Build a Billing System in Lovable

A B2B billing system with customers, draft-to-sent-to-paid invoice lifecycle, gap-free invoice numbering via a SECURITY DEFINER finalize function, line items with integer-cent arithmetic, Stripe Payment Link generation, idempotent raw-body webhook reconciliation, PDF generation, and automated dunning emails.

Time to MVP

1-2 days

Credits

~150-250 credits for full chain

Difficulty

Advanced

Cloud features

4

TL;DR

The one-paragraph version before you dive in.

Paste the starter prompt into Lovable Agent Mode and get a working B2B billing system: customers, draft-to-sent-to-paid invoice workflow, gap-free sequential numbering via a Postgres sequence assigned only at finalize, line items, Stripe payment links, idempotent webhook reconciliation, PDF generation, and dunning emails. Full build takes 1-2 days. Expected credit burn: 150-250 on a Pro plan with one top-up.

Setup checklist

Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.

Cloud tab settings

Database

Stores 6 tables: profiles, customers, invoices, line_items, payments, stripe_events. The invoices_seq Postgres sequence provides gap-free invoice numbering.

  1. 1Click the + button next to Preview in the top toolbar to open the Cloud tab
  2. 2Click Database — you'll see an empty Table Editor
  3. 3Leave it empty — the starter prompt creates all six tables and the sequence via migration

Auth

Email+password for admin and finance roles. The public /pay/:invoiceNumber view uses a signed JWT token in the query string instead of user auth — customers pay without creating an account.

  1. 1Cloud tab → Users & Auth
  2. 2Under Sign-in methods, enable Email (already on)
  3. 3Disable 'Allow new users to sign up' — you'll invite finance team members manually
  4. 4After creating your first admin user, edit their app_metadata to {"role": "admin"}

Storage

invoice-pdfs bucket stores generated PDFs. Access is via signed URL only — customers get a time-limited link in their email, not a public bucket URL.

  1. 1Cloud tab → Storage
  2. 2Click Create bucket, name it 'invoice-pdfs', leave Public OFF
  3. 3The starter prompt adds admin-only upload RLS; signed URLs handle customer download

Edge Functions

Four functions: finalize-invoice (sequence + Stripe Payment Link + PDF + email), stripe-webhook (raw body), generate-pdf, and send-dunning-email (cron).

  1. 1No manual setup needed before running the starter prompt
  2. 2After the starter finishes, confirm all four functions appear in Cloud tab → Edge Functions
  3. 3Add Stripe and Resend secrets before running any invoice through the flow

Secrets (Cloud tab → Secrets)

STRIPE_SECRET_KEY

Purpose: Used by finalize-invoice to create Stripe Payment Links and by stripe-webhook to construct and verify events

Where to get it: https://dashboard.stripe.com/apikeys — copy the Secret key (sk_test_ for test mode, sk_live_ for production)

STRIPE_WEBHOOK_SECRET

Purpose: Verifies webhook payload signature via constructEventAsync — required to prevent spoofed payment confirmation calls

Where to get it: Stripe Dashboard → Developers → Webhooks → click your endpoint → Signing secret (starts whsec_)

RESEND_API_KEY

Purpose: Sends invoice emails (with PDF attachment) and dunning escalation emails to customers

Where to get it: https://resend.com/api-keys — create a key with 'Sending access'

INVOICE_TOKEN_SECRET

Purpose: Signs the JWT in the /pay/:invoiceNumber?token= URL so customers can view and pay invoices without logging in, without exposing guessable invoice numbers

Where to get it: Generate a random 32-byte secret: run `openssl rand -hex 32` in your terminal and copy the output

Preflight checklist

  • You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
  • You're on Pro $25/mo with one $15 top-up budget — the full chain burns 150-250 credits
  • You have a Stripe account in test mode
  • Add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and RESEND_API_KEY to Cloud tab → Secrets before testing the finalize flow
  • Stripe webhook endpoint must point to your deployed URL (Publish first) — the webhook does not function in preview

The starter prompt — paste this first

Copy this. Paste it into Lovable Agent Mode (the default chat at the bottom-left of the editor). Hit send.

lovable-agent-mode.txt
~70-100 credits
Build a B2B billing system. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6 for routes, Supabase JS client against Lovable Cloud.

## Database schema (one migration)

IMPORTANT: Create the sequence BEFORE the invoices table:
`CREATE SEQUENCE IF NOT EXISTS invoices_seq START 1;`

Create six tables in the public schema:

1. `profiles` — id uuid PK references auth.users.id, full_name text, email text not null, role text default 'admin' check in ('admin','finance','viewer'), created_at timestamptz default now(). RLS: own read; role enforced via JWT app_metadata.

2. `customers` — id uuid PK default gen_random_uuid(), display_name text not null, billing_email text not null, billing_address jsonb, tax_id text, currency text default 'USD' check in ('USD','EUR','GBP','CAD','AUD'), stripe_customer_id text, payment_terms_days int default 30, created_at timestamptz default now(). RLS: admin/finance read/write; viewer read-only.

3. `invoices` — id uuid PK default gen_random_uuid(), invoice_number text unique (NULL until finalize — DO NOT set as DEFAULT from sequence), customer_id uuid not null references customers(id), status text not null default 'draft' check in ('draft','sent','paid','overdue','void','refunded'), currency text not null default 'USD', subtotal_cents int not null default 0, tax_cents int not null default 0, total_cents int not null default 0, issued_at timestamptz, due_at timestamptz, paid_at timestamptz, void_reason text, notes text, pdf_storage_path text, stripe_payment_link_url text, created_at timestamptz default now(). RLS: admin/finance all; viewer read.

4. `line_items` — id uuid PK, invoice_id uuid not null references invoices(id) on delete cascade, description text not null, quantity numeric not null default 1, unit_price_cents int not null, amount_cents int not null (= ROUND(quantity * unit_price_cents), computed and stored — never calculated in JS), position int default 0, created_at timestamptz default now(). RLS: same as parent invoice. ADD CONSTRAINT: a BEFORE INSERT/UPDATE trigger on line_items that RAISEs EXCEPTION if the parent invoice.status != 'draft'.

5. `payments` — id uuid PK, invoice_id uuid not null references invoices(id), stripe_payment_intent_id text unique, amount_cents int not null, currency text not null, status text check in ('succeeded','pending','failed','refunded'), paid_at timestamptz, payment_method text, created_at timestamptz default now(). RLS: admin/finance all; webhook-only write via SECURITY DEFINER.

6. `stripe_events` — id uuid PK, stripe_event_id text unique not null, event_type text not null, received_at timestamptz default now(), processed_at timestamptz. RLS: no client access; webhook-only writes.

## Required functions and triggers

Create as part of the migration:

A. `has_role(check_role text) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE` — reads `(auth.jwt() -> 'app_metadata' ->> 'role') = check_role`.

B. `finalize_invoice(p_invoice_id uuid) RETURNS text LANGUAGE plpgsql SECURITY DEFINER` — assigns gap-free invoice number and transitions status:
```sql
CREATE FUNCTION finalize_invoice(p_invoice_id uuid) RETURNS text LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE new_num text;
BEGIN
  SELECT 'INV-' || extract(year from now())::text || '-' || lpad(nextval('invoices_seq')::text, 5, '0') INTO new_num;
  UPDATE invoices
    SET invoice_number = new_num,
        status = 'sent',
        issued_at = now(),
        due_at = now() + (SELECT payment_terms_days * interval '1 day' FROM customers WHERE id = customer_id)
    WHERE id = p_invoice_id AND status = 'draft' AND invoice_number IS NULL;
  IF NOT FOUND THEN RAISE EXCEPTION 'Invoice not found, not draft, or already finalized'; END IF;
  RETURN new_num;
END; $$;
```

C. `apply_payment(p_invoice_id uuid, p_pi_id text, p_amount_cents int) RETURNS void LANGUAGE plpgsql SECURITY DEFINER` — inserts payments row + transitions invoice to 'paid'.

D. Trigger `line_items_draft_only` BEFORE INSERT OR UPDATE OR DELETE ON line_items — if parent invoice.status != 'draft', RAISE EXCEPTION 'Cannot modify line items on a finalized invoice'.

Seed: 1 customer + 1 draft invoice with 2 line items.

## Layout

Create `src/layouts/AppLayout.tsx` — fixed left sidebar (240px) with nav: Dashboard, Invoices, Customers, Payments, Settings + bottom user menu. Main area with breadcrumbs and filter bar in top-right.

Create `src/components/AdminGuard.tsx` — wraps AppLayout routes; checks JWT app_metadata.role IN ('admin','finance'); redirects to /login if not authorized.

## Pages

Create routes in src/App.tsx:
- `/login` (Login.tsx) — sign in form
- `/dashboard` (Dashboard.tsx) — 3 metric cards: outstanding receivables (SUM total_cents WHERE status IN ('sent','overdue')), paid this month, overdue count
- `/invoices` (InvoicesList.tsx) — table with status chips, customer name, total, due date; filter by status and customer
- `/invoices/new` (InvoiceNew.tsx) — CustomerPicker combobox + notes + currency dropdown; on submit creates draft invoice and redirects to /invoices/:id
- `/invoices/:id` (InvoiceView.tsx) — EITHER edit mode (status='draft': editable InvoiceEditor with line items) OR view mode (status='sent'/'paid': read-only thread with Finalize/Void/Mark Paid buttons). Finalize button calls finalize_invoice() RPC then calls finalize-invoice Edge Function for Stripe Payment Link and PDF generation.
- `/customers` (CustomersList.tsx) — CRUD table of customers
- `/customers/:id` (CustomerView.tsx) — customer detail with invoice history and total revenue
- `/payments` (PaymentsLog.tsx) — log of all payments with filter by customer and date
- `/settings` (Settings.tsx) — org name, from-email, default currency, invoice number prefix, payment terms default
- `/pay/:invoiceNumber` (PublicInvoiceView.tsx) — PUBLIC route, no auth required. Read invoice by invoice_number, verify signed token in query string (?token=), display invoice line items + total + 'Pay Now' button that redirects to stripe_payment_link_url. Render 404 if token missing or invalid — do not confirm the invoice exists without a valid token.

## Components

Build:
- `InvoiceTable` — sortable/filterable invoice list with StatusChip and customer name
- `InvoiceEditor` — sortable line items rows with description, quantity (number input), unit price (CurrencyInput), amount (computed display). Auto-recalculates subtotal_cents + tax_cents + total_cents on every change. Saves on blur.
- `StatusChip` — gray draft / blue sent / emerald paid / rose overdue / amber void
- `CustomerPicker` — combobox (shadcn Command) searching customers by display_name or billing_email
- `CurrencyInput` — dollar/cent input that converts to integer cents on blur (Math.round(value * 100)); never uses parseFloat on stored values
- `AmountDisplay` — formats cents to currency string: (amount_cents / 100).toLocaleString(locale, {style:'currency', currency: invoice.currency})
- `PayButton` — links to stripe_payment_link_url; disabled with tooltip if invoice not yet finalized

## Edge Functions

Scaffold four functions in supabase/functions/:

1. `finalize-invoice/index.ts` — after finalize_invoice() RPC assigns number: creates Stripe Payment Link, calls generate-pdf to get PDF, uploads to invoice-pdfs/${customer_id}/${invoice_id}.pdf, sends Resend invoice email to customer's billing_email with PDF attachment and payment link.

2. `stripe-webhook/index.ts` — reads `const body = await req.text()`; validates `await stripe.webhooks.constructEventAsync(body, sig, secret)`; idempotency check against stripe_events; on payment_intent.succeeded calls apply_payment() RPC.

3. `generate-pdf/index.ts` — accepts invoice JSON, renders HTML template, converts to PDF via @pdf-lib (pure JS, no browser) and returns the PDF buffer.

4. `send-dunning-email/index.ts` — scheduled cron function; queries invoices WHERE status='sent' AND due_at < now(); groups by days_overdue (3/7/14/30); updates status to 'overdue' on first detection; sends Resend dunning email per escalation stage.

## Styling
Light + dark mode via shadcn ThemeProvider. Primary color emerald-600 (money). Status chips as above. Amount columns right-aligned, monospaced (font-mono). Invoice editor rows: compact 40px density. PDF preview sidebar: 320px fixed panel with iframe showing signed URL to generated PDF.

What this prompt generates

  • Creates a SQL migration with 6 tables, invoices_seq sequence, finalize_invoice() and apply_payment() SECURITY DEFINER functions, and draft-only trigger on line_items
  • Scaffolds AppLayout with AdminGuard and 8 page components including the public /pay/:invoiceNumber view
  • Builds InvoiceEditor with integer-cent arithmetic, CurrencyInput, and AmountDisplay components
  • Scaffolds 4 Edge Functions: finalize-invoice, stripe-webhook (raw body), generate-pdf, and send-dunning-email
  • Seeds 1 customer and 1 draft invoice with 2 line items so you can test the finalize flow immediately

Paste into: Lovable Agent Mode (the default chat at the bottom-left of the editor)

Expected output

What Lovable will generate after the starter prompt runs successfully.

Files
supabase/migrations/0001_billing_schema.sql

6 tables + invoices_seq + finalize_invoice() + apply_payment() SECURITY DEFINER + draft-only trigger + Storage bucket + seed

src/layouts/AppLayout.tsx

Fixed sidebar with billing nav and user menu

src/components/AdminGuard.tsx

Checks JWT app_metadata.role IN (admin, finance)

src/components/InvoiceTable.tsx

Sortable/filterable invoice list with StatusChip

src/components/InvoiceEditor.tsx

Sortable line items with auto-calculation in integer cents

src/components/StatusChip.tsx

Color-coded invoice status pill

src/components/CustomerPicker.tsx

Combobox for selecting customers by name or email

src/components/CurrencyInput.tsx

Dollar/cent input that stores and reads integer cents

src/components/AmountDisplay.tsx

Formats integer cents to localized currency string

src/components/PayButton.tsx

Links to Stripe Payment Link; disabled until finalized

src/pages/Dashboard.tsx

Receivables, paid-this-month, and overdue metric cards

src/pages/InvoicesList.tsx

Invoice table with status and customer filters

src/pages/InvoiceNew.tsx

New draft invoice form

src/pages/InvoiceView.tsx

Edit mode (draft) or view mode (sent/paid) with Finalize button

src/pages/CustomersList.tsx

Customer CRUD table

src/pages/CustomerView.tsx

Customer detail with invoice history

src/pages/PaymentsLog.tsx

Payment history with customer and date filters

src/pages/Settings.tsx

Org settings including invoice prefix and payment terms

src/pages/PublicInvoiceView.tsx

Public invoice view with signed-token validation and Pay button

supabase/functions/finalize-invoice/index.ts

Stripe Payment Link creation + PDF generation + Resend send

supabase/functions/stripe-webhook/index.ts

Raw-body webhook with constructEventAsync, idempotency, and apply_payment()

supabase/functions/generate-pdf/index.ts

HTML invoice template to PDF via @pdf-lib

supabase/functions/send-dunning-email/index.ts

Cron function for overdue detection and escalating Resend emails

Routes
/login

Authentication for finance team members

/dashboard

AR summary: outstanding, paid this month, overdue

/invoices

Invoice list with status and customer filters

/invoices/new

Create new draft invoice

/invoices/:id

Draft edit or finalized view with action buttons

/customers

Customer management

/customers/:id

Customer detail with invoice history

/payments

All payment transactions log

/settings

Org and invoice defaults

/pay/:invoiceNumber

Public customer-facing invoice view with Stripe Pay button

DB Tables
invoices
ColumnType
iduuid primary key
invoice_numbertext unique (NULL until finalize)
customer_iduuid not null references customers(id)
statustext default 'draft'
total_centsint not null default 0
due_attimestamptz
stripe_payment_link_urltext

RLS: Admin/finance all; viewer read; invoice_number stays NULL on draft rows

line_items
ColumnType
iduuid primary key
invoice_iduuid not null references invoices(id) on delete cascade
quantitynumeric not null default 1
unit_price_centsint not null
amount_centsint not null (= ROUND(quantity * unit_price_cents))

RLS: BEFORE INSERT/UPDATE/DELETE trigger rejects writes when parent invoice.status != 'draft'

payments
ColumnType
iduuid primary key
invoice_iduuid not null references invoices(id)
stripe_payment_intent_idtext unique
amount_centsint not null
statustext check in ('succeeded','pending','failed','refunded')

RLS: Admin/finance read; webhook-only writes via apply_payment() SECURITY DEFINER

stripe_events
ColumnType
iduuid primary key
stripe_event_idtext unique not null
event_typetext not null
processed_attimestamptz

RLS: No client access — webhook-only writes for idempotency dedup

customers
ColumnType
iduuid primary key
billing_emailtext not null
currencytext default 'USD'
payment_terms_daysint default 30

RLS: Admin/finance read/write; viewer read-only

profiles
ColumnType
iduuid primary key references auth.users.id
roletext default 'admin'

RLS: Own read/write; role enforced via JWT app_metadata

Components
InvoiceEditor

Line items editor with integer-cent arithmetic and auto-recalculation

CurrencyInput

Converts dollar display to integer cents on blur — never uses parseFloat on stored values

AmountDisplay

Formats integer cents to localized currency string via Intl.NumberFormat

StatusChip

Color-coded invoice status pill

PayButton

Stripe Payment Link redirect; disabled with tooltip when invoice is still draft

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Wire Stripe webhook with raw body, async constructEventAsync, and idempotency

Idempotent Stripe webhook that marks invoices paid via apply_payment() — the most critical piece of the billing flow

~80 credits
prompt
Wire the Stripe payment webhook end to end. This is non-negotiable — do it before any test payment.

In stripe-webhook/index.ts:
1. Read raw body FIRST: const body = await req.text()
2. Get signature header: const sig = req.headers.get('stripe-signature')
3. Construct event asynchronously: const event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'))
4. Idempotency check: const { data: existing } = await supabase.from('stripe_events').select('id').eq('stripe_event_id', event.id).single(); if (existing) return new Response('OK', {status: 200})
5. INSERT stripe_events row with event.id, event.type, now()
6. On event.type === 'payment_intent.succeeded': extract invoice_id from event.data.object.metadata, call apply_payment(invoice_id, event.data.object.id, event.data.object.amount_received)
7. Return new Response('OK', {status: 200})

Do NOT call req.json() anywhere — JSON-parsing destroys the raw body that constructEventAsync needs.

In Stripe Dashboard: Developers → Webhooks → Add endpoint → your deployed URL + /api/stripe-webhook → select payment_intent.succeeded. Copy the signing secret to STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets.

When to use: Immediately after the starter prompt — before any test payment attempt

2

Add Stripe Payment Link generation on Finalize

Stripe Payment Link creation on finalize, with PDF attachment and invoice email sent to the customer

~50 credits
prompt
In finalize-invoice/index.ts, after the finalize_invoice() RPC assigns the invoice number:

1. Fetch the invoice with its line_items from the DB
2. Create a Stripe Payment Link:
   - product_data per line item (use description as product name)
   - quantity and unit_amount from line_items.unit_price_cents
   - mode='payment'
   - metadata = {invoice_id: invoice.id, customer_id: invoice.customer_id}
   - after_completion = {type: 'redirect', redirect: {url: 'https://yourdomain.com/pay/' + invoice.invoice_number + '?paid=true'}}
3. Store the payment link URL: UPDATE invoices SET stripe_payment_link_url = paymentLink.url WHERE id = invoice.id
4. Then call the generate-pdf function to produce the PDF and upload to Storage
5. Then call Resend to send the invoice email to customer.billing_email with subject '[Invoice #${invoice_number}] ${display_name}' and PDF attachment + payment link in the email body

In InvoiceView, after clicking Finalize:
- Call finalize_invoice() RPC via supabase.rpc('finalize_invoice', {p_invoice_id: id})
- Then call the finalize-invoice Edge Function with the invoice_id
- Show a success toast: 'Invoice ${invoice_number} sent to ${billing_email}'
- Reload the invoice to show the updated status and PayButton

When to use: After the webhook works and you've verified apply_payment() runs correctly

3

Add PDF generation and Storage upload

PDF invoice generation using @pdf-lib stored in Supabase Storage with a signed-URL preview sidebar

~80 credits
prompt
Build the invoice PDF generator.

In generate-pdf/index.ts, use @pdf-lib (pure JS, works in Deno, no browser required). The function accepts the full invoice JSON with customer and line_items.

Layout (standard invoice format):
- Header: org name (from Settings) top-left, 'INVOICE' heading top-right
- Invoice details: Invoice #, Issue Date, Due Date, Customer name + billing address
- Line items table: description | qty | unit price | amount — right-aligned numbers
- Subtotal, tax, and total at bottom right
- Footer: payment instructions + 'Pay at [payment_link_url]'

All amounts formatted via (cents/100).toFixed(2) — never floating point arithmetic.

After generating: upload to invoice-pdfs/${customer_id}/${invoice_id}.pdf via supabase.storage.from('invoice-pdfs').upload(path, pdfBytes). UPDATE invoices SET pdf_storage_path = path.

In InvoiceView (view mode), show a PDFPreview sidebar: fetch a signed URL via supabase.storage.from('invoice-pdfs').createSignedUrl(pdf_storage_path, 3600) and render it in an iframe. Add a 'Download PDF' button that triggers the signed URL download.

When to use: Once the Stripe Payment Link generation works — PDF makes the invoice email professional

4

Add Resend invoice emails and payment confirmations

Automated invoice delivery email with PDF attachment and payment confirmation email on Stripe webhook success

~50 credits
prompt
Wire Resend transactional emails for the full invoice lifecycle.

In finalize-invoice/index.ts, after the PDF is uploaded, send the invoice email via Resend:
- To: customer.billing_email
- Subject: Invoice #${invoice_number} from ${org_name} — ${formatted_total} due ${due_date}
- HTML body: invoice summary table, 'Pay Now' button linking to stripe_payment_link_url
- Attachment: the PDF buffer as application/pdf with filename Invoice-${invoice_number}.pdf

Add a Postgres trigger on invoices UPDATE when status changes to 'paid' (NEW.status='paid' AND OLD.status != 'paid'). Trigger fires a send-payment-received Edge Function that sends Resend email to customer.billing_email with subject 'Payment received — Invoice #${invoice_number}' and a 'Download your invoice' link to the signed PDF URL.

Add RESEND_FROM_EMAIL to Cloud tab → Secrets — this must be a verified sending domain in your Resend account (e.g., billing@yourdomain.com).

When to use: After PDF generation works — the invoice email is what the customer receives

5

Add dunning emails for overdue invoices

Automated dunning with 4-stage escalating email cadence, daily cron trigger, and stage tracking to prevent duplicate sends

~60 credits
prompt
Wire automated dunning (overdue payment reminders) via a scheduled cron function.

In send-dunning-email/index.ts:
1. Query invoices WHERE status IN ('sent','overdue') AND due_at < now()
2. For each, compute days_overdue = EXTRACT(DAY FROM now() - due_at)
3. Map to escalation stage:
   - 1-3 days: 'gentle reminder' — friendly tone, link to pay
   - 4-7 days: 'second notice' — more direct, include late fee info if applicable
   - 8-14 days: 'urgent notice' — formal tone, mention next steps
   - 15-30 days: 'final notice' — state consequences (collections/service suspension)
4. On first detection (status='sent' AND due_at < now()), UPDATE invoice status='overdue'
5. Track which escalation stage was last sent via a dunning_stage int column on invoices (0 initially, increment per send)
6. Only send if current stage > dunning_stage (prevents re-sending same stage on re-run)
7. Send via Resend with appropriate template per stage

Schedule via pg_cron at 09:00 UTC daily:
SELECT cron.schedule('dunning_cron', '0 9 * * *', $$SELECT net.http_post(url:='YOUR_FUNCTION_URL', headers:='{"Content-Type":"application/json"}', body:='{}')$$);

When to use: Once you have ~5 invoices issued and want to automate collections follow-up

6

Add invoice voiding with audit trail

Compliance-correct invoice voiding that keeps the invoice number in the sequence with void_reason — never deletes or reuses numbers

~30 credits
prompt
Add invoice voiding for compliance-correct cancellation.

In InvoiceView (view mode), add a Void button visible when invoice.status IN ('sent','overdue'). Clicking it opens a Dialog with a required void_reason text input (min 10 chars) and a warning: 'Voiding this invoice number permanently removes it from your active receivables. The number is NOT reused — it remains in sequence as a voided record (required for tax compliance).' Confirm button sends {invoice_id, void_reason} to a void-invoice Edge Function.

The void-invoice function: UPDATE invoices SET status='void', void_reason=p_void_reason WHERE id=p_invoice_id AND status IN ('sent','overdue'). The invoice_number is KEPT as-is — do not NULL it. The gap in the sequence is intentional and visible in your invoices list as a void row.

In InvoicesList, void invoices show with amber StatusChip and void_reason on hover. They are included in the list (not hidden) so auditors can verify the sequence has no unexplained gaps.

If the invoice had a Stripe Payment Link, also call stripe.paymentLinks.update(id, {active: false}) to deactivate it.

When to use: Before your first real invoice is sent — voids are inevitable and need to be handled correctly from the start

7

Add credit notes for refunds

Credit notes with gap-free numbering for refunds, optional Stripe refund initiation, and net-revenue accounting in customer view

~50 credits
prompt
Add credit notes (negative invoices) for customer refunds.

Create a credit_notes table: id uuid PK, credit_note_number text unique (gap-free from credit_notes_seq sequence, format CN-2026-00001), original_invoice_id uuid references invoices(id), customer_id uuid references customers(id), status text default 'issued' check in ('issued','applied','void'), total_cents int not null, reason text not null, issued_at timestamptz, created_at timestamptz. RLS: admin/finance all.

Create credit_note_items (same as line_items but for credit notes, amounts positive but interpreted as reductions).

Create CREATE SEQUENCE credit_notes_seq START 1 and a SECURITY DEFINER issue_credit_note(p_invoice_id uuid, p_amount_cents int, p_reason text) function that assigns the CN number and creates the credit_note row.

In InvoiceView (paid status), add 'Issue Credit Note' button for partial or full refunds. Opens a Dialog with amount (CurrencyInput, max = total_cents) and reason. On confirm, calls issue_credit_note() and optionally calls stripe.refunds.create({payment_intent: payments.stripe_payment_intent_id, amount: p_amount_cents}) if refund should go back to the card.

Credit notes appear in /invoices list with a 'CN' prefix badge and in CustomerView showing net revenue after credits.

When to use: Once you start issuing refunds — credit notes are the compliance-correct way to handle them in double-entry accounting

Common errors

Real error strings you'll see. Find yours, paste the fix prompt.

Two invoices get the same invoice_number under concurrent finalize calls

Your starter prompt used COUNT(*) + 1 as the invoice number formula, or you set nextval('invoices_seq') as the column DEFAULT (which assigns on INSERT of the draft, then leaves a gap when the draft is abandoned). Neither produces gap-free numbering under concurrent load.

Fix — paste into Lovable Agent Mode
Drop the DEFAULT on invoices.invoice_number (it must be NULL on draft rows). Create the SECURITY DEFINER finalize function: CREATE FUNCTION finalize_invoice(p_invoice_id uuid) RETURNS text LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE new_num text; BEGIN SELECT 'INV-' || extract(year from now())::text || '-' || lpad(nextval('invoices_seq')::text, 5, '0') INTO new_num; UPDATE invoices SET invoice_number = new_num, status = 'sent', issued_at = now() WHERE id = p_invoice_id AND status = 'draft' AND invoice_number IS NULL; IF NOT FOUND THEN RAISE EXCEPTION 'Not draft or already finalized'; END IF; RETURN new_num; END; $$. Call this function ONLY from the Finalize button — never automatically.
SubtleCryptoProvider cannot be used in a synchronous context. Use await constructEventAsync(...) instead of constructEvent(...)

Lovable scaffolded the stripe-webhook Edge Function using the synchronous constructEvent, which fails in Deno because Deno's WebCrypto is async. Every Stripe-integrated Lovable app hits this on first webhook test.

Fix — paste into Lovable Agent Mode
In stripe-webhook/index.ts, replace constructEvent with await constructEventAsync. Read raw body BEFORE anything else: const body = await req.text(). Then: const event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')). Do NOT call req.json() anywhere — JSON-parsing destroys the raw body that signature verification needs. This is a hard requirement, not optional.
Same payment_intent.succeeded event processed twice — duplicate payments row created

Stripe retries webhook delivery on any non-200 response within ~10 seconds. Without the idempotency check against stripe_events, you process the same event twice and create two payments rows — potentially marking an invoice as double-paid.

Fix — paste into Lovable Agent Mode
At the top of stripe-webhook, after signature verification: const { data: existing } = await supabase.from('stripe_events').select('id').eq('stripe_event_id', event.id).single(); if (existing) { return new Response('OK', {status: 200}); } // already processed. Then INSERT the stripe_events row. Then call apply_payment(). Wrap the INSERT + apply_payment in a try/catch so partial failures don't leave a processed event without a row.
Customer pays but invoice stays 'sent' — webhook fires 200 OK but apply_payment() silently fails

apply_payment() is silently failing because of RLS — the webhook context (using the anon/service key) doesn't have INSERT permission on payments or UPDATE on invoices unless the function is SECURITY DEFINER.

Fix — paste into Lovable Agent Mode
Confirm apply_payment() is defined as LANGUAGE plpgsql SECURITY DEFINER. SECURITY DEFINER runs the function with the function-owner's privileges, bypassing RLS. Test: SELECT apply_payment('test-invoice-uuid', 'pi_test_123', 5000) from SQL Editor as the postgres role — should succeed and show the invoice status change to 'paid'.
Line item total shows $299.96999999999996 instead of $299.97 for quantity=3 at $99.99

You're using JavaScript floating-point arithmetic to calculate the amount. JavaScript cannot represent 99.99 exactly in binary floating-point — the calculation drifts. This is a billing bug that can appear in customer invoices and confuse your accountant.

Fix — paste into Lovable Agent Mode
Store ALL money values as integer cents. In InvoiceEditor, read unit_price_cents (the integer column) and quantity. Calculate: const amount_cents = Math.round(quantity * unit_price_cents). Store amount_cents. Display via AmountDisplay component: (amount_cents / 100).toLocaleString('en-US', {style:'currency', currency: invoice.currency}). In CurrencyInput on blur: const cents = Math.round(parseFloat(displayValue) * 100) — store cents. Never perform arithmetic on dollar-formatted strings or display-formatted numbers.
Anyone can view Customer B's invoice by guessing the invoice number URL (/pay/INV-2026-00042)

Your /pay/:invoiceNumber page doesn't validate the signed token or checks only that a token is present but not that it's cryptographically valid — any signed-in (or even signed-out) user can enumerate invoice numbers.

Fix — paste into Lovable Agent Mode
In PublicInvoiceView, decode the token query param as a JWT: import {jwtVerify} from 'jose'; const {payload} = await jwtVerify(token, new TextEncoder().encode(Deno.env.get('INVOICE_TOKEN_SECRET'))). Verify payload.invoice_id matches the invoice's id and payload.exp is in the future. If the token is missing, invalid, or expired, render a 404 response — not a 401 or 403, which would confirm the invoice exists. Generate the token in finalize-invoice Edge Function using the same secret and sign it with a 30-day expiry.

Cost reality

What this build actually costs — no surprises on your card.

Recommended Lovable plan

Pro $25/mo with one $15 top-up budgeted. Most credits go into the webhook + finalize_invoice function + PDF generation iteration. Free plan's ~30 monthly cap covers only the starter prompt.

Monthly run cost breakdown

~150-250 credits (starter ~70-100, follow-ups 1-4 ~260, follow-ups 5-7 add ~140 more if done). Counter-data: disciplined prompts for the Stripe webhook can land under 30 credits if you paste the exact raw-body pattern from follow-up #1 directly. total
ItemCost
Lovable Pro

Drop to Free after build — the system runs on Cloud

$25/mo
Supabase / Lovable Cloud

500MB DB handles 100K+ invoices; billing data volume is small

$0/mo at MVP
Stripe

The dominant ongoing cost — at $10K monthly invoiced you pay ~$320/mo in Stripe fees

2.9% + 30¢ per charge
Resend

3K free emails covers ~1K invoices/month with send + confirmation + one dunning

$0-20/mo
PDF generation

Pure JS in Deno Edge Function — no external service needed for basic invoices. Switch to Browserless (~$5-10/mo) at 1K+ invoices/mo when you want complex layouts.

$0 with @pdf-lib
Custom domain

Via Cloud tab → Publish → custom domain on Pro plan

$10-15/yr

Scaling notes: Stripe fees scale linearly with revenue and are the only unavoidable cost. PDF generation is the next concern — @pdf-lib in Edge Functions is free but slow on complex layouts (500ms+); at 1K+ invoices/month switch to a dedicated PDF service. DB capacity is rarely a bottleneck for billing — invoice rows are small and 500MB handles years of data. The main operational risk is the dunning cron missing overdue invoices due to Edge Function timeouts — add an alert on failed cron executions once you're generating real invoices.

Production checklist

Steps to take before you share the URL with real users.

Sequential Invoice Numbers

  • Verify invoices.invoice_number has NO column DEFAULT

    SQL Editor: SELECT column_default FROM information_schema.columns WHERE table_name='invoices' AND column_name='invoice_number'. Should return NULL — not nextval('invoices_seq'). The sequence is only called inside finalize_invoice().

  • Test concurrent finalize calls

    In SQL Editor, run two concurrent sessions and call finalize_invoice() on two different draft invoices simultaneously. Both should succeed with different invoice numbers — not the same number.

Stripe Production Keys

  • Switch from test to live Stripe keys

    Stripe Dashboard → toggle Live mode → copy sk_live_* key and live webhook signing secret. Update STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets. Create a new live webhook endpoint pointing to your production URL.

  • Verify apply_payment() is SECURITY DEFINER

    SQL Editor: SELECT prosecdef FROM pg_proc WHERE proname = 'apply_payment'. Should return true. If false, recreate with SECURITY DEFINER.

Domain & Email

  • Connect custom domain

    Click Publish → Settings → Custom domain. Add CNAME record. Update INVOICE_TOKEN_SECRET to a new random value for production (different from the one used in development).

  • Verify Resend sending domain DNS

    Resend Dashboard → Domains → verify SPF, DKIM, and DMARC records are all green for your sending domain before any invoice emails go to real customers.

Dunning Cron

  • Verify pg_cron is enabled and scheduled

    SQL Editor: SELECT * FROM cron.job. Confirm dunning_cron appears with schedule '0 9 * * *'. Check cron.job_run_details for the last run status — it should show 'succeeded'.

Frequently asked questions

Why is gap-free invoice numbering required, and why is COUNT(*)+1 wrong?

Most tax jurisdictions (EU, UK, Canada, Australia, and many US states) require invoice numbers to be sequential and gap-free. An auditor who sees INV-2026-00041, INV-2026-00043 will ask where #42 is — and 'we abandoned a draft' is not an acceptable answer in a VAT audit. COUNT(*)+1 is wrong because two concurrent finalizations can both read the same count and assign the same number. nextval() as a column DEFAULT is wrong because the sequence increments when the draft is created, then leaves a gap when the draft is abandoned. The correct approach: invoice_number is NULL on all draft rows; the SECURITY DEFINER finalize_invoice() function calls nextval() at the moment of finalization and cannot be interrupted or rolled back independently.

Does Stripe Invoicing handle this? When should I just use that?

Stripe Invoicing (stripe.com/invoicing) covers simple invoice → pay flows at 0.4% per paid invoice (capped at $2). Use it if your needs are: create invoice, customer pays via Stripe-hosted page, you reconcile in Stripe Dashboard. Build in Lovable when you need invoice numbering that matches a legacy scheme your accountant requires, integration with your existing Lovable customer table as the single source of truth, custom line-item logic Stripe can't model (tiered usage, hourly billing from a time-tracking integration), a customer-facing portal that matches your brand exactly, or you want offline payment support (bank transfer marked paid manually). The break-even point is roughly 150 invoices/month where Stripe Invoicing fees ($2 × 150 = $300/mo) exceed the cost of this custom build.

How do I generate a PDF of the invoice?

The generate-pdf follow-up (follow-up #3) uses @pdf-lib, a pure JavaScript PDF library that works inside Deno Edge Functions without any external service. It renders a simple table layout with your org name, invoice details, and line items. For complex branded layouts with custom fonts and pixel-perfect design, you can proxy to Browserless ($5-10/mo) or PDFShift ($9/mo) — pass your HTML template and get a PDF back. The @pdf-lib approach is free and sufficient for standard B2B invoice formats. Include a PDF preview iframe in InvoiceView so you can review before sending.

Can my customer pay without creating an account?

Yes — the /pay/:invoiceNumber route is intentionally public. Customers receive an email with a link like /pay/INV-2026-00042?token=eyJhbGc... and can view and pay via Stripe Payment Link without logging in. The signed JWT token in the query string prevents invoice enumeration — without a valid token for the specific invoice number, the page returns a 404 (not a 403, which would confirm the invoice exists). Tokens are generated in finalize-invoice with a 30-day expiry and signed with INVOICE_TOKEN_SECRET stored in Cloud tab → Secrets.

Why must the Stripe webhook read raw body?

Stripe verifies webhook authenticity by generating an HMAC-SHA256 signature over the raw request body and comparing it to the stripe-signature header. If you call req.json() first, the body is JSON-parsed and converted back to a string when constructEventAsync tries to verify it — the whitespace and key ordering may differ from the original, producing a different hash. The signature check fails silently: constructEventAsync returns a response that appears valid but with a false flag, and your webhook processes no events. The rule is: const body = await req.text() must be the first thing your webhook handler does, before any JSON parsing, before logging the body, before anything.

How do I handle EU VAT or US sales tax?

For EU VAT: store tax_id and billing_address.country on the customers table. When generating a line item, apply the correct VAT rate based on customer country (you'll need a VAT rates lookup table or use a library). For B2B transactions with a valid VAT ID (reverse charge), tax_cents = 0 and add a 'VAT reverse charge applies' note. For US sales tax: Stripe Tax ($0.50/transaction above $500/mo volume) can auto-calculate and collect state sales tax if enabled in finalize-invoice. The billing system starter scaffolds tax_cents as a separate column specifically so VAT/tax can be tracked independently from the subtotal for reporting.

When does it make sense to hire RapidDev for billing instead?

If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.

Need a production-grade version?

RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.