mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 04:13:12 +00:00
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
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:
parent
f7adb6e8b8
commit
d8a2412d75
7 changed files with 420 additions and 38 deletions
|
|
@ -1 +0,0 @@
|
|||
{"sessionId":"a426a233-ab23-4dc4-a01b-27f4e6970165","pid":33318,"procStart":"Fri Apr 24 17:44:23 2026","acquiredAt":1777054008237}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@ BofA API Resources/
|
|||
postman-output.txt
|
||||
stampli_*.txt
|
||||
.env
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
|
|
|
|||
15
README.md
15
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#<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.
|
||||
|
||||
|
|
|
|||
113
src/boaRecon.js
113
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],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
27
tests/postingDate.test.js
Normal 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");
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue