How I Track My Money Across Multiple Accounts, Currencies and Countries

I work seasonal jobs in Europe and spend the winters in Southeast Asia, so my money lives in a Dutch current account, a savings account, a credit card, a multi-currency app, an online wallet, a money-transfer service and, most of the time, a wallet full of local cash.

No budgeting app handles that well. Banks only see their own account, apps get confused by cash in four currencies, and none of them know that the €820 that left my bank and the 28,500 baht I handed over for a room are the same money. So I built my own system: one long table of entries, a handful of codes per line, and a set of checks that tell me when something does not add up.

I want one place that shows all of it, in euros, and tells me what a month in Chiang Mai really costs compared to a month in the Netherlands.

View it in action here: https://rcsmit.github.io/kasboek/ (download the Excel file in the GitHub folder below and drag it to the website).

Find the example file here: https://github.com/rcsmit/rcsmit.github.io/tree/master/kasboek

A warning before you start. This system takes a lot of effort and discipline. Every receipt, withdrawal and transfer has to be entered, often daily, and every transfer needs two legs that match to the cent. It also takes some logical thinking: for each line you have to decide where the money came from, where it went and whether it was really spent. If you skip a few days or guess, the checks will tell you, and finding the mistake later costs more time than entering it right the first time.

This post explains the whole system, with most of the space going to transfers: money that moves without being earned or spent. Get those wrong and every total is wrong. Get them right and the books balance to the cent across years and currencies.

Everything here is anonymised. Account numbers, names and amounts in the examples are made up.

Contents

The ENTRIES table
Four layers of classification
Getting the data in
Sources: real money and helper accounts
The golden rule of TRANSFER
Every TRANSFER case I have run into
· A. Between my own sources
· B. Parked with someone else
· C. Owed to me or by me
· D. Pass-through and look-alikes
Foreign currency and counting cash
Opening balances, year end and zero-amount rows
Checks that keep the books honest
Common mistakes
The example workbook
Appendix A: Is this double-entry bookkeeping?
Appendix B: The helper file

The ENTRIES table

Everything lives on one tab called ENTRIES: one row per money movement, newest at the bottom, with twelve columns that matter. Other tabs (pivots, rules, per-day stats) only read from it. A row records what happened to one source: a bank account, a card, a wallet, a pile of cash. A purchase is one row. A transfer between two of my sources is two rows.

ColumnWhat goes in itExample
idA number shared by all rows that belong to one event. The two legs of a transfer, or a payment split over three categories, carry the same id.202611042
sourceWhere the money sits: an account, card, wallet, cash pile or helper account.CASH_THB
dateThe day it happened on that source, not the day I typed it in.2026-11-16
amountIn euros, signed from the point of view of the source: plus = in, minus = out.-6.77
descriptionFree text, usually the counterparty or what I bought.ATM fee
typeThe first classification layer: IN, OUT, TRANSFER and a few more.OUT_ASIA
groupThe second layer: a budget group such as GROCERIES or FIXED_COSTS.ASIA_DAILY
categoryThe third layer: the specific item, such as storage_unit or espresso.bank_fees_asia
daily_codeA short code for daily spending abroad: bf, l, d, c, h and so on.misc
fx_amountThe amount in the local currency, signed like amount. Empty for euro rows.-250
currencyThe ISO code of fx_amount.THB
rateLocal currency per euro on that row.36.91

Three rules keep the table usable:

  • Amount is always euros. For a foreign-currency row I store the local amount in fx_amount and the rate in rate, and amount = fx_amount ÷ rate. Totals then work across currencies without lookups.
  • Sign follows the source. Money leaving my wallet is negative, even when it is a refund I am passing on. There is no separate debit and credit column.
  • One id per event. When a bank line is split over categories, or a transfer has two legs, the rows share an id so I can find and check them together.

Four layers of classification

Each row gets up to four labels, from coarse to fine:

  • type,
  • group,
  • category
  • daily code (for spendings in Asia)

The type decides whether a row counts as income, spending or neither; the other three only matter for reports.

Type

TypeMeaningCounts in
INMoney I earned or received for keeps: salary, benefits, tax refunds, interest, gifts.Income
OUTDaily life in the Netherlands and Europe.Spending, Europe
OUT_FIXEDFixed costs that run all year wherever I am: insurance, phone, subscriptions, storage.Spending, fixed
OUT_ASIADaily life during the winter in Asia.Spending, Asia
OUT_ASIA_FLIGHTSFlights to and from Asia.Spending, Asia
OUT_ASIA_INSURANCETravel insurance for the trip.Spending, Asia
OUT_ASIA_PREPAIDVisa, vaccinations and other things paid before leaving.Spending, Asia
TRANSFERMoney moving between my own sources, or parked with someone else.Nothing: must net to zero
OPENING_BALANCEThe balance of a source on the day I started tracking it.Net worth only
PAPER_GAIN_LOSSRevaluation of something I hold, such as crypto, without buying or selling.Net worth only
UNCATEGORISEDImported, not yet labelled.Spending, flagged

The split of OUT into Europe, fixed and Asia is the whole point of the system. It answers the question I care about: how many days in Asia does a month of seasonal work buy?

Group and category

The group is a budget bucket; the category is the line item inside it. I keep the list short and stable so years stay comparable.

GroupTypical categories
SALARYsalary, holiday_pay, benefits, side_income
TAX_REFUNDSincome_tax_refund, health_allowance_back
INTERESTinterest
MISC_INgifts_received, sold_items
GROCERIESgroceries, groceries_seasonal_job, protein_powder
EATING_OUTespresso, restaurant, snacks, day_trips
MISCmisc, gifts_given, books, bank_fees, fx_costs
CLOTHING_CAREclothing, haircut, dentist, pharmacy
TRAVEL_NLpublic_transport, train
TRAVEL_EUROPEflights_europe, hostel_europe
FIXED_COSTShealth_insurance, phone_internet, storage_unit, subscriptions, hosting
ASIA_DAILYdaily_spending, housing, scooter, bank_fees_asia
ASIA_MISCmedical_asia, gear, tours
ASIA_FLIGHTSflights, flights_unused
ASIA_PREPtravel_insurance, visa, vaccinations
PREPAID_UNUSEDaccommodation_unused
TRANSFERtransfer, deposit, fraud

Daily code

In Asia I log almost every receipt from cash. The daily code tells me what a day is made of, without inventing fifty categories.

CodeMeaningCodeMeaning
bfBreakfasthHousing
lLunchscootScooter rent and fuel
dDinnertaxiTaxi or ride-hailing
cCoffeetixBus, train, ferry tickets
sSnacks and drinksyogaClasses
alcAlcohollndryLaundry
tourSights and entry feestipTips
visaVisa extensionmiscEverything else
diffCash count differenceinCash coming in (withdrawal)

Getting the data in: bank CSVs and a rule-based categoriser

Nothing flows into my books automatically. Once a month I download every statement as a CSV, paste the rows into ENTRIES by hand, and let a small Python script label most of them. Cash in Asia I type in from receipts and notes, usually the same evening.

The monthly routine

  1. Download. From each online banking site or app I export the period since my last import as CSV: current account, savings, credit card, every currency pocket of the multi-currency app, the online bank and the online wallet.
  2. Fix the odd export. Not every export is clean. The online wallet, for example, writes dates as month/day/year. A few lines of Python rewrite that column to day/month/year before I touch the file.
  3. Paste by hand. I copy the rows to the bottom of ENTRIES and line up the columns: date, amount, counterparty into description, and the account as source. Doing this by hand is slower than an import macro, but I see every line once, and that is when I spot the odd charge.
  4. Run the categoriser. The script fills type, group and category for every row it recognises (below).
  5. Label the rest. Whatever it did not recognise, I label myself. If the same counterparty comes back, I add a rule so I never label it again.
  6. Pair the transfers. Transfers from the bank side get their second leg on the other source with the same id, and then the checks run.

Sources: real money and helper accounts

A source is anything that has a balance: if I can ask “how much is in it?”, it is a source. That includes things that are not accounts at all, like the money a friend owes me or a room I paid for months ahead. The running total of amount per source must always equal what is really there.

In my own file bank accounts use their full account number as the source name, so a bank CSV export can be matched line by line. In this post and the example they are replaced by readable names.

Real sources

SourceWhat it isChecked against
BANK_CURRENTCurrent account; salary in, most bills outBank statement
BANK_SAVINGSSavings accountBank statement
BANK_PENSIONPension savings, filled monthly and not to be touched until retirement; the dashboard shows a second total without itBank statement
CREDIT_CARDCredit card, settled monthly from BANK_CURRENTCard statement
FOREIGN_BANKSmall account in another EU country for a seasonal jobStatement
MULTI_EUR, MULTI_THB, MULTI_VNDMulti-currency app, one source per currency pocketApp export per currency
BANK_ONLINEOnline bank and international money-transfer serviceApp history
ONLINE_WALLETOnline payment walletActivity export
CASH_EUR, CASH_THB, CASH_VND, CASH_USD, CASH_AEDPhysical cash, one source per currencyCounting it
CRYPTOA small crypto holdingExchange app

Helper sources

Helper sources hold money that is mine (or that I owe) but not in any account. They are what make the hard transfer cases work, and each one has to come back to zero, or to a known amount, eventually.

SourceHoldsBack to zero when
DEPOSITSDeposits held by landlords and scooter rentalsThe deposit comes back (inverse amounts), or is kept and becomes a cost
RECEIVABLESMoney owed to me by an organisation: an insurance claim, a benefit already earned, fraud the bank will refundThe money arrives
PAYABLESMoney I owe to someone who paid for meI pay them back
PREPAIDServices paid ahead: a room for next seasonThe service is used, or written off
INSURER_POTMonthly instalments of my health insurance deductible, held by the insurerThe insurer settles the year
PERSON_ALoans to and from one friendThe loan is repaid or forgiven
TAB_FRIEND_BCash I gave a friend who pays for me with her QR codeThe tab is used up
FX_DIFFSmall rounding differences when someone pays me backI clear it to fx_costs
RECLASSBookkeeping-only moves between categoriesAlways zero, by construction
SUSPENSEMoney I cannot place yetI find out what it was
STATS_ONLYZero-amount rows that only carry local-currency detailsAlways zer

Money only enters or leaves my books through the outside world; everything between the boxes on the right is a transfer. Helper sources hand money back to the outside world only when something is actually consumed or lost.

The golden rule of TRANSFER

A transfer moves money between two of my sources, so it is always two rows that add up to exactly zero. One leg is negative on the source the money leaves, the other is positive on the source it lands on, and both carry type TRANSFER, group TRANSFER and the same id.

idsourceamounttypecategory
7001BANK_CURRENT-300.00TRANSFERtransfer
7001BANK_SAVINGS300.00TRANSFERtransfer

Because income and spending only look at IN and the OUT types, this pair changes no report. It only changes where the money sits. Three consequences follow:

  1. The sum of all TRANSFER rows is zero. Over the whole file, to the cent. This is the single most useful check I have; a pivot of amount by type shows it at a glance.
  2. Each id sums to zero. If the total is off, grouping TRANSFER rows by id finds the broken pair: a leg typed twice, a leg missing, or a sign flipped.
  3. Each month usually sums to zero. When it does not, it is almost always a pair that crosses a month boundary on purpose: a transfer sent on the 31st that lands on the 2nd. Those two months cancel each other out.

Is it a transfer?

When a line comes in, I ask three questions in order:

  1. Did my net worth change? If I paid a shop, earned money or lost money, no: it is IN or OUT. If the money only moved, go on.
  2. Is the other side one of my sources? If yes, it is a transfer and I need both legs. If the other side is a person or company that will give the money back (a deposit, a loan, a prepaid room), I create or reuse a helper source so that the other side becomes one of mine.
  3. Is any part of it a cost? Fees, exchange margins and the part of a loan I forgive are not transfers. I split them off into their own OUT row, with the same id, so the transfer legs stay equal and opposite.

The third question is where most errors come from. A €100 transfer that arrives as €99.50 is not a €100 transfer: it is a €99.50 transfer plus a €0.50 cost.

Every TRANSFER case I have run into

Twenty-nine cases cover every transfer I have met in years of keeping these books. They fall into four families: money moving between my own sources, money parked with someone else, money owed, and things that look like transfers but are not. The overview comes first; after it, each case shows the exact rows in euros, as they appear in the example workbook (same ids, same case codes).

All cases at a glance

CaseWhat happensHelper sourcePart that is not a transfer
T01Saving
T02ATM at home
T03ATM abroadATM fee (negative)
T04Exchange in an app
T05Card settlementThe purchases
T06Online bank top-upService fee, rent
T07Arrives shortTransfer fee
T08Cash-to-cash exchange
T09Crosses a month
T10Rate adjusted by hand
T11DepositDEPOSITS
T12Card fraudRECEIVABLES
T13Deductible instalmentsINSURER_POTDeductible used
T14Prepaid, usedPREPAIDThe stay
T15Prepaid, unusedPREPAIDThe write-off
T16CryptoCRYPTORevaluation
T17Insurance claimRECEIVABLESCost and claim
T18Pay earned, paid laterRECEIVABLESThe income
T19Friend paid for mePAYABLESMy share
T20Bought for a friendPERSON_A, FX_DIFF
T21Loan, partly forgivenPERSON_AForgiven part
T22Tab with a friendTAB_FRIEND_BThe purchases
T23Groceries for my parents, paid next monthRECEIVABLESReceipt and their part
T24Passing money on
T25Cash swap
T26Shared billAll of it
T27ReclassificationRECLASSAll of it
T28Unknown moneySUSPENSEOnce identified
T29Clearing FX_DIFFFX_DIFFAll of it

Two habits run through every case with a foreign currency. First, I adjust the rate on a transfer leg by hand so that the euro amount is exactly the same on both sides; the rate takes the difference, never the euros. Second, the description of a transfer holds both amounts, such as “277.70 EUR = 10,250 THB”, so the real exchange stays readable.

A. Between my own sources

T01 · Saving: current account to savings

The classic pair. Two legs, same id, opposite amounts.

iddatesourceamount €descriptiontypecategory
1012025-11-01BANK_CURRENT-300.00to savingsTRANSFERtransfer
1012025-11-01BANK_SAVINGS300.00to savingsTRANSFERtransfer
1022025-12-01BANK_CURRENT-100.00monthly to pension savingsTRANSFERtransfer
1022025-12-01BANK_PENSION100.00monthly to pension savingsTRANSFERtransfer

Nothing is earned or spent, so no report changes. Only the balances per source move.

T02 · Cash from a home ATM

Cash is a source like any other, so a withdrawal is a transfer, not spending.

iddatesourceamount €descriptiontypecategory
2012025-11-03BANK_CURRENT-100.00ATM withdrawalTRANSFERtransfer
2012025-11-03CASH_EUR100.00ATM withdrawalTRANSFERtransfer

Book the withdrawal on the cash source with the date you took the money out, not the date the bank processes it.

T03 · ATM abroad, with a local ATM fee

Book the transfer at the euro amount the bank charged and the gross local amount; the fee is a negative expense row inside the cash wallet.

iddatesourceamount €descriptiontypecategorylocal
3012025-11-05BANK_CURRENT-277.70ATM withdrawal 277.70 EUR = 10,250 THB (10,000 + 250 fee)TRANSFERtransfer
3012025-11-05CASH_THB277.70ATM withdrawal 277.70 EUR = 10,250 THB (10,000 + 250 fee)TRANSFERtransfer · in10,250 THB
3012025-11-05CASH_THB-6.77ATM fee 250 THBOUT_ASIAbank_fees_asia · misc-250 THB

The rate on the cash leg is calculated from the withdrawal itself: 10,250 / 277.70 = 36.9103. Every cash payment from this wallet uses that rate. The fee is a separate row with a negative amount, like any other cost; booking it as positive would add money to the wallet instead of taking it out.

T04 · Exchange inside a multi-currency app

One source per currency pocket. The exchange is a transfer between two of them.

iddatesourceamount €descriptiontypecategorylocal
4012025-11-10BANK_CURRENT-250.00top up appTRANSFERtransfer
4012025-11-10MULTI_EUR250.00top up appTRANSFERtransfer
4022025-11-13MULTI_EUR-200.00exchanged 200.00 EUR = 7,364 THBTRANSFERtransfer
4022025-11-13MULTI_THB200.00exchanged 200.00 EUR = 7,364 THBTRANSFERtransfer7,364 THB

The euro amount is identical on both legs; only the THB leg carries fx_amount and rate. The rate is again calculated from the exchange: 7,364 / 200.00 = 36.82.

T05 · Credit card: purchase, then monthly settlement

The purchase is the expense, on the card. The settlement from the bank is only a transfer.

iddatesourceamount €descriptiontypecategory
5012025-11-08CREDIT_CARD-612.40airline, return flightOUT_ASIA_FLIGHTSflights
5022025-11-28BANK_CURRENT-612.40card settlement previous statementTRANSFERtransfer
5022025-11-28CREDIT_CARD612.40card settlement previous statementTRANSFERtransfer

Booking the settlement as spending would count every card purchase twice: once on the card, once on the bank.

T06 · Top up an online bank, pay a fee, pay rent

Top-up is a transfer; the service fee is an expense; the rent is an expense. Three rows on the online bank, one on the bank.

iddatesourceamount €descriptiontypecategorylocal
6012025-12-01BANK_CURRENT-126.45top up online bankTRANSFERtransfer
6012025-12-01BANK_ONLINE126.45top up online bankTRANSFERtransfer
6012025-12-01BANK_ONLINE-0.45service feeOUTbank_fees
6022025-12-01BANK_ONLINE-126.00guesthouse December 126.00 EUR = 4,650 THBOUT_ASIAhousing · h-4,650 THB

The online bank’s balance after these rows is zero, just like the app shows.

T07 · A transfer that arrives short

The bank shows one line of -150. I split it: -149.50 transfer and -0.50 fee, same id. The legs stay equal.

iddatesourceamount €descriptiontypecategory
7012026-05-02BANK_CURRENT-149.50to foreign account (part 1 of bank line -150.00)TRANSFERtransfer
7012026-05-02BANK_CURRENT-0.50international transfer fee (part 2 of bank line -150.00)OUTbank_fees
7012026-05-02FOREIGN_BANK149.50received from home accountTRANSFERtransfer

My bank-statement checker recognises this: one bank line that matches two ledger rows with the same id.

T08 · Cash-to-cash exchange at an airport

Leftover dollars swapped for dirhams during a layover. Both legs in euros, each with its own local amount and rate.

iddatesourceamount €descriptiontypecategorylocal
8012025-11-04CASH_USD-51.24exchanged at airport 60 USD = 220 AED (51.24 EUR)TRANSFERtransfer-60 USD
8012025-11-04CASH_AED51.24exchanged at airport 60 USD = 220 AED (51.24 EUR)TRANSFERtransfer220 AED

Each currency has its own cash source. What I spend from the dirhams at the airport are ordinary OUT_ASIA rows on CASH_AED, not part of the transfer.

T09 · A transfer that crosses a month boundary

Each leg gets the date it happened on its own source. January shows -500, February +500, the pair is zero.

iddatesourceamount €descriptiontypecategory
9012026-01-31BANK_CURRENT-500.00top up app (arrived 2 Feb)TRANSFERtransfer
9012026-02-02MULTI_EUR500.00top up app (arrived 2 Feb)TRANSFERtransfer

The monthly TRANSFER check shows -500 in January and +500 in February. That is expected; the id check shows the pair is fine.

T10 · Rate adjusted so both legs are equal

The app credited 3,690 THB for 100.00 EUR. I set the rate on the THB leg by hand to 3,690 / 100 = 36.90, so both legs are exactly 100.00.

iddatesourceamount €descriptiontypecategorylocal
10012026-01-15BANK_CURRENT-100.00top up 100.00 EUR = 3,690 THBTRANSFERtransfer
10012026-01-15MULTI_THB100.00top up 100.00 EUR = 3,690 THBTRANSFERtransfer3,690 THB

This is my standard way of handling any exchange: I never let the euro amounts differ. The rate absorbs the difference, and the description holds both amounts so I can always see what really happened.

B. Money parked with someone else

Here the money leaves all my real accounts but is still mine: a landlord holds a deposit, an insurer holds instalments, a guesthouse holds next season’s rent. The trick is the same every time: treat the place where the money waits as a source.

T11 · Deposit paid and returned

A deposit is still mine, so it moves from cash to the helper source DEPOSITS. When it comes back, the same two rows appear with inverse amounts.

iddatesourceamount €descriptiontypecategorylocal
11012025-11-06CASH_THB-27.09scooter deposit 1,000 THBTRANSFERdeposit-1,000 THB
11012025-11-06DEPOSITS27.09scooter deposit 1,000 THBTRANSFERdeposit
11022025-12-30DEPOSITS-27.09scooter deposit returned 1,000 THBTRANSFERdeposit
11022025-12-30CASH_THB27.09scooter deposit returned 1,000 THBTRANSFERdeposit1,000 THB

The return mirrors the payment: what was -27.09 on CASH_THB is now +27.09, and DEPOSITS goes from +27.09 back to zero. If a landlord keeps the deposit, the return row on DEPOSITS becomes a cost (OUT_ASIA, housing) instead of a transfer.

T12 · Fraudulent card charges, reimbursed later

Never spending, so never OUT. The bank owes me the money, so the charges go to RECEIVABLES and come back from there.

iddatesourceamount €descriptiontypecategorylocal
12012026-02-14CREDIT_CARD-50.70unknown merchant 55 USD = 50.70 EUR (fraud)TRANSFERfraud-55 USD
12012026-02-14RECEIVABLES50.70unknown merchant 55 USD = 50.70 EUR (fraud)TRANSFERfraud
12022026-02-14CREDIT_CARD-1.01FX surcharge on fraudulent chargeTRANSFERfraud
12022026-02-14RECEIVABLES1.01FX surcharge on fraudulent chargeTRANSFERfraud
12032026-03-20RECEIVABLES-51.71reimbursement fraudulent charge + surchargeTRANSFERfraud
12032026-03-20CREDIT_CARD51.71reimbursement fraudulent charge + surchargeTRANSFERfraud

Booked as OUT, fraud would inflate spending for the weeks the bank investigates, then the refund would look like income. If the bank never pays back, the open amount on RECEIVABLES becomes a cost (OUT, misc).

T13 · Health insurance deductible paid in instalments

Instalments go into INSURER_POT. The part actually used becomes a cost; the rest comes back.

iddatesourceamount €descriptiontypecategory
13012026-01-26BANK_CURRENT-37.50deductible instalmentTRANSFERtransfer
13012026-01-26INSURER_POT37.50deductible instalmentTRANSFERtransfer
13022026-02-26BANK_CURRENT-37.50deductible instalmentTRANSFERtransfer
13022026-02-26INSURER_POT37.50deductible instalmentTRANSFERtransfer
13032026-03-26BANK_CURRENT-37.50deductible instalmentTRANSFERtransfer
13032026-03-26INSURER_POT37.50deductible instalmentTRANSFERtransfer
13042026-04-15INSURER_POT-30.00deductible used: prescriptionOUTpharmacy
13042026-04-15INSURER_POT-82.50deductible settlement, unused part backTRANSFERtransfer
13042026-04-15BANK_CURRENT82.50deductible settlement, unused part backTRANSFERtransfer

Some insurers let you pay the yearly deductible in fixed monthly instalments and settle the year afterwards. INSURER_POT always shows what is still with them.

T14 · Room paid months ahead, used later

The cost belongs to the month I sleep there. Until then the money sits in PREPAID.

iddatesourceamount €descriptiontypecategorylocal
14012025-11-02BANK_CURRENT-180.00homestay February, paid aheadTRANSFERtransfer
14012025-11-02PREPAID180.00homestay February, paid aheadTRANSFERtransfer
14012026-02-01PREPAID-180.00homestay February (prepaid) 180.00 EUR = 5,400,000 VNDOUT_ASIAhousing · h-5,400,000 VND

Without PREPAID, November would look expensive and February free. With it, the cost lands in the season it belongs to.

T15 · Prepaid but never used, partly sold on

Someone takes over the booking and pays me 600. The 220 I do not get back is written off to PREPAID_UNUSED.

iddatesourceamount €descriptiontypecategory
15012026-03-10BANK_CURRENT-820.00cabin next winter, paid aheadTRANSFERtransfer
15012026-03-10PREPAID820.00cabin next winter, paid aheadTRANSFERtransfer
15022026-05-15PREPAID-600.00booking taken over by someone elseTRANSFERtransfer
15022026-05-15ONLINE_WALLET600.00booking taken over by someone elseTRANSFERtransfer
15032026-05-15PREPAID-220.00cabin next winter, not refundedOUT_ASIAaccommodation_unused · h
15042026-05-18ONLINE_WALLET-600.00wallet to bankTRANSFERtransfer
15042026-05-18BANK_CURRENT600.00wallet to bankTRANSFERtransfer

PREPAID_UNUSED is its own group so I can see what plans that fell through cost me, separately from normal housing.

T16 · Buying crypto, then a paper loss

Buying is a transfer. A change in value is PAPER_GAIN_LOSS: it moves net worth, not spending.

iddatesourceamount €descriptiontypecategory
16012026-05-10BANK_CURRENT-150.00buy cryptoTRANSFERtransfer
16012026-05-10CRYPTO150.00buy cryptoTRANSFERtransfer
16022026-05-31CRYPTO-18.20revaluation end of monthPAPER_GAIN_LOSSrevaluation
16032026-06-30CRYPTO6.40revaluation end of monthPAPER_GAIN_LOSSrevaluation

PAPER_GAIN_LOSS keeps the crypto balance equal to what the app shows, without pretending I spent or earned anything.

C. Money owed to me or by me

A claim, a loan or a friend’s IOU is money that exists but sits nowhere yet. RECEIVABLES, PAYABLES and a person account per friend give it a place. The cost or income is booked when it happens; the payment later is only a transfer.

T17 · Insurance claim: cost now, refund later

The claim reduces the cost in the month it happened. When the money arrives, it is only a transfer out of RECEIVABLES.

iddatesourceamount €descriptiontypecategorylocal
17012025-12-12CASH_THB-54.19doctor visit 2,000 THBOUT_ASIAmedical_asia · misc-2,000 THB
17012025-12-20RECEIVABLES54.19claim filed with insurerOUT_ASIAmedical_asia
17022026-03-05BANK_CURRENT54.19insurer pays claimTRANSFERtransfer
17022026-03-05RECEIVABLES-54.19insurer pays claimTRANSFERtransfer

If the insurer pays less than claimed, I book the difference back as cost: RECEIVABLES minus the difference, type OUT_ASIA, category medical_asia.

T18 · Income earned in one month, paid in the next

April’s pay arrives on 3 May. Booking it in RECEIVABLES in April keeps income in the month I worked.

iddatesourceamount €descriptiontypecategory
18012026-04-30RECEIVABLES1,450.00April pay, earnedINsalary
18022026-05-03RECEIVABLES-1,450.00April pay receivedTRANSFERtransfer
18022026-05-03BANK_CURRENT1,450.00April pay receivedTRANSFERtransfer

Optional. I only do it when the month matters, such as comparing seasons that start and end around a pay day.

T19 · A friend paid for me, I pay back later

My share is spending on the day of the dinner, booked on PAYABLES. Paying back is a transfer.

iddatesourceamount €descriptiontypecategory
19012026-05-09PAYABLES-18.50my share of group dinner, paid by friendOUTrestaurant
19022026-05-12BANK_CURRENT-18.50paid friend backTRANSFERtransfer
19022026-05-12PAYABLES18.50paid friend backTRANSFERtransfer

PAYABLES runs negative while I owe money, and back to zero when I have paid.

T20 · Bought something for a friend, paid back with a rounding loss

Not my spending: it goes to the friend’s person account. Repaid €24.00 for €24.38; the €0.38 moves to FX_DIFF.

iddatesourceamount €descriptiontypecategorylocal
20012025-12-20CASH_THB-24.38protein powder for friend 900 THB = 24.38 EURTRANSFERtransfer-900 THB
20012025-12-20PERSON_A24.38protein powder for friend 900 THB = 24.38 EURTRANSFERtransfer
20022026-02-02PERSON_A-24.00friend pays back via payment requestTRANSFERtransfer
20022026-02-02BANK_CURRENT24.00friend pays back via payment requestTRANSFERtransfer
20032026-02-02PERSON_A-0.38rounding difference, acceptedTRANSFERtransfer
20032026-02-02FX_DIFF0.38rounding difference, acceptedTRANSFERtransfer

Without the last pair, PERSON_A would show €0.38 forever and the person-account check would never be clean.

T21 · Loan to a friend, partly forgiven

Lend and repay are transfers. The part I let go is a gift, so it becomes OUT on the person account.

iddatesourceamount €descriptiontypecategory
21012026-05-05BANK_CURRENT-300.00loan to friendTRANSFERtransfer
21012026-05-05PERSON_A300.00loan to friendTRANSFERtransfer
21022026-06-01PERSON_A-250.00loan repaidTRANSFERtransfer
21022026-06-01BANK_CURRENT250.00loan repaidTRANSFERtransfer
21032026-06-02PERSON_A-50.00rest of loan forgivenOUTgifts_given

A loan is still my money. Only the forgiven part changes my net worth, and only that part is a cost. A loan that a friend gives me works the same way, with PERSON_A running negative.

T22 · A tab with a friend who pays by QR

I give her cash; she pays small things for me by QR code. The cash is a transfer, each purchase is spending on TAB_FRIEND_B.

iddatesourceamount €descriptiontypecategorylocal
22012026-01-08CASH_VND-20.00cash to friend for QR payments 600,000 VND = 20.00 EURTRANSFERtransfer-600,000 VND
22012026-01-08TAB_FRIEND_B20.00cash to friend for QR payments 600,000 VND = 20.00 EURTRANSFERtransfer600,000 VND
22022026-01-10TAB_FRIEND_B-4.00vegan dinner, paid by friend’s QROUT_ASIAdaily_spending · d-120,000 VND
22032026-01-14TAB_FRIEND_B-2.50coffee, paid by friend’s QROUT_ASIAdaily_spending · c-75,000 VND
22042026-01-20TAB_FRIEND_B-13.50noodle soup and juices, paid by friend’s QROUT_ASIAdaily_spending · d-405,000 VND

TAB_FRIEND_B shows how much of the cash she still holds for me. When it reaches zero I give her another note.

T23 · Groceries for my parents, paid back next month

I shop for my parents at the end of the month; they pay me back a few days later. The receipt stays groceries, a positive row on RECEIVABLES takes their part out of my spending, and the payment is only a transfer.

iddatesourceamount €descriptiontypecategory
23012026-05-30BANK_CURRENT-112.40supermarket, own groceries and my parents’ orderOUTgroceries
23012026-05-31RECEIVABLES86.27my parents’ part of the groceries, they pay next monthOUTgroceries
23022026-06-03RECEIVABLES-86.27parents pay back groceriesTRANSFERtransfer
23022026-06-03BANK_CURRENT86.27parents pay back groceriesTRANSFERtransfer

One receipt can hold my own groceries and theirs: I book the whole receipt as groceries and put only their part on RECEIVABLES. May shows only what I ate myself, June gets no fake income, and the transfer in June nets to zero within the month. Buying something only for someone else works too (T20), but this way suits a mixed receipt.

D. Pass-through, look-alikes and corrections

The last family holds the edge of the definition: money that is never mine, refunds that look like transfers but are not, and the bookkeeping-only moves that keep everything else clean.

T24 · Passing money on for someone else

Money in and straight out again on someone’s behalf. Never income, never spending: four TRANSFER rows, one id, sum zero.

iddatesourceamount €descriptiontypecategory
24012026-03-02BANK_CURRENT820.00received from friend for her rentTRANSFERtransfer
24012026-03-02BANK_CURRENT-820.00to online bankTRANSFERtransfer
24012026-03-02BANK_ONLINE820.00to online bankTRANSFERtransfer
24012026-03-03BANK_ONLINE-820.00sent to friend’s landlordTRANSFERtransfer

Booked as IN and OUT, I would have earned €820 and spent €820 on rent I never had. If the money stays with me for weeks, it goes through PAYABLES instead, so I can see that I am holding it.

T25 · Swapping cash for a bank payment

A colleague needed cash; I got a bank payment back. Cash down, bank up, nothing spent.

iddatesourceamount €descriptiontypecategory
25012026-05-22CASH_EUR-8.00cash swapped for bank paymentTRANSFERtransfer
25012026-05-22BANK_CURRENT8.00cash swapped for bank paymentTRANSFERtransfer

Two legs, two different sources, same id. Exactly like an ATM withdrawal in reverse.

T26 · Shared bill paid back: NOT a transfer

I paid for two, my friend paid me back half. The refund is a positive amount in the same expense category, so it reduces my spending.

iddatesourceamount €descriptiontypecategory
26012026-05-16BANK_CURRENT-64.00dinner for twoOUTrestaurant
26012026-05-17BANK_CURRENT32.00friend pays back half via payment requestOUTrestaurant

The test is the question from the golden rule: did my net worth change? Yes, I paid €32 for my own dinner. So it is spending, not a transfer, and the refund belongs to the same category. Booked as IN, it would inflate income and spending by €32 each. Shop refunds and returned purchases work the same way. When the money comes back in a later month, the same idea runs through RECEIVABLES (T23).

T27 · Moving an amount between categories: NOT a transfer

Groceries partly bought as a gift. RECLASS takes 15 out of groceries and puts it in gifts. Type stays OUT; RECLASS nets to zero.

iddatesourceamount €descriptiontypecategory
27012026-05-25RECLASS15.00move from groceries to giftsOUTgroceries_seasonal_job
27012026-05-25RECLASS-15.00move from groceries to giftsOUTgifts_given

RECLASS rows are type OUT because they change what the spending is, not where the money is. I use it when the original bank line is already reconciled and I do not want to split it. The same trick moves an amount from income to spending, for example a work expense my employer paid back.

T28 · Money I cannot place yet

An unknown €12.34 arrives. It waits in SUSPENSE until I know what it was: a shop refund.

iddatesourceamount €descriptiontypecategory
28012026-04-02SUSPENSE-12.34unknown incoming, to investigateTRANSFERtransfer
28012026-04-02BANK_CURRENT12.34unknown incoming, to investigateTRANSFERtransfer
28022026-04-20SUSPENSE12.34identified: refund webshopOUTmisc

SUSPENSE should be empty at every year end. If it is not, the unexplained amount is visible instead of hidden in a random category.

T29 · Clearing FX_DIFF at the end of a period

The small rounding differences parked in FX_DIFF are a real cost. Twice a year I book them out to fx_costs.

iddatesourceamount €descriptiontypecategory
29012026-06-30FX_DIFF-0.38clear rounding differencesOUTfx_costs

€0.38 in this example (T20); in real life a few euros a year. Small, but booking it keeps every helper source at zero. Exchange differences on my own transfers never land here, because I adjust the rate instead (T10).

Foreign currency and counting cash

Every row is in euros; the local amount rides along in its own columns. That one decision keeps sums, pivots and charts simple, and it means I never need a rate table to read my own books.

  • fx_amount holds the original value, as it appeared on the receipt or the ATM screen, signed like amount: -120,000 VND for a dinner.
  • rate is local currency per euro. I calculate it from the cash withdrawal or exchange that filled the wallet: 10,250 THB for €277.70 gives 36.9103. Every payment from that cash uses the same rate until the next withdrawal, so the cash source in euros always matches the cash in my hand at what it cost me.
  • amount = fx_amount ÷ rate, rounded to cents. Check 5 compares the two on every row and must be exactly zero rows off. When rounding makes a row one cent out, I adjust the amount by hand.

On transfers between currencies I go one step further and change the rate by hand, so that the euro amount is identical on both legs (T10). The description then carries both amounts, such as “100.00 EUR = 3,690 THB”, so the real exchange is never lost.

Cash in local currency is counted, not trusted. Every few days I count the wallet and compare it with the running total of fx_amount on that cash source. The difference goes in as a row with daily code diff:

sourceamount €descriptiontypecategorydaily codelocal
CASH_THB-0.33cash count short 12 THBOUT_ASIAdaily_spendingdiff-12 THB

A short count is spending I forgot to log; an over count (positive) is a receipt I logged twice or change I got back. Small diffs every few days are normal. A big one means a lost note or a missing withdrawal, and that is worth searching for before it disappears into the averages.

Cash left over when I fly home stays in its own CASH source at the old rate. When I swap it, the swap is a transfer like T08; when I bring it back next season, it is simply still there.

This means my books value foreign cash at what it cost me, not at today’s rate. Sixty dollars bought for €51.24 stay €51.24 until I spend or swap them, even if the dollar rises. My net worth is therefore not exactly the market value of everything I hold. For what I want to know, what my life costs, the cost price is the right measure.

If I want to adjust to know my net wordth, I can revalue it with PAPER_GAIN_LOSS . Same with Crypto, I revalue it every month with PAPER_GAIN_LOSS (T16), so its balance follows the market.

Opening balances, year end and zero-amount rows

The file is one continuous table across all years. There is no closing and reopening of books; a few special rows keep it readable.

Opening balances

Every source starts with one OPENING_BALANCE row on the day before its first real row, with the balance at that moment. These rows are excluded from income and spending, but included in net worth. A new source, such as a new card, gets its own opening row (usually 0) when I start using it.

sourcedateamount €type
BANK_CURRENT2025-10-316,600.00OPENING_BALANCE
CASH_USD2025-10-3151.24OPENING_BALANCE (60 USD)

Year-end checkpoints

On 31 December I add a zero-amount row per source with the balance written in the description, for example “checkpoint 31 Dec 2025: balance 4,653.45”. It changes nothing, but a year later I can compare the running balance on that date with the text. If they differ, someone (usually me) edited an old row.

Zero-amount rows

A row with amount 0 never changes a total, which makes it a safe place for information:

  • Statistics only. Rent paid in euros from a card has no baht amount, but my Asia statistics want nights and local prices. A STATS_ONLY row with amount 0, the same id and the local amount carries that detail without counting the money twice.
  • Notes. “Counted: notes 230, coins 12.00” on the cash source, on the day I counted.
  • Placeholders. An expense I know happened but whose amount I do not know yet. It shows up in my uncategorised list until I fill it in.

The only rule: a zero-amount row must never be the only place a real payment is recorded.

Checks that keep the books honest

Seven checks, from cheap to thorough, catch almost every mistake. The first five are formulas in the example workbook; the last two need the outside world.

#CheckShould beWhen I run itWhat a failure usually means
1Sum of all TRANSFER rows0.00Every entry sessionA leg is missing, doubled or has the wrong sign
2TRANSFER sum per id0.00 for every idWhen check 1 failsPoints to the exact broken pair
3TRANSFER sum per month0.00, or a known pair across monthsMonthlyA transfer dated differently on each leg (T09)
4Helper sources0.00, or a known open amountMonthlyA claim not yet paid, a deposit still out, a forgotten write-off
5amount vs ROUND(fx_amount ÷ rate, 2)Exactly 0 rows offOn every entryA typo, a wrong sign or a rounding cent; adjust the amount by hand
6Balance per real sourceEqual to bank, app or counted cashMonthly for banks, every few days for cashA missing, doubled or wrongly signed row
7Bank statement line by lineEvery line foundMonthlyShows which row is the problem

Check 4 deserves a word. Every helper source has a story: RECEIVABLES open means an insurer still owes me, PREPAID open means a stay is still ahead. I keep a short list of what each open amount is. Anything on a helper source that I cannot explain is a mistake.

Reconciling with the bank

Check 6 tells me that a balance is off; check 7 tells me why. I load the bank’s CSV export next to my ENTRIES and match every statement line to rows on that same source. A small browser tool does it for me, in this order:

  1. One to one. Same amount, same day; then widening to a week either side, because shops and banks do not agree on dates.
  2. Net of fees. One ledger row equal to a bank line plus its fee line, as multi-currency apps report them separately.
  3. Split. Several ledger rows with the same date that add up to one bank line, as in T07 or a supermarket bill split over groceries and gifts.
  4. Offsetting pairs. Two ledger rows that cancel each other, such as a reclassification, and have no bank line on purpose.
  5. Sign flipped. A ledger row of +X where the bank has -X.
  6. Pending or reversed. Booked by me, still pending or reversed at the bank.
  7. Doubles and leftovers. A ledger row matching one that is already matched is a double; anything else is either missing from my books or missing from the bank.

The tool then shows how the difference between bank and ledger built up over time, so a jump on a single date points straight at the culprit. The same matching works for app accounts in foreign currency, by comparing fx_amount instead of amount.

Common mistakes and how the checks catch them

Most errors are one of eleven, and most of them trip a specific check.

MistakeWhat it doesCaught byFix
Only one leg of a transfer enteredTotal TRANSFER is off by the full amount1, 2, 6Add the missing leg with the same id
Both legs with the same signTotal off by twice the amount1, 2Flip the sign on the receiving leg
ATM fee or other cost entered as positive instead of negativeThe wallet grows by the fee instead of shrinking; spending is too low by twice the fee5 (fx_amount is negative, amount positive), cash countMake the amount negative (T03)
Fee left inside the transferLegs differ by the fee; the fee is missing from spending1, 2Split the fee into its own negative OUT row (T03, T07)
Euro amounts on two legs differ after an exchangeTRANSFER no longer sums to zero1, 2Adjust the rate on the foreign leg by hand (T10)
Card settlement booked as spendingEvery card purchase counted twice6 on the card (balance drifts negative)Make the settlement a transfer (T05)
Deposit or loan booked as spendingA spike in spending, later a fake incomeNot by a formula; watch for big IN rows from peopleUse DEPOSITS or a person account (T11, T21)
Shared-bill refund booked as INIncome and spending both too highNot by a formula; by reviewing IN rowsBook it as positive OUT in the same category (T26), or via RECEIVABLES when it is paid back later (T23)
A bank line entered twiceBalance too high or too low by one line6, 7 (shows as a double)Delete one of the two
Wrong local amount or rateWrong euro amount, cash count drifts5, cash countCorrect the typo
Typing variants of a code, such as “out”, “OUT ” or “TRANSFR”Rows vanish from pivots and reportsA pivot on type shows an extra rowUse a dropdown list on type, group and source

The last one sounds trivial and is the most common in a file that grows for years. A trailing tab after a type code is invisible in Excel and splits a pivot in two. A dropdown on the columns that hold codes prevents it entirely.

The example workbook

The workbook ledger_transfer_examples.xlsx has 761 made-up rows from 31 October 2025 to 30 June 2026: opening balances, all 29 transfer cases, the housekeeping rows and a complete background season (case B0) with daily cash spending in Thailand and Vietnam, ATM withdrawals, fixed costs, a storage unit, health insurance, subscriptions and a few months of work in Europe. Because the codes are translated on loading, the Ledger dashboard (index.html) reads the file as it is and fills every tab, including a second net-worth line without the pension savings. Checks 1, 2, 4 and 5 are at zero; check 3 flags January and February on purpose.

TabWhat it shows
READMEEvery case with its row count, TRANSFER sum and effect on income and spending, all as formulas
ENTRIESThe rows, with a case column to filter on, a note per row, dropdowns on source, type, group and daily code, and an fx check per row
CHECKSChecks 1 to 5 as formulas, the TRANSFER sum per id and per month, and the balance per source in euros and local currency
SUMMARYIncome, spending and net worth, plus a month table that shows transfers changing nothing
CODESThe lists behind the dropdowns

The two flagged months are the month-boundary case T09: -500.00 leaves the bank on 31 January and lands on 2 February. That is exactly what check 3 is for: not every non-zero month is an error, but every one needs a reason. Deposits and fraud no longer show up here, because DEPOSITS and RECEIVABLES carry both legs within the same month. On transfer legs in a foreign currency, the rate column is a formula (fx_amount ÷ amount), so it shows the rate calculated from the withdrawal or exchange itself.

The README tab is the quickest way to see the point of the whole system. The column “effect on spending” uses the same sign as amount, so costs are negative: T01, T02, T04, T09 and most other transfers add nothing, while the cases that contain a real cost (the ATM fee in T03 at -6.77, the write-off in T15 at -220.00, the forgiven loan in T21 at -50.00) add exactly that cost and no more.

Closing thoughts

A transfer is never difficult on its own. What makes my books hard is the number of places money can wait: in a wallet, with a landlord, on a friend’s QR code, with an insurer, or in a booking I paid a year ahead. Giving each of those places a source, and booking every transfer as two equal legs with one id, turns all of them into the same simple case.

If you build something similar, start with three things: one row per movement per source, a TRANSFER type that must sum to zero, and a monthly balance check against the real world.

It takes me a few minutes a day. In return I know exactly where my money goes, in any country, in any currency, and what my net worth is.

Let me help you!

Want this for your own money?

Building a system like this took me years of trial and error. You do not have to start from scratch. I can set it up for you, adapt it to your life, and help you keep it going until it runs on its own.

What I can do for you

  • Set it up. Your own ledger in Excel or Google Sheets, with your accounts, cards, wallets and currencies as sources, and the checks already built in.
  • Personalise it. Categories that match how you live, whether you are a digital nomad, a seasonal worker, someone with accounts in several countries, or simply someone who wants to know where the money goes.
  • Bring your history in. I turn the CSV exports of your banks into ledger rows and write the rules that label the recurring lines for you.
  • Build your dashboard. The interactive dashboard from this post, set up for your data: what a month really costs, where it goes, and how your savings grow.
  • Guide you. A few sessions to learn the method: how to book a transfer, a deposit, a loan or a refund, what to do when a check fails, and how to keep it to a few minutes a day.

Who I am

I am a data analyst and a life and financial coach. I have lived and worked across Europe and Southeast Asia, so I know what it is like to have money in four currencies and a wallet full of receipts. I work in English, Dutch, French, Italian and German.

My work is about insight and habits, not tax or investment advice. For those I gladly point you to a specialist.

Interested? Send me a short message through the contact page and tell me a little about your situation. We will start with a free conversation to see whether this is the right fit for you.

Appendix A: Is this double-entry bookkeeping?

Partly. The ideas are the same, but the way rows are written is simpler. Every row of mine is a journal entry with one side written out (the source) and the other side folded into its labels (type, group and category).

Where it matches

In my booksIn double-entry terms
A transfer: two legs, same id, sum zeroA journal entry where debit equals credit
Sources: bank, cash, card, appsBalance-sheet accounts (assets, and the card as a liability)
RECEIVABLES, PAYABLES, PREPAID, DEPOSITSDebtors, creditors, prepaid expenses, deposits paid
Groups and categories of IN and OUTIncome and expense accounts (profit and loss)
OPENING_BALANCE rowsOpening balance sheet, with net worth as equity
SUSPENSE, FX_DIFF, PAPER_GAIN_LOSSSuspense account, exchange differences, revaluation
Pay booked in the month I worked, rent in the month I stayAccruals and deferrals
Checks 6 and 7 against the bankBank reconciliation

Where it differs

  • A purchase is one row, not two. Double entry writes €40 groceries as debit Groceries and credit Bank. I write one row on the bank and let the category stand in for the second line. Only money moving between two balance-sheet accounts gets two real rows.
  • The zero check covers less. In double entry the whole ledger always balances, so any one-sided entry shows up at once. My “sum is zero” check only covers TRANSFER rows. A wrong amount on a purchase is caught only by comparing balances with the bank or the counted cash.
  • Signs instead of debit and credit. Plus is money in, minus is money out, seen from the source. A refund is simply a positive row in an expense category, where double entry would credit the expense account.
  • Cash basis by default. Most rows are booked when the money moves. Accruals (RECEIVABLES, PREPAID) are used only when the month really matters, not as a rule.
  • No year-end closing. Double entry closes profit and loss into equity every year and starts new books. My file is one continuous table. A zero-amount checkpoint row on 31 December is all that marks the year end.
  • No separate equity account. Net worth is simply the sum of every row, opening balances included.

In short: it is double entry where it matters most, for money moving between my own pots. For the rest it is a well-labelled cash book, kept honest by the bank.


Appendix B: The helper file

I use a helper script to categorise and check for zero sums.

AI avant la lettre: the categoriser

Long before language models, my books already had a tab that “knows” what most bank lines are. It is called RULES and it is just a table: a search term, the type, group and category to fill in, and optionally an amount.

termtypegroupcategoryamount
health insurerTRANSFERTRANSFERtransfer-37.50
health insurer nvOUT_FIXEDFIXED_COSTShealth_insurance-152.00
google paymentOUT_FIXEDFIXED_COSTSsubscriptions-9.99
storage shareOUT_FIXEDFIXED_COSTSstorage_unit
card settlementTRANSFERTRANSFERtransfer
supermarketOUTGROCERIESgroceries

The amount column is what makes it clever enough. The insurer appears on two kinds of bank lines: the monthly deductible instalment of exactly -37.50 is a transfer to INSURER_POT (T13), the premium of -152.00 is a fixed cost. Each term may appear only once, because the rules are looked up by term; a longer, more specific term is how I tell two lines from the same company apart.

The script works in four steps:

  1. Make a timestamped backup of the workbook. Always, before anything else.
  2. Read RULES into a lookup of term → (type, group, category, amount).
  3. Walk through ENTRIES. For each row whose type, group and category are all empty, take the first rule whose term occurs in the description (ignoring case) and whose amount matches, if the rule has one. Fill in the three columns and write “generated” plus today’s date in a note column.
  4. Save a timestamped copy and the workbook itself.

The heart of it is a dozen lines:

for row in entries.iter_rows(min_row=2):
    desc, amount = row[DESC].value, row[AMOUNT].value
    if not desc or any(row[c].value for c in (TYPE, GROUP, CATEGORY)):
        continue  # nothing to match, or already labelled: never overwrite
    for term, (type_, group, category, rule_amount) in rules.items():
        if term in str(desc).lower() and (not rule_amount or amount == rule_amount):
            row[TYPE].value, row[GROUP].value, row[CATEGORY].value = type_, group, category
            row[NOTE].value = f"generated {date.today():%Y-%m-%d}"
            break  # first match wins

Three design choices keep it trustworthy:

  • It never overwrites. A row I labelled by hand stays exactly as it is, so I can run the script as often as I like.
  • First match wins. Rules are tried from top to bottom, so specific ones (with an amount, or a longer term) go above general ones that would also match.
  • It signs its work. The “generated” note shows which rows a rule filled in, so I can filter them and spot a rule that is too greedy.

The rule list is small. About 120 rules, of which 17 also check the amount, have labelled over 800 rows so far. Those are exactly the boring ones: subscriptions, insurance, phone, storage, card settlements and the same few supermarkets. The rest, the interesting lines, still pass through my own head.

A second helper: transfers without a partner

The same script file holds a helper for check 2 when a pair has no shared id yet. Per year it takes all TRANSFER rows, groups them by absolute amount, pairs each positive row with a negative one, and writes whatever is left over to a tab per year. That short list is where the missing leg or the wrong sign is hiding.

Similar Posts