payments-dashboard/src/slackAppHome.js

793 lines
26 KiB
JavaScript
Raw Normal View History

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),
],
};
}