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,204 @@
1
+ /**
2
+ * Reading the date column.
3
+ *
4
+ * `03/04/2026` is the third of April in London and the fourth of March in New
5
+ * York, and a bank export rarely says which it means. Guessing per row is the
6
+ * worst option: it produces a statement where some rows are right and some are
7
+ * silently six weeks out.
8
+ *
9
+ * So the format is decided once for the whole column. A single value with a
10
+ * component above 12 settles it for every other row. Where the column is
11
+ * genuinely ambiguous — every day-of-month is 12 or under — the caller is told
12
+ * so and can insist on a format rather than accept a coin flip.
13
+ */
14
+ import { date as toCalendarDate, daysInMonth } from "../ledger/date.js";
15
+ export const DATE_FORMATS = [
16
+ "YYYY-MM-DD",
17
+ "YYYY/MM/DD",
18
+ "DD/MM/YYYY",
19
+ "MM/DD/YYYY",
20
+ "DD-MM-YYYY",
21
+ "MM-DD-YYYY",
22
+ "DD.MM.YYYY",
23
+ "DD-MMM-YYYY",
24
+ "MMM DD, YYYY",
25
+ ];
26
+ export class DateParseError extends Error {
27
+ input;
28
+ format;
29
+ constructor(input, format) {
30
+ super(format === null
31
+ ? `Cannot read ${JSON.stringify(input)} as a date`
32
+ : `Cannot read ${JSON.stringify(input)} as ${format}`);
33
+ this.input = input;
34
+ this.format = format;
35
+ this.name = "DateParseError";
36
+ }
37
+ }
38
+ const MONTH_NAMES = {
39
+ jan: 1,
40
+ feb: 2,
41
+ mar: 3,
42
+ apr: 4,
43
+ may: 5,
44
+ jun: 6,
45
+ jul: 7,
46
+ aug: 8,
47
+ sep: 9,
48
+ sept: 9,
49
+ oct: 10,
50
+ nov: 11,
51
+ dec: 12,
52
+ };
53
+ function pad(value, width) {
54
+ return String(value).padStart(width, "0");
55
+ }
56
+ /** Expand a two-digit year with the usual 1970 pivot. */
57
+ function expandYear(year) {
58
+ if (year >= 100)
59
+ return year;
60
+ return year < 70 ? 2000 + year : 1900 + year;
61
+ }
62
+ function componentsFor(input, format) {
63
+ const text = input.trim();
64
+ if (format === "DD-MMM-YYYY") {
65
+ const m = /^(\d{1,2})[-\s]([A-Za-z]{3,4})[-\s](\d{2,4})$/.exec(text);
66
+ if (m === null)
67
+ return null;
68
+ const month = MONTH_NAMES[m[2].toLowerCase()];
69
+ if (month === undefined)
70
+ return null;
71
+ return { year: expandYear(Number(m[3])), month, day: Number(m[1]) };
72
+ }
73
+ if (format === "MMM DD, YYYY") {
74
+ const m = /^([A-Za-z]{3,4})\.?\s+(\d{1,2}),?\s+(\d{2,4})$/.exec(text);
75
+ if (m === null)
76
+ return null;
77
+ const month = MONTH_NAMES[m[1].toLowerCase()];
78
+ if (month === undefined)
79
+ return null;
80
+ return { year: expandYear(Number(m[3])), month, day: Number(m[2]) };
81
+ }
82
+ const separator = format.includes("/") ? "/" : format.includes(".") ? "." : "-";
83
+ const parts = text.split(separator);
84
+ if (parts.length !== 3)
85
+ return null;
86
+ if (parts.some((p) => !/^\d{1,4}$/.test(p.trim())))
87
+ return null;
88
+ const [a, b, c] = parts.map((p) => Number(p.trim()));
89
+ switch (format) {
90
+ case "YYYY-MM-DD":
91
+ case "YYYY/MM/DD":
92
+ return { year: a, month: b, day: c };
93
+ case "DD/MM/YYYY":
94
+ case "DD-MM-YYYY":
95
+ case "DD.MM.YYYY":
96
+ return { year: expandYear(c), month: b, day: a };
97
+ case "MM/DD/YYYY":
98
+ case "MM-DD-YYYY":
99
+ return { year: expandYear(c), month: a, day: b };
100
+ default:
101
+ return null;
102
+ }
103
+ }
104
+ function isRealDate(c) {
105
+ if (c.year < 1000 || c.year > 9999)
106
+ return false;
107
+ if (c.month < 1 || c.month > 12)
108
+ return false;
109
+ if (c.day < 1 || c.day > daysInMonth(c.year, c.month))
110
+ return false;
111
+ return true;
112
+ }
113
+ /** Parse one value in a known format. */
114
+ export function parseDateIn(input, format) {
115
+ const components = componentsFor(input, format);
116
+ if (components === null || !isRealDate(components)) {
117
+ throw new DateParseError(input, format);
118
+ }
119
+ return toCalendarDate(`${pad(components.year, 4)}-${pad(components.month, 2)}-${pad(components.day, 2)}`);
120
+ }
121
+ export function canParseIn(input, format) {
122
+ const components = componentsFor(input, format);
123
+ return components !== null && isRealDate(components);
124
+ }
125
+ /**
126
+ * Share of a column a format must read to be considered at all. Below this it
127
+ * is the wrong format rather than the right one with bad rows in it.
128
+ */
129
+ const MINIMUM_COVERAGE = 0.8;
130
+ /**
131
+ * Work out which format a column of dates is in.
132
+ *
133
+ * A format survives only if it reads *every* sample. Where both DD/MM and
134
+ * MM/DD survive, the column contains no value with a component above 12 and
135
+ * the order is genuinely undecidable from the data alone.
136
+ */
137
+ export function detectDateFormat(samples, options = {}) {
138
+ const usable = samples.map((s) => s.trim()).filter((s) => s !== "");
139
+ if (usable.length === 0) {
140
+ return {
141
+ format: options.prefer ?? "YYYY-MM-DD",
142
+ candidates: [],
143
+ confident: false,
144
+ unparsed: [],
145
+ };
146
+ }
147
+ // A format need not read *every* sample: one impossible value like 31/02
148
+ // should not disqualify the format the other four hundred rows are in. But
149
+ // it must read a strong majority, or it is simply the wrong format.
150
+ const coverage = DATE_FORMATS.map((format) => ({
151
+ format,
152
+ read: usable.filter((sample) => canParseIn(sample, format)).length,
153
+ }));
154
+ const best = Math.max(...coverage.map((c) => c.read));
155
+ const threshold = Math.max(1, Math.ceil(usable.length * MINIMUM_COVERAGE));
156
+ const candidates = best >= threshold
157
+ ? coverage.filter((c) => c.read === best).map((c) => c.format)
158
+ : [];
159
+ if (candidates.length === 0) {
160
+ return {
161
+ format: options.prefer ?? "YYYY-MM-DD",
162
+ candidates: [],
163
+ confident: false,
164
+ unparsed: usable.filter((s) => !DATE_FORMATS.some((f) => canParseIn(s, f))),
165
+ };
166
+ }
167
+ const unreadable = usable.filter((s) => !candidates.some((f) => canParseIn(s, f)));
168
+ // Prefer the caller's format when it is still viable — this is how a user
169
+ // resolves an ambiguous column.
170
+ const preferred = options.prefer;
171
+ if (preferred !== undefined && candidates.includes(preferred)) {
172
+ return { format: preferred, candidates, confident: true, unparsed: unreadable };
173
+ }
174
+ // Day-first and month-first both surviving means the data cannot tell them
175
+ // apart; the ordering of DATE_FORMATS then decides, and confidence is false.
176
+ const dayFirst = candidates.filter((f) => f.startsWith("DD"));
177
+ const monthFirst = candidates.filter((f) => f.startsWith("MM"));
178
+ const ambiguous = dayFirst.length > 0 && monthFirst.length > 0;
179
+ return {
180
+ format: candidates[0],
181
+ candidates,
182
+ confident: !ambiguous,
183
+ unparsed: unreadable,
184
+ };
185
+ }
186
+ /** Parse a column, detecting the format from the column itself. */
187
+ export function parseDateColumn(values, options = {}) {
188
+ const detection = detectDateFormat(values, options);
189
+ const dates = values.map((value) => {
190
+ if (value.trim() === "")
191
+ return null;
192
+ try {
193
+ return parseDateIn(value, detection.format);
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ });
199
+ return { dates, detection };
200
+ }
201
+ /** True when a string parses as a date in any supported format. */
202
+ export function looksLikeDate(input) {
203
+ return DATE_FORMATS.some((format) => canParseIn(input, format));
204
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Duplicate detection on import.
3
+ *
4
+ * The naive rule — same date, same amount, same description means duplicate —
5
+ * is wrong, and wrong in a way that loses money. Two £2.85 coffees from the
6
+ * same shop on the same day are two transactions, not one imported twice.
7
+ *
8
+ * So the rule here is about *counts*, not existence: a batch that contains a
9
+ * fingerprint three times is fine if the account already has none of it, and
10
+ * is a duplicate only for the excess over what is already known. Everything is
11
+ * flagged for review rather than dropped, because only the account holder can
12
+ * say whether they bought two coffees.
13
+ */
14
+ import type { StatementLine } from "./line.js";
15
+ export type DuplicateKind =
16
+ /** Same fingerprint as an earlier line in this same import. */
17
+ "within-batch"
18
+ /** Same fingerprint as a line already imported previously. */
19
+ | "already-imported";
20
+ export interface DuplicateFlag {
21
+ readonly line: StatementLine;
22
+ readonly kind: DuplicateKind;
23
+ /** Which occurrence this is, 1-based, counting prior sightings. */
24
+ readonly occurrence: number;
25
+ readonly reason: string;
26
+ /** Lines that share the fingerprint. */
27
+ readonly conflictsWith: readonly StatementLine[];
28
+ }
29
+ export interface DuplicateReport {
30
+ readonly unique: readonly StatementLine[];
31
+ readonly flagged: readonly DuplicateFlag[];
32
+ }
33
+ /**
34
+ * Compare a batch against itself and against lines already held.
35
+ *
36
+ * `existing` is the set already imported; its counts are what a new line has
37
+ * to exceed before it is considered a repeat.
38
+ */
39
+ export declare function findDuplicates(batch: readonly StatementLine[], existing?: readonly StatementLine[]): DuplicateReport;
40
+ /**
41
+ * A softer signal: lines that are suspiciously similar without being identical
42
+ * — same amount and description within a few days. Re-submitted payments and
43
+ * accidental double-imports of overlapping date ranges look like this.
44
+ */
45
+ export declare function findNearDuplicates(lines: readonly StatementLine[], options?: {
46
+ windowDays?: number;
47
+ }): readonly {
48
+ a: StatementLine;
49
+ b: StatementLine;
50
+ daysApart: number;
51
+ }[];
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Duplicate detection on import.
3
+ *
4
+ * The naive rule — same date, same amount, same description means duplicate —
5
+ * is wrong, and wrong in a way that loses money. Two £2.85 coffees from the
6
+ * same shop on the same day are two transactions, not one imported twice.
7
+ *
8
+ * So the rule here is about *counts*, not existence: a batch that contains a
9
+ * fingerprint three times is fine if the account already has none of it, and
10
+ * is a duplicate only for the excess over what is already known. Everything is
11
+ * flagged for review rather than dropped, because only the account holder can
12
+ * say whether they bought two coffees.
13
+ */
14
+ import { daysBetween } from "../ledger/date.js";
15
+ /**
16
+ * Compare a batch against itself and against lines already held.
17
+ *
18
+ * `existing` is the set already imported; its counts are what a new line has
19
+ * to exceed before it is considered a repeat.
20
+ */
21
+ export function findDuplicates(batch, existing = []) {
22
+ const existingByFingerprint = new Map();
23
+ for (const line of existing) {
24
+ const bucket = existingByFingerprint.get(line.fingerprint);
25
+ if (bucket === undefined)
26
+ existingByFingerprint.set(line.fingerprint, [line]);
27
+ else
28
+ bucket.push(line);
29
+ }
30
+ const seenInBatch = new Map();
31
+ const unique = [];
32
+ const flagged = [];
33
+ for (const line of batch) {
34
+ const priorInBatch = seenInBatch.get(line.fingerprint) ?? [];
35
+ const priorExisting = existingByFingerprint.get(line.fingerprint) ?? [];
36
+ if (priorExisting.length > 0 && priorInBatch.length === 0) {
37
+ flagged.push({
38
+ line,
39
+ kind: "already-imported",
40
+ occurrence: priorExisting.length + 1,
41
+ reason: `A line with the same date, amount and description was already imported ` +
42
+ `(${priorExisting.length} time${priorExisting.length === 1 ? "" : "s"})`,
43
+ conflictsWith: Object.freeze([...priorExisting]),
44
+ });
45
+ }
46
+ else if (priorInBatch.length > 0) {
47
+ flagged.push({
48
+ line,
49
+ kind: "within-batch",
50
+ occurrence: priorInBatch.length + priorExisting.length + 1,
51
+ reason: `Row ${line.sourceRow} repeats row ${priorInBatch[0].sourceRow} ` +
52
+ `— same date, amount and description`,
53
+ conflictsWith: Object.freeze([...priorInBatch, ...priorExisting]),
54
+ });
55
+ }
56
+ else {
57
+ unique.push(line);
58
+ }
59
+ seenInBatch.set(line.fingerprint, [...priorInBatch, line]);
60
+ }
61
+ return Object.freeze({
62
+ unique: Object.freeze(unique),
63
+ flagged: Object.freeze(flagged),
64
+ });
65
+ }
66
+ /**
67
+ * A softer signal: lines that are suspiciously similar without being identical
68
+ * — same amount and description within a few days. Re-submitted payments and
69
+ * accidental double-imports of overlapping date ranges look like this.
70
+ */
71
+ export function findNearDuplicates(lines, options = {}) {
72
+ const window = options.windowDays ?? 3;
73
+ const out = [];
74
+ const byKey = new Map();
75
+ for (const line of lines) {
76
+ const key = `${line.amount.currency.code}|${line.amount.minorUnits}|${line.normalisedDescription}`;
77
+ const bucket = byKey.get(key);
78
+ if (bucket === undefined)
79
+ byKey.set(key, [line]);
80
+ else
81
+ bucket.push(line);
82
+ }
83
+ for (const bucket of byKey.values()) {
84
+ if (bucket.length < 2)
85
+ continue;
86
+ const sorted = [...bucket].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
87
+ for (let i = 0; i < sorted.length - 1; i++) {
88
+ for (let j = i + 1; j < sorted.length; j++) {
89
+ const a = sorted[i];
90
+ const b = sorted[j];
91
+ const daysApart = Math.abs(daysBetween(a.date, b.date));
92
+ if (daysApart === 0)
93
+ continue; // exact duplicates are the other function's job
94
+ if (daysApart > window)
95
+ break;
96
+ out.push({ a, b, daysApart });
97
+ }
98
+ }
99
+ }
100
+ return Object.freeze(out);
101
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The CSV import pipeline.
3
+ *
4
+ * One malformed row must not lose the other four hundred. Every row is
5
+ * attempted independently and failures are collected as data, so the caller
6
+ * gets the lines that worked plus a precise account of what did not and why.
7
+ * An import that half-succeeds and says so is worth far more than one that
8
+ * throws on row 137.
9
+ */
10
+ import type { Currency } from "../money/currency.js";
11
+ import { type ParseAmountOptions } from "./amount.js";
12
+ import { type ColumnMapping, type ColumnRole } from "./columns.js";
13
+ import { type Delimiter } from "./csv.js";
14
+ import { type DateFormat } from "./dates.js";
15
+ import { type DuplicateFlag } from "./duplicates.js";
16
+ import { type StatementLine } from "./line.js";
17
+ export interface RowError {
18
+ /** Zero-based index within the data rows, excluding the header. */
19
+ readonly row: number;
20
+ readonly reason: string;
21
+ readonly cells: readonly string[];
22
+ }
23
+ export interface ImportOptions {
24
+ currency?: Currency | string;
25
+ delimiter?: Delimiter;
26
+ dateFormat?: DateFormat;
27
+ decimal?: "dot" | "comma";
28
+ /** Force a column assignment when inference gets it wrong. */
29
+ columns?: Partial<Record<Exclude<ColumnRole, "unknown">, number>>;
30
+ /** Lines already imported, for duplicate detection. */
31
+ existing?: readonly StatementLine[];
32
+ /** Prefix for generated line ids. */
33
+ idPrefix?: string;
34
+ /** Accept amounts with more precision than the currency holds. */
35
+ rounding?: ParseAmountOptions["rounding"];
36
+ }
37
+ export interface ImportResult {
38
+ readonly lines: readonly StatementLine[];
39
+ readonly duplicates: readonly DuplicateFlag[];
40
+ readonly errors: readonly RowError[];
41
+ readonly warnings: readonly string[];
42
+ readonly mapping: ColumnMapping;
43
+ readonly delimiter: Delimiter;
44
+ readonly dateFormat: DateFormat;
45
+ readonly decimal: "dot" | "comma";
46
+ readonly currency: Currency;
47
+ readonly rowsRead: number;
48
+ }
49
+ /** Parse a bank CSV into statement lines. */
50
+ export declare function importCsv(input: string, options?: ImportOptions): ImportResult;
51
+ /** A one-line summary of how an import went, for the CLI and for logs. */
52
+ export declare function describeImport(result: ImportResult): string;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The CSV import pipeline.
3
+ *
4
+ * One malformed row must not lose the other four hundred. Every row is
5
+ * attempted independently and failures are collected as data, so the caller
6
+ * gets the lines that worked plus a precise account of what did not and why.
7
+ * An import that half-succeeds and says so is worth far more than one that
8
+ * throws on row 137.
9
+ */
10
+ import { Money } from "../money/money.js";
11
+ import { currency as lookupCurrency } from "../money/currency.js";
12
+ import { combineDebitCredit, detectDecimalConvention, parseAmount, } from "./amount.js";
13
+ import { inferColumns } from "./columns.js";
14
+ import { readTable } from "./csv.js";
15
+ import { detectDateFormat, parseDateIn } from "./dates.js";
16
+ import { findDuplicates } from "./duplicates.js";
17
+ import { statementLine } from "./line.js";
18
+ function columnValue(row, index) {
19
+ if (index === null)
20
+ return "";
21
+ return row[index] ?? "";
22
+ }
23
+ /** Parse a bank CSV into statement lines. */
24
+ export function importCsv(input, options = {}) {
25
+ const table = readTable(input, options.delimiter === undefined ? {} : { delimiter: options.delimiter });
26
+ const mapping = inferColumns(table.header, table.rows, options.columns ?? {});
27
+ const warnings = [...mapping.warnings];
28
+ const errors = [];
29
+ const currency = typeof options.currency === "string"
30
+ ? lookupCurrency(options.currency)
31
+ : (options.currency ?? lookupCurrency("GBP"));
32
+ // Decide the date format and decimal convention from whole columns before
33
+ // touching any individual row.
34
+ const dateSamples = table.rows.map((r) => columnValue(r, mapping.date)).filter((v) => v !== "");
35
+ const dateDetection = detectDateFormat(dateSamples, options.dateFormat === undefined ? {} : { prefer: options.dateFormat });
36
+ if (!dateDetection.confident && dateSamples.length > 0) {
37
+ if (dateDetection.candidates.length > 1) {
38
+ warnings.push(`Date column is ambiguous between ${dateDetection.candidates.join(" and ")}; ` +
39
+ `reading it as ${dateDetection.format}. Pass dateFormat to be sure.`);
40
+ }
41
+ else if (dateDetection.candidates.length === 0) {
42
+ warnings.push(`No date format reads every value in the date column` +
43
+ (dateDetection.unparsed.length > 0
44
+ ? ` (for example ${JSON.stringify(dateDetection.unparsed[0])})`
45
+ : ""));
46
+ }
47
+ }
48
+ const amountColumns = [mapping.amount, mapping.debit, mapping.credit].filter((i) => i !== null);
49
+ const amountSamples = table.rows.flatMap((r) => amountColumns.map((i) => r[i] ?? "").filter((v) => v.trim() !== ""));
50
+ const decimalDetection = detectDecimalConvention(amountSamples);
51
+ const decimal = options.decimal ?? decimalDetection.convention;
52
+ if (options.decimal === undefined && !decimalDetection.confident && amountSamples.length > 0) {
53
+ warnings.push(`Could not tell whether amounts use '.' or ',' as the decimal separator; ` +
54
+ `assuming '${decimal === "dot" ? "." : ","}'.`);
55
+ }
56
+ const amountOptions = {
57
+ convention: decimal,
58
+ ...(options.rounding === undefined ? {} : { rounding: options.rounding }),
59
+ };
60
+ const prefix = options.idPrefix ?? "SL";
61
+ const parsed = [];
62
+ table.rows.forEach((row, index) => {
63
+ const rawDate = columnValue(row, mapping.date).trim();
64
+ if (rawDate === "") {
65
+ errors.push({ row: index, reason: "no date", cells: [...row] });
66
+ return;
67
+ }
68
+ let when;
69
+ try {
70
+ when = parseDateIn(rawDate, dateDetection.format);
71
+ }
72
+ catch {
73
+ errors.push({
74
+ row: index,
75
+ reason: `date ${JSON.stringify(rawDate)} is not ${dateDetection.format}`,
76
+ cells: [...row],
77
+ });
78
+ return;
79
+ }
80
+ let amount;
81
+ try {
82
+ if (mapping.amount !== null) {
83
+ const rawAmount = columnValue(row, mapping.amount).trim();
84
+ if (rawAmount === "")
85
+ throw new Error("amount cell is empty");
86
+ amount = parseAmount(rawAmount, currency, amountOptions);
87
+ }
88
+ else if (mapping.debit !== null && mapping.credit !== null) {
89
+ amount = combineDebitCredit(columnValue(row, mapping.debit), columnValue(row, mapping.credit), currency, amountOptions);
90
+ }
91
+ else {
92
+ throw new Error("no amount, debit or credit column");
93
+ }
94
+ }
95
+ catch (error) {
96
+ errors.push({
97
+ row: index,
98
+ reason: error instanceof Error ? error.message : String(error),
99
+ cells: [...row],
100
+ });
101
+ return;
102
+ }
103
+ let balance;
104
+ if (mapping.balance !== null) {
105
+ const rawBalance = columnValue(row, mapping.balance).trim();
106
+ if (rawBalance !== "") {
107
+ try {
108
+ balance = parseAmount(rawBalance, currency, amountOptions);
109
+ }
110
+ catch {
111
+ // A bad balance is not worth losing the transaction over; it is
112
+ // informational, and its absence is noted rather than fatal.
113
+ warnings.push(`Row ${index}: could not read the balance ${JSON.stringify(rawBalance)}`);
114
+ }
115
+ }
116
+ }
117
+ let valueDate;
118
+ if (mapping.valueDate !== null) {
119
+ const rawValueDate = columnValue(row, mapping.valueDate).trim();
120
+ if (rawValueDate !== "") {
121
+ try {
122
+ valueDate = parseDateIn(rawValueDate, dateDetection.format);
123
+ }
124
+ catch {
125
+ warnings.push(`Row ${index}: could not read the value date`);
126
+ }
127
+ }
128
+ }
129
+ const raw = {};
130
+ table.header.forEach((name, i) => {
131
+ if (name !== "")
132
+ raw[name] = row[i] ?? "";
133
+ });
134
+ const description = columnValue(row, mapping.description).trim();
135
+ const reference = columnValue(row, mapping.reference).trim();
136
+ const type = columnValue(row, mapping.type).trim();
137
+ parsed.push(statementLine({
138
+ id: `${prefix}-${String(index + 1).padStart(4, "0")}`,
139
+ date: when,
140
+ ...(valueDate === undefined ? {} : { valueDate }),
141
+ description,
142
+ amount,
143
+ ...(balance === undefined ? {} : { balance }),
144
+ ...(reference === "" ? {} : { reference }),
145
+ ...(type === "" ? {} : { type }),
146
+ sourceRow: index,
147
+ raw,
148
+ }));
149
+ });
150
+ const { unique, flagged } = findDuplicates(parsed, options.existing ?? []);
151
+ return Object.freeze({
152
+ lines: Object.freeze(unique),
153
+ duplicates: Object.freeze(flagged),
154
+ errors: Object.freeze(errors),
155
+ warnings: Object.freeze(warnings),
156
+ mapping,
157
+ delimiter: table.delimiter,
158
+ dateFormat: dateDetection.format,
159
+ decimal,
160
+ currency,
161
+ rowsRead: table.rows.length,
162
+ });
163
+ }
164
+ /** A one-line summary of how an import went, for the CLI and for logs. */
165
+ export function describeImport(result) {
166
+ const parts = [
167
+ `${result.lines.length} line${result.lines.length === 1 ? "" : "s"}`,
168
+ `${result.duplicates.length} duplicate${result.duplicates.length === 1 ? "" : "s"}`,
169
+ `${result.errors.length} error${result.errors.length === 1 ? "" : "s"}`,
170
+ ];
171
+ return `${parts.join(", ")} from ${result.rowsRead} rows (${result.dateFormat}, '${result.decimal === "dot" ? "." : ","}' decimal, ${result.currency.code})`;
172
+ }
@@ -0,0 +1,20 @@
1
+ export { type Delimiter, DELIMITERS, CsvParseError, stripBom, parseCsv, sniffDelimiter, readTable, type CsvTable, } from "./csv.js";
2
+ export { type DecimalConvention, type ParseAmountOptions, AmountParseError, detectDecimalConvention, normaliseAmount, parseAmount, combineDebitCredit, looksLikeAmount, } from "./amount.js";
3
+ export { type DateFormat, type DateFormatDetection, DATE_FORMATS, DateParseError, parseDateIn, canParseIn, detectDateFormat, parseDateColumn, looksLikeDate, } from "./dates.js";
4
+ export { type ColumnRole, type ColumnAssignment, type ColumnMapping, inferColumns, } from "./columns.js";
5
+ export { type StatementLine, type StatementLineInput, statementLine, normaliseDescription, hashString, fingerprintOf, isMoneyIn, isMoneyOut, } from "./line.js";
6
+ export { type DuplicateKind, type DuplicateFlag, type DuplicateReport, findDuplicates, findNearDuplicates, } from "./duplicates.js";
7
+ export { type ImportOptions, type ImportResult, type RowError, importCsv, describeImport, } from "./import.js";
8
+ export { type OfxAccount, type OfxImportOptions, type OfxImportResult, importOfx, parseOfxDate, extractTransactionBlocks, looksLikeOfx, } from "./ofx.js";
9
+ import { type ImportOptions, type ImportResult } from "./import.js";
10
+ import type { StatementLine } from "./line.js";
11
+ /**
12
+ * Import a statement without being told which format it is in. Bank downloads
13
+ * arrive as whatever the bank felt like that day.
14
+ */
15
+ export declare function importStatement(input: string, options?: ImportOptions): {
16
+ lines: readonly StatementLine[];
17
+ warnings: readonly string[];
18
+ errors: ImportResult["errors"];
19
+ format: "csv" | "ofx";
20
+ };
@@ -0,0 +1,32 @@
1
+ export { DELIMITERS, CsvParseError, stripBom, parseCsv, sniffDelimiter, readTable, } from "./csv.js";
2
+ export { AmountParseError, detectDecimalConvention, normaliseAmount, parseAmount, combineDebitCredit, looksLikeAmount, } from "./amount.js";
3
+ export { DATE_FORMATS, DateParseError, parseDateIn, canParseIn, detectDateFormat, parseDateColumn, looksLikeDate, } from "./dates.js";
4
+ export { inferColumns, } from "./columns.js";
5
+ export { statementLine, normaliseDescription, hashString, fingerprintOf, isMoneyIn, isMoneyOut, } from "./line.js";
6
+ export { findDuplicates, findNearDuplicates, } from "./duplicates.js";
7
+ export { importCsv, describeImport, } from "./import.js";
8
+ export { importOfx, parseOfxDate, extractTransactionBlocks, looksLikeOfx, } from "./ofx.js";
9
+ import { importCsv } from "./import.js";
10
+ import { importOfx, looksLikeOfx } from "./ofx.js";
11
+ /**
12
+ * Import a statement without being told which format it is in. Bank downloads
13
+ * arrive as whatever the bank felt like that day.
14
+ */
15
+ export function importStatement(input, options = {}) {
16
+ if (looksLikeOfx(input)) {
17
+ const result = importOfx(input, options.currency === undefined ? {} : { currency: options.currency });
18
+ return {
19
+ lines: result.lines,
20
+ warnings: result.warnings,
21
+ errors: result.errors,
22
+ format: "ofx",
23
+ };
24
+ }
25
+ const result = importCsv(input, options);
26
+ return {
27
+ lines: result.lines,
28
+ warnings: result.warnings,
29
+ errors: result.errors,
30
+ format: "csv",
31
+ };
32
+ }