payments-dashboard/scripts/simulate-csv.cjs
Adam Moussa 5ff8099b7f
Some checks failed
Deploy / Deploy to dev (push) Has been cancelled
Deploy / Deploy to prod (push) Has been cancelled
ci: require ci-complete for test and Terraform (#121)
* 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.
2026-10-01 21:39:38 -04:00

152 lines
5.6 KiB
JavaScript

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