payments-dashboard/tests/slackAppHome.test.js
amoussa1229 c7de44cc64 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
2026-07-22 22:05:51 +00:00

200 lines
7.1 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { categorizePayments, buildHomeView } from "../src/slackAppHome.js";
import { parseISOLocal } from "../src/dates.js";
// Fixture shapes mirror real DDB payment records after the fetchBoa v2
// transitions (#66/#69): a bank-returned check keeps status "Cleared" (the
// CSV ladder has no Returned rung) while clear_status flips to "Returned".
const payment = (overrides = {}) => ({
method: "Check",
status: "Sent for payment",
send_payment_on: "01/05/2020",
amount_usd: 100,
payee: "Vendor",
check_number: "1122200030",
...overrides,
});
const returnedCheck = (overrides = {}) =>
payment({
status: "Cleared",
clear_status: "Returned",
returned_date: "2026-02-20",
...overrides,
});
test("categorize: returned check surfaces despite status Cleared", () => {
const { returnedPayments, outstandingChecks, scheduledChecks } = categorizePayments([
returnedCheck(),
]);
assert.equal(returnedPayments.length, 1);
assert.equal(outstandingChecks.length, 0);
assert.equal(scheduledChecks.length, 0);
});
test("categorize: returned records are excluded from Outstanding totals", () => {
const { outstandingChecks, bucketedOutstanding, returnedPayments } = categorizePayments([
returnedCheck({ amount_usd: 5256.62 }),
payment({ amount_usd: 40 }),
]);
assert.equal(outstandingChecks.length, 1);
assert.equal(returnedPayments.length, 1);
const bucketTotal = bucketedOutstanding.reduce((sum, b) => sum + b.total, 0);
assert.equal(bucketTotal, 40);
});
test("categorize: terminal voided-and-bounced stays out of the action queue", () => {
for (const status of ["Marked as Void", "Voided", "Cancelled", "canceled"]) {
const { returnedPayments, outstandingChecks } = categorizePayments([
returnedCheck({ status }),
]);
assert.equal(returnedPayments.length, 0, `status ${status} in returned bucket`);
assert.equal(outstandingChecks.length, 0, `status ${status} in outstanding`);
}
});
test("categorize: redeposited check (re-Cleared, returned_date retained) is not Returned", () => {
const { returnedPayments, outstandingChecks } = categorizePayments([
returnedCheck({ clear_status: "Cleared" }),
]);
assert.equal(returnedPayments.length, 0);
assert.equal(outstandingChecks.length, 0); // status Cleared → skip-list
});
test("categorize: returned ACH is included in the returned bucket", () => {
const { returnedPayments } = categorizePayments([
returnedCheck({ method: "ACH", check_number: "PMT123" }),
]);
assert.equal(returnedPayments.length, 1);
});
test("categorize: returned surfaces even when send_payment_on no longer parses", () => {
const { returnedPayments } = categorizePayments([
returnedCheck({ send_payment_on: "garbage" }),
]);
assert.equal(returnedPayments.length, 1);
});
test("categorize: returned bucket sorts oldest return first, unknown date first", () => {
const { returnedPayments } = categorizePayments([
returnedCheck({ payee: "B", returned_date: "2026-04-08" }),
returnedCheck({ payee: "C", returned_date: undefined }),
returnedCheck({ payee: "A", returned_date: "2026-02-20" }),
]);
assert.deepEqual(
returnedPayments.map((p) => p.payee),
["C", "A", "B"]
);
});
test("categorize: non-returned flow is unchanged", () => {
const { outstandingChecks, scheduledChecks } = categorizePayments([
payment(),
payment({ send_payment_on: "12/31/2099" }),
payment({ status: "Marked as Void" }),
]);
assert.equal(outstandingChecks.length, 1);
assert.equal(scheduledChecks.length, 1);
});
const metadata = { last_updated: new Date().toISOString(), file_name: "export.csv" };
test("home view: renders the needs-action section and summary tile when returns exist", () => {
const view = buildHomeView([returnedCheck({ amount_usd: 1892.62 }), payment()], metadata);
const json = JSON.stringify(view);
assert.ok(json.includes("Returned — Needs Action"));
assert.ok(json.includes("reissue or void decision"));
assert.ok(json.includes(":rotating_light: *Returned*"));
assert.ok(json.includes("$1,892.62"));
});
test("home view: omits the returned section and tile when there are none", () => {
const view = buildHomeView([payment()], metadata);
const json = JSON.stringify(view);
assert.ok(!json.includes("Returned — Needs Action"));
assert.ok(!json.includes(":rotating_light: *Returned*"));
});
test("home view: unknown returned_date renders without an age instead of crashing", () => {
const view = buildHomeView([returnedCheck({ returned_date: "not-a-date" })], metadata);
assert.ok(JSON.stringify(view).includes("Return date unknown"));
});
test("parseISOLocal: valid ISO date parses to local midnight", () => {
const dt = parseISOLocal("2026-02-20");
assert.equal(dt.getFullYear(), 2026);
assert.equal(dt.getMonth(), 1);
assert.equal(dt.getDate(), 20);
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);
assert.equal(parseISOLocal(""), null);
assert.equal(parseISOLocal(undefined), null);
});