Files
siddharthd b4a116c134
ci / lint-test (push) Successful in 46s
feat(scripts): import the matched split history as settled
Adds --write to the matcher. Wrote 1,242 split rows across 657 transactions.

Imported settled, and that is the whole design. These obligations were
discharged years ago on a platform we no longer run, and their residual is
already carried by transaction 2348. Writing them unsettled would re-open
roughly $40k of debts that were paid. ACTIVE_OBLIGATION keeps settled splits
out of every owed figure while myShare/mySplitOf still count them, which is
exactly the asymmetry this needs: the import exists to correct historical
SPEND, not to move a balance.

Effect: $35,259 leaves my historical spend -- $13,088 in 2024, $22,117 in
2025 -- because a $200 grocery shop that was always half hers no longer reads
as $200 of mine. Balances are byte-identical before and after (Molina
-1226.72/145, Sonu 5428.08/419), which is the assertion that matters.

Shares are written as the CSV computed them, so a 50/50 row can land as
50.01/49.99. That is faithful rather than tidy; no transaction exceeds 100%.

Rehearsed on the 37-row Rome file first (24 rows) and verified before the full
run -- both the balances and one split read back through the API.
2026-07-28 12:02:57 +10:00

377 lines
14 KiB
Python

#!/usr/bin/env python3
"""Match SplitMyExpenses CSV rows to transactions already in the ledger.
Dry-run by default. It prints what it would do and writes nothing; `--write`
is a separate step (task #9) and is deliberately not implemented here.
Why this exists
---------------
The CSVs are the record of how expenses were actually shared before this app
existed. Importing them is what makes historical *spend* correct: without a
split row, a $200 grocery shop counts as $200 of my spending when half of it
was never mine. The balances are already settled by carryover transaction 2348,
so these splits are imported as `settled = true` and move no balance.
Three things make the matching harder than "same date, same amount":
1. **Dates are ambiguous across files.** The household file writes D/M/YYYY;
the four trip files write ISO. 474 rows parse validly under both readings,
so the format is decided per file, from the file, and never guessed per row.
2. **The sign convention is not "who paid".** A person's column is their net
balance impact: positive means they are owed. So the payer is whoever is
positive, and the other person's share is |their negative| / cost. A row
reading +cost / -cost therefore means the other party owes 100% -- NOT that
the expense was unshared, which is the reading that would fake an
arrangement change.
3. **A settlement is not an expense.** Rows where one person hands the other
money must not become split transactions; they are already represented by
the carryover.
Usage:
.venv/bin/python scripts/split_csv_match.py [--verbose] [--file NAME]
"""
from __future__ import annotations
import argparse
import csv
import glob
import os
import re
import sys
from collections import Counter
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
import psycopg2
import psycopg2.extras
DUMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dump")
# Read from the same place the app does rather than hardcoding a container IP,
# which changes on every recreate.
def db_url() -> str:
url = os.environ.get("DATABASE_URL")
if url:
return url
for envfile in (".env", ".env.test"):
path = os.path.join(os.path.dirname(DUMP_DIR), envfile)
if not os.path.exists(path):
continue
for line in open(path):
if line.startswith("DATABASE_URL"):
return line.split("=", 1)[1].strip().strip('"').strip("'")
sys.exit("No DATABASE_URL found (env, .env, or .env.test)")
# The two people in these files. The CSV writes full names; the ledger uses
# first names.
CSV_ME = "Siddharth Bose"
CSV_THEM = "Meghalee"
PARTICIPANT_ME = 1
PARTICIPANT_THEM = 4
# A settlement transfers money; it is not a shared cost. These are the
# descriptions SplitMyExpenses uses for them.
SETTLEMENT_PAT = re.compile(
r"payment|settle|debts? remainder|reimburse|transfer to|paid back", re.I
)
@dataclass
class CsvRow:
source: str
line: int
when: date
description: str
category: str
cost: float
currency: str
net_me: float
net_them: float
# Filled in by classify()
mode: str = ""
payer: int = 0 # participant id who paid
ower: int = 0 # participant id who owes
ower_share: float = 0.0 # 0-100
def __str__(self) -> str:
return f"{self.source}:{self.line} {self.when} {self.description[:38]!r} ${self.cost:.2f} [{self.mode}]"
@dataclass
class MatchReport:
rows: list = field(default_factory=list)
matched: list = field(default_factory=list)
ambiguous: list = field(default_factory=list)
unmatched: list = field(default_factory=list)
skipped: list = field(default_factory=list)
def sniff_date_format(sample: list[str]) -> str:
"""Decide ISO vs D/M/YYYY for a whole file.
Deciding per row is what produces a ledger where January and February are
silently swapped for some rows and not others. A file is written by one
exporter in one format, so the file is the unit of decision.
"""
if not sample:
return "%Y-%m-%d"
slashes = sum(1 for s in sample if "/" in s)
return "%d/%m/%Y" if slashes > len(sample) / 2 else "%Y-%m-%d"
def parse_rows(path: str) -> list[CsvRow]:
with open(path, newline="", encoding="utf-8-sig") as fh:
reader = list(csv.DictReader(fh))
if not reader:
return []
# Header names vary in quoting between exports.
def col(row: dict, *names: str):
for n in names:
for k in row:
if k.strip().strip('"') == n:
return row[k]
return None
fmt = sniff_date_format([col(r, "Date") or "" for r in reader[:40]])
out: list[CsvRow] = []
for i, r in enumerate(reader, start=2):
raw_date = (col(r, "Date") or "").strip()
try:
when = datetime.strptime(raw_date, fmt).date()
except ValueError:
continue
try:
cost = float(col(r, "Cost") or 0)
net_me = float(col(r, CSV_ME) or 0)
net_them = float(col(r, CSV_THEM) or 0)
except ValueError:
continue
out.append(
CsvRow(
source=os.path.basename(path),
line=i,
when=when,
description=(col(r, "Description") or "").strip(),
category=(col(r, "Category") or "").strip(),
cost=cost,
currency=(col(r, "Currency") or "AUD").strip(),
net_me=net_me,
net_them=net_them,
)
)
return out
def classify(row: CsvRow) -> CsvRow:
"""Work out who paid and what share the other person owes.
A person's column is their net balance impact, not their share: positive
means they are owed money. So the payer is whoever is positive.
"""
if row.cost == 0:
row.mode = "zero-cost"
return row
if SETTLEMENT_PAT.search(row.description):
row.mode = "settlement"
return row
# Both zero against a real cost: recorded but not shared.
if abs(row.net_me) < 0.005 and abs(row.net_them) < 0.005:
row.mode = "unshared"
return row
if row.net_me > 0:
row.payer, row.ower, owed = PARTICIPANT_ME, PARTICIPANT_THEM, abs(row.net_them)
else:
row.payer, row.ower, owed = PARTICIPANT_THEM, PARTICIPANT_ME, abs(row.net_me)
row.ower_share = round(owed / row.cost * 100, 2)
if abs(row.ower_share - 50) < 0.6:
row.mode = "50/50"
elif abs(row.ower_share - 100) < 0.6:
row.mode = "other-owes-all"
else:
row.mode = f"uneven-{row.ower_share:.0f}"
return row
def norm(s: str) -> set[str]:
return {w for w in re.split(r"[^a-z0-9]+", (s or "").lower()) if len(w) > 2}
def score(row: CsvRow, tx: dict) -> float:
"""How well a ledger row matches a CSV row. Amount and date gate it;
description only ranks among survivors."""
days = abs((tx["transaction_date"] - row.when).days)
s = 100.0 - days * 4
overlap = norm(row.description) & (norm(tx["description"]) | norm(tx["merchant_normalized"]))
s += 12 * len(overlap)
return s
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--verbose", action="store_true")
ap.add_argument("--file", help="only this CSV (substring match)")
ap.add_argument("--window", type=int, default=5, help="date tolerance in days")
ap.add_argument(
"--write", action="store_true",
help="actually insert the splits (settled=true). Without this, nothing is written.",
)
args = ap.parse_args()
paths = sorted(glob.glob(os.path.join(DUMP_DIR, "*SplitMyExpenses*.csv")))
if args.file:
paths = [p for p in paths if args.file in os.path.basename(p)]
if not paths:
sys.exit("No SplitMyExpenses CSVs found in dump/")
conn = psycopg2.connect(db_url())
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# Superseded rows are duplicates; matching against them would attach a split
# to a row nothing else counts.
cur.execute(
"""
SELECT t.id, t.transaction_date, COALESCE(t.description,'') AS description,
COALESCE(t.merchant_normalized,'') AS merchant_normalized,
COALESCE(t.amount_aud, t.amount)::float AS amount,
t.superseded_by_id,
EXISTS(SELECT 1 FROM transaction_splits x WHERE x.transaction_id=t.id) AS has_split
FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
WHERE COALESCE(t.owner_id, s.owner_id) IN (%s, %s)
AND t.superseded_by_id IS NULL
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
AND t.transaction_type IN ('debit','fee','interest')
""",
(PARTICIPANT_ME, PARTICIPANT_THEM),
)
txs = cur.fetchall()
# Index by rounded amount: the amount must agree, so it is the only cheap
# gate that never needs fuzzy comparison.
by_amount: dict[float, list[dict]] = {}
for t in txs:
by_amount.setdefault(round(t["amount"], 2), []).append(t)
rep = MatchReport()
modes: Counter = Counter()
candidates: list = []
for path in paths:
for row in parse_rows(path):
classify(row)
rep.rows.append(row)
modes[row.mode] += 1
if row.mode in ("settlement", "zero-cost", "unshared"):
rep.skipped.append(row)
continue
if row.currency != "AUD":
rep.skipped.append(row)
continue
cands = [
t for t in by_amount.get(round(row.cost, 2), [])
if abs((t["transaction_date"] - row.when).days) <= args.window
]
if not cands:
rep.unmatched.append(row)
else:
candidates.append((row, cands))
# Assign one-to-one, best pair first.
#
# Without this a ledger row can be claimed by several CSV rows. That is not
# hypothetical: the NZ trip has two identical $10.16 Uber trips on one day
# and three matching ledger rows, and four PayMyPark rows in the same shape.
# Attaching a split twice is harmless (the unique key absorbs it) but it
# leaves the second CSV row silently unrepresented while looking matched,
# which is a lie in the report rather than a defect in the data.
scored = sorted(
((score(row, t), row, t) for row, cands in candidates for t in cands),
key=lambda x: -x[0],
)
taken_tx: set[int] = set()
taken_row: set[int] = set()
for s, row, t in scored:
if id(row) in taken_row or t["id"] in taken_tx:
continue
taken_row.add(id(row))
taken_tx.add(t["id"])
rep.matched.append((row, t))
for row, cands in candidates:
if id(row) not in taken_row:
rep.ambiguous.append((row, cands))
total = len(rep.rows)
considered = total - len(rep.skipped)
print(f"CSV rows {total}")
print(f" skipped {len(rep.skipped)} (settlements, zero-cost, unshared, non-AUD)")
print(f" considered {considered}")
print(f" matched {len(rep.matched)} ({len(rep.matched)/max(considered,1)*100:.1f}%)")
print(f" ambiguous {len(rep.ambiguous)}")
print(f" unmatched {len(rep.unmatched)}")
print()
print("Row modes:")
for m, n in modes.most_common():
print(f" {m:<18} {n}")
already = sum(1 for _, t in rep.matched if t["has_split"])
print()
print(f"Of the matched, {already} already carry a split and would be left alone;")
print(f"{len(rep.matched) - already} would gain one.")
if args.write:
# Imported as settled: these obligations were discharged on a platform
# we no longer run, and the residual is already carried by transaction
# 2348. Writing them unsettled would re-open ~$40k of debts that were
# paid years ago. See ACTIVE_OBLIGATION in analytics-sql.ts -- settled
# rows stay in spend and leave every owed figure, which is exactly the
# point: this import exists to correct historical SPEND.
SETTLED_ON = "2026-01-09" # the carryover's date
written = 0
for row, tx in rep.matched:
if tx["has_split"]:
continue
payer_share = round(100 - row.ower_share, 2)
pairs = [(row.ower, row.ower_share)]
if payer_share > 0:
pairs.append((row.payer, payer_share))
for pid, share in pairs:
cur.execute(
"""
INSERT INTO transaction_splits
(transaction_id, participant_id, share_percent, settled, settled_at)
VALUES (%s, %s, %s, true, %s)
ON CONFLICT (transaction_id, participant_id) DO NOTHING
""",
(tx["id"], pid, share, SETTLED_ON),
)
written += cur.rowcount
conn.commit()
print(f"\nWROTE {written} split rows (settled=true, settled_at={SETTLED_ON}).")
if args.verbose:
print("\n--- ambiguous ---")
for row, cands in rep.ambiguous[:40]:
print(f" {row}")
for t in cands[:3]:
print(f" -> #{t['id']} {t['transaction_date']} {t['description'][:44]!r}")
print("\n--- unmatched ---")
for row in rep.unmatched[:60]:
print(f" {row}")
conn.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())