mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-03 01:03:28 +00:00
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:
parent
e6fbd3de1b
commit
b242df15b5
8 changed files with 513 additions and 5 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,3 +5,5 @@ data/
|
|||
.DS_Store
|
||||
BofA API Resources/
|
||||
*.csv
|
||||
postman-output.txt
|
||||
stampli_*.txt
|
||||
|
|
|
|||
|
|
@ -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
184
scripts/reissue-checks.cjs
Normal 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
116
scripts/simulate-csv.cjs
Normal 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
45
scripts/stampli-uploader.sh
Executable 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
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,9 @@ Resources:
|
|||
KeySchema:
|
||||
- AttributeName: pk
|
||||
KeyType: HASH
|
||||
TimeToLiveSpecification:
|
||||
AttributeName: ttl
|
||||
Enabled: true
|
||||
|
||||
ProcessPaymentCsvFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue