mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-07 03:32:08 +00:00
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
This commit is contained in:
parent
957fb726ab
commit
c445fa947a
4 changed files with 146 additions and 26 deletions
63
README.md
Normal file
63
README.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -14,12 +14,36 @@ async function getSSMParam(name) {
|
||||||
return Parameter.Value;
|
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 () => {
|
export const handler = async () => {
|
||||||
const [apiToken, accountNumber] = await Promise.all([
|
const [appId, clientId, clientSecret, accountNumber, bankId] = await Promise.all([
|
||||||
getSSMParam(process.env.BOA_API_TOKEN_PARAM),
|
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_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
|
// Get yesterday's date in YYYY-MM-DD
|
||||||
const yesterday = new Date();
|
const yesterday = new Date();
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
@ -30,12 +54,12 @@ export const handler = async () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiToken}`,
|
Authorization: `Bearer ${bearerToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
accounts: [{ accountNumber, bankId: "BOFAFRPP" }],
|
|
||||||
fromDate: dateStr,
|
fromDate: dateStr,
|
||||||
toDate: dateStr,
|
toDate: dateStr,
|
||||||
|
accounts: [{ accountNumber, bankId }],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -45,10 +69,14 @@ export const handler = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
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)
|
// Filter for cleared checks (255) and returned checks (475)
|
||||||
const relevant = transactions.filter(
|
const relevant = allTransactions.filter(
|
||||||
(t) => t.transactionCode === "255" || t.transactionCode === "475"
|
(t) => t.transactionCode === "255" || t.transactionCode === "475"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -74,12 +102,6 @@ export const handler = async () => {
|
||||||
lastKey = result.LastEvaluatedKey;
|
lastKey = result.LastEvaluatedKey;
|
||||||
} while (lastKey);
|
} 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;
|
let matched = 0;
|
||||||
|
|
||||||
for (const txn of relevant) {
|
for (const txn of relevant) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,25 @@ async function getSSMParam(name) {
|
||||||
return Parameter.Value;
|
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
|
// Convert MM/DD/YYYY to YYYY-MM-DD
|
||||||
function toISODate(mdyDate) {
|
function toISODate(mdyDate) {
|
||||||
const parts = String(mdyDate).split("/");
|
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
|
// Submit to CashPro if there are any new issues or cancels
|
||||||
if (newChecks.length || cancelChecks.length) {
|
if (newChecks.length || cancelChecks.length) {
|
||||||
const [apiToken, accountNumber, companyId] = await Promise.all([
|
const [appId, clientId, clientSecret, accountNumber, companyId] = await Promise.all([
|
||||||
getSSMParam(process.env.BOA_API_TOKEN_PARAM),
|
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_ACCOUNT_NUMBER_PARAM),
|
||||||
getSSMParam(process.env.BOA_COMPANY_ID_PARAM),
|
getSSMParam(process.env.BOA_COMPANY_ID_PARAM),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
|
||||||
|
|
||||||
const submitToBoA = async (items, action) => {
|
const submitToBoA = async (items, action) => {
|
||||||
const issueList = items.map((item) => ({
|
const issueList = items.map((item) => ({
|
||||||
accountNumber,
|
accountNumber,
|
||||||
|
issueAction: action,
|
||||||
checkNumber: item.checkNumber,
|
checkNumber: item.checkNumber,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
issueAction: action,
|
|
||||||
issueDate: item.issueDate,
|
issueDate: item.issueDate,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -160,7 +183,7 @@ export const handler = async (event) => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${apiToken}`,
|
Authorization: `Bearer ${bearerToken}`,
|
||||||
companyId,
|
companyId,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ issueList }),
|
body: JSON.stringify({ issueList }),
|
||||||
|
|
@ -181,11 +204,11 @@ export const handler = async (event) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (newChecks.length) {
|
if (newChecks.length) {
|
||||||
await submitToBoA(newChecks, "add_issue");
|
await submitToBoA(newChecks, "add_Issue");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelChecks.length) {
|
if (cancelChecks.length) {
|
||||||
await submitToBoA(cancelChecks, "cancel_issue");
|
await submitToBoA(cancelChecks, "cancel_Issue");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,10 @@ Resources:
|
||||||
Timeout: 120
|
Timeout: 120
|
||||||
Environment:
|
Environment:
|
||||||
Variables:
|
Variables:
|
||||||
BOA_BASE_URL: https://sandbox.cashpro.bankofamerica.com
|
BOA_BASE_URL: https://api.bofa.com
|
||||||
BOA_API_TOKEN_PARAM: /payments-dashboard/boa-api-token
|
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_ACCOUNT_NUMBER_PARAM: /payments-dashboard/boa-account-number
|
||||||
BOA_COMPANY_ID_PARAM: /payments-dashboard/boa-company-id
|
BOA_COMPANY_ID_PARAM: /payments-dashboard/boa-company-id
|
||||||
VpcConfig:
|
VpcConfig:
|
||||||
|
|
@ -160,7 +162,11 @@ Resources:
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref DashboardTable
|
TableName: !Ref DashboardTable
|
||||||
- SSMParameterReadPolicy:
|
- 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:
|
- SSMParameterReadPolicy:
|
||||||
ParameterName: payments-dashboard/boa-account-number
|
ParameterName: payments-dashboard/boa-account-number
|
||||||
- SSMParameterReadPolicy:
|
- SSMParameterReadPolicy:
|
||||||
|
|
@ -220,10 +226,12 @@ Resources:
|
||||||
- !Ref LambdaSecurityGroup
|
- !Ref LambdaSecurityGroup
|
||||||
Environment:
|
Environment:
|
||||||
Variables:
|
Variables:
|
||||||
BOA_BASE_URL: https://sandbox.cashpro.bankofamerica.com
|
BOA_BASE_URL: https://api.bofa.com
|
||||||
BOA_API_TOKEN_PARAM: /payments-dashboard/boa-api-token
|
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_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:
|
Events:
|
||||||
DailySchedule:
|
DailySchedule:
|
||||||
Type: Schedule
|
Type: Schedule
|
||||||
|
|
@ -235,11 +243,15 @@ Resources:
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref DashboardTable
|
TableName: !Ref DashboardTable
|
||||||
- SSMParameterReadPolicy:
|
- 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:
|
- SSMParameterReadPolicy:
|
||||||
ParameterName: payments-dashboard/boa-account-number
|
ParameterName: payments-dashboard/boa-account-number
|
||||||
- SSMParameterReadPolicy:
|
- SSMParameterReadPolicy:
|
||||||
ParameterName: payments-dashboard/boa-company-id
|
ParameterName: payments-dashboard/boa-bank-id
|
||||||
- Version: "2012-10-17"
|
- Version: "2012-10-17"
|
||||||
Statement:
|
Statement:
|
||||||
- Effect: Allow
|
- Effect: Allow
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue