tallyd 0.0.0-stage → 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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1493 -2
  3. package/dist/accounts/chart.d.ts +96 -0
  4. package/dist/accounts/chart.js +244 -0
  5. package/dist/accounts/index.d.ts +3 -0
  6. package/dist/accounts/index.js +3 -0
  7. package/dist/accounts/standard.d.ts +12 -0
  8. package/dist/accounts/standard.js +95 -0
  9. package/dist/accounts/types.d.ts +20 -0
  10. package/dist/accounts/types.js +40 -0
  11. package/dist/cli/args.d.ts +45 -0
  12. package/dist/cli/args.js +147 -0
  13. package/dist/cli/index.d.ts +2 -0
  14. package/dist/cli/index.js +2 -0
  15. package/dist/cli/main.d.ts +6 -0
  16. package/dist/cli/main.js +25 -0
  17. package/dist/cli/run.d.ts +34 -0
  18. package/dist/cli/run.js +1660 -0
  19. package/dist/dashboard/charts.d.ts +38 -0
  20. package/dist/dashboard/charts.js +173 -0
  21. package/dist/dashboard/index.d.ts +3 -0
  22. package/dist/dashboard/index.js +3 -0
  23. package/dist/dashboard/model.d.ts +137 -0
  24. package/dist/dashboard/model.js +190 -0
  25. package/dist/dashboard/render.d.ts +23 -0
  26. package/dist/dashboard/render.js +231 -0
  27. package/dist/dashboard/script.d.ts +28 -0
  28. package/dist/dashboard/script.js +704 -0
  29. package/dist/dashboard/styles.d.ts +14 -0
  30. package/dist/dashboard/styles.js +726 -0
  31. package/dist/demo/disposal.d.ts +80 -0
  32. package/dist/demo/disposal.js +215 -0
  33. package/dist/demo/evidence.d.ts +153 -0
  34. package/dist/demo/evidence.js +348 -0
  35. package/dist/demo/foreign.d.ts +29 -0
  36. package/dist/demo/foreign.js +113 -0
  37. package/dist/demo/foreignReport.d.ts +10 -0
  38. package/dist/demo/foreignReport.js +89 -0
  39. package/dist/demo/generator.d.ts +108 -0
  40. package/dist/demo/generator.js +445 -0
  41. package/dist/demo/group.d.ts +70 -0
  42. package/dist/demo/group.js +195 -0
  43. package/dist/demo/groupPeriods.d.ts +27 -0
  44. package/dist/demo/groupPeriods.js +125 -0
  45. package/dist/demo/groupReport.d.ts +13 -0
  46. package/dist/demo/groupReport.js +87 -0
  47. package/dist/demo/ingest.d.ts +6 -0
  48. package/dist/demo/ingest.js +61 -0
  49. package/dist/demo/main.d.ts +6 -0
  50. package/dist/demo/main.js +7 -0
  51. package/dist/demo/mainDisposal.d.ts +1 -0
  52. package/dist/demo/mainDisposal.js +2 -0
  53. package/dist/demo/mainEvidence.d.ts +8 -0
  54. package/dist/demo/mainEvidence.js +9 -0
  55. package/dist/demo/mainForeign.d.ts +6 -0
  56. package/dist/demo/mainForeign.js +7 -0
  57. package/dist/demo/mainGroup.d.ts +6 -0
  58. package/dist/demo/mainGroup.js +7 -0
  59. package/dist/demo/mainGroupPeriods.d.ts +6 -0
  60. package/dist/demo/mainGroupPeriods.js +7 -0
  61. package/dist/demo/mainIngest.d.ts +6 -0
  62. package/dist/demo/mainIngest.js +7 -0
  63. package/dist/demo/mainMidYear.d.ts +6 -0
  64. package/dist/demo/mainMidYear.js +7 -0
  65. package/dist/demo/mainReconcile.d.ts +6 -0
  66. package/dist/demo/mainReconcile.js +7 -0
  67. package/dist/demo/mainStatements.d.ts +6 -0
  68. package/dist/demo/mainStatements.js +7 -0
  69. package/dist/demo/midYear.d.ts +51 -0
  70. package/dist/demo/midYear.js +162 -0
  71. package/dist/demo/month.d.ts +13 -0
  72. package/dist/demo/month.js +142 -0
  73. package/dist/demo/random.d.ts +48 -0
  74. package/dist/demo/random.js +94 -0
  75. package/dist/demo/receivables.d.ts +36 -0
  76. package/dist/demo/receivables.js +104 -0
  77. package/dist/demo/reconcile.d.ts +20 -0
  78. package/dist/demo/reconcile.js +104 -0
  79. package/dist/demo/report.d.ts +6 -0
  80. package/dist/demo/report.js +26 -0
  81. package/dist/demo/statement.d.ts +13 -0
  82. package/dist/demo/statement.js +51 -0
  83. package/dist/demo/statements.d.ts +6 -0
  84. package/dist/demo/statements.js +29 -0
  85. package/dist/demo/supplierRun.d.ts +46 -0
  86. package/dist/demo/supplierRun.js +94 -0
  87. package/dist/demo/unrealisedEvidence.d.ts +51 -0
  88. package/dist/demo/unrealisedEvidence.js +183 -0
  89. package/dist/fx/average.d.ts +60 -0
  90. package/dist/fx/average.js +139 -0
  91. package/dist/fx/document.d.ts +60 -0
  92. package/dist/fx/document.js +205 -0
  93. package/dist/fx/exposure.d.ts +108 -0
  94. package/dist/fx/exposure.js +262 -0
  95. package/dist/fx/index.d.ts +8 -0
  96. package/dist/fx/index.js +8 -0
  97. package/dist/fx/rate.d.ts +77 -0
  98. package/dist/fx/rate.js +200 -0
  99. package/dist/fx/revaluation.d.ts +92 -0
  100. package/dist/fx/revaluation.js +197 -0
  101. package/dist/fx/settlement.d.ts +84 -0
  102. package/dist/fx/settlement.js +162 -0
  103. package/dist/fx/table.d.ts +120 -0
  104. package/dist/fx/table.js +271 -0
  105. package/dist/fx/translate.d.ts +126 -0
  106. package/dist/fx/translate.js +258 -0
  107. package/dist/group/accounts.d.ts +74 -0
  108. package/dist/group/accounts.js +179 -0
  109. package/dist/group/acquisition.d.ts +324 -0
  110. package/dist/group/acquisition.js +416 -0
  111. package/dist/group/aggregate.d.ts +143 -0
  112. package/dist/group/aggregate.js +288 -0
  113. package/dist/group/comparative.d.ts +102 -0
  114. package/dist/group/comparative.js +166 -0
  115. package/dist/group/consolidate.d.ts +196 -0
  116. package/dist/group/consolidate.js +635 -0
  117. package/dist/group/disposal.d.ts +181 -0
  118. package/dist/group/disposal.js +233 -0
  119. package/dist/group/document.d.ts +41 -0
  120. package/dist/group/document.js +326 -0
  121. package/dist/group/index.d.ts +12 -0
  122. package/dist/group/index.js +12 -0
  123. package/dist/group/intercompany.d.ts +136 -0
  124. package/dist/group/intercompany.js +238 -0
  125. package/dist/group/interest.d.ts +109 -0
  126. package/dist/group/interest.js +224 -0
  127. package/dist/group/movement.d.ts +206 -0
  128. package/dist/group/movement.js +432 -0
  129. package/dist/group/structure.d.ts +151 -0
  130. package/dist/group/structure.js +327 -0
  131. package/dist/group/timeline.d.ts +109 -0
  132. package/dist/group/timeline.js +169 -0
  133. package/dist/group/unrealised.d.ts +165 -0
  134. package/dist/group/unrealised.js +314 -0
  135. package/dist/index.d.ts +10 -0
  136. package/dist/index.js +10 -0
  137. package/dist/ledger/close.d.ts +75 -0
  138. package/dist/ledger/close.js +142 -0
  139. package/dist/ledger/date.d.ts +48 -0
  140. package/dist/ledger/date.js +141 -0
  141. package/dist/ledger/entry.d.ts +150 -0
  142. package/dist/ledger/entry.js +264 -0
  143. package/dist/ledger/index.d.ts +6 -0
  144. package/dist/ledger/index.js +6 -0
  145. package/dist/ledger/ledger.d.ts +101 -0
  146. package/dist/ledger/ledger.js +308 -0
  147. package/dist/ledger/serialise.d.ts +68 -0
  148. package/dist/ledger/serialise.js +204 -0
  149. package/dist/ledger/trialBalance.d.ts +49 -0
  150. package/dist/ledger/trialBalance.js +103 -0
  151. package/dist/money/currency.d.ts +37 -0
  152. package/dist/money/currency.js +67 -0
  153. package/dist/money/index.d.ts +3 -0
  154. package/dist/money/index.js +3 -0
  155. package/dist/money/money.d.ts +106 -0
  156. package/dist/money/money.js +300 -0
  157. package/dist/money/rounding.d.ts +41 -0
  158. package/dist/money/rounding.js +117 -0
  159. package/dist/reconcile/accuracy.d.ts +65 -0
  160. package/dist/reconcile/accuracy.js +162 -0
  161. package/dist/reconcile/assignment.d.ts +83 -0
  162. package/dist/reconcile/assignment.js +326 -0
  163. package/dist/reconcile/bankView.d.ts +65 -0
  164. package/dist/reconcile/bankView.js +86 -0
  165. package/dist/reconcile/bridge.d.ts +76 -0
  166. package/dist/reconcile/bridge.js +129 -0
  167. package/dist/reconcile/candidates.d.ts +84 -0
  168. package/dist/reconcile/candidates.js +174 -0
  169. package/dist/reconcile/decisions.d.ts +116 -0
  170. package/dist/reconcile/decisions.js +168 -0
  171. package/dist/reconcile/index.d.ts +12 -0
  172. package/dist/reconcile/index.js +12 -0
  173. package/dist/reconcile/matcher.d.ts +89 -0
  174. package/dist/reconcile/matcher.js +306 -0
  175. package/dist/reconcile/memory.d.ts +120 -0
  176. package/dist/reconcile/memory.js +281 -0
  177. package/dist/reconcile/posting.d.ts +185 -0
  178. package/dist/reconcile/posting.js +481 -0
  179. package/dist/reconcile/scoring.d.ts +98 -0
  180. package/dist/reconcile/scoring.js +319 -0
  181. package/dist/reconcile/similarity.d.ts +88 -0
  182. package/dist/reconcile/similarity.js +272 -0
  183. package/dist/reconcile/subsetSum.d.ts +61 -0
  184. package/dist/reconcile/subsetSum.js +136 -0
  185. package/dist/reports/ageing.d.ts +75 -0
  186. package/dist/reports/ageing.js +149 -0
  187. package/dist/reports/balanceSheet.d.ts +57 -0
  188. package/dist/reports/balanceSheet.js +107 -0
  189. package/dist/reports/incomeStatement.d.ts +59 -0
  190. package/dist/reports/incomeStatement.js +132 -0
  191. package/dist/reports/index.d.ts +4 -0
  192. package/dist/reports/index.js +4 -0
  193. package/dist/reports/period.d.ts +37 -0
  194. package/dist/reports/period.js +78 -0
  195. package/dist/statement/amount.d.ts +55 -0
  196. package/dist/statement/amount.js +211 -0
  197. package/dist/statement/columns.d.ts +39 -0
  198. package/dist/statement/columns.js +221 -0
  199. package/dist/statement/csv.d.ts +50 -0
  200. package/dist/statement/csv.js +185 -0
  201. package/dist/statement/dates.d.ts +52 -0
  202. package/dist/statement/dates.js +204 -0
  203. package/dist/statement/duplicates.d.ts +51 -0
  204. package/dist/statement/duplicates.js +101 -0
  205. package/dist/statement/import.d.ts +52 -0
  206. package/dist/statement/import.js +172 -0
  207. package/dist/statement/index.d.ts +20 -0
  208. package/dist/statement/index.js +32 -0
  209. package/dist/statement/line.d.ts +60 -0
  210. package/dist/statement/line.js +73 -0
  211. package/dist/statement/ofx.d.ts +47 -0
  212. package/dist/statement/ofx.js +199 -0
  213. package/package.json +72 -4
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A single line from a bank statement.
3
+ *
4
+ * Note what this is *not*: it is not a journal entry. A statement line is
5
+ * someone else's record of what happened, and the whole reconciliation problem
6
+ * exists because it disagrees with ours. So it keeps its own raw text, its own
7
+ * sign convention (positive is money into the account), and the row it came
8
+ * from, so a match can be explained by pointing at the source.
9
+ */
10
+ import type { Money } from "../money/money.js";
11
+ import type { CalendarDate } from "../ledger/date.js";
12
+ export interface StatementLineInput {
13
+ id: string;
14
+ date: CalendarDate;
15
+ /** Where the money actually settled, when the export distinguishes the two. */
16
+ valueDate?: CalendarDate;
17
+ description: string;
18
+ amount: Money;
19
+ balance?: Money;
20
+ reference?: string;
21
+ type?: string;
22
+ /** Zero-based index in the source file, after the header. */
23
+ sourceRow: number;
24
+ raw?: Readonly<Record<string, string>>;
25
+ }
26
+ export interface StatementLine {
27
+ readonly id: string;
28
+ readonly date: CalendarDate;
29
+ readonly valueDate: CalendarDate | null;
30
+ readonly description: string;
31
+ /** Positive is money in, negative is money out. */
32
+ readonly amount: Money;
33
+ readonly balance: Money | null;
34
+ readonly reference: string | null;
35
+ readonly type: string | null;
36
+ readonly sourceRow: number;
37
+ readonly raw: Readonly<Record<string, string>>;
38
+ /** Description reduced to comparable form. */
39
+ readonly normalisedDescription: string;
40
+ /** Stable hash of date, amount and normalised description. */
41
+ readonly fingerprint: string;
42
+ }
43
+ /**
44
+ * Reduce a description to something comparable.
45
+ *
46
+ * Card descriptors carry a lot of noise that varies between otherwise
47
+ * identical transactions: terminal ids, dates baked into the text, the `SQ *`
48
+ * and `PAYPAL *` processor prefixes, and inconsistent spacing. Stripping them
49
+ * is what lets two records of one purchase look alike.
50
+ */
51
+ export declare function normaliseDescription(input: string): string;
52
+ /**
53
+ * A short, stable hash. FNV-1a: not cryptographic, but deterministic across
54
+ * runs and platforms, which is what a fingerprint needs to be.
55
+ */
56
+ export declare function hashString(input: string): string;
57
+ export declare function fingerprintOf(date: CalendarDate, amount: Money, normalisedDescription: string): string;
58
+ export declare function statementLine(input: StatementLineInput): StatementLine;
59
+ export declare function isMoneyIn(line: StatementLine): boolean;
60
+ export declare function isMoneyOut(line: StatementLine): boolean;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A single line from a bank statement.
3
+ *
4
+ * Note what this is *not*: it is not a journal entry. A statement line is
5
+ * someone else's record of what happened, and the whole reconciliation problem
6
+ * exists because it disagrees with ours. So it keeps its own raw text, its own
7
+ * sign convention (positive is money into the account), and the row it came
8
+ * from, so a match can be explained by pointing at the source.
9
+ */
10
+ /**
11
+ * Reduce a description to something comparable.
12
+ *
13
+ * Card descriptors carry a lot of noise that varies between otherwise
14
+ * identical transactions: terminal ids, dates baked into the text, the `SQ *`
15
+ * and `PAYPAL *` processor prefixes, and inconsistent spacing. Stripping them
16
+ * is what lets two records of one purchase look alike.
17
+ */
18
+ export function normaliseDescription(input) {
19
+ let text = input.toUpperCase();
20
+ // Processor prefixes: "SQ *COFFEE", "PAYPAL *ACME", "IZ *SHOP".
21
+ text = text.replace(/\b(SQ|PAYPAL|PP|IZ|TST|SP|WWW)\s*\*\s*/g, " ");
22
+ // Card-scheme noise.
23
+ text = text.replace(/\b(VISA|MASTERCARD|MAESTRO|DEBIT|CREDIT)\s+(CARD\s+)?(PAYMENT|PURCHASE)\b/g, " ");
24
+ text = text.replace(/\bCARD\s+(PAYMENT|PURCHASE)\b/g, " ");
25
+ text = text.replace(/\b(POS|ATM|CHQ|BGC|DD|SO|FPI|FPO|TFR)\b/g, " ");
26
+ text = text.replace(/\bON\s+\d{1,2}[-/ ][A-Z]{3}[-/ ]?\d{0,4}\b/g, " ");
27
+ // Dates and long digit runs baked into the descriptor.
28
+ text = text.replace(/\b\d{1,2}[-/.]\d{1,2}([-/.]\d{2,4})?\b/g, " ");
29
+ text = text.replace(/\b\d{6,}\b/g, " ");
30
+ // Trailing terminal/reference numbers: "COFFEE 4471".
31
+ text = text.replace(/\s+\d{2,6}\s*$/, " ");
32
+ text = text.replace(/[^A-Z0-9 ]+/g, " ");
33
+ text = text.replace(/\s+/g, " ").trim();
34
+ return text;
35
+ }
36
+ /**
37
+ * A short, stable hash. FNV-1a: not cryptographic, but deterministic across
38
+ * runs and platforms, which is what a fingerprint needs to be.
39
+ */
40
+ export function hashString(input) {
41
+ let hash = 0x811c9dc5;
42
+ for (let i = 0; i < input.length; i++) {
43
+ hash ^= input.charCodeAt(i);
44
+ hash = Math.imul(hash, 0x01000193) >>> 0;
45
+ }
46
+ return hash.toString(16).padStart(8, "0");
47
+ }
48
+ export function fingerprintOf(date, amount, normalisedDescription) {
49
+ return hashString(`${date}|${amount.currency.code}|${amount.minorUnits.toString()}|${normalisedDescription}`);
50
+ }
51
+ export function statementLine(input) {
52
+ const normalisedDescription = normaliseDescription(input.description);
53
+ return Object.freeze({
54
+ id: input.id,
55
+ date: input.date,
56
+ valueDate: input.valueDate ?? null,
57
+ description: input.description.trim(),
58
+ amount: input.amount,
59
+ balance: input.balance ?? null,
60
+ reference: input.reference?.trim() ?? null,
61
+ type: input.type?.trim() ?? null,
62
+ sourceRow: input.sourceRow,
63
+ raw: Object.freeze({ ...(input.raw ?? {}) }),
64
+ normalisedDescription,
65
+ fingerprint: fingerprintOf(input.date, input.amount, normalisedDescription),
66
+ });
67
+ }
68
+ export function isMoneyIn(line) {
69
+ return line.amount.isPositive;
70
+ }
71
+ export function isMoneyOut(line) {
72
+ return line.amount.isNegative;
73
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A reader for the subset of OFX/QFX that bank downloads actually contain.
3
+ *
4
+ * OFX is SGML, not XML: tags are frequently unclosed, so `<NAME>TESCO` runs to
5
+ * the end of the line. A strict XML parser fails on nearly every real file.
6
+ * This reads the aggregate structure directly, which is both simpler and more
7
+ * tolerant, and it only understands the handful of tags a bank statement uses.
8
+ *
9
+ * Not supported, deliberately: investment transactions, bill-pay aggregates
10
+ * and the OFX request/response envelope. Those are a different product.
11
+ */
12
+ import { Money } from "../money/money.js";
13
+ import type { Currency } from "../money/currency.js";
14
+ import { type CalendarDate } from "../ledger/date.js";
15
+ import type { RowError } from "./import.js";
16
+ import { type StatementLine } from "./line.js";
17
+ export interface OfxAccount {
18
+ readonly bankId: string | null;
19
+ readonly accountId: string | null;
20
+ readonly accountType: string | null;
21
+ readonly currency: Currency;
22
+ }
23
+ export interface OfxImportResult {
24
+ readonly lines: readonly StatementLine[];
25
+ readonly errors: readonly RowError[];
26
+ readonly warnings: readonly string[];
27
+ readonly account: OfxAccount;
28
+ readonly ledgerBalance: Money | null;
29
+ readonly balanceAsOf: CalendarDate | null;
30
+ readonly transactionCount: number;
31
+ }
32
+ /**
33
+ * Read an OFX datetime: `YYYYMMDD`, optionally with a time and a bracketed
34
+ * timezone, e.g. `20260814120000[-5:EST]`. Only the date survives — see the
35
+ * note in `ledger/date.ts` on why postings do not carry an instant.
36
+ */
37
+ export declare function parseOfxDate(raw: string): CalendarDate;
38
+ /** Split out every `<STMTTRN>` aggregate. */
39
+ export declare function extractTransactionBlocks(input: string): readonly string[];
40
+ export interface OfxImportOptions {
41
+ /** Fallback when the file does not declare one. */
42
+ currency?: Currency | string;
43
+ idPrefix?: string;
44
+ }
45
+ export declare function importOfx(input: string, options?: OfxImportOptions): OfxImportResult;
46
+ /** True when the text looks like OFX rather than CSV. */
47
+ export declare function looksLikeOfx(input: string): boolean;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * A reader for the subset of OFX/QFX that bank downloads actually contain.
3
+ *
4
+ * OFX is SGML, not XML: tags are frequently unclosed, so `<NAME>TESCO` runs to
5
+ * the end of the line. A strict XML parser fails on nearly every real file.
6
+ * This reads the aggregate structure directly, which is both simpler and more
7
+ * tolerant, and it only understands the handful of tags a bank statement uses.
8
+ *
9
+ * Not supported, deliberately: investment transactions, bill-pay aggregates
10
+ * and the OFX request/response envelope. Those are a different product.
11
+ */
12
+ import { Money } from "../money/money.js";
13
+ import { currency as lookupCurrency, isRegistered } from "../money/currency.js";
14
+ import { date as toCalendarDate } from "../ledger/date.js";
15
+ import { statementLine } from "./line.js";
16
+ /**
17
+ * Read an OFX datetime: `YYYYMMDD`, optionally with a time and a bracketed
18
+ * timezone, e.g. `20260814120000[-5:EST]`. Only the date survives — see the
19
+ * note in `ledger/date.ts` on why postings do not carry an instant.
20
+ */
21
+ export function parseOfxDate(raw) {
22
+ const text = raw.trim();
23
+ const match = /^(\d{4})(\d{2})(\d{2})/.exec(text);
24
+ if (match === null)
25
+ throw new RangeError(`Not an OFX date: ${JSON.stringify(raw)}`);
26
+ return toCalendarDate(`${match[1]}-${match[2]}-${match[3]}`);
27
+ }
28
+ /** Pull the value of an unclosed-or-closed SGML tag from one aggregate. */
29
+ function tagValue(block, tag) {
30
+ const pattern = new RegExp(`<${tag}>([^<\\r\\n]*)`, "i");
31
+ const match = pattern.exec(block);
32
+ if (match === null)
33
+ return null;
34
+ const value = (match[1] ?? "").trim();
35
+ return value === "" ? null : value;
36
+ }
37
+ function decodeEntities(input) {
38
+ return input
39
+ .replace(/&amp;/gi, "&")
40
+ .replace(/&lt;/gi, "<")
41
+ .replace(/&gt;/gi, ">")
42
+ .replace(/&quot;/gi, '"')
43
+ .replace(/&apos;/gi, "'")
44
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
45
+ }
46
+ /** Split out every `<STMTTRN>` aggregate. */
47
+ export function extractTransactionBlocks(input) {
48
+ const blocks = [];
49
+ const pattern = /<STMTTRN>([\s\S]*?)(?:<\/STMTTRN>|(?=<STMTTRN>)|$)/gi;
50
+ let match;
51
+ while ((match = pattern.exec(input)) !== null) {
52
+ const body = match[1] ?? "";
53
+ if (body.trim() !== "")
54
+ blocks.push(body);
55
+ }
56
+ return blocks;
57
+ }
58
+ export function importOfx(input, options = {}) {
59
+ const text = input.replace(/\r\n/g, "\n");
60
+ const warnings = [];
61
+ const errors = [];
62
+ const declared = tagValue(text, "CURDEF");
63
+ const fallback = typeof options.currency === "string"
64
+ ? lookupCurrency(options.currency)
65
+ : (options.currency ?? lookupCurrency("GBP"));
66
+ let currency = fallback;
67
+ if (declared !== null) {
68
+ if (isRegistered(declared)) {
69
+ currency = lookupCurrency(declared);
70
+ }
71
+ else {
72
+ warnings.push(`File declares unknown currency ${declared}; using ${fallback.code}`);
73
+ }
74
+ }
75
+ else {
76
+ warnings.push(`File declares no currency; assuming ${fallback.code}`);
77
+ }
78
+ const account = Object.freeze({
79
+ bankId: tagValue(text, "BANKID"),
80
+ accountId: tagValue(text, "ACCTID"),
81
+ accountType: tagValue(text, "ACCTTYPE"),
82
+ currency,
83
+ });
84
+ const blocks = extractTransactionBlocks(text);
85
+ const prefix = options.idPrefix ?? "OFX";
86
+ const lines = [];
87
+ blocks.forEach((block, index) => {
88
+ const rawDate = tagValue(block, "DTPOSTED");
89
+ const rawAmount = tagValue(block, "TRNAMT");
90
+ if (rawDate === null) {
91
+ errors.push({ row: index, reason: "transaction has no DTPOSTED", cells: [block.trim()] });
92
+ return;
93
+ }
94
+ if (rawAmount === null) {
95
+ errors.push({ row: index, reason: "transaction has no TRNAMT", cells: [block.trim()] });
96
+ return;
97
+ }
98
+ let when;
99
+ try {
100
+ when = parseOfxDate(rawDate);
101
+ }
102
+ catch (error) {
103
+ errors.push({
104
+ row: index,
105
+ reason: error instanceof Error ? error.message : String(error),
106
+ cells: [block.trim()],
107
+ });
108
+ return;
109
+ }
110
+ let amount;
111
+ try {
112
+ // OFX mandates a dot decimal and no grouping, and signs the amount
113
+ // itself: negative is money out, which matches our convention already.
114
+ amount = Money.parse(rawAmount.replace(/,/g, ""), currency);
115
+ }
116
+ catch (error) {
117
+ errors.push({
118
+ row: index,
119
+ reason: error instanceof Error ? error.message : String(error),
120
+ cells: [block.trim()],
121
+ });
122
+ return;
123
+ }
124
+ const name = tagValue(block, "NAME");
125
+ const memo = tagValue(block, "MEMO");
126
+ const payee = tagValue(block, "PAYEE");
127
+ const description = decodeEntities([payee, name, memo].filter((v) => v !== null).join(" — "));
128
+ const fitId = tagValue(block, "FITID");
129
+ const checkNum = tagValue(block, "CHECKNUM");
130
+ const type = tagValue(block, "TRNTYPE");
131
+ let valueDate;
132
+ const rawValueDate = tagValue(block, "DTAVAIL");
133
+ if (rawValueDate !== null) {
134
+ try {
135
+ valueDate = parseOfxDate(rawValueDate);
136
+ }
137
+ catch {
138
+ warnings.push(`Transaction ${index}: could not read DTAVAIL`);
139
+ }
140
+ }
141
+ const raw = {};
142
+ for (const tag of ["FITID", "TRNTYPE", "DTPOSTED", "TRNAMT", "NAME", "MEMO", "CHECKNUM"]) {
143
+ const value = tagValue(block, tag);
144
+ if (value !== null)
145
+ raw[tag] = value;
146
+ }
147
+ lines.push(statementLine({
148
+ // FITID is the bank's own unique id and is the right thing to key on
149
+ // when present, because it survives a re-download of the same period.
150
+ id: fitId !== null ? `${prefix}-${fitId}` : `${prefix}-${String(index + 1).padStart(4, "0")}`,
151
+ date: when,
152
+ ...(valueDate === undefined ? {} : { valueDate }),
153
+ description,
154
+ amount,
155
+ ...(checkNum === null ? {} : { reference: checkNum }),
156
+ ...(type === null ? {} : { type }),
157
+ sourceRow: index,
158
+ raw,
159
+ }));
160
+ });
161
+ let ledgerBalance = null;
162
+ let balanceAsOf = null;
163
+ const ledgerBalanceBlock = /<LEDGERBAL>([\s\S]*?)(?:<\/LEDGERBAL>|$)/i.exec(text);
164
+ if (ledgerBalanceBlock !== null) {
165
+ const body = ledgerBalanceBlock[1] ?? "";
166
+ const rawBalance = tagValue(body, "BALAMT");
167
+ if (rawBalance !== null) {
168
+ try {
169
+ ledgerBalance = Money.parse(rawBalance.replace(/,/g, ""), currency);
170
+ }
171
+ catch {
172
+ warnings.push(`Could not read LEDGERBAL BALAMT ${JSON.stringify(rawBalance)}`);
173
+ }
174
+ }
175
+ const rawAsOf = tagValue(body, "DTASOF");
176
+ if (rawAsOf !== null) {
177
+ try {
178
+ balanceAsOf = parseOfxDate(rawAsOf);
179
+ }
180
+ catch {
181
+ warnings.push("Could not read LEDGERBAL DTASOF");
182
+ }
183
+ }
184
+ }
185
+ return Object.freeze({
186
+ lines: Object.freeze(lines),
187
+ errors: Object.freeze(errors),
188
+ warnings: Object.freeze(warnings),
189
+ account,
190
+ ledgerBalance,
191
+ balanceAsOf,
192
+ transactionCount: blocks.length,
193
+ });
194
+ }
195
+ /** True when the text looks like OFX rather than CSV. */
196
+ export function looksLikeOfx(input) {
197
+ const head = input.slice(0, 4096).toUpperCase();
198
+ return head.includes("<OFX>") || head.includes("OFXHEADER") || head.includes("<STMTTRN>");
199
+ }
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "tallyd",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Double-entry ledger and bank-reconciliation engine",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Daniyal",
8
+ "keywords": [
9
+ "accounting",
10
+ "double-entry",
11
+ "ledger",
12
+ "bookkeeping",
13
+ "reconciliation",
14
+ "bank-statement",
15
+ "trial-balance",
16
+ "typescript"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/DaniyalMlk/tallyd.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/DaniyalMlk/tallyd/issues"
24
+ },
25
+ "homepage": "https://github.com/DaniyalMlk/tallyd#readme",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ }
33
+ },
34
+ "bin": {
35
+ "tallyd": "dist/cli/main.js"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "engines": {
43
+ "node": ">=20.19.0"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json",
50
+ "prepack": "npm run build",
51
+ "typecheck": "tsc -p tsconfig.json --noEmit",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest",
54
+ "demo": "vite-node src/demo/main.ts",
55
+ "demo:ingest": "vite-node src/demo/mainIngest.ts",
56
+ "demo:reconcile": "vite-node src/demo/mainReconcile.ts",
57
+ "demo:reports": "vite-node src/demo/mainStatements.ts",
58
+ "demo:foreign": "vite-node src/demo/mainForeign.ts",
59
+ "demo:group": "vite-node src/demo/mainGroup.ts",
60
+ "demo:periods": "vite-node src/demo/mainGroupPeriods.ts",
61
+ "demo:midyear": "vite-node src/demo/mainMidYear.ts",
62
+ "demo:disposal": "vite-node src/demo/mainDisposal.ts",
63
+ "demo:evidence": "vite-node src/demo/mainEvidence.ts",
64
+ "cli": "vite-node src/cli/main.ts --",
65
+ "bench": "vite-node src/cli/main.ts -- bench",
66
+ "generate": "vite-node src/cli/main.ts -- generate"
67
+ },
68
+ "devDependencies": {
69
+ "@types/node": "^22.10.2",
70
+ "fast-check": "^3.23.2",
71
+ "typescript": "^5.7.2",
72
+ "vitest": "^2.1.8"
73
+ }
74
+ }