payments-dashboard/tests/boaRecon.test.js

1033 lines
40 KiB
JavaScript
Raw Normal View History

feat(fetchboa): bank-truth reconciliation v2 — returns, redeposits, ACH confirmation (#73) * feat(fetchboa): v2 return/redeposit-aware reconciliation (#66) The two-code 475/255 filter missed every return on the Jan-Jul statement (29 ARP refer-to-maker credits, 24 electronic return credits, and the redeposit cycles), leaving 5 paid-then-returned checks stuck at Cleared ($5,256.62, vendors unpaid). Rewrite fetchBoaTransactions around a pure reconciliation module (src/boaRecon.js) covered by node:test: - BAI transaction-code event map (only 475 = check paid is confirmed; remaining codes pend the enumeration replay) with a description-text fallback classifier for ARP refer-to-maker, return-of-posted-check (check-numbered and electronic) and ACH DES:PAYMENTS formats. Unknown check-shaped transactions are logged and counted, never dropped. - Matching on check number AND amount over all candidates; wrong-amount or collapsed-number postings fall back to a unique exact-amount match within checks issued in the last 120 days; ambiguity means unmatched with no write. - Transitions always set BOTH status and clear_status (the missed returns slipped through the divergence between them). clear_status is bank truth: returns apply even to Cleared records, a second paid debit on a Returned check is a redeposit back to Cleared, and a return on a voided check is terminal voided-and-bounced. Every applied event is appended to a history list; identical replayed events are noops. - Optional {fromDate, toDate} replay payload (strictly validated) for gap replays and the BAI-code enumeration runs; default stays yesterday. - Each run writes a boa_recon#<runDate> summary item (90-day TTL) with per-event counts, unmatched check numbers/amounts, and unknown codes; unmatched/unknown also console.error (Slack alerting is #71). ACH transactions are classified but not yet acted on; bank-confirming ACH lands with #69. * feat(ach): bank-confirm ACH, drop CSV-date auto-clear (#69) processPaymentCsv auto-cleared ACH the moment send_payment_on passed, but the bank disagrees often enough to matter: settlement lags the send date by up to 8 days, settled ACH can bounce and re-debit (Uline $19,281.12, Heights $10,000.00 on the 5/26 overdrawn week), and voided ACH that settled anyway returned via electronic credits invisible to a Check-only feed. Move ACH to bank confirmation: - processPaymentCsv: ACH rows keep their Stampli status; the send-date auto-clear is removed. The bankConfirmed protection is unchanged, so bank-cleared records still cannot be moved backward by the CSV. - fetchBoaTransactions scans all payments (method filter dropped) and processes ACH CCD lines: match by stored pmt_id first (reversals reuse the original PMT id), then by the Stampli payment number embedded in PMT INFO (internal spaces stripped, amount must agree), then vendor + exact amount within send_payment_on -2..+14 days. Settled debit -> Cleared/Cleared with dates, pmt_id persisted on the record; credit reusing the pmt_id (or a unique same-amount return credit against a bank-confirmed ACH) -> clear_status=Returned + returned_date + history event. Ambiguity is unmatched, no write. Existing DDB ACH records already Cleared by the old auto-clear are unaffected: bank confirmation is additive and the CSV path never moves a record backward. Handler event contract extended (optional fromDate/toDate); cross-family review required before merge. * fix(fetchboa): close review findings — coverage gap, matcher corroboration, replay identity (#66) Security-review gate (detector fan-out + verifier + GPT-4.1 cross-family) blocked on F6 and confirmed six lower findings; all are closed here. - F6 (HIGH): the default run now queries a trailing 3-day window (today-3..today-1) so the Monday run covers Friday-Sunday postings the previous-day cadence silently skipped. A per-run staleness sweep flags never-bank-confirmed payments (ACH > 16 days, checks > 60 days) in the summary and via console.error. - F2: replay identity is {event, date, amount} — bankRef excluded (feeds omit/reformat it between runs); a paid event dated on/before the latest return in history is a replayed original, never a redeposit; rows without a valid valueDate are routed to unmatched instead of being applied under a substituted date; within a day, debits sort before credits. - F1: a check-number match with the wrong amount no longer falls through to the amount fallback (altered-check/collapsed-posting is a human-review case); the amount fallback requires digit-subsequence corroboration between posting and candidate numbers; check_return fallback requires a bank-confirmed candidate. - F4: the ach_return amount fallback requires cleared_date within 45 days before the credit; an unattributable PMT id is an unmatched alert, never an amount guess. - F5: the pmt_id rung requires amount equality; mismatch is the partial-reversal human case. - F3: vendor prefix matching requires >= 10 normalized chars (short names must match exactly); candidates with a conflicting stored pmt_id are excluded; vendor+amount matches are audit-logged. - F7: payment writes are conditioned on the snapshot's status/clear_status and retried once against a fresh read; residual conflicts are counted, not silently interleaved. - F9/summary bounds: run summary pk is append-only (boa_recon#<from>_<to>#<runAt>); unmatched list capped at 50, unknown codes at 20 keys/16 chars, stale list at 50 (full counts kept). - ReDoS: description bounded to 500 chars before classification; CHECK/embedded-number regexes use bounded quantifiers. - FBOA2-1: both BoA res.json() parses are wrapped so a malformed 200 throws a status-only error and cannot leak a body snippet. Handler event contract extended (optional fromDate/toDate); cross-family review required before merge. * fix(boaRecon): add method=Check filter to matchElectronicReturn Electronic returns only apply to checks, but the matcher was not filtering by method, so an electronic return could incorrectly match against a same-amount, bank-confirmed ACH record.
2026-07-21 22:07:37 -04:00
import { test } from "node:test";
import assert from "node:assert/strict";
import {
BAI_CODE_EVENTS,
STALE_LIST_CAP,
amountsEqual,
applyEvent,
buildEventUpdate,
classifyTransaction,
digitsCorroborate,
isCancelStatus,
isValidISODate,
matchAchTransaction,
matchCheckTransaction,
matchElectronicReturn,
resolveDateRange,
sweepStalePayments,
vendorMatches,
} from "../src/boaRecon.js";
// Description fixtures are real statement lines from the 2026-07-21
// reconciliation (amounts/refs anonymized where they don't matter).
test("classifier: BAI code 475 is a check paid debit", () => {
const c = classifyTransaction({
transactionCode: "475",
customerReference: "0001122200030",
bankReference: "813312345",
amount: "1,892.62",
});
assert.equal(c.event, "check_paid");
assert.equal(c.direction, "debit");
assert.equal(c.checkNumber, "1122200030");
assert.equal(c.amount, 1892.62);
assert.equal(BAI_CODE_EVENTS["475"].event, "check_paid");
});
test("classifier: statement-style 'Check <n>' description is check paid", () => {
const c = classifyTransaction({ text: "Check 3176", amount: "-350" });
assert.equal(c.event, "check_paid");
assert.equal(c.checkNumber, "3176");
});
test("classifier: ARP refer-to-maker return credit", () => {
const c = classifyTransaction({
transactionCode: "354",
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.direction, "credit");
assert.equal(c.checkNumber, "1122200030");
});
test("classifier: return of posted check, check-numbered variant", () => {
const c = classifyTransaction({
text: "RETURN OF POSTED CHECK / ITEM (RECEIVED ON 03-02) CHECK #1122200236",
amount: "3,350.00",
});
assert.equal(c.event, "check_return");
assert.equal(c.checkNumber, "1122200236");
});
test("classifier: return of posted check, electronic variant has no check number", () => {
const c = classifyTransaction({
text: "RETURN OF POSTED CHECK / ITEM (RECEIVED ON 03-02) ELECTRONIC TRANSACTION",
amount: "3,992.15",
});
assert.equal(c.event, "electronic_return");
assert.equal(c.checkNumber, null);
});
test("classifier: ACH CCD debit without embedded payment number", () => {
const c = classifyTransaction({
text: "FISK EXCAVATING DES:PAYMENTS ID:PMT 6814295 INDN:Sea Haven Industries, CO ID:1811679038 CCD PMT INFO:Fisk Excavating Inc",
amount: "-650",
});
assert.equal(c.event, "ach_debit");
assert.equal(c.pmtId, "6814295");
assert.equal(c.embeddedPaymentNumber, null);
assert.equal(c.vendorText, "Fisk Excavating Inc");
});
test("classifier: ACH debit with embedded payment number", () => {
const c = classifyTransaction({
text: "COBRA SEPTIC DES:PAYMENTS ID:PMT 7584198 INDN:Sea Haven Industries, CO ID:1811679038 CCD PMT INFO:Cobra Septic 21222000264",
amount: "-8,300.00",
});
assert.equal(c.event, "ach_debit");
assert.equal(c.pmtId, "7584198");
assert.equal(c.embeddedPaymentNumber, "21222000264");
assert.equal(c.vendorText, "Cobra Septic");
});
test("classifier: embedded payment number with internal space '21 222000273'", () => {
const c = classifyTransaction({
text: "HALL PUMP SALES DES:PAYMENTS ID:PMT 7617407 INDN:Sea Haven Industries, CO ID:1811679038 CCD PMT INFO:Hall Pump Sales & Service Corporation 21 222000273",
amount: "-5,495.00",
});
assert.equal(c.embeddedPaymentNumber, "21222000273");
assert.equal(c.vendorText, "Hall Pump Sales & Service Corporation");
});
test("classifier: embedded payment number with internal space '2122200 0256'", () => {
const c = classifyTransaction({
text: "STERLING SEPTIC DES:PAYMENTS ID:PMT 7603631 INDN:Sea Haven Industries, CO ID:1811679038 CCD PMT INFO:Sterling Septic & Plumbing, LLC. 2122200 0256",
amount: "-1,407.59",
});
assert.equal(c.embeddedPaymentNumber, "21222000256");
assert.equal(c.vendorText, "Sterling Septic & Plumbing, LLC.");
});
test("classifier: ACH reversal credit reusing PMT id is ach_return", () => {
const c = classifyTransaction({
text: "SEA HAVEN INDUST DES:PAYMENTS ID:PMT 7176466 INDN:Sea Haven Industries, CO ID:1811679038 CCD PMT INFO:Sea Haven Industries, Inc 21222000128",
amount: "2,100.00",
});
assert.equal(c.event, "ach_return");
assert.equal(c.pmtId, "7176466");
assert.equal(c.embeddedPaymentNumber, "21222000128");
});
test("classifier: short trailing digits are not an embedded payment number", () => {
const c = classifyTransaction({
text: "SOME VENDOR DES:PAYMENTS ID:PMT 700001 INDN:Sea Haven Industries, CO ID:1811679038 CCD PMT INFO:Vendor Company 2000",
amount: "-100.00",
});
assert.equal(c.embeddedPaymentNumber, null);
assert.equal(c.vendorText, "Vendor Company 2000");
});
test("classifier: unknown code on a check-shaped transaction is 'unknown', never dropped", () => {
const c = classifyTransaction({
transactionCode: "699",
customerReference: "3301",
text: "SOMETHING NEW",
amount: "-100.00",
});
assert.equal(c.event, "unknown");
assert.equal(c.checkShaped, true);
assert.equal(c.code, "699");
});
test("classifier: transfers and misc bank activity are ignored", () => {
const c = classifyTransaction({
text: "ACCOUNT TRANSFER TRSF FROM 483096772516",
amount: "15,000.00",
});
assert.equal(c.event, "ignored");
// Not check-shaped (no code, no reference, no CHECK text) -> ignored too.
const c2 = classifyTransaction({ text: "RETURN ITEM CHARGEBACK", amount: "-215" });
assert.equal(c2.event, "ignored");
});
test("amountsEqual: comma-tolerant, sign-insensitive, zero never matches", () => {
assert.equal(amountsEqual("1,892.62", -1892.62), true);
assert.equal(amountsEqual(0, 0), false);
assert.equal(amountsEqual("", ""), false);
});
// ---------------------------------------------------------------- matcher
const payment = (over = {}) => ({
pk: `payment#${over.check_number ?? "1001"}`,
check_number: "1001",
method: "Check",
amount_usd: 500,
status: "Outstanding",
send_payment_on: "07/01/2026",
...over,
});
test("matcher: check number AND amount must both match", () => {
const payments = [payment({ check_number: "1001", amount_usd: 500 })];
const m = matchCheckTransaction({ checkNumber: "1001", amount: 500 }, payments, "2026-07-20");
assert.equal(m.payment, payments[0]);
assert.equal(m.matchedBy, "number+amount");
});
test("matcher: number match with wrong amount NEVER falls through — human review", () => {
const payments = [
payment({ check_number: "1001", amount_usd: 500 }),
payment({ check_number: "1002", amount_usd: 750.25, pk: "payment#1002" }),
];
// Altered-check / collapsed-posting signal: alert, no guessing.
const m = matchCheckTransaction({ checkNumber: "1001", amount: 750.25 }, payments, "2026-07-20");
assert.equal(m.payment, undefined);
assert.equal(m.unmatched, "number matched, amount mismatch");
});
test("matcher: digit-dropped number we never issued recovers via corroborated amount fallback", () => {
// 1222000012 is a digit-subsequence of issued 11222000012.
const payments = [payment({ check_number: "11222000012", amount_usd: 6413 })];
const m = matchCheckTransaction({ checkNumber: "1222000012", amount: 6413 }, payments, "2026-07-20");
assert.equal(m.payment.check_number, "11222000012");
assert.equal(m.matchedBy, "amount");
});
test("matcher: amount fallback without digit-subsequence corroboration is unmatched", () => {
// Same amount, but 9876 shares no digit-subsequence relation with 11222000012.
const payments = [payment({ check_number: "11222000012", amount_usd: 6413 })];
const m = matchCheckTransaction({ checkNumber: "9876", amount: 6413 }, payments, "2026-07-20");
assert.equal(m.payment, undefined);
assert.match(m.unmatched, /no corroborated amount fallback/);
});
test("matcher: check_return amount fallback requires a bank-confirmed candidate", () => {
const unconfirmed = [payment({ check_number: "11222000012", amount_usd: 6413 })];
const r1 = matchCheckTransaction(
{ event: "check_return", checkNumber: "1222000012", amount: 6413 },
unconfirmed,
"2026-07-20"
);
assert.equal(r1.payment, undefined);
const confirmed = [
payment({ check_number: "11222000012", amount_usd: 6413, clear_status: "Cleared" }),
];
const r2 = matchCheckTransaction(
{ event: "check_return", checkNumber: "1222000012", amount: 6413 },
confirmed,
"2026-07-20"
);
assert.equal(r2.payment.check_number, "11222000012");
});
test("digitsCorroborate: digit-subsequence in either direction, nothing else", () => {
assert.equal(digitsCorroborate("1222000012", "11222000012"), true); // dropped digit
assert.equal(digitsCorroborate("11222000012", "1222000012"), true); // symmetric
assert.equal(digitsCorroborate("9876", "11222000012"), false);
assert.equal(digitsCorroborate("", "1234"), false);
});
test("matcher: ambiguous amount fallback is unmatched — no write", () => {
// "101" digit-corroborates both 1001 and 1011; same amount -> ambiguous.
const payments = [
payment({ check_number: "1001", amount_usd: 350 }),
payment({ check_number: "1011", amount_usd: 350, pk: "payment#1011" }),
];
const m = matchCheckTransaction({ checkNumber: "101", amount: 350 }, payments, "2026-07-20");
assert.equal(m.payment, undefined);
assert.match(m.unmatched, /ambiguous/);
});
test("matcher: zero candidates is unmatched", () => {
const m = matchCheckTransaction({ checkNumber: "9999", amount: 123.45 }, [payment()], "2026-07-20");
assert.equal(m.payment, undefined);
});
test("matcher: amount fallback only considers checks issued in the last 120 days", () => {
// Corroborated ("101" is a subsequence of "1001") but issued too long ago.
const payments = [
payment({ check_number: "1001", amount_usd: 350, send_payment_on: "01/02/2026" }),
];
const m = matchCheckTransaction({ checkNumber: "101", amount: 350 }, payments, "2026-07-20");
assert.equal(m.payment, undefined);
});
test("matcher: non-Check payments are never check-match candidates", () => {
const payments = [payment({ method: "ACH", check_number: "21222000264", amount_usd: 350 })];
const m = matchCheckTransaction({ checkNumber: "21222000264", amount: 350 }, payments, "2026-07-20");
assert.equal(m.payment, undefined);
});
test("electronic return: unique amount among bank-confirmed payments matches", () => {
const payments = [
payment({ check_number: "1001", amount_usd: 3992.15, clear_status: "Cleared" }),
payment({ check_number: "1002", amount_usd: 3992.15, pk: "payment#1002" }), // not bank-confirmed
];
const m = matchElectronicReturn({ amount: 3992.15 }, payments, "2026-07-20");
assert.equal(m.payment.check_number, "1001");
});
test("electronic return: never matches ACH payments", () => {
const achCleared = payment({
method: "ACH",
check_number: "21222000264",
amount_usd: 3992.15,
clear_status: "Cleared",
pk: "payment#21222000264",
});
const checkCleared = payment({
check_number: "1001",
amount_usd: 3992.15,
clear_status: "Cleared",
});
// Only the Check should be a candidate — ACH is excluded.
const m = matchElectronicReturn({ amount: 3992.15 }, [achCleared, checkCleared], "2026-07-20");
assert.equal(m.payment.check_number, "1001");
assert.equal(m.matchedBy, "amount+cleared");
});
test("electronic return: ambiguity is unmatched", () => {
const payments = [
payment({ check_number: "1001", amount_usd: 2500, clear_status: "Cleared" }),
payment({ check_number: "1002", amount_usd: 2500, clear_status: "Cleared", pk: "payment#1002" }),
];
const m = matchElectronicReturn({ amount: 2500 }, payments, "2026-07-20");
assert.equal(m.payment, undefined);
assert.match(m.unmatched, /ambiguous/);
});
// ------------------------------------------------------------ transitions
const paidEvent = (over = {}) => ({
event: "check_paid",
amount: 500,
bankReference: "813300001",
...over,
});
const returnEvent = (over = {}) => ({
event: "check_return",
amount: 500,
bankReference: "813300002",
...over,
});
test("transitions: paid debit sets BOTH status and clear_status to Cleared with dates", () => {
const p = payment();
const r = applyEvent(p, paidEvent(), "2026-07-19");
assert.equal(r.kind, "cleared");
assert.equal(r.updates.status, "Cleared");
assert.equal(r.updates.clear_status, "Cleared");
assert.equal(r.updates.paid_date, "2026-07-19");
assert.equal(r.updates.cleared_date, "2026-07-19");
assert.equal(r.updates.bank_reference, "813300001");
assert.deepEqual(r.historyEvent, {
event: "check_paid",
date: "2026-07-19",
bankRef: "813300001",
amount: 500,
});
});
test("transitions: return applies EVEN IF currently Cleared; clear_status is bank truth", () => {
const p = payment({ status: "Cleared", clear_status: "Cleared" });
const r = applyEvent(p, returnEvent(), "2026-07-20");
assert.equal(r.kind, "returned");
assert.equal(r.updates.clear_status, "Returned");
assert.equal(r.updates.returned_date, "2026-07-20");
// status has no "Returned" rung on the CSV ladder — re-written unchanged.
assert.equal(r.updates.status, "Cleared");
});
test("transitions: second paid debit on a Returned check is a redeposit back to Cleared", () => {
const p = payment({ status: "Cleared", clear_status: "Returned" });
const r = applyEvent(p, paidEvent({ bankReference: "813300003" }), "2026-07-21");
assert.equal(r.kind, "redeposit");
assert.equal(r.updates.status, "Cleared");
assert.equal(r.updates.clear_status, "Cleared");
assert.equal(r.updates.paid_date, "2026-07-21");
});
test("transitions: paid -> returned -> redeposit sequence accumulates history", () => {
const p = payment();
p.history = p.history || [];
const seq = [
[paidEvent(), "2026-07-18"],
[returnEvent(), "2026-07-19"],
[paidEvent({ bankReference: "813300004" }), "2026-07-20"],
];
const kinds = [];
for (const [ev, date] of seq) {
const r = applyEvent(p, ev, date);
kinds.push(r.kind);
Object.assign(p, r.updates);
p.history.push(r.historyEvent);
}
assert.deepEqual(kinds, ["cleared", "returned", "redeposit"]);
assert.equal(p.clear_status, "Cleared");
assert.equal(p.history.length, 3);
assert.deepEqual(
p.history.map((h) => h.event),
["check_paid", "check_return", "check_paid"]
);
});
test("transitions: return on a canceled record is terminal voided-and-bounced", () => {
const p = payment({ status: "Voided", clear_status: "Cleared" });
const r = applyEvent(p, returnEvent(), "2026-07-20");
assert.equal(r.kind, "voided_and_bounced");
assert.equal(r.updates.clear_status, "Returned");
assert.equal(r.updates.status, "Voided"); // cancel status stays visible
});
test("transitions: paid debit on a canceled record keeps the cancel status", () => {
const p = payment({ status: "Marked as Void" });
const r = applyEvent(p, paidEvent(), "2026-07-19");
assert.equal(r.kind, "cleared_on_canceled");
assert.equal(r.updates.status, "Marked as Void");
assert.equal(r.updates.clear_status, "Cleared");
});
test("transitions: identical replayed event is a noop (idempotent replays)", () => {
const p = payment({
status: "Cleared",
clear_status: "Cleared",
history: [{ event: "check_paid", date: "2026-07-19", bankRef: "813300001", amount: 500 }],
});
const r = applyEvent(p, paidEvent(), "2026-07-19");
assert.equal(r.kind, "noop");
assert.equal(r.updates, null);
});
test("transitions: dedup identity is {event, date, amount} — bankRef differences are ignored", () => {
const p = payment({
status: "Cleared",
clear_status: "Cleared",
history: [{ event: "check_paid", date: "2026-07-19", bankRef: "OLD-FORMAT-REF", amount: 500 }],
});
// Same event replayed with a reformatted/omitted bankRef must still dedup.
const r = applyEvent(p, paidEvent({ bankReference: "813399999" }), "2026-07-19");
assert.equal(r.kind, "noop");
});
test("transitions: a paid event dated on/before the latest return is a replayed original, not a redeposit", () => {
const p = payment({
status: "Cleared",
clear_status: "Returned",
history: [
{ event: "check_paid", date: "2026-07-18", bankRef: "a", amount: 500 },
{ event: "check_return", date: "2026-07-19", bankRef: "b", amount: 500 },
],
});
// Replayed original paid (different bankRef, date <= return date): noop.
const equalDate = applyEvent(p, paidEvent({ bankReference: "c" }), "2026-07-19");
assert.equal(equalDate.kind, "noop");
const beforeDate = applyEvent(p, paidEvent({ bankReference: "c" }), "2026-07-17");
assert.equal(beforeDate.kind, "noop");
// Strictly after the return: genuine redeposit.
const after = applyEvent(p, paidEvent({ bankReference: "c" }), "2026-07-20");
assert.equal(after.kind, "redeposit");
});
test("transitions: every non-noop result sets both status and clear_status", () => {
const cases = [
[payment(), paidEvent()],
[payment({ status: "Cleared", clear_status: "Cleared" }), returnEvent()],
[payment({ status: "Cleared", clear_status: "Returned" }), paidEvent()],
[payment({ status: "Voided" }), returnEvent()],
[payment({ clear_status: "Cleared" }), { event: "electronic_return", amount: 500, bankReference: "x" }],
];
for (const [p, ev] of cases) {
const r = applyEvent(p, ev, "2026-07-20");
assert.notEqual(r.kind, "noop");
assert.ok("status" in r.updates, `${r.kind} must set status`);
assert.ok("clear_status" in r.updates, `${r.kind} must set clear_status`);
}
});
test("isCancelStatus covers the Stampli cancel vocabulary", () => {
for (const s of ["Voided", "cancelled", "Canceled", "Marked as Void"]) {
assert.equal(isCancelStatus(s), true);
}
assert.equal(isCancelStatus("Cleared"), false);
});
// ------------------------------------------------------------ ACH (#69)
const achPayment = (over = {}) => ({
pk: `payment#${over.check_number ?? "21222000264"}`,
check_number: "21222000264",
method: "ACH",
payee: "Cobra Septic",
amount_usd: 8300,
status: "Payment Submitted",
send_payment_on: "06/02/2026",
...over,
});
test("ach matcher: embedded payment number + amount wins", () => {
const payments = [achPayment(), achPayment({ check_number: "21222000265", pk: "payment#21222000265" })];
const m = matchAchTransaction(
{ event: "ach_debit", pmtId: "7584198", embeddedPaymentNumber: "21222000264", vendorText: "Cobra Septic", amount: 8300 },
payments,
"2026-06-08"
);
assert.equal(m.payment.check_number, "21222000264");
assert.equal(m.matchedBy, "payment-number+amount");
});
test("ach matcher: embedded number with wrong amount falls through to vendor+amount", () => {
const payments = [
achPayment({ check_number: "21222000264", amount_usd: 100 }),
achPayment({ check_number: "21222000265", pk: "payment#21222000265", amount_usd: 8300 }),
];
const m = matchAchTransaction(
{ event: "ach_debit", pmtId: "7584198", embeddedPaymentNumber: "21222000264", vendorText: "Cobra Septic", amount: 8300 },
payments,
"2026-06-08"
);
assert.equal(m.payment.check_number, "21222000265");
assert.equal(m.matchedBy, "vendor+amount");
});
test("ach matcher: stored pmt_id attributes a reversal credit", () => {
const payments = [
achPayment({ pmt_id: "7584198", clear_status: "Cleared", status: "Cleared" }),
];
const m = matchAchTransaction(
{ event: "ach_return", pmtId: "7584198", embeddedPaymentNumber: null, vendorText: "Sea Haven Industries, Inc", amount: 8300 },
payments,
"2026-06-10"
);
assert.equal(m.matchedBy, "pmt_id");
});
test("ach matcher: vendor+amount only within send_payment_on -2..+14 days", () => {
// Reddi 21222000211: sent 5/27, debited 6/4 (8-day lag) must match...
const inWindow = [achPayment({ payee: "Reddi Services", send_payment_on: "05/27/2026", amount_usd: 1958 })];
const m1 = matchAchTransaction(
{ event: "ach_debit", pmtId: "1", embeddedPaymentNumber: null, vendorText: "Reddi Services", amount: 1958 },
inWindow,
"2026-06-04"
);
assert.equal(m1.matchedBy, "vendor+amount");
// ...but a posting 30 days after the send date must not.
const m2 = matchAchTransaction(
{ event: "ach_debit", pmtId: "1", embeddedPaymentNumber: null, vendorText: "Reddi Services", amount: 1958 },
inWindow,
"2026-06-26"
);
assert.equal(m2.payment, undefined);
});
test("ach matcher: PMT-id-less same-amount return credit matches a recently cleared ACH", () => {
const payments = [
achPayment({
payee: "Uline",
clear_status: "Cleared",
status: "Cleared",
cleared_date: "2026-05-22",
amount_usd: 19281.12,
}),
];
const m = matchAchTransaction(
{ event: "ach_return", pmtId: null, embeddedPaymentNumber: null, vendorText: null, amount: 19281.12 },
payments,
"2026-05-26"
);
assert.equal(m.matchedBy, "amount+cleared");
});
test("ach matcher: amount+cleared fallback requires cleared_date within 45 days before the credit", () => {
const base = {
payee: "Uline",
clear_status: "Cleared",
status: "Cleared",
amount_usd: 19281.12,
};
const tooOld = [achPayment({ ...base, cleared_date: "2026-03-01" })];
const m1 = matchAchTransaction(
{ event: "ach_return", pmtId: null, embeddedPaymentNumber: null, vendorText: null, amount: 19281.12 },
tooOld,
"2026-05-26"
);
assert.equal(m1.payment, undefined);
const noDate = [achPayment({ ...base })];
const m2 = matchAchTransaction(
{ event: "ach_return", pmtId: null, embeddedPaymentNumber: null, vendorText: null, amount: 19281.12 },
noDate,
"2026-05-26"
);
assert.equal(m2.payment, undefined);
});
test("ach matcher: return credit with an unattributable PMT id is unmatched, never amount-guessed", () => {
const payments = [
achPayment({ clear_status: "Cleared", status: "Cleared", cleared_date: "2026-05-22", amount_usd: 8300 }),
];
const m = matchAchTransaction(
{ event: "ach_return", pmtId: "999", embeddedPaymentNumber: null, vendorText: null, amount: 8300 },
payments,
"2026-05-26"
);
assert.equal(m.payment, undefined);
assert.equal(m.unmatched, "unknown PMT id");
});
test("ach matcher: pmt_id match with amount mismatch is unmatched — partial-reversal human case", () => {
const payments = [achPayment({ pmt_id: "7584198", amount_usd: 8300 })];
const m = matchAchTransaction(
{ event: "ach_return", pmtId: "7584198", embeddedPaymentNumber: null, vendorText: null, amount: 4150 },
payments,
"2026-06-10"
);
assert.equal(m.payment, undefined);
assert.equal(m.unmatched, "pmt_id matched, amount mismatch");
});
test("ach matcher: candidates with a DIFFERENT stored pmt_id are excluded from vendor+amount", () => {
const payments = [
achPayment({ pmt_id: "1111111", amount_usd: 8300 }),
achPayment({ check_number: "21222000270", pk: "payment#21222000270", amount_usd: 8300 }),
];
// Both are Cobra Septic @ 8300 in-window; the pmt_id conflict on the
// first disambiguates to the second instead of going ambiguous.
const m = matchAchTransaction(
{ event: "ach_debit", pmtId: "2222222", embeddedPaymentNumber: null, vendorText: "Cobra Septic", amount: 8300 },
payments,
"2026-06-08"
);
assert.equal(m.payment.check_number, "21222000270");
assert.equal(m.matchedBy, "vendor+amount");
});
test("ach matcher: ambiguity is unmatched — no write", () => {
const payments = [
achPayment({ pk: "payment#a", check_number: "a", clear_status: "Cleared", cleared_date: "2026-06-01", amount_usd: 500 }),
achPayment({ pk: "payment#b", check_number: "b", clear_status: "Cleared", cleared_date: "2026-06-02", amount_usd: 500 }),
];
const m = matchAchTransaction(
{ event: "ach_return", pmtId: null, embeddedPaymentNumber: null, vendorText: null, amount: 500 },
payments,
"2026-06-08"
);
assert.equal(m.payment, undefined);
assert.match(m.unmatched, /ambiguous/);
});
test("vendorMatches: truncated bank originator vs full payee, punctuation-insensitive", () => {
assert.equal(vendorMatches("Hugill's Septic Service LLC", "Hugill's Septic Service LLC"), true);
assert.equal(vendorMatches("Alliance Sanitation LLC", "ALLIANCE SANITAT"), true);
assert.equal(vendorMatches("Cobra Septic", "Reddi Services"), false);
assert.equal(vendorMatches("", "Cobra Septic"), false);
});
test("vendorMatches: prefix only counts at >= 10 normalized chars; short names need exact equality", () => {
// "ACME" (4 chars) must not claim "ACME Plumbing Co" by prefix...
assert.equal(vendorMatches("ACME Plumbing Co", "ACME"), false);
// ...but exact short-name equality still matches.
assert.equal(vendorMatches("ACME", "A.C.M.E."), true);
// 10+ char prefix still matches (truncated originators).
assert.equal(vendorMatches("Alliance Sanitation LLC", "ALLIANCESANITAT"), true);
});
test("ach transitions: settled debit clears both fields and persists pmt_id", () => {
const p = achPayment();
const r = applyEvent(
{ ...p },
{ event: "ach_debit", pmtId: "7584198", amount: 8300, bankReference: "905512345" },
"2026-06-04"
);
assert.equal(r.kind, "cleared");
assert.equal(r.updates.status, "Cleared");
assert.equal(r.updates.clear_status, "Cleared");
assert.equal(r.updates.paid_date, "2026-06-04");
assert.equal(r.updates.pmt_id, "7584198");
});
test("ach transitions: return credit then re-debit (bounce and re-settle)", () => {
const p = achPayment({ status: "Cleared", clear_status: "Cleared", pmt_id: "7500000", history: [] });
const ret = applyEvent(p, { event: "ach_return", pmtId: "7500000", amount: 8300, bankReference: "r1" }, "2026-05-26");
assert.equal(ret.kind, "returned");
assert.equal(ret.updates.clear_status, "Returned");
assert.equal(ret.updates.returned_date, "2026-05-26");
Object.assign(p, ret.updates);
p.history.push(ret.historyEvent);
const redebit = applyEvent(p, { event: "ach_debit", pmtId: "7500000", amount: 8300, bankReference: "d2" }, "2026-06-01");
assert.equal(redebit.kind, "redeposit");
assert.equal(redebit.updates.status, "Cleared");
assert.equal(redebit.updates.clear_status, "Cleared");
});
test("ach transitions: electronic return on a voided-but-settled ACH is voided-and-bounced", () => {
const p = achPayment({ status: "Voided", clear_status: "Cleared" });
const r = applyEvent(p, { event: "electronic_return", amount: 8300, bankReference: "r2" }, "2026-06-09");
assert.equal(r.kind, "voided_and_bounced");
assert.equal(r.updates.status, "Voided");
assert.equal(r.updates.clear_status, "Returned");
});
// -------------------------------------------------- staleness sweep (F6)
test("stale sweep: unconfirmed ACH older than 16 days is listed; checks older than 60 days counted", () => {
const payments = [
achPayment({ check_number: "21222000300", send_payment_on: "06/20/2026" }), // 31d, stale
achPayment({ check_number: "21222000301", pk: "payment#21222000301", send_payment_on: "07/10/2026" }), // 11d, fresh
achPayment({ check_number: "21222000302", pk: "payment#21222000302", send_payment_on: "06/01/2026", clear_status: "Cleared" }), // confirmed
achPayment({ check_number: "21222000303", pk: "payment#21222000303", send_payment_on: "06/01/2026", status: "Voided" }), // cancel-exempt
payment({ check_number: "3001", send_payment_on: "04/01/2026" }), // check, 111d, stale
payment({ check_number: "3002", pk: "payment#3002", send_payment_on: "07/01/2026" }), // check, fresh
];
const s = sweepStalePayments(payments, "2026-07-21");
assert.equal(s.staleAchCount, 1);
assert.deepEqual(s.staleAch, [
{ check_number: "21222000300", amount: 8300, send_payment_on: "06/20/2026" },
]);
assert.equal(s.staleChecksCount, 1);
});
test("stale sweep: ACH list is capped, count is not", () => {
const payments = [];
for (let i = 0; i < STALE_LIST_CAP + 5; i++) {
payments.push(
achPayment({ check_number: `2122200${1000 + i}`, pk: `payment#s${i}`, send_payment_on: "06/01/2026" })
);
}
const s = sweepStalePayments(payments, "2026-07-21");
assert.equal(s.staleAchCount, STALE_LIST_CAP + 5);
assert.equal(s.staleAch.length, STALE_LIST_CAP);
});
// ---------------------------------------------- conditioned writes (F7)
test("buildEventUpdate: condition asserts the snapshot's status and clear_status", () => {
const p = payment({ status: "Outstanding", clear_status: "Cleared" });
const applied = applyEvent(p, returnEvent(), "2026-07-20");
const cmd = buildEventUpdate("Table", p, applied);
assert.equal(cmd.TableName, "Table");
assert.deepEqual(cmd.Key, { pk: p.pk });
assert.equal(cmd.ConditionExpression, "#c0 = :c0 AND #c1 = :c1");
assert.equal(cmd.ExpressionAttributeNames["#c0"], "status");
assert.equal(cmd.ExpressionAttributeNames["#c1"], "clear_status");
assert.equal(cmd.ExpressionAttributeValues[":c0"], "Outstanding");
assert.equal(cmd.ExpressionAttributeValues[":c1"], "Cleared");
assert.match(cmd.UpdateExpression, /^SET #history = list_append/);
// Every applied field is present in the SET clause.
const setFields = Object.entries(cmd.ExpressionAttributeNames)
.filter(([k]) => k.startsWith("#f"))
.map(([, v]) => v);
assert.deepEqual(setFields.sort(), Object.keys(applied.updates).sort());
});
test("buildEventUpdate: missing snapshot clear_status becomes attribute_not_exists", () => {
const p = payment(); // no clear_status yet
const applied = applyEvent(p, paidEvent(), "2026-07-19");
const cmd = buildEventUpdate("Table", p, applied);
assert.equal(cmd.ConditionExpression, "#c0 = :c0 AND attribute_not_exists(#c1)");
assert.equal(":c1" in cmd.ExpressionAttributeValues, false);
});
// ----------------------------------------------------- ReDoS hardening
test("classifier: bounded regexes still parse zero-padded and normal check descriptions", () => {
const c = classifyTransaction({ text: "CHECK # 0003176", amount: "-350" });
assert.equal(c.event, "check_paid");
assert.equal(c.checkNumber, "3176");
});
test("classifier: pathological long input is bounded, classified without hanging", () => {
const junk = `CHECK ${" ".repeat(2000)}${"9".repeat(2000)}`;
const start = Date.now();
const c = classifyTransaction({ text: junk, customerReference: "42", amount: "-1" });
assert.ok(Date.now() - start < 1000);
// Not parseable as a paid check -> unknown (check-shaped), never dropped.
assert.equal(c.event, "unknown");
assert.ok(c.description.length <= 500);
});
// ------------------------------------------------------------- date range
test("resolveDateRange: default is the trailing 7-day window ending yesterday", () => {
feat(fetchboa): bank-truth reconciliation v2 — returns, redeposits, ACH confirmation (#73) * feat(fetchboa): v2 return/redeposit-aware reconciliation (#66) The two-code 475/255 filter missed every return on the Jan-Jul statement (29 ARP refer-to-maker credits, 24 electronic return credits, and the redeposit cycles), leaving 5 paid-then-returned checks stuck at Cleared ($5,256.62, vendors unpaid). Rewrite fetchBoaTransactions around a pure reconciliation module (src/boaRecon.js) covered by node:test: - BAI transaction-code event map (only 475 = check paid is confirmed; remaining codes pend the enumeration replay) with a description-text fallback classifier for ARP refer-to-maker, return-of-posted-check (check-numbered and electronic) and ACH DES:PAYMENTS formats. Unknown check-shaped transactions are logged and counted, never dropped. - Matching on check number AND amount over all candidates; wrong-amount or collapsed-number postings fall back to a unique exact-amount match within checks issued in the last 120 days; ambiguity means unmatched with no write. - Transitions always set BOTH status and clear_status (the missed returns slipped through the divergence between them). clear_status is bank truth: returns apply even to Cleared records, a second paid debit on a Returned check is a redeposit back to Cleared, and a return on a voided check is terminal voided-and-bounced. Every applied event is appended to a history list; identical replayed events are noops. - Optional {fromDate, toDate} replay payload (strictly validated) for gap replays and the BAI-code enumeration runs; default stays yesterday. - Each run writes a boa_recon#<runDate> summary item (90-day TTL) with per-event counts, unmatched check numbers/amounts, and unknown codes; unmatched/unknown also console.error (Slack alerting is #71). ACH transactions are classified but not yet acted on; bank-confirming ACH lands with #69. * feat(ach): bank-confirm ACH, drop CSV-date auto-clear (#69) processPaymentCsv auto-cleared ACH the moment send_payment_on passed, but the bank disagrees often enough to matter: settlement lags the send date by up to 8 days, settled ACH can bounce and re-debit (Uline $19,281.12, Heights $10,000.00 on the 5/26 overdrawn week), and voided ACH that settled anyway returned via electronic credits invisible to a Check-only feed. Move ACH to bank confirmation: - processPaymentCsv: ACH rows keep their Stampli status; the send-date auto-clear is removed. The bankConfirmed protection is unchanged, so bank-cleared records still cannot be moved backward by the CSV. - fetchBoaTransactions scans all payments (method filter dropped) and processes ACH CCD lines: match by stored pmt_id first (reversals reuse the original PMT id), then by the Stampli payment number embedded in PMT INFO (internal spaces stripped, amount must agree), then vendor + exact amount within send_payment_on -2..+14 days. Settled debit -> Cleared/Cleared with dates, pmt_id persisted on the record; credit reusing the pmt_id (or a unique same-amount return credit against a bank-confirmed ACH) -> clear_status=Returned + returned_date + history event. Ambiguity is unmatched, no write. Existing DDB ACH records already Cleared by the old auto-clear are unaffected: bank confirmation is additive and the CSV path never moves a record backward. Handler event contract extended (optional fromDate/toDate); cross-family review required before merge. * fix(fetchboa): close review findings — coverage gap, matcher corroboration, replay identity (#66) Security-review gate (detector fan-out + verifier + GPT-4.1 cross-family) blocked on F6 and confirmed six lower findings; all are closed here. - F6 (HIGH): the default run now queries a trailing 3-day window (today-3..today-1) so the Monday run covers Friday-Sunday postings the previous-day cadence silently skipped. A per-run staleness sweep flags never-bank-confirmed payments (ACH > 16 days, checks > 60 days) in the summary and via console.error. - F2: replay identity is {event, date, amount} — bankRef excluded (feeds omit/reformat it between runs); a paid event dated on/before the latest return in history is a replayed original, never a redeposit; rows without a valid valueDate are routed to unmatched instead of being applied under a substituted date; within a day, debits sort before credits. - F1: a check-number match with the wrong amount no longer falls through to the amount fallback (altered-check/collapsed-posting is a human-review case); the amount fallback requires digit-subsequence corroboration between posting and candidate numbers; check_return fallback requires a bank-confirmed candidate. - F4: the ach_return amount fallback requires cleared_date within 45 days before the credit; an unattributable PMT id is an unmatched alert, never an amount guess. - F5: the pmt_id rung requires amount equality; mismatch is the partial-reversal human case. - F3: vendor prefix matching requires >= 10 normalized chars (short names must match exactly); candidates with a conflicting stored pmt_id are excluded; vendor+amount matches are audit-logged. - F7: payment writes are conditioned on the snapshot's status/clear_status and retried once against a fresh read; residual conflicts are counted, not silently interleaved. - F9/summary bounds: run summary pk is append-only (boa_recon#<from>_<to>#<runAt>); unmatched list capped at 50, unknown codes at 20 keys/16 chars, stale list at 50 (full counts kept). - ReDoS: description bounded to 500 chars before classification; CHECK/embedded-number regexes use bounded quantifiers. - FBOA2-1: both BoA res.json() parses are wrapped so a malformed 200 throws a status-only error and cannot leak a body snippet. Handler event contract extended (optional fromDate/toDate); cross-family review required before merge. * fix(boaRecon): add method=Check filter to matchElectronicReturn Electronic returns only apply to checks, but the matcher was not filtering by method, so an electronic return could incorrectly match against a same-amount, bank-confirmed ACH record.
2026-07-21 22:07:37 -04:00
const r = resolveDateRange({}, new Date("2026-07-21T13:00:00Z"));
assert.deepEqual(r, { fromDate: "2026-07-14", toDate: "2026-07-20" });
feat(fetchboa): bank-truth reconciliation v2 — returns, redeposits, ACH confirmation (#73) * feat(fetchboa): v2 return/redeposit-aware reconciliation (#66) The two-code 475/255 filter missed every return on the Jan-Jul statement (29 ARP refer-to-maker credits, 24 electronic return credits, and the redeposit cycles), leaving 5 paid-then-returned checks stuck at Cleared ($5,256.62, vendors unpaid). Rewrite fetchBoaTransactions around a pure reconciliation module (src/boaRecon.js) covered by node:test: - BAI transaction-code event map (only 475 = check paid is confirmed; remaining codes pend the enumeration replay) with a description-text fallback classifier for ARP refer-to-maker, return-of-posted-check (check-numbered and electronic) and ACH DES:PAYMENTS formats. Unknown check-shaped transactions are logged and counted, never dropped. - Matching on check number AND amount over all candidates; wrong-amount or collapsed-number postings fall back to a unique exact-amount match within checks issued in the last 120 days; ambiguity means unmatched with no write. - Transitions always set BOTH status and clear_status (the missed returns slipped through the divergence between them). clear_status is bank truth: returns apply even to Cleared records, a second paid debit on a Returned check is a redeposit back to Cleared, and a return on a voided check is terminal voided-and-bounced. Every applied event is appended to a history list; identical replayed events are noops. - Optional {fromDate, toDate} replay payload (strictly validated) for gap replays and the BAI-code enumeration runs; default stays yesterday. - Each run writes a boa_recon#<runDate> summary item (90-day TTL) with per-event counts, unmatched check numbers/amounts, and unknown codes; unmatched/unknown also console.error (Slack alerting is #71). ACH transactions are classified but not yet acted on; bank-confirming ACH lands with #69. * feat(ach): bank-confirm ACH, drop CSV-date auto-clear (#69) processPaymentCsv auto-cleared ACH the moment send_payment_on passed, but the bank disagrees often enough to matter: settlement lags the send date by up to 8 days, settled ACH can bounce and re-debit (Uline $19,281.12, Heights $10,000.00 on the 5/26 overdrawn week), and voided ACH that settled anyway returned via electronic credits invisible to a Check-only feed. Move ACH to bank confirmation: - processPaymentCsv: ACH rows keep their Stampli status; the send-date auto-clear is removed. The bankConfirmed protection is unchanged, so bank-cleared records still cannot be moved backward by the CSV. - fetchBoaTransactions scans all payments (method filter dropped) and processes ACH CCD lines: match by stored pmt_id first (reversals reuse the original PMT id), then by the Stampli payment number embedded in PMT INFO (internal spaces stripped, amount must agree), then vendor + exact amount within send_payment_on -2..+14 days. Settled debit -> Cleared/Cleared with dates, pmt_id persisted on the record; credit reusing the pmt_id (or a unique same-amount return credit against a bank-confirmed ACH) -> clear_status=Returned + returned_date + history event. Ambiguity is unmatched, no write. Existing DDB ACH records already Cleared by the old auto-clear are unaffected: bank confirmation is additive and the CSV path never moves a record backward. Handler event contract extended (optional fromDate/toDate); cross-family review required before merge. * fix(fetchboa): close review findings — coverage gap, matcher corroboration, replay identity (#66) Security-review gate (detector fan-out + verifier + GPT-4.1 cross-family) blocked on F6 and confirmed six lower findings; all are closed here. - F6 (HIGH): the default run now queries a trailing 3-day window (today-3..today-1) so the Monday run covers Friday-Sunday postings the previous-day cadence silently skipped. A per-run staleness sweep flags never-bank-confirmed payments (ACH > 16 days, checks > 60 days) in the summary and via console.error. - F2: replay identity is {event, date, amount} — bankRef excluded (feeds omit/reformat it between runs); a paid event dated on/before the latest return in history is a replayed original, never a redeposit; rows without a valid valueDate are routed to unmatched instead of being applied under a substituted date; within a day, debits sort before credits. - F1: a check-number match with the wrong amount no longer falls through to the amount fallback (altered-check/collapsed-posting is a human-review case); the amount fallback requires digit-subsequence corroboration between posting and candidate numbers; check_return fallback requires a bank-confirmed candidate. - F4: the ach_return amount fallback requires cleared_date within 45 days before the credit; an unattributable PMT id is an unmatched alert, never an amount guess. - F5: the pmt_id rung requires amount equality; mismatch is the partial-reversal human case. - F3: vendor prefix matching requires >= 10 normalized chars (short names must match exactly); candidates with a conflicting stored pmt_id are excluded; vendor+amount matches are audit-logged. - F7: payment writes are conditioned on the snapshot's status/clear_status and retried once against a fresh read; residual conflicts are counted, not silently interleaved. - F9/summary bounds: run summary pk is append-only (boa_recon#<from>_<to>#<runAt>); unmatched list capped at 50, unknown codes at 20 keys/16 chars, stale list at 50 (full counts kept). - ReDoS: description bounded to 500 chars before classification; CHECK/embedded-number regexes use bounded quantifiers. - FBOA2-1: both BoA res.json() parses are wrapped so a malformed 200 throws a status-only error and cannot leak a body snippet. Handler event contract extended (optional fromDate/toDate); cross-family review required before merge. * fix(boaRecon): add method=Check filter to matchElectronicReturn Electronic returns only apply to checks, but the matcher was not filtering by method, so an electronic return could incorrectly match against a same-amount, bank-confirmed ACH record.
2026-07-21 22:07:37 -04:00
});
test("resolveDateRange: explicit valid range passes through", () => {
const r = resolveDateRange({ fromDate: "2026-06-16", toDate: "2026-06-23" });
assert.deepEqual(r, { fromDate: "2026-06-16", toDate: "2026-06-23" });
});
test("resolveDateRange: fromDate only replays a single day", () => {
const r = resolveDateRange({ fromDate: "2026-06-08" });
assert.deepEqual(r, { fromDate: "2026-06-08", toDate: "2026-06-08" });
});
test("resolveDateRange: malformed and impossible dates throw, naming the offending value", () => {
assert.throws(() => resolveDateRange({ fromDate: "06/08/2026" }), /06\/08\/2026/);
assert.throws(() => resolveDateRange({ fromDate: "2026-02-30" }), /2026-02-30/);
assert.throws(
() => resolveDateRange({ fromDate: "2026-07-02", toDate: "2026-07-01" }),
/fromDate="2026-07-02", toDate="2026-07-01"/
);
assert.throws(() => resolveDateRange({ fromDate: "2026-07-01; DROP", toDate: "2026-07-02" }));
});
test("isValidISODate rejects non-strings and bad calendar dates", () => {
assert.equal(isValidISODate("2026-07-20"), true);
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");
});