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