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); // Categorize payments const scheduledChecks = []; const scheduledACH = []; const outstandingChecks = []; const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"]; const ninetyDaysAgo = new Date(today); ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); 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) { // Future — scheduled if (p.method === "Check") scheduledChecks.push(p); else scheduledACH.push(p); } else if (p.method === "Check") { // Past-due check not cleared — outstanding outstandingChecks.push(p); } // Past-due ACH not cleared is ignored (should have been auto-cleared) } // 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); // Split outstanding into <=90 days and >90 days const outstanding90 = outstandingChecks.filter((p) => parseMDYLocal(p.send_payment_on) >= ninetyDaysAgo); const outstandingOver90 = outstandingChecks.filter((p) => parseMDYLocal(p.send_payment_on) < ninetyDaysAgo); const buildScheduledBlocks = (title, emoji, items) => { const total = items.reduce((sum, p) => sum + p.amount_usd, 0); const blocks = [ { type: "section", text: { type: "mrkdwn", text: `*${emoji} ${title}*\n${items.length} payments • ${formatCurrency(total)}`, }, }, { type: "divider" }, ]; if (!items.length) { blocks.push({ type: "section", text: { type: "mrkdwn", text: "_None_" }, }); 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"; blocks.push({ type: "section", fields: [ { type: "mrkdwn", text: `*${dateLabel}*` }, { type: "mrkdwn", text: `${group.payments.length} payments • ${formatCurrency(dayTotal)}`, }, ], }); } blocks.push({ type: "divider" }); return blocks; }; const buildOutstandingBlocks = () => { const totalAll = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0); const blocks = [ { type: "section", text: { type: "mrkdwn", text: `*:warning: Outstanding Checks*\n${outstandingChecks.length} checks • ${formatCurrency(totalAll)}`, }, }, { type: "divider" }, ]; if (!outstandingChecks.length) { blocks.push({ type: "section", text: { type: "mrkdwn", text: "_None_" }, }); blocks.push({ type: "divider" }); return blocks; } const total90 = outstanding90.reduce((sum, p) => sum + p.amount_usd, 0); const totalOver90 = outstandingOver90.reduce((sum, p) => sum + p.amount_usd, 0); blocks.push({ type: "section", fields: [ { type: "mrkdwn", text: `*90 days or less*` }, { type: "mrkdwn", text: `${outstanding90.length} checks • ${formatCurrency(total90)}` }, ], }); blocks.push({ type: "section", fields: [ { type: "mrkdwn", text: `*Over 90 days*` }, { type: "mrkdwn", text: `${outstandingOver90.length} checks • ${formatCurrency(totalOver90)}` }, ], }); blocks.push({ type: "divider" }); return blocks; }; return { type: "home", blocks: [ { type: "header", text: { type: "plain_text", text: "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" }, ...buildScheduledBlocks("Scheduled Checks", ":calendar:", scheduledChecks), ...buildScheduledBlocks("Scheduled ACH", ":calendar:", scheduledACH), ...buildOutstandingBlocks(), ], }; }