1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import { bofa } from "./parse-bofa.js";
18import { wells } from "./parse-wells.js";
19import { capone } from "./parse-capone.js";
20import { td } from "./parse-td.js";
21
22export const SCAN_PROBLEM =
23 "This PDF has no text layer, so it is a scan. Scanned statements are not supported.";
24export const LAYOUT_PROBLEM =
25 "This does not look like a statement layout we have tested (Chase, Bank of America, Wells Fargo, Capital One or TD Bank).";
26
27export async function extractLines(pdfjsLib, data) {
28 const task = pdfjsLib.getDocument({
29 data,
30 isEvalSupported: false,
31 disableFontFace: true,
32 });
33 const doc = await task.promise;
34 const lines = [];
35 let chars = 0;
36 try {
37 for (let p = 1; p <= doc.numPages; p++) {
38 const page = await doc.getPage(p);
39 const { items } = await page.getTextContent();
40 const rows = [];
41 for (const it of items) {
42 const str = (it.str || "").trim();
43 if (!str) continue;
44 chars += str.replace(/\s/g, "").length;
45 const x = Math.round(it.transform[4] * 10) / 10;
46 const y = Math.round(it.transform[5] * 10) / 10;
47 const w = Math.round((it.width || 0) * 10) / 10;
48
49 let row = rows.find((r) => Math.abs(r.y - y) <= 2);
50 if (!row) rows.push((row = { page: p, y, text: "", cells: [] }));
51 row.cells.push({ x, w, str });
52 }
53 rows.sort((a, b) => b.y - a.y);
54 for (const r of rows) {
55 r.cells.sort((a, b) => a.x - b.x);
56 r.text = r.cells.map((c) => c.str).join(" ");
57 }
58 lines.push(...rows);
59 page.cleanup();
60 }
61 return { pages: doc.numPages, lines, chars };
62 } finally {
63 await task.destroy();
64 }
65}
66
67export const DATE = /^(\d{2})\/(\d{2})$/;
68const MONEY = /^([-+])?\$?(-)?(\d{1,3}(?:,\d{3})*|\d+)\.(\d{2})$/;
69export const MONTHS =
70 "January February March April May June July August September October November December".split(
71 " ",
72 );
73const NOISE = /(^|\s)Page \d+ of \d+$|^(Primary )?Account Number:/i;
74
75export function cents(s) {
76 const m = MONEY.exec(s);
77 if (!m) return null;
78 const v = Number(m[3].replace(/,/g, "")) * 100 + Number(m[4]);
79 return m[1] === "-" || m[2] ? -v : v;
80}
81
82export function money(c) {
83 const a = Math.abs(c);
84 const whole = String(Math.floor(a / 100)).replace(/\B(?=(\d{3})+$)/g, ",");
85 return (c < 0 ? "-" : "") + whole + "." + String(a % 100).padStart(2, "0");
86}
87
88
89
90function pairs(cells) {
91 const out = [];
92 let label = [];
93 for (const c of cells) {
94 const v = cents(c.str);
95 if (v !== null) {
96 if (label.length) out.push({ label: label.join(" "), cents: v });
97 label = [];
98 } else if (!/^\d+$/.test(c.str)) label.push(c.str);
99 }
100 return out;
101}
102
103
104
105
106
107const CHECKING_HEADS = {
108 "CHECKING SUMMARY": { kind: "summary", prefix: true },
109 "TRANSACTION DETAIL": { kind: "rows", prefix: true, section: "", sign: 1 },
110 "DEPOSITS AND ADDITIONS": {
111 kind: "rows",
112 section: "Deposits and Additions",
113 sign: 1,
114 },
115 "CHECKS PAID": { kind: "rows", section: "Checks Paid", sign: -1 },
116 "ATM & DEBIT CARD WITHDRAWALS": {
117 kind: "rows",
118 section: "ATM & Debit Card Withdrawals",
119 sign: -1,
120 },
121 "ELECTRONIC WITHDRAWALS": {
122 kind: "rows",
123 section: "Electronic Withdrawals",
124 sign: -1,
125 },
126 "OTHER WITHDRAWALS": { kind: "rows", section: "Other Withdrawals", sign: -1 },
127 FEES: { kind: "rows", section: "Fees", sign: -1 },
128 "DAILY ENDING BALANCE": { kind: "stop" },
129 "SERVICE CHARGE SUMMARY": { kind: "stop" },
130 "IN CASE OF ERRORS": { kind: "stop", prefix: true },
131};
132
133
134const CARD_HEADS = {
135 "ACCOUNT ACTIVITY": { kind: "activity" },
136 "PAYMENTS AND OTHER CREDITS": {
137 kind: "rows",
138 section: "Payments and Other Credits",
139 sum: /^Payments?, Credits$/,
140 sign: -1,
141 },
142 PURCHASE: { kind: "rows", section: "Purchase", sum: /^Purchases$/, sign: -1 },
143 PURCHASES: {
144 kind: "rows",
145 section: "Purchase",
146 sum: /^Purchases$/,
147 sign: -1,
148 },
149 "CASH ADVANCE": {
150 kind: "rows",
151 section: "Cash Advances",
152 sum: /^Cash Advances$/,
153 sign: -1,
154 },
155 "CASH ADVANCES": {
156 kind: "rows",
157 section: "Cash Advances",
158 sum: /^Cash Advances$/,
159 sign: -1,
160 },
161 "BALANCE TRANSFER": {
162 kind: "rows",
163 section: "Balance Transfers",
164 sum: /^Balance Transfers$/,
165 sign: -1,
166 },
167 "BALANCE TRANSFERS": {
168 kind: "rows",
169 section: "Balance Transfers",
170 sum: /^Balance Transfers$/,
171 sign: -1,
172 },
173 "FEES CHARGED": {
174 kind: "rows",
175 section: "Fees Charged",
176 sum: /^Fees Charged$/,
177 sign: -1,
178 },
179 "INTEREST CHARGED": {
180 kind: "rows",
181 section: "Interest Charged",
182 sum: /^Interest Charged$/,
183 sign: -1,
184 },
185 "INTEREST CHARGES": { kind: "stop", final: true },
186};
187
188function heading(text, heads) {
189 const t = text.replace(/\s*\(continued\)$/i, "");
190 for (const [k, h] of Object.entries(heads))
191 if (t === k || (h.prefix && t.startsWith(k))) return h;
192 if (heads === CARD_HEADS && /Totals Year-to-Date/i.test(t))
193 return { kind: "stop", final: true };
194 return null;
195}
196
197function findPeriod(L) {
198 const iso = (y, m, d) =>
199 `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
200 for (const { text } of L) {
201 let m =
202 /([A-Z][a-z]+) (\d{1,2}), (\d{4}) through ([A-Z][a-z]+) (\d{1,2}), (\d{4})/.exec(
203 text,
204 );
205 if (m && MONTHS.includes(m[1]) && MONTHS.includes(m[4]))
206 return {
207 start: iso(m[3], MONTHS.indexOf(m[1]) + 1, m[2]),
208 end: iso(m[6], MONTHS.indexOf(m[4]) + 1, m[5]),
209 };
210 m =
211 /Opening\/Closing Date:?\s*(\d{2})\/(\d{2})\/(\d{2})\s*-\s*(\d{2})\/(\d{2})\/(\d{2})/.exec(
212 text,
213 );
214 if (m)
215 return {
216 start: iso("20" + m[3], m[1], m[2]),
217 end: iso("20" + m[6], m[4], m[5]),
218 };
219 }
220 return null;
221}
222
223
224
225export function rowDate(mm, dd, period) {
226 const sy = Number(period.start.slice(0, 4));
227 const [s, e] = [period.start, period.end].map(Date.parse);
228 const away = (y) => {
229 const t = Date.UTC(y, mm - 1, dd);
230 return Math.max(s - t, t - e, 0);
231 };
232 const y = [sy - 1, sy + 1].reduce((a, b) => (away(b) < away(a) ? b : a), sy);
233 return `${y}-${mm}-${dd}`;
234}
235
236
237function walk(L, heads, card) {
238 const rows = [];
239 const summary = new Map();
240 const sectionTotals = [];
241 const table = {};
242 let mode = card ? "summary" : null;
243 let head = null;
244 let last = null;
245 let amtX = 0;
246 let page = 0;
247 for (const l of L) {
248 if (l.page !== page) [page, amtX, last] = [l.page, 0, null];
249 if (NOISE.test(l.text)) continue;
250 const { cells } = l;
251 const lead = DATE.exec(cells[0].str);
252 const h =
253 !lead && cells.every((c) => cents(c.str) === null)
254 ? heading(l.text, heads)
255 : null;
256 if (h) {
257 if (h.final) break;
258 [mode, last] = [
259 h.kind === "activity" ? (head ? "rows" : null) : h.kind,
260 null,
261 ];
262 if (h.kind === "rows") head = h;
263 continue;
264 }
265 const amtHead = cells.find((c) => /^(\$ )?amount$/i.test(c.str));
266 if (amtHead && cells.every((c) => cents(c.str) === null)) {
267 amtX = amtHead.x - 80;
268 continue;
269 }
270 if (mode === "summary") {
271 for (const p of pairs(cells))
272 if (!summary.has(p.label)) summary.set(p.label, p.cents);
273 if (!card && summary.has("Ending Balance")) mode = null;
274 continue;
275 }
276 if (mode !== "rows") continue;
277 const tail = [];
278 for (let i = cells.length - 1; i > 0 && tail.length < 2; i--) {
279 const v = cents(cells[i].str);
280 if (v === null || cells[i].x < amtX) break;
281 tail.unshift({ i, v });
282 }
283 const check =
284 !lead &&
285 head.section === "Checks Paid" &&
286 /^\d+$/.test(cells[0].str) &&
287 tail.length;
288 if ((lead || check) && tail.length) {
289 let date = lead;
290 let desc = cells.slice(1, tail[0].i);
291 if (check) {
292 const at = desc.findIndex((c) => DATE.test(c.str));
293 if (at < 0) continue;
294 date = DATE.exec(desc[at].str);
295 desc = [
296 { str: "Check #" + cells[0].str },
297 ...desc.filter((c, i) => i !== at && c.str !== "^"),
298 ];
299 } else if (card && desc[0] && DATE.test(desc[0].str))
300 desc = desc.slice(1);
301 const twoCol = !card && head.section === "" && tail.length === 2;
302 const printed = twoCol ? tail[0].v : tail[tail.length - 1].v;
303 last = {
304 mm: date[1],
305 dd: date[2],
306 description: desc.map((c) => c.str).join(" "),
307 printed,
308 cents: card ? -printed : head.sign * printed,
309 balance: twoCol ? tail[1].v : null,
310 section: head.section,
311 y: l.y,
312 };
313 rows.push(last);
314 continue;
315 }
316 const v = cents(cells[cells.length - 1].str);
317 if (v === null) {
318
319
320 if (last && last.y - l.y <= 11) {
321 last.description += " " + l.text;
322 last.y = l.y;
323 }
324 continue;
325 }
326 last = null;
327 const label = pairs(cells)[0]?.label || "";
328 if (/^Beginning Balance$/i.test(label)) table.begin = v;
329 else if (/^Ending Balance$/i.test(label)) [table.end, mode] = [v, null];
330 else if (/^Total /i.test(label))
331 sectionTotals.push({ label, cents: v, head });
332 }
333 return { rows, summary, sectionTotals, table };
334}
335
336
337export const total = (label, statement, parsed, every = false) => ({
338 label,
339 statement,
340 parsed,
341 every,
342});
343export const sum = (rows, f = (r) => r.cents) =>
344 rows.reduce((n, r) => n + f(r), 0);
345
346function checkingTotals(w, layout) {
347 const out = [];
348 const { rows, summary, table } = w;
349 const begin = summary.get("Beginning Balance") ?? table.begin;
350 const end = summary.get("Ending Balance") ?? table.end;
351 const cats = [...summary].filter(
352 ([k]) => !/^(Beginning|Ending) Balance$/.test(k),
353 );
354 if (layout === "chase-checking") {
355 const dep = summary.get("Deposits and Additions");
356 if (dep !== undefined)
357 out.push(
358 total(
359 "Deposits and Additions",
360 dep,
361 sum(rows.filter((r) => r.cents > 0)),
362 ),
363 );
364 const wd = cats.filter(([k]) => k !== "Deposits and Additions");
365 if (wd.length)
366 out.push(
367 total(
368 wd.map(([k]) => k).join(" + "),
369 wd.reduce((n, [, v]) => n - Math.abs(v), 0),
370 sum(rows.filter((r) => r.cents < 0)),
371 ),
372 );
373 } else {
374 for (const [k, v] of cats) {
375 const sec = rows.filter(
376 (r) => r.section.toLowerCase() === k.toLowerCase(),
377 );
378 out.push(total(k, v, sum(sec)));
379 }
380 for (const t of w.sectionTotals) {
381 const sec = rows.filter((r) => r.section === t.head?.section);
382 out.push(
383 total(
384 t.label,
385 t.cents,
386 sum(sec, (r) => r.printed),
387 ),
388 );
389 }
390 }
391 if (table.begin !== undefined && summary.has("Beginning Balance"))
392 out.push(
393 total(
394 "Beginning Balance (summary and table)",
395 summary.get("Beginning Balance"),
396 table.begin,
397 ),
398 );
399 if (begin !== undefined && end !== undefined)
400 out.push(
401 total(
402 "Ending Balance (Beginning Balance + all rows)",
403 end,
404 begin + sum(rows),
405 true,
406 ),
407 );
408 return out;
409}
410
411function cardTotals(w) {
412 const out = [];
413 const { rows, summary } = w;
414 const heads = [...new Set(Object.values(CARD_HEADS).filter((h) => h.sum))];
415 const seen = new Set();
416 for (const [k, v] of summary) {
417 const h = heads.find((x) => x.sum.test(k));
418 if (!h || seen.has(h.section)) continue;
419 seen.add(h.section);
420 const sec = rows.filter((r) => r.section === h.section);
421 out.push(
422 total(
423 k,
424 v,
425 sum(sec, (r) => r.printed),
426 ),
427 );
428 }
429 for (const t of w.sectionTotals) {
430 const sec = rows.filter((r) => r.section === t.head?.section);
431 out.push(
432 total(
433 t.label,
434 t.cents,
435 sum(sec, (r) => r.printed),
436 ),
437 );
438 }
439 const prev = summary.get("Previous Balance");
440 const next = summary.get("New Balance");
441 if (prev !== undefined && next !== undefined)
442 out.push(
443 total(
444 "New Balance (Previous Balance + all activity)",
445 next,
446 prev + sum(rows, (r) => r.printed),
447 true,
448 ),
449 );
450 return out;
451}
452
453const result = (layout, problem, period = null, rows = [], totals = []) => ({
454 layout,
455 period,
456 rows,
457 totals,
458 ok: !problem,
459 problem,
460});
461
462
463function finish(layout, period, rows, raw) {
464 if (!period)
465 return result(
466 layout,
467 "We could not find the statement period, so we cannot tell the year of each date.",
468 );
469 const out = rows.map((r) => ({
470 date: r.date,
471 description: r.description,
472 amount: r.cents / 100,
473 balance: r.balance === null ? null : r.balance / 100,
474 section: r.section,
475 }));
476 const totals = raw.map((t) => ({
477 label: t.label,
478 statement: t.statement / 100,
479 parsed: t.parsed / 100,
480 match: t.statement === t.parsed,
481 }));
482 let problem = null;
483 if (!rows.length) problem = "We found no transactions in this statement.";
484 else if (!totals.length)
485 problem =
486 "We could not find the statement's own totals, so we cannot check the rows.";
487 else {
488 const bad = raw.filter((t) => t.statement !== t.parsed);
489 if (bad.length)
490 problem =
491 "These totals do not match the statement: " +
492 bad
493 .map(
494 (t) =>
495 `${t.label} (statement ${money(t.statement)}, rows ${money(t.parsed)})`,
496 )
497 .join("; ") +
498 ". Please check the rows before you use them.";
499
500 else if (!raw.some((t) => t.every))
501 problem =
502 "We could not find the statement's ending balance, so we cannot check every row.";
503 }
504 return result(layout, problem, period, out, totals);
505}
506
507export function parseStatement(lines) {
508 const L = [];
509 for (const l of lines || []) {
510 const cells = (l.cells || [])
511 .map((c) => ({ x: c.x, w: c.w || 0, str: String(c.str).trim() }))
512 .filter((c) => c.str && !/^\*(start|end)\*/.test(c.str));
513 if (cells.length)
514 L.push({
515 page: l.page,
516 y: l.y,
517 cells,
518 text: cells.map((c) => c.str).join(" "),
519 });
520 }
521 if (L.reduce((n, l) => n + l.text.replace(/\s/g, "").length, 0) < 20)
522 return result(null, SCAN_PROBLEM);
523 const has = (re) => L.some((l) => re.test(l.text));
524 for (const bank of [bofa, wells, capone, td]) {
525 const layout = bank.detect(has);
526 if (!layout) continue;
527 const { period, rows, raw } = bank.parse(L, layout);
528 return finish(layout, period, rows, raw);
529 }
530 let layout = null;
531 if (has(/\bchase\b/i) && has(/^CHECKING SUMMARY/))
532 layout = has(/^TRANSACTION DETAIL/)
533 ? "chase-checking"
534 : "chase-checking-sections";
535 else if (
536 has(/\bchase\b/i) &&
537 has(/^ACCOUNT ACTIVITY/) &&
538 has(/Opening\/Closing Date/)
539 )
540 layout = "chase-card";
541 if (!layout) return result(null, LAYOUT_PROBLEM);
542 const period = findPeriod(L);
543 if (!period) return finish(layout, null);
544 const card = layout === "chase-card";
545 const w = walk(L, card ? CARD_HEADS : CHECKING_HEADS, card);
546 const rows = w.rows.map((r) => ({
547 ...r,
548 date: rowDate(r.mm, r.dd, period),
549 }));
550 return finish(
551 layout,
552 period,
553 rows,
554 card ? cardTotals(w) : checkingTotals(w, layout),
555 );
556}
557
558const cell = (s) => {
559 let v = String(s ?? "");
560 if (/^[=+\-@\t\r]/.test(v)) v = "'" + v;
561 return /[",\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
562};
563const num = (n) =>
564 n === null || n === undefined
565 ? ""
566 : money(Math.round(n * 100)).replace(/,/g, "");
567
568export function toCsv(rows) {
569 const out = ["Date,Description,Amount,Balance,Section"];
570 for (const r of rows)
571 out.push(
572 [
573 r.date,
574 cell(r.description),
575 num(r.amount),
576 num(r.balance),
577 cell(r.section),
578 ].join(","),
579 );
580 return out.join("\r\n") + "\r\n";
581}