mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 04:13:12 +00:00
API tokens and credentials must live in Secrets Manager per secrets-and-config.md, but the four original payment Lambdas still read 10 SecureString SSM params. Move them to three grouped secrets (slack-bot-token plaintext, boa-check-mgmt and boa-reporting as JSON), matching the pattern the expense Lambdas already use. IAM is scoped to secretsmanager:GetSecretValue per secret; the VPC Lambdas reach the public endpoint over the existing NAT path. Test/reissue scripts and the client-ssm dependency are updated/removed accordingly. Refs: #3
178 lines
6.4 KiB
JavaScript
178 lines
6.4 KiB
JavaScript
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}`);
|
|
}
|
|
})();
|