From c43228831d523938e78a6bc8953b741fef53acf2 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:23:54 -0400 Subject: [PATCH 1/6] Add unified payment status, ACH auto-clear, and categorized dashboard - processPaymentCsv: switch from BatchWrite to upsert, auto-mark ACH payments as Cleared when send date has passed - slackAppHome: categorize payments into Scheduled/Outstanding/Cleared sections using unified status field - Add seed scripts for bulk-loading Stampli and bank CSV exports --- scripts/seed-bank-status.js | 141 +++++++++++++++++++++++++++++++++ scripts/seed-from-csv.js | 150 ++++++++++++++++++++++++++++++++++++ src/processPaymentCsv.js | 87 ++++++++++++++------- src/slackAppHome.js | 113 ++++++++++++++------------- template.yaml | 1 + 5 files changed, 414 insertions(+), 78 deletions(-) create mode 100644 scripts/seed-bank-status.js create mode 100644 scripts/seed-from-csv.js diff --git a/scripts/seed-bank-status.js b/scripts/seed-bank-status.js new file mode 100644 index 0000000..5ad1045 --- /dev/null +++ b/scripts/seed-bank-status.js @@ -0,0 +1,141 @@ +/** + * Seed bank status data from BoA exports into DynamoDB. + * + * Usage: + * node scripts/seed-bank-status.js \ + * --cleared "Bank Export.csv" \ + * --outstanding "PrintIssuesForInquiry.csv" \ + * --issued "Positive Pay Import original.csv" + * + * All flags are optional — supply whichever files you have. + * + * Updates existing payment records: + * - status: overwritten to "Cleared" | "Outstanding" | "Issued" + * - paid_date: date the check cleared (from Bank Export) + */ + +import { readFileSync } from "fs"; +import { parse } from "csv-parse/sync"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb"; + +const TABLE_NAME = "PaymentsDashboard"; +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); + +function parseArgs() { + const args = process.argv.slice(2); + const result = {}; + for (let i = 0; i < args.length; i += 2) { + const flag = args[i].replace(/^--/, ""); + result[flag] = args[i + 1]; + } + return result; +} + +function readCSV(filePath) { + const text = readFileSync(filePath, "utf-8"); + return parse(text, { columns: true, skip_empty_lines: true, trim: true }); +} + +// Normalize short dates like "1/27/26" → "2026-01-27" +function toISODate(dateStr) { + if (!dateStr) return null; + dateStr = dateStr.trim(); + + // Already ISO-ish: 04/13/2026 + let parts = dateStr.split("/"); + if (parts.length === 3) { + let [mm, dd, yyyy] = parts; + if (yyyy.length === 2) yyyy = `20${yyyy}`; + return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`; + } + return null; +} + +async function updateStatus(checkNumber, status, paidDate) { + const pk = `payment#${checkNumber}`; + const expr = ["#status = :s"]; + const names = { "#status": "status" }; + const values = { ":s": status }; + + if (paidDate) { + expr.push("paid_date = :pd"); + values[":pd"] = paidDate; + } + + await ddb.send( + new UpdateCommand({ + TableName: TABLE_NAME, + Key: { pk }, + UpdateExpression: `SET ${expr.join(", ")}`, + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + }) + ); +} + +async function main() { + const flags = parseArgs(); + const statusMap = new Map(); // checkNumber → { status, paidDate } + + // 1. Positive Pay Import (lowest priority) — "Issued" + // Format: check_number, date, ?, ?, amount (no headers) + if (flags.issued) { + console.log(`Reading positive pay import: ${flags.issued}`); + const text = readFileSync(flags.issued, "utf-8"); + const rows = parse(text, { skip_empty_lines: true, trim: true }); + for (const row of rows) { + const checkNumber = row[0]?.trim(); + if (!checkNumber) continue; + statusMap.set(checkNumber, { status: "Issued", paidDate: null }); + } + console.log(` ${rows.length} positive pay entries`); + } + + // 2. Outstanding Issues (medium priority) — "Outstanding" + if (flags.outstanding) { + console.log(`Reading outstanding issues: ${flags.outstanding}`); + const rows = readCSV(flags.outstanding); + for (const row of rows) { + const checkNumber = (row["Check Number"] || "").trim(); + if (!checkNumber) continue; + statusMap.set(checkNumber, { status: "Outstanding", paidDate: null }); + } + console.log(` ${rows.length} outstanding entries`); + } + + // 3. Bank Export / Cleared (highest priority) — "Cleared" + // Duplicates exist (same check appears twice); just take the first paid date. + if (flags.cleared) { + console.log(`Reading bank export (cleared): ${flags.cleared}`); + const rows = readCSV(flags.cleared); + const seen = new Set(); + let deduped = 0; + for (const row of rows) { + const checkNumber = (row["Check Number"] || "").trim(); + if (!checkNumber) continue; + if (seen.has(checkNumber)) { + deduped++; + continue; + } + seen.add(checkNumber); + const paidDate = toISODate(row["Paid Date"] || row["CD Volume Number"] || ""); + statusMap.set(checkNumber, { status: "Cleared", paidDate }); + } + console.log(` ${seen.size} unique cleared checks (${deduped} duplicates skipped)`); + } + + // Write to DynamoDB + let count = 0; + for (const [checkNumber, { status, paidDate }] of statusMap) { + await updateStatus(checkNumber, status, paidDate); + count++; + } + + console.log(`\nDone — ${count} payments updated with bank status.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/seed-from-csv.js b/scripts/seed-from-csv.js new file mode 100644 index 0000000..8c0edf7 --- /dev/null +++ b/scripts/seed-from-csv.js @@ -0,0 +1,150 @@ +/** + * Seed DynamoDB PaymentsDashboard table from Stampli CSV exports. + * + * Usage: + * node scripts/seed-from-csv.js [csv-file2] ... + * + * Reads one or more Stampli CSV exports and upserts every row into the + * PaymentsDashboard table. Skips CashPro API calls entirely — this is + * purely for populating the DB so the Slack App Home has data to display. + */ + +import { readFileSync } from "fs"; +import { parse } from "csv-parse/sync"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + DynamoDBDocumentClient, + UpdateCommand, + PutCommand, +} from "@aws-sdk/lib-dynamodb"; + +const TABLE_NAME = "PaymentsDashboard"; +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); + +function toISODate(mdyDate) { + const parts = String(mdyDate || "").split("/"); + if (parts.length !== 3) return null; + const [mm, dd, yyyy] = parts; + return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`; +} + +const parseAmount = (value) => { + const num = parseFloat(String(value || "0").replace(/,/g, "").trim()); + return isNaN(num) ? 0 : num; +}; + +async function seedFile(filePath) { + const csvText = readFileSync(filePath, "utf-8"); + const rows = parse(csvText, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + + const normalizedRows = rows.map((row) => { + const clean = {}; + for (const [k, v] of Object.entries(row)) { + clean[String(k).trim()] = typeof v === "string" ? v.trim() : v; + } + return clean; + }); + + let count = 0; + let skipped = 0; + + for (const row of normalizedRows) { + const checkNumber = (row["Check Number"] || "").trim(); + if (!checkNumber) { + skipped++; + continue; + } + + const method = (row["Method"] || "").trim(); + let status = (row["Status"] || "").trim(); + const sendOn = (row["Send Payment On"] || "").trim(); + + // ACH payments clear automatically on their send date — + // mark as Cleared unless voided/canceled + const cancelStatuses = ["canceled", "cancelled", "marked as void"]; + if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) { + const sendDate = toISODate(sendOn); + const today = new Date().toISOString().slice(0, 10); + if (sendDate && sendDate <= today) { + status = "Cleared"; + } + } + + // Amount in USD is blank for canceled payments — fall back to Amount + const amount = parseAmount(row["Amount in USD"] || row["Amount"]); + const pk = `payment#${checkNumber}`; + + await ddb.send( + new UpdateCommand({ + TableName: TABLE_NAME, + Key: { pk }, + UpdateExpression: ` + SET #method = :method, + payee = :payee, + check_number = :check_number, + invoice_numbers = :invoice_numbers, + send_payment_on = :send_payment_on, + amount_usd = :amount_usd, + #status = :status, + company_subsidiary = :company_subsidiary + `, + ExpressionAttributeNames: { + "#method": "method", + "#status": "status", + }, + ExpressionAttributeValues: { + ":method": method, + ":payee": (row["Payee"] || "").trim(), + ":check_number": checkNumber, + ":invoice_numbers": (row["Invoice Numbers"] || "").trim(), + ":send_payment_on": (row["Send Payment On"] || "").trim(), + ":amount_usd": amount, + ":status": status, + ":company_subsidiary": (row["Company/Subsidiary"] || "").trim(), + }, + }) + ); + count++; + } + + console.log(` ${filePath}: ${count} upserted, ${skipped} skipped (no check number)`); + return count; +} + +async function main() { + const files = process.argv.slice(2); + if (!files.length) { + console.error("Usage: node scripts/seed-from-csv.js [csv-file2] ..."); + process.exit(1); + } + + let total = 0; + for (const f of files) { + console.log(`Processing ${f}...`); + total += await seedFile(f); + } + + // Update metadata + await ddb.send( + new PutCommand({ + TableName: TABLE_NAME, + Item: { + pk: "metadata", + file_name: "seed-from-csv", + last_updated: new Date().toISOString(), + last_file_count: total, + }, + }) + ); + + console.log(`\nDone — ${total} total payments seeded.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index bab2cd0..7b0492d 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -1,12 +1,20 @@ import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { DynamoDBDocumentClient, BatchWriteCommand, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { DynamoDBDocumentClient, UpdateCommand, PutCommand } from "@aws-sdk/lib-dynamodb"; import { parse } from "csv-parse/sync"; const s3 = new S3Client(); const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); const TABLE_NAME = process.env.TABLE_NAME; +// Convert MM/DD/YYYY to YYYY-MM-DD +function toISODate(mdyDate) { + const parts = String(mdyDate).split("/"); + if (parts.length !== 3) return null; + const [mm, dd, yyyy] = parts; + return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`; +} + export const handler = async (event) => { const record = event.Records[0]; const bucket = record.s3.bucket.name; @@ -36,33 +44,60 @@ export const handler = async (event) => { return isNaN(num) ? 0 : num; }; - const payments = normalizedRows.map((row) => ({ - pk: `payment#${(row["Check Number"] || "").trim()}`, - method: (row["Method"] || "").trim(), - payee: (row["Payee"] || "").trim(), - check_number: (row["Check Number"] || "").trim(), - invoice_numbers: (row["Invoice Numbers"] || "").trim(), - send_payment_on: (row["Send Payment On"] || "").trim(), - amount_usd: parseAmount(row["Amount in USD"]), - status: (row["Status"] || "").trim(), - company_subsidiary: (row["Company/Subsidiary"] || "").trim(), - })); + const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"]; - // Write payments in batches of 25 (DynamoDB BatchWrite limit) - const batches = []; - for (let i = 0; i < payments.length; i += 25) { - const batch = payments.slice(i, i + 25).map((item) => ({ - PutRequest: { Item: item }, - })); - batches.push(batch); - } + // Upsert each payment + let count = 0; + for (const row of normalizedRows) { + const checkNumber = (row["Check Number"] || "").trim(); + if (!checkNumber) continue; + + const method = (row["Method"] || "").trim(); + let status = (row["Status"] || "").trim(); + const sendOn = (row["Send Payment On"] || "").trim(); + + // ACH payments clear automatically on their send date + if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) { + const sendDate = toISODate(sendOn); + const today = new Date().toISOString().slice(0, 10); + if (sendDate && sendDate <= today) { + status = "Cleared"; + } + } + + const pk = `payment#${checkNumber}`; - for (const batch of batches) { await ddb.send( - new BatchWriteCommand({ - RequestItems: { [TABLE_NAME]: batch }, + new UpdateCommand({ + TableName: TABLE_NAME, + Key: { pk }, + UpdateExpression: ` + SET #method = :method, + payee = :payee, + check_number = :check_number, + invoice_numbers = :invoice_numbers, + send_payment_on = :send_payment_on, + amount_usd = :amount_usd, + #status = :status, + company_subsidiary = :company_subsidiary + `, + ExpressionAttributeNames: { + "#method": "method", + "#status": "status", + }, + ExpressionAttributeValues: { + ":method": method, + ":payee": (row["Payee"] || "").trim(), + ":check_number": checkNumber, + ":invoice_numbers": (row["Invoice Numbers"] || "").trim(), + ":send_payment_on": sendOn, + ":amount_usd": parseAmount(row["Amount in USD"]), + ":status": status, + ":company_subsidiary": (row["Company/Subsidiary"] || "").trim(), + }, }) ); + count++; } // Update metadata record @@ -73,11 +108,11 @@ export const handler = async (event) => { pk: "metadata", file_name: key.split("/").pop(), last_updated: new Date().toISOString(), - last_file_count: payments.length, + last_file_count: count, }, }) ); - console.log(`Upserted ${payments.length} payments from ${key}`); - return { statusCode: 200, body: `Upserted ${payments.length} payments` }; + console.log(`Upserted ${count} payments from ${key}`); + return { statusCode: 200, body: `Upserted ${count} payments` }; }; diff --git a/src/slackAppHome.js b/src/slackAppHome.js index ea978c3..7ac45e4 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -83,7 +83,6 @@ export const handler = async (event) => { }; function buildHomeView(payments, metadata) { - const formatCurrency = (value) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format( Number(value || 0) @@ -98,88 +97,97 @@ function buildHomeView(payments, metadata) { return new Date(yyyy, mm - 1, dd); }; - const formatDateKey = (date) => { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, "0"); - const d = String(date.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; - }; - - const formatDisplayDate = (dateKey) => { - const [y, m, d] = dateKey.split("-").map(Number); - const dt = new Date(y, m - 1, d); - return dt.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - }); - }; + const formatDisplayDate = (date) => + date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); const today = new Date(); today.setHours(0, 0, 0, 0); - const futurePayments = payments.filter((p) => { + // Categorize payments by status + const scheduled = []; + const outstanding = []; + const cleared = []; + + const skipStatuses = ["voided", "cancelled", "canceled", "marked as void"]; + + for (const p of payments) { + if (p.method !== "ACH" && p.method !== "Check") continue; + const dt = parseMDYLocal(p.send_payment_on); - return dt && dt >= today && (p.method === "ACH" || p.method === "Check"); - }); + if (!dt) continue; - const groupByMethodAndDay = (methodName) => { - const grouped = {}; + const status = (p.status || "").toLowerCase(); - for (const payment of futurePayments) { - if (payment.method !== methodName) continue; - const dt = parseMDYLocal(payment.send_payment_on); - if (!dt) continue; - const key = formatDateKey(dt); + // Skip voided/cancelled payments entirely + if (skipStatuses.includes(status)) continue; - if (!grouped[key]) grouped[key] = { count: 0, total: 0 }; - grouped[key].count += 1; - grouped[key].total += payment.amount_usd; + if (status === "cleared") { + cleared.push(p); + } else if (status === "outstanding") { + outstanding.push(p); + } else if (dt < today) { + // Past due date but not yet confirmed cleared — outstanding + outstanding.push(p); + } else { + scheduled.push(p); } + } - return Object.keys(grouped) - .sort() - .map((dateKey) => ({ - date_key: dateKey, - display_date: formatDisplayDate(dateKey), - count: grouped[dateKey].count, - total: Number(grouped[dateKey].total.toFixed(2)), - })); + // Sort by date + const byDate = (a, b) => { + const da = parseMDYLocal(a.send_payment_on); + const db = parseMDYLocal(b.send_payment_on); + return (da || 0) - (db || 0); }; + scheduled.sort(byDate); + outstanding.sort(byDate); + cleared.sort(byDate); - const achDays = groupByMethodAndDay("ACH"); - const checkDays = groupByMethodAndDay("Check"); - - const buildDayBlocks = (title, days) => { - const total = days.reduce((sum, d) => sum + d.total, 0); - const count = days.reduce((sum, d) => sum + d.count, 0); + const buildSectionBlocks = (title, emoji, items) => { + const total = items.reduce((sum, p) => sum + p.amount_usd, 0); const blocks = [ { type: "section", text: { type: "mrkdwn", - text: `*${title}*\n${count} payments • ${formatCurrency(total)}`, + text: `*${emoji} ${title}*\n${items.length} payments • ${formatCurrency(total)}`, }, }, { type: "divider" }, ]; - if (!days.length) { + if (!items.length) { blocks.push({ type: "section", - text: { type: "mrkdwn", text: "_No upcoming payments found._" }, + text: { type: "mrkdwn", text: "_None_" }, }); blocks.push({ type: "divider" }); return blocks; } - for (const day of days) { + // Group by date + const grouped = {}; + for (const p of items) { + const dt = parseMDYLocal(p.send_payment_on); + const key = dt ? dt.toISOString().split("T")[0] : "unknown"; + if (!grouped[key]) grouped[key] = { date: dt, payments: [] }; + grouped[key].payments.push(p); + } + + for (const key of Object.keys(grouped).sort()) { + const group = grouped[key]; + const dayTotal = group.payments.reduce((sum, p) => sum + p.amount_usd, 0); + const dateLabel = group.date ? formatDisplayDate(group.date) : "Unknown"; + blocks.push({ type: "section", fields: [ - { type: "mrkdwn", text: `*${day.display_date}*` }, - { type: "mrkdwn", text: `${day.count} payments • ${formatCurrency(day.total)}` }, + { type: "mrkdwn", text: `*${dateLabel}*` }, + { + type: "mrkdwn", + text: `${group.payments.length} payments • ${formatCurrency(dayTotal)}`, + }, ], }); } @@ -203,8 +211,9 @@ function buildHomeView(payments, metadata) { ], }, { type: "divider" }, - ...buildDayBlocks("ACH", achDays), - ...buildDayBlocks("Check", checkDays), + ...buildSectionBlocks("Scheduled", ":calendar:", scheduled), + ...buildSectionBlocks("Outstanding", ":warning:", outstanding), + ...buildSectionBlocks("Cleared", ":white_check_mark:", cleared), ], }; } diff --git a/template.yaml b/template.yaml index 0802d96..384e817 100644 --- a/template.yaml +++ b/template.yaml @@ -131,6 +131,7 @@ Resources: Properties: FunctionName: payments-processPaymentCsv Handler: src/processPaymentCsv.handler + Timeout: 120 VpcConfig: SubnetIds: - !Ref PrivateSubnet From 53842f0821f79c8a91ba5b5adf168a121eec422c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:28:01 -0400 Subject: [PATCH 2/6] Update dashboard: split scheduled by method, outstanding checks by age - Scheduled section split into Scheduled Checks and Scheduled ACH - Remove Cleared section from dashboard - Outstanding renamed to Outstanding Checks with <=90 day and >90 day totals --- src/slackAppHome.js | 94 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 23 deletions(-) diff --git a/src/slackAppHome.js b/src/slackAppHome.js index 7ac45e4..c720491 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -103,12 +103,14 @@ function buildHomeView(payments, metadata) { const today = new Date(); today.setHours(0, 0, 0, 0); - // Categorize payments by status - const scheduled = []; - const outstanding = []; - const cleared = []; + // Categorize payments + const scheduledChecks = []; + const scheduledACH = []; + const outstandingChecks = []; - const skipStatuses = ["voided", "cancelled", "canceled", "marked as void"]; + const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"]; + const ninetyDaysAgo = new Date(today); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); for (const p of payments) { if (p.method !== "ACH" && p.method !== "Check") continue; @@ -117,20 +119,17 @@ function buildHomeView(payments, metadata) { if (!dt) continue; const status = (p.status || "").toLowerCase(); - - // Skip voided/cancelled payments entirely if (skipStatuses.includes(status)) continue; - if (status === "cleared") { - cleared.push(p); - } else if (status === "outstanding") { - outstanding.push(p); - } else if (dt < today) { - // Past due date but not yet confirmed cleared — outstanding - outstanding.push(p); - } else { - scheduled.push(p); + if (dt >= today) { + // Future — scheduled + if (p.method === "Check") scheduledChecks.push(p); + else scheduledACH.push(p); + } else if (p.method === "Check") { + // Past-due check not cleared — outstanding + outstandingChecks.push(p); } + // Past-due ACH not cleared is ignored (should have been auto-cleared) } // Sort by date @@ -139,11 +138,14 @@ function buildHomeView(payments, metadata) { const db = parseMDYLocal(b.send_payment_on); return (da || 0) - (db || 0); }; - scheduled.sort(byDate); - outstanding.sort(byDate); - cleared.sort(byDate); + scheduledChecks.sort(byDate); + scheduledACH.sort(byDate); - const buildSectionBlocks = (title, emoji, items) => { + // Split outstanding into <=90 days and >90 days + const outstanding90 = outstandingChecks.filter((p) => parseMDYLocal(p.send_payment_on) >= ninetyDaysAgo); + const outstandingOver90 = outstandingChecks.filter((p) => parseMDYLocal(p.send_payment_on) < ninetyDaysAgo); + + const buildScheduledBlocks = (title, emoji, items) => { const total = items.reduce((sum, p) => sum + p.amount_usd, 0); const blocks = [ @@ -196,6 +198,52 @@ function buildHomeView(payments, metadata) { return blocks; }; + const buildOutstandingBlocks = () => { + const totalAll = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0); + + const blocks = [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*:warning: Outstanding Checks*\n${outstandingChecks.length} checks • ${formatCurrency(totalAll)}`, + }, + }, + { type: "divider" }, + ]; + + if (!outstandingChecks.length) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: "_None_" }, + }); + blocks.push({ type: "divider" }); + return blocks; + } + + const total90 = outstanding90.reduce((sum, p) => sum + p.amount_usd, 0); + const totalOver90 = outstandingOver90.reduce((sum, p) => sum + p.amount_usd, 0); + + blocks.push({ + type: "section", + fields: [ + { type: "mrkdwn", text: `*90 days or less*` }, + { type: "mrkdwn", text: `${outstanding90.length} checks • ${formatCurrency(total90)}` }, + ], + }); + + blocks.push({ + type: "section", + fields: [ + { type: "mrkdwn", text: `*Over 90 days*` }, + { type: "mrkdwn", text: `${outstandingOver90.length} checks • ${formatCurrency(totalOver90)}` }, + ], + }); + + blocks.push({ type: "divider" }); + return blocks; + }; + return { type: "home", blocks: [ @@ -211,9 +259,9 @@ function buildHomeView(payments, metadata) { ], }, { type: "divider" }, - ...buildSectionBlocks("Scheduled", ":calendar:", scheduled), - ...buildSectionBlocks("Outstanding", ":warning:", outstanding), - ...buildSectionBlocks("Cleared", ":white_check_mark:", cleared), + ...buildScheduledBlocks("Scheduled Checks", ":calendar:", scheduledChecks), + ...buildScheduledBlocks("Scheduled ACH", ":calendar:", scheduledACH), + ...buildOutstandingBlocks(), ], }; } From 7f94eee308c270b37e788170bd0d9ba923746141 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:03:10 -0400 Subject: [PATCH 3/6] Redesign dashboard: 6 outstanding age buckets, summary bar, improved layout --- src/slackAppHome.js | 133 +++++++++++++++++++++++++++++--------------- 1 file changed, 87 insertions(+), 46 deletions(-) diff --git a/src/slackAppHome.js b/src/slackAppHome.js index c720491..4551776 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -103,14 +103,14 @@ function buildHomeView(payments, metadata) { const today = new Date(); today.setHours(0, 0, 0, 0); + const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24)); + // Categorize payments const scheduledChecks = []; const scheduledACH = []; const outstandingChecks = []; const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"]; - const ninetyDaysAgo = new Date(today); - ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); for (const p of payments) { if (p.method !== "ACH" && p.method !== "Check") continue; @@ -122,14 +122,11 @@ function buildHomeView(payments, metadata) { if (skipStatuses.includes(status)) continue; if (dt >= today) { - // Future — scheduled if (p.method === "Check") scheduledChecks.push(p); else scheduledACH.push(p); } else if (p.method === "Check") { - // Past-due check not cleared — outstanding outstandingChecks.push(p); } - // Past-due ACH not cleared is ignored (should have been auto-cleared) } // Sort by date @@ -141,28 +138,50 @@ function buildHomeView(payments, metadata) { scheduledChecks.sort(byDate); scheduledACH.sort(byDate); - // Split outstanding into <=90 days and >90 days - const outstanding90 = outstandingChecks.filter((p) => parseMDYLocal(p.send_payment_on) >= ninetyDaysAgo); - const outstandingOver90 = outstandingChecks.filter((p) => parseMDYLocal(p.send_payment_on) < ninetyDaysAgo); + // Age buckets for outstanding checks + const ageBuckets = [ + { label: "0 – 15 days", min: 0, max: 15 }, + { label: "15 – 30 days", min: 15, max: 30 }, + { label: "30 – 45 days", min: 30, max: 45 }, + { label: "45 – 60 days", min: 45, max: 60 }, + { label: "60 – 90 days", min: 60, max: 90 }, + { label: "90+ days", min: 90, max: Infinity }, + ]; + + const bucketedOutstanding = ageBuckets.map((bucket) => { + const items = outstandingChecks.filter((p) => { + const age = daysSince(parseMDYLocal(p.send_payment_on)); + return age >= bucket.min && age < bucket.max; + }); + const total = items.reduce((sum, p) => sum + p.amount_usd, 0); + return { ...bucket, items, total }; + }); + + // --- Block builders --- const buildScheduledBlocks = (title, emoji, items) => { const total = items.reduce((sum, p) => sum + p.amount_usd, 0); const blocks = [ { - type: "section", - text: { - type: "mrkdwn", - text: `*${emoji} ${title}*\n${items.length} payments • ${formatCurrency(total)}`, - }, + type: "header", + text: { type: "plain_text", text: `${emoji} ${title}` }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `${items.length} payment${items.length !== 1 ? "s" : ""} · ${formatCurrency(total)} total`, + }, + ], }, - { type: "divider" }, ]; if (!items.length) { blocks.push({ type: "section", - text: { type: "mrkdwn", text: "_None_" }, + text: { type: "mrkdwn", text: "_No upcoming payments_" }, }); blocks.push({ type: "divider" }); return blocks; @@ -181,14 +200,16 @@ function buildHomeView(payments, metadata) { const group = grouped[key]; const dayTotal = group.payments.reduce((sum, p) => sum + p.amount_usd, 0); const dateLabel = group.date ? formatDisplayDate(group.date) : "Unknown"; + const daysUntil = group.date ? Math.ceil((group.date - today) / (1000 * 60 * 60 * 24)) : null; + const daysTag = daysUntil === 0 ? ":rotating_light: _Today_" : daysUntil === 1 ? "_Tomorrow_" : daysUntil != null ? `_in ${daysUntil} days_` : ""; blocks.push({ type: "section", fields: [ - { type: "mrkdwn", text: `*${dateLabel}*` }, + { type: "mrkdwn", text: `*${dateLabel}* ${daysTag}` }, { type: "mrkdwn", - text: `${group.payments.length} payments • ${formatCurrency(dayTotal)}`, + text: `${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`, }, ], }); @@ -203,64 +224,84 @@ function buildHomeView(payments, metadata) { const blocks = [ { - type: "section", - text: { - type: "mrkdwn", - text: `*:warning: Outstanding Checks*\n${outstandingChecks.length} checks • ${formatCurrency(totalAll)}`, - }, + type: "header", + text: { type: "plain_text", text: ":warning: Outstanding Checks" }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `${outstandingChecks.length} check${outstandingChecks.length !== 1 ? "s" : ""} · ${formatCurrency(totalAll)} total`, + }, + ], }, - { type: "divider" }, ]; if (!outstandingChecks.length) { blocks.push({ type: "section", - text: { type: "mrkdwn", text: "_None_" }, + text: { type: "mrkdwn", text: ":white_check_mark: _All checks have cleared_" }, }); blocks.push({ type: "divider" }); return blocks; } - const total90 = outstanding90.reduce((sum, p) => sum + p.amount_usd, 0); - const totalOver90 = outstandingOver90.reduce((sum, p) => sum + p.amount_usd, 0); + for (const bucket of bucketedOutstanding) { + if (!bucket.items.length) continue; - blocks.push({ - type: "section", - fields: [ - { type: "mrkdwn", text: `*90 days or less*` }, - { type: "mrkdwn", text: `${outstanding90.length} checks • ${formatCurrency(total90)}` }, - ], - }); - - blocks.push({ - type: "section", - fields: [ - { type: "mrkdwn", text: `*Over 90 days*` }, - { type: "mrkdwn", text: `${outstandingOver90.length} checks • ${formatCurrency(totalOver90)}` }, - ], - }); + blocks.push({ + type: "section", + fields: [ + { type: "mrkdwn", text: `*${bucket.label}*` }, + { + type: "mrkdwn", + text: `${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`, + }, + ], + }); + } blocks.push({ type: "divider" }); return blocks; }; + // --- Summary bar --- + 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); + return { type: "home", blocks: [ { type: "header", - text: { type: "plain_text", text: "Payments Dashboard" }, + text: { type: "plain_text", text: ":bank: Payments Dashboard" }, }, { type: "context", elements: [ - { type: "mrkdwn", text: `*Last updated:* ${metadata?.last_updated || "N/A"}` }, - { type: "mrkdwn", text: `*Source:* ${metadata?.file_name || "N/A"}` }, + { type: "mrkdwn", text: `Last updated · ${metadata?.last_updated || "N/A"}` }, + { type: "mrkdwn", text: `Source · ${metadata?.file_name || "N/A"}` }, ], }, { type: "divider" }, - ...buildScheduledBlocks("Scheduled Checks", ":calendar:", scheduledChecks), - ...buildScheduledBlocks("Scheduled ACH", ":calendar:", scheduledACH), + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `:calendar: *Scheduled*\n${totalScheduled} payments · ${formatCurrency(totalScheduledAmt)}`, + }, + { + type: "mrkdwn", + text: `:warning: *Outstanding*\n${outstandingChecks.length} checks · ${formatCurrency(totalOutstandingAmt)}`, + }, + ], + }, + { type: "divider" }, + ...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks), + ...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH), ...buildOutstandingBlocks(), ], }; From 941cd094fc257a2ac346455db66f246a5ca66508 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:08:06 -0400 Subject: [PATCH 4/6] Add drill-down modals: View buttons open payment detail for each row --- src/slackAppHome.js | 331 +++++++++++++++++++++++++++++++------------- 1 file changed, 232 insertions(+), 99 deletions(-) diff --git a/src/slackAppHome.js b/src/slackAppHome.js index 4551776..2a204cf 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -19,14 +19,106 @@ async function getSlackToken() { return cachedToken; } +// --- Shared helpers used by both home view and modals --- + +const formatCurrency = (value) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format( + Number(value || 0) + ); + +const parseMDYLocal = (value) => { + if (!value) return null; + const parts = String(value).split("/"); + if (parts.length !== 3) return null; + const [mm, dd, yyyy] = parts.map((p) => parseInt(p, 10)); + if (!mm || !dd || !yyyy) return null; + return new Date(yyyy, mm - 1, dd); +}; + +const formatDisplayDate = (date) => + date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); + +const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"]; + +const ageBuckets = [ + { label: "0 – 15 days", min: 0, max: 15 }, + { label: "15 – 30 days", min: 15, max: 30 }, + { label: "30 – 45 days", min: 30, max: 45 }, + { label: "45 – 60 days", min: 45, max: 60 }, + { label: "60 – 90 days", min: 60, max: 90 }, + { label: "90+ days", min: 90, max: Infinity }, +]; + +const byDate = (a, b) => { + const da = parseMDYLocal(a.send_payment_on); + const db = parseMDYLocal(b.send_payment_on); + return (da || 0) - (db || 0); +}; + +// --- Categorize payments into buckets --- + +function categorizePayments(payments) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24)); + + const scheduledChecks = []; + const scheduledACH = []; + const outstandingChecks = []; + + for (const p of payments) { + if (p.method !== "ACH" && p.method !== "Check") continue; + const dt = parseMDYLocal(p.send_payment_on); + if (!dt) continue; + const status = (p.status || "").toLowerCase(); + if (skipStatuses.includes(status)) continue; + + if (dt >= today) { + if (p.method === "Check") scheduledChecks.push(p); + else scheduledACH.push(p); + } else if (p.method === "Check") { + outstandingChecks.push(p); + } + } + + scheduledChecks.sort(byDate); + scheduledACH.sort(byDate); + + const bucketedOutstanding = ageBuckets.map((bucket) => { + const items = outstandingChecks.filter((p) => { + const age = daysSince(parseMDYLocal(p.send_payment_on)); + return age >= bucket.min && age < bucket.max; + }); + items.sort(byDate); + const total = items.reduce((sum, p) => sum + p.amount_usd, 0); + return { ...bucket, items, total }; + }); + + return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding }; +} + +// --- Main handler --- + export const handler = async (event) => { - const body = JSON.parse(event.body || "{}"); + // Slack sends block_actions as form-encoded: payload= + let body; + const rawBody = event.body || ""; + if (rawBody.startsWith("payload=")) { + body = JSON.parse(decodeURIComponent(rawBody.replace("payload=", ""))); + } else { + body = JSON.parse(rawBody || "{}"); + } // Handle Slack URL verification challenge if (body.type === "url_verification") { return { statusCode: 200, body: body.challenge }; } + // Handle button clicks → open modal + if (body.type === "block_actions") { + return handleBlockAction(body); + } + // Only process app_home_opened events if (body.event?.type !== "app_home_opened") { return { statusCode: 200, body: JSON.stringify({ ok: true }) }; @@ -34,32 +126,16 @@ export const handler = async (event) => { const userId = body.event.user; - // Get metadata const { Item: metadata } = await ddb.send( new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } }) ); - // Scan all payment items - const payments = []; - let lastKey; - do { - const result = await ddb.send( - new ScanCommand({ - TableName: TABLE_NAME, - FilterExpression: "begins_with(pk, :prefix)", - ExpressionAttributeValues: { ":prefix": "payment#" }, - ExclusiveStartKey: lastKey, - }) - ); - payments.push(...result.Items); - lastKey = result.LastEvaluatedKey; - } while (lastKey); + const payments = await scanPayments(); if (!payments.length) { return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) }; } - // Build and publish the home view const view = buildHomeView(payments, metadata); const slackToken = await getSlackToken(); @@ -82,84 +158,136 @@ export const handler = async (event) => { return { statusCode: 200, body: JSON.stringify({ ok: true }) }; }; -function buildHomeView(payments, metadata) { - const formatCurrency = (value) => - new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format( - Number(value || 0) - ); +// --- Block action handler (button clicks) --- - const parseMDYLocal = (value) => { - if (!value) return null; - const parts = String(value).split("/"); - if (parts.length !== 3) return null; - const [mm, dd, yyyy] = parts.map((p) => parseInt(p, 10)); - if (!mm || !dd || !yyyy) return null; - return new Date(yyyy, mm - 1, dd); - }; +async function handleBlockAction(body) { + const action = body.actions?.[0]; + if (!action) return { statusCode: 200, body: JSON.stringify({ ok: true }) }; - const formatDisplayDate = (date) => - date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); + const triggerId = body.trigger_id; + const actionId = action.action_id; - const today = new Date(); - today.setHours(0, 0, 0, 0); + const payments = await scanPayments(); + const { today, daysSince, scheduledChecks, scheduledACH, bucketedOutstanding } = categorizePayments(payments); - const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24)); + let modalTitle = ""; + let items = []; - // Categorize payments - const scheduledChecks = []; - const scheduledACH = []; - const outstandingChecks = []; + if (actionId.startsWith("view_scheduled_")) { + const dateKey = actionId.replace("view_scheduled_checks_", "").replace("view_scheduled_ach_", ""); + const method = actionId.includes("_checks_") ? "Check" : "ACH"; + const source = method === "Check" ? scheduledChecks : scheduledACH; - const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"]; + items = source.filter((p) => { + const dt = parseMDYLocal(p.send_payment_on); + return dt && dt.toISOString().split("T")[0] === dateKey; + }); - for (const p of payments) { - if (p.method !== "ACH" && p.method !== "Check") continue; - - const dt = parseMDYLocal(p.send_payment_on); - if (!dt) continue; - - const status = (p.status || "").toLowerCase(); - if (skipStatuses.includes(status)) continue; - - if (dt >= today) { - if (p.method === "Check") scheduledChecks.push(p); - else scheduledACH.push(p); - } else if (p.method === "Check") { - outstandingChecks.push(p); + const dt = items[0] ? parseMDYLocal(items[0].send_payment_on) : null; + modalTitle = dt ? formatDisplayDate(dt) : dateKey; + } else if (actionId.startsWith("view_outstanding_")) { + const bucketIdx = parseInt(actionId.replace("view_outstanding_", ""), 10); + const bucket = bucketedOutstanding[bucketIdx]; + if (bucket) { + items = bucket.items; + modalTitle = bucket.label; } } - // Sort by date - const byDate = (a, b) => { - const da = parseMDYLocal(a.send_payment_on); - const db = parseMDYLocal(b.send_payment_on); - return (da || 0) - (db || 0); - }; - scheduledChecks.sort(byDate); - scheduledACH.sort(byDate); + if (!items.length) { + return { statusCode: 200, body: JSON.stringify({ ok: true }) }; + } - // Age buckets for outstanding checks - const ageBuckets = [ - { label: "0 – 15 days", min: 0, max: 15 }, - { label: "15 – 30 days", min: 15, max: 30 }, - { label: "30 – 45 days", min: 30, max: 45 }, - { label: "45 – 60 days", min: 45, max: 60 }, - { label: "60 – 90 days", min: 60, max: 90 }, - { label: "90+ days", min: 90, max: Infinity }, - ]; + const modalBlocks = buildPaymentListBlocks(items); - const bucketedOutstanding = ageBuckets.map((bucket) => { - const items = outstandingChecks.filter((p) => { - const age = daysSince(parseMDYLocal(p.send_payment_on)); - return age >= bucket.min && age < bucket.max; - }); - const total = items.reduce((sum, p) => sum + p.amount_usd, 0); - return { ...bucket, items, total }; + const slackToken = await getSlackToken(); + const res = await fetch("https://slack.com/api/views.open", { + method: "POST", + headers: { + Authorization: `Bearer ${slackToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + trigger_id: triggerId, + view: { + type: "modal", + title: { type: "plain_text", text: modalTitle.slice(0, 24) }, + close: { type: "plain_text", text: "Close" }, + blocks: modalBlocks, + }, + }), }); - // --- Block builders --- + const data = await res.json(); + if (!data.ok) { + console.error("Slack views.open error:", JSON.stringify(data)); + } - const buildScheduledBlocks = (title, emoji, items) => { + return { statusCode: 200, body: JSON.stringify({ ok: true }) }; +} + +// --- Build modal payment list --- + +function buildPaymentListBlocks(items) { + const total = items.reduce((sum, p) => sum + p.amount_usd, 0); + + const blocks = [ + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `${items.length} payment${items.length !== 1 ? "s" : ""} · ${formatCurrency(total)} total`, + }, + ], + }, + { type: "divider" }, + ]; + + for (const p of items) { + const dt = parseMDYLocal(p.send_payment_on); + const dateStr = dt ? formatDisplayDate(dt) : "N/A"; + const payee = p.payee || "—"; + const checkNum = p.check_number || "—"; + + blocks.push({ + type: "section", + fields: [ + { type: "mrkdwn", text: `*${payee}*\nCheck #${checkNum}` }, + { type: "mrkdwn", text: `*${formatCurrency(p.amount_usd)}*\n${dateStr}` }, + ], + }); + } + + return blocks; +} + +// --- Scan all payments from DynamoDB --- + +async function scanPayments() { + const payments = []; + let lastKey; + do { + const result = await ddb.send( + new ScanCommand({ + TableName: TABLE_NAME, + FilterExpression: "begins_with(pk, :prefix)", + ExpressionAttributeValues: { ":prefix": "payment#" }, + ExclusiveStartKey: lastKey, + }) + ); + payments.push(...result.Items); + lastKey = result.LastEvaluatedKey; + } while (lastKey); + return payments; +} + +// --- Build the App Home view --- + +function buildHomeView(payments, metadata) { + const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding } = categorizePayments(payments); + + const buildScheduledBlocks = (title, emoji, items, methodKey) => { const total = items.reduce((sum, p) => sum + p.amount_usd, 0); const blocks = [ @@ -205,13 +333,15 @@ function buildHomeView(payments, metadata) { blocks.push({ type: "section", - fields: [ - { type: "mrkdwn", text: `*${dateLabel}* ${daysTag}` }, - { - type: "mrkdwn", - text: `${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`, - }, - ], + text: { + type: "mrkdwn", + text: `*${dateLabel}* ${daysTag}\n${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`, + }, + accessory: { + type: "button", + text: { type: "plain_text", text: "View" }, + action_id: `view_scheduled_${methodKey}_${key}`, + }, }); } @@ -247,18 +377,21 @@ function buildHomeView(payments, metadata) { return blocks; } - for (const bucket of bucketedOutstanding) { + for (let i = 0; i < bucketedOutstanding.length; i++) { + const bucket = bucketedOutstanding[i]; if (!bucket.items.length) continue; blocks.push({ type: "section", - fields: [ - { type: "mrkdwn", text: `*${bucket.label}*` }, - { - type: "mrkdwn", - text: `${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`, - }, - ], + text: { + type: "mrkdwn", + text: `*${bucket.label}*\n${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`, + }, + accessory: { + type: "button", + text: { type: "plain_text", text: "View" }, + action_id: `view_outstanding_${i}`, + }, }); } @@ -266,7 +399,7 @@ function buildHomeView(payments, metadata) { return blocks; }; - // --- Summary bar --- + // Summary bar 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); @@ -300,8 +433,8 @@ function buildHomeView(payments, metadata) { ], }, { type: "divider" }, - ...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks), - ...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH), + ...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "checks"), + ...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "ach"), ...buildOutstandingBlocks(), ], }; From f769ea58725b40ab00caea59fcd2dc1cdfcbfdda Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:11:54 -0400 Subject: [PATCH 5/6] Fix base64 body decoding for Slack interactivity payloads --- src/slackAppHome.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/slackAppHome.js b/src/slackAppHome.js index 2a204cf..67e533e 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -100,9 +100,14 @@ function categorizePayments(payments) { // --- Main handler --- export const handler = async (event) => { + // Decode body — API Gateway may base64-encode it + let rawBody = event.body || ""; + if (event.isBase64Encoded) { + rawBody = Buffer.from(rawBody, "base64").toString("utf-8"); + } + // Slack sends block_actions as form-encoded: payload= let body; - const rawBody = event.body || ""; if (rawBody.startsWith("payload=")) { body = JSON.parse(decodeURIComponent(rawBody.replace("payload=", ""))); } else { From cb0742128241ea32530d8200dde6ab503abacaa1 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:14:22 -0400 Subject: [PATCH 6/6] Show Reference # instead of Check # for ACH payments in modal --- src/slackAppHome.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slackAppHome.js b/src/slackAppHome.js index 67e533e..e099616 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -258,7 +258,7 @@ function buildPaymentListBlocks(items) { blocks.push({ type: "section", fields: [ - { type: "mrkdwn", text: `*${payee}*\nCheck #${checkNum}` }, + { type: "mrkdwn", text: `*${payee}*\n${p.method === "ACH" ? "Reference" : "Check"} #${checkNum}` }, { type: "mrkdwn", text: `*${formatCurrency(p.amount_usd)}*\n${dateStr}` }, ], });