payments-dashboard/scripts/seed-from-csv.js

151 lines
4.2 KiB
JavaScript
Raw Normal View History

/**
* Seed DynamoDB PaymentsDashboard table from Stampli CSV exports.
*
* Usage:
* node scripts/seed-from-csv.js <csv-file> [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-file> [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);
});