feat(fetchboa): v2 return/redeposit-aware reconciliation (#66)
The two-code 475/255 filter missed every return on the Jan-Jul statement
(29 ARP refer-to-maker credits, 24 electronic return credits, and the
redeposit cycles), leaving 5 paid-then-returned checks stuck at Cleared
($5,256.62, vendors unpaid). Rewrite fetchBoaTransactions around a pure
reconciliation module (src/boaRecon.js) covered by node:test:
- BAI transaction-code event map (only 475 = check paid is confirmed;
remaining codes pend the enumeration replay) with a description-text
fallback classifier for ARP refer-to-maker, return-of-posted-check
(check-numbered and electronic) and ACH DES:PAYMENTS formats. Unknown
check-shaped transactions are logged and counted, never dropped.
- Matching on check number AND amount over all candidates; wrong-amount
or collapsed-number postings fall back to a unique exact-amount match
within checks issued in the last 120 days; ambiguity means unmatched
with no write.
- Transitions always set BOTH status and clear_status (the missed
returns slipped through the divergence between them). clear_status is
bank truth: returns apply even to Cleared records, a second paid debit
on a Returned check is a redeposit back to Cleared, and a return on a
voided check is terminal voided-and-bounced. Every applied event is
appended to a history list; identical replayed events are noops.
- Optional {fromDate, toDate} replay payload (strictly validated) for
gap replays and the BAI-code enumeration runs; default stays
yesterday.
- Each run writes a boa_recon#<runDate> summary item (90-day TTL) with
per-event counts, unmatched check numbers/amounts, and unknown codes;
unmatched/unknown also console.error (Slack alerting is #71).
ACH transactions are classified but not yet acted on; bank-confirming
ACH lands with #69.
2026-07-21 19:40:25 -04:00
|
|
|
// Pure reconciliation logic for fetchBoaTransactions (payments-dashboard#66).
|
|
|
|
|
// No AWS clients or environment access here so node:test can exercise the
|
|
|
|
|
// classifier, matcher, and state transitions directly.
|
|
|
|
|
|
|
|
|
|
import { toISODate } from "./dates.js";
|
|
|
|
|
|
|
|
|
|
// Untrusted values (bank descriptions, references) must be JSON-encoded
|
|
|
|
|
// before log interpolation — bank text can carry newlines, which would forge
|
|
|
|
|
// CloudWatch log lines (CWE-117). Same rule as processPaymentCsv's logSafe.
|
|
|
|
|
export const logSafe = (v) => JSON.stringify(String(v ?? "").slice(0, 128));
|
|
|
|
|
|
|
|
|
|
// Comma-tolerant amount parsing ("1,234.56" bank strings and stored values).
|
|
|
|
|
export const parseAmount = (value) => {
|
|
|
|
|
const num = parseFloat(String(value ?? "0").replace(/,/g, "").trim());
|
|
|
|
|
return isNaN(num) ? 0 : num;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cents = (v) => Math.round(Math.abs(parseAmount(v)) * 100);
|
|
|
|
|
|
|
|
|
|
// Sign-insensitive cent equality; zero never matches (a blank amount must
|
|
|
|
|
// not pair with another blank amount).
|
|
|
|
|
export const amountsEqual = (a, b) => cents(a) > 0 && cents(a) === cents(b);
|
|
|
|
|
|
|
|
|
|
// BAI transaction-code -> event map. BoA's Previous Day feed uses standard
|
|
|
|
|
// BAI type codes; only 475 (check paid, debit) is empirically confirmed so
|
|
|
|
|
// far. The codes for ARP refer-to-maker return credits, return-of-posted-
|
|
|
|
|
// check credits (check-numbered and electronic variants), ACH CCD debits and
|
|
|
|
|
// return credits, and the true meaning of 255 (the old filter assumed
|
|
|
|
|
// "returned check", unverified) are pending the enumeration replay over
|
|
|
|
|
// known event dates (2/20, 3/31, 4/8, 6/16-6/23) — add them here as they
|
|
|
|
|
// are confirmed. Until then classification falls back to description text,
|
|
|
|
|
// and unmapped codes on check-shaped transactions are logged and counted,
|
|
|
|
|
// never silently dropped.
|
|
|
|
|
export const BAI_CODE_EVENTS = {
|
|
|
|
|
475: { event: "check_paid", direction: "debit" },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Standard BAI ranges: 100-399 are credit type codes, 400-699 are debit
|
|
|
|
|
// type codes. Used only when the feed carries no explicit indicator.
|
|
|
|
|
export function directionFromCode(code) {
|
|
|
|
|
const n = parseInt(String(code ?? ""), 10);
|
|
|
|
|
if (!Number.isInteger(n)) return null;
|
|
|
|
|
if (n >= 100 && n < 400) return "credit";
|
|
|
|
|
if (n >= 400 && n < 700) return "debit";
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function directionOf(txn) {
|
|
|
|
|
const indicator = String(
|
|
|
|
|
txn.debitCreditIndicator ?? txn.creditDebitIndicator ?? ""
|
|
|
|
|
).toUpperCase();
|
|
|
|
|
if (indicator.includes("DEBIT")) return "debit";
|
|
|
|
|
if (indicator.includes("CREDIT")) return "credit";
|
|
|
|
|
const fromCode = directionFromCode(txn.transactionCode);
|
|
|
|
|
if (fromCode) return fromCode;
|
|
|
|
|
const amt = parseAmount(txn.amount);
|
|
|
|
|
if (amt < 0) return "debit";
|
|
|
|
|
if (amt > 0) return "credit";
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Statement description formats observed on the 2026-07-21 reconciliation.
|
|
|
|
|
const ARP_RETURN_RE = /^ARP RETURNED CHECK REFER TO MAKER CHECK #\s*(\d+)\b/i;
|
|
|
|
|
const POSTED_RETURN_CHECK_RE =
|
|
|
|
|
/^RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*CHECK #\s*(\d+)\b/i;
|
|
|
|
|
const POSTED_RETURN_ELECTRONIC_RE =
|
|
|
|
|
/^RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*ELECTRONIC TRANSACTION\b/i;
|
|
|
|
|
const ACH_PMT_RE = /DES:PAYMENTS\s+ID:PMT\s*(\d+)/i;
|
|
|
|
|
const PMT_INFO_RE = /PMT INFO:\s*(.*)$/i;
|
|
|
|
|
const CHECK_PAID_RE = /^CHECK\s*#?\s*(\d+)$/i;
|
|
|
|
|
// Trailing digit run at the end of PMT INFO — Stampli recently started
|
|
|
|
|
// embedding the payment number there, and the bank wraps it with arbitrary
|
|
|
|
|
// internal spaces ("21 222000108", "2122200 0256").
|
|
|
|
|
const EMBEDDED_NUMBER_RE = /(\d[\d ]{6,}\d)\s*$/;
|
|
|
|
|
|
|
|
|
|
const stripLeadingZeros = (s) => String(s ?? "").replace(/^0+(?=\d)/, "");
|
|
|
|
|
|
|
|
|
|
// Classify one Previous Day transaction into a reconciliation event.
|
|
|
|
|
// Precedence: confirmed BAI code map first, then description text. Anything
|
|
|
|
|
// unmapped that still looks check/payment-shaped comes back as "unknown" so
|
|
|
|
|
// the caller can log and count it — never silently drop it. Everything else
|
|
|
|
|
// (transfers, misc bank activity) is "ignored".
|
|
|
|
|
export function classifyTransaction(txn) {
|
|
|
|
|
const code = String(txn.transactionCode ?? "").trim();
|
|
|
|
|
const description = String(
|
|
|
|
|
txn.text ?? txn.description ?? txn.transactionDescription ?? ""
|
|
|
|
|
).trim();
|
|
|
|
|
const customerReference = stripLeadingZeros(String(txn.customerReference ?? "").trim());
|
|
|
|
|
const bankReference = String(txn.bankReference ?? "").trim();
|
|
|
|
|
const amount = Math.abs(parseAmount(txn.amount));
|
|
|
|
|
const direction = BAI_CODE_EVENTS[code]?.direction ?? directionOf(txn);
|
|
|
|
|
|
|
|
|
|
const base = {
|
|
|
|
|
code,
|
|
|
|
|
description,
|
|
|
|
|
customerReference,
|
|
|
|
|
bankReference,
|
|
|
|
|
amount,
|
|
|
|
|
direction,
|
|
|
|
|
checkNumber: null,
|
|
|
|
|
pmtId: null,
|
|
|
|
|
embeddedPaymentNumber: null,
|
|
|
|
|
vendorText: null,
|
|
|
|
|
checkShaped: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Description facts, extracted regardless of code so a code-mapped event
|
|
|
|
|
// still carries the check number / PMT id it references.
|
|
|
|
|
let descriptionEvent = null;
|
|
|
|
|
let m;
|
|
|
|
|
if ((m = ARP_RETURN_RE.exec(description))) {
|
|
|
|
|
descriptionEvent = "check_return";
|
|
|
|
|
base.checkNumber = stripLeadingZeros(m[1]);
|
|
|
|
|
} else if ((m = POSTED_RETURN_CHECK_RE.exec(description))) {
|
|
|
|
|
descriptionEvent = "check_return";
|
|
|
|
|
base.checkNumber = stripLeadingZeros(m[1]);
|
|
|
|
|
} else if (POSTED_RETURN_ELECTRONIC_RE.test(description)) {
|
|
|
|
|
descriptionEvent = "electronic_return";
|
|
|
|
|
} else if ((m = ACH_PMT_RE.exec(description))) {
|
|
|
|
|
base.pmtId = m[1];
|
|
|
|
|
const info = PMT_INFO_RE.exec(description);
|
|
|
|
|
if (info) {
|
|
|
|
|
let vendorText = info[1].trim();
|
|
|
|
|
const embedded = EMBEDDED_NUMBER_RE.exec(vendorText);
|
|
|
|
|
if (embedded && embedded[1].replace(/ /g, "").length >= 8) {
|
|
|
|
|
base.embeddedPaymentNumber = embedded[1].replace(/ /g, "");
|
|
|
|
|
vendorText = vendorText.slice(0, embedded.index).trim();
|
|
|
|
|
}
|
|
|
|
|
base.vendorText = vendorText || null;
|
|
|
|
|
}
|
|
|
|
|
if (direction === "debit") descriptionEvent = "ach_debit";
|
|
|
|
|
else if (direction === "credit") descriptionEvent = "ach_return";
|
|
|
|
|
// direction unknown -> leave null; falls through to "unknown" below.
|
|
|
|
|
} else if ((m = CHECK_PAID_RE.exec(description))) {
|
|
|
|
|
descriptionEvent = "check_paid";
|
|
|
|
|
base.checkNumber = stripLeadingZeros(m[1]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const event = BAI_CODE_EVENTS[code]?.event ?? descriptionEvent;
|
|
|
|
|
|
|
|
|
|
if (event === "check_paid" && !base.checkNumber) {
|
|
|
|
|
base.checkNumber = customerReference || null;
|
|
|
|
|
}
|
|
|
|
|
if (event === "check_return" && !base.checkNumber) {
|
|
|
|
|
base.checkNumber = customerReference || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event) return { ...base, event };
|
|
|
|
|
|
|
|
|
|
base.checkShaped =
|
|
|
|
|
Boolean(customerReference) || /CHECK/i.test(description) || Boolean(base.pmtId);
|
|
|
|
|
return { ...base, event: base.checkShaped ? "unknown" : "ignored" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Days from a stored Stampli send date (canonical MM/DD/YYYY) to an ISO
|
|
|
|
|
// reference date; null when the stored date does not parse.
|
|
|
|
|
function daysSinceIssue(payment, refISO) {
|
|
|
|
|
const iso = toISODate(payment.send_payment_on);
|
|
|
|
|
if (!iso) return null;
|
|
|
|
|
return Math.round((Date.parse(refISO) - Date.parse(iso)) / 86400000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const issuedWithinDays = (payment, refISO, days) => {
|
|
|
|
|
const d = daysSinceIssue(payment, refISO);
|
|
|
|
|
return d !== null && d >= 0 && d <= days;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Match a check event (paid debit or return credit) against payment records.
|
|
|
|
|
// Match on check number AND amount, evaluating all candidates — never
|
|
|
|
|
// first-match on number alone: bank postings drop/collapse digits on long
|
|
|
|
|
// check numbers, so a posting under one number can belong to another check
|
|
|
|
|
// (or to a number we never issued). When the number matches but the amount
|
|
|
|
|
// does not — or the number is unknown — fall back to an exact-amount match
|
|
|
|
|
// within checks issued in the last 120 days. Anything but a single fallback
|
|
|
|
|
// candidate is unmatched: no write on ambiguity.
|
|
|
|
|
export function matchCheckTransaction(classified, payments, refDateISO) {
|
|
|
|
|
const { checkNumber, amount } = classified;
|
|
|
|
|
const checks = payments.filter((p) => p.method === "Check" && p.check_number);
|
|
|
|
|
|
|
|
|
|
const numberMatches = checkNumber
|
|
|
|
|
? checks.filter((p) => p.check_number === checkNumber)
|
|
|
|
|
: [];
|
|
|
|
|
const exact = numberMatches.filter((p) => amountsEqual(p.amount_usd, amount));
|
|
|
|
|
if (exact.length === 1) return { payment: exact[0], matchedBy: "number+amount" };
|
|
|
|
|
if (exact.length > 1) return { unmatched: "multiple number+amount matches" };
|
|
|
|
|
|
|
|
|
|
const fallback = checks.filter(
|
|
|
|
|
(p) => amountsEqual(p.amount_usd, amount) && issuedWithinDays(p, refDateISO, 120)
|
|
|
|
|
);
|
|
|
|
|
if (fallback.length === 1) return { payment: fallback[0], matchedBy: "amount" };
|
|
|
|
|
if (fallback.length > 1) {
|
|
|
|
|
return { unmatched: `amount fallback ambiguous (${fallback.length} candidates)` };
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
unmatched: numberMatches.length
|
|
|
|
|
? "number matched with wrong amount; no unique amount fallback"
|
|
|
|
|
: "no number match; no unique amount fallback",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Electronic return credits carry no check number at all. Match by exact
|
|
|
|
|
// amount among bank-confirmed payments issued in the last 120 days
|
|
|
|
|
// (clear_status Cleared, or Returned so replays of an already-applied
|
|
|
|
|
// return dedupe to a noop instead of alerting). Ambiguity is unmatched.
|
|
|
|
|
export function matchElectronicReturn(classified, payments, refDateISO) {
|
|
|
|
|
const candidates = payments.filter(
|
|
|
|
|
(p) =>
|
|
|
|
|
(p.clear_status === "Cleared" || p.clear_status === "Returned") &&
|
|
|
|
|
amountsEqual(p.amount_usd, classified.amount) &&
|
|
|
|
|
issuedWithinDays(p, refDateISO, 120)
|
|
|
|
|
);
|
|
|
|
|
if (candidates.length === 1) return { payment: candidates[0], matchedBy: "amount+cleared" };
|
|
|
|
|
if (candidates.length > 1) {
|
|
|
|
|
return { unmatched: `electronic return ambiguous (${candidates.length} candidates)` };
|
|
|
|
|
}
|
|
|
|
|
return { unmatched: "no bank-confirmed payment with this amount" };
|
|
|
|
|
}
|
|
|
|
|
|
feat(ach): bank-confirm ACH, drop CSV-date auto-clear (#69)
processPaymentCsv auto-cleared ACH the moment send_payment_on passed,
but the bank disagrees often enough to matter: settlement lags the send
date by up to 8 days, settled ACH can bounce and re-debit (Uline
$19,281.12, Heights $10,000.00 on the 5/26 overdrawn week), and voided
ACH that settled anyway returned via electronic credits invisible to a
Check-only feed. Move ACH to bank confirmation:
- processPaymentCsv: ACH rows keep their Stampli status; the send-date
auto-clear is removed. The bankConfirmed protection is unchanged, so
bank-cleared records still cannot be moved backward by the CSV.
- fetchBoaTransactions scans all payments (method filter dropped) and
processes ACH CCD lines: match by stored pmt_id first (reversals
reuse the original PMT id), then by the Stampli payment number
embedded in PMT INFO (internal spaces stripped, amount must agree),
then vendor + exact amount within send_payment_on -2..+14 days.
Settled debit -> Cleared/Cleared with dates, pmt_id persisted on the
record; credit reusing the pmt_id (or a unique same-amount return
credit against a bank-confirmed ACH) -> clear_status=Returned +
returned_date + history event. Ambiguity is unmatched, no write.
Existing DDB ACH records already Cleared by the old auto-clear are
unaffected: bank confirmation is additive and the CSV path never moves
a record backward.
Handler event contract extended (optional fromDate/toDate); cross-family
review required before merge.
2026-07-21 19:42:51 -04:00
|
|
|
// 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, "");
|
|
|
|
|
|
|
|
|
|
export function vendorMatches(payee, vendorText) {
|
|
|
|
|
const a = normalizeVendor(payee);
|
|
|
|
|
const b = normalizeVendor(vendorText);
|
|
|
|
|
if (!a || !b) 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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Match an ACH CCD debit or return credit (payments-dashboard#69).
|
|
|
|
|
// Order: stored pmt_id (reversals reuse the original PMT id), then the
|
|
|
|
|
// Stampli payment number embedded in PMT INFO (spaces stripped; amount must
|
|
|
|
|
// also agree — a mismatch falls through), then vendor + exact amount within
|
|
|
|
|
// the send window. Return credits additionally fall back to a unique
|
|
|
|
|
// same-amount match among bank-confirmed ACH payments, since reversal
|
|
|
|
|
// credits carry the originator's name, not the vendor's. 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) return { payment: byPmtId[0], matchedBy: "pmt_id" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 byVendor = achs.filter(
|
|
|
|
|
(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") {
|
|
|
|
|
const byAmount = achs.filter(
|
|
|
|
|
(p) =>
|
|
|
|
|
(p.clear_status === "Cleared" || p.clear_status === "Returned") &&
|
|
|
|
|
amountsEqual(p.amount_usd, amount)
|
|
|
|
|
);
|
|
|
|
|
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" };
|
|
|
|
|
}
|
|
|
|
|
|
feat(fetchboa): v2 return/redeposit-aware reconciliation (#66)
The two-code 475/255 filter missed every return on the Jan-Jul statement
(29 ARP refer-to-maker credits, 24 electronic return credits, and the
redeposit cycles), leaving 5 paid-then-returned checks stuck at Cleared
($5,256.62, vendors unpaid). Rewrite fetchBoaTransactions around a pure
reconciliation module (src/boaRecon.js) covered by node:test:
- BAI transaction-code event map (only 475 = check paid is confirmed;
remaining codes pend the enumeration replay) with a description-text
fallback classifier for ARP refer-to-maker, return-of-posted-check
(check-numbered and electronic) and ACH DES:PAYMENTS formats. Unknown
check-shaped transactions are logged and counted, never dropped.
- Matching on check number AND amount over all candidates; wrong-amount
or collapsed-number postings fall back to a unique exact-amount match
within checks issued in the last 120 days; ambiguity means unmatched
with no write.
- Transitions always set BOTH status and clear_status (the missed
returns slipped through the divergence between them). clear_status is
bank truth: returns apply even to Cleared records, a second paid debit
on a Returned check is a redeposit back to Cleared, and a return on a
voided check is terminal voided-and-bounced. Every applied event is
appended to a history list; identical replayed events are noops.
- Optional {fromDate, toDate} replay payload (strictly validated) for
gap replays and the BAI-code enumeration runs; default stays
yesterday.
- Each run writes a boa_recon#<runDate> summary item (90-day TTL) with
per-event counts, unmatched check numbers/amounts, and unknown codes;
unmatched/unknown also console.error (Slack alerting is #71).
ACH transactions are classified but not yet acted on; bank-confirming
ACH lands with #69.
2026-07-21 19:40:25 -04:00
|
|
|
const CANCEL_STATUSES = ["voided", "cancelled", "canceled", "marked as void"];
|
|
|
|
|
export const isCancelStatus = (s) => CANCEL_STATUSES.includes(String(s ?? "").toLowerCase());
|
|
|
|
|
|
|
|
|
|
// Decide the DDB write for a matched bank event. Pure: returns the fields
|
|
|
|
|
// to set plus the history entry; the handler turns it into an UpdateCommand
|
|
|
|
|
// and mirrors the fields onto its in-memory copy.
|
|
|
|
|
//
|
|
|
|
|
// Invariant: every non-noop result sets BOTH status and clear_status — the
|
|
|
|
|
// 2026-07-21 reconciliation traced five missed returns ($5,256.62) to
|
|
|
|
|
// writers touching one field but not the other.
|
|
|
|
|
//
|
|
|
|
|
// Status semantics (documented decision, #66): `clear_status` is bank truth
|
|
|
|
|
// ("Cleared without a subsequent return is permanent" keys off it). `status`
|
|
|
|
|
// stays on the CSV lifecycle ladder, which has no "Returned" rung, so on a
|
|
|
|
|
// return it is re-written with its current value to keep the both-fields
|
|
|
|
|
// invariant; consumers that need bounce visibility read clear_status. On a
|
|
|
|
|
// paid debit against a canceled record (a voided check the bank paid —
|
|
|
|
|
// expected without Positive Pay), the cancel status is likewise preserved:
|
|
|
|
|
// the ARP return that follows lands as terminal voided-and-bounced.
|
|
|
|
|
export function applyEvent(payment, classified, eventDateISO) {
|
|
|
|
|
const { event, amount, bankReference } = classified;
|
|
|
|
|
const canceled = isCancelStatus(payment.status);
|
|
|
|
|
const historyEvent = {
|
|
|
|
|
event,
|
|
|
|
|
date: eventDateISO,
|
|
|
|
|
bankRef: bankReference,
|
|
|
|
|
amount,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Replay idempotence: an identical event already recorded in history is a
|
|
|
|
|
// noop, so re-running a date range never double-applies transitions.
|
|
|
|
|
const alreadyApplied = (payment.history || []).some(
|
|
|
|
|
(h) =>
|
|
|
|
|
h.event === historyEvent.event &&
|
|
|
|
|
h.date === historyEvent.date &&
|
|
|
|
|
h.bankRef === historyEvent.bankRef &&
|
|
|
|
|
amountsEqual(h.amount, amount)
|
|
|
|
|
);
|
|
|
|
|
if (alreadyApplied) return { kind: "noop", updates: null, historyEvent: null };
|
|
|
|
|
|
|
|
|
|
if (event === "check_paid" || event === "ach_debit") {
|
|
|
|
|
const redeposit = payment.clear_status === "Returned";
|
|
|
|
|
const updates = {
|
|
|
|
|
status: canceled ? payment.status : "Cleared",
|
|
|
|
|
clear_status: "Cleared",
|
|
|
|
|
paid_date: eventDateISO,
|
|
|
|
|
cleared_date: eventDateISO,
|
|
|
|
|
bank_reference: bankReference,
|
|
|
|
|
};
|
|
|
|
|
if (event === "ach_debit" && classified.pmtId) updates.pmt_id = classified.pmtId;
|
|
|
|
|
return {
|
|
|
|
|
kind: redeposit ? "redeposit" : canceled ? "cleared_on_canceled" : "cleared",
|
|
|
|
|
updates,
|
|
|
|
|
historyEvent,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event === "check_return" || event === "electronic_return" || event === "ach_return") {
|
|
|
|
|
const updates = {
|
|
|
|
|
status: payment.status ?? "",
|
|
|
|
|
clear_status: "Returned",
|
|
|
|
|
returned_date: eventDateISO,
|
|
|
|
|
bank_reference: bankReference,
|
|
|
|
|
};
|
|
|
|
|
if (event === "ach_return" && classified.pmtId) updates.pmt_id = classified.pmtId;
|
|
|
|
|
return {
|
|
|
|
|
// A return against a canceled record is the expected void-then-bounce
|
|
|
|
|
// ARP cycle: terminal voided-and-bounced, counted separately.
|
|
|
|
|
kind: canceled ? "voided_and_bounced" : "returned",
|
|
|
|
|
updates,
|
|
|
|
|
historyEvent,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { kind: "noop", updates: null, historyEvent: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
|
|
|
|
|
|
export function isValidISODate(s) {
|
|
|
|
|
if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false;
|
|
|
|
|
const [y, mo, d] = s.split("-").map(Number);
|
|
|
|
|
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
|
|
|
return (
|
|
|
|
|
dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Optional {fromDate, toDate} replay payload; the default stays yesterday,
|
|
|
|
|
// matching the previous-day inquiry cadence. Strings are validated strictly
|
|
|
|
|
// so a malformed payload fails loudly instead of querying a garbage range.
|
|
|
|
|
export function resolveDateRange(event, now = new Date()) {
|
|
|
|
|
const hasFrom = event?.fromDate != null;
|
|
|
|
|
const hasTo = event?.toDate != null;
|
|
|
|
|
if (!hasFrom && !hasTo) {
|
|
|
|
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
|
|
|
const d = yesterday.toISOString().split("T")[0];
|
|
|
|
|
return { fromDate: d, toDate: d };
|
|
|
|
|
}
|
|
|
|
|
const fromDate = hasFrom ? event.fromDate : event.toDate;
|
|
|
|
|
const toDate = hasTo ? event.toDate : event.fromDate;
|
|
|
|
|
if (!isValidISODate(fromDate) || !isValidISODate(toDate)) {
|
|
|
|
|
throw new Error("fromDate/toDate must be valid YYYY-MM-DD strings");
|
|
|
|
|
}
|
|
|
|
|
if (fromDate > toDate) {
|
|
|
|
|
throw new Error("fromDate must be <= toDate");
|
|
|
|
|
}
|
|
|
|
|
return { fromDate, toDate };
|
|
|
|
|
}
|