payments-dashboard/scripts/simulate-csv.cjs

117 lines
5.4 KiB
JavaScript
Raw Normal View History

const fs = require("fs");
const { parse } = require("csv-parse/sync");
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { DynamoDBDocumentClient, BatchGetCommand } = require("@aws-sdk/lib-dynamodb");
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" }));
const CSV_PATH = process.argv[2];
if (!CSV_PATH) { console.error("Usage: node simulate-csv.cjs <csv-path>"); process.exit(1); }
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
const statusRank = { "scheduled": 1, "payment submitted": 2, "issued": 3, "outstanding": 4, "cleared": 5 };
function toISODate(mdy) {
const p = String(mdy).split("/");
if (p.length !== 3) return null;
const [mm, dd, yyyy] = p;
return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`;
}
(async () => {
const text = fs.readFileSync(CSV_PATH, "utf-8");
const rows = parse(text, { columns: true, skip_empty_lines: true, trim: true })
.map((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [String(k).trim(), typeof v === "string" ? v.trim() : v])));
const validRows = rows.filter((r) => (r["Check Number"] || "").trim());
console.log(`CSV rows: ${rows.length} total, ${validRows.length} with check numbers`);
// Batch get existing records
const keys = validRows.map((r) => ({ pk: `payment#${r["Check Number"].trim()}` }));
const existing = {};
for (let i = 0; i < keys.length; i += 100) {
const batch = keys.slice(i, i + 100);
const res = await ddb.send(new BatchGetCommand({ RequestItems: { PaymentsDashboard: { Keys: batch } } }));
for (const item of res.Responses.PaymentsDashboard) existing[item.pk] = item;
}
console.log(`DDB records found for: ${Object.keys(existing).length} of ${validRows.length}\n`);
const today = new Date().toISOString().slice(0, 10);
const newChecks = [];
const cancelChecks = [];
const statusChanges = [];
const frozenByBank = [];
const frozenByRank = [];
const newNonChecks = [];
for (const row of validRows) {
const checkNumber = row["Check Number"].trim();
const method = (row["Method"] || "").trim();
let status = (row["Status"] || "").trim();
const sendOn = (row["Send Payment On"] || "").trim();
if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) {
const sendDate = toISODate(sendOn);
if (sendDate && sendDate <= today) status = "Cleared";
}
const pk = `payment#${checkNumber}`;
const ex = existing[pk];
const bankConfirmed = ex?.clear_status === "Cleared";
if (bankConfirmed) status = "Cleared";
let frozenReason = null;
if (ex) {
const oldRank = statusRank[(ex.status || "").toLowerCase()] || 0;
const newRank = statusRank[status.toLowerCase()] || 0;
if (oldRank === 5) {
if (status !== ex.status) frozenReason = `cleared→${status} blocked`;
status = ex.status;
} else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) {
if (status !== ex.status) frozenReason = `${ex.status}→${status} regressed`;
status = ex.status;
}
}
if (frozenReason) {
if (bankConfirmed) frozenByBank.push({ checkNumber, payee: row["Payee"], reason: frozenReason });
else frozenByRank.push({ checkNumber, payee: row["Payee"], reason: frozenReason });
} else if (ex && ex.status !== status) {
statusChanges.push({ checkNumber, payee: row["Payee"], from: ex.status, to: status });
}
if (method !== "Check") {
if (!ex) newNonChecks.push({ checkNumber, payee: row["Payee"], method, status, amount: row["Amount in USD"] });
continue;
}
if (!ex) {
newChecks.push({ checkNumber, payee: row["Payee"], amount: row["Amount in USD"], status, sendOn });
} else if (cancelStatuses.includes(status.toLowerCase()) && !cancelStatuses.includes((ex.status || "").toLowerCase())) {
cancelChecks.push({ checkNumber, payee: row["Payee"], from: ex.status, amount: row["Amount in USD"] });
}
}
console.log(`========= WOULD HIT BoA =========`);
console.log(`NEW checks → add_Issue: ${newChecks.length}`);
for (const c of newChecks) console.log(` ${c.checkNumber} | ${c.payee} | $${c.amount} | ${c.status} | ${c.sendOn}`);
console.log(`\nCancellations → cancel_Issue: ${cancelChecks.length}`);
for (const c of cancelChecks) console.log(` ${c.checkNumber} | ${c.payee} | ${c.from}→cancel | $${c.amount}`);
console.log(`\n========= DDB-ONLY CHANGES =========`);
console.log(`Status changes (existing records): ${statusChanges.length}`);
for (const c of statusChanges.slice(0, 30)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.from} → ${c.to}`);
if (statusChanges.length > 30) console.log(` ... +${statusChanges.length - 30} more`);
console.log(`\nNew non-check rows (ACH/etc., DDB only): ${newNonChecks.length}`);
for (const c of newNonChecks.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.method} | ${c.status} | $${c.amount}`);
if (newNonChecks.length > 10) console.log(` ... +${newNonChecks.length - 10} more`);
console.log(`\n========= BLOCKED / FROZEN =========`);
console.log(`Frozen by bank-confirmed Cleared: ${frozenByBank.length}`);
for (const c of frozenByBank.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
console.log(`\nFrozen by status-rank protection: ${frozenByRank.length}`);
for (const c of frozenByRank.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
})();