From d8a2412d7577b6c8cbec24a9c6c4d17b2e059a38 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:40:49 -0400 Subject: [PATCH] fix(fetchboa): read real CashPro field names; extend BAI code map; 7-day window (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed v2 pipeline was fully inert: the classifier never read detailText (where the live API carries the ACH DES:PAYMENTS text) and the handler keyed event dates off valueDate, which the API does not send (it sends asOfDate) — so ACH could not classify and no event of any kind could apply. Both proven against real captured responses. - classifyTransaction: detailText first in the description chain; Summary-row guard (new "summary" event); all-zeros customerReference normalizes to empty instead of fabricating check number 0. - BAI_CODE_EVENTS: empirical enumeration lands — 255 + 252 are both check-numbered return credits, 266 is the no-number return credit (text disambiguates ach_return vs electronic_return via new fallbackEvent semantics), 455 is ach_debit via DES text or ignored third-party autopay, 170/201/470/481 are noise. - postingDate helper (asOfDate ?? valueDate) drives both the sort and event dates; unmatched reason is now "missing posting date". - Default replay window widened to trailing 7 days ending yesterday: self-healing across missed runs; responses verified pagination-free. Refs: #66, #69 --- .claude/scheduled_tasks.lock | 1 - .gitignore | 4 +- README.md | 15 ++- src/boaRecon.js | 113 ++++++++++++---- src/fetchBoaTransactions.js | 46 +++++-- tests/boaRecon.test.js | 252 ++++++++++++++++++++++++++++++++++- tests/postingDate.test.js | 27 ++++ 7 files changed, 420 insertions(+), 38 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock create mode 100644 tests/postingDate.test.js diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 9957315..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"a426a233-ab23-4dc4-a01b-27f4e6970165","pid":33318,"procStart":"Fri Apr 24 17:44:23 2026","acquiredAt":1777054008237} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5e43b98..1412c9c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ BofA API Resources/ *.csv postman-output.txt stampli_*.txt -.env \ No newline at end of file +.env +.claude/settings.local.json +.claude/scheduled_tasks.lock diff --git a/README.md b/README.md index 7e6ffef..fba1f28 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,18 @@ Two separate CashPro APIs are used, each with its own OAuth credentials: The scheduled Lambda reconciles the Previous Day feed onto `payment#` records (pure logic lives in `src/boaRecon.js`, covered by `npm test`). -**Classification.** Transactions are classified via a BAI transaction-code map (`BAI_CODE_EVENTS`; only 475 = check paid debit is empirically confirmed so far — the remaining codes are pending an enumeration replay over known event dates) with a fallback classifier over the statement description formats (ARP refer-to-maker, return-of-posted-check check-numbered and electronic variants, ACH CCD `DES:PAYMENTS ID:PMT` lines). Unmapped codes on check-shaped transactions are logged (`console.error`) and counted in the run summary — never silently dropped. +**Classification.** The API's Detail rows carry the statement line in `detailText` (ACH `DES:PAYMENTS ID:PMT` text, return descriptions) and the posting date in `asOfDate` (ISO); `transactionType: "Summary"` rows are balance/total lines and classify as `summary` (skipped, counted). Only 475 check-paid rows omit `detailText` — their check number rides in `customerReference` (all-zeros references normalize to empty, never check number 0). Classification runs the empirically-enumerated BAI code map first, then the description-text classifiers: + +| BAI code | Feed label | Event | +|---|---|---| +| 475 | Check Paid | `check_paid` (number in `customerReference`) | +| 255 | Check Posted and Returned CR | `check_return` (number in `customerReference`) | +| 252 | Debit Reversal Credit | `check_return` (second return-credit code) | +| 266 | Return Item Credit | text decides (`ach_return` on PMT text, `check_return`/`electronic_return` on return text); bare rows fall back to `electronic_return`; unreadable text stays `unknown` (loud, no write) | +| 455 | Preauthorized ACH Debit | `ach_debit` via `DES:PAYMENTS` text; DES-less = third-party autopay → ignored | +| 170/201/470/481 | totals, transfers, loan payments | ignored | + +A hard code-map event wins over description text; 266/455 carry *fallback* events consulted only when the text yields nothing. Unmapped codes on check-shaped transactions are logged (`console.error`) and counted in the run summary — never silently dropped. **Matching.** Check events match on check number AND amount, evaluating all candidates (bank postings can drop/collapse digits on long check numbers). A number match with the WRONG amount never auto-resolves — it is the altered-check/collapsed-posting signal and goes to unmatched for human review. An unknown number falls back to an exact-amount match within checks issued in the last 120 days, and only when the posting's digits are a subsequence of the candidate's check number (or vice versa); return credits additionally require a bank-confirmed candidate. Zero or multiple fallback candidates means unmatched, recorded in the run summary with no write. Electronic returns (no check number) match by exact amount among bank-confirmed payments. @@ -90,7 +101,7 @@ The scheduled Lambda reconciles the Previous Day feed onto `payment#` records (p Each applied event is appended to a `history` list attribute (`{event, date, bankRef, amount}`); identical replayed events are idempotent noops. -**Replay.** The handler accepts an optional payload `{"fromDate": "YYYY-MM-DD", "toDate": "YYYY-MM-DD"}` (strictly validated; `toDate` defaults to `fromDate`) for weekend/outage gap replays and BAI-code enumeration runs. With no payload it queries the trailing 3-day window (today−3 .. today−1), so the Monday run covers Friday–Sunday; overlapping days are idempotent (event identity `{event, date, amount}`). Feed rows without a valid `valueDate` are never applied with a substituted date — they go to unmatched for review. +**Replay.** The handler accepts an optional payload `{"fromDate": "YYYY-MM-DD", "toDate": "YYYY-MM-DD"}` (strictly validated; `toDate` defaults to `fromDate`) for weekend/outage gap replays and BAI-code enumeration runs. With no payload it queries the trailing 7-day window (today−7 .. today−1) — self-healing across missed runs and holiday gaps; overlapping days are idempotent (event identity `{event, date, amount}`), and multi-day responses were verified pagination-free up to 9-day windows. Feed rows without a valid posting date (`asOfDate`, with `valueDate` as a fixture-era fallback) are never applied with a substituted date — they go to unmatched for review. **Run summary.** Each run writes an append-only `boa_recon#_#` item (90-day TTL) with counts per classified event type, matched/applied/redeposit/voided-and-bounced/write-conflict totals, the unmatched check numbers and amounts (list capped at 50; full count kept), unknown BAI codes (capped at 20 distinct keys), and the staleness sweep results. Unmatched and unknown-code transactions also `console.error` (Slack alerting is tracked in #71). Payment writes are conditioned on the read snapshot's `status`/`clear_status` and retried once against a fresh read on conflict. diff --git a/src/boaRecon.js b/src/boaRecon.js index 4a84788..b304270 100644 --- a/src/boaRecon.js +++ b/src/boaRecon.js @@ -21,18 +21,38 @@ const cents = (v) => Math.round(Math.abs(parseAmount(v)) * 100); // not pair with another blank amount). export const amountsEqual = (a, b) => cents(a) > 0 && cents(a) === cents(b); -// BAI transaction-code -> event map. BoA's Previous Day feed uses standard -// BAI type codes; only 475 (check paid, debit) is empirically confirmed so -// far. The codes for ARP refer-to-maker return credits, return-of-posted- -// check credits (check-numbered and electronic variants), ACH CCD debits and -// return credits, and the true meaning of 255 (the old filter assumed -// "returned check", unverified) are pending the enumeration replay over -// known event dates (2/20, 3/31, 4/8, 6/16-6/23) — add them here as they -// are confirmed. Until then classification falls back to description text, -// and unmapped codes on check-shaped transactions are logged and counted, -// never silently dropped. +// BAI transaction-code -> event map, enumerated empirically from replays +// over the known event windows (2/19-21, 3/30-4/1, 4/7-9, 6/15-23) plus +// live captures on 2026-07-22: +// 475 "Check Paid" debit -> check_paid (these rows carry NO detailText; +// the check number rides in customerReference) +// 255 "Check Posted and Returned CR" credit -> check_return (check # in +// customerReference; the ARP refer-to-maker family) +// 252 "Debit Reversal Credit" credit -> check_return (second return-credit +// code; check # in customerReference) +// 266 "Return Item Credit" credit -> covers BOTH electronic check returns +// AND ACH/bill-pay bounces (customerReference all zeros); the +// description text disambiguates, so its event is a FALLBACK used only +// when the text yields nothing +// 455 "Preauthorized ACH Debit" debit -> ours carry DES:PAYMENTS in +// detailText -> ach_debit via text; a DES-less 455 is a third-party +// autopay -> ignored (counted in the histogram, not alarmed as unknown) +// 170/201/470/481 -> statement noise (summary totals, funding transfers, +// loan payments) -> ignored +// A hard `event` wins over description text; `fallbackEvent` is consulted +// only when the description yields no event. Unmapped codes on check-shaped +// transactions still come back "unknown" — logged and counted, never +// silently dropped. export const BAI_CODE_EVENTS = { 475: { event: "check_paid", direction: "debit" }, + 255: { event: "check_return", direction: "credit" }, + 252: { event: "check_return", direction: "credit" }, + 266: { fallbackEvent: "electronic_return", direction: "credit" }, + 455: { fallbackEvent: "ignored", direction: "debit" }, + 170: { event: "ignored" }, + 201: { event: "ignored" }, + 470: { event: "ignored" }, + 481: { event: "ignored" }, }; // Standard BAI ranges: 100-399 are credit type codes, 400-699 are debit @@ -61,10 +81,13 @@ export function directionOf(txn) { // Statement description formats observed on the 2026-07-21 reconciliation. const ARP_RETURN_RE = /^ARP RETURNED CHECK REFER TO MAKER CHECK #\s*(\d+)\b/i; +// Live detailText prefixes the statement line with an MMDDYY token +// ("061626 RETURN OF POSTED CHECK / ITEM ..."); both anchors accept it so +// the return classifiers work on the real feed, not just statement exports. const POSTED_RETURN_CHECK_RE = - /^RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*CHECK #\s*(\d+)\b/i; + /^(?:\d{6}\s+)?RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*CHECK #\s*(\d+)\b/i; const POSTED_RETURN_ELECTRONIC_RE = - /^RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*ELECTRONIC TRANSACTION\b/i; + /^(?:\d{6}\s+)?RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*ELECTRONIC TRANSACTION\b/i; const ACH_PMT_RE = /DES:PAYMENTS\s+ID:PMT\s*(\d+)/i; const PMT_INFO_RE = /PMT INFO:\s*(.*)$/i; const CHECK_PAID_RE = /^CHECK\s{0,10}#?\s{0,10}0*(\d{1,12})$/i; @@ -86,13 +109,30 @@ const stripLeadingZeros = (s) => String(s ?? "").replace(/^0+(?=\d)/, ""); // (transfers, misc bank activity) is "ignored". export function classifyTransaction(txn) { const code = String(txn.transactionCode ?? "").trim(); - const description = String( - txn.text ?? txn.description ?? txn.transactionDescription ?? "" - ) - .slice(0, MAX_DESCRIPTION_LEN) - .trim(); - const customerReference = stripLeadingZeros(String(txn.customerReference ?? "").trim()); - const bankReference = String(txn.bankReference ?? "").trim(); + // Substantive statement text: detailText is where the live API carries + // the rich line (ACH DES:PAYMENTS text, return descriptions); text / + // description cover statement-derived fixtures. transactionDescription + // (the short code label, e.g. "Return Item Credit") is deliberately NOT + // substantive — a label is not evidence — and is used only as the display + // fallback. + // First NON-EMPTY of the substantive fields — ?? alone would let an + // empty-string detailText shadow a populated text/description field. + const substantiveText = [txn.detailText, txn.text, txn.description] + .map((v) => String(v ?? "").slice(0, MAX_DESCRIPTION_LEN).trim()) + .find(Boolean) ?? ""; + const description = + substantiveText || + String(txn.transactionDescription ?? "").slice(0, MAX_DESCRIPTION_LEN).trim(); + // Reference fields are bank-generated and short (12 digits observed); + // bound them so a malformed feed can never balloon DDB items or matching. + const rawReference = stripLeadingZeros( + String(txn.customerReference ?? "").trim().slice(0, 64) + ); + // An all-zeros reference ("000000000000" on 266 return credits) means "no + // reference", not check number 0 — stripLeadingZeros alone leaves "0", + // which would fabricate a checkNumber. + const customerReference = rawReference === "0" ? "" : rawReference; + const bankReference = String(txn.bankReference ?? "").trim().slice(0, 64); const amount = Math.abs(parseAmount(txn.amount)); const direction = BAI_CODE_EVENTS[code]?.direction ?? directionOf(txn); @@ -110,6 +150,13 @@ export function classifyTransaction(txn) { checkShaped: false, }; + // Summary rows (balance/total lines, transactionType "Summary") are not + // events; bail before the description regexes so their labels ("Total + // Checks Paid Debit") can't pollute unknown/check-shaped counting. + if (String(txn.transactionType ?? "").trim().toLowerCase() === "summary") { + return { ...base, event: "summary" }; + } + // Description facts, extracted regardless of code so a code-mapped event // still carries the check number / PMT id it references. let descriptionEvent = null; @@ -142,7 +189,18 @@ export function classifyTransaction(txn) { base.checkNumber = stripLeadingZeros(m[1]); } - const event = BAI_CODE_EVENTS[code]?.event ?? descriptionEvent; + const mapped = BAI_CODE_EVENTS[code]; + let event = mapped?.event ?? descriptionEvent ?? null; + if (!event && mapped?.fallbackEvent) { + // "ignored" is a safe fallback anytime (455 third-party autopays carry + // substantive non-PMT text by design). A WRITE-capable fallback (266 -> + // electronic_return, which enters amount-only matching) applies only to + // a bare row: substantive text that matched no classifier must stay + // loud as "unknown", never silently become a Returned write. + if (mapped.fallbackEvent === "ignored" || !substantiveText) { + event = mapped.fallbackEvent; + } + } if (event === "check_paid" && !base.checkNumber) { base.checkNumber = customerReference || null; @@ -153,8 +211,11 @@ export function classifyTransaction(txn) { if (event) return { ...base, event }; + // Shape test uses the RAW reference: an all-zeros reference still means + // the bank posted a structured row (the zero-guard must not silence + // unmapped return-credit codes, which present exactly this way). base.checkShaped = - Boolean(customerReference) || /CHECK/i.test(description) || Boolean(base.pmtId); + Boolean(rawReference) || /CHECK/i.test(description) || Boolean(base.pmtId); return { ...base, event: base.checkShaped ? "unknown" : "ignored" }; } @@ -454,8 +515,8 @@ export function isValidISODate(s) { } // Optional {fromDate, toDate} replay payload. The default is a trailing -// 3-day window (today-3 .. today-1): the previous-day feed only runs on -// weekdays, so the Monday run must cover Friday through Sunday. Overlapping +// 7-day window (today-7 .. today-1): weekend/holiday gaps and missed runs +// self-heal inside a week without manual replays. Overlapping // days are safe — event identity makes replays idempotent. Strings are // validated strictly so a malformed payload fails loudly instead of // querying a garbage range. @@ -463,9 +524,13 @@ export function resolveDateRange(event, now = new Date()) { const hasFrom = event?.fromDate != null; const hasTo = event?.toDate != null; if (!hasFrom && !hasTo) { + // Trailing 7 days ending yesterday: self-healing across missed runs, + // outages, and holiday gaps — replays are idempotent (event identity + // noops), so the overlap is free. Pagination-free responses verified + // empirically up to 9-day/420-row windows (2026-07-22 captures). const day = 24 * 60 * 60 * 1000; return { - fromDate: new Date(now.getTime() - 3 * day).toISOString().split("T")[0], + fromDate: new Date(now.getTime() - 7 * day).toISOString().split("T")[0], toDate: new Date(now.getTime() - day).toISOString().split("T")[0], }; } diff --git a/src/fetchBoaTransactions.js b/src/fetchBoaTransactions.js index 45d2eec..c303893 100644 --- a/src/fetchBoaTransactions.js +++ b/src/fetchBoaTransactions.js @@ -13,6 +13,7 @@ import { resolveDateRange, sweepStalePayments, } from "./boaRecon.js"; +import { toISODate } from "./dates.js"; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); const secrets = new SecretsManagerClient(); @@ -22,6 +23,29 @@ const BOA_BASE_URL = process.env.BOA_BASE_URL; const UNMATCHED_LIST_CAP = 50; const UNKNOWN_CODES_CAP = 20; +// Posting date for one feed row. The live API carries it in asOfDate (ISO, +// verified 2026-07-22); valueDate never appears in real responses but is +// kept as a fallback for statement-derived fixtures. toISODate normalizes a +// non-ISO capture (M/D/YY[YY]) and returns null on garbage — never +// substitute a date. +export const postingDate = (txn) => { + // First NON-EMPTY — an empty-string asOfDate must not shadow a populated + // valueDate (?? only skips null/undefined). + const raw = [txn.asOfDate, txn.valueDate] + .map((v) => String(v ?? "").trim()) + .find(Boolean); + const iso = isValidISODate(raw) ? raw : toISODate(raw); + if (!iso) return null; + // Plausibility window mirroring the CSV path's isPlausibleSendYear (#65): + // epoch artifacts ("12/31/69" -> 2069 via the 2-digit-year fallback) and + // absurd years must go loud via the missing-posting-date path — a wrong + // far-future returned_date would permanently noop later real events. + const year = parseInt(iso.slice(0, 4), 10); + const nowYear = new Date().getFullYear(); + if (year < nowYear - 7 || year > nowYear + 2) return null; + return iso; +}; + let cachedCreds; async function getReportingCreds() { if (cachedCreds) return cachedCreds; @@ -64,7 +88,7 @@ const directionRank = (d) => (d === "debit" ? 0 : d === "credit" ? 1 : 2); export const handler = async (event) => { // Optional replay payload {fromDate, toDate}; default is the trailing - // 3-day window so the Monday run covers Friday through Sunday. + // 7-day window ending yesterday (self-healing across missed runs). const { fromDate, toDate } = resolveDateRange(event ?? {}); const { appId, clientId, token: clientSecret, accountNumber, bankId } = await getReportingCreds(); @@ -105,7 +129,7 @@ export const handler = async (event) => { })); classifiedTxns.sort( (a, b) => - String(a.txn.valueDate ?? "").localeCompare(String(b.txn.valueDate ?? "")) || + String(postingDate(a.txn) ?? "").localeCompare(String(postingDate(b.txn) ?? "")) || directionRank(a.classified.direction) - directionRank(b.classified.direction) ); @@ -161,13 +185,19 @@ export const handler = async (event) => { for (const { txn, classified } of classifiedTxns) { summary.classified[classified.event] = (summary.classified[classified.event] || 0) + 1; - if (classified.event === "ignored") continue; + if (classified.event === "ignored" || classified.event === "summary") continue; if (classified.event === "unknown") { summary.unknown_count++; - const codeKey = (classified.code || "?").slice(0, 16); + // Histogram keys must be inert: real BAI codes are short digit runs; + // anything else ("__proto__", "constructor", junk) gets a prefix so + // it can never collide with Object.prototype semantics or break SDK + // marshalling (a bare "constructor" own key crashes convertToAttr — + // Object.create(null) is no escape, it crashes marshalling outright). + const rawCode = classified.code || "?"; + const codeKey = /^[0-9]{1,16}$/.test(rawCode) ? rawCode : "x" + rawCode.slice(0, 15); if ( - codeKey in summary.unknown_codes || + Object.hasOwn(summary.unknown_codes, codeKey) || Object.keys(summary.unknown_codes).length < UNKNOWN_CODES_CAP ) { summary.unknown_codes[codeKey] = (summary.unknown_codes[codeKey] || 0) + 1; @@ -182,11 +212,11 @@ export const handler = async (event) => { // Never substitute a date: event identity and transition ordering both // key on the posting date, so a feed row without one is a human case. - if (!isValidISODate(txn.valueDate)) { - recordUnmatched(classified, "missing valueDate"); + const eventDate = postingDate(txn); + if (!eventDate) { + recordUnmatched(classified, "missing posting date"); continue; } - const eventDate = txn.valueDate; let match; if (classified.event === "ach_debit" || classified.event === "ach_return") { diff --git a/tests/boaRecon.test.js b/tests/boaRecon.test.js index ff945ad..943f7a4 100644 --- a/tests/boaRecon.test.js +++ b/tests/boaRecon.test.js @@ -752,9 +752,9 @@ test("classifier: pathological long input is bounded, classified without hanging // ------------------------------------------------------------- date range -test("resolveDateRange: default is the trailing 3-day window (Mon covers Fri-Sun)", () => { +test("resolveDateRange: default is the trailing 7-day window ending yesterday", () => { const r = resolveDateRange({}, new Date("2026-07-21T13:00:00Z")); - assert.deepEqual(r, { fromDate: "2026-07-18", toDate: "2026-07-20" }); + assert.deepEqual(r, { fromDate: "2026-07-14", toDate: "2026-07-20" }); }); test("resolveDateRange: explicit valid range passes through", () => { @@ -782,3 +782,251 @@ test("isValidISODate rejects non-strings and bad calendar dates", () => { assert.equal(isValidISODate("2026-13-01"), false); assert.equal(isValidISODate(20260720), false); }); + +// --- Live-API field shapes (real captures, 2026-07-22; account/bank ids +// --- not part of transaction rows, amounts/refs/detailText verbatim) --- + +test("live shape: 455 ACH debit classifies via detailText", () => { + const c = classifyTransaction({ + asOfDate: "2026-07-22", + transactionCode: "455", + transactionDescription: "Preauthorized ACH Debit", + transactionType: "Detail", + amount: "14516.84", + creditDebitIndicator: "Debit", + customerReference: "000000000000", + bankReference: "02023379940", + detailText: + "ALLIANCE SANITAT DES:PAYMENTS ID:PMT 7854980 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:Alliance Sanitation LLC 21222000280", + }); + assert.equal(c.event, "ach_debit"); + assert.equal(c.pmtId, "7854980"); + assert.equal(c.embeddedPaymentNumber, "21222000280"); + assert.equal(c.vendorText, "Alliance Sanitation LLC"); + assert.equal(c.checkNumber, null); +}); + +test("live shape: DES-less 455 is a third-party autopay, ignored not unknown", () => { + const c = classifyTransaction({ + transactionCode: "455", + transactionDescription: "Preauthorized ACH Debit", + transactionType: "Detail", + amount: "1850", + creditDebitIndicator: "Debit", + customerReference: "000000000000", + detailText: "SOME UTILITY CO DES:AUTOPAY ID:12345 INDN:Sea Haven Industries CO ID:999 WEB", + }); + assert.equal(c.event, "ignored"); +}); + +test("live shape: 255 return credit maps to check_return with number from ref", () => { + const c = classifyTransaction({ + asOfDate: "2026-02-20", + transactionCode: "255", + transactionDescription: "Check Posted and Returned CR", + transactionType: "Detail", + amount: "1892.62", + creditDebitIndicator: "Credit", + customerReference: "1122200030", + }); + assert.equal(c.event, "check_return"); + assert.equal(c.checkNumber, "1122200030"); + assert.equal(c.direction, "credit"); +}); + +test("live shape: 252 debit reversal credit maps to check_return; text agrees", () => { + const c = classifyTransaction({ + asOfDate: "2026-06-16", + transactionCode: "252", + transactionDescription: "Debit Reversal Credit", + transactionType: "Detail", + amount: "6932.76", + creditDebitIndicator: "Credit", + customerReference: "001122200802", + detailText: "061626 RETURN OF POSTED CHECK / ITEM (RECEIVED ON 06-16) CHECK #1122200802", + }); + assert.equal(c.event, "check_return"); + assert.equal(c.checkNumber, "1122200802"); +}); + +test("live shape: 266 with electronic-return text stays electronic_return, no fabricated check number", () => { + const c = classifyTransaction({ + asOfDate: "2026-06-16", + transactionCode: "266", + transactionDescription: "Return Item Credit", + transactionType: "Detail", + amount: "1555.13", + creditDebitIndicator: "Credit", + customerReference: "000000000000", + detailText: "061626 RETURN OF POSTED CHECK / ITEM (RECEIVED ON 06-16) ELECTRONIC TRANSACTION", + }); + assert.equal(c.event, "electronic_return"); + assert.equal(c.checkNumber, null); +}); + +test("266: ACH PMT text wins over the electronic_return fallback", () => { + const c = classifyTransaction({ + transactionCode: "266", + transactionType: "Detail", + amount: "4364.95", + creditDebitIndicator: "Credit", + customerReference: "000000000000", + detailText: + "REDDI SERVICES DES:PAYMENTS ID:PMT 7712345 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:Reddi Services 21222000211", + }); + assert.equal(c.event, "ach_return"); + assert.equal(c.pmtId, "7712345"); +}); + +test("266: bare row (code label only, no detailText) falls back to electronic_return", () => { + const c = classifyTransaction({ + transactionCode: "266", + transactionDescription: "Return Item Credit", + transactionType: "Detail", + amount: "360.00", + creditDebitIndicator: "Credit", + customerReference: "000000000000", + }); + assert.equal(c.event, "electronic_return"); + assert.equal(c.checkNumber, null); +}); + +test("266: substantive text matching no classifier stays loud as unknown, never a write event", () => { + // Security review F1: a write-capable fallback must not fire on a row the + // classifiers could not read — amount-only Returned writes were reachable. + const c = classifyTransaction({ + transactionCode: "266", + transactionDescription: "Return Item Credit", + transactionType: "Detail", + amount: "360.00", + creditDebitIndicator: "Credit", + customerReference: "000000000000", + detailText: "DEPOSITED ITEM RETURNED 12345", + }); + assert.equal(c.event, "unknown"); + assert.equal(c.checkShaped, true); +}); + +test("live-format return text with leading MMDDYY token classifies via regex", () => { + // Security review F2: the anchors must accept the live detailText prefix. + const c = classifyTransaction({ + transactionCode: "699", + creditDebitIndicator: "Credit", + amount: "6932.76", + detailText: "061626 RETURN OF POSTED CHECK / ITEM (RECEIVED ON 06-16) CHECK #1122200802", + }); + assert.equal(c.event, "check_return"); + assert.equal(c.checkNumber, "1122200802"); + const e = classifyTransaction({ + transactionCode: "699", + creditDebitIndicator: "Credit", + amount: "360.00", + detailText: "061626 RETURN OF POSTED CHECK / ITEM (RECEIVED ON 06-16) ELECTRONIC TRANSACTION", + }); + assert.equal(e.event, "electronic_return"); +}); + +test("unmapped code with all-zeros reference stays unknown, not silently ignored", () => { + // Security review F4: the zero-guard must not remove the shape signal. + const c = classifyTransaction({ + transactionCode: "275", + transactionType: "Detail", + creditDebitIndicator: "Credit", + customerReference: "000000000000", + amount: "4364.95", + detailText: "VENDOR DES:REVERSAL ID:123 CCD", + }); + assert.equal(c.event, "unknown"); + assert.equal(c.checkNumber, null); +}); + +test("reference fields are length-bounded", () => { + const c = classifyTransaction({ + transactionCode: "475", + customerReference: "9".repeat(500), + bankReference: "8".repeat(500), + amount: "1.00", + }); + assert.ok(c.checkNumber.length <= 64); + assert.ok(c.bankReference.length <= 64); +}); + +test("zero-reference guard: all-zeros customerReference never becomes check number 0", () => { + for (const ref of ["0", "0000", "000000000000"]) { + const c = classifyTransaction({ + transactionCode: "255", + transactionType: "Detail", + amount: "100", + creditDebitIndicator: "Credit", + customerReference: ref, + }); + assert.equal(c.checkNumber, null, `ref ${ref}`); + assert.equal(c.customerReference, ""); + } +}); + +test("summary rows classify as summary before any text matching", () => { + const c = classifyTransaction({ + asOfDate: "2026-07-22", + transactionCode: "470", + transactionDescription: "Total Checks Paid Debit", + transactionType: "Summary", + amount: "4547.62", + }); + assert.equal(c.event, "summary"); +}); + +test("noise codes are ignored even when check-shaped", () => { + for (const [code, desc] of [ + ["170", "Total Other Check Deposits CR"], + ["201", "Individual Auto Transfer CR"], + ["470", "Total Checks Paid Debit"], + ["481", "Individual Loan Payment Debit"], + ]) { + const c = classifyTransaction({ + transactionCode: code, + transactionDescription: desc, + transactionType: "Detail", + amount: "100", + customerReference: "197", + }); + assert.equal(c.event, "ignored", `code ${code}`); + } +}); + +test("live shape: 475 check paid has no detailText; number rides in customerReference", () => { + const c = classifyTransaction({ + asOfDate: "2026-06-16", + transactionCode: "475", + transactionDescription: "Check Paid", + transactionType: "Detail", + amount: "3350.00", + creditDebitIndicator: "Debit", + customerReference: "0001122200236", + bankReference: "813312345", + }); + assert.equal(c.event, "check_paid"); + assert.equal(c.checkNumber, "1122200236"); +}); + +test("legacy statement-text fixtures still classify via the text fallback", () => { + const c = classifyTransaction({ + text: "ARP RETURNED CHECK REFER TO MAKER CHECK # 1122200030 PAID DATE 02/19/26", + amount: "1,892.62", + transactionCode: "354", + }); + assert.equal(c.event, "check_return"); + assert.equal(c.checkNumber, "1122200030"); +}); + +test("empty-string detailText does not shadow populated text (substantive chain)", () => { + // Open SWE review: ?? alone would let detailText:"" hide the statement text. + const c = classifyTransaction({ + transactionCode: "699", + detailText: "", + text: "ARP RETURNED CHECK REFER TO MAKER CHECK # 1122200030 PAID DATE 02/19/26", + amount: "1,892.62", + }); + assert.equal(c.event, "check_return"); + assert.equal(c.checkNumber, "1122200030"); +}); diff --git a/tests/postingDate.test.js b/tests/postingDate.test.js new file mode 100644 index 0000000..37e800a --- /dev/null +++ b/tests/postingDate.test.js @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { postingDate } from "../src/fetchBoaTransactions.js"; + +test("postingDate: ISO asOfDate passes; valueDate is the fallback", () => { + assert.equal(postingDate({ asOfDate: "2026-07-22" }), "2026-07-22"); + assert.equal(postingDate({ valueDate: "2026-07-21" }), "2026-07-21"); +}); + +test("postingDate: non-ISO capture formats normalize", () => { + assert.equal(postingDate({ asOfDate: "7/21/2026" }), "2026-07-21"); +}); + +test("postingDate: implausible years reject to null (epoch artifacts, far future)", () => { + // Security review INJ-002: "12/31/69" would become 2069 via the 2-digit + // fallback and permanently noop later real events on that record. + assert.equal(postingDate({ asOfDate: "12/31/69" }), null); + assert.equal(postingDate({ asOfDate: "9999-12-31" }), null); + assert.equal(postingDate({ asOfDate: "1901-01-01" }), null); + assert.equal(postingDate({ asOfDate: "garbage" }), null); + assert.equal(postingDate({}), null); +}); + +test("postingDate: empty-string asOfDate falls through to valueDate", () => { + // Open SWE review: ?? alone would return "" and reject the row. + assert.equal(postingDate({ asOfDate: "", valueDate: "2026-07-21" }), "2026-07-21"); +});