From 44a6cd1b6359b9dcc7cdd87d27ae0f9010b27375 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:59:39 -0400 Subject: [PATCH] Add BoA CashPro integration and payment status tracking - New fetchBoaTransactions Lambda: daily 9am ET schedule (disabled until API key set) Calls CashPro Previous Day Transaction Inquiry, filters for codes 255/475, matches bankReference to check_number, updates clear_status in DynamoDB - CSV processor switched to UpdateCommand to preserve clearing data on re-upload - Slack dashboard now shows Scheduled, Outstanding, Cleared, and Returned sections - ACH payments auto-assumed cleared once past due date --- src/fetchBoaTransactions.js | 123 ++++++++++++++++++++++++++++++++++++ src/processPaymentCsv.js | 64 +++++++++++-------- src/slackAppHome.js | 116 ++++++++++++++++++---------------- template.yaml | 39 ++++++++++++ 4 files changed, 261 insertions(+), 81 deletions(-) create mode 100644 src/fetchBoaTransactions.js diff --git a/src/fetchBoaTransactions.js b/src/fetchBoaTransactions.js new file mode 100644 index 0000000..489f73d --- /dev/null +++ b/src/fetchBoaTransactions.js @@ -0,0 +1,123 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, ScanCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); +const ssm = new SSMClient(); +const TABLE_NAME = process.env.TABLE_NAME; +const BOA_BASE_URL = process.env.BOA_BASE_URL; + +async function getSSMParam(name) { + const { Parameter } = await ssm.send( + new GetParameterCommand({ Name: name, WithDecryption: true }) + ); + return Parameter.Value; +} + +export const handler = async () => { + const [apiToken, accountNumber] = await Promise.all([ + getSSMParam(process.env.BOA_API_TOKEN_PARAM), + getSSMParam(process.env.BOA_ACCOUNT_NUMBER_PARAM), + ]); + + // Get yesterday's date in YYYY-MM-DD + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const dateStr = yesterday.toISOString().split("T")[0]; + + // Call CashPro Previous Day Transaction Inquiry + const res = await fetch(`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}`, + }, + body: JSON.stringify({ + accounts: [{ accountNumber, bankId: "BOFAFRPP" }], + fromDate: dateStr, + toDate: dateStr, + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`BoA API error ${res.status}: ${text}`); + } + + const data = await res.json(); + const transactions = data.accountTransactions?.transactions || []; + + // Filter for cleared checks (255) and returned checks (475) + const relevant = transactions.filter( + (t) => t.transactionCode === "255" || t.transactionCode === "475" + ); + + if (!relevant.length) { + console.log(`No check transactions (255/475) found for ${dateStr}`); + return { statusCode: 200, body: `No relevant transactions for ${dateStr}` }; + } + + // Load all check payments from DynamoDB to match against + const payments = []; + let lastKey; + do { + const result = await ddb.send( + new ScanCommand({ + TableName: TABLE_NAME, + FilterExpression: "begins_with(pk, :prefix) AND #m = :method", + ExpressionAttributeNames: { "#m": "method" }, + ExpressionAttributeValues: { ":prefix": "payment#", ":method": "Check" }, + ExclusiveStartKey: lastKey, + }) + ); + payments.push(...result.Items); + lastKey = result.LastEvaluatedKey; + } while (lastKey); + + // Build a map of check_number -> payment for matching + const checkMap = new Map(); + for (const p of payments) { + checkMap.set(p.check_number, p); + } + + let matched = 0; + + for (const txn of relevant) { + const bankRef = txn.bankReference || ""; + + // BoA may append extra digits to the reference number + // Try to find a check_number that the bankReference starts with + const matchedPayment = payments.find((p) => + p.check_number && bankRef.startsWith(p.check_number) + ); + + if (!matchedPayment) { + console.log(`No match for bankReference: ${bankRef}`); + continue; + } + + const clearStatus = txn.transactionCode === "255" ? "Cleared" : "Returned"; + + await ddb.send( + new UpdateCommand({ + TableName: TABLE_NAME, + Key: { pk: matchedPayment.pk }, + UpdateExpression: "SET clear_status = :status, bank_reference = :ref, cleared_date = :date", + ExpressionAttributeValues: { + ":status": clearStatus, + ":ref": bankRef, + ":date": txn.valueDate || dateStr, + }, + }) + ); + + matched++; + console.log(`${clearStatus}: check ${matchedPayment.check_number} (bankRef: ${bankRef})`); + } + + console.log(`Processed ${relevant.length} transactions, matched ${matched} payments`); + return { + statusCode: 200, + body: `${matched} of ${relevant.length} transactions matched to payments`, + }; +}; diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index bab2cd0..3246432 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -1,6 +1,6 @@ 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, PutCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; import { parse } from "csv-parse/sync"; const s3 = new S3Client(); @@ -36,33 +36,43 @@ 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(), - })); + // Upsert each payment, preserving clear_status/bank_reference/cleared_date if they exist + let count = 0; + for (const row of normalizedRows) { + const checkNumber = (row["Check Number"] || "").trim(); + const pk = `payment#${checkNumber}`; - // 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); - } - - 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": (row["Method"] || "").trim(), + ":payee": (row["Payee"] || "").trim(), + ":check_number": checkNumber, + ":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(), + }, }) ); + count++; } // Update metadata record @@ -73,11 +83,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..41a6ad0 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,95 @@ 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 + const scheduled = []; // future payments not yet due + const outstanding = []; // past-due checks not yet cleared + const cleared = []; // confirmed cleared by BoA or ACH past due date + const returned = []; // returned by BoA + + 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 = {}; - - for (const payment of futurePayments) { - if (payment.method !== methodName) continue; - const dt = parseMDYLocal(payment.send_payment_on); - if (!dt) continue; - const key = formatDateKey(dt); - - if (!grouped[key]) grouped[key] = { count: 0, total: 0 }; - grouped[key].count += 1; - grouped[key].total += payment.amount_usd; + if (p.clear_status === "Returned") { + returned.push(p); + } else if (p.clear_status === "Cleared") { + cleared.push(p); + } else if (p.method === "ACH" && dt < today) { + // ACH assumed cleared once past due date + cleared.push(p); + } else if (dt < today) { + // Check past due date but not yet confirmed by BoA + 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); + returned.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 +209,10 @@ 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), + ...buildSectionBlocks("Returned", ":x:", returned), ], }; } diff --git a/template.yaml b/template.yaml index 0802d96..c8389f9 100644 --- a/template.yaml +++ b/template.yaml @@ -194,6 +194,45 @@ Resources: - ec2:DeleteNetworkInterface Resource: "*" + FetchBoaTransactionsFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: payments-fetchBoaTransactions + Handler: src/fetchBoaTransactions.handler + Timeout: 60 + VpcConfig: + SubnetIds: + - !Ref PrivateSubnet + SecurityGroupIds: + - !Ref LambdaSecurityGroup + Environment: + Variables: + BOA_BASE_URL: https://sandbox.cashpro.bankofamerica.com + BOA_API_TOKEN_PARAM: /payments-dashboard/boa-api-token + BOA_ACCOUNT_NUMBER_PARAM: /payments-dashboard/boa-account-number + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 13 ? * MON-FRI *) + Description: Fetch BoA previous day transactions at 9am ET (13:00 UTC) + Enabled: false + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref DashboardTable + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-api-token + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-account-number + - Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - ec2:CreateNetworkInterface + - ec2:DescribeNetworkInterfaces + - ec2:DeleteNetworkInterface + Resource: "*" + Outputs: SlackEventUrl: Description: URL to set as the Slack app Request URL