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 01/11] 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 From 8e262c3b887dd5e5ba16e134760e4de35b9c8293 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:03:09 -0400 Subject: [PATCH 02/11] Merge returned into outstanding, exclude voided/cancelled payments --- src/slackAppHome.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/slackAppHome.js b/src/slackAppHome.js index 41a6ad0..44959b6 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -105,9 +105,10 @@ function buildHomeView(payments, metadata) { // Categorize payments const scheduled = []; // future payments not yet due - const outstanding = []; // past-due checks not yet cleared + const outstanding = []; // past-due checks not yet cleared, or returned by BoA const cleared = []; // confirmed cleared by BoA or ACH past due date - const returned = []; // returned by BoA + + const skipStatuses = ["voided", "cancelled"]; for (const p of payments) { if (p.method !== "ACH" && p.method !== "Check") continue; @@ -115,8 +116,11 @@ function buildHomeView(payments, metadata) { const dt = parseMDYLocal(p.send_payment_on); if (!dt) continue; + // Skip voided/cancelled payments entirely + if (skipStatuses.includes((p.status || "").toLowerCase())) continue; + if (p.clear_status === "Returned") { - returned.push(p); + outstanding.push(p); } else if (p.clear_status === "Cleared") { cleared.push(p); } else if (p.method === "ACH" && dt < today) { @@ -139,7 +143,6 @@ function buildHomeView(payments, metadata) { scheduled.sort(byDate); outstanding.sort(byDate); cleared.sort(byDate); - returned.sort(byDate); const buildSectionBlocks = (title, emoji, items) => { const total = items.reduce((sum, p) => sum + p.amount_usd, 0); @@ -212,7 +215,6 @@ function buildHomeView(payments, metadata) { ...buildSectionBlocks("Scheduled", ":calendar:", scheduled), ...buildSectionBlocks("Outstanding", ":warning:", outstanding), ...buildSectionBlocks("Cleared", ":white_check_mark:", cleared), - ...buildSectionBlocks("Returned", ":x:", returned), ], }; } From 33cd9759b450d54bffb761df94a4fc8beb17752c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:14:51 -0400 Subject: [PATCH 03/11] Add CashPro check issue/cancel on CSV upload - CSV processor detects new checks and submits add_issue to CashPro - Checks updated to voided/cancelled trigger cancel_issue to CashPro - Added SSM params for boa-api-token, boa-account-number, boa-company-id to both processPaymentCsv and fetchBoaTransactions Lambdas - Increased CSV processor timeout to 120s for API calls --- src/processPaymentCsv.js | 120 +++++++++++++++++++++++++++++++++++++-- template.yaml | 16 ++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index 3246432..cc67469 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -1,11 +1,29 @@ import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import { DynamoDBDocumentClient, PutCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; import { parse } from "csv-parse/sync"; const s3 = new S3Client(); 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; +} + +// 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]; @@ -36,12 +54,23 @@ export const handler = async (event) => { return isNaN(num) ? 0 : num; }; - // Upsert each payment, preserving clear_status/bank_reference/cleared_date if they exist + const cancelStatuses = ["voided", "cancelled"]; + const newChecks = []; + const cancelChecks = []; + + // Upsert each payment, tracking new and cancelled checks let count = 0; for (const row of normalizedRows) { const checkNumber = (row["Check Number"] || "").trim(); + const method = (row["Method"] || "").trim(); + const status = (row["Status"] || "").trim(); const pk = `payment#${checkNumber}`; + // Check if record already exists (for detecting new vs updated) + const { Item: existing } = await ddb.send( + new GetCommand({ TableName: TABLE_NAME, Key: { pk } }) + ); + await ddb.send( new UpdateCommand({ TableName: TABLE_NAME, @@ -61,18 +90,92 @@ export const handler = async (event) => { "#status": "status", }, ExpressionAttributeValues: { - ":method": (row["Method"] || "").trim(), + ":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": parseAmount(row["Amount in USD"]), - ":status": (row["Status"] || "").trim(), + ":status": status, ":company_subsidiary": (row["Company/Subsidiary"] || "").trim(), }, }) ); count++; + + // Only process checks for CashPro + if (method !== "Check" || !checkNumber) continue; + + if (!existing) { + // New check → issue + newChecks.push({ + checkNumber, + amount: parseAmount(row["Amount in USD"]).toFixed(2), + issueDate: toISODate(row["Send Payment On"] || ""), + }); + } else if ( + cancelStatuses.includes(status.toLowerCase()) && + !cancelStatuses.includes((existing.status || "").toLowerCase()) + ) { + // Existing check now voided/cancelled → cancel + cancelChecks.push({ + checkNumber, + amount: parseAmount(row["Amount in USD"]).toFixed(2), + issueDate: toISODate(row["Send Payment On"] || ""), + }); + } + } + + // Submit to CashPro if there are any new issues or cancels + if (newChecks.length || cancelChecks.length) { + const [apiToken, accountNumber, companyId] = await Promise.all([ + getSSMParam(process.env.BOA_API_TOKEN_PARAM), + getSSMParam(process.env.BOA_ACCOUNT_NUMBER_PARAM), + getSSMParam(process.env.BOA_COMPANY_ID_PARAM), + ]); + + const submitToBoA = async (items, action) => { + const issueList = items.map((item) => ({ + accountNumber, + checkNumber: item.checkNumber, + amount: item.amount, + issueAction: action, + issueDate: item.issueDate, + })); + + const res = await fetch( + `${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}`, + companyId, + }, + body: JSON.stringify({ issueList }), + } + ); + + const data = await res.json(); + + if (!res.ok) { + console.error(`BoA ${action} error ${res.status}:`, JSON.stringify(data)); + throw new Error(`BoA ${action} failed: ${res.status}`); + } + + console.log( + `BoA ${action}: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed` + ); + return data; + }; + + if (newChecks.length) { + await submitToBoA(newChecks, "add_issue"); + } + + if (cancelChecks.length) { + await submitToBoA(cancelChecks, "cancel_issue"); + } } // Update metadata record @@ -88,6 +191,11 @@ export const handler = async (event) => { }) ); - console.log(`Upserted ${count} payments from ${key}`); - return { statusCode: 200, body: `Upserted ${count} payments` }; + console.log( + `Upserted ${count} payments, ${newChecks.length} issued, ${cancelChecks.length} cancelled` + ); + return { + statusCode: 200, + body: `Upserted ${count}, issued ${newChecks.length}, cancelled ${cancelChecks.length}`, + }; }; diff --git a/template.yaml b/template.yaml index c8389f9..5c7febd 100644 --- a/template.yaml +++ b/template.yaml @@ -131,6 +131,13 @@ Resources: Properties: FunctionName: payments-processPaymentCsv Handler: src/processPaymentCsv.handler + Timeout: 120 + 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 + BOA_COMPANY_ID_PARAM: /payments-dashboard/boa-company-id VpcConfig: SubnetIds: - !Ref PrivateSubnet @@ -152,6 +159,12 @@ Resources: BucketName: !Sub seahaven-payments-csv-${AWS::AccountId} - DynamoDBCrudPolicy: TableName: !Ref DashboardTable + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-api-token + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-account-number + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-company-id - Version: "2012-10-17" Statement: - Effect: Allow @@ -210,6 +223,7 @@ Resources: 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 + BOA_COMPANY_ID_PARAM: /payments-dashboard/boa-company-id Events: DailySchedule: Type: Schedule @@ -224,6 +238,8 @@ Resources: ParameterName: payments-dashboard/boa-api-token - SSMParameterReadPolicy: ParameterName: payments-dashboard/boa-account-number + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-company-id - Version: "2012-10-17" Statement: - Effect: Allow From 3b8c2014449c519d357e80b55f58e35ec6b12cb2 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:31:27 -0400 Subject: [PATCH 04/11] Add ACH auto-clear and expanded cancel status list ACH payments auto-marked as Cleared when send date has passed. Cancel statuses expanded to include "canceled" and "marked as void". --- src/processPaymentCsv.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index cc67469..8d1b9ba 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -54,7 +54,7 @@ export const handler = async (event) => { return isNaN(num) ? 0 : num; }; - const cancelStatuses = ["voided", "cancelled"]; + const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"]; const newChecks = []; const cancelChecks = []; @@ -63,7 +63,18 @@ export const handler = async (event) => { for (const row of normalizedRows) { const checkNumber = (row["Check Number"] || "").trim(); const method = (row["Method"] || "").trim(); - const status = (row["Status"] || "").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}`; // Check if record already exists (for detecting new vs updated) From 7d1686bf9495e63a744dec08e658326bb02307b6 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:35:47 -0400 Subject: [PATCH 05/11] Update dashboard: split scheduled by method, outstanding checks with 90-day buckets, remove cleared section --- src/slackAppHome.js | 96 +++++++++++++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 25 deletions(-) diff --git a/src/slackAppHome.js b/src/slackAppHome.js index 44959b6..10cf945 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -104,11 +104,13 @@ function buildHomeView(payments, metadata) { today.setHours(0, 0, 0, 0); // Categorize payments - const scheduled = []; // future payments not yet due - const outstanding = []; // past-due checks not yet cleared, or returned by BoA - const cleared = []; // confirmed cleared by BoA or ACH past due date + const scheduledChecks = []; + const scheduledACH = []; + const outstandingChecks = []; - const skipStatuses = ["voided", "cancelled"]; + 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; @@ -116,21 +118,16 @@ function buildHomeView(payments, metadata) { const dt = parseMDYLocal(p.send_payment_on); if (!dt) continue; - // Skip voided/cancelled payments entirely - if (skipStatuses.includes((p.status || "").toLowerCase())) continue; + const status = (p.status || "").toLowerCase(); + if (skipStatuses.includes(status)) continue; - if (p.clear_status === "Returned") { - outstanding.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); + 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); } } @@ -140,11 +137,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 = [ @@ -197,6 +197,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: [ @@ -212,9 +258,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 fc37c6e8f94ddcd0f7959cf12c7fb47ca7cb00cd Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:35:57 -0400 Subject: [PATCH 06/11] Add seed scripts from master (seed-from-csv, seed-bank-status) --- scripts/seed-bank-status.js | 141 +++++++++++++++++++++++++++++++++ scripts/seed-from-csv.js | 150 ++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) 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); +}); From a9ad5f7dd1d80ccd7e03a3d30d60752ad1c7dc65 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:34:01 -0400 Subject: [PATCH 07/11] Add BoA CashPro sandbox test script and update seed script - Add standalone test script to validate sandbox API connectivity for check management and account info endpoints - Update seed-bank-status to persist amount, issueDate, and method fields - Add data/ to gitignore --- .gitignore | 1 + scripts/seed-bank-status.js | 43 ++++++++++-- scripts/test-boa-sandbox.js | 126 ++++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 scripts/test-boa-sandbox.js diff --git a/.gitignore b/.gitignore index efc9465..24a0e44 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ .aws-sam/ samconfig.toml +data/ diff --git a/scripts/seed-bank-status.js b/scripts/seed-bank-status.js index 5ad1045..2e7449d 100644 --- a/scripts/seed-bank-status.js +++ b/scripts/seed-bank-status.js @@ -52,7 +52,7 @@ function toISODate(dateStr) { return null; } -async function updateStatus(checkNumber, status, paidDate) { +async function updateStatus(checkNumber, status, { paidDate, amount, issueDate, method } = {}) { const pk = `payment#${checkNumber}`; const expr = ["#status = :s"]; const names = { "#status": "status" }; @@ -62,6 +62,23 @@ async function updateStatus(checkNumber, status, paidDate) { expr.push("paid_date = :pd"); values[":pd"] = paidDate; } + if (amount != null) { + expr.push("amount_usd = :amt"); + values[":amt"] = amount; + } + if (issueDate) { + expr.push("send_payment_on = :sd"); + values[":sd"] = issueDate; + } + if (checkNumber) { + expr.push("check_number = :cn"); + values[":cn"] = checkNumber; + } + if (method) { + expr.push("#method = :m"); + names["#method"] = "method"; + values[":m"] = method; + } await ddb.send( new UpdateCommand({ @@ -87,7 +104,9 @@ async function main() { for (const row of rows) { const checkNumber = row[0]?.trim(); if (!checkNumber) continue; - statusMap.set(checkNumber, { status: "Issued", paidDate: null }); + const amount = parseFloat(String(row[4] || "0").replace(/,/g, "")); + const issueDate = row[1]?.trim() || null; + statusMap.set(checkNumber, { status: "Issued", paidDate: null, amount: isNaN(amount) ? 0 : amount, issueDate, method: "Check" }); } console.log(` ${rows.length} positive pay entries`); } @@ -99,7 +118,18 @@ async function main() { for (const row of rows) { const checkNumber = (row["Check Number"] || "").trim(); if (!checkNumber) continue; - statusMap.set(checkNumber, { status: "Outstanding", paidDate: null }); + const amount = parseFloat(String(row["Amount"] || "0").replace(/,/g, "")); + const rawDate = (row["Issue Date"] || "").trim(); + // Normalize 2-digit year to 4-digit: "12/10/24" → "12/10/2024" + let issueDate = rawDate || null; + if (rawDate) { + const parts = rawDate.split("/"); + if (parts.length === 3 && parts[2].length === 2) { + parts[2] = `20${parts[2]}`; + issueDate = parts.join("/"); + } + } + statusMap.set(checkNumber, { status: "Outstanding", paidDate: null, amount: isNaN(amount) ? 0 : amount, issueDate, method: "Check" }); } console.log(` ${rows.length} outstanding entries`); } @@ -120,15 +150,16 @@ async function main() { } seen.add(checkNumber); const paidDate = toISODate(row["Paid Date"] || row["CD Volume Number"] || ""); - statusMap.set(checkNumber, { status: "Cleared", paidDate }); + const amount = parseFloat(String(row["Amount"] || "0").replace(/,/g, "")); + statusMap.set(checkNumber, { status: "Cleared", paidDate, amount: isNaN(amount) ? 0 : amount }); } 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); + for (const [checkNumber, { status, paidDate, amount, issueDate, method }] of statusMap) { + await updateStatus(checkNumber, status, { paidDate, amount, issueDate, method }); count++; } diff --git a/scripts/test-boa-sandbox.js b/scripts/test-boa-sandbox.js new file mode 100644 index 0000000..fbc3cb6 --- /dev/null +++ b/scripts/test-boa-sandbox.js @@ -0,0 +1,126 @@ +/** + * One-off script to test BoA CashPro sandbox API connectivity. + * Reads credentials from SSM Parameter Store (same params as production), + * makes test API calls, and prints the full responses so you can capture + * the client ID, timestamp, and transactionIdentification for BoA onboarding. + * + * Usage: + * node scripts/test-boa-sandbox.js + */ + +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; + +const ssm = new SSMClient(); +const BOA_BASE_URL = "https://developer.bankofamerica.com"; + +async function getSSMParam(name) { + const { Parameter } = await ssm.send( + new GetParameterCommand({ Name: name, WithDecryption: true }) + ); + return Parameter.Value; +} + +async function main() { + console.log("Loading credentials from SSM...\n"); + + const [checkMgmtToken, accountInfoToken, accountNumber, companyId] = await Promise.all([ + getSSMParam("/payments-dashboard/boa-check-mgmt-token"), + getSSMParam("/payments-dashboard/boa-account-info-token"), + getSSMParam("/payments-dashboard/boa-account-number"), + getSSMParam("/payments-dashboard/boa-company-id"), + ]); + + console.log("Credentials loaded. Testing sandbox endpoints...\n"); + + // --- Test 1: Check Issue (add_issue with a test check) --- + console.log("=".repeat(60)); + console.log("TEST 1: Check Issue (add_issue)"); + console.log("=".repeat(60)); + + const issuePayload = { + issueList: [ + { + accountNumber, + checkNumber: "999999", + amount: "1.00", + issueAction: "add_issue", + issueDate: new Date().toISOString().split("T")[0], + }, + ], + }; + + console.log("Request:", JSON.stringify(issuePayload, null, 2), "\n"); + + try { + const issueRes = await fetch( + `${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${checkMgmtToken}`, + companyId, + }, + body: JSON.stringify(issuePayload), + } + ); + + const issueHeaders = Object.fromEntries(issueRes.headers.entries()); + const issueText = await issueRes.text(); + + console.log("Status:", issueRes.status); + console.log("Response Headers:", JSON.stringify(issueHeaders, null, 2)); + console.log("Response Body:", issueText); + } catch (err) { + console.error("Check Issue request failed:", err.message); + } + + // --- Test 2: Previous Day Transaction Inquiry --- + console.log("\n" + "=".repeat(60)); + console.log("TEST 2: Previous Day Transaction Inquiry"); + console.log("=".repeat(60)); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const dateStr = yesterday.toISOString().split("T")[0]; + + const inquiryPayload = { + accounts: [{ accountNumber, bankId: "BOFAFRPP" }], + fromDate: dateStr, + toDate: dateStr, + }; + + console.log("Request:", JSON.stringify(inquiryPayload, null, 2), "\n"); + + try { + const inquiryRes = await fetch( + `${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accountInfoToken}`, + }, + body: JSON.stringify(inquiryPayload), + } + ); + + const inquiryHeaders = Object.fromEntries(inquiryRes.headers.entries()); + const inquiryText = await inquiryRes.text(); + + console.log("Status:", inquiryRes.status); + console.log("Response Headers:", JSON.stringify(inquiryHeaders, null, 2)); + console.log("Response Body:", inquiryText); + } catch (err) { + console.error("Transaction Inquiry request failed:", err.message); + } + + console.log("\n" + "=".repeat(60)); + console.log("Done. Look for: client ID, timestamp, transactionIdentification"); + console.log("=".repeat(60)); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); From 957fb726ab92dad86bd827d620f248eac313b41d Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:08:57 -0400 Subject: [PATCH 08/11] Add OAuth token exchange to BoA sandbox test script Use correct sandbox base URL (api-sb.bofa.com), proper OAuth client-credentials flow with applicationID, and routing number for transaction inquiries. Both APIs now return 200 in sandbox. --- scripts/test-boa-sandbox.js | 96 +++++++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/scripts/test-boa-sandbox.js b/scripts/test-boa-sandbox.js index fbc3cb6..3e93e30 100644 --- a/scripts/test-boa-sandbox.js +++ b/scripts/test-boa-sandbox.js @@ -1,8 +1,11 @@ /** * One-off script to test BoA CashPro sandbox API connectivity. - * Reads credentials from SSM Parameter Store (same params as production), - * makes test API calls, and prints the full responses so you can capture - * the client ID, timestamp, and transactionIdentification for BoA onboarding. + * Reads credentials from SSM Parameter Store, exchanges them for + * OAuth Bearer tokens, then makes test API calls to both Check + * Management and Reporting APIs. + * + * Prints full responses so you can capture the client ID, timestamp, + * and transactionIdentification for BoA production onboarding. * * Usage: * node scripts/test-boa-sandbox.js @@ -11,7 +14,8 @@ import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; const ssm = new SSMClient(); -const BOA_BASE_URL = "https://developer.bankofamerica.com"; +const SANDBOX_BASE = "https://api-sb.bofa.com"; +const AUTH_URL = `${SANDBOX_BASE}/authn/v1/client-authentication`; async function getSSMParam(name) { const { Parameter } = await ssm.send( @@ -20,31 +24,88 @@ async function getSSMParam(name) { return Parameter.Value; } +async function getAccessToken(applicationID, clientId, clientSecret) { + console.log(` Requesting token for ${applicationID}...`); + + const res = await fetch(AUTH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + applicationID, + authn: { + client_id: clientId, + client_secret: clientSecret, + }, + }), + }); + + const text = await res.text(); + console.log(` Auth response (${res.status}):`, text, "\n"); + + if (!res.ok) { + throw new Error(`Auth failed for ${applicationID}: ${res.status} - ${text}`); + } + + const data = JSON.parse(text); + return data.access_token; +} + async function main() { console.log("Loading credentials from SSM...\n"); - const [checkMgmtToken, accountInfoToken, accountNumber, companyId] = await Promise.all([ + const [ + checkMgmtClientId, + checkMgmtSecret, + accountInfoClientId, + accountInfoSecret, + accountNumber, + companyId, + ] = await Promise.all([ + getSSMParam("/payments-dashboard/boa-check-mgmt-client-id"), getSSMParam("/payments-dashboard/boa-check-mgmt-token"), + getSSMParam("/payments-dashboard/boa-account-info-client-id"), getSSMParam("/payments-dashboard/boa-account-info-token"), getSSMParam("/payments-dashboard/boa-account-number"), getSSMParam("/payments-dashboard/boa-company-id"), ]); - console.log("Credentials loaded. Testing sandbox endpoints...\n"); + console.log("Credentials loaded.\n"); - // --- Test 1: Check Issue (add_issue with a test check) --- + // --- Step 1: Get OAuth tokens for both APIs --- console.log("=".repeat(60)); - console.log("TEST 1: Check Issue (add_issue)"); + console.log("STEP 1: OAuth Token Exchange"); + console.log("=".repeat(60)); + + console.log("\n[Check Management]"); + const checkMgmtBearerToken = await getAccessToken( + "app_SeaHavenIndustries_Checkmanagement_SB", + checkMgmtClientId, + checkMgmtSecret + ); + + console.log("[Account Info / Reporting]"); + const accountInfoBearerToken = await getAccessToken( + "app_SeaHavenIndustries_Reporting_SB", + accountInfoClientId, + accountInfoSecret + ); + + console.log("Both tokens acquired.\n"); + + // --- Step 2: Check Issue (add_Issue with a test check) --- + console.log("=".repeat(60)); + console.log("TEST 1: Check Issue (add_Issue)"); console.log("=".repeat(60)); const issuePayload = { issueList: [ { accountNumber, + issueAction: "add_Issue", checkNumber: "999999", amount: "1.00", - issueAction: "add_issue", issueDate: new Date().toISOString().split("T")[0], + payee: "Sandbox Test", }, ], }; @@ -53,12 +114,12 @@ async function main() { try { const issueRes = await fetch( - `${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, + `${SANDBOX_BASE}/cashpro/checkmanagement/v1/check-issues`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${checkMgmtToken}`, + Authorization: `Bearer ${checkMgmtBearerToken}`, companyId, }, body: JSON.stringify(issuePayload), @@ -75,7 +136,7 @@ async function main() { console.error("Check Issue request failed:", err.message); } - // --- Test 2: Previous Day Transaction Inquiry --- + // --- Step 3: Previous Day Transaction Inquiry --- console.log("\n" + "=".repeat(60)); console.log("TEST 2: Previous Day Transaction Inquiry"); console.log("=".repeat(60)); @@ -85,21 +146,26 @@ async function main() { const dateStr = yesterday.toISOString().split("T")[0]; const inquiryPayload = { - accounts: [{ accountNumber, bankId: "BOFAFRPP" }], fromDate: dateStr, toDate: dateStr, + accounts: [ + { + accountNumber, + bankId: "021000322", + }, + ], }; console.log("Request:", JSON.stringify(inquiryPayload, null, 2), "\n"); try { const inquiryRes = await fetch( - `${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`, + `${SANDBOX_BASE}/cashpro/reporting/v1/transaction-inquiries/previous-day`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${accountInfoToken}`, + Authorization: `Bearer ${accountInfoBearerToken}`, }, body: JSON.stringify(inquiryPayload), } From c445fa947a2799d47d3340b8dd5703556c0daaa5 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:24:20 -0400 Subject: [PATCH 09/11] Update integration code with correct BoA CashPro API specs and add README - Add OAuth client-credentials token exchange to both Lambda handlers - Fix sandbox/prod base URL (api-sb.bofa.com / api.bofa.com) - Fix issueAction casing to add_Issue / cancel_Issue per API docs - Fix transaction inquiry response parsing (accountTransactions array) - Move all BoA config (app IDs, bank ID) from env vars to SSM params - Update template.yaml with correct SSM param names and policies - Add project README with architecture, API details, and SSM param reference --- README.md | 63 +++++++++++++++++++++++++++++++++++++ src/fetchBoaTransactions.js | 46 ++++++++++++++++++++------- src/processPaymentCsv.js | 35 +++++++++++++++++---- template.yaml | 28 ++++++++++++----- 4 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..99dccd6 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Payments Dashboard + +AWS SAM application that ingests payment CSVs, syncs check data with Bank of America CashPro APIs, and surfaces an outstanding-payments dashboard in Slack. + +## Architecture + +- **ProcessPaymentCsv** - Lambda triggered by S3 CSV upload. Parses payments, upserts to DynamoDB, and submits new/cancelled checks to the CashPro Check Management API. +- **FetchBoaTransactions** - Scheduled Lambda (weekdays 9am ET). Calls the CashPro Previous Day Transaction Inquiry API and matches cleared/returned checks back to DynamoDB records. +- **SlackAppHome** - Lambda behind API Gateway. Renders the payments dashboard on the Slack App Home tab with outstanding aging buckets and drill-down modals. + +All three Lambdas run inside a VPC with a NAT Gateway for a static outbound IP (required by BoA IP whitelisting). + +## BoA CashPro API Integration + +Two separate CashPro APIs are used, each with its own OAuth credentials: + +| API | Purpose | Endpoint | +|-----|---------|----------| +| Check Management | Issue and cancel checks | `/cashpro/checkmanagement/v1/check-issues` | +| Reporting (Transaction Inquiry) | Fetch previous-day transactions | `/cashpro/reporting/v1/transaction-inquiries/previous-day` | + +**Authentication flow:** +1. POST to `/authn/v1/client-authentication` with `applicationID`, `client_id`, and `client_secret` +2. Receive a Bearer `access_token` (valid 1 hour) +3. Pass the token in the `Authorization` header for subsequent API calls + +**Base URLs:** +- Production: `https://api.bofa.com` +- Sandbox: `https://api-sb.bofa.com` + +## SSM Parameters + +All BoA credentials and config are stored in AWS SSM Parameter Store (SecureString): + +| Parameter | Description | +|-----------|-------------| +| `/payments-dashboard/boa-check-mgmt-app-id` | Check Management application ID | +| `/payments-dashboard/boa-check-mgmt-client-id` | Check Management client ID | +| `/payments-dashboard/boa-check-mgmt-token` | Check Management client secret | +| `/payments-dashboard/boa-reporting-app-id` | Reporting application ID | +| `/payments-dashboard/boa-account-info-client-id` | Reporting client ID | +| `/payments-dashboard/boa-account-info-token` | Reporting client secret | +| `/payments-dashboard/boa-account-number` | BoA account number | +| `/payments-dashboard/boa-company-id` | CashPro company ID (check management) | +| `/payments-dashboard/boa-bank-id` | BoA routing number | +| `/payments-dashboard/slack-bot-token` | Slack Bot OAuth token | + +## Scripts + +| Script | Purpose | +|--------|---------| +| `scripts/test-boa-sandbox.js` | One-off sandbox connectivity test for both CashPro APIs | +| `scripts/seed-from-csv.js` | Seed DynamoDB from a local CSV file | +| `scripts/seed-bank-status.js` | Seed bank clear status data into DynamoDB | + +## Deployment + +```bash +sam build +sam deploy --guided +``` + +The `BOA_BASE_URL` environment variable in `template.yaml` controls whether Lambdas hit production (`https://api.bofa.com`) or sandbox (`https://api-sb.bofa.com`). All other BoA config is read from SSM at runtime. diff --git a/src/fetchBoaTransactions.js b/src/fetchBoaTransactions.js index 489f73d..08004f2 100644 --- a/src/fetchBoaTransactions.js +++ b/src/fetchBoaTransactions.js @@ -14,12 +14,36 @@ async function getSSMParam(name) { return Parameter.Value; } +async function getAccessToken(applicationID, clientId, clientSecret) { + const res = await fetch(`${BOA_BASE_URL}/authn/v1/client-authentication`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + applicationID, + authn: { client_id: clientId, client_secret: clientSecret }, + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`OAuth token exchange failed: ${res.status} - ${text}`); + } + + const data = await res.json(); + return data.access_token; +} + export const handler = async () => { - const [apiToken, accountNumber] = await Promise.all([ - getSSMParam(process.env.BOA_API_TOKEN_PARAM), + const [appId, clientId, clientSecret, accountNumber, bankId] = await Promise.all([ + getSSMParam(process.env.BOA_REPORTING_APP_ID_PARAM), + getSSMParam(process.env.BOA_REPORTING_CLIENT_ID_PARAM), + getSSMParam(process.env.BOA_REPORTING_SECRET_PARAM), getSSMParam(process.env.BOA_ACCOUNT_NUMBER_PARAM), + getSSMParam(process.env.BOA_BANK_ID_PARAM), ]); + const bearerToken = await getAccessToken(appId, clientId, clientSecret); + // Get yesterday's date in YYYY-MM-DD const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); @@ -30,12 +54,12 @@ export const handler = async () => { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${apiToken}`, + Authorization: `Bearer ${bearerToken}`, }, body: JSON.stringify({ - accounts: [{ accountNumber, bankId: "BOFAFRPP" }], fromDate: dateStr, toDate: dateStr, + accounts: [{ accountNumber, bankId }], }), }); @@ -45,10 +69,14 @@ export const handler = async () => { } const data = await res.json(); - const transactions = data.accountTransactions?.transactions || []; + + // Response shape: { accountTransactions: [{ accountNumber, bankId, currency, transactions: [...] }] } + const allTransactions = (data.accountTransactions || []).flatMap( + (acct) => acct.transactions || [] + ); // Filter for cleared checks (255) and returned checks (475) - const relevant = transactions.filter( + const relevant = allTransactions.filter( (t) => t.transactionCode === "255" || t.transactionCode === "475" ); @@ -74,12 +102,6 @@ export const handler = async () => { 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) { diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index 8d1b9ba..7f5f2c6 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -17,6 +17,25 @@ async function getSSMParam(name) { return Parameter.Value; } +async function getAccessToken(applicationID, clientId, clientSecret) { + const res = await fetch(`${BOA_BASE_URL}/authn/v1/client-authentication`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + applicationID, + authn: { client_id: clientId, client_secret: clientSecret }, + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`OAuth token exchange failed: ${res.status} - ${text}`); + } + + const data = await res.json(); + return data.access_token; +} + // Convert MM/DD/YYYY to YYYY-MM-DD function toISODate(mdyDate) { const parts = String(mdyDate).split("/"); @@ -139,18 +158,22 @@ export const handler = async (event) => { // Submit to CashPro if there are any new issues or cancels if (newChecks.length || cancelChecks.length) { - const [apiToken, accountNumber, companyId] = await Promise.all([ - getSSMParam(process.env.BOA_API_TOKEN_PARAM), + const [appId, clientId, clientSecret, accountNumber, companyId] = await Promise.all([ + getSSMParam(process.env.BOA_CHECK_MGMT_APP_ID_PARAM), + getSSMParam(process.env.BOA_CHECK_MGMT_CLIENT_ID_PARAM), + getSSMParam(process.env.BOA_CHECK_MGMT_SECRET_PARAM), getSSMParam(process.env.BOA_ACCOUNT_NUMBER_PARAM), getSSMParam(process.env.BOA_COMPANY_ID_PARAM), ]); + const bearerToken = await getAccessToken(appId, clientId, clientSecret); + const submitToBoA = async (items, action) => { const issueList = items.map((item) => ({ accountNumber, + issueAction: action, checkNumber: item.checkNumber, amount: item.amount, - issueAction: action, issueDate: item.issueDate, })); @@ -160,7 +183,7 @@ export const handler = async (event) => { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${apiToken}`, + Authorization: `Bearer ${bearerToken}`, companyId, }, body: JSON.stringify({ issueList }), @@ -181,11 +204,11 @@ export const handler = async (event) => { }; if (newChecks.length) { - await submitToBoA(newChecks, "add_issue"); + await submitToBoA(newChecks, "add_Issue"); } if (cancelChecks.length) { - await submitToBoA(cancelChecks, "cancel_issue"); + await submitToBoA(cancelChecks, "cancel_Issue"); } } diff --git a/template.yaml b/template.yaml index 5c7febd..f9b9111 100644 --- a/template.yaml +++ b/template.yaml @@ -134,8 +134,10 @@ Resources: Timeout: 120 Environment: Variables: - BOA_BASE_URL: https://sandbox.cashpro.bankofamerica.com - BOA_API_TOKEN_PARAM: /payments-dashboard/boa-api-token + BOA_BASE_URL: https://api.bofa.com + BOA_CHECK_MGMT_APP_ID_PARAM: /payments-dashboard/boa-check-mgmt-app-id + BOA_CHECK_MGMT_CLIENT_ID_PARAM: /payments-dashboard/boa-check-mgmt-client-id + BOA_CHECK_MGMT_SECRET_PARAM: /payments-dashboard/boa-check-mgmt-token BOA_ACCOUNT_NUMBER_PARAM: /payments-dashboard/boa-account-number BOA_COMPANY_ID_PARAM: /payments-dashboard/boa-company-id VpcConfig: @@ -160,7 +162,11 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref DashboardTable - SSMParameterReadPolicy: - ParameterName: payments-dashboard/boa-api-token + ParameterName: payments-dashboard/boa-check-mgmt-app-id + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-check-mgmt-client-id + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-check-mgmt-token - SSMParameterReadPolicy: ParameterName: payments-dashboard/boa-account-number - SSMParameterReadPolicy: @@ -220,10 +226,12 @@ Resources: - !Ref LambdaSecurityGroup Environment: Variables: - BOA_BASE_URL: https://sandbox.cashpro.bankofamerica.com - BOA_API_TOKEN_PARAM: /payments-dashboard/boa-api-token + BOA_BASE_URL: https://api.bofa.com + BOA_REPORTING_APP_ID_PARAM: /payments-dashboard/boa-reporting-app-id + BOA_REPORTING_CLIENT_ID_PARAM: /payments-dashboard/boa-account-info-client-id + BOA_REPORTING_SECRET_PARAM: /payments-dashboard/boa-account-info-token BOA_ACCOUNT_NUMBER_PARAM: /payments-dashboard/boa-account-number - BOA_COMPANY_ID_PARAM: /payments-dashboard/boa-company-id + BOA_BANK_ID_PARAM: /payments-dashboard/boa-bank-id Events: DailySchedule: Type: Schedule @@ -235,11 +243,15 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref DashboardTable - SSMParameterReadPolicy: - ParameterName: payments-dashboard/boa-api-token + ParameterName: payments-dashboard/boa-reporting-app-id + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-account-info-client-id + - SSMParameterReadPolicy: + ParameterName: payments-dashboard/boa-account-info-token - SSMParameterReadPolicy: ParameterName: payments-dashboard/boa-account-number - SSMParameterReadPolicy: - ParameterName: payments-dashboard/boa-company-id + ParameterName: payments-dashboard/boa-bank-id - Version: "2012-10-17" Statement: - Effect: Allow From ffd46c4bda7e28e02685a515478bf82c6d512b79 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:43:13 -0400 Subject: [PATCH 10/11] Fix BoA transaction matching, add status progression protection, enable daily schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix transaction code mapping (475=Cleared, 255=Returned) in fetchBoaTransactions - Match on customerReference instead of bankReference for check number matching - Add bank-confirmed protection: CSV cannot override status once bank confirms Cleared - Add status progression guard: CSV cannot regress status backward in lifecycle - Cleared status is permanent — cannot be voided, cancelled, or changed - Enable daily fetchBoaTransactions schedule (9am ET weekdays) - Add production test script and dry run simulation script --- scripts/dryrun.cjs | 204 ++++++++++++++++++++++++++++++++++++ scripts/test-boa-prod.js | 130 +++++++++++++++++++++++ src/fetchBoaTransactions.js | 16 +-- src/processPaymentCsv.js | 31 ++++++ template.yaml | 2 +- 5 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 scripts/dryrun.cjs create mode 100644 scripts/test-boa-prod.js diff --git a/scripts/dryrun.cjs b/scripts/dryrun.cjs new file mode 100644 index 0000000..173b751 --- /dev/null +++ b/scripts/dryrun.cjs @@ -0,0 +1,204 @@ +const { DynamoDBClient } = require("@aws-sdk/client-dynamodb"); +const { DynamoDBDocumentClient, ScanCommand } = require("@aws-sdk/lib-dynamodb"); +const { parse } = require("csv-parse/sync"); +const fs = require("fs"); + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" })); + +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 cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"]; + +(async () => { + // Load all existing payments from DB + const dbPayments = {}; + let lastKey; + do { + const result = await ddb.send( + new ScanCommand({ + TableName: "PaymentsDashboard", + FilterExpression: "begins_with(pk, :prefix)", + ExpressionAttributeValues: { ":prefix": "payment#" }, + ExclusiveStartKey: lastKey, + }) + ); + for (const item of result.Items) { + dbPayments[item.pk] = item; + } + lastKey = result.LastEvaluatedKey; + } while (lastKey); + + // Read CSV from stdin + const csvText = fs.readFileSync(0, "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; + }); + + const parseAmount = (value) => { + const num = parseFloat(String(value || "0").replace(/,/g, "").trim()); + return isNaN(num) ? 0 : num; + }; + + const statusRank = { + "scheduled": 1, + "payment submitted": 2, + "issued": 3, + "outstanding": 4, + "cleared": 5, + }; + + const newRecords = []; + const statusChanges = []; + const bankProtected = []; + const statusProtected = []; + const newCheckIssues = []; + const cancelCheckIssues = []; + let noChangeCount = 0; + const today = new Date().toISOString().slice(0, 10); + + 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(); + const payee = (row["Payee"] || "").trim(); + const amount = parseAmount(row["Amount in USD"]); + + // ACH auto-clear logic + if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) { + const sendDate = toISODate(sendOn); + if (sendDate && sendDate <= today) { + status = "Cleared"; + } + } + + const pk = "payment#" + checkNumber; + const existing = dbPayments[pk]; + + // Bank-confirmed protection + const bankConfirmed = existing?.clear_status === "Cleared"; + let originalCsvStatus = status; + if (bankConfirmed) { + status = "Cleared"; + } + + // Status progression protection + if (existing) { + const oldRank = statusRank[(existing.status || "").toLowerCase()] || 0; + const newRank = statusRank[status.toLowerCase()] || 0; + + if (oldRank === 5) { + // Cleared is permanent — cannot be voided, cancelled, or anything else + if (status !== existing.status) { + statusProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: existing.status, reason: "Cleared is permanent" }); + } + status = existing.status; + } else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) { + // Non-cancel status regression — keep the existing (higher) status + statusProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: existing.status, reason: "would regress status" }); + status = existing.status; + } + } + + if (!existing) { + newRecords.push({ checkNumber, payee, method, status, amount, sendOn }); + if (method === "Check") { + newCheckIssues.push({ checkNumber, payee, amount: amount.toFixed(2), issueDate: toISODate(sendOn) }); + } + } else { + const oldStatus = existing.status || ""; + + if (bankConfirmed && originalCsvStatus !== "Cleared") { + bankProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: oldStatus }); + } + + if (oldStatus !== status) { + statusChanges.push({ checkNumber, payee, method, oldStatus, newStatus: status, amount }); + } else { + noChangeCount++; + } + + // Check for new cancel (only if not bank-confirmed and not cleared) + if (method === "Check" && !bankConfirmed && (statusRank[(oldStatus).toLowerCase()] || 0) < 5 && cancelStatuses.includes(status.toLowerCase()) && !cancelStatuses.includes(oldStatus.toLowerCase())) { + cancelCheckIssues.push({ checkNumber, payee, amount: amount.toFixed(2), issueDate: toISODate(sendOn) }); + } + } + } + + console.log("=== DRY RUN SUMMARY ==="); + console.log("CSV rows: " + normalizedRows.length); + console.log("Existing DB records: " + Object.keys(dbPayments).length); + console.log(""); + + if (newRecords.length) { + console.log("--- NEW RECORDS (" + newRecords.length + ") ---"); + for (const r of newRecords) { + console.log(" + " + r.checkNumber + " | " + r.payee + " | " + r.method + " | " + r.status + " | $" + r.amount); + } + console.log(""); + } + + if (statusChanges.length) { + console.log("--- STATUS CHANGES (" + statusChanges.length + ") ---"); + for (const r of statusChanges) { + console.log(" ~ " + r.checkNumber + " | " + r.payee + " | " + r.method + " | \"" + r.oldStatus + "\" -> \"" + r.newStatus + "\" | $" + r.amount); + } + console.log(""); + } + + if (statusProtected.length) { + console.log("--- STATUS PROGRESSION PROTECTED (" + statusProtected.length + ") ---"); + console.log(" (CSV tried to regress status — blocked by progression guard)"); + for (const r of statusProtected) { + console.log(" # " + r.checkNumber + " | " + r.payee + " | CSV: \"" + r.csvStatus + "\" | Kept: \"" + r.dbStatus + "\" | " + r.reason); + } + console.log(""); + } + + if (bankProtected.length) { + console.log("--- BANK-CONFIRMED PROTECTED (" + bankProtected.length + ") ---"); + console.log(" (CSV tried to change status but bank already confirmed Cleared)"); + for (const r of bankProtected) { + console.log(" ! " + r.checkNumber + " | " + r.payee + " | CSV: \"" + r.csvStatus + "\" | Kept: Cleared"); + } + console.log(""); + } + + if (newCheckIssues.length) { + console.log("--- BOA: CHECK ISSUES / add_Issue (" + newCheckIssues.length + ") ---"); + for (const r of newCheckIssues) { + console.log(" >> " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate); + } + console.log(""); + } + + if (cancelCheckIssues.length) { + console.log("--- BOA: CHECK CANCELS / cancel_Issue (" + cancelCheckIssues.length + ") ---"); + for (const r of cancelCheckIssues) { + console.log(" XX " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate); + } + console.log(""); + } + + console.log("=== TOTALS ==="); + console.log("New DB records: " + newRecords.length); + console.log("Status updates: " + statusChanges.length); + console.log("Status-protected: " + statusProtected.length); + console.log("Bank-protected: " + bankProtected.length); + console.log("BoA add_Issue: " + newCheckIssues.length); + console.log("BoA cancel_Issue: " + cancelCheckIssues.length); + console.log("Unchanged: " + noChangeCount); +})(); diff --git a/scripts/test-boa-prod.js b/scripts/test-boa-prod.js new file mode 100644 index 0000000..ad9606f --- /dev/null +++ b/scripts/test-boa-prod.js @@ -0,0 +1,130 @@ +/** + * Dry-run test for BoA CashPro production API connectivity. + * 1. Authenticates with both Check Management and Reporting credentials + * 2. Calls Previous Day Transaction Inquiry (read-only) + * 3. Does NOT issue or cancel any checks + * + * Usage: + * node scripts/test-boa-prod.js + */ + +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; + +const ssm = new SSMClient(); +const BASE_URL = "https://api.bofa.com"; + +async function getSSMParam(name) { + const { Parameter } = await ssm.send( + new GetParameterCommand({ Name: name, WithDecryption: true }) + ); + return Parameter.Value; +} + +async function getAccessToken(applicationID, clientId, clientSecret) { + console.log(` Requesting token for ${applicationID}...`); + + const res = await fetch(`${BASE_URL}/authn/v1/client-authentication`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + applicationID, + authn: { client_id: clientId, client_secret: clientSecret }, + }), + }); + + const text = await res.text(); + console.log(` Auth response (${res.status}):`, text, "\n"); + + if (!res.ok) { + throw new Error(`Auth failed for ${applicationID}: ${res.status} - ${text}`); + } + + const data = JSON.parse(text); + return data.access_token; +} + +async function main() { + console.log("Loading credentials from SSM...\n"); + + const [ + checkMgmtAppId, + checkMgmtClientId, + checkMgmtSecret, + reportingAppId, + reportingClientId, + reportingSecret, + accountNumber, + bankId, + ] = await Promise.all([ + getSSMParam("/payments-dashboard/boa-check-mgmt-app-id"), + getSSMParam("/payments-dashboard/boa-check-mgmt-client-id"), + getSSMParam("/payments-dashboard/boa-check-mgmt-token"), + getSSMParam("/payments-dashboard/boa-reporting-app-id"), + getSSMParam("/payments-dashboard/boa-account-info-client-id"), + getSSMParam("/payments-dashboard/boa-account-info-token"), + getSSMParam("/payments-dashboard/boa-account-number"), + getSSMParam("/payments-dashboard/boa-bank-id"), + ]); + + console.log("Credentials loaded.\n"); + + // --- Step 1: OAuth for Check Management --- + console.log("=".repeat(60)); + console.log("STEP 1: OAuth — Check Management"); + console.log("=".repeat(60)); + console.log(); + + const checkMgmtToken = await getAccessToken(checkMgmtAppId, checkMgmtClientId, checkMgmtSecret); + console.log(" ✓ Check Management token acquired\n"); + + // --- Step 2: OAuth for Reporting --- + console.log("=".repeat(60)); + console.log("STEP 2: OAuth — Reporting"); + console.log("=".repeat(60)); + console.log(); + + const reportingToken = await getAccessToken(reportingAppId, reportingClientId, reportingSecret); + console.log(" ✓ Reporting token acquired\n"); + + // --- Step 3: Previous Day Transaction Inquiry (read-only) --- + console.log("=".repeat(60)); + console.log("STEP 3: Previous Day Transaction Inquiry (read-only)"); + console.log("=".repeat(60)); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const dateStr = yesterday.toISOString().split("T")[0]; + + const inquiryPayload = { + fromDate: dateStr, + toDate: dateStr, + accounts: [{ accountNumber, bankId }], + }; + + console.log("Request:", JSON.stringify(inquiryPayload, null, 2), "\n"); + + const inquiryRes = await fetch( + `${BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${reportingToken}`, + }, + body: JSON.stringify(inquiryPayload), + } + ); + + const inquiryText = await inquiryRes.text(); + console.log("Status:", inquiryRes.status); + console.log("Response:", inquiryText); + + console.log("\n" + "=".repeat(60)); + console.log("Dry run complete. No checks were issued or cancelled."); + console.log("=".repeat(60)); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/src/fetchBoaTransactions.js b/src/fetchBoaTransactions.js index 08004f2..a891fee 100644 --- a/src/fetchBoaTransactions.js +++ b/src/fetchBoaTransactions.js @@ -75,13 +75,13 @@ export const handler = async () => { (acct) => acct.transactions || [] ); - // Filter for cleared checks (255) and returned checks (475) + // Filter for cleared checks (475) and returned checks (255) const relevant = allTransactions.filter( - (t) => t.transactionCode === "255" || t.transactionCode === "475" + (t) => t.transactionCode === "475" || t.transactionCode === "255" ); if (!relevant.length) { - console.log(`No check transactions (255/475) found for ${dateStr}`); + console.log(`No check transactions (475/255) found for ${dateStr}`); return { statusCode: 200, body: `No relevant transactions for ${dateStr}` }; } @@ -105,20 +105,20 @@ export const handler = async () => { let matched = 0; for (const txn of relevant) { + const custRef = (txn.customerReference || "").replace(/^0+/, ""); const bankRef = txn.bankReference || ""; - // BoA may append extra digits to the reference number - // Try to find a check_number that the bankReference starts with + // Match customerReference (check number with leading zeros stripped) to our check_number const matchedPayment = payments.find((p) => - p.check_number && bankRef.startsWith(p.check_number) + p.check_number && p.check_number === custRef ); if (!matchedPayment) { - console.log(`No match for bankReference: ${bankRef}`); + console.log(`No match for customerReference: ${txn.customerReference} (bankRef: ${bankRef})`); continue; } - const clearStatus = txn.transactionCode === "255" ? "Cleared" : "Returned"; + const clearStatus = txn.transactionCode === "475" ? "Cleared" : "Returned"; await ddb.send( new UpdateCommand({ diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index 5bfd0b0..51f2a87 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -74,6 +74,17 @@ export const handler = async (event) => { }; const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"]; + + // Status progression ranks — higher number = further along in lifecycle + // Once a payment reaches a higher rank, CSV cannot move it backward + const statusRank = { + "scheduled": 1, + "payment submitted": 2, + "issued": 3, + "outstanding": 4, + "cleared": 5, + }; + const newChecks = []; const cancelChecks = []; @@ -103,6 +114,26 @@ export const handler = async (event) => { new GetCommand({ TableName: TABLE_NAME, Key: { pk } }) ); + // Don't overwrite status once the bank has confirmed it as Cleared + const bankConfirmed = existing?.clear_status === "Cleared"; + if (bankConfirmed) { + status = "Cleared"; + } + + // Status progression protection — never allow status to move backward + if (existing) { + const oldRank = statusRank[(existing.status || "").toLowerCase()] || 0; + const newRank = statusRank[status.toLowerCase()] || 0; + + if (oldRank === 5) { + // Cleared is permanent — cannot be voided, cancelled, or anything else + status = existing.status; + } else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) { + // Non-cancel status regression — keep the existing (higher) status + status = existing.status; + } + } + await ddb.send( new UpdateCommand({ TableName: TABLE_NAME, diff --git a/template.yaml b/template.yaml index f9b9111..4cc4d5a 100644 --- a/template.yaml +++ b/template.yaml @@ -238,7 +238,7 @@ Resources: Properties: Schedule: cron(0 13 ? * MON-FRI *) Description: Fetch BoA previous day transactions at 9am ET (13:00 UTC) - Enabled: false + Enabled: true Policies: - DynamoDBCrudPolicy: TableName: !Ref DashboardTable From c8492807054501791c86512f5c6d0910123dbe2f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:15:41 -0400 Subject: [PATCH 11/11] Improve BoA API error logging and update .gitignore Read response as text before JSON parsing to capture non-JSON error responses from BoA API. Add .DS_Store, BofA API Resources, and CSV files to .gitignore. --- .gitignore | 3 +++ src/processPaymentCsv.js | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 24a0e44..e469f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules/ .aws-sam/ samconfig.toml data/ +.DS_Store +BofA API Resources/ +*.csv diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index 51f2a87..f8fdb41 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -223,13 +223,15 @@ export const handler = async (event) => { } ); - const data = await res.json(); + const text = await res.text(); if (!res.ok) { - console.error(`BoA ${action} error ${res.status}:`, JSON.stringify(data)); + console.error(`BoA ${action} error ${res.status}:`, text); throw new Error(`BoA ${action} failed: ${res.status}`); } + const data = JSON.parse(text); + console.log( `BoA ${action}: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed` );