Claude does my bookkeeping now

30 August 2026

Every month there is a list in Moneybird, my accounting software, of bank transactions that have no invoice attached to them yet. Forty of them, sometimes more. For each one you dig through your email for a PDF, check that it really is an invoice, pick a ledger account, guess at the VAT rate, upload the file, and link it to the transaction. It is the kind of work that is not hard, just tedious enough that you keep putting it off.

I now type “process the unprocessed transactions” and Claude Code does it one by one, asking me for a yes before every booking.

Three skills stacked

The setup is three skills on top of each other, and that split is the main design decision.

onverwerkte-transacties (Dutch for unprocessed transactions) is the orchestration. It knows the order of the steps, when to stop and ask, and where it is in the list. moneybird is the API layer, with all the curl commands, endpoints, ledger accounts, VAT rates and API quirks. fastmail and gmail are the mailboxes it searches for invoices.

The orchestration skill does not need to know how you upload an attachment, and the API skill does not need to know what order to work in. Both stay readable that way, and I can use the API layer on its own when I just want to book a single receipt.

Three guardrails

Bookings are hard to undo and have tax consequences, so three rules sit at the top of the skill.

It shows me the plan per transaction and waits for an explicit yes before any POST or PATCH. Reading is always fine, only writing needs approval.

It never invents a receipt. If it cannot find an invoice, it stops and asks me to find it and drop it in my downloads folder. No guessing, no quietly skipping.

It opens and reads every PDF before booking anything, even the ones that came straight out of my email. That third rule turned out to be the most valuable one. Without it you end up with shipping confirmations and sales agreements booked as invoices.

The part that took the longest

The interesting section is at the bottom, under “lessons learned”. Every line there is in the file because something went wrong once.

The one that cost me the most: link_booking, the call that links a bank transaction to a document, is not idempotent. Every call creates a new payment on the invoice. One innocent retry and you have a double payment. Worse, Moneybird has its own matching engine that runs at the same time. I first thought that only happened with direct debit suppliers, but it also fires within seconds on card and iDEAL payments, right after you create the document. So the skill always does a GET on the transaction first and only links if something is genuinely still open.

Then there is a whole category of things that are not API problems but bookkeeping problems. A payment receipt is not an invoice, and foreign services tend to send you both in separate emails. Train tickets do have deductible VAT, which I had wrong for years. Phone bills need to be split, because the “online purchases” line on there is App Store purchases through carrier billing, and the VAT on those sits on the Apple invoice even though the phone bill says no VAT was charged. Some webshops put your personal name on an invoice that is otherwise entirely addressed to your company.

None of that is knowledge I want to keep in my head. It is in a file now.

The whole skill

This is the file as it runs here, translated to English. Only the administration ids, the tokens, my email addresses, one real order number and my name have been replaced with placeholders. Nothing else is cleaned up.

---
name: unprocessed-transactions
description: Walk through the unprocessed bank transactions in Moneybird one by one, find the matching invoice or receipt PDF in email, book it and link the transaction. Use for "unprocessed transactions", "process outstanding invoices", "clean up the bank inbox", "link receipts".
user-invocable: true
---

# Processing unprocessed transactions

Walk through the **unprocessed bank transactions** of a Moneybird administration one by one. For each transaction: find the matching invoice or receipt PDF in email, book it, link the transaction. If searching email does not work out, the user puts the PDF in `~/Downloads/` themselves.

This skill is the orchestration layer. The mechanics lean on other skills:

- **`moneybird`** for all Moneybird API calls (tokens, admin ids, ledger accounts, VAT rates, creating receipts and purchase invoices, uploading attachments, linking transactions). **Read that SKILL.md first**, it has the exact curl commands, ids and pitfalls.
- **`fastmail`** for searching the personal mailbox (`fastmail search ...`, `fastmail download ID`).
- **`gmail`** for searching the work mailbox (`gws`).

## Financial changes always need approval

Bookings are hard to undo. Per transaction: **show the plan first** (which document, which amounts, which ledger account, which link), then **wait for explicit approval** ("yes" / "go ahead"), and only then POST or PATCH. GET actions (fetching transactions and invoices) do not need approval.

## Step 0: pick the administration

Ask which administration if it is not obvious:

| Administration | Admin id | Token | Mailbox (primary) |
|---------------|----------|-------|--------------------|
| **Company 1 B.V.** | `<ADMIN_ID_1>` | `$MONEYBIRD_API_KEY_1` | `fastmail` (personal) |
| **Company 2 B.V.** | `<ADMIN_ID_2>` | `$MONEYBIRD_API_KEY_2` | `gmail` (gws, work) |

The mailbox column is the **primary** place to look, not an absolute rule: invoices can also arrive on the other account. If you find nothing in the primary mailbox, search the other one before falling back to Downloads.

```bash
TOKEN=$MONEYBIRD_API_KEY_1   # or $MONEYBIRD_API_KEY_2
ADMIN=<ADMIN_ID_1>           # or <ADMIN_ID_2>
```

## Step 1: fetch the unprocessed transactions

```bash
curl -s "https://moneybird.com/api/v2/$ADMIN/financial_mutations.json?filter=state:unprocessed&per_page=100" \
  -H "Authorization: Bearer $TOKEN" \
  | python3 -c "
import json,sys
d=json.load(sys.stdin)
for m in d:
    print('='*70)
    print(f\"id:{m['id']}  {m['date']}  amount:{m['amount']}  open:{m['amount_open']}\")
    print(f\"  contra account: {m.get('contra_account_name')}  {m.get('contra_account_number')}\")
    print(f\"  message: {m.get('message')}\")
"
```

Show the user a short overview (date, amount, contra account, description) and the total count.

Watch the **`amount_open`** per transaction:
- If it shows up in the `unprocessed` list it is by definition not done, even if `amount_open` is only a few cents. A small remainder (say 0.11 euro on a USD payment) is usually an **incompletely linked foreign currency invoice**, not a rounded off transaction. Do not skip it, that is exactly the case that needs attention. Fix the link with `price_base` set to the actual transaction amount (see 2e, foreign currency), or unlink the existing payment and book it again.
- Only skip such a remainder if the user explicitly says to leave it.

## Step 2: process them one by one

Handle the transactions strictly one at a time. For each one:

### 2a. First check whether Moneybird already has it (incoming documents)

**Before you go searching email**: Moneybird sometimes imports invoices itself (UBL, email forward, integration) as a document, without linking it to the bank transaction. Look for an unmatched document for the same supplier and period:

```bash
curl -s "https://moneybird.com/api/v2/$ADMIN/documents/purchase_invoices.json?filter=period:this_month&per_page=50" \
  -H "Authorization: Bearer $TOKEN" \
  | python3 -c "
import json,sys
d=json.load(sys.stdin)
for doc in d:
    print(doc['id'], doc['state'], doc.get('reference'), doc.get('total_price_incl_tax'), doc.get('contact',{}).get('company_name'))
"
```

(use `period:prev_month` if the transaction date falls in the previous month). **`state: "new"`** means the document exists but is not linked yet, which is exactly what you are looking for. Do the supplier and amount match the transaction? Then you do not need to create an invoice or find a PDF, go straight to 2f and only link (`link_booking`). Does it already have an attachment (`GET .../purchase_invoices/$ID.json`, field `attachments`)? Then that step is done too.

Nothing with `state: "new"`? Continue with 2b (search email).

### 2b. Find the PDF in email

The **description and contra account** of the transaction are the search keys. Strategy, in order:

1. **Order number or reference** from the `message` (for example `COM-XXXXXXXXXX`, giving order number `XXXXXXXXXX`). Search for it literally:
   ```bash
   fastmail search "XXXXXXXXXX"
   ```
2. **Supplier name** from `contra_account_name` (for example "Tesla", "Employes", "DigitalOcean"):
   ```bash
   fastmail search tesla
   ```
3. Cross check on **amount and date**: the invoice has to match the transaction. Watch for VAT inclusive or exclusive, and for currency.

For the work administration use `gws` (see the `gmail` skill) instead of `fastmail`.

Found it? Read the email (`fastmail read ID`), check the amount, and download the attachment:
```bash
fastmail download ID    # PDF lands in ~/Downloads/
```

### 2c. Fallback: not found in email

If you cannot find a matching invoice in email, do **not** guess, do not fabricate a receipt, and do not quietly skip the transaction. **Stop and ask the user:**

> "I can't find an invoice in your email for *[supplier, amount, date]*. Do you want to look it up and put it in `~/Downloads/`? Let me know when it's there and I'll pick it up."

Wait for the user to respond. Then take the newest relevant PDF from `~/Downloads/`:
```bash
ls -t ~/Downloads/*.pdf | head -5
```
Confirm with the user which file it is before you use it.

### 2d. Check the PDF, always, whatever the source

**Before you book anything: open the PDF and read it.** This applies to PDFs from email as well as from `~/Downloads/`. Use the Read tool (it reads PDFs directly) or `pdftotext file.pdf -`.

Check:
- **Is it actually an invoice or receipt?** Not a shipping confirmation, order confirmation, sales agreement, packing slip, order status, advertisement, or an empty or unreadable document. A real invoice has an invoice number, an invoice date, a VAT breakdown, and is addressed to the company. If the real invoice sometimes arrives separately or later (Tesla for instance sends a confirmation or sales agreement first and a separate "Invoice" after), keep looking.
- **Is the invoice addressed to the right company?** (not to a private name). Check the "sold to" field.
- **Does the supplier match** the contra account of the transaction?
- **Does the amount match** the transaction (watch VAT inclusive or exclusive, and currency)?
- **Is there a date** that fits the transaction?
- **Is the VAT on it** (amount and rate), so you can book it correctly?

Something off, or not a valid document? **Stop and ask the user** (same as 2c). Never book on a PDF you have not checked.

### 2e. Book it

1. **Contact**: find or create (`/contacts.json`).
2. **Receipt** (till receipt, no formal invoice) or **purchase invoice** (formal invoice addressed to the company). Pick the right ledger account and VAT rate (see the `moneybird` skill; for doubts about ledger accounts or capitalising versus expensing: **ask the user**).
3. **Upload the PDF** as an attachment (`.../attachments.json`, plural).

**Foreign currency (USD invoice, EUR transaction).** Create the purchase invoice in the invoice currency (USD). Link with `price` set to the invoice amount in the invoice currency (say 21.60) and `price_base` set to the actual EUR amount of the bank transaction (say 18.53). So set `price_base` to the transaction amount, not to the converted invoice value, otherwise a few cents of exchange difference stay open. Moneybird writes off the difference itself.

### 2f. Link the transaction

Link the bank transaction with `PATCH .../financial_mutations/$MUT_ID/link_booking.json`, `booking_type: "Document"`, and a **positive `price`**.

**`link_booking` is NOT idempotent: call it exactly once.** Every call creates a new payment on the invoice. Multiple calls (including "let me just try again" or a debug call) stack up duplicate payments.

**With suppliers on direct debit (Employes, Hetzner and the like): check first whether Moneybird already matched it itself**, before you link. Moneybird's own matching can run at the same time as your call and then you get a duplicate payment, guaranteed. After creating the invoice (and before you call `link_booking` yourself) do a GET on the transaction; if `amount_open` is already 0 with 1 payment, it is linked and there is nothing to do. See the `moneybird` skill, section "API: linking a bank transaction to a document" for the command. **This also applies to card and iDEAL payments**: Moneybird matched an NS receipt (iDEAL) and two Slack invoices (Mastercard) within seconds of them being created, and an immediate `link_booking` produced a duplicate payment right away. So: always GET first, only link if `amount_open` is still open.

**Do not trust the response of the `link_booking` PATCH** (it is often just a status code, not a usable JSON dict). Always verify the link with a separate GET on the transaction and check that `amount_open` is 0, `state` is `processed`, and there is **exactly 1 payment**:
```bash
curl -s "https://moneybird.com/api/v2/$ADMIN/financial_mutations/$MUT_ID.json" \
  -H "Authorization: Bearer $TOKEN" \
  | python3 -c "import json,sys; m=json.load(sys.stdin); print('open:',m['amount_open'],'state:',m['state'],'payments:',len(m.get('payments',[])))"
```

Is `amount_open` not 0, or are there multiple payments? **Do not link again.** Remove all payments first and start clean:
```bash
# payment ids from the GET above (m['payments'][n]['id'])
curl -s -X DELETE "https://moneybird.com/api/v2/$ADMIN/documents/purchase_invoices/$INV_ID/payments/$PAYMENT_ID.json" \
  -H "Authorization: Bearer $TOKEN"   # or .../receipts/$ID/payments/... for a receipt
```
Only then one new link call.

Report briefly, then move on to the next one.

## Step 3: wrapping up

Once all transactions have been handled: give a short overview of what was booked, what was skipped, and what is still open (invoices the user still has to supply, for instance).

**Then offer to open the booked documents in the browser** so the user can check them. URL per document:
```
https://moneybird.com/{ADMIN}/documents/{DOCUMENT_ID}
```
Opening them (after approval), one tab per document:
```bash
for url in URL1 URL2 URL3; do nohup xdg-open "$url" >/dev/null 2>&1 & done
```

## Lessons learned

- The order number in the bank description is often the fastest match with the order confirmation in email (Tesla `COM-XXXX` leads to the Tesla Shop email with the invoice as `salesagreement.pdf`).
- `fastmail download ID` puts attachments in `~/Downloads/`.
- A transaction in the `unprocessed` list with a few cents `amount_open` is not "done": usually an incompletely linked foreign currency invoice. Fix the link (`price_base` set to the transaction amount) instead of skipping it.
- Never invent an invoice you cannot find. Always ask the user for the PDF.
- The `link_booking` PATCH response is unreliable to parse. Verify the link with a separate GET on the transaction, not on the PATCH output.
- `link_booking` is not idempotent: one call per link. A second call (retry or debug) creates a duplicate payment. With direct debit Moneybird sometimes matches it itself, so check for exactly 1 payment.
- Creating a new contact works with just `company_name` and `country` (a VAT number is not required).
- Recurring suppliers (Employes, hosting, SaaS): look at how earlier invoices from the same contact were booked (`purchase_invoices.json?filter=contact_id:...`) and follow the same ledger account and VAT rate. Employes payroll administration goes to Administration costs at 21%, DigitalOcean to Hosting at outside EU 21% (reverse charge).
- **A payment receipt is not an invoice.** Foreign services (DigitalOcean for instance) often send a "Received payment" email with only proof of payment, and a separate "Your invoice is available" email with the real invoice (invoice number and VAT breakdown or reverse charge). Take the invoice, not the payment receipt.
- **Foreign currency (USD invoice, EUR transaction).** Create the purchase invoice in the invoice currency (USD). Link with `price` set to the invoice amount in the invoice currency (say 21.60) and `price_base` set to the actual EUR amount of the bank transaction (say 18.53). So set `price_base` to the transaction amount, not the converted invoice value, otherwise a few cents of exchange difference stay open. Moneybird writes off the difference itself.
- **KPN Mobiel, splitting the invoice.** KPN delivers no PDF by email (only a notification, the invoice sits in MijnKPN, the user puts it in `~/Downloads/`). The monthly bill has a **subscription** part and often a line for **"online purchases"**, which are App Store purchases settled through carrier billing. Book the invoice **split**:
  - Subscription (plus usage, minus one off discount) goes to **Phone and internet**, 21% VAT.
  - "Online purchases": check whether it is business. If so, put it separately on **Software**, 21% VAT. If the online purchase is personal, book it as a private withdrawal (not deductible). **Watch the VAT:** the online purchase line on the KPN invoice often carries the footnote *"no VAT has been charged by KPN on this invoice line"*. Do **not** book this as 0% VAT. KPN is only passing it through (carrier billing); the 21% VAT sits on the **Apple** invoice (no reverse charge) and is simply deductible. So book the line at 21% VAT inclusive, with the Apple invoice as the supporting document.
  - **Add the matching Apple PDF as an attachment per online purchase line.** The Apple email "Your invoice from Apple" (in `fastmail`, recognisable by the payment line `KPN / Telfort ...` or `KPN Pay ...`) is the supporting document. Apple does not attach a PDF, so make one yourself with the chromium method (see the `moneybird` skill, "Email as proof of purchase") and attach it to the invoice. One "online purchases" total can consist of multiple Apple purchases (15.98 being iCloud at 9.99 plus Flighty at 5.99, for instance), so reconstruct the separate items from the Apple invoices for that period and add them all.
- **Check Moneybird itself before searching email.** TransIP invoices (and presumably other suppliers with a forward to Moneybird rule) sometimes sit there as a `state: "new"` purchase invoice with attachment, before the bank transaction even exists. Step 2a above catches this, do not skip it, it saves a lot of email digging.
- **Ubiquiti order confirmations contain no direct PDF.** The "Download Invoice" link in the email (through an awstrack.me redirect to `ecomm.svc.ui.com/invoice/...`) needs a logged in ui.com session; without login you land on the order status page, not on a PDF. Ask the user to log in and download the invoice to `~/Downloads/` right away, instead of trying to make a screenshot or PDF of the order page as a supporting document.
- **Amazon.nl invoices carry your personal name**, even when the address and the invoice otherwise run through the company (delivery and billing address are the company address). That is a known Amazon pattern, not a reason to reject the invoice, but do confirm with the user that the purchase was for business before booking.

What it actually buys me

The time saved is nice, but that is not the point. Everything I ever figured out about my own bookkeeping now lives in a file instead of in my head. That train tickets carry deductible VAT. That the phone bill needs splitting. That one supplier already forwarded the invoice. Every time something goes wrong I add one line under “lessons learned”, and the next time it goes right.

That is probably the real lesson about writing these skills. You do not build one in one go. You start with the happy path, let reality break it, and write down what broke every time. After a few rounds it is not a script anymore, it is a pile of experience that happens to be executable.

Jankees van Woezik profile picture

Hello, I'm Jankees van Woezik

Like this post? Follow me on X (@jankeesvw)