diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ce23746..970d753 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..593c6f6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +package-lock.json +build +.aws-sam +terraform/.terraform +terraform/build diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..de753c5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/AGENTS.md b/AGENTS.md index 3da33df..3aab260 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 8c13691..a3f8b56 100644 --- a/README.md +++ b/README.md @@ -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--...`): -| 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 diff --git a/SETUP.md b/SETUP.md index 00cbe9b..a116ec2 100644 --- a/SETUP.md +++ b/SETUP.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 7384511..7044972 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5ba1df6..0b940c3 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/dryrun.cjs b/scripts/dryrun.cjs index 173b751..a58fbca 100644 --- a/scripts/dryrun.cjs +++ b/scripts/dryrun.cjs @@ -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(""); } diff --git a/scripts/reissue-checks.cjs b/scripts/reissue-checks.cjs index 52f56a3..0e8396a 100644 --- a/scripts/reissue-checks.cjs +++ b/scripts/reissue-checks.cjs @@ -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; diff --git a/scripts/seed-bank-status.js b/scripts/seed-bank-status.js index 2e7449d..c8fb42e 100644 --- a/scripts/seed-bank-status.js +++ b/scripts/seed-bank-status.js @@ -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)`); } diff --git a/scripts/seed-from-csv.js b/scripts/seed-from-csv.js index 8c0edf7..7ad5b6b 100644 --- a/scripts/seed-from-csv.js +++ b/scripts/seed-from-csv.js @@ -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.`); diff --git a/scripts/simulate-csv.cjs b/scripts/simulate-csv.cjs index 8def8ea..de1ebb7 100644 --- a/scripts/simulate-csv.cjs +++ b/scripts/simulate-csv.cjs @@ -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 "); process.exit(1); } +if (!CSV_PATH) { + console.error("Usage: node simulate-csv.cjs "); + 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}`); })(); diff --git a/scripts/test-boa-prod.js b/scripts/test-boa-prod.js index d941cbd..584c056 100644 --- a/scripts/test-boa-prod.js +++ b/scripts/test-boa-prod.js @@ -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(); diff --git a/scripts/test-boa-sandbox.js b/scripts/test-boa-sandbox.js index f59851f..7212f28 100644 --- a/scripts/test-boa-sandbox.js +++ b/scripts/test-boa-sandbox.js @@ -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()); diff --git a/src/boaRecon.js b/src/boaRecon.js index 9601718..8bec492 100644 --- a/src/boaRecon.js +++ b/src/boaRecon.js @@ -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)) { diff --git a/src/expenseProcessor.js b/src/expenseProcessor.js index 6fa2f08..8507e4d 100644 --- a/src/expenseProcessor.js +++ b/src/expenseProcessor.js @@ -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) { diff --git a/src/expenseReceiver.js b/src/expenseReceiver.js index 59450fe..c5e9d1e 100644 --- a/src/expenseReceiver.js +++ b/src/expenseReceiver.js @@ -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), - }) + }), ); } diff --git a/src/fetchBoaTransactions.js b/src/fetchBoaTransactions.js index 18bca89..cae5a1f 100644 --- a/src/fetchBoaTransactions.js +++ b/src/fetchBoaTransactions.js @@ -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 { diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js index 3087c43..90dadc2 100644 --- a/src/processPaymentCsv.js +++ b/src/processPaymentCsv.js @@ -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)`, ); } diff --git a/src/slackAppHome.js b/src/slackAppHome.js index 0dac971..400510e 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -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), ], diff --git a/tests/boaRecon.test.js b/tests/boaRecon.test.js index 943f7a4..6cbaf2b 100644 --- a/tests/boaRecon.test.js +++ b/tests/boaRecon.test.js @@ -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" })); }); diff --git a/tests/boaReconIntraday.test.js b/tests/boaReconIntraday.test.js index 58a7101..79be205 100644 --- a/tests/boaReconIntraday.test.js +++ b/tests/boaReconIntraday.test.js @@ -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); }); diff --git a/tests/infra/hcpContract.test.js b/tests/infra/hcpContract.test.js index 1798a52..02743b8 100644 --- a/tests/infra/hcpContract.test.js +++ b/tests/infra/hcpContract.test.js @@ -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/); }); - }); diff --git a/tests/slackAppHome.test.js b/tests/slackAppHome.test.js index f804242..039d3b8 100644 --- a/tests/slackAppHome.test.js +++ b/tests/slackAppHome.test.js @@ -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"], ); });