fix(fetchboa): read real CashPro field names; extend BAI code map; 7-day window (#75)
Some checks are pending
Deploy / deploy (push) Waiting to run

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
This commit is contained in:
Adam Moussa 2026-07-22 15:40:49 -04:00 • committed by GitHub
parent f7adb6e8b8
commit d8a2412d75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 420 additions and 38 deletions

View file

@ -1 +0,0 @@
{"sessionId":"a426a233-ab23-4dc4-a01b-27f4e6970165","pid":33318,"procStart":"Fri Apr 24 17:44:23 2026","acquiredAt":1777054008237}

4
.gitignore vendored
View file

@ -7,4 +7,6 @@ BofA API Resources/
*.csv
postman-output.txt
stampli_*.txt
.env
.env
.claude/settings.local.json
.claude/scheduled_tasks.lock

View file

@ -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#<fromDate>_<toDate>#<runAt>` 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.

View file

@ -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],
};
}

View file

@ -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") {

View file

@ -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");
});

27
tests/postingDate.test.js Normal file
View file

@ -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");
});