mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 08:53:12 +00:00
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>
211 lines
7.6 KiB
JavaScript
211 lines
7.6 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: {},
|
|
...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);
|
|
assert.equal(parseISOLocal(""), null);
|
|
assert.equal(parseISOLocal(undefined), null);
|
|
});
|