feat: add cash-position tile from boa_balance snapshots to App Home (#80)
Some checks are pending
Deploy / deploy (push) Waiting to run

* 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

* 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

---------

Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
This commit is contained in:
seahaven-openswe[bot] 2026-07-22 18:41:32 -04:00 • committed by GitHub
parent aacdb3eca6
commit bf235e70d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 159 additions and 4 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();
@ -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)}`,

View file

@ -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);