# How to Build an Expense Tracking with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable free tier or higher
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
Create an expense tracking schema in Supabase and set up storage.

Tables:
- expense_categories: id, name, monthly_limit (decimal, nullable), icon (text, emoji)
- expense_policies: id, category_id (references expense_categories), max_per_transaction (decimal), requires_receipt_above (decimal, e.g. 25.00), created_at
- 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
- profiles: id (references auth.users), role ('employee' | 'manager' | 'finance'), manager_id (references auth.users, nullable), department

Storage:
- Create a private bucket named 'receipts'
- 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).

RLS on expenses:
- Employees SELECT/INSERT/UPDATE (draft only) their own rows
- Managers SELECT expenses from employees in their team (manager_id = auth.uid() in profiles)
- Managers UPDATE status, approved_by, rejection_reason
- 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.

```
Build an expense submission form at src/pages/NewExpense.tsx.

Requirements:
- Form fields (react-hook-form + zod):
  - Category Select (fetch from expense_categories, show icon + name)
  - Merchant name Input
  - Amount Input (decimal number)
  - Currency Select (USD/EUR/GBP)
  - Expense date DatePicker (default today)
  - Description Textarea (optional)
  - Receipt upload: a file Input accepting image/* and application/pdf. Show a preview thumbnail for images.
- Upload logic when a file is selected:
  - Call supabase.storage.from('receipts').upload(path, file) where path = `receipts/${userId}/${Date.now()}-${file.name}`
  - Show a progress indicator during upload
  - Store the path in form state, not the full URL
- Submit behavior:
  - First call the validate-expense Edge Function with category_id and amount
  - If policy_violated, show a yellow Alert: 'This expense exceeds the $X limit for [category]. It will be flagged for manager review.'
  - Allow the user to proceed anyway
  - INSERT the expense with status='submitted' and policy_violated flag
  - Show a success Toast and navigate to /expenses
- 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
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

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

    const { category_id, amount } = await req.json()

    const { data: policy } = await supabase
      .from('expense_policies')
      .select('max_per_transaction, requires_receipt_above, expense_categories(name)')
      .eq('category_id', category_id)
      .single()

    if (!policy) {
      return new Response(JSON.stringify({ violated: false }), { headers: corsHeaders })
    }

    const violations: string[] = []

    if (policy.max_per_transaction && amount > policy.max_per_transaction) {
      violations.push(`Exceeds the $${policy.max_per_transaction} per-transaction limit for ${(policy.expense_categories as any)?.name}`)
    }

    return new Response(JSON.stringify({
      violated: violations.length > 0,
      violations,
      requires_receipt: amount >= (policy.requires_receipt_above ?? 0),
    }), { headers: corsHeaders })
  } catch (err) {
    return new Response(JSON.stringify({ violated: false }), { headers: corsHeaders })
  }
})
```

> 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.

```
Build a manager approval page at src/pages/ApprovalQueue.tsx.

Requirements:
- Fetch all expenses WHERE status='submitted' and the expense belongs to an employee whose manager_id = current user
- Display as a list of Cards (not a table — cards allow more detail)
- Each Card shows:
  - Employee name and avatar/initials
  - Merchant name, amount (formatted as currency), category icon + name
  - Expense date
  - Description
  - Policy violation Alert if policy_violated = true (yellow, show policy_note)
  - 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.
  - Two Buttons: 'Approve' (green) and 'Reject' (destructive outline)
- Clicking Approve: UPDATE expense status='approved', approved_by=auth.uid(), approved_at=now(). Remove from queue.
- Clicking Reject: open a Dialog with a required Textarea for rejection_reason. On submit, UPDATE status='rejected', rejection_reason. Remove from queue.
- Show a badge count of pending items in the page heading
- 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.

```
Build an expense list page at src/pages/Expenses.tsx.

Requirements:
- Detect user role from profiles table
- Employee view: show own expenses only
- Manager view: show own + team expenses
- Finance view: show all expenses with employee name column
- 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
- Clicking a row opens a Sheet with full expense details and receipt preview
- Filter bar: status multi-select, category multi-select, date range Popover, search Input for merchant name
- Total shown below table: filtered total amount, count of records
- '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'
- 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 example

File: `src/components/ReceiptUpload.tsx`

```typescript
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Paperclip, X, Loader2 } from 'lucide-react'
import { supabase } from '@/integrations/supabase/client'

type ReceiptUploadProps = {
  userId: string
  onUpload: (path: string) => void
  onRemove: () => void
  currentPath?: string
}

export function ReceiptUpload({ userId, onUpload, onRemove, currentPath }: ReceiptUploadProps) {
  const [uploading, setUploading] = useState(false)
  const [preview, setPreview] = useState<string | null>(null)
  const [error, setError] = useState<string | null>(null)
  const inputRef = useRef<HTMLInputElement>(null)

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    if (file.size > 10 * 1024 * 1024) {
      setError('File must be under 10MB')
      return
    }

    setError(null)
    setUploading(true)

    // Show local preview immediately
    if (file.type.startsWith('image/')) {
      const reader = new FileReader()
      reader.onloadend = () => setPreview(reader.result as string)
      reader.readAsDataURL(file)
    }

    const path = `receipts/${userId}/${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`
    const { error: uploadError } = await supabase.storage
      .from('receipts')
      .upload(path, file, { contentType: file.type })

    if (uploadError) {
      setError('Upload failed. Please try again.')
      setPreview(null)
    } else {
      onUpload(path)
    }
    setUploading(false)
  }

  const remove = () => {
    setPreview(null)
    onRemove()
    if (inputRef.current) inputRef.current.value = ''
  }

  if (currentPath || preview) {
    return (
      <div className="flex items-center gap-2 rounded-md border p-2">
        {preview && <img src={preview} alt="Receipt" className="h-12 w-12 rounded object-cover" />}
        <span className="flex-1 truncate text-sm text-muted-foreground">{currentPath?.split('/').pop()}</span>
        <Button type="button" variant="ghost" size="icon" onClick={remove}>
          <X className="h-4 w-4" />
        </Button>
      </div>
    )
  }

  return (
    <div>
      <input ref={inputRef} type="file" accept="image/*,application/pdf" className="hidden" onChange={handleFileChange} />
      <Button type="button" variant="outline" className="w-full" onClick={() => inputRef.current?.click()} disabled={uploading}>
        {uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Paperclip className="mr-2 h-4 w-4" />}
        {uploading ? 'Uploading...' : 'Attach Receipt'}
      </Button>
      {error && <p className="mt-1 text-xs text-destructive">{error}</p>}
    </div>
  )
}
```

## Common mistakes

- **Using public storage bucket for receipts** — Receipts contain sensitive financial information. A public bucket means anyone with the URL can view any receipt, and URLs are often logged in browser history and analytics tools. Fix: 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.
- **Storing the signed URL in the database instead of the storage path** — Signed URLs expire. If you store the URL, it becomes invalid after 1 hour. Subsequent attempts to view the receipt fail with a 403 error. Fix: 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.
- **Allowing employees to approve their own expenses** — An employee who is also a manager could approve their own expense submissions, bypassing the review process entirely. Fix: 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.
- **Not validating the file type and size on the server side** — Client-side file type checks can be bypassed. A malicious user could upload a large file or an executable disguised with an image extension. Fix: 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.

## 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.

---

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