mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 20:33:12 +00:00
Some checks are pending
Deploy / deploy (push) Waiting to run
* feat: add cash-position tile from boa_balance snapshots to App Home Read the latest previous-day boa_balance snapshot (authoritative) to display current ledger and available balance on the App Home summary bar. Optionally surfaces today's intraday snapshot as provisional. Walks backward up to 14 days to handle weekend/holiday gaps, using targeted GetItem by computed key instead of a scan. Refs: #77 * fix: null-guard cash-position tile, add error logging, drop UTC date skew and serial GetItems - Null-guard current_ledger on the authoritative cash-position tile so a missing ledger renders 'Not available' instead of the false '/bin/bash.00' - Log GetCommand failures with console.error + logSafe instead of silent catch, matching the rest of the codebase - Derive dates from local midnight instead of toISOString UTC, so the current-day lookup doesn't miss from 8pm ET to midnight - Fetch all 14 balance keys in parallel with Promise.allSettled instead of 14 serial GetCommands; start previous-day walk at i=1 since today's previous-day snapshot can't exist until tomorrow - Use Item.as_of_date directly instead of a synthetic _asOfDate field - Remove _asOfDate from test fixtures; add test for the null-ledger guard --------- Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
792 lines
26 KiB
JavaScript
792 lines
26 KiB
JavaScript
import crypto from "node:crypto";
|
||
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";
|
||
import { parseISOLocal, parseMDYLocal } from "./dates.js";
|
||
import { isCancelStatus, logSafe } from "./boaRecon.js";
|
||
|
||
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
||
const secrets = new SecretsManagerClient();
|
||
const TABLE_NAME = process.env.TABLE_NAME;
|
||
const SLACK_SIGNING_SECRET_NAME = process.env.SLACK_SIGNING_SECRET_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;
|
||
}
|
||
|
||
let cachedSigningSecret;
|
||
async function getSigningSecret() {
|
||
if (cachedSigningSecret) return cachedSigningSecret;
|
||
const { SecretString } = await secrets.send(
|
||
new GetSecretValueCommand({ SecretId: SLACK_SIGNING_SECRET_NAME })
|
||
);
|
||
cachedSigningSecret = SecretString;
|
||
return cachedSigningSecret;
|
||
}
|
||
|
||
// Verify the Slack request signature (HMAC-SHA256 over v0:<ts>:<raw body>).
|
||
// Rejects requests older than 5 minutes to blunt replay attacks.
|
||
function verifySignature(body, timestamp, signature, secret) {
|
||
if (!timestamp || !signature) return false;
|
||
const ts = Number(timestamp);
|
||
if (!Number.isFinite(ts)) return false;
|
||
if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
|
||
|
||
const base = `v0:${timestamp}:${body}`;
|
||
const expected =
|
||
"v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
|
||
|
||
const expectedBuf = Buffer.from(expected);
|
||
const signatureBuf = Buffer.from(signature);
|
||
if (expectedBuf.length !== signatureBuf.length) return false;
|
||
return crypto.timingSafeEqual(expectedBuf, signatureBuf);
|
||
}
|
||
|
||
// --- 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 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 ---
|
||
|
||
export 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 = [];
|
||
const returnedPayments = [];
|
||
|
||
for (const p of payments) {
|
||
if (p.method !== "ACH" && p.method !== "Check") continue;
|
||
|
||
// Bank truth outranks the CSV lifecycle: a returned check usually
|
||
// carries status "Cleared" (the ladder has no Returned rung, #66), so
|
||
// route on clear_status before the skip-list can hide it (#70).
|
||
// Membership keys off clear_status, not returned_date — a redeposit
|
||
// re-clears the record but keeps returned_date. Routed before the send-
|
||
// date parse: a bank-confirmed return must surface even on a record
|
||
// whose send_payment_on no longer parses.
|
||
if (p.clear_status === "Returned") {
|
||
// Terminal voided-and-bounced (the expected void-then-ARP-bounce
|
||
// cycle) is not a reissue decision: audit trail only.
|
||
if (!isCancelStatus(p.status)) returnedPayments.push(p);
|
||
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);
|
||
|
||
// Oldest return first: the longest-unpaid vendor is the most overdue
|
||
// decision. Missing returned_date sorts first (unknown = assume worst).
|
||
returnedPayments.sort((a, b) =>
|
||
String(a.returned_date || "").localeCompare(String(b.returned_date || ""))
|
||
);
|
||
|
||
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, returnedPayments };
|
||
}
|
||
|
||
// --- 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");
|
||
}
|
||
|
||
// Verify the Slack signature over the raw request body before doing anything
|
||
// else — without this, an unauthenticated caller could forge events and
|
||
// exfiltrate payment data via views.publish.
|
||
const headers = Object.fromEntries(
|
||
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
||
);
|
||
const timestamp = headers["x-slack-request-timestamp"] || "";
|
||
const signature = headers["x-slack-signature"] || "";
|
||
|
||
const signingSecret = await getSigningSecret();
|
||
if (!verifySignature(rawBody, timestamp, signature, signingSecret)) {
|
||
console.log("Signature verification failed");
|
||
return { statusCode: 401, body: "unauthorized" };
|
||
}
|
||
|
||
// 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, cashPosition] = await Promise.all([
|
||
scanPayments(),
|
||
scanBoATransactions(),
|
||
fetchCashPosition(),
|
||
]);
|
||
|
||
if (!payments.length) {
|
||
return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) };
|
||
}
|
||
|
||
const view = buildHomeView(payments, metadata, boaTransactions, cashPosition, 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;
|
||
}
|
||
|
||
// --- Read the latest previous-day balance snapshot ---
|
||
|
||
async function fetchCashPosition() {
|
||
const now = new Date();
|
||
// Local-midnight today so date arithmetic stays in local time: toISOString()
|
||
// gives UTC, which from 8pm ET is tomorrow's date (#80-review).
|
||
const localToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
|
||
const toDateStr = (d) =>
|
||
d.getFullYear() + "-" +
|
||
String(d.getMonth() + 1).padStart(2, "0") + "-" +
|
||
String(d.getDate()).padStart(2, "0");
|
||
|
||
const todayStr = toDateStr(localToday);
|
||
|
||
// Intraday (today, current-day) + up to 13 prior previous-day snapshots,
|
||
// all fetched in parallel. A previous-day snapshot dated today cannot exist
|
||
// until tomorrow, so the walk starts at i = 1 (#80-review).
|
||
const keys = [{ key: `boa_balance#${todayStr}#current-day` }];
|
||
for (let i = 1; i < 14; i++) {
|
||
const d = new Date(localToday);
|
||
d.setDate(d.getDate() - i);
|
||
const ds = toDateStr(d);
|
||
keys.push({ key: `boa_balance#${ds}#previous-day` });
|
||
}
|
||
|
||
const results = await Promise.allSettled(
|
||
keys.map(({ key }) =>
|
||
ddb.send(new GetCommand({ TableName: TABLE_NAME, Key: { pk: key } }))
|
||
)
|
||
);
|
||
|
||
let intraday = null;
|
||
if (results[0].status === "fulfilled" && results[0].value.Item) {
|
||
intraday = results[0].value.Item;
|
||
} else if (results[0].status === "rejected") {
|
||
console.error(
|
||
"Cash-position intraday GetItem failed:",
|
||
logSafe(results[0].reason?.name),
|
||
logSafe(results[0].reason?.message)
|
||
);
|
||
}
|
||
|
||
// Walk previous-day results (indices 1..) newest-first; pick the first hit.
|
||
let previousDay = null;
|
||
for (let i = 1; i < results.length; i++) {
|
||
const res = results[i];
|
||
if (res.status === "rejected") {
|
||
console.error(
|
||
"Cash-position previous-day GetItem failed:",
|
||
logSafe(res.reason?.name),
|
||
logSafe(res.reason?.message)
|
||
);
|
||
continue;
|
||
}
|
||
if (res.value.Item) {
|
||
previousDay = res.value.Item;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return { previousDay, intraday };
|
||
}
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
export function buildHomeView(payments, metadata, boaTransactions = [], cashPosition = null, expanded = []) {
|
||
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments } = categorizePayments(payments);
|
||
const isExpanded = (key) => expanded.includes(key);
|
||
|
||
// Always expanded, no toggle: these sat invisible for months once (#70),
|
||
// so the action queue never collapses. Returns are rare and get resolved,
|
||
// so the list stays short.
|
||
const buildReturnedBlocks = () => {
|
||
if (!returnedPayments.length) return [];
|
||
const total = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0);
|
||
|
||
const blocks = [
|
||
{
|
||
type: "header",
|
||
text: { type: "plain_text", text: ":rotating_light: Returned — Needs Action" },
|
||
},
|
||
{
|
||
type: "context",
|
||
elements: [
|
||
{
|
||
type: "mrkdwn",
|
||
text: `${returnedPayments.length} payment${returnedPayments.length !== 1 ? "s" : ""} · ${formatCurrency(total)} · bank-returned, each needs a reissue or void decision`,
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
for (const p of returnedPayments) {
|
||
const rd = parseISOLocal(p.returned_date);
|
||
const age = rd ? daysSince(rd) : null;
|
||
const returnedLine = rd
|
||
? `Returned ${formatDisplayDate(rd)} · *${age} day${age !== 1 ? "s" : ""} ago*`
|
||
: "_Return date unknown_";
|
||
|
||
blocks.push({
|
||
type: "section",
|
||
fields: [
|
||
{
|
||
type: "mrkdwn",
|
||
text: `*${p.payee || "—"}*\n${p.method === "ACH" ? "Reference" : "Check"} #${p.check_number || "—"}`,
|
||
},
|
||
{
|
||
type: "mrkdwn",
|
||
text: `*${formatCurrency(p.amount_usd)}*\n${returnedLine}`,
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
blocks.push({ type: "divider" });
|
||
return blocks;
|
||
};
|
||
|
||
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 totalReturnedAmt = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0);
|
||
|
||
const balance = cashPosition?.previousDay;
|
||
const intraday = cashPosition?.intraday;
|
||
|
||
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: balance && balance.current_ledger != null
|
||
? `:moneybag: *Cash Position* — ${balance.as_of_date}\nLedger ${formatCurrency(balance.current_ledger)} · Available ${formatCurrency(balance.current_available)}`
|
||
: `:moneybag: *Cash Position*\nNot available`,
|
||
},
|
||
{
|
||
type: "mrkdwn",
|
||
text: intraday && intraday.current_ledger != null
|
||
? `_Intraday (provisional)_\nLedger ${formatCurrency(intraday.current_ledger)} · Available ${formatCurrency(intraday.current_available)}`
|
||
: balance
|
||
? "_Intraday not yet available_"
|
||
: "_No balance data_",
|
||
},
|
||
{
|
||
type: "mrkdwn",
|
||
text: `:calendar: *Scheduled*\n${totalScheduled} payments · ${formatCurrency(totalScheduledAmt)}`,
|
||
},
|
||
{
|
||
type: "mrkdwn",
|
||
text: `:warning: *Outstanding*\n${outstandingChecks.length} checks · ${formatCurrency(totalOutstandingAmt)}`,
|
||
},
|
||
...(returnedPayments.length
|
||
? [
|
||
{
|
||
type: "mrkdwn",
|
||
text: `:rotating_light: *Returned*\n${returnedPayments.length} payment${returnedPayments.length !== 1 ? "s" : ""} · ${formatCurrency(totalReturnedAmt)}`,
|
||
},
|
||
]
|
||
: []),
|
||
],
|
||
},
|
||
{ type: "divider" },
|
||
...buildReturnedBlocks(),
|
||
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "scheduled_checks", "checks"),
|
||
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "scheduled_ach", "ach"),
|
||
...buildOutstandingBlocks(),
|
||
...buildBoABlocks(boaTransactions),
|
||
],
|
||
};
|
||
}
|