mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-02 21:33:54 +00:00
552 lines
22 KiB
JavaScript
552 lines
22 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. 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{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();
|
||
|
|
const description = String(
|
||
|
|
txn.text ?? txn.description ?? txn.transactionDescription ?? ""
|
||
|
|
)
|
||
|
|
.slice(0, MAX_DESCRIPTION_LEN)
|
||
|
|
.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;
|
||
|
|
};
|
||
|
|
|
||
|
|
// 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) {
|
||
|
|
const { event, amount, bankReference } = classified;
|
||
|
|
const canceled = isCancelStatus(payment.status);
|
||
|
|
const historyEvent = {
|
||
|
|
event,
|
||
|
|
date: eventDateISO,
|
||
|
|
bankRef: bankReference,
|
||
|
|
amount,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 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
|
||
|
|
// 3-day window (today-3 .. today-1): the previous-day feed only runs on
|
||
|
|
// weekdays, so the Monday run must cover Friday through Sunday. 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.
|
||
|
|
export function resolveDateRange(event, now = new Date()) {
|
||
|
|
const hasFrom = event?.fromDate != null;
|
||
|
|
const hasTo = event?.toDate != null;
|
||
|
|
if (!hasFrom && !hasTo) {
|
||
|
|
const day = 24 * 60 * 60 * 1000;
|
||
|
|
return {
|
||
|
|
fromDate: new Date(now.getTime() - 3 * 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,
|
||
|
|
};
|
||
|
|
}
|