mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-01 18:33:12 +00:00
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:
parent
7150028bd3
commit
44a6cd1b63
4 changed files with 261 additions and 81 deletions
123
src/fetchBoaTransactions.js
Normal file
123
src/fetchBoaTransactions.js
Normal 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`,
|
||||
};
|
||||
};
|
||||
|
|
@ -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` };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue