← All posts
· 4 min read ·
SwiftSwiftUIiOSClaude AIOpen Source

Building Expenses: An iOS Tracker That Categorises Itself with Claude AI

How I built a native iOS expense tracker in SwiftUI and SwiftData that listens to banking notifications, parses transactions automatically, uses Claude Haiku for AI categorisation, and syncs to Google Sheets.

iPhone on a desk showing a finance application

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.

← All posts