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
This commit is contained in:
amoussa1229 2026-07-22 22:21:54 +00:00
parent c7de44cc64
commit 6af1a6ab90
2 changed files with 65 additions and 31 deletions

View file

@ -3,7 +3,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager"; import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import { parseISOLocal, parseMDYLocal } from "./dates.js"; import { parseISOLocal, parseMDYLocal } from "./dates.js";
import { isCancelStatus } from "./boaRecon.js"; import { isCancelStatus, logSafe } from "./boaRecon.js";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const secrets = new SecretsManagerClient(); const secrets = new SecretsManagerClient();
@ -405,39 +405,62 @@ async function scanPayments() {
// --- Read the latest previous-day balance snapshot --- // --- Read the latest previous-day balance snapshot ---
async function fetchCashPosition() { async function fetchCashPosition() {
const today = new Date(); const now = new Date();
const todayStr = today.toISOString().split("T")[0]; // 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. const toDateStr = (d) =>
// Weekends and holidays may leave gaps; scan up to 14 days back. d.getFullYear() + "-" +
let previousDay = null; String(d.getMonth() + 1).padStart(2, "0") + "-" +
for (let i = 0; i < 14; i++) { String(d.getDate()).padStart(2, "0");
const d = new Date(today);
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); d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split("T")[0]; const ds = toDateStr(d);
const key = `boa_balance#${dateStr}#previous-day`; keys.push({ key: `boa_balance#${ds}#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. const results = await Promise.allSettled(
keys.map(({ key }) =>
ddb.send(new GetCommand({ TableName: TABLE_NAME, Key: { pk: key } }))
)
);
let intraday = null; let intraday = null;
try { if (results[0].status === "fulfilled" && results[0].value.Item) {
const { Item } = await ddb.send( intraday = results[0].value.Item;
new GetCommand({ TableName: TABLE_NAME, Key: { pk: `boa_balance#${todayStr}#current-day` } }) } 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 }; return { previousDay, intraday };
@ -728,8 +751,8 @@ export function buildHomeView(payments, metadata, boaTransactions = [], cashPosi
fields: [ fields: [
{ {
type: "mrkdwn", type: "mrkdwn",
text: balance text: balance && balance.current_ledger != null
? `:moneybag: *Cash Position* — ${balance._asOfDate}\nLedger ${formatCurrency(balance.current_ledger)} · Available ${formatCurrency(balance.current_available)}` ? `:moneybag: *Cash Position* — ${balance.as_of_date}\nLedger ${formatCurrency(balance.current_ledger)} · Available ${formatCurrency(balance.current_available)}`
: `:moneybag: *Cash Position*\nNot available`, : `:moneybag: *Cash Position*\nNot available`,
}, },
{ {

View file

@ -144,7 +144,6 @@ const balanceItem = (overrides = {}) => ({
total_debits_count: 5, total_debits_count: 5,
total_ach_debits: 20650.65, total_ach_debits: 20650.65,
other: {}, other: {},
_asOfDate: "2026-07-22",
...overrides, ...overrides,
}); });
@ -173,6 +172,18 @@ test("home view: cash position shows 'Not available' when balance is missing", (
assert.ok(json.includes("Not available")); 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", () => { test("home view: intraday provisional tile renders when current-day exists", () => {
const view = buildHomeView([payment()], metadata, [], { const view = buildHomeView([payment()], metadata, [], {
previousDay: balanceItem(), previousDay: balanceItem(),