mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-06 22:52:08 +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 { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
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";
|
import { parse } from "csv-parse/sync";
|
||||||
|
|
||||||
const s3 = new S3Client();
|
const s3 = new S3Client();
|
||||||
|
|
@ -36,33 +36,43 @@ export const handler = async (event) => {
|
||||||
return isNaN(num) ? 0 : num;
|
return isNaN(num) ? 0 : num;
|
||||||
};
|
};
|
||||||
|
|
||||||
const payments = normalizedRows.map((row) => ({
|
// Upsert each payment, preserving clear_status/bank_reference/cleared_date if they exist
|
||||||
pk: `payment#${(row["Check Number"] || "").trim()}`,
|
let count = 0;
|
||||||
method: (row["Method"] || "").trim(),
|
for (const row of normalizedRows) {
|
||||||
payee: (row["Payee"] || "").trim(),
|
const checkNumber = (row["Check Number"] || "").trim();
|
||||||
check_number: (row["Check Number"] || "").trim(),
|
const pk = `payment#${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(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 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(
|
await ddb.send(
|
||||||
new BatchWriteCommand({
|
new UpdateCommand({
|
||||||
RequestItems: { [TABLE_NAME]: batch },
|
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
|
// Update metadata record
|
||||||
|
|
@ -73,11 +83,11 @@ export const handler = async (event) => {
|
||||||
pk: "metadata",
|
pk: "metadata",
|
||||||
file_name: key.split("/").pop(),
|
file_name: key.split("/").pop(),
|
||||||
last_updated: new Date().toISOString(),
|
last_updated: new Date().toISOString(),
|
||||||
last_file_count: payments.length,
|
last_file_count: count,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Upserted ${payments.length} payments from ${key}`);
|
console.log(`Upserted ${count} payments from ${key}`);
|
||||||
return { statusCode: 200, body: `Upserted ${payments.length} payments` };
|
return { statusCode: 200, body: `Upserted ${count} payments` };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,6 @@ export const handler = async (event) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildHomeView(payments, metadata) {
|
function buildHomeView(payments, metadata) {
|
||||||
|
|
||||||
const formatCurrency = (value) =>
|
const formatCurrency = (value) =>
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
|
||||||
Number(value || 0)
|
Number(value || 0)
|
||||||
|
|
@ -98,88 +97,95 @@ function buildHomeView(payments, metadata) {
|
||||||
return new Date(yyyy, mm - 1, dd);
|
return new Date(yyyy, mm - 1, dd);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateKey = (date) => {
|
const formatDisplayDate = (date) =>
|
||||||
const y = date.getFullYear();
|
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
||||||
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 today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
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);
|
const dt = parseMDYLocal(p.send_payment_on);
|
||||||
return dt && dt >= today && (p.method === "ACH" || p.method === "Check");
|
if (!dt) continue;
|
||||||
});
|
|
||||||
|
|
||||||
const groupByMethodAndDay = (methodName) => {
|
if (p.clear_status === "Returned") {
|
||||||
const grouped = {};
|
returned.push(p);
|
||||||
|
} else if (p.clear_status === "Cleared") {
|
||||||
for (const payment of futurePayments) {
|
cleared.push(p);
|
||||||
if (payment.method !== methodName) continue;
|
} else if (p.method === "ACH" && dt < today) {
|
||||||
const dt = parseMDYLocal(payment.send_payment_on);
|
// ACH assumed cleared once past due date
|
||||||
if (!dt) continue;
|
cleared.push(p);
|
||||||
const key = formatDateKey(dt);
|
} else if (dt < today) {
|
||||||
|
// Check past due date but not yet confirmed by BoA
|
||||||
if (!grouped[key]) grouped[key] = { count: 0, total: 0 };
|
outstanding.push(p);
|
||||||
grouped[key].count += 1;
|
} else {
|
||||||
grouped[key].total += payment.amount_usd;
|
scheduled.push(p);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Object.keys(grouped)
|
// Sort by date
|
||||||
.sort()
|
const byDate = (a, b) => {
|
||||||
.map((dateKey) => ({
|
const da = parseMDYLocal(a.send_payment_on);
|
||||||
date_key: dateKey,
|
const db = parseMDYLocal(b.send_payment_on);
|
||||||
display_date: formatDisplayDate(dateKey),
|
return (da || 0) - (db || 0);
|
||||||
count: grouped[dateKey].count,
|
|
||||||
total: Number(grouped[dateKey].total.toFixed(2)),
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
scheduled.sort(byDate);
|
||||||
|
outstanding.sort(byDate);
|
||||||
|
cleared.sort(byDate);
|
||||||
|
returned.sort(byDate);
|
||||||
|
|
||||||
const achDays = groupByMethodAndDay("ACH");
|
const buildSectionBlocks = (title, emoji, items) => {
|
||||||
const checkDays = groupByMethodAndDay("Check");
|
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
|
|
||||||
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 blocks = [
|
const blocks = [
|
||||||
{
|
{
|
||||||
type: "section",
|
type: "section",
|
||||||
text: {
|
text: {
|
||||||
type: "mrkdwn",
|
type: "mrkdwn",
|
||||||
text: `*${title}*\n${count} payments • ${formatCurrency(total)}`,
|
text: `*${emoji} ${title}*\n${items.length} payments • ${formatCurrency(total)}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
{ type: "divider" },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!days.length) {
|
if (!items.length) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "section",
|
type: "section",
|
||||||
text: { type: "mrkdwn", text: "_No upcoming payments found._" },
|
text: { type: "mrkdwn", text: "_None_" },
|
||||||
});
|
});
|
||||||
blocks.push({ type: "divider" });
|
blocks.push({ type: "divider" });
|
||||||
return blocks;
|
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({
|
blocks.push({
|
||||||
type: "section",
|
type: "section",
|
||||||
fields: [
|
fields: [
|
||||||
{ type: "mrkdwn", text: `*${day.display_date}*` },
|
{ type: "mrkdwn", text: `*${dateLabel}*` },
|
||||||
{ type: "mrkdwn", text: `${day.count} payments • ${formatCurrency(day.total)}` },
|
{
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `${group.payments.length} payments • ${formatCurrency(dayTotal)}`,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -203,8 +209,10 @@ function buildHomeView(payments, metadata) {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
{ type: "divider" },
|
||||||
...buildDayBlocks("ACH", achDays),
|
...buildSectionBlocks("Scheduled", ":calendar:", scheduled),
|
||||||
...buildDayBlocks("Check", checkDays),
|
...buildSectionBlocks("Outstanding", ":warning:", outstanding),
|
||||||
|
...buildSectionBlocks("Cleared", ":white_check_mark:", cleared),
|
||||||
|
...buildSectionBlocks("Returned", ":x:", returned),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,45 @@ Resources:
|
||||||
- ec2:DeleteNetworkInterface
|
- ec2:DeleteNetworkInterface
|
||||||
Resource: "*"
|
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:
|
Outputs:
|
||||||
SlackEventUrl:
|
SlackEventUrl:
|
||||||
Description: URL to set as the Slack app Request URL
|
Description: URL to set as the Slack app Request URL
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue