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"]; 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= 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 = await scanPayments(); if (!payments.length) { return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) }; } 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 }) }; }; // --- 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}*\nCheck #${checkNum}` }, { type: "mrkdwn", text: `*${formatCurrency(p.amount_usd)}*\n${dateStr}` }, ], }); } return blocks; } // --- 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 buildHomeView(payments, metadata) { 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); 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, "checks"), ...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "ach"), ...buildOutstandingBlocks(), ], }; }