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 +115 -0
- package/agent.md +89 -0
- package/check.js +58 -0
- package/docs/ARCHITECTURE.md +125 -0
- package/kopeika.js +3025 -0
- package/package.json +40 -0
- package/profile.example.json +28 -0
- package/proposed/.gitkeep +1 -0
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>
|