Fix BoA transaction matching, add status progression protection, enable daily schedule

- Fix transaction code mapping (475=Cleared, 255=Returned) in fetchBoaTransactions
- Match on customerReference instead of bankReference for check number matching
- Add bank-confirmed protection: CSV cannot override status once bank confirms Cleared
- Add status progression guard: CSV cannot regress status backward in lifecycle
- Cleared status is permanent — cannot be voided, cancelled, or changed
- Enable daily fetchBoaTransactions schedule (9am ET weekdays)
- Add production test script and dry run simulation script
This commit is contained in:
Adam Moussa 2026-04-14 17:43:13 -04:00
parent d065b320eb
commit ffd46c4bda
5 changed files with 374 additions and 9 deletions

204
scripts/dryrun.cjs Normal file
View file

@ -0,0 +1,204 @@
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { DynamoDBDocumentClient, ScanCommand } = require("@aws-sdk/lib-dynamodb");
const { parse } = require("csv-parse/sync");
const fs = require("fs");
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" }));
function toISODate(mdyDate) {
const parts = String(mdyDate).split("/");
if (parts.length !== 3) return null;
const [mm, dd, yyyy] = parts;
return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`;
}
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
(async () => {
// Load all existing payments from DB
const dbPayments = {};
let lastKey;
do {
const result = await ddb.send(
new ScanCommand({
TableName: "PaymentsDashboard",
FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey,
})
);
for (const item of result.Items) {
dbPayments[item.pk] = item;
}
lastKey = result.LastEvaluatedKey;
} while (lastKey);
// Read CSV from stdin
const csvText = fs.readFileSync(0, "utf-8");
const rows = parse(csvText, { columns: true, skip_empty_lines: true, trim: true });
const normalizedRows = rows.map((row) => {
const clean = {};
for (const [k, v] of Object.entries(row)) {
clean[String(k).trim()] = typeof v === "string" ? v.trim() : v;
}
return clean;
});
const parseAmount = (value) => {
const num = parseFloat(String(value || "0").replace(/,/g, "").trim());
return isNaN(num) ? 0 : num;
};
const statusRank = {
"scheduled": 1,
"payment submitted": 2,
"issued": 3,
"outstanding": 4,
"cleared": 5,
};
const newRecords = [];
const statusChanges = [];
const bankProtected = [];
const statusProtected = [];
const newCheckIssues = [];
const cancelCheckIssues = [];
let noChangeCount = 0;
const today = new Date().toISOString().slice(0, 10);
for (const row of normalizedRows) {
const checkNumber = (row["Check Number"] || "").trim();
if (!checkNumber) continue;
const method = (row["Method"] || "").trim();
let status = (row["Status"] || "").trim();
const sendOn = (row["Send Payment On"] || "").trim();
const payee = (row["Payee"] || "").trim();
const amount = parseAmount(row["Amount in USD"]);
// ACH auto-clear logic
if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) {
const sendDate = toISODate(sendOn);
if (sendDate && sendDate <= today) {
status = "Cleared";
}
}
const pk = "payment#" + checkNumber;
const existing = dbPayments[pk];
// Bank-confirmed protection
const bankConfirmed = existing?.clear_status === "Cleared";
let originalCsvStatus = status;
if (bankConfirmed) {
status = "Cleared";
}
// Status progression protection
if (existing) {
const oldRank = statusRank[(existing.status || "").toLowerCase()] || 0;
const newRank = statusRank[status.toLowerCase()] || 0;
if (oldRank === 5) {
// Cleared is permanent — cannot be voided, cancelled, or anything else
if (status !== existing.status) {
statusProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: existing.status, reason: "Cleared is permanent" });
}
status = existing.status;
} else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) {
// Non-cancel status regression — keep the existing (higher) status
statusProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: existing.status, reason: "would regress status" });
status = existing.status;
}
}
if (!existing) {
newRecords.push({ checkNumber, payee, method, status, amount, sendOn });
if (method === "Check") {
newCheckIssues.push({ checkNumber, payee, amount: amount.toFixed(2), issueDate: toISODate(sendOn) });
}
} else {
const oldStatus = existing.status || "";
if (bankConfirmed && originalCsvStatus !== "Cleared") {
bankProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: oldStatus });
}
if (oldStatus !== status) {
statusChanges.push({ checkNumber, payee, method, oldStatus, newStatus: status, amount });
} else {
noChangeCount++;
}
// Check for new cancel (only if not bank-confirmed and not cleared)
if (method === "Check" && !bankConfirmed && (statusRank[(oldStatus).toLowerCase()] || 0) < 5 && cancelStatuses.includes(status.toLowerCase()) && !cancelStatuses.includes(oldStatus.toLowerCase())) {
cancelCheckIssues.push({ checkNumber, payee, amount: amount.toFixed(2), issueDate: toISODate(sendOn) });
}
}
}
console.log("=== DRY RUN SUMMARY ===");
console.log("CSV rows: " + normalizedRows.length);
console.log("Existing DB records: " + Object.keys(dbPayments).length);
console.log("");
if (newRecords.length) {
console.log("--- NEW RECORDS (" + newRecords.length + ") ---");
for (const r of newRecords) {
console.log(" + " + r.checkNumber + " | " + r.payee + " | " + r.method + " | " + r.status + " | $" + r.amount);
}
console.log("");
}
if (statusChanges.length) {
console.log("--- STATUS CHANGES (" + statusChanges.length + ") ---");
for (const r of statusChanges) {
console.log(" ~ " + r.checkNumber + " | " + r.payee + " | " + r.method + " | \"" + r.oldStatus + "\" -> \"" + r.newStatus + "\" | $" + r.amount);
}
console.log("");
}
if (statusProtected.length) {
console.log("--- STATUS PROGRESSION PROTECTED (" + statusProtected.length + ") ---");
console.log(" (CSV tried to regress status — blocked by progression guard)");
for (const r of statusProtected) {
console.log(" # " + r.checkNumber + " | " + r.payee + " | CSV: \"" + r.csvStatus + "\" | Kept: \"" + r.dbStatus + "\" | " + r.reason);
}
console.log("");
}
if (bankProtected.length) {
console.log("--- BANK-CONFIRMED PROTECTED (" + bankProtected.length + ") ---");
console.log(" (CSV tried to change status but bank already confirmed Cleared)");
for (const r of bankProtected) {
console.log(" ! " + r.checkNumber + " | " + r.payee + " | CSV: \"" + r.csvStatus + "\" | Kept: Cleared");
}
console.log("");
}
if (newCheckIssues.length) {
console.log("--- BOA: CHECK ISSUES / add_Issue (" + newCheckIssues.length + ") ---");
for (const r of newCheckIssues) {
console.log(" >> " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate);
}
console.log("");
}
if (cancelCheckIssues.length) {
console.log("--- BOA: CHECK CANCELS / cancel_Issue (" + cancelCheckIssues.length + ") ---");
for (const r of cancelCheckIssues) {
console.log(" XX " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate);
}
console.log("");
}
console.log("=== TOTALS ===");
console.log("New DB records: " + newRecords.length);
console.log("Status updates: " + statusChanges.length);
console.log("Status-protected: " + statusProtected.length);
console.log("Bank-protected: " + bankProtected.length);
console.log("BoA add_Issue: " + newCheckIssues.length);
console.log("BoA cancel_Issue: " + cancelCheckIssues.length);
console.log("Unchanged: " + noChangeCount);
})();

130
scripts/test-boa-prod.js Normal file
View file

@ -0,0 +1,130 @@
/**
* Dry-run test for BoA CashPro production API connectivity.
* 1. Authenticates with both Check Management and Reporting credentials
* 2. Calls Previous Day Transaction Inquiry (read-only)
* 3. Does NOT issue or cancel any checks
*
* Usage:
* node scripts/test-boa-prod.js
*/
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
const ssm = new SSMClient();
const BASE_URL = "https://api.bofa.com";
async function getSSMParam(name) {
const { Parameter } = await ssm.send(
new GetParameterCommand({ Name: name, WithDecryption: true })
);
return Parameter.Value;
}
async function getAccessToken(applicationID, clientId, clientSecret) {
console.log(` Requesting token for ${applicationID}...`);
const res = await fetch(`${BASE_URL}/authn/v1/client-authentication`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
applicationID,
authn: { client_id: clientId, client_secret: clientSecret },
}),
});
const text = await res.text();
console.log(` Auth response (${res.status}):`, text, "\n");
if (!res.ok) {
throw new Error(`Auth failed for ${applicationID}: ${res.status} - ${text}`);
}
const data = JSON.parse(text);
return data.access_token;
}
async function main() {
console.log("Loading credentials from SSM...\n");
const [
checkMgmtAppId,
checkMgmtClientId,
checkMgmtSecret,
reportingAppId,
reportingClientId,
reportingSecret,
accountNumber,
bankId,
] = await Promise.all([
getSSMParam("/payments-dashboard/boa-check-mgmt-app-id"),
getSSMParam("/payments-dashboard/boa-check-mgmt-client-id"),
getSSMParam("/payments-dashboard/boa-check-mgmt-token"),
getSSMParam("/payments-dashboard/boa-reporting-app-id"),
getSSMParam("/payments-dashboard/boa-account-info-client-id"),
getSSMParam("/payments-dashboard/boa-account-info-token"),
getSSMParam("/payments-dashboard/boa-account-number"),
getSSMParam("/payments-dashboard/boa-bank-id"),
]);
console.log("Credentials loaded.\n");
// --- Step 1: OAuth for Check Management ---
console.log("=".repeat(60));
console.log("STEP 1: OAuth — Check Management");
console.log("=".repeat(60));
console.log();
const checkMgmtToken = await getAccessToken(checkMgmtAppId, checkMgmtClientId, checkMgmtSecret);
console.log(" ✓ Check Management token acquired\n");
// --- Step 2: OAuth for Reporting ---
console.log("=".repeat(60));
console.log("STEP 2: OAuth — Reporting");
console.log("=".repeat(60));
console.log();
const reportingToken = await getAccessToken(reportingAppId, reportingClientId, reportingSecret);
console.log(" ✓ Reporting token acquired\n");
// --- Step 3: Previous Day Transaction Inquiry (read-only) ---
console.log("=".repeat(60));
console.log("STEP 3: Previous Day Transaction Inquiry (read-only)");
console.log("=".repeat(60));
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split("T")[0];
const inquiryPayload = {
fromDate: dateStr,
toDate: dateStr,
accounts: [{ accountNumber, bankId }],
};
console.log("Request:", JSON.stringify(inquiryPayload, null, 2), "\n");
const inquiryRes = await fetch(
`${BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${reportingToken}`,
},
body: JSON.stringify(inquiryPayload),
}
);
const inquiryText = await inquiryRes.text();
console.log("Status:", inquiryRes.status);
console.log("Response:", inquiryText);
console.log("\n" + "=".repeat(60));
console.log("Dry run complete. No checks were issued or cancelled.");
console.log("=".repeat(60));
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View file

@ -75,13 +75,13 @@ export const handler = async () => {
(acct) => acct.transactions || []
);
// Filter for cleared checks (255) and returned checks (475)
// Filter for cleared checks (475) and returned checks (255)
const relevant = allTransactions.filter(
(t) => t.transactionCode === "255" || t.transactionCode === "475"
(t) => t.transactionCode === "475" || t.transactionCode === "255"
);
if (!relevant.length) {
console.log(`No check transactions (255/475) found for ${dateStr}`);
console.log(`No check transactions (475/255) found for ${dateStr}`);
return { statusCode: 200, body: `No relevant transactions for ${dateStr}` };
}
@ -105,20 +105,20 @@ export const handler = async () => {
let matched = 0;
for (const txn of relevant) {
const custRef = (txn.customerReference || "").replace(/^0+/, "");
const bankRef = txn.bankReference || "";
// BoA may append extra digits to the reference number
// Try to find a check_number that the bankReference starts with
// Match customerReference (check number with leading zeros stripped) to our check_number
const matchedPayment = payments.find((p) =>
p.check_number && bankRef.startsWith(p.check_number)
p.check_number && p.check_number === custRef
);
if (!matchedPayment) {
console.log(`No match for bankReference: ${bankRef}`);
console.log(`No match for customerReference: ${txn.customerReference} (bankRef: ${bankRef})`);
continue;
}
const clearStatus = txn.transactionCode === "255" ? "Cleared" : "Returned";
const clearStatus = txn.transactionCode === "475" ? "Cleared" : "Returned";
await ddb.send(
new UpdateCommand({

View file

@ -74,6 +74,17 @@ export const handler = async (event) => {
};
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
// Status progression ranks — higher number = further along in lifecycle
// Once a payment reaches a higher rank, CSV cannot move it backward
const statusRank = {
"scheduled": 1,
"payment submitted": 2,
"issued": 3,
"outstanding": 4,
"cleared": 5,
};
const newChecks = [];
const cancelChecks = [];
@ -103,6 +114,26 @@ export const handler = async (event) => {
new GetCommand({ TableName: TABLE_NAME, Key: { pk } })
);
// Don't overwrite status once the bank has confirmed it as Cleared
const bankConfirmed = existing?.clear_status === "Cleared";
if (bankConfirmed) {
status = "Cleared";
}
// Status progression protection — never allow status to move backward
if (existing) {
const oldRank = statusRank[(existing.status || "").toLowerCase()] || 0;
const newRank = statusRank[status.toLowerCase()] || 0;
if (oldRank === 5) {
// Cleared is permanent — cannot be voided, cancelled, or anything else
status = existing.status;
} else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) {
// Non-cancel status regression — keep the existing (higher) status
status = existing.status;
}
}
await ddb.send(
new UpdateCommand({
TableName: TABLE_NAME,

View file

@ -238,7 +238,7 @@ Resources:
Properties:
Schedule: cron(0 13 ? * MON-FRI *)
Description: Fetch BoA previous day transactions at 9am ET (13:00 UTC)
Enabled: false
Enabled: true
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref DashboardTable