// 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 }; }