imprnt-plugin-kopeika 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # imprnt-plugin-kopeika
2
+
3
+ A deterministic, local-first personal-finance and net-worth CLI, packaged as an imprnt plugin. It
4
+ parses bank CSV exports into one clean, deduplicated ledger, categorizes via ratified rules, tracks
5
+ savings, and projects net worth, then renders a self-contained bilingual dashboard. No LLM touches a
6
+ row at runtime. All personal data lives in the plugin's own `data/` folder (local only, never on a remote), never in a vault note.
7
+
8
+ It is a sibling of [imprnt](https://github.com/aleksandr-bogdanov/imprnt). The shared idea: the LLM
9
+ builds the tools at authoring time, the deterministic tools do the work at runtime, and verification is
10
+ structural (golden tests plus reconcilable invariants). The problem it answers is the ZenMoney one:
11
+ categorize each merchant once and it is automatic forever, with no per-transaction manual entry and no
12
+ untrustworthy auto-categorization.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ imprnt plugin add kopeika
18
+ ```
19
+
20
+ This fetches the package, copies it to `plugins/kopeika/` in your project, and wires
21
+ `@plugins/kopeika/agent.md` into `CLAUDE.local.md` (gitignored, per-machine). To wire it by hand
22
+ instead, add `@plugins/kopeika/agent.md` to `CLAUDE.local.md` yourself.
23
+
24
+ Then create your profile: copy `plugins/kopeika/profile.example.json` to
25
+ `plugins/kopeika/data/profile.json` and fill it in (see Profile below). Without a profile the tool
26
+ runs in generic mode (raw labels, no net-worth layer).
27
+
28
+ ## Remove
29
+
30
+ ```bash
31
+ imprnt plugin rm kopeika # unwire it
32
+ imprnt plugin rm kopeika --purge # and delete plugins/kopeika/
33
+ ```
34
+
35
+ ## The monthly run
36
+
37
+ kopeika does not take hand-typed transactions. You feed it the bank's CSV exports. Commands run as
38
+ `imprnt kopeika <cmd>` (the core dispatches to `node plugins/kopeika/kopeika.js`).
39
+
40
+ 1. **Export** a fresh CSV from each account (re-importing an overlap is a safe no-op, dedup handles it).
41
+ 2. **Import** each file with its stable `--account` label:
42
+ ```bash
43
+ imprnt kopeika import <revolut|n26|trading212|tbank|alfa> <file> --account <label> --owner <owner>
44
+ ```
45
+ 3. **Categorize and match transfers:**
46
+ ```bash
47
+ imprnt kopeika categorize # applies data/rules.csv
48
+ imprnt kopeika transfers # pairs internal account-to-account legs
49
+ imprnt kopeika categorize --review # the one judgement step: new merchants by spend
50
+ ```
51
+ For each new merchant worth a label, add one line to `data/rules.csv` and re-run `categorize`.
52
+ 4. **Verify:** `imprnt kopeika report` and `imprnt kopeika project`.
53
+
54
+ ### Correcting an existing transaction
55
+
56
+ The ledger is derived, so you rarely hand-edit a row. You change the data file that governs it, then
57
+ re-run:
58
+
59
+ | You want to change | Edit | Then |
60
+ |---|---|---|
61
+ | A wrong or blank category | `data/rules.csv` (one rule) | `categorize` |
62
+ | Floor vs flex of a category | `data/tiers.csv` | nothing, `report` reads it live |
63
+ | A savings destination | `data/savings.csv` | `project` |
64
+ | A merchant display name / note, or net-worth marks | `data/profile.json` | re-render |
65
+
66
+ A clean reset is `rm data/ledger.csv` then re-import every account.
67
+
68
+ ## Commands
69
+
70
+ ```
71
+ imprnt kopeika import <revolut|n26|trading212|tbank|alfa> <file> --account <name> --owner <owner>
72
+ imprnt kopeika categorize [--review]
73
+ imprnt kopeika transfers
74
+ imprnt kopeika recurring [--min-months N] [--from YYYY-MM]
75
+ imprnt kopeika list [--source <x>] [--uncategorized] [--month YYYY-MM]
76
+ imprnt kopeika report [--month YYYY-MM] [--from YYYY-MM] [--html <path> --lang <en|ru>]
77
+ imprnt kopeika project [--rate <eur/mo>] [--lump-sum <eur>] [--years N]
78
+ ```
79
+
80
+ `report --html <path> --lang <en|ru>` writes the self-contained dashboard. The deep model (raw vs
81
+ clean, stock vs flow, the two axes, net worth, projection) is in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
82
+
83
+ ## Data files (all under `plugins/kopeika/data/`, local only)
84
+
85
+ | File | Format |
86
+ |------|--------|
87
+ | `data/raw/<source>/` | archived immutable original exports |
88
+ | `data/ledger.csv` | clean normalized ledger |
89
+ | `data/rules.csv` | `pattern,match_type,field,category,type` |
90
+ | `data/rates.csv` | `month,currency,rate_to_eur` |
91
+ | `data/tiers.csv` | `scope,value,tier` (tier `mandatory` = floor, else flex) |
92
+ | `data/savings.csv` | `scope,value,balance_eur` (`account` cost basis, `marker` pot, `anchor` manual) |
93
+ | `data/profile.json` | your personal layer (see Profile) |
94
+
95
+ ## Profile
96
+
97
+ Every personal fact lives in `data/profile.json`, kept local (never on a remote). The code ships generic. Copy
98
+ `profile.example.json` and fill in: `owners` (allowed `--owner` labels), `ownNames` / `ownIbans` (for
99
+ internal-transfer detection), `netWorth` (`flatsRub`, `mortgageRub`, `bcsNominalCny`, `propertyApr`),
100
+ `accountLabels`, `merchantInfo`, and `footer`. Every field is optional. A missing profile runs in
101
+ generic mode. Nothing here is hardcoded in the source, so the package carries no personal data.
102
+
103
+ ## Privacy
104
+
105
+ Your `data/` folder holds raw financial data: the bank exports, the clean ledger, and
106
+ `data/profile.json` with your names, IBANs, and net-worth marks. The one hard rule is that **it must
107
+ never reach a remote.**
108
+
109
+ There are two safe setups. In a private, remoteless store like the imprnt vault, `data/` is committed
110
+ as the canonical source of truth - that is the intended arrangement, and the data never leaves the
111
+ machine because the repo has no remote. Anywhere a remote exists, gitignore `data/` so it is never
112
+ committed. `check.js` enforces exactly this: it fails if `data/` is tracked in a repo that has a
113
+ remote, and passes otherwise. The published package itself ships no personal data (only the generic
114
+ code and `profile.example.json`). The rendered `deploy/` bundle is derived, so it stays local. The
115
+ hosted page carries no account numbers, IBANs, or balances, and sits behind a password.
package/agent.md ADDED
@@ -0,0 +1,89 @@
1
+ # Plugin: Kopeika (personal finance & net worth)
2
+
3
+ > The agent fragment — the plugin's entry point. The core never reads it; you (the assistant) do.
4
+ > Install = `imprnt plugin add kopeika`, which wires `@plugins/kopeika/agent.md` into `CLAUDE.local.md`
5
+ > (gitignored, per-machine). Remove = `imprnt plugin rm kopeika` (add `--purge` to delete the folder).
6
+
7
+ ## What this plugin is
8
+
9
+ A deterministic, local-first personal-finance and net-worth CLI. It parses bank CSV exports into one
10
+ clean, deduplicated ledger, categorizes rows via ratified rules, matches internal transfers, tracks
11
+ savings destinations, and projects net worth. It renders a self-contained bilingual HTML dashboard. No
12
+ LLM ever touches a row at runtime, so the same exports always produce the same ledger, report, and
13
+ dashboard. It is the local money tool that lives next to the vault, not inside it.
14
+
15
+ ## Where its data lives
16
+
17
+ Everything personal stays inside this plugin folder, gitignored, never in a vault note:
18
+
19
+ - `data/ledger.csv` — the clean normalized ledger, built from the raw exports.
20
+ - `data/raw/<source>/` — the immutable original bank exports.
21
+ - `data/rules.csv`, `data/tiers.csv`, `data/rates.csv`, `data/savings.csv` — the edit layer
22
+ (categorization, floor vs flex, FX rates, savings destinations).
23
+ - `data/profile.json` — the personal layer: own names and IBANs (for transfer detection), the
24
+ net-worth marks, account and merchant display labels, the footer. Copy `profile.example.json` to
25
+ start one. This file is the PII. It is gitignored and never leaves the machine.
26
+ - `deploy/` — the optional hosted-dashboard bundle (the rendered HTML plus a tiny server). Gitignored.
27
+
28
+ `data/` holds raw financial data and must never reach a remote. In a remoteless private store like the
29
+ imprnt vault it is committed as the canonical source of truth; anywhere a remote exists, gitignore it.
30
+ `deploy/` is derived and stays local. Neither ships with the plugin code.
31
+
32
+ ## Commands (you run these; nothing runs on its own)
33
+
34
+ Run as `imprnt kopeika <cmd>` (the core dispatches to `node plugins/kopeika/kopeika.js`), or call
35
+ `node plugins/kopeika/kopeika.js <cmd>` directly:
36
+
37
+ - `imprnt kopeika import <revolut|n26|trading212|tbank|alfa> <file> --account <label> --owner <owner>`
38
+ archives the raw export, parses, FX-converts to EUR, dedups, and appends.
39
+ - `imprnt kopeika categorize [--review]` applies `data/rules.csv`. `--review` lists new merchants by spend.
40
+ - `imprnt kopeika transfers` pairs internal account-to-account legs.
41
+ - `imprnt kopeika report [--month YYYY-MM] [--html <path> --lang <en|ru>]` gives income/spend/saved per
42
+ month, the category and floor/flex split, and optionally writes the self-contained dashboard.
43
+ - `imprnt kopeika project [--rate <eur/mo>] [--years N]` rolls the savings stock forward 1 and 5 years.
44
+ - `imprnt kopeika recurring` and `imprnt kopeika list` show the recurring backbone and a row table.
45
+
46
+ The monthly procedure (export, import, categorize, transfers, review, report) is in README.md.
47
+
48
+ ## How to answer a money question
49
+
50
+ When the user asks about their spending, savings, net worth, or a given month, run the relevant command
51
+ (`report`, `project`, `list`) and read its output. The ledger is the source of truth and the numbers
52
+ are deterministic. To change how a transaction is treated, edit the data file that governs it
53
+ (`rules.csv` for category, `tiers.csv` for floor/flex, `savings.csv` for destinations, `profile.json`
54
+ for display labels and net-worth marks), then re-run. Never hand-edit a ledger row.
55
+
56
+ ## Adding new data and publishing (the "eat this CSV" loop)
57
+
58
+ When the user hands you a fresh export ("here is my Revolut CSV, add it and update the site"), run the
59
+ loop and report what changed at each step:
60
+
61
+ 1. `imprnt kopeika import <connector> <path> --account <label> --owner <owner>` — confirm the
62
+ imported / skipped-dup counts and any missing FX rates it warns about.
63
+ 2. `imprnt kopeika categorize` then `imprnt kopeika transfers`.
64
+ 3. `imprnt kopeika categorize --review` — for each new merchant worth a label, add one line to
65
+ `data/rules.csv` and re-run `categorize`. This is the one judgement step.
66
+ 4. `imprnt kopeika report` (and `project`) to sanity-check the month against the known anchor.
67
+ 5. Publish: render both languages, then deploy the `deploy/` bundle to the host.
68
+ ```
69
+ imprnt kopeika report --html deploy/public/en.html --lang en
70
+ imprnt kopeika report --html deploy/public/ru.html --lang ru
71
+ # then deploy the deploy/ bundle (the exact host command is in the user's deploy note)
72
+ ```
73
+
74
+ Reuse the same `--account` label for an account every month; the display aliases and savings
75
+ destinations are keyed to them. The plugin's `data/` folder is the single source of truth for both the
76
+ CLI and the hosted dashboard.
77
+
78
+ ## Rules (always-on while this fragment is installed)
79
+
80
+ - **Personal data stays in `data/` (gitignored), never in a vault note.** The PII is `data/profile.json`.
81
+ To put something durable in the vault, propose a *summary* note (net worth, savings rate) into
82
+ `proposed/` for `imprnt ingest --apply`. Never a per-transaction dump, never account numbers or balances.
83
+ - **The pipeline is deterministic; no LLM touches a row at runtime.** An LLM may propose a categorization
84
+ *rule* at authoring time, never mutate a ledger row directly.
85
+ - **Financial data must never reach a remote.** `data/` holds the real exports, ledger, and profile.
86
+ Committing it in a remoteless private store (the imprnt vault) is the intended setup; anywhere with a
87
+ remote, gitignore `data/`. `check.js` fails if tracked `data/` meets a remote. `deploy/` stays local.
88
+ - **Namespace any vault label** you ever add with `kopeika.*`.
89
+ - The hosted dashboard carries no account numbers, IBANs, or balances, and sits behind a password.
package/check.js ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/check.ts
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { basename, dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { spawnSync } from "node:child_process";
8
+ var HERE = dirname(fileURLToPath(import.meta.url));
9
+ var ROOT = basename(HERE) === "src" ? dirname(HERE) : HERE;
10
+ var DATA = join(ROOT, "data");
11
+ var problems = [];
12
+ var notes = [];
13
+ function git(args) {
14
+ const r = spawnSync("git", args, { cwd: ROOT, encoding: "utf8" });
15
+ return r.status === 0 ? (r.stdout ?? "").trim() : "";
16
+ }
17
+ var dataTracked = git(["ls-files", "data"]).length > 0;
18
+ var hasRemote = git(["remote"]).length > 0;
19
+ if (dataTracked && hasRemote) {
20
+ problems.push("data/ is git-tracked in a repo that HAS A REMOTE — your financial data (ledger, raw bank " + "exports, profile.json) could be pushed. Add data/ to .gitignore, or remove the remote. " + "Committing data/ is only safe in a remoteless local store like the imprnt vault.");
21
+ } else if (dataTracked) {
22
+ notes.push("data: committed (no remote — canonical local store, safe)");
23
+ } else {
24
+ notes.push("data: not tracked by git");
25
+ }
26
+ var profilePath = join(DATA, "profile.json");
27
+ if (existsSync(profilePath)) {
28
+ try {
29
+ JSON.parse(readFileSync(profilePath, "utf8"));
30
+ notes.push("profile: present");
31
+ } catch (e) {
32
+ problems.push(`data/profile.json is not valid JSON (${e.message})`);
33
+ }
34
+ } else {
35
+ notes.push("profile: none (generic mode — no net-worth layer, raw labels)");
36
+ }
37
+ var ledgerPath = join(DATA, "ledger.csv");
38
+ if (existsSync(ledgerPath)) {
39
+ const lines = readFileSync(ledgerPath, "utf8").split(`
40
+ `).filter((l) => l.trim() !== "");
41
+ const rows = Math.max(0, lines.length - 1);
42
+ notes.push(`ledger: ${rows} row(s)`);
43
+ } else {
44
+ notes.push("ledger: none yet (import something first)");
45
+ }
46
+ console.log("kopeika check");
47
+ for (const n of notes)
48
+ console.log(` ${n}`);
49
+ if (problems.length) {
50
+ console.log(`
51
+ ⚠ ${problems.length} issue(s):`);
52
+ for (const p of problems)
53
+ console.log(` - ${p}`);
54
+ process.exit(1);
55
+ }
56
+ console.log(`
57
+ sound.`);
58
+ process.exit(0);
@@ -0,0 +1,125 @@
1
+ # Architecture
2
+
3
+ How kopeika is built and why it is shaped this way. The [README](../README.md) is the front door and the command reference. [RUNBOOK.md](../RUNBOOK.md) is the monthly operating procedure. This document is the durable design: the model, the data layers, and the decisions that do not change month to month.
4
+
5
+ ## The core principle
6
+
7
+ The LLM builds the tools at authoring time. The deterministic tools do the work at runtime. No model touches a transaction at runtime, so the same exports always produce the same ledger, report, and dashboard.
8
+
9
+ Verification is structural, not a model's opinion:
10
+
11
+ - **A golden test suite** over parsing, dedup, FX, rules, transfers, savings, and projection.
12
+ - **Reconcilable invariants.** A known month must reproduce a known spend and saved figure. Income, spend, and saved are derived from the same signed rows, so they cannot silently drift apart. This is the lesson that started the project: an earlier money tool reported income and outflow that did not reconcile to the actual balance, a double-count no single test run catches. Reconciliation is the missing check.
13
+
14
+ The cost of the principle is that vendor-specific logic is the one human-written surface (the connectors). Everything downstream is shared and deterministic.
15
+
16
+ ## The pipeline
17
+
18
+ ```
19
+ bank CSV export
20
+ │ import archive raw, parse, FX→EUR, dedup by id, append
21
+
22
+ data/ledger.csv (clean, normalized)
23
+ │ categorize apply data/rules.csv, first match wins
24
+ │ transfers pair internal account-to-account legs
25
+
26
+ ├─ report income / spend / saved per month, category + floor/flex
27
+ ├─ project roll the savings stock forward 1y / 5y at a set rate
28
+ └─ report --html the self-contained net-worth dashboard
29
+ ```
30
+
31
+ Each stage is a CLI command and is safe to re-run. Import dedups, so an overlapping re-export is a no-op. Transfers re-pairs idempotently. A full reset is `rm data/ledger.csv` then re-import every account.
32
+
33
+ ## Raw vs clean
34
+
35
+ Original exports are archived under `data/raw/<source>/` and never edited. The clean ledger `data/ledger.csv` is rebuilt from them. Keeping both layers means a re-import or a rebuild can never lose the source of truth, and any categorization or transfer logic can be replayed from scratch.
36
+
37
+ ## Stock vs flow
38
+
39
+ Two different questions about money:
40
+
41
+ - **Flow:** transactions summed over a month. Income, spend, saved. This is what `report` answers.
42
+ - **Stock:** an account level at a moment. The buffer balance, the savings total, the projection's starting point. The `balance` column carries the running level when an export reports it (Revolut does, N26 and Trading 212 do not).
43
+
44
+ Savings is the stock that matters: money that has landed in a savings destination. Everything else on any account is spendable float. Money leaving a savings destination is a negative flow that lowers the stock, so a bad month reports itself with no penny-counting. The only glance needed is whether the buffer slid backwards, which is just "did we raid it."
45
+
46
+ ## The two axes
47
+
48
+ A row has a **category** (what it is) and the ledger separately reads a **tier** (floor vs flex) from `data/tiers.csv`.
49
+
50
+ - Category answers "what was this": Groceries, Rent, Shopping. It comes from the rules.
51
+ - Tier answers "could we skip it": floor (mandatory) vs flex (optional). It is a property of the category, set in one file, not a per-row column and not derived from recurrence. A frequent buy recurs but can still be flex.
52
+
53
+ Keeping them orthogonal means the spend view can split mandatory from discretionary without re-touching a single transaction. Changing a tier is a one-line data edit.
54
+
55
+ ## Savings as destinations
56
+
57
+ The "saved this month" number is the inflow to the savings destinations, not income-minus-spend. Defining savings as the inflow to specific accounts means a bad month shows up as less landing there, without having to count every euro of spend first. The destinations are configured in `data/savings.csv`:
58
+
59
+ - `account,<label>` builds the stock from that account's flow (the cost basis: cumulative deposits minus withdrawals).
60
+ - `marker,<name>` is an in-account pot (a stash inside a regular account).
61
+ - `anchor,<value>` is a manual balance, the last resort for an account with no export.
62
+
63
+ ## The net-worth layer
64
+
65
+ On top of liquid savings sits an illiquid layer that the dashboard charts as its own toggleable lines:
66
+
67
+ - **Property:** the flats, valued in RUB, net of their mortgage. The flats compound at a conservative nominal appreciation rate. The mortgage is carried flat (subtracted, never appreciating). The mortgage figure is the real current balance after prepayment, not the origination schedule.
68
+ - **A structured note (BCS):** a nominal held flat in CNY.
69
+
70
+ These marks (flat values, mortgage balance, note nominal, the appreciation rate) live in `data/profile.json` (see "The personal layer" below), not in code. They are facts about the world, not ledger rows, so they are a single edit point rather than transactions. With no marks declared, the chart shows liquid savings only.
71
+
72
+ The dashboard's Total sums only the **visible** lines. Toggling real estate off collapses the headline number to the liquid, touchable amount. That is the point: a large net worth is real but not spendable, and the page lets you see the number you can actually touch.
73
+
74
+ ## Projection
75
+
76
+ The projection rolls the savings stock forward 1 and 5 years at a monthly rate you set with a slider, defaulting to the recent actual. The slider is what keeps the forecast honest: the rate is an assumption you own, not a number the tool pretends to know.
77
+
78
+ - The ETF is held flat at cost basis. Growth is unmodeled upside, a loss is not drawn either. The transactions export has no portfolio-value column, so cost basis is what a flat projection can carry. A live market value would be one number a month from a second source, an optional add.
79
+ - Real estate compounds at its nominal rate, the mortgage stays flat.
80
+ - Output is dual-currency, EUR and RUB, because the income mix is mostly RUB.
81
+
82
+ ## The dashboard
83
+
84
+ One self-contained HTML file: inline CSS, a hand-rolled SVG chart, vanilla JS, no CDN, no fetch, no external JS. Rendered by `report --html`, deployed as a static file. It is bilingual (RU default, EN), light/dark, and mobile-first, and it follows the shared house style (Inter + Space Grotesk + JetBrains Mono, an emerald accent, mono stats and eyebrows). On a phone the chart runs full-bleed with the line labels floating over a soft right-edge fade, so the chart reads as the centerpiece rather than a cropped strip.
85
+
86
+ The page carries no account numbers, no IBANs, and no balances. When hosted it sits behind a password. The display labels (clean merchant names, the "what for" note, account labels, the footer) come from `data/profile.json` at render time, so they survive ledger re-imports and keep personal text out of the code. The category colour palette is generic and stays in `src/dashboard.ts`.
87
+
88
+ ## FX
89
+
90
+ `amount_eur = amount_native * rate_to_eur` for the row's month, from `data/rates.csv`. EUR converts at 1 with no row. A missing `(month, currency)` rate leaves `amount_eur` empty and is reported. kopeika never guesses a rate. Rates can be per-month, the schema has a `month` column.
91
+
92
+ ## Connectors
93
+
94
+ A connector is `src/connectors/<name>.ts` exporting `parse<Name>(text: string): ParsedRow[]`. It translates the vendor CSV into the `ParsedRow` shape and filters out non-completed or invalid rows. It does not compute `id`, FX, dedup, or transfer grouping, the import pipeline owns those. Register it in `src/connectors/index.ts`. The built connectors are Revolut, N26, Trading 212, T-Bank, and Alfa. The Alfa connector keeps only savings-vehicle accounts by design, dropping card and current-account rows.
95
+
96
+ Some connectors flag a row as a likely internal transfer when it names one of your own accounts. The names and IBANs that define "yours" are not hardcoded. They come from the profile and are installed at startup through `src/identity.ts`, so a connector carries vendor-format logic only, never a person. The `transfers` command pairs the authoritative legs by amount and date regardless, so a missed hint never loses a transfer.
97
+
98
+ ## The personal layer (the profile)
99
+
100
+ Every personal fact lives in `data/profile.json` (kept local, never on a remote), never in the code. This is the same "data is the source of truth" rule the ledger config follows, applied to identity and display: own names and IBANs for transfer detection, the net-worth marks, the account and merchant display labels, and the footer. `src/profile.ts` loads it once at startup. A committed `profile.example.json` is the template.
101
+
102
+ Loading is forgiving by design. A missing file yields an empty profile, so a fresh checkout runs in generic mode: no own-name matching, no net-worth layer, raw merchant and account strings. Filling the profile in lights those features up. The payoff is that the package ships with no personal data and the same binary works for anyone, while one local file holds everything specific to one household. That file, and the whole `data/` folder, must never reach a remote: committed in a remoteless private store (the imprnt vault) is the intended setup, and `check.js` fails if `data/` is tracked in a repo that has a remote.
103
+
104
+ ## Data scope
105
+
106
+ What the ledger is allowed to contain, and why:
107
+
108
+ - **EUR side: fully tracked.** Spend, income, floor/flex, and savings. German transactions are stable merchants, and the rules already strip internal transfers, so full tracking is cheap and honest.
109
+ - **RU side: savings only.** The RU savings accounts are tracked as destinations (the accumulated stock). RU card spend and RU income are out of scope. RU income lands in a separate account and is taxed there.
110
+ - **Off the books:** cash (logged only on demand, never reconciled, a missing cash row is never a gap), and accounts whose labor outweighs their signal.
111
+
112
+ The scope is a policy choice, not a technical limit. Including an account is about whether it belongs in the household picture, not about effort. Cross-currency transfer matching is not needed because cross-border transfers do not happen.
113
+
114
+ ## Design decisions
115
+
116
+ The durable choices, stated as principles rather than dated events:
117
+
118
+ - The household number that gets surfaced is savings going up, never spending picked apart. Full books underneath, a calm outcome on top.
119
+ - Savings is the inflow to named destinations, nothing else.
120
+ - Projection is the reason the tool exists. The go-forward rate is a slider, defaulting to the recent actual.
121
+ - The ETF is flat at cost basis. Live market value is an optional later add from a second source.
122
+ - Dual currency throughout, EUR and RUB.
123
+ - Net-worth marks are profile data, not ledger rows and not code.
124
+ - The data files are the source of truth, the code reads them. Categorization, tiers, savings destinations, FX rates, and the whole personal layer (names, marks, display labels) are all data edits, never code changes. The code ships with no personal data.
125
+ </content>