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.
This commit is contained in:
Adam Moussa 2026-07-21 19:42:51 -04:00
parent f3772861d0
commit a49b91a25f
No known key found for this signature in database
5 changed files with 234 additions and 25 deletions

View file

@ -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 <id>`), 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 |

View file

@ -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());

View file

@ -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)})`
);

View file

@ -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}`;

View file

@ -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", () => {