AINode.jsAutomation

Receipt Scanner & Expense Tracker

TT
TopicTrick Team
Receipt Scanner & Expense Tracker

Receipt Scanner & Expense Tracker

Manually entering receipts into expense software is one of the most tedious business tasks. This tool photographs or uploads a receipt, extracts all the data with GPT-4o Vision, and logs the expense automatically — with merchant, date, total, category, and line items.

This is Tool 19 of the Build 50 AI Automation Tools course.


What You'll Build

  • POST /scan — upload a receipt image, extract data, save to database
  • GET /expenses — query expenses by date range, category, or amount
  • GET /expenses/export — export as CSV for accounting software

Setup

bash
mkdir receipt-scanner && cd receipt-scanner
npm init -y
npm install express multer openai better-sqlite3 dotenv
bash
# .env
OPENAI_API_KEY=sk-your-key-here
PORT=3000

Database Setup

js
// src/db.js
import Database from 'better-sqlite3';

const db = new Database('expenses.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS expenses (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    merchant TEXT NOT NULL,
    date TEXT,
    total REAL NOT NULL,
    currency TEXT DEFAULT 'USD',
    tax REAL,
    tip REAL,
    category TEXT,
    paymentMethod TEXT,
    notes TEXT,
    lineItems TEXT, -- JSON string
    rawData TEXT,   -- Full AI response JSON
    imageHash TEXT,
    scannedAt DATETIME DEFAULT CURRENT_TIMESTAMP
  );
`);

export const queries = {
  insertExpense: db.prepare(`
    INSERT INTO expenses (merchant, date, total, currency, tax, tip, category, paymentMethod, notes, lineItems, rawData)
    VALUES (@merchant, @date, @total, @currency, @tax, @tip, @category, @paymentMethod, @notes, @lineItems, @rawData)
  `),
  getExpenses: db.prepare('SELECT * FROM expenses WHERE scannedAt BETWEEN ? AND ? ORDER BY date DESC'),
  getByCategory: db.prepare('SELECT * FROM expenses WHERE category = ? ORDER BY date DESC'),
  getTotal: db.prepare('SELECT SUM(total) as total, COUNT(*) as count FROM expenses WHERE scannedAt BETWEEN ? AND ?'),
};

export default db;

Receipt Scanning Service

js
// src/services/receiptService.js
import OpenAI from 'openai';
import { queries } from '../db.js';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const CATEGORIES = [
  'meals_entertainment', 'travel', 'accommodation', 'software_subscriptions',
  'office_supplies', 'transportation', 'utilities', 'marketing', 'healthcare',
  'education', 'equipment', 'fuel', 'miscellaneous'
];

export async function scanReceipt(buffer, mimetype) {
  const dataUrl = `data:${mimetype};base64,${buffer.toString('base64')}`;

  const response = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages: [
      {
        role: 'system',
        content: `You are an expert expense management system. Extract all data from this receipt image.
Available categories: ${CATEGORIES.join(', ')}

Return ONLY a JSON object — no markdown:
{
  "merchant": "business name",
  "address": "string or null",
  "date": "YYYY-MM-DD format or null",
  "time": "HH:MM format or null",
  "lineItems": [
    { "description": "string", "quantity": number or null, "unitPrice": number or null, "total": number }
  ],
  "subtotal": number or null,
  "tax": number or null,
  "taxRate": "string or null (e.g. '8.5%')",
  "tip": number or null,
  "discount": number or null,
  "total": number,
  "currency": "3-letter code (e.g. USD, EUR, GBP)",
  "paymentMethod": "cash | credit_card | debit_card | digital_wallet | unknown",
  "receiptNumber": "string or null",
  "category": "one of the available categories",
  "notes": "any other notable information (e.g. 'Business lunch with client')",
  "confidence": 0.0-1.0
}
If the image is not a receipt, set merchant to null and confidence to 0.`,
      },
      {
        role: 'user',
        content: [
          { type: 'text', text: 'Extract all expense data from this receipt:' },
          { type: 'image_url', image_url: { url: dataUrl, detail: 'high' } },
        ],
      },
    ],
    temperature: 0,
    response_format: { type: 'json_object' },
  });

  const extracted = JSON.parse(response.choices[0].message.content);

  if (!extracted.merchant || extracted.confidence < 0.3) {
    throw new Error('Could not extract receipt data — image may not be a receipt or quality is too low');
  }

  // Save to database
  const expenseId = queries.insertExpense.run({
    merchant:      extracted.merchant,
    date:          extracted.date,
    total:         extracted.total,
    currency:      extracted.currency || 'USD',
    tax:           extracted.tax,
    tip:           extracted.tip,
    category:      extracted.category,
    paymentMethod: extracted.paymentMethod,
    notes:         extracted.notes,
    lineItems:     JSON.stringify(extracted.lineItems || []),
    rawData:       JSON.stringify(extracted),
  }).lastInsertRowid;

  return { id: expenseId, ...extracted };
}

export function getExpenses(startDate, endDate, category = null) {
  if (category) return queries.getByCategory.all(category);
  const start = startDate || '2000-01-01';
  const end = endDate || '2099-12-31';
  return queries.getExpenses.all(start, end);
}

Server

js
// src/server.js
import 'dotenv/config';
import express from 'express';
import multer from 'multer';
import { scanReceipt, getExpenses } from './services/receiptService.js';
import { queries } from './db.js';

const app = express();
app.use(express.json());
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 10 * 1024 * 1024 },
  fileFilter: (_req, file, cb) =>
    ['image/jpeg', 'image/png', 'image/webp', 'image/heic'].includes(file.mimetype)
      ? cb(null, true) : cb(new Error('Image files only')),
});

app.post('/scan', upload.single('receipt'), async (req, res, next) => {
  try {
    if (!req.file) return res.status(400).json({ error: 'No receipt image uploaded' });
    const result = await scanReceipt(req.file.buffer, req.file.mimetype);
    res.json({ success: true, expense: result });
  } catch (err) { next(err); }
});

app.get('/expenses', (req, res) => {
  const { startDate, endDate, category } = req.query;
  const expenses = getExpenses(startDate, endDate, category);
  const total = queries.getTotal.get(startDate || '2000-01-01', endDate || '2099-12-31');
  res.json({ success: true, count: expenses.length, totalSpend: total.total, expenses });
});

app.get('/expenses/export', (req, res) => {
  const { startDate, endDate } = req.query;
  const expenses = getExpenses(startDate, endDate);
  const csv = ['Date,Merchant,Category,Total,Currency,Tax,Tip,Payment Method']
    .concat(expenses.map(e =>
      `"${e.date}","${e.merchant}","${e.category}","${e.total}","${e.currency}","${e.tax || ''}","${e.tip || ''}","${e.paymentMethod}"`
    )).join('\n');
  res.setHeader('Content-Type', 'text/csv');
  res.setHeader('Content-Disposition', `attachment; filename="expenses-${startDate || 'all'}.csv"`);
  res.send(csv);
});

app.use((err, _req, res, _next) => res.status(500).json({ error: err.message }));
app.listen(process.env.PORT ?? 3000, () => console.log('Receipt Scanner running'));

Testing

bash
# Scan a receipt
curl -X POST http://localhost:3000/scan \
  -F "receipt=@restaurant-receipt.jpg"

# Get all expenses this month
curl "http://localhost:3000/expenses?startDate=2025-11-01&endDate=2025-11-30"

# Export as CSV
curl "http://localhost:3000/expenses/export?startDate=2025-11-01" \
  -o november-expenses.csv

Build 50 AI Automation Tools — Tool 19 of 50

Receipt scanning is live. Continue to Tool 20 to build a screenshot-to-code converter with GPT-4o Vision.


    Summary

    • GPT-4o Vision handles skewed, low-quality, and printed receipt photos where traditional OCR fails
    • SQLite with better-sqlite3 provides zero-setup persistent storage — no database server required
    • Confidence threshold prevents bad extractions from polluting the expense database
    • The CSV export maps directly to accounting software import formats
    • Extend by adding category budget limits and alerting when spending exceeds thresholds per category

    Continue to Tool 20: Screenshot to Code Converter →