payments-dashboard/tests/slackAppHome.test.js
Adam Moussa a6a8132a69
feat(apphome): surface returned payments as a needs-action queue (#74)
* feat(apphome): surface returned payments as a needs-action queue

Bank-returned payments carry status "Cleared" (the CSV ladder has no
Returned rung), so the status skip-list hid them from every App Home
bucket; five Feb-Apr returns ($5,256.62) surfaced only via a manual
statement reconciliation. Route on clear_status ahead of the status
skip-list: non-canceled Returned records render in an always-visible
action queue (oldest return first) plus a summary tile, and are
excluded from Outstanding totals. Terminal voided-and-bounced records
stay out of both (audit trail only). Membership keys off clear_status,
not returned_date, so redeposited checks drop back out of the queue.

Refs: #70

* docs(apphome): restore returned-queue README bullet dropped in rebase
2026-07-22 16:38:56 -04:00

138 lines
5.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);
});
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);
});