All projects

Expense Tracker

Expense Tracker

· Source

An app for logging expenses. Add what you spent and it keeps a running list you can browse, edit and delete. To save typing, photograph a receipt and it fills in the details for you.

What it does

The expense list, showing merchant, category, date and amount for each entry
The list
The add-expense form with amount, merchant, category, date and note fields
Adding an expense

How it’s built

Ionic and Angular for the screens, wrapped by Capacitor so the same code runs in a browser and as a real Android or iOS app. Expenses live in SQLite on the device itself: on a phone that is the real database, and in a browser it is a WASM build backed by browser storage.

Nothing is sent anywhere to save an expense. The only feature that touches the internet is receipt scanning, and only when you use it.

Decisions, and what they cost

Money is stored as whole cents

€12.34 is saved as 1234, an integer, never as 12.34.

Decimals in binary floating point cannot represent most money values exactly. The classic demonstration is that 0.1 + 0.2 does not equal 0.3, and a tracker that adds up hundreds of amounts would drift by a cent here and there until the totals stopped matching what the person actually spent.

Integers have none of that problem. The cost is that every display has to divide by 100 and every input has to multiply, and if you forget once you show someone a €4,215 grocery shop. It is a small, contained cost in exchange for arithmetic that is exactly right.

The data never leaves the device

There is no account and no server. That means nothing to run, nothing to pay for, no privacy policy to write, and it works with no signal. Bank statements are personal, and the safest place for them is the one place they have to be.

The cost is real: your expenses live on exactly one phone. Lose it and they are gone, and there is no way to see them on a second device. That is the trade I would revisit first.

Gemini reads receipts, not an OCR library

The first version used Tesseract, a classic OCR library that runs offline and free. It works, and it gives you the wrong thing: a wall of raw text. Turning TOTAL 42.15 buried in forty lines of shop address and VAT numbers into an amount, a merchant, a date and a category is the actual problem, and OCR does not solve it.

So the photo now goes to Gemini instead. The model is gemini-2.5-flash, chosen because it reads images, is fast enough to wait on while a form sits open, and is free at this volume.

1. Split the photo. The camera hands back a data URL, one string holding the format and the image together. The API wants them separately.

// "data:image/jpeg;base64,XXXX"
const [header, base64] = imageDataUrl.split(',');
const mimeType = header.match(/data:(.*?);/)?.[1] ?? 'image/jpeg';

2. Send the photo and the instruction as one message. They travel as two parts of the same request, so the words are about that image.

parts: [
  { inline_data: { mime_type: mimeType, data: base64 } },
  { text:
      'This is a photo of a receipt. Extract the total amount paid ' +
      '(as a number), the merchant/shop name, and the date (formatted ' +
      'YYYY-MM-DD). Also classify it into exactly one of these categories: ' +
      categories.join(', ') + '. ' +
      'If a field is missing, use null for amount and "" for text.',
  },
]

The prompt is deliberately dull. It names each field, pins the date format so 03/04 cannot be ambiguous, and says what to do when something is missing. That last line is the difference between an honest gap and an invented total.

3. Fix the shape of the reply.

generationConfig: {
  response_mime_type: 'application/json',
  response_schema: {
    type: 'object',
    properties: {
      amount:   { type: 'number', nullable: true },
      merchant: { type: 'string' },
      date:     { type: 'string' },
      category: { type: 'string', enum: categories },
    },
    required: ['amount', 'merchant', 'date', 'category'],
  },
}

JSON only, so there is no “Sure! Here’s what I found:” to strip off. amount comes back as a number I can do arithmetic on, not the string "€42.15". nullable lets it admit the total was unreadable. required means all four keys always arrive. And enum: categories is the app’s own list, so the answer can only ever be a category the form offers.

4. Read it back. The JSON arrives as text nested in the response, so it gets pulled out and parsed once.

const jsonText = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}';
return JSON.parse(jsonText) as ReceiptData;

5. Fill in the form. Each field is only set if it actually came back, so a value Gemini couldn’t read is left alone rather than wiped. And if the scan fails altogether, nothing touches the form: you just fill it in by hand, the way you would have without the photo at all.

try {
  const data = await this.ocr.extractReceipt(this.photoDataUrl, this.categories);
  if (data.amount != null) this.amountEuros = data.amount;
  if (data.merchant)       this.merchant    = data.merchant;
  if (data.date)           this.date        = data.date;
  if (data.category)       this.category    = data.category;
} catch (err) {
  console.error('Receipt scan failed:', err);
} finally {
  this.isScanning = false;
}

The trade-off is that Gemini needs the internet. It runs in the cloud, so scanning a receipt is the one feature that needs a connection and an API key. Everything else in the app works offline. For when there is no connection, I kept Tesseract in as an on-device backup: it reads the text off the receipt locally, though it cannot do Gemini’s neat field-by-field fill.

The parts that were actually hard

The browser has no SQLite

On a phone, SQLite is just there. In a browser it does not exist, so the same code has to run against a WASM build that stores itself in browser storage, which has to be set up before the app starts and flushed after every write.

Asking twice, on purpose

The category list is handed to the model twice: once in the wording of the prompt, and again as the enum in the schema. That is redundant on purpose.

The prompt is what makes the model classify sensibly, since it tells the model what the job is. The enum is what makes a wrong answer impossible: the schema only allows values from the list, so the model literally cannot hand back “Groceries” when the app’s category is “Food”. The prompt guides; the schema guarantees. For something that has to match a dropdown exactly, I did not want to lean on the guiding alone.

Both read from the same array the form uses, so adding a category updates the prompt, the schema and the dropdown at once.

What’s next