mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 07:43:12 +00:00
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:
parent
f3772861d0
commit
a49b91a25f
5 changed files with 234 additions and 25 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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)})`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue