# PLAN-M9: Splitwise importer ## 1. Goal Implement the one-off CLI `kvitt-import` that imports Splitwise CSV exports, one file per group, into Kvitt accounts, repeatably without duplicates, with audit entries and no pushes. It must read both the English and the Swedish export variants and map Splitwise categories in both languages through one committed table. ## 2. Read first 1. SPEC §10.8 Splitwise import (all of it, including the two header variants and the category table). 2. SPEC §4 FR-17 Splitwise import (AC-17.1 to AC-17.6). 3. SPEC §12 OQ-2 (answered) and `docs/decisions.md` D-003. 4. SPEC §6.3 (crate table: `kvitt-import` reuses `kvitt-server` as a library). 5. SPEC §14.1 SQLite schema v1 (`import_key` columns, `accounts`, `expenses`, `expense_splits`, `settlements`, `settlement_allocations`, `audit_log`) and its server-side rules. 6. SPEC §9.1 Splits (exact rule), §9.6 Limits and validation, §14.4.7 Audit diff (`create`), §14.5.2 (`compute_split`, `validate_settlement`). 7. SPEC §11.2 (row M9), §14.7, §14.8. 8. Files: `server/crates/kvitt-server/src/lib.rs` and the modules it exposes (DB pool, sequence, the `AuditWriter` trait and M4's implementation, D-015), M2a's shell of `server/crates/kvitt-import/` (D-004), `server/Cargo.toml`, `CLAUDE.md` (ANTI-PATTERNS: never commit personal data or the Splitwise CSV). ## 3. Already established - Command shape: `kvitt-import --db --file --account "" --user "=" --user "=" [--fx =]... [--dry-run]` (§10.8). - One CSV per group; the account is created with default 50/50 if no non-deleted account with that name exists, else rows go into it (§10.8). - Two header variants: English `Date,Description,Category,Cost,Currency` and Swedish `Datum,Beskrivning,Kategori,Kostnad,Valuta`, then one column per member by full Splitwise name; member columns appear in either order, so names are read from the header and matched to `--user`; never by position; any other header fails with a clear message and writes nothing (§10.8, D-003). - Fields with commas are double-quoted; ISO dates; point decimal; amounts converted to öre exactly by string parsing; a trailing empty line then a `Total balance` / `Totalsumma` row, both ignored, as are blank rows (§10.8). - Expense rows: payer = member with positive net; `share_other = net_payer`, `share_payer = C − net_payer`, mode `exact`; both nets 0 → payer the owner, mode `full` on the owner; nets not summing to 0 or a negative share → invalid row, abort, nothing written (§10.8). - Payment rows (`Payment` or `Betalning`) become settlements: `from` = positive-net member, `to` = the other, amount = that net, one allocation to the account, `note` = description truncated to 200 code points (§10.8). - Non-SEK rows abort unless `--fx` is given; then `round_half_up(amount_ore × rate)` per value and exact shares recomputed (§10.8). The real exports are all SEK (D-003). - `import_key` = lowercase hex SHA-256 of `splitwise|v1||||||||`; existing keys are skipped (AC-17.2, §10.8). - Created rows: owner as `created_by_user_id` and `updated_by_user_id`, `updated_by_device_id` null, `created_at` = `updated_at` = import time, one audit `create` with `source` `import`, new `server_version`s, no pushes (§10.8, AC-17.6, AC-13.3). - Description truncated to 100 code points, full original into `notes` when truncated (§10.8). - Category mapping: case-insensitive exact match on the category column against the English and Swedish names of the §10.8 table, committed as a data file in the crate; unknown names map to `other`, are listed in the summary with row counts, and the total unmapped count is a warning (§10.8, D-003). - `--dry-run` prints rows read, expenses, settlements, skipped duplicates, unknown categories and non-SEK rows, and writes nothing (AC-17.5). - The real CSV files live in `export/` in the main checkout (git-ignored); they are never committed, copied into the repository, or quoted in code, fixtures, tests or reports (D-003, CLAUDE.md). Mapping notes (recorded here and in D-003 only): the English export (group "hemma", account "Hemma") has the header line `Date,Description,Category,Cost,Currency,Amelie Bennich,Jacob Hagstedt`; the Swedish export (general group, account "Vardag") has `Datum,Beskrivning,Kategori,Kostnad,Valuta,Jacob Hagstedt,Amelie Bennich`. The owner is Jacob Hagstedt; the partner is Amelie Bennich, display name Amelie. Fixtures use synthetic member names, never these. ## 4. Owned paths From SPEC §14.7, verbatim: `server/crates/kvitt-import/**` (owner M9; after landing: "same"). Also from §14.7: M9 "may add a dependency line to a crate's `Cargo.toml` (second to land resolves `Cargo.lock` on rebase)". Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator. ## 5. Deliverables - `server/crates/kvitt-import/Cargo.toml`: depends on `kvitt-domain` and `kvitt-server` (library). - `server/crates/kvitt-import/src/main.rs`: CLI parsing per §10.8. - `server/crates/kvitt-import/src/**`: CSV reader (quoting, both header variants, member matching by name), row classification (expense, payment, total, blank), amount parsing, split derivation, fx conversion, import key, category mapping, writer (one transaction, audit through the `AuditWriter` trait, nothing through `PushQueue`), summary. - `server/crates/kvitt-import/data/splitwise_categories.csv`: the mapping table, columns `splitwise_name,language,default_key`, every English and Swedish name of the §10.8 table (location fixed in §10.8 and §14.7, v0.3). - `server/crates/kvitt-import/tests/fixtures/*.csv`: synthetic CSVs only (English variant, Swedish variant with reversed member order, quoted commas, payments in both languages, totals rows in both languages, blank rows, a non-SEK row, a truncated description, an unknown category, an invalid row, duplicate identical lines). - `server/crates/kvitt-import/tests/*.rs`: integration tests against a temp database created with the server's migrations. ## 6. Steps 1. Worktree. From the main checkout `/Users/jhag/projects/kvitt`: `git worktree add .claude/worktrees/agent-m9 -b agent-m9 main`, then in `.claude/worktrees/agent-m9`: `git reset --hard main`. Do not copy `export/` into the worktree. Run `tools/install-git-hooks.sh`. 2. Read §2 in order. Confirm M2a and M4 have landed (the audit writer exists); if not, stop and report. 3. Versions: a CSV crate (for example `csv`) and a CLI parser at latest stable from crates.io / docs.rs; record each. 4. Write the category table file from §10.8 and a test that every name of the §10.8 table appears in it exactly once. 5. Write the synthetic fixtures, then the tests (AC-17.1 to AC-17.6, both variants, member order, header failure, invalid row aborts with nothing written, re-run creates nothing, dry-run writes nothing, fx conversion, no pushes, audit `source` `import`). 6. Implement until the tests pass. 7. Optional real-file dry run, only if the start prompt allows: run `kvitt-import --dry-run` against a throwaway database with each file in `/Users/jhag/projects/kvitt/export/`, report aggregates only (row counts, unmapped category names with counts; no descriptions, no amounts). 8. Run the gate (§7). Commit on `agent-m9`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree. Check `git status` shows no CSV from `export/`. ## 7. Gate From SPEC §11.2, verbatim: "Server gate". Command, from `server/`: `cargo fmt --check && cargo clippy -- -D warnings && cargo test`. Green means: all three exit 0 in this session; `test result:` lines for `kvitt-import` show 0 failed and include each case of step 5. ## 8. HUMAN steps From SPEC §11.2, verbatim: "Provide the CSV files (kept outside git) when OQ-2 is answered". Where in the flow: already done; both exports are in `export/` (git-ignored) and OQ-2 is answered (D-003). They are only used by the optional dry run of step 7; the gate uses synthetic fixtures. ## 9. Out of scope - Running the real import into the production database (M11). - Changing the server schema, audit writer or sync (contract request). - Any export feature (§2 Non-goals); any Android work. - Committing, copying or quoting the real CSV files. ## 10. Report - Branch `agent-m9`, head commit hash after rebasing on `main`. - Gate output: fmt and clippy exit status, every `test result:` line. - Versions of crates added, with sources. - Optional dry-run aggregates (row counts per file, unmapped category names with counts), if run. - Proposed decision entries (never written to `SPEC.md` or `docs/decisions.md` by this agent) or `TODO(spec)` occurrences. - Contract requests, open questions, spec gaps (with section numbers). ## 11. Numbering Decision ids, spec version and schema version are assigned by the orchestrator in the start prompt; use `TODO(spec)` if none was given.