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.
Architecture
┌──────────────────────────────┐
│ 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
# 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 nodemonStep 2: Build the Backend (OCR Server)
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}`))"scripts": {
"dev": "ts-node --esm src/server.ts",
"start": "node dist/server.js",
"build": "tsc"
}Step 3: Build the Frontend
Entry Point
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
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
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
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
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)
cd backend
npm run build
railway init
railway up
# Note your URL: https://expense-scanner-backend.railway.appDeploy Frontend (Vercel)
cd frontend
npm run build
vercel deploy --prod
# Note your URL: https://expense-scanner.vercel.appStep 5: Register & Configure
- Go to /developers → New Addon
- Set Launch URL:
https://expense-scanner.vercel.app - Permissions:
expenses.write - Placements:
navigation.item - Create → Install on your org → Open the addon
- Go to Settings tab → enter your backend URL
- 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 write —
expenses.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.