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/kopeika.js ADDED
@@ -0,0 +1,3025 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
5
+ import { basename as basename2, dirname as dirname2, join as join2 } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ // src/csv.ts
9
+ function parseCsv(text, delimiter = ",") {
10
+ const rows = parseRows(text, delimiter);
11
+ if (rows.length === 0) {
12
+ throw new Error("parseCsv: empty document — no header row found");
13
+ }
14
+ const header = rows[0];
15
+ const headerIndex = new Map;
16
+ header.forEach((name, i) => {
17
+ if (!headerIndex.has(name))
18
+ headerIndex.set(name, i);
19
+ });
20
+ const records = [];
21
+ for (let r = 1;r < rows.length; r++) {
22
+ const fields = rows[r];
23
+ records.push({
24
+ fields,
25
+ get(column) {
26
+ const idx = headerIndex.get(column);
27
+ if (idx === undefined) {
28
+ throw new Error(`parseCsv: column "${column}" not present in header [${header.join(", ")}]`);
29
+ }
30
+ return fields[idx] ?? "";
31
+ }
32
+ });
33
+ }
34
+ return { header, records };
35
+ }
36
+ function parseRows(text, delimiter = ",") {
37
+ const rows = [];
38
+ let field = "";
39
+ let row = [];
40
+ let inQuotes = false;
41
+ let sawAnyChar = false;
42
+ const pushField = () => {
43
+ row.push(field);
44
+ field = "";
45
+ };
46
+ const pushRow = () => {
47
+ pushField();
48
+ rows.push(row);
49
+ row = [];
50
+ sawAnyChar = false;
51
+ };
52
+ for (let i = 0;i < text.length; i++) {
53
+ const ch = text[i];
54
+ if (inQuotes) {
55
+ if (ch === '"') {
56
+ const next = text[i + 1];
57
+ if (next === '"') {
58
+ field += '"';
59
+ i++;
60
+ } else {
61
+ inQuotes = false;
62
+ }
63
+ } else {
64
+ field += ch;
65
+ }
66
+ sawAnyChar = true;
67
+ continue;
68
+ }
69
+ if (ch === '"') {
70
+ inQuotes = true;
71
+ sawAnyChar = true;
72
+ } else if (ch === delimiter) {
73
+ pushField();
74
+ sawAnyChar = true;
75
+ } else if (ch === "\r") {
76
+ if (text[i + 1] === `
77
+ `)
78
+ i++;
79
+ pushRow();
80
+ } else if (ch === `
81
+ `) {
82
+ pushRow();
83
+ } else {
84
+ field += ch;
85
+ sawAnyChar = true;
86
+ }
87
+ }
88
+ if (sawAnyChar || field.length > 0 || row.length > 0) {
89
+ pushField();
90
+ rows.push(row);
91
+ }
92
+ return rows;
93
+ }
94
+ function quoteField(value) {
95
+ if (/[",\r\n]/.test(value)) {
96
+ return `"${value.replace(/"/g, '""')}"`;
97
+ }
98
+ return value;
99
+ }
100
+ function writeCsv(header, rows) {
101
+ const lines = [];
102
+ lines.push(header.map(quoteField).join(","));
103
+ for (const row of rows) {
104
+ lines.push(row.map(quoteField).join(","));
105
+ }
106
+ return lines.join(`
107
+ `) + `
108
+ `;
109
+ }
110
+
111
+ // src/tiers.ts
112
+ import { existsSync, readFileSync } from "node:fs";
113
+ var VALID_SCOPES = new Set(["category", "merchant"]);
114
+ var VALID_TIERS = new Set(["mandatory", "optional"]);
115
+ function loadTiers(path) {
116
+ if (!existsSync(path))
117
+ return { mandatoryCategories: new Set, mandatoryMerchants: [] };
118
+ const text = readFileSync(path, "utf8");
119
+ if (text.trim().length === 0)
120
+ return { mandatoryCategories: new Set, mandatoryMerchants: [] };
121
+ const { records } = parseCsv(text);
122
+ const mandatoryCategories = new Set;
123
+ const mandatoryMerchants = [];
124
+ records.forEach((rec, i) => {
125
+ const scope = rec.get("scope").trim().toLowerCase();
126
+ const value = rec.get("value").trim();
127
+ const tier = rec.get("tier").trim().toLowerCase();
128
+ if (scope === "" && value === "" && tier === "")
129
+ return;
130
+ if (!VALID_SCOPES.has(scope)) {
131
+ throw new Error(`loadTiers: row ${i + 2}: invalid scope "${scope}" (expected category|merchant)`);
132
+ }
133
+ if (!VALID_TIERS.has(tier)) {
134
+ throw new Error(`loadTiers: row ${i + 2}: invalid tier "${tier}" (expected mandatory|optional)`);
135
+ }
136
+ if (value === "") {
137
+ throw new Error(`loadTiers: row ${i + 2}: empty value`);
138
+ }
139
+ if (tier !== "mandatory")
140
+ return;
141
+ if (scope === "category")
142
+ mandatoryCategories.add(value.toLowerCase());
143
+ else
144
+ mandatoryMerchants.push(value.toLowerCase());
145
+ });
146
+ return { mandatoryCategories, mandatoryMerchants };
147
+ }
148
+ function tiersConfigured(tiers) {
149
+ return tiers.mandatoryCategories.size > 0 || tiers.mandatoryMerchants.length > 0;
150
+ }
151
+ function tierOf(tiers, category, merchantRaw) {
152
+ if (tiers.mandatoryCategories.has(category.toLowerCase()))
153
+ return "mandatory";
154
+ const m = merchantRaw.toLowerCase();
155
+ if (tiers.mandatoryMerchants.some((sub) => m.includes(sub)))
156
+ return "mandatory";
157
+ return "optional";
158
+ }
159
+
160
+ // src/analytics.ts
161
+ var BANK_FEES_CATEGORY = "Bank fees";
162
+ var UNCATEGORIZED_CATEGORY = "Uncategorized";
163
+ var EXCLUDE_CATEGORY = "Exclude";
164
+ var SAVINGS_CATEGORY = "Savings";
165
+ var EXCLUDED_TYPES = new Set(["transfer", "exchange"]);
166
+ function isAnalyticsExcluded(txn) {
167
+ if (txn.is_transfer)
168
+ return true;
169
+ if (EXCLUDED_TYPES.has(txn.type))
170
+ return true;
171
+ if (txn.category === EXCLUDE_CATEGORY)
172
+ return true;
173
+ return false;
174
+ }
175
+ function monthOf(isoDate) {
176
+ return isoDate.slice(0, 7);
177
+ }
178
+ function inRange(month, range) {
179
+ if (range.month !== undefined)
180
+ return month === range.month;
181
+ if (range.from !== undefined)
182
+ return month >= range.from;
183
+ return true;
184
+ }
185
+ function spendCategoryOf(txn) {
186
+ if (txn.type === "fee")
187
+ return BANK_FEES_CATEGORY;
188
+ if (txn.category !== "")
189
+ return txn.category;
190
+ return UNCATEGORIZED_CATEGORY;
191
+ }
192
+ function round2(n) {
193
+ const r = Math.round((n + Number.EPSILON) * 100) / 100;
194
+ return r === 0 ? 0 : r;
195
+ }
196
+ function newMonthAcc() {
197
+ return {
198
+ income: 0,
199
+ spend: 0,
200
+ invested: 0,
201
+ floor: 0,
202
+ flex: 0,
203
+ missingEurCount: 0,
204
+ categoryAmounts: new Map,
205
+ categoryCounts: new Map
206
+ };
207
+ }
208
+ function sortCategories(entries) {
209
+ return [...entries].sort((a, b) => {
210
+ if (b.amount !== a.amount)
211
+ return b.amount - a.amount;
212
+ return a.category < b.category ? -1 : a.category > b.category ? 1 : 0;
213
+ });
214
+ }
215
+ function finalizeCategories(amounts, counts, totalSpend) {
216
+ const entries = [];
217
+ for (const [category, amount] of amounts) {
218
+ const rounded = round2(amount);
219
+ entries.push({
220
+ category,
221
+ amount: rounded,
222
+ share: totalSpend > 0 ? rounded / totalSpend : 0,
223
+ count: counts.get(category) ?? 0
224
+ });
225
+ }
226
+ return sortCategories(entries);
227
+ }
228
+ function savingsRateOf(income, saved) {
229
+ return income > 0 ? saved / income : 0;
230
+ }
231
+ function buildReport(txs, range = {}, tiers) {
232
+ const splitTiers = tiers !== undefined && tiersConfigured(tiers);
233
+ const monthAccs = new Map;
234
+ const overallAmounts = new Map;
235
+ const overallCounts = new Map;
236
+ let overallIncome = 0;
237
+ let overallSpend = 0;
238
+ let overallInvested = 0;
239
+ let overallFloor = 0;
240
+ let overallFlex = 0;
241
+ let overallMissing = 0;
242
+ let overallCounted = 0;
243
+ let consideredRows = 0;
244
+ let excludedRows = 0;
245
+ for (const tx of txs) {
246
+ const month = monthOf(tx.date);
247
+ if (!inRange(month, range))
248
+ continue;
249
+ consideredRows += 1;
250
+ const getAcc = () => {
251
+ let a = monthAccs.get(month);
252
+ if (a === undefined) {
253
+ a = newMonthAcc();
254
+ monthAccs.set(month, a);
255
+ }
256
+ return a;
257
+ };
258
+ if (tx.category === SAVINGS_CATEGORY && tx.amount_eur !== null) {
259
+ const amt = Math.abs(tx.amount_eur);
260
+ getAcc().invested += amt;
261
+ overallInvested += amt;
262
+ }
263
+ if (isAnalyticsExcluded(tx)) {
264
+ excludedRows += 1;
265
+ continue;
266
+ }
267
+ const acc = getAcc();
268
+ if (tx.amount_eur === null) {
269
+ acc.missingEurCount += 1;
270
+ overallMissing += 1;
271
+ continue;
272
+ }
273
+ overallCounted += 1;
274
+ const eur = tx.amount_eur;
275
+ if (eur > 0) {
276
+ acc.income += eur;
277
+ overallIncome += eur;
278
+ } else if (eur < 0) {
279
+ const abs = -eur;
280
+ acc.spend += abs;
281
+ overallSpend += abs;
282
+ const category = spendCategoryOf(tx);
283
+ acc.categoryAmounts.set(category, (acc.categoryAmounts.get(category) ?? 0) + abs);
284
+ acc.categoryCounts.set(category, (acc.categoryCounts.get(category) ?? 0) + 1);
285
+ overallAmounts.set(category, (overallAmounts.get(category) ?? 0) + abs);
286
+ overallCounts.set(category, (overallCounts.get(category) ?? 0) + 1);
287
+ if (splitTiers) {
288
+ const tier = tierOf(tiers, category, tx.merchant_raw);
289
+ if (tier === "mandatory") {
290
+ acc.floor += abs;
291
+ overallFloor += abs;
292
+ } else {
293
+ acc.flex += abs;
294
+ overallFlex += abs;
295
+ }
296
+ }
297
+ }
298
+ }
299
+ const months = [...monthAccs.keys()].sort().map((month) => {
300
+ const acc = monthAccs.get(month);
301
+ const income = round2(acc.income);
302
+ const spend = round2(acc.spend);
303
+ const saved = round2(income - spend);
304
+ return {
305
+ month,
306
+ income,
307
+ spend,
308
+ saved,
309
+ savingsRate: savingsRateOf(income, saved),
310
+ invested: round2(acc.invested),
311
+ floor: splitTiers ? round2(acc.floor) : null,
312
+ flex: splitTiers ? round2(acc.flex) : null,
313
+ categories: finalizeCategories(acc.categoryAmounts, acc.categoryCounts, spend),
314
+ missingEurCount: acc.missingEurCount
315
+ };
316
+ });
317
+ const overallIncomeR = round2(overallIncome);
318
+ const overallSpendR = round2(overallSpend);
319
+ const overallSaved = round2(overallIncomeR - overallSpendR);
320
+ const overall = {
321
+ income: overallIncomeR,
322
+ spend: overallSpendR,
323
+ saved: overallSaved,
324
+ savingsRate: savingsRateOf(overallIncomeR, overallSaved),
325
+ invested: round2(overallInvested),
326
+ floor: splitTiers ? round2(overallFloor) : null,
327
+ flex: splitTiers ? round2(overallFlex) : null,
328
+ monthCount: months.length,
329
+ categories: finalizeCategories(overallAmounts, overallCounts, overallSpendR),
330
+ missingEurCount: overallMissing,
331
+ countedRows: overallCounted
332
+ };
333
+ return { months, overall, consideredRows, excludedRows };
334
+ }
335
+ function latestCompleteMonth(report, now = new Date) {
336
+ if (report.months.length === 0)
337
+ return null;
338
+ const currentMonth = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
339
+ const complete = report.months.filter((m) => m.month < currentMonth);
340
+ if (complete.length > 0)
341
+ return complete[complete.length - 1].month;
342
+ return report.months[report.months.length - 1].month;
343
+ }
344
+ function buildSpendGroups(txs, period, tiers) {
345
+ const splitTiers = tiers !== undefined && tiersConfigured(tiers);
346
+ const byTier = new Map([
347
+ ["mandatory", new Map],
348
+ ["non-mandatory", new Map]
349
+ ]);
350
+ for (const tx of txs) {
351
+ if (!tx.date.startsWith(period))
352
+ continue;
353
+ if (isAnalyticsExcluded(tx))
354
+ continue;
355
+ if (tx.amount_eur === null || tx.amount_eur >= 0)
356
+ continue;
357
+ const abs = -tx.amount_eur;
358
+ const category = spendCategoryOf(tx);
359
+ const tier = splitTiers && tierOf(tiers, category, tx.merchant_raw) === "mandatory" ? "mandatory" : "non-mandatory";
360
+ const cats = byTier.get(tier);
361
+ let group = cats.get(category);
362
+ if (group === undefined) {
363
+ group = { category, total: 0, count: 0, txns: [] };
364
+ cats.set(category, group);
365
+ }
366
+ group.total += abs;
367
+ group.count += 1;
368
+ group.txns.push({ date: tx.date, merchant: tx.merchant_raw, eur: round2(abs), account: tx.account });
369
+ }
370
+ const tierOrder = ["mandatory", "non-mandatory"];
371
+ const out = [];
372
+ for (const tier of tierOrder) {
373
+ const cats = [...byTier.get(tier).values()].map((g) => ({
374
+ ...g,
375
+ total: round2(g.total),
376
+ txns: [...g.txns].sort((a, b) => b.eur - a.eur || (a.date < b.date ? 1 : -1))
377
+ }));
378
+ cats.sort((a, b) => b.total - a.total || (a.category < b.category ? -1 : 1));
379
+ const total = round2(cats.reduce((s, c) => s + c.total, 0));
380
+ out.push({ tier, total, categories: cats });
381
+ }
382
+ return out;
383
+ }
384
+
385
+ // src/dashboard.ts
386
+ var LANG = "en";
387
+ var DISPLAY = {};
388
+ var PALETTE = {
389
+ green: "#18935a",
390
+ greenBright: "#1faf76",
391
+ amber: "#c2641e",
392
+ blue: "#3E7CA8"
393
+ };
394
+ var HOUSE_CAP_EUR = 2000;
395
+ var SLIDER_DEFAULT_EUR = 1500;
396
+ var SLIDER_DEFAULT_RUB = 20000;
397
+ var CATEGORY_COLORS = {
398
+ "Rent & utilities": "#3E7CA8",
399
+ Subscriptions: "#7E5B9E",
400
+ Groceries: "#3E8E6B",
401
+ "Eating out": "#E07B39",
402
+ "Business lunch": "#C98A2B",
403
+ Travel: "#2C9C9C",
404
+ Shopping: "#C9568E",
405
+ Clothing: "#D6788F",
406
+ Commute: "#5B7C99",
407
+ Transport: "#5B7C99",
408
+ Health: "#C0392B",
409
+ Entertainment: "#9B59B6",
410
+ Gaming: "#5E5BB8",
411
+ Music: "#B5485D",
412
+ Drogerie: "#5FAE9E",
413
+ Cash: "#8A867A",
414
+ Other: "#9E988A",
415
+ Miscellaneous: "#A89A86",
416
+ Admin: "#6B7A8F",
417
+ PayPal: "#3B6EA5",
418
+ Home: "#9B6A43",
419
+ Household: "#B5925A",
420
+ Books: "#8E4A5E",
421
+ Sport: "#5BA37E",
422
+ Fitness: "#4CAF7D",
423
+ Fees: "#A03A3A",
424
+ Insurance: "#5E7E8F",
425
+ Phone: "#3E9C9C",
426
+ Kids: "#E0795B",
427
+ Crypto: "#C8A23E",
428
+ Beauty: "#D67A9E",
429
+ Band: "#8E5BA0",
430
+ Uncategorized: "#B0AA9C"
431
+ };
432
+ var CATEGORY_FALLBACK = ["#3E7CA8", "#E07B39", "#3E8E6B", "#9B59B6", "#C9568E", "#2C9C9C", "#C98A2B", "#5E5BB8", "#C0392B", "#5FAE9E"];
433
+ function categoryColor(cat) {
434
+ const hit = CATEGORY_COLORS[cat];
435
+ if (hit)
436
+ return hit;
437
+ let h = 0;
438
+ for (let i = 0;i < cat.length; i++)
439
+ h = h * 31 + cat.charCodeAt(i) >>> 0;
440
+ return CATEGORY_FALLBACK[h % CATEGORY_FALLBACK.length];
441
+ }
442
+ var STRINGS = {
443
+ title: { en: "Where We Are", ru: "Где мы сейчас" },
444
+ subtitle: {
445
+ en: "What you've put aside, where it's heading, and where the rest goes.",
446
+ ru: "Сколько отложено, куда это растёт и на что уходит остальное."
447
+ },
448
+ savedSoFar: { en: "Saved so far", ru: "Накоплено" },
449
+ netWorth: { en: "Net worth", ru: "Чистый капитал" },
450
+ in1y: { en: "In 1 year", ru: "Через год" },
451
+ in5y: { en: "In 5 years", ru: "Через 5 лет" },
452
+ total: { en: "Total", ru: "Всего" },
453
+ tapToggle: { en: "tap to toggle", ru: "нажмите, чтобы скрыть" },
454
+ monthly: { en: "Monthly savings", ru: "Откладываем в месяц" },
455
+ eurMonthly: { en: "In euros", ru: "В евро" },
456
+ rubMonthly: { en: "In roubles", ru: "В рублях" },
457
+ whereItGoes: { en: "Where it goes", ru: "Куда уходит" },
458
+ spendKicker: { en: "Spending", ru: "Траты" },
459
+ worthKicker: { en: "kopeika", ru: "kopeika" },
460
+ tapCategory: { en: "Tap a category to see the transactions", ru: "Нажмите на категорию, чтобы увидеть операции" },
461
+ mandatory: { en: "Mandatory", ru: "Обязательные" },
462
+ nonMandatory: { en: "Non-mandatory", ru: "Необязательные" },
463
+ mandatorySub: { en: "Rent, utilities and subscriptions - owed no matter what", ru: "Аренда, коммуналка и подписки - платим всегда" },
464
+ flexSub: { en: "Everything else — the part you can flex", ru: "Всё остальное — здесь можно ужаться" },
465
+ spent: { en: "spent", ru: "потрачено" },
466
+ none: { en: "No spend recorded — nice and quiet.", ru: "Трат нет — тихий период." },
467
+ now: { en: "now", ru: "сейчас" },
468
+ safe: { en: "safe", ru: "запас" },
469
+ projected: { en: "projected", ru: "прогноз" },
470
+ house: { en: "House · 10M ₽", ru: "Квартира · 10М ₽" },
471
+ soFar: { en: "so far", ru: "пока" },
472
+ updated: { en: "Updated", ru: "Обновлено" },
473
+ theme: { en: "Theme", ru: "Тема" }
474
+ };
475
+ function t(key) {
476
+ return STRINGS[key]?.[LANG] ?? STRINGS[key]?.en ?? String(key);
477
+ }
478
+ var CATEGORY_RU = {
479
+ Rent: "Аренда",
480
+ "Rent & utilities": "Аренда и ЖКХ",
481
+ Subscriptions: "Подписки",
482
+ Groceries: "Продукты",
483
+ "Eating out": "Кафе и рестораны",
484
+ Drinking: "Бары",
485
+ Travel: "Путешествия",
486
+ Clothing: "Одежда",
487
+ Cash: "Наличные",
488
+ Transport: "Транспорт",
489
+ Commute: "Транспорт",
490
+ Micromobility: "Самокаты",
491
+ Miscellaneous: "Разное",
492
+ Utilities: "Коммуналка",
493
+ Health: "Здоровье",
494
+ Kids: "Дети",
495
+ Shopping: "Покупки",
496
+ Entertainment: "Развлечения",
497
+ Music: "Музыка",
498
+ Sport: "Спорт",
499
+ Insurance: "Страховка",
500
+ Phone: "Связь",
501
+ Home: "Дом",
502
+ Household: "Хозяйство",
503
+ Drogerie: "Дрогери",
504
+ Books: "Книги",
505
+ Gaming: "Игры",
506
+ Crypto: "Крипта",
507
+ Fitness: "Фитнес",
508
+ "Business lunch": "Бизнес-ланч",
509
+ Other: "Другое",
510
+ Admin: "Документы",
511
+ Uncategorized: "Без категории",
512
+ "Bank fees": "Комиссии банка"
513
+ };
514
+ function catName(cat) {
515
+ return LANG === "ru" ? CATEGORY_RU[cat] ?? cat : cat;
516
+ }
517
+ function accountLabel(account) {
518
+ return DISPLAY.accountLabels?.[account]?.[LANG] ?? account;
519
+ }
520
+ function merchantInfo(raw) {
521
+ const r = raw.toLowerCase();
522
+ for (const m of DISPLAY.merchantInfo ?? []) {
523
+ if (r.includes(m.pat.toLowerCase())) {
524
+ const note = (LANG === "ru" ? m.ru ?? m.en : m.en) ?? "";
525
+ return { name: m.name ?? raw, note };
526
+ }
527
+ }
528
+ return { name: raw, note: "" };
529
+ }
530
+ function txDate(iso) {
531
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
532
+ return m ? `${m[3]}-${m[2]}-${m[1]}` : iso;
533
+ }
534
+ var MONTHS_LONG = {
535
+ en: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
536
+ ru: ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"]
537
+ };
538
+ var MONTHS_SHORT = {
539
+ en: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
540
+ ru: ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]
541
+ };
542
+ function itemsWord(n) {
543
+ if (LANG === "en")
544
+ return n === 1 ? "item" : "items";
545
+ const mod10 = n % 10;
546
+ const mod100 = n % 100;
547
+ if (mod10 === 1 && mod100 !== 11)
548
+ return "операция";
549
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
550
+ return "операции";
551
+ return "операций";
552
+ }
553
+ function esc(value) {
554
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
555
+ }
556
+ function sep() {
557
+ return LANG === "ru" ? " " : ",";
558
+ }
559
+ function money(amount, symbol) {
560
+ const r = Math.round(amount);
561
+ const sign = r < 0 ? "-" : "";
562
+ const digits = String(Math.abs(r)).replace(/\B(?=(\d{3})+(?!\d))/g, sep());
563
+ return `${sign}${symbol}${digits}`;
564
+ }
565
+ function eur(amount) {
566
+ return money(amount, "€");
567
+ }
568
+ function rub(amount) {
569
+ return money(amount, "₽");
570
+ }
571
+ function splitMonth(month) {
572
+ const m = /^(\d{4})-(\d{2})$/.exec(month);
573
+ if (!m)
574
+ return null;
575
+ return { year: Number(m[1]), monthIndex: Number(m[2]) - 1 };
576
+ }
577
+ function prettyMonth(month) {
578
+ const parts = splitMonth(month);
579
+ if (parts === null)
580
+ return month;
581
+ const name = MONTHS_LONG[LANG][parts.monthIndex];
582
+ if (name === undefined)
583
+ return month;
584
+ const cap = LANG === "ru" ? name.charAt(0).toUpperCase() + name.slice(1) : name;
585
+ return `${cap} ${parts.year}`;
586
+ }
587
+ function periodLabel(period) {
588
+ if (/^\d{4}$/.test(period))
589
+ return `${period} ${t("soFar")}`;
590
+ return prettyMonth(period);
591
+ }
592
+ function monthsBetween(from, to) {
593
+ const a = splitMonth(from);
594
+ const b = splitMonth(to);
595
+ if (a === null || b === null)
596
+ return 0;
597
+ return (b.year - a.year) * 12 + (b.monthIndex - a.monthIndex);
598
+ }
599
+ function seriesMeta(key, fallbackLabel) {
600
+ const k = key.toLowerCase();
601
+ const ru = LANG === "ru";
602
+ if (k === "total")
603
+ return { label: t("total"), short: t("total"), color: PALETTE.green, cap: null, cur: null };
604
+ if (k === "trading212")
605
+ return { label: "EUR · Trading212 ETF", short: "Trading212", color: PALETTE.blue, cap: null, cur: "eur" };
606
+ if (k === "house")
607
+ return { label: ru ? "N26 (квартира)" : "N26 (house)", short: "N26", color: PALETTE.amber, cap: HOUSE_CAP_EUR, cur: "eur" };
608
+ if (k === "alfa-deposit")
609
+ return { label: ru ? "RUR · Альфа-Банк вклад" : "RUR · Alfa Bank", short: ru ? "Альфа-Банк" : "Alfa Bank", color: "#8A5A9E", cap: null, cur: "rub" };
610
+ if (k === "property")
611
+ return { label: ru ? "Недвижимость" : "Real estate", short: ru ? "Недвижимость" : "Real estate", color: "#9B6A43", cap: null, cur: null };
612
+ if (k === "bcs")
613
+ return { label: ru ? "RUR · БКС Инвестиции" : "RUR · BCS", short: "BCS", color: "#B84C4C", cap: null, cur: null };
614
+ return { label: fallbackLabel, short: fallbackLabel, color: "#7A776F", cap: null, cur: "rub" };
615
+ }
616
+ function savingsSection(p, series, nowMonth) {
617
+ const start = Math.round(p.startEur);
618
+ const showRub = p.rubPerEur !== null;
619
+ const rubAt = showRub ? p.rubPerEur : 0;
620
+ const maxEur = 5000;
621
+ const maxRub = 1e5;
622
+ const initEur = Math.min(SLIDER_DEFAULT_EUR, maxEur);
623
+ const initRub = Math.min(SLIDER_DEFAULT_RUB, maxRub);
624
+ const firstMonth = series.months.length > 0 ? series.months[0] : nowMonth;
625
+ const firstParts = splitMonth(firstMonth) ?? { year: 2020, monthIndex: 0 };
626
+ const nowIndex = Math.max(0, monthsBetween(firstMonth, nowMonth));
627
+ const idxOf = (m) => monthsBetween(firstMonth, m);
628
+ const histOf = (vals) => series.months.map((m, i) => [idxOf(m), Math.round(vals[i])]);
629
+ const nw = p.netWorth;
630
+ const nwBase = nw ? nw.propertyEur + nw.bcsEur : 0;
631
+ const netWorthStart = start + Math.round(nwBase);
632
+ const chartSeries = [
633
+ { key: "total", ...seriesMeta("total", "Total"), start: netWorthStart, hist: histOf(series.total.map((v) => v + nwBase)) }
634
+ ];
635
+ for (const line of series.lines) {
636
+ if (line.key === "house")
637
+ continue;
638
+ const meta = seriesMeta(line.key, line.label);
639
+ chartSeries.push({ key: line.key, label: meta.label, short: meta.short, color: meta.color, cap: meta.cap, cur: meta.cur, start: Math.round(line.values[line.values.length - 1] ?? 0), hist: histOf(line.values) });
640
+ }
641
+ if (nw) {
642
+ const flat = (v) => series.months.map((m) => [idxOf(m), Math.round(v)]);
643
+ const pMeta = seriesMeta("property", "Property");
644
+ const bMeta = seriesMeta("bcs", "BCS");
645
+ chartSeries.push({ key: "bcs", label: bMeta.label, short: bMeta.short, color: bMeta.color, cap: null, cur: null, start: Math.round(nw.bcsEur), hist: flat(nw.bcsEur), nw: true });
646
+ chartSeries.push({ key: "property", label: pMeta.label, short: pMeta.short, color: pMeta.color, cap: null, cur: null, start: Math.round(nw.propertyEur), hist: flat(nw.propertyEur), nw: true, base: Math.round(nw.propertyBaseEur), debt: Math.round(nw.propertyDebtEur), apr: nw.propertyApr, off: true });
647
+ }
648
+ const houseSeries = chartSeries.find((s) => s.cap !== null);
649
+ const houseStart = houseSeries?.start ?? 0;
650
+ const houseCap = houseSeries?.cap ?? HOUSE_CAP_EUR;
651
+ const chips = chartSeries.map((s) => `<button type="button" class="sv-chip ${s.key === "total" ? "sv-chip-tot" : "sv-chip-sec"}${s.off ? " off" : ""}" data-key="${esc(s.key)}" style="--c:${s.color}">` + `<span class="sv-dot"></span><span class="sv-cname">${esc(s.label)}</span> <strong>${esc(eur(s.start))}</strong></button>`).join("");
652
+ const eurPerRub = showRub && rubAt > 0 ? 1 / rubAt : 0.0105;
653
+ const initEff = initEur + initRub * eurPerRub;
654
+ const initVisStart = netWorthStart - (nw ? Math.round(nw.propertyEur) : 0);
655
+ const projEur = (mo) => initVisStart + initEff * mo;
656
+ const houseEur = Math.round(1e7 * eurPerRub / 100) * 100;
657
+ const milestones = nw ? nw.milestones : [
658
+ { eur: 25000, label: "25k", hero: false },
659
+ { eur: 50000, label: "50k", hero: false },
660
+ { eur: houseEur, label: "\uD83C\uDFE0 " + t("house"), hero: true }
661
+ ];
662
+ const data = {
663
+ nowI: nowIndex,
664
+ fy: firstParts.year,
665
+ fm: firstParts.monthIndex,
666
+ rub: showRub ? Number(rubAt.toFixed(4)) : 0,
667
+ houseStart,
668
+ houseCap,
669
+ eurPerRub: Number(eurPerRub.toFixed(6)),
670
+ maxRub,
671
+ series: chartSeries,
672
+ milestones,
673
+ sep: sep(),
674
+ mShort: MONTHS_SHORT[LANG],
675
+ sNow: t("now"),
676
+ sSafe: t("safe"),
677
+ sProj: t("projected")
678
+ };
679
+ const dataJson = JSON.stringify(data).replace(/</g, "\\u003c");
680
+ return `
681
+ <section class="card savings" aria-label="${esc(t("savedSoFar"))}">
682
+ <div class="sv-head">
683
+ <div class="sv-now">
684
+ <div class="sv-now-label">${esc(nw ? t("netWorth") : t("savedSoFar"))}</div>
685
+ <div class="sv-now-amt" id="svNowEur">${esc(eur(initVisStart))}</div>
686
+ ${showRub ? `<div class="sv-now-rub" id="svNowRub">${esc(rub(initVisStart * rubAt))}</div>` : ""}
687
+ </div>
688
+ <div class="sv-figs">
689
+ <div class="sv-fig">
690
+ <span class="sv-fig-label">${esc(t("in1y"))}</span>
691
+ <strong class="sv-fig-amt" id="svY1eur">${esc(eur(projEur(12)))}</strong>
692
+ ${showRub ? `<em class="sv-fig-rub" id="svY1rub">${esc(rub(projEur(12) * rubAt))}</em>` : ""}
693
+ </div>
694
+ <div class="sv-fig sv-fig-hero">
695
+ <span class="sv-fig-label">${esc(t("in5y"))}</span>
696
+ <strong class="sv-fig-amt" id="svY5eur">${esc(eur(projEur(60)))}</strong>
697
+ ${showRub ? `<em class="sv-fig-rub" id="svY5rub">${esc(rub(projEur(60) * rubAt))}</em>` : ""}
698
+ </div>
699
+ </div>
700
+ </div>
701
+ <div class="sv-legend">${chips}</div>
702
+ <div class="sv-chart-wrap">
703
+ <svg id="svChart" viewBox="0 0 1040 480" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${esc(t("savedSoFar"))}"></svg>
704
+ <div id="svTip" class="sv-tip" hidden></div>
705
+ </div>
706
+ <div class="sv-rate-head">${esc(t("monthly"))}</div>
707
+ <div class="sv-controls">
708
+ <div class="sv-slider">
709
+ <div class="sv-control-row">
710
+ <span class="sv-rate-label">${esc(t("eurMonthly"))}</span>
711
+ <span class="sv-rate"><span id="svRateEurLabel">${esc(eur(initEur))}</span>/${LANG === "ru" ? "мес" : "mo"}</span>
712
+ </div>
713
+ <input type="range" id="svRateEur" min="0" max="${maxEur}" step="25" value="${initEur}" aria-label="${esc(t("eurMonthly"))}" />
714
+ </div>
715
+ <div class="sv-slider">
716
+ <div class="sv-control-row">
717
+ <span class="sv-rate-label">${esc(t("rubMonthly"))}</span>
718
+ <span class="sv-rate sv-rate-rub"><span id="svRateRubLabel">${esc(rub(initRub))}</span>/${LANG === "ru" ? "мес" : "mo"}</span>
719
+ </div>
720
+ <input type="range" id="svRateRub" min="0" max="${maxRub}" step="1000" value="${initRub}" aria-label="${esc(t("rubMonthly"))}" />
721
+ </div>
722
+ </div>
723
+ <script>${savingsScript(dataJson)}</script>
724
+ </section>`;
725
+ }
726
+ function savingsScript(dataJson) {
727
+ return `(function(){
728
+ var D=JSON.parse(${JSON.stringify(dataJson)});
729
+ var SH=D.mShort;
730
+ var svg=document.getElementById('svChart'),tip=document.getElementById('svTip');
731
+ var eurS=document.getElementById('svRateEur'),rubS=document.getElementById('svRateRub');
732
+ var W=1040,H=480,PL=24,PR=82,PT=28,PB=38,pw=W-PL-PR,ph=H-PT-PB,narrow=false;
733
+ function layout(){
734
+ var r=svg.getBoundingClientRect();
735
+ W=Math.max(260,Math.round(r.width)||1040); H=Math.max(220,Math.round(r.height)||480);
736
+ svg.setAttribute('viewBox','0 0 '+W+' '+H);
737
+ narrow=W<560;
738
+ PL=narrow?8:24; PR=narrow?12:92; PT=narrow?20:28; PB=narrow?34:38;
739
+ pw=W-PL-PR; ph=H-PT-PB;
740
+ }
741
+ var BORD='rgba(125,120,108,.28)',SOFT='rgba(120,116,104,.75)',GAP='rgba(150,145,132,.6)';
742
+ var vis={}; D.series.forEach(function(s){vis[s.key]=!s.off;});
743
+ var RUBN=D.series.filter(function(s){return s.cur==='rub';}).length||1;
744
+ var pts=[], dMin=0, dMax=1, capY=-99;
745
+ var vS=D.nowI-12, vE=D.nowI+12;
746
+ function fmt(n){var s=Math.round(n),sg=s<0?'-':'';s=Math.abs(s);return sg+s.toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g,D.sep);}
747
+ function setText(id,t){var e=document.getElementById(id);if(e)e.textContent=t;}
748
+ function lbl(i){var t=D.fy*12+D.fm+Math.round(i);return SH[((t%12)+12)%12]+" '"+String(Math.floor(t/12)).slice(2);}
749
+ function xAt(i){return PL+pw*(i-vS)/((vE-vS)||1);}
750
+ function yAt(v){return PT+ph*(1-(v-dMin)/((dMax-dMin)||1));}
751
+ function projVal(s,k,rE,rEff){ if(s.nw)return (s.base!=null?s.base:s.start)*Math.pow(1+(s.apr||0),k/12)-(s.debt||0);
752
+ if(s.cur==='rub')return s.start+((rEff-rE)/RUBN)*k;
753
+ return s.start+rE*k; }
754
+ function clampV(){ var sp=vE-vS; sp=Math.max(4,Math.min(D.nowI+126,sp));
755
+ if(vS<-4){vE=-4+sp;vS=-4;} if(vE>D.nowI+120){vS=D.nowI+120-sp;vE=D.nowI+120;} }
756
+ function hx(c){return [parseInt(c.slice(1,3),16),parseInt(c.slice(3,5),16),parseInt(c.slice(5,7),16)];}
757
+ function mix(a,b,t){var x=hx(a),y=hx(b);return 'rgb('+Math.round(x[0]+(y[0]-x[0])*t)+','+Math.round(x[1]+(y[1]-x[1])*t)+','+Math.round(x[2]+(y[2]-x[2])*t)+')';}
758
+ function valColor(r){var S=[[0,'#C0392B'],[500,'#E07B39'],[1000,'#C7A23E'],[1500,'#6BA877'],[2500,'#2F8F5E'],[3000,'#1FB07A'],[5000,'#10D6A1']];
759
+ for(var i=1;i<S.length;i++){if(r<=S[i][0]){var t=(r-S[i-1][0])/((S[i][0]-S[i-1][0])||1);return mix(S[i-1][1],S[i][1],t);}}return S[S.length-1][1];}
760
+ function line(a,color,dash,w,op){ if(a.length<1)return '';
761
+ var p=a.map(function(d){return xAt(d[0]).toFixed(1)+','+yAt(d[1]).toFixed(1);}).join(' ');
762
+ return '<polyline points="'+p+'" fill="none" stroke="'+color+'" stroke-width="'+(w||2.6)+'" stroke-linejoin="round" stroke-linecap="round" stroke-opacity="'+(op==null?1:op)+'"'+(dash?' stroke-dasharray="'+dash+'"':'')+'/>'; }
763
+ function draw(){
764
+ var rE=Number(eurS.value), rR=Number(rubS.value), rEff=rE+rR*D.eurPerRub;
765
+ var colE=valColor(rE), colR=valColor(rR*(5000/(D.maxRub||100000)));
766
+ // Total is computed from the VISIBLE component lines, so toggling a chip (e.g.
767
+ // real estate) moves the headline number too — hide property and you see the liquid pile.
768
+ var comps=[]; D.series.forEach(function(s){ if(s.key!=='total') comps.push(s); });
769
+ var nMonths=comps.length?comps[0].hist.length:0;
770
+ function visC(){ var a=[]; comps.forEach(function(c){ if(vis[c.key]) a.push(c); }); return a; }
771
+ function totHistArr(){ var vc=visC(),arr=[]; for(var i=0;i<nMonths;i++){ var sum=0,mi=comps[0].hist[i][0]; for(var j=0;j<vc.length;j++){ var h=vc[j].hist[i]; if(h)sum+=h[1]; } arr.push([mi,sum]); } return arr; }
772
+ function totProj(k){ var vc=visC(),s=0; for(var j=0;j<vc.length;j++) s+=projVal(vc[j],k,rE,rEff); return s; }
773
+ var TH=totHistArr();
774
+ var allV=[0];
775
+ comps.forEach(function(s){ if(!vis[s.key])return;
776
+ s.hist.forEach(function(p){ if(p[0]>=vS&&p[0]<=vE) allV.push(p[1]); });
777
+ for(var k=0;k<=120;k++){ if(D.nowI+k>vE)break; allV.push(projVal(s,k,rE,rEff)); } });
778
+ if(vis.total){ TH.forEach(function(p){ if(p[0]>=vS&&p[0]<=vE) allV.push(p[1]); });
779
+ for(var kt=0;kt<=120;kt++){ if(D.nowI+kt>vE)break; allV.push(totProj(kt)); } }
780
+ dMax=Math.max.apply(null,allV); dMin=Math.min.apply(null,allV); if(dMin>0)dMin=0;
781
+ var out='<defs><linearGradient id="svFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${PALETTE.greenBright}" stop-opacity="0.16"/><stop offset="100%" stop-color="${PALETTE.greenBright}" stop-opacity="0.01"/></linearGradient><linearGradient id="svScrim" x1="0" y1="0" x2="1" y2="0"><stop offset="0" class="sv-scrimc" stop-opacity="0"/><stop offset="0.55" class="sv-scrimc" stop-opacity="0.82"/><stop offset="1" class="sv-scrimc" stop-opacity="1"/></linearGradient><clipPath id="svClip"><rect x="'+PL+'" y="0" width="'+pw+'" height="'+H+'"/></clipPath></defs>';
782
+ out+='<g clip-path="url(#svClip)">';
783
+ var cal0=D.fy*12+D.fm, ci;
784
+ for(ci=Math.ceil(vS);ci<=Math.floor(vE);ci++){ if(((((cal0+ci)%12)+12)%12)===0){ var yx=xAt(ci);
785
+ out+='<line x1="'+yx.toFixed(1)+'" y1="'+PT+'" x2="'+yx.toFixed(1)+'" y2="'+(PT+ph)+'" stroke="'+BORD+'" stroke-width="1"/>';
786
+ if(yx<W-(narrow?108:40)) out+='<text x="'+(yx+5).toFixed(1)+'" y="'+(PT+12)+'" class="sv-yr">'+Math.floor((cal0+ci)/12)+'</text>'; } }
787
+ D.milestones.forEach(function(m){ var my=yAt(m.eur); if(my>PT+2&&my<PT+ph){
788
+ out+='<line x1="'+PL+'" y1="'+my.toFixed(1)+'" x2="'+(W-PR)+'" y2="'+my.toFixed(1)+'" stroke="'+(m.hero?'${PALETTE.green}':SOFT)+'" stroke-width="'+(m.hero?1.4:1)+'" stroke-dasharray="'+(m.hero?'6 4':'2 6')+'" stroke-opacity="'+(m.hero?0.8:0.4)+'"/>'; } });
789
+ out+='<line x1="'+PL+'" y1="'+yAt(0).toFixed(1)+'" x2="'+(W-PR)+'" y2="'+yAt(0).toFixed(1)+'" stroke="'+BORD+'" stroke-width="1" stroke-dasharray="3 4"/>';
790
+ capY=yAt(D.houseCap);
791
+ if(capY>PT && capY<PT+ph){ out+='<line x1="'+PL+'" y1="'+capY.toFixed(1)+'" x2="'+(W-PR)+'" y2="'+capY.toFixed(1)+'" stroke="'+SOFT+'" stroke-width="1" stroke-dasharray="2 6" stroke-opacity="0.4"/>'; }
792
+ pts=[]; var labels=[];
793
+ if(vis.total && TH.length){ var thlast=TH[TH.length-1][0];
794
+ var ha=TH.filter(function(p){return p[0]>=vS&&p[0]<=Math.min(vE,thlast);});
795
+ if(ha.length){ var base=(PT+ph).toFixed(1);
796
+ out+='<path d="M '+xAt(ha[0][0]).toFixed(1)+' '+base+' '+ha.map(function(p){return 'L '+xAt(p[0]).toFixed(1)+' '+yAt(p[1]).toFixed(1);}).join(' ')+' L '+xAt(ha[ha.length-1][0]).toFixed(1)+' '+base+' Z" fill="url(#svFill)"/>'; } }
797
+ comps.forEach(function(s){ if(!vis[s.key])return;
798
+ var lh=s.hist.length?s.hist[s.hist.length-1]:[D.nowI,s.start];
799
+ var hp=s.hist.filter(function(p){return p[0]>=vS-1&&p[0]<=vE;});
800
+ out+=line(hp,s.color,'',2,0.52);
801
+ if(lh[0]<D.nowI){ out+=line([[lh[0],lh[1]],[D.nowI,lh[1]]],GAP,'1 5',2.2,0.8); }
802
+ var pp=[]; for(var k=0;k<=120;k++){var i=D.nowI+k; if(i>vE)break; pp.push([i,projVal(s,k,rE,rEff)]);}
803
+ out+=line(pp,s.color,'7 6',1.7,0.52);
804
+ hp.forEach(function(p){pts.push({x:xAt(p[0]),y:yAt(p[1]),i:p[0],v:p[1],s:s.label,c:s.color,f:0});});
805
+ pp.forEach(function(p,j){if(j>0)pts.push({x:xAt(p[0]),y:yAt(p[1]),i:p[0],v:p[1],s:s.label,c:s.color,f:1});});
806
+ var ep=pp.length?pp[pp.length-1]:(hp.length?hp[hp.length-1]:null); if(ep)labels.push({y:yAt(ep[1]),t:s.short||s.label,c:s.color}); });
807
+ if(vis.total && TH.length){ var T0=D.series[0],tcol=T0.color,tlab=T0.label,tsh=T0.short||T0.label;
808
+ var tlh=TH[TH.length-1];
809
+ var thp=TH.filter(function(p){return p[0]>=vS-1&&p[0]<=vE;});
810
+ out+=line(thp,tcol,'',3.6,1);
811
+ if(tlh[0]<D.nowI){ out+=line([[tlh[0],tlh[1]],[D.nowI,tlh[1]]],GAP,'1 5',2.2,0.8); }
812
+ var tpp=[]; for(var kk=0;kk<=120;kk++){var ii=D.nowI+kk; if(ii>vE)break; tpp.push([ii,totProj(kk)]);}
813
+ out+=line(tpp,tcol,'7 6',3,1);
814
+ thp.forEach(function(p){pts.push({x:xAt(p[0]),y:yAt(p[1]),i:p[0],v:p[1],s:tlab,c:tcol,f:0});});
815
+ tpp.forEach(function(p,j){if(j>0)pts.push({x:xAt(p[0]),y:yAt(p[1]),i:p[0],v:p[1],s:tlab,c:tcol,f:1});});
816
+ var tep=tpp.length?tpp[tpp.length-1]:(thp.length?thp[thp.length-1]:null); if(tep)labels.push({y:yAt(tep[1]),t:tsh,c:tcol}); }
817
+ if(D.nowI>=vS&&D.nowI<=vE){ var nx=xAt(D.nowI).toFixed(1);
818
+ out+='<line x1="'+nx+'" y1="'+PT+'" x2="'+nx+'" y2="'+(PT+ph)+'" stroke="'+SOFT+'" stroke-width="1.2"/><text x="'+nx+'" y="'+(PT-8)+'" text-anchor="middle" class="sv-now-mk">'+D.sNow+'</text>'; }
819
+ out+='</g>';
820
+ var ticks=narrow?3:4,i; for(i=0;i<=ticks;i++){ var idx=vS+(vE-vS)*i/ticks; var x=xAt(idx).toFixed(1);
821
+ var an=(i===0?'start':(i===ticks?'end':'middle'));
822
+ out+='<text x="'+x+'" y="'+(H-12)+'" text-anchor="'+an+'" class="sv-ax">'+lbl(idx)+'</text>'; }
823
+ D.milestones.forEach(function(m){ var my=yAt(m.eur); if(my>PT+2&&my<PT+ph){
824
+ out+='<text x="'+(PL+5)+'" y="'+(my-5).toFixed(1)+'" class="sv-ms'+(m.hero?' hero':'')+'">'+m.label+'</text>'; } });
825
+ out+='<text id="svTgt" x="'+(PL+5)+'" y="'+(capY-5).toFixed(1)+'" class="sv-tgt" style="display:none">'+fmt(D.houseCap)+' '+D.sSafe+'</text>';
826
+ if(labels.length){ labels.sort(function(a,b){return a.y-b.y;});
827
+ var gap=narrow?13:13, fs=narrow?10:10.5;
828
+ for(var li=1;li<labels.length;li++){ if(labels[li].y-labels[li-1].y<gap) labels[li].y=labels[li-1].y+gap; }
829
+ var ov=labels[labels.length-1].y-(PT+ph-2); if(ov>0){ for(var lj=0;lj<labels.length;lj++) labels[lj].y-=ov; }
830
+ if(labels[0].y<PT+8){ var un=PT+8-labels[0].y; for(var lm=0;lm<labels.length;lm++) labels[lm].y+=un; }
831
+ if(narrow){ var scW=Math.min(104,pw*0.46);
832
+ out+='<rect x="'+(W-scW).toFixed(1)+'" y="'+PT+'" width="'+scW.toFixed(1)+'" height="'+ph+'" fill="url(#svScrim)"/>';
833
+ labels.forEach(function(L){ out+='<text x="'+(W-7)+'" y="'+(L.y+3).toFixed(1)+'" text-anchor="end" class="sv-llabel" style="font-size:'+fs+'px" fill="'+L.c+'">'+L.t+'</text>'; }); }
834
+ else { labels.forEach(function(L){ out+='<text x="'+(W-PR+6)+'" y="'+(L.y+3).toFixed(1)+'" class="sv-llabel" style="font-size:'+fs+'px" fill="'+L.c+'">'+L.t+'</text>'; }); } }
835
+ out+='<circle id="svDot" r="5.5" fill="#fff" stroke="${PALETTE.green}" stroke-width="2.5" style="display:none"/>';
836
+ svg.innerHTML=out;
837
+ setText('svRateEurLabel','€'+fmt(rE)); setText('svRateRubLabel','₽'+fmt(rR));
838
+ var rle=document.getElementById('svRateEurLabel'); if(rle)rle.style.color=colE;
839
+ var rlr=document.getElementById('svRateRubLabel'); if(rlr)rlr.style.color=colR;
840
+ eurS.style.setProperty('--thumb',colE); eurS.classList.toggle('hot', rE>=3000);
841
+ rubS.style.setProperty('--thumb',colR); rubS.classList.toggle('hot', rR>=60000);
842
+ setText('svNowEur','€'+fmt(totProj(0))); if(D.rub)setText('svNowRub','₽'+fmt(totProj(0)*D.rub));
843
+ setText('svY1eur','€'+fmt(totProj(12))); setText('svY5eur','€'+fmt(totProj(60)));
844
+ if(D.rub){setText('svY1rub','₽'+fmt(totProj(12)*D.rub)); setText('svY5rub','₽'+fmt(totProj(60)*D.rub));}
845
+ var tc=document.querySelector('.sv-chip[data-key="total"] strong'); if(tc)tc.textContent='€'+fmt(totProj(0));
846
+ }
847
+ function showTip(cx,cy){ if(!pts.length)return;
848
+ var rc=svg.getBoundingClientRect(), vx=(cx-rc.left)/rc.width*W, vy=(cy-rc.top)/rc.height*H;
849
+ var tgt=document.getElementById('svTgt'); if(tgt)tgt.style.display=(Math.abs(vy-capY)<13)?'':'none';
850
+ var best=null,bd=1e9; pts.forEach(function(p){var d=(p.x-vx)*(p.x-vx)+(p.y-vy)*(p.y-vy)*0.3; if(d<bd){bd=d;best=p;}});
851
+ if(!best)return;
852
+ var dot=document.getElementById('svDot');
853
+ if(dot){dot.setAttribute('cx',best.x.toFixed(1));dot.setAttribute('cy',best.y.toFixed(1));dot.setAttribute('stroke',best.c);dot.style.display='';}
854
+ tip.innerHTML='<span class="sv-tip-m">'+best.s+' · '+lbl(best.i)+(best.f?' · '+D.sProj:'')+'</span><span class="sv-tip-v" style="color:'+best.c+'">€'+fmt(best.v)+(D.rub?' · ₽'+fmt(best.v*D.rub):'')+'</span>';
855
+ tip.style.left=(best.x/W*rc.width)+'px'; tip.style.top=(best.y/H*rc.height)+'px'; tip.hidden=false;
856
+ }
857
+ function hideTip(){tip.hidden=true; var d=document.getElementById('svDot'); if(d)d.style.display='none'; var t=document.getElementById('svTgt'); if(t)t.style.display='none';}
858
+ var drag=false,dragX=0;
859
+ svg.addEventListener('mousemove',function(e){ if(drag){var rc=svg.getBoundingClientRect();var sp=vE-vS;var d=-(e.clientX-dragX)/rc.width*sp;vS+=d;vE+=d;dragX=e.clientX;clampV();draw();} else showTip(e.clientX,e.clientY); });
860
+ svg.addEventListener('mouseleave',function(){hideTip();drag=false;});
861
+ svg.addEventListener('mousedown',function(e){drag=true;dragX=e.clientX;});
862
+ window.addEventListener('mouseup',function(){drag=false;});
863
+ svg.addEventListener('wheel',function(e){
864
+ var rc=svg.getBoundingClientRect();
865
+ if(e.ctrlKey||e.metaKey){ e.preventDefault(); var vx=(e.clientX-rc.left)/rc.width*W; var cidx=vS+(vx-PL)/pw*(vE-vS);
866
+ var f=Math.exp(e.deltaY*0.012); var nsp=Math.max(4,Math.min(D.nowI+126,(vE-vS)*f)); var fr=(cidx-vS)/((vE-vS)||1);
867
+ vS=cidx-fr*nsp; vE=vS+nsp; clampV(); draw(); }
868
+ else if(Math.abs(e.deltaX)>Math.abs(e.deltaY)){ e.preventDefault(); var sp=vE-vS,d=e.deltaX/pw*sp; vS+=d;vE+=d;clampV();draw(); }
869
+ },{passive:false});
870
+ var pd=0;
871
+ svg.addEventListener('touchstart',function(e){if(e.touches.length===2)pd=Math.abs(e.touches[0].clientX-e.touches[1].clientX); else if(e.touches.length===1)showTip(e.touches[0].clientX,e.touches[0].clientY);},{passive:true});
872
+ svg.addEventListener('touchmove',function(e){ if(e.touches.length===2){ e.preventDefault(); var rc=svg.getBoundingClientRect();
873
+ var d=Math.abs(e.touches[0].clientX-e.touches[1].clientX); if(pd){ var cx=((e.touches[0].clientX+e.touches[1].clientX)/2-rc.left)/rc.width*W;
874
+ var cidx=vS+(cx-PL)/pw*(vE-vS); var f=pd/d; var nsp=Math.max(4,Math.min(D.nowI+126,(vE-vS)*f)); var fr=(cidx-vS)/((vE-vS)||1);
875
+ vS=cidx-fr*nsp; vE=vS+nsp; clampV(); draw(); } pd=d; }
876
+ else if(e.touches.length===1){ showTip(e.touches[0].clientX,e.touches[0].clientY); } },{passive:false});
877
+ svg.addEventListener('touchend',function(){hideTip();});
878
+ document.querySelectorAll('.sv-chip').forEach(function(b){ b.addEventListener('click',function(){
879
+ var k=b.getAttribute('data-key'); vis[k]=!vis[k]; b.classList.toggle('off',!vis[k]); draw(); }); });
880
+ eurS.addEventListener('input',draw);
881
+ rubS.addEventListener('input',draw);
882
+ var raf=0;
883
+ function relayout(){ if(raf)cancelAnimationFrame(raf); raf=requestAnimationFrame(function(){layout();draw();}); }
884
+ if(window.ResizeObserver){ new ResizeObserver(relayout).observe(svg); } else { window.addEventListener('resize',relayout); }
885
+ layout(); draw();
886
+ })();`;
887
+ }
888
+ function splitBar(groups, total) {
889
+ if (total <= 0)
890
+ return "";
891
+ const mand = groups.find((g) => g.tier === "mandatory")?.total ?? 0;
892
+ const flex = groups.find((g) => g.tier === "non-mandatory")?.total ?? 0;
893
+ const seg = (amount, color, name) => {
894
+ if (amount <= 0)
895
+ return "";
896
+ const pct = amount / total * 100;
897
+ return `<div class="bd-seg" style="width:${pct.toFixed(2)}%;background:${color}" title="${esc(name)} ${esc(eur(amount))} (${Math.round(pct)}%)"><span class="bd-seg-l">${esc(name)} ${Math.round(pct)}%</span></div>`;
898
+ };
899
+ return `<div class="bd-bar bd-split">${seg(mand, PALETTE.green, t("mandatory"))}${seg(flex, PALETTE.amber, t("nonMandatory"))}</div>`;
900
+ }
901
+ function tierBar(g) {
902
+ if (g.total <= 0 || g.categories.length === 0)
903
+ return "";
904
+ const bars = g.categories.map((c) => {
905
+ const pct = c.total / g.total * 100;
906
+ const color = categoryColor(c.category);
907
+ const label = pct >= 4.5 ? `<span class="bd-seg-l">${esc(catName(c.category))} ${Math.round(pct)}%</span>` : "";
908
+ return `<div class="bd-seg" data-cat="${esc(c.category)}" style="width:${pct.toFixed(2)}%;background:${color}" title="${esc(catName(c.category))} ${esc(eur(c.total))}">${label}</div>`;
909
+ }).join("");
910
+ return `<div class="bd-bar bd-tier">${bars}</div>`;
911
+ }
912
+ function categoryDetails(c, monthTotal, tierMax) {
913
+ const pct = monthTotal > 0 ? Math.round(c.total / monthTotal * 100) : 0;
914
+ const widthPct = tierMax > 0 ? c.total / tierMax * 100 : 0;
915
+ const rows = c.txns.map((tx) => (() => {
916
+ const mi = merchantInfo(tx.merchant);
917
+ return `<li><span class="t-date">${esc(txDate(tx.date))}</span><span class="t-merch"><span class="t-name">${esc(mi.name)}</span>${mi.note ? `<span class="t-note">${esc(mi.note)}</span>` : ""}</span><span class="t-acct">${esc(accountLabel(tx.account))}</span><span class="t-amt">${esc(eur(tx.eur))}</span></li>`;
918
+ })()).join("");
919
+ return `
920
+ <details class="cat" data-cat="${esc(c.category)}">
921
+ <summary>
922
+ <span class="cat-fill" style="width:${widthPct.toFixed(1)}%;background:${categoryColor(c.category)}2E"></span>
923
+ <span class="cat-dot" style="background:${categoryColor(c.category)}"></span>
924
+ <span class="cat-name">${esc(catName(c.category))}</span>
925
+ <span class="cat-pct">${pct}%</span>
926
+ <span class="cat-meta">${c.count} ${esc(itemsWord(c.count))}</span>
927
+ <span class="cat-amt">${esc(eur(c.total))}</span>
928
+ </summary>
929
+ <ul class="txns">${rows}</ul>
930
+ </details>`;
931
+ }
932
+ function tierBlock(g, monthTotal) {
933
+ if (g.categories.length === 0)
934
+ return "";
935
+ const label = g.tier === "mandatory" ? t("mandatory") : t("nonMandatory");
936
+ const sub = g.tier === "mandatory" ? t("mandatorySub") : t("flexSub");
937
+ const tierMax = g.categories.reduce((mx, c) => Math.max(mx, c.total), 0);
938
+ const cats = g.categories.map((c) => categoryDetails(c, monthTotal, tierMax)).join("");
939
+ return `
940
+ <div class="tier tier-${g.tier}">
941
+ <div class="tier-head"><h3>${esc(label)}</h3><span class="tier-total">${esc(eur(g.total))}</span></div>
942
+ <p class="tier-sub">${esc(sub)}</p>
943
+ ${tierBar(g)}
944
+ ${cats}
945
+ </div>`;
946
+ }
947
+ function monthBlock(m, selected) {
948
+ const total = m.groups.reduce((s, g) => s + g.total, 0);
949
+ const blocks = m.groups.map((g) => tierBlock(g, total)).join("");
950
+ return `
951
+ <div class="month-block" data-month="${esc(m.month)}"${selected ? "" : " hidden"}>
952
+ <div class="month-total">${esc(eur(total))}<span class="month-total-label">${esc(periodLabel(m.month))} · ${esc(t("spent"))}</span></div>
953
+ ${splitBar(m.groups, total)}
954
+ ${blocks || `<p class="muted">${esc(t("none"))}</p>`}
955
+ </div>`;
956
+ }
957
+ function spendSection(months, selected) {
958
+ const ordered = [...months].sort((a, b) => {
959
+ const ay = /^\d{4}$/.test(a.month) ? 1 : 0;
960
+ const by = /^\d{4}$/.test(b.month) ? 1 : 0;
961
+ if (ay !== by)
962
+ return ay - by;
963
+ return a.month < b.month ? 1 : -1;
964
+ });
965
+ const options = ordered.map((m) => `<option value="${esc(m.month)}"${m.month === selected ? " selected" : ""}>${esc(periodLabel(m.month))}</option>`).join("");
966
+ const blocks = ordered.map((m) => monthBlock(m, m.month === selected)).join("");
967
+ const script = `(function(){
968
+ var sec=document.currentScript.parentElement, sel=document.getElementById('monthSel');
969
+ if(sel)sel.addEventListener('change',function(){var m=sel.value;
970
+ sec.querySelectorAll('.month-block').forEach(function(b){b.hidden=b.getAttribute('data-month')!==m;});});
971
+ function setFocus(blk,cat){ blk.querySelectorAll('.bd-seg').forEach(function(s){var on=s.getAttribute('data-cat')===cat;s.classList.toggle('dim',!on);s.classList.toggle('hot',on);});
972
+ blk.querySelectorAll('details.cat').forEach(function(d){d.classList.toggle('rowdim',d.getAttribute('data-cat')!==cat);}); }
973
+ function clearFocus(blk){ blk.querySelectorAll('.bd-seg').forEach(function(s){s.classList.remove('dim','hot');}); blk.querySelectorAll('details.cat').forEach(function(d){d.classList.remove('rowdim');}); }
974
+ sec.addEventListener('mouseover',function(e){var el=e.target.closest('[data-cat]'); if(!el)return; var blk=el.closest('.month-block'); if(blk)setFocus(blk,el.getAttribute('data-cat')); });
975
+ sec.addEventListener('mouseout',function(e){var el=e.target.closest('[data-cat]'); if(!el)return; var blk=el.closest('.month-block'); if(blk&&!blk.querySelector('[data-cat]:hover'))clearFocus(blk); });
976
+ })();`;
977
+ return `
978
+ <section class="card spend" aria-label="${esc(t("whereItGoes"))}">
979
+ <header class="block-head spend-head">
980
+ <div><div class="eyebrow">${esc(t("spendKicker"))}</div><h2>${esc(t("whereItGoes"))}</h2><p class="muted">${esc(t("tapCategory"))}</p></div>
981
+ <select id="monthSel" class="month-pick" aria-label="${esc(t("whereItGoes"))}">${options}</select>
982
+ </header>
983
+ ${blocks}
984
+ <script>${script}</script>
985
+ </section>`;
986
+ }
987
+ function isoDay(today) {
988
+ const y = today.getUTCFullYear();
989
+ const m = String(today.getUTCMonth() + 1).padStart(2, "0");
990
+ const d = String(today.getUTCDate()).padStart(2, "0");
991
+ return `${y}-${m}-${d}`;
992
+ }
993
+ function controls() {
994
+ const other = LANG === "ru" ? "en" : "ru";
995
+ return `
996
+ <div class="controls">
997
+ <a class="ctl lang ${LANG === "en" ? "on" : ""}" href="/?lang=en">EN</a>
998
+ <a class="ctl lang ${LANG === "ru" ? "on" : ""}" href="/?lang=ru" data-other="${other}">RU</a>
999
+ <button type="button" class="ctl theme" id="themeBtn" aria-label="${esc(t("theme"))}"><span class="theme-ic">\uD83C\uDF19</span></button>
1000
+ </div>`;
1001
+ }
1002
+ function renderDashboard(input) {
1003
+ LANG = input.lang ?? "en";
1004
+ DISPLAY = input.display ?? {};
1005
+ const { report, focusMonth, today, nowMonth, projection, series, months, selectedMonth } = input;
1006
+ const focus = report.months.find((m) => m.month === focusMonth);
1007
+ if (focus === undefined) {
1008
+ throw new Error(`renderDashboard: focus month "${focusMonth}" not found in report (have: ${report.months.map((m) => m.month).join(", ") || "none"})`);
1009
+ }
1010
+ const savingsBlock = projection && series && series.months.length >= 2 ? savingsSection(projection, series, nowMonth) : "";
1011
+ const spendBlock = months && months.length > 0 ? spendSection(months, selectedMonth ?? focusMonth) : "";
1012
+ const themeBoot = `(function(){try{var t=localStorage.getItem('kopeika-theme');if(!t)t=window.matchMedia&&window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`;
1013
+ const themeToggle = `(function(){var b=document.getElementById('themeBtn');if(!b)return;b.addEventListener('click',function(){var d=document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',d);try{localStorage.setItem('kopeika-theme',d);}catch(e){}b.querySelector('.theme-ic').textContent=d==='dark'?'\\u2600\\ufe0f':'\\u{1F319}';});var cur=document.documentElement.getAttribute('data-theme');b.querySelector('.theme-ic').textContent=cur==='dark'?'\\u2600\\ufe0f':'\\u{1F319}';})();`;
1014
+ return `<!DOCTYPE html>
1015
+ <html lang="${LANG}">
1016
+ <head>
1017
+ <meta charset="utf-8" />
1018
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
1019
+ <title>${esc(t("title"))} · ${esc(prettyMonth(focus.month))}</title>
1020
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
1021
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1022
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@500;600;700&display=swap" rel="stylesheet" />
1023
+ <script>${themeBoot}</script>
1024
+ <style>
1025
+ ${buildCss()}
1026
+ </style>
1027
+ </head>
1028
+ <body>
1029
+ <main class="page">
1030
+ <header class="page-head">
1031
+ ${controls()}
1032
+ <div class="eyebrow">${esc(t("worthKicker"))}</div>
1033
+ <h1>${esc(t("title"))}</h1>
1034
+ <p class="subtitle">${esc(t("subtitle"))}</p>
1035
+ </header>
1036
+ ${savingsBlock}
1037
+ ${spendBlock}
1038
+ <footer class="page-foot">
1039
+ ${esc(t("updated"))} ${esc(isoDay(today))} · kopeika${DISPLAY.footer ? ` · ${esc(DISPLAY.footer[LANG])}` : ""}
1040
+ </footer>
1041
+ </main>
1042
+ <script>${themeToggle}</script>
1043
+ </body>
1044
+ </html>
1045
+ `;
1046
+ }
1047
+ function buildCss() {
1048
+ return ` :root {
1049
+ --bg:#f6f4ec; --card:#fcfaf3; --card-soft:#efeadd;
1050
+ --ink:#1b1d1a; --ink-soft:#585b51; --ink-faint:#8d9083;
1051
+ --green:#16864a; --green-bright:#11a06a; --green-soft:#e4efe6;
1052
+ --amber:#c2641e; --amber-soft:#f3e7d6; --blue:#3e7ca8; --border:#e3ddcd; --line-soft:#ece7da;
1053
+ --dot:rgba(27,29,26,.05);
1054
+ --display:"Space Grotesk","Inter",sans-serif; --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
1055
+ --radius:22px; --radius-sm:13px; --radius-xs:9px;
1056
+ --shadow:0 1px 2px rgba(27,29,26,.04), 0 18px 40px -24px rgba(27,29,26,.26);
1057
+ }
1058
+ [data-theme="dark"] {
1059
+ --bg:#0e100d; --card:#181a15; --card-soft:#22251c;
1060
+ --ink:#ece9df; --ink-soft:#a6a99f; --ink-faint:#71756b;
1061
+ --green:#5ccf90; --green-bright:#79e7a6; --green-soft:rgba(92,207,144,.13);
1062
+ --amber:#e8a652; --amber-soft:rgba(232,166,82,.14); --blue:#6ba6d0; --border:#2b2e26; --line-soft:#23261d;
1063
+ --dot:rgba(255,255,255,.035);
1064
+ --shadow:0 1px 2px rgba(0,0,0,.34), 0 22px 46px -26px rgba(0,0,0,.6);
1065
+ }
1066
+ * { box-sizing:border-box; }
1067
+ html,body { margin:0; padding:0; }
1068
+ body { background-color:var(--bg);
1069
+ background-image:radial-gradient(circle at 1px 1px, var(--dot) 1px, transparent 0);
1070
+ background-size:26px 26px; color:var(--ink); line-height:1.55;
1071
+ font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; transition:background-color .2s,color .2s; }
1072
+ .page { max-width:1120px; margin:0 auto; padding:52px 40px 80px; }
1073
+ .eyebrow { font-family:var(--mono); font-size:11.5px; font-weight:600; text-transform:uppercase; letter-spacing:.18em; color:var(--green); }
1074
+ .page-head { margin-bottom:32px; position:relative; }
1075
+ .page-head .eyebrow { margin-bottom:12px; }
1076
+ .page-head h1 { font-family:var(--display); font-size:46px; font-weight:600; letter-spacing:-.035em; line-height:1.02; margin:0; color:var(--ink); }
1077
+ .page-head .subtitle { font-size:16px; color:var(--ink-soft); margin:12px 0 0; max-width:60ch; }
1078
+ .controls { position:absolute; top:2px; right:0; display:flex; gap:6px; align-items:center; }
1079
+ .ctl { font-family:var(--mono); font-size:12px; font-weight:600; color:var(--ink-soft); background:var(--card); border:1px solid var(--border);
1080
+ border-radius:10px; padding:7px 10px; cursor:pointer; text-decoration:none; line-height:1; display:inline-flex; align-items:center; transition:.15s; }
1081
+ .ctl:hover { border-color:var(--green); color:var(--green); }
1082
+ .ctl.lang.on, .ctl.lang.on:hover { background:var(--green); border-color:var(--green); color:var(--card); }
1083
+ .ctl.theme { font-size:14px; padding:6px 9px; }
1084
+ .card { background:linear-gradient(180deg, var(--card) 0%, var(--card-soft) 240%); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow); padding:30px 34px; margin-bottom:22px; }
1085
+ .block-head { margin-bottom:18px; }
1086
+ .block-head h2 { font-family:var(--display); font-size:23px; font-weight:600; letter-spacing:-.02em; margin:0; }
1087
+ .block-head .eyebrow { margin-bottom:9px; }
1088
+ .block-head .muted { font-size:13.5px; color:var(--ink-soft); margin:4px 0 0; }
1089
+ .muted { color:var(--ink-soft); }
1090
+
1091
+ .savings { background:linear-gradient(168deg, var(--card) 0%, color-mix(in srgb, var(--green) 6%, var(--card)) 140%); border-color:color-mix(in srgb, var(--green) 22%, var(--border)); }
1092
+ .sv-head { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; flex-wrap:wrap; margin-bottom:18px; }
1093
+ .sv-now-label { font-family:var(--mono); font-size:11.5px; font-weight:600; text-transform:uppercase; letter-spacing:.18em; color:var(--green); }
1094
+ .sv-now-amt { font-family:var(--display); font-size:58px; font-weight:600; letter-spacing:-.035em; line-height:1; margin-top:8px; font-variant-numeric:tabular-nums; }
1095
+ .sv-now-rub { font-family:var(--mono); font-size:18px; font-weight:600; color:var(--ink-soft); letter-spacing:-.01em; margin-top:8px; font-variant-numeric:tabular-nums; }
1096
+ .sv-figs { display:flex; gap:12px; }
1097
+ .sv-fig { background:var(--card); border:1px solid var(--border); border-radius:var(--radius-sm); padding:14px 18px; min-width:138px; }
1098
+ .sv-fig-label { font-family:var(--mono); font-size:10.5px; font-weight:600; text-transform:uppercase; letter-spacing:.14em; color:var(--ink-faint); display:block; }
1099
+ .sv-fig-amt { font-family:var(--display); font-size:27px; font-weight:600; letter-spacing:-.02em; display:block; margin-top:5px; font-variant-numeric:tabular-nums; }
1100
+ .sv-fig-rub { font-family:var(--mono); font-size:12.5px; color:var(--ink-soft); font-style:normal; display:block; margin-top:3px; font-variant-numeric:tabular-nums; }
1101
+ .sv-fig-hero { background:linear-gradient(150deg,#15935f 0%,#1fb079 100%); border-color:transparent; color:#fff; box-shadow:0 16px 32px -16px rgba(21,147,95,.7); }
1102
+ .sv-fig-hero .sv-fig-label { color:rgba(255,255,255,.85); }
1103
+ .sv-fig-hero .sv-fig-amt { color:#fff; }
1104
+ .sv-fig-hero .sv-fig-rub { color:rgba(255,255,255,.92); }
1105
+ .sv-legend { display:flex; flex-wrap:wrap; gap:7px; align-items:center; margin-bottom:4px; }
1106
+ /* ON: chip ringed + tinted in its own colour, solid dot, dark text. OFF: grey,
1107
+ hollow dot, struck-through, dimmed — so on/off reads at a glance. */
1108
+ .sv-chip { display:inline-flex; align-items:center; gap:7px; font-family:var(--mono); font-size:12px; font-weight:600; color:var(--ink);
1109
+ background:color-mix(in srgb, var(--c) 10%, var(--card)); border:1.4px solid color-mix(in srgb, var(--c) 55%, var(--border));
1110
+ border-radius:999px; padding:5px 12px; cursor:pointer; transition:.15s; }
1111
+ .sv-chip:hover { border-color:var(--c); }
1112
+ .sv-chip strong { color:var(--ink); font-weight:700; font-variant-numeric:tabular-nums; }
1113
+ .sv-chip .sv-dot { width:9px; height:9px; border-radius:50%; background:var(--c); flex:0 0 auto; }
1114
+ .sv-chip.off { background:var(--card); border-color:var(--border); color:var(--ink-faint); opacity:.75; }
1115
+ .sv-chip.off strong { color:var(--ink-faint); font-weight:600; }
1116
+ .sv-chip.off .sv-dot { background:transparent; box-shadow:inset 0 0 0 1.6px var(--ink-faint); }
1117
+ .sv-chip.off .sv-cname { text-decoration:line-through; }
1118
+ .sv-yr { fill:var(--ink-faint); font-family:var(--mono); font-size:10.5px; font-weight:600; opacity:.85; }
1119
+ .sv-ms { fill:var(--ink-faint); font-family:var(--mono); font-size:10px; font-weight:500; }
1120
+ .sv-ms.hero { fill:var(--green); font-size:10.5px; font-weight:700; }
1121
+ .sv-llabel { font-family:var(--mono); font-size:10px; font-weight:600; }
1122
+ .sv-scrimc { stop-color:var(--card); }
1123
+ .sv-chart-wrap { position:relative; margin:14px 0 4px; height:clamp(360px,40vw,500px); touch-action:pan-y; }
1124
+ #svChart { display:block; width:100%; height:100%; cursor:grab; }
1125
+ #svChart:active { cursor:grabbing; }
1126
+ .sv-ax { fill:var(--ink-faint); font-family:var(--mono); font-size:11px; font-weight:500; }
1127
+ .sv-tgt { fill:var(--amber); font-family:var(--mono); font-size:10.5px; font-weight:600; }
1128
+ .sv-now-mk { fill:var(--ink-faint); font-family:var(--mono); font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.12em; }
1129
+ .sv-tip { position:absolute; transform:translate(-50%,-130%); pointer-events:none; background:var(--ink); color:var(--bg); border-radius:10px; padding:7px 11px; font-family:var(--mono); font-size:12px; white-space:nowrap; box-shadow:0 10px 26px -8px rgba(0,0,0,.4); z-index:3; }
1130
+ .sv-tip-m { display:block; opacity:.7; font-size:10.5px; }
1131
+ .sv-tip-v { display:block; font-weight:700; font-size:14px; font-variant-numeric:tabular-nums; }
1132
+ .sv-rate-head { margin-top:24px; font-family:var(--mono); font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.16em; color:var(--ink-faint); }
1133
+ .sv-controls { margin-top:14px; display:grid; grid-template-columns:1fr 1fr; gap:18px 30px; }
1134
+ .sv-slider { min-width:0; }
1135
+ .sv-control-row { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:9px; gap:10px; }
1136
+ .sv-rate-label { font-family:var(--mono); font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.12em; color:var(--ink-soft); }
1137
+ .sv-rate { font-family:var(--display); font-size:26px; font-weight:600; letter-spacing:-.02em; color:var(--green); transition:color .1s; white-space:nowrap; font-variant-numeric:tabular-nums; }
1138
+ input[type=range] { -webkit-appearance:none; appearance:none; width:100%; height:8px; border-radius:6px;
1139
+ background:linear-gradient(90deg,#C0392B 0%,#E07B39 10%,#C7A23E 20%,#6BA877 30%,#2F8F5E 50%,#1FB07A 60%,#10D6A1 100%); outline:none; }
1140
+ input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:26px; height:26px; border-radius:50%; background:var(--thumb,var(--green)); border:4px solid var(--card); box-shadow:0 2px 8px rgba(0,0,0,.22); cursor:grab; transition:box-shadow .2s; }
1141
+ input[type=range]::-moz-range-thumb { width:26px; height:26px; border-radius:50%; background:var(--thumb,var(--green)); border:4px solid var(--card); box-shadow:0 2px 8px rgba(0,0,0,.22); cursor:grab; }
1142
+ input[type=range].hot::-webkit-slider-thumb { box-shadow:0 0 0 5px rgba(16,214,161,.22), 0 0 22px rgba(16,214,161,.75); animation:pulse 1.1s ease-in-out infinite; }
1143
+ input[type=range].hot::-moz-range-thumb { box-shadow:0 0 0 5px rgba(16,214,161,.22), 0 0 22px rgba(16,214,161,.75); }
1144
+ @keyframes pulse { 0%,100%{box-shadow:0 0 0 5px rgba(16,214,161,.20), 0 0 18px rgba(16,214,161,.6);} 50%{box-shadow:0 0 0 8px rgba(16,214,161,.10), 0 0 30px rgba(16,214,161,.95);} }
1145
+
1146
+ .spend-head { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
1147
+ select.month-pick { font-family:var(--mono); font-size:13px; font-weight:600; color:var(--ink); padding:10px 14px; border:1px solid var(--border); border-radius:var(--radius-xs); background:var(--card); cursor:pointer; }
1148
+ .month-total { font-family:var(--display); font-size:34px; font-weight:600; letter-spacing:-.025em; margin:8px 0 16px; font-variant-numeric:tabular-nums; }
1149
+ .month-total-label { font-family:var(--mono); font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.1em; color:var(--ink-faint); margin-left:12px; }
1150
+ .bd-bar { display:flex; width:100%; border-radius:var(--radius-xs); overflow:hidden; border:1px solid var(--border); }
1151
+ .bd-split { height:32px; margin-bottom:8px; }
1152
+ .bd-tier { height:22px; margin:10px 0 14px; }
1153
+ .bd-seg { height:100%; display:flex; align-items:center; padding:0 9px; overflow:hidden; min-width:2px; cursor:default; transition:opacity .12s, filter .12s; }
1154
+ .bd-seg.dim { opacity:.3; filter:saturate(.5); }
1155
+ .bd-seg.hot { box-shadow:inset 0 0 0 2px rgba(255,255,255,.85); }
1156
+ .bd-seg-l { font-family:var(--mono); font-size:10.5px; font-weight:600; color:#fff; white-space:nowrap; text-shadow:0 1px 1px rgba(0,0,0,.2);
1157
+ overflow:hidden; -webkit-mask-image:linear-gradient(90deg,#000 72%,transparent); mask-image:linear-gradient(90deg,#000 72%,transparent); }
1158
+ .tier { margin-top:28px; padding-left:16px; border-left:3px solid var(--border); }
1159
+ .tier-mandatory { border-left-color:var(--green); } .tier-non-mandatory { border-left-color:var(--amber); }
1160
+ .tier-head { display:flex; justify-content:space-between; align-items:baseline; }
1161
+ .tier-head h3 { font-family:var(--display); font-size:17px; font-weight:600; letter-spacing:-.01em; margin:0; }
1162
+ .tier-mandatory .tier-head h3 { color:var(--green); } .tier-non-mandatory .tier-head h3 { color:var(--amber); }
1163
+ .tier-total { font-family:var(--mono); font-size:16px; font-weight:600; font-variant-numeric:tabular-nums; }
1164
+ .tier-sub { font-size:12.5px; color:var(--ink-soft); margin:3px 0 14px; }
1165
+ details.cat { border:1px solid var(--line-soft); border-radius:var(--radius-sm); background:var(--card); margin-bottom:7px; overflow:hidden; transition:opacity .12s, border-color .12s; }
1166
+ details.cat:hover { border-color:var(--border); }
1167
+ details.cat.rowdim { opacity:.4; }
1168
+ details.cat summary { list-style:none; cursor:pointer; display:flex; align-items:center; gap:12px; padding:13px 16px; user-select:none; position:relative; }
1169
+ .cat-fill { position:absolute; left:0; top:0; bottom:0; z-index:0; background:var(--green-soft); }
1170
+ .tier-non-mandatory .cat-fill { background:var(--amber-soft); }
1171
+ details.cat summary > *:not(.cat-fill) { position:relative; z-index:1; }
1172
+ details.cat summary::-webkit-details-marker { display:none; }
1173
+ details.cat summary::before { content:"\\203A"; color:var(--ink-faint); font-size:18px; line-height:1; width:12px; display:inline-block; transition:transform .15s ease; position:relative; z-index:1; }
1174
+ details.cat[open] summary::before { transform:rotate(90deg); }
1175
+ .cat-dot { width:8px; height:8px; border-radius:50%; flex:0 0 auto; position:relative; z-index:1; }
1176
+ .cat-name { font-size:14.5px; font-weight:600; flex:1; }
1177
+ .cat-pct { font-family:var(--mono); font-size:12px; font-weight:600; color:var(--ink-soft); min-width:38px; text-align:right; font-variant-numeric:tabular-nums; }
1178
+ .cat-meta { font-family:var(--mono); font-size:11px; color:var(--ink-faint); min-width:56px; text-align:right; }
1179
+ .cat-amt { font-family:var(--mono); font-size:14px; font-weight:700; min-width:74px; text-align:right; font-variant-numeric:tabular-nums; }
1180
+ .txns { list-style:none; margin:0; padding:2px 16px 10px 40px; background:var(--card); position:relative; z-index:1; }
1181
+ .txns li { display:flex; align-items:center; gap:12px; padding:8px 0; border-top:1px solid var(--line-soft); font-size:13.5px; }
1182
+ .t-date { font-family:var(--mono); color:var(--ink-faint); font-variant-numeric:tabular-nums; font-size:12px; min-width:82px; white-space:nowrap; }
1183
+ .t-merch { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; }
1184
+ .t-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1185
+ .t-note { font-size:11px; font-weight:500; color:var(--ink-faint); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1186
+ .t-acct { font-family:var(--mono); font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; color:var(--ink-soft); background:var(--card-soft); border:1px solid var(--line-soft); border-radius:999px; padding:2px 9px; white-space:nowrap; }
1187
+ .t-amt { font-family:var(--mono); font-weight:700; min-width:66px; text-align:right; font-variant-numeric:tabular-nums; }
1188
+ .page-foot { text-align:center; font-family:var(--mono); font-size:11px; font-weight:500; letter-spacing:.04em; color:var(--ink-faint); margin-top:44px; }
1189
+
1190
+ @media (max-width:760px) {
1191
+ .page { padding:22px 11px 52px; }
1192
+ .card { padding:18px 13px; border-radius:18px; }
1193
+ .page-head { margin-top:46px; }
1194
+ .page-head h1 { font-size:33px; }
1195
+ .page-head .subtitle { font-size:14px; }
1196
+ .sv-head { gap:14px; }
1197
+ .sv-now-amt { font-size:44px; }
1198
+ /* The chart is the centerpiece: break it out of the card padding so it runs
1199
+ edge-to-edge, and give it real height. Right-edge labels overlay a soft
1200
+ fade (drawn in the SVG) instead of eating a margin that crops the plot. */
1201
+ .sv-chart-wrap { height:454px; margin:14px -13px 6px; }
1202
+ .sv-figs { width:100%; gap:9px; }
1203
+ .sv-fig { flex:1; min-width:0; padding:12px 14px; }
1204
+ .sv-fig-amt { font-size:22px; }
1205
+ .sv-fig-rub { font-size:11.5px; }
1206
+ .sv-control-row { flex-wrap:wrap; }
1207
+ .sv-rate-label { font-size:10.5px; }
1208
+ .sv-rate { font-size:22px; }
1209
+ .sv-controls { grid-template-columns:1fr; gap:14px; }
1210
+ .controls .ctl { padding:6px 9px; font-size:11px; }
1211
+ .spend-head { flex-direction:column; gap:12px; }
1212
+ select.month-pick { width:100%; }
1213
+ .month-total { font-size:27px; }
1214
+ .bd-split { height:30px; } .bd-tier { height:20px; }
1215
+ .cat-meta { display:none; }
1216
+ /* Flatten the nested look on phones: drop the tier's left rule and indent,
1217
+ drop the card-in-card borders, keep the category colour via the fill. */
1218
+ .tier { margin-top:22px; padding-left:0; border-left:none; }
1219
+ .tier-head h3 { font-size:16.5px; }
1220
+ details.cat { border:none; border-radius:9px; margin-bottom:2px; }
1221
+ details.cat summary { padding:13px 10px; gap:9px; }
1222
+ .cat-amt { min-width:0; }
1223
+ .t-date { min-width:0; font-size:11px; }
1224
+ .t-acct { font-size:9.5px; padding:2px 7px; }
1225
+ .txns { padding-left:12px; padding-right:2px; }
1226
+ .txns li { gap:9px; }
1227
+ }`;
1228
+ }
1229
+
1230
+ // src/profile.ts
1231
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1232
+ var EMPTY_PROFILE = {
1233
+ owners: [],
1234
+ ownNames: [],
1235
+ ownIbans: [],
1236
+ accountLabels: {},
1237
+ merchantInfo: []
1238
+ };
1239
+ function loadProfile(path) {
1240
+ if (!existsSync2(path))
1241
+ return { ...EMPTY_PROFILE };
1242
+ let raw;
1243
+ try {
1244
+ raw = JSON.parse(readFileSync2(path, "utf8"));
1245
+ } catch (e) {
1246
+ throw new Error(`profile load: ${path} is not valid JSON (${e.message})`);
1247
+ }
1248
+ const strList = (v) => Array.isArray(v) ? v.map((x) => String(x)) : [];
1249
+ const nwRaw = raw.netWorth;
1250
+ return {
1251
+ owners: strList(raw.owners),
1252
+ ownNames: strList(raw.ownNames),
1253
+ ownIbans: strList(raw.ownIbans),
1254
+ footer: isBilingual(raw.footer) ? raw.footer : undefined,
1255
+ netWorth: nwRaw ? {
1256
+ flatsRub: Number(nwRaw.flatsRub) || 0,
1257
+ mortgageRub: Number(nwRaw.mortgageRub) || 0,
1258
+ bcsNominalCny: Number(nwRaw.bcsNominalCny) || 0,
1259
+ propertyApr: Number(nwRaw.propertyApr) || 0
1260
+ } : undefined,
1261
+ accountLabels: isRecord(raw.accountLabels) ? raw.accountLabels : {},
1262
+ merchantInfo: Array.isArray(raw.merchantInfo) ? raw.merchantInfo : []
1263
+ };
1264
+ }
1265
+ function isBilingual(v) {
1266
+ return typeof v === "object" && v !== null && typeof v.en === "string";
1267
+ }
1268
+ function isRecord(v) {
1269
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1270
+ }
1271
+
1272
+ // src/identity.ts
1273
+ var ownNames = [];
1274
+ var ownIbans = new Set;
1275
+ function normName(s) {
1276
+ return s.toLowerCase().replace(/\s+/g, " ").trim();
1277
+ }
1278
+ function normIban(s) {
1279
+ return s.toUpperCase().replace(/\s+/g, "");
1280
+ }
1281
+ function setIdentity(names, ibans) {
1282
+ ownNames = names.map(normName).filter((n) => n.length > 0);
1283
+ ownIbans = new Set(ibans.map(normIban).filter((i) => i.length > 0));
1284
+ }
1285
+ function matchesOwnName(text) {
1286
+ if (ownNames.length === 0)
1287
+ return false;
1288
+ const t2 = normName(text);
1289
+ return ownNames.some((n) => t2.includes(n));
1290
+ }
1291
+ function isOwnIban(iban) {
1292
+ if (iban === "")
1293
+ return false;
1294
+ return ownIbans.has(normIban(iban));
1295
+ }
1296
+
1297
+ // src/connectors/revolut.ts
1298
+ var TRANSFER_PREFIX = /Transfer\s+(from|to)\s/i;
1299
+ var REQUIRED_HEADERS = [
1300
+ "Type",
1301
+ "Started Date",
1302
+ "Completed Date",
1303
+ "Description",
1304
+ "Amount",
1305
+ "Fee",
1306
+ "Currency",
1307
+ "State"
1308
+ ];
1309
+ function mapType(revolutType) {
1310
+ switch (revolutType) {
1311
+ case "Card Payment":
1312
+ case "ATM":
1313
+ return "spend";
1314
+ case "Topup":
1315
+ case "Card Refund":
1316
+ case "CARD_CREDIT":
1317
+ case "Rev Payment":
1318
+ return "income";
1319
+ case "Transfer":
1320
+ return "transfer";
1321
+ case "Fee":
1322
+ return "fee";
1323
+ case "Exchange":
1324
+ return "exchange";
1325
+ default:
1326
+ return "unknown";
1327
+ }
1328
+ }
1329
+ function datePart(timestamp) {
1330
+ return timestamp.trim().split(" ")[0] ?? "";
1331
+ }
1332
+ function parseRevolut(text) {
1333
+ const { header, records } = parseCsv(text);
1334
+ for (const required of REQUIRED_HEADERS) {
1335
+ if (!header.includes(required)) {
1336
+ throw new Error(`parseRevolut: missing expected column "${required}". Header was: [${header.join(", ")}]`);
1337
+ }
1338
+ }
1339
+ const rows = [];
1340
+ for (const rec of records) {
1341
+ const state = rec.get("State").trim();
1342
+ if (state !== "COMPLETED")
1343
+ continue;
1344
+ const completed = datePart(rec.get("Completed Date"));
1345
+ const started = datePart(rec.get("Started Date"));
1346
+ const date = completed !== "" ? completed : started;
1347
+ if (date === "")
1348
+ continue;
1349
+ const description = rec.get("Description").trim();
1350
+ const amountRaw = rec.get("Amount").trim();
1351
+ const feeRaw = rec.get("Fee").trim();
1352
+ const currency = rec.get("Currency").trim().toUpperCase();
1353
+ const amount = Number(amountRaw);
1354
+ if (!Number.isFinite(amount)) {
1355
+ throw new Error(`parseRevolut: non-numeric Amount "${amountRaw}" for "${description}" on ${date}`);
1356
+ }
1357
+ const fee = feeRaw === "" ? 0 : Number(feeRaw);
1358
+ if (!Number.isFinite(fee)) {
1359
+ throw new Error(`parseRevolut: non-numeric Fee "${feeRaw}" for "${description}" on ${date}`);
1360
+ }
1361
+ const type = mapType(rec.get("Type").trim());
1362
+ const transferCandidate = TRANSFER_PREFIX.test(description) && matchesOwnName(description);
1363
+ const balanceRaw = header.includes("Balance") ? rec.get("Balance").trim() : "";
1364
+ let balance = null;
1365
+ if (balanceRaw !== "") {
1366
+ const parsed = Number(balanceRaw);
1367
+ if (!Number.isFinite(parsed)) {
1368
+ throw new Error(`parseRevolut: non-numeric Balance "${balanceRaw}" for "${description}" on ${date}`);
1369
+ }
1370
+ balance = parsed;
1371
+ }
1372
+ rows.push({
1373
+ date,
1374
+ merchant_raw: description,
1375
+ amount_native: amount,
1376
+ currency,
1377
+ type,
1378
+ fee,
1379
+ note: "",
1380
+ transferCandidate,
1381
+ amountEur: null,
1382
+ balance
1383
+ });
1384
+ }
1385
+ return rows;
1386
+ }
1387
+
1388
+ // src/connectors/n26.ts
1389
+ var REQUIRED_HEADERS2 = [
1390
+ "Booking Date",
1391
+ "Partner Name",
1392
+ "Partner Iban",
1393
+ "Type",
1394
+ "Payment Reference",
1395
+ "Amount (EUR)",
1396
+ "Original Amount",
1397
+ "Original Currency"
1398
+ ];
1399
+ function mapType2(n26Type) {
1400
+ switch (n26Type) {
1401
+ case "Presentment":
1402
+ case "Direct Debit":
1403
+ return "spend";
1404
+ case "Presentment Refund":
1405
+ return "income";
1406
+ case "MoneyBeam":
1407
+ return "transfer";
1408
+ case "Credit Transfer":
1409
+ case "Debit Transfer":
1410
+ return "transfer";
1411
+ default:
1412
+ return "unknown";
1413
+ }
1414
+ }
1415
+ function parseN26(text) {
1416
+ const { header, records } = parseCsv(text);
1417
+ for (const required of REQUIRED_HEADERS2) {
1418
+ if (!header.includes(required)) {
1419
+ throw new Error(`parseN26: missing expected column "${required}". Header was: [${header.join(", ")}]`);
1420
+ }
1421
+ }
1422
+ const rows = [];
1423
+ for (const rec of records) {
1424
+ const date = rec.get("Booking Date").trim();
1425
+ if (date === "")
1426
+ continue;
1427
+ const partnerName = rec.get("Partner Name").trim();
1428
+ const partnerIban = rec.get("Partner Iban").trim();
1429
+ const n26Type = rec.get("Type").trim();
1430
+ const reference = rec.get("Payment Reference").trim();
1431
+ const amountEurRaw = rec.get("Amount (EUR)").trim();
1432
+ const origAmountRaw = rec.get("Original Amount").trim();
1433
+ const origCurrencyRaw = rec.get("Original Currency").trim().toUpperCase();
1434
+ const amountEur = Number(amountEurRaw);
1435
+ if (!Number.isFinite(amountEur)) {
1436
+ throw new Error(`parseN26: non-numeric Amount (EUR) "${amountEurRaw}" for "${partnerName}" on ${date}`);
1437
+ }
1438
+ let amount_native;
1439
+ let currency;
1440
+ if (origAmountRaw !== "" && origCurrencyRaw !== "") {
1441
+ const origMagnitude = Number(origAmountRaw);
1442
+ if (!Number.isFinite(origMagnitude)) {
1443
+ throw new Error(`parseN26: non-numeric Original Amount "${origAmountRaw}" for "${partnerName}" on ${date}`);
1444
+ }
1445
+ const sign = amountEur < 0 ? -1 : 1;
1446
+ amount_native = sign * Math.abs(origMagnitude);
1447
+ currency = origCurrencyRaw;
1448
+ } else {
1449
+ amount_native = amountEur;
1450
+ currency = "EUR";
1451
+ }
1452
+ const note = reference !== "" && reference !== "-" ? reference : "";
1453
+ const type = mapType2(n26Type);
1454
+ const transferCandidate = n26Type === "MoneyBeam" || isOwnIban(partnerIban) || matchesOwnName(partnerName);
1455
+ rows.push({
1456
+ date,
1457
+ merchant_raw: partnerName,
1458
+ amount_native,
1459
+ currency,
1460
+ type,
1461
+ fee: 0,
1462
+ note,
1463
+ transferCandidate,
1464
+ amountEur,
1465
+ balance: null
1466
+ });
1467
+ }
1468
+ return rows;
1469
+ }
1470
+
1471
+ // src/connectors/trading212.ts
1472
+ var REQUIRED_HEADERS3 = ["Action", "Time", "Total", "Currency (Total)"];
1473
+ function mapAction(action) {
1474
+ const a = action.toLowerCase();
1475
+ if (a === "deposit" || a === "withdrawal")
1476
+ return "transfer";
1477
+ if (a.includes("interest") || a.includes("dividend"))
1478
+ return "income";
1479
+ if (a.includes("buy") || a.includes("sell"))
1480
+ return "exchange";
1481
+ return "unknown";
1482
+ }
1483
+ function parseTrading212(text) {
1484
+ const { header, records } = parseCsv(text);
1485
+ for (const required of REQUIRED_HEADERS3) {
1486
+ if (!header.includes(required)) {
1487
+ throw new Error(`parseTrading212: missing expected column "${required}". Header was: [${header.join(", ")}]`);
1488
+ }
1489
+ }
1490
+ const rows = [];
1491
+ for (const rec of records) {
1492
+ const time = rec.get("Time").trim();
1493
+ if (time === "")
1494
+ continue;
1495
+ const date = time.slice(0, 10);
1496
+ const action = rec.get("Action").trim();
1497
+ const totalRaw = rec.get("Total").trim();
1498
+ if (totalRaw === "")
1499
+ continue;
1500
+ const total = Number(totalRaw);
1501
+ if (!Number.isFinite(total)) {
1502
+ throw new Error(`parseTrading212: non-numeric Total "${totalRaw}" for action "${action}" on ${date}`);
1503
+ }
1504
+ const currency = (rec.get("Currency (Total)").trim() || "EUR").toUpperCase();
1505
+ const name = rec.get("Name").trim();
1506
+ const notes = rec.get("Notes").trim();
1507
+ const type = mapAction(action);
1508
+ const txnId = header.includes("ID") ? rec.get("ID").trim() : "";
1509
+ rows.push({
1510
+ date,
1511
+ merchant_raw: name !== "" ? name : action,
1512
+ amount_native: total,
1513
+ currency,
1514
+ type,
1515
+ fee: 0,
1516
+ note: notes !== "" ? notes : action,
1517
+ transferCandidate: type === "transfer",
1518
+ amountEur: currency === "EUR" ? total : null,
1519
+ balance: null,
1520
+ dedupExtra: txnId
1521
+ });
1522
+ }
1523
+ return rows;
1524
+ }
1525
+
1526
+ // src/connectors/tbank.ts
1527
+ var REQUIRED_HEADERS4 = [
1528
+ "Дата операции",
1529
+ "Статус",
1530
+ "Сумма операции",
1531
+ "Валюта операции",
1532
+ "Сумма платежа",
1533
+ "Валюта платежа",
1534
+ "Категория",
1535
+ "Описание"
1536
+ ];
1537
+ var BANK_FEE_CATEGORY = "Услуги банка";
1538
+ function parseAmount(raw) {
1539
+ const normalized = raw.replace(/[\s  ]/g, "").replace(",", ".");
1540
+ return Number(normalized);
1541
+ }
1542
+ function toIsoDate(raw) {
1543
+ const dmy = raw.trim().slice(0, 10).split(".");
1544
+ if (dmy.length !== 3)
1545
+ return "";
1546
+ const [dd, mm, yyyy] = dmy;
1547
+ if (!/^\d{2}$/.test(dd) || !/^\d{2}$/.test(mm) || !/^\d{4}$/.test(yyyy))
1548
+ return "";
1549
+ return `${yyyy}-${mm}-${dd}`;
1550
+ }
1551
+ function timePart(raw) {
1552
+ const t2 = raw.trim().slice(11);
1553
+ return /^\d{2}:\d{2}:\d{2}$/.test(t2) ? t2 : "";
1554
+ }
1555
+ function parseTbank(text) {
1556
+ const { header, records } = parseCsv(text, ";");
1557
+ for (const required of REQUIRED_HEADERS4) {
1558
+ if (!header.includes(required)) {
1559
+ throw new Error(`parseTbank: missing expected column "${required}". Header was: [${header.join(", ")}]`);
1560
+ }
1561
+ }
1562
+ const rows = [];
1563
+ for (const rec of records) {
1564
+ if (rec.get("Статус").trim() !== "OK")
1565
+ continue;
1566
+ const opTimestamp = rec.get("Дата операции");
1567
+ const date = toIsoDate(opTimestamp);
1568
+ if (date === "")
1569
+ continue;
1570
+ let amountRaw = rec.get("Сумма платежа").trim();
1571
+ let currency = rec.get("Валюта платежа").trim().toUpperCase();
1572
+ if (amountRaw === "") {
1573
+ amountRaw = rec.get("Сумма операции").trim();
1574
+ currency = rec.get("Валюта операции").trim().toUpperCase();
1575
+ }
1576
+ if (amountRaw === "")
1577
+ continue;
1578
+ const amount = parseAmount(amountRaw);
1579
+ if (!Number.isFinite(amount)) {
1580
+ throw new Error(`parseTbank: non-numeric amount "${amountRaw}" on ${date}`);
1581
+ }
1582
+ if (currency === "")
1583
+ currency = "RUB";
1584
+ const description = rec.get("Описание").trim();
1585
+ const tbankCategory = rec.get("Категория").trim();
1586
+ const type = tbankCategory === BANK_FEE_CATEGORY ? "fee" : amount < 0 ? "spend" : "income";
1587
+ const opAmountRaw = rec.get("Сумма операции").trim();
1588
+ const opCurrency = rec.get("Валюта операции").trim().toUpperCase();
1589
+ const noteParts = [];
1590
+ if (tbankCategory !== "")
1591
+ noteParts.push(tbankCategory);
1592
+ if (opCurrency !== "" && opCurrency !== currency && opAmountRaw !== "") {
1593
+ noteParts.push(`ориг. ${opAmountRaw} ${opCurrency}`);
1594
+ }
1595
+ rows.push({
1596
+ date,
1597
+ merchant_raw: description,
1598
+ amount_native: amount,
1599
+ currency,
1600
+ type,
1601
+ fee: 0,
1602
+ note: noteParts.join(" · "),
1603
+ transferCandidate: false,
1604
+ amountEur: currency === "EUR" ? amount : null,
1605
+ balance: null,
1606
+ dedupExtra: timePart(opTimestamp)
1607
+ });
1608
+ }
1609
+ return rows;
1610
+ }
1611
+
1612
+ // src/connectors/alfa.ts
1613
+ var REQUIRED_HEADERS5 = [
1614
+ "operationDate",
1615
+ "accountName",
1616
+ "merchant",
1617
+ "amount",
1618
+ "currency",
1619
+ "type"
1620
+ ];
1621
+ var SAVINGS_ACCOUNT = /депозит|накопительн/i;
1622
+ var INTEREST = /выплата\s+проц|процент/i;
1623
+ function toIsoDate2(raw) {
1624
+ const dmy = raw.trim().slice(0, 10).split(".");
1625
+ if (dmy.length !== 3)
1626
+ return "";
1627
+ const [dd, mm, yyyy] = dmy;
1628
+ if (!/^\d{2}$/.test(dd) || !/^\d{2}$/.test(mm) || !/^\d{4}$/.test(yyyy))
1629
+ return "";
1630
+ return `${yyyy}-${mm}-${dd}`;
1631
+ }
1632
+ function parseMagnitude(raw) {
1633
+ return Number(raw.replace(/\s/g, "").replace(",", "."));
1634
+ }
1635
+ function parseAlfa(text) {
1636
+ const clean = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
1637
+ const { header, records } = parseCsv(clean);
1638
+ for (const required of REQUIRED_HEADERS5) {
1639
+ if (!header.includes(required)) {
1640
+ throw new Error(`parseAlfa: missing expected column "${required}". Header was: [${header.join(", ")}]`);
1641
+ }
1642
+ }
1643
+ const rows = [];
1644
+ for (const rec of records) {
1645
+ const accountName = rec.get("accountName").trim();
1646
+ if (!SAVINGS_ACCOUNT.test(accountName))
1647
+ continue;
1648
+ const date = toIsoDate2(rec.get("operationDate"));
1649
+ if (date === "")
1650
+ continue;
1651
+ const magnitude = parseMagnitude(rec.get("amount").trim());
1652
+ if (!Number.isFinite(magnitude)) {
1653
+ throw new Error(`parseAlfa: non-numeric amount "${rec.get("amount")}" on ${date}`);
1654
+ }
1655
+ const direction = rec.get("type").trim() === "Списание" ? -1 : 1;
1656
+ const amount = direction * magnitude;
1657
+ let currency = rec.get("currency").trim().toUpperCase();
1658
+ if (currency === "RUR")
1659
+ currency = "RUB";
1660
+ const merchant = rec.get("merchant").trim();
1661
+ const comment = header.includes("comment") ? rec.get("comment").trim() : "";
1662
+ const category = header.includes("category") ? rec.get("category").trim() : "";
1663
+ const isInterest = INTEREST.test(merchant) || INTEREST.test(comment);
1664
+ const type = isInterest ? "income" : "transfer";
1665
+ rows.push({
1666
+ date,
1667
+ merchant_raw: merchant !== "" ? merchant : accountName,
1668
+ amount_native: amount,
1669
+ currency,
1670
+ type,
1671
+ fee: 0,
1672
+ note: category,
1673
+ transferCandidate: type === "transfer",
1674
+ amountEur: currency === "EUR" ? amount : null,
1675
+ balance: null
1676
+ });
1677
+ }
1678
+ return rows;
1679
+ }
1680
+
1681
+ // src/connectors/index.ts
1682
+ var CONNECTORS = {
1683
+ revolut: parseRevolut,
1684
+ n26: parseN26,
1685
+ trading212: parseTrading212,
1686
+ tbank: parseTbank,
1687
+ alfa: parseAlfa
1688
+ };
1689
+ function getConnector(name) {
1690
+ return CONNECTORS[name] ?? null;
1691
+ }
1692
+ function connectorNames() {
1693
+ return Object.keys(CONNECTORS);
1694
+ }
1695
+
1696
+ // src/fx.ts
1697
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1698
+ function loadRates(path) {
1699
+ const rates = new Map;
1700
+ if (!existsSync3(path)) {
1701
+ return { rates };
1702
+ }
1703
+ const text = readFileSync3(path, "utf8");
1704
+ if (text.trim().length === 0) {
1705
+ return { rates };
1706
+ }
1707
+ const { records } = parseCsv(text);
1708
+ for (const rec of records) {
1709
+ const month = rec.get("month").trim();
1710
+ const currency = rec.get("currency").trim().toUpperCase();
1711
+ const raw = rec.get("rate_to_eur").trim();
1712
+ if (month === "" && currency === "" && raw === "")
1713
+ continue;
1714
+ const rate = Number(raw);
1715
+ if (!Number.isFinite(rate)) {
1716
+ throw new Error(`loadRates: non-numeric rate_to_eur "${raw}" for ${month}/${currency} in ${path}`);
1717
+ }
1718
+ rates.set(`${month}|${currency}`, rate);
1719
+ }
1720
+ return { rates };
1721
+ }
1722
+ function monthOf2(isoDate) {
1723
+ return isoDate.slice(0, 7);
1724
+ }
1725
+ function toEur(amount_native, currency, isoDate, table) {
1726
+ const cur = currency.toUpperCase();
1727
+ if (cur === "EUR") {
1728
+ return { amount_eur: amount_native, missing: null };
1729
+ }
1730
+ const month = monthOf2(isoDate);
1731
+ const rate = table.rates.get(`${month}|${cur}`) ?? table.rates.get(`*|${cur}`);
1732
+ if (rate === undefined) {
1733
+ return { amount_eur: null, missing: { month, currency: cur } };
1734
+ }
1735
+ return { amount_eur: round22(amount_native * rate), missing: null };
1736
+ }
1737
+ function rateToEur(table, currency, isoDate) {
1738
+ const cur = currency.toUpperCase();
1739
+ if (cur === "EUR")
1740
+ return 1;
1741
+ if (isoDate !== undefined) {
1742
+ const exact = table.rates.get(`${monthOf2(isoDate)}|${cur}`);
1743
+ if (exact !== undefined)
1744
+ return exact;
1745
+ }
1746
+ return table.rates.get(`*|${cur}`) ?? null;
1747
+ }
1748
+ function round22(n) {
1749
+ const r = Math.round((n + Number.EPSILON) * 100) / 100;
1750
+ return r === 0 ? 0 : r;
1751
+ }
1752
+
1753
+ // src/savings.ts
1754
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
1755
+ var VALID_SCOPES2 = new Set(["account", "marker", "anchor"]);
1756
+ function savingsConfigured(config) {
1757
+ return config.accounts.length > 0 || config.markers.length > 0 || config.anchors.length > 0;
1758
+ }
1759
+ function loadSavingsConfig(path) {
1760
+ const empty = { accounts: [], markers: [], anchors: [] };
1761
+ if (!existsSync4(path))
1762
+ return empty;
1763
+ const text = readFileSync4(path, "utf8");
1764
+ if (text.trim().length === 0)
1765
+ return empty;
1766
+ const { records } = parseCsv(text);
1767
+ const accounts = [];
1768
+ const markers = [];
1769
+ const anchors = [];
1770
+ records.forEach((rec, i) => {
1771
+ const scope = rec.get("scope").trim().toLowerCase();
1772
+ const value = rec.get("value").trim();
1773
+ const balanceRaw = rec.get("balance_eur").trim();
1774
+ if (scope === "" && value === "" && balanceRaw === "")
1775
+ return;
1776
+ if (!VALID_SCOPES2.has(scope)) {
1777
+ throw new Error(`loadSavingsConfig: row ${i + 2}: invalid scope "${scope}" (expected account|marker|anchor)`);
1778
+ }
1779
+ if (value === "") {
1780
+ throw new Error(`loadSavingsConfig: row ${i + 2}: empty value`);
1781
+ }
1782
+ if (scope === "anchor") {
1783
+ if (balanceRaw === "") {
1784
+ throw new Error(`loadSavingsConfig: row ${i + 2}: anchor "${value}" needs a balance_eur`);
1785
+ }
1786
+ const balanceEur = Number(balanceRaw);
1787
+ if (!Number.isFinite(balanceEur)) {
1788
+ throw new Error(`loadSavingsConfig: row ${i + 2}: non-numeric balance_eur "${balanceRaw}" for anchor "${value}"`);
1789
+ }
1790
+ anchors.push({ label: value, balanceEur });
1791
+ return;
1792
+ }
1793
+ const dest = { match: value.toLowerCase(), label: value };
1794
+ if (scope === "account")
1795
+ accounts.push(dest);
1796
+ else
1797
+ markers.push(dest);
1798
+ });
1799
+ return { accounts, markers, anchors };
1800
+ }
1801
+ function savingsFlowEur(tx, config) {
1802
+ if (tx.amount_eur === null)
1803
+ return 0;
1804
+ const acct = tx.account.trim().toLowerCase();
1805
+ if (tx.type === "transfer" && config.accounts.some((a) => a.match === acct)) {
1806
+ return tx.amount_eur;
1807
+ }
1808
+ const merchant = tx.merchant_raw.trim().toLowerCase();
1809
+ if (config.markers.some((m) => m.match === merchant)) {
1810
+ return tx.amount_eur;
1811
+ }
1812
+ return 0;
1813
+ }
1814
+ function monthOf3(isoDate) {
1815
+ return isoDate.slice(0, 7);
1816
+ }
1817
+ function round23(n) {
1818
+ const r = Math.round((n + Number.EPSILON) * 100) / 100;
1819
+ return r === 0 ? 0 : r;
1820
+ }
1821
+ function savingsFlowByMonth(txs, config) {
1822
+ const byMonth = new Map;
1823
+ for (const tx of txs) {
1824
+ const flow = savingsFlowEur(tx, config);
1825
+ if (flow === 0)
1826
+ continue;
1827
+ const m = monthOf3(tx.date);
1828
+ byMonth.set(m, (byMonth.get(m) ?? 0) + flow);
1829
+ }
1830
+ for (const [m, v] of byMonth)
1831
+ byMonth.set(m, round23(v));
1832
+ return new Map([...byMonth].sort((a, b) => a[0] < b[0] ? -1 : 1));
1833
+ }
1834
+ function savingsSeries(txs, config) {
1835
+ const defs = [
1836
+ ...config.accounts.map((a) => ({ ...a, kind: "account" })),
1837
+ ...config.markers.map((m) => ({ ...m, kind: "marker" }))
1838
+ ];
1839
+ const flow = new Map;
1840
+ for (const d of defs)
1841
+ flow.set(d.match, new Map);
1842
+ for (const tx of txs) {
1843
+ if (tx.amount_eur === null)
1844
+ continue;
1845
+ const acct = tx.account.trim().toLowerCase();
1846
+ const accDef = config.accounts.find((a) => a.match === acct);
1847
+ if (accDef !== undefined && tx.type === "transfer") {
1848
+ addFlow(flow.get(accDef.match), monthOf3(tx.date), tx.amount_eur);
1849
+ continue;
1850
+ }
1851
+ const merchant = tx.merchant_raw.trim().toLowerCase();
1852
+ const mkDef = config.markers.find((m) => m.match === merchant);
1853
+ if (mkDef !== undefined)
1854
+ addFlow(flow.get(mkDef.match), monthOf3(tx.date), tx.amount_eur);
1855
+ }
1856
+ const monthSet = new Set;
1857
+ for (const perMonth of flow.values())
1858
+ for (const m of perMonth.keys())
1859
+ monthSet.add(m);
1860
+ const months = [...monthSet].sort();
1861
+ const lines = defs.map((d) => {
1862
+ const perMonth = flow.get(d.match);
1863
+ let running = 0;
1864
+ const values = months.map((m) => {
1865
+ running = round23(running + (perMonth.get(m) ?? 0));
1866
+ return running;
1867
+ });
1868
+ return { key: d.match, label: d.label, kind: d.kind, values };
1869
+ });
1870
+ const anchorsTotal = config.anchors.reduce((s, a) => s + a.balanceEur, 0);
1871
+ const total = months.map((_, i) => round23(lines.reduce((s, l) => s + l.values[i], 0) + anchorsTotal));
1872
+ return { months, total, lines };
1873
+ }
1874
+ function addFlow(m, month, eur2) {
1875
+ m.set(month, (m.get(month) ?? 0) + eur2);
1876
+ }
1877
+ function savingsStock(txs, config) {
1878
+ const accountSums = new Map;
1879
+ const markerSums = new Map;
1880
+ for (const tx of txs) {
1881
+ if (tx.amount_eur === null)
1882
+ continue;
1883
+ const acct = tx.account.trim().toLowerCase();
1884
+ const acctDest = config.accounts.find((a) => a.match === acct);
1885
+ if (acctDest !== undefined && tx.type === "transfer") {
1886
+ accountSums.set(acctDest.match, (accountSums.get(acctDest.match) ?? 0) + tx.amount_eur);
1887
+ continue;
1888
+ }
1889
+ const merchant = tx.merchant_raw.trim().toLowerCase();
1890
+ const markerDest = config.markers.find((m) => m.match === merchant);
1891
+ if (markerDest !== undefined) {
1892
+ markerSums.set(markerDest.match, (markerSums.get(markerDest.match) ?? 0) + tx.amount_eur);
1893
+ }
1894
+ }
1895
+ const components = [];
1896
+ for (const a of config.accounts) {
1897
+ components.push({ label: a.label, eur: round23(accountSums.get(a.match) ?? 0), kind: "account" });
1898
+ }
1899
+ for (const m of config.markers) {
1900
+ components.push({ label: m.label, eur: round23(markerSums.get(m.match) ?? 0), kind: "marker" });
1901
+ }
1902
+ for (const anc of config.anchors) {
1903
+ components.push({ label: anc.label, eur: round23(anc.balanceEur), kind: "anchor" });
1904
+ }
1905
+ const totalEur = round23(components.reduce((s, c) => s + c.eur, 0));
1906
+ return { totalEur, components };
1907
+ }
1908
+ var DEFAULT_RATE_LOOKBACK = 12;
1909
+ function currentMonth(now) {
1910
+ return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
1911
+ }
1912
+ function previousMonth(month) {
1913
+ return trailingMonths(month, 2)[0];
1914
+ }
1915
+ function recentMonthlyRate(txs, config, options = {}) {
1916
+ const lookback = options.lookbackMonths ?? DEFAULT_RATE_LOOKBACK;
1917
+ const asOf = options.asOf ?? new Date;
1918
+ const lastComplete = previousMonth(currentMonth(asOf));
1919
+ const flow = savingsFlowByMonth(txs, config);
1920
+ const flowMonths = [...flow.keys()];
1921
+ if (flowMonths.length === 0)
1922
+ return 0;
1923
+ const firstFlow = flowMonths[0];
1924
+ if (lastComplete < firstFlow)
1925
+ return 0;
1926
+ const window = trailingMonths(lastComplete, lookback).filter((m) => m >= firstFlow);
1927
+ if (window.length === 0)
1928
+ return 0;
1929
+ const sum = window.reduce((s, m) => s + (flow.get(m) ?? 0), 0);
1930
+ return round23(sum / window.length);
1931
+ }
1932
+ function trailingMonths(lastMonth, count) {
1933
+ const [yStr, mStr] = lastMonth.split("-");
1934
+ let y = Number(yStr);
1935
+ let m = Number(mStr);
1936
+ const out = [];
1937
+ for (let i = 0;i < count; i++) {
1938
+ out.push(`${y}-${String(m).padStart(2, "0")}`);
1939
+ m -= 1;
1940
+ if (m === 0) {
1941
+ m = 12;
1942
+ y -= 1;
1943
+ }
1944
+ }
1945
+ return out.reverse();
1946
+ }
1947
+
1948
+ // src/projection.ts
1949
+ function round24(n) {
1950
+ const r = Math.round((n + Number.EPSILON) * 100) / 100;
1951
+ return r === 0 ? 0 : r;
1952
+ }
1953
+ function projectAt(input, month) {
1954
+ if (!Number.isInteger(month) || month < 0) {
1955
+ throw new Error(`projectAt: month must be a non-negative integer (got ${month})`);
1956
+ }
1957
+ const eur2 = round24(input.startEur + input.monthlyRateEur * month);
1958
+ const rubPerEur = input.rubPerEur ?? null;
1959
+ return { month, eur: eur2, rub: rubPerEur === null ? null : round24(eur2 * rubPerEur) };
1960
+ }
1961
+
1962
+ // src/hash.ts
1963
+ import { createHash } from "node:crypto";
1964
+ function transactionId(input) {
1965
+ const amount = input.amount_native.toFixed(2);
1966
+ const fields = [
1967
+ input.data_source,
1968
+ input.account,
1969
+ input.date,
1970
+ input.merchant_raw,
1971
+ amount,
1972
+ input.currency
1973
+ ];
1974
+ if (input.dedupExtra)
1975
+ fields.push(input.dedupExtra);
1976
+ return createHash("sha256").update(fields.join("|"), "utf8").digest("hex").slice(0, 16);
1977
+ }
1978
+
1979
+ // src/ledger.ts
1980
+ import { copyFileSync, existsSync as existsSync5, mkdirSync, readFileSync as readFileSync5, writeFileSync } from "node:fs";
1981
+ import { basename, dirname, join } from "node:path";
1982
+
1983
+ // src/types.ts
1984
+ var TX_TYPES = [
1985
+ "spend",
1986
+ "income",
1987
+ "transfer",
1988
+ "fee",
1989
+ "exchange",
1990
+ "unknown"
1991
+ ];
1992
+ function isTxType(value) {
1993
+ return TX_TYPES.includes(value);
1994
+ }
1995
+ function isOwner(value) {
1996
+ return value.trim().length > 0;
1997
+ }
1998
+ var LEDGER_COLUMNS = [
1999
+ "id",
2000
+ "date",
2001
+ "data_source",
2002
+ "account",
2003
+ "owner",
2004
+ "merchant_raw",
2005
+ "merchant_clean",
2006
+ "amount_native",
2007
+ "currency",
2008
+ "amount_eur",
2009
+ "category",
2010
+ "type",
2011
+ "is_transfer",
2012
+ "transfer_group",
2013
+ "fee",
2014
+ "note",
2015
+ "source_file",
2016
+ "balance"
2017
+ ];
2018
+
2019
+ // src/ledger.ts
2020
+ function txToRow(tx) {
2021
+ return [
2022
+ tx.id,
2023
+ tx.date,
2024
+ tx.data_source,
2025
+ tx.account,
2026
+ tx.owner,
2027
+ tx.merchant_raw,
2028
+ tx.merchant_clean,
2029
+ formatAmount(tx.amount_native),
2030
+ tx.currency,
2031
+ tx.amount_eur === null ? "" : formatAmount(tx.amount_eur),
2032
+ tx.category,
2033
+ tx.type,
2034
+ tx.is_transfer ? "true" : "false",
2035
+ tx.transfer_group,
2036
+ formatAmount(tx.fee),
2037
+ tx.note,
2038
+ tx.source_file,
2039
+ tx.balance === null ? "" : formatAmount(tx.balance)
2040
+ ];
2041
+ }
2042
+ function formatAmount(n) {
2043
+ return n.toFixed(2);
2044
+ }
2045
+ function parseAmount2(raw, label) {
2046
+ const n = Number(raw);
2047
+ if (!Number.isFinite(n)) {
2048
+ throw new Error(`ledger load: non-numeric ${label} "${raw}"`);
2049
+ }
2050
+ return n;
2051
+ }
2052
+ function rowToTx(get, rowNum) {
2053
+ const owner = get("owner").trim();
2054
+ if (!isOwner(owner)) {
2055
+ throw new Error(`ledger load: row ${rowNum}: invalid owner "${owner}"`);
2056
+ }
2057
+ const type = get("type").trim();
2058
+ if (!isTxType(type)) {
2059
+ throw new Error(`ledger load: row ${rowNum}: invalid type "${type}"`);
2060
+ }
2061
+ const eurRaw = get("amount_eur").trim();
2062
+ const isTransferRaw = get("is_transfer").trim();
2063
+ const balanceRaw = get("balance").trim();
2064
+ return {
2065
+ id: get("id").trim(),
2066
+ date: get("date").trim(),
2067
+ data_source: get("data_source").trim(),
2068
+ account: get("account").trim(),
2069
+ owner,
2070
+ merchant_raw: get("merchant_raw"),
2071
+ merchant_clean: get("merchant_clean"),
2072
+ amount_native: parseAmount2(get("amount_native"), "amount_native"),
2073
+ currency: get("currency").trim(),
2074
+ amount_eur: eurRaw === "" ? null : parseAmount2(eurRaw, "amount_eur"),
2075
+ category: get("category"),
2076
+ type,
2077
+ is_transfer: isTransferRaw === "true",
2078
+ transfer_group: get("transfer_group").trim(),
2079
+ fee: parseAmount2(get("fee"), "fee"),
2080
+ note: get("note"),
2081
+ source_file: get("source_file"),
2082
+ balance: balanceRaw === "" ? null : parseAmount2(balanceRaw, "balance")
2083
+ };
2084
+ }
2085
+ function loadLedger(path) {
2086
+ if (!existsSync5(path))
2087
+ return [];
2088
+ const text = readFileSync5(path, "utf8");
2089
+ if (text.trim().length === 0)
2090
+ return [];
2091
+ const { header, records } = parseCsv(text);
2092
+ const present = new Set(header);
2093
+ return records.map((rec, i) => rowToTx((c) => present.has(c) ? rec.get(c) : "", i + 2));
2094
+ }
2095
+ function writeLedger(path, txs) {
2096
+ mkdirSync(dirname(path), { recursive: true });
2097
+ const rows = txs.map(txToRow);
2098
+ writeFileSync(path, writeCsv(LEDGER_COLUMNS, rows), "utf8");
2099
+ }
2100
+ function appendDeduped(existing, candidates) {
2101
+ const seen = new Set(existing.map((t2) => t2.id));
2102
+ const merged = [...existing];
2103
+ let appended = 0;
2104
+ let skippedDuplicate = 0;
2105
+ for (const cand of candidates) {
2106
+ if (seen.has(cand.id)) {
2107
+ skippedDuplicate += 1;
2108
+ continue;
2109
+ }
2110
+ seen.add(cand.id);
2111
+ merged.push(cand);
2112
+ appended += 1;
2113
+ }
2114
+ return { appended, skippedDuplicate, merged };
2115
+ }
2116
+ function archiveRaw(dataDir, source, sourceFilePath) {
2117
+ const name = basename(sourceFilePath);
2118
+ const destDir = join(dataDir, "raw", source);
2119
+ mkdirSync(destDir, { recursive: true });
2120
+ copyFileSync(sourceFilePath, join(destDir, name));
2121
+ return name;
2122
+ }
2123
+
2124
+ // src/rules.ts
2125
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
2126
+ var MATCH_TYPES = ["substring", "regex", "exact"];
2127
+ function isMatchType(value) {
2128
+ return MATCH_TYPES.includes(value);
2129
+ }
2130
+ var ALLOWED_FIELDS = new Set([
2131
+ "merchant_raw",
2132
+ "merchant_clean",
2133
+ "note",
2134
+ "account",
2135
+ "data_source"
2136
+ ]);
2137
+ function loadRules(path) {
2138
+ if (!existsSync6(path))
2139
+ return [];
2140
+ const text = readFileSync6(path, "utf8");
2141
+ if (text.trim().length === 0)
2142
+ return [];
2143
+ const { records } = parseCsv(text);
2144
+ const rules = [];
2145
+ records.forEach((rec, i) => {
2146
+ const pattern = rec.get("pattern");
2147
+ const matchTypeRaw = rec.get("match_type").trim();
2148
+ const fieldRaw = rec.get("field").trim() || "merchant_raw";
2149
+ const category = rec.get("category").trim();
2150
+ const typeRaw = rec.get("type").trim();
2151
+ if (pattern === "" && matchTypeRaw === "" && category === "")
2152
+ return;
2153
+ if (!isMatchType(matchTypeRaw)) {
2154
+ throw new Error(`loadRules: row ${i + 2}: invalid match_type "${matchTypeRaw}" (expected substring|regex|exact)`);
2155
+ }
2156
+ if (!ALLOWED_FIELDS.has(fieldRaw)) {
2157
+ throw new Error(`loadRules: row ${i + 2}: unsupported field "${fieldRaw}" (allowed: ${[...ALLOWED_FIELDS].join(", ")})`);
2158
+ }
2159
+ let type = null;
2160
+ if (typeRaw !== "") {
2161
+ if (!isTxType(typeRaw)) {
2162
+ throw new Error(`loadRules: row ${i + 2}: invalid type "${typeRaw}"`);
2163
+ }
2164
+ type = typeRaw;
2165
+ }
2166
+ let regex = null;
2167
+ if (matchTypeRaw === "regex") {
2168
+ try {
2169
+ regex = new RegExp(pattern, "i");
2170
+ } catch (err) {
2171
+ throw new Error(`loadRules: row ${i + 2}: invalid regex "${pattern}": ${err.message}`);
2172
+ }
2173
+ }
2174
+ rules.push({
2175
+ pattern,
2176
+ matchType: matchTypeRaw,
2177
+ field: fieldRaw,
2178
+ category,
2179
+ type,
2180
+ regex
2181
+ });
2182
+ });
2183
+ return rules;
2184
+ }
2185
+ function fieldValue(tx, field) {
2186
+ const v = tx[field];
2187
+ return typeof v === "string" ? v : String(v);
2188
+ }
2189
+ function ruleMatches(rule, tx) {
2190
+ const value = fieldValue(tx, rule.field);
2191
+ switch (rule.matchType) {
2192
+ case "substring":
2193
+ return value.toLowerCase().includes(rule.pattern.toLowerCase());
2194
+ case "exact":
2195
+ return value === rule.pattern;
2196
+ case "regex":
2197
+ return rule.regex.test(value);
2198
+ default: {
2199
+ const _never = rule.matchType;
2200
+ throw new Error(`ruleMatches: unhandled match_type ${String(_never)}`);
2201
+ }
2202
+ }
2203
+ }
2204
+ function firstMatch(rules, tx) {
2205
+ for (const rule of rules) {
2206
+ if (ruleMatches(rule, tx))
2207
+ return rule;
2208
+ }
2209
+ return null;
2210
+ }
2211
+ function summarizeUnknowns(txs) {
2212
+ const byMerchant = new Map;
2213
+ for (const tx of txs) {
2214
+ if (tx.category !== "")
2215
+ continue;
2216
+ const key = tx.merchant_raw;
2217
+ const existing = byMerchant.get(key);
2218
+ const entry = existing ?? { merchant_raw: key, count: 0, totalEur: 0, missingEurCount: 0 };
2219
+ entry.count += 1;
2220
+ if (tx.amount_eur === null) {
2221
+ entry.missingEurCount += 1;
2222
+ } else {
2223
+ entry.totalEur += Math.abs(tx.amount_eur);
2224
+ }
2225
+ byMerchant.set(key, entry);
2226
+ }
2227
+ return [...byMerchant.values()].sort((a, b) => b.totalEur - a.totalEur);
2228
+ }
2229
+
2230
+ // src/recurring.ts
2231
+ var DEFAULT_RECURRING_OPTIONS = {
2232
+ minMonths: 4
2233
+ };
2234
+ function detectRecurring(txs, tiers, options = {}) {
2235
+ const minMonths = options.minMonths ?? DEFAULT_RECURRING_OPTIONS.minMonths;
2236
+ const from = options.from;
2237
+ const byMerchant = new Map;
2238
+ for (const tx of txs) {
2239
+ const month = tx.date.slice(0, 7);
2240
+ if (from !== undefined && month < from)
2241
+ continue;
2242
+ if (isAnalyticsExcluded(tx))
2243
+ continue;
2244
+ if (tx.amount_eur === null || tx.amount_eur >= 0)
2245
+ continue;
2246
+ const key = tx.merchant_raw;
2247
+ if (key.trim() === "")
2248
+ continue;
2249
+ let a = byMerchant.get(key);
2250
+ if (a === undefined) {
2251
+ a = {
2252
+ months: new Set,
2253
+ totalEur: 0,
2254
+ count: 0,
2255
+ accounts: new Set,
2256
+ category: tx.category,
2257
+ firstMonth: month,
2258
+ lastMonth: month
2259
+ };
2260
+ byMerchant.set(key, a);
2261
+ }
2262
+ a.months.add(month);
2263
+ a.totalEur += Math.abs(tx.amount_eur);
2264
+ a.count += 1;
2265
+ a.accounts.add(tx.account);
2266
+ if (tx.category !== "")
2267
+ a.category = tx.category;
2268
+ if (month < a.firstMonth)
2269
+ a.firstMonth = month;
2270
+ if (month > a.lastMonth)
2271
+ a.lastMonth = month;
2272
+ }
2273
+ const out = [];
2274
+ for (const [merchant_raw, a] of byMerchant) {
2275
+ if (a.months.size < minMonths)
2276
+ continue;
2277
+ const category = a.category === "" ? "—" : a.category;
2278
+ out.push({
2279
+ merchant_raw,
2280
+ monthsCount: a.months.size,
2281
+ totalEur: a.totalEur,
2282
+ perMonth: a.totalEur / a.months.size,
2283
+ count: a.count,
2284
+ category,
2285
+ tier: tierOf(tiers, category, merchant_raw),
2286
+ accounts: [...a.accounts].sort(),
2287
+ firstMonth: a.firstMonth,
2288
+ lastMonth: a.lastMonth
2289
+ });
2290
+ }
2291
+ return out.sort((x, y) => {
2292
+ if (y.perMonth !== x.perMonth)
2293
+ return y.perMonth - x.perMonth;
2294
+ return x.merchant_raw < y.merchant_raw ? -1 : x.merchant_raw > y.merchant_raw ? 1 : 0;
2295
+ });
2296
+ }
2297
+
2298
+ // src/transfers.ts
2299
+ var DEFAULT_TRANSFER_OPTIONS = {
2300
+ toleranceEur: 1.5,
2301
+ maxDayGap: 3
2302
+ };
2303
+ function isCandidate(tx) {
2304
+ return tx.is_transfer || tx.type === "transfer";
2305
+ }
2306
+ function dayGap(isoA, isoB) {
2307
+ const a = Date.parse(isoA + "T00:00:00Z");
2308
+ const b = Date.parse(isoB + "T00:00:00Z");
2309
+ if (Number.isNaN(a) || Number.isNaN(b))
2310
+ return Number.POSITIVE_INFINITY;
2311
+ return Math.abs(a - b) / 86400000;
2312
+ }
2313
+ function groupIdFor(idA, idB) {
2314
+ const [first, second] = [idA, idB].sort();
2315
+ return `tg_${first}_${second}`;
2316
+ }
2317
+ function matchTransfers(txs, options = DEFAULT_TRANSFER_OPTIONS) {
2318
+ const updated = txs.map((t2) => ({ ...t2 }));
2319
+ const byId = new Map(updated.map((t2) => [t2.id, t2]));
2320
+ const candidates = updated.filter((t2) => isCandidate(t2) && t2.transfer_group === "" && t2.amount_eur !== null).sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
2321
+ const consumed = new Set;
2322
+ const pairs = [];
2323
+ for (let i = 0;i < candidates.length; i++) {
2324
+ const legA = candidates[i];
2325
+ if (consumed.has(legA.id))
2326
+ continue;
2327
+ const eurA = legA.amount_eur;
2328
+ for (let j = i + 1;j < candidates.length; j++) {
2329
+ const legB = candidates[j];
2330
+ if (consumed.has(legB.id))
2331
+ continue;
2332
+ if (legB.account === legA.account)
2333
+ continue;
2334
+ const eurB = legB.amount_eur;
2335
+ const oppositeSign = Math.sign(eurA) !== Math.sign(eurB) && eurA !== 0 && eurB !== 0;
2336
+ if (!oppositeSign)
2337
+ continue;
2338
+ if (Math.abs(Math.abs(eurA) - Math.abs(eurB)) > options.toleranceEur)
2339
+ continue;
2340
+ if (dayGap(legA.date, legB.date) > options.maxDayGap)
2341
+ continue;
2342
+ const outflow = eurA < 0 ? legA : legB;
2343
+ const inflow = eurA < 0 ? legB : legA;
2344
+ const groupId = groupIdFor(legA.id, legB.id);
2345
+ for (const id of [legA.id, legB.id]) {
2346
+ const tx = byId.get(id);
2347
+ tx.is_transfer = true;
2348
+ tx.transfer_group = groupId;
2349
+ if (tx.type === "unknown" || tx.type === "spend" || tx.type === "income") {
2350
+ tx.type = "transfer";
2351
+ }
2352
+ }
2353
+ consumed.add(legA.id);
2354
+ consumed.add(legB.id);
2355
+ pairs.push({ groupId, outflow, inflow });
2356
+ break;
2357
+ }
2358
+ }
2359
+ const unmatched = candidates.filter((t2) => !consumed.has(t2.id));
2360
+ return { pairs, unmatched, updated };
2361
+ }
2362
+
2363
+ // src/cli.ts
2364
+ var HERE = dirname2(fileURLToPath(import.meta.url));
2365
+ var ROOT = basename2(HERE) === "src" ? dirname2(HERE) : HERE;
2366
+ var DATA_DIR = join2(ROOT, "data");
2367
+ var LEDGER_PATH = join2(DATA_DIR, "ledger.csv");
2368
+ var RULES_PATH = join2(DATA_DIR, "rules.csv");
2369
+ var RATES_PATH = join2(DATA_DIR, "rates.csv");
2370
+ var TIERS_PATH = join2(DATA_DIR, "tiers.csv");
2371
+ var SAVINGS_PATH = join2(DATA_DIR, "savings.csv");
2372
+ var PROFILE_PATH = join2(DATA_DIR, "profile.json");
2373
+ var PROFILE = EMPTY_PROFILE;
2374
+ function parseArgs(argv) {
2375
+ const positionals = [];
2376
+ const flags = new Map;
2377
+ for (let i = 0;i < argv.length; i++) {
2378
+ const a = argv[i];
2379
+ if (a.startsWith("--")) {
2380
+ const key = a.slice(2);
2381
+ const next = argv[i + 1];
2382
+ if (next !== undefined && !next.startsWith("--")) {
2383
+ flags.set(key, next);
2384
+ i++;
2385
+ } else {
2386
+ flags.set(key, true);
2387
+ }
2388
+ } else {
2389
+ positionals.push(a);
2390
+ }
2391
+ }
2392
+ return { positionals, flags };
2393
+ }
2394
+ function flagString(args, name) {
2395
+ const v = args.flags.get(name);
2396
+ return typeof v === "string" ? v : undefined;
2397
+ }
2398
+ function hasFlag(args, name) {
2399
+ return args.flags.has(name);
2400
+ }
2401
+ function fmtEur(n) {
2402
+ return n === null ? "—" : n.toFixed(2);
2403
+ }
2404
+ function fmtNative(n) {
2405
+ return n.toFixed(2);
2406
+ }
2407
+ function padEnd(s, w) {
2408
+ return s.length >= w ? s.slice(0, w) : s + " ".repeat(w - s.length);
2409
+ }
2410
+ function padStart(s, w) {
2411
+ return s.length >= w ? s.slice(0, w) : " ".repeat(w - s.length) + s;
2412
+ }
2413
+ async function cmdImport(args) {
2414
+ const [source, file] = args.positionals;
2415
+ const account = flagString(args, "account");
2416
+ const ownerRaw = flagString(args, "owner");
2417
+ const ownerHint = PROFILE.owners.length > 0 ? PROFILE.owners.join("|") : "owner";
2418
+ if (!source || !file) {
2419
+ console.error(`usage: kopeika import <${connectorNames().join("|")}> <file> --account <name> --owner <${ownerHint}>`);
2420
+ return 1;
2421
+ }
2422
+ const connector = getConnector(source);
2423
+ if (!connector) {
2424
+ console.error(`unknown source "${source}". Known: ${connectorNames().join(", ")}`);
2425
+ return 1;
2426
+ }
2427
+ if (!account) {
2428
+ console.error("--account <name> is required (e.g. revolut-eur, n26-alex)");
2429
+ return 1;
2430
+ }
2431
+ if (!ownerRaw || !isOwner(ownerRaw)) {
2432
+ console.error(`--owner is required (a non-empty label like ${ownerHint})`);
2433
+ return 1;
2434
+ }
2435
+ if (PROFILE.owners.length > 0 && !PROFILE.owners.includes(ownerRaw)) {
2436
+ console.error(`--owner "${ownerRaw}" is not in your profile owners: ${PROFILE.owners.join(", ")}`);
2437
+ return 1;
2438
+ }
2439
+ const owner = ownerRaw;
2440
+ if (!existsSync7(file)) {
2441
+ console.error(`file not found: ${file}`);
2442
+ return 1;
2443
+ }
2444
+ const rawText = readFileSync7(file, "utf8");
2445
+ const parsedRows = connector(rawText);
2446
+ const rawDataRowCount = countDataRows(rawText);
2447
+ const skippedNonCompleted = rawDataRowCount - parsedRows.length;
2448
+ const sourceFile = archiveRaw(DATA_DIR, source, file);
2449
+ const rates = loadRates(RATES_PATH);
2450
+ const missingRates = new Map;
2451
+ const candidates = parsedRows.map((row) => {
2452
+ let amountEur;
2453
+ if (row.amountEur !== null) {
2454
+ amountEur = row.amountEur;
2455
+ } else {
2456
+ const fx = toEur(row.amount_native, row.currency, row.date, rates);
2457
+ amountEur = fx.amount_eur;
2458
+ if (fx.missing) {
2459
+ missingRates.set(`${fx.missing.month}|${fx.missing.currency}`, fx.missing);
2460
+ }
2461
+ }
2462
+ const id = transactionId({
2463
+ data_source: source,
2464
+ account,
2465
+ date: row.date,
2466
+ merchant_raw: row.merchant_raw,
2467
+ amount_native: row.amount_native,
2468
+ currency: row.currency,
2469
+ dedupExtra: row.dedupExtra
2470
+ });
2471
+ const tx = {
2472
+ id,
2473
+ date: row.date,
2474
+ data_source: source,
2475
+ account,
2476
+ owner,
2477
+ merchant_raw: row.merchant_raw,
2478
+ merchant_clean: row.merchant_raw,
2479
+ amount_native: row.amount_native,
2480
+ currency: row.currency,
2481
+ amount_eur: amountEur,
2482
+ category: "",
2483
+ type: row.type,
2484
+ is_transfer: row.transferCandidate,
2485
+ transfer_group: "",
2486
+ fee: row.fee,
2487
+ note: row.note,
2488
+ source_file: sourceFile,
2489
+ balance: row.balance
2490
+ };
2491
+ return tx;
2492
+ });
2493
+ const existing = loadLedger(LEDGER_PATH);
2494
+ const { appended, skippedDuplicate, merged } = appendDeduped(existing, candidates);
2495
+ writeLedger(LEDGER_PATH, merged);
2496
+ console.log(`imported ${appended} / skipped-dup ${skippedDuplicate} / skipped-non-completed ${skippedNonCompleted}`);
2497
+ if (missingRates.size > 0) {
2498
+ console.log("");
2499
+ console.log(`⚠ missing FX rates for ${missingRates.size} (month, currency) pair(s) — amount_eur left empty:`);
2500
+ for (const { month, currency } of [...missingRates.values()].sort((a, b) => `${a.month}${a.currency}`.localeCompare(`${b.month}${b.currency}`))) {
2501
+ console.log(` ${month} ${currency} (add a row to data/rates.csv: ${month},${currency},<rate_to_eur>)`);
2502
+ }
2503
+ }
2504
+ return 0;
2505
+ }
2506
+ function countDataRows(text) {
2507
+ return parseCsv(text).records.length;
2508
+ }
2509
+ async function cmdCategorize(args) {
2510
+ const review = hasFlag(args, "review");
2511
+ const ledger = loadLedger(LEDGER_PATH);
2512
+ if (ledger.length === 0) {
2513
+ console.log("ledger is empty — import something first.");
2514
+ return 0;
2515
+ }
2516
+ if (review) {
2517
+ const unknowns = summarizeUnknowns(ledger);
2518
+ if (unknowns.length === 0) {
2519
+ console.log("no uncategorized merchants — every row has a category. \uD83C\uDF89");
2520
+ return 0;
2521
+ }
2522
+ console.log(`${unknowns.length} unique uncategorized merchant(s), sorted by spend desc:`);
2523
+ console.log("");
2524
+ console.log(` ${padEnd("merchant_raw", 40)} ${padStart("count", 6)} ${padStart("eur_total", 12)} missing_fx`);
2525
+ console.log(` ${"-".repeat(40)} ${"-".repeat(6)} ${"-".repeat(12)} ${"-".repeat(10)}`);
2526
+ for (const u of unknowns) {
2527
+ const missing = u.missingEurCount > 0 ? String(u.missingEurCount) : "";
2528
+ console.log(` ${padEnd(u.merchant_raw, 40)} ${padStart(String(u.count), 6)} ${padStart(u.totalEur.toFixed(2), 12)} ${missing}`);
2529
+ }
2530
+ console.log("");
2531
+ console.log("Add a rule per merchant in data/rules.csv, then run: kopeika categorize");
2532
+ return 0;
2533
+ }
2534
+ const rules = loadRules(RULES_PATH);
2535
+ if (rules.length === 0) {
2536
+ console.log("no rules in data/rules.csv — nothing to apply. Run `categorize --review` to see unknowns.");
2537
+ return 0;
2538
+ }
2539
+ let categorized = 0;
2540
+ let retyped = 0;
2541
+ for (const tx of ledger) {
2542
+ if (tx.category !== "")
2543
+ continue;
2544
+ const rule = firstMatch(rules, tx);
2545
+ if (!rule)
2546
+ continue;
2547
+ tx.category = rule.category;
2548
+ categorized += 1;
2549
+ if (rule.type !== null && rule.type !== tx.type) {
2550
+ tx.type = rule.type;
2551
+ retyped += 1;
2552
+ }
2553
+ }
2554
+ writeLedger(LEDGER_PATH, ledger);
2555
+ console.log(`categorized ${categorized} row(s)` + (retyped > 0 ? `, retyped ${retyped}` : ""));
2556
+ const remaining = ledger.filter((t2) => t2.category === "").length;
2557
+ if (remaining > 0) {
2558
+ console.log(`${remaining} row(s) still uncategorized — run \`categorize --review\` to triage.`);
2559
+ }
2560
+ return 0;
2561
+ }
2562
+ async function cmdTransfers(_args) {
2563
+ const ledger = loadLedger(LEDGER_PATH);
2564
+ if (ledger.length === 0) {
2565
+ console.log("ledger is empty — import something first.");
2566
+ return 0;
2567
+ }
2568
+ const { pairs, unmatched, updated } = matchTransfers(ledger, DEFAULT_TRANSFER_OPTIONS);
2569
+ writeLedger(LEDGER_PATH, updated);
2570
+ console.log(`matched ${pairs.length} transfer pair(s) (tolerance €${DEFAULT_TRANSFER_OPTIONS.toleranceEur.toFixed(2)}, ±${DEFAULT_TRANSFER_OPTIONS.maxDayGap}d)`);
2571
+ if (pairs.length > 0) {
2572
+ console.log("");
2573
+ for (const p of pairs) {
2574
+ console.log(` ${p.groupId}`);
2575
+ console.log(` out ${p.outflow.date} ${padEnd(p.outflow.account, 14)} ${padStart(fmtEur(p.outflow.amount_eur), 12)} EUR ${p.outflow.merchant_raw}`);
2576
+ console.log(` in ${p.inflow.date} ${padEnd(p.inflow.account, 14)} ${padStart(fmtEur(p.inflow.amount_eur), 12)} EUR ${p.inflow.merchant_raw}`);
2577
+ }
2578
+ }
2579
+ if (unmatched.length > 0) {
2580
+ console.log("");
2581
+ console.log(`${unmatched.length} unmatched candidate leg(s):`);
2582
+ for (const u of unmatched) {
2583
+ console.log(` ${u.date} ${padEnd(u.account, 14)} ${padStart(fmtEur(u.amount_eur), 12)} EUR ${u.merchant_raw}`);
2584
+ }
2585
+ }
2586
+ return 0;
2587
+ }
2588
+ async function cmdRecurring(args) {
2589
+ const ledger = loadLedger(LEDGER_PATH);
2590
+ if (ledger.length === 0) {
2591
+ console.log("ledger is empty — import something first.");
2592
+ return 0;
2593
+ }
2594
+ const minMonthsRaw = flagString(args, "min-months");
2595
+ let minMonths = DEFAULT_RECURRING_OPTIONS.minMonths;
2596
+ if (minMonthsRaw !== undefined) {
2597
+ const parsed = Number(minMonthsRaw);
2598
+ if (!Number.isInteger(parsed) || parsed < 1) {
2599
+ console.error(`--min-months must be a positive integer (got "${minMonthsRaw}")`);
2600
+ return 1;
2601
+ }
2602
+ minMonths = parsed;
2603
+ }
2604
+ const fromFlag = flagString(args, "from");
2605
+ if (fromFlag !== undefined && !/^\d{4}-\d{2}$/.test(fromFlag)) {
2606
+ console.error(`--from must be YYYY-MM (got "${fromFlag}")`);
2607
+ return 1;
2608
+ }
2609
+ const tiers = loadTiers(TIERS_PATH);
2610
+ const recurring = detectRecurring(ledger, tiers, { minMonths, from: fromFlag });
2611
+ if (recurring.length === 0) {
2612
+ console.log(`no merchant appears in ≥${minMonths} distinct months${fromFlag ? ` since ${fromFlag}` : ""}.`);
2613
+ return 0;
2614
+ }
2615
+ const tiered = tiersConfigured(tiers);
2616
+ const floorTotal = recurring.filter((r) => r.tier === "mandatory").reduce((s, r) => s + r.perMonth, 0);
2617
+ const flexTotal = recurring.filter((r) => r.tier === "optional").reduce((s, r) => s + r.perMonth, 0);
2618
+ console.log(`${recurring.length} recurring merchant(s) — seen in ≥${minMonths} distinct months${fromFlag ? ` since ${fromFlag}` : ""}, by €/mo desc`);
2619
+ console.log("");
2620
+ console.log(` ${padStart("#mo", 4)} ${padStart("~eur/mo", 9)} ${padEnd("tier", 6)} ${padEnd("category", 16)} ${padEnd("since", 8)} merchant`);
2621
+ console.log(` ${"-".repeat(4)} ${"-".repeat(9)} ${"-".repeat(6)} ${"-".repeat(16)} ${"-".repeat(8)} ${"-".repeat(24)}`);
2622
+ for (const r of recurring) {
2623
+ const tierLabel = tiered ? r.tier === "mandatory" ? `\uD83D\uDD12 ${padEnd("fix", 3)}` : `\uD83C\uDF88 ${padEnd("opt", 3)}` : padEnd("—", 6);
2624
+ console.log(` ${padStart(String(r.monthsCount), 4)} ${padStart(fmtMoney(r.perMonth), 9)} ${tierLabel} ${padEnd(r.category, 16)} ${padEnd(r.firstMonth, 8)} ${r.merchant_raw}`);
2625
+ }
2626
+ console.log("");
2627
+ if (tiered) {
2628
+ console.log(`recurring backbone ≈ €${fmtMoney(floorTotal + flexTotal)}/mo (\uD83D\uDD12 floor €${fmtMoney(floorTotal)}/mo · \uD83C\uDF88 flex €${fmtMoney(flexTotal)}/mo)`);
2629
+ console.log("note: recurring ≠ mandatory — frequent buys (groceries, Amazon) recur but flex; only \uD83D\uDD12 rows are the fixed floor.");
2630
+ } else {
2631
+ console.log(`recurring backbone ≈ €${fmtMoney(floorTotal + flexTotal)}/mo (no tiers configured — add data/tiers.csv to split floor vs flex)`);
2632
+ }
2633
+ return 0;
2634
+ }
2635
+ async function cmdList(args) {
2636
+ const ledger = loadLedger(LEDGER_PATH);
2637
+ const sourceFilter = flagString(args, "source");
2638
+ const monthFilter = flagString(args, "month");
2639
+ const onlyUncategorized = hasFlag(args, "uncategorized");
2640
+ let rows = ledger;
2641
+ if (sourceFilter)
2642
+ rows = rows.filter((t2) => t2.data_source === sourceFilter);
2643
+ if (monthFilter)
2644
+ rows = rows.filter((t2) => t2.date.startsWith(monthFilter));
2645
+ if (onlyUncategorized)
2646
+ rows = rows.filter((t2) => t2.category === "");
2647
+ rows = [...rows].sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
2648
+ if (rows.length === 0) {
2649
+ console.log("no rows match the given filters.");
2650
+ return 0;
2651
+ }
2652
+ console.log(` ${padEnd("date", 10)} ${padEnd("account", 13)} ${padStart("native", 12)} ${padEnd("cur", 4)} ${padStart("eur", 11)} ${padEnd("type", 9)} ${padEnd("category", 14)} merchant`);
2653
+ console.log(` ${"-".repeat(10)} ${"-".repeat(13)} ${"-".repeat(12)} ${"-".repeat(4)} ${"-".repeat(11)} ${"-".repeat(9)} ${"-".repeat(14)} ${"-".repeat(20)}`);
2654
+ let totalEur = 0;
2655
+ let missingEur = 0;
2656
+ for (const t2 of rows) {
2657
+ if (t2.amount_eur === null)
2658
+ missingEur += 1;
2659
+ else
2660
+ totalEur += t2.amount_eur;
2661
+ const cat = t2.category === "" ? "(none)" : t2.category;
2662
+ console.log(` ${padEnd(t2.date, 10)} ${padEnd(t2.account, 13)} ${padStart(fmtNative(t2.amount_native), 12)} ${padEnd(t2.currency, 4)} ${padStart(fmtEur(t2.amount_eur), 11)} ${padEnd(t2.type, 9)} ${padEnd(cat, 14)} ${t2.merchant_raw}`);
2663
+ }
2664
+ console.log("");
2665
+ console.log(` ${rows.length} row(s) net EUR ${totalEur.toFixed(2)}` + (missingEur > 0 ? ` (${missingEur} row(s) missing FX — excluded from total)` : ""));
2666
+ return 0;
2667
+ }
2668
+ function fmtMoney(n) {
2669
+ const rounded = Math.round(n);
2670
+ const sign = rounded < 0 ? "-" : "";
2671
+ const digits = String(Math.abs(rounded)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
2672
+ return `${sign}${digits}`;
2673
+ }
2674
+ function fmtRate(fraction) {
2675
+ const clamped = Math.max(0, Math.min(1, fraction));
2676
+ return `${Math.round(clamped * 100)}%`;
2677
+ }
2678
+ function printMonthsTable(report) {
2679
+ console.log(` ${padEnd("month", 9)} ${padStart("income", 11)} ${padStart("spend", 11)} ${padStart("saved", 11)} ${padStart("rate", 6)}`);
2680
+ console.log(` ${"-".repeat(9)} ${"-".repeat(11)} ${"-".repeat(11)} ${"-".repeat(11)} ${"-".repeat(6)}`);
2681
+ for (const m of report.months) {
2682
+ console.log(` ${padEnd(m.month, 9)} ${padStart(fmtMoney(m.income), 11)} ${padStart(fmtMoney(m.spend), 11)} ${padStart(fmtMoney(m.saved), 11)} ${padStart(fmtRate(m.savingsRate), 6)}`);
2683
+ }
2684
+ const o = report.overall;
2685
+ console.log(` ${"-".repeat(9)} ${"-".repeat(11)} ${"-".repeat(11)} ${"-".repeat(11)} ${"-".repeat(6)}`);
2686
+ console.log(` ${padEnd("ALL", 9)} ${padStart(fmtMoney(o.income), 11)} ${padStart(fmtMoney(o.spend), 11)} ${padStart(fmtMoney(o.saved), 11)} ${padStart(fmtRate(o.savingsRate), 6)}`);
2687
+ }
2688
+ function printCategoryTable(month) {
2689
+ console.log(`category breakdown — ${month.month} (spend €${fmtMoney(month.spend)})`);
2690
+ console.log("");
2691
+ if (month.categories.length === 0) {
2692
+ console.log(" (no spend recorded this month)");
2693
+ return;
2694
+ }
2695
+ console.log(` ${padEnd("category", 24)} ${padStart("eur", 11)} ${padStart("share", 7)} ${padStart("count", 6)}`);
2696
+ console.log(` ${"-".repeat(24)} ${"-".repeat(11)} ${"-".repeat(7)} ${"-".repeat(6)}`);
2697
+ for (const c of month.categories) {
2698
+ console.log(` ${padEnd(c.category, 24)} ${padStart(fmtMoney(c.amount), 11)} ${padStart(`${Math.round(c.share * 100)}%`, 7)} ${padStart(String(c.count), 6)}`);
2699
+ }
2700
+ console.log(` ${"-".repeat(24)} ${"-".repeat(11)} ${"-".repeat(7)} ${"-".repeat(6)}`);
2701
+ console.log(` ${padEnd("TOTAL", 24)} ${padStart(fmtMoney(month.spend), 11)} ${padStart("100%", 7)} ${padStart("", 6)}`);
2702
+ }
2703
+ function printFloorFlex(focus, report) {
2704
+ if (focus.floor === null || focus.flex === null) {
2705
+ console.log("floor vs flex: (no tiers configured — add mandatory rows to data/tiers.csv)");
2706
+ return;
2707
+ }
2708
+ const o = report.overall;
2709
+ const months = o.monthCount > 0 ? o.monthCount : 1;
2710
+ const floorAvg = (o.floor ?? 0) / months;
2711
+ const flexAvg = (o.flex ?? 0) / months;
2712
+ const total = focus.floor + focus.flex;
2713
+ const floorPct = total > 0 ? Math.round(focus.floor / total * 100) : 0;
2714
+ console.log(`floor vs flex — ${focus.month}`);
2715
+ console.log("");
2716
+ console.log(` \uD83D\uDD12 Floor (mandatory) €${fmtMoney(focus.floor)}/mo ${floorPct}% of spend`);
2717
+ console.log(` \uD83C\uDF88 Flex (optional) €${fmtMoney(focus.flex)}/mo ${100 - floorPct}% of spend`);
2718
+ if (report.months.length > 1) {
2719
+ console.log(` range avg/mo: floor €${fmtMoney(floorAvg)} · flex €${fmtMoney(flexAvg)} (over ${months} month(s))`);
2720
+ }
2721
+ }
2722
+ async function cmdReport(args) {
2723
+ const ledger = loadLedger(LEDGER_PATH);
2724
+ if (ledger.length === 0) {
2725
+ console.log("ledger is empty — import something first.");
2726
+ return 0;
2727
+ }
2728
+ const monthFlag = flagString(args, "month");
2729
+ const fromFlag = flagString(args, "from");
2730
+ const htmlPath = flagString(args, "html");
2731
+ for (const [name, value] of [["month", monthFlag], ["from", fromFlag]]) {
2732
+ if (value !== undefined && !/^\d{4}-\d{2}$/.test(value)) {
2733
+ console.error(`--${name} must be YYYY-MM (got "${value}")`);
2734
+ return 1;
2735
+ }
2736
+ }
2737
+ const tiers = loadTiers(TIERS_PATH);
2738
+ const report = buildReport(ledger, { month: monthFlag, from: fromFlag }, tiers);
2739
+ if (report.months.length === 0) {
2740
+ console.log("no transactions matched the selected range (after excluding transfers/exchanges).");
2741
+ return 0;
2742
+ }
2743
+ const focusMonth = monthFlag !== undefined ? monthFlag : latestCompleteMonth(report) ?? report.months[report.months.length - 1].month;
2744
+ const focus = report.months.find((m) => m.month === focusMonth);
2745
+ if (focus === undefined) {
2746
+ console.error(`internal error: focus month ${focusMonth} not in report`);
2747
+ return 1;
2748
+ }
2749
+ const o = report.overall;
2750
+ console.log(`kopeika report — ${report.months[0].month} … ${report.months[report.months.length - 1].month} (${report.months.length} month(s))`);
2751
+ console.log("");
2752
+ printMonthsTable(report);
2753
+ console.log("");
2754
+ printCategoryTable(focus);
2755
+ console.log("");
2756
+ printFloorFlex(focus, report);
2757
+ console.log("");
2758
+ const uncategorized = focus.categories.find((c) => c.category === "Uncategorized");
2759
+ const uncatShare = uncategorized ? uncategorized.share : 0;
2760
+ console.log(`range totals: income €${fmtMoney(o.income)} · spend €${fmtMoney(o.spend)} · saved €${fmtMoney(o.saved)} (${fmtRate(o.savingsRate)})` + (o.invested > 0 ? ` · put aside €${fmtMoney(o.invested)} (into savings)` : ""));
2761
+ console.log(`coverage: ${o.countedRows} row(s) counted, ${report.excludedRows} excluded (transfers/exchanges/Exclude)` + (o.missingEurCount > 0 ? `, ⚠ ${o.missingEurCount} row(s) missing FX — not counted` : ""));
2762
+ if (uncatShare >= 0.4) {
2763
+ console.log(`⚠ ${Math.round(uncatShare * 100)}% of ${focus.month} spend is Uncategorized — expected until rules land; add rules and re-run \`categorize\`.`);
2764
+ }
2765
+ if (htmlPath !== undefined) {
2766
+ const savingsCfg = loadSavingsConfig(SAVINGS_PATH);
2767
+ let projection;
2768
+ let series;
2769
+ if (savingsConfigured(savingsCfg)) {
2770
+ const stock = savingsStock(ledger, savingsCfg);
2771
+ const rates = loadRates(RATES_PATH);
2772
+ const rubToEur = rateToEur(rates, "RUB");
2773
+ const cnyToEur = rateToEur(rates, "CNY");
2774
+ const rub2 = rubToEur ?? 0.0105;
2775
+ const cny = cnyToEur ?? 0.127;
2776
+ let netWorth;
2777
+ const marks = PROFILE.netWorth;
2778
+ if (marks) {
2779
+ const flatsEur = Math.round(marks.flatsRub * rub2);
2780
+ const mortgageEur = Math.round(marks.mortgageRub * rub2);
2781
+ netWorth = {
2782
+ propertyEur: flatsEur - mortgageEur,
2783
+ propertyBaseEur: flatsEur,
2784
+ propertyDebtEur: mortgageEur,
2785
+ propertyApr: marks.propertyApr,
2786
+ bcsEur: Math.round(marks.bcsNominalCny * cny),
2787
+ milestones: [2500000, 5000000, 1e7, 15000000, 20000000, 25000000, 30000000].map((r) => ({ eur: Math.round(r * rub2), label: `${r / 1e6}M ₽`, hero: false }))
2788
+ };
2789
+ }
2790
+ projection = {
2791
+ startEur: stock.totalEur,
2792
+ defaultRateEur: recentMonthlyRate(ledger, savingsCfg),
2793
+ rubPerEur: rubToEur !== null && rubToEur > 0 ? 1 / rubToEur : null,
2794
+ components: stock.components,
2795
+ lookbackMonths: DEFAULT_RATE_LOOKBACK,
2796
+ netWorth
2797
+ };
2798
+ series = savingsSeries(ledger, savingsCfg);
2799
+ }
2800
+ const now = new Date;
2801
+ const nowMonth = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
2802
+ const spendMonths = report.months.map((m) => m.month).filter((m) => m >= "2026-01");
2803
+ const months = spendMonths.map((m) => ({ month: m, groups: buildSpendGroups(ledger, m, tiers) }));
2804
+ if (spendMonths.length > 0) {
2805
+ months.push({ month: "2026", groups: buildSpendGroups(ledger, "2026", tiers) });
2806
+ }
2807
+ const selectedMonth = spendMonths.length > 0 ? spendMonths[spendMonths.length - 1] : focus.month;
2808
+ const lang = flagString(args, "lang") === "ru" ? "ru" : "en";
2809
+ const html = renderDashboard({
2810
+ report,
2811
+ focusMonth: focus.month,
2812
+ today: now,
2813
+ nowMonth,
2814
+ lang,
2815
+ projection,
2816
+ series,
2817
+ months,
2818
+ selectedMonth,
2819
+ display: {
2820
+ footer: PROFILE.footer,
2821
+ accountLabels: PROFILE.accountLabels,
2822
+ merchantInfo: PROFILE.merchantInfo
2823
+ }
2824
+ });
2825
+ mkdirSync2(dirname2(htmlPath), { recursive: true });
2826
+ writeFileSync2(htmlPath, html, "utf8");
2827
+ console.log("");
2828
+ console.log(`✓ wrote dashboard (${html.length} bytes) → ${htmlPath}`);
2829
+ console.log(` open it: file://${htmlPath.startsWith("/") ? htmlPath : join2(process.cwd(), htmlPath)}`);
2830
+ }
2831
+ return 0;
2832
+ }
2833
+ function stockKindNote(kind) {
2834
+ switch (kind) {
2835
+ case "account":
2836
+ return "cost basis";
2837
+ case "marker":
2838
+ return "savings pot";
2839
+ case "anchor":
2840
+ return "set balance";
2841
+ }
2842
+ }
2843
+ function numberFlag(args, name) {
2844
+ const raw = flagString(args, name);
2845
+ if (raw === undefined)
2846
+ return { value: undefined, bad: null };
2847
+ const n = Number(raw);
2848
+ if (!Number.isFinite(n))
2849
+ return { value: undefined, bad: raw };
2850
+ return { value: n, bad: null };
2851
+ }
2852
+ async function cmdProject(args) {
2853
+ const ledger = loadLedger(LEDGER_PATH);
2854
+ if (ledger.length === 0) {
2855
+ console.log("ledger is empty — import something first.");
2856
+ return 0;
2857
+ }
2858
+ const config = loadSavingsConfig(SAVINGS_PATH);
2859
+ const lump = numberFlag(args, "lump-sum");
2860
+ if (lump.bad !== null) {
2861
+ console.error(`--lump-sum must be a number in EUR (got "${lump.bad}")`);
2862
+ return 1;
2863
+ }
2864
+ const rateFlag = numberFlag(args, "rate");
2865
+ if (rateFlag.bad !== null) {
2866
+ console.error(`--rate must be a number in EUR/month (got "${rateFlag.bad}")`);
2867
+ return 1;
2868
+ }
2869
+ const yearsFlag = numberFlag(args, "years");
2870
+ if (yearsFlag.bad !== null || yearsFlag.value !== undefined && (!Number.isInteger(yearsFlag.value) || yearsFlag.value < 1)) {
2871
+ console.error(`--years must be a positive integer (got "${flagString(args, "years")}")`);
2872
+ return 1;
2873
+ }
2874
+ const years = yearsFlag.value ?? 5;
2875
+ const stock = savingsStock(ledger, config);
2876
+ const components = [...stock.components];
2877
+ if (lump.value !== undefined) {
2878
+ components.push({ label: "lump-sum (--lump-sum)", eur: lump.value, kind: "anchor" });
2879
+ }
2880
+ const startEur = round2Eur(components.reduce((s, c) => s + c.eur, 0));
2881
+ const defaultRate = recentMonthlyRate(ledger, config);
2882
+ const rateIsDefault = rateFlag.value === undefined;
2883
+ const monthlyRateEur = rateFlag.value ?? defaultRate;
2884
+ const rates = loadRates(RATES_PATH);
2885
+ const rubToEur = rateToEur(rates, "RUB");
2886
+ const rubPerEur = rubToEur !== null && rubToEur > 0 ? 1 / rubToEur : null;
2887
+ const input = { startEur, monthlyRateEur, horizonMonths: years * 12, rubPerEur };
2888
+ console.log("kopeika projection — where the savings land");
2889
+ console.log("");
2890
+ if (!savingsConfigured(config) && lump.value === undefined) {
2891
+ console.log("no savings declared yet — add destinations to data/savings.csv:");
2892
+ console.log(" account,trading212, (a whole account is savings; stock = cost basis)");
2893
+ console.log(" marker,HOUSE, (a move into an in-account savings pot)");
2894
+ console.log(" anchor,Lump-sum,12000 (a not-yet-imported savings account, by balance)");
2895
+ console.log("");
2896
+ console.log("then re-run `kopeika project`. Starting from €0 for now.");
2897
+ console.log("");
2898
+ }
2899
+ console.log("starting stock (today)");
2900
+ for (const c of components) {
2901
+ console.log(` ${padEnd(c.label, 28)} ${padStart("€" + fmtMoney(c.eur), 12)} ${stockKindNote(c.kind)}`);
2902
+ }
2903
+ console.log(` ${"-".repeat(28)} ${"-".repeat(12)}`);
2904
+ console.log(` ${padEnd("total savings now", 28)} ${padStart("€" + fmtMoney(startEur), 12)}`);
2905
+ console.log("");
2906
+ const rateNote = rateIsDefault ? `recent actual, last ${DEFAULT_RATE_LOOKBACK} complete months — drag with --rate <eur/mo>` : "your set rate (--rate)";
2907
+ console.log(`go-forward rate: €${fmtMoney(monthlyRateEur)}/mo (${rateNote})`);
2908
+ if (rateIsDefault && defaultRate === 0) {
2909
+ console.log(" (no recent savings flow found — the line stays flat until you set a rate or save more)");
2910
+ }
2911
+ console.log("");
2912
+ const milestoneMonths = [...new Set([0, 12, years * 12])].filter((m) => m <= years * 12).sort((a, b) => a - b);
2913
+ const showRub = rubPerEur !== null;
2914
+ console.log(` ${padEnd("", 18)} ${padStart("EUR", 12)}${showRub ? " " + padStart("RUB", 14) : ""}`);
2915
+ for (const m of milestoneMonths) {
2916
+ const p = projectAt(input, m);
2917
+ const label = m === 0 ? "now" : m % 12 === 0 ? `in ${m / 12} year${m === 12 ? "" : "s"}` : `in ${m} months`;
2918
+ const eurCol = padStart("€" + fmtMoney(p.eur), 12);
2919
+ const rubCol = showRub && p.rub !== null ? " " + padStart("₽" + fmtMoney(p.rub), 14) : "";
2920
+ console.log(` ${padEnd(label, 18)} ${eurCol}${rubCol}`);
2921
+ }
2922
+ console.log("");
2923
+ const fxNote = rubPerEur !== null ? ` RUB at ₽${fmtMoney(rubPerEur)}/€ (data/rates.csv, flat for now).` : "";
2924
+ console.log(`ETF held flat at cost basis. The rate is an assumption you set.${fxNote}`);
2925
+ return 0;
2926
+ }
2927
+ function round2Eur(n) {
2928
+ const r = Math.round((n + Number.EPSILON) * 100) / 100;
2929
+ return r === 0 ? 0 : r;
2930
+ }
2931
+ function printHelp() {
2932
+ console.log(`kopeika — deterministic local-first bookkeeping
2933
+
2934
+ USAGE
2935
+ kopeika import <${connectorNames().join("|")}> <file> --account <name> --owner <owner>
2936
+ Archive the raw export, parse, normalize, FX-convert, dedup, append to ledger.
2937
+ Prints: imported / skipped-dup / skipped-non-completed, plus any missing FX rates.
2938
+
2939
+ kopeika categorize [--review]
2940
+ Apply ratified rules (data/rules.csv) to uncategorized rows, first match wins.
2941
+ --review list unique uncategorized merchants with counts + summed EUR (spend desc).
2942
+
2943
+ kopeika transfers
2944
+ Pair internal-transfer legs across accounts (opposite sign, |Δeur| ≤ €${DEFAULT_TRANSFER_OPTIONS.toleranceEur}, ±${DEFAULT_TRANSFER_OPTIONS.maxDayGap}d).
2945
+ Assigns a shared transfer_group and sets is_transfer=true. Re-runnable.
2946
+
2947
+ kopeika recurring [--min-months N] [--from YYYY-MM]
2948
+ List merchants seen as spend in many distinct months (the deterministic
2949
+ backbone), sorted by €/mo. Tags each \uD83D\uDD12 floor / \uD83C\uDF88 flex from data/tiers.csv.
2950
+ Recurring ≠ mandatory: frequent buys recur but flex; only \uD83D\uDD12 is the floor.
2951
+ --min-months N distinct-month threshold (default ${DEFAULT_RECURRING_OPTIONS.minMonths}).
2952
+ --from YYYY-MM only count rows on/after this month.
2953
+
2954
+ kopeika list [--source <x>] [--uncategorized] [--month YYYY-MM]
2955
+ Print a table of ledger rows with a net-EUR total.
2956
+
2957
+ kopeika report [--month YYYY-MM] [--from YYYY-MM] [--html <path>]
2958
+ Income / spend / saved per month + category breakdown + floor-vs-flex split,
2959
+ from amount_eur. Excludes internal transfers, exchanges, and Exclude rows.
2960
+ Floor (mandatory) vs flex (optional) is read from data/tiers.csv.
2961
+ Default (no flags): all-month summary + the most recent complete month.
2962
+ --month YYYY-MM focus a single month (summary still spans that month).
2963
+ --from YYYY-MM only months >= this one.
2964
+ --html <path> also write a self-contained HTML dashboard to <path>.
2965
+
2966
+ kopeika project [--rate <eur/mo>] [--lump-sum <eur>] [--years N]
2967
+ Project the savings stock forward. Starting stock = the savings destinations
2968
+ in data/savings.csv (account cost basis + in-account pots + manual anchors).
2969
+ Roll it forward at a monthly rate (the slider), default the recent actual,
2970
+ shown in EUR and RUB with the ETF held flat at cost basis.
2971
+ --rate <eur/mo> set the go-forward monthly savings rate (overrides default).
2972
+ --lump-sum <eur> add a what-if lump sum to today's stock.
2973
+ --years N projection horizon in years (default 5; always shows 1y too).
2974
+
2975
+ kopeika --help
2976
+
2977
+ DATA (all gitignored under data/)
2978
+ data/raw/<source>/ immutable original exports
2979
+ data/ledger.csv clean normalized ledger
2980
+ data/rules.csv pattern,match_type,field,category,type
2981
+ data/rates.csv month,currency,rate_to_eur
2982
+ data/tiers.csv scope,value,tier (mandatory=floor, else flex)
2983
+ data/savings.csv scope,value,balance_eur (account|marker|anchor savings)
2984
+
2985
+ NOT IMPLEMENTED (v0 stubs): Google Sheets mirror/push, LLM --suggest categorization.
2986
+ No LLM is called at runtime — the core is fully deterministic.`);
2987
+ }
2988
+ async function main() {
2989
+ const argv = process.argv.slice(2);
2990
+ const args = parseArgs(argv);
2991
+ PROFILE = loadProfile(PROFILE_PATH);
2992
+ setIdentity(PROFILE.ownNames, PROFILE.ownIbans);
2993
+ const command = args.positionals.shift();
2994
+ if (!command || command === "--help" || hasFlag(args, "help") || command === "help") {
2995
+ printHelp();
2996
+ return 0;
2997
+ }
2998
+ switch (command) {
2999
+ case "import":
3000
+ return cmdImport(args);
3001
+ case "categorize":
3002
+ return cmdCategorize(args);
3003
+ case "transfers":
3004
+ return cmdTransfers(args);
3005
+ case "recurring":
3006
+ return cmdRecurring(args);
3007
+ case "list":
3008
+ return cmdList(args);
3009
+ case "report":
3010
+ return cmdReport(args);
3011
+ case "project":
3012
+ return cmdProject(args);
3013
+ default:
3014
+ console.error(`unknown command "${command}". Run \`kopeika --help\`.`);
3015
+ return 1;
3016
+ }
3017
+ }
3018
+ main().then((code) => {
3019
+ process.exit(code);
3020
+ }).catch((err) => {
3021
+ const message = err instanceof Error ? err.stack ?? err.message : String(err);
3022
+ console.error(`kopeika: fatal error
3023
+ ` + message);
3024
+ process.exit(1);
3025
+ });