const { DynamoDBClient } = require("@aws-sdk/client-dynamodb"); const { DynamoDBDocumentClient, ScanCommand } = require("@aws-sdk/lib-dynamodb"); const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm"); const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" })); const ssm = new SSMClient({ region: "us-east-1" }); const BOA_BASE_URL = "https://api.bofa.com"; async function getSSMParam(name) { const { Parameter } = await ssm.send( new GetParameterCommand({ Name: name, WithDecryption: true }) ); return Parameter.Value; } 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, clientSecret, accountNumber, companyId] = await Promise.all([ getSSMParam("/payments-dashboard/boa-check-mgmt-app-id"), getSSMParam("/payments-dashboard/boa-check-mgmt-client-id"), getSSMParam("/payments-dashboard/boa-check-mgmt-token"), getSSMParam("/payments-dashboard/boa-account-number"), getSSMParam("/payments-dashboard/boa-company-id"), ]); // 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}`); } })();