mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-02 17:53:21 +00:00
* ci: aggregate test and Terraform as ci-complete Converted callers emit that check so this repo can leave the ci / ci ruleset. * ci: add Prettier format and format:check The autofix prettier preset runs npm run format, and CI fails closed when the tree is unformatted.
282 lines
8.1 KiB
JavaScript
282 lines
8.1 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);
|
|
})();
|