Compare commits

...

4 commits

Author SHA1 Message Date
Adam Moussa
aacdb3eca6
Merge pull request #67
Some checks are pending
Deploy / deploy (push) Waiting to run
Bump the minor-and-patch group across 1 directory with 6 updates
2026-07-22 16:47:48 -04:00
dependabot[bot]
ca778c5806
Bump the minor-and-patch group across 1 directory with 6 updates
Bumps the minor-and-patch group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-dynamodb](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-dynamodb) | `3.1087.0` | `3.1092.0` |
| [@aws-sdk/client-lambda](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-lambda) | `3.1087.0` | `3.1092.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1087.0` | `3.1092.0` |
| [@aws-sdk/client-secrets-manager](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-secrets-manager) | `3.1087.0` | `3.1092.0` |
| [@aws-sdk/client-sqs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sqs) | `3.1087.0` | `3.1092.0` |
| [@aws-sdk/lib-dynamodb](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-dynamodb) | `3.1087.0` | `3.1092.0` |



Updates `@aws-sdk/client-dynamodb` from 3.1087.0 to 3.1092.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-dynamodb/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1092.0/clients/client-dynamodb)

Updates `@aws-sdk/client-lambda` from 3.1087.0 to 3.1092.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-lambda/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1092.0/clients/client-lambda)

Updates `@aws-sdk/client-s3` from 3.1087.0 to 3.1092.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1092.0/clients/client-s3)

Updates `@aws-sdk/client-secrets-manager` from 3.1087.0 to 3.1092.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-secrets-manager/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1092.0/clients/client-secrets-manager)

Updates `@aws-sdk/client-sqs` from 3.1087.0 to 3.1092.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sqs/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1092.0/clients/client-sqs)

Updates `@aws-sdk/lib-dynamodb` from 3.1087.0 to 3.1092.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-dynamodb/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1092.0/lib/lib-dynamodb)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-dynamodb"
  dependency-version: 3.1092.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/client-lambda"
  dependency-version: 3.1092.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1092.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/client-secrets-manager"
  dependency-version: 3.1092.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/client-sqs"
  dependency-version: 3.1092.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@aws-sdk/lib-dynamodb"
  dependency-version: 3.1092.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 16:44:13 -04:00
Adam Moussa
a6a8132a69
feat(apphome): surface returned payments as a needs-action queue (#74)
* feat(apphome): surface returned payments as a needs-action queue

Bank-returned payments carry status "Cleared" (the CSV ladder has no
Returned rung), so the status skip-list hid them from every App Home
bucket; five Feb-Apr returns ($5,256.62) surfaced only via a manual
statement reconciliation. Route on clear_status ahead of the status
skip-list: non-canceled Returned records render in an always-visible
action queue (oldest return first) plus a summary tile, and are
excluded from Outstanding totals. Terminal voided-and-bounced records
stay out of both (audit trail only). Membership keys off clear_status,
not returned_date, so redeposited checks drop back out of the queue.

Refs: #70

* docs(apphome): restore returned-queue README bullet dropped in rebase
2026-07-22 16:38:56 -04:00
Adam Moussa
ffab1c8f7b
feat(fetchboa): intraday current-day runs, raw S3 archive, balance records (#76)
Same-day settlement visibility plus maximal data capture from the
reporting feed. The handler gains an allowlisted endpoint parameter
(EventBridge passes {"endpoint":"current-day"} on a new 16/19/22 UTC
weekday rule; the 9am previous-day sweep is unchanged and remains
authoritative). Every response's exact bytes archive to a new
Retain-protected bucket before classification, so the feed is
replayable and auditable even across parser changes. Summary rows
become per-date boa_balance# snapshots (latest-wins on run_at, no
TTL) instead of being discarded. The staleness sweep is gated to
previous-day runs so intraday runs don't re-alert the backlog three
times a day. History events carry a via:<endpoint> audit tag outside
the replay-idempotence identity. Fixtures are sanitized real API
captures; classification histograms assert against live-verified
counts.

Refs: #66, #69
2026-07-22 16:02:48 -04:00
14 changed files with 5351 additions and 252 deletions

View file

@ -11,8 +11,8 @@ AWS SAM application that ingests payment CSVs, syncs check data with Bank of Ame
- **ProcessPayrollEmail** — Lambda triggered by S3 (inbound email) and SQS (batch timer). SES receives Gusto payroll emails at `payroll@int.seahaven.com`, stores them to S3, and this Lambda parses the email body, extracts financial data, and posts a combined Slack notification (employee payroll + contractor payments) after a 10-minute batching window. Runs outside VPC.
- **ProcessPaymentCsv** — Lambda triggered by S3 CSV upload. Parses Stampli payment exports, upserts to DynamoDB, and submits new/cancelled checks to the CashPro Check Management API.
- **FetchBoaTransactions** — Scheduled Lambda (weekdays 9am ET). Calls the CashPro Previous Day Transaction Inquiry API, classifies each transaction (paid checks, ARP refer-to-maker and return-of-posted-check credits, electronic returns), and reconciles them onto DynamoDB payment records. See [Bank reconciliation](#bank-reconciliation-fetchboatransactions).
- **SlackAppHome** — Lambda behind API Gateway (`POST /slack/events`). Verifies the Slack signing secret (HMAC-SHA256, 5-minute replay window) before processing, then renders the payments dashboard on the Slack App Home tab with outstanding aging buckets and drill-down modals.
- **FetchBoaTransactions** — Scheduled Lambda. Weekdays 9am ET it calls the CashPro **previous-day** Transaction Inquiry (authoritative sweep, trailing 7 days); weekdays at 16:00/19:00/22:00 UTC (~12/3/6pm ET, fixed-UTC so it drifts an hour in winter) it calls the **current-day** inquiry for same-day visibility (EventBridge `Input: {"endpoint":"current-day"}`, today-only, staleness sweep skipped). Every run archives the exact raw response to the `seahaven-payments-boa-raw-*` bucket (`raw/<endpoint>/<fromDate>_<toDate>/<runAt>.json`, SSE-S3, 730-day lifecycle, PutObject-only grant; Retain-protected — decommission goes through the CFN decommission runbook) and upserts per-date `boa_balance#<asOfDate>#<endpoint>` snapshots (latest-wins on `run_at`, no TTL) from the Summary rows. Classifies each transaction and reconciles onto DynamoDB payment records. Event payload: `{fromDate?, toDate?, endpoint?}` (endpoint allowlisted and validated; unknown fields ignored). Intraday runs are disable-able as a unit via the `IntradaySchedule` rule. See [Bank reconciliation](#bank-reconciliation-fetchboatransactions).
- **SlackAppHome** — Lambda behind API Gateway (`POST /slack/events`). Verifies the Slack signing secret (HMAC-SHA256, 5-minute replay window) before processing, then renders the payments dashboard on the Slack App Home tab with outstanding aging buckets, drill-down modals, and an always-visible "Returned — Needs Action" queue (bank-returned payments awaiting a reissue/void decision, sorted oldest return first). Returned records are excluded from Outstanding totals; terminal voided-and-bounced records appear in neither (audit trail only).
- **ExpenseReceiver** — Lambda behind API Gateway (`POST /slack/expense-events`). Verifies the Slack signing secret (HMAC-SHA256), handles URL verification challenges, and async-invokes ExpenseProcessor. Runs outside VPC.
- **ExpenseProcessor** — Async Lambda invoked by ExpenseReceiver. Processes `:white_check_mark:` reactions to advance expense messages through a four-stage Slack channel pipeline: Submitted → Processed → Authorized → Matched. Runs outside VPC.
@ -136,6 +136,8 @@ The `PaymentsDashboard` DynamoDB table (`AWS::DynamoDB::Table`, `TableName: Paym
The table is imported by name, so there is no compile-time link between the stacks: any change to the table name, `pk` format, these attribute names, the encryption key, or the table's lifecycle policy will silently break the Bedrock agent at runtime. Coordinate such changes with `seahaven-slack-bot` before shipping (INFRA-138).
**Key prefixes in this table** (all owned by this stack): `payment#<check_number>` (payment records), `metadata` (ingest metadata), `boa_txn#<ts>#<action>` (BoA submission journal, 90d TTL), `boa_recon#<from>_<to>#<runAt>` (reconciliation run summaries, 90d TTL), `boa_balance#<asOfDate>#<endpoint>` (daily balance snapshots, latest-wins, no TTL), `PAYROLL_*` (payroll pipeline, 90d TTL). New prefixes are invisible to `seahaven-slack-bot`'s `begins_with(pk, "payment#")` scan — no consumer coordination needed when adding one.
## Monitoring & Alarms
All CloudWatch alarms publish to the shared `site-alerts` SNS topic (`arn:aws:sns:us-east-1:328440206208:site-alerts`). Alarms are ALARM-only by convention (no OK/recovery action) and treat missing data as `notBreaching`. Each alarm evaluates a single 5-minute period.

420
package-lock.json generated
View file

@ -8,25 +8,25 @@
"name": "payments-dashboard",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.1087.0",
"@aws-sdk/client-lambda": "^3.1087.0",
"@aws-sdk/client-s3": "^3.1087.0",
"@aws-sdk/client-secrets-manager": "^3.1087.0",
"@aws-sdk/client-sqs": "^3.1087.0",
"@aws-sdk/lib-dynamodb": "^3.1087.0",
"@aws-sdk/client-dynamodb": "^3.1092.0",
"@aws-sdk/client-lambda": "^3.1092.0",
"@aws-sdk/client-s3": "^3.1092.0",
"@aws-sdk/client-secrets-manager": "^3.1092.0",
"@aws-sdk/client-sqs": "^3.1092.0",
"@aws-sdk/lib-dynamodb": "^3.1092.0",
"csv-parse": "^7.0.1",
"mailparser": "^3.9.14"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.17.tgz",
"integrity": "sha512-k45iXM7w8Pyknnz/vTXSS/IkLDNFdgnBQI7Hmq+uhsrW7NGT/BiUJGlv20QSKzihmH1XDvSCRILICaKi57/35w==",
"version": "3.1000.19",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.19.tgz",
"integrity": "sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -35,19 +35,19 @@
}
},
"node_modules/@aws-sdk/client-dynamodb": {
"version": "3.1087.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1087.0.tgz",
"integrity": "sha512-3Rsk29aQltTbAI5QKXqhnVNyLAGTeiU3UkErqiqplduV2jdj8l+ySL45yhq7z/GXN7Q1Rh6x38dRsxazwOTUfQ==",
"version": "3.1092.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1092.0.tgz",
"integrity": "sha512-4aSTSu5VaTe6YlyMrSbRE49Cd4v54kP57NmzU0H00dxBniNbeN5ZdjmKoBN8bWmS4DAQfiqFUXZIHup6Boa/xQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/credential-provider-node": "^3.972.68",
"@aws-sdk/dynamodb-codec": "^3.973.32",
"@aws-sdk/middleware-endpoint-discovery": "^3.972.24",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@smithy/fetch-http-handler": "^5.6.5",
"@smithy/node-http-handler": "^4.9.5",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/credential-provider-node": "^3.972.71",
"@aws-sdk/dynamodb-codec": "^3.973.34",
"@aws-sdk/middleware-endpoint-discovery": "^3.972.25",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/node-http-handler": "^4.9.6",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -56,17 +56,17 @@
}
},
"node_modules/@aws-sdk/client-lambda": {
"version": "3.1087.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1087.0.tgz",
"integrity": "sha512-qZVBwoJjdphfnvTpDoeg7vCF16tGmP5B15f7hcGJm7J2mQGRPXA+II16b9jguORgud3y6CHHjsBfcg8o5cHNew==",
"version": "3.1092.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1092.0.tgz",
"integrity": "sha512-n4dEn0DEKX3D22d0r+qFjRiMaNMXdARd6mPTxkFY/5A1AWVZz4nFu3PbTVhoGL+y0uMDjQiWWtjiLWjMTsM12Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/credential-provider-node": "^3.972.68",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@smithy/fetch-http-handler": "^5.6.5",
"@smithy/node-http-handler": "^4.9.5",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/credential-provider-node": "^3.972.71",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/node-http-handler": "^4.9.6",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -75,20 +75,20 @@
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1087.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1087.0.tgz",
"integrity": "sha512-44ua5vo5JiFFSnG3oeeWTMfSjOtblQSoJiGbf37s0W4Lr7IY9NmIVkBHIfD4hBI1VEKdvJhDmvuJawwTfi2pvw==",
"version": "3.1092.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1092.0.tgz",
"integrity": "sha512-NfcptdANQM1IgUT8QITKBN+PZPjshm5FyLKKjotEwscsDQGik4iDdLgwFYJSTlGoREv26Tf97WHpL7IZ3HF9nA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.17",
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/credential-provider-node": "^3.972.68",
"@aws-sdk/middleware-sdk-s3": "^3.972.63",
"@aws-sdk/signature-v4-multi-region": "^3.996.40",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@smithy/fetch-http-handler": "^5.6.5",
"@smithy/node-http-handler": "^4.9.5",
"@aws-sdk/checksums": "^3.1000.19",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/credential-provider-node": "^3.972.71",
"@aws-sdk/middleware-sdk-s3": "^3.972.65",
"@aws-sdk/signature-v4-multi-region": "^3.996.41",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/node-http-handler": "^4.9.6",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -97,17 +97,17 @@
}
},
"node_modules/@aws-sdk/client-secrets-manager": {
"version": "3.1087.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1087.0.tgz",
"integrity": "sha512-muY3Z3S8iFUIum2ir97iwGSW4xsRtzfgI74xmfDibuvkaUb4YnBKcJ+V5OOwoiZCKa9rFrR6gXjFfEN3zvbmPA==",
"version": "3.1092.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1092.0.tgz",
"integrity": "sha512-DiUnsmpt8ypPoakaVJ3VAgd3NnwMUdw3jgSizrD0H/SfjJkhBxClIyy9aA68D81s7QBDvh4fdV9ecJi5Xq0NHQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/credential-provider-node": "^3.972.68",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@smithy/fetch-http-handler": "^5.6.5",
"@smithy/node-http-handler": "^4.9.5",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/credential-provider-node": "^3.972.71",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/node-http-handler": "^4.9.6",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -116,18 +116,18 @@
}
},
"node_modules/@aws-sdk/client-sqs": {
"version": "3.1087.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1087.0.tgz",
"integrity": "sha512-pejcGuwvPrPcKLJiQKsQE7jycoognZrJS5OjxeYC4Qx4ePzx5gRkaagxyE1tRIp4PBdRbz3vOMpLjsruE73wuQ==",
"version": "3.1092.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1092.0.tgz",
"integrity": "sha512-O8uXSCdN3HG3Qj5UDBMJ5vJYl2wIueFoPH6IAikKTTQoVXJbHGLDWt1i+nOJtvV4ijSJzP/a56iREYJRiPFNSw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/credential-provider-node": "^3.972.68",
"@aws-sdk/middleware-sdk-sqs": "^3.972.36",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@smithy/fetch-http-handler": "^5.6.5",
"@smithy/node-http-handler": "^4.9.5",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/credential-provider-node": "^3.972.71",
"@aws-sdk/middleware-sdk-sqs": "^3.972.37",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/node-http-handler": "^4.9.6",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -136,16 +136,16 @@
}
},
"node_modules/@aws-sdk/core": {
"version": "3.975.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.2.tgz",
"integrity": "sha512-iyeXwziyjJpixq5OmhsIyrSWx8vwcI7gDo4yRUC3EP7NQtOo9iAJiIEc3G+/HkhtNXqOhofiCK7Lc34Sq+fJWg==",
"version": "3.976.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz",
"integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.1",
"@aws-sdk/xml-builder": "^3.972.35",
"@aws-sdk/types": "^3.974.2",
"@aws-sdk/xml-builder": "^3.972.36",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.29.3",
"@smithy/signature-v4": "^5.6.3",
"@smithy/core": "^3.29.4",
"@smithy/signature-v4": "^5.6.5",
"@smithy/types": "^4.16.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
@ -155,14 +155,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.58.tgz",
"integrity": "sha512-vyGtvK1rY940eq7JT0yIGKuZ+2kpPSJcHibSvGlit5oiMFDamzC7cxBGLl4FLnd6suihMXDI2FSF2dL6TmBqPA==",
"version": "3.972.60",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz",
"integrity": "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -171,16 +171,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.60",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.60.tgz",
"integrity": "sha512-g9b9YzDrD5pcKiPBJfCSXRfFMrA39eR0guUhZ5SRm+7vMAVc43+effxbcamxBjSd5bUhrdKo5te/yQuWurLXLA==",
"version": "3.972.62",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz",
"integrity": "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@smithy/fetch-http-handler": "^5.6.5",
"@smithy/node-http-handler": "^4.9.5",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/node-http-handler": "^4.9.6",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -189,22 +189,22 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.2.tgz",
"integrity": "sha512-Yr7yxNyQ8aHt9Ww0RPFUZx+xiem+vl7vuwhP0tniTijoesJNV5jou9HCgVpI0GEPAF+89TkOvilE5uRrZJnjaw==",
"version": "3.973.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz",
"integrity": "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/credential-provider-env": "^3.972.58",
"@aws-sdk/credential-provider-http": "^3.972.60",
"@aws-sdk/credential-provider-login": "^3.972.64",
"@aws-sdk/credential-provider-process": "^3.972.58",
"@aws-sdk/credential-provider-sso": "^3.973.2",
"@aws-sdk/credential-provider-web-identity": "^3.972.64",
"@aws-sdk/nested-clients": "^3.997.32",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@smithy/credential-provider-imds": "^4.4.7",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/credential-provider-env": "^3.972.60",
"@aws-sdk/credential-provider-http": "^3.972.62",
"@aws-sdk/credential-provider-login": "^3.972.67",
"@aws-sdk/credential-provider-process": "^3.972.60",
"@aws-sdk/credential-provider-sso": "^3.973.4",
"@aws-sdk/credential-provider-web-identity": "^3.972.66",
"@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -213,15 +213,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.64",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.64.tgz",
"integrity": "sha512-YQoSI4d6kXvoenoG/0Jv/PqaAuukHzGmGXGyHBQYeEUNsYovlNAn/Sw1wp/WQbhcQ3HsEMGgjEahvD3igz6ecQ==",
"version": "3.972.67",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz",
"integrity": "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/nested-clients": "^3.997.32",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -230,20 +230,20 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.68",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.68.tgz",
"integrity": "sha512-4akjzW9CjorByYfqXBXmYUh/h7Io3U4DtVgGGh9TQraZ7ZlyJqNyHwDRGiUFnHD+BTOeTbCesCa4sJaK7BGZ7A==",
"version": "3.972.71",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz",
"integrity": "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.58",
"@aws-sdk/credential-provider-http": "^3.972.60",
"@aws-sdk/credential-provider-ini": "^3.973.2",
"@aws-sdk/credential-provider-process": "^3.972.58",
"@aws-sdk/credential-provider-sso": "^3.973.2",
"@aws-sdk/credential-provider-web-identity": "^3.972.64",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@smithy/credential-provider-imds": "^4.4.7",
"@aws-sdk/credential-provider-env": "^3.972.60",
"@aws-sdk/credential-provider-http": "^3.972.62",
"@aws-sdk/credential-provider-ini": "^3.973.5",
"@aws-sdk/credential-provider-process": "^3.972.60",
"@aws-sdk/credential-provider-sso": "^3.973.4",
"@aws-sdk/credential-provider-web-identity": "^3.972.66",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/credential-provider-imds": "^4.4.9",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -252,14 +252,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.58.tgz",
"integrity": "sha512-1nYitRCaDmXWUrpBJt6WlcGjLx1JVsMY8rlYuHHsTYTSaYikbixYdQSyINN2VYq1F798uTO9qHAzytL25M8g3A==",
"version": "3.972.60",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz",
"integrity": "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -268,16 +268,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.2.tgz",
"integrity": "sha512-pjMLaLU/JZi5lVfmR14V1OZqRBTuMHf6AwGNZA0K9hK+JKtO3jcLBarfD8iq5oc8cSowvc/9R32sqMVXZPo6xQ==",
"version": "3.973.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz",
"integrity": "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/nested-clients": "^3.997.32",
"@aws-sdk/token-providers": "3.1087.0",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/token-providers": "3.1092.0",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -286,15 +286,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.64",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.64.tgz",
"integrity": "sha512-7Buc7p0OvDHW7iBsu4b+YdS0WnaFBDGKDfbVQqaac9dkWiSiUtIoarBDsA1RmOVXZijaZJDoHJFIQiicQvWRlQ==",
"version": "3.972.66",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz",
"integrity": "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/nested-clients": "^3.997.32",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -303,13 +303,13 @@
}
},
"node_modules/@aws-sdk/dynamodb-codec": {
"version": "3.973.32",
"resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.32.tgz",
"integrity": "sha512-mc2IvGFtgNgYnySd1wWdx78flpHdAYvbl9C/tnAzGT0D0przwGeHSB6W3L8grldIXjAG1elWJdyDYiiZaz1dCw==",
"version": "3.973.34",
"resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.34.tgz",
"integrity": "sha512-/7kfOufSN9t/g7OR6gNpCsSj/4nR7WdRnd54qSlnRuzQN7JmOz6ulJSwrUpqkDk/mkFgQqUIFBfnAZ0OTw2pxw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -331,14 +331,14 @@
}
},
"node_modules/@aws-sdk/lib-dynamodb": {
"version": "3.1087.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.1087.0.tgz",
"integrity": "sha512-Xtx+KuCPvpluVj0WMpSRN4skxrjzlKvo6T+LwAZ6VOzbojlINSXWKuemsev5GVgbGqIZSsv3+/beI8Q3jGoymQ==",
"version": "3.1092.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.1092.0.tgz",
"integrity": "sha512-wEfCtDfPhCeQ4zlgy+eyVeEMwcW8+Mty0Z8HtoUK+KtOzIxcND63WeWmIaXLY63HyJ3f53didmkrIT/KHjYDwQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/util-dynamodb": "^3.996.6",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/util-dynamodb": "^3.996.7",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -346,18 +346,18 @@
"node": ">=20.0.0"
},
"peerDependencies": {
"@aws-sdk/client-dynamodb": "^3.1087.0"
"@aws-sdk/client-dynamodb": "^3.1092.0"
}
},
"node_modules/@aws-sdk/middleware-endpoint-discovery": {
"version": "3.972.24",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.24.tgz",
"integrity": "sha512-1NwwiqMZw5LMnFZ8LsPYkuVN+nFf5oWRxgeb4WfsyOw0MKcA8qGI8Yq4AjvEzWHzp1a1aq8TEMqqlh2IiKVKgQ==",
"version": "3.972.25",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.25.tgz",
"integrity": "sha512-5G2aVPmbWC5dFI76D0vsNg2L0fgWP4uybcetf+06dzeZuRtpihnow88fOl5zm3+ABdIw27FKgyFzV4yB5Bm29Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/endpoint-cache": "^3.972.9",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -366,15 +366,15 @@
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.63",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.63.tgz",
"integrity": "sha512-EklitJscj/Aq2Pk8nz0xSxvL+VMSP29iFqouQU0Ow05JKn5y/OhI81ujkfVS72MjD1yeoU9uIdT8c/W6vhktQw==",
"version": "3.972.65",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.65.tgz",
"integrity": "sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/signature-v4-multi-region": "^3.996.40",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/signature-v4-multi-region": "^3.996.41",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -383,13 +383,13 @@
}
},
"node_modules/@aws-sdk/middleware-sdk-sqs": {
"version": "3.972.36",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.36.tgz",
"integrity": "sha512-A3y33ETRHygvS4yZAM92NowTKZVAfsCNOeJuuOPgJO4vUS/rHDu7u+CDzrSq+W+T1bF7f4LMO9PxnEbo4ZaQuA==",
"version": "3.972.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.37.tgz",
"integrity": "sha512-ObKPWZWpog+4zZQ2q+LdBwf/nm+HF2afgBxCtyHeqjRlsnATuKOV3dPYnzCGyjRZ/RHTEre4LRrYRcIxlWhzVQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -398,17 +398,17 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.32",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.32.tgz",
"integrity": "sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ==",
"version": "3.997.34",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz",
"integrity": "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/signature-v4-multi-region": "^3.996.40",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@smithy/fetch-http-handler": "^5.6.5",
"@smithy/node-http-handler": "^4.9.5",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/signature-v4-multi-region": "^3.996.41",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/fetch-http-handler": "^5.6.6",
"@smithy/node-http-handler": "^4.9.6",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -417,13 +417,13 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.40",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.40.tgz",
"integrity": "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g==",
"version": "3.996.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz",
"integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.1",
"@smithy/signature-v4": "^5.6.3",
"@aws-sdk/types": "^3.974.2",
"@smithy/signature-v4": "^5.6.5",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -432,15 +432,15 @@
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1087.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1087.0.tgz",
"integrity": "sha512-umM+qNq16f2fH+VLM5MqXW4ORNQAjk+TOSto73xbUHcKaU41L48j786r3UWQYlejeJk37NlvRYgxBT+MBkfaYQ==",
"version": "3.1092.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz",
"integrity": "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.975.2",
"@aws-sdk/nested-clients": "^3.997.32",
"@aws-sdk/types": "^3.974.1",
"@smithy/core": "^3.29.3",
"@aws-sdk/core": "^3.976.0",
"@aws-sdk/nested-clients": "^3.997.34",
"@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.29.4",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -449,9 +449,9 @@
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.1.tgz",
"integrity": "sha512-W0IQZR0eaBqlBFIIofMapaWkw1W0U+Xi4dvW+BqwmCEMd8Ng2U6IhkxuPSjMVnR8klLjfuS9PeZWUl1N6UaZdg==",
"version": "3.974.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz",
"integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
@ -462,9 +462,9 @@
}
},
"node_modules/@aws-sdk/util-dynamodb": {
"version": "3.996.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.6.tgz",
"integrity": "sha512-xLimZjA/ebA7jZn4NNkvLgj9WRl14rhidGND6oPt14s7ywe3I64g9X+WJpBraU5UR7loRq2K1tAXZj0FXkWLZg==",
"version": "3.996.7",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.7.tgz",
"integrity": "sha512-v+WJASG9yaW8qNM7pNSgH1PBYz5mVTf7gzKPi0NqGjLlaCtPdk6EjM1lmv03egA07iXUw6OToGYfW2w8D3kBrg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@ -473,13 +473,13 @@
"node": ">=20.0.0"
},
"peerDependencies": {
"@aws-sdk/client-dynamodb": "^3.1082.0"
"@aws-sdk/client-dynamodb": "^3.1088.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.35.tgz",
"integrity": "sha512-pXzaWe3evZhjxDXAlMnqISe/XefTCGwBJG4nFTXaWSgAnMkqPEhxEPqJNhhpGesEvKFhvNpnozJJ4GTL11bRYw==",
"version": "3.972.36",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz",
"integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
@ -515,9 +515,9 @@
}
},
"node_modules/@smithy/core": {
"version": "3.29.4",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.4.tgz",
"integrity": "sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg==",
"version": "3.29.7",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.7.tgz",
"integrity": "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
@ -528,12 +528,12 @@
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.4.9",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.9.tgz",
"integrity": "sha512-2nfV4qRKiYeXU4zD2vvSCfg5dfp/BuhrM73vt7q9gzBhxs4rbPxXY21wo+kyI3bRmXcEGRnCLTaW8O437jzHIg==",
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.12.tgz",
"integrity": "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.4",
"@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
@ -570,13 +570,13 @@
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.3.tgz",
"integrity": "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==",
"version": "5.6.8",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.8.tgz",
"integrity": "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@smithy/core": "^3.29.7",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {

View file

@ -10,12 +10,12 @@
"test": "node --test \"tests/**/*.test.js\""
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.1087.0",
"@aws-sdk/client-lambda": "^3.1087.0",
"@aws-sdk/client-s3": "^3.1087.0",
"@aws-sdk/client-secrets-manager": "^3.1087.0",
"@aws-sdk/client-sqs": "^3.1087.0",
"@aws-sdk/lib-dynamodb": "^3.1087.0",
"@aws-sdk/client-dynamodb": "^3.1092.0",
"@aws-sdk/client-lambda": "^3.1092.0",
"@aws-sdk/client-s3": "^3.1092.0",
"@aws-sdk/client-secrets-manager": "^3.1092.0",
"@aws-sdk/client-sqs": "^3.1092.0",
"@aws-sdk/lib-dynamodb": "^3.1092.0",
"csv-parse": "^7.0.1",
"mailparser": "^3.9.14"
}

View file

@ -433,7 +433,7 @@ const RETURN_EVENTS = new Set(["check_return", "electronic_return", "ach_return"
// paid debit against a canceled record (a voided check the bank paid —
// expected without Positive Pay), the cancel status is likewise preserved:
// the ARP return that follows lands as terminal voided-and-bounced.
export function applyEvent(payment, classified, eventDateISO) {
export function applyEvent(payment, classified, eventDateISO, via = null) {
const { event, amount, bankReference } = classified;
const canceled = isCancelStatus(payment.status);
const historyEvent = {
@ -441,6 +441,9 @@ export function applyEvent(payment, classified, eventDateISO) {
date: eventDateISO,
bankRef: bankReference,
amount,
// Audit tag only (which endpoint applied this): deliberately excluded
// from the replay-idempotence identity below.
...(via ? { via } : {}),
};
// Replay idempotence: event identity is {event, date, amount} — bankRef
@ -520,9 +523,27 @@ export function isValidISODate(s) {
// days are safe — event identity makes replays idempotent. Strings are
// validated strictly so a malformed payload fails loudly instead of
// querying a garbage range.
export function resolveDateRange(event, now = new Date()) {
// Endpoint allowlist: the value is interpolated into the request URL, so
// anything outside the two known inquiry endpoints throws loudly (path
// injection guard; also catches typo'd EventBridge Input).
const ENDPOINTS = new Set(["previous-day", "current-day"]);
export function resolveEndpoint(event) {
const endpoint = event?.endpoint ?? "previous-day";
if (!ENDPOINTS.has(endpoint)) {
throw new Error(`endpoint must be one of ${[...ENDPOINTS].join(", ")}: ${logSafe(endpoint)}`);
}
return endpoint;
}
export function resolveDateRange(event, now = new Date(), endpoint = "previous-day") {
const hasFrom = event?.fromDate != null;
const hasTo = event?.toDate != null;
if (!hasFrom && !hasTo && endpoint === "current-day") {
// Intraday default: today only. Explicit ranges are allowed (the API
// accepts them — verified 2026-07-22) but the schedule never sends one.
const today = now.toISOString().split("T")[0];
return { fromDate: today, toDate: today };
}
if (!hasFrom && !hasTo) {
// Trailing 7 days ending yesterday: self-healing across missed runs,
// outages, and holiday gaps — replays are idempotent (event identity
@ -614,3 +635,61 @@ export function buildEventUpdate(tableName, payment, applied) {
ExpressionAttributeValues: values,
};
}
// Balance/summary extraction (#improvement-plan PR 2). Summary rows carry
// the account's balance and total lines per asOfDate; one output object per
// date so multi-day replay windows produce one snapshot each. Unrecognized
// summary codes land in `other` — never dropped.
const BALANCE_FIELDS = {
"010": "opening_ledger",
"030": "current_ledger",
"040": "opening_available",
"060": "current_available",
"072": "float_one_day",
"074": "float_two_day",
"100": "total_credits",
"400": "total_debits",
"450": "total_ach_debits",
};
const BALANCE_COUNT_FIELDS = new Set(["100", "400", "450"]);
// Caps: a well-formed response has ~46 summary codes per date and windows
// are at most a month; anything beyond is a malformed/hostile feed and must
// not balloon DDB items or write counts (counted, never silent).
const BALANCE_OTHER_CAP = 50;
const BALANCE_DATES_CAP = 31;
export function extractBalances(transactions) {
const byDate = new Map();
let datesTruncated = 0;
for (const txn of transactions ?? []) {
if (String(txn?.transactionType ?? "").trim().toLowerCase() !== "summary") continue;
const date = String(txn.asOfDate ?? "").trim();
if (!isValidISODate(date)) continue;
if (!byDate.has(date)) {
if (byDate.size >= BALANCE_DATES_CAP) {
datesTruncated++;
continue;
}
byDate.set(date, { as_of_date: date, other: {} });
}
const snap = byDate.get(date);
const code = String(txn.transactionCode ?? "").trim();
const amount = parseAmount(txn.amount);
if (!Number.isFinite(amount)) continue; // "Infinity" survives parseFloat; unmarshallable
const field = BALANCE_FIELDS[Object.hasOwn(BALANCE_FIELDS, code) ? code : ""];
if (field) {
snap[field] = amount;
if (BALANCE_COUNT_FIELDS.has(code) && txn.itemCount != null) {
const n = Number(txn.itemCount);
snap[`${field}_count`] = Number.isFinite(n) ? n : 0;
}
} else if (/^[0-9]{1,16}$/.test(code)) {
if (Object.keys(snap.other).length < BALANCE_OTHER_CAP) snap.other[code] = amount;
else snap.other_truncated = (snap.other_truncated || 0) + 1;
}
}
const out = [...byDate.values()];
if (datesTruncated) out.forEach((s) => (s.dates_truncated = datesTruncated));
return out;
}

View file

@ -41,6 +41,23 @@ export function parseMDYLocal(value) {
return new Date(p.year, p.month - 1, p.day);
}
// Local-midnight Date from a stored ISO "YYYY-MM-DD" (returned_date,
// paid_date), or null. new Date("YYYY-MM-DD") parses as UTC midnight and
// renders a day early in US-local display; this stays local like
// parseMDYLocal. Validates the date is real (no 2026-02-30).
export function parseISOLocal(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? "").trim());
if (!match) return null;
const year = parseInt(match[1], 10);
const month = parseInt(match[2], 10);
const day = parseInt(match[3], 10);
const dt = new Date(year, month - 1, day);
if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) {
return null;
}
return dt;
}
// Plausibility window for ingested send dates. Anything outside is treated
// as parser garbage and the row is rejected loudly rather than stored.
// Relative to the current year so it never expires (7 years back covers

View file

@ -1,3 +1,4 @@
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 { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
@ -5,20 +6,24 @@ import {
applyEvent,
buildEventUpdate,
classifyTransaction,
extractBalances,
isValidISODate,
logSafe,
matchAchTransaction,
matchCheckTransaction,
matchElectronicReturn,
resolveDateRange,
resolveEndpoint,
sweepStalePayments,
} from "./boaRecon.js";
import { toISODate } from "./dates.js";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const secrets = new SecretsManagerClient();
const s3 = new S3Client();
const TABLE_NAME = process.env.TABLE_NAME;
const BOA_BASE_URL = process.env.BOA_BASE_URL;
const BOA_RAW_BUCKET = process.env.BOA_RAW_BUCKET;
const UNMATCHED_LIST_CAP = 50;
const UNKNOWN_CODES_CAP = 20;
@ -57,10 +62,13 @@ async function getReportingCreds() {
}
// Parse a BoA response body without ever surfacing a body snippet — a
// malformed 200 must not leak account data into logs/errors.
// malformed 200 must not leak account data into logs/errors. Returns the
// exact raw body text alongside the parse so the archive stores the bytes
// the bank actually sent.
async function parseJsonResponse(res, label) {
const rawBody = await res.text();
try {
return await res.json();
return { data: JSON.parse(rawBody), rawBody };
} catch {
throw new Error(`BoA ${label} response not JSON: HTTP ${res.status}`);
}
@ -80,23 +88,25 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
throw new Error(`OAuth token exchange failed: HTTP ${res.status}`);
}
const data = await parseJsonResponse(res, "authn");
const { data } = await parseJsonResponse(res, "authn");
return data.access_token;
}
const directionRank = (d) => (d === "debit" ? 0 : d === "credit" ? 1 : 2);
export const handler = async (event) => {
// Optional replay payload {fromDate, toDate}; default is the trailing
// 7-day window ending yesterday (self-healing across missed runs).
const { fromDate, toDate } = resolveDateRange(event ?? {});
// Optional payload {fromDate, toDate, endpoint}; endpoint defaults to
// previous-day (the authoritative sweep). The intraday schedule passes
// {"endpoint":"current-day"} and defaults to today..today; previous-day
// defaults to the trailing 7-day window ending yesterday (self-healing).
const endpoint = resolveEndpoint(event ?? {});
const { fromDate, toDate } = resolveDateRange(event ?? {}, new Date(), endpoint);
const { appId, clientId, token: clientSecret, accountNumber, bankId } = await getReportingCreds();
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
// Call CashPro Previous Day Transaction Inquiry
const res = await fetch(`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`, {
const res = await fetch(`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -113,7 +123,35 @@ export const handler = async (event) => {
throw new Error(`BoA API error: HTTP ${res.status}`);
}
const data = await parseJsonResponse(res, "reporting");
// Archive the exact response bytes BEFORE parsing or classification: even
// a malformed-200 (which throws below) or a crash mid-reconciliation
// leaves the raw feed recoverable/replayable. runAt is minted at response
// receipt so the balance latest-wins comparison orders by data freshness,
// not invocation start (overlapping runs). Archive failure never fails
// the reconciliation — flagged on the run summary.
const rawBody = await res.text();
const runAt = new Date().toISOString();
let archiveError = false;
try {
await s3.send(
new PutObjectCommand({
Bucket: BOA_RAW_BUCKET,
Key: `raw/${endpoint}/${fromDate}_${toDate}/${runAt}.json`,
Body: rawBody,
ContentType: "application/json",
})
);
} catch (err) {
archiveError = true;
console.error(`Raw archive put failed: ${logSafe(err?.name)} ${logSafe(err?.message)}`);
}
let data;
try {
data = JSON.parse(rawBody);
} catch {
throw new Error(`BoA reporting response not JSON: HTTP ${res.status}`);
}
// Response shape: { accountTransactions: [{ accountNumber, bankId, currency, transactions: [...] }] }
const allTransactions = (data.accountTransactions || []).flatMap(
@ -242,7 +280,7 @@ export const handler = async (event) => {
summary.matched++;
const target = match.payment;
let applied = applyEvent(target, classified, eventDate);
let applied = applyEvent(target, classified, eventDate, endpoint);
if (applied.kind === "noop") {
summary.already_applied++;
continue;
@ -267,7 +305,7 @@ export const handler = async (event) => {
// payments array) and re-derive the transition.
for (const k of Object.keys(target)) delete target[k];
Object.assign(target, fresh);
applied = applyEvent(target, classified, eventDate);
applied = applyEvent(target, classified, eventDate, endpoint);
if (applied.kind === "noop") {
outcome = "noop";
break;
@ -302,25 +340,59 @@ export const handler = async (event) => {
);
}
// Staleness sweep: payments the bank has never confirmed (#69).
const todayISO = new Date().toISOString().split("T")[0];
const stale = sweepStalePayments(payments, todayISO);
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(","))}`
);
// Staleness sweep: payments the bank has never confirmed (#69). Gated to
// the authoritative previous-day runs — intraday runs would re-alert the
// same backlog several times a day with no new information.
let stale = null;
if (endpoint === "previous-day") {
const todayISO = new Date().toISOString().split("T")[0];
stale = sweepStalePayments(payments, todayISO);
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(","))}`
);
}
if (stale.staleChecksCount) {
console.error(
`Stale checks (no bank activity, issued > 60 days ago): ${stale.staleChecksCount} records`
);
}
}
if (stale.staleChecksCount) {
console.error(
`Stale checks (no bank activity, issued > 60 days ago): ${stale.staleChecksCount} records`
);
// Balance snapshots: one boa_balance#<asOfDate>#<endpoint> item per date,
// latest-wins (run_at conditioned so a stale replay never clobbers fresher
// data). Intraday snapshots are provisional and keyed separately from the
// authoritative previous-day figures. Write failures are informational.
const balances = extractBalances(allTransactions);
let balanceWriteErrors = 0;
const balanceDatesWritten = [];
for (const snap of balances) {
try {
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: `boa_balance#${snap.as_of_date}#${endpoint}`,
endpoint,
run_at: runAt,
...snap,
},
ConditionExpression: "attribute_not_exists(run_at) OR run_at <= :runAt",
ExpressionAttributeValues: { ":runAt": runAt },
})
);
balanceDatesWritten.push(snap.as_of_date);
} catch (err) {
if (err?.name === "ConditionalCheckFailedException") continue; // stale replay, fresher data kept
balanceWriteErrors++;
console.error(`Balance write failed for ${logSafe(snap.as_of_date)}: ${logSafe(err?.name)}`);
}
}
// Run summary record for auditing/alerting (Slack wiring is #71). The pk
// is append-only (run timestamp suffix) so re-runs never overwrite a
// prior run's record.
const runAt = new Date().toISOString();
const runKey = `boa_recon#${fromDate}_${toDate}#${runAt}`;
await ddb.send(
new PutCommand({
@ -330,6 +402,10 @@ export const handler = async (event) => {
run_at: runAt,
from_date: fromDate,
to_date: toDate,
endpoint,
archive_error: archiveError,
balance_write_errors: balanceWriteErrors,
balance_dates_written: balanceDatesWritten,
transactions_seen: summary.transactions_seen,
classified: summary.classified,
matched: summary.matched,
@ -342,9 +418,13 @@ export const handler = async (event) => {
unmatched: summary.unmatched,
unknown_count: summary.unknown_count,
unknown_codes: summary.unknown_codes,
stale_ach: stale.staleAch,
stale_ach_count: stale.staleAchCount,
stale_checks_count: stale.staleChecksCount,
...(stale
? {
stale_ach: stale.staleAch,
stale_ach_count: stale.staleAchCount,
stale_checks_count: stale.staleChecksCount,
}
: {}),
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
},
})

View file

@ -2,7 +2,8 @@ import crypto from "node:crypto";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import { parseMDYLocal } from "./dates.js";
import { parseISOLocal, parseMDYLocal } from "./dates.js";
import { isCancelStatus } from "./boaRecon.js";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const secrets = new SecretsManagerClient();
@ -91,7 +92,7 @@ const byDate = (a, b) => {
// --- Categorize payments into buckets ---
function categorizePayments(payments) {
export function categorizePayments(payments) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24));
@ -99,9 +100,25 @@ function categorizePayments(payments) {
const scheduledChecks = [];
const scheduledACH = [];
const outstandingChecks = [];
const returnedPayments = [];
for (const p of payments) {
if (p.method !== "ACH" && p.method !== "Check") continue;
// Bank truth outranks the CSV lifecycle: a returned check usually
// carries status "Cleared" (the ladder has no Returned rung, #66), so
// route on clear_status before the skip-list can hide it (#70).
// Membership keys off clear_status, not returned_date — a redeposit
// re-clears the record but keeps returned_date. Routed before the send-
// date parse: a bank-confirmed return must surface even on a record
// whose send_payment_on no longer parses.
if (p.clear_status === "Returned") {
// Terminal voided-and-bounced (the expected void-then-ARP-bounce
// cycle) is not a reissue decision: audit trail only.
if (!isCancelStatus(p.status)) returnedPayments.push(p);
continue;
}
const dt = parseMDYLocal(p.send_payment_on);
if (!dt) continue;
const status = (p.status || "").toLowerCase();
@ -118,6 +135,12 @@ function categorizePayments(payments) {
scheduledChecks.sort(byDate);
scheduledACH.sort(byDate);
// 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 || ""))
);
const bucketedOutstanding = ageBuckets.map((bucket) => {
const items = outstandingChecks.filter((p) => {
const age = daysSince(parseMDYLocal(p.send_payment_on));
@ -128,7 +151,7 @@ function categorizePayments(payments) {
return { ...bucket, items, total };
});
return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding };
return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments };
}
// --- Main handler ---
@ -445,10 +468,59 @@ function buildBoABlocks(transactions) {
return blocks;
}
function buildHomeView(payments, metadata, boaTransactions = [], expanded = []) {
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding } = categorizePayments(payments);
export function buildHomeView(payments, metadata, boaTransactions = [], 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),
// so the action queue never collapses. Returns are rare and get resolved,
// so the list stays short.
const buildReturnedBlocks = () => {
if (!returnedPayments.length) return [];
const total = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0);
const blocks = [
{
type: "header",
text: { type: "plain_text", text: ":rotating_light: Returned — Needs Action" },
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `${returnedPayments.length} payment${returnedPayments.length !== 1 ? "s" : ""} · ${formatCurrency(total)} · bank-returned, each needs a reissue or void decision`,
},
],
},
];
for (const p of returnedPayments) {
const rd = parseISOLocal(p.returned_date);
const age = rd ? daysSince(rd) : null;
const returnedLine = rd
? `Returned ${formatDisplayDate(rd)} · *${age} day${age !== 1 ? "s" : ""} ago*`
: "_Return date unknown_";
blocks.push({
type: "section",
fields: [
{
type: "mrkdwn",
text: `*${p.payee || "—"}*\n${p.method === "ACH" ? "Reference" : "Check"} #${p.check_number || "—"}`,
},
{
type: "mrkdwn",
text: `*${formatCurrency(p.amount_usd)}*\n${returnedLine}`,
},
],
});
}
blocks.push({ type: "divider" });
return blocks;
};
const buildScheduledBlocks = (title, emoji, items, sectionKey, methodKey) => {
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
const open = isExpanded(sectionKey);
@ -575,6 +647,7 @@ function buildHomeView(payments, metadata, boaTransactions = [], expanded = [])
const totalScheduled = scheduledChecks.length + scheduledACH.length;
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);
const staleness = formatStaleness(metadata?.last_updated);
@ -616,9 +689,18 @@ function buildHomeView(payments, metadata, boaTransactions = [], expanded = [])
type: "mrkdwn",
text: `:warning: *Outstanding*\n${outstandingChecks.length} checks · ${formatCurrency(totalOutstandingAmt)}`,
},
...(returnedPayments.length
? [
{
type: "mrkdwn",
text: `:rotating_light: *Returned*\n${returnedPayments.length} payment${returnedPayments.length !== 1 ? "s" : ""} · ${formatCurrency(totalReturnedAmt)}`,
},
]
: []),
],
},
{ type: "divider" },
...buildReturnedBlocks(),
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "scheduled_checks", "checks"),
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "scheduled_ach", "ach"),
...buildOutstandingBlocks(),

View file

@ -168,6 +168,58 @@ Resources:
BlockPublicPolicy: true
RestrictPublicBuckets: true
# Raw archive of every BoA reporting API response (exact bytes, keyed
# raw/<endpoint>/<fromDate>_<toDate>/<runAt>.json). Replayable corpus for
# parser changes + audit trail. Retain: a template revert must never
# attempt to delete a bank-data bucket; decommission goes through the CFN
# decommission runbook (inventory, purge, deliberate deletion).
# Retain + fixed name = rollback-orphan hazard (same class as the RETAIN
# secret deadlock): if a failed deploy orphans the bucket, ADOPT it back
# with a CloudFormation resource import — never delete-and-recreate.
BoaRawBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketName: !Sub seahaven-payments-boa-raw-${AWS::AccountId}
PublicAccessBlockConfiguration:
BlockPublicAcls: true
IgnorePublicAcls: true
BlockPublicPolicy: true
RestrictPublicBuckets: true
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
LifecycleConfiguration:
Rules:
- Id: expire-raw-responses
Status: Enabled
ExpirationInDays: 730
# Defense-in-depth for bank data: deny any non-TLS access. No Allow
# statements — access is IAM-only (the Lambda's PutObject grant); unlike
# PayrollEmailBucket there is no cross-service principal here. The Deny
# covers bucket-level actions too, which is safe because nothing is
# granted List/Get — revisit if read access is ever added.
BoaRawBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref BoaRawBucket
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: DenyInsecureTransport
Effect: Deny
Principal: "*"
Action: s3:*
Resource:
- !GetAtt BoaRawBucket.Arn
- !Sub "${BoaRawBucket.Arn}/*"
Condition:
Bool:
aws:SecureTransport: "false"
DashboardTable:
Type: AWS::DynamoDB::Table
Properties:
@ -1015,6 +1067,7 @@ Resources:
Variables:
BOA_BASE_URL: https://api.bofa.com
BOA_REPORTING_SECRET_NAME: payments-dashboard/boa-reporting
BOA_RAW_BUCKET: !Ref BoaRawBucket
Events:
DailySchedule:
Type: Schedule
@ -1022,9 +1075,28 @@ Resources:
Schedule: cron(0 13 ? * MON-FRI *)
Description: Fetch BoA previous day transactions at 9am ET (13:00 UTC)
Enabled: true
# One rule for all intraday runs so they can be disabled as a unit
# (aws events disable-rule) without touching the authoritative 9am
# previous-day sweep. Fixed UTC: ~12/3/6pm ET in DST, 11/2/5pm in
# winter (accepted drift, documented in README).
IntradaySchedule:
Type: Schedule
Properties:
Schedule: cron(0 16,19,22 ? * MON-FRI *)
Description: Intraday BoA current-day sweep (~12pm/3pm/6pm ET)
Enabled: true
Input: '{"endpoint":"current-day"}'
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref DashboardTable
- Version: "2012-10-17"
Statement:
# Archive writes only: no read, no list, no other principal.
# Derived from the bucket resource so a rename can't silently
# detach the grant.
- Effect: Allow
Action: s3:PutObject
Resource: !Sub "${BoaRawBucket.Arn}/*"
- Version: "2012-10-17"
Statement:
- Effect: Allow

View file

@ -0,0 +1,142 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
classifyTransaction,
extractBalances,
resolveDateRange,
resolveEndpoint,
} from "../src/boaRecon.js";
const fixture = (name) =>
JSON.parse(readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url)))
.accountTransactions[0].transactions;
const histogram = (txns) => {
const h = {};
for (const t of txns) {
const e = classifyTransaction(t).event;
h[e] = (h[e] || 0) + 1;
}
return h;
};
// --- resolveEndpoint ---
test("resolveEndpoint: defaults to previous-day; accepts current-day", () => {
assert.equal(resolveEndpoint({}), "previous-day");
assert.equal(resolveEndpoint(undefined), "previous-day");
assert.equal(resolveEndpoint({ endpoint: "current-day" }), "current-day");
});
test("resolveEndpoint: rejects anything else (URL injection guard)", () => {
for (const bad of ["previous-day/../admin", "current", "", "PREVIOUS-DAY", 42]) {
assert.throws(() => resolveEndpoint({ endpoint: bad }), /endpoint must be one of/);
}
});
// --- endpoint-aware resolveDateRange ---
test("resolveDateRange: current-day default pins today..today", () => {
const r = resolveDateRange({}, new Date("2026-07-22T16:00:00Z"), "current-day");
assert.deepEqual(r, { fromDate: "2026-07-22", toDate: "2026-07-22" });
});
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");
assert.deepEqual(r, { fromDate: "2026-07-21", toDate: "2026-07-22" });
});
test("resolveDateRange: previous-day default unchanged by endpoint param", () => {
const r = resolveDateRange({}, new Date("2026-07-21T13:00:00Z"), "previous-day");
assert.deepEqual(r, { fromDate: "2026-07-14", toDate: "2026-07-20" });
});
// --- extractBalances ---
test("extractBalances: current-day fixture yields one snapshot with named fields", () => {
const snaps = extractBalances(fixture("current-day"));
assert.equal(snaps.length, 1);
const s = snaps[0];
assert.equal(s.as_of_date, "2026-07-22");
assert.equal(s.opening_ledger, 209541.48);
assert.equal(s.current_ledger, 203890.83);
assert.equal(s.opening_available, 209541.48);
assert.equal(s.current_available, 203890.83);
assert.equal(s.total_credits, 15000);
assert.equal(s.total_credits_count, 1);
assert.equal(s.total_debits, 20650.65);
assert.equal(s.total_debits_count, 5);
assert.equal(s.total_ach_debits, 20650.65);
// Unrecognized summary codes captured, never dropped.
assert.ok(Object.keys(s.other).length > 0);
assert.equal(s.other["200"], 15000);
});
test("extractBalances: multi-day window groups one snapshot per asOfDate", () => {
const snaps = extractBalances(fixture("previous-day-week"));
const dates = snaps.map((s) => s.as_of_date).sort();
assert.deepEqual(dates, ["2026-07-15", "2026-07-16", "2026-07-17", "2026-07-20", "2026-07-21"]);
for (const s of snaps) assert.ok(typeof s.opening_ledger === "number");
});
test("extractBalances: Detail rows and garbage dates are excluded", () => {
const snaps = extractBalances([
{ transactionType: "Detail", transactionCode: "010", asOfDate: "2026-07-22", amount: "5" },
{ transactionType: "Summary", transactionCode: "010", asOfDate: "garbage", amount: "5" },
]);
assert.deepEqual(snaps, []);
});
// --- fixture-driven classification histograms (real captured responses) ---
test("fixture: previous-day returns window classifies exactly as the live replay did", () => {
const h = histogram(fixture("previous-day-returns"));
assert.deepEqual(h, {
summary: 100,
ignored: 3,
check_paid: 32,
check_return: 10,
electronic_return: 4,
ach_debit: 4,
});
});
test("fixture: quiet previous-day week has zero unknowns and full summary coverage", () => {
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);
});
test("fixture: current-day classifies the five live ACH settlements", () => {
const h = histogram(fixture("current-day"));
assert.equal(h.ach_debit, 5);
assert.equal(h.unknown ?? 0, 0);
assert.equal(h.summary, 46);
});
test("fixture: current-day range covers both days without unknowns", () => {
const h = histogram(fixture("current-day-range"));
assert.equal(h.unknown ?? 0, 0);
assert.equal(h.ach_debit, 5);
});
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" });
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" });
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.ok(snaps.length <= 31);
assert.ok(snaps[0].dates_truncated > 0);
});

538
tests/fixtures/current-day-range.json vendored Normal file
View file

@ -0,0 +1,538 @@
{
"accountTransactions": [
{
"accountNumber": "000000000000",
"bankId": "000000000",
"currency": "USD",
"transactions": [
{
"asOfDate": "2026-07-21",
"transactionCode": "010",
"transactionDescription": "Opening Ledger Balance",
"transactionType": "Summary",
"amount": "194541.48"
},
{
"asOfDate": "2026-07-21",
"transactionCode": "030",
"transactionDescription": "Current Ledger Balance",
"transactionType": "Summary",
"amount": "209541.48"
},
{
"asOfDate": "2026-07-21",
"transactionCode": "040",
"transactionDescription": "Opening Avail Balance",
"transactionType": "Summary",
"amount": "194541.48"
},
{
"asOfDate": "2026-07-21",
"transactionCode": "060",
"transactionDescription": "Current Available Balance",
"transactionType": "Summary",
"amount": "209541.48"
},
{
"asOfDate": "2026-07-21",
"transactionCode": "072",
"transactionDescription": "1 Day Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-21",
"transactionCode": "074",
"transactionDescription": "2 or More Days Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-21",
"transactionCode": "906",
"transactionDescription": "Today Opening 1 Day Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-21",
"transactionCode": "907",
"transactionDescription": "Today Opening 2+ Day Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-21",
"transactionCode": "100",
"transactionDescription": "Total Credits",
"transactionType": "Summary",
"amount": "15000.00",
"availabilityImmediateAmount": "15000.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"itemCount": 1
},
{
"asOfDate": "2026-07-21",
"transactionCode": "200",
"transactionDescription": "Total Automatic Transfer CR",
"transactionType": "Summary",
"amount": "15000.00",
"itemCount": 1
},
{
"asOfDate": "2026-07-21",
"transactionCode": "201",
"transactionDescription": "Individual Auto Transfer CR",
"transactionType": "Detail",
"amount": "15000.00",
"creditDebitIndicator": "Credit",
"availabilityImmediateAmount": "15000.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"customerReference": "07520",
"bankReference": "200009387568",
"detailText": "ACCOUNT TRANSFER TRSF FROM 483096772516"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "010",
"transactionDescription": "Opening Ledger Balance",
"transactionType": "Summary",
"amount": "209541.48"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "030",
"transactionDescription": "Current Ledger Balance",
"transactionType": "Summary",
"amount": "203890.83"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "040",
"transactionDescription": "Opening Avail Balance",
"transactionType": "Summary",
"amount": "209541.48"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "060",
"transactionDescription": "Current Available Balance",
"transactionType": "Summary",
"amount": "203890.83"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "072",
"transactionDescription": "1 Day Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "074",
"transactionDescription": "2 or More Days Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "906",
"transactionDescription": "Today Opening 1 Day Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "907",
"transactionDescription": "Today Opening 2+ Day Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "100",
"transactionDescription": "Total Credits",
"transactionType": "Summary",
"amount": "15000.00",
"availabilityImmediateAmount": "15000.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"itemCount": 1
},
{
"asOfDate": "2026-07-22",
"transactionCode": "110",
"transactionDescription": "Total Lockbox Deposit CR",
"transactionType": "Summary",
"amount": "0.00",
"availabilityImmediateAmount": "0.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "120",
"transactionDescription": "Total EDI Transaction Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "140",
"transactionDescription": "Total ACH Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "150",
"transactionDescription": "Total Preauthorized Payment CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "159",
"transactionDescription": "Total Real Time Payment Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "170",
"transactionDescription": "Total Other Check Deposits CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "180",
"transactionDescription": "Total Loan Proceed Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "186",
"transactionDescription": "Total Cash Letter Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "190",
"transactionDescription": "Total Incoming Money Trnsfr CR",
"transactionType": "Summary",
"amount": "0.00",
"availabilityImmediateAmount": "0.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "200",
"transactionDescription": "Total Automatic Transfer CR",
"transactionType": "Summary",
"amount": "15000.00",
"itemCount": 1
},
{
"asOfDate": "2026-07-22",
"transactionCode": "210",
"transactionDescription": "Total FX/International Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "239",
"transactionDescription": "Total Bankers Acceptance CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "250",
"transactionDescription": "Total Deposited Items Ret CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "260",
"transactionDescription": "Total Rejected Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "294",
"transactionDescription": "Total ATM Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "310",
"transactionDescription": "Total Commercial Deposit CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "350",
"transactionDescription": "Total Sweep Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "390",
"transactionDescription": "Total Miscellaneous Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "400",
"transactionDescription": "Total Debits",
"transactionType": "Summary",
"amount": "20650.65",
"itemCount": 5
},
{
"asOfDate": "2026-07-22",
"transactionCode": "412",
"transactionDescription": "Total Debits Less Return Items",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "416",
"transactionDescription": "Total Lockbox Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "420",
"transactionDescription": "Total EDI Transaction Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "450",
"transactionDescription": "Total ACH Debits",
"transactionType": "Summary",
"amount": "20650.65",
"itemCount": 5
},
{
"asOfDate": "2026-07-22",
"transactionCode": "459",
"transactionDescription": "Total Real Time Payment Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "470",
"transactionDescription": "Total Checks Paid Debit",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "480",
"transactionDescription": "Total Loan Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "486",
"transactionDescription": "Total Cash Letter Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "490",
"transactionDescription": "Total Outgoing Money Trnsfr DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "500",
"transactionDescription": "Total Automatic Transfer DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "510",
"transactionDescription": "Total FX/International Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "530",
"transactionDescription": "Total Security Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "539",
"transactionDescription": "Total Bankers Acceptance DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "550",
"transactionDescription": "Total Deposited Items Ret DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "594",
"transactionDescription": "Total ATM Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "596",
"transactionDescription": "Total ARP Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "650",
"transactionDescription": "Total Investments Purchased DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "690",
"transactionDescription": "Total Miscellaneous Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "201",
"transactionDescription": "Individual Auto Transfer CR",
"transactionType": "Detail",
"amount": "15000.00",
"creditDebitIndicator": "Credit",
"availabilityImmediateAmount": "15000.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"customerReference": "073032",
"bankReference": "200009933345",
"detailText": "ACCOUNT TRANSFER TRSF FROM 483096772516"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "14516.84",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379940",
"detailText": "ALLIANCE SANITAT DES:PAYMENTS ID:PMT 7854980 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:Alliance Sanitation LLC 21222000280"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "3988.81",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379931",
"detailText": "SUMMIT FIRE & SE DES:PAYMENTS ID:PMT 7854978 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:Summit Fire & Security LLC 21222000281"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "1400.00",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379928",
"detailText": "AWT NORTH AMERIC DES:PAYMENTS ID:PMT 7854976 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:AWT North America 21222000283"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "385.00",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379934",
"detailText": "SASSI INC DES:PAYMENTS ID:PMT 7854974 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:SASSI INC 21222000285"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "360.00",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379937",
"detailText": "SKYWOOD MAINTENA DES:PAYMENTS ID:PMT 7854973 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:SKYWOOD MAINTENANCE LLC 21222000282"
}
]
}
]
}

449
tests/fixtures/current-day.json vendored Normal file
View file

@ -0,0 +1,449 @@
{
"accountTransactions": [
{
"accountNumber": "000000000000",
"bankId": "000000000",
"currency": "USD",
"transactions": [
{
"asOfDate": "2026-07-22",
"transactionCode": "010",
"transactionDescription": "Opening Ledger Balance",
"transactionType": "Summary",
"amount": "209541.48"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "030",
"transactionDescription": "Current Ledger Balance",
"transactionType": "Summary",
"amount": "203890.83"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "040",
"transactionDescription": "Opening Avail Balance",
"transactionType": "Summary",
"amount": "209541.48"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "060",
"transactionDescription": "Current Available Balance",
"transactionType": "Summary",
"amount": "203890.83"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "072",
"transactionDescription": "1 Day Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "074",
"transactionDescription": "2 or More Days Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "906",
"transactionDescription": "Today Opening 1 Day Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "907",
"transactionDescription": "Today Opening 2+ Day Float",
"transactionType": "Summary",
"amount": "0.00"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "100",
"transactionDescription": "Total Credits",
"transactionType": "Summary",
"amount": "15000.00",
"availabilityImmediateAmount": "15000.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"itemCount": 1
},
{
"asOfDate": "2026-07-22",
"transactionCode": "110",
"transactionDescription": "Total Lockbox Deposit CR",
"transactionType": "Summary",
"amount": "0.00",
"availabilityImmediateAmount": "0.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "120",
"transactionDescription": "Total EDI Transaction Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "140",
"transactionDescription": "Total ACH Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "150",
"transactionDescription": "Total Preauthorized Payment CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "159",
"transactionDescription": "Total Real Time Payment Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "170",
"transactionDescription": "Total Other Check Deposits CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "180",
"transactionDescription": "Total Loan Proceed Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "186",
"transactionDescription": "Total Cash Letter Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "190",
"transactionDescription": "Total Incoming Money Trnsfr CR",
"transactionType": "Summary",
"amount": "0.00",
"availabilityImmediateAmount": "0.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "200",
"transactionDescription": "Total Automatic Transfer CR",
"transactionType": "Summary",
"amount": "15000.00",
"itemCount": 1
},
{
"asOfDate": "2026-07-22",
"transactionCode": "210",
"transactionDescription": "Total FX/International Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "239",
"transactionDescription": "Total Bankers Acceptance CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "250",
"transactionDescription": "Total Deposited Items Ret CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "260",
"transactionDescription": "Total Rejected Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "294",
"transactionDescription": "Total ATM Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "310",
"transactionDescription": "Total Commercial Deposit CR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "350",
"transactionDescription": "Total Sweep Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "390",
"transactionDescription": "Total Miscellaneous Credits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "400",
"transactionDescription": "Total Debits",
"transactionType": "Summary",
"amount": "20650.65",
"itemCount": 5
},
{
"asOfDate": "2026-07-22",
"transactionCode": "412",
"transactionDescription": "Total Debits Less Return Items",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "416",
"transactionDescription": "Total Lockbox Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "420",
"transactionDescription": "Total EDI Transaction Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "450",
"transactionDescription": "Total ACH Debits",
"transactionType": "Summary",
"amount": "20650.65",
"itemCount": 5
},
{
"asOfDate": "2026-07-22",
"transactionCode": "459",
"transactionDescription": "Total Real Time Payment Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "470",
"transactionDescription": "Total Checks Paid Debit",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "480",
"transactionDescription": "Total Loan Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "486",
"transactionDescription": "Total Cash Letter Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "490",
"transactionDescription": "Total Outgoing Money Trnsfr DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "500",
"transactionDescription": "Total Automatic Transfer DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "510",
"transactionDescription": "Total FX/International Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "530",
"transactionDescription": "Total Security Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "539",
"transactionDescription": "Total Bankers Acceptance DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "550",
"transactionDescription": "Total Deposited Items Ret DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "594",
"transactionDescription": "Total ATM Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "596",
"transactionDescription": "Total ARP Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "650",
"transactionDescription": "Total Investments Purchased DR",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "690",
"transactionDescription": "Total Miscellaneous Debits",
"transactionType": "Summary",
"amount": "0.00",
"itemCount": 0
},
{
"asOfDate": "2026-07-22",
"transactionCode": "201",
"transactionDescription": "Individual Auto Transfer CR",
"transactionType": "Detail",
"amount": "15000.00",
"creditDebitIndicator": "Credit",
"availabilityImmediateAmount": "15000.00",
"availability1DayAmount": "0.00",
"availability2PlusDayAmount": "0.00",
"customerReference": "073032",
"bankReference": "200009933345",
"detailText": "ACCOUNT TRANSFER TRSF FROM 483096772516"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "14516.84",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379940",
"detailText": "ALLIANCE SANITAT DES:PAYMENTS ID:PMT 7854980 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:Alliance Sanitation LLC 21222000280"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "3988.81",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379931",
"detailText": "SUMMIT FIRE & SE DES:PAYMENTS ID:PMT 7854978 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:Summit Fire & Security LLC 21222000281"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "1400.00",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379928",
"detailText": "AWT NORTH AMERIC DES:PAYMENTS ID:PMT 7854976 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:AWT North America 21222000283"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "385.00",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379934",
"detailText": "SASSI INC DES:PAYMENTS ID:PMT 7854974 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:SASSI INC 21222000285"
},
{
"asOfDate": "2026-07-22",
"transactionCode": "455",
"transactionDescription": "Preauthorized ACH Debit",
"transactionType": "Detail",
"amount": "360.00",
"creditDebitIndicator": "Debit",
"customerReference": "000000000000",
"bankReference": "02023379937",
"detailText": "SKYWOOD MAINTENA DES:PAYMENTS ID:PMT 7854973 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:SKYWOOD MAINTENANCE LLC 21222000282"
}
]
}
]
}

1370
tests/fixtures/previous-day-returns.json vendored Normal file

File diff suppressed because it is too large Load diff

2130
tests/fixtures/previous-day-week.json vendored Normal file

File diff suppressed because it is too large Load diff

138
tests/slackAppHome.test.js Normal file
View file

@ -0,0 +1,138 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { categorizePayments, buildHomeView } from "../src/slackAppHome.js";
import { parseISOLocal } from "../src/dates.js";
// Fixture shapes mirror real DDB payment records after the fetchBoa v2
// transitions (#66/#69): a bank-returned check keeps status "Cleared" (the
// CSV ladder has no Returned rung) while clear_status flips to "Returned".
const payment = (overrides = {}) => ({
method: "Check",
status: "Sent for payment",
send_payment_on: "01/05/2020",
amount_usd: 100,
payee: "Vendor",
check_number: "1122200030",
...overrides,
});
const returnedCheck = (overrides = {}) =>
payment({
status: "Cleared",
clear_status: "Returned",
returned_date: "2026-02-20",
...overrides,
});
test("categorize: returned check surfaces despite status Cleared", () => {
const { returnedPayments, outstandingChecks, scheduledChecks } = categorizePayments([
returnedCheck(),
]);
assert.equal(returnedPayments.length, 1);
assert.equal(outstandingChecks.length, 0);
assert.equal(scheduledChecks.length, 0);
});
test("categorize: returned records are excluded from Outstanding totals", () => {
const { outstandingChecks, bucketedOutstanding, returnedPayments } = categorizePayments([
returnedCheck({ amount_usd: 5256.62 }),
payment({ amount_usd: 40 }),
]);
assert.equal(outstandingChecks.length, 1);
assert.equal(returnedPayments.length, 1);
const bucketTotal = bucketedOutstanding.reduce((sum, b) => sum + b.total, 0);
assert.equal(bucketTotal, 40);
});
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 }),
]);
assert.equal(returnedPayments.length, 0, `status ${status} in returned bucket`);
assert.equal(outstandingChecks.length, 0, `status ${status} in outstanding`);
}
});
test("categorize: redeposited check (re-Cleared, returned_date retained) is not Returned", () => {
const { returnedPayments, outstandingChecks } = categorizePayments([
returnedCheck({ clear_status: "Cleared" }),
]);
assert.equal(returnedPayments.length, 0);
assert.equal(outstandingChecks.length, 0); // status Cleared → skip-list
});
test("categorize: returned ACH is included in the returned bucket", () => {
const { returnedPayments } = categorizePayments([
returnedCheck({ method: "ACH", check_number: "PMT123" }),
]);
assert.equal(returnedPayments.length, 1);
});
test("categorize: returned surfaces even when send_payment_on no longer parses", () => {
const { returnedPayments } = categorizePayments([
returnedCheck({ send_payment_on: "garbage" }),
]);
assert.equal(returnedPayments.length, 1);
});
test("categorize: returned bucket sorts oldest return first, unknown date first", () => {
const { returnedPayments } = categorizePayments([
returnedCheck({ payee: "B", returned_date: "2026-04-08" }),
returnedCheck({ payee: "C", returned_date: undefined }),
returnedCheck({ payee: "A", returned_date: "2026-02-20" }),
]);
assert.deepEqual(
returnedPayments.map((p) => p.payee),
["C", "A", "B"]
);
});
test("categorize: non-returned flow is unchanged", () => {
const { outstandingChecks, scheduledChecks } = categorizePayments([
payment(),
payment({ send_payment_on: "12/31/2099" }),
payment({ status: "Marked as Void" }),
]);
assert.equal(outstandingChecks.length, 1);
assert.equal(scheduledChecks.length, 1);
});
const metadata = { last_updated: new Date().toISOString(), file_name: "export.csv" };
test("home view: renders the needs-action section and summary tile when returns exist", () => {
const view = buildHomeView([returnedCheck({ amount_usd: 1892.62 }), payment()], metadata);
const json = JSON.stringify(view);
assert.ok(json.includes("Returned — Needs Action"));
assert.ok(json.includes("reissue or void decision"));
assert.ok(json.includes(":rotating_light: *Returned*"));
assert.ok(json.includes("$1,892.62"));
});
test("home view: omits the returned section and tile when there are none", () => {
const view = buildHomeView([payment()], metadata);
const json = JSON.stringify(view);
assert.ok(!json.includes("Returned — Needs Action"));
assert.ok(!json.includes(":rotating_light: *Returned*"));
});
test("home view: unknown returned_date renders without an age instead of crashing", () => {
const view = buildHomeView([returnedCheck({ returned_date: "not-a-date" })], metadata);
assert.ok(JSON.stringify(view).includes("Return date unknown"));
});
test("parseISOLocal: valid ISO date parses to local midnight", () => {
const dt = parseISOLocal("2026-02-20");
assert.equal(dt.getFullYear(), 2026);
assert.equal(dt.getMonth(), 1);
assert.equal(dt.getDate(), 20);
assert.equal(dt.getHours(), 0);
});
test("parseISOLocal: rejects impossible, non-ISO, and empty values", () => {
assert.equal(parseISOLocal("2026-02-30"), null);
assert.equal(parseISOLocal("02/20/2026"), null);
assert.equal(parseISOLocal(""), null);
assert.equal(parseISOLocal(undefined), null);
});