ci: require ci-complete for test and Terraform (#121)
Some checks are pending
Deploy / Deploy to dev (push) Waiting to run
Deploy / Deploy to prod (push) Waiting to run

* ci: aggregate test and Terraform as ci-complete

Converted callers emit that check so this repo can leave the ci / ci ruleset.

* 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:39:38 -04:00 • committed by GitHub
parent 7c2c806339
commit 5ff8099b7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 914 additions and 440 deletions

View file

@ -1,16 +1,31 @@
name: CI
# Parallel test and Terraform portions. The required check is ci-complete.
on:
pull_request:
branches: [main]
branches: [main, hotfix/**, release/**]
merge_group:
push:
branches: [hotfix/**, release/**]
permissions:
contents: read
jobs:
autofix:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
permissions:
contents: write
secrets: inherit
with:
presets: prettier,terraform
test:
name: Test
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@ -26,11 +41,16 @@ jobs:
- name: Install
run: npm ci
- name: Check format
run: npm run format:check
- name: Test
run: npm test
terraform:
name: Terraform
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
with:
terraform-version: "1.16.0"
@ -40,33 +60,18 @@ jobs:
package.json
package-lock.json
ci:
name: ci / ci
needs: [test, terraform]
if: ${{ always() && !cancelled() }}
ci-complete:
name: ci-complete
needs: [autofix, test, terraform]
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check jobs
- name: Require portions
env:
TEST_RESULT: ${{ needs.test.result }}
TERRAFORM_RESULT: ${{ needs.terraform.result }}
TEST: ${{ needs.test.result }}
TERRAFORM: ${{ needs.terraform.result }}
run: |
set -euo pipefail
fail=0
check() {
local name="$1"
local result="$2"
case "${result}" in
success)
echo "${name}: ${result}"
;;
*)
echo "${name}: ${result}" >&2
fail=1
;;
esac
}
check test "${TEST_RESULT}"
check terraform "${TERRAFORM_RESULT}"
exit "${fail}"
test "${TEST}" = success
test "${TERRAFORM}" = success

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 body headings** (exact, in this order):
1. Summary
2. Validation
3. Tests

View file

@ -24,37 +24,39 @@ Reaction-driven workflow that routes expense submissions through four Slack chan
**Channel pipeline:**
| Stage | Channel ID | Action on :white_check_mark: |
|-------|-----------|------------------------------|
| Submitted | `C0AQ2AWLNEN` | Thread reply on original, copy to Processed |
| Processed | `C0APLSGABAB` | Delete from Processed, post to Authorized |
| Authorized | `C0AQ09CDJH4` | Delete from Authorized, post to Matched |
| Matched | `C0APYUM1JFP` | Terminal stage (no further routing) |
| Stage | Channel ID | Action on :white_check_mark: |
| ---------- | ------------- | ------------------------------------------- |
| Submitted | `C0AQ2AWLNEN` | Thread reply on original, copy to Processed |
| Processed | `C0APLSGABAB` | Delete from Processed, post to Authorized |
| Authorized | `C0AQ09CDJH4` | Delete from Authorized, post to Matched |
| 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.
**Secrets (Secrets Manager):**
| Secret | Purpose |
|--------|---------|
| `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 |
| Secret | Purpose |
| ------------------------------------------------- | ------------------------------------------------ |
| `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 |
## BoA CashPro API Integration
Two separate CashPro APIs are used, each with its own OAuth credentials:
| API | Purpose | Endpoint |
|-----|---------|----------|
| Check Management | Issue and cancel checks | `/cashpro/checkmanagement/v1/check-issues` |
| API | Purpose | Endpoint |
| ------------------------------- | ------------------------------- | ---------------------------------------------------------- |
| Check Management | Issue and cancel checks | `/cashpro/checkmanagement/v1/check-issues` |
| Reporting (Transaction Inquiry) | Fetch previous-day transactions | `/cashpro/reporting/v1/transaction-inquiries/previous-day` |
**Authentication flow:**
1. POST to `/authn/v1/client-authentication` with `applicationID`, `client_id`, and `client_secret`
2. Receive a Bearer `access_token` (valid 1 hour)
3. Pass the token in the `Authorization` header for subsequent API calls
**Base URLs:**
- Production: `https://api.bofa.com`
- Sandbox: `https://api-sb.bofa.com`
@ -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:
| BAI code | Feed label | Event |
|---|---|---|
| 475 | Check Paid | `check_paid` (number in `customerReference`) |
| 255 | Check Posted and Returned CR | `check_return` (number in `customerReference`) |
| 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) |
| 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 |
| BAI code | Feed label | Event |
| --------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 475 | Check Paid | `check_paid` (number in `customerReference`) |
| 255 | Check Posted and Returned CR | `check_return` (number in `customerReference`) |
| 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) |
| 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 |
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.
@ -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):
| Bank event | Result |
|---|---|
| Paid debit | `status=Cleared`, `clear_status=Cleared`, `paid_date`, `cleared_date`, `bank_reference` |
| Bank event | Result |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| 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) |
| 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 |
| 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 |
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:
| Secret | Type | Contents |
|--------|------|----------|
| `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/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`) |
| Secret | Type | Contents |
| ----------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
| `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/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`) |
`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):
| Alarm | Source |
|-------|--------|
| Alarm | Source |
| ----------------------------------------------- | -------------------------- |
| `payments-processPaymentCsv-async-dlq-messages` | async-invoke OnFailure DLQ |
**Lambda** (per function — `payments-<fn>-...`):
| Type | Metric / Statistic | Threshold |
|----------------------|--------------------|-----------|
| `-errors` (all 5) | `Errors` / Sum | > 0 |
| `-throttles` (all 5) | `Throttles` / Sum | > 0 |
| Type | Metric / Statistic | Threshold |
| -------------------- | -------------------- | ------------------------------- |
| `-errors` (all 5) | `Errors` / Sum | > 0 |
| `-throttles` (all 5) | `Throttles` / Sum | > 0 |
| `-duration` (all 5) | `Duration` / Maximum | ~80% of each function's timeout |
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
| Script | Purpose |
|--------|---------|
| Script | Purpose |
| ----------------------------- | ------------------------------------------------------- |
| `scripts/test-boa-sandbox.js` | One-off sandbox connectivity test for both CashPro APIs |
| `scripts/seed-from-csv.js` | Seed DynamoDB from a local CSV file |
| `scripts/seed-bank-status.js` | Seed bank clear status data into DynamoDB |
| `scripts/seed-from-csv.js` | Seed DynamoDB from a local CSV file |
| `scripts/seed-bank-status.js` | Seed bank clear status data into DynamoDB |
## Deployment

View file

@ -2,10 +2,10 @@
Two workspaces, one configuration, selected by HCP variable `environment`:
| Workspace | Project | Account | `environment` | `boa_base_url` |
|-----------|---------|---------|---------------|----------------|
| `payments-dashboard-prod` | `seahaven-prod` | `011934824531` | `prod` | `https://api.bofa.com` |
| `payments-dashboard-dev` | `seahaven-dev` | `710827005802` | `dev` | `https://api-sb.bofa.com` |
| Workspace | Project | Account | `environment` | `boa_base_url` |
| ------------------------- | --------------- | -------------- | ------------- | ------------------------- |
| `payments-dashboard-prod` | `seahaven-prod` | `011934824531` | `prod` | `https://api.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.
@ -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
copies of the prod secrets.
| Name | Used by |
|------|---------|
| `payments-dashboard/slack-bot-token` | slackAppHome |
| `payments-dashboard/slack-signing-secret` | slackAppHome |
| `payments-dashboard/boa-check-mgmt` | processPaymentCsv |
| `payments-dashboard/boa-reporting` | fetchBoaTransactions |
| `payments-dashboard/expense-slack-token` | expenseProcessor |
| `payments-dashboard/expense-slack-signing-secret` | expenseReceiver |
| Name | Used by |
| ------------------------------------------------- | -------------------- |
| `payments-dashboard/slack-bot-token` | slackAppHome |
| `payments-dashboard/slack-signing-secret` | slackAppHome |
| `payments-dashboard/boa-check-mgmt` | processPaymentCsv |
| `payments-dashboard/boa-reporting` | fetchBoaTransactions |
| `payments-dashboard/expense-slack-token` | expenseProcessor |
| `payments-dashboard/expense-slack-signing-secret` | expenseReceiver |
## 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/lib-dynamodb": "^3.1140.0",
"csv-parse": "^7.0.2"
},
"devDependencies": {
"prettier": "^3.9.9"
}
},
"node_modules/@aws-sdk/checksums": {
@ -569,6 +572,22 @@
"integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==",
"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": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View file

@ -7,6 +7,8 @@
"src/"
],
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "node --test \"tests/**/*.test.js\""
},
"dependencies": {
@ -16,5 +18,8 @@
"@aws-sdk/client-secrets-manager": "^3.1140.0",
"@aws-sdk/lib-dynamodb": "^3.1140.0",
"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)",
ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey,
})
}),
);
for (const item of result.Items) {
dbPayments[item.pk] = item;
@ -46,16 +46,20 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
});
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;
};
const statusRank = {
"scheduled": 1,
scheduled: 1,
"payment submitted": 2,
"issued": 3,
"outstanding": 4,
"cleared": 5,
issued: 3,
outstanding: 4,
cleared: 5,
};
const newRecords = [];
@ -103,12 +107,24 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (oldRank === 5) {
// Cleared is permanent — cannot be voided, cancelled, or anything else
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;
} else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) {
// 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;
}
}
@ -116,13 +132,23 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (!existing) {
newRecords.push({ checkNumber, payee, method, status, amount, sendOn });
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 {
const oldStatus = existing.status || "";
if (bankConfirmed && originalCsvStatus !== "Cleared") {
bankProtected.push({ checkNumber, payee, csvStatus: originalCsvStatus, dbStatus: oldStatus });
bankProtected.push({
checkNumber,
payee,
csvStatus: originalCsvStatus,
dbStatus: oldStatus,
});
}
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)
if (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) });
if (
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) {
console.log("--- NEW RECORDS (" + newRecords.length + ") ---");
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("");
}
@ -154,7 +202,20 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (statusChanges.length) {
console.log("--- STATUS CHANGES (" + statusChanges.length + ") ---");
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("");
}
@ -163,7 +224,18 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
console.log("--- STATUS PROGRESSION PROTECTED (" + statusProtected.length + ") ---");
console.log(" (CSV tried to regress status — blocked by progression guard)");
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("");
}
@ -172,7 +244,9 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
console.log("--- BANK-CONFIRMED PROTECTED (" + bankProtected.length + ") ---");
console.log(" (CSV tried to change status but bank already confirmed Cleared)");
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("");
}
@ -180,7 +254,9 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (newCheckIssues.length) {
console.log("--- BOA: CHECK ISSUES / add_Issue (" + newCheckIssues.length + ") ---");
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("");
}
@ -188,7 +264,9 @@ const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
if (cancelCheckIssues.length) {
console.log("--- BOA: CHECK CANCELS / cancel_Issue (" + cancelCheckIssues.length + ") ---");
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("");
}

View file

@ -7,9 +7,7 @@ const secrets = new SecretsManagerClient({ region: "us-east-1" });
const BOA_BASE_URL = "https://api.bofa.com";
async function getSecretJson(secretId) {
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: secretId })
);
const { SecretString } = await secrets.send(new GetSecretValueCommand({ SecretId: secretId }));
return JSON.parse(SecretString);
}
@ -43,9 +41,13 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
TableName: "PaymentsDashboard",
FilterExpression: "begins_with(pk, :prefix) AND #m = :method AND #s = :status",
ExpressionAttributeNames: { "#m": "method", "#s": "status" },
ExpressionAttributeValues: { ":prefix": "payment#", ":method": "Check", ":status": "Scheduled" },
ExpressionAttributeValues: {
":prefix": "payment#",
":method": "Check",
":status": "Scheduled",
},
ExclusiveStartKey: lastKey,
})
}),
);
payments.push(...result.Items);
lastKey = result.LastEvaluatedKey;
@ -106,16 +108,25 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
}
if (skip) issueList.splice(0, skip);
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) {
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;
}
// 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
for (const item of issueList) {
@ -156,7 +167,9 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
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;
totalFailed += data.unprocessedItems || 0;

View file

@ -87,7 +87,7 @@ async function updateStatus(checkNumber, status, { paidDate, amount, issueDate,
UpdateExpression: `SET ${expr.join(", ")}`,
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
})
}),
);
}
@ -106,7 +106,13 @@ async function main() {
if (!checkNumber) continue;
const amount = parseFloat(String(row[4] || "0").replace(/,/g, ""));
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`);
}
@ -129,7 +135,13 @@ async function main() {
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`);
}
@ -151,7 +163,11 @@ async function main() {
seen.add(checkNumber);
const paidDate = toISODate(row["Paid Date"] || row["CD Volume Number"] || "");
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)`);
}

View file

@ -12,11 +12,7 @@
import { readFileSync } from "fs";
import { parse } from "csv-parse/sync";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
UpdateCommand,
PutCommand,
} from "@aws-sdk/lib-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
const TABLE_NAME = "PaymentsDashboard";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
@ -29,7 +25,11 @@ function toISODate(mdyDate) {
}
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;
};
@ -106,7 +106,7 @@ async function seedFile(filePath) {
":status": status,
":company_subsidiary": (row["Company/Subsidiary"] || "").trim(),
},
})
}),
);
count++;
}
@ -138,7 +138,7 @@ async function main() {
last_updated: new Date().toISOString(),
last_file_count: total,
},
})
}),
);
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 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 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) {
const p = String(mdy).split("/");
@ -19,8 +22,11 @@ function toISODate(mdy) {
(async () => {
const text = fs.readFileSync(CSV_PATH, "utf-8");
const rows = parse(text, { columns: true, skip_empty_lines: true, trim: true })
.map((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [String(k).trim(), typeof v === "string" ? v.trim() : v])));
const rows = parse(text, { columns: true, skip_empty_lines: true, trim: true }).map((r) =>
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());
console.log(`CSV rows: ${rows.length} total, ${validRows.length} with check numbers`);
@ -30,7 +36,9 @@ function toISODate(mdy) {
const existing = {};
for (let i = 0; i < keys.length; 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;
}
console.log(`DDB records found for: ${Object.keys(existing).length} of ${validRows.length}\n`);
@ -74,43 +82,71 @@ function toISODate(mdy) {
}
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 if (ex && ex.status !== status) {
statusChanges.push({ checkNumber, payee: row["Payee"], from: ex.status, to: status });
}
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;
}
if (!ex) {
newChecks.push({ checkNumber, 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"] });
newChecks.push({
checkNumber,
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(`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}`);
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(`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`);
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`);
console.log(`\n========= BLOCKED / FROZEN =========`);
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}`);
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";
async function getSecretJson(secretId) {
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: secretId })
);
const { SecretString } = await secrets.send(new GetSecretValueCommand({ SecretId: secretId }));
return JSON.parse(SecretString);
}
@ -104,7 +102,7 @@ async function main() {
Authorization: `Bearer ${reportingToken}`,
},
body: JSON.stringify(inquiryPayload),
}
},
);
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`;
async function getSecretJson(secretId) {
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: secretId })
);
const { SecretString } = await secrets.send(new GetSecretValueCommand({ SecretId: secretId }));
return JSON.parse(SecretString);
}
@ -74,14 +72,14 @@ async function main() {
const checkMgmtBearerToken = await getAccessToken(
"app_SeaHavenIndustries_Checkmanagement_SB",
checkMgmtClientId,
checkMgmtSecret
checkMgmtSecret,
);
console.log("[Account Info / Reporting]");
const accountInfoBearerToken = await getAccessToken(
"app_SeaHavenIndustries_Reporting_SB",
accountInfoClientId,
accountInfoSecret
accountInfoSecret,
);
console.log("Both tokens acquired.\n");
@ -107,18 +105,15 @@ async function main() {
console.log("Request:", JSON.stringify(issuePayload, null, 2), "\n");
try {
const issueRes = await fetch(
`${SANDBOX_BASE}/cashpro/checkmanagement/v1/check-issues`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${checkMgmtBearerToken}`,
companyId,
},
body: JSON.stringify(issuePayload),
}
);
const issueRes = await fetch(`${SANDBOX_BASE}/cashpro/checkmanagement/v1/check-issues`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${checkMgmtBearerToken}`,
companyId,
},
body: JSON.stringify(issuePayload),
});
const issueHeaders = Object.fromEntries(issueRes.headers.entries());
const issueText = await issueRes.text();
@ -162,7 +157,7 @@ async function main() {
Authorization: `Bearer ${accountInfoBearerToken}`,
},
body: JSON.stringify(inquiryPayload),
}
},
);
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).
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;
};
@ -67,7 +71,7 @@ export function directionFromCode(code) {
export function directionOf(txn) {
const indicator = String(
txn.debitCreditIndicator ?? txn.creditDebitIndicator ?? ""
txn.debitCreditIndicator ?? txn.creditDebitIndicator ?? "",
).toUpperCase();
if (indicator.includes("DEBIT")) return "debit";
if (indicator.includes("CREDIT")) return "credit";
@ -117,22 +121,33 @@ export function classifyTransaction(txn) {
// fallback.
// First NON-EMPTY of the substantive fields — ?? alone would let an
// empty-string detailText shadow a populated text/description field.
const substantiveText = [txn.detailText, txn.text, txn.description]
.map((v) => String(v ?? "").slice(0, MAX_DESCRIPTION_LEN).trim())
.find(Boolean) ?? "";
const substantiveText =
[txn.detailText, txn.text, txn.description]
.map((v) =>
String(v ?? "")
.slice(0, MAX_DESCRIPTION_LEN)
.trim(),
)
.find(Boolean) ?? "";
const description =
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);
// bound them so a malformed feed can never balloon DDB items or matching.
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
// reference", not check number 0 — stripLeadingZeros alone leaves "0",
// which would fabricate a checkNumber.
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 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
// events; bail before the description regexes so their labels ("Total
// 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" };
}
@ -214,8 +233,7 @@ export function classifyTransaction(txn) {
// Shape test uses the RAW reference: an all-zeros reference still means
// the bank posted a structured row (the zero-guard must not silence
// unmapped return-credit codes, which present exactly this way).
base.checkShaped =
Boolean(rawReference) || /CHECK/i.test(description) || Boolean(base.pmtId);
base.checkShaped = Boolean(rawReference) || /CHECK/i.test(description) || Boolean(base.pmtId);
return { ...base, event: base.checkShaped ? "unknown" : "ignored" };
}
@ -264,9 +282,7 @@ export function matchCheckTransaction(classified, payments, refDateISO) {
const { checkNumber, amount, event } = classified;
const checks = payments.filter((p) => p.method === "Check" && p.check_number);
const numberMatches = checkNumber
? checks.filter((p) => p.check_number === checkNumber)
: [];
const numberMatches = checkNumber ? checks.filter((p) => p.check_number === checkNumber) : [];
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 { unmatched: "multiple number+amount matches" };
@ -279,9 +295,7 @@ export function matchCheckTransaction(classified, payments, refDateISO) {
amountsEqual(p.amount_usd, amount) &&
issuedWithinDays(p, refDateISO, 120) &&
digitsCorroborate(checkNumber, p.check_number) &&
(event !== "check_return" ||
p.clear_status === "Cleared" ||
p.clear_status === "Returned")
(event !== "check_return" || p.clear_status === "Cleared" || p.clear_status === "Returned"),
);
if (fallback.length === 1) return { payment: fallback[0], matchedBy: "amount" };
if (fallback.length > 1) {
@ -300,7 +314,7 @@ export function matchElectronicReturn(classified, payments, refDateISO) {
p.method === "Check" &&
(p.clear_status === "Cleared" || p.clear_status === "Returned") &&
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) {
@ -375,7 +389,7 @@ export function matchAchTransaction(classified, payments, postingISO) {
if (embeddedPaymentNumber) {
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" };
}
@ -387,7 +401,7 @@ export function matchAchTransaction(classified, payments, postingISO) {
!pmtIdConflicts(p) &&
amountsEqual(p.amount_usd, amount) &&
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) {
@ -401,7 +415,7 @@ export function matchAchTransaction(classified, payments, postingISO) {
!pmtIdConflicts(p) &&
(p.clear_status === "Cleared" || p.clear_status === "Returned") &&
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) {
@ -455,7 +469,7 @@ export function applyEvent(payment, classified, eventDateISO, via = null) {
(h) =>
h.event === historyEvent.event &&
h.date === historyEvent.date &&
amountsEqual(h.amount, amount)
amountsEqual(h.amount, amount),
);
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;
const [y, mo, d] = s.split("-").map(Number);
const dt = new Date(Date.UTC(y, mo - 1, d));
return (
dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d
);
return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d;
}
// 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)) {
throw new Error(
`fromDate/toDate must be valid YYYY-MM-DD strings: ` +
`fromDate=${logSafe(fromDate)}, toDate=${logSafe(toDate)}`
`fromDate=${logSafe(fromDate)}, toDate=${logSafe(toDate)}`,
);
}
if (fromDate > toDate) {
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 };
@ -647,9 +659,9 @@ const BALANCE_FIELDS = {
"060": "current_available",
"072": "float_one_day",
"074": "float_two_day",
"100": "total_credits",
"400": "total_debits",
"450": "total_ach_debits",
100: "total_credits",
400: "total_debits",
450: "total_ach_debits",
};
const BALANCE_COUNT_FIELDS = new Set(["100", "400", "450"]);
@ -663,7 +675,12 @@ export function extractBalances(transactions) {
const byDate = new Map();
let datesTruncated = 0;
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();
if (!isValidISODate(date)) continue;
if (!byDate.has(date)) {

View file

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

View file

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

View file

@ -1,6 +1,12 @@
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
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 {
applyEvent,
@ -36,9 +42,7 @@ const UNKNOWN_CODES_CAP = 20;
export const postingDate = (txn) => {
// First NON-EMPTY — an empty-string asOfDate must not shadow a populated
// valueDate (?? only skips null/undefined).
const raw = [txn.asOfDate, txn.valueDate]
.map((v) => String(v ?? "").trim())
.find(Boolean);
const raw = [txn.asOfDate, txn.valueDate].map((v) => String(v ?? "").trim()).find(Boolean);
const iso = isValidISODate(raw) ? raw : toISODate(raw);
if (!iso) return null;
// Plausibility window mirroring the CSV path's isPlausibleSendYear (#65):
@ -55,7 +59,7 @@ let cachedCreds;
async function getReportingCreds() {
if (cachedCreds) return cachedCreds;
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);
return cachedCreds;
@ -106,18 +110,21 @@ export const handler = async (event) => {
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
const res = await fetch(`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
const res = await fetch(
`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/${endpoint}`,
{
method: "POST",
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) {
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`,
Body: rawBody,
ContentType: "application/json",
})
}),
);
} catch (err) {
archiveError = true;
@ -155,7 +162,7 @@ export const handler = async (event) => {
// Response shape: { accountTransactions: [{ accountNumber, bankId, currency, transactions: [...] }] }
const allTransactions = (data.accountTransactions || []).flatMap(
(acct) => acct.transactions || []
(acct) => acct.transactions || [],
);
// Process in posting-date order so multi-day replays apply paid -> return
@ -168,7 +175,7 @@ export const handler = async (event) => {
classifiedTxns.sort(
(a, b) =>
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
@ -182,7 +189,7 @@ export const handler = async (event) => {
FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey,
})
}),
);
payments.push(...result.Items);
lastKey = result.LastEvaluatedKey;
@ -216,7 +223,7 @@ export const handler = async (event) => {
console.error(
`Unmatched ${classified.event}: check=${logSafe(classified.checkNumber || classified.embeddedPaymentNumber)}, ` +
`amount=${classified.amount}, reason=${reason} ` +
`(bankRef: ${logSafe(classified.bankReference)})`
`(bankRef: ${logSafe(classified.bankReference)})`,
);
};
@ -243,7 +250,7 @@ export const handler = async (event) => {
console.error(
`Unknown check-shaped transaction: code=${logSafe(classified.code)}, ` +
`description=${logSafe(classified.description)}, ` +
`ref=${logSafe(classified.customerReference)}, amount=${classified.amount}`
`ref=${logSafe(classified.customerReference)}, amount=${classified.amount}`,
);
continue;
}
@ -274,7 +281,7 @@ export const handler = async (event) => {
// Audit trail for the loosest ACH rung.
console.log(
`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 (attempt === 1) break;
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;
// Refresh the in-memory record in place (it is shared with the
@ -320,7 +327,7 @@ export const handler = async (event) => {
if (outcome !== "written") {
summary.write_conflicts++;
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;
}
@ -336,7 +343,7 @@ export const handler = async (event) => {
console.log(
`${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) {
console.error(
`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) {
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",
ExpressionAttributeValues: { ":runAt": runAt },
})
}),
);
balanceDatesWritten.push(snap.as_of_date);
} catch (err) {
@ -427,14 +434,14 @@ export const handler = async (event) => {
: {}),
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
},
})
}),
);
if (summary.unmatched_count || summary.unknown_count || summary.write_conflicts) {
console.error(
`Reconciliation ${fromDate}..${toDate}: ${summary.unmatched_count} unmatched, ` +
`${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}: ` +
`${summary.matched} matched, ${summary.applied} applied, ` +
`${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 {

View file

@ -1,6 +1,12 @@
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
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 { parse } from "csv-parse/sync";
import { toISODate, toCanonicalMDY, isPlausibleSendYear } from "./dates.js";
@ -15,7 +21,7 @@ let cachedCreds;
async function getCheckMgmtCreds() {
if (cachedCreds) return cachedCreds;
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);
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
// a hand-inserted "1,234.56" string record can't NaN out of registration.
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;
};
@ -58,14 +68,16 @@ async function backfillBoA() {
const submitted = new Set();
let txnKey;
do {
const txnScan = await ddb.send(new ScanCommand({
TableName: TABLE_NAME,
FilterExpression: "begins_with(pk, :prefix) AND success = :t AND #action = :add",
ExpressionAttributeNames: { "#action": "action" },
ExpressionAttributeValues: { ":prefix": "boa_txn#", ":t": true, ":add": "add_Issue" },
ProjectionExpression: "check_numbers",
...(txnKey && { ExclusiveStartKey: txnKey }),
}));
const txnScan = await ddb.send(
new ScanCommand({
TableName: TABLE_NAME,
FilterExpression: "begins_with(pk, :prefix) AND success = :t AND #action = :add",
ExpressionAttributeNames: { "#action": "action" },
ExpressionAttributeValues: { ":prefix": "boa_txn#", ":t": true, ":add": "add_Issue" },
ProjectionExpression: "check_numbers",
...(txnKey && { ExclusiveStartKey: txnKey }),
}),
);
for (const item of txnScan.Items || []) {
for (const cn of item.check_numbers || []) submitted.add(cn);
}
@ -76,14 +88,16 @@ async function backfillBoA() {
const toSubmit = [];
let payKey;
do {
const payScan = await ddb.send(new ScanCommand({
TableName: TABLE_NAME,
FilterExpression: "begins_with(pk, :prefix) AND #method = :check",
ExpressionAttributeNames: { "#method": "method", "#status": "status" },
ExpressionAttributeValues: { ":prefix": "payment#", ":check": "Check" },
ProjectionExpression: "check_number, amount_usd, send_payment_on, #status",
...(payKey && { ExclusiveStartKey: payKey }),
}));
const payScan = await ddb.send(
new ScanCommand({
TableName: TABLE_NAME,
FilterExpression: "begins_with(pk, :prefix) AND #method = :check",
ExpressionAttributeNames: { "#method": "method", "#status": "status" },
ExpressionAttributeValues: { ":prefix": "payment#", ":check": "Check" },
ProjectionExpression: "check_number, amount_usd, send_payment_on, #status",
...(payKey && { ExclusiveStartKey: payKey }),
}),
);
for (const item of payScan.Items || []) {
if (submitted.has(item.check_number)) continue;
if (cancelStatuses.includes((item.status || "").toLowerCase())) continue;
@ -91,7 +105,9 @@ async function backfillBoA() {
const issueDate = toISODate(item.send_payment_on);
const amount = parseAmount(item.amount_usd);
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;
}
toSubmit.push({
@ -110,7 +126,13 @@ async function backfillBoA() {
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 BOA_BATCH_SIZE = 100;
@ -129,24 +151,24 @@ async function backfillBoA() {
}));
const timestamp = new Date().toISOString();
const res = await fetch(
`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
companyId,
},
body: JSON.stringify({ issueList }),
}
);
const res = await fetch(`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
companyId,
},
body: JSON.stringify({ issueList }),
});
const text = await res.text();
const headers = Object.fromEntries(res.headers.entries());
const transactionId =
headers["transactionid"] || headers["x-transactionid"] ||
headers["x-correlation-id"] || headers["x-cashpro-transaction-id"] || null;
headers["transactionid"] ||
headers["x-transactionid"] ||
headers["x-correlation-id"] ||
headers["x-cashpro-transaction-id"] ||
null;
let data = {};
let parseError = null;
if (text.trim()) {
@ -159,23 +181,25 @@ async function backfillBoA() {
parseError = new Error("BoA returned an empty response body");
}
await ddb.send(new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: `boa_txn#${timestamp}#add_Issue`,
timestamp,
action: "add_Issue",
backfill: true,
http_status: res.status,
transaction_id: transactionId,
check_numbers: items.map((i) => i.checkNumber),
total_amount: items.reduce((sum, i) => sum + parseFloat(i.amount), 0).toFixed(2),
success: res.ok,
processed_items: data.processedItems || 0,
total_items: data.totalItems || 0,
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
},
}));
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: `boa_txn#${timestamp}#add_Issue`,
timestamp,
action: "add_Issue",
backfill: true,
http_status: res.status,
transaction_id: transactionId,
check_numbers: items.map((i) => i.checkNumber),
total_amount: items.reduce((sum, i) => sum + parseFloat(i.amount), 0).toFixed(2),
success: res.ok,
processed_items: data.processedItems || 0,
total_items: data.totalItems || 0,
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
},
}),
);
if (res.ok && parseError) {
throw new Error(`Backfill ${label} failed: BoA returned invalid JSON (HTTP ${res.status})`);
@ -214,7 +238,9 @@ async function backfillBoA() {
totalSkippedDuplicates += dupeCheckNumbers.size;
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;
@ -226,15 +252,24 @@ async function backfillBoA() {
const detail = retryResult.parseError
? "BoA returned a non-JSON response"
: "BoA returned a non-duplicate error";
console.error(`Backfill batch ${batchNum} retry failed: HTTP ${retryResult.status}, txnId=${retryResult.transactionId}`);
throw new Error(`Backfill batch ${batchNum} retry failed: ${detail} (HTTP ${retryResult.status})`);
console.error(
`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;
console.log(`Backfill batch ${batchNum} retry: ${retryResult.data.processedItems} processed`);
}
console.log(`Backfill complete: ${totalProcessed} submitted, ${totalSkippedDuplicates} already in BoA`);
return { statusCode: 200, body: `Backfilled ${totalProcessed} checks, ${totalSkippedDuplicates} already in BoA` };
console.log(
`Backfill complete: ${totalProcessed} submitted, ${totalSkippedDuplicates} already in BoA`,
);
return {
statusCode: 200,
body: `Backfilled ${totalProcessed} checks, ${totalSkippedDuplicates} already in BoA`,
};
}
export const handler = async (event) => {
@ -268,11 +303,11 @@ export const handler = async (event) => {
// Status progression ranks — higher number = further along in lifecycle
// Once a payment reaches a higher rank, CSV cannot move it backward
const statusRank = {
"scheduled": 1,
scheduled: 1,
"payment submitted": 2,
"issued": 3,
"outstanding": 4,
"cleared": 5,
issued: 3,
outstanding: 4,
cleared: 5,
};
const newChecks = [];
@ -308,7 +343,7 @@ export const handler = async (event) => {
// Check if record already exists (for detecting new vs updated)
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
@ -397,7 +432,7 @@ export const handler = async (event) => {
"#status": "status",
},
ExpressionAttributeValues: values,
})
}),
);
count++;
@ -418,7 +453,13 @@ export const handler = async (event) => {
// Submit to CashPro if there are any new issues or cancels
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);
@ -436,18 +477,15 @@ export const handler = async (event) => {
const checkNumbers = items.map((i) => i.checkNumber);
const totalAmount = items.reduce((sum, i) => sum + parseFloat(i.amount), 0);
const res = await fetch(
`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
companyId,
},
body: JSON.stringify({ issueList }),
}
);
const res = await fetch(`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
companyId,
},
body: JSON.stringify({ issueList }),
});
const text = await res.text();
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 }));
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;
};
@ -499,7 +537,9 @@ export const handler = async (event) => {
const submitInBatches = async (items, action) => {
for (let i = 0; i < items.length; 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);
}
};
@ -524,11 +564,11 @@ export const handler = async (event) => {
last_file_count: count,
last_rejected_count: rejectedRows.length,
},
})
}),
);
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
@ -537,10 +577,12 @@ export const handler = async (event) => {
// re-offered to BoA.
if (rejectedRows.length) {
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(
`${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() {
if (cachedToken) return cachedToken;
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;
return cachedToken;
@ -24,7 +24,7 @@ let cachedSigningSecret;
async function getSigningSecret() {
if (cachedSigningSecret) return cachedSigningSecret;
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: SLACK_SIGNING_SECRET_NAME })
new GetSecretValueCommand({ SecretId: SLACK_SIGNING_SECRET_NAME }),
);
cachedSigningSecret = SecretString;
return cachedSigningSecret;
@ -39,8 +39,7 @@ function verifySignature(body, timestamp, signature, secret) {
if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const base = `v0:${timestamp}:${body}`;
const expected =
"v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
const expected = "v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature);
@ -51,12 +50,15 @@ function verifySignature(body, timestamp, signature, secret) {
// --- Shared helpers used by both home view and modals ---
const formatCurrency = (value) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
Number(value || 0)
);
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(Number(value || 0));
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"];
@ -138,7 +140,7 @@ export function categorizePayments(payments) {
// Oldest return first: the longest-unpaid vendor is the most overdue
// decision. Missing returned_date sorts first (unknown = assume worst).
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) => {
@ -151,7 +153,15 @@ export function categorizePayments(payments) {
return { ...bucket, items, total };
});
return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments };
return {
today,
daysSince,
scheduledChecks,
scheduledACH,
outstandingChecks,
bucketedOutstanding,
returnedPayments,
};
}
// --- Main handler ---
@ -167,7 +177,7 @@ export const handler = async (event) => {
// else — without this, an unauthenticated caller could forge events and
// exfiltrate payment data via views.publish.
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 signature = headers["x-slack-signature"] || "";
@ -208,7 +218,7 @@ export const handler = async (event) => {
async function publishHomeView(userId, expanded) {
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([
@ -266,13 +276,16 @@ async function handleBlockAction(body) {
}
const payments = await scanPayments();
const { today, daysSince, scheduledChecks, scheduledACH, bucketedOutstanding } = categorizePayments(payments);
const { today, daysSince, scheduledChecks, scheduledACH, bucketedOutstanding } =
categorizePayments(payments);
let modalTitle = "";
let items = [];
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 source = method === "Check" ? scheduledChecks : scheduledACH;
@ -351,7 +364,10 @@ function buildPaymentListBlocks(items) {
blocks.push({
type: "section",
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}` },
],
});
@ -372,7 +388,7 @@ async function scanBoATransactions() {
FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "boa_txn#" },
ExclusiveStartKey: lastKey,
})
}),
);
items.push(...result.Items);
lastKey = result.LastEvaluatedKey;
@ -394,7 +410,7 @@ async function scanPayments() {
FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey,
})
}),
);
payments.push(...result.Items);
lastKey = result.LastEvaluatedKey;
@ -411,8 +427,10 @@ async function fetchCashPosition() {
const localToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const toDateStr = (d) =>
d.getFullYear() + "-" +
String(d.getMonth() + 1).padStart(2, "0") + "-" +
d.getFullYear() +
"-" +
String(d.getMonth() + 1).padStart(2, "0") +
"-" +
String(d.getDate()).padStart(2, "0");
const todayStr = toDateStr(localToday);
@ -429,9 +447,7 @@ async function fetchCashPosition() {
}
const results = await Promise.allSettled(
keys.map(({ key }) =>
ddb.send(new GetCommand({ TableName: TABLE_NAME, Key: { pk: key } }))
)
keys.map(({ key }) => ddb.send(new GetCommand({ TableName: TABLE_NAME, Key: { pk: key } }))),
);
let intraday = null;
@ -441,7 +457,7 @@ async function fetchCashPosition() {
console.error(
"Cash-position intraday GetItem failed:",
logSafe(results[0].reason?.name),
logSafe(results[0].reason?.message)
logSafe(results[0].reason?.message),
);
}
@ -453,7 +469,7 @@ async function fetchCashPosition() {
console.error(
"Cash-position previous-day GetItem failed:",
logSafe(res.reason?.name),
logSafe(res.reason?.message)
logSafe(res.reason?.message),
);
continue;
}
@ -472,14 +488,16 @@ function formatBoATimestamp(iso) {
if (!iso) return "unknown";
const d = new Date(iso);
if (isNaN(d.getTime())) return "unknown";
return d.toLocaleString("en-US", {
timeZone: "America/New_York",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}) + " ET";
return (
d.toLocaleString("en-US", {
timeZone: "America/New_York",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}) + " ET"
);
}
function buildBoABlocks(transactions) {
@ -509,16 +527,15 @@ function buildBoABlocks(transactions) {
for (const t of transactions) {
const when = formatBoATimestamp(t.timestamp);
const checks = Array.isArray(t.check_numbers) ? t.check_numbers : [];
const checksLabel = checks.length <= 3
? checks.join(", ")
: `${checks.slice(0, 3).join(", ")} +${checks.length - 3} more`;
const checksLabel =
checks.length <= 3
? checks.join(", ")
: `${checks.slice(0, 3).join(", ")} +${checks.length - 3} more`;
const statusIcon = t.success ? ":white_check_mark:" : ":x:";
const summary = t.success
? `${t.processed_items}/${t.total_items} processed`
: `HTTP ${t.http_status} · failed`;
const txnLine = t.transaction_id
? `TxnID: \`${t.transaction_id}\``
: "_TxnID not captured_";
const txnLine = t.transaction_id ? `TxnID: \`${t.transaction_id}\`` : "_TxnID not captured_";
blocks.push({
type: "section",
@ -533,8 +550,22 @@ function buildBoABlocks(transactions) {
return blocks;
}
export function buildHomeView(payments, metadata, boaTransactions = [], cashPosition = null, expanded = []) {
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments } = categorizePayments(payments);
export function buildHomeView(
payments,
metadata,
boaTransactions = [],
cashPosition = null,
expanded = [],
) {
const {
today,
daysSince,
scheduledChecks,
scheduledACH,
outstandingChecks,
bucketedOutstanding,
returnedPayments,
} = categorizePayments(payments);
const isExpanded = (key) => expanded.includes(key);
// 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 dateLabel = group.date ? formatDisplayDate(group.date) : "Unknown";
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({
type: "section",
@ -710,7 +748,10 @@ export function buildHomeView(payments, metadata, boaTransactions = [], cashPosi
// Summary bar
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 totalReturnedAmt = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0);
@ -751,17 +792,19 @@ export function buildHomeView(payments, metadata, boaTransactions = [], cashPosi
fields: [
{
type: "mrkdwn",
text: balance && balance.current_ledger != null
? `:moneybag: *Cash Position* — ${balance.as_of_date}\nLedger ${formatCurrency(balance.current_ledger)} · Available ${formatCurrency(balance.current_available)}`
: `:moneybag: *Cash Position*\nNot available`,
text:
balance && balance.current_ledger != null
? `:moneybag: *Cash Position* — ${balance.as_of_date}\nLedger ${formatCurrency(balance.current_ledger)} · Available ${formatCurrency(balance.current_available)}`
: `:moneybag: *Cash Position*\nNot available`,
},
{
type: "mrkdwn",
text: intraday && intraday.current_ledger != null
? `_Intraday (provisional)_\nLedger ${formatCurrency(intraday.current_ledger)} · Available ${formatCurrency(intraday.current_available)}`
: balance
? "_Intraday not yet available_"
: "_No balance data_",
text:
intraday && intraday.current_ledger != null
? `_Intraday (provisional)_\nLedger ${formatCurrency(intraday.current_ledger)} · Available ${formatCurrency(intraday.current_available)}`
: balance
? "_Intraday not yet available_"
: "_No balance data_",
},
{
type: "mrkdwn",
@ -783,8 +826,20 @@ export function buildHomeView(payments, metadata, boaTransactions = [], cashPosi
},
{ type: "divider" },
...buildReturnedBlocks(),
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "scheduled_checks", "checks"),
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "scheduled_ach", "ach"),
...buildScheduledBlocks(
"Scheduled Checks",
":ledger:",
scheduledChecks,
"scheduled_checks",
"checks",
),
...buildScheduledBlocks(
"Scheduled ACH",
":electric_plug:",
scheduledACH,
"scheduled_ach",
"ach",
),
...buildOutstandingBlocks(),
...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", () => {
// 1222000012 is a digit-subsequence of issued 11222000012.
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.matchedBy, "amount");
});
@ -209,7 +213,7 @@ test("matcher: check_return amount fallback requires a bank-confirmed candidate"
const r1 = matchCheckTransaction(
{ event: "check_return", checkNumber: "1222000012", amount: 6413 },
unconfirmed,
"2026-07-20"
"2026-07-20",
);
assert.equal(r1.payment, undefined);
@ -219,7 +223,7 @@ test("matcher: check_return amount fallback requires a bank-confirmed candidate"
const r2 = matchCheckTransaction(
{ event: "check_return", checkNumber: "1222000012", amount: 6413 },
confirmed,
"2026-07-20"
"2026-07-20",
);
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", () => {
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);
});
@ -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", () => {
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);
});
@ -293,7 +305,12 @@ test("electronic return: never matches ACH payments", () => {
test("electronic return: ambiguity is unmatched", () => {
const payments = [
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");
assert.equal(m.payment, undefined);
@ -371,7 +388,7 @@ test("transitions: paid -> returned -> redeposit sequence accumulates history",
assert.equal(p.history.length, 3);
assert.deepEqual(
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: "Returned" }), paidEvent()],
[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) {
const r = applyEvent(p, ev, "2026-07-20");
@ -469,11 +489,20 @@ const achPayment = (over = {}) => ({
});
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(
{ 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,
"2026-06-08"
"2026-06-08",
);
assert.equal(m.payment.check_number, "21222000264");
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 }),
];
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,
"2026-06-08"
"2026-06-08",
);
assert.equal(m.payment.check_number, "21222000265");
assert.equal(m.matchedBy, "vendor+amount");
});
test("ach matcher: stored pmt_id attributes a reversal credit", () => {
const payments = [
achPayment({ pmt_id: "7584198", clear_status: "Cleared", status: "Cleared" }),
];
const payments = [achPayment({ pmt_id: "7584198", clear_status: "Cleared", status: "Cleared" })];
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,
"2026-06-10"
"2026-06-10",
);
assert.equal(m.matchedBy, "pmt_id");
});
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...
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(
{ 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,
"2026-06-04"
"2026-06-04",
);
assert.equal(m1.matchedBy, "vendor+amount");
// ...but a posting 30 days after the send date must not.
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,
"2026-06-26"
"2026-06-26",
);
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(
{ 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,
"2026-05-26"
"2026-05-26",
);
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 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,
"2026-05-26"
"2026-05-26",
);
assert.equal(m1.payment, undefined);
const noDate = [achPayment({ ...base })];
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,
"2026-05-26"
"2026-05-26",
);
assert.equal(m2.payment, undefined);
});
test("ach matcher: return credit with an unattributable PMT id is unmatched, never amount-guessed", () => {
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(
{ event: "ach_return", pmtId: "999", embeddedPaymentNumber: null, vendorText: null, amount: 8300 },
{
event: "ach_return",
pmtId: "999",
embeddedPaymentNumber: null,
vendorText: null,
amount: 8300,
},
payments,
"2026-05-26"
"2026-05-26",
);
assert.equal(m.payment, undefined);
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", () => {
const payments = [achPayment({ pmt_id: "7584198", amount_usd: 8300 })];
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,
"2026-06-10"
"2026-06-10",
);
assert.equal(m.payment, undefined);
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
// first disambiguates to the second instead of going ambiguous.
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,
"2026-06-08"
"2026-06-08",
);
assert.equal(m.payment.check_number, "21222000270");
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", () => {
const payments = [
achPayment({ 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 }),
achPayment({
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(
{ event: "ach_return", pmtId: null, embeddedPaymentNumber: null, vendorText: null, amount: 500 },
{
event: "ach_return",
pmtId: null,
embeddedPaymentNumber: null,
vendorText: null,
amount: 500,
},
payments,
"2026-06-08"
"2026-06-08",
);
assert.equal(m.payment, undefined);
assert.match(m.unmatched, /ambiguous/);
@ -640,7 +752,7 @@ test("ach transitions: settled debit clears both fields and persists pmt_id", ()
const r = applyEvent(
{ ...p },
{ event: "ach_debit", pmtId: "7584198", amount: 8300, bankReference: "905512345" },
"2026-06-04"
"2026-06-04",
);
assert.equal(r.kind, "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)", () => {
const p = achPayment({ 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");
const p = achPayment({
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.updates.clear_status, "Returned");
assert.equal(ret.updates.returned_date, "2026-05-26");
Object.assign(p, ret.updates);
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.updates.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", () => {
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.updates.status, "Voided");
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", () => {
const payments = [
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({ 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
achPayment({
check_number: "21222000301",
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: "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 = [];
for (let i = 0; i < STALE_LIST_CAP + 5; i++) {
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");
@ -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-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" }));
});

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)", () => {
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" });
});
@ -106,7 +110,10 @@ test("fixture: quiet previous-day week has zero unknowns and full summary covera
const h = histogram(fixture("previous-day-week"));
assert.equal(h.unknown ?? 0, 0);
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", () => {
@ -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", () => {
const rows = [];
for (let i = 0; i < 60; i++)
rows.push({ transactionType: "Summary", 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" });
rows.push({
transactionType: "Summary",
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 (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 s = snaps.find((x) => x.as_of_date === "2026-07-22");
assert.ok(Object.keys(s.other).length <= 50);
assert.ok(s.other_truncated > 0);
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[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", () => {
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, /lifecycle/);
@ -68,18 +74,27 @@ describe("HCP Terraform seam (PLAT-79)", () => {
assert.match(deploy, /environment: prod/);
assert.match(deploy, /ship-gate: true/);
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 / ci", () => {
it("runs npm test and the Terraform callable behind ci-complete", () => {
assert.doesNotMatch(ci, /ci-typescript-cdk/);
assert.doesNotMatch(ci, /run-sam-validate/);
assert.match(ci, /npm test/);
assert.match(ci, /ci-terraform\.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd/);
assert.match(ci, /ci-autofix\.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd/);
assert.match(ci, /presets: prettier,terraform/);
assert.match(ci, /npm run format:check/);
assert.match(ci, /src\//);
assert.match(ci, /package\.json/);
assert.match(ci, /package-lock\.json/);
assert.match(ci, /name: ci \/ ci/);
assert.match(ci, /hotfix\/\*\*/);
assert.match(ci, /release\/\*\*/);
assert.match(ci, /name: ci-complete/);
assert.doesNotMatch(ci, /name: ci \/ ci/);
});
it("names the five live functions", () => {
@ -108,5 +123,4 @@ describe("HCP Terraform seam (PLAT-79)", () => {
assert.doesNotMatch(githubDeploy, /deploy\.yaml@refs\/heads/);
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", () => {
for (const status of ["Marked as Void", "Voided", "Cancelled", "canceled"]) {
const { returnedPayments, outstandingChecks } = categorizePayments([
returnedCheck({ status }),
]);
const { returnedPayments, outstandingChecks } = categorizePayments([returnedCheck({ status })]);
assert.equal(returnedPayments.length, 0, `status ${status} in returned bucket`);
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", () => {
const { returnedPayments } = categorizePayments([
returnedCheck({ send_payment_on: "garbage" }),
]);
const { returnedPayments } = categorizePayments([returnedCheck({ send_payment_on: "garbage" })]);
assert.equal(returnedPayments.length, 1);
});
@ -85,7 +81,7 @@ test("categorize: returned bucket sorts oldest return first, unknown date first"
]);
assert.deepEqual(
returnedPayments.map((p) => p.payee),
["C", "A", "B"]
["C", "A", "B"],
);
});