mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 06:33:11 +00:00
Merge pull request #2 from Sea-Haven-Industries/feature/boa-api-integration
Add BoA CashPro API integration
This commit is contained in:
commit
e6fbd3de1b
9 changed files with 997 additions and 10 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
.aws-sam/
|
.aws-sam/
|
||||||
samconfig.toml
|
samconfig.toml
|
||||||
|
data/
|
||||||
|
.DS_Store
|
||||||
|
BofA API Resources/
|
||||||
|
*.csv
|
||||||
|
|
|
||||||
63
README.md
Normal file
63
README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Payments Dashboard
|
||||||
|
|
||||||
|
AWS SAM application that ingests payment CSVs, syncs check data with Bank of America CashPro APIs, and surfaces an outstanding-payments dashboard in Slack.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **ProcessPaymentCsv** - Lambda triggered by S3 CSV upload. Parses payments, upserts to DynamoDB, and submits new/cancelled checks to the CashPro Check Management API.
|
||||||
|
- **FetchBoaTransactions** - Scheduled Lambda (weekdays 9am ET). Calls the CashPro Previous Day Transaction Inquiry API and matches cleared/returned checks back to DynamoDB records.
|
||||||
|
- **SlackAppHome** - Lambda behind API Gateway. Renders the payments dashboard on the Slack App Home tab with outstanding aging buckets and drill-down modals.
|
||||||
|
|
||||||
|
All three Lambdas run inside a VPC with a NAT Gateway for a static outbound IP (required by BoA IP whitelisting).
|
||||||
|
|
||||||
|
## BoA CashPro API Integration
|
||||||
|
|
||||||
|
Two separate CashPro APIs are used, each with its own OAuth credentials:
|
||||||
|
|
||||||
|
| API | Purpose | Endpoint |
|
||||||
|
|-----|---------|----------|
|
||||||
|
| Check Management | Issue and cancel checks | `/cashpro/checkmanagement/v1/check-issues` |
|
||||||
|
| Reporting (Transaction Inquiry) | Fetch previous-day transactions | `/cashpro/reporting/v1/transaction-inquiries/previous-day` |
|
||||||
|
|
||||||
|
**Authentication flow:**
|
||||||
|
1. POST to `/authn/v1/client-authentication` with `applicationID`, `client_id`, and `client_secret`
|
||||||
|
2. Receive a Bearer `access_token` (valid 1 hour)
|
||||||
|
3. Pass the token in the `Authorization` header for subsequent API calls
|
||||||
|
|
||||||
|
**Base URLs:**
|
||||||
|
- Production: `https://api.bofa.com`
|
||||||
|
- Sandbox: `https://api-sb.bofa.com`
|
||||||
|
|
||||||
|
## SSM Parameters
|
||||||
|
|
||||||
|
All BoA credentials and config are stored in AWS SSM Parameter Store (SecureString):
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `/payments-dashboard/boa-check-mgmt-app-id` | Check Management application ID |
|
||||||
|
| `/payments-dashboard/boa-check-mgmt-client-id` | Check Management client ID |
|
||||||
|
| `/payments-dashboard/boa-check-mgmt-token` | Check Management client secret |
|
||||||
|
| `/payments-dashboard/boa-reporting-app-id` | Reporting application ID |
|
||||||
|
| `/payments-dashboard/boa-account-info-client-id` | Reporting client ID |
|
||||||
|
| `/payments-dashboard/boa-account-info-token` | Reporting client secret |
|
||||||
|
| `/payments-dashboard/boa-account-number` | BoA account number |
|
||||||
|
| `/payments-dashboard/boa-company-id` | CashPro company ID (check management) |
|
||||||
|
| `/payments-dashboard/boa-bank-id` | BoA routing number |
|
||||||
|
| `/payments-dashboard/slack-bot-token` | Slack Bot OAuth token |
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `scripts/test-boa-sandbox.js` | One-off sandbox connectivity test for both CashPro APIs |
|
||||||
|
| `scripts/seed-from-csv.js` | Seed DynamoDB from a local CSV file |
|
||||||
|
| `scripts/seed-bank-status.js` | Seed bank clear status data into DynamoDB |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sam build
|
||||||
|
sam deploy --guided
|
||||||
|
```
|
||||||
|
|
||||||
|
The `BOA_BASE_URL` environment variable in `template.yaml` controls whether Lambdas hit production (`https://api.bofa.com`) or sandbox (`https://api-sb.bofa.com`). All other BoA config is read from SSM at runtime.
|
||||||
204
scripts/dryrun.cjs
Normal file
204
scripts/dryrun.cjs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
|
||||||
|
const { DynamoDBDocumentClient, ScanCommand } = require("@aws-sdk/lib-dynamodb");
|
||||||
|
const { parse } = require("csv-parse/sync");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" }));
|
||||||
|
|
||||||
|
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 cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Load all existing payments from DB
|
||||||
|
const dbPayments = {};
|
||||||
|
let lastKey;
|
||||||
|
do {
|
||||||
|
const result = await ddb.send(
|
||||||
|
new ScanCommand({
|
||||||
|
TableName: "PaymentsDashboard",
|
||||||
|
FilterExpression: "begins_with(pk, :prefix)",
|
||||||
|
ExpressionAttributeValues: { ":prefix": "payment#" },
|
||||||
|
ExclusiveStartKey: lastKey,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const item of result.Items) {
|
||||||
|
dbPayments[item.pk] = item;
|
||||||
|
}
|
||||||
|
lastKey = result.LastEvaluatedKey;
|
||||||
|
} while (lastKey);
|
||||||
|
|
||||||
|
// Read CSV from stdin
|
||||||
|
const csvText = fs.readFileSync(0, "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;
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseAmount = (value) => {
|
||||||
|
const num = parseFloat(String(value || "0").replace(/,/g, "").trim());
|
||||||
|
return isNaN(num) ? 0 : num;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusRank = {
|
||||||
|
"scheduled": 1,
|
||||||
|
"payment submitted": 2,
|
||||||
|
"issued": 3,
|
||||||
|
"outstanding": 4,
|
||||||
|
"cleared": 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newRecords = [];
|
||||||
|
const statusChanges = [];
|
||||||
|
const bankProtected = [];
|
||||||
|
const statusProtected = [];
|
||||||
|
const newCheckIssues = [];
|
||||||
|
const cancelCheckIssues = [];
|
||||||
|
let noChangeCount = 0;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
for (const row of normalizedRows) {
|
||||||
|
const checkNumber = (row["Check Number"] || "").trim();
|
||||||
|
if (!checkNumber) continue;
|
||||||
|
|
||||||
|
const method = (row["Method"] || "").trim();
|
||||||
|
let status = (row["Status"] || "").trim();
|
||||||
|
const sendOn = (row["Send Payment On"] || "").trim();
|
||||||
|
const payee = (row["Payee"] || "").trim();
|
||||||
|
const amount = parseAmount(row["Amount in USD"]);
|
||||||
|
|
||||||
|
// ACH auto-clear logic
|
||||||
|
if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) {
|
||||||
|
const sendDate = toISODate(sendOn);
|
||||||
|
if (sendDate && sendDate <= today) {
|
||||||
|
status = "Cleared";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pk = "payment#" + checkNumber;
|
||||||
|
const existing = dbPayments[pk];
|
||||||
|
|
||||||
|
// Bank-confirmed protection
|
||||||
|
const bankConfirmed = existing?.clear_status === "Cleared";
|
||||||
|
let originalCsvStatus = status;
|
||||||
|
if (bankConfirmed) {
|
||||||
|
status = "Cleared";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status progression protection
|
||||||
|
if (existing) {
|
||||||
|
const oldRank = statusRank[(existing.status || "").toLowerCase()] || 0;
|
||||||
|
const newRank = statusRank[status.toLowerCase()] || 0;
|
||||||
|
|
||||||
|
if (oldRank === 5) {
|
||||||
|
// Cleared is permanent — cannot be voided, cancelled, or anything else
|
||||||
|
if (status !== existing.status) {
|
||||||
|
statusProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: existing.status, reason: "Cleared is permanent" });
|
||||||
|
}
|
||||||
|
status = existing.status;
|
||||||
|
} else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) {
|
||||||
|
// Non-cancel status regression — keep the existing (higher) status
|
||||||
|
statusProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: existing.status, reason: "would regress status" });
|
||||||
|
status = existing.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
newRecords.push({ checkNumber, payee, method, status, amount, sendOn });
|
||||||
|
if (method === "Check") {
|
||||||
|
newCheckIssues.push({ checkNumber, payee, amount: amount.toFixed(2), issueDate: toISODate(sendOn) });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const oldStatus = existing.status || "";
|
||||||
|
|
||||||
|
if (bankConfirmed && originalCsvStatus !== "Cleared") {
|
||||||
|
bankProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: oldStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldStatus !== status) {
|
||||||
|
statusChanges.push({ checkNumber, payee, method, oldStatus, newStatus: status, amount });
|
||||||
|
} else {
|
||||||
|
noChangeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for new cancel (only if not bank-confirmed and not cleared)
|
||||||
|
if (method === "Check" && !bankConfirmed && (statusRank[(oldStatus).toLowerCase()] || 0) < 5 && cancelStatuses.includes(status.toLowerCase()) && !cancelStatuses.includes(oldStatus.toLowerCase())) {
|
||||||
|
cancelCheckIssues.push({ checkNumber, payee, amount: amount.toFixed(2), issueDate: toISODate(sendOn) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("=== DRY RUN SUMMARY ===");
|
||||||
|
console.log("CSV rows: " + normalizedRows.length);
|
||||||
|
console.log("Existing DB records: " + Object.keys(dbPayments).length);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
if (newRecords.length) {
|
||||||
|
console.log("--- NEW RECORDS (" + newRecords.length + ") ---");
|
||||||
|
for (const r of newRecords) {
|
||||||
|
console.log(" + " + r.checkNumber + " | " + r.payee + " | " + r.method + " | " + r.status + " | $" + r.amount);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusChanges.length) {
|
||||||
|
console.log("--- STATUS CHANGES (" + statusChanges.length + ") ---");
|
||||||
|
for (const r of statusChanges) {
|
||||||
|
console.log(" ~ " + r.checkNumber + " | " + r.payee + " | " + r.method + " | \"" + r.oldStatus + "\" -> \"" + r.newStatus + "\" | $" + r.amount);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusProtected.length) {
|
||||||
|
console.log("--- STATUS PROGRESSION PROTECTED (" + statusProtected.length + ") ---");
|
||||||
|
console.log(" (CSV tried to regress status — blocked by progression guard)");
|
||||||
|
for (const r of statusProtected) {
|
||||||
|
console.log(" # " + r.checkNumber + " | " + r.payee + " | CSV: \"" + r.csvStatus + "\" | Kept: \"" + r.dbStatus + "\" | " + r.reason);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bankProtected.length) {
|
||||||
|
console.log("--- BANK-CONFIRMED PROTECTED (" + bankProtected.length + ") ---");
|
||||||
|
console.log(" (CSV tried to change status but bank already confirmed Cleared)");
|
||||||
|
for (const r of bankProtected) {
|
||||||
|
console.log(" ! " + r.checkNumber + " | " + r.payee + " | CSV: \"" + r.csvStatus + "\" | Kept: Cleared");
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newCheckIssues.length) {
|
||||||
|
console.log("--- BOA: CHECK ISSUES / add_Issue (" + newCheckIssues.length + ") ---");
|
||||||
|
for (const r of newCheckIssues) {
|
||||||
|
console.log(" >> " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelCheckIssues.length) {
|
||||||
|
console.log("--- BOA: CHECK CANCELS / cancel_Issue (" + cancelCheckIssues.length + ") ---");
|
||||||
|
for (const r of cancelCheckIssues) {
|
||||||
|
console.log(" XX " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("=== TOTALS ===");
|
||||||
|
console.log("New DB records: " + newRecords.length);
|
||||||
|
console.log("Status updates: " + statusChanges.length);
|
||||||
|
console.log("Status-protected: " + statusProtected.length);
|
||||||
|
console.log("Bank-protected: " + bankProtected.length);
|
||||||
|
console.log("BoA add_Issue: " + newCheckIssues.length);
|
||||||
|
console.log("BoA cancel_Issue: " + cancelCheckIssues.length);
|
||||||
|
console.log("Unchanged: " + noChangeCount);
|
||||||
|
})();
|
||||||
|
|
@ -52,7 +52,7 @@ function toISODate(dateStr) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateStatus(checkNumber, status, paidDate) {
|
async function updateStatus(checkNumber, status, { paidDate, amount, issueDate, method } = {}) {
|
||||||
const pk = `payment#${checkNumber}`;
|
const pk = `payment#${checkNumber}`;
|
||||||
const expr = ["#status = :s"];
|
const expr = ["#status = :s"];
|
||||||
const names = { "#status": "status" };
|
const names = { "#status": "status" };
|
||||||
|
|
@ -62,6 +62,23 @@ async function updateStatus(checkNumber, status, paidDate) {
|
||||||
expr.push("paid_date = :pd");
|
expr.push("paid_date = :pd");
|
||||||
values[":pd"] = paidDate;
|
values[":pd"] = paidDate;
|
||||||
}
|
}
|
||||||
|
if (amount != null) {
|
||||||
|
expr.push("amount_usd = :amt");
|
||||||
|
values[":amt"] = amount;
|
||||||
|
}
|
||||||
|
if (issueDate) {
|
||||||
|
expr.push("send_payment_on = :sd");
|
||||||
|
values[":sd"] = issueDate;
|
||||||
|
}
|
||||||
|
if (checkNumber) {
|
||||||
|
expr.push("check_number = :cn");
|
||||||
|
values[":cn"] = checkNumber;
|
||||||
|
}
|
||||||
|
if (method) {
|
||||||
|
expr.push("#method = :m");
|
||||||
|
names["#method"] = "method";
|
||||||
|
values[":m"] = method;
|
||||||
|
}
|
||||||
|
|
||||||
await ddb.send(
|
await ddb.send(
|
||||||
new UpdateCommand({
|
new UpdateCommand({
|
||||||
|
|
@ -87,7 +104,9 @@ async function main() {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const checkNumber = row[0]?.trim();
|
const checkNumber = row[0]?.trim();
|
||||||
if (!checkNumber) continue;
|
if (!checkNumber) continue;
|
||||||
statusMap.set(checkNumber, { status: "Issued", paidDate: null });
|
const amount = parseFloat(String(row[4] || "0").replace(/,/g, ""));
|
||||||
|
const issueDate = row[1]?.trim() || null;
|
||||||
|
statusMap.set(checkNumber, { status: "Issued", paidDate: null, amount: isNaN(amount) ? 0 : amount, issueDate, method: "Check" });
|
||||||
}
|
}
|
||||||
console.log(` ${rows.length} positive pay entries`);
|
console.log(` ${rows.length} positive pay entries`);
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +118,18 @@ async function main() {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const checkNumber = (row["Check Number"] || "").trim();
|
const checkNumber = (row["Check Number"] || "").trim();
|
||||||
if (!checkNumber) continue;
|
if (!checkNumber) continue;
|
||||||
statusMap.set(checkNumber, { status: "Outstanding", paidDate: null });
|
const amount = parseFloat(String(row["Amount"] || "0").replace(/,/g, ""));
|
||||||
|
const rawDate = (row["Issue Date"] || "").trim();
|
||||||
|
// Normalize 2-digit year to 4-digit: "12/10/24" → "12/10/2024"
|
||||||
|
let issueDate = rawDate || null;
|
||||||
|
if (rawDate) {
|
||||||
|
const parts = rawDate.split("/");
|
||||||
|
if (parts.length === 3 && parts[2].length === 2) {
|
||||||
|
parts[2] = `20${parts[2]}`;
|
||||||
|
issueDate = parts.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusMap.set(checkNumber, { status: "Outstanding", paidDate: null, amount: isNaN(amount) ? 0 : amount, issueDate, method: "Check" });
|
||||||
}
|
}
|
||||||
console.log(` ${rows.length} outstanding entries`);
|
console.log(` ${rows.length} outstanding entries`);
|
||||||
}
|
}
|
||||||
|
|
@ -120,15 +150,16 @@ async function main() {
|
||||||
}
|
}
|
||||||
seen.add(checkNumber);
|
seen.add(checkNumber);
|
||||||
const paidDate = toISODate(row["Paid Date"] || row["CD Volume Number"] || "");
|
const paidDate = toISODate(row["Paid Date"] || row["CD Volume Number"] || "");
|
||||||
statusMap.set(checkNumber, { status: "Cleared", paidDate });
|
const amount = parseFloat(String(row["Amount"] || "0").replace(/,/g, ""));
|
||||||
|
statusMap.set(checkNumber, { status: "Cleared", paidDate, amount: isNaN(amount) ? 0 : amount });
|
||||||
}
|
}
|
||||||
console.log(` ${seen.size} unique cleared checks (${deduped} duplicates skipped)`);
|
console.log(` ${seen.size} unique cleared checks (${deduped} duplicates skipped)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to DynamoDB
|
// Write to DynamoDB
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [checkNumber, { status, paidDate }] of statusMap) {
|
for (const [checkNumber, { status, paidDate, amount, issueDate, method }] of statusMap) {
|
||||||
await updateStatus(checkNumber, status, paidDate);
|
await updateStatus(checkNumber, status, { paidDate, amount, issueDate, method });
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
130
scripts/test-boa-prod.js
Normal file
130
scripts/test-boa-prod.js
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
/**
|
||||||
|
* Dry-run test for BoA CashPro production API connectivity.
|
||||||
|
* 1. Authenticates with both Check Management and Reporting credentials
|
||||||
|
* 2. Calls Previous Day Transaction Inquiry (read-only)
|
||||||
|
* 3. Does NOT issue or cancel any checks
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/test-boa-prod.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
|
||||||
|
|
||||||
|
const ssm = new SSMClient();
|
||||||
|
const 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) {
|
||||||
|
console.log(` Requesting token for ${applicationID}...`);
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/authn/v1/client-authentication`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
applicationID,
|
||||||
|
authn: { client_id: clientId, client_secret: clientSecret },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
console.log(` Auth response (${res.status}):`, text, "\n");
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Auth failed for ${applicationID}: ${res.status} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Loading credentials from SSM...\n");
|
||||||
|
|
||||||
|
const [
|
||||||
|
checkMgmtAppId,
|
||||||
|
checkMgmtClientId,
|
||||||
|
checkMgmtSecret,
|
||||||
|
reportingAppId,
|
||||||
|
reportingClientId,
|
||||||
|
reportingSecret,
|
||||||
|
accountNumber,
|
||||||
|
bankId,
|
||||||
|
] = 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-reporting-app-id"),
|
||||||
|
getSSMParam("/payments-dashboard/boa-account-info-client-id"),
|
||||||
|
getSSMParam("/payments-dashboard/boa-account-info-token"),
|
||||||
|
getSSMParam("/payments-dashboard/boa-account-number"),
|
||||||
|
getSSMParam("/payments-dashboard/boa-bank-id"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Credentials loaded.\n");
|
||||||
|
|
||||||
|
// --- Step 1: OAuth for Check Management ---
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log("STEP 1: OAuth — Check Management");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
const checkMgmtToken = await getAccessToken(checkMgmtAppId, checkMgmtClientId, checkMgmtSecret);
|
||||||
|
console.log(" ✓ Check Management token acquired\n");
|
||||||
|
|
||||||
|
// --- Step 2: OAuth for Reporting ---
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log("STEP 2: OAuth — Reporting");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
const reportingToken = await getAccessToken(reportingAppId, reportingClientId, reportingSecret);
|
||||||
|
console.log(" ✓ Reporting token acquired\n");
|
||||||
|
|
||||||
|
// --- Step 3: Previous Day Transaction Inquiry (read-only) ---
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log("STEP 3: Previous Day Transaction Inquiry (read-only)");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const dateStr = yesterday.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const inquiryPayload = {
|
||||||
|
fromDate: dateStr,
|
||||||
|
toDate: dateStr,
|
||||||
|
accounts: [{ accountNumber, bankId }],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Request:", JSON.stringify(inquiryPayload, null, 2), "\n");
|
||||||
|
|
||||||
|
const inquiryRes = await fetch(
|
||||||
|
`${BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${reportingToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(inquiryPayload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const inquiryText = await inquiryRes.text();
|
||||||
|
console.log("Status:", inquiryRes.status);
|
||||||
|
console.log("Response:", inquiryText);
|
||||||
|
|
||||||
|
console.log("\n" + "=".repeat(60));
|
||||||
|
console.log("Dry run complete. No checks were issued or cancelled.");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
192
scripts/test-boa-sandbox.js
Normal file
192
scripts/test-boa-sandbox.js
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
/**
|
||||||
|
* One-off script to test BoA CashPro sandbox API connectivity.
|
||||||
|
* Reads credentials from SSM Parameter Store, exchanges them for
|
||||||
|
* OAuth Bearer tokens, then makes test API calls to both Check
|
||||||
|
* Management and Reporting APIs.
|
||||||
|
*
|
||||||
|
* Prints full responses so you can capture the client ID, timestamp,
|
||||||
|
* and transactionIdentification for BoA production onboarding.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/test-boa-sandbox.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
|
||||||
|
|
||||||
|
const ssm = new SSMClient();
|
||||||
|
const SANDBOX_BASE = "https://api-sb.bofa.com";
|
||||||
|
const AUTH_URL = `${SANDBOX_BASE}/authn/v1/client-authentication`;
|
||||||
|
|
||||||
|
async function getSSMParam(name) {
|
||||||
|
const { Parameter } = await ssm.send(
|
||||||
|
new GetParameterCommand({ Name: name, WithDecryption: true })
|
||||||
|
);
|
||||||
|
return Parameter.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAccessToken(applicationID, clientId, clientSecret) {
|
||||||
|
console.log(` Requesting token for ${applicationID}...`);
|
||||||
|
|
||||||
|
const res = await fetch(AUTH_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
applicationID,
|
||||||
|
authn: {
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
console.log(` Auth response (${res.status}):`, text, "\n");
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Auth failed for ${applicationID}: ${res.status} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Loading credentials from SSM...\n");
|
||||||
|
|
||||||
|
const [
|
||||||
|
checkMgmtClientId,
|
||||||
|
checkMgmtSecret,
|
||||||
|
accountInfoClientId,
|
||||||
|
accountInfoSecret,
|
||||||
|
accountNumber,
|
||||||
|
companyId,
|
||||||
|
] = await Promise.all([
|
||||||
|
getSSMParam("/payments-dashboard/boa-check-mgmt-client-id"),
|
||||||
|
getSSMParam("/payments-dashboard/boa-check-mgmt-token"),
|
||||||
|
getSSMParam("/payments-dashboard/boa-account-info-client-id"),
|
||||||
|
getSSMParam("/payments-dashboard/boa-account-info-token"),
|
||||||
|
getSSMParam("/payments-dashboard/boa-account-number"),
|
||||||
|
getSSMParam("/payments-dashboard/boa-company-id"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("Credentials loaded.\n");
|
||||||
|
|
||||||
|
// --- Step 1: Get OAuth tokens for both APIs ---
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log("STEP 1: OAuth Token Exchange");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
|
||||||
|
console.log("\n[Check Management]");
|
||||||
|
const checkMgmtBearerToken = await getAccessToken(
|
||||||
|
"app_SeaHavenIndustries_Checkmanagement_SB",
|
||||||
|
checkMgmtClientId,
|
||||||
|
checkMgmtSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[Account Info / Reporting]");
|
||||||
|
const accountInfoBearerToken = await getAccessToken(
|
||||||
|
"app_SeaHavenIndustries_Reporting_SB",
|
||||||
|
accountInfoClientId,
|
||||||
|
accountInfoSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Both tokens acquired.\n");
|
||||||
|
|
||||||
|
// --- Step 2: Check Issue (add_Issue with a test check) ---
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
console.log("TEST 1: Check Issue (add_Issue)");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
|
||||||
|
const issuePayload = {
|
||||||
|
issueList: [
|
||||||
|
{
|
||||||
|
accountNumber,
|
||||||
|
issueAction: "add_Issue",
|
||||||
|
checkNumber: "999999",
|
||||||
|
amount: "1.00",
|
||||||
|
issueDate: new Date().toISOString().split("T")[0],
|
||||||
|
payee: "Sandbox Test",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Request:", JSON.stringify(issuePayload, null, 2), "\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issueRes = await fetch(
|
||||||
|
`${SANDBOX_BASE}/cashpro/checkmanagement/v1/check-issues`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${checkMgmtBearerToken}`,
|
||||||
|
companyId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(issuePayload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const issueHeaders = Object.fromEntries(issueRes.headers.entries());
|
||||||
|
const issueText = await issueRes.text();
|
||||||
|
|
||||||
|
console.log("Status:", issueRes.status);
|
||||||
|
console.log("Response Headers:", JSON.stringify(issueHeaders, null, 2));
|
||||||
|
console.log("Response Body:", issueText);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Check Issue request failed:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: Previous Day Transaction Inquiry ---
|
||||||
|
console.log("\n" + "=".repeat(60));
|
||||||
|
console.log("TEST 2: Previous Day Transaction Inquiry");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const dateStr = yesterday.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const inquiryPayload = {
|
||||||
|
fromDate: dateStr,
|
||||||
|
toDate: dateStr,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
accountNumber,
|
||||||
|
bankId: "021000322",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Request:", JSON.stringify(inquiryPayload, null, 2), "\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inquiryRes = await fetch(
|
||||||
|
`${SANDBOX_BASE}/cashpro/reporting/v1/transaction-inquiries/previous-day`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${accountInfoBearerToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(inquiryPayload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const inquiryHeaders = Object.fromEntries(inquiryRes.headers.entries());
|
||||||
|
const inquiryText = await inquiryRes.text();
|
||||||
|
|
||||||
|
console.log("Status:", inquiryRes.status);
|
||||||
|
console.log("Response Headers:", JSON.stringify(inquiryHeaders, null, 2));
|
||||||
|
console.log("Response Body:", inquiryText);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Transaction Inquiry request failed:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n" + "=".repeat(60));
|
||||||
|
console.log("Done. Look for: client ID, timestamp, transactionIdentification");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
145
src/fetchBoaTransactions.js
Normal file
145
src/fetchBoaTransactions.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||||
|
import { DynamoDBDocumentClient, ScanCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
||||||
|
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
|
||||||
|
|
||||||
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
||||||
|
const ssm = new SSMClient();
|
||||||
|
const TABLE_NAME = process.env.TABLE_NAME;
|
||||||
|
const BOA_BASE_URL = process.env.BOA_BASE_URL;
|
||||||
|
|
||||||
|
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 token exchange failed: ${res.status} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler = async () => {
|
||||||
|
const [appId, clientId, clientSecret, accountNumber, bankId] = await Promise.all([
|
||||||
|
getSSMParam(process.env.BOA_REPORTING_APP_ID_PARAM),
|
||||||
|
getSSMParam(process.env.BOA_REPORTING_CLIENT_ID_PARAM),
|
||||||
|
getSSMParam(process.env.BOA_REPORTING_SECRET_PARAM),
|
||||||
|
getSSMParam(process.env.BOA_ACCOUNT_NUMBER_PARAM),
|
||||||
|
getSSMParam(process.env.BOA_BANK_ID_PARAM),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
|
||||||
|
|
||||||
|
// Get yesterday's date in YYYY-MM-DD
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const dateStr = yesterday.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
// Call CashPro Previous Day Transaction Inquiry
|
||||||
|
const res = await fetch(`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${bearerToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
fromDate: dateStr,
|
||||||
|
toDate: dateStr,
|
||||||
|
accounts: [{ accountNumber, bankId }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`BoA API error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Response shape: { accountTransactions: [{ accountNumber, bankId, currency, transactions: [...] }] }
|
||||||
|
const allTransactions = (data.accountTransactions || []).flatMap(
|
||||||
|
(acct) => acct.transactions || []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter for cleared checks (475) and returned checks (255)
|
||||||
|
const relevant = allTransactions.filter(
|
||||||
|
(t) => t.transactionCode === "475" || t.transactionCode === "255"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relevant.length) {
|
||||||
|
console.log(`No check transactions (475/255) found for ${dateStr}`);
|
||||||
|
return { statusCode: 200, body: `No relevant transactions for ${dateStr}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all check payments from DynamoDB to match against
|
||||||
|
const payments = [];
|
||||||
|
let lastKey;
|
||||||
|
do {
|
||||||
|
const result = await ddb.send(
|
||||||
|
new ScanCommand({
|
||||||
|
TableName: TABLE_NAME,
|
||||||
|
FilterExpression: "begins_with(pk, :prefix) AND #m = :method",
|
||||||
|
ExpressionAttributeNames: { "#m": "method" },
|
||||||
|
ExpressionAttributeValues: { ":prefix": "payment#", ":method": "Check" },
|
||||||
|
ExclusiveStartKey: lastKey,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
payments.push(...result.Items);
|
||||||
|
lastKey = result.LastEvaluatedKey;
|
||||||
|
} while (lastKey);
|
||||||
|
|
||||||
|
let matched = 0;
|
||||||
|
|
||||||
|
for (const txn of relevant) {
|
||||||
|
const custRef = (txn.customerReference || "").replace(/^0+/, "");
|
||||||
|
const bankRef = txn.bankReference || "";
|
||||||
|
|
||||||
|
// Match customerReference (check number with leading zeros stripped) to our check_number
|
||||||
|
const matchedPayment = payments.find((p) =>
|
||||||
|
p.check_number && p.check_number === custRef
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchedPayment) {
|
||||||
|
console.log(`No match for customerReference: ${txn.customerReference} (bankRef: ${bankRef})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearStatus = txn.transactionCode === "475" ? "Cleared" : "Returned";
|
||||||
|
|
||||||
|
await ddb.send(
|
||||||
|
new UpdateCommand({
|
||||||
|
TableName: TABLE_NAME,
|
||||||
|
Key: { pk: matchedPayment.pk },
|
||||||
|
UpdateExpression: "SET clear_status = :status, bank_reference = :ref, cleared_date = :date",
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
":status": clearStatus,
|
||||||
|
":ref": bankRef,
|
||||||
|
":date": txn.valueDate || dateStr,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
matched++;
|
||||||
|
console.log(`${clearStatus}: check ${matchedPayment.check_number} (bankRef: ${bankRef})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Processed ${relevant.length} transactions, matched ${matched} payments`);
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: `${matched} of ${relevant.length} transactions matched to payments`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,40 @@
|
||||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||||
import { DynamoDBDocumentClient, UpdateCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
|
import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
||||||
|
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
|
||||||
import { parse } from "csv-parse/sync";
|
import { parse } from "csv-parse/sync";
|
||||||
|
|
||||||
const s3 = new S3Client();
|
const s3 = new S3Client();
|
||||||
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
||||||
|
const ssm = new SSMClient();
|
||||||
const TABLE_NAME = process.env.TABLE_NAME;
|
const TABLE_NAME = process.env.TABLE_NAME;
|
||||||
|
const BOA_BASE_URL = process.env.BOA_BASE_URL;
|
||||||
|
|
||||||
|
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 token exchange failed: ${res.status} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert MM/DD/YYYY to YYYY-MM-DD
|
// Convert MM/DD/YYYY to YYYY-MM-DD
|
||||||
function toISODate(mdyDate) {
|
function toISODate(mdyDate) {
|
||||||
|
|
@ -46,7 +75,20 @@ export const handler = async (event) => {
|
||||||
|
|
||||||
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
|
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
|
||||||
|
|
||||||
// Upsert each payment
|
// Status progression ranks — higher number = further along in lifecycle
|
||||||
|
// Once a payment reaches a higher rank, CSV cannot move it backward
|
||||||
|
const statusRank = {
|
||||||
|
"scheduled": 1,
|
||||||
|
"payment submitted": 2,
|
||||||
|
"issued": 3,
|
||||||
|
"outstanding": 4,
|
||||||
|
"cleared": 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newChecks = [];
|
||||||
|
const cancelChecks = [];
|
||||||
|
|
||||||
|
// Upsert each payment, tracking new and cancelled checks
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const row of normalizedRows) {
|
for (const row of normalizedRows) {
|
||||||
const checkNumber = (row["Check Number"] || "").trim();
|
const checkNumber = (row["Check Number"] || "").trim();
|
||||||
|
|
@ -67,6 +109,31 @@ export const handler = async (event) => {
|
||||||
|
|
||||||
const pk = `payment#${checkNumber}`;
|
const pk = `payment#${checkNumber}`;
|
||||||
|
|
||||||
|
// Check if record already exists (for detecting new vs updated)
|
||||||
|
const { Item: existing } = await ddb.send(
|
||||||
|
new GetCommand({ TableName: TABLE_NAME, Key: { pk } })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Don't overwrite status once the bank has confirmed it as Cleared
|
||||||
|
const bankConfirmed = existing?.clear_status === "Cleared";
|
||||||
|
if (bankConfirmed) {
|
||||||
|
status = "Cleared";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status progression protection — never allow status to move backward
|
||||||
|
if (existing) {
|
||||||
|
const oldRank = statusRank[(existing.status || "").toLowerCase()] || 0;
|
||||||
|
const newRank = statusRank[status.toLowerCase()] || 0;
|
||||||
|
|
||||||
|
if (oldRank === 5) {
|
||||||
|
// Cleared is permanent — cannot be voided, cancelled, or anything else
|
||||||
|
status = existing.status;
|
||||||
|
} else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) {
|
||||||
|
// Non-cancel status regression — keep the existing (higher) status
|
||||||
|
status = existing.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ddb.send(
|
await ddb.send(
|
||||||
new UpdateCommand({
|
new UpdateCommand({
|
||||||
TableName: TABLE_NAME,
|
TableName: TABLE_NAME,
|
||||||
|
|
@ -98,6 +165,86 @@ export const handler = async (event) => {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
count++;
|
count++;
|
||||||
|
|
||||||
|
// Only process checks for CashPro
|
||||||
|
if (method !== "Check") continue;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// New check → issue
|
||||||
|
newChecks.push({
|
||||||
|
checkNumber,
|
||||||
|
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
||||||
|
issueDate: toISODate(sendOn),
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
cancelStatuses.includes(status.toLowerCase()) &&
|
||||||
|
!cancelStatuses.includes((existing.status || "").toLowerCase())
|
||||||
|
) {
|
||||||
|
// Existing check now voided/cancelled → cancel
|
||||||
|
cancelChecks.push({
|
||||||
|
checkNumber,
|
||||||
|
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
||||||
|
issueDate: toISODate(sendOn),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit to CashPro if there are any new issues or cancels
|
||||||
|
if (newChecks.length || cancelChecks.length) {
|
||||||
|
const [appId, clientId, clientSecret, accountNumber, companyId] = await Promise.all([
|
||||||
|
getSSMParam(process.env.BOA_CHECK_MGMT_APP_ID_PARAM),
|
||||||
|
getSSMParam(process.env.BOA_CHECK_MGMT_CLIENT_ID_PARAM),
|
||||||
|
getSSMParam(process.env.BOA_CHECK_MGMT_SECRET_PARAM),
|
||||||
|
getSSMParam(process.env.BOA_ACCOUNT_NUMBER_PARAM),
|
||||||
|
getSSMParam(process.env.BOA_COMPANY_ID_PARAM),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
|
||||||
|
|
||||||
|
const submitToBoA = async (items, action) => {
|
||||||
|
const issueList = items.map((item) => ({
|
||||||
|
accountNumber,
|
||||||
|
issueAction: action,
|
||||||
|
checkNumber: item.checkNumber,
|
||||||
|
amount: item.amount,
|
||||||
|
issueDate: item.issueDate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${bearerToken}`,
|
||||||
|
companyId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ issueList }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`BoA ${action} error ${res.status}:`, text);
|
||||||
|
throw new Error(`BoA ${action} failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`BoA ${action}: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newChecks.length) {
|
||||||
|
await submitToBoA(newChecks, "add_Issue");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelChecks.length) {
|
||||||
|
await submitToBoA(cancelChecks, "cancel_Issue");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update metadata record
|
// Update metadata record
|
||||||
|
|
@ -113,6 +260,11 @@ export const handler = async (event) => {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Upserted ${count} payments from ${key}`);
|
console.log(
|
||||||
return { statusCode: 200, body: `Upserted ${count} payments` };
|
`Upserted ${count} payments, ${newChecks.length} issued, ${cancelChecks.length} cancelled`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: `Upserted ${count}, issued ${newChecks.length}, cancelled ${cancelChecks.length}`,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,14 @@ Resources:
|
||||||
FunctionName: payments-processPaymentCsv
|
FunctionName: payments-processPaymentCsv
|
||||||
Handler: src/processPaymentCsv.handler
|
Handler: src/processPaymentCsv.handler
|
||||||
Timeout: 120
|
Timeout: 120
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
BOA_BASE_URL: https://api.bofa.com
|
||||||
|
BOA_CHECK_MGMT_APP_ID_PARAM: /payments-dashboard/boa-check-mgmt-app-id
|
||||||
|
BOA_CHECK_MGMT_CLIENT_ID_PARAM: /payments-dashboard/boa-check-mgmt-client-id
|
||||||
|
BOA_CHECK_MGMT_SECRET_PARAM: /payments-dashboard/boa-check-mgmt-token
|
||||||
|
BOA_ACCOUNT_NUMBER_PARAM: /payments-dashboard/boa-account-number
|
||||||
|
BOA_COMPANY_ID_PARAM: /payments-dashboard/boa-company-id
|
||||||
VpcConfig:
|
VpcConfig:
|
||||||
SubnetIds:
|
SubnetIds:
|
||||||
- !Ref PrivateSubnet
|
- !Ref PrivateSubnet
|
||||||
|
|
@ -153,6 +161,16 @@ Resources:
|
||||||
BucketName: !Sub seahaven-payments-csv-${AWS::AccountId}
|
BucketName: !Sub seahaven-payments-csv-${AWS::AccountId}
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref DashboardTable
|
TableName: !Ref DashboardTable
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-check-mgmt-app-id
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-check-mgmt-client-id
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-check-mgmt-token
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-account-number
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-company-id
|
||||||
- Version: "2012-10-17"
|
- Version: "2012-10-17"
|
||||||
Statement:
|
Statement:
|
||||||
- Effect: Allow
|
- Effect: Allow
|
||||||
|
|
@ -195,6 +213,54 @@ Resources:
|
||||||
- ec2:DeleteNetworkInterface
|
- ec2:DeleteNetworkInterface
|
||||||
Resource: "*"
|
Resource: "*"
|
||||||
|
|
||||||
|
FetchBoaTransactionsFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: payments-fetchBoaTransactions
|
||||||
|
Handler: src/fetchBoaTransactions.handler
|
||||||
|
Timeout: 60
|
||||||
|
VpcConfig:
|
||||||
|
SubnetIds:
|
||||||
|
- !Ref PrivateSubnet
|
||||||
|
SecurityGroupIds:
|
||||||
|
- !Ref LambdaSecurityGroup
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
BOA_BASE_URL: https://api.bofa.com
|
||||||
|
BOA_REPORTING_APP_ID_PARAM: /payments-dashboard/boa-reporting-app-id
|
||||||
|
BOA_REPORTING_CLIENT_ID_PARAM: /payments-dashboard/boa-account-info-client-id
|
||||||
|
BOA_REPORTING_SECRET_PARAM: /payments-dashboard/boa-account-info-token
|
||||||
|
BOA_ACCOUNT_NUMBER_PARAM: /payments-dashboard/boa-account-number
|
||||||
|
BOA_BANK_ID_PARAM: /payments-dashboard/boa-bank-id
|
||||||
|
Events:
|
||||||
|
DailySchedule:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 13 ? * MON-FRI *)
|
||||||
|
Description: Fetch BoA previous day transactions at 9am ET (13:00 UTC)
|
||||||
|
Enabled: true
|
||||||
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref DashboardTable
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-reporting-app-id
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-account-info-client-id
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-account-info-token
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-account-number
|
||||||
|
- SSMParameterReadPolicy:
|
||||||
|
ParameterName: payments-dashboard/boa-bank-id
|
||||||
|
- Version: "2012-10-17"
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- ec2:CreateNetworkInterface
|
||||||
|
- ec2:DescribeNetworkInterfaces
|
||||||
|
- ec2:DeleteNetworkInterface
|
||||||
|
Resource: "*"
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
SlackEventUrl:
|
SlackEventUrl:
|
||||||
Description: URL to set as the Slack app Request URL
|
Description: URL to set as the Slack app Request URL
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue