mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 11:13:11 +00:00
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.
247 lines
8.4 KiB
JavaScript
247 lines
8.4 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,
|
|
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 check payments from DynamoDB to match against. (#69 extends this
|
|
// to ACH by dropping the method filter.)
|
|
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" },
|
|
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;
|
|
}
|
|
|
|
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);
|
|
|
|
if (!match.payment) {
|
|
summary.unmatched.push({
|
|
event: classified.event,
|
|
check_number: classified.checkNumber,
|
|
amount: classified.amount,
|
|
reason: match.unmatched,
|
|
});
|
|
console.error(
|
|
`Unmatched ${classified.event}: check=${logSafe(classified.checkNumber)}, ` +
|
|
`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)`,
|
|
};
|
|
};
|