From fc37c6e8f94ddcd0f7959cf12c7fb47ca7cb00cd Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:35:57 -0400 Subject: [PATCH] Add seed scripts from master (seed-from-csv, seed-bank-status) --- scripts/seed-bank-status.js | 141 +++++++++++++++++++++++++++++++++ scripts/seed-from-csv.js | 150 ++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 scripts/seed-bank-status.js create mode 100644 scripts/seed-from-csv.js diff --git a/scripts/seed-bank-status.js b/scripts/seed-bank-status.js new file mode 100644 index 0000000..5ad1045 --- /dev/null +++ b/scripts/seed-bank-status.js @@ -0,0 +1,141 @@ +/** + * Seed bank status data from BoA exports into DynamoDB. + * + * Usage: + * node scripts/seed-bank-status.js \ + * --cleared "Bank Export.csv" \ + * --outstanding "PrintIssuesForInquiry.csv" \ + * --issued "Positive Pay Import original.csv" + * + * All flags are optional — supply whichever files you have. + * + * Updates existing payment records: + * - status: overwritten to "Cleared" | "Outstanding" | "Issued" + * - paid_date: date the check cleared (from Bank Export) + */ + +import { readFileSync } from "fs"; +import { parse } from "csv-parse/sync"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb"; + +const TABLE_NAME = "PaymentsDashboard"; +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); + +function parseArgs() { + const args = process.argv.slice(2); + const result = {}; + for (let i = 0; i < args.length; i += 2) { + const flag = args[i].replace(/^--/, ""); + result[flag] = args[i + 1]; + } + return result; +} + +function readCSV(filePath) { + const text = readFileSync(filePath, "utf-8"); + return parse(text, { columns: true, skip_empty_lines: true, trim: true }); +} + +// Normalize short dates like "1/27/26" → "2026-01-27" +function toISODate(dateStr) { + if (!dateStr) return null; + dateStr = dateStr.trim(); + + // Already ISO-ish: 04/13/2026 + let parts = dateStr.split("/"); + if (parts.length === 3) { + let [mm, dd, yyyy] = parts; + if (yyyy.length === 2) yyyy = `20${yyyy}`; + return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`; + } + return null; +} + +async function updateStatus(checkNumber, status, paidDate) { + const pk = `payment#${checkNumber}`; + const expr = ["#status = :s"]; + const names = { "#status": "status" }; + const values = { ":s": status }; + + if (paidDate) { + expr.push("paid_date = :pd"); + values[":pd"] = paidDate; + } + + await ddb.send( + new UpdateCommand({ + TableName: TABLE_NAME, + Key: { pk }, + UpdateExpression: `SET ${expr.join(", ")}`, + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + }) + ); +} + +async function main() { + const flags = parseArgs(); + const statusMap = new Map(); // checkNumber → { status, paidDate } + + // 1. Positive Pay Import (lowest priority) — "Issued" + // Format: check_number, date, ?, ?, amount (no headers) + if (flags.issued) { + console.log(`Reading positive pay import: ${flags.issued}`); + const text = readFileSync(flags.issued, "utf-8"); + const rows = parse(text, { skip_empty_lines: true, trim: true }); + for (const row of rows) { + const checkNumber = row[0]?.trim(); + if (!checkNumber) continue; + statusMap.set(checkNumber, { status: "Issued", paidDate: null }); + } + console.log(` ${rows.length} positive pay entries`); + } + + // 2. Outstanding Issues (medium priority) — "Outstanding" + if (flags.outstanding) { + console.log(`Reading outstanding issues: ${flags.outstanding}`); + const rows = readCSV(flags.outstanding); + for (const row of rows) { + const checkNumber = (row["Check Number"] || "").trim(); + if (!checkNumber) continue; + statusMap.set(checkNumber, { status: "Outstanding", paidDate: null }); + } + console.log(` ${rows.length} outstanding entries`); + } + + // 3. Bank Export / Cleared (highest priority) — "Cleared" + // Duplicates exist (same check appears twice); just take the first paid date. + if (flags.cleared) { + console.log(`Reading bank export (cleared): ${flags.cleared}`); + const rows = readCSV(flags.cleared); + const seen = new Set(); + let deduped = 0; + for (const row of rows) { + const checkNumber = (row["Check Number"] || "").trim(); + if (!checkNumber) continue; + if (seen.has(checkNumber)) { + deduped++; + continue; + } + seen.add(checkNumber); + const paidDate = toISODate(row["Paid Date"] || row["CD Volume Number"] || ""); + statusMap.set(checkNumber, { status: "Cleared", paidDate }); + } + console.log(` ${seen.size} unique cleared checks (${deduped} duplicates skipped)`); + } + + // Write to DynamoDB + let count = 0; + for (const [checkNumber, { status, paidDate }] of statusMap) { + await updateStatus(checkNumber, status, paidDate); + count++; + } + + console.log(`\nDone — ${count} payments updated with bank status.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/seed-from-csv.js b/scripts/seed-from-csv.js new file mode 100644 index 0000000..8c0edf7 --- /dev/null +++ b/scripts/seed-from-csv.js @@ -0,0 +1,150 @@ +/** + * Seed DynamoDB PaymentsDashboard table from Stampli CSV exports. + * + * Usage: + * node scripts/seed-from-csv.js [csv-file2] ... + * + * Reads one or more Stampli CSV exports and upserts every row into the + * PaymentsDashboard table. Skips CashPro API calls entirely — this is + * purely for populating the DB so the Slack App Home has data to display. + */ + +import { readFileSync } from "fs"; +import { parse } from "csv-parse/sync"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + DynamoDBDocumentClient, + UpdateCommand, + PutCommand, +} from "@aws-sdk/lib-dynamodb"; + +const TABLE_NAME = "PaymentsDashboard"; +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); + +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 parseAmount = (value) => { + const num = parseFloat(String(value || "0").replace(/,/g, "").trim()); + return isNaN(num) ? 0 : num; +}; + +async function seedFile(filePath) { + const csvText = readFileSync(filePath, "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; + }); + + let count = 0; + let skipped = 0; + + for (const row of normalizedRows) { + const checkNumber = (row["Check Number"] || "").trim(); + if (!checkNumber) { + skipped++; + continue; + } + + const method = (row["Method"] || "").trim(); + let status = (row["Status"] || "").trim(); + const sendOn = (row["Send Payment On"] || "").trim(); + + // ACH payments clear automatically on their send date — + // mark as Cleared unless voided/canceled + const cancelStatuses = ["canceled", "cancelled", "marked as void"]; + if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) { + const sendDate = toISODate(sendOn); + const today = new Date().toISOString().slice(0, 10); + if (sendDate && sendDate <= today) { + status = "Cleared"; + } + } + + // Amount in USD is blank for canceled payments — fall back to Amount + const amount = parseAmount(row["Amount in USD"] || row["Amount"]); + const pk = `payment#${checkNumber}`; + + await ddb.send( + new UpdateCommand({ + TableName: TABLE_NAME, + Key: { pk }, + UpdateExpression: ` + SET #method = :method, + payee = :payee, + check_number = :check_number, + invoice_numbers = :invoice_numbers, + send_payment_on = :send_payment_on, + amount_usd = :amount_usd, + #status = :status, + company_subsidiary = :company_subsidiary + `, + ExpressionAttributeNames: { + "#method": "method", + "#status": "status", + }, + ExpressionAttributeValues: { + ":method": method, + ":payee": (row["Payee"] || "").trim(), + ":check_number": checkNumber, + ":invoice_numbers": (row["Invoice Numbers"] || "").trim(), + ":send_payment_on": (row["Send Payment On"] || "").trim(), + ":amount_usd": amount, + ":status": status, + ":company_subsidiary": (row["Company/Subsidiary"] || "").trim(), + }, + }) + ); + count++; + } + + console.log(` ${filePath}: ${count} upserted, ${skipped} skipped (no check number)`); + return count; +} + +async function main() { + const files = process.argv.slice(2); + if (!files.length) { + console.error("Usage: node scripts/seed-from-csv.js [csv-file2] ..."); + process.exit(1); + } + + let total = 0; + for (const f of files) { + console.log(`Processing ${f}...`); + total += await seedFile(f); + } + + // Update metadata + await ddb.send( + new PutCommand({ + TableName: TABLE_NAME, + Item: { + pk: "metadata", + file_name: "seed-from-csv", + last_updated: new Date().toISOString(), + last_file_count: total, + }, + }) + ); + + console.log(`\nDone — ${total} total payments seeded.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});