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
This commit is contained in:
Adam Moussa 2026-04-09 14:59:39 -04:00
parent 7150028bd3
commit 44a6cd1b63
4 changed files with 261 additions and 81 deletions

123
src/fetchBoaTransactions.js Normal file
View file

@ -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`,
};
};

View file

@ -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` };
};

View file

@ -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),
],
};
}

View file

@ -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