Skip to main content
RapidDev - Software Development Agency

How to Build a Expense Tracking with Lovable

Build a business expense tracker in Lovable where employees photograph receipts and upload them to Supabase Storage, a manager approval workflow routes expenses through review, an Edge Function validates amounts against company policy, and finance teams export monthly CSV reports — with category spending Charts and a full audit trail.

What you'll build

  • Receipt photo upload to a private Supabase Storage bucket with signed URLs for secure viewing
  • Expense submission form with merchant, amount, category, and optional receipt photo
  • Manager approval workflow with pending queue, approve and reject actions, and rejection reason
  • Policy validation Edge Function that flags expenses exceeding category limits
  • Monthly expense report DataTable with category filters and CSV export for accounting
  • Category spending bar chart showing budget vs actual per category
  • Dashboard with pending approvals count, total approved this month, and reimbursed balance
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner14 min read1.5–2 hoursLovable free tier or higherApril 2026RapidDev Engineering Team
TL;DR

Build a business expense tracker in Lovable where employees photograph receipts and upload them to Supabase Storage, a manager approval workflow routes expenses through review, an Edge Function validates amounts against company policy, and finance teams export monthly CSV reports — with category spending Charts and a full audit trail.

What you're building

Expense tracking has two friction points: submitting expenses and getting them approved. This build minimizes both.

Receipt upload uses Supabase Storage with a private bucket. The file path follows the pattern receipts/{user_id}/{timestamp}-{filename}. Access requires a signed URL generated server-side — reviewers see the receipt without the file being publicly accessible.

The approval workflow is a simple status machine on the expenses table: draft → submitted → approved (or rejected). Managers see a dedicated pending queue filtered to their team's submitted expenses. The queue updates in real time via Supabase Realtime. Approving an expense updates status to 'approved' and sets approved_by and approved_at. Rejecting requires a reason which is saved and displayed to the submitter.

Policy validation runs in an Edge Function called on submission. It checks the expense amount against a category limit from the expense_policies table. If the amount exceeds the limit, the expense is flagged with policy_violated = true but is still submitted — the manager sees the flag and can approve or reject accordingly.

Final result

A complete expense management workflow from mobile receipt capture to manager approval to finance export — without any external tools.

Tech stack

LovableFrontend app
SupabaseDatabase with RLS
Supabase StorageReceipt photo storage
Supabase Edge FunctionsPolicy validation (Deno)
shadcn/uiUI components
RechartsCategory spending chart

Prerequisites

  • Lovable account (free tier works for most of this build)
  • Supabase project with Storage enabled
  • SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY added to Cloud tab → Secrets
  • A list of expense categories and monthly limits for your company (for the policy table)
  • Manager and employee accounts set up in Supabase Auth

Build steps

1

Create the expense schema and storage bucket

Ask Lovable to create the tables, set up the private storage bucket, and seed the expense policy table with category limits.

prompt.txt
1Create an expense tracking schema in Supabase and set up storage.
2
3Tables:
4- expense_categories: id, name, monthly_limit (decimal, nullable), icon (text, emoji)
5- expense_policies: id, category_id (references expense_categories), max_per_transaction (decimal), requires_receipt_above (decimal, e.g. 25.00), created_at
6- expenses: id, user_id (references auth.users), category_id (references expense_categories), merchant_name, amount (decimal), currency (text default 'USD'), expense_date (date), description, receipt_path (text, nullable, Supabase Storage path), status ('draft' | 'submitted' | 'approved' | 'rejected' | 'reimbursed'), policy_violated (bool default false), policy_note (text, nullable), submitted_at, approved_by (references auth.users, nullable), approved_at, rejection_reason (text, nullable), created_at
7- profiles: id (references auth.users), role ('employee' | 'manager' | 'finance'), manager_id (references auth.users, nullable), department
8
9Storage:
10- Create a private bucket named 'receipts'
11- Storage RLS policy: users can INSERT files at path 'receipts/{auth.uid()}/*'. Users can SELECT their own files. Managers can SELECT files from employees in their team (implement via a storage policy joining to profiles).
12
13RLS on expenses:
14- Employees SELECT/INSERT/UPDATE (draft only) their own rows
15- Managers SELECT expenses from employees in their team (manager_id = auth.uid() in profiles)
16- Managers UPDATE status, approved_by, rejection_reason
17- Finance role can SELECT all expenses

Pro tip: Set requires_receipt_above = 25.00 in expense_policies. In the submission form, make the receipt upload required only when the amount exceeds this threshold. This reduces friction for small expenses while ensuring large ones have documentation.

Expected result: Tables are created. The receipts bucket exists as private. A seed of common categories (meals, travel, software, office supplies) and their limits is inserted. TypeScript types are generated.

2

Build the receipt upload and expense form

The primary employee workflow: fill out an expense form and optionally attach a receipt photo. Ask Lovable to build this form with the storage upload logic.

prompt.txt
1Build an expense submission form at src/pages/NewExpense.tsx.
2
3Requirements:
4- Form fields (react-hook-form + zod):
5 - Category Select (fetch from expense_categories, show icon + name)
6 - Merchant name Input
7 - Amount Input (decimal number)
8 - Currency Select (USD/EUR/GBP)
9 - Expense date DatePicker (default today)
10 - Description Textarea (optional)
11 - Receipt upload: a file Input accepting image/* and application/pdf. Show a preview thumbnail for images.
12- Upload logic when a file is selected:
13 - Call supabase.storage.from('receipts').upload(path, file) where path = `receipts/${userId}/${Date.now()}-${file.name}`
14 - Show a progress indicator during upload
15 - Store the path in form state, not the full URL
16- Submit behavior:
17 - First call the validate-expense Edge Function with category_id and amount
18 - If policy_violated, show a yellow Alert: 'This expense exceeds the $X limit for [category]. It will be flagged for manager review.'
19 - Allow the user to proceed anyway
20 - INSERT the expense with status='submitted' and policy_violated flag
21 - Show a success Toast and navigate to /expenses
22- Add a 'Save Draft' Button that inserts with status='draft'

Expected result: The form uploads the receipt file to Storage on selection and shows a preview. Submitting calls the Edge Function for policy check, shows a warning if violated, and creates the expense record. Draft saving works.

3

Build the policy validation Edge Function

Create the Edge Function that checks an expense amount against the policy table. It returns whether the expense violates policy and a human-readable reason.

supabase/functions/validate-expense/index.ts
1// supabase/functions/validate-expense/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const corsHeaders = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8 'Content-Type': 'application/json',
9}
10
11serve(async (req: Request) => {
12 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
13
14 try {
15 const supabase = createClient(
16 Deno.env.get('SUPABASE_URL') ?? '',
17 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
18 )
19
20 const { category_id, amount } = await req.json()
21
22 const { data: policy } = await supabase
23 .from('expense_policies')
24 .select('max_per_transaction, requires_receipt_above, expense_categories(name)')
25 .eq('category_id', category_id)
26 .single()
27
28 if (!policy) {
29 return new Response(JSON.stringify({ violated: false }), { headers: corsHeaders })
30 }
31
32 const violations: string[] = []
33
34 if (policy.max_per_transaction && amount > policy.max_per_transaction) {
35 violations.push(`Exceeds the $${policy.max_per_transaction} per-transaction limit for ${(policy.expense_categories as any)?.name}`)
36 }
37
38 return new Response(JSON.stringify({
39 violated: violations.length > 0,
40 violations,
41 requires_receipt: amount >= (policy.requires_receipt_above ?? 0),
42 }), { headers: corsHeaders })
43 } catch (err) {
44 return new Response(JSON.stringify({ violated: false }), { headers: corsHeaders })
45 }
46})

Pro tip: Return requires_receipt from the Edge Function response. Use this in the form to dynamically make the receipt upload required when the amount crosses the threshold. This avoids a separate client-side check.

Expected result: The Edge Function returns { violated: true, violations: ['Exceeds the $100 limit for Meals'] } for over-limit amounts. Below-limit amounts return { violated: false }. The form shows the appropriate warning.

4

Build the manager approval queue

Managers need a clean queue of expenses waiting for review. Ask Lovable to build the approval interface.

prompt.txt
1Build a manager approval page at src/pages/ApprovalQueue.tsx.
2
3Requirements:
4- Fetch all expenses WHERE status='submitted' and the expense belongs to an employee whose manager_id = current user
5- Display as a list of Cards (not a table cards allow more detail)
6- Each Card shows:
7 - Employee name and avatar/initials
8 - Merchant name, amount (formatted as currency), category icon + name
9 - Expense date
10 - Description
11 - Policy violation Alert if policy_violated = true (yellow, show policy_note)
12 - Receipt preview: if receipt_path is set, show a thumbnail using a signed URL fetched from supabase.storage.from('receipts').createSignedUrl(path, 3600). Clicking opens the full receipt in a new tab.
13 - Two Buttons: 'Approve' (green) and 'Reject' (destructive outline)
14- Clicking Approve: UPDATE expense status='approved', approved_by=auth.uid(), approved_at=now(). Remove from queue.
15- Clicking Reject: open a Dialog with a required Textarea for rejection_reason. On submit, UPDATE status='rejected', rejection_reason. Remove from queue.
16- Show a badge count of pending items in the page heading
17- Subscribe to Realtime INSERT events on expenses WHERE status='submitted' to show new items without refresh

Expected result: The approval queue shows all submitted expenses for the manager's team. Receipt images display via signed URLs. Approving and rejecting updates the status and removes the card from the queue. New submissions appear in real time.

5

Build the expense history and CSV export

Employees see their own expense history. Finance sees everything. Both need CSV export for accounting. Ask Lovable to build the shared reports page.

prompt.txt
1Build an expense list page at src/pages/Expenses.tsx.
2
3Requirements:
4- Detect user role from profiles table
5- Employee view: show own expenses only
6- Manager view: show own + team expenses
7- Finance view: show all expenses with employee name column
8- DataTable with columns: Date, Employee (manager/finance only), Category (icon + name), Merchant, Amount (currency formatted), Status Badge (submitted=blue, approved=green, rejected=red, reimbursed=teal, draft=gray), Receipt indicator (paperclip icon if receipt exists), Actions
9- Clicking a row opens a Sheet with full expense details and receipt preview
10- Filter bar: status multi-select, category multi-select, date range Popover, search Input for merchant name
11- Total shown below table: filtered total amount, count of records
12- 'Export CSV' Button: download visible rows as CSV. Columns: date, employee_email, category, merchant, amount, currency, status, description, receipt_url (signed URL valid 7 days). Name file: 'expenses-{from}-{to}.csv'
13- Recharts BarChart below table: spending by category for the selected period (actual vs category monthly_limit if set)

Expected result: The DataTable shows correctly filtered expenses by role. Filters work correctly. The CSV export includes signed receipt URLs. The bar chart shows category spending vs limits.

Complete code

src/components/ReceiptUpload.tsx
1import { useState, useRef } from 'react'
2import { Button } from '@/components/ui/button'
3import { Paperclip, X, Loader2 } from 'lucide-react'
4import { supabase } from '@/integrations/supabase/client'
5
6type ReceiptUploadProps = {
7 userId: string
8 onUpload: (path: string) => void
9 onRemove: () => void
10 currentPath?: string
11}
12
13export function ReceiptUpload({ userId, onUpload, onRemove, currentPath }: ReceiptUploadProps) {
14 const [uploading, setUploading] = useState(false)
15 const [preview, setPreview] = useState<string | null>(null)
16 const [error, setError] = useState<string | null>(null)
17 const inputRef = useRef<HTMLInputElement>(null)
18
19 const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
20 const file = e.target.files?.[0]
21 if (!file) return
22
23 if (file.size > 10 * 1024 * 1024) {
24 setError('File must be under 10MB')
25 return
26 }
27
28 setError(null)
29 setUploading(true)
30
31 // Show local preview immediately
32 if (file.type.startsWith('image/')) {
33 const reader = new FileReader()
34 reader.onloadend = () => setPreview(reader.result as string)
35 reader.readAsDataURL(file)
36 }
37
38 const path = `receipts/${userId}/${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`
39 const { error: uploadError } = await supabase.storage
40 .from('receipts')
41 .upload(path, file, { contentType: file.type })
42
43 if (uploadError) {
44 setError('Upload failed. Please try again.')
45 setPreview(null)
46 } else {
47 onUpload(path)
48 }
49 setUploading(false)
50 }
51
52 const remove = () => {
53 setPreview(null)
54 onRemove()
55 if (inputRef.current) inputRef.current.value = ''
56 }
57
58 if (currentPath || preview) {
59 return (
60 <div className="flex items-center gap-2 rounded-md border p-2">
61 {preview && <img src={preview} alt="Receipt" className="h-12 w-12 rounded object-cover" />}
62 <span className="flex-1 truncate text-sm text-muted-foreground">{currentPath?.split('/').pop()}</span>
63 <Button type="button" variant="ghost" size="icon" onClick={remove}>
64 <X className="h-4 w-4" />
65 </Button>
66 </div>
67 )
68 }
69
70 return (
71 <div>
72 <input ref={inputRef} type="file" accept="image/*,application/pdf" className="hidden" onChange={handleFileChange} />
73 <Button type="button" variant="outline" className="w-full" onClick={() => inputRef.current?.click()} disabled={uploading}>
74 {uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Paperclip className="mr-2 h-4 w-4" />}
75 {uploading ? 'Uploading...' : 'Attach Receipt'}
76 </Button>
77 {error && <p className="mt-1 text-xs text-destructive">{error}</p>}
78 </div>
79 )
80}

Customization ideas

Multi-step approval for large expenses

Add a second approver requirement when expense amount exceeds a threshold (e.g. $500 requires both direct manager and department head approval). Add a second_approver_id and second_approved_at column to expenses. The status machine gains an 'awaiting_second_approval' state shown in a separate queue for department heads.

Budget tracking per department

Add a budgets table with department_id, category_id, month, and budgeted_amount. The category bar chart compares actual approved spending against the budget. Show a red warning when any category exceeds 80% of its monthly budget. Add a budget management page for finance admins.

Recurring expense templates

Add an expense_templates table where employees can save common expenses (e.g. monthly software subscription). From the new expense form, a 'Use Template' option pre-fills all fields. Templates save time for predictable regular expenses and improve data consistency.

Accounting software export format

Add QuickBooks or Xero-compatible CSV export columns (Account, Class, Memo, Debit, Credit) alongside the standard CSV. This allows finance to import expense reports directly into their accounting software without manual reformatting.

OCR receipt parsing

After a receipt is uploaded, call an Edge Function that sends the image to an AI vision API to extract merchant name, date, and amount. Pre-fill these fields in the expense form with a confidence indicator. This saves employees significant data entry time. Mark AI-extracted fields visually so users verify before submitting.

Common pitfalls

Pitfall: Using public storage bucket for receipts

How to avoid: Use a private bucket and generate short-lived signed URLs when displaying receipts. Set expiry to 1 hour for viewing in the approval queue, and 7 days for CSV exports. Signed URLs expire automatically and cannot be shared long-term.

Pitfall: Storing the signed URL in the database instead of the storage path

How to avoid: Store only the storage path (e.g. receipts/user-id/filename.jpg). Generate fresh signed URLs on demand when displaying the receipt. This takes an extra round trip but the URL is always valid.

Pitfall: Allowing employees to approve their own expenses

How to avoid: In the approval queue query and the UPDATE RLS policy, add a check that approved_by != expenses.user_id. The manager who approves must be a different person from the submitter, even if they technically have the manager role.

Pitfall: Not validating the file type and size on the server side

How to avoid: In the Supabase Storage bucket configuration, set allowed MIME types to image/jpeg, image/png, image/webp, application/pdf. Set a maximum file size. These checks enforce the policy at the infrastructure level, not just the UI.

Best practices

  • Store only the storage path in the database, never the full URL or signed URL. Generate signed URLs on demand when displaying receipts.
  • Require a rejection reason in the manager approval UI. Employees who receive a rejected expense without explanation are frustrated and re-submit incorrectly. The reason saves back-and-forth communication.
  • Send email notifications via an Edge Function triggered by status changes. When an expense moves from submitted to approved or rejected, email the submitter with the decision and (for rejections) the reason.
  • Add a UNIQUE constraint on (user_id, merchant_name, expense_date, amount) to prevent accidental duplicate submissions. Show a clear warning if the user tries to submit an expense that looks identical to a recent one.
  • Use the policy_violated flag as advisory information for managers, not a hard block. Legitimate over-limit expenses exist (client dinner, emergency travel). The flag ensures the manager consciously approves the exception.
  • For CSV export, generate signed URLs with a 7-day expiry for the receipt column. This makes the export actionable for accountants who need to verify receipts without accessing your app.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building an expense approval app in Supabase. I have a manager approval queue that should show expenses submitted by employees whose manager_id equals the current user's auth.uid(). Help me write the Supabase RLS policy for the expenses table that allows managers to SELECT expenses where the submitting user's profiles.manager_id matches auth.uid(). Show me the SQL for the policy using a subquery or EXISTS clause.

Lovable Prompt

Add an expense summary email feature to the admin panel. Build a 'Send Monthly Summary' Button on the finance admin page. When clicked, it should call a send-expense-report Edge Function that: queries all approved expenses for the current month, groups them by category and employee, and uses Resend to send a formatted HTML email to the finance team address stored in app_settings table. The email should include total spend, a breakdown by category, and a link to the full CSV export.

Build Prompt

In Supabase Storage, write the RLS policy that allows managers to view receipts uploaded by employees in their team. The policy should allow SELECT on objects in the receipts bucket where the path starts with 'receipts/{employee_user_id}/' and that employee's profiles.manager_id = auth.uid(). Show me how to write this as a Supabase Storage policy using the storage.objects table.

Frequently asked questions

How do employees submit expenses from their phone?

The app is fully mobile-responsive. On mobile, the file input's accept='image/*' attribute triggers the camera directly on iOS and Android, letting employees photograph receipts immediately. The upload component shows a preview so they can verify the image is readable before submitting.

What happens to the receipt file if an expense is rejected?

The receipt file stays in Storage even if the expense is rejected. Employees should be able to resubmit with the same receipt by referencing the original path or uploading again. Do not automatically delete files on rejection — the audit trail may be needed. Add a manual cleanup function in the admin panel to delete orphaned files older than 90 days.

Can I track mileage expenses without a receipt?

Yes. Add a category called 'Mileage' with a per-mile rate stored in expense_policies. Add optional miles_driven and purpose fields to the expense form that appear only for the Mileage category. Calculate the expense amount as miles_driven * rate_per_mile automatically. No receipt is required for mileage — the policy validation Edge Function skips receipt requirements for this category.

How long should signed URLs last for the approval queue?

Set a 3,600-second (1 hour) expiry for signed URLs used in the approval queue. Managers reviewing expenses within a normal session will not encounter expired URLs. For CSV exports intended for accountants to use over several days, generate 7-day URLs. Never generate URLs with no expiry — they create permanent exposure of sensitive financial documents.

Can the same person be both an employee and a manager?

Yes. In the profiles table, a manager still has a manager_id pointing to their own manager. Their role is set to 'manager'. They see both the employee view (their own expenses) and the manager view (their team's approval queue). Use the role column to determine which pages and features are shown to each user.

How do I handle multi-currency expenses for international teams?

Store the original amount and currency on each expense. Add a base_currency_amount column that stores the equivalent in your company's base currency using the exchange rate at the time of submission. Add an exchange_rate column. Budgets and reports aggregate base_currency_amount for consistency. Fetch exchange rates from an external API in the Edge Function at submission time.

What is the storage cost for receipt photos in Supabase?

Supabase free tier includes 1GB of Storage. A compressed receipt photo is typically 200-500KB. The free tier supports roughly 2,000-5,000 receipt photos. Supabase Pro ($25/month) includes 100GB. For a team of 50 employees submitting 5 expenses per month, you generate roughly 25MB of receipts monthly — well within the free tier for a long time.

Is there help building an expense system with ERP or accounting integrations?

RapidDev builds Lovable apps with QuickBooks, Xero, and NetSuite integrations for automated expense synchronization. Reach out if your expense workflow requires direct accounting system connectivity.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

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.