2026-04-09 13:29:28 -04:00
|
|
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
2026-04-09 14:27:34 -04:00
|
|
|
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
|
|
|
|
|
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
|
2026-04-09 13:29:28 -04:00
|
|
|
|
|
|
|
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
2026-04-09 14:27:34 -04:00
|
|
|
const ssm = new SSMClient();
|
2026-04-09 13:29:28 -04:00
|
|
|
const TABLE_NAME = process.env.TABLE_NAME;
|
2026-04-09 14:27:34 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-04-09 13:29:28 -04:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-04-09 14:27:34 -04:00
|
|
|
// Get metadata
|
|
|
|
|
const { Item: metadata } = await ddb.send(
|
|
|
|
|
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
|
2026-04-09 13:29:28 -04:00
|
|
|
);
|
|
|
|
|
|
2026-04-09 14:27:34 -04:00
|
|
|
// 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" }) };
|
2026-04-09 13:29:28 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build and publish the home view
|
2026-04-09 14:27:34 -04:00
|
|
|
const view = buildHomeView(payments, metadata);
|
|
|
|
|
const slackToken = await getSlackToken();
|
2026-04-09 13:29:28 -04:00
|
|
|
|
|
|
|
|
const res = await fetch("https://slack.com/api/views.publish", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
2026-04-09 14:27:34 -04:00
|
|
|
Authorization: `Bearer ${slackToken}`,
|
2026-04-09 13:29:28 -04:00
|
|
|
"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 }) };
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-09 14:27:34 -04:00
|
|
|
function buildHomeView(payments, metadata) {
|
2026-04-09 13:29:28 -04:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-10 17:23:54 -04:00
|
|
|
const formatDisplayDate = (date) =>
|
|
|
|
|
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
2026-04-09 13:29:28 -04:00
|
|
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
|
|
2026-04-10 17:28:01 -04:00
|
|
|
// Categorize payments
|
|
|
|
|
const scheduledChecks = [];
|
|
|
|
|
const scheduledACH = [];
|
|
|
|
|
const outstandingChecks = [];
|
2026-04-09 13:29:28 -04:00
|
|
|
|
2026-04-10 17:28:01 -04:00
|
|
|
const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"];
|
|
|
|
|
const ninetyDaysAgo = new Date(today);
|
|
|
|
|
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
|
2026-04-09 13:29:28 -04:00
|
|
|
|
2026-04-10 17:23:54 -04:00
|
|
|
for (const p of payments) {
|
|
|
|
|
if (p.method !== "ACH" && p.method !== "Check") continue;
|
2026-04-09 13:29:28 -04:00
|
|
|
|
2026-04-10 17:23:54 -04:00
|
|
|
const dt = parseMDYLocal(p.send_payment_on);
|
|
|
|
|
if (!dt) continue;
|
|
|
|
|
|
|
|
|
|
const status = (p.status || "").toLowerCase();
|
|
|
|
|
if (skipStatuses.includes(status)) continue;
|
|
|
|
|
|
2026-04-10 17:28:01 -04:00
|
|
|
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);
|
2026-04-09 13:29:28 -04:00
|
|
|
}
|
2026-04-10 17:28:01 -04:00
|
|
|
// Past-due ACH not cleared is ignored (should have been auto-cleared)
|
2026-04-10 17:23:54 -04:00
|
|
|
}
|
2026-04-09 13:29:28 -04:00
|
|
|
|
2026-04-10 17:23:54 -04:00
|
|
|
// 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);
|
2026-04-09 13:29:28 -04:00
|
|
|
};
|
2026-04-10 17:28:01 -04:00
|
|
|
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);
|
2026-04-09 13:29:28 -04:00
|
|
|
|
2026-04-10 17:28:01 -04:00
|
|
|
const buildScheduledBlocks = (title, emoji, items) => {
|
2026-04-10 17:23:54 -04:00
|
|
|
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
|
2026-04-09 13:29:28 -04:00
|
|
|
|
|
|
|
|
const blocks = [
|
|
|
|
|
{
|
|
|
|
|
type: "section",
|
|
|
|
|
text: {
|
|
|
|
|
type: "mrkdwn",
|
2026-04-10 17:23:54 -04:00
|
|
|
text: `*${emoji} ${title}*\n${items.length} payments • ${formatCurrency(total)}`,
|
2026-04-09 13:29:28 -04:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ type: "divider" },
|
|
|
|
|
];
|
|
|
|
|
|
2026-04-10 17:23:54 -04:00
|
|
|
if (!items.length) {
|
2026-04-09 13:29:28 -04:00
|
|
|
blocks.push({
|
|
|
|
|
type: "section",
|
2026-04-10 17:23:54 -04:00
|
|
|
text: { type: "mrkdwn", text: "_None_" },
|
2026-04-09 13:29:28 -04:00
|
|
|
});
|
|
|
|
|
blocks.push({ type: "divider" });
|
|
|
|
|
return blocks;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:23:54 -04:00
|
|
|
// 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";
|
|
|
|
|
|
2026-04-09 13:29:28 -04:00
|
|
|
blocks.push({
|
|
|
|
|
type: "section",
|
|
|
|
|
fields: [
|
2026-04-10 17:23:54 -04:00
|
|
|
{ type: "mrkdwn", text: `*${dateLabel}*` },
|
|
|
|
|
{
|
|
|
|
|
type: "mrkdwn",
|
|
|
|
|
text: `${group.payments.length} payments • ${formatCurrency(dayTotal)}`,
|
|
|
|
|
},
|
2026-04-09 13:29:28 -04:00
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks.push({ type: "divider" });
|
|
|
|
|
return blocks;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-10 17:28:01 -04:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-09 13:29:28 -04:00
|
|
|
return {
|
|
|
|
|
type: "home",
|
|
|
|
|
blocks: [
|
|
|
|
|
{
|
|
|
|
|
type: "header",
|
|
|
|
|
text: { type: "plain_text", text: "Payments Dashboard" },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
type: "context",
|
|
|
|
|
elements: [
|
2026-04-09 14:27:34 -04:00
|
|
|
{ type: "mrkdwn", text: `*Last updated:* ${metadata?.last_updated || "N/A"}` },
|
|
|
|
|
{ type: "mrkdwn", text: `*Source:* ${metadata?.file_name || "N/A"}` },
|
2026-04-09 13:29:28 -04:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{ type: "divider" },
|
2026-04-10 17:28:01 -04:00
|
|
|
...buildScheduledBlocks("Scheduled Checks", ":calendar:", scheduledChecks),
|
|
|
|
|
...buildScheduledBlocks("Scheduled ACH", ":calendar:", scheduledACH),
|
|
|
|
|
...buildOutstandingBlocks(),
|
2026-04-09 13:29:28 -04:00
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|