mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 05:23:12 +00:00
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
This commit is contained in:
parent
ffab1c8f7b
commit
a6a8132a69
4 changed files with 243 additions and 6 deletions
|
|
@ -12,7 +12,7 @@ AWS SAM application that ingests payment CSVs, syncs check data with Bank of Ame
|
|||
- **ProcessPayrollEmail** — Lambda triggered by S3 (inbound email) and SQS (batch timer). SES receives Gusto payroll emails at `payroll@int.seahaven.com`, stores them to S3, and this Lambda parses the email body, extracts financial data, and posts a combined Slack notification (employee payroll + contractor payments) after a 10-minute batching window. Runs outside VPC.
|
||||
- **ProcessPaymentCsv** — Lambda triggered by S3 CSV upload. Parses Stampli payment exports, upserts to DynamoDB, and submits new/cancelled checks to the CashPro Check Management API.
|
||||
- **FetchBoaTransactions** — Scheduled Lambda. Weekdays 9am ET it calls the CashPro **previous-day** Transaction Inquiry (authoritative sweep, trailing 7 days); weekdays at 16:00/19:00/22:00 UTC (~12/3/6pm ET, fixed-UTC so it drifts an hour in winter) it calls the **current-day** inquiry for same-day visibility (EventBridge `Input: {"endpoint":"current-day"}`, today-only, staleness sweep skipped). Every run archives the exact raw response to the `seahaven-payments-boa-raw-*` bucket (`raw/<endpoint>/<fromDate>_<toDate>/<runAt>.json`, SSE-S3, 730-day lifecycle, PutObject-only grant; Retain-protected — decommission goes through the CFN decommission runbook) and upserts per-date `boa_balance#<asOfDate>#<endpoint>` snapshots (latest-wins on `run_at`, no TTL) from the Summary rows. Classifies each transaction and reconciles onto DynamoDB payment records. Event payload: `{fromDate?, toDate?, endpoint?}` (endpoint allowlisted and validated; unknown fields ignored). Intraday runs are disable-able as a unit via the `IntradaySchedule` rule. See [Bank reconciliation](#bank-reconciliation-fetchboatransactions).
|
||||
- **SlackAppHome** — Lambda behind API Gateway (`POST /slack/events`). Verifies the Slack signing secret (HMAC-SHA256, 5-minute replay window) before processing, then renders the payments dashboard on the Slack App Home tab with outstanding aging buckets and drill-down modals.
|
||||
- **SlackAppHome** — Lambda behind API Gateway (`POST /slack/events`). Verifies the Slack signing secret (HMAC-SHA256, 5-minute replay window) before processing, then renders the payments dashboard on the Slack App Home tab with outstanding aging buckets, drill-down modals, and an always-visible "Returned — Needs Action" queue (bank-returned payments awaiting a reissue/void decision, sorted oldest return first). Returned records are excluded from Outstanding totals; terminal voided-and-bounced records appear in neither (audit trail only).
|
||||
|
||||
- **ExpenseReceiver** — Lambda behind API Gateway (`POST /slack/expense-events`). Verifies the Slack signing secret (HMAC-SHA256), handles URL verification challenges, and async-invokes ExpenseProcessor. Runs outside VPC.
|
||||
- **ExpenseProcessor** — Async Lambda invoked by ExpenseReceiver. Processes `:white_check_mark:` reactions to advance expense messages through a four-stage Slack channel pipeline: Submitted → Processed → Authorized → Matched. Runs outside VPC.
|
||||
|
|
|
|||
17
src/dates.js
17
src/dates.js
|
|
@ -41,6 +41,23 @@ export function parseMDYLocal(value) {
|
|||
return new Date(p.year, p.month - 1, p.day);
|
||||
}
|
||||
|
||||
// Local-midnight Date from a stored ISO "YYYY-MM-DD" (returned_date,
|
||||
// paid_date), or null. new Date("YYYY-MM-DD") parses as UTC midnight and
|
||||
// renders a day early in US-local display; this stays local like
|
||||
// parseMDYLocal. Validates the date is real (no 2026-02-30).
|
||||
export function parseISOLocal(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? "").trim());
|
||||
if (!match) return null;
|
||||
const year = parseInt(match[1], 10);
|
||||
const month = parseInt(match[2], 10);
|
||||
const day = parseInt(match[3], 10);
|
||||
const dt = new Date(year, month - 1, day);
|
||||
if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) {
|
||||
return null;
|
||||
}
|
||||
return dt;
|
||||
}
|
||||
|
||||
// Plausibility window for ingested send dates. Anything outside is treated
|
||||
// as parser garbage and the row is rejected loudly rather than stored.
|
||||
// Relative to the current year so it never expires (7 years back covers
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import crypto from "node:crypto";
|
|||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
|
||||
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
||||
import { parseMDYLocal } from "./dates.js";
|
||||
import { parseISOLocal, parseMDYLocal } from "./dates.js";
|
||||
import { isCancelStatus } from "./boaRecon.js";
|
||||
|
||||
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
||||
const secrets = new SecretsManagerClient();
|
||||
|
|
@ -91,7 +92,7 @@ const byDate = (a, b) => {
|
|||
|
||||
// --- Categorize payments into buckets ---
|
||||
|
||||
function categorizePayments(payments) {
|
||||
export function categorizePayments(payments) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24));
|
||||
|
|
@ -99,9 +100,25 @@ function categorizePayments(payments) {
|
|||
const scheduledChecks = [];
|
||||
const scheduledACH = [];
|
||||
const outstandingChecks = [];
|
||||
const returnedPayments = [];
|
||||
|
||||
for (const p of payments) {
|
||||
if (p.method !== "ACH" && p.method !== "Check") continue;
|
||||
|
||||
// Bank truth outranks the CSV lifecycle: a returned check usually
|
||||
// carries status "Cleared" (the ladder has no Returned rung, #66), so
|
||||
// route on clear_status before the skip-list can hide it (#70).
|
||||
// Membership keys off clear_status, not returned_date — a redeposit
|
||||
// re-clears the record but keeps returned_date. Routed before the send-
|
||||
// date parse: a bank-confirmed return must surface even on a record
|
||||
// whose send_payment_on no longer parses.
|
||||
if (p.clear_status === "Returned") {
|
||||
// Terminal voided-and-bounced (the expected void-then-ARP-bounce
|
||||
// cycle) is not a reissue decision: audit trail only.
|
||||
if (!isCancelStatus(p.status)) returnedPayments.push(p);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dt = parseMDYLocal(p.send_payment_on);
|
||||
if (!dt) continue;
|
||||
const status = (p.status || "").toLowerCase();
|
||||
|
|
@ -118,6 +135,12 @@ function categorizePayments(payments) {
|
|||
scheduledChecks.sort(byDate);
|
||||
scheduledACH.sort(byDate);
|
||||
|
||||
// Oldest return first: the longest-unpaid vendor is the most overdue
|
||||
// decision. Missing returned_date sorts first (unknown = assume worst).
|
||||
returnedPayments.sort((a, b) =>
|
||||
String(a.returned_date || "").localeCompare(String(b.returned_date || ""))
|
||||
);
|
||||
|
||||
const bucketedOutstanding = ageBuckets.map((bucket) => {
|
||||
const items = outstandingChecks.filter((p) => {
|
||||
const age = daysSince(parseMDYLocal(p.send_payment_on));
|
||||
|
|
@ -128,7 +151,7 @@ function categorizePayments(payments) {
|
|||
return { ...bucket, items, total };
|
||||
});
|
||||
|
||||
return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding };
|
||||
return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments };
|
||||
}
|
||||
|
||||
// --- Main handler ---
|
||||
|
|
@ -445,10 +468,59 @@ function buildBoABlocks(transactions) {
|
|||
return blocks;
|
||||
}
|
||||
|
||||
function buildHomeView(payments, metadata, boaTransactions = [], expanded = []) {
|
||||
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding } = categorizePayments(payments);
|
||||
export function buildHomeView(payments, metadata, boaTransactions = [], expanded = []) {
|
||||
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments } = categorizePayments(payments);
|
||||
const isExpanded = (key) => expanded.includes(key);
|
||||
|
||||
// Always expanded, no toggle: these sat invisible for months once (#70),
|
||||
// so the action queue never collapses. Returns are rare and get resolved,
|
||||
// so the list stays short.
|
||||
const buildReturnedBlocks = () => {
|
||||
if (!returnedPayments.length) return [];
|
||||
const total = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||
|
||||
const blocks = [
|
||||
{
|
||||
type: "header",
|
||||
text: { type: "plain_text", text: ":rotating_light: Returned — Needs Action" },
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${returnedPayments.length} payment${returnedPayments.length !== 1 ? "s" : ""} · ${formatCurrency(total)} · bank-returned, each needs a reissue or void decision`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of returnedPayments) {
|
||||
const rd = parseISOLocal(p.returned_date);
|
||||
const age = rd ? daysSince(rd) : null;
|
||||
const returnedLine = rd
|
||||
? `Returned ${formatDisplayDate(rd)} · *${age} day${age !== 1 ? "s" : ""} ago*`
|
||||
: "_Return date unknown_";
|
||||
|
||||
blocks.push({
|
||||
type: "section",
|
||||
fields: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*${p.payee || "—"}*\n${p.method === "ACH" ? "Reference" : "Check"} #${p.check_number || "—"}`,
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*${formatCurrency(p.amount_usd)}*\n${returnedLine}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
blocks.push({ type: "divider" });
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const buildScheduledBlocks = (title, emoji, items, sectionKey, methodKey) => {
|
||||
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||
const open = isExpanded(sectionKey);
|
||||
|
|
@ -575,6 +647,7 @@ function buildHomeView(payments, metadata, boaTransactions = [], expanded = [])
|
|||
const totalScheduled = scheduledChecks.length + scheduledACH.length;
|
||||
const totalScheduledAmt = [...scheduledChecks, ...scheduledACH].reduce((sum, p) => sum + p.amount_usd, 0);
|
||||
const totalOutstandingAmt = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||
const totalReturnedAmt = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||
|
||||
const staleness = formatStaleness(metadata?.last_updated);
|
||||
|
||||
|
|
@ -616,9 +689,18 @@ function buildHomeView(payments, metadata, boaTransactions = [], expanded = [])
|
|||
type: "mrkdwn",
|
||||
text: `:warning: *Outstanding*\n${outstandingChecks.length} checks · ${formatCurrency(totalOutstandingAmt)}`,
|
||||
},
|
||||
...(returnedPayments.length
|
||||
? [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `:rotating_light: *Returned*\n${returnedPayments.length} payment${returnedPayments.length !== 1 ? "s" : ""} · ${formatCurrency(totalReturnedAmt)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{ type: "divider" },
|
||||
...buildReturnedBlocks(),
|
||||
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "scheduled_checks", "checks"),
|
||||
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "scheduled_ach", "ach"),
|
||||
...buildOutstandingBlocks(),
|
||||
|
|
|
|||
138
tests/slackAppHome.test.js
Normal file
138
tests/slackAppHome.test.js
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue