payments-dashboard/scripts/dryrun.cjs
Adam Moussa ffd46c4bda 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
2026-04-14 17:43:13 -04:00

204 lines
7.4 KiB
JavaScript

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);
})();