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 { 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`,
},
{

View file

@ -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(),