From 6af1a6ab9079370dfe30430c6e914efb255bb61a Mon Sep 17 00:00:00 2001 From: amoussa1229 <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:21:54 +0000 Subject: [PATCH] fix: null-guard cash-position tile, add error logging, drop UTC date skew and serial GetItems - Null-guard current_ledger on the authoritative cash-position tile so a missing ledger renders 'Not available' instead of the false '/bin/bash.00' - Log GetCommand failures with console.error + logSafe instead of silent catch, matching the rest of the codebase - Derive dates from local midnight instead of toISOString UTC, so the current-day lookup doesn't miss from 8pm ET to midnight - Fetch all 14 balance keys in parallel with Promise.allSettled instead of 14 serial GetCommands; start previous-day walk at i=1 since today's previous-day snapshot can't exist until tomorrow - Use Item.as_of_date directly instead of a synthetic _asOfDate field - Remove _asOfDate from test fixtures; add test for the null-ledger guard --- src/slackAppHome.js | 83 ++++++++++++++++++++++++-------------- tests/slackAppHome.test.js | 13 +++++- 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/src/slackAppHome.js b/src/slackAppHome.js index 516005a..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(); @@ -405,39 +405,62 @@ async function scanPayments() { // --- Read the latest previous-day balance snapshot --- async function fetchCashPosition() { - const today = new Date(); - const todayStr = today.toISOString().split("T")[0]; + 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()); - // 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); + 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 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. - } + const ds = toDateStr(d); + keys.push({ key: `boa_balance#${ds}#previous-day` }); } - // Try today's intraday snapshot (current-day) for optional provisional display. + const results = await Promise.allSettled( + keys.map(({ key }) => + ddb.send(new GetCommand({ TableName: TABLE_NAME, Key: { pk: key } })) + ) + ); + let intraday = null; - try { - const { Item } = await ddb.send( - new GetCommand({ TableName: TABLE_NAME, Key: { pk: `boa_balance#${todayStr}#current-day` } }) + 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) ); - if (Item) intraday = { ...Item, _asOfDate: todayStr }; - } catch { - // Non-fatal. + } + + // 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 }; @@ -728,8 +751,8 @@ export function buildHomeView(payments, metadata, boaTransactions = [], cashPosi fields: [ { type: "mrkdwn", - text: balance - ? `:moneybag: *Cash Position* — ${balance._asOfDate}\nLedger ${formatCurrency(balance.current_ledger)} · Available ${formatCurrency(balance.current_available)}` + 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`, }, { diff --git a/tests/slackAppHome.test.js b/tests/slackAppHome.test.js index c901a7c..f804242 100644 --- a/tests/slackAppHome.test.js +++ b/tests/slackAppHome.test.js @@ -144,7 +144,6 @@ const balanceItem = (overrides = {}) => ({ total_debits_count: 5, total_ach_debits: 20650.65, other: {}, - _asOfDate: "2026-07-22", ...overrides, }); @@ -173,6 +172,18 @@ test("home view: cash position shows 'Not available' when balance is missing", ( 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(),