diff --git a/src/slackAppHome.js b/src/slackAppHome.js index e8d9e44..0dac971 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -3,7 +3,7 @@ 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 } from "./boaRecon.js"; +import { isCancelStatus, logSafe } from "./boaRecon.js"; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); const secrets = new SecretsManagerClient(); @@ -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,70 @@ async function scanPayments() { 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) { @@ -468,7 +533,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 +714,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 +749,20 @@ export function buildHomeView(payments, metadata, boaTransactions = [], expanded { 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)}`, diff --git a/tests/slackAppHome.test.js b/tests/slackAppHome.test.js index 83a0cbe..f804242 100644 --- a/tests/slackAppHome.test.js +++ b/tests/slackAppHome.test.js @@ -130,6 +130,79 @@ 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: {}, + ...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: cash position shows 'Not available' when previousDay exists but current_ledger is null", () => { + const view = buildHomeView([payment()], metadata, [], { + previousDay: balanceItem({ current_ledger: undefined, current_available: undefined }), + intraday: null, + }); + const json = JSON.stringify(view); + // The cash-position tile falls back to "Not available" instead of "Ledger $0.00" + assert.ok(json.includes("Not available")); + // Confirm the tile does NOT contain the false "Ledger" line + assert.ok(!json.includes("Ledger ")); +}); + +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);