@morroc/open-ledger 0.14.1

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 (81) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +191 -0
  3. package/datasets/defaults/cn.json +1 -0
  4. package/datasets/defaults/jp.json +1 -0
  5. package/datasets/defaults/th.json +1 -0
  6. package/datasets/defaults/us.json +1 -0
  7. package/datasets/institutions/cn.json +47 -0
  8. package/datasets/institutions/jp.json +59 -0
  9. package/datasets/institutions/th.json +99 -0
  10. package/datasets/institutions/us.json +70 -0
  11. package/dist/accounts/accounts.js +99 -0
  12. package/dist/accounts/balances.js +109 -0
  13. package/dist/accounts/matching.js +72 -0
  14. package/dist/accounts/resolve.js +118 -0
  15. package/dist/cli/commands/accounts.js +477 -0
  16. package/dist/cli/commands/config.js +175 -0
  17. package/dist/cli/commands/datasets.js +56 -0
  18. package/dist/cli/commands/doctor.js +153 -0
  19. package/dist/cli/commands/files.js +72 -0
  20. package/dist/cli/commands/ingest-commit.js +279 -0
  21. package/dist/cli/commands/ingest.js +203 -0
  22. package/dist/cli/commands/merchants.js +140 -0
  23. package/dist/cli/commands/notes.js +71 -0
  24. package/dist/cli/commands/open.js +54 -0
  25. package/dist/cli/commands/questions.js +119 -0
  26. package/dist/cli/commands/report.js +50 -0
  27. package/dist/cli/commands/setup.js +61 -0
  28. package/dist/cli/commands/status.js +187 -0
  29. package/dist/cli/commands/transactions.js +420 -0
  30. package/dist/cli/commands/vault.js +65 -0
  31. package/dist/cli/currency.js +28 -0
  32. package/dist/cli/db.js +5 -0
  33. package/dist/cli/format.js +71 -0
  34. package/dist/cli/index.js +19 -0
  35. package/dist/cli/output.js +304 -0
  36. package/dist/cli/program.js +140 -0
  37. package/dist/config.js +104 -0
  38. package/dist/context.js +30 -0
  39. package/dist/datasets/defaults.js +29 -0
  40. package/dist/datasets/index.js +33 -0
  41. package/dist/datasets/institutions.js +31 -0
  42. package/dist/datasets/loader.js +57 -0
  43. package/dist/db/connection.js +39 -0
  44. package/dist/db/encryption.js +42 -0
  45. package/dist/db/migrations/0001_baseline.js +121 -0
  46. package/dist/db/migrations/index.js +3 -0
  47. package/dist/db/queries/accounts.js +108 -0
  48. package/dist/db/queries/balances.js +61 -0
  49. package/dist/db/queries/files.js +72 -0
  50. package/dist/db/queries/merchants.js +163 -0
  51. package/dist/db/queries/notes.js +28 -0
  52. package/dist/db/queries/questions.js +117 -0
  53. package/dist/db/queries/transactions-dedup.js +80 -0
  54. package/dist/db/queries/transactions.js +319 -0
  55. package/dist/db/queries/vault.js +48 -0
  56. package/dist/db/schema.js +105 -0
  57. package/dist/extract/extract.js +89 -0
  58. package/dist/extract/ocr.js +164 -0
  59. package/dist/extract/pdf.js +127 -0
  60. package/dist/extract/presets/index.js +18 -0
  61. package/dist/extract/presets/lighton-ocr.js +12 -0
  62. package/dist/extract/presets/typhoon-ocr.js +23 -0
  63. package/dist/extract/route.js +40 -0
  64. package/dist/extract/source.js +84 -0
  65. package/dist/ingest/commit.js +313 -0
  66. package/dist/ingest/dedup.js +35 -0
  67. package/dist/ingest/prepare.js +241 -0
  68. package/dist/ingest/vault.js +56 -0
  69. package/dist/lib/date.js +6 -0
  70. package/dist/lib/ids.js +25 -0
  71. package/dist/lib/json.js +12 -0
  72. package/dist/lib/masked.js +49 -0
  73. package/dist/lib/money.js +35 -0
  74. package/dist/lib/patch.js +29 -0
  75. package/dist/lib/result.js +15 -0
  76. package/dist/lib/validate.js +154 -0
  77. package/dist/privacy/redactor.js +140 -0
  78. package/dist/setup/hosts.js +32 -0
  79. package/dist/setup/install.js +60 -0
  80. package/package.json +60 -0
  81. package/skills/SKILL.md +18 -0
@@ -0,0 +1,99 @@
1
+ {
2
+ "country": "TH",
3
+ "institutions": [
4
+ { "code": "KBANK", "label": "Kasikornbank", "kind": "bank" },
5
+ { "code": "SCB", "label": "Siam Commercial Bank", "kind": "bank" },
6
+ { "code": "BBL", "label": "Bangkok Bank", "kind": "bank" },
7
+ { "code": "KTB", "label": "Krungthai Bank", "kind": "bank", "notes": "State-owned." },
8
+ { "code": "BAY", "label": "Krungsri (Bank of Ayudhya)", "kind": "bank" },
9
+ { "code": "TTB", "label": "TMBThanachart Bank", "kind": "bank", "notes": "Result of TMB + Thanachart merger." },
10
+ { "code": "UOB-TH", "label": "UOB Thailand", "kind": "bank", "notes": "Absorbed Citi Thailand's consumer banking (incl. cards) in Nov 2022." },
11
+ { "code": "CIMB-TH", "label": "CIMB Thai", "kind": "bank" },
12
+ { "code": "GHB", "label": "Government Housing Bank", "kind": "bank", "notes": "State-owned, mortgage focus." },
13
+ { "code": "GSB", "label": "Government Savings Bank", "kind": "bank", "notes": "State-owned." },
14
+ { "code": "LH-BANK", "label": "Land and Houses Bank", "kind": "bank" },
15
+ { "code": "KKP", "label": "Kiatnakin Phatra Bank", "kind": "bank", "notes": "Merged 2020 from KK + Phatra." },
16
+ { "code": "TISCO", "label": "TISCO Bank", "kind": "bank" },
17
+ { "code": "IBT", "label": "Islamic Bank of Thailand", "kind": "bank", "notes": "State-owned; serves all customers." },
18
+ { "code": "ICBC-TH", "label": "ICBC (Thai)", "kind": "bank", "notes": "Subsidiary of ICBC China." },
19
+ { "code": "BAAC", "label": "Bank for Agriculture and Agricultural Cooperatives", "kind": "bank", "notes": "State-owned, rural finance." },
20
+
21
+ { "code": "KTC", "label": "Krungthai Card", "kind": "card_issuer", "notes": "Listed subsidiary of KTB." },
22
+ { "code": "AEON", "label": "AEON Thana Sinsap", "kind": "card_issuer" },
23
+ { "code": "FIRSTCHOICE", "label": "Krungsri First Choice", "kind": "card_issuer" },
24
+ { "code": "CITI-TH", "label": "Citibank Thailand", "kind": "card_issuer", "notes": "Consumer cards migrated to UOB-TH in Nov 2022; only historical statements still reference Citi." },
25
+ { "code": "AMEX-TH", "label": "American Express Thailand", "kind": "card_issuer" },
26
+ { "code": "CARDX", "label": "CardX", "kind": "card_issuer", "notes": "SCB X consumer-card spinoff; issues cards previously branded SCB." },
27
+ { "code": "DINERS", "label": "Diners Club Thailand", "kind": "card_issuer" },
28
+ { "code": "UOB-TH", "label": "UOB Thailand (Cards)", "kind": "card_issuer", "notes": "Same legal entity as the bank UOB-TH; now issues both its own card line (UOB Yolo, UOB Premier) and the migrated Citi consumer cards." },
29
+
30
+ { "code": "TRUEMONEY", "label": "TrueMoney Wallet", "kind": "wallet" },
31
+ { "code": "LINEPAY", "label": "Rabbit LINE Pay", "kind": "wallet" },
32
+ { "code": "SHOPEEPAY", "label": "ShopeePay", "kind": "wallet" },
33
+ { "code": "GRABPAY", "label": "GrabPay (Thailand)", "kind": "wallet" },
34
+ { "code": "DOLFIN", "label": "Dolfin Wallet", "kind": "wallet", "notes": "Central Group + JD.com joint venture." },
35
+ { "code": "MPAY", "label": "mPay", "kind": "wallet", "notes": "AIS-operated." },
36
+ { "code": "PAOTANG", "label": "Paotang", "kind": "wallet", "notes": "Krungthai-operated; government-benefits and tax e-wallet." },
37
+
38
+ { "code": "PROMPTPAY", "label": "PromptPay", "kind": "payment_rail", "notes": "National 24/7 interbank rail; appears on payment slips, not an issuer." },
39
+
40
+ { "code": "INNOVESTX", "label": "InnovestX Securities", "kind": "broker", "notes": "Former SCBS; SCBX subsidiary." },
41
+ { "code": "BLS", "label": "Bualuang Securities", "kind": "broker", "notes": "BBL subsidiary." },
42
+ { "code": "KS", "label": "Kasikorn Securities", "kind": "broker", "notes": "KBANK subsidiary." },
43
+ { "code": "KGI-TH", "label": "KGI Securities (Thailand)", "kind": "broker" },
44
+ { "code": "MAYBANK-SEC", "label": "Maybank Securities (Thailand)", "kind": "broker" },
45
+ { "code": "ASP", "label": "Asia Plus Securities", "kind": "broker" },
46
+ { "code": "TISCO-SEC", "label": "TISCO Securities", "kind": "broker" },
47
+ { "code": "KSS", "label": "Krungsri Securities", "kind": "broker", "notes": "BAY subsidiary." },
48
+ { "code": "KKPS", "label": "Kiatnakin Phatra Securities", "kind": "broker", "notes": "KKP subsidiary." },
49
+ { "code": "LH-SEC", "label": "Land and Houses Securities", "kind": "broker" },
50
+ { "code": "FINANSIA", "label": "Finansia Syrus Securities", "kind": "broker" },
51
+ { "code": "YUANTA-TH", "label": "Yuanta Securities (Thailand)", "kind": "broker" },
52
+ { "code": "DBSVICKERS", "label": "DBS Vickers Securities (Thailand)", "kind": "broker" },
53
+ { "code": "KTBST", "label": "Krungthai Xspring Securities", "kind": "broker", "notes": "Formerly KTBST; KTB-affiliated." },
54
+
55
+ { "code": "BITKUB", "label": "Bitkub Exchange", "kind": "crypto_exchange", "notes": "SEC-licensed; dominant market share." },
56
+ { "code": "UPBIT-TH", "label": "Upbit Thailand", "kind": "crypto_exchange", "notes": "SEC-licensed; subsidiary of South Korean Upbit." },
57
+ { "code": "ORBIX", "label": "Orbix Trade", "kind": "crypto_exchange", "notes": "Former Satang Pro; rebranded under KBank ownership." },
58
+ { "code": "GULF-BINANCE", "label": "Binance Thailand (Gulf Binance)", "kind": "crypto_exchange", "notes": "Binance + Gulf Innova JV; SEC-licensed." },
59
+ { "code": "KUCOIN-TH", "label": "KuCoin Thailand", "kind": "crypto_exchange", "notes": "Former ERX; rebranded 2025 after KuCoin acquisition." },
60
+ { "code": "WAANX", "label": "WaanX", "kind": "crypto_exchange", "notes": "SEC-licensed." },
61
+ { "code": "TDX", "label": "Thai Digital Assets Exchange", "kind": "crypto_exchange", "notes": "SET Group subsidiary." },
62
+ { "code": "GMO-Z-EX", "label": "Z.com EX (GMO-Z.com)", "kind": "crypto_exchange", "notes": "Japanese GMO subsidiary." },
63
+ { "code": "ZIPMEX", "label": "Zipmex", "kind": "crypto_exchange", "notes": "Defunct since Nov 2023; statements only appear in historical files." },
64
+
65
+ { "code": "AIA-TH", "label": "AIA Thailand", "kind": "insurer", "notes": "Life; market leader." },
66
+ { "code": "MUANG-THAI-LIFE", "label": "Muang Thai Life Insurance", "kind": "insurer", "notes": "Life." },
67
+ { "code": "THAI-LIFE", "label": "Thai Life Insurance", "kind": "insurer", "notes": "Life; listed (TLI)." },
68
+ { "code": "FWD-TH", "label": "FWD Thailand", "kind": "insurer", "notes": "Life." },
69
+ { "code": "ALLIANZ-AYUDHYA-LIFE", "label": "Allianz Ayudhya Assurance", "kind": "insurer", "notes": "Life; Allianz + BAY JV." },
70
+ { "code": "KTAXA", "label": "Krungthai-AXA Life", "kind": "insurer", "notes": "Life; KTB + AXA JV." },
71
+ { "code": "BANGKOK-LIFE", "label": "Bangkok Life Assurance", "kind": "insurer", "notes": "Life." },
72
+ { "code": "CHUBB-SAMAGGI", "label": "Chubb Samaggi Insurance", "kind": "insurer", "notes": "Life and non-life." },
73
+ { "code": "GENERALI-TH", "label": "Generali Thailand", "kind": "insurer", "notes": "Life." },
74
+ { "code": "DHIPAYA", "label": "Dhipaya Insurance", "kind": "insurer", "notes": "Non-life." },
75
+ { "code": "BANGKOK-INS", "label": "Bangkok Insurance", "kind": "insurer", "notes": "Non-life." },
76
+ { "code": "ALLIANZ-AYUDHYA-GENERAL", "label": "Allianz Ayudhya General Insurance", "kind": "insurer", "notes": "Non-life." },
77
+ { "code": "VIRIYAH", "label": "Viriyah Insurance", "kind": "insurer", "notes": "Non-life; #1 motor insurer." },
78
+ { "code": "TOKIO-MARINE", "label": "Tokio Marine (Thailand)", "kind": "insurer", "notes": "Non-life." },
79
+
80
+ { "code": "REVDEPT", "label": "Revenue Department (กรมสรรพากร)", "kind": "gov", "notes": "Income tax, VAT, excise." },
81
+ { "code": "SSO", "label": "Social Security Office (สำนักงานประกันสังคม)", "kind": "gov", "notes": "Employee + self-employed social security contributions." },
82
+ { "code": "BOT", "label": "Bank of Thailand", "kind": "gov", "notes": "Central bank; rarely on consumer statements outside of regulatory letters." },
83
+ { "code": "MOF", "label": "Ministry of Finance", "kind": "gov" },
84
+ { "code": "LDT", "label": "Department of Lands (กรมที่ดิน)", "kind": "gov", "notes": "Land registration, property tax." },
85
+ { "code": "CUSTOMS", "label": "Customs Department (กรมศุลกากร)", "kind": "gov", "notes": "Import/export duties." },
86
+
87
+ { "code": "AIS", "label": "Advanced Info Service (AIS)", "kind": "telco" },
88
+ { "code": "TRUE-CORP", "label": "True Corporation", "kind": "telco", "notes": "Merged entity of True + dtac since March 2023." },
89
+ { "code": "TRUEMOVE", "label": "TrueMove H", "kind": "telco", "notes": "Brand retained under TRUE-CORP per NBTC ruling." },
90
+ { "code": "DTAC", "label": "dtac", "kind": "telco", "notes": "Brand retained under TRUE-CORP per NBTC ruling." },
91
+ { "code": "NT", "label": "National Telecom (NT)", "kind": "telco", "notes": "Former TOT; state-owned, minimal consumer presence." },
92
+
93
+ { "code": "MEA", "label": "Metropolitan Electricity Authority (กฟน.)", "kind": "utility", "notes": "Electricity for Bangkok, Nonthaburi, Samut Prakan." },
94
+ { "code": "PEA", "label": "Provincial Electricity Authority (กฟภ.)", "kind": "utility", "notes": "Electricity for the rest of Thailand outside MEA's area." },
95
+ { "code": "MWA", "label": "Metropolitan Waterworks Authority (กปน.)", "kind": "utility", "notes": "Water for Bangkok, Nonthaburi, Samut Prakan." },
96
+ { "code": "PWA", "label": "Provincial Waterworks Authority (กปภ.)", "kind": "utility", "notes": "Water for the rest of Thailand." },
97
+ { "code": "EGAT", "label": "Electricity Generating Authority of Thailand (กฟผ.)", "kind": "utility", "notes": "Power generation; rarely appears on consumer bills directly." }
98
+ ]
99
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "country": "US",
3
+ "institutions": [
4
+ { "code": "CHASE", "label": "JPMorgan Chase", "kind": "bank" },
5
+ { "code": "BOFA", "label": "Bank of America", "kind": "bank" },
6
+ { "code": "WELLSFARGO", "label": "Wells Fargo", "kind": "bank" },
7
+ { "code": "CITI-US", "label": "Citibank", "kind": "bank" },
8
+ { "code": "USBANK", "label": "U.S. Bank", "kind": "bank" },
9
+ { "code": "PNC", "label": "PNC Bank", "kind": "bank" },
10
+ { "code": "TRUIST", "label": "Truist", "kind": "bank", "notes": "BB&T + SunTrust merger (2019)." },
11
+ { "code": "CAPITALONE", "label": "Capital One", "kind": "bank", "notes": "Also a major card issuer; acquired Discover in 2025." },
12
+ { "code": "TD-US", "label": "TD Bank (US)", "kind": "bank" },
13
+ { "code": "ALLY", "label": "Ally Bank", "kind": "bank", "notes": "Online-only." },
14
+ { "code": "MARCUS", "label": "Marcus by Goldman Sachs", "kind": "bank", "notes": "Online savings; no checking." },
15
+ { "code": "SCHWAB-BANK", "label": "Charles Schwab Bank", "kind": "bank", "notes": "Banking arm of the broker; same statements family as SCHWAB." },
16
+ { "code": "FIFTHTHIRD", "label": "Fifth Third Bank", "kind": "bank" },
17
+ { "code": "CITIZENS", "label": "Citizens Bank", "kind": "bank" },
18
+ { "code": "KEYBANK", "label": "KeyBank", "kind": "bank" },
19
+ { "code": "REGIONS", "label": "Regions Bank", "kind": "bank" },
20
+ { "code": "MTB", "label": "M&T Bank", "kind": "bank" },
21
+ { "code": "HUNTINGTON", "label": "Huntington Bank", "kind": "bank" },
22
+ { "code": "SOFI", "label": "SoFi", "kind": "bank", "notes": "Fintech with a national bank charter since 2022." },
23
+ { "code": "CHIME", "label": "Chime", "kind": "bank", "notes": "Neobank; accounts held by partner banks (Bancorp/Stride)." },
24
+ { "code": "AMEX", "label": "American Express", "kind": "card_issuer", "notes": "Issuer and network; also offers savings via Amex Bank." },
25
+ { "code": "DISCOVER", "label": "Discover", "kind": "card_issuer", "notes": "Acquired by Capital One in 2025; brand retained." },
26
+ { "code": "CAPITALONE-CARDS", "label": "Capital One (Cards)", "kind": "card_issuer", "notes": "Same entity as the bank CAPITALONE." },
27
+ { "code": "SYNCHRONY", "label": "Synchrony Bank", "kind": "card_issuer", "notes": "Largest store/private-label card issuer (Amazon, Lowe's, etc.)." },
28
+ { "code": "BARCLAYS-US", "label": "Barclays US", "kind": "card_issuer", "notes": "Co-branded cards (airlines, retail)." },
29
+ { "code": "PAYPAL", "label": "PayPal", "kind": "wallet" },
30
+ { "code": "VENMO", "label": "Venmo", "kind": "wallet", "notes": "PayPal subsidiary." },
31
+ { "code": "CASHAPP", "label": "Cash App", "kind": "wallet", "notes": "Block (Square) product." },
32
+ { "code": "APPLECASH", "label": "Apple Cash / Apple Card", "kind": "wallet", "notes": "Apple Card issued by Goldman Sachs." },
33
+ { "code": "GOOGLEPAY", "label": "Google Pay", "kind": "wallet" },
34
+ { "code": "ZELLE", "label": "Zelle", "kind": "payment_rail", "notes": "Bank-to-bank instant rail; appears on bank statements, not an account." },
35
+ { "code": "ACH", "label": "ACH", "kind": "payment_rail", "notes": "Batch interbank rail; shows as ACH credit/debit descriptors." },
36
+ { "code": "FIDELITY", "label": "Fidelity Investments", "kind": "broker" },
37
+ { "code": "VANGUARD", "label": "Vanguard", "kind": "broker" },
38
+ { "code": "SCHWAB", "label": "Charles Schwab", "kind": "broker", "notes": "Absorbed TD Ameritrade accounts by 2024." },
39
+ { "code": "ROBINHOOD", "label": "Robinhood", "kind": "broker" },
40
+ { "code": "ETRADE", "label": "E*TRADE", "kind": "broker", "notes": "Morgan Stanley subsidiary." },
41
+ { "code": "MERRILL", "label": "Merrill", "kind": "broker", "notes": "Bank of America subsidiary." },
42
+ { "code": "IBKR", "label": "Interactive Brokers", "kind": "broker" },
43
+ { "code": "WEBULL", "label": "Webull", "kind": "broker" },
44
+ { "code": "COINBASE", "label": "Coinbase", "kind": "crypto_exchange", "notes": "Largest US-regulated exchange." },
45
+ { "code": "KRAKEN", "label": "Kraken", "kind": "crypto_exchange" },
46
+ { "code": "GEMINI", "label": "Gemini", "kind": "crypto_exchange" },
47
+ { "code": "CRYPTOCOM-US", "label": "Crypto.com (US)", "kind": "crypto_exchange" },
48
+ { "code": "BINANCE-US", "label": "Binance.US", "kind": "crypto_exchange", "notes": "Separate entity from global Binance; fiat rails limited since 2023." },
49
+ { "code": "STATEFARM", "label": "State Farm", "kind": "insurer", "notes": "Auto/home leader." },
50
+ { "code": "GEICO", "label": "GEICO", "kind": "insurer", "notes": "Berkshire Hathaway subsidiary; auto." },
51
+ { "code": "PROGRESSIVE", "label": "Progressive", "kind": "insurer", "notes": "Auto." },
52
+ { "code": "ALLSTATE", "label": "Allstate", "kind": "insurer" },
53
+ { "code": "METLIFE", "label": "MetLife", "kind": "insurer", "notes": "Life and employee benefits." },
54
+ { "code": "PRUDENTIAL-US", "label": "Prudential Financial", "kind": "insurer", "notes": "Life; unrelated to UK Prudential." },
55
+ { "code": "NYLIFE", "label": "New York Life", "kind": "insurer", "notes": "Life; mutual company." },
56
+ { "code": "IRS", "label": "Internal Revenue Service", "kind": "gov", "notes": "Federal income tax." },
57
+ { "code": "SSA", "label": "Social Security Administration", "kind": "gov", "notes": "Benefits deposits; SSA TREAS descriptors." },
58
+ { "code": "TREASURY", "label": "U.S. Treasury", "kind": "gov", "notes": "Tax refunds, savings bonds, treasuries via TreasuryDirect." },
59
+ { "code": "VERIZON", "label": "Verizon", "kind": "telco" },
60
+ { "code": "ATT", "label": "AT&T", "kind": "telco" },
61
+ { "code": "TMOBILE", "label": "T-Mobile", "kind": "telco", "notes": "Absorbed Sprint (2020)." },
62
+ { "code": "XFINITY", "label": "Xfinity (Comcast)", "kind": "telco", "notes": "Cable internet/TV; billed as Comcast." },
63
+ { "code": "SPECTRUM", "label": "Spectrum (Charter)", "kind": "telco" },
64
+ { "code": "PGE-CA", "label": "Pacific Gas and Electric", "kind": "utility", "notes": "Northern California." },
65
+ { "code": "SCE", "label": "Southern California Edison", "kind": "utility" },
66
+ { "code": "CONED", "label": "Con Edison", "kind": "utility", "notes": "New York City area." },
67
+ { "code": "DUKE", "label": "Duke Energy", "kind": "utility", "notes": "Southeast/Midwest." },
68
+ { "code": "NATIONALGRID-US", "label": "National Grid (US)", "kind": "utility", "notes": "Northeast gas/electric." }
69
+ ]
70
+ }
@@ -0,0 +1,99 @@
1
+ import { countChildAccounts, deleteAccount as deleteAccountRow, findAccountById, insertAccount, insertStructuralAccount, TOP_LEVEL_TYPES, } from "../db/queries/accounts.js";
2
+ import { accountHasTransactions, repointTransactions } from "../db/queries/transactions.js";
3
+ const TYPE_ROOT_NAME = {
4
+ asset: "Assets",
5
+ liability: "Liabilities",
6
+ income: "Income",
7
+ expense: "Expenses",
8
+ equity: "Equity",
9
+ };
10
+ export function ensureTopLevelRoot(db, type) {
11
+ if (findAccountById(db, type))
12
+ return;
13
+ insertStructuralAccount(db, {
14
+ id: type,
15
+ name: TYPE_ROOT_NAME[type],
16
+ type,
17
+ parent_id: null,
18
+ });
19
+ }
20
+ /**
21
+ * Idempotently insert one of the structural accounts the system auto-creates:
22
+ * - `expense:uncategorized` (suspense for unclassifiable expense entries)
23
+ * - `equity:adjustments` (balancing side of `adjust_account_balance`)
24
+ * - `equity:opening-balance` (starting state imports)
25
+ * The top-level root is bootstrapped first when missing.
26
+ */
27
+ export function ensureStructuralAccount(db, id) {
28
+ if (findAccountById(db, id))
29
+ return;
30
+ const [type, leaf] = id.split(":");
31
+ ensureTopLevelRoot(db, type);
32
+ const name = leaf === "uncategorized" ? "Uncategorized"
33
+ : leaf === "adjustments" ? "Adjustments"
34
+ : "Opening Balance";
35
+ insertStructuralAccount(db, { id, name, type, parent_id: type });
36
+ }
37
+ // Throws on any violation; the caller does the INSERT.
38
+ function validateAccountHierarchy(db, input, parentId) {
39
+ if (parentId === null) {
40
+ if (!TOP_LEVEL_TYPES.includes(input.id)) {
41
+ throw new Error(`Account "${input.id}" has no parent_id; only top-level type roots may have a null parent (one of ${TOP_LEVEL_TYPES.join(", ")}).`);
42
+ }
43
+ if (input.id !== input.type) {
44
+ throw new Error(`Top-level root id "${input.id}" must equal its type "${input.type}".`);
45
+ }
46
+ return;
47
+ }
48
+ let parent = findAccountById(db, parentId);
49
+ if (!parent && TOP_LEVEL_TYPES.includes(parentId)) {
50
+ ensureTopLevelRoot(db, parentId);
51
+ parent = findAccountById(db, parentId);
52
+ }
53
+ if (!parent) {
54
+ throw new Error(`Parent account "${parentId}" does not exist; create it first.`);
55
+ }
56
+ if (parent.type !== input.type) {
57
+ throw new Error(`Account "${input.id}" type "${input.type}" does not match parent "${parentId}" type "${parent.type}".`);
58
+ }
59
+ if (!input.id.startsWith(parent.id + ":")) {
60
+ throw new Error(`Account id "${input.id}" must start with parent id "${parent.id}:".`);
61
+ }
62
+ }
63
+ /** A duplicate id surfaces as an Error with code 'ACCOUNT_EXISTS'. */
64
+ export function createAccount(db, input) {
65
+ validateAccountHierarchy(db, input, input.parent_id ?? null);
66
+ insertAccount(db, input);
67
+ }
68
+ /**
69
+ * Re-points every leg on `fromId` to `toId`, then deletes the source account.
70
+ * Refuses if the source account still has children.
71
+ */
72
+ export function mergeAccounts(db, fromId, toId) {
73
+ if (fromId === toId)
74
+ throw new Error("Cannot merge an account into itself.");
75
+ const from = findAccountById(db, fromId);
76
+ if (!from)
77
+ throw new Error(`Source account ${fromId} not found.`);
78
+ const to = findAccountById(db, toId);
79
+ if (!to)
80
+ throw new Error(`Destination account ${toId} not found.`);
81
+ const children = countChildAccounts(db, fromId);
82
+ if (children > 0) {
83
+ throw new Error(`Account ${fromId} has ${children} child account(s); merge or delete them first.`);
84
+ }
85
+ const { moved, deletedSelfTransactions } = repointTransactions(db, fromId, toId);
86
+ deleteAccountRow(db, fromId);
87
+ return { moved, deletedSelfTransactions };
88
+ }
89
+ /** Delete an account only if no transactions reference it AND it has no children. */
90
+ export function deleteAccount(db, id) {
91
+ if (accountHasTransactions(db, id)) {
92
+ throw new Error(`Account ${id} still has transactions; merge it first.`);
93
+ }
94
+ const children = countChildAccounts(db, id);
95
+ if (children > 0) {
96
+ throw new Error(`Account ${id} has ${children} child account(s); delete them first.`);
97
+ }
98
+ deleteAccountRow(db, id);
99
+ }
@@ -0,0 +1,109 @@
1
+ import { findAccountById, getAccountSubtree, } from "../db/queries/accounts.js";
2
+ import { getAccountLegSums, getLegSumsForAccounts, getPeriodLegSums, } from "../db/queries/balances.js";
3
+ import { insertTransaction } from "../db/queries/transactions.js";
4
+ import { config } from "../config.js";
5
+ import { fromMinorUnits, toMinorUnits } from "../lib/money.js";
6
+ import { todayIso, ISO_DATE_RE } from "../lib/date.js";
7
+ import { ensureStructuralAccount } from "./accounts.js";
8
+ /**
9
+ * Shared normal-balance rule: asset/expense are debit-normal, the rest
10
+ * credit-normal. Leg sums are integer minor units; every decimal here comes
11
+ * from the account's currency exponent (`fromMinorUnits`).
12
+ */
13
+ export function getAccountBalances(db, opts = {}) {
14
+ return getAccountLegSums(db, opts).map((r) => {
15
+ const debitNormal = r.type === "asset" || r.type === "expense";
16
+ const balance_minor = debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
17
+ const { sum_debit, sum_credit, ...account } = r;
18
+ return {
19
+ ...account,
20
+ debits_posted: sum_debit,
21
+ credits_posted: sum_credit,
22
+ balance_minor,
23
+ balance: fromMinorUnits(balance_minor, account.currency),
24
+ };
25
+ });
26
+ }
27
+ export function getNetWorth(db) {
28
+ const balances = getAccountBalances(db);
29
+ let assets = 0;
30
+ let liabilities = 0;
31
+ for (const b of balances) {
32
+ if (b.type === "asset")
33
+ assets += b.balance;
34
+ else if (b.type === "liability")
35
+ liabilities += b.balance;
36
+ }
37
+ return { assets, liabilities, net_worth: assets - liabilities };
38
+ }
39
+ /**
40
+ * Income (credits − debits) and expenses (debits − credits) over a date range.
41
+ * Grouped by (type, currency) so each currency converts with its own exponent.
42
+ */
43
+ export function getPeriodTotals(db, from, to) {
44
+ let income = 0;
45
+ let expenses = 0;
46
+ for (const r of getPeriodLegSums(db, from, to)) {
47
+ if (r.type === "income")
48
+ income += fromMinorUnits(r.c_minus_d, r.currency);
49
+ else if (r.type === "expense")
50
+ expenses += fromMinorUnits(-r.c_minus_d, r.currency);
51
+ }
52
+ return { income, expenses };
53
+ }
54
+ /** Subtree balance (root inclusive), grouped by (type, currency) for correct conversion. */
55
+ export function getRollupBalance(db, rootId) {
56
+ const subtree = getAccountSubtree(db, rootId);
57
+ if (subtree.length === 0)
58
+ return 0;
59
+ let total = 0;
60
+ for (const r of getLegSumsForAccounts(db, subtree.map((a) => a.id))) {
61
+ const debitNormal = r.type === "asset" || r.type === "expense";
62
+ const minor = debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
63
+ total += fromMinorUnits(minor, r.currency);
64
+ }
65
+ return total;
66
+ }
67
+ const EQUITY_ADJUST_ID = "equity:adjustments";
68
+ /**
69
+ * Moves an account to `targetAmount` by posting one balancing transaction
70
+ * against `equity:adjustments`. Delta math is integer minor units (no float
71
+ * drift); a zero delta is a no-op.
72
+ */
73
+ export function adjustAccountBalance(db, opts) {
74
+ const account = findAccountById(db, opts.accountId);
75
+ if (!account)
76
+ throw new Error(`Account "${opts.accountId}" not found.`);
77
+ const target = Number(opts.targetAmount);
78
+ if (!Number.isFinite(target)) {
79
+ throw new Error(`targetAmount must be a number, got ${JSON.stringify(opts.targetAmount)}.`);
80
+ }
81
+ const currency = account.currency || config.displayCurrency;
82
+ const currentMinor = getAccountBalances(db, { idOrParent: account.id }).find((b) => b.id === account.id)
83
+ ?.balance_minor ?? 0;
84
+ const targetMinor = toMinorUnits(target, currency);
85
+ const deltaMinor = targetMinor - currentMinor;
86
+ if (deltaMinor === 0)
87
+ return { transactionId: null, delta: 0 };
88
+ const amount = Math.abs(deltaMinor);
89
+ const debitNormal = account.type === "asset" || account.type === "expense";
90
+ const accountIsDebit = (debitNormal && deltaMinor > 0) || (!debitNormal && deltaMinor < 0);
91
+ const debitAccountId = accountIsDebit ? account.id : EQUITY_ADJUST_ID;
92
+ const creditAccountId = accountIsDebit ? EQUITY_ADJUST_ID : account.id;
93
+ const date = opts.date && ISO_DATE_RE.test(opts.date) ? opts.date : todayIso();
94
+ const reason = String(opts.reason || "Balance adjustment").trim();
95
+ let transactionId = "";
96
+ const tx = db.transaction(() => {
97
+ ensureStructuralAccount(db, "equity:adjustments");
98
+ transactionId = insertTransaction(db, {
99
+ date,
100
+ description: reason,
101
+ debit_account_id: debitAccountId,
102
+ credit_account_id: creditAccountId,
103
+ amount,
104
+ currency,
105
+ }).id;
106
+ });
107
+ tx();
108
+ return { transactionId, delta: fromMinorUnits(deltaMinor, currency) };
109
+ }
@@ -0,0 +1,72 @@
1
+ import { listAccounts } from "../db/queries/accounts.js";
2
+ import { accountNumberKey, tailAfterMask } from "../lib/masked.js";
3
+ // A mask in the text (`470686XXXXXX9483`) is authoritative: its tail wins over the
4
+ // longest-digit-run fallback, which would prefer the longer unmasked prefix.
5
+ function queryNumberKey(text) {
6
+ const tail = tailAfterMask(text);
7
+ if (tail !== text)
8
+ return accountNumberKey(tail);
9
+ const runs = text.match(/\d+/g);
10
+ if (!runs)
11
+ return "";
12
+ const longest = runs.reduce((a, b) => (b.length > a.length ? b : a));
13
+ return accountNumberKey(longest);
14
+ }
15
+ /** Substring hits get a 0.85 floor ("ttb saving" finds "TTB Savings ••1234" despite
16
+ * mediocre Levenshtein), and a query carrying an account number matches the row's
17
+ * masked number — callers confirm before acting, so a same-last-4 collision is recoverable. */
18
+ export function findAccountsByFuzzyName(db, query, threshold = 0.5) {
19
+ const q = query.trim().toLowerCase();
20
+ if (!q)
21
+ return [];
22
+ const qKey = queryNumberKey(q);
23
+ const rows = listAccounts(db);
24
+ const out = [];
25
+ for (const row of rows) {
26
+ const name = row.name.toLowerCase();
27
+ let score = similarity(q, name);
28
+ if (name.includes(q) || q.includes(name))
29
+ score = Math.max(score, 0.85);
30
+ if (qKey) {
31
+ const rowKey = row.account_number_masked
32
+ ? accountNumberKey(row.account_number_masked)
33
+ : queryNumberKey(name);
34
+ if (rowKey && rowKey === qKey)
35
+ score = Math.max(score, 0.9);
36
+ }
37
+ if (score >= threshold) {
38
+ out.push({ account: row, similarity: Math.round(score * 1000) / 1000 });
39
+ }
40
+ }
41
+ out.sort((a, b) => b.similarity - a.similarity);
42
+ return out;
43
+ }
44
+ function similarity(a, b) {
45
+ if (a === b)
46
+ return 1;
47
+ if (a.length === 0 || b.length === 0)
48
+ return 0;
49
+ const dist = levenshtein(a, b);
50
+ return 1 - dist / Math.max(a.length, b.length);
51
+ }
52
+ function levenshtein(a, b) {
53
+ const m = a.length, n = b.length;
54
+ if (m === 0)
55
+ return n;
56
+ if (n === 0)
57
+ return m;
58
+ const prev = new Array(n + 1);
59
+ const curr = new Array(n + 1);
60
+ for (let j = 0; j <= n; j++)
61
+ prev[j] = j;
62
+ for (let i = 1; i <= m; i++) {
63
+ curr[0] = i;
64
+ for (let j = 1; j <= n; j++) {
65
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
66
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
67
+ }
68
+ for (let j = 0; j <= n; j++)
69
+ prev[j] = curr[j];
70
+ }
71
+ return prev[n];
72
+ }
@@ -0,0 +1,118 @@
1
+ import { createAccount, ensureStructuralAccount, ensureTopLevelRoot, } from "./accounts.js";
2
+ import { findAccountById, TOP_LEVEL_TYPES, } from "../db/queries/accounts.js";
3
+ import { merchantExists } from "../db/queries/merchants.js";
4
+ import { findAccountsByFuzzyName } from "./matching.js";
5
+ /**
6
+ * A merchant id that doesn't exist is recorded as attempted-unknown, so the
7
+ * caller can raise a question.
8
+ */
9
+ export function resolveMerchantId(db, merchantId) {
10
+ if (!merchantId)
11
+ return { merchantId: null, attemptedUnknownId: null };
12
+ if (merchantExists(db, merchantId))
13
+ return { merchantId, attemptedUnknownId: null };
14
+ return { merchantId: null, attemptedUnknownId: merchantId };
15
+ }
16
+ /** `hint` is null on an exact match; `uncategorized_fallback` is the ambiguous case the commit pipeline turns into a question. */
17
+ export function resolveOnePosting(db, posting, opts = {}) {
18
+ if (findAccountById(db, posting.account_id)) {
19
+ return { posting, hint: null };
20
+ }
21
+ if (!opts.skipFuzzy) {
22
+ const matched = bestFuzzyMatch(db, posting.account_id);
23
+ if (matched) {
24
+ return {
25
+ posting: { ...posting, account_id: matched },
26
+ hint: {
27
+ type: "similar_matched",
28
+ originalId: posting.account_id,
29
+ matchedId: matched,
30
+ },
31
+ };
32
+ }
33
+ }
34
+ const placeholder = ensurePlaceholderAccount(db, posting.account_id);
35
+ return {
36
+ posting: { ...posting, account_id: placeholder.accountId },
37
+ hint: placeholder.fellBack
38
+ ? { type: "uncategorized_fallback", accountId: placeholder.accountId }
39
+ : { type: "placeholder_created", accountId: placeholder.accountId },
40
+ };
41
+ }
42
+ const FUZZY_THRESHOLD = 0.7;
43
+ function bestFuzzyMatch(db, accountId) {
44
+ const leaf = leafSegment(accountId).replace(/[-_]+/g, " ");
45
+ if (!leaf)
46
+ return null;
47
+ const matches = findAccountsByFuzzyName(db, leaf, FUZZY_THRESHOLD);
48
+ return matches[0]?.account.id ?? null;
49
+ }
50
+ function leafSegment(id) {
51
+ const segments = id.split(":");
52
+ return segments[segments.length - 1] ?? id;
53
+ }
54
+ // `upTo` is 1-based. `ACCOUNT_EXISTS` races are swallowed as a no-op; every other error propagates.
55
+ function walkAncestorChain(db, segments, type, upTo) {
56
+ const created = [];
57
+ if (!findAccountById(db, type))
58
+ created.push(type);
59
+ ensureTopLevelRoot(db, type);
60
+ for (let i = 2; i <= upTo; i++) {
61
+ const id = segments.slice(0, i).join(":");
62
+ if (findAccountById(db, id))
63
+ continue;
64
+ const parentId = segments.slice(0, i - 1).join(":");
65
+ const name = humanizeSegment(segments[i - 1]);
66
+ try {
67
+ createAccount(db, { id, name, type, parent_id: parentId });
68
+ created.push(id);
69
+ }
70
+ catch (err) {
71
+ if (err?.code === "ACCOUNT_EXISTS")
72
+ continue;
73
+ throw err;
74
+ }
75
+ }
76
+ return created;
77
+ }
78
+ // Never surfaces an error: always returns a usable id, falling back to `expense:uncategorized`.
79
+ function ensurePlaceholderAccount(db, accountId) {
80
+ const segments = accountId.split(":").filter(Boolean);
81
+ if (segments.length < 2)
82
+ return { accountId: ensureUncategorizedFallback(db), fellBack: true };
83
+ const type = segments[0];
84
+ if (!TOP_LEVEL_TYPES.includes(type))
85
+ return { accountId: ensureUncategorizedFallback(db), fellBack: true };
86
+ // Any hierarchy failure here degrades to the fallback silently; `--resolve` depends on that.
87
+ try {
88
+ walkAncestorChain(db, segments, type, segments.length);
89
+ }
90
+ catch {
91
+ return { accountId: ensureUncategorizedFallback(db), fellBack: true };
92
+ }
93
+ return { accountId, fellBack: false };
94
+ }
95
+ /**
96
+ * Creates any missing ancestor above the leaf for a multi-segment id (e.g.
97
+ * `asset:bank:ttb`), so `accounts create` doesn't need every intermediate
98
+ * category pre-created. Unlike `ensurePlaceholderAccount`, propagates
99
+ * hierarchy errors as-is so the CLI can surface a real INVALID.
100
+ */
101
+ export function ensureAccountAncestors(db, id, type) {
102
+ const segments = id.split(":").filter(Boolean);
103
+ if (segments.length < 2)
104
+ return { parentId: null, createdParents: [] };
105
+ const createdParents = walkAncestorChain(db, segments, type, segments.length - 1);
106
+ const parentId = segments.slice(0, segments.length - 1).join(":");
107
+ return { parentId, createdParents };
108
+ }
109
+ function ensureUncategorizedFallback(db) {
110
+ ensureStructuralAccount(db, "expense:uncategorized");
111
+ return "expense:uncategorized";
112
+ }
113
+ function humanizeSegment(segment) {
114
+ const spaced = segment.replace(/[-_]+/g, " ").trim();
115
+ if (!spaced)
116
+ return "Placeholder";
117
+ return spaced.replace(/\b\w/g, (c) => c.toUpperCase());
118
+ }