Published on

How I track my money with Hermes: the flow, not the numbers

Authors
On this page

I do not open banking apps to see where I stand. A set of small scripts reads the alerts my banks already send, writes them into a sheet, and a Hermes job drafts a summary each morning.

I am writing about the flow and the mistakes only. No balances, no bank names, no account details.

The flow

8:00  cron job: sync messages
        SMS alerts      -> json files, one folder per month
        email alerts    -> same
        family chat     -> same (manual money notes)

      txn-sync.py (run on demand)
        parse each source -> Transaction rows
        hash each row, drop duplicates
        append new rows to a Google Sheet, one tab per month
        refresh the Snapshots tab (balances, totals)

9:30  cron job: finance summary
        read the snapshot + config
        draft a short summary for email and Telegram

Two Hermes cron jobs, one Python script, one sheet. The jobs use a cheap model. Its work is to run a script and report what happened, so it does not need to be clever.

Step 1: collect, do not parse yet

The 8:00 job runs a shell script that copies raw messages into folders named by month (2026-10 style). SMS comes from imsg, email from gog gmail, and a chat with a family member from wacli, for the money notes we send each other.

Saving the raw files first matters. If a parser has a bug, I fix it and re-run on the same files. I never have to ask a bank for the message again.

Step 2: one parser per source

Every bank writes its alerts differently, so each source gets its own small function that returns the same shape:

@dataclass
class Transaction:
    date: str        # YYYY-MM-DD
    bank: str
    account: str
    txn_type: str    # DR or CR
    amount: float
    currency: str
    description: str
    source: str      # SMS or email
    hash: str = ""

Everything downstream sees only Transaction. Adding a bank means writing one function and one test message, not touching the rest.

The family chat is the odd one, because it is free text. The parser looks for amounts with a k suffix or a currency word, and guesses the direction from a few keywords. Anything it cannot place becomes a NOTE row for me to read, and does not enter the totals.

Step 3: dedupe with a hash

The sync runs often and the same message shows up again each time. A row's identity is a short hash of four fields:

def make_hash(date, bank, amount, txn_type):
    key = f"{date[:10]}|{bank.upper()}|{abs(amount):.2f}|{txn_type}"
    return hashlib.sha256(key.encode()).hexdigest()[:16]

Before appending, the script reads the hashes already in that month's tab and skips anything it has seen. Running it twice does nothing the second time.

The known weakness: two real payments of the same amount to the same bank on the same day collapse into one. For me that is rare enough to accept. If it were not, I would add the message time to the key.

The script has a --dry-run flag that parses and prints without writing, and --days 30 to limit the window. I use --dry-run after every parser change.

Step 4: the sheet

Rows land in a Google Sheet through the Sheets API, one tab per month, plus an Overview tab and a Snapshots tab that records balances over time. A sheet is easy to filter and easy to read, and I do not have to build a dashboard to use it.

Step 5: the morning summary

The 9:30 job loads a finance skill and reads two local files, a snapshot and a config. It drafts a summary for email and Telegram, and the prompt says plainly: drafting only, use stored data, do not launch a browser. I send anything myself. The job never sends on my behalf.

The prompt also tells the job not to launch a browser or wait on a slow web scraper. Reading stored data is fast and cannot hang.

Mistakes worth knowing about

  • Double-counting a transfer. If I tell the assistant about a transfer and it edits a balance by hand, but the sync already applied that message, the balance is wrong. The skill now has a rule: check whether the message is already in the data before editing a balance.
  • Card balance is not what you owe. Many card alerts report the remaining credit. The amount owed is the limit minus that. Some banks send a monthly bill figure instead, and that one is the amount owed. Read each sender's wording.
  • A silent override. The config has a balance_overrides section that wins over anything parsed from messages. I left an old value in it, and a balance looked stuck. It stays empty unless a source is truly broken.
  • Config is not state. Limits live in config. Balances come from messages. Mixing them gave me wrong numbers twice.
  • Unknown destinations. A debit to an account the script has never seen can be a new savings product. The skill makes the assistant ask me what it is, instead of guessing.
  • Delivery can fail on its own. The Telegram step of the 9:30 job has failed delivery on its last run. The summary exists, the message did not arrive. I check the job status, not just the output.

What I would tell you to copy

  1. Save raw messages before parsing.
  2. One parser per source, one shared row shape.
  3. Hash rows so the job can run twice without harm.
  4. Keep a dry-run mode.
  5. Let the model report and draft. Do the maths in code.
  6. Write down every wrong number you find as a rule for the next run.

There is a sensitive-data cost to all this. SMS and bank email are private, so the files stay on my machine and the sheet is not public. Do the same, and keep it out of any repository.