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
This commit is contained in:
amoussa1229 2026-07-22 22:05:51 +00:00
parent aacdb3eca6
commit c7de44cc64
2 changed files with 124 additions and 3 deletions

View file

@ -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,47 @@ async function scanPayments() {
return payments;
}
// --- Read the latest previous-day balance snapshot ---
async function fetchCashPosition() {
const today = new Date();
const todayStr = today.toISOString().split("T")[0];
// 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);
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.
}
}
// Try today's intraday snapshot (current-day) for optional provisional display.
let intraday = null;
try {
const { Item } = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: `boa_balance#${todayStr}#current-day` } })
);
if (Item) intraday = { ...Item, _asOfDate: todayStr };
} catch {
// Non-fatal.
}
return { previousDay, intraday };
}
// --- Build the App Home view ---
function formatBoATimestamp(iso) {
@ -468,7 +510,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 +691,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 +726,20 @@ export function buildHomeView(payments, metadata, boaTransactions = [], expanded
{
type: "section",
fields: [
{
type: "mrkdwn",
text: balance
? `:moneybag: *Cash Position* — ${balance._asOfDate}\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,68 @@ 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: {},
_asOfDate: "2026-07-22",
...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: 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);