2026-07-10 17:04:35 -04:00
import crypto from "node:crypto" ;
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" ;
2026-06-02 20:29:18 -04:00
import { SecretsManagerClient , GetSecretValueCommand } from "@aws-sdk/client-secrets-manager" ;
2026-07-22 16:38:56 -04:00
import { parseISOLocal , parseMDYLocal } from "./dates.js" ;
2026-07-22 18:41:32 -04:00
import { isCancelStatus , logSafe } from "./boaRecon.js" ;
2026-04-09 13:29:28 -04:00
const ddb = DynamoDBDocumentClient . from ( new DynamoDBClient ( ) ) ;
2026-06-02 20:29:18 -04:00
const secrets = new SecretsManagerClient ( ) ;
2026-04-09 13:29:28 -04:00
const TABLE _NAME = process . env . TABLE _NAME ;
2026-07-10 17:04:35 -04:00
const SLACK _SIGNING _SECRET _NAME = process . env . SLACK _SIGNING _SECRET _NAME ;
2026-04-09 14:27:34 -04:00
let cachedToken ;
async function getSlackToken ( ) {
if ( cachedToken ) return cachedToken ;
2026-06-02 20:29:18 -04:00
const { SecretString } = await secrets . send (
new GetSecretValueCommand ( { SecretId : process . env . SLACK _BOT _TOKEN _SECRET _NAME } )
2026-04-09 14:27:34 -04:00
) ;
2026-06-02 20:29:18 -04:00
cachedToken = SecretString ;
2026-04-09 14:27:34 -04:00
return cachedToken ;
}
2026-04-09 13:29:28 -04:00
2026-07-10 17:04:35 -04:00
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 ) ;
}
2026-04-10 18:08:06 -04:00
// --- 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" ] ;
2026-04-20 17:40:55 -04:00
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 } ;
}
2026-04-10 18:08:06 -04:00
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 ---
2026-07-22 16:38:56 -04:00
export function categorizePayments ( payments ) {
2026-04-10 18:08:06 -04:00
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 = [ ] ;
2026-07-22 16:38:56 -04:00
const returnedPayments = [ ] ;
2026-04-10 18:08:06 -04:00
for ( const p of payments ) {
if ( p . method !== "ACH" && p . method !== "Check" ) continue ;
2026-07-22 16:38:56 -04:00
// 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 ;
}
2026-04-10 18:08:06 -04:00
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 ) ;
2026-07-22 16:38:56 -04:00
// 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 || "" ) )
) ;
2026-04-10 18:08:06 -04:00
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 } ;
} ) ;
2026-07-22 16:38:56 -04:00
return { today , daysSince , scheduledChecks , scheduledACH , outstandingChecks , bucketedOutstanding , returnedPayments } ;
2026-04-10 18:08:06 -04:00
}
// --- Main handler ---
2026-04-09 13:29:28 -04:00
export const handler = async ( event ) => {
2026-04-10 18:11:54 -04:00
// Decode body — API Gateway may base64-encode it
let rawBody = event . body || "" ;
if ( event . isBase64Encoded ) {
rawBody = Buffer . from ( rawBody , "base64" ) . toString ( "utf-8" ) ;
}
2026-07-10 17:04:35 -04:00
// 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" } ;
}
2026-04-10 18:08:06 -04:00
// 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 || "{}" ) ;
}
2026-04-09 13:29:28 -04:00
// Handle Slack URL verification challenge
if ( body . type === "url_verification" ) {
return { statusCode : 200 , body : body . challenge } ;
}
2026-05-08 11:24:32 -04:00
// Handle button clicks → toggle sections or open modal
2026-04-10 18:08:06 -04:00
if ( body . type === "block_actions" ) {
return handleBlockAction ( body ) ;
}
2026-04-09 13:29:28 -04:00
// 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-05-08 11:24:32 -04:00
return publishHomeView ( userId , [ ] ) ;
} ;
async function publishHomeView ( userId , expanded ) {
2026-04-09 14:27:34 -04:00
const { Item : metadata } = await ddb . send (
new GetCommand ( { TableName : TABLE _NAME , Key : { pk : "metadata" } } )
2026-04-09 13:29:28 -04:00
) ;
2026-07-22 18:41:32 -04:00
const [ payments , boaTransactions , cashPosition ] = await Promise . all ( [
2026-04-20 17:40:55 -04:00
scanPayments ( ) ,
scanBoATransactions ( ) ,
2026-07-22 18:41:32 -04:00
fetchCashPosition ( ) ,
2026-04-20 17:40:55 -04:00
] ) ;
2026-04-09 14:27:34 -04:00
if ( ! payments . length ) {
return { statusCode : 200 , body : JSON . stringify ( { error : "No payment data" } ) } ;
2026-04-09 13:29:28 -04:00
}
2026-07-22 18:41:32 -04:00
const view = buildHomeView ( payments , metadata , boaTransactions , cashPosition , expanded ) ;
2026-04-09 14:27:34 -04:00
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-05-08 11:24:32 -04:00
}
2026-04-09 13:29:28 -04:00
2026-04-10 18:08:06 -04:00
// --- Block action handler (button clicks) ---
2026-04-09 13:29:28 -04:00
2026-04-10 18:08:06 -04:00
async function handleBlockAction ( body ) {
const action = body . actions ? . [ 0 ] ;
if ( ! action ) return { statusCode : 200 , body : JSON . stringify ( { ok : true } ) } ;
2026-04-09 13:29:28 -04:00
2026-04-10 18:08:06 -04:00
const triggerId = body . trigger _id ;
const actionId = action . action _id ;
2026-04-09 13:29:28 -04:00
2026-05-08 11:24:32 -04:00
// 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 ) ;
}
2026-04-10 18:08:06 -04:00
const payments = await scanPayments ( ) ;
const { today , daysSince , scheduledChecks , scheduledACH , bucketedOutstanding } = categorizePayments ( payments ) ;
2026-04-09 13:29:28 -04:00
2026-04-10 18:08:06 -04:00
let modalTitle = "" ;
let items = [ ] ;
2026-04-10 18:03:10 -04:00
2026-04-10 18:08:06 -04:00
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 ;
} ) ;
2026-04-09 13:29:28 -04:00
2026-04-10 18:08:06 -04:00
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 ;
}
}
2026-04-09 13:29:28 -04:00
2026-04-10 18:08:06 -04:00
if ( ! items . length ) {
return { statusCode : 200 , body : JSON . stringify ( { ok : true } ) } ;
}
2026-04-09 13:29:28 -04:00
2026-04-10 18:08:06 -04:00
const modalBlocks = buildPaymentListBlocks ( items ) ;
2026-04-10 17:23:54 -04:00
2026-04-10 18:08:06 -04:00
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 ,
} ,
} ) ,
} ) ;
2026-04-10 17:23:54 -04:00
2026-04-10 18:08:06 -04:00
const data = await res . json ( ) ;
if ( ! data . ok ) {
console . error ( "Slack views.open error:" , JSON . stringify ( data ) ) ;
2026-04-10 17:23:54 -04:00
}
2026-04-09 13:29:28 -04:00
2026-04-10 18:08:06 -04:00
return { statusCode : 200 , body : JSON . stringify ( { ok : true } ) } ;
}
// --- Build modal payment list ---
2026-04-10 17:28:01 -04:00
2026-04-10 18:08:06 -04:00
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" } ,
2026-04-10 18:03:10 -04:00
] ;
2026-04-10 18:08:06 -04:00
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 : [
2026-04-10 18:14:22 -04:00
{ type : "mrkdwn" , text : ` * ${ payee } * \n ${ p . method === "ACH" ? "Reference" : "Check" } # ${ checkNum } ` } ,
2026-04-10 18:08:06 -04:00
{ type : "mrkdwn" , text : ` * ${ formatCurrency ( p . amount _usd ) } * \n ${ dateStr } ` } ,
] ,
2026-04-10 18:03:10 -04:00
} ) ;
2026-04-10 18:08:06 -04:00
}
return blocks ;
}
2026-04-20 17:40:55 -04:00
// --- 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 ) ;
2026-05-08 11:24:32 -04:00
const filtered = items . filter ( ( t ) => ! ( t . backfill && ! t . success ) ) ;
filtered . sort ( ( a , b ) => ( b . timestamp || "" ) . localeCompare ( a . timestamp || "" ) ) ;
return filtered . slice ( 0 , 5 ) ;
2026-04-20 17:40:55 -04:00
}
2026-04-10 18:08:06 -04:00
// --- 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 ;
}
2026-04-10 18:03:10 -04:00
2026-07-22 18:41:32 -04:00
// --- 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 } ;
}
2026-04-10 18:08:06 -04:00
// --- Build the App Home view ---
2026-04-09 13:29:28 -04:00
2026-04-20 17:40:55 -04:00
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
2026-05-08 11:24:32 -04:00
? ` Last ${ transactions . length } submission ${ transactions . length !== 1 ? "s" : "" } · 90-day retention `
2026-04-20 17:40:55 -04:00
: "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 ;
}
2026-07-22 18:41:32 -04:00
export function buildHomeView ( payments , metadata , boaTransactions = [ ] , cashPosition = null , expanded = [ ] ) {
2026-07-22 16:38:56 -04:00
const { today , daysSince , scheduledChecks , scheduledACH , outstandingChecks , bucketedOutstanding , returnedPayments } = categorizePayments ( payments ) ;
2026-05-08 11:24:32 -04:00
const isExpanded = ( key ) => expanded . includes ( key ) ;
2026-04-10 18:08:06 -04:00
2026-07-22 16:38:56 -04:00
// 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 ;
} ;
2026-05-08 11:24:32 -04:00
const buildScheduledBlocks = ( title , emoji , items , sectionKey , methodKey ) => {
2026-04-10 17:23:54 -04:00
const total = items . reduce ( ( sum , p ) => sum + p . amount _usd , 0 ) ;
2026-05-08 11:24:32 -04:00
const open = isExpanded ( sectionKey ) ;
2026-04-09 13:29:28 -04:00
const blocks = [
{
2026-05-08 11:24:32 -04:00
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 } ` ,
} ,
2026-04-09 13:29:28 -04:00
} ,
] ;
2026-05-08 11:24:32 -04:00
if ( ! open ) {
blocks . push ( { type : "divider" } ) ;
return blocks ;
}
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 18:03:10 -04:00
text : { type : "mrkdwn" , text : "_No upcoming payments_" } ,
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-10 18:03:10 -04:00
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_ ` : "" ;
2026-04-10 17:23:54 -04:00
2026-04-09 13:29:28 -04:00
blocks . push ( {
type : "section" ,
2026-04-10 18:08:06 -04:00
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 } ` ,
} ,
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 ) ;
2026-05-08 11:24:32 -04:00
const open = isExpanded ( "outstanding" ) ;
2026-04-10 17:28:01 -04:00
const blocks = [
{
2026-05-08 11:24:32 -04:00
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" ,
} ,
2026-04-10 17:28:01 -04:00
} ,
] ;
2026-05-08 11:24:32 -04:00
if ( ! open ) {
blocks . push ( { type : "divider" } ) ;
return blocks ;
}
2026-04-10 17:28:01 -04:00
if ( ! outstandingChecks . length ) {
blocks . push ( {
type : "section" ,
2026-04-10 18:03:10 -04:00
text : { type : "mrkdwn" , text : ":white_check_mark: _All checks have cleared_" } ,
2026-04-10 17:28:01 -04:00
} ) ;
blocks . push ( { type : "divider" } ) ;
return blocks ;
}
2026-04-10 18:08:06 -04:00
for ( let i = 0 ; i < bucketedOutstanding . length ; i ++ ) {
const bucket = bucketedOutstanding [ i ] ;
2026-04-10 18:03:10 -04:00
if ( ! bucket . items . length ) continue ;
2026-04-10 17:28:01 -04:00
2026-04-10 18:03:10 -04:00
blocks . push ( {
type : "section" ,
2026-04-10 18:08:06 -04:00
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 } ` ,
} ,
2026-04-10 18:03:10 -04:00
} ) ;
}
2026-04-10 17:28:01 -04:00
blocks . push ( { type : "divider" } ) ;
return blocks ;
} ;
2026-04-10 18:08:06 -04:00
// Summary bar
2026-04-10 18:03:10 -04:00
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 ) ;
2026-07-22 16:38:56 -04:00
const totalReturnedAmt = returnedPayments . reduce ( ( sum , p ) => sum + p . amount _usd , 0 ) ;
2026-04-10 18:03:10 -04:00
2026-07-22 18:41:32 -04:00
const balance = cashPosition ? . previousDay ;
const intraday = cashPosition ? . intraday ;
2026-04-20 17:40:55 -04:00
const staleness = formatStaleness ( metadata ? . last _updated ) ;
2026-04-09 13:29:28 -04:00
return {
type : "home" ,
2026-05-08 11:24:32 -04:00
private _metadata : JSON . stringify ( { expanded } ) ,
2026-04-09 13:29:28 -04:00
blocks : [
{
type : "header" ,
2026-04-10 18:03:10 -04:00
text : { type : "plain_text" , text : ":bank: Payments Dashboard" } ,
2026-04-09 13:29:28 -04:00
} ,
{
type : "context" ,
elements : [
2026-04-20 17:40:55 -04:00
{ type : "mrkdwn" , text : ` Last updated · ${ staleness . label } ` } ,
2026-04-10 18:03:10 -04:00
{ type : "mrkdwn" , text : ` Source · ${ metadata ? . file _name || "N/A" } ` } ,
] ,
} ,
2026-04-20 17:40:55 -04:00
... ( staleness . stale
? [
{
type : "section" ,
text : {
type : "mrkdwn" ,
text : ` :warning: *Stampli data is ${ staleness . label } .* Upload a fresh export to refresh the dashboard. ` ,
} ,
} ,
]
: [ ] ) ,
2026-04-10 18:03:10 -04:00
{ type : "divider" } ,
{
type : "section" ,
fields : [
2026-07-22 18:41:32 -04:00
{
type : "mrkdwn" ,
text : balance && balance . current _ledger != null
? ` :moneybag: *Cash Position* — ${ balance . as _of _date } \n Ledger ${ formatCurrency ( balance . current _ledger ) } · Available ${ formatCurrency ( balance . current _available ) } `
: ` :moneybag: *Cash Position* \n Not available ` ,
} ,
{
type : "mrkdwn" ,
text : intraday && intraday . current _ledger != null
? ` _Intraday (provisional)_ \n Ledger ${ formatCurrency ( intraday . current _ledger ) } · Available ${ formatCurrency ( intraday . current _available ) } `
: balance
? "_Intraday not yet available_"
: "_No balance data_" ,
} ,
2026-04-10 18:03:10 -04:00
{
type : "mrkdwn" ,
text : ` :calendar: *Scheduled* \n ${ totalScheduled } payments · ${ formatCurrency ( totalScheduledAmt ) } ` ,
} ,
{
type : "mrkdwn" ,
text : ` :warning: *Outstanding* \n ${ outstandingChecks . length } checks · ${ formatCurrency ( totalOutstandingAmt ) } ` ,
} ,
2026-07-22 16:38:56 -04:00
... ( returnedPayments . length
? [
{
type : "mrkdwn" ,
text : ` :rotating_light: *Returned* \n ${ returnedPayments . length } payment ${ returnedPayments . length !== 1 ? "s" : "" } · ${ formatCurrency ( totalReturnedAmt ) } ` ,
} ,
]
: [ ] ) ,
2026-04-09 13:29:28 -04:00
] ,
} ,
{ type : "divider" } ,
2026-07-22 16:38:56 -04:00
... buildReturnedBlocks ( ) ,
2026-05-08 11:24:32 -04:00
... buildScheduledBlocks ( "Scheduled Checks" , ":ledger:" , scheduledChecks , "scheduled_checks" , "checks" ) ,
... buildScheduledBlocks ( "Scheduled ACH" , ":electric_plug:" , scheduledACH , "scheduled_ach" , "ach" ) ,
2026-04-10 17:28:01 -04:00
... buildOutstandingBlocks ( ) ,
2026-04-20 17:40:55 -04:00
... buildBoABlocks ( boaTransactions ) ,
2026-04-09 13:29:28 -04:00
] ,
} ;
}