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