import crypto from "node:crypto"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager"; import { parseISOLocal, parseMDYLocal } from "./dates.js"; import { isCancelStatus, logSafe } from "./boaRecon.js"; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); const secrets = new SecretsManagerClient(); const TABLE_NAME = process.env.TABLE_NAME; const SLACK_SIGNING_SECRET_NAME = process.env.SLACK_SIGNING_SECRET_NAME; let cachedToken; async function getSlackToken() { if (cachedToken) return cachedToken; const { SecretString } = await secrets.send( new GetSecretValueCommand({ SecretId: process.env.SLACK_BOT_TOKEN_SECRET_NAME }) ); cachedToken = SecretString; return cachedToken; } let cachedSigningSecret; async function getSigningSecret() { if (cachedSigningSecret) return cachedSigningSecret; const { SecretString } = await secrets.send( new GetSecretValueCommand({ SecretId: SLACK_SIGNING_SECRET_NAME }) ); cachedSigningSecret = SecretString; return cachedSigningSecret; } // Verify the Slack request signature (HMAC-SHA256 over v0::). // Rejects requests older than 5 minutes to blunt replay attacks. function verifySignature(body, timestamp, signature, secret) { if (!timestamp || !signature) return false; const ts = Number(timestamp); if (!Number.isFinite(ts)) return false; if (Math.abs(Date.now() / 1000 - ts) > 300) return false; const base = `v0:${timestamp}:${body}`; const expected = "v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex"); const expectedBuf = Buffer.from(expected); const signatureBuf = Buffer.from(signature); if (expectedBuf.length !== signatureBuf.length) return false; return crypto.timingSafeEqual(expectedBuf, signatureBuf); } // --- Shared helpers used by both home view and modals --- const formatCurrency = (value) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format( Number(value || 0) ); const formatDisplayDate = (date) => date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); 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 }, { label: "30 – 45 days", min: 30, max: 45 }, { label: "45 – 60 days", min: 45, max: 60 }, { label: "60 – 90 days", min: 60, max: 90 }, { label: "90+ days", min: 90, max: Infinity }, ]; const byDate = (a, b) => { const da = parseMDYLocal(a.send_payment_on); const db = parseMDYLocal(b.send_payment_on); return (da || 0) - (db || 0); }; // --- Categorize payments into buckets --- export function categorizePayments(payments) { const today = new Date(); today.setHours(0, 0, 0, 0); const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24)); const scheduledChecks = []; const scheduledACH = []; const outstandingChecks = []; const returnedPayments = []; for (const p of payments) { if (p.method !== "ACH" && p.method !== "Check") continue; // Bank truth outranks the CSV lifecycle: a returned check usually // carries status "Cleared" (the ladder has no Returned rung, #66), so // route on clear_status before the skip-list can hide it (#70). // Membership keys off clear_status, not returned_date — a redeposit // re-clears the record but keeps returned_date. Routed before the send- // date parse: a bank-confirmed return must surface even on a record // whose send_payment_on no longer parses. if (p.clear_status === "Returned") { // Terminal voided-and-bounced (the expected void-then-ARP-bounce // cycle) is not a reissue decision: audit trail only. if (!isCancelStatus(p.status)) returnedPayments.push(p); continue; } const dt = parseMDYLocal(p.send_payment_on); if (!dt) continue; const status = (p.status || "").toLowerCase(); if (skipStatuses.includes(status)) continue; if (dt >= today) { if (p.method === "Check") scheduledChecks.push(p); else scheduledACH.push(p); } else if (p.method === "Check") { outstandingChecks.push(p); } } scheduledChecks.sort(byDate); scheduledACH.sort(byDate); // Oldest return first: the longest-unpaid vendor is the most overdue // decision. Missing returned_date sorts first (unknown = assume worst). returnedPayments.sort((a, b) => String(a.returned_date || "").localeCompare(String(b.returned_date || "")) ); const bucketedOutstanding = ageBuckets.map((bucket) => { const items = outstandingChecks.filter((p) => { const age = daysSince(parseMDYLocal(p.send_payment_on)); return age >= bucket.min && age < bucket.max; }); items.sort(byDate); const total = items.reduce((sum, p) => sum + p.amount_usd, 0); return { ...bucket, items, total }; }); return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments }; } // --- Main handler --- export const handler = async (event) => { // Decode body — API Gateway may base64-encode it let rawBody = event.body || ""; if (event.isBase64Encoded) { rawBody = Buffer.from(rawBody, "base64").toString("utf-8"); } // Verify the Slack signature over the raw request body before doing anything // else — without this, an unauthenticated caller could forge events and // exfiltrate payment data via views.publish. const headers = Object.fromEntries( Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v]) ); const timestamp = headers["x-slack-request-timestamp"] || ""; const signature = headers["x-slack-signature"] || ""; const signingSecret = await getSigningSecret(); if (!verifySignature(rawBody, timestamp, signature, signingSecret)) { console.log("Signature verification failed"); return { statusCode: 401, body: "unauthorized" }; } // Slack sends block_actions as form-encoded: payload= let body; if (rawBody.startsWith("payload=")) { body = JSON.parse(decodeURIComponent(rawBody.replace("payload=", ""))); } else { body = JSON.parse(rawBody || "{}"); } // Handle Slack URL verification challenge if (body.type === "url_verification") { return { statusCode: 200, body: body.challenge }; } // Handle button clicks → toggle sections or open modal if (body.type === "block_actions") { return handleBlockAction(body); } // Only process app_home_opened events if (body.event?.type !== "app_home_opened") { return { statusCode: 200, body: JSON.stringify({ ok: true }) }; } const userId = body.event.user; return publishHomeView(userId, []); }; async function publishHomeView(userId, expanded) { const { Item: metadata } = await ddb.send( new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } }) ); const [payments, boaTransactions, cashPosition] = await Promise.all([ scanPayments(), scanBoATransactions(), fetchCashPosition(), ]); if (!payments.length) { return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) }; } const view = buildHomeView(payments, metadata, boaTransactions, cashPosition, expanded); const slackToken = await getSlackToken(); const res = await fetch("https://slack.com/api/views.publish", { method: "POST", headers: { Authorization: `Bearer ${slackToken}`, "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify({ user_id: userId, view }), }); const data = await res.json(); if (!data.ok) { console.error("Slack API error:", JSON.stringify(data)); return { statusCode: 500, body: JSON.stringify(data) }; } return { statusCode: 200, body: JSON.stringify({ ok: true }) }; } // --- Block action handler (button clicks) --- async function handleBlockAction(body) { const action = body.actions?.[0]; if (!action) return { statusCode: 200, body: JSON.stringify({ ok: true }) }; const triggerId = body.trigger_id; const actionId = action.action_id; // Toggle section expand/collapse → re-publish home view if (actionId.startsWith("toggle_section_")) { const section = actionId.replace("toggle_section_", ""); const meta = JSON.parse(body.view?.private_metadata || "{}"); const expanded = Array.isArray(meta.expanded) ? [...meta.expanded] : []; const idx = expanded.indexOf(section); if (idx >= 0) expanded.splice(idx, 1); else expanded.push(section); const userId = body.user?.id; return publishHomeView(userId, expanded); } const payments = await scanPayments(); const { today, daysSince, scheduledChecks, scheduledACH, bucketedOutstanding } = categorizePayments(payments); let modalTitle = ""; let items = []; if (actionId.startsWith("view_scheduled_")) { const dateKey = actionId.replace("view_scheduled_checks_", "").replace("view_scheduled_ach_", ""); const method = actionId.includes("_checks_") ? "Check" : "ACH"; const source = method === "Check" ? scheduledChecks : scheduledACH; items = source.filter((p) => { const dt = parseMDYLocal(p.send_payment_on); return dt && dt.toISOString().split("T")[0] === dateKey; }); const dt = items[0] ? parseMDYLocal(items[0].send_payment_on) : null; modalTitle = dt ? formatDisplayDate(dt) : dateKey; } else if (actionId.startsWith("view_outstanding_")) { const bucketIdx = parseInt(actionId.replace("view_outstanding_", ""), 10); const bucket = bucketedOutstanding[bucketIdx]; if (bucket) { items = bucket.items; modalTitle = bucket.label; } } if (!items.length) { return { statusCode: 200, body: JSON.stringify({ ok: true }) }; } const modalBlocks = buildPaymentListBlocks(items); const slackToken = await getSlackToken(); const res = await fetch("https://slack.com/api/views.open", { method: "POST", headers: { Authorization: `Bearer ${slackToken}`, "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify({ trigger_id: triggerId, view: { type: "modal", title: { type: "plain_text", text: modalTitle.slice(0, 24) }, close: { type: "plain_text", text: "Close" }, blocks: modalBlocks, }, }), }); const data = await res.json(); if (!data.ok) { console.error("Slack views.open error:", JSON.stringify(data)); } return { statusCode: 200, body: JSON.stringify({ ok: true }) }; } // --- Build modal payment list --- function buildPaymentListBlocks(items) { const total = items.reduce((sum, p) => sum + p.amount_usd, 0); const blocks = [ { type: "context", elements: [ { type: "mrkdwn", text: `${items.length} payment${items.length !== 1 ? "s" : ""} · ${formatCurrency(total)} total`, }, ], }, { type: "divider" }, ]; for (const p of items) { const dt = parseMDYLocal(p.send_payment_on); const dateStr = dt ? formatDisplayDate(dt) : "N/A"; const payee = p.payee || "—"; const checkNum = p.check_number || "—"; blocks.push({ type: "section", fields: [ { type: "mrkdwn", text: `*${payee}*\n${p.method === "ACH" ? "Reference" : "Check"} #${checkNum}` }, { type: "mrkdwn", text: `*${formatCurrency(p.amount_usd)}*\n${dateStr}` }, ], }); } 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); const filtered = items.filter((t) => !(t.backfill && !t.success)); filtered.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || "")); return filtered.slice(0, 5); } // --- Scan all payments from DynamoDB --- async function scanPayments() { const payments = []; let lastKey; do { const result = await ddb.send( new ScanCommand({ TableName: TABLE_NAME, FilterExpression: "begins_with(pk, :prefix)", ExpressionAttributeValues: { ":prefix": "payment#" }, ExclusiveStartKey: lastKey, }) ); payments.push(...result.Items); lastKey = result.LastEvaluatedKey; } while (lastKey); return payments; } // --- Read the latest previous-day balance snapshot --- async function fetchCashPosition() { const now = new Date(); // Local-midnight today so date arithmetic stays in local time: toISOString() // gives UTC, which from 8pm ET is tomorrow's date (#80-review). const localToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const toDateStr = (d) => d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); const todayStr = toDateStr(localToday); // Intraday (today, current-day) + up to 13 prior previous-day snapshots, // all fetched in parallel. A previous-day snapshot dated today cannot exist // until tomorrow, so the walk starts at i = 1 (#80-review). const keys = [{ key: `boa_balance#${todayStr}#current-day` }]; for (let i = 1; i < 14; i++) { const d = new Date(localToday); d.setDate(d.getDate() - i); const ds = toDateStr(d); keys.push({ key: `boa_balance#${ds}#previous-day` }); } const results = await Promise.allSettled( keys.map(({ key }) => ddb.send(new GetCommand({ TableName: TABLE_NAME, Key: { pk: key } })) ) ); let intraday = null; if (results[0].status === "fulfilled" && results[0].value.Item) { intraday = results[0].value.Item; } else if (results[0].status === "rejected") { console.error( "Cash-position intraday GetItem failed:", logSafe(results[0].reason?.name), logSafe(results[0].reason?.message) ); } // Walk previous-day results (indices 1..) newest-first; pick the first hit. let previousDay = null; for (let i = 1; i < results.length; i++) { const res = results[i]; if (res.status === "rejected") { console.error( "Cash-position previous-day GetItem failed:", logSafe(res.reason?.name), logSafe(res.reason?.message) ); continue; } if (res.value.Item) { previousDay = res.value.Item; break; } } return { previousDay, intraday }; } // --- Build the App Home view --- 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" : ""} · 90-day retention` : "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; } export function buildHomeView(payments, metadata, boaTransactions = [], cashPosition = null, expanded = []) { const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments } = categorizePayments(payments); const isExpanded = (key) => expanded.includes(key); // Always expanded, no toggle: these sat invisible for months once (#70), // so the action queue never collapses. Returns are rare and get resolved, // so the list stays short. const buildReturnedBlocks = () => { if (!returnedPayments.length) return []; const total = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0); const blocks = [ { type: "header", text: { type: "plain_text", text: ":rotating_light: Returned — Needs Action" }, }, { type: "context", elements: [ { type: "mrkdwn", text: `${returnedPayments.length} payment${returnedPayments.length !== 1 ? "s" : ""} · ${formatCurrency(total)} · bank-returned, each needs a reissue or void decision`, }, ], }, ]; for (const p of returnedPayments) { const rd = parseISOLocal(p.returned_date); const age = rd ? daysSince(rd) : null; const returnedLine = rd ? `Returned ${formatDisplayDate(rd)} · *${age} day${age !== 1 ? "s" : ""} ago*` : "_Return date unknown_"; blocks.push({ type: "section", fields: [ { type: "mrkdwn", text: `*${p.payee || "—"}*\n${p.method === "ACH" ? "Reference" : "Check"} #${p.check_number || "—"}`, }, { type: "mrkdwn", text: `*${formatCurrency(p.amount_usd)}*\n${returnedLine}`, }, ], }); } blocks.push({ type: "divider" }); return blocks; }; const buildScheduledBlocks = (title, emoji, items, sectionKey, methodKey) => { const total = items.reduce((sum, p) => sum + p.amount_usd, 0); const open = isExpanded(sectionKey); const blocks = [ { type: "section", text: { type: "mrkdwn", text: `${open ? ":large_orange_diamond:" : ":small_orange_diamond:"} ${emoji} *${title}* · ${items.length} payment${items.length !== 1 ? "s" : ""} · ${formatCurrency(total)}`, }, accessory: { type: "button", text: { type: "plain_text", text: open ? "Collapse" : "Expand" }, action_id: `toggle_section_${sectionKey}`, }, }, ]; if (!open) { blocks.push({ type: "divider" }); return blocks; } if (!items.length) { blocks.push({ type: "section", text: { type: "mrkdwn", text: "_No upcoming payments_" }, }); blocks.push({ type: "divider" }); return blocks; } // Group by date const grouped = {}; for (const p of items) { const dt = parseMDYLocal(p.send_payment_on); const key = dt ? dt.toISOString().split("T")[0] : "unknown"; if (!grouped[key]) grouped[key] = { date: dt, payments: [] }; grouped[key].payments.push(p); } for (const key of Object.keys(grouped).sort()) { const group = grouped[key]; const dayTotal = group.payments.reduce((sum, p) => sum + p.amount_usd, 0); const dateLabel = group.date ? formatDisplayDate(group.date) : "Unknown"; const daysUntil = group.date ? Math.ceil((group.date - today) / (1000 * 60 * 60 * 24)) : null; const daysTag = daysUntil === 0 ? ":rotating_light: _Today_" : daysUntil === 1 ? "_Tomorrow_" : daysUntil != null ? `_in ${daysUntil} days_` : ""; blocks.push({ type: "section", text: { type: "mrkdwn", text: `*${dateLabel}* ${daysTag}\n${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`, }, accessory: { type: "button", text: { type: "plain_text", text: "View" }, action_id: `view_scheduled_${methodKey}_${key}`, }, }); } blocks.push({ type: "divider" }); return blocks; }; const buildOutstandingBlocks = () => { const totalAll = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0); const open = isExpanded("outstanding"); const blocks = [ { type: "section", text: { type: "mrkdwn", text: `${open ? ":large_orange_diamond:" : ":small_orange_diamond:"} :warning: *Outstanding Checks* · ${outstandingChecks.length} check${outstandingChecks.length !== 1 ? "s" : ""} · ${formatCurrency(totalAll)}`, }, accessory: { type: "button", text: { type: "plain_text", text: open ? "Collapse" : "Expand" }, action_id: "toggle_section_outstanding", }, }, ]; if (!open) { blocks.push({ type: "divider" }); return blocks; } if (!outstandingChecks.length) { blocks.push({ type: "section", text: { type: "mrkdwn", text: ":white_check_mark: _All checks have cleared_" }, }); blocks.push({ type: "divider" }); return blocks; } for (let i = 0; i < bucketedOutstanding.length; i++) { const bucket = bucketedOutstanding[i]; if (!bucket.items.length) continue; blocks.push({ type: "section", text: { type: "mrkdwn", text: `*${bucket.label}*\n${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`, }, accessory: { type: "button", text: { type: "plain_text", text: "View" }, action_id: `view_outstanding_${i}`, }, }); } blocks.push({ type: "divider" }); return blocks; }; // Summary bar const totalScheduled = scheduledChecks.length + scheduledACH.length; const totalScheduledAmt = [...scheduledChecks, ...scheduledACH].reduce((sum, p) => sum + p.amount_usd, 0); const totalOutstandingAmt = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0); const totalReturnedAmt = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0); const balance = cashPosition?.previousDay; const intraday = cashPosition?.intraday; const staleness = formatStaleness(metadata?.last_updated); return { type: "home", private_metadata: JSON.stringify({ expanded }), blocks: [ { type: "header", text: { type: "plain_text", text: ":bank: Payments Dashboard" }, }, { type: "context", elements: [ { 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", fields: [ { type: "mrkdwn", text: balance && balance.current_ledger != null ? `:moneybag: *Cash Position* — ${balance.as_of_date}\nLedger ${formatCurrency(balance.current_ledger)} · Available ${formatCurrency(balance.current_available)}` : `:moneybag: *Cash Position*\nNot available`, }, { type: "mrkdwn", text: intraday && intraday.current_ledger != null ? `_Intraday (provisional)_\nLedger ${formatCurrency(intraday.current_ledger)} · Available ${formatCurrency(intraday.current_available)}` : balance ? "_Intraday not yet available_" : "_No balance data_", }, { type: "mrkdwn", text: `:calendar: *Scheduled*\n${totalScheduled} payments · ${formatCurrency(totalScheduledAmt)}`, }, { type: "mrkdwn", text: `:warning: *Outstanding*\n${outstandingChecks.length} checks · ${formatCurrency(totalOutstandingAmt)}`, }, ...(returnedPayments.length ? [ { type: "mrkdwn", text: `:rotating_light: *Returned*\n${returnedPayments.length} payment${returnedPayments.length !== 1 ? "s" : ""} · ${formatCurrency(totalReturnedAmt)}`, }, ] : []), ], }, { type: "divider" }, ...buildReturnedBlocks(), ...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "scheduled_checks", "checks"), ...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "scheduled_ach", "ach"), ...buildOutstandingBlocks(), ...buildBoABlocks(boaTransactions), ], }; }