imprnt-plugin-kopeika 0.1.0 → 0.1.1-edge.62

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.
Files changed (3) hide show
  1. package/check.js +21 -8
  2. package/kopeika.js +62 -14
  3. package/package.json +2 -2
package/check.js CHANGED
@@ -12,16 +12,29 @@ var problems = [];
12
12
  var notes = [];
13
13
  function git(args) {
14
14
  const r = spawnSync("git", args, { cwd: ROOT, encoding: "utf8" });
15
- return r.status === 0 ? (r.stdout ?? "").trim() : "";
15
+ return r.status === 0 ? (r.stdout ?? "").trim() : null;
16
16
  }
17
- var dataTracked = git(["ls-files", "data"]).length > 0;
18
- var hasRemote = git(["remote"]).length > 0;
19
- if (dataTracked && hasRemote) {
20
- problems.push("data/ is git-tracked in a repo that HAS A REMOTE — your financial data (ledger, raw bank " + "exports, profile.json) could be pushed. Add data/ to .gitignore, or remove the remote. " + "Committing data/ is only safe in a remoteless local store like the imprnt vault.");
21
- } else if (dataTracked) {
22
- notes.push("data: committed (no remote — canonical local store, safe)");
17
+ var dataLs = git(["ls-files", "data"]);
18
+ var deployLs = git(["ls-files", "deploy"]);
19
+ var remoteLs = git(["remote"]);
20
+ if (dataLs === null || deployLs === null || remoteLs === null) {
21
+ notes.push("data: could not check git tracking (git unavailable or not a repo)");
23
22
  } else {
24
- notes.push("data: not tracked by git");
23
+ const dataTracked = dataLs.length > 0;
24
+ const deployTracked = deployLs.length > 0;
25
+ const hasRemote = remoteLs.length > 0;
26
+ if (dataTracked && hasRemote) {
27
+ problems.push("data/ is git-tracked in a repo that HAS A REMOTE — your financial data (ledger, raw bank " + "exports, profile.json) could be pushed. Add data/ to .gitignore, or remove the remote. " + "Committing data/ is only safe in a remoteless local store like the imprnt vault.");
28
+ } else if (dataTracked) {
29
+ notes.push("data: committed (no remote — canonical local store, safe)");
30
+ } else {
31
+ notes.push("data: not tracked by git");
32
+ }
33
+ if (deployTracked && hasRemote) {
34
+ problems.push("deploy/ is git-tracked in a repo that HAS A REMOTE — the rendered dashboard (net worth, " + "savings, transactions) could be pushed. Add deploy/ to .gitignore, or remove the remote.");
35
+ } else if (deployTracked) {
36
+ notes.push("deploy: committed (no remote — safe)");
37
+ }
25
38
  }
26
39
  var profilePath = join(DATA, "profile.json");
27
40
  if (existsSync(profilePath)) {
package/kopeika.js CHANGED
@@ -1017,9 +1017,6 @@ function renderDashboard(input) {
1017
1017
  <meta charset="utf-8" />
1018
1018
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
1019
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
1020
  <script>${themeBoot}</script>
1024
1021
  <style>
1025
1022
  ${buildCss()}
@@ -1329,6 +1326,10 @@ function mapType(revolutType) {
1329
1326
  function datePart(timestamp) {
1330
1327
  return timestamp.trim().split(" ")[0] ?? "";
1331
1328
  }
1329
+ function timePart(timestamp) {
1330
+ const t2 = timestamp.trim().split(" ")[1] ?? "";
1331
+ return /^\d{2}:\d{2}:\d{2}$/.test(t2) ? t2 : "";
1332
+ }
1332
1333
  function parseRevolut(text) {
1333
1334
  const { header, records } = parseCsv(text);
1334
1335
  for (const required of REQUIRED_HEADERS) {
@@ -1379,7 +1380,8 @@ function parseRevolut(text) {
1379
1380
  note: "",
1380
1381
  transferCandidate,
1381
1382
  amountEur: null,
1382
- balance
1383
+ balance,
1384
+ dedupExtra: timePart(rec.get("Started Date"))
1383
1385
  });
1384
1386
  }
1385
1387
  return rows;
@@ -1548,7 +1550,7 @@ function toIsoDate(raw) {
1548
1550
  return "";
1549
1551
  return `${yyyy}-${mm}-${dd}`;
1550
1552
  }
1551
- function timePart(raw) {
1553
+ function timePart2(raw) {
1552
1554
  const t2 = raw.trim().slice(11);
1553
1555
  return /^\d{2}:\d{2}:\d{2}$/.test(t2) ? t2 : "";
1554
1556
  }
@@ -1603,7 +1605,7 @@ function parseTbank(text) {
1603
1605
  transferCandidate: false,
1604
1606
  amountEur: currency === "EUR" ? amount : null,
1605
1607
  balance: null,
1606
- dedupExtra: timePart(opTimestamp)
1608
+ dedupExtra: timePart2(opTimestamp)
1607
1609
  });
1608
1610
  }
1609
1611
  return rows;
@@ -1629,6 +1631,10 @@ function toIsoDate2(raw) {
1629
1631
  return "";
1630
1632
  return `${yyyy}-${mm}-${dd}`;
1631
1633
  }
1634
+ function timePart3(raw) {
1635
+ const t2 = raw.trim().slice(11);
1636
+ return /^\d{2}:\d{2}:\d{2}$/.test(t2) ? t2 : "";
1637
+ }
1632
1638
  function parseMagnitude(raw) {
1633
1639
  return Number(raw.replace(/\s/g, "").replace(",", "."));
1634
1640
  }
@@ -1645,7 +1651,8 @@ function parseAlfa(text) {
1645
1651
  const accountName = rec.get("accountName").trim();
1646
1652
  if (!SAVINGS_ACCOUNT.test(accountName))
1647
1653
  continue;
1648
- const date = toIsoDate2(rec.get("operationDate"));
1654
+ const opTimestamp = rec.get("operationDate");
1655
+ const date = toIsoDate2(opTimestamp);
1649
1656
  if (date === "")
1650
1657
  continue;
1651
1658
  const magnitude = parseMagnitude(rec.get("amount").trim());
@@ -1672,7 +1679,8 @@ function parseAlfa(text) {
1672
1679
  note: category,
1673
1680
  transferCandidate: type === "transfer",
1674
1681
  amountEur: currency === "EUR" ? amount : null,
1675
- balance: null
1682
+ balance: null,
1683
+ dedupExtra: timePart3(opTimestamp)
1676
1684
  });
1677
1685
  }
1678
1686
  return rows;
@@ -1734,6 +1742,19 @@ function toEur(amount_native, currency, isoDate, table) {
1734
1742
  }
1735
1743
  return { amount_eur: round22(amount_native * rate), missing: null };
1736
1744
  }
1745
+ function backfillEur(txs, table) {
1746
+ let filled = 0;
1747
+ for (const tx of txs) {
1748
+ if (tx.amount_eur !== null)
1749
+ continue;
1750
+ const fx = toEur(tx.amount_native, tx.currency, tx.date, table);
1751
+ if (fx.amount_eur !== null) {
1752
+ tx.amount_eur = fx.amount_eur;
1753
+ filled += 1;
1754
+ }
1755
+ }
1756
+ return filled;
1757
+ }
1737
1758
  function rateToEur(table, currency, isoDate) {
1738
1759
  const cur = currency.toUpperCase();
1739
1760
  if (cur === "EUR")
@@ -1977,8 +1998,9 @@ function transactionId(input) {
1977
1998
  }
1978
1999
 
1979
2000
  // 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";
2001
+ import { existsSync as existsSync5, mkdirSync, readFileSync as readFileSync5, writeFileSync } from "node:fs";
2002
+ import { createHash as createHash2 } from "node:crypto";
2003
+ import { basename, dirname, extname, join } from "node:path";
1982
2004
 
1983
2005
  // src/types.ts
1984
2006
  var TX_TYPES = [
@@ -2114,10 +2136,24 @@ function appendDeduped(existing, candidates) {
2114
2136
  return { appended, skippedDuplicate, merged };
2115
2137
  }
2116
2138
  function archiveRaw(dataDir, source, sourceFilePath) {
2117
- const name = basename(sourceFilePath);
2118
2139
  const destDir = join(dataDir, "raw", source);
2119
2140
  mkdirSync(destDir, { recursive: true });
2120
- copyFileSync(sourceFilePath, join(destDir, name));
2141
+ const srcBytes = readFileSync5(sourceFilePath);
2142
+ let name = basename(sourceFilePath);
2143
+ if (existsSync5(join(destDir, name)) && !srcBytes.equals(readFileSync5(join(destDir, name)))) {
2144
+ const ext = extname(name);
2145
+ const stem = name.slice(0, name.length - ext.length);
2146
+ const hash8 = createHash2("sha256").update(srcBytes).digest("hex").slice(0, 8);
2147
+ name = `${stem}-${hash8}${ext}`;
2148
+ let n = 2;
2149
+ while (existsSync5(join(destDir, name)) && !srcBytes.equals(readFileSync5(join(destDir, name)))) {
2150
+ name = `${stem}-${hash8}-${n}${ext}`;
2151
+ n += 1;
2152
+ }
2153
+ }
2154
+ const dest = join(destDir, name);
2155
+ if (!existsSync5(dest))
2156
+ writeFileSync(dest, srcBytes);
2121
2157
  return name;
2122
2158
  }
2123
2159
 
@@ -2492,14 +2528,19 @@ async function cmdImport(args) {
2492
2528
  });
2493
2529
  const existing = loadLedger(LEDGER_PATH);
2494
2530
  const { appended, skippedDuplicate, merged } = appendDeduped(existing, candidates);
2531
+ const backfilled = backfillEur(merged, rates);
2495
2532
  writeLedger(LEDGER_PATH, merged);
2496
2533
  console.log(`imported ${appended} / skipped-dup ${skippedDuplicate} / skipped-non-completed ${skippedNonCompleted}`);
2534
+ if (backfilled > 0) {
2535
+ console.log(`backfilled amount_eur for ${backfilled} earlier row(s) from data/rates.csv`);
2536
+ }
2497
2537
  if (missingRates.size > 0) {
2498
2538
  console.log("");
2499
2539
  console.log(`⚠ missing FX rates for ${missingRates.size} (month, currency) pair(s) — amount_eur left empty:`);
2500
2540
  for (const { month, currency } of [...missingRates.values()].sort((a, b) => `${a.month}${a.currency}`.localeCompare(`${b.month}${b.currency}`))) {
2501
2541
  console.log(` ${month} ${currency} (add a row to data/rates.csv: ${month},${currency},<rate_to_eur>)`);
2502
2542
  }
2543
+ console.log(" then re-run `kopeika report` — already-imported rows are backfilled once the rate exists.");
2503
2544
  }
2504
2545
  return 0;
2505
2546
  }
@@ -2725,6 +2766,12 @@ async function cmdReport(args) {
2725
2766
  console.log("ledger is empty — import something first.");
2726
2767
  return 0;
2727
2768
  }
2769
+ const backfilled = backfillEur(ledger, loadRates(RATES_PATH));
2770
+ if (backfilled > 0) {
2771
+ writeLedger(LEDGER_PATH, ledger);
2772
+ console.log(`backfilled amount_eur for ${backfilled} row(s) from data/rates.csv`);
2773
+ console.log("");
2774
+ }
2728
2775
  const monthFlag = flagString(args, "month");
2729
2776
  const fromFlag = flagString(args, "from");
2730
2777
  const htmlPath = flagString(args, "html");
@@ -2799,10 +2846,11 @@ async function cmdReport(args) {
2799
2846
  }
2800
2847
  const now = new Date;
2801
2848
  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");
2849
+ const spendMonths = report.months.map((m) => m.month);
2803
2850
  const months = spendMonths.map((m) => ({ month: m, groups: buildSpendGroups(ledger, m, tiers) }));
2804
2851
  if (spendMonths.length > 0) {
2805
- months.push({ month: "2026", groups: buildSpendGroups(ledger, "2026", tiers) });
2852
+ const latestYear = spendMonths[spendMonths.length - 1].slice(0, 4);
2853
+ months.push({ month: latestYear, groups: buildSpendGroups(ledger, latestYear, tiers) });
2806
2854
  }
2807
2855
  const selectedMonth = spendMonths.length > 0 ? spendMonths[spendMonths.length - 1] : focus.month;
2808
2856
  const lang = flagString(args, "lang") === "ru" ? "ru" : "en";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imprnt-plugin-kopeika",
3
- "version": "0.1.0",
3
+ "version": "0.1.1-edge.62",
4
4
  "description": "imprnt plugin: a deterministic local-first personal-finance and net-worth CLI. Parses bank CSV exports into one clean ledger and renders a self-contained dashboard. All personal data stays in its own gitignored folder; no LLM at runtime.",
5
5
  "type": "module",
6
6
  "files": [
@@ -9,7 +9,7 @@
9
9
  "check.js",
10
10
  "kopeika.js",
11
11
  "profile.example.json",
12
- "proposed",
12
+ "proposed/.gitkeep",
13
13
  "docs"
14
14
  ],
15
15
  "scripts": {