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