mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 05:23:12 +00:00
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:
parent
d065b320eb
commit
ffd46c4bda
5 changed files with 374 additions and 9 deletions
204
scripts/dryrun.cjs
Normal file
204
scripts/dryrun.cjs
Normal 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
130
scripts/test-boa-prod.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue