mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 10:03:12 +00:00
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
695 lines
29 KiB
JavaScript
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;
|
|
}
|