Log BoA submissions to DDB and surface in Slack App Home

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.
This commit is contained in:
Adam Moussa 2026-04-20 17:40:55 -04:00
parent e6fbd3de1b
commit b242df15b5
8 changed files with 513 additions and 5 deletions

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ data/
.DS_Store
BofA API Resources/
*.csv
postman-output.txt
stampli_*.txt

View file

@ -3,6 +3,9 @@
"version": "1.0.0",
"type": "module",
"description": "Payments CSV ingestion to Slack App Home dashboard",
"files": [
"src/"
],
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.600.0",
"@aws-sdk/client-s3": "^3.600.0",

184
scripts/reissue-checks.cjs Normal file
View file

@ -0,0 +1,184 @@
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}`);
}
})();

116
scripts/simulate-csv.cjs Normal file
View file

@ -0,0 +1,116 @@
const fs = require("fs");
const { parse } = require("csv-parse/sync");
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { DynamoDBDocumentClient, BatchGetCommand } = require("@aws-sdk/lib-dynamodb");
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" }));
const CSV_PATH = process.argv[2];
if (!CSV_PATH) { console.error("Usage: node simulate-csv.cjs <csv-path>"); process.exit(1); }
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
const statusRank = { "scheduled": 1, "payment submitted": 2, "issued": 3, "outstanding": 4, "cleared": 5 };
function toISODate(mdy) {
const p = String(mdy).split("/");
if (p.length !== 3) return null;
const [mm, dd, yyyy] = p;
return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`;
}
(async () => {
const text = fs.readFileSync(CSV_PATH, "utf-8");
const rows = parse(text, { columns: true, skip_empty_lines: true, trim: true })
.map((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [String(k).trim(), typeof v === "string" ? v.trim() : v])));
const validRows = rows.filter((r) => (r["Check Number"] || "").trim());
console.log(`CSV rows: ${rows.length} total, ${validRows.length} with check numbers`);
// Batch get existing records
const keys = validRows.map((r) => ({ pk: `payment#${r["Check Number"].trim()}` }));
const existing = {};
for (let i = 0; i < keys.length; i += 100) {
const batch = keys.slice(i, i + 100);
const res = await ddb.send(new BatchGetCommand({ RequestItems: { PaymentsDashboard: { Keys: batch } } }));
for (const item of res.Responses.PaymentsDashboard) existing[item.pk] = item;
}
console.log(`DDB records found for: ${Object.keys(existing).length} of ${validRows.length}\n`);
const today = new Date().toISOString().slice(0, 10);
const newChecks = [];
const cancelChecks = [];
const statusChanges = [];
const frozenByBank = [];
const frozenByRank = [];
const newNonChecks = [];
for (const row of validRows) {
const checkNumber = row["Check Number"].trim();
const method = (row["Method"] || "").trim();
let status = (row["Status"] || "").trim();
const sendOn = (row["Send Payment On"] || "").trim();
if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) {
const sendDate = toISODate(sendOn);
if (sendDate && sendDate <= today) status = "Cleared";
}
const pk = `payment#${checkNumber}`;
const ex = existing[pk];
const bankConfirmed = ex?.clear_status === "Cleared";
if (bankConfirmed) status = "Cleared";
let frozenReason = null;
if (ex) {
const oldRank = statusRank[(ex.status || "").toLowerCase()] || 0;
const newRank = statusRank[status.toLowerCase()] || 0;
if (oldRank === 5) {
if (status !== ex.status) frozenReason = `cleared→${status} blocked`;
status = ex.status;
} else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) {
if (status !== ex.status) frozenReason = `${ex.status}→${status} regressed`;
status = ex.status;
}
}
if (frozenReason) {
if (bankConfirmed) frozenByBank.push({ checkNumber, payee: row["Payee"], reason: frozenReason });
else frozenByRank.push({ checkNumber, payee: row["Payee"], reason: frozenReason });
} else if (ex && ex.status !== status) {
statusChanges.push({ checkNumber, payee: row["Payee"], from: ex.status, to: status });
}
if (method !== "Check") {
if (!ex) newNonChecks.push({ checkNumber, payee: row["Payee"], method, status, amount: row["Amount in USD"] });
continue;
}
if (!ex) {
newChecks.push({ checkNumber, payee: row["Payee"], amount: row["Amount in USD"], status, sendOn });
} else if (cancelStatuses.includes(status.toLowerCase()) && !cancelStatuses.includes((ex.status || "").toLowerCase())) {
cancelChecks.push({ checkNumber, payee: row["Payee"], from: ex.status, amount: row["Amount in USD"] });
}
}
console.log(`========= WOULD HIT BoA =========`);
console.log(`NEW checks → add_Issue: ${newChecks.length}`);
for (const c of newChecks) console.log(` ${c.checkNumber} | ${c.payee} | $${c.amount} | ${c.status} | ${c.sendOn}`);
console.log(`\nCancellations → cancel_Issue: ${cancelChecks.length}`);
for (const c of cancelChecks) console.log(` ${c.checkNumber} | ${c.payee} | ${c.from}→cancel | $${c.amount}`);
console.log(`\n========= DDB-ONLY CHANGES =========`);
console.log(`Status changes (existing records): ${statusChanges.length}`);
for (const c of statusChanges.slice(0, 30)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.from} → ${c.to}`);
if (statusChanges.length > 30) console.log(` ... +${statusChanges.length - 30} more`);
console.log(`\nNew non-check rows (ACH/etc., DDB only): ${newNonChecks.length}`);
for (const c of newNonChecks.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.method} | ${c.status} | $${c.amount}`);
if (newNonChecks.length > 10) console.log(` ... +${newNonChecks.length - 10} more`);
console.log(`\n========= BLOCKED / FROZEN =========`);
console.log(`Frozen by bank-confirmed Cleared: ${frozenByBank.length}`);
for (const c of frozenByBank.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
console.log(`\nFrozen by status-rank protection: ${frozenByRank.length}`);
for (const c of frozenByRank.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
})();

45
scripts/stampli-uploader.sh Executable file
View file

@ -0,0 +1,45 @@
#!/bin/bash
set -euo pipefail
DROP_DIR="$HOME/stampli-drop"
UPLOADED_DIR="$DROP_DIR/uploaded"
LOG_FILE="$DROP_DIR/.upload.log"
BUCKET="seahaven-payments-csv-328440206208"
mkdir -p "$UPLOADED_DIR"
exec >> "$LOG_FILE" 2>&1
LOCK_DIR="$DROP_DIR/.upload.lock"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
echo "$(date '+%Y-%m-%dT%H:%M:%S') skipping — another run holds the lock"
exit 0
fi
trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
sleep 2
shopt -s nullglob
uploaded_count=0
failed_count=0
for f in "$DROP_DIR"/*.csv; do
[ -f "$f" ] || continue
name=$(basename "$f")
ts=$(date '+%Y%m%d-%H%M%S')
echo "$(date '+%Y-%m-%dT%H:%M:%S') uploading $name"
if aws s3 cp "$f" "s3://$BUCKET/$name"; then
mv "$f" "$UPLOADED_DIR/$ts-$name"
echo "$(date '+%Y-%m-%dT%H:%M:%S') OK → uploaded/$ts-$name"
uploaded_count=$((uploaded_count + 1))
osascript -e "display notification \"Uploaded $name\" with title \"Stampli Uploader\"" 2>/dev/null || true
else
echo "$(date '+%Y-%m-%dT%H:%M:%S') FAIL $name"
failed_count=$((failed_count + 1))
osascript -e "display notification \"Failed to upload $name — see .upload.log\" with title \"Stampli Uploader\" sound name \"Basso\"" 2>/dev/null || true
fi
done
if [ $uploaded_count -eq 0 ] && [ $failed_count -eq 0 ]; then
echo "$(date '+%Y-%m-%dT%H:%M:%S') folder change triggered but no .csv files found"
fi

View file

@ -208,8 +208,13 @@ export const handler = async (event) => {
checkNumber: item.checkNumber,
amount: item.amount,
issueDate: item.issueDate,
payee: "",
}));
const timestamp = new Date().toISOString();
const checkNumbers = items.map((i) => i.checkNumber);
const totalAmount = items.reduce((sum, i) => sum + parseFloat(i.amount), 0);
const res = await fetch(
`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`,
{
@ -224,17 +229,49 @@ export const handler = async (event) => {
);
const text = await res.text();
const headers = Object.fromEntries(res.headers.entries());
const transactionId =
headers["transactionid"] ||
headers["x-transactionid"] ||
headers["x-correlation-id"] ||
headers["x-cashpro-transaction-id"] ||
null;
const record = {
pk: `boa_txn#${timestamp}#${action}`,
timestamp,
action,
http_status: res.status,
transaction_id: transactionId,
check_numbers: checkNumbers,
total_amount: totalAmount.toFixed(2),
response_headers: JSON.stringify(headers),
response_body: text,
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
};
if (!res.ok) {
console.error(`BoA ${action} error ${res.status}:`, text);
console.error(`BoA ${action} response headers:`, JSON.stringify(headers));
record.success = false;
await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: record }));
throw new Error(`BoA ${action} failed: ${res.status}`);
}
const data = JSON.parse(text);
record.success = true;
record.processed_items = data.processedItems;
record.total_items = data.totalItems;
record.unprocessed_items = data.unprocessedItems;
record.message = data.issueList?.[0]?.message || null;
await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: record }));
console.log(
`BoA ${action}: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed`
`BoA ${action}: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed, txnId=${transactionId}`
);
console.log(`BoA ${action} response headers:`, JSON.stringify(headers));
console.log(`BoA ${action} response body:`, text);
return data;
};

View file

@ -40,6 +40,21 @@ const formatDisplayDate = (date) =>
const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"];
function formatStaleness(lastUpdated) {
if (!lastUpdated) return { label: "never", stale: true, hours: Infinity };
const then = new Date(lastUpdated);
if (isNaN(then.getTime())) return { label: "unknown", stale: true, hours: Infinity };
const hours = (Date.now() - then.getTime()) / 3600000;
let label;
if (hours < 1) label = "just now";
else if (hours < 24) label = `${Math.floor(hours)}h ago`;
else {
const days = Math.floor(hours / 24);
label = `${days} day${days !== 1 ? "s" : ""} ago`;
}
return { label, stale: hours > 30, hours };
}
const ageBuckets = [
{ label: "0 – 15 days", min: 0, max: 15 },
{ label: "15 – 30 days", min: 15, max: 30 },
@ -135,13 +150,16 @@ export const handler = async (event) => {
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
);
const payments = await scanPayments();
const [payments, boaTransactions] = await Promise.all([
scanPayments(),
scanBoATransactions(),
]);
if (!payments.length) {
return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) };
}
const view = buildHomeView(payments, metadata);
const view = buildHomeView(payments, metadata, boaTransactions);
const slackToken = await getSlackToken();
const res = await fetch("https://slack.com/api/views.publish", {
@ -267,6 +285,27 @@ function buildPaymentListBlocks(items) {
return blocks;
}
// --- Scan recent BoA transaction records ---
async function scanBoATransactions() {
const items = [];
let lastKey;
do {
const result = await ddb.send(
new ScanCommand({
TableName: TABLE_NAME,
FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "boa_txn#" },
ExclusiveStartKey: lastKey,
})
);
items.push(...result.Items);
lastKey = result.LastEvaluatedKey;
} while (lastKey);
items.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
return items.slice(0, 10);
}
// --- Scan all payments from DynamoDB ---
async function scanPayments() {
@ -289,7 +328,72 @@ async function scanPayments() {
// --- Build the App Home view ---
function buildHomeView(payments, metadata) {
function formatBoATimestamp(iso) {
if (!iso) return "unknown";
const d = new Date(iso);
if (isNaN(d.getTime())) return "unknown";
return d.toLocaleString("en-US", {
timeZone: "America/New_York",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}) + " ET";
}
function buildBoABlocks(transactions) {
const blocks = [
{
type: "header",
text: { type: "plain_text", text: ":bank: Recent BoA Submissions" },
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: transactions.length
? `Last ${transactions.length} submission${transactions.length !== 1 ? "s" : ""} · retained 90 days`
: "No submissions in the last 90 days",
},
],
},
];
if (!transactions.length) {
blocks.push({ type: "divider" });
return blocks;
}
for (const t of transactions) {
const when = formatBoATimestamp(t.timestamp);
const checks = Array.isArray(t.check_numbers) ? t.check_numbers : [];
const checksLabel = checks.length <= 3
? checks.join(", ")
: `${checks.slice(0, 3).join(", ")} +${checks.length - 3} more`;
const statusIcon = t.success ? ":white_check_mark:" : ":x:";
const summary = t.success
? `${t.processed_items}/${t.total_items} processed`
: `HTTP ${t.http_status} · failed`;
const txnLine = t.transaction_id
? `TxnID: \`${t.transaction_id}\``
: "_TxnID not captured_";
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `*${when}* · \`${t.action}\` · ${statusIcon} ${summary}\n${checks.length} check${checks.length !== 1 ? "s" : ""} ($${t.total_amount}) · ${checksLabel}\n${txnLine}`,
},
});
}
blocks.push({ type: "divider" });
return blocks;
}
function buildHomeView(payments, metadata, boaTransactions = []) {
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding } = categorizePayments(payments);
const buildScheduledBlocks = (title, emoji, items, methodKey) => {
@ -409,6 +513,8 @@ function buildHomeView(payments, metadata) {
const totalScheduledAmt = [...scheduledChecks, ...scheduledACH].reduce((sum, p) => sum + p.amount_usd, 0);
const totalOutstandingAmt = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0);
const staleness = formatStaleness(metadata?.last_updated);
return {
type: "home",
blocks: [
@ -419,10 +525,21 @@ function buildHomeView(payments, metadata) {
{
type: "context",
elements: [
{ type: "mrkdwn", text: `Last updated · ${metadata?.last_updated || "N/A"}` },
{ type: "mrkdwn", text: `Last updated · ${staleness.label}` },
{ type: "mrkdwn", text: `Source · ${metadata?.file_name || "N/A"}` },
],
},
...(staleness.stale
? [
{
type: "section",
text: {
type: "mrkdwn",
text: `:warning: *Stampli data is ${staleness.label}.* Upload a fresh export to refresh the dashboard.`,
},
},
]
: []),
{ type: "divider" },
{
type: "section",
@ -441,6 +558,7 @@ function buildHomeView(payments, metadata) {
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "checks"),
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "ach"),
...buildOutstandingBlocks(),
...buildBoABlocks(boaTransactions),
],
};
}

View file

@ -125,6 +125,9 @@ Resources:
KeySchema:
- AttributeName: pk
KeyType: HASH
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
ProcessPaymentCsvFunction:
Type: AWS::Serverless::Function