payments-dashboard/src/fetchBoaTransactions.js
Adam Moussa c8245797f0
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

245 lines
8.3 KiB
JavaScript

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, ScanCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import {
applyEvent,
classifyTransaction,
isValidISODate,
logSafe,
matchAchTransaction,
matchCheckTransaction,
matchElectronicReturn,
resolveDateRange,
} from "./boaRecon.js";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const secrets = new SecretsManagerClient();
const TABLE_NAME = process.env.TABLE_NAME;
const BOA_BASE_URL = process.env.BOA_BASE_URL;
let cachedCreds;
async function getReportingCreds() {
if (cachedCreds) return cachedCreds;
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: process.env.BOA_REPORTING_SECRET_NAME })
);
cachedCreds = JSON.parse(SecretString);
return cachedCreds;
}
async function getAccessToken(applicationID, clientId, clientSecret) {
const res = await fetch(`${BOA_BASE_URL}/authn/v1/client-authentication`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
applicationID,
authn: { client_id: clientId, client_secret: clientSecret },
}),
});
if (!res.ok) {
throw new Error(`OAuth token exchange failed: HTTP ${res.status}`);
}
const data = await res.json();
return data.access_token;
}
export const handler = async (event) => {
// Optional replay payload {fromDate, toDate}; default is yesterday.
const { fromDate, toDate } = resolveDateRange(event ?? {});
const { appId, clientId, token: clientSecret, accountNumber, bankId } = await getReportingCreds();
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
// Call CashPro Previous Day Transaction Inquiry
const res = await fetch(`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify({
fromDate,
toDate,
accounts: [{ accountNumber, bankId }],
}),
});
if (!res.ok) {
throw new Error(`BoA API error: HTTP ${res.status}`);
}
const data = await res.json();
// Response shape: { accountTransactions: [{ accountNumber, bankId, currency, transactions: [...] }] }
const allTransactions = (data.accountTransactions || []).flatMap(
(acct) => acct.transactions || []
);
// Process in posting-date order so multi-day replays apply paid -> return
// -> redeposit sequences in the order the bank did.
allTransactions.sort((a, b) =>
String(a.valueDate ?? "").localeCompare(String(b.valueDate ?? ""))
);
// 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)",
ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey,
})
);
payments.push(...result.Items);
lastKey = result.LastEvaluatedKey;
} while (lastKey);
const summary = {
transactions_seen: allTransactions.length,
classified: {},
matched: 0,
applied: 0,
already_applied: 0,
redeposits: 0,
voided_and_bounced: 0,
unmatched: [],
unknown_codes: {},
};
for (const txn of allTransactions) {
const classified = classifyTransaction(txn);
summary.classified[classified.event] = (summary.classified[classified.event] || 0) + 1;
if (classified.event === "ignored") continue;
if (classified.event === "unknown") {
summary.unknown_codes[classified.code || "?"] =
(summary.unknown_codes[classified.code || "?"] || 0) + 1;
console.error(
`Unknown check-shaped transaction: code=${logSafe(classified.code)}, ` +
`description=${logSafe(classified.description)}, ` +
`ref=${logSafe(classified.customerReference)}, amount=${classified.amount}`
);
continue;
}
const eventDate = isValidISODate(txn.valueDate) ? txn.valueDate : toDate;
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 || classified.embeddedPaymentNumber,
amount: classified.amount,
reason: match.unmatched,
});
console.error(
`Unmatched ${classified.event}: check=${logSafe(classified.checkNumber || classified.embeddedPaymentNumber)}, ` +
`amount=${classified.amount}, reason=${match.unmatched} ` +
`(bankRef: ${logSafe(classified.bankReference)})`
);
continue;
}
summary.matched++;
const applied = applyEvent(match.payment, classified, eventDate);
if (applied.kind === "noop") {
summary.already_applied++;
continue;
}
if (applied.kind === "redeposit") summary.redeposits++;
if (applied.kind === "voided_and_bounced") summary.voided_and_bounced++;
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}`);
});
await ddb.send(
new UpdateCommand({
TableName: TABLE_NAME,
Key: { pk: match.payment.pk },
UpdateExpression: `SET ${sets.join(", ")}`,
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
})
);
summary.applied++;
// Mirror the write onto the in-memory copy so later transactions in the
// same run (return after paid, redeposit after return) see current state.
Object.assign(match.payment, applied.updates);
match.payment.history = [...(match.payment.history || []), applied.historyEvent];
console.log(
`${applied.kind}: check ${logSafe(match.payment.check_number)} via ${match.matchedBy} ` +
`(bankRef: ${logSafe(classified.bankReference)})`
);
}
// Run summary record for auditing/alerting (Slack wiring is #71).
const runDate = fromDate === toDate ? fromDate : `${fromDate}_${toDate}`;
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: `boa_recon#${runDate}`,
run_at: new Date().toISOString(),
from_date: fromDate,
to_date: toDate,
transactions_seen: summary.transactions_seen,
classified: summary.classified,
matched: summary.matched,
applied: summary.applied,
already_applied: summary.already_applied,
redeposits: summary.redeposits,
voided_and_bounced: summary.voided_and_bounced,
unmatched_count: summary.unmatched.length,
unmatched: summary.unmatched,
unknown_codes: summary.unknown_codes,
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
},
})
);
const unknownCount = Object.values(summary.unknown_codes).reduce((a, b) => a + b, 0);
if (summary.unmatched.length || unknownCount) {
console.error(
`Reconciliation ${fromDate}..${toDate}: ${summary.unmatched.length} unmatched, ` +
`${unknownCount} unknown-code transactions (see boa_recon#${runDate})`
);
}
console.log(
`Processed ${summary.transactions_seen} transactions ${fromDate}..${toDate}: ` +
`${summary.matched} matched, ${summary.applied} applied, ` +
`${summary.already_applied} already applied, ${summary.redeposits} redeposits, ` +
`${summary.voided_and_bounced} voided-and-bounced, ${summary.unmatched.length} unmatched`
);
return {
statusCode: 200,
body:
`${summary.matched} of ${summary.transactions_seen} transactions matched ` +
`(${summary.applied} applied, ${summary.unmatched.length} unmatched)`,
};
};