diff --git a/README.md b/README.md index 4d3c71b..dbd18dd 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ The scheduled Lambda reconciles the Previous Day feed onto `payment#` records (p **Matching.** Check events match on check number AND amount, evaluating all candidates (bank postings can drop/collapse digits on long check numbers). A number match with the wrong amount — or an unknown number — falls back to an exact-amount match within checks issued in the last 120 days; zero or multiple fallback candidates means unmatched, and unmatched transactions are recorded in the run summary with no write. Electronic returns (no check number) match by exact amount among bank-confirmed payments. +**ACH.** ACH is bank-confirmed too (#69): `processPaymentCsv` no longer auto-clears ACH on the send date (rows keep their Stampli status until the bank settles). ACH CCD lines match by the stored `pmt_id` first (reversals reuse the original `PMT `), then by the Stampli payment number embedded at the end of `PMT INFO` (internal spaces stripped, amount must agree), then by vendor + exact amount within `send_payment_on` −2..+14 days. A settled debit clears the record and persists `pmt_id`; a credit reusing the `pmt_id` (or a unique same-amount return credit against a bank-confirmed ACH) marks it Returned. + **State transitions.** Every write sets BOTH `status` and `clear_status` (`clear_status` is bank truth; "Cleared without a subsequent return is permanent" keys off it): | Bank event | Result | diff --git a/src/boaRecon.js b/src/boaRecon.js index 6a8c4bb..9b7df43 100644 --- a/src/boaRecon.js +++ b/src/boaRecon.js @@ -216,6 +216,78 @@ export function matchElectronicReturn(classified, payments, refDateISO) { 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, ""); + +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" }; +} + const CANCEL_STATUSES = ["voided", "cancelled", "canceled", "marked as void"]; export const isCancelStatus = (s) => CANCEL_STATUSES.includes(String(s ?? "").toLowerCase()); diff --git a/src/fetchBoaTransactions.js b/src/fetchBoaTransactions.js index a877e1f..953c62a 100644 --- a/src/fetchBoaTransactions.js +++ b/src/fetchBoaTransactions.js @@ -6,6 +6,7 @@ import { classifyTransaction, isValidISODate, logSafe, + matchAchTransaction, matchCheckTransaction, matchElectronicReturn, resolveDateRange, @@ -83,17 +84,16 @@ export const handler = async (event) => { String(a.valueDate ?? "").localeCompare(String(b.valueDate ?? "")) ); - // Load check payments from DynamoDB to match against. (#69 extends this - // to ACH by dropping the method filter.) + // Load all payment records (Check AND ACH — ACH is bank-confirmed here + // too, #69) to match against. const payments = []; let lastKey; do { const result = await ddb.send( new ScanCommand({ TableName: TABLE_NAME, - FilterExpression: "begins_with(pk, :prefix) AND #m = :method", - ExpressionAttributeNames: { "#m": "method" }, - ExpressionAttributeValues: { ":prefix": "payment#", ":method": "Check" }, + FilterExpression: "begins_with(pk, :prefix)", + ExpressionAttributeValues: { ":prefix": "payment#" }, ExclusiveStartKey: lastKey, }) ); @@ -130,28 +130,26 @@ export const handler = async (event) => { continue; } - if (classified.event === "ach_debit" || classified.event === "ach_return") { - // ACH bank confirmation lands with #69. - console.log(`ACH ${classified.event} classified (PMT ${logSafe(classified.pmtId)}); deferred to #69`); - continue; - } - const eventDate = isValidISODate(txn.valueDate) ? txn.valueDate : toDate; - const match = - classified.event === "electronic_return" - ? matchElectronicReturn(classified, payments, eventDate) - : matchCheckTransaction(classified, payments, eventDate); + let match; + if (classified.event === "ach_debit" || classified.event === "ach_return") { + match = matchAchTransaction(classified, payments, eventDate); + } else if (classified.event === "electronic_return") { + match = matchElectronicReturn(classified, payments, eventDate); + } else { + match = matchCheckTransaction(classified, payments, eventDate); + } if (!match.payment) { summary.unmatched.push({ event: classified.event, - check_number: classified.checkNumber, + check_number: classified.checkNumber || classified.embeddedPaymentNumber, amount: classified.amount, reason: match.unmatched, }); console.error( - `Unmatched ${classified.event}: check=${logSafe(classified.checkNumber)}, ` + + `Unmatched ${classified.event}: check=${logSafe(classified.checkNumber || classified.embeddedPaymentNumber)}, ` + `amount=${classified.amount}, reason=${match.unmatched} ` + `(bankRef: ${logSafe(classified.bankReference)})` ); diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index bb29558..3087c43 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -299,14 +299,10 @@ export const handler = async (event) => { continue; } - // ACH payments clear automatically on their send date - if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) { - const sendDate = toISODate(sendOn); - const today = new Date().toISOString().slice(0, 10); - if (sendDate && sendDate <= today) { - status = "Cleared"; - } - } + // ACH rows keep their Stampli status — the bank confirms settlement via + // fetchBoaTransactions (#69). The old send-date auto-clear marked ACH + // Cleared days before the debit settled (and past bounced settlements + // that re-debited later were invisible to it). const pk = `payment#${checkNumber}`; diff --git a/tests/boaRecon.test.js b/tests/boaRecon.test.js index dbc25e2..26a07b3 100644 --- a/tests/boaRecon.test.js +++ b/tests/boaRecon.test.js @@ -7,9 +7,11 @@ import { classifyTransaction, isCancelStatus, isValidISODate, + matchAchTransaction, matchCheckTransaction, matchElectronicReturn, resolveDateRange, + vendorMatches, } from "../src/boaRecon.js"; // Description fixtures are real statement lines from the 2026-07-21 @@ -363,6 +365,145 @@ test("isCancelStatus covers the Stampli cancel vocabulary", () => { assert.equal(isCancelStatus("Cleared"), false); }); +// ------------------------------------------------------------ ACH (#69) + +const achPayment = (over = {}) => ({ + pk: `payment#${over.check_number ?? "21222000264"}`, + check_number: "21222000264", + method: "ACH", + payee: "Cobra Septic", + amount_usd: 8300, + status: "Payment Submitted", + send_payment_on: "06/02/2026", + ...over, +}); + +test("ach matcher: embedded payment number + amount wins", () => { + const payments = [achPayment(), achPayment({ check_number: "21222000265", pk: "payment#21222000265" })]; + const m = matchAchTransaction( + { event: "ach_debit", pmtId: "7584198", embeddedPaymentNumber: "21222000264", vendorText: "Cobra Septic", amount: 8300 }, + payments, + "2026-06-08" + ); + assert.equal(m.payment.check_number, "21222000264"); + assert.equal(m.matchedBy, "payment-number+amount"); +}); + +test("ach matcher: embedded number with wrong amount falls through to vendor+amount", () => { + const payments = [ + achPayment({ check_number: "21222000264", amount_usd: 100 }), + achPayment({ check_number: "21222000265", pk: "payment#21222000265", amount_usd: 8300 }), + ]; + const m = matchAchTransaction( + { event: "ach_debit", pmtId: "7584198", embeddedPaymentNumber: "21222000264", vendorText: "Cobra Septic", amount: 8300 }, + payments, + "2026-06-08" + ); + assert.equal(m.payment.check_number, "21222000265"); + assert.equal(m.matchedBy, "vendor+amount"); +}); + +test("ach matcher: stored pmt_id attributes a reversal credit", () => { + const payments = [ + achPayment({ pmt_id: "7584198", clear_status: "Cleared", status: "Cleared" }), + ]; + const m = matchAchTransaction( + { event: "ach_return", pmtId: "7584198", embeddedPaymentNumber: null, vendorText: "Sea Haven Industries, Inc", amount: 8300 }, + payments, + "2026-06-10" + ); + assert.equal(m.matchedBy, "pmt_id"); +}); + +test("ach matcher: vendor+amount only within send_payment_on -2..+14 days", () => { + // Reddi 21222000211: sent 5/27, debited 6/4 (8-day lag) must match... + const inWindow = [achPayment({ payee: "Reddi Services", send_payment_on: "05/27/2026", amount_usd: 1958 })]; + const m1 = matchAchTransaction( + { event: "ach_debit", pmtId: "1", embeddedPaymentNumber: null, vendorText: "Reddi Services", amount: 1958 }, + inWindow, + "2026-06-04" + ); + assert.equal(m1.matchedBy, "vendor+amount"); + // ...but a posting 30 days after the send date must not. + const m2 = matchAchTransaction( + { event: "ach_debit", pmtId: "1", embeddedPaymentNumber: null, vendorText: "Reddi Services", amount: 1958 }, + inWindow, + "2026-06-26" + ); + assert.equal(m2.payment, undefined); +}); + +test("ach matcher: same-amount return credit after the debit matches a bank-confirmed ACH", () => { + const payments = [ + achPayment({ payee: "Uline", clear_status: "Cleared", status: "Cleared", amount_usd: 19281.12 }), + ]; + // Reversal credit: originator is SEA HAVEN INDUST, so vendor match fails. + const m = matchAchTransaction( + { event: "ach_return", pmtId: "999", embeddedPaymentNumber: null, vendorText: "Sea Haven Industries, Inc", amount: 19281.12 }, + payments, + "2026-05-26" + ); + assert.equal(m.matchedBy, "amount+cleared"); +}); + +test("ach matcher: ambiguity is unmatched — no write", () => { + const payments = [ + achPayment({ pk: "payment#a", check_number: "a", clear_status: "Cleared", amount_usd: 500 }), + achPayment({ pk: "payment#b", check_number: "b", clear_status: "Cleared", amount_usd: 500 }), + ]; + const m = matchAchTransaction( + { event: "ach_return", pmtId: "999", embeddedPaymentNumber: null, vendorText: null, amount: 500 }, + payments, + "2026-06-08" + ); + assert.equal(m.payment, undefined); + assert.match(m.unmatched, /ambiguous/); +}); + +test("vendorMatches: truncated bank originator vs full payee, punctuation-insensitive", () => { + assert.equal(vendorMatches("Hugill's Septic Service LLC", "Hugill's Septic Service LLC"), true); + assert.equal(vendorMatches("Alliance Sanitation LLC", "ALLIANCE SANITAT"), true); + assert.equal(vendorMatches("Cobra Septic", "Reddi Services"), false); + assert.equal(vendorMatches("", "Cobra Septic"), false); +}); + +test("ach transitions: settled debit clears both fields and persists pmt_id", () => { + const p = achPayment(); + const r = applyEvent( + { ...p }, + { event: "ach_debit", pmtId: "7584198", amount: 8300, bankReference: "905512345" }, + "2026-06-04" + ); + assert.equal(r.kind, "cleared"); + assert.equal(r.updates.status, "Cleared"); + assert.equal(r.updates.clear_status, "Cleared"); + assert.equal(r.updates.paid_date, "2026-06-04"); + assert.equal(r.updates.pmt_id, "7584198"); +}); + +test("ach transitions: return credit then re-debit (bounce and re-settle)", () => { + const p = achPayment({ status: "Cleared", clear_status: "Cleared", pmt_id: "7500000", history: [] }); + const ret = applyEvent(p, { event: "ach_return", pmtId: "7500000", amount: 8300, bankReference: "r1" }, "2026-05-26"); + assert.equal(ret.kind, "returned"); + assert.equal(ret.updates.clear_status, "Returned"); + assert.equal(ret.updates.returned_date, "2026-05-26"); + Object.assign(p, ret.updates); + p.history.push(ret.historyEvent); + + const redebit = applyEvent(p, { event: "ach_debit", pmtId: "7500000", amount: 8300, bankReference: "d2" }, "2026-06-01"); + assert.equal(redebit.kind, "redeposit"); + assert.equal(redebit.updates.status, "Cleared"); + assert.equal(redebit.updates.clear_status, "Cleared"); +}); + +test("ach transitions: electronic return on a voided-but-settled ACH is voided-and-bounced", () => { + const p = achPayment({ status: "Voided", clear_status: "Cleared" }); + const r = applyEvent(p, { event: "electronic_return", amount: 8300, bankReference: "r2" }, "2026-06-09"); + assert.equal(r.kind, "voided_and_bounced"); + assert.equal(r.updates.status, "Voided"); + assert.equal(r.updates.clear_status, "Returned"); +}); + // ------------------------------------------------------------- date range test("resolveDateRange: default is yesterday", () => {