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; } export const handler = async (event) => { const body = JSON.parse(event.body || "{}"); // Handle Slack URL verification challenge if (body.type === "url_verification") { return { statusCode: 200, body: body.challenge }; } // 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; // Get metadata const { Item: metadata } = await ddb.send( new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } }) ); // Scan all payment items 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); if (!payments.length) { return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) }; } // Build and publish the home view const view = buildHomeView(payments, metadata); 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 }) }; }; function buildHomeView(payments, metadata) { 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" }); const today = new Date(); today.setHours(0, 0, 0, 0); const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24)); // Categorize payments const scheduledChecks = []; const scheduledACH = []; const outstandingChecks = []; const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"]; 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); } } // Sort by date const byDate = (a, b) => { const da = parseMDYLocal(a.send_payment_on); const db = parseMDYLocal(b.send_payment_on); return (da || 0) - (db || 0); }; scheduledChecks.sort(byDate); scheduledACH.sort(byDate); // Age buckets for outstanding checks 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 bucketedOutstanding = ageBuckets.map((bucket) => { const items = outstandingChecks.filter((p) => { const age = daysSince(parseMDYLocal(p.send_payment_on)); return age >= bucket.min && age < bucket.max; }); const total = items.reduce((sum, p) => sum + p.amount_usd, 0); return { ...bucket, items, total }; }); // --- Block builders --- const buildScheduledBlocks = (title, emoji, items) => { 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", fields: [ { type: "mrkdwn", text: `*${dateLabel}* ${daysTag}` }, { type: "mrkdwn", text: `${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`, }, ], }); } 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 (const bucket of bucketedOutstanding) { if (!bucket.items.length) continue; blocks.push({ type: "section", fields: [ { type: "mrkdwn", text: `*${bucket.label}*` }, { type: "mrkdwn", text: `${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`, }, ], }); } 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); return { type: "home", blocks: [ { type: "header", text: { type: "plain_text", text: ":bank: Payments Dashboard" }, }, { type: "context", elements: [ { type: "mrkdwn", text: `Last updated · ${metadata?.last_updated || "N/A"}` }, { type: "mrkdwn", text: `Source · ${metadata?.file_name || "N/A"}` }, ], }, { 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), ...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH), ...buildOutstandingBlocks(), ], }; }