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.
- 1Click the + button next to Preview in the top toolbar to open the Cloud tab
- 2Click Database — you'll see an empty Table Editor
- 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.
- 1Cloud tab → Users & Auth
- 2Under Sign-in methods, enable Email (already on)
- 3Disable 'Allow new users to sign up' — you'll invite finance team members manually
- 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.
- 1Cloud tab → Storage
- 2Click Create bucket, name it 'invoice-pdfs', leave Public OFF
- 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).
- 1No manual setup needed before running the starter prompt
- 2After the starter finishes, confirm all four functions appear in Cloud tab → Edge Functions
- 3Add Stripe and Resend secrets before running any invoice through the flow
Secrets (Cloud tab → Secrets)
STRIPE_SECRET_KEYPurpose: 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_SECRETPurpose: 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_KEYPurpose: 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_SECRETPurpose: 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.
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.sql6 tables + invoices_seq + finalize_invoice() + apply_payment() SECURITY DEFINER + draft-only trigger + Storage bucket + seed
src/layouts/AppLayout.tsxFixed sidebar with billing nav and user menu
src/components/AdminGuard.tsxChecks JWT app_metadata.role IN (admin, finance)
src/components/InvoiceTable.tsxSortable/filterable invoice list with StatusChip
src/components/InvoiceEditor.tsxSortable line items with auto-calculation in integer cents
src/components/StatusChip.tsxColor-coded invoice status pill
src/components/CustomerPicker.tsxCombobox for selecting customers by name or email
src/components/CurrencyInput.tsxDollar/cent input that stores and reads integer cents
src/components/AmountDisplay.tsxFormats integer cents to localized currency string
src/components/PayButton.tsxLinks to Stripe Payment Link; disabled until finalized
src/pages/Dashboard.tsxReceivables, paid-this-month, and overdue metric cards
src/pages/InvoicesList.tsxInvoice table with status and customer filters
src/pages/InvoiceNew.tsxNew draft invoice form
src/pages/InvoiceView.tsxEdit mode (draft) or view mode (sent/paid) with Finalize button
src/pages/CustomersList.tsxCustomer CRUD table
src/pages/CustomerView.tsxCustomer detail with invoice history
src/pages/PaymentsLog.tsxPayment history with customer and date filters
src/pages/Settings.tsxOrg settings including invoice prefix and payment terms
src/pages/PublicInvoiceView.tsxPublic invoice view with signed-token validation and Pay button
supabase/functions/finalize-invoice/index.tsStripe Payment Link creation + PDF generation + Resend send
supabase/functions/stripe-webhook/index.tsRaw-body webhook with constructEventAsync, idempotency, and apply_payment()
supabase/functions/generate-pdf/index.tsHTML invoice template to PDF via @pdf-lib
supabase/functions/send-dunning-email/index.tsCron function for overdue detection and escalating Resend emails
Routes
Authentication for finance team members
AR summary: outstanding, paid this month, overdue
Invoice list with status and customer filters
Create new draft invoice
Draft edit or finalized view with action buttons
Customer management
Customer detail with invoice history
All payment transactions log
Org and invoice defaults
Public customer-facing invoice view with Stripe Pay button
DB Tables
invoices| Column | Type |
|---|---|
| id | uuid primary key |
| invoice_number | text unique (NULL until finalize) |
| customer_id | uuid not null references customers(id) |
| status | text default 'draft' |
| total_cents | int not null default 0 |
| due_at | timestamptz |
| stripe_payment_link_url | text |
RLS: Admin/finance all; viewer read; invoice_number stays NULL on draft rows
line_items| Column | Type |
|---|---|
| id | uuid primary key |
| invoice_id | uuid not null references invoices(id) on delete cascade |
| quantity | numeric not null default 1 |
| unit_price_cents | int not null |
| amount_cents | int not null (= ROUND(quantity * unit_price_cents)) |
RLS: BEFORE INSERT/UPDATE/DELETE trigger rejects writes when parent invoice.status != 'draft'
payments| Column | Type |
|---|---|
| id | uuid primary key |
| invoice_id | uuid not null references invoices(id) |
| stripe_payment_intent_id | text unique |
| amount_cents | int not null |
| status | text check in ('succeeded','pending','failed','refunded') |
RLS: Admin/finance read; webhook-only writes via apply_payment() SECURITY DEFINER
stripe_events| Column | Type |
|---|---|
| id | uuid primary key |
| stripe_event_id | text unique not null |
| event_type | text not null |
| processed_at | timestamptz |
RLS: No client access — webhook-only writes for idempotency dedup
customers| Column | Type |
|---|---|
| id | uuid primary key |
| billing_email | text not null |
| currency | text default 'USD' |
| payment_terms_days | int default 30 |
RLS: Admin/finance read/write; viewer read-only
profiles| Column | Type |
|---|---|
| id | uuid primary key references auth.users.id |
| role | text default 'admin' |
RLS: Own read/write; role enforced via JWT app_metadata
Components
InvoiceEditorLine items editor with integer-cent arithmetic and auto-recalculation
CurrencyInputConverts dollar display to integer cents on blur — never uses parseFloat on stored values
AmountDisplayFormats integer cents to localized currency string via Intl.NumberFormat
StatusChipColor-coded invoice status pill
PayButtonStripe 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.
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
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
Add Stripe Payment Link generation on Finalize
Stripe Payment Link creation on finalize, with PDF attachment and invoice email sent to the customer
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 PayButtonWhen to use: After the webhook works and you've verified apply_payment() runs correctly
Add PDF generation and Storage upload
PDF invoice generation using @pdf-lib stored in Supabase Storage with a signed-URL preview sidebar
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
Add Resend invoice emails and payment confirmations
Automated invoice delivery email with PDF attachment and payment confirmation email on Stripe webhook success
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
Add dunning emails for overdue invoices
Automated dunning with 4-stage escalating email cadence, daily cron trigger, and stage tracking to prevent duplicate sends
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
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
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
Add credit notes for refunds
Credit notes with gap-free numbering for refunds, optional Stripe refund initiation, and net-revenue accounting in customer view
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 callsYour 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.
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.
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 createdStripe 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.
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 failsapply_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.
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.99You'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.
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.
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| Item | Cost |
|---|---|
| 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 consultation30-min call. No commitment.