payments-dashboard/scripts/reissue-checks.cjs

179 lines
6.4 KiB
JavaScript
Raw Normal View History

const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { DynamoDBDocumentClient, ScanCommand } = require("@aws-sdk/lib-dynamodb");
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" }));
const secrets = new SecretsManagerClient({ region: "us-east-1" });
const BOA_BASE_URL = "https://api.bofa.com";
async function getSecretJson(secretId) {
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: secretId })
);
return JSON.parse(SecretString);
}
async function getAccessToken(applicationID, clientId, clientSecret) {
const res = await fetch(`${BOA_BASE_URL}/authn/v1/client-authentication`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
applicationID,
authn: { client_id: clientId, client_secret: clientSecret },
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`OAuth failed: ${res.status} - ${text}`);
}
const data = await res.json();
return data.access_token;
}
(async () => {
// Find all checks with status "Scheduled" that were just added (new checks from the CSV)
// These are the ones that need to be issued to BoA
const payments = [];
let lastKey;
do {
const result = await ddb.send(
new ScanCommand({
TableName: "PaymentsDashboard",
FilterExpression: "begins_with(pk, :prefix) AND #m = :method AND #s = :status",
ExpressionAttributeNames: { "#m": "method", "#s": "status" },
ExpressionAttributeValues: { ":prefix": "payment#", ":method": "Check", ":status": "Scheduled" },
ExclusiveStartKey: lastKey,
})
);
payments.push(...result.Items);
lastKey = result.LastEvaluatedKey;
} while (lastKey);
console.log(`Found ${payments.length} checks with status "Scheduled":`);
for (const p of payments) {
console.log(` ${p.check_number} | ${p.payee} | $${p.amount_usd} | ${p.send_payment_on}`);
}
if (!payments.length) {
console.log("No checks to issue.");
return;
}
// Convert MM/DD/YYYY to YYYY-MM-DD
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 issueList = payments.map((p) => ({
accountNumber: null, // filled below
issueAction: "add_Issue",
checkNumber: p.check_number,
amount: p.amount_usd.toFixed(2),
issueDate: toISODate(p.send_payment_on),
payee: "",
}));
const dryRun = process.argv.includes("--dry-run");
const limitArg = process.argv.find((a) => a.startsWith("--limit="));
const skipArg = process.argv.find((a) => a.startsWith("--skip="));
const checkArg = process.argv.find((a) => a.startsWith("--check="));
const excludeArg = process.argv.find((a) => a.startsWith("--exclude="));
const batchArg = process.argv.find((a) => a.startsWith("--batch-size="));
const limit = limitArg ? parseInt(limitArg.split("=")[1], 10) : null;
const skip = skipArg ? parseInt(skipArg.split("=")[1], 10) : 0;
const checkNum = checkArg ? checkArg.split("=")[1] : null;
const exclude = excludeArg ? excludeArg.split("=")[1].split(",") : [];
if (checkNum) {
const idx = issueList.findIndex((i) => i.checkNumber === checkNum);
if (idx === -1) {
console.log(`\nCheck ${checkNum} not found in pending list.`);
return;
}
issueList.splice(0, issueList.length, issueList[idx]);
console.log(`\nFiltered to check ${checkNum}.`);
} else {
if (exclude.length) {
const before = issueList.length;
for (let i = issueList.length - 1; i >= 0; i--) {
if (exclude.includes(issueList[i].checkNumber)) issueList.splice(i, 1);
}
console.log(`\nExcluded ${before - issueList.length} check(s): ${exclude.join(", ")}`);
}
if (skip) issueList.splice(0, skip);
if (limit) issueList.length = limit;
if (skip || limit) console.log(`\nSkip ${skip}, limit ${limit ?? "all"} → ${issueList.length} check(s).`);
}
if (dryRun) {
console.log(`\nDRY RUN — would issue ${issueList.length} checks to BoA. Use without --dry-run to execute.`);
return;
}
// Get credentials
const { appId, clientId, token: clientSecret, accountNumber, companyId } = await getSecretJson("payments-dashboard/boa-check-mgmt");
// Fill in account number
for (const item of issueList) {
item.accountNumber = accountNumber;
}
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
const BATCH_SIZE = batchArg ? parseInt(batchArg.split("=")[1], 10) : 1;
console.log(`\nAuth successful. Submitting to BoA in batches of ${BATCH_SIZE}...\n`);
let totalProcessed = 0;
let totalFailed = 0;
const failures = [];
for (let i = 0; i < issueList.length; i += BATCH_SIZE) {
const batch = issueList.slice(i, i + BATCH_SIZE);
const requestBody = JSON.stringify({ issueList: batch });
console.log(`Batch ${Math.floor(i / BATCH_SIZE) + 1}: sending ${batch.length} checks...`);
const res = await fetch(`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
companyId,
},
body: requestBody,
});
const text = await res.text();
console.log(` Status: ${res.status}`);
let data;
try {
data = JSON.parse(text);
} catch {
console.error(` Non-JSON response — aborting. Body: ${text.slice(0, 300)}`);
throw new Error(`BoA returned non-JSON: ${res.status}`);
}
console.log(` Result: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed`);
totalProcessed += data.processedItems || 0;
totalFailed += data.unprocessedItems || 0;
if (data.issueList) {
for (const item of data.issueList) {
if (item.message || item.status) {
failures.push(item);
console.log(` ✗ ${item.checkNumber}: [${item.status}] ${item.message}`);
}
}
}
}
console.log(`\nDone. Total: ${totalProcessed} processed, ${totalFailed} failed`);
if (failures.length) {
console.log(`\nFailures (${failures.length}):`);
for (const f of failures) console.log(` ${f.checkNumber} [${f.status}]: ${f.message}`);
}
})();