diff --git a/src/slackAppHome.js b/src/slackAppHome.js index e8d9e44..516005a 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -211,16 +211,17 @@ async function publishHomeView(userId, expanded) { new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } }) ); - const [payments, boaTransactions] = await Promise.all([ + 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, expanded); + const view = buildHomeView(payments, metadata, boaTransactions, cashPosition, expanded); const slackToken = await getSlackToken(); const res = await fetch("https://slack.com/api/views.publish", { @@ -401,6 +402,47 @@ async function scanPayments() { return payments; } +// --- Read the latest previous-day balance snapshot --- + +async function fetchCashPosition() { + const today = new Date(); + const todayStr = today.toISOString().split("T")[0]; + + // Walk backward from today looking for a previous-day balance snapshot. + // Weekends and holidays may leave gaps; scan up to 14 days back. + let previousDay = null; + for (let i = 0; i < 14; i++) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().split("T")[0]; + const key = `boa_balance#${dateStr}#previous-day`; + try { + const { Item } = await ddb.send( + new GetCommand({ TableName: TABLE_NAME, Key: { pk: key } }) + ); + if (Item) { + previousDay = { ...Item, _asOfDate: dateStr }; + break; + } + } catch { + // GetCommand failure is non-fatal; move to the prior day. + } + } + + // Try today's intraday snapshot (current-day) for optional provisional display. + let intraday = null; + try { + const { Item } = await ddb.send( + new GetCommand({ TableName: TABLE_NAME, Key: { pk: `boa_balance#${todayStr}#current-day` } }) + ); + if (Item) intraday = { ...Item, _asOfDate: todayStr }; + } catch { + // Non-fatal. + } + + return { previousDay, intraday }; +} + // --- Build the App Home view --- function formatBoATimestamp(iso) { @@ -468,7 +510,7 @@ function buildBoABlocks(transactions) { return blocks; } -export function buildHomeView(payments, metadata, boaTransactions = [], expanded = []) { +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); @@ -649,6 +691,9 @@ export function buildHomeView(payments, metadata, boaTransactions = [], expanded 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 { @@ -681,6 +726,20 @@ export function buildHomeView(payments, metadata, boaTransactions = [], expanded { type: "section", fields: [ + { + type: "mrkdwn", + text: balance + ? `:moneybag: *Cash Position* — ${balance._asOfDate}\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)}`, diff --git a/tests/slackAppHome.test.js b/tests/slackAppHome.test.js index 83a0cbe..c901a7c 100644 --- a/tests/slackAppHome.test.js +++ b/tests/slackAppHome.test.js @@ -130,6 +130,68 @@ test("parseISOLocal: valid ISO date parses to local midnight", () => { assert.equal(dt.getHours(), 0); }); +const balanceItem = (overrides = {}) => ({ + as_of_date: "2026-07-22", + endpoint: "previous-day", + opening_ledger: 209541.48, + current_ledger: 203890.83, + opening_available: 209541.48, + current_available: 203890.83, + float_one_day: 0, + total_credits: 15000, + total_credits_count: 1, + total_debits: 20650.65, + total_debits_count: 5, + total_ach_debits: 20650.65, + other: {}, + _asOfDate: "2026-07-22", + ...overrides, +}); + +const intradayItem = (overrides = {}) => ({ + as_of_date: "2026-07-22", + endpoint: "current-day", + opening_ledger: 203890.83, + current_ledger: 199500.12, + opening_available: 203890.83, + current_available: 199500.12, + ...overrides, +}); + +test("home view: cash position tile renders when previous-day balance exists", () => { + const view = buildHomeView([payment()], metadata, [], { previousDay: balanceItem() }); + const json = JSON.stringify(view); + assert.ok(json.includes(":moneybag: *Cash Position*")); + assert.ok(json.includes("2026-07-22")); + assert.ok(json.includes("$203,890.83")); +}); + +test("home view: cash position shows 'Not available' when balance is missing", () => { + const view = buildHomeView([payment()], metadata, [], { previousDay: null, intraday: null }); + const json = JSON.stringify(view); + assert.ok(json.includes("Cash Position")); + assert.ok(json.includes("Not available")); +}); + +test("home view: intraday provisional tile renders when current-day exists", () => { + const view = buildHomeView([payment()], metadata, [], { + previousDay: balanceItem(), + intraday: intradayItem(), + }); + const json = JSON.stringify(view); + assert.ok(json.includes("Intraday (provisional)")); + assert.ok(json.includes("$199,500.12")); +}); + +test("home view: intraday tile shows 'not yet available' when balance exists but no intraday", () => { + const view = buildHomeView([payment()], metadata, [], { + previousDay: balanceItem(), + intraday: null, + }); + const json = JSON.stringify(view); + assert.ok(json.includes("Intraday not yet available")); +}); + test("parseISOLocal: rejects impossible, non-ISO, and empty values", () => { assert.equal(parseISOLocal("2026-02-30"), null); assert.equal(parseISOLocal("02/20/2026"), null);