mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 15:53:12 +00:00
- 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
204 lines
7.4 KiB
JavaScript
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);
|
|
})();
|