payments-dashboard/src/slackAppHome.js
Adam Moussa b242df15b5 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.
2026-04-20 17:40:55 -04:00

564 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const ssm = new SSMClient();
const TABLE_NAME = process.env.TABLE_NAME;
let cachedToken;
async function getSlackToken() {
if (cachedToken) return cachedToken;
const { Parameter } = await ssm.send(
new GetParameterCommand({
Name: process.env.SLACK_BOT_TOKEN_PARAM,
WithDecryption: true,
})
);
cachedToken = Parameter.Value;
return cachedToken;
}
// --- 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 parseMDYLocal = (value) => {
if (!value) return null;
const parts = String(value).split("/");
if (parts.length !== 3) return null;
const [mm, dd, yyyy] = parts.map((p) => parseInt(p, 10));
if (!mm || !dd || !yyyy) return null;
return new Date(yyyy, mm - 1, dd);
};
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 ---
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 = [];
for (const p of payments) {
if (p.method !== "ACH" && p.method !== "Check") 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);
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 };
}
// --- 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");
}
// Slack sends block_actions as form-encoded: payload=<JSON>
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 → 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;
const { Item: metadata } = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
);
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, boaTransactions);
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;
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);
items.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
return items.slice(0, 10);
}
// --- 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;
}
// --- 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" : ""} · 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) => {
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
const blocks = [
{
type: "header",
text: { type: "plain_text", text: `${emoji} ${title}` },
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `${items.length} payment${items.length !== 1 ? "s" : ""} · ${formatCurrency(total)} total`,
},
],
},
];
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 blocks = [
{
type: "header",
text: { type: "plain_text", text: ":warning: Outstanding Checks" },
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `${outstandingChecks.length} check${outstandingChecks.length !== 1 ? "s" : ""} · ${formatCurrency(totalAll)} total`,
},
],
},
];
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 staleness = formatStaleness(metadata?.last_updated);
return {
type: "home",
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: `:calendar: *Scheduled*\n${totalScheduled} payments · ${formatCurrency(totalScheduledAmt)}`,
},
{
type: "mrkdwn",
text: `:warning: *Outstanding*\n${outstandingChecks.length} checks · ${formatCurrency(totalOutstandingAmt)}`,
},
],
},
{ type: "divider" },
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "checks"),
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "ach"),
...buildOutstandingBlocks(),
...buildBoABlocks(boaTransactions),
],
};
}