From ffd46c4bda7e28e02685a515478bf82c6d512b79 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:43:13 -0400 Subject: [PATCH] Fix BoA transaction matching, add status progression protection, enable daily schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/dryrun.cjs | 204 ++++++++++++++++++++++++++++++++++++ scripts/test-boa-prod.js | 130 +++++++++++++++++++++++ src/fetchBoaTransactions.js | 16 +-- src/processPaymentCsv.js | 31 ++++++ template.yaml | 2 +- 5 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 scripts/dryrun.cjs create mode 100644 scripts/test-boa-prod.js diff --git a/scripts/dryrun.cjs b/scripts/dryrun.cjs new file mode 100644 index 0000000..173b751 --- /dev/null +++ b/scripts/dryrun.cjs @@ -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); +})(); diff --git a/scripts/test-boa-prod.js b/scripts/test-boa-prod.js new file mode 100644 index 0000000..ad9606f --- /dev/null +++ b/scripts/test-boa-prod.js @@ -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); +}); diff --git a/src/fetchBoaTransactions.js b/src/fetchBoaTransactions.js index 08004f2..a891fee 100644 --- a/src/fetchBoaTransactions.js +++ b/src/fetchBoaTransactions.js @@ -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({ diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index 5bfd0b0..51f2a87 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -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, diff --git a/template.yaml b/template.yaml index f9b9111..4cc4d5a 100644 --- a/template.yaml +++ b/template.yaml @@ -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