payments-dashboard/src/boaRecon.js

328 lines
14 KiB
JavaScript
Raw Normal View History

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.
2026-07-21 19:40:25 -04:00
// Pure reconciliation logic for fetchBoaTransactions (payments-dashboard#66).
// No AWS clients or environment access here so node:test can exercise the
// classifier, matcher, and state transitions directly.
import { toISODate } from "./dates.js";
// Untrusted values (bank descriptions, references) must be JSON-encoded
// before log interpolation — bank text can carry newlines, which would forge
// CloudWatch log lines (CWE-117). Same rule as processPaymentCsv's logSafe.
export const logSafe = (v) => JSON.stringify(String(v ?? "").slice(0, 128));
// Comma-tolerant amount parsing ("1,234.56" bank strings and stored values).
export const parseAmount = (value) => {
const num = parseFloat(String(value ?? "0").replace(/,/g, "").trim());
return isNaN(num) ? 0 : num;
};
const cents = (v) => Math.round(Math.abs(parseAmount(v)) * 100);
// Sign-insensitive cent equality; zero never matches (a blank amount must
// 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.
export const BAI_CODE_EVENTS = {
475: { event: "check_paid", direction: "debit" },
};
// Standard BAI ranges: 100-399 are credit type codes, 400-699 are debit
// type codes. Used only when the feed carries no explicit indicator.
export function directionFromCode(code) {
const n = parseInt(String(code ?? ""), 10);
if (!Number.isInteger(n)) return null;
if (n >= 100 && n < 400) return "credit";
if (n >= 400 && n < 700) return "debit";
return null;
}
export function directionOf(txn) {
const indicator = String(
txn.debitCreditIndicator ?? txn.creditDebitIndicator ?? ""
).toUpperCase();
if (indicator.includes("DEBIT")) return "debit";
if (indicator.includes("CREDIT")) return "credit";
const fromCode = directionFromCode(txn.transactionCode);
if (fromCode) return fromCode;
const amt = parseAmount(txn.amount);
if (amt < 0) return "debit";
if (amt > 0) return "credit";
return null;
}
// 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;
const POSTED_RETURN_CHECK_RE =
/^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;
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*#?\s*(\d+)$/i;
// Trailing digit run at the end of PMT INFO — Stampli recently started
// embedding the payment number there, and the bank wraps it with arbitrary
// internal spaces ("21 222000108", "2122200 0256").
const EMBEDDED_NUMBER_RE = /(\d[\d ]{6,}\d)\s*$/;
const stripLeadingZeros = (s) => String(s ?? "").replace(/^0+(?=\d)/, "");
// Classify one Previous Day transaction into a reconciliation event.
// Precedence: confirmed BAI code map first, then description text. Anything
// unmapped that still looks check/payment-shaped comes back as "unknown" so
// the caller can log and count it — never silently drop it. Everything else
// (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 ?? ""
).trim();
const customerReference = stripLeadingZeros(String(txn.customerReference ?? "").trim());
const bankReference = String(txn.bankReference ?? "").trim();
const amount = Math.abs(parseAmount(txn.amount));
const direction = BAI_CODE_EVENTS[code]?.direction ?? directionOf(txn);
const base = {
code,
description,
customerReference,
bankReference,
amount,
direction,
checkNumber: null,
pmtId: null,
embeddedPaymentNumber: null,
vendorText: null,
checkShaped: false,
};
// Description facts, extracted regardless of code so a code-mapped event
// still carries the check number / PMT id it references.
let descriptionEvent = null;
let m;
if ((m = ARP_RETURN_RE.exec(description))) {
descriptionEvent = "check_return";
base.checkNumber = stripLeadingZeros(m[1]);
} else if ((m = POSTED_RETURN_CHECK_RE.exec(description))) {
descriptionEvent = "check_return";
base.checkNumber = stripLeadingZeros(m[1]);
} else if (POSTED_RETURN_ELECTRONIC_RE.test(description)) {
descriptionEvent = "electronic_return";
} else if ((m = ACH_PMT_RE.exec(description))) {
base.pmtId = m[1];
const info = PMT_INFO_RE.exec(description);
if (info) {
let vendorText = info[1].trim();
const embedded = EMBEDDED_NUMBER_RE.exec(vendorText);
if (embedded && embedded[1].replace(/ /g, "").length >= 8) {
base.embeddedPaymentNumber = embedded[1].replace(/ /g, "");
vendorText = vendorText.slice(0, embedded.index).trim();
}
base.vendorText = vendorText || null;
}
if (direction === "debit") descriptionEvent = "ach_debit";
else if (direction === "credit") descriptionEvent = "ach_return";
// direction unknown -> leave null; falls through to "unknown" below.
} else if ((m = CHECK_PAID_RE.exec(description))) {
descriptionEvent = "check_paid";
base.checkNumber = stripLeadingZeros(m[1]);
}
const event = BAI_CODE_EVENTS[code]?.event ?? descriptionEvent;
if (event === "check_paid" && !base.checkNumber) {
base.checkNumber = customerReference || null;
}
if (event === "check_return" && !base.checkNumber) {
base.checkNumber = customerReference || null;
}
if (event) return { ...base, event };
base.checkShaped =
Boolean(customerReference) || /CHECK/i.test(description) || Boolean(base.pmtId);
return { ...base, event: base.checkShaped ? "unknown" : "ignored" };
}
// Days from a stored Stampli send date (canonical MM/DD/YYYY) to an ISO
// reference date; null when the stored date does not parse.
function daysSinceIssue(payment, refISO) {
const iso = toISODate(payment.send_payment_on);
if (!iso) return null;
return Math.round((Date.parse(refISO) - Date.parse(iso)) / 86400000);
}
const issuedWithinDays = (payment, refISO, days) => {
const d = daysSinceIssue(payment, refISO);
return d !== null && d >= 0 && d <= days;
};
// Match a check event (paid debit or return credit) against payment records.
// Match on check number AND amount, evaluating all candidates — never
// first-match on number alone: bank postings drop/collapse digits on long
// check numbers, so a posting under one number can belong to another check
// (or to a number we never issued). When the number matches but the amount
// does not — or the number is unknown — fall back to an exact-amount match
// within checks issued in the last 120 days. Anything but a single fallback
// candidate is unmatched: no write on ambiguity.
export function matchCheckTransaction(classified, payments, refDateISO) {
const { checkNumber, amount } = classified;
const checks = payments.filter((p) => p.method === "Check" && p.check_number);
const numberMatches = checkNumber
? checks.filter((p) => p.check_number === checkNumber)
: [];
const exact = numberMatches.filter((p) => amountsEqual(p.amount_usd, amount));
if (exact.length === 1) return { payment: exact[0], matchedBy: "number+amount" };
if (exact.length > 1) return { unmatched: "multiple number+amount matches" };
const fallback = checks.filter(
(p) => amountsEqual(p.amount_usd, amount) && issuedWithinDays(p, refDateISO, 120)
);
if (fallback.length === 1) return { payment: fallback[0], matchedBy: "amount" };
if (fallback.length > 1) {
return { unmatched: `amount fallback ambiguous (${fallback.length} candidates)` };
}
return {
unmatched: numberMatches.length
? "number matched with wrong amount; no unique amount fallback"
: "no number match; no unique amount fallback",
};
}
// Electronic return credits carry no check number at all. Match by exact
// amount among bank-confirmed payments issued in the last 120 days
// (clear_status Cleared, or Returned so replays of an already-applied
// return dedupe to a noop instead of alerting). Ambiguity is unmatched.
export function matchElectronicReturn(classified, payments, refDateISO) {
const candidates = payments.filter(
(p) =>
(p.clear_status === "Cleared" || p.clear_status === "Returned") &&
amountsEqual(p.amount_usd, classified.amount) &&
issuedWithinDays(p, refDateISO, 120)
);
if (candidates.length === 1) return { payment: candidates[0], matchedBy: "amount+cleared" };
if (candidates.length > 1) {
return { unmatched: `electronic return ambiguous (${candidates.length} candidates)` };
}
return { unmatched: "no bank-confirmed payment with this amount" };
}
const CANCEL_STATUSES = ["voided", "cancelled", "canceled", "marked as void"];
export const isCancelStatus = (s) => CANCEL_STATUSES.includes(String(s ?? "").toLowerCase());
// Decide the DDB write for a matched bank event. Pure: returns the fields
// to set plus the history entry; the handler turns it into an UpdateCommand
// and mirrors the fields onto its in-memory copy.
//
// Invariant: every non-noop result sets BOTH status and clear_status — the
// 2026-07-21 reconciliation traced five missed returns ($5,256.62) to
// writers touching one field but not the other.
//
// Status semantics (documented decision, #66): `clear_status` is bank truth
// ("Cleared without a subsequent return is permanent" keys off it). `status`
// stays on the CSV lifecycle ladder, which has no "Returned" rung, so on a
// return it is re-written with its current value to keep the both-fields
// invariant; consumers that need bounce visibility read clear_status. On a
// paid debit against a canceled record (a voided check the bank paid —
// expected without Positive Pay), the cancel status is likewise preserved:
// the ARP return that follows lands as terminal voided-and-bounced.
export function applyEvent(payment, classified, eventDateISO) {
const { event, amount, bankReference } = classified;
const canceled = isCancelStatus(payment.status);
const historyEvent = {
event,
date: eventDateISO,
bankRef: bankReference,
amount,
};
// Replay idempotence: an identical event already recorded in history is a
// noop, so re-running a date range never double-applies transitions.
const alreadyApplied = (payment.history || []).some(
(h) =>
h.event === historyEvent.event &&
h.date === historyEvent.date &&
h.bankRef === historyEvent.bankRef &&
amountsEqual(h.amount, amount)
);
if (alreadyApplied) return { kind: "noop", updates: null, historyEvent: null };
if (event === "check_paid" || event === "ach_debit") {
const redeposit = payment.clear_status === "Returned";
const updates = {
status: canceled ? payment.status : "Cleared",
clear_status: "Cleared",
paid_date: eventDateISO,
cleared_date: eventDateISO,
bank_reference: bankReference,
};
if (event === "ach_debit" && classified.pmtId) updates.pmt_id = classified.pmtId;
return {
kind: redeposit ? "redeposit" : canceled ? "cleared_on_canceled" : "cleared",
updates,
historyEvent,
};
}
if (event === "check_return" || event === "electronic_return" || event === "ach_return") {
const updates = {
status: payment.status ?? "",
clear_status: "Returned",
returned_date: eventDateISO,
bank_reference: bankReference,
};
if (event === "ach_return" && classified.pmtId) updates.pmt_id = classified.pmtId;
return {
// A return against a canceled record is the expected void-then-bounce
// ARP cycle: terminal voided-and-bounced, counted separately.
kind: canceled ? "voided_and_bounced" : "returned",
updates,
historyEvent,
};
}
return { kind: "noop", updates: null, historyEvent: null };
}
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
export function isValidISODate(s) {
if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false;
const [y, mo, d] = s.split("-").map(Number);
const dt = new Date(Date.UTC(y, mo - 1, d));
return (
dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d
);
}
// Optional {fromDate, toDate} replay payload; the default stays yesterday,
// matching the previous-day inquiry cadence. Strings are validated strictly
// so a malformed payload fails loudly instead of querying a garbage range.
export function resolveDateRange(event, now = new Date()) {
const hasFrom = event?.fromDate != null;
const hasTo = event?.toDate != null;
if (!hasFrom && !hasTo) {
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const d = yesterday.toISOString().split("T")[0];
return { fromDate: d, toDate: d };
}
const fromDate = hasFrom ? event.fromDate : event.toDate;
const toDate = hasTo ? event.toDate : event.fromDate;
if (!isValidISODate(fromDate) || !isValidISODate(toDate)) {
throw new Error("fromDate/toDate must be valid YYYY-MM-DD strings");
}
if (fromDate > toDate) {
throw new Error("fromDate must be <= toDate");
}
return { fromDate, toDate };
}