I wanted an expense tracker that required zero manual input. Every time I tap my phone to pay, the app should capture the transaction, categorise it, and sync it to a spreadsheet - without me doing anything. That’s what expenses does.
It’s a native iOS app built with SwiftUI and SwiftData. The core insight that makes it work: banking apps push notifications to your phone for every transaction. Those notifications contain the merchant name and amount. With notification access, you can parse them in real time.
The Notification Parsing Problem
Banking notification formats are wildly inconsistent. Apple Pay notifications look different from Monzo, which looks different from Starling, which looks different from Amex. And the same bank’s format can change between app versions.
The solution: configurable regex patterns with named capture groups, stored in the app and editable by the user.
struct NotificationPattern: Codable, Identifiable {
var name: String // "Apple Pay (£)"
var regex: String // named-group regex
var groupMapping: [String: String] // maps group names to fields
var bundleIdentifiers: [String] // restrict to specific apps
}
The Apple Pay pattern:
(?<merchant>.+?)\s+£(?<amount>[\d,]+\.?\d*)
The Monzo pattern:
You (?:paid|spent) (?<currency>[£$€])(?<amount>[\d,]+\.?\d*) at (?<merchant>.+)
When a notification arrives, the NotificationParser tries each enabled pattern in priority order and returns the first match. The parser is @MainActor and runs on every notification the system delivers to the app.
SwiftData Model
The data model is simple:
@Model
final class Transaction {
var id: UUID
var timestamp: Date
var amount: Double
var currency: String
var merchant: String
var category: TransactionCategory
var source: TransactionSource // walletNotification, manual, shortcut, sheets
var syncedToSheets: Bool
var syncError: String?
var customCategoryKey: String? // user-created category override
var externalTransactionId: String? // deduplication for Sheets imports
}
SwiftData handles persistence, iCloud sync (if enabled), and migrations. I added a legacyMapping to TransactionCategory to handle records saved before category renames - SwiftData doesn’t provide migration hooks for enum raw value changes, so the custom Decodable init handles it.
AI Categorisation with Claude
The categorisation pipeline is two-stage:
Stage 1 - keyword rules: Check the merchant name against a dictionary of keywords per category. “starbucks” → Coffee, “tesco” → Groceries, “uber” → Transport. This handles ~85% of transactions instantly with no API call.
Stage 2 - Claude Haiku: For merchants not matched by any keyword rule, call the Anthropic API with a minimal prompt:
let prompt = """
You are a transaction categoriser. Given a merchant name, respond with ONLY \
the category name from this list - no explanation, no punctuation, nothing else:
\(categoryList)
Merchant: \(merchant)
"""
The model (claude-haiku-4-5-20251001) returns a single word - the category name. The app matches it to the enum case and assigns it. The full API round-trip takes ~300ms and costs a fraction of a cent.
The two-stage approach is important: most transactions are categorised locally with zero latency and zero cost. The AI is a fallback for edge cases, not the default path.
Google Sheets Sync
Every transaction syncs to a Google Sheet via a Google Apps Script web app. The script accepts POST requests with a JSON payload and upserts rows using a transaction ID as the key:
let payload: [String: Any] = [
"action": "upsert",
"txId": transaction.id.uuidString,
"name": transaction.merchant,
"amount": String(format: "%.2f", transaction.amount),
"currency": transaction.currency,
"category": effectiveCategoryName,
"cardOrPass": transaction.source == .walletNotification ? "Pass" : "Card"
]
The upsert action means syncing the same transaction twice is idempotent - useful when retrying after a network failure. Failed syncs store the error on the transaction and can be batch-retried from the UI.
Multi-Currency Support
The app converts foreign amounts to GBP (configurable) using the fawazahmed0/currency-api - a free, open-source exchange rate API served via jsDelivr CDN. Rates are fetched per calendar day per base currency and cached to disk. The in-memory cache avoids redundant disk reads within a session.
func convert(amount: Double, from currency: String, date: Date) async -> Double? {
let mainCurrency = UserDefaults.standard.string(forKey: "mainCurrency") ?? "GBP"
guard currency != mainCurrency else { return amount }
let rates = await ratesFor(date: date, base: mainCurrency)
guard let rate = rates[currency.lowercased()], rate > 0 else { return nil }
return amount / rate
}
Home Screen Widget
The ExpensesWidget target shows a summary of recent spend on the home screen. It reads from the shared SwiftData container and updates on a timeline schedule. The widget intentionally shows only totals - the home screen isn’t the right place for transaction detail.
What I’d Do Differently
Keychain instead of UserDefaults for secrets. The Anthropic API key and Sheets secret are currently stored in UserDefaults, which is plaintext on disk. They should be in the Keychain - it’s a straightforward migration but I haven’t prioritised it yet.
App Group for widget data sharing. The widget currently reads from the main app’s SwiftData container via a shared App Group. This works but requires the main app to have written at least once. A dedicated widget-only data store would be more robust.
Categorisation feedback loop. When the user corrects a categorisation, that correction should feed back into the keyword rules. Currently you have to go to Settings → Category Rules to add a keyword manually.
The project is on GitHub.