payments-dashboard/src/boaRecon.js
Adam Moussa ffab1c8f7b
feat(fetchboa): intraday current-day runs, raw S3 archive, balance records (#76)
Same-day settlement visibility plus maximal data capture from the
reporting feed. The handler gains an allowlisted endpoint parameter
(EventBridge passes {"endpoint":"current-day"} on a new 16/19/22 UTC
weekday rule; the 9am previous-day sweep is unchanged and remains
authoritative). Every response's exact bytes archive to a new
Retain-protected bucket before classification, so the feed is
replayable and auditable even across parser changes. Summary rows
become per-date boa_balance# snapshots (latest-wins on run_at, no
TTL) instead of being discarded. The staleness sweep is gated to
previous-day runs so intraday runs don't re-alert the backlog three
times a day. History events carry a via:<endpoint> audit tag outside
the replay-idempotence identity. Fixtures are sanitized real API
captures; classification histograms assert against live-verified
counts.

Refs: #66, #69
2026-07-22 16:02:48 -04:00

695 lines
29 KiB
JavaScript

// 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, 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
// 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;
// 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 =
/^(?:\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 =
/^(?:\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;
// 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"). Run length is bounded
// against pathological input.
const EMBEDDED_NUMBER_RE = /(\d[\d ]{6,40}\d)\s*$/;
// Bound untrusted text before any regex work (ReDoS hardening).
const MAX_DESCRIPTION_LEN = 500;
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();
// 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);
const base = {
code,
description,
customerReference,
bankReference,
amount,
direction,
checkNumber: null,
pmtId: null,
embeddedPaymentNumber: null,
vendorText: null,
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;
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 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;
}
if (event === "check_return" && !base.checkNumber) {
base.checkNumber = customerReference || null;
}
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(rawReference) || /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;
};
// True when a's digits appear, in order, inside b (digit-dropped mangling:
// posting 1222000012 came from issued 11222000012).
function isDigitSubsequence(a, b) {
if (a.length > b.length) return false;
let i = 0;
for (let j = 0; j < b.length && i < a.length; j++) {
if (a[i] === b[j]) i++;
}
return i === a.length;
}
export const digitsCorroborate = (a, b) =>
Boolean(a && b) && (isDigitSubsequence(a, b) || isDigitSubsequence(b, a));
// 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).
//
// A number match with the WRONG amount never falls through to the amount
// fallback: that pattern is the altered-check / collapsed-posting signal a
// human must review, so it goes to unmatched. The amount-only fallback
// (number unknown) is limited to checks issued in the last 120 days AND
// requires digit-subsequence corroboration between the posting's number and
// the candidate's; return credits additionally require a bank-confirmed
// candidate. Anything but a single fallback candidate is unmatched: no
// write on ambiguity.
export function matchCheckTransaction(classified, payments, refDateISO) {
const { checkNumber, amount, event } = 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" };
if (numberMatches.length) {
return { unmatched: "number matched, amount mismatch" };
}
const fallback = checks.filter(
(p) =>
amountsEqual(p.amount_usd, amount) &&
issuedWithinDays(p, refDateISO, 120) &&
digitsCorroborate(checkNumber, p.check_number) &&
(event !== "check_return" ||
p.clear_status === "Cleared" ||
p.clear_status === "Returned")
);
if (fallback.length === 1) return { payment: fallback[0], matchedBy: "amount" };
if (fallback.length > 1) {
return { unmatched: `amount fallback ambiguous (${fallback.length} candidates)` };
}
return { unmatched: "no number match; no corroborated 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.method === "Check" &&
(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" };
}
// Bank originator names are truncated ("ALLIANCE SANITAT") and punctuation
// drifts, so vendor comparison is prefix-based over normalized text.
const normalizeVendor = (s) =>
String(s ?? "")
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
// Prefix matching only counts when the shorter normalized string is at
// least 10 chars; short names must match exactly ("ACME" must not claim
// "ACME Plumbing Co").
export function vendorMatches(payee, vendorText) {
const a = normalizeVendor(payee);
const b = normalizeVendor(vendorText);
if (!a || !b) return false;
if (a === b) return true;
if (Math.min(a.length, b.length) < 10) return false;
return a.startsWith(b) || b.startsWith(a);
}
// ACH settles up to 8 days after the Stampli send date, and can post a
// couple of days early; the posting must fall within send_payment_on
// -2..+14 days.
const withinSendWindow = (payment, postingISO) => {
const d = daysSinceIssue(payment, postingISO);
return d !== null && d >= -2 && d <= 14;
};
// Candidate cleared_date must sit within `days` before the credit posting;
// records without a valid cleared_date are excluded.
const clearedWithinDaysBefore = (payment, postingISO, days) => {
if (!isValidISODate(payment.cleared_date)) return false;
const d = Math.round((Date.parse(postingISO) - Date.parse(payment.cleared_date)) / 86400000);
return d >= 0 && d <= days;
};
// Match an ACH CCD debit or return credit (payments-dashboard#69).
// Rungs, every one amount-corroborated:
// 1. stored pmt_id (reversals reuse the original PMT id); a pmt_id match
// with the wrong amount is the partial-reversal human case — unmatched.
// 2. Stampli payment number embedded in PMT INFO (spaces stripped); a
// wrong-amount embedded match falls through.
// 3. vendor + exact amount within the send window (candidates whose stored
// pmt_id differs from the transaction's are excluded).
// 4. return credits only: if the credit's PMT id attributes to no stored
// pmt_id, that is an unmatched alert ("unknown PMT id") — never an
// amount guess. Credits without a PMT id may fall back to a unique
// same-amount match among bank-confirmed ACH payments whose
// cleared_date is within 45 days before the credit.
// Ambiguity is always unmatched — no write.
export function matchAchTransaction(classified, payments, postingISO) {
const { event, pmtId, embeddedPaymentNumber, vendorText, amount } = classified;
const achs = payments.filter((p) => p.method === "ACH");
if (pmtId) {
const byPmtId = achs.filter((p) => p.pmt_id === pmtId);
if (byPmtId.length === 1) {
if (amountsEqual(byPmtId[0].amount_usd, amount)) {
return { payment: byPmtId[0], matchedBy: "pmt_id" };
}
return { unmatched: "pmt_id matched, amount mismatch" };
}
if (byPmtId.length > 1) return { unmatched: "multiple pmt_id matches" };
}
if (embeddedPaymentNumber) {
const byNumber = achs.filter(
(p) => p.check_number === embeddedPaymentNumber && amountsEqual(p.amount_usd, amount)
);
if (byNumber.length === 1) return { payment: byNumber[0], matchedBy: "payment-number+amount" };
}
const pmtIdConflicts = (p) => Boolean(pmtId && p.pmt_id && p.pmt_id !== pmtId);
const byVendor = achs.filter(
(p) =>
!pmtIdConflicts(p) &&
amountsEqual(p.amount_usd, amount) &&
vendorMatches(p.payee, vendorText) &&
withinSendWindow(p, postingISO)
);
if (byVendor.length === 1) return { payment: byVendor[0], matchedBy: "vendor+amount" };
if (byVendor.length > 1) {
return { unmatched: `vendor+amount ambiguous (${byVendor.length} candidates)` };
}
if (event === "ach_return") {
if (pmtId) return { unmatched: "unknown PMT id" };
const byAmount = achs.filter(
(p) =>
!pmtIdConflicts(p) &&
(p.clear_status === "Cleared" || p.clear_status === "Returned") &&
amountsEqual(p.amount_usd, amount) &&
clearedWithinDaysBefore(p, postingISO, 45)
);
if (byAmount.length === 1) return { payment: byAmount[0], matchedBy: "amount+cleared" };
if (byAmount.length > 1) {
return { unmatched: `return amount ambiguous (${byAmount.length} candidates)` };
}
}
return { unmatched: "no unique pmt_id, payment-number, or vendor+amount match" };
}
const CANCEL_STATUSES = ["voided", "cancelled", "canceled", "marked as void"];
export const isCancelStatus = (s) => CANCEL_STATUSES.includes(String(s ?? "").toLowerCase());
const RETURN_EVENTS = new Set(["check_return", "electronic_return", "ach_return"]);
// 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, via = null) {
const { event, amount, bankReference } = classified;
const canceled = isCancelStatus(payment.status);
const historyEvent = {
event,
date: eventDateISO,
bankRef: bankReference,
amount,
// Audit tag only (which endpoint applied this): deliberately excluded
// from the replay-idempotence identity below.
...(via ? { via } : {}),
};
// Replay idempotence: event identity is {event, date, amount} — bankRef
// is deliberately excluded because feeds omit/reformat it between runs.
// Assumption: the bank never posts two DISTINCT same-type events for the
// same payment on the same date with the same amount; an identical
// identity already in history is therefore the same event, and a noop.
const alreadyApplied = (payment.history || []).some(
(h) =>
h.event === historyEvent.event &&
h.date === historyEvent.date &&
amountsEqual(h.amount, amount)
);
if (alreadyApplied) return { kind: "noop", updates: null, historyEvent: null };
if (event === "check_paid" || event === "ach_debit") {
// A paid event dated on/before the latest known return is a replayed
// original paid debit, not a redeposit — never re-clear from it.
const latestReturnDate = (payment.history || [])
.filter((h) => RETURN_EVENTS.has(h.event) && typeof h.date === "string")
.map((h) => h.date)
.sort()
.pop();
if (latestReturnDate && eventDateISO <= latestReturnDate) {
return { kind: "noop", updates: null, historyEvent: null };
}
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 is a trailing
// 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.
// Endpoint allowlist: the value is interpolated into the request URL, so
// anything outside the two known inquiry endpoints throws loudly (path
// injection guard; also catches typo'd EventBridge Input).
const ENDPOINTS = new Set(["previous-day", "current-day"]);
export function resolveEndpoint(event) {
const endpoint = event?.endpoint ?? "previous-day";
if (!ENDPOINTS.has(endpoint)) {
throw new Error(`endpoint must be one of ${[...ENDPOINTS].join(", ")}: ${logSafe(endpoint)}`);
}
return endpoint;
}
export function resolveDateRange(event, now = new Date(), endpoint = "previous-day") {
const hasFrom = event?.fromDate != null;
const hasTo = event?.toDate != null;
if (!hasFrom && !hasTo && endpoint === "current-day") {
// Intraday default: today only. Explicit ranges are allowed (the API
// accepts them — verified 2026-07-22) but the schedule never sends one.
const today = now.toISOString().split("T")[0];
return { fromDate: today, toDate: today };
}
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() - 7 * day).toISOString().split("T")[0],
toDate: new Date(now.getTime() - day).toISOString().split("T")[0],
};
}
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: ` +
`fromDate=${logSafe(fromDate)}, toDate=${logSafe(toDate)}`
);
}
if (fromDate > toDate) {
throw new Error(
`fromDate must be <= toDate: fromDate=${logSafe(fromDate)}, toDate=${logSafe(toDate)}`
);
}
return { fromDate, toDate };
}
// Staleness sweep (#66 review F6): flag records the bank has never
// confirmed. ACH with no clear_status and a send date older than 16 days
// (settlement lags at most ~8 business days) and checks older than 60 days
// are surfaced in the run summary; cancel-status records are exempt.
export const STALE_LIST_CAP = 50;
export function sweepStalePayments(payments, todayISO) {
const staleAch = [];
let staleAchCount = 0;
let staleChecksCount = 0;
for (const p of payments) {
if (p.clear_status || isCancelStatus(p.status)) continue;
const age = daysSinceIssue(p, todayISO);
if (age === null) continue;
if (p.method === "ACH" && age > 16) {
staleAchCount++;
if (staleAch.length < STALE_LIST_CAP) {
staleAch.push({
check_number: p.check_number,
amount: p.amount_usd,
send_payment_on: p.send_payment_on,
});
}
} else if (p.method === "Check" && age > 60) {
staleChecksCount++;
}
}
return { staleAch, staleAchCount, staleChecksCount };
}
// Conditioned write for an applied event (#66 review F7): the update only
// lands if the snapshot's status/clear_status are still current, so a
// concurrent CSV upsert can't be silently interleaved. The handler retries
// once against a fresh read on ConditionalCheckFailedException.
export function buildEventUpdate(tableName, payment, applied) {
const sets = ["#history = list_append(if_not_exists(#history, :empty), :hist)"];
const names = { "#history": "history" };
const values = { ":empty": [], ":hist": [applied.historyEvent] };
Object.entries(applied.updates).forEach(([field, value], i) => {
names[`#f${i}`] = field;
values[`:v${i}`] = value;
sets.push(`#f${i} = :v${i}`);
});
const conditions = [];
[
["status", payment.status],
["clear_status", payment.clear_status],
].forEach(([field, snapshot], i) => {
names[`#c${i}`] = field;
if (snapshot == null) {
conditions.push(`attribute_not_exists(#c${i})`);
} else {
values[`:c${i}`] = snapshot;
conditions.push(`#c${i} = :c${i}`);
}
});
return {
TableName: tableName,
Key: { pk: payment.pk },
UpdateExpression: `SET ${sets.join(", ")}`,
ConditionExpression: conditions.join(" AND "),
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
};
}
// Balance/summary extraction (#improvement-plan PR 2). Summary rows carry
// the account's balance and total lines per asOfDate; one output object per
// date so multi-day replay windows produce one snapshot each. Unrecognized
// summary codes land in `other` — never dropped.
const BALANCE_FIELDS = {
"010": "opening_ledger",
"030": "current_ledger",
"040": "opening_available",
"060": "current_available",
"072": "float_one_day",
"074": "float_two_day",
"100": "total_credits",
"400": "total_debits",
"450": "total_ach_debits",
};
const BALANCE_COUNT_FIELDS = new Set(["100", "400", "450"]);
// Caps: a well-formed response has ~46 summary codes per date and windows
// are at most a month; anything beyond is a malformed/hostile feed and must
// not balloon DDB items or write counts (counted, never silent).
const BALANCE_OTHER_CAP = 50;
const BALANCE_DATES_CAP = 31;
export function extractBalances(transactions) {
const byDate = new Map();
let datesTruncated = 0;
for (const txn of transactions ?? []) {
if (String(txn?.transactionType ?? "").trim().toLowerCase() !== "summary") continue;
const date = String(txn.asOfDate ?? "").trim();
if (!isValidISODate(date)) continue;
if (!byDate.has(date)) {
if (byDate.size >= BALANCE_DATES_CAP) {
datesTruncated++;
continue;
}
byDate.set(date, { as_of_date: date, other: {} });
}
const snap = byDate.get(date);
const code = String(txn.transactionCode ?? "").trim();
const amount = parseAmount(txn.amount);
if (!Number.isFinite(amount)) continue; // "Infinity" survives parseFloat; unmarshallable
const field = BALANCE_FIELDS[Object.hasOwn(BALANCE_FIELDS, code) ? code : ""];
if (field) {
snap[field] = amount;
if (BALANCE_COUNT_FIELDS.has(code) && txn.itemCount != null) {
const n = Number(txn.itemCount);
snap[`${field}_count`] = Number.isFinite(n) ? n : 0;
}
} else if (/^[0-9]{1,16}$/.test(code)) {
if (Object.keys(snap.other).length < BALANCE_OTHER_CAP) snap.other[code] = amount;
else snap.other_truncated = (snap.other_truncated || 0) + 1;
}
}
const out = [...byDate.values()];
if (datesTruncated) out.forEach((s) => (s.dates_truncated = datesTruncated));
return out;
}