mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-02 09:43:24 +00:00
ci: require ci-complete for test and Terraform (#121)
* ci: aggregate test and Terraform as ci-complete Converted callers emit that check so this repo can leave the ci / ci ruleset. * ci: add Prettier format and format:check The autofix prettier preset runs npm run format, and CI fails closed when the tree is unformatted.
This commit is contained in:
parent
7c2c806339
commit
5ff8099b7f
25 changed files with 914 additions and 440 deletions
55
.github/workflows/ci.yaml
vendored
55
.github/workflows/ci.yaml
vendored
|
|
@ -1,16 +1,31 @@
|
|||
name: CI
|
||||
|
||||
# Parallel test and Terraform portions. The required check is ci-complete.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, hotfix/**, release/**]
|
||||
merge_group:
|
||||
push:
|
||||
branches: [hotfix/**, release/**]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||
permissions:
|
||||
contents: write
|
||||
secrets: inherit
|
||||
with:
|
||||
presets: prettier,terraform
|
||||
|
||||
test:
|
||||
name: Test
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
|
|
@ -26,11 +41,16 @@ jobs:
|
|||
- name: Install
|
||||
run: npm ci
|
||||
|
||||
- name: Check format
|
||||
run: npm run format:check
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
terraform:
|
||||
name: Terraform
|
||||
needs: autofix
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@47185fa602dffddb8297db5f3525d7c9bc05d7cd # v1.0.21
|
||||
with:
|
||||
terraform-version: "1.16.0"
|
||||
|
|
@ -40,33 +60,18 @@ jobs:
|
|||
package.json
|
||||
package-lock.json
|
||||
|
||||
ci:
|
||||
name: ci / ci
|
||||
needs: [test, terraform]
|
||||
if: ${{ always() && !cancelled() }}
|
||||
ci-complete:
|
||||
name: ci-complete
|
||||
needs: [autofix, test, terraform]
|
||||
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check jobs
|
||||
- name: Require portions
|
||||
env:
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
TERRAFORM_RESULT: ${{ needs.terraform.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
TERRAFORM: ${{ needs.terraform.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail=0
|
||||
check() {
|
||||
local name="$1"
|
||||
local result="$2"
|
||||
case "${result}" in
|
||||
success)
|
||||
echo "${name}: ${result}"
|
||||
;;
|
||||
*)
|
||||
echo "${name}: ${result}" >&2
|
||||
fail=1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
check test "${TEST_RESULT}"
|
||||
check terraform "${TERRAFORM_RESULT}"
|
||||
exit "${fail}"
|
||||
test "${TEST}" = success
|
||||
test "${TERRAFORM}" = success
|
||||
|
|
|
|||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
package-lock.json
|
||||
build
|
||||
.aws-sam
|
||||
terraform/.terraform
|
||||
terraform/build
|
||||
3
.prettierrc
Normal file
3
.prettierrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"printWidth": 100
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
88
README.md
88
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-<fn>-...`):
|
||||
|
||||
| Type | Metric / Statistic | Threshold |
|
||||
|----------------------|--------------------|-----------|
|
||||
| `-errors` (all 5) | `Errors` / Sum | > 0 |
|
||||
| `-throttles` (all 5) | `Throttles` / Sum | > 0 |
|
||||
| Type | Metric / Statistic | Threshold |
|
||||
| -------------------- | -------------------- | ------------------------------- |
|
||||
| `-errors` (all 5) | `Errors` / Sum | > 0 |
|
||||
| `-throttles` (all 5) | `Throttles` / Sum | > 0 |
|
||||
| `-duration` (all 5) | `Duration` / Maximum | ~80% of each function's timeout |
|
||||
|
||||
Duration thresholds (ms): processPaymentCsv 96000, fetchBoaTransactions 48000, slackAppHome 24000, expenseProcessor 12000, expenseReceiver 4000.
|
||||
|
|
@ -157,11 +159,11 @@ Duration thresholds (ms): processPaymentCsv 96000, fetchBoaTransactions 48000, s
|
|||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| Script | Purpose |
|
||||
| ----------------------------- | ------------------------------------------------------- |
|
||||
| `scripts/test-boa-sandbox.js` | One-off sandbox connectivity test for both CashPro APIs |
|
||||
| `scripts/seed-from-csv.js` | Seed DynamoDB from a local CSV file |
|
||||
| `scripts/seed-bank-status.js` | Seed bank clear status data into DynamoDB |
|
||||
| `scripts/seed-from-csv.js` | Seed DynamoDB from a local CSV file |
|
||||
| `scripts/seed-bank-status.js` | Seed bank clear status data into DynamoDB |
|
||||
|
||||
## Deployment
|
||||
|
||||
|
|
|
|||
24
SETUP.md
24
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
|
||||
|
||||
|
|
|
|||
19
package-lock.json
generated
19
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ const { DynamoDBDocumentClient, BatchGetCommand } = require("@aws-sdk/lib-dynamo
|
|||
|
||||
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "us-east-1" }));
|
||||
const CSV_PATH = process.argv[2];
|
||||
if (!CSV_PATH) { console.error("Usage: node simulate-csv.cjs <csv-path>"); process.exit(1); }
|
||||
if (!CSV_PATH) {
|
||||
console.error("Usage: node simulate-csv.cjs <csv-path>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
|
||||
const statusRank = { "scheduled": 1, "payment submitted": 2, "issued": 3, "outstanding": 4, "cleared": 5 };
|
||||
const statusRank = { scheduled: 1, "payment submitted": 2, issued: 3, outstanding: 4, cleared: 5 };
|
||||
|
||||
function toISODate(mdy) {
|
||||
const p = String(mdy).split("/");
|
||||
|
|
@ -19,8 +22,11 @@ function toISODate(mdy) {
|
|||
|
||||
(async () => {
|
||||
const text = fs.readFileSync(CSV_PATH, "utf-8");
|
||||
const rows = parse(text, { columns: true, skip_empty_lines: true, trim: true })
|
||||
.map((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [String(k).trim(), typeof v === "string" ? v.trim() : v])));
|
||||
const rows = parse(text, { columns: true, skip_empty_lines: true, trim: true }).map((r) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(r).map(([k, v]) => [String(k).trim(), typeof v === "string" ? v.trim() : v]),
|
||||
),
|
||||
);
|
||||
|
||||
const validRows = rows.filter((r) => (r["Check Number"] || "").trim());
|
||||
console.log(`CSV rows: ${rows.length} total, ${validRows.length} with check numbers`);
|
||||
|
|
@ -30,7 +36,9 @@ function toISODate(mdy) {
|
|||
const existing = {};
|
||||
for (let i = 0; i < keys.length; i += 100) {
|
||||
const batch = keys.slice(i, i + 100);
|
||||
const res = await ddb.send(new BatchGetCommand({ RequestItems: { PaymentsDashboard: { Keys: batch } } }));
|
||||
const res = await ddb.send(
|
||||
new BatchGetCommand({ RequestItems: { PaymentsDashboard: { Keys: batch } } }),
|
||||
);
|
||||
for (const item of res.Responses.PaymentsDashboard) existing[item.pk] = item;
|
||||
}
|
||||
console.log(`DDB records found for: ${Object.keys(existing).length} of ${validRows.length}\n`);
|
||||
|
|
@ -74,43 +82,71 @@ function toISODate(mdy) {
|
|||
}
|
||||
|
||||
if (frozenReason) {
|
||||
if (bankConfirmed) frozenByBank.push({ checkNumber, payee: row["Payee"], reason: frozenReason });
|
||||
if (bankConfirmed)
|
||||
frozenByBank.push({ checkNumber, payee: row["Payee"], reason: frozenReason });
|
||||
else frozenByRank.push({ checkNumber, payee: row["Payee"], reason: frozenReason });
|
||||
} else if (ex && ex.status !== status) {
|
||||
statusChanges.push({ checkNumber, payee: row["Payee"], from: ex.status, to: status });
|
||||
}
|
||||
|
||||
if (method !== "Check") {
|
||||
if (!ex) newNonChecks.push({ checkNumber, payee: row["Payee"], method, status, amount: row["Amount in USD"] });
|
||||
if (!ex)
|
||||
newNonChecks.push({
|
||||
checkNumber,
|
||||
payee: row["Payee"],
|
||||
method,
|
||||
status,
|
||||
amount: row["Amount in USD"],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ex) {
|
||||
newChecks.push({ checkNumber, payee: row["Payee"], amount: row["Amount in USD"], status, sendOn });
|
||||
} else if (cancelStatuses.includes(status.toLowerCase()) && !cancelStatuses.includes((ex.status || "").toLowerCase())) {
|
||||
cancelChecks.push({ checkNumber, payee: row["Payee"], from: ex.status, amount: row["Amount in USD"] });
|
||||
newChecks.push({
|
||||
checkNumber,
|
||||
payee: row["Payee"],
|
||||
amount: row["Amount in USD"],
|
||||
status,
|
||||
sendOn,
|
||||
});
|
||||
} else if (
|
||||
cancelStatuses.includes(status.toLowerCase()) &&
|
||||
!cancelStatuses.includes((ex.status || "").toLowerCase())
|
||||
) {
|
||||
cancelChecks.push({
|
||||
checkNumber,
|
||||
payee: row["Payee"],
|
||||
from: ex.status,
|
||||
amount: row["Amount in USD"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`========= WOULD HIT BoA =========`);
|
||||
console.log(`NEW checks → add_Issue: ${newChecks.length}`);
|
||||
for (const c of newChecks) console.log(` ${c.checkNumber} | ${c.payee} | $${c.amount} | ${c.status} | ${c.sendOn}`);
|
||||
for (const c of newChecks)
|
||||
console.log(` ${c.checkNumber} | ${c.payee} | $${c.amount} | ${c.status} | ${c.sendOn}`);
|
||||
|
||||
console.log(`\nCancellations → cancel_Issue: ${cancelChecks.length}`);
|
||||
for (const c of cancelChecks) console.log(` ${c.checkNumber} | ${c.payee} | ${c.from}→cancel | $${c.amount}`);
|
||||
for (const c of cancelChecks)
|
||||
console.log(` ${c.checkNumber} | ${c.payee} | ${c.from}→cancel | $${c.amount}`);
|
||||
|
||||
console.log(`\n========= DDB-ONLY CHANGES =========`);
|
||||
console.log(`Status changes (existing records): ${statusChanges.length}`);
|
||||
for (const c of statusChanges.slice(0, 30)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.from} → ${c.to}`);
|
||||
for (const c of statusChanges.slice(0, 30))
|
||||
console.log(` ${c.checkNumber} | ${c.payee} | ${c.from} → ${c.to}`);
|
||||
if (statusChanges.length > 30) console.log(` ... +${statusChanges.length - 30} more`);
|
||||
|
||||
console.log(`\nNew non-check rows (ACH/etc., DDB only): ${newNonChecks.length}`);
|
||||
for (const c of newNonChecks.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.method} | ${c.status} | $${c.amount}`);
|
||||
for (const c of newNonChecks.slice(0, 10))
|
||||
console.log(` ${c.checkNumber} | ${c.payee} | ${c.method} | ${c.status} | $${c.amount}`);
|
||||
if (newNonChecks.length > 10) console.log(` ... +${newNonChecks.length - 10} more`);
|
||||
|
||||
console.log(`\n========= BLOCKED / FROZEN =========`);
|
||||
console.log(`Frozen by bank-confirmed Cleared: ${frozenByBank.length}`);
|
||||
for (const c of frozenByBank.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
|
||||
for (const c of frozenByBank.slice(0, 10))
|
||||
console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
|
||||
console.log(`\nFrozen by status-rank protection: ${frozenByRank.length}`);
|
||||
for (const c of frozenByRank.slice(0, 10)) console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
|
||||
for (const c of frozenByRank.slice(0, 10))
|
||||
console.log(` ${c.checkNumber} | ${c.payee} | ${c.reason}`);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue