From c7de44cc64b211e0dd40ae923478103ea5217acb Mon Sep 17 00:00:00 2001 From: amoussa1229 <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:05:51 +0000 Subject: [PATCH] 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 --- src/slackAppHome.js | 65 ++++++++++++++++++++++++++++++++++++-- tests/slackAppHome.test.js | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) 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);