From b242df15b59e24018aecc113961e1965e388d529 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:40:55 -0400 Subject: [PATCH] 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## 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. --- .gitignore | 2 + package.json | 3 + scripts/reissue-checks.cjs | 184 ++++++++++++++++++++++++++++++++++++ scripts/simulate-csv.cjs | 116 +++++++++++++++++++++++ scripts/stampli-uploader.sh | 45 +++++++++ src/processPaymentCsv.js | 39 +++++++- src/slackAppHome.js | 126 +++++++++++++++++++++++- template.yaml | 3 + 8 files changed, 513 insertions(+), 5 deletions(-) create mode 100644 scripts/reissue-checks.cjs create mode 100644 scripts/simulate-csv.cjs create mode 100755 scripts/stampli-uploader.sh diff --git a/.gitignore b/.gitignore index e469f0d..b62cb70 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ data/ .DS_Store BofA API Resources/ *.csv +postman-output.txt +stampli_*.txt diff --git a/package.json b/package.json index 2dc9bf0..533fdb5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/reissue-checks.cjs b/scripts/reissue-checks.cjs new file mode 100644 index 0000000..d4596e5 --- /dev/null +++ b/scripts/reissue-checks.cjs @@ -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}`); + } +})(); diff --git a/scripts/simulate-csv.cjs b/scripts/simulate-csv.cjs new file mode 100644 index 0000000..8def8ea --- /dev/null +++ b/scripts/simulate-csv.cjs @@ -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 "); 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}`); +})(); diff --git a/scripts/stampli-uploader.sh b/scripts/stampli-uploader.sh new file mode 100755 index 0000000..11302d3 --- /dev/null +++ b/scripts/stampli-uploader.sh @@ -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 diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index f8fdb41..fc080ea 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -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; }; diff --git a/src/slackAppHome.js b/src/slackAppHome.js index e099616..fa3c2cb 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -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), ], }; } diff --git a/template.yaml b/template.yaml index 4cc4d5a..965f2ab 100644 --- a/template.yaml +++ b/template.yaml @@ -125,6 +125,9 @@ Resources: KeySchema: - AttributeName: pk KeyType: HASH + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true ProcessPaymentCsvFunction: Type: AWS::Serverless::Function