mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 14:43:11 +00:00
Capture response headers, body, and transactionId on every check-issue API call; persist as boa_txn#<ts>#<action> records with 90-day TTL. Add "Recent BoA Submissions" section to App Home showing the last 10 with click-to-copy transactionId. Motivation: BoA support asked for a transactionId from a past successful call and we had no way to recover it from CloudWatch summary logs alone. Also adds simulate-csv.cjs (dry-run preview) and stampli-uploader.sh (launchd-invoked S3 uploader), and gitignores debug artifacts.
184 lines
6.6 KiB
JavaScript
184 lines
6.6 KiB
JavaScript
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}`);
|
|
}
|
|
})();
|