/
Documentation

Tutorial: Expense Receipt Scanner

Build an add-on that lets users photograph receipts, extracts data using OCR, and auto-creates expense records in SyncBooks. Includes its own backend and database.

What you'll learn

• Building an add-on with its own backend (Express.js)
• File upload handling
• Calling external APIs (OCR service)
• Creating SyncBooks records from extracted data
• Storing add-on config (API key settings)
• Multi-page add-on navigation
• Error handling and validation

Final result:

An add-on where users upload/photograph receipts → OCR extracts amount, vendor, date → user confirms → expense record is created in SyncBooks automatically.

Difficulty: IntermediateTime: 45 minutesScopes: expenses.write

Architecture

text
┌──────────────────────────────┐
│  Frontend (React + Vite)      │  ← SyncBooks iframe loads this
│  • Upload receipt image       │
│  • Show extracted data        │
│  • Confirm & create expense   │
└──────────────┬───────────────┘
               │
       ┌───────┴────────┐
       │                 │
       ▼                 ▼
┌─────────────┐   ┌──────────────────┐
│ SyncBooks   │   │ Your Backend      │  (Express.js)
│ API         │   │ • POST /scan      │
│             │   │ • Calls OCR API   │
│ Creates     │   │ • Returns parsed  │
│ expense     │   │   receipt data    │
│ records     │   │                   │
└─────────────┘   └──────────────────┘

Step 1: Set Up the Project

Terminal
# Create monorepo structure
mkdir expense-scanner && cd expense-scanner
mkdir frontend backend

# ─── Frontend ───
cd frontend
npm create vite@latest . -- --template react-ts
npm install @syncbooks/addon-sdk
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

# ─── Backend ───
cd ../backend
npm init -y
npm install express cors multer tesseract.js
npm install -D typescript @types/express @types/multer ts-node nodemon

Step 2: Build the Backend (OCR Server)

backend/src/server.ts
import express from 'express'
import cors from 'cors'
import multer from 'multer'
import Tesseract from 'tesseract.js'

const app = express()
app.use(cors())
app.use(express.json())

// File upload config (store in memory)
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } })

// ─── OCR Endpoint ──────────────────────────────────────────────
app.post('/api/scan', upload.single('receipt'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: 'No file uploaded' })
    }

    // Run OCR on the image
    const { data: { text } } = await Tesseract.recognize(req.file.buffer, 'eng')

    // Extract structured data from OCR text
    const parsed = parseReceipt(text)

    res.json({
      success: true,
      rawText: text,
      parsed: {
        vendor: parsed.vendor,
        amount: parsed.amount,
        date: parsed.date,
        description: parsed.description,
      },
    })
  } catch (error: any) {
    console.error('OCR Error:', error)
    res.status(500).json({ error: 'Failed to process receipt' })
  }
})

// ─── Simple Receipt Parser ─────────────────────────────────────
function parseReceipt(text: string) {
  const lines = text.split('\n').map(l => l.trim()).filter(Boolean)

  // Try to find amount (look for currency patterns)
  let amount = 0
  const amountRegex = /(?:GH[S₵C]|GHS|Total|Amount)[:\s]*([\d,]+\.?\d*)/i
  const amountMatch = text.match(amountRegex)
  if (amountMatch) {
    amount = parseFloat(amountMatch[1].replace(',', ''))
  } else {
    // Fallback: find largest number in the text
    const numbers = text.match(/\d+\.\d{2}/g)
    if (numbers) {
      amount = Math.max(...numbers.map(n => parseFloat(n)))
    }
  }

  // Try to find date
  let date = new Date().toISOString().split('T')[0]
  const dateRegex = /(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})/
  const dateMatch = text.match(dateRegex)
  if (dateMatch) {
    const [_, d, m, y] = dateMatch
    const year = y.length === 2 ? '20' + y : y
    date = `${year}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`
  }

  // Vendor: usually the first non-empty line
  const vendor = lines[0] || 'Unknown Vendor'

  // Description: combine first few lines
  const description = lines.slice(0, 3).join(' — ')

  return { vendor, amount, date, description }
}

const PORT = process.env.PORT || 3001
app.listen(PORT, () => console.log(`Receipt scanner backend running on :${PORT}`))
backend/package.json (scripts section)
"scripts": {
  "dev": "ts-node --esm src/server.ts",
  "start": "node dist/server.js",
  "build": "tsc"
}

Step 3: Build the Frontend

Entry Point

frontend/src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { SyncBooksProvider } from '@syncbooks/addon-sdk'
import App from './App'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <SyncBooksProvider>
    <App />
  </SyncBooksProvider>
)

Main App Component

frontend/src/App.tsx
import { useState } from 'react'
import { useSyncBooks, expenses, ui, useConfig, config } from '@syncbooks/addon-sdk'
import UploadStep from './components/UploadStep'
import ReviewStep from './components/ReviewStep'
import SettingsPage from './components/SettingsPage'

type Page = 'upload' | 'review' | 'settings'

interface ParsedReceipt {
  vendor: string
  amount: number
  date: string
  description: string
  rawText: string
}

export default function App() {
  const { ready, scopes } = useSyncBooks()
  const [page, setPage] = useState<Page>('upload')
  const [parsedData, setParsedData] = useState<ParsedReceipt | null>(null)
  const [creating, setCreating] = useState(false)

  if (!ready) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-600 border-t-transparent" />
      </div>
    )
  }

  if (!scopes.includes('expenses.write') && !scopes.includes('expenses.*') && !scopes.includes('*')) {
    return (
      <div className="p-8 text-center">
        <p className="text-red-600 font-semibold">Permission Required</p>
        <p className="text-sm text-gray-500 mt-1">This add-on needs "expenses.write" permission.</p>
      </div>
    )
  }

  // ─── Handle receipt scanned ─────────────────────────────────
  function handleScanned(data: ParsedReceipt) {
    setParsedData(data)
    setPage('review')
  }

  // ─── Handle create expense ──────────────────────────────────
  async function handleCreateExpense(data: { vendor: string; amount: number; date: string; description: string; category: string }) {
    setCreating(true)
    try {
      await expenses.create({
        description: `${data.vendor} — ${data.description}`,
        amount: data.amount,
        date: data.date,
        category: data.category,
        notes: 'Created from receipt scan',
      })
      ui.notify('Expense created from receipt! ✓', 'success')
      setParsedData(null)
      setPage('upload')
    } catch (error: any) {
      ui.notify(`Failed: ${error.message}`, 'error')
    } finally {
      setCreating(false)
    }
  }

  return (
    <div className="min-h-screen bg-white">
      {/* Navigation */}
      <nav className="border-b px-4 py-2 flex items-center justify-between">
        <h1 className="font-bold text-sm">📸 Receipt Scanner</h1>
        <div className="flex gap-1">
          <button
            onClick={() => setPage('upload')}
            className={`px-3 py-1 text-xs rounded-md ${page === 'upload' || page === 'review' ? 'bg-emerald-100 text-emerald-700' : 'text-gray-500 hover:bg-gray-100'}`}
          >
            Scan
          </button>
          <button
            onClick={() => setPage('settings')}
            className={`px-3 py-1 text-xs rounded-md ${page === 'settings' ? 'bg-emerald-100 text-emerald-700' : 'text-gray-500 hover:bg-gray-100'}`}
          >
            Settings
          </button>
        </div>
      </nav>

      {/* Content */}
      <div className="p-4 sm:p-6">
        {page === 'upload' && <UploadStep onScanned={handleScanned} />}
        {page === 'review' && parsedData && (
          <ReviewStep
            data={parsedData}
            onConfirm={handleCreateExpense}
            onBack={() => setPage('upload')}
            loading={creating}
          />
        )}
        {page === 'settings' && <SettingsPage />}
      </div>
    </div>
  )
}

Upload Step

frontend/src/components/UploadStep.tsx
import { useState, useRef } from 'react'
import { useConfig } from '@syncbooks/addon-sdk'

interface Props {
  onScanned: (data: any) => void
}

export default function UploadStep({ onScanned }: Props) {
  const [uploading, setUploading] = useState(false)
  const [error, setError] = useState('')
  const fileRef = useRef<HTMLInputElement>(null)
  const { data: settings } = useConfig()

  // Backend URL from config (or default)
  const backendUrl = settings?.backendUrl || 'https://your-backend.railway.app'

  async function handleFile(file: File) {
    if (!file.type.startsWith('image/')) {
      setError('Please upload an image file (JPG, PNG)')
      return
    }
    if (file.size > 5 * 1024 * 1024) {
      setError('File too large (max 5MB)')
      return
    }

    setUploading(true)
    setError('')

    try {
      const formData = new FormData()
      formData.append('receipt', file)

      const res = await fetch(`${backendUrl}/api/scan`, {
        method: 'POST',
        body: formData,
      })

      if (!res.ok) throw new Error('Scan failed')
      const result = await res.json()

      onScanned({
        vendor: result.parsed.vendor,
        amount: result.parsed.amount,
        date: result.parsed.date,
        description: result.parsed.description,
        rawText: result.rawText,
      })
    } catch (err: any) {
      setError(err.message || 'Failed to scan receipt')
    } finally {
      setUploading(false)
    }
  }

  function handleDrop(e: React.DragEvent) {
    e.preventDefault()
    const file = e.dataTransfer.files[0]
    if (file) handleFile(file)
  }

  return (
    <div className="max-w-md mx-auto">
      <h2 className="text-lg font-semibold mb-1">Scan a Receipt</h2>
      <p className="text-sm text-gray-500 mb-4">
        Upload or photograph a receipt and we'll extract the details automatically.
      </p>

      {/* Drop Zone */}
      <div
        onDragOver={(e) => e.preventDefault()}
        onDrop={handleDrop}
        onClick={() => fileRef.current?.click()}
        className={`border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors ${
          uploading ? 'border-emerald-300 bg-emerald-50' : 'border-gray-300 hover:border-emerald-400 hover:bg-emerald-50/50'
        }`}
      >
        {uploading ? (
          <>
            <div className="h-10 w-10 mx-auto animate-spin rounded-full border-2 border-emerald-600 border-t-transparent" />
            <p className="text-sm text-emerald-700 mt-3">Scanning receipt...</p>
            <p className="text-xs text-emerald-600">This may take a few seconds</p>
          </>
        ) : (
          <>
            <p className="text-3xl mb-2">📸</p>
            <p className="text-sm font-medium text-gray-700">Drop receipt image here</p>
            <p className="text-xs text-gray-500 mt-1">or click to browse (JPG, PNG, max 5MB)</p>
          </>
        )}
      </div>

      <input
        ref={fileRef}
        type="file"
        accept="image/*"
        capture="environment"
        onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
        className="hidden"
      />

      {error && (
        <p className="mt-3 text-sm text-red-600 bg-red-50 p-2 rounded">{error}</p>
      )}

      <p className="text-xs text-gray-400 mt-4 text-center">
        Supports receipts from any store. Best results with clear, well-lit photos.
      </p>
    </div>
  )
}

Review Step

frontend/src/components/ReviewStep.tsx
import { useState } from 'react'

interface Props {
  data: { vendor: string; amount: number; date: string; description: string; rawText: string }
  onConfirm: (data: any) => void
  onBack: () => void
  loading: boolean
}

const CATEGORIES = ['office', 'travel', 'meals', 'utilities', 'supplies', 'transport', 'other']

export default function ReviewStep({ data, onConfirm, onBack, loading }: Props) {
  const [form, setForm] = useState({
    vendor: data.vendor,
    amount: data.amount,
    date: data.date,
    description: data.description,
    category: 'office',
  })

  return (
    <div className="max-w-md mx-auto">
      <h2 className="text-lg font-semibold mb-1">Review & Confirm</h2>
      <p className="text-sm text-gray-500 mb-4">Verify the extracted data before creating the expense.</p>

      <div className="space-y-3">
        <div>
          <label className="text-xs font-medium text-gray-700">Vendor</label>
          <input value={form.vendor} onChange={e => setForm({...form, vendor: e.target.value})} className="mt-1 w-full h-9 px-3 text-sm border rounded-md" />
        </div>
        <div>
          <label className="text-xs font-medium text-gray-700">Amount (GHS)</label>
          <input type="number" step="0.01" value={form.amount} onChange={e => setForm({...form, amount: +e.target.value})} className="mt-1 w-full h-9 px-3 text-sm border rounded-md" />
        </div>
        <div>
          <label className="text-xs font-medium text-gray-700">Date</label>
          <input type="date" value={form.date} onChange={e => setForm({...form, date: e.target.value})} className="mt-1 w-full h-9 px-3 text-sm border rounded-md" />
        </div>
        <div>
          <label className="text-xs font-medium text-gray-700">Category</label>
          <select value={form.category} onChange={e => setForm({...form, category: e.target.value})} className="mt-1 w-full h-9 px-3 text-sm border rounded-md">
            {CATEGORIES.map(c => <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
          </select>
        </div>
        <div>
          <label className="text-xs font-medium text-gray-700">Description</label>
          <input value={form.description} onChange={e => setForm({...form, description: e.target.value})} className="mt-1 w-full h-9 px-3 text-sm border rounded-md" />
        </div>
      </div>

      {/* Raw OCR Text (collapsible) */}
      <details className="mt-4">
        <summary className="text-xs text-gray-400 cursor-pointer">View raw OCR text</summary>
        <pre className="mt-2 p-2 bg-gray-50 rounded text-[10px] text-gray-500 overflow-auto max-h-32">{data.rawText}</pre>
      </details>

      {/* Actions */}
      <div className="flex gap-2 mt-6">
        <button onClick={onBack} className="flex-1 py-2 text-sm border rounded-md hover:bg-gray-50" disabled={loading}>
          ← Back
        </button>
        <button onClick={() => onConfirm(form)} disabled={loading || !form.amount} className="flex-1 py-2 text-sm bg-emerald-600 text-white rounded-md hover:bg-emerald-700 disabled:opacity-50">
          {loading ? 'Creating...' : 'Create Expense ✓'}
        </button>
      </div>
    </div>
  )
}

Settings Page

frontend/src/components/SettingsPage.tsx
import { useState, useEffect } from 'react'
import { useConfig, config, ui } from '@syncbooks/addon-sdk'

export default function SettingsPage() {
  const { data: saved, loading } = useConfig()
  const [backendUrl, setBackendUrl] = useState('')
  const [saving, setSaving] = useState(false)

  useEffect(() => {
    if (saved?.backendUrl) setBackendUrl(saved.backendUrl)
  }, [saved])

  async function handleSave() {
    setSaving(true)
    await config.set({ ...saved, backendUrl })
    setSaving(false)
    ui.notify('Settings saved!', 'success')
  }

  if (loading) return <p className="text-sm text-gray-500">Loading settings...</p>

  return (
    <div className="max-w-md mx-auto">
      <h2 className="text-lg font-semibold mb-1">Settings</h2>
      <p className="text-sm text-gray-500 mb-4">Configure your receipt scanner backend.</p>

      <div className="space-y-4">
        <div>
          <label className="text-xs font-medium text-gray-700">Backend URL</label>
          <input
            value={backendUrl}
            onChange={e => setBackendUrl(e.target.value)}
            placeholder="https://your-backend.railway.app"
            className="mt-1 w-full h-9 px-3 text-sm border rounded-md"
          />
          <p className="text-xs text-gray-400 mt-1">URL of your OCR backend server</p>
        </div>

        <button onClick={handleSave} disabled={saving} className="px-4 py-2 text-sm bg-emerald-600 text-white rounded-md hover:bg-emerald-700 disabled:opacity-50">
          {saving ? 'Saving...' : 'Save Settings'}
        </button>
      </div>
    </div>
  )
}

Step 4: Deploy Both

Deploy Backend (Railway)

Terminal
cd backend
npm run build
railway init
railway up
# Note your URL: https://expense-scanner-backend.railway.app

Deploy Frontend (Vercel)

Terminal
cd frontend
npm run build
vercel deploy --prod
# Note your URL: https://expense-scanner.vercel.app

Step 5: Register & Configure

  1. Go to /developers → New Addon
  2. Set Launch URL: https://expense-scanner.vercel.app
  3. Permissions: expenses.write
  4. Placements: navigation.item
  5. Create → Install on your org → Open the addon
  6. Go to Settings tab → enter your backend URL
  7. Go to Scan tab → upload a receipt → confirm → expense created!

Key Patterns Used

  • Own backend — Express.js handles OCR processing (separate from SyncBooks)
  • Config storage — backend URL saved per-org using config.set()
  • File upload — FormData + fetch to your own backend
  • Multi-page — local state-based navigation (upload → review → settings)
  • SyncBooks writeexpenses.create() to create the record
  • Native components — plain HTML inputs (not SDK components) — proving you can use anything

Extend this further

Ideas: add receipt history (your own database), support camera capture on mobile, use Google Vision API for better OCR, auto-categorize using AI, bulk upload multiple receipts.