ci: add Prettier format and format:check

The autofix prettier preset runs npm run format, and CI fails closed when the tree is unformatted.
This commit is contained in:
Adam Moussa 2026-10-01 21:16:13 -04:00
parent 1f6e312537
commit 3a9d10307a
No known key found for this signature in database
25 changed files with 882 additions and 415 deletions

View file

@ -20,7 +20,7 @@ jobs:
contents: write contents: write
secrets: inherit secrets: inherit
with: with:
presets: terraform presets: prettier,terraform
test: test:
name: Test name: Test
@ -41,6 +41,9 @@ jobs:
- name: Install - name: Install
run: npm ci run: npm ci
- name: Check format
run: npm run format:check
- name: Test - name: Test
run: npm test run: npm test

6
.prettierignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
package-lock.json
build
.aws-sam
terraform/.terraform
terraform/build

3
.prettierrc Normal file
View file

@ -0,0 +1,3 @@
{
"printWidth": 100
}

View file

@ -11,6 +11,7 @@
**PR title format**: `type(scope): description (DEV-123)` — Jira key required on every non-exempt PR. Dependabot and permission-controlled emergency reverts are exempt. **PR title format**: `type(scope): description (DEV-123)` — Jira key required on every non-exempt PR. Dependabot and permission-controlled emergency reverts are exempt.
**PR body headings** (exact, in this order): **PR body headings** (exact, in this order):
1. Summary 1. Summary
2. Validation 2. Validation
3. Tests 3. Tests

View file

@ -24,37 +24,39 @@ Reaction-driven workflow that routes expense submissions through four Slack chan
**Channel pipeline:** **Channel pipeline:**
| Stage | Channel ID | Action on :white_check_mark: | | Stage | Channel ID | Action on :white_check_mark: |
|-------|-----------|------------------------------| | ---------- | ------------- | ------------------------------------------- |
| Submitted | `C0AQ2AWLNEN` | Thread reply on original, copy to Processed | | Submitted | `C0AQ2AWLNEN` | Thread reply on original, copy to Processed |
| Processed | `C0APLSGABAB` | Delete from Processed, post to Authorized | | Processed | `C0APLSGABAB` | Delete from Processed, post to Authorized |
| Authorized | `C0AQ09CDJH4` | Delete from Authorized, post to Matched | | Authorized | `C0AQ09CDJH4` | Delete from Authorized, post to Matched |
| Matched | `C0APYUM1JFP` | Terminal stage (no further routing) | | Matched | `C0APYUM1JFP` | Terminal stage (no further routing) |
**Architecture:** Two Lambdas — ExpenseReceiver (HTTP endpoint, signature verification, async invoke) and ExpenseProcessor (business logic). This is the same receiver/processor pattern used for Slack's 3-second timeout requirement. **Architecture:** Two Lambdas — ExpenseReceiver (HTTP endpoint, signature verification, async invoke) and ExpenseProcessor (business logic). This is the same receiver/processor pattern used for Slack's 3-second timeout requirement.
**Secrets (Secrets Manager):** **Secrets (Secrets Manager):**
| Secret | Purpose | | Secret | Purpose |
|--------|---------| | ------------------------------------------------- | ------------------------------------------------ |
| `payments-dashboard/expense-slack-token` | Slack Bot token for the Expense Approval Bot app | | `payments-dashboard/expense-slack-token` | Slack Bot token for the Expense Approval Bot app |
| `payments-dashboard/expense-slack-signing-secret` | Slack signing secret for request verification | | `payments-dashboard/expense-slack-signing-secret` | Slack signing secret for request verification |
## BoA CashPro API Integration ## BoA CashPro API Integration
Two separate CashPro APIs are used, each with its own OAuth credentials: Two separate CashPro APIs are used, each with its own OAuth credentials:
| API | Purpose | Endpoint | | API | Purpose | Endpoint |
|-----|---------|----------| | ------------------------------- | ------------------------------- | ---------------------------------------------------------- |
| Check Management | Issue and cancel checks | `/cashpro/checkmanagement/v1/check-issues` | | 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` | | Reporting (Transaction Inquiry) | Fetch previous-day transactions | `/cashpro/reporting/v1/transaction-inquiries/previous-day` |
**Authentication flow:** **Authentication flow:**
1. POST to `/authn/v1/client-authentication` with `applicationID`, `client_id`, and `client_secret` 1. POST to `/authn/v1/client-authentication` with `applicationID`, `client_id`, and `client_secret`
2. Receive a Bearer `access_token` (valid 1 hour) 2. Receive a Bearer `access_token` (valid 1 hour)
3. Pass the token in the `Authorization` header for subsequent API calls 3. Pass the token in the `Authorization` header for subsequent API calls
**Base URLs:** **Base URLs:**
- Production: `https://api.bofa.com` - Production: `https://api.bofa.com`
- Sandbox: `https://api-sb.bofa.com` - Sandbox: `https://api-sb.bofa.com`
@ -64,16 +66,16 @@ The scheduled Lambda reconciles the Previous Day feed onto `payment#` records (p
**Classification.** The API's Detail rows carry the statement line in `detailText` (ACH `DES:PAYMENTS ID:PMT` text, return descriptions) and the posting date in `asOfDate` (ISO); `transactionType: "Summary"` rows are balance/total lines and classify as `summary` (skipped, counted). Only 475 check-paid rows omit `detailText` — their check number rides in `customerReference` (all-zeros references normalize to empty, never check number 0). Classification runs the empirically-enumerated BAI code map first, then the description-text classifiers: **Classification.** The API's Detail rows carry the statement line in `detailText` (ACH `DES:PAYMENTS ID:PMT` text, return descriptions) and the posting date in `asOfDate` (ISO); `transactionType: "Summary"` rows are balance/total lines and classify as `summary` (skipped, counted). Only 475 check-paid rows omit `detailText` — their check number rides in `customerReference` (all-zeros references normalize to empty, never check number 0). Classification runs the empirically-enumerated BAI code map first, then the description-text classifiers:
| BAI code | Feed label | Event | | BAI code | Feed label | Event |
|---|---|---| | --------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 475 | Check Paid | `check_paid` (number in `customerReference`) | | 475 | Check Paid | `check_paid` (number in `customerReference`) |
| 255 | Check Posted and Returned CR | `check_return` (number in `customerReference`) | | 255 | Check Posted and Returned CR | `check_return` (number in `customerReference`) |
| 252 | Debit Reversal Credit | `check_return` (second return-credit code) | | 252 | Debit Reversal Credit | `check_return` (second return-credit code) |
| 266 | Return Item Credit | text decides (`ach_return` on PMT text, `check_return`/`electronic_return` on return text); bare rows fall back to `electronic_return`; unreadable text stays `unknown` (loud, no write) | | 266 | Return Item Credit | text decides (`ach_return` on PMT text, `check_return`/`electronic_return` on return text); bare rows fall back to `electronic_return`; unreadable text stays `unknown` (loud, no write) |
| 455 | Preauthorized ACH Debit | `ach_debit` via `DES:PAYMENTS` text; DES-less = third-party autopay → ignored | | 455 | Preauthorized ACH Debit | `ach_debit` via `DES:PAYMENTS` text; DES-less = third-party autopay → ignored |
| 170/201/470/481 | totals, transfers, loan payments | ignored | | 170/201/470/481 | totals, transfers, loan payments | ignored |
A hard code-map event wins over description text; 266/455 carry *fallback* events consulted only when the text yields nothing. Unmapped codes on check-shaped transactions are logged (`console.error`) and counted in the run summary — never silently dropped. A hard code-map event wins over description text; 266/455 carry _fallback_ events consulted only when the text yields nothing. Unmapped codes on check-shaped transactions are logged (`console.error`) and counted in the run summary — never silently dropped.
**Matching.** Check events match on check number AND amount, evaluating all candidates (bank postings can drop/collapse digits on long check numbers). A number match with the WRONG amount never auto-resolves — it is the altered-check/collapsed-posting signal and goes to unmatched for human review. An unknown number falls back to an exact-amount match within checks issued in the last 120 days, and only when the posting's digits are a subsequence of the candidate's check number (or vice versa); return credits additionally require a bank-confirmed candidate. Zero or multiple fallback candidates means unmatched, recorded in the run summary with no write. Electronic returns (no check number) match by exact amount among bank-confirmed payments. **Matching.** Check events match on check number AND amount, evaluating all candidates (bank postings can drop/collapse digits on long check numbers). A number match with the WRONG amount never auto-resolves — it is the altered-check/collapsed-posting signal and goes to unmatched for human review. An unknown number falls back to an exact-amount match within checks issued in the last 120 days, and only when the posting's digits are a subsequence of the candidate's check number (or vice versa); return credits additionally require a bank-confirmed candidate. Zero or multiple fallback candidates means unmatched, recorded in the run summary with no write. Electronic returns (no check number) match by exact amount among bank-confirmed payments.
@ -83,12 +85,12 @@ A hard code-map event wins over description text; 266/455 carry *fallback* event
**State transitions.** Every write sets BOTH `status` and `clear_status` (`clear_status` is bank truth; "Cleared without a subsequent return is permanent" keys off it): **State transitions.** Every write sets BOTH `status` and `clear_status` (`clear_status` is bank truth; "Cleared without a subsequent return is permanent" keys off it):
| Bank event | Result | | Bank event | Result |
|---|---| | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Paid debit | `status=Cleared`, `clear_status=Cleared`, `paid_date`, `cleared_date`, `bank_reference` | | Paid debit | `status=Cleared`, `clear_status=Cleared`, `paid_date`, `cleared_date`, `bank_reference` |
| Return credit (even if currently Cleared) | `clear_status=Returned`, `returned_date`; `status` re-written unchanged (the CSV ladder has no Returned rung) | | Return credit (even if currently Cleared) | `clear_status=Returned`, `returned_date`; `status` re-written unchanged (the CSV ladder has no Returned rung) |
| Second paid debit on a Returned check | Redeposit: back to Cleared with new dates | | Second paid debit on a Returned check | Redeposit: back to Cleared with new dates |
| Return on a Stampli-voided check | Terminal voided-and-bounced (`clear_status=Returned`, cancel status preserved), counted separately | | Return on a Stampli-voided check | Terminal voided-and-bounced (`clear_status=Returned`, cancel status preserved), counted separately |
Each applied event is appended to a `history` list attribute (`{event, date, bankRef, amount}`); identical replayed events are idempotent noops. Each applied event is appended to a `history` list attribute (`{event, date, bankRef, amount}`); identical replayed events are idempotent noops.
@ -106,12 +108,12 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr
All BoA and Slack credentials are stored in AWS Secrets Manager (per `engineering-handbook/secrets-and-config.md`). The Slack token is a plaintext secret; the two BoA secrets are JSON grouping each API's credentials: All BoA and Slack credentials are stored in AWS Secrets Manager (per `engineering-handbook/secrets-and-config.md`). The Slack token is a plaintext secret; the two BoA secrets are JSON grouping each API's credentials:
| Secret | Type | Contents | | Secret | Type | Contents |
|--------|------|----------| | ----------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
| `payments-dashboard/slack-bot-token` | plaintext | Slack Bot OAuth token (used by `slackAppHome`) | | `payments-dashboard/slack-bot-token` | plaintext | Slack Bot OAuth token (used by `slackAppHome`) |
| `payments-dashboard/slack-signing-secret` | plaintext | Slack signing secret for `slackAppHome` request verification | | `payments-dashboard/slack-signing-secret` | plaintext | Slack signing secret for `slackAppHome` request verification |
| `payments-dashboard/boa-check-mgmt` | JSON | `appId`, `clientId`, `token`, `accountNumber`, `companyId` — Check Management API (`processPaymentCsv`) | | `payments-dashboard/boa-check-mgmt` | JSON | `appId`, `clientId`, `token`, `accountNumber`, `companyId` — Check Management API (`processPaymentCsv`) |
| `payments-dashboard/boa-reporting` | JSON | `appId`, `clientId`, `token`, `accountNumber`, `bankId` — Reporting API (`fetchBoaTransactions`) | | `payments-dashboard/boa-reporting` | JSON | `appId`, `clientId`, `token`, `accountNumber`, `bankId` — Reporting API (`fetchBoaTransactions`) |
`boa-account-number` is duplicated into both BoA secrets. Each Lambda is granted `secretsmanager:GetSecretValue` scoped to only the secret it needs. The Expense Approval Bot uses two additional secrets (`payments-dashboard/expense-slack-token`, `payments-dashboard/expense-slack-signing-secret`). `boa-account-number` is duplicated into both BoA secrets. Each Lambda is granted `secretsmanager:GetSecretValue` scoped to only the secret it needs. The Expense Approval Bot uses two additional secrets (`payments-dashboard/expense-slack-token`, `payments-dashboard/expense-slack-signing-secret`).
@ -137,16 +139,16 @@ All CloudWatch alarms publish to the shared `site-alerts` SNS topic (`arn:aws:sn
**SQS dead-letter queues** (messages-present, Maximum > 0): **SQS dead-letter queues** (messages-present, Maximum > 0):
| Alarm | Source | | Alarm | Source |
|-------|--------| | ----------------------------------------------- | -------------------------- |
| `payments-processPaymentCsv-async-dlq-messages` | async-invoke OnFailure DLQ | | `payments-processPaymentCsv-async-dlq-messages` | async-invoke OnFailure DLQ |
**Lambda** (per function — `payments-<fn>-...`): **Lambda** (per function — `payments-<fn>-...`):
| Type | Metric / Statistic | Threshold | | Type | Metric / Statistic | Threshold |
|----------------------|--------------------|-----------| | -------------------- | -------------------- | ------------------------------- |
| `-errors` (all 5) | `Errors` / Sum | > 0 | | `-errors` (all 5) | `Errors` / Sum | > 0 |
| `-throttles` (all 5) | `Throttles` / Sum | > 0 | | `-throttles` (all 5) | `Throttles` / Sum | > 0 |
| `-duration` (all 5) | `Duration` / Maximum | ~80% of each function's timeout | | `-duration` (all 5) | `Duration` / Maximum | ~80% of each function's timeout |
Duration thresholds (ms): processPaymentCsv 96000, fetchBoaTransactions 48000, slackAppHome 24000, expenseProcessor 12000, expenseReceiver 4000. Duration thresholds (ms): processPaymentCsv 96000, fetchBoaTransactions 48000, slackAppHome 24000, expenseProcessor 12000, expenseReceiver 4000.
@ -157,11 +159,11 @@ Duration thresholds (ms): processPaymentCsv 96000, fetchBoaTransactions 48000, s
## Scripts ## Scripts
| Script | Purpose | | Script | Purpose |
|--------|---------| | ----------------------------- | ------------------------------------------------------- |
| `scripts/test-boa-sandbox.js` | One-off sandbox connectivity test for both CashPro APIs | | `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-from-csv.js` | Seed DynamoDB from a local CSV file |
| `scripts/seed-bank-status.js` | Seed bank clear status data into DynamoDB | | `scripts/seed-bank-status.js` | Seed bank clear status data into DynamoDB |
## Deployment ## Deployment

View file

@ -2,10 +2,10 @@
Two workspaces, one configuration, selected by HCP variable `environment`: Two workspaces, one configuration, selected by HCP variable `environment`:
| Workspace | Project | Account | `environment` | `boa_base_url` | | Workspace | Project | Account | `environment` | `boa_base_url` |
|-----------|---------|---------|---------------|----------------| | ------------------------- | --------------- | -------------- | ------------- | ------------------------- |
| `payments-dashboard-prod` | `seahaven-prod` | `011934824531` | `prod` | `https://api.bofa.com` | | `payments-dashboard-prod` | `seahaven-prod` | `011934824531` | `prod` | `https://api.bofa.com` |
| `payments-dashboard-dev` | `seahaven-dev` | `710827005802` | `dev` | `https://api-sb.bofa.com` | | `payments-dashboard-dev` | `seahaven-dev` | `710827005802` | `dev` | `https://api-sb.bofa.com` |
Both carry tag `app:payments-dashboard`. `schedules_enabled` stays false until cutover. Both carry tag `app:payments-dashboard`. `schedules_enabled` stays false until cutover.
@ -15,14 +15,14 @@ Six Secrets Manager names exist in each account. Terraform pins the exact
ARNs in `terraform/locals.tf`. Values stay out of state. Dev shells are not ARNs in `terraform/locals.tf`. Values stay out of state. Dev shells are not
copies of the prod secrets. copies of the prod secrets.
| Name | Used by | | Name | Used by |
|------|---------| | ------------------------------------------------- | -------------------- |
| `payments-dashboard/slack-bot-token` | slackAppHome | | `payments-dashboard/slack-bot-token` | slackAppHome |
| `payments-dashboard/slack-signing-secret` | slackAppHome | | `payments-dashboard/slack-signing-secret` | slackAppHome |
| `payments-dashboard/boa-check-mgmt` | processPaymentCsv | | `payments-dashboard/boa-check-mgmt` | processPaymentCsv |
| `payments-dashboard/boa-reporting` | fetchBoaTransactions | | `payments-dashboard/boa-reporting` | fetchBoaTransactions |
| `payments-dashboard/expense-slack-token` | expenseProcessor | | `payments-dashboard/expense-slack-token` | expenseProcessor |
| `payments-dashboard/expense-slack-signing-secret` | expenseReceiver | | `payments-dashboard/expense-slack-signing-secret` | expenseReceiver |
## 2. HCP Terraform and GitHub Environments ## 2. HCP Terraform and GitHub Environments

19
package-lock.json generated
View file

@ -14,6 +14,9 @@
"@aws-sdk/client-secrets-manager": "^3.1140.0", "@aws-sdk/client-secrets-manager": "^3.1140.0",
"@aws-sdk/lib-dynamodb": "^3.1140.0", "@aws-sdk/lib-dynamodb": "^3.1140.0",
"csv-parse": "^7.0.2" "csv-parse": "^7.0.2"
},
"devDependencies": {
"prettier": "^3.9.9"
} }
}, },
"node_modules/@aws-sdk/checksums": { "node_modules/@aws-sdk/checksums": {
@ -569,6 +572,22 @@
"integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/prettier": {
"version": "3.9.9",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View file

@ -7,6 +7,8 @@
"src/" "src/"
], ],
"scripts": { "scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "node --test \"tests/**/*.test.js\"" "test": "node --test \"tests/**/*.test.js\""
}, },
"dependencies": { "dependencies": {
@ -16,5 +18,8 @@
"@aws-sdk/client-secrets-manager": "^3.1140.0", "@aws-sdk/client-secrets-manager": "^3.1140.0",
"@aws-sdk/lib-dynamodb": "^3.1140.0", "@aws-sdk/lib-dynamodb": "^3.1140.0",
"csv-parse": "^7.0.2" "csv-parse": "^7.0.2"
},
"devDependencies": {
"prettier": "^3.9.9"
} }
} }

View file

@ -25,7 +25,7 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
FilterExpression: "begins_with(pk, :prefix)", FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "payment#" }, ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey, ExclusiveStartKey: lastKey,
}) }),
); );
for (const item of result.Items) { for (const item of result.Items) {
dbPayments[item.pk] = item; dbPayments[item.pk] = item;
@ -46,16 +46,20 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
}); });
const parseAmount = (value) => { const parseAmount = (value) => {
const num = parseFloat(String(value || "0").replace(/,/g, "").trim()); const num = parseFloat(
String(value || "0")
.replace(/,/g, "")
.trim(),
);
return isNaN(num) ? 0 : num; return isNaN(num) ? 0 : num;
}; };
const statusRank = { const statusRank = {
"scheduled": 1, scheduled: 1,
"payment submitted": 2, "payment submitted": 2,
"issued": 3, issued: 3,
"outstanding": 4, outstanding: 4,
"cleared": 5, cleared: 5,
}; };
const newRecords = []; const newRecords = [];
@ -103,12 +107,24 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (oldRank === 5) { if (oldRank === 5) {
// Cleared is permanent — cannot be voided, cancelled, or anything else // Cleared is permanent — cannot be voided, cancelled, or anything else
if (status !== existing.status) { if (status !== existing.status) {
statusProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: existing.status, reason: "Cleared is permanent" }); statusProtected.push({
checkNumber,
payee,
csvStatus: originalCsvStatus,
dbStatus: existing.status,
reason: "Cleared is permanent",
});
} }
status = existing.status; status = existing.status;
} else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) { } else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) {
// Non-cancel status regression — keep the existing (higher) status // Non-cancel status regression — keep the existing (higher) status
statusProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: existing.status, reason: "would regress status" }); statusProtected.push({
checkNumber,
payee,
csvStatus: originalCsvStatus,
dbStatus: existing.status,
reason: "would regress status",
});
status = existing.status; status = existing.status;
} }
} }
@ -116,13 +132,23 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (!existing) { if (!existing) {
newRecords.push({ checkNumber, payee, method, status, amount, sendOn }); newRecords.push({ checkNumber, payee, method, status, amount, sendOn });
if (method === "Check") { if (method === "Check") {
newCheckIssues.push({ checkNumber, payee, amount: amount.toFixed(2), issueDate: toISODate(sendOn) }); newCheckIssues.push({
checkNumber,
payee,
amount: amount.toFixed(2),
issueDate: toISODate(sendOn),
});
} }
} else { } else {
const oldStatus = existing.status || ""; const oldStatus = existing.status || "";
if (bankConfirmed && originalCsvStatus !== "Cleared") { if (bankConfirmed && originalCsvStatus !== "Cleared") {
bankProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: oldStatus }); bankProtected.push({
checkNumber,
payee,
csvStatus: originalCsvStatus,
dbStatus: oldStatus,
});
} }
if (oldStatus !== status) { if (oldStatus !== status) {
@ -132,8 +158,19 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
} }
// Check for new cancel (only if not bank-confirmed and not cleared) // 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())) { if (
cancelCheckIssues.push({ checkNumber, payee, amount: amount.toFixed(2), issueDate: toISODate(sendOn) }); 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),
});
} }
} }
} }
@ -146,7 +183,18 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (newRecords.length) { if (newRecords.length) {
console.log("--- NEW RECORDS (" + newRecords.length + ") ---"); console.log("--- NEW RECORDS (" + newRecords.length + ") ---");
for (const r of newRecords) { for (const r of newRecords) {
console.log(" + " + r.checkNumber + " | " + r.payee + " | " + r.method + " | " + r.status + " | $" + r.amount); console.log(
" + " +
r.checkNumber +
" | " +
r.payee +
" | " +
r.method +
" | " +
r.status +
" | $" +
r.amount,
);
} }
console.log(""); console.log("");
} }
@ -154,7 +202,20 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (statusChanges.length) { if (statusChanges.length) {
console.log("--- STATUS CHANGES (" + statusChanges.length + ") ---"); console.log("--- STATUS CHANGES (" + statusChanges.length + ") ---");
for (const r of statusChanges) { for (const r of statusChanges) {
console.log(" ~ " + r.checkNumber + " | " + r.payee + " | " + r.method + " | \"" + r.oldStatus + "\" -> \"" + r.newStatus + "\" | $" + r.amount); console.log(
" ~ " +
r.checkNumber +
" | " +
r.payee +
" | " +
r.method +
' | "' +
r.oldStatus +
'" -> "' +
r.newStatus +
'" | $' +
r.amount,
);
} }
console.log(""); console.log("");
} }
@ -163,7 +224,18 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
console.log("--- STATUS PROGRESSION PROTECTED (" + statusProtected.length + ") ---"); console.log("--- STATUS PROGRESSION PROTECTED (" + statusProtected.length + ") ---");
console.log(" (CSV tried to regress status — blocked by progression guard)"); console.log(" (CSV tried to regress status — blocked by progression guard)");
for (const r of statusProtected) { for (const r of statusProtected) {
console.log(" # " + r.checkNumber + " | " + r.payee + " | CSV: \"" + r.csvStatus + "\" | Kept: \"" + r.dbStatus + "\" | " + r.reason); console.log(
" # " +
r.checkNumber +
" | " +
r.payee +
' | CSV: "' +
r.csvStatus +
'" | Kept: "' +
r.dbStatus +
'" | ' +
r.reason,
);
} }
console.log(""); console.log("");
} }
@ -172,7 +244,9 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
console.log("--- BANK-CONFIRMED PROTECTED (" + bankProtected.length + ") ---"); console.log("--- BANK-CONFIRMED PROTECTED (" + bankProtected.length + ") ---");
console.log(" (CSV tried to change status but bank already confirmed Cleared)"); console.log(" (CSV tried to change status but bank already confirmed Cleared)");
for (const r of bankProtected) { for (const r of bankProtected) {
console.log(" ! " + r.checkNumber + " | " + r.payee + " | CSV: \"" + r.csvStatus + "\" | Kept: Cleared"); console.log(
" ! " + r.checkNumber + " | " + r.payee + ' | CSV: "' + r.csvStatus + '" | Kept: Cleared',
);
} }
console.log(""); console.log("");
} }
@ -180,7 +254,9 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (newCheckIssues.length) { if (newCheckIssues.length) {
console.log("--- BOA: CHECK ISSUES / add_Issue (" + newCheckIssues.length + ") ---"); console.log("--- BOA: CHECK ISSUES / add_Issue (" + newCheckIssues.length + ") ---");
for (const r of newCheckIssues) { for (const r of newCheckIssues) {
console.log(" >> " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate); console.log(
" >> " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate,
);
} }
console.log(""); console.log("");
} }
@ -188,7 +264,9 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (cancelCheckIssues.length) { if (cancelCheckIssues.length) {
console.log("--- BOA: CHECK CANCELS / cancel_Issue (" + cancelCheckIssues.length + ") ---"); console.log("--- BOA: CHECK CANCELS / cancel_Issue (" + cancelCheckIssues.length + ") ---");
for (const r of cancelCheckIssues) { for (const r of cancelCheckIssues) {
console.log(" XX " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate); console.log(
" XX " + r.checkNumber + " | " + r.payee + " | $" + r.amount + " | " + r.issueDate,
);
} }
console.log(""); console.log("");
} }

View file

@ -7,9 +7,7 @@ const secrets = new SecretsManagerClient({ region: "us-east-1" });
const BOA_BASE_URL = "https://api.bofa.com"; const BOA_BASE_URL = "https://api.bofa.com";
async function getSecretJson(secretId) { async function getSecretJson(secretId) {
const { SecretString } = await secrets.send( const { SecretString } = await secrets.send(new GetSecretValueCommand({ SecretId: secretId }));
new GetSecretValueCommand({ SecretId: secretId })
);
return JSON.parse(SecretString); return JSON.parse(SecretString);
} }
@ -43,9 +41,13 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
TableName: "PaymentsDashboard", TableName: "PaymentsDashboard",
FilterExpression: "begins_with(pk, :prefix) AND #m = :method AND #s = :status", FilterExpression: "begins_with(pk, :prefix) AND #m = :method AND #s = :status",
ExpressionAttributeNames: { "#m": "method", "#s": "status" }, ExpressionAttributeNames: { "#m": "method", "#s": "status" },
ExpressionAttributeValues: { ":prefix": "payment#", ":method": "Check", ":status": "Scheduled" }, ExpressionAttributeValues: {
":prefix": "payment#",
":method": "Check",
":status": "Scheduled",
},
ExclusiveStartKey: lastKey, ExclusiveStartKey: lastKey,
}) }),
); );
payments.push(...result.Items); payments.push(...result.Items);
lastKey = result.LastEvaluatedKey; lastKey = result.LastEvaluatedKey;
@ -106,16 +108,25 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
} }
if (skip) issueList.splice(0, skip); if (skip) issueList.splice(0, skip);
if (limit) issueList.length = limit; if (limit) issueList.length = limit;
if (skip || limit) console.log(`\nSkip ${skip}, limit ${limit ?? "all"} → ${issueList.length} check(s).`); if (skip || limit)
console.log(`\nSkip ${skip}, limit ${limit ?? "all"} → ${issueList.length} check(s).`);
} }
if (dryRun) { if (dryRun) {
console.log(`\nDRY RUN — would issue ${issueList.length} checks to BoA. Use without --dry-run to execute.`); console.log(
`\nDRY RUN — would issue ${issueList.length} checks to BoA. Use without --dry-run to execute.`,
);
return; return;
} }
// Get credentials // Get credentials
const { appId, clientId, token: clientSecret, accountNumber, companyId } = await getSecretJson("payments-dashboard/boa-check-mgmt"); const {
appId,
clientId,
token: clientSecret,
accountNumber,
companyId,
} = await getSecretJson("payments-dashboard/boa-check-mgmt");
// Fill in account number // Fill in account number
for (const item of issueList) { for (const item of issueList) {
@ -156,7 +167,9 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
throw new Error(`BoA returned non-JSON: ${res.status}`); throw new Error(`BoA returned non-JSON: ${res.status}`);
} }
console.log(` Result: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed`); console.log(
` Result: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed`,
);
totalProcessed += data.processedItems || 0; totalProcessed += data.processedItems || 0;
totalFailed += data.unprocessedItems || 0; totalFailed += data.unprocessedItems || 0;

View file

@ -87,7 +87,7 @@ async function updateStatus(checkNumber, status, { paidDate, amount, issueDate,
UpdateExpression: `SET ${expr.join(", ")}`, UpdateExpression: `SET ${expr.join(", ")}`,
ExpressionAttributeNames: names, ExpressionAttributeNames: names,
ExpressionAttributeValues: values, ExpressionAttributeValues: values,
}) }),
); );
} }
@ -106,7 +106,13 @@ async function main() {
if (!checkNumber) continue; if (!checkNumber) continue;
const amount = parseFloat(String(row[4] || "0").replace(/,/g, "")); const amount = parseFloat(String(row[4] || "0").replace(/,/g, ""));
const issueDate = row[1]?.trim() || null; const issueDate = row[1]?.trim() || null;
statusMap.set(checkNumber, { status: "Issued", paidDate: null, amount: isNaN(amount) ? 0 : amount, issueDate, method: "Check" }); statusMap.set(checkNumber, {
status: "Issued",
paidDate: null,
amount: isNaN(amount) ? 0 : amount,
issueDate,
method: "Check",
});
} }
console.log(` ${rows.length} positive pay entries`); console.log(` ${rows.length} positive pay entries`);
} }
@ -129,7 +135,13 @@ async function main() {
issueDate = parts.join("/"); issueDate = parts.join("/");
} }
} }
statusMap.set(checkNumber, { status: "Outstanding", paidDate: null, amount: isNaN(amount) ? 0 : amount, issueDate, method: "Check" }); statusMap.set(checkNumber, {
status: "Outstanding",
paidDate: null,
amount: isNaN(amount) ? 0 : amount,
issueDate,
method: "Check",
});
} }
console.log(` ${rows.length} outstanding entries`); console.log(` ${rows.length} outstanding entries`);
} }
@ -151,7 +163,11 @@ async function main() {
seen.add(checkNumber); seen.add(checkNumber);
const paidDate = toISODate(row["Paid Date"] || row["CD Volume Number"] || ""); const paidDate = toISODate(row["Paid Date"] || row["CD Volume Number"] || "");
const amount = parseFloat(String(row["Amount"] || "0").replace(/,/g, "")); const amount = parseFloat(String(row["Amount"] || "0").replace(/,/g, ""));
statusMap.set(checkNumber, { status: "Cleared", paidDate, amount: isNaN(amount) ? 0 : amount }); statusMap.set(checkNumber, {
status: "Cleared",
paidDate,
amount: isNaN(amount) ? 0 : amount,
});
} }
console.log(` ${seen.size} unique cleared checks (${deduped} duplicates skipped)`); console.log(` ${seen.size} unique cleared checks (${deduped} duplicates skipped)`);
} }

View file

@ -12,11 +12,7 @@
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { parse } from "csv-parse/sync"; import { parse } from "csv-parse/sync";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { import { DynamoDBDocumentClient, UpdateCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
DynamoDBDocumentClient,
UpdateCommand,
PutCommand,
} from "@aws-sdk/lib-dynamodb";
const TABLE_NAME = "PaymentsDashboard"; const TABLE_NAME = "PaymentsDashboard";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
@ -29,7 +25,11 @@ function toISODate(mdyDate) {
} }
const parseAmount = (value) => { const parseAmount = (value) => {
const num = parseFloat(String(value || "0").replace(/,/g, "").trim()); const num = parseFloat(
String(value || "0")
.replace(/,/g, "")
.trim(),
);
return isNaN(num) ? 0 : num; return isNaN(num) ? 0 : num;
}; };
@ -106,7 +106,7 @@ async function seedFile(filePath) {
":status": status, ":status": status,
":company_subsidiary": (row["Company/Subsidiary"] || "").trim(), ":company_subsidiary": (row["Company/Subsidiary"] || "").trim(),
}, },
}) }),
); );
count++; count++;
} }
@ -138,7 +138,7 @@ async function main() {
last_updated: new Date().toISOString(), last_updated: new Date().toISOString(),
last_file_count: total, last_file_count: total,
}, },
}) }),
); );
console.log(`\nDone — ${total} total payments seeded.`); console.log(`\nDone — ${total} total payments seeded.`);

View file

@ -5,10 +5,13 @@ const { DynamoDBDocumentClient, BatchGetCommand } = require("@aws-sdk/lib-dynamo
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" })); const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" }));
const CSV_PATH = process.argv[2]; const CSV_PATH = process.argv[2];
if (!CSV_PATH) { console.error("Usage: node simulate-csv.cjs <csv-path>"); process.exit(1); } if (!CSV_PATH) {
console.error("Usage: node simulate-csv.cjs <csv-path>");
process.exit(1);
}
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"]; const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
const statusRank = { "scheduled": 1, "payment submitted": 2, "issued": 3, "outstanding": 4, "cleared": 5 }; const statusRank = { scheduled: 1, "payment submitted": 2, issued: 3, outstanding: 4, cleared: 5 };
function toISODate(mdy) { function toISODate(mdy) {
const p = String(mdy).split("/"); const p = String(mdy).split("/");
@ -19,8 +22,11 @@ function toISODate(mdy) {
(async () => { (async () => {
const text = fs.readFileSync(CSV_PATH, "utf-8"); const text = fs.readFileSync(CSV_PATH, "utf-8");
const rows = parse(text, { columns: true, skip_empty_lines: true, trim: true }) const rows = parse(text, { columns: true, skip_empty_lines: true, trim: true }).map((r) =>
.map((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [String(k).trim(), typeof v === "string" ? v.trim() : v]))); Object.fromEntries(
Object.entries(r).map(([k, v]) => [String(k).trim(), typeof v === "string" ? v.trim() : v]),
),
);
const validRows = rows.filter((r) => (r["Check Number"] || "").trim()); const validRows = rows.filter((r) => (r["Check Number"] || "").trim());
console.log(`CSV rows: ${rows.length} total, ${validRows.length} with check numbers`); console.log(`CSV rows: ${rows.length} total, ${validRows.length} with check numbers`);
@ -30,7 +36,9 @@ function toISODate(mdy) {
const existing = {}; const existing = {};
for (let i = 0; i < keys.length; i += 100) { for (let i = 0; i < keys.length; i += 100) {
const batch = keys.slice(i, i + 100); const batch = keys.slice(i, i + 100);
const res = await ddb.send(new BatchGetCommand({ RequestItems: { PaymentsDashboard: { Keys: batch } } })); const res = await ddb.send(
new BatchGetCommand({ RequestItems: { PaymentsDashboard: { Keys: batch } } }),
);
for (const item of res.Responses.PaymentsDashboard) existing[item.pk] = item; for (const item of res.Responses.PaymentsDashboard) existing[item.pk] = item;
} }
console.log(`DDB records found for: ${Object.keys(existing).length} of ${validRows.length}\n`); console.log(`DDB records found for: ${Object.keys(existing).length} of ${validRows.length}\n`);
@ -74,43 +82,71 @@ function toISODate(mdy) {
} }
if (frozenReason) { if (frozenReason) {
if (bankConfirmed) frozenByBank.push({ checkNumber, payee: row["Payee"], reason: frozenReason }); if (bankConfirmed)
frozenByBank.push({ checkNumber, payee: row["Payee"], reason: frozenReason });
else frozenByRank.push({ checkNumber, payee: row["Payee"], reason: frozenReason }); else frozenByRank.push({ checkNumber, payee: row["Payee"], reason: frozenReason });
} else if (ex && ex.status !== status) { } else if (ex && ex.status !== status) {
statusChanges.push({ checkNumber, payee: row["Payee"], from: ex.status, to: status }); statusChanges.push({ checkNumber, payee: row["Payee"], from: ex.status, to: status });
} }
if (method !== "Check") { if (method !== "Check") {
if (!ex) newNonChecks.push({ checkNumber, payee: row["Payee"], method, status, amount: row["Amount in USD"] }); if (!ex)
newNonChecks.push({
checkNumber,
payee: row["Payee"],
method,
status,
amount: row["Amount in USD"],
});
continue; continue;
} }
if (!ex) { if (!ex) {
newChecks.push({ checkNumber, payee: row["Payee"], amount: row["Amount in USD"], status, sendOn }); newChecks.push({
} else if (cancelStatuses.includes(status.toLowerCase()) && !cancelStatuses.includes((ex.status || "").toLowerCase())) { checkNumber,
cancelChecks.push({ checkNumber, payee: row["Payee"], from: ex.status, amount: row["Amount in USD"] }); payee: row["Payee"],
amount: row["Amount in USD"],
status,
sendOn,
});
} else if (
cancelStatuses.includes(status.toLowerCase()) &&
!cancelStatuses.includes((ex.status || "").toLowerCase())
) {
cancelChecks.push({
checkNumber,
payee: row["Payee"],
from: ex.status,
amount: row["Amount in USD"],
});
} }
} }
console.log(`========= WOULD HIT BoA =========`); console.log(`========= WOULD HIT BoA =========`);
console.log(`NEW checks → add_Issue: ${newChecks.length}`); console.log(`NEW checks → add_Issue: ${newChecks.length}`);
for (const c of newChecks) console.log(` ${c.checkNumber} | ${c.payee} | $${c.amount} | ${c.status} | ${c.sendOn}`); for (const c of newChecks)
console.log(` ${c.checkNumber} | ${c.payee} | $${c.amount} | ${c.status} | ${c.sendOn}`);
console.log(`\nCancellations → cancel_Issue: ${cancelChecks.length}`); console.log(`\nCancellations → cancel_Issue: ${cancelChecks.length}`);
for (const c of cancelChecks) console.log(` ${c.checkNumber} | ${c.payee} | ${c.from}→cancel | $${c.amount}`); for (const c of cancelChecks)
console.log(` ${c.checkNumber} | ${c.payee} | ${c.from}→cancel | $${c.amount}`);
console.log(`\n========= DDB-ONLY CHANGES =========`); console.log(`\n========= DDB-ONLY CHANGES =========`);
console.log(`Status changes (existing records): ${statusChanges.length}`); console.log(`Status changes (existing records): ${statusChanges.length}`);
for (const c of statusChanges.slice(0, 30)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.from} → ${c.to}`); for (const c of statusChanges.slice(0, 30))
console.log(` ${c.checkNumber} | ${c.payee} | ${c.from} → ${c.to}`);
if (statusChanges.length > 30) console.log(` ... +${statusChanges.length - 30} more`); if (statusChanges.length > 30) console.log(` ... +${statusChanges.length - 30} more`);
console.log(`\nNew non-check rows (ACH/etc., DDB only): ${newNonChecks.length}`); console.log(`\nNew non-check rows (ACH/etc., DDB only): ${newNonChecks.length}`);
for (const c of newNonChecks.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.method} | ${c.status} | $${c.amount}`); for (const c of newNonChecks.slice(0, 10))
console.log(` ${c.checkNumber} | ${c.payee} | ${c.method} | ${c.status} | $${c.amount}`);
if (newNonChecks.length > 10) console.log(` ... +${newNonChecks.length - 10} more`); if (newNonChecks.length > 10) console.log(` ... +${newNonChecks.length - 10} more`);
console.log(`\n========= BLOCKED / FROZEN =========`); console.log(`\n========= BLOCKED / FROZEN =========`);
console.log(`Frozen by bank-confirmed Cleared: ${frozenByBank.length}`); console.log(`Frozen by bank-confirmed Cleared: ${frozenByBank.length}`);
for (const c of frozenByBank.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`); for (const c of frozenByBank.slice(0, 10))
console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
console.log(`\nFrozen by status-rank protection: ${frozenByRank.length}`); console.log(`\nFrozen by status-rank protection: ${frozenByRank.length}`);
for (const c of frozenByRank.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`); for (const c of frozenByRank.slice(0, 10))
console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
})(); })();

View file

@ -14,9 +14,7 @@ const secrets = new SecretsManagerClient();
const BASE_URL = "https://api.bofa.com"; const BASE_URL = "https://api.bofa.com";
async function getSecretJson(secretId) { async function getSecretJson(secretId) {
const { SecretString } = await secrets.send( const { SecretString } = await secrets.send(new GetSecretValueCommand({ SecretId: secretId }));
new GetSecretValueCommand({ SecretId: secretId })
);
return JSON.parse(SecretString); return JSON.parse(SecretString);
} }
@ -104,7 +102,7 @@ async function main() {
Authorization: `Bearer ${reportingToken}`, Authorization: `Bearer ${reportingToken}`,
}, },
body: JSON.stringify(inquiryPayload), body: JSON.stringify(inquiryPayload),
} },
); );
const inquiryText = await inquiryRes.text(); const inquiryText = await inquiryRes.text();

View file

@ -18,9 +18,7 @@ const SANDBOX_BASE = "https://api-sb.bofa.com";
const AUTH_URL = `${SANDBOX_BASE}/authn/v1/client-authentication`; const AUTH_URL = `${SANDBOX_BASE}/authn/v1/client-authentication`;
async function getSecretJson(secretId) { async function getSecretJson(secretId) {
const { SecretString } = await secrets.send( const { SecretString } = await secrets.send(new GetSecretValueCommand({ SecretId: secretId }));
new GetSecretValueCommand({ SecretId: secretId })
);
return JSON.parse(SecretString); return JSON.parse(SecretString);
} }
@ -74,14 +72,14 @@ async function main() {
const checkMgmtBearerToken = await getAccessToken( const checkMgmtBearerToken = await getAccessToken(
"app_SeaHavenIndustries_Checkmanagement_SB", "app_SeaHavenIndustries_Checkmanagement_SB",
checkMgmtClientId, checkMgmtClientId,
checkMgmtSecret checkMgmtSecret,
); );
console.log("[Account Info / Reporting]"); console.log("[Account Info / Reporting]");
const accountInfoBearerToken = await getAccessToken( const accountInfoBearerToken = await getAccessToken(
"app_SeaHavenIndustries_Reporting_SB", "app_SeaHavenIndustries_Reporting_SB",
accountInfoClientId, accountInfoClientId,
accountInfoSecret accountInfoSecret,
); );
console.log("Both tokens acquired.\n"); console.log("Both tokens acquired.\n");
@ -107,18 +105,15 @@ async function main() {
console.log("Request:", JSON.stringify(issuePayload, null, 2), "\n"); console.log("Request:", JSON.stringify(issuePayload, null, 2), "\n");
try { try {
const issueRes = await fetch( const issueRes = await fetch(`${SANDBOX_BASE}/cashpro/checkmanagement/v1/check-issues`, {
`${SANDBOX_BASE}/cashpro/checkmanagement/v1/check-issues`, method: "POST",
{ headers: {
method: "POST", "Content-Type": "application/json",
headers: { Authorization: `Bearer ${checkMgmtBearerToken}`,
"Content-Type": "application/json", companyId,
Authorization: `Bearer ${checkMgmtBearerToken}`, },
companyId, body: JSON.stringify(issuePayload),
}, });
body: JSON.stringify(issuePayload),
}
);
const issueHeaders = Object.fromEntries(issueRes.headers.entries()); const issueHeaders = Object.fromEntries(issueRes.headers.entries());
const issueText = await issueRes.text(); const issueText = await issueRes.text();
@ -162,7 +157,7 @@ async function main() {
Authorization: `Bearer ${accountInfoBearerToken}`, Authorization: `Bearer ${accountInfoBearerToken}`,
}, },
body: JSON.stringify(inquiryPayload), body: JSON.stringify(inquiryPayload),
} },
); );
const inquiryHeaders = Object.fromEntries(inquiryRes.headers.entries()); const inquiryHeaders = Object.fromEntries(inquiryRes.headers.entries());

View file

@ -11,7 +11,11 @@ export const logSafe = (v) => JSON.stringify(String(v ?? "").slice(0, 128));
// Comma-tolerant amount parsing ("1,234.56" bank strings and stored values). // Comma-tolerant amount parsing ("1,234.56" bank strings and stored values).
export const parseAmount = (value) => { export const parseAmount = (value) => {
const num = parseFloat(String(value ?? "0").replace(/,/g, "").trim()); const num = parseFloat(
String(value ?? "0")
.replace(/,/g, "")
.trim(),
);
return isNaN(num) ? 0 : num; return isNaN(num) ? 0 : num;
}; };
@ -67,7 +71,7 @@ export function directionFromCode(code) {
export function directionOf(txn) { export function directionOf(txn) {
const indicator = String( const indicator = String(
txn.debitCreditIndicator ?? txn.creditDebitIndicator ?? "" txn.debitCreditIndicator ?? txn.creditDebitIndicator ?? "",
).toUpperCase(); ).toUpperCase();
if (indicator.includes("DEBIT")) return "debit"; if (indicator.includes("DEBIT")) return "debit";
if (indicator.includes("CREDIT")) return "credit"; if (indicator.includes("CREDIT")) return "credit";
@ -117,22 +121,33 @@ export function classifyTransaction(txn) {
// fallback. // fallback.
// First NON-EMPTY of the substantive fields — ?? alone would let an // First NON-EMPTY of the substantive fields — ?? alone would let an
// empty-string detailText shadow a populated text/description field. // empty-string detailText shadow a populated text/description field.
const substantiveText = [txn.detailText, txn.text, txn.description] const substantiveText =
.map((v) => String(v ?? "").slice(0, MAX_DESCRIPTION_LEN).trim()) [txn.detailText, txn.text, txn.description]
.find(Boolean) ?? ""; .map((v) =>
String(v ?? "")
.slice(0, MAX_DESCRIPTION_LEN)
.trim(),
)
.find(Boolean) ?? "";
const description = const description =
substantiveText || substantiveText ||
String(txn.transactionDescription ?? "").slice(0, MAX_DESCRIPTION_LEN).trim(); String(txn.transactionDescription ?? "")
.slice(0, MAX_DESCRIPTION_LEN)
.trim();
// Reference fields are bank-generated and short (12 digits observed); // Reference fields are bank-generated and short (12 digits observed);
// bound them so a malformed feed can never balloon DDB items or matching. // bound them so a malformed feed can never balloon DDB items or matching.
const rawReference = stripLeadingZeros( const rawReference = stripLeadingZeros(
String(txn.customerReference ?? "").trim().slice(0, 64) String(txn.customerReference ?? "")
.trim()
.slice(0, 64),
); );
// An all-zeros reference ("000000000000" on 266 return credits) means "no // An all-zeros reference ("000000000000" on 266 return credits) means "no
// reference", not check number 0 — stripLeadingZeros alone leaves "0", // reference", not check number 0 — stripLeadingZeros alone leaves "0",
// which would fabricate a checkNumber. // which would fabricate a checkNumber.
const customerReference = rawReference === "0" ? "" : rawReference; const customerReference = rawReference === "0" ? "" : rawReference;
const bankReference = String(txn.bankReference ?? "").trim().slice(0, 64); const bankReference = String(txn.bankReference ?? "")
.trim()
.slice(0, 64);
const amount = Math.abs(parseAmount(txn.amount)); const amount = Math.abs(parseAmount(txn.amount));
const direction = BAI_CODE_EVENTS[code]?.direction ?? directionOf(txn); const direction = BAI_CODE_EVENTS[code]?.direction ?? directionOf(txn);
@ -153,7 +168,11 @@ export function classifyTransaction(txn) {
// Summary rows (balance/total lines, transactionType "Summary") are not // Summary rows (balance/total lines, transactionType "Summary") are not
// events; bail before the description regexes so their labels ("Total // events; bail before the description regexes so their labels ("Total
// Checks Paid Debit") can't pollute unknown/check-shaped counting. // Checks Paid Debit") can't pollute unknown/check-shaped counting.
if (String(txn.transactionType ?? "").trim().toLowerCase() === "summary") { if (
String(txn.transactionType ?? "")
.trim()
.toLowerCase() === "summary"
) {
return { ...base, event: "summary" }; return { ...base, event: "summary" };
} }
@ -214,8 +233,7 @@ export function classifyTransaction(txn) {
// Shape test uses the RAW reference: an all-zeros reference still means // Shape test uses the RAW reference: an all-zeros reference still means
// the bank posted a structured row (the zero-guard must not silence // the bank posted a structured row (the zero-guard must not silence
// unmapped return-credit codes, which present exactly this way). // unmapped return-credit codes, which present exactly this way).
base.checkShaped = base.checkShaped = Boolean(rawReference) || /CHECK/i.test(description) || Boolean(base.pmtId);
Boolean(rawReference) || /CHECK/i.test(description) || Boolean(base.pmtId);
return { ...base, event: base.checkShaped ? "unknown" : "ignored" }; return { ...base, event: base.checkShaped ? "unknown" : "ignored" };
} }
@ -264,9 +282,7 @@ export function matchCheckTransaction(classified, payments, refDateISO) {
const { checkNumber, amount, event } = classified; const { checkNumber, amount, event } = classified;
const checks = payments.filter((p) => p.method === "Check" && p.check_number); const checks = payments.filter((p) => p.method === "Check" && p.check_number);
const numberMatches = checkNumber const numberMatches = checkNumber ? checks.filter((p) => p.check_number === checkNumber) : [];
? checks.filter((p) => p.check_number === checkNumber)
: [];
const exact = numberMatches.filter((p) => amountsEqual(p.amount_usd, amount)); const exact = numberMatches.filter((p) => amountsEqual(p.amount_usd, amount));
if (exact.length === 1) return { payment: exact[0], matchedBy: "number+amount" }; if (exact.length === 1) return { payment: exact[0], matchedBy: "number+amount" };
if (exact.length > 1) return { unmatched: "multiple number+amount matches" }; if (exact.length > 1) return { unmatched: "multiple number+amount matches" };
@ -279,9 +295,7 @@ export function matchCheckTransaction(classified, payments, refDateISO) {
amountsEqual(p.amount_usd, amount) && amountsEqual(p.amount_usd, amount) &&
issuedWithinDays(p, refDateISO, 120) && issuedWithinDays(p, refDateISO, 120) &&
digitsCorroborate(checkNumber, p.check_number) && digitsCorroborate(checkNumber, p.check_number) &&
(event !== "check_return" || (event !== "check_return" || p.clear_status === "Cleared" || p.clear_status === "Returned"),
p.clear_status === "Cleared" ||
p.clear_status === "Returned")
); );
if (fallback.length === 1) return { payment: fallback[0], matchedBy: "amount" }; if (fallback.length === 1) return { payment: fallback[0], matchedBy: "amount" };
if (fallback.length > 1) { if (fallback.length > 1) {
@ -300,7 +314,7 @@ export function matchElectronicReturn(classified, payments, refDateISO) {
p.method === "Check" && p.method === "Check" &&
(p.clear_status === "Cleared" || p.clear_status === "Returned") && (p.clear_status === "Cleared" || p.clear_status === "Returned") &&
amountsEqual(p.amount_usd, classified.amount) && amountsEqual(p.amount_usd, classified.amount) &&
issuedWithinDays(p, refDateISO, 120) issuedWithinDays(p, refDateISO, 120),
); );
if (candidates.length === 1) return { payment: candidates[0], matchedBy: "amount+cleared" }; if (candidates.length === 1) return { payment: candidates[0], matchedBy: "amount+cleared" };
if (candidates.length > 1) { if (candidates.length > 1) {
@ -375,7 +389,7 @@ export function matchAchTransaction(classified, payments, postingISO) {
if (embeddedPaymentNumber) { if (embeddedPaymentNumber) {
const byNumber = achs.filter( const byNumber = achs.filter(
(p) => p.check_number === embeddedPaymentNumber && amountsEqual(p.amount_usd, amount) (p) => p.check_number === embeddedPaymentNumber && amountsEqual(p.amount_usd, amount),
); );
if (byNumber.length === 1) return { payment: byNumber[0], matchedBy: "payment-number+amount" }; if (byNumber.length === 1) return { payment: byNumber[0], matchedBy: "payment-number+amount" };
} }
@ -387,7 +401,7 @@ export function matchAchTransaction(classified, payments, postingISO) {
!pmtIdConflicts(p) && !pmtIdConflicts(p) &&
amountsEqual(p.amount_usd, amount) && amountsEqual(p.amount_usd, amount) &&
vendorMatches(p.payee, vendorText) && vendorMatches(p.payee, vendorText) &&
withinSendWindow(p, postingISO) withinSendWindow(p, postingISO),
); );
if (byVendor.length === 1) return { payment: byVendor[0], matchedBy: "vendor+amount" }; if (byVendor.length === 1) return { payment: byVendor[0], matchedBy: "vendor+amount" };
if (byVendor.length > 1) { if (byVendor.length > 1) {
@ -401,7 +415,7 @@ export function matchAchTransaction(classified, payments, postingISO) {
!pmtIdConflicts(p) && !pmtIdConflicts(p) &&
(p.clear_status === "Cleared" || p.clear_status === "Returned") && (p.clear_status === "Cleared" || p.clear_status === "Returned") &&
amountsEqual(p.amount_usd, amount) && amountsEqual(p.amount_usd, amount) &&
clearedWithinDaysBefore(p, postingISO, 45) clearedWithinDaysBefore(p, postingISO, 45),
); );
if (byAmount.length === 1) return { payment: byAmount[0], matchedBy: "amount+cleared" }; if (byAmount.length === 1) return { payment: byAmount[0], matchedBy: "amount+cleared" };
if (byAmount.length > 1) { if (byAmount.length > 1) {
@ -455,7 +469,7 @@ export function applyEvent(payment, classified, eventDateISO, via = null) {
(h) => (h) =>
h.event === historyEvent.event && h.event === historyEvent.event &&
h.date === historyEvent.date && h.date === historyEvent.date &&
amountsEqual(h.amount, amount) amountsEqual(h.amount, amount),
); );
if (alreadyApplied) return { kind: "noop", updates: null, historyEvent: null }; if (alreadyApplied) return { kind: "noop", updates: null, historyEvent: null };
@ -512,9 +526,7 @@ export function isValidISODate(s) {
if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false; if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false;
const [y, mo, d] = s.split("-").map(Number); const [y, mo, d] = s.split("-").map(Number);
const dt = new Date(Date.UTC(y, mo - 1, d)); const dt = new Date(Date.UTC(y, mo - 1, d));
return ( return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d
);
} }
// Optional {fromDate, toDate} replay payload. The default is a trailing // Optional {fromDate, toDate} replay payload. The default is a trailing
@ -560,12 +572,12 @@ export function resolveDateRange(event, now = new Date(), endpoint = "previous-d
if (!isValidISODate(fromDate) || !isValidISODate(toDate)) { if (!isValidISODate(fromDate) || !isValidISODate(toDate)) {
throw new Error( throw new Error(
`fromDate/toDate must be valid YYYY-MM-DD strings: ` + `fromDate/toDate must be valid YYYY-MM-DD strings: ` +
`fromDate=${logSafe(fromDate)}, toDate=${logSafe(toDate)}` `fromDate=${logSafe(fromDate)}, toDate=${logSafe(toDate)}`,
); );
} }
if (fromDate > toDate) { if (fromDate > toDate) {
throw new Error( throw new Error(
`fromDate must be <= toDate: fromDate=${logSafe(fromDate)}, toDate=${logSafe(toDate)}` `fromDate must be <= toDate: fromDate=${logSafe(fromDate)}, toDate=${logSafe(toDate)}`,
); );
} }
return { fromDate, toDate }; return { fromDate, toDate };
@ -647,9 +659,9 @@ const BALANCE_FIELDS = {
"060": "current_available", "060": "current_available",
"072": "float_one_day", "072": "float_one_day",
"074": "float_two_day", "074": "float_two_day",
"100": "total_credits", 100: "total_credits",
"400": "total_debits", 400: "total_debits",
"450": "total_ach_debits", 450: "total_ach_debits",
}; };
const BALANCE_COUNT_FIELDS = new Set(["100", "400", "450"]); const BALANCE_COUNT_FIELDS = new Set(["100", "400", "450"]);
@ -663,7 +675,12 @@ export function extractBalances(transactions) {
const byDate = new Map(); const byDate = new Map();
let datesTruncated = 0; let datesTruncated = 0;
for (const txn of transactions ?? []) { for (const txn of transactions ?? []) {
if (String(txn?.transactionType ?? "").trim().toLowerCase() !== "summary") continue; if (
String(txn?.transactionType ?? "")
.trim()
.toLowerCase() !== "summary"
)
continue;
const date = String(txn.asOfDate ?? "").trim(); const date = String(txn.asOfDate ?? "").trim();
if (!isValidISODate(date)) continue; if (!isValidISODate(date)) continue;
if (!byDate.has(date)) { if (!byDate.has(date)) {

View file

@ -1,7 +1,4 @@
import { import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
const secrets = new SecretsManagerClient(); const secrets = new SecretsManagerClient();
const EXPENSE_BOT_TOKEN_SECRET_NAME = process.env.EXPENSE_BOT_TOKEN_SECRET_NAME; const EXPENSE_BOT_TOKEN_SECRET_NAME = process.env.EXPENSE_BOT_TOKEN_SECRET_NAME;
@ -10,7 +7,7 @@ let cachedToken;
async function getBotToken() { async function getBotToken() {
if (cachedToken) return cachedToken; if (cachedToken) return cachedToken;
const { SecretString } = await secrets.send( const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: EXPENSE_BOT_TOKEN_SECRET_NAME }) new GetSecretValueCommand({ SecretId: EXPENSE_BOT_TOKEN_SECRET_NAME }),
); );
cachedToken = SecretString; cachedToken = SecretString;
return cachedToken; return cachedToken;
@ -81,9 +78,7 @@ export const handler = async (event) => {
const text = originalText.replace(REACT_HINT_RE, "").trim(); const text = originalText.replace(REACT_HINT_RE, "").trim();
const nextStage = STAGES[toChannel]; const nextStage = STAGES[toChannel];
const nextLabel = nextStage ? nextStage.label : null; const nextLabel = nextStage ? nextStage.label : null;
const reactLine = nextLabel const reactLine = nextLabel ? `\n\n_React_ :white_check_mark: _to advance to ${nextLabel}_` : "";
? `\n\n_React_ :white_check_mark: _to advance to ${nextLabel}_`
: "";
let permalinkLine = ""; let permalinkLine = "";
if (isOrigin) { if (isOrigin) {

View file

@ -1,8 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda"; import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
const secrets = new SecretsManagerClient(); const secrets = new SecretsManagerClient();
@ -15,7 +12,7 @@ let cachedSigningSecret;
async function getSigningSecret() { async function getSigningSecret() {
if (cachedSigningSecret) return cachedSigningSecret; if (cachedSigningSecret) return cachedSigningSecret;
const { SecretString } = await secrets.send( const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: EXPENSE_SIGNING_SECRET_NAME }) new GetSecretValueCommand({ SecretId: EXPENSE_SIGNING_SECRET_NAME }),
); );
cachedSigningSecret = SecretString; cachedSigningSecret = SecretString;
return cachedSigningSecret; return cachedSigningSecret;
@ -28,8 +25,7 @@ function verifySignature(body, timestamp, signature, secret) {
if (Math.abs(Date.now() / 1000 - ts) > 300) return false; if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const base = `v0:${timestamp}:${body}`; const base = `v0:${timestamp}:${body}`;
const expected = const expected = "v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
"v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
const expectedBuf = Buffer.from(expected); const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature); const signatureBuf = Buffer.from(signature);
@ -44,7 +40,7 @@ export const handler = async (event) => {
} }
const headers = Object.fromEntries( const headers = Object.fromEntries(
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v]) Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v]),
); );
const timestamp = headers["x-slack-request-timestamp"] || ""; const timestamp = headers["x-slack-request-timestamp"] || "";
const signature = headers["x-slack-signature"] || ""; const signature = headers["x-slack-signature"] || "";
@ -71,7 +67,7 @@ export const handler = async (event) => {
FunctionName: EXPENSE_PROCESSOR_FN, FunctionName: EXPENSE_PROCESSOR_FN,
InvocationType: "Event", InvocationType: "Event",
Payload: JSON.stringify(payload.event), Payload: JSON.stringify(payload.event),
}) }),
); );
} }

View file

@ -1,6 +1,12 @@
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand, ScanCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
ScanCommand,
UpdateCommand,
} from "@aws-sdk/lib-dynamodb";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager"; import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import { import {
applyEvent, applyEvent,
@ -36,9 +42,7 @@ const UNKNOWN_CODES_CAP = 20;
export const postingDate = (txn) => { export const postingDate = (txn) => {
// First NON-EMPTY — an empty-string asOfDate must not shadow a populated // First NON-EMPTY — an empty-string asOfDate must not shadow a populated
// valueDate (?? only skips null/undefined). // valueDate (?? only skips null/undefined).
const raw = [txn.asOfDate, txn.valueDate] const raw = [txn.asOfDate, txn.valueDate].map((v) => String(v ?? "").trim()).find(Boolean);
.map((v) => String(v ?? "").trim())
.find(Boolean);
const iso = isValidISODate(raw) ? raw : toISODate(raw); const iso = isValidISODate(raw) ? raw : toISODate(raw);
if (!iso) return null; if (!iso) return null;
// Plausibility window mirroring the CSV path's isPlausibleSendYear (#65): // Plausibility window mirroring the CSV path's isPlausibleSendYear (#65):
@ -55,7 +59,7 @@ let cachedCreds;
async function getReportingCreds() { async function getReportingCreds() {
if (cachedCreds) return cachedCreds; if (cachedCreds) return cachedCreds;
const { SecretString } = await secrets.send( const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: process.env.BOA_REPORTING_SECRET_NAME }) new GetSecretValueCommand({ SecretId: process.env.BOA_REPORTING_SECRET_NAME }),
); );
cachedCreds = JSON.parse(SecretString); cachedCreds = JSON.parse(SecretString);
return cachedCreds; return cachedCreds;
@ -106,18 +110,21 @@ export const handler = async (event) => {
const bearerToken = await getAccessToken(appId, clientId, clientSecret); const bearerToken = await getAccessToken(appId, clientId, clientSecret);
const res = await fetch(`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/${endpoint}`, { const res = await fetch(
method: "POST", `${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/${endpoint}`,
headers: { {
"Content-Type": "application/json", method: "POST",
Authorization: `Bearer ${bearerToken}`, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify({
fromDate,
toDate,
accounts: [{ accountNumber, bankId }],
}),
}, },
body: JSON.stringify({ );
fromDate,
toDate,
accounts: [{ accountNumber, bankId }],
}),
});
if (!res.ok) { if (!res.ok) {
throw new Error(`BoA API error: HTTP ${res.status}`); throw new Error(`BoA API error: HTTP ${res.status}`);
@ -139,7 +146,7 @@ export const handler = async (event) => {
Key: `raw/${endpoint}/${fromDate}_${toDate}/${runAt}.json`, Key: `raw/${endpoint}/${fromDate}_${toDate}/${runAt}.json`,
Body: rawBody, Body: rawBody,
ContentType: "application/json", ContentType: "application/json",
}) }),
); );
} catch (err) { } catch (err) {
archiveError = true; archiveError = true;
@ -155,7 +162,7 @@ export const handler = async (event) => {
// Response shape: { accountTransactions: [{ accountNumber, bankId, currency, transactions: [...] }] } // Response shape: { accountTransactions: [{ accountNumber, bankId, currency, transactions: [...] }] }
const allTransactions = (data.accountTransactions || []).flatMap( const allTransactions = (data.accountTransactions || []).flatMap(
(acct) => acct.transactions || [] (acct) => acct.transactions || [],
); );
// Process in posting-date order so multi-day replays apply paid -> return // Process in posting-date order so multi-day replays apply paid -> return
@ -168,7 +175,7 @@ export const handler = async (event) => {
classifiedTxns.sort( classifiedTxns.sort(
(a, b) => (a, b) =>
String(postingDate(a.txn) ?? "").localeCompare(String(postingDate(b.txn) ?? "")) || String(postingDate(a.txn) ?? "").localeCompare(String(postingDate(b.txn) ?? "")) ||
directionRank(a.classified.direction) - directionRank(b.classified.direction) directionRank(a.classified.direction) - directionRank(b.classified.direction),
); );
// Load all payment records (Check AND ACH — ACH is bank-confirmed here // Load all payment records (Check AND ACH — ACH is bank-confirmed here
@ -182,7 +189,7 @@ export const handler = async (event) => {
FilterExpression: "begins_with(pk, :prefix)", FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "payment#" }, ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey, ExclusiveStartKey: lastKey,
}) }),
); );
payments.push(...result.Items); payments.push(...result.Items);
lastKey = result.LastEvaluatedKey; lastKey = result.LastEvaluatedKey;
@ -216,7 +223,7 @@ export const handler = async (event) => {
console.error( console.error(
`Unmatched ${classified.event}: check=${logSafe(classified.checkNumber || classified.embeddedPaymentNumber)}, ` + `Unmatched ${classified.event}: check=${logSafe(classified.checkNumber || classified.embeddedPaymentNumber)}, ` +
`amount=${classified.amount}, reason=${reason} ` + `amount=${classified.amount}, reason=${reason} ` +
`(bankRef: ${logSafe(classified.bankReference)})` `(bankRef: ${logSafe(classified.bankReference)})`,
); );
}; };
@ -243,7 +250,7 @@ export const handler = async (event) => {
console.error( console.error(
`Unknown check-shaped transaction: code=${logSafe(classified.code)}, ` + `Unknown check-shaped transaction: code=${logSafe(classified.code)}, ` +
`description=${logSafe(classified.description)}, ` + `description=${logSafe(classified.description)}, ` +
`ref=${logSafe(classified.customerReference)}, amount=${classified.amount}` `ref=${logSafe(classified.customerReference)}, amount=${classified.amount}`,
); );
continue; continue;
} }
@ -274,7 +281,7 @@ export const handler = async (event) => {
// Audit trail for the loosest ACH rung. // Audit trail for the loosest ACH rung.
console.log( console.log(
`ACH vendor+amount match: payee=${logSafe(match.payment.payee)}, ` + `ACH vendor+amount match: payee=${logSafe(match.payment.payee)}, ` +
`bankVendor=${logSafe(classified.vendorText)}, pmt=${logSafe(classified.pmtId)}` `bankVendor=${logSafe(classified.vendorText)}, pmt=${logSafe(classified.pmtId)}`,
); );
} }
@ -298,7 +305,7 @@ export const handler = async (event) => {
if (err.name !== "ConditionalCheckFailedException") throw err; if (err.name !== "ConditionalCheckFailedException") throw err;
if (attempt === 1) break; if (attempt === 1) break;
const { Item: fresh } = await ddb.send( const { Item: fresh } = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: target.pk } }) new GetCommand({ TableName: TABLE_NAME, Key: { pk: target.pk } }),
); );
if (!fresh) break; if (!fresh) break;
// Refresh the in-memory record in place (it is shared with the // Refresh the in-memory record in place (it is shared with the
@ -320,7 +327,7 @@ export const handler = async (event) => {
if (outcome !== "written") { if (outcome !== "written") {
summary.write_conflicts++; summary.write_conflicts++;
console.error( console.error(
`Write conflict (gave up after retry): pk=${logSafe(target.pk)}, event=${classified.event}` `Write conflict (gave up after retry): pk=${logSafe(target.pk)}, event=${classified.event}`,
); );
continue; continue;
} }
@ -336,7 +343,7 @@ export const handler = async (event) => {
console.log( console.log(
`${applied.kind}: check ${logSafe(target.check_number)} via ${match.matchedBy} ` + `${applied.kind}: check ${logSafe(target.check_number)} via ${match.matchedBy} ` +
`(bankRef: ${logSafe(classified.bankReference)})` `(bankRef: ${logSafe(classified.bankReference)})`,
); );
} }
@ -350,12 +357,12 @@ export const handler = async (event) => {
if (stale.staleAchCount) { if (stale.staleAchCount) {
console.error( console.error(
`Stale ACH (no bank settlement, sent > 16 days ago): ${stale.staleAchCount} records, ` + `Stale ACH (no bank settlement, sent > 16 days ago): ${stale.staleAchCount} records, ` +
`checks=${logSafe(stale.staleAch.map((s) => s.check_number).join(","))}` `checks=${logSafe(stale.staleAch.map((s) => s.check_number).join(","))}`,
); );
} }
if (stale.staleChecksCount) { if (stale.staleChecksCount) {
console.error( console.error(
`Stale checks (no bank activity, issued > 60 days ago): ${stale.staleChecksCount} records` `Stale checks (no bank activity, issued > 60 days ago): ${stale.staleChecksCount} records`,
); );
} }
} }
@ -380,7 +387,7 @@ export const handler = async (event) => {
}, },
ConditionExpression: "attribute_not_exists(run_at) OR run_at <= :runAt", ConditionExpression: "attribute_not_exists(run_at) OR run_at <= :runAt",
ExpressionAttributeValues: { ":runAt": runAt }, ExpressionAttributeValues: { ":runAt": runAt },
}) }),
); );
balanceDatesWritten.push(snap.as_of_date); balanceDatesWritten.push(snap.as_of_date);
} catch (err) { } catch (err) {
@ -427,14 +434,14 @@ export const handler = async (event) => {
: {}), : {}),
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60, ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
}, },
}) }),
); );
if (summary.unmatched_count || summary.unknown_count || summary.write_conflicts) { if (summary.unmatched_count || summary.unknown_count || summary.write_conflicts) {
console.error( console.error(
`Reconciliation ${fromDate}..${toDate}: ${summary.unmatched_count} unmatched, ` + `Reconciliation ${fromDate}..${toDate}: ${summary.unmatched_count} unmatched, ` +
`${summary.unknown_count} unknown-code transactions, ` + `${summary.unknown_count} unknown-code transactions, ` +
`${summary.write_conflicts} write conflicts (see ${logSafe(runKey)})` `${summary.write_conflicts} write conflicts (see ${logSafe(runKey)})`,
); );
} }
@ -442,7 +449,7 @@ export const handler = async (event) => {
`Processed ${summary.transactions_seen} transactions ${fromDate}..${toDate}: ` + `Processed ${summary.transactions_seen} transactions ${fromDate}..${toDate}: ` +
`${summary.matched} matched, ${summary.applied} applied, ` + `${summary.matched} matched, ${summary.applied} applied, ` +
`${summary.already_applied} already applied, ${summary.redeposits} redeposits, ` + `${summary.already_applied} already applied, ${summary.redeposits} redeposits, ` +
`${summary.voided_and_bounced} voided-and-bounced, ${summary.unmatched_count} unmatched` `${summary.voided_and_bounced} voided-and-bounced, ${summary.unmatched_count} unmatched`,
); );
return { return {

View file

@ -1,6 +1,12 @@
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, GetCommand, PutCommand, UpdateCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
UpdateCommand,
ScanCommand,
} from "@aws-sdk/lib-dynamodb";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager"; import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import { parse } from "csv-parse/sync"; import { parse } from "csv-parse/sync";
import { toISODate, toCanonicalMDY, isPlausibleSendYear } from "./dates.js"; import { toISODate, toCanonicalMDY, isPlausibleSendYear } from "./dates.js";
@ -15,7 +21,7 @@ let cachedCreds;
async function getCheckMgmtCreds() { async function getCheckMgmtCreds() {
if (cachedCreds) return cachedCreds; if (cachedCreds) return cachedCreds;
const { SecretString } = await secrets.send( const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: process.env.BOA_CHECK_MGMT_SECRET_NAME }) new GetSecretValueCommand({ SecretId: process.env.BOA_CHECK_MGMT_SECRET_NAME }),
); );
cachedCreds = JSON.parse(SecretString); cachedCreds = JSON.parse(SecretString);
return cachedCreds; return cachedCreds;
@ -47,7 +53,11 @@ const logSafe = (v) => JSON.stringify(String(v ?? "").slice(0, 64));
// Comma-tolerant amount parsing, shared by the CSV path and the backfill so // Comma-tolerant amount parsing, shared by the CSV path and the backfill so
// a hand-inserted "1,234.56" string record can't NaN out of registration. // a hand-inserted "1,234.56" string record can't NaN out of registration.
const parseAmount = (value) => { const parseAmount = (value) => {
const num = parseFloat(String(value || "0").replace(/,/g, "").trim()); const num = parseFloat(
String(value || "0")
.replace(/,/g, "")
.trim(),
);
return isNaN(num) ? 0 : num; return isNaN(num) ? 0 : num;
}; };
@ -58,14 +68,16 @@ async function backfillBoA() {
const submitted = new Set(); const submitted = new Set();
let txnKey; let txnKey;
do { do {
const txnScan = await ddb.send(new ScanCommand({ const txnScan = await ddb.send(
TableName: TABLE_NAME, new ScanCommand({
FilterExpression: "begins_with(pk, :prefix) AND success = :t AND #action = :add", TableName: TABLE_NAME,
ExpressionAttributeNames: { "#action": "action" }, FilterExpression: "begins_with(pk, :prefix) AND success = :t AND #action = :add",
ExpressionAttributeValues: { ":prefix": "boa_txn#", ":t": true, ":add": "add_Issue" }, ExpressionAttributeNames: { "#action": "action" },
ProjectionExpression: "check_numbers", ExpressionAttributeValues: { ":prefix": "boa_txn#", ":t": true, ":add": "add_Issue" },
...(txnKey && { ExclusiveStartKey: txnKey }), ProjectionExpression: "check_numbers",
})); ...(txnKey && { ExclusiveStartKey: txnKey }),
}),
);
for (const item of txnScan.Items || []) { for (const item of txnScan.Items || []) {
for (const cn of item.check_numbers || []) submitted.add(cn); for (const cn of item.check_numbers || []) submitted.add(cn);
} }
@ -76,14 +88,16 @@ async function backfillBoA() {
const toSubmit = []; const toSubmit = [];
let payKey; let payKey;
do { do {
const payScan = await ddb.send(new ScanCommand({ const payScan = await ddb.send(
TableName: TABLE_NAME, new ScanCommand({
FilterExpression: "begins_with(pk, :prefix) AND #method = :check", TableName: TABLE_NAME,
ExpressionAttributeNames: { "#method": "method", "#status": "status" }, FilterExpression: "begins_with(pk, :prefix) AND #method = :check",
ExpressionAttributeValues: { ":prefix": "payment#", ":check": "Check" }, ExpressionAttributeNames: { "#method": "method", "#status": "status" },
ProjectionExpression: "check_number, amount_usd, send_payment_on, #status", ExpressionAttributeValues: { ":prefix": "payment#", ":check": "Check" },
...(payKey && { ExclusiveStartKey: payKey }), ProjectionExpression: "check_number, amount_usd, send_payment_on, #status",
})); ...(payKey && { ExclusiveStartKey: payKey }),
}),
);
for (const item of payScan.Items || []) { for (const item of payScan.Items || []) {
if (submitted.has(item.check_number)) continue; if (submitted.has(item.check_number)) continue;
if (cancelStatuses.includes((item.status || "").toLowerCase())) continue; if (cancelStatuses.includes((item.status || "").toLowerCase())) continue;
@ -91,7 +105,9 @@ async function backfillBoA() {
const issueDate = toISODate(item.send_payment_on); const issueDate = toISODate(item.send_payment_on);
const amount = parseAmount(item.amount_usd); const amount = parseAmount(item.amount_usd);
if (!issueDate || !(amount > 0)) { if (!issueDate || !(amount > 0)) {
console.error(`Backfill skipping check ${logSafe(item.check_number)}: missing issue date or amount`); console.error(
`Backfill skipping check ${logSafe(item.check_number)}: missing issue date or amount`,
);
continue; continue;
} }
toSubmit.push({ toSubmit.push({
@ -110,7 +126,13 @@ async function backfillBoA() {
console.log(`Backfill: ${toSubmit.length} checks to submit`); console.log(`Backfill: ${toSubmit.length} checks to submit`);
const { appId, clientId, token: clientSecret, accountNumber, companyId } = await getCheckMgmtCreds(); const {
appId,
clientId,
token: clientSecret,
accountNumber,
companyId,
} = await getCheckMgmtCreds();
const bearerToken = await getAccessToken(appId, clientId, clientSecret); const bearerToken = await getAccessToken(appId, clientId, clientSecret);
const BOA_BATCH_SIZE = 100; const BOA_BATCH_SIZE = 100;
@ -129,24 +151,24 @@ async function backfillBoA() {
})); }));
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const res = await fetch( const res = await fetch(`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, {
`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, method: "POST",
{ headers: {
method: "POST", "Content-Type": "application/json",
headers: { Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json", companyId,
Authorization: `Bearer ${bearerToken}`, },
companyId, body: JSON.stringify({ issueList }),
}, });
body: JSON.stringify({ issueList }),
}
);
const text = await res.text(); const text = await res.text();
const headers = Object.fromEntries(res.headers.entries()); const headers = Object.fromEntries(res.headers.entries());
const transactionId = const transactionId =
headers["transactionid"] || headers["x-transactionid"] || headers["transactionid"] ||
headers["x-correlation-id"] || headers["x-cashpro-transaction-id"] || null; headers["x-transactionid"] ||
headers["x-correlation-id"] ||
headers["x-cashpro-transaction-id"] ||
null;
let data = {}; let data = {};
let parseError = null; let parseError = null;
if (text.trim()) { if (text.trim()) {
@ -159,23 +181,25 @@ async function backfillBoA() {
parseError = new Error("BoA returned an empty response body"); parseError = new Error("BoA returned an empty response body");
} }
await ddb.send(new PutCommand({ await ddb.send(
TableName: TABLE_NAME, new PutCommand({
Item: { TableName: TABLE_NAME,
pk: `boa_txn#${timestamp}#add_Issue`, Item: {
timestamp, pk: `boa_txn#${timestamp}#add_Issue`,
action: "add_Issue", timestamp,
backfill: true, action: "add_Issue",
http_status: res.status, backfill: true,
transaction_id: transactionId, http_status: res.status,
check_numbers: items.map((i) => i.checkNumber), transaction_id: transactionId,
total_amount: items.reduce((sum, i) => sum + parseFloat(i.amount), 0).toFixed(2), check_numbers: items.map((i) => i.checkNumber),
success: res.ok, total_amount: items.reduce((sum, i) => sum + parseFloat(i.amount), 0).toFixed(2),
processed_items: data.processedItems || 0, success: res.ok,
total_items: data.totalItems || 0, processed_items: data.processedItems || 0,
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60, total_items: data.totalItems || 0,
}, ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
})); },
}),
);
if (res.ok && parseError) { if (res.ok && parseError) {
throw new Error(`Backfill ${label} failed: BoA returned invalid JSON (HTTP ${res.status})`); throw new Error(`Backfill ${label} failed: BoA returned invalid JSON (HTTP ${res.status})`);
@ -214,7 +238,9 @@ async function backfillBoA() {
totalSkippedDuplicates += dupeCheckNumbers.size; totalSkippedDuplicates += dupeCheckNumbers.size;
const retryItems = batch.filter((item) => !dupeCheckNumbers.has(item.checkNumber)); const retryItems = batch.filter((item) => !dupeCheckNumbers.has(item.checkNumber));
console.log(`Backfill batch ${batchNum}: ${dupeCheckNumbers.size} already in BoA, ${retryItems.length} to retry`); console.log(
`Backfill batch ${batchNum}: ${dupeCheckNumbers.size} already in BoA, ${retryItems.length} to retry`,
);
if (retryItems.length === 0) continue; if (retryItems.length === 0) continue;
@ -226,15 +252,24 @@ async function backfillBoA() {
const detail = retryResult.parseError const detail = retryResult.parseError
? "BoA returned a non-JSON response" ? "BoA returned a non-JSON response"
: "BoA returned a non-duplicate error"; : "BoA returned a non-duplicate error";
console.error(`Backfill batch ${batchNum} retry failed: HTTP ${retryResult.status}, txnId=${retryResult.transactionId}`); console.error(
throw new Error(`Backfill batch ${batchNum} retry failed: ${detail} (HTTP ${retryResult.status})`); `Backfill batch ${batchNum} retry failed: HTTP ${retryResult.status}, txnId=${retryResult.transactionId}`,
);
throw new Error(
`Backfill batch ${batchNum} retry failed: ${detail} (HTTP ${retryResult.status})`,
);
} }
totalProcessed += retryResult.data.processedItems; totalProcessed += retryResult.data.processedItems;
console.log(`Backfill batch ${batchNum} retry: ${retryResult.data.processedItems} processed`); console.log(`Backfill batch ${batchNum} retry: ${retryResult.data.processedItems} processed`);
} }
console.log(`Backfill complete: ${totalProcessed} submitted, ${totalSkippedDuplicates} already in BoA`); console.log(
return { statusCode: 200, body: `Backfilled ${totalProcessed} checks, ${totalSkippedDuplicates} already in BoA` }; `Backfill complete: ${totalProcessed} submitted, ${totalSkippedDuplicates} already in BoA`,
);
return {
statusCode: 200,
body: `Backfilled ${totalProcessed} checks, ${totalSkippedDuplicates} already in BoA`,
};
} }
export const handler = async (event) => { export const handler = async (event) => {
@ -268,11 +303,11 @@ export const handler = async (event) => {
// Status progression ranks — higher number = further along in lifecycle // Status progression ranks — higher number = further along in lifecycle
// Once a payment reaches a higher rank, CSV cannot move it backward // Once a payment reaches a higher rank, CSV cannot move it backward
const statusRank = { const statusRank = {
"scheduled": 1, scheduled: 1,
"payment submitted": 2, "payment submitted": 2,
"issued": 3, issued: 3,
"outstanding": 4, outstanding: 4,
"cleared": 5, cleared: 5,
}; };
const newChecks = []; const newChecks = [];
@ -308,7 +343,7 @@ export const handler = async (event) => {
// Check if record already exists (for detecting new vs updated) // Check if record already exists (for detecting new vs updated)
const { Item: existing } = await ddb.send( const { Item: existing } = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk } }) new GetCommand({ TableName: TABLE_NAME, Key: { pk } }),
); );
// Don't overwrite status once the bank has confirmed it as Cleared // Don't overwrite status once the bank has confirmed it as Cleared
@ -397,7 +432,7 @@ export const handler = async (event) => {
"#status": "status", "#status": "status",
}, },
ExpressionAttributeValues: values, ExpressionAttributeValues: values,
}) }),
); );
count++; count++;
@ -418,7 +453,13 @@ 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 { appId, clientId, token: clientSecret, accountNumber, companyId } = await getCheckMgmtCreds(); const {
appId,
clientId,
token: clientSecret,
accountNumber,
companyId,
} = await getCheckMgmtCreds();
const bearerToken = await getAccessToken(appId, clientId, clientSecret); const bearerToken = await getAccessToken(appId, clientId, clientSecret);
@ -436,18 +477,15 @@ export const handler = async (event) => {
const checkNumbers = items.map((i) => i.checkNumber); const checkNumbers = items.map((i) => i.checkNumber);
const totalAmount = items.reduce((sum, i) => sum + parseFloat(i.amount), 0); const totalAmount = items.reduce((sum, i) => sum + parseFloat(i.amount), 0);
const res = await fetch( const res = await fetch(`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, {
`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, method: "POST",
{ headers: {
method: "POST", "Content-Type": "application/json",
headers: { Authorization: `Bearer ${bearerToken}`,
"Content-Type": "application/json", companyId,
Authorization: `Bearer ${bearerToken}`, },
companyId, body: JSON.stringify({ issueList }),
}, });
body: JSON.stringify({ issueList }),
}
);
const text = await res.text(); const text = await res.text();
const headers = Object.fromEntries(res.headers.entries()); const headers = Object.fromEntries(res.headers.entries());
@ -489,7 +527,7 @@ export const handler = async (event) => {
await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: record })); await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: record }));
console.log( console.log(
`BoA ${action}: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed, txnId=${transactionId}` `BoA ${action}: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed, txnId=${transactionId}`,
); );
return data; return data;
}; };
@ -499,7 +537,9 @@ export const handler = async (event) => {
const submitInBatches = async (items, action) => { const submitInBatches = async (items, action) => {
for (let i = 0; i < items.length; i += BOA_BATCH_SIZE) { for (let i = 0; i < items.length; i += BOA_BATCH_SIZE) {
const batch = items.slice(i, i + BOA_BATCH_SIZE); const batch = items.slice(i, i + BOA_BATCH_SIZE);
console.log(`BoA ${action} batch ${Math.floor(i / BOA_BATCH_SIZE) + 1}: ${batch.length} items`); console.log(
`BoA ${action} batch ${Math.floor(i / BOA_BATCH_SIZE) + 1}: ${batch.length} items`,
);
await submitToBoA(batch, action); await submitToBoA(batch, action);
} }
}; };
@ -524,11 +564,11 @@ export const handler = async (event) => {
last_file_count: count, last_file_count: count,
last_rejected_count: rejectedRows.length, last_rejected_count: rejectedRows.length,
}, },
}) }),
); );
console.log( console.log(
`Upserted ${count} payments, ${newChecks.length} issued, ${cancelChecks.length} cancelled, ${rejectedRows.length} rejected` `Upserted ${count} payments, ${newChecks.length} issued, ${cancelChecks.length} cancelled, ${rejectedRows.length} rejected`,
); );
// All valid rows are processed and BoA submissions are done; now fail the // All valid rows are processed and BoA submissions are done; now fail the
@ -537,10 +577,12 @@ export const handler = async (event) => {
// re-offered to BoA. // re-offered to BoA.
if (rejectedRows.length) { if (rejectedRows.length) {
for (const r of rejectedRows) { for (const r of rejectedRows) {
console.error(`Rejected row: check ${logSafe(r.checkNumber)}, ${r.field}=${logSafe(r.value)}`); console.error(
`Rejected row: check ${logSafe(r.checkNumber)}, ${r.field}=${logSafe(r.value)}`,
);
} }
throw new Error( throw new Error(
`${rejectedRows.length} of ${normalizedRows.length} rows rejected (bad dates or missing BoA data; ${count} valid rows processed)` `${rejectedRows.length} of ${normalizedRows.length} rows rejected (bad dates or missing BoA data; ${count} valid rows processed)`,
); );
} }

View file

@ -14,7 +14,7 @@ let cachedToken;
async function getSlackToken() { async function getSlackToken() {
if (cachedToken) return cachedToken; if (cachedToken) return cachedToken;
const { SecretString } = await secrets.send( const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: process.env.SLACK_BOT_TOKEN_SECRET_NAME }) new GetSecretValueCommand({ SecretId: process.env.SLACK_BOT_TOKEN_SECRET_NAME }),
); );
cachedToken = SecretString; cachedToken = SecretString;
return cachedToken; return cachedToken;
@ -24,7 +24,7 @@ let cachedSigningSecret;
async function getSigningSecret() { async function getSigningSecret() {
if (cachedSigningSecret) return cachedSigningSecret; if (cachedSigningSecret) return cachedSigningSecret;
const { SecretString } = await secrets.send( const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: SLACK_SIGNING_SECRET_NAME }) new GetSecretValueCommand({ SecretId: SLACK_SIGNING_SECRET_NAME }),
); );
cachedSigningSecret = SecretString; cachedSigningSecret = SecretString;
return cachedSigningSecret; return cachedSigningSecret;
@ -39,8 +39,7 @@ function verifySignature(body, timestamp, signature, secret) {
if (Math.abs(Date.now() / 1000 - ts) > 300) return false; if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const base = `v0:${timestamp}:${body}`; const base = `v0:${timestamp}:${body}`;
const expected = const expected = "v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
"v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
const expectedBuf = Buffer.from(expected); const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature); const signatureBuf = Buffer.from(signature);
@ -51,12 +50,15 @@ function verifySignature(body, timestamp, signature, secret) {
// --- Shared helpers used by both home view and modals --- // --- Shared helpers used by both home view and modals ---
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)
);
const formatDisplayDate = (date) => const formatDisplayDate = (date) =>
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"]; const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"];
@ -138,7 +140,7 @@ export function categorizePayments(payments) {
// Oldest return first: the longest-unpaid vendor is the most overdue // Oldest return first: the longest-unpaid vendor is the most overdue
// decision. Missing returned_date sorts first (unknown = assume worst). // decision. Missing returned_date sorts first (unknown = assume worst).
returnedPayments.sort((a, b) => returnedPayments.sort((a, b) =>
String(a.returned_date || "").localeCompare(String(b.returned_date || "")) String(a.returned_date || "").localeCompare(String(b.returned_date || "")),
); );
const bucketedOutstanding = ageBuckets.map((bucket) => { const bucketedOutstanding = ageBuckets.map((bucket) => {
@ -151,7 +153,15 @@ export function categorizePayments(payments) {
return { ...bucket, items, total }; return { ...bucket, items, total };
}); });
return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments }; return {
today,
daysSince,
scheduledChecks,
scheduledACH,
outstandingChecks,
bucketedOutstanding,
returnedPayments,
};
} }
// --- Main handler --- // --- Main handler ---
@ -167,7 +177,7 @@ export const handler = async (event) => {
// else — without this, an unauthenticated caller could forge events and // else — without this, an unauthenticated caller could forge events and
// exfiltrate payment data via views.publish. // exfiltrate payment data via views.publish.
const headers = Object.fromEntries( const headers = Object.fromEntries(
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v]) Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v]),
); );
const timestamp = headers["x-slack-request-timestamp"] || ""; const timestamp = headers["x-slack-request-timestamp"] || "";
const signature = headers["x-slack-signature"] || ""; const signature = headers["x-slack-signature"] || "";
@ -208,7 +218,7 @@ export const handler = async (event) => {
async function publishHomeView(userId, expanded) { async function publishHomeView(userId, expanded) {
const { Item: metadata } = await ddb.send( const { Item: metadata } = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } }) new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } }),
); );
const [payments, boaTransactions, cashPosition] = await Promise.all([ const [payments, boaTransactions, cashPosition] = await Promise.all([
@ -266,13 +276,16 @@ async function handleBlockAction(body) {
} }
const payments = await scanPayments(); const payments = await scanPayments();
const { today, daysSince, scheduledChecks, scheduledACH, bucketedOutstanding } = categorizePayments(payments); const { today, daysSince, scheduledChecks, scheduledACH, bucketedOutstanding } =
categorizePayments(payments);
let modalTitle = ""; let modalTitle = "";
let items = []; let items = [];
if (actionId.startsWith("view_scheduled_")) { if (actionId.startsWith("view_scheduled_")) {
const dateKey = actionId.replace("view_scheduled_checks_", "").replace("view_scheduled_ach_", ""); const dateKey = actionId
.replace("view_scheduled_checks_", "")
.replace("view_scheduled_ach_", "");
const method = actionId.includes("_checks_") ? "Check" : "ACH"; const method = actionId.includes("_checks_") ? "Check" : "ACH";
const source = method === "Check" ? scheduledChecks : scheduledACH; const source = method === "Check" ? scheduledChecks : scheduledACH;
@ -351,7 +364,10 @@ function buildPaymentListBlocks(items) {
blocks.push({ blocks.push({
type: "section", type: "section",
fields: [ fields: [
{ type: "mrkdwn", text: `*${payee}*\n${p.method === "ACH" ? "Reference" : "Check"} #${checkNum}` }, {
type: "mrkdwn",
text: `*${payee}*\n${p.method === "ACH" ? "Reference" : "Check"} #${checkNum}`,
},
{ type: "mrkdwn", text: `*${formatCurrency(p.amount_usd)}*\n${dateStr}` }, { type: "mrkdwn", text: `*${formatCurrency(p.amount_usd)}*\n${dateStr}` },
], ],
}); });
@ -372,7 +388,7 @@ async function scanBoATransactions() {
FilterExpression: "begins_with(pk, :prefix)", FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "boa_txn#" }, ExpressionAttributeValues: { ":prefix": "boa_txn#" },
ExclusiveStartKey: lastKey, ExclusiveStartKey: lastKey,
}) }),
); );
items.push(...result.Items); items.push(...result.Items);
lastKey = result.LastEvaluatedKey; lastKey = result.LastEvaluatedKey;
@ -394,7 +410,7 @@ async function scanPayments() {
FilterExpression: "begins_with(pk, :prefix)", FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "payment#" }, ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey, ExclusiveStartKey: lastKey,
}) }),
); );
payments.push(...result.Items); payments.push(...result.Items);
lastKey = result.LastEvaluatedKey; lastKey = result.LastEvaluatedKey;
@ -411,8 +427,10 @@ async function fetchCashPosition() {
const localToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const localToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const toDateStr = (d) => const toDateStr = (d) =>
d.getFullYear() + "-" + d.getFullYear() +
String(d.getMonth() + 1).padStart(2, "0") + "-" + "-" +
String(d.getMonth() + 1).padStart(2, "0") +
"-" +
String(d.getDate()).padStart(2, "0"); String(d.getDate()).padStart(2, "0");
const todayStr = toDateStr(localToday); const todayStr = toDateStr(localToday);
@ -429,9 +447,7 @@ async function fetchCashPosition() {
} }
const results = await Promise.allSettled( const results = await Promise.allSettled(
keys.map(({ key }) => keys.map(({ key }) => ddb.send(new GetCommand({ TableName: TABLE_NAME, Key: { pk: key } }))),
ddb.send(new GetCommand({ TableName: TABLE_NAME, Key: { pk: key } }))
)
); );
let intraday = null; let intraday = null;
@ -441,7 +457,7 @@ async function fetchCashPosition() {
console.error( console.error(
"Cash-position intraday GetItem failed:", "Cash-position intraday GetItem failed:",
logSafe(results[0].reason?.name), logSafe(results[0].reason?.name),
logSafe(results[0].reason?.message) logSafe(results[0].reason?.message),
); );
} }
@ -453,7 +469,7 @@ async function fetchCashPosition() {
console.error( console.error(
"Cash-position previous-day GetItem failed:", "Cash-position previous-day GetItem failed:",
logSafe(res.reason?.name), logSafe(res.reason?.name),
logSafe(res.reason?.message) logSafe(res.reason?.message),
); );
continue; continue;
} }
@ -472,14 +488,16 @@ function formatBoATimestamp(iso) {
if (!iso) return "unknown"; if (!iso) return "unknown";
const d = new Date(iso); const d = new Date(iso);
if (isNaN(d.getTime())) return "unknown"; if (isNaN(d.getTime())) return "unknown";
return d.toLocaleString("en-US", { return (
timeZone: "America/New_York", d.toLocaleString("en-US", {
month: "short", timeZone: "America/New_York",
day: "numeric", month: "short",
hour: "numeric", day: "numeric",
minute: "2-digit", hour: "numeric",
hour12: true, minute: "2-digit",
}) + " ET"; hour12: true,
}) + " ET"
);
} }
function buildBoABlocks(transactions) { function buildBoABlocks(transactions) {
@ -509,16 +527,15 @@ function buildBoABlocks(transactions) {
for (const t of transactions) { for (const t of transactions) {
const when = formatBoATimestamp(t.timestamp); const when = formatBoATimestamp(t.timestamp);
const checks = Array.isArray(t.check_numbers) ? t.check_numbers : []; const checks = Array.isArray(t.check_numbers) ? t.check_numbers : [];
const checksLabel = checks.length <= 3 const checksLabel =
? checks.join(", ") checks.length <= 3
: `${checks.slice(0, 3).join(", ")} +${checks.length - 3} more`; ? checks.join(", ")
: `${checks.slice(0, 3).join(", ")} +${checks.length - 3} more`;
const statusIcon = t.success ? ":white_check_mark:" : ":x:"; const statusIcon = t.success ? ":white_check_mark:" : ":x:";
const summary = t.success const summary = t.success
? `${t.processed_items}/${t.total_items} processed` ? `${t.processed_items}/${t.total_items} processed`
: `HTTP ${t.http_status} · failed`; : `HTTP ${t.http_status} · failed`;
const txnLine = t.transaction_id const txnLine = t.transaction_id ? `TxnID: \`${t.transaction_id}\`` : "_TxnID not captured_";
? `TxnID: \`${t.transaction_id}\``
: "_TxnID not captured_";
blocks.push({ blocks.push({
type: "section", type: "section",
@ -533,8 +550,22 @@ function buildBoABlocks(transactions) {
return blocks; return blocks;
} }
export function buildHomeView(payments, metadata, boaTransactions = [], cashPosition = null, expanded = []) { export function buildHomeView(
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments } = categorizePayments(payments); payments,
metadata,
boaTransactions = [],
cashPosition = null,
expanded = [],
) {
const {
today,
daysSince,
scheduledChecks,
scheduledACH,
outstandingChecks,
bucketedOutstanding,
returnedPayments,
} = categorizePayments(payments);
const isExpanded = (key) => expanded.includes(key); const isExpanded = (key) => expanded.includes(key);
// Always expanded, no toggle: these sat invisible for months once (#70), // Always expanded, no toggle: these sat invisible for months once (#70),
@ -633,7 +664,14 @@ export function buildHomeView(payments, metadata, boaTransactions = [], cashPosi
const dayTotal = group.payments.reduce((sum, p) => sum + p.amount_usd, 0); const dayTotal = group.payments.reduce((sum, p) => sum + p.amount_usd, 0);
const dateLabel = group.date ? formatDisplayDate(group.date) : "Unknown"; const dateLabel = group.date ? formatDisplayDate(group.date) : "Unknown";
const daysUntil = group.date ? Math.ceil((group.date - today) / (1000 * 60 * 60 * 24)) : null; const daysUntil = group.date ? Math.ceil((group.date - today) / (1000 * 60 * 60 * 24)) : null;
const daysTag = daysUntil === 0 ? ":rotating_light: _Today_" : daysUntil === 1 ? "_Tomorrow_" : daysUntil != null ? `_in ${daysUntil} days_` : ""; const daysTag =
daysUntil === 0
? ":rotating_light: _Today_"
: daysUntil === 1
? "_Tomorrow_"
: daysUntil != null
? `_in ${daysUntil} days_`
: "";
blocks.push({ blocks.push({
type: "section", type: "section",
@ -710,7 +748,10 @@ export function buildHomeView(payments, metadata, boaTransactions = [], cashPosi
// Summary bar // Summary bar
const totalScheduled = scheduledChecks.length + scheduledACH.length; const totalScheduled = scheduledChecks.length + scheduledACH.length;
const totalScheduledAmt = [...scheduledChecks, ...scheduledACH].reduce((sum, p) => sum + p.amount_usd, 0); const totalScheduledAmt = [...scheduledChecks, ...scheduledACH].reduce(
(sum, p) => sum + p.amount_usd,
0,
);
const totalOutstandingAmt = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0); const totalOutstandingAmt = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0);
const totalReturnedAmt = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0); const totalReturnedAmt = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0);
@ -751,17 +792,19 @@ export function buildHomeView(payments, metadata, boaTransactions = [], cashPosi
fields: [ fields: [
{ {
type: "mrkdwn", type: "mrkdwn",
text: balance && balance.current_ledger != null text:
? `:moneybag: *Cash Position* — ${balance.as_of_date}\nLedger ${formatCurrency(balance.current_ledger)} · Available ${formatCurrency(balance.current_available)}` balance && balance.current_ledger != null
: `:moneybag: *Cash Position*\nNot available`, ? `:moneybag: *Cash Position* — ${balance.as_of_date}\nLedger ${formatCurrency(balance.current_ledger)} · Available ${formatCurrency(balance.current_available)}`
: `:moneybag: *Cash Position*\nNot available`,
}, },
{ {
type: "mrkdwn", type: "mrkdwn",
text: intraday && intraday.current_ledger != null text:
? `_Intraday (provisional)_\nLedger ${formatCurrency(intraday.current_ledger)} · Available ${formatCurrency(intraday.current_available)}` intraday && intraday.current_ledger != null
: balance ? `_Intraday (provisional)_\nLedger ${formatCurrency(intraday.current_ledger)} · Available ${formatCurrency(intraday.current_available)}`
? "_Intraday not yet available_" : balance
: "_No balance data_", ? "_Intraday not yet available_"
: "_No balance data_",
}, },
{ {
type: "mrkdwn", type: "mrkdwn",
@ -783,8 +826,20 @@ export function buildHomeView(payments, metadata, boaTransactions = [], cashPosi
}, },
{ type: "divider" }, { type: "divider" },
...buildReturnedBlocks(), ...buildReturnedBlocks(),
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "scheduled_checks", "checks"), ...buildScheduledBlocks(
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "scheduled_ach", "ach"), "Scheduled Checks",
":ledger:",
scheduledChecks,
"scheduled_checks",
"checks",
),
...buildScheduledBlocks(
"Scheduled ACH",
":electric_plug:",
scheduledACH,
"scheduled_ach",
"ach",
),
...buildOutstandingBlocks(), ...buildOutstandingBlocks(),
...buildBoABlocks(boaTransactions), ...buildBoABlocks(boaTransactions),
], ],

View file

@ -191,7 +191,11 @@ test("matcher: number match with wrong amount NEVER falls through — human revi
test("matcher: digit-dropped number we never issued recovers via corroborated amount fallback", () => { test("matcher: digit-dropped number we never issued recovers via corroborated amount fallback", () => {
// 1222000012 is a digit-subsequence of issued 11222000012. // 1222000012 is a digit-subsequence of issued 11222000012.
const payments = [payment({ check_number: "11222000012", amount_usd: 6413 })]; const payments = [payment({ check_number: "11222000012", amount_usd: 6413 })];
const m = matchCheckTransaction({ checkNumber: "1222000012", amount: 6413 }, payments, "2026-07-20"); const m = matchCheckTransaction(
{ checkNumber: "1222000012", amount: 6413 },
payments,
"2026-07-20",
);
assert.equal(m.payment.check_number, "11222000012"); assert.equal(m.payment.check_number, "11222000012");
assert.equal(m.matchedBy, "amount"); assert.equal(m.matchedBy, "amount");
}); });
@ -209,7 +213,7 @@ test("matcher: check_return amount fallback requires a bank-confirmed candidate"
const r1 = matchCheckTransaction( const r1 = matchCheckTransaction(
{ event: "check_return", checkNumber: "1222000012", amount: 6413 }, { event: "check_return", checkNumber: "1222000012", amount: 6413 },
unconfirmed, unconfirmed,
"2026-07-20" "2026-07-20",
); );
assert.equal(r1.payment, undefined); assert.equal(r1.payment, undefined);
@ -219,7 +223,7 @@ test("matcher: check_return amount fallback requires a bank-confirmed candidate"
const r2 = matchCheckTransaction( const r2 = matchCheckTransaction(
{ event: "check_return", checkNumber: "1222000012", amount: 6413 }, { event: "check_return", checkNumber: "1222000012", amount: 6413 },
confirmed, confirmed,
"2026-07-20" "2026-07-20",
); );
assert.equal(r2.payment.check_number, "11222000012"); assert.equal(r2.payment.check_number, "11222000012");
}); });
@ -243,7 +247,11 @@ test("matcher: ambiguous amount fallback is unmatched — no write", () => {
}); });
test("matcher: zero candidates is unmatched", () => { test("matcher: zero candidates is unmatched", () => {
const m = matchCheckTransaction({ checkNumber: "9999", amount: 123.45 }, [payment()], "2026-07-20"); const m = matchCheckTransaction(
{ checkNumber: "9999", amount: 123.45 },
[payment()],
"2026-07-20",
);
assert.equal(m.payment, undefined); assert.equal(m.payment, undefined);
}); });
@ -258,7 +266,11 @@ test("matcher: amount fallback only considers checks issued in the last 120 days
test("matcher: non-Check payments are never check-match candidates", () => { test("matcher: non-Check payments are never check-match candidates", () => {
const payments = [payment({ method: "ACH", check_number: "21222000264", amount_usd: 350 })]; const payments = [payment({ method: "ACH", check_number: "21222000264", amount_usd: 350 })];
const m = matchCheckTransaction({ checkNumber: "21222000264", amount: 350 }, payments, "2026-07-20"); const m = matchCheckTransaction(
{ checkNumber: "21222000264", amount: 350 },
payments,
"2026-07-20",
);
assert.equal(m.payment, undefined); assert.equal(m.payment, undefined);
}); });
@ -293,7 +305,12 @@ test("electronic return: never matches ACH payments", () => {
test("electronic return: ambiguity is unmatched", () => { test("electronic return: ambiguity is unmatched", () => {
const payments = [ const payments = [
payment({ check_number: "1001", amount_usd: 2500, clear_status: "Cleared" }), payment({ check_number: "1001", amount_usd: 2500, clear_status: "Cleared" }),
payment({ check_number: "1002", amount_usd: 2500, clear_status: "Cleared", pk: "payment#1002" }), payment({
check_number: "1002",
amount_usd: 2500,
clear_status: "Cleared",
pk: "payment#1002",
}),
]; ];
const m = matchElectronicReturn({ amount: 2500 }, payments, "2026-07-20"); const m = matchElectronicReturn({ amount: 2500 }, payments, "2026-07-20");
assert.equal(m.payment, undefined); assert.equal(m.payment, undefined);
@ -371,7 +388,7 @@ test("transitions: paid -> returned -> redeposit sequence accumulates history",
assert.equal(p.history.length, 3); assert.equal(p.history.length, 3);
assert.deepEqual( assert.deepEqual(
p.history.map((h) => h.event), p.history.map((h) => h.event),
["check_paid", "check_return", "check_paid"] ["check_paid", "check_return", "check_paid"],
); );
}); });
@ -438,7 +455,10 @@ test("transitions: every non-noop result sets both status and clear_status", ()
[payment({ status: "Cleared", clear_status: "Cleared" }), returnEvent()], [payment({ status: "Cleared", clear_status: "Cleared" }), returnEvent()],
[payment({ status: "Cleared", clear_status: "Returned" }), paidEvent()], [payment({ status: "Cleared", clear_status: "Returned" }), paidEvent()],
[payment({ status: "Voided" }), returnEvent()], [payment({ status: "Voided" }), returnEvent()],
[payment({ clear_status: "Cleared" }), { event: "electronic_return", amount: 500, bankReference: "x" }], [
payment({ clear_status: "Cleared" }),
{ event: "electronic_return", amount: 500, bankReference: "x" },
],
]; ];
for (const [p, ev] of cases) { for (const [p, ev] of cases) {
const r = applyEvent(p, ev, "2026-07-20"); const r = applyEvent(p, ev, "2026-07-20");
@ -469,11 +489,20 @@ const achPayment = (over = {}) => ({
}); });
test("ach matcher: embedded payment number + amount wins", () => { test("ach matcher: embedded payment number + amount wins", () => {
const payments = [achPayment(), achPayment({ check_number: "21222000265", pk: "payment#21222000265" })]; const payments = [
achPayment(),
achPayment({ check_number: "21222000265", pk: "payment#21222000265" }),
];
const m = matchAchTransaction( const m = matchAchTransaction(
{ event: "ach_debit", pmtId: "7584198", embeddedPaymentNumber: "21222000264", vendorText: "Cobra Septic", amount: 8300 }, {
event: "ach_debit",
pmtId: "7584198",
embeddedPaymentNumber: "21222000264",
vendorText: "Cobra Septic",
amount: 8300,
},
payments, payments,
"2026-06-08" "2026-06-08",
); );
assert.equal(m.payment.check_number, "21222000264"); assert.equal(m.payment.check_number, "21222000264");
assert.equal(m.matchedBy, "payment-number+amount"); assert.equal(m.matchedBy, "payment-number+amount");
@ -485,40 +514,64 @@ test("ach matcher: embedded number with wrong amount falls through to vendor+amo
achPayment({ check_number: "21222000265", pk: "payment#21222000265", amount_usd: 8300 }), achPayment({ check_number: "21222000265", pk: "payment#21222000265", amount_usd: 8300 }),
]; ];
const m = matchAchTransaction( const m = matchAchTransaction(
{ event: "ach_debit", pmtId: "7584198", embeddedPaymentNumber: "21222000264", vendorText: "Cobra Septic", amount: 8300 }, {
event: "ach_debit",
pmtId: "7584198",
embeddedPaymentNumber: "21222000264",
vendorText: "Cobra Septic",
amount: 8300,
},
payments, payments,
"2026-06-08" "2026-06-08",
); );
assert.equal(m.payment.check_number, "21222000265"); assert.equal(m.payment.check_number, "21222000265");
assert.equal(m.matchedBy, "vendor+amount"); assert.equal(m.matchedBy, "vendor+amount");
}); });
test("ach matcher: stored pmt_id attributes a reversal credit", () => { test("ach matcher: stored pmt_id attributes a reversal credit", () => {
const payments = [ const payments = [achPayment({ pmt_id: "7584198", clear_status: "Cleared", status: "Cleared" })];
achPayment({ pmt_id: "7584198", clear_status: "Cleared", status: "Cleared" }),
];
const m = matchAchTransaction( const m = matchAchTransaction(
{ event: "ach_return", pmtId: "7584198", embeddedPaymentNumber: null, vendorText: "Sea Haven Industries, Inc", amount: 8300 }, {
event: "ach_return",
pmtId: "7584198",
embeddedPaymentNumber: null,
vendorText: "Sea Haven Industries, Inc",
amount: 8300,
},
payments, payments,
"2026-06-10" "2026-06-10",
); );
assert.equal(m.matchedBy, "pmt_id"); assert.equal(m.matchedBy, "pmt_id");
}); });
test("ach matcher: vendor+amount only within send_payment_on -2..+14 days", () => { test("ach matcher: vendor+amount only within send_payment_on -2..+14 days", () => {
// Reddi 21222000211: sent 5/27, debited 6/4 (8-day lag) must match... // Reddi 21222000211: sent 5/27, debited 6/4 (8-day lag) must match...
const inWindow = [achPayment({ payee: "Reddi Services", send_payment_on: "05/27/2026", amount_usd: 1958 })]; const inWindow = [
achPayment({ payee: "Reddi Services", send_payment_on: "05/27/2026", amount_usd: 1958 }),
];
const m1 = matchAchTransaction( const m1 = matchAchTransaction(
{ event: "ach_debit", pmtId: "1", embeddedPaymentNumber: null, vendorText: "Reddi Services", amount: 1958 }, {
event: "ach_debit",
pmtId: "1",
embeddedPaymentNumber: null,
vendorText: "Reddi Services",
amount: 1958,
},
inWindow, inWindow,
"2026-06-04" "2026-06-04",
); );
assert.equal(m1.matchedBy, "vendor+amount"); assert.equal(m1.matchedBy, "vendor+amount");
// ...but a posting 30 days after the send date must not. // ...but a posting 30 days after the send date must not.
const m2 = matchAchTransaction( const m2 = matchAchTransaction(
{ event: "ach_debit", pmtId: "1", embeddedPaymentNumber: null, vendorText: "Reddi Services", amount: 1958 }, {
event: "ach_debit",
pmtId: "1",
embeddedPaymentNumber: null,
vendorText: "Reddi Services",
amount: 1958,
},
inWindow, inWindow,
"2026-06-26" "2026-06-26",
); );
assert.equal(m2.payment, undefined); assert.equal(m2.payment, undefined);
}); });
@ -534,9 +587,15 @@ test("ach matcher: PMT-id-less same-amount return credit matches a recently clea
}), }),
]; ];
const m = matchAchTransaction( const m = matchAchTransaction(
{ event: "ach_return", pmtId: null, embeddedPaymentNumber: null, vendorText: null, amount: 19281.12 }, {
event: "ach_return",
pmtId: null,
embeddedPaymentNumber: null,
vendorText: null,
amount: 19281.12,
},
payments, payments,
"2026-05-26" "2026-05-26",
); );
assert.equal(m.matchedBy, "amount+cleared"); assert.equal(m.matchedBy, "amount+cleared");
}); });
@ -550,29 +609,52 @@ test("ach matcher: amount+cleared fallback requires cleared_date within 45 days
}; };
const tooOld = [achPayment({ ...base, cleared_date: "2026-03-01" })]; const tooOld = [achPayment({ ...base, cleared_date: "2026-03-01" })];
const m1 = matchAchTransaction( const m1 = matchAchTransaction(
{ event: "ach_return", pmtId: null, embeddedPaymentNumber: null, vendorText: null, amount: 19281.12 }, {
event: "ach_return",
pmtId: null,
embeddedPaymentNumber: null,
vendorText: null,
amount: 19281.12,
},
tooOld, tooOld,
"2026-05-26" "2026-05-26",
); );
assert.equal(m1.payment, undefined); assert.equal(m1.payment, undefined);
const noDate = [achPayment({ ...base })]; const noDate = [achPayment({ ...base })];
const m2 = matchAchTransaction( const m2 = matchAchTransaction(
{ event: "ach_return", pmtId: null, embeddedPaymentNumber: null, vendorText: null, amount: 19281.12 }, {
event: "ach_return",
pmtId: null,
embeddedPaymentNumber: null,
vendorText: null,
amount: 19281.12,
},
noDate, noDate,
"2026-05-26" "2026-05-26",
); );
assert.equal(m2.payment, undefined); assert.equal(m2.payment, undefined);
}); });
test("ach matcher: return credit with an unattributable PMT id is unmatched, never amount-guessed", () => { test("ach matcher: return credit with an unattributable PMT id is unmatched, never amount-guessed", () => {
const payments = [ const payments = [
achPayment({ clear_status: "Cleared", status: "Cleared", cleared_date: "2026-05-22", amount_usd: 8300 }), achPayment({
clear_status: "Cleared",
status: "Cleared",
cleared_date: "2026-05-22",
amount_usd: 8300,
}),
]; ];
const m = matchAchTransaction( const m = matchAchTransaction(
{ event: "ach_return", pmtId: "999", embeddedPaymentNumber: null, vendorText: null, amount: 8300 }, {
event: "ach_return",
pmtId: "999",
embeddedPaymentNumber: null,
vendorText: null,
amount: 8300,
},
payments, payments,
"2026-05-26" "2026-05-26",
); );
assert.equal(m.payment, undefined); assert.equal(m.payment, undefined);
assert.equal(m.unmatched, "unknown PMT id"); assert.equal(m.unmatched, "unknown PMT id");
@ -581,9 +663,15 @@ test("ach matcher: return credit with an unattributable PMT id is unmatched, nev
test("ach matcher: pmt_id match with amount mismatch is unmatched — partial-reversal human case", () => { test("ach matcher: pmt_id match with amount mismatch is unmatched — partial-reversal human case", () => {
const payments = [achPayment({ pmt_id: "7584198", amount_usd: 8300 })]; const payments = [achPayment({ pmt_id: "7584198", amount_usd: 8300 })];
const m = matchAchTransaction( const m = matchAchTransaction(
{ event: "ach_return", pmtId: "7584198", embeddedPaymentNumber: null, vendorText: null, amount: 4150 }, {
event: "ach_return",
pmtId: "7584198",
embeddedPaymentNumber: null,
vendorText: null,
amount: 4150,
},
payments, payments,
"2026-06-10" "2026-06-10",
); );
assert.equal(m.payment, undefined); assert.equal(m.payment, undefined);
assert.equal(m.unmatched, "pmt_id matched, amount mismatch"); assert.equal(m.unmatched, "pmt_id matched, amount mismatch");
@ -597,9 +685,15 @@ test("ach matcher: candidates with a DIFFERENT stored pmt_id are excluded from v
// Both are Cobra Septic @ 8300 in-window; the pmt_id conflict on the // Both are Cobra Septic @ 8300 in-window; the pmt_id conflict on the
// first disambiguates to the second instead of going ambiguous. // first disambiguates to the second instead of going ambiguous.
const m = matchAchTransaction( const m = matchAchTransaction(
{ event: "ach_debit", pmtId: "2222222", embeddedPaymentNumber: null, vendorText: "Cobra Septic", amount: 8300 }, {
event: "ach_debit",
pmtId: "2222222",
embeddedPaymentNumber: null,
vendorText: "Cobra Septic",
amount: 8300,
},
payments, payments,
"2026-06-08" "2026-06-08",
); );
assert.equal(m.payment.check_number, "21222000270"); assert.equal(m.payment.check_number, "21222000270");
assert.equal(m.matchedBy, "vendor+amount"); assert.equal(m.matchedBy, "vendor+amount");
@ -607,13 +701,31 @@ test("ach matcher: candidates with a DIFFERENT stored pmt_id are excluded from v
test("ach matcher: ambiguity is unmatched — no write", () => { test("ach matcher: ambiguity is unmatched — no write", () => {
const payments = [ const payments = [
achPayment({ pk: "payment#a", check_number: "a", clear_status: "Cleared", cleared_date: "2026-06-01", amount_usd: 500 }), achPayment({
achPayment({ pk: "payment#b", check_number: "b", clear_status: "Cleared", cleared_date: "2026-06-02", amount_usd: 500 }), pk: "payment#a",
check_number: "a",
clear_status: "Cleared",
cleared_date: "2026-06-01",
amount_usd: 500,
}),
achPayment({
pk: "payment#b",
check_number: "b",
clear_status: "Cleared",
cleared_date: "2026-06-02",
amount_usd: 500,
}),
]; ];
const m = matchAchTransaction( const m = matchAchTransaction(
{ event: "ach_return", pmtId: null, embeddedPaymentNumber: null, vendorText: null, amount: 500 }, {
event: "ach_return",
pmtId: null,
embeddedPaymentNumber: null,
vendorText: null,
amount: 500,
},
payments, payments,
"2026-06-08" "2026-06-08",
); );
assert.equal(m.payment, undefined); assert.equal(m.payment, undefined);
assert.match(m.unmatched, /ambiguous/); assert.match(m.unmatched, /ambiguous/);
@ -640,7 +752,7 @@ test("ach transitions: settled debit clears both fields and persists pmt_id", ()
const r = applyEvent( const r = applyEvent(
{ ...p }, { ...p },
{ event: "ach_debit", pmtId: "7584198", amount: 8300, bankReference: "905512345" }, { event: "ach_debit", pmtId: "7584198", amount: 8300, bankReference: "905512345" },
"2026-06-04" "2026-06-04",
); );
assert.equal(r.kind, "cleared"); assert.equal(r.kind, "cleared");
assert.equal(r.updates.status, "Cleared"); assert.equal(r.updates.status, "Cleared");
@ -650,15 +762,28 @@ test("ach transitions: settled debit clears both fields and persists pmt_id", ()
}); });
test("ach transitions: return credit then re-debit (bounce and re-settle)", () => { test("ach transitions: return credit then re-debit (bounce and re-settle)", () => {
const p = achPayment({ status: "Cleared", clear_status: "Cleared", pmt_id: "7500000", history: [] }); const p = achPayment({
const ret = applyEvent(p, { event: "ach_return", pmtId: "7500000", amount: 8300, bankReference: "r1" }, "2026-05-26"); status: "Cleared",
clear_status: "Cleared",
pmt_id: "7500000",
history: [],
});
const ret = applyEvent(
p,
{ event: "ach_return", pmtId: "7500000", amount: 8300, bankReference: "r1" },
"2026-05-26",
);
assert.equal(ret.kind, "returned"); assert.equal(ret.kind, "returned");
assert.equal(ret.updates.clear_status, "Returned"); assert.equal(ret.updates.clear_status, "Returned");
assert.equal(ret.updates.returned_date, "2026-05-26"); assert.equal(ret.updates.returned_date, "2026-05-26");
Object.assign(p, ret.updates); Object.assign(p, ret.updates);
p.history.push(ret.historyEvent); p.history.push(ret.historyEvent);
const redebit = applyEvent(p, { event: "ach_debit", pmtId: "7500000", amount: 8300, bankReference: "d2" }, "2026-06-01"); const redebit = applyEvent(
p,
{ event: "ach_debit", pmtId: "7500000", amount: 8300, bankReference: "d2" },
"2026-06-01",
);
assert.equal(redebit.kind, "redeposit"); assert.equal(redebit.kind, "redeposit");
assert.equal(redebit.updates.status, "Cleared"); assert.equal(redebit.updates.status, "Cleared");
assert.equal(redebit.updates.clear_status, "Cleared"); assert.equal(redebit.updates.clear_status, "Cleared");
@ -666,7 +791,11 @@ test("ach transitions: return credit then re-debit (bounce and re-settle)", () =
test("ach transitions: electronic return on a voided-but-settled ACH is voided-and-bounced", () => { test("ach transitions: electronic return on a voided-but-settled ACH is voided-and-bounced", () => {
const p = achPayment({ status: "Voided", clear_status: "Cleared" }); const p = achPayment({ status: "Voided", clear_status: "Cleared" });
const r = applyEvent(p, { event: "electronic_return", amount: 8300, bankReference: "r2" }, "2026-06-09"); const r = applyEvent(
p,
{ event: "electronic_return", amount: 8300, bankReference: "r2" },
"2026-06-09",
);
assert.equal(r.kind, "voided_and_bounced"); assert.equal(r.kind, "voided_and_bounced");
assert.equal(r.updates.status, "Voided"); assert.equal(r.updates.status, "Voided");
assert.equal(r.updates.clear_status, "Returned"); assert.equal(r.updates.clear_status, "Returned");
@ -677,9 +806,23 @@ test("ach transitions: electronic return on a voided-but-settled ACH is voided-a
test("stale sweep: unconfirmed ACH older than 16 days is listed; checks older than 60 days counted", () => { test("stale sweep: unconfirmed ACH older than 16 days is listed; checks older than 60 days counted", () => {
const payments = [ const payments = [
achPayment({ check_number: "21222000300", send_payment_on: "06/20/2026" }), // 31d, stale achPayment({ check_number: "21222000300", send_payment_on: "06/20/2026" }), // 31d, stale
achPayment({ check_number: "21222000301", pk: "payment#21222000301", send_payment_on: "07/10/2026" }), // 11d, fresh achPayment({
achPayment({ check_number: "21222000302", pk: "payment#21222000302", send_payment_on: "06/01/2026", clear_status: "Cleared" }), // confirmed check_number: "21222000301",
achPayment({ check_number: "21222000303", pk: "payment#21222000303", send_payment_on: "06/01/2026", status: "Voided" }), // cancel-exempt pk: "payment#21222000301",
send_payment_on: "07/10/2026",
}), // 11d, fresh
achPayment({
check_number: "21222000302",
pk: "payment#21222000302",
send_payment_on: "06/01/2026",
clear_status: "Cleared",
}), // confirmed
achPayment({
check_number: "21222000303",
pk: "payment#21222000303",
send_payment_on: "06/01/2026",
status: "Voided",
}), // cancel-exempt
payment({ check_number: "3001", send_payment_on: "04/01/2026" }), // check, 111d, stale payment({ check_number: "3001", send_payment_on: "04/01/2026" }), // check, 111d, stale
payment({ check_number: "3002", pk: "payment#3002", send_payment_on: "07/01/2026" }), // check, fresh payment({ check_number: "3002", pk: "payment#3002", send_payment_on: "07/01/2026" }), // check, fresh
]; ];
@ -695,7 +838,11 @@ test("stale sweep: ACH list is capped, count is not", () => {
const payments = []; const payments = [];
for (let i = 0; i < STALE_LIST_CAP + 5; i++) { for (let i = 0; i < STALE_LIST_CAP + 5; i++) {
payments.push( payments.push(
achPayment({ check_number: `2122200${1000 + i}`, pk: `payment#s${i}`, send_payment_on: "06/01/2026" }) achPayment({
check_number: `2122200${1000 + i}`,
pk: `payment#s${i}`,
send_payment_on: "06/01/2026",
}),
); );
} }
const s = sweepStalePayments(payments, "2026-07-21"); const s = sweepStalePayments(payments, "2026-07-21");
@ -772,7 +919,7 @@ test("resolveDateRange: malformed and impossible dates throw, naming the offendi
assert.throws(() => resolveDateRange({ fromDate: "2026-02-30" }), /2026-02-30/); assert.throws(() => resolveDateRange({ fromDate: "2026-02-30" }), /2026-02-30/);
assert.throws( assert.throws(
() => resolveDateRange({ fromDate: "2026-07-02", toDate: "2026-07-01" }), () => resolveDateRange({ fromDate: "2026-07-02", toDate: "2026-07-01" }),
/fromDate="2026-07-02", toDate="2026-07-01"/ /fromDate="2026-07-02", toDate="2026-07-01"/,
); );
assert.throws(() => resolveDateRange({ fromDate: "2026-07-01; DROP", toDate: "2026-07-02" })); assert.throws(() => resolveDateRange({ fromDate: "2026-07-01; DROP", toDate: "2026-07-02" }));
}); });

View file

@ -43,7 +43,11 @@ test("resolveDateRange: current-day default pins today..today", () => {
}); });
test("resolveDateRange: current-day explicit range passes through (API accepts ranges)", () => { test("resolveDateRange: current-day explicit range passes through (API accepts ranges)", () => {
const r = resolveDateRange({ fromDate: "2026-07-21", toDate: "2026-07-22" }, new Date(), "current-day"); const r = resolveDateRange(
{ fromDate: "2026-07-21", toDate: "2026-07-22" },
new Date(),
"current-day",
);
assert.deepEqual(r, { fromDate: "2026-07-21", toDate: "2026-07-22" }); assert.deepEqual(r, { fromDate: "2026-07-21", toDate: "2026-07-22" });
}); });
@ -106,7 +110,10 @@ test("fixture: quiet previous-day week has zero unknowns and full summary covera
const h = histogram(fixture("previous-day-week")); const h = histogram(fixture("previous-day-week"));
assert.equal(h.unknown ?? 0, 0); assert.equal(h.unknown ?? 0, 0);
assert.equal(h.summary, 250); assert.equal(h.summary, 250);
assert.equal(Object.values(h).reduce((a, b) => a + b, 0), 259); assert.equal(
Object.values(h).reduce((a, b) => a + b, 0),
259,
);
}); });
test("fixture: current-day classifies the five live ACH settlements", () => { test("fixture: current-day classifies the five live ACH settlements", () => {
@ -125,18 +132,39 @@ test("fixture: current-day range covers both days without unknowns", () => {
test("extractBalances: hostile responses hit caps and finite guards, counted not silent", () => { test("extractBalances: hostile responses hit caps and finite guards, counted not silent", () => {
const rows = []; const rows = [];
for (let i = 0; i < 60; i++) for (let i = 0; i < 60; i++)
rows.push({ transactionType: "Summary", transactionCode: String(500 + i), asOfDate: "2026-07-22", amount: "1" }); rows.push({
rows.push({ transactionType: "Summary", transactionCode: "010", asOfDate: "2026-07-22", amount: "Infinity" }); transactionType: "Summary",
rows.push({ transactionType: "Summary", transactionCode: "100", asOfDate: "2026-07-22", amount: "5", itemCount: "Infinity" }); transactionCode: String(500 + i),
asOfDate: "2026-07-22",
amount: "1",
});
rows.push({
transactionType: "Summary",
transactionCode: "010",
asOfDate: "2026-07-22",
amount: "Infinity",
});
rows.push({
transactionType: "Summary",
transactionCode: "100",
asOfDate: "2026-07-22",
amount: "5",
itemCount: "Infinity",
});
for (const month of ["05", "06"]) for (const month of ["05", "06"])
for (let d = 1; d <= 28; d++) for (let d = 1; d <= 28; d++)
rows.push({ transactionType: "Summary", transactionCode: "030", asOfDate: `2026-${month}-${String(d).padStart(2, "0")}`, amount: "1" }); rows.push({
transactionType: "Summary",
transactionCode: "030",
asOfDate: `2026-${month}-${String(d).padStart(2, "0")}`,
amount: "1",
});
const snaps = extractBalances(rows); const snaps = extractBalances(rows);
const s = snaps.find((x) => x.as_of_date === "2026-07-22"); const s = snaps.find((x) => x.as_of_date === "2026-07-22");
assert.ok(Object.keys(s.other).length <= 50); assert.ok(Object.keys(s.other).length <= 50);
assert.ok(s.other_truncated > 0); assert.ok(s.other_truncated > 0);
assert.equal(s.opening_ledger, undefined); // Infinity amount skipped assert.equal(s.opening_ledger, undefined); // Infinity amount skipped
assert.equal(s.total_credits_count, 0); // Infinity itemCount zeroed assert.equal(s.total_credits_count, 0); // Infinity itemCount zeroed
assert.ok(snaps.length <= 31); assert.ok(snaps.length <= 31);
assert.ok(snaps[0].dates_truncated > 0); assert.ok(snaps[0].dates_truncated > 0);
}); });

View file

@ -22,7 +22,13 @@ describe("HCP Terraform seam (PLAT-79)", () => {
}); });
it("ignores Lambda code attributes so zip CD is not drift", () => { it("ignores Lambda code attributes so zip CD is not drift", () => {
for (const attr of ["filename", "s3_bucket", "s3_key", "s3_object_version", "source_code_hash"]) { for (const attr of [
"filename",
"s3_bucket",
"s3_key",
"s3_object_version",
"source_code_hash",
]) {
assert.match(lambdaTf, new RegExp(attr)); assert.match(lambdaTf, new RegExp(attr));
} }
assert.match(lambdaTf, /lifecycle/); assert.match(lambdaTf, /lifecycle/);
@ -68,7 +74,10 @@ describe("HCP Terraform seam (PLAT-79)", () => {
assert.match(deploy, /environment: prod/); assert.match(deploy, /environment: prod/);
assert.match(deploy, /ship-gate: true/); assert.match(deploy, /ship-gate: true/);
assert.match(deploy, /ssm-prefix: \/payments-dashboard\/deploy/); assert.match(deploy, /ssm-prefix: \/payments-dashboard\/deploy/);
assert.match(deploy, /function-keys: process_csv,slack_app_home,fetch_boa,expense_receiver,expense_processor/); assert.match(
deploy,
/function-keys: process_csv,slack_app_home,fetch_boa,expense_receiver,expense_processor/,
);
}); });
it("runs npm test and the Terraform callable behind ci-complete", () => { it("runs npm test and the Terraform callable behind ci-complete", () => {
@ -77,7 +86,8 @@ describe("HCP Terraform seam (PLAT-79)", () => {
assert.match(ci, /npm test/); assert.match(ci, /npm test/);
assert.match(ci, /ci-terraform\.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd/); assert.match(ci, /ci-terraform\.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd/);
assert.match(ci, /ci-autofix\.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd/); assert.match(ci, /ci-autofix\.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd/);
assert.match(ci, /presets: terraform/); assert.match(ci, /presets: prettier,terraform/);
assert.match(ci, /npm run format:check/);
assert.match(ci, /src\//); assert.match(ci, /src\//);
assert.match(ci, /package\.json/); assert.match(ci, /package\.json/);
assert.match(ci, /package-lock\.json/); assert.match(ci, /package-lock\.json/);
@ -113,5 +123,4 @@ describe("HCP Terraform seam (PLAT-79)", () => {
assert.doesNotMatch(githubDeploy, /deploy\.yaml@refs\/heads/); assert.doesNotMatch(githubDeploy, /deploy\.yaml@refs\/heads/);
assert.doesNotMatch(variables, /github_deploy_branch/); assert.doesNotMatch(variables, /github_deploy_branch/);
}); });
}); });

View file

@ -47,9 +47,7 @@ test("categorize: returned records are excluded from Outstanding totals", () =>
test("categorize: terminal voided-and-bounced stays out of the action queue", () => { test("categorize: terminal voided-and-bounced stays out of the action queue", () => {
for (const status of ["Marked as Void", "Voided", "Cancelled", "canceled"]) { for (const status of ["Marked as Void", "Voided", "Cancelled", "canceled"]) {
const { returnedPayments, outstandingChecks } = categorizePayments([ const { returnedPayments, outstandingChecks } = categorizePayments([returnedCheck({ status })]);
returnedCheck({ status }),
]);
assert.equal(returnedPayments.length, 0, `status ${status} in returned bucket`); assert.equal(returnedPayments.length, 0, `status ${status} in returned bucket`);
assert.equal(outstandingChecks.length, 0, `status ${status} in outstanding`); assert.equal(outstandingChecks.length, 0, `status ${status} in outstanding`);
} }
@ -71,9 +69,7 @@ test("categorize: returned ACH is included in the returned bucket", () => {
}); });
test("categorize: returned surfaces even when send_payment_on no longer parses", () => { test("categorize: returned surfaces even when send_payment_on no longer parses", () => {
const { returnedPayments } = categorizePayments([ const { returnedPayments } = categorizePayments([returnedCheck({ send_payment_on: "garbage" })]);
returnedCheck({ send_payment_on: "garbage" }),
]);
assert.equal(returnedPayments.length, 1); assert.equal(returnedPayments.length, 1);
}); });
@ -85,7 +81,7 @@ test("categorize: returned bucket sorts oldest return first, unknown date first"
]); ]);
assert.deepEqual( assert.deepEqual(
returnedPayments.map((p) => p.payee), returnedPayments.map((p) => p.payee),
["C", "A", "B"] ["C", "A", "B"],
); );
}); });