payments-dashboard/src/slackAppHome.js
Adam Moussa 90accf2f39 Migrate secrets from SSM to Secrets Manager
API tokens and credentials must live in Secrets Manager per
secrets-and-config.md, but the four original payment Lambdas still
read 10 SecureString SSM params. Move them to three grouped secrets
(slack-bot-token plaintext, boa-check-mgmt and boa-reporting as JSON),
matching the pattern the expense Lambdas already use. IAM is scoped to
secretsmanager:GetSecretValue per secret; the VPC Lambdas reach the
public endpoint over the existing NAT path. Test/reissue scripts and
the client-ssm dependency are updated/removed accordingly.

Refs: #3
2026-06-02 20:29:18 -04:00

591 lines
18 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 { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const secrets = new SecretsManagerClient();
const TABLE_NAME = process.env.TABLE_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;
}
// --- 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 → 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] = await Promise.all([
scanPayments(),
scanBoATransactions(),
]);
if (!payments.length) {
return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) };
}
const view = buildHomeView(payments, metadata, boaTransactions, 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;
}
// --- 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;
}
function buildHomeView(payments, metadata, boaTransactions = [], expanded = []) {
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding } = categorizePayments(payments);
const isExpanded = (key) => expanded.includes(key);
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 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: `: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, "scheduled_checks", "checks"),
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "scheduled_ach", "ach"),
...buildOutstandingBlocks(),
...buildBoABlocks(boaTransactions),
],
};
}