mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-07 16:19:03 +00:00
Compare commits
1 commit
ca778c5806
...
1d53ebd02c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d53ebd02c |
16 changed files with 73 additions and 5554 deletions
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"sessionId":"a426a233-ab23-4dc4-a01b-27f4e6970165","pid":33318,"procStart":"Fri Apr 24 17:44:23 2026","acquiredAt":1777054008237}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,5 +8,3 @@ BofA API Resources/
|
||||||
postman-output.txt
|
postman-output.txt
|
||||||
stampli_*.txt
|
stampli_*.txt
|
||||||
.env
|
.env
|
||||||
.claude/settings.local.json
|
|
||||||
.claude/scheduled_tasks.lock
|
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -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.
|
- **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.
|
- **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 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).
|
- **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, 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).
|
- **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.
|
||||||
|
|
||||||
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
@ -71,18 +71,7 @@ Two separate CashPro APIs are used, each with its own OAuth credentials:
|
||||||
|
|
||||||
The scheduled Lambda reconciles the Previous Day feed onto `payment#` records (pure logic lives in `src/boaRecon.js`, covered by `npm test`).
|
The scheduled Lambda reconciles the Previous Day feed onto `payment#` records (pure logic lives in `src/boaRecon.js`, covered by `npm test`).
|
||||||
|
|
||||||
**Classification.** The API's Detail rows carry the statement line in `detailText` (ACH `DES:PAYMENTS ID:PMT` text, return descriptions) and the posting date in `asOfDate` (ISO); `transactionType: "Summary"` rows are balance/total lines and classify as `summary` (skipped, counted). Only 475 check-paid rows omit `detailText` — their check number rides in `customerReference` (all-zeros references normalize to empty, never check number 0). Classification runs the empirically-enumerated BAI code map first, then the description-text classifiers:
|
**Classification.** Transactions are classified via a BAI transaction-code map (`BAI_CODE_EVENTS`; only 475 = check paid debit is empirically confirmed so far — the remaining codes are pending an enumeration replay over known event dates) with a fallback classifier over the statement description formats (ARP refer-to-maker, return-of-posted-check check-numbered and electronic variants, ACH CCD `DES:PAYMENTS ID:PMT` lines). Unmapped codes on check-shaped transactions are logged (`console.error`) and counted in the run summary — never silently dropped.
|
||||||
|
|
||||||
| BAI code | Feed label | Event |
|
|
||||||
|---|---|---|
|
|
||||||
| 475 | Check Paid | `check_paid` (number in `customerReference`) |
|
|
||||||
| 255 | Check Posted and Returned CR | `check_return` (number in `customerReference`) |
|
|
||||||
| 252 | Debit Reversal Credit | `check_return` (second return-credit code) |
|
|
||||||
| 266 | Return Item Credit | text decides (`ach_return` on PMT text, `check_return`/`electronic_return` on return text); bare rows fall back to `electronic_return`; unreadable text stays `unknown` (loud, no write) |
|
|
||||||
| 455 | Preauthorized ACH Debit | `ach_debit` via `DES:PAYMENTS` text; DES-less = third-party autopay → ignored |
|
|
||||||
| 170/201/470/481 | totals, transfers, loan payments | ignored |
|
|
||||||
|
|
||||||
A hard code-map event wins over description text; 266/455 carry *fallback* events consulted only when the text yields nothing. Unmapped codes on check-shaped transactions are logged (`console.error`) and counted in the run summary — never silently dropped.
|
|
||||||
|
|
||||||
**Matching.** Check events match on check number AND amount, evaluating all candidates (bank postings can drop/collapse digits on long check numbers). A number match with the WRONG amount never auto-resolves — it is the altered-check/collapsed-posting signal and goes to unmatched for human review. An unknown number falls back to an exact-amount match within checks issued in the last 120 days, and only when the posting's digits are a subsequence of the candidate's check number (or vice versa); return credits additionally require a bank-confirmed candidate. Zero or multiple fallback candidates means unmatched, recorded in the run summary with no write. Electronic returns (no check number) match by exact amount among bank-confirmed payments.
|
**Matching.** Check events match on check number AND amount, evaluating all candidates (bank postings can drop/collapse digits on long check numbers). A number match with the WRONG amount never auto-resolves — it is the altered-check/collapsed-posting signal and goes to unmatched for human review. An unknown number falls back to an exact-amount match within checks issued in the last 120 days, and only when the posting's digits are a subsequence of the candidate's check number (or vice versa); return credits additionally require a bank-confirmed candidate. Zero or multiple fallback candidates means unmatched, recorded in the run summary with no write. Electronic returns (no check number) match by exact amount among bank-confirmed payments.
|
||||||
|
|
||||||
|
|
@ -101,7 +90,7 @@ A hard code-map event wins over description text; 266/455 carry *fallback* event
|
||||||
|
|
||||||
Each applied event is appended to a `history` list attribute (`{event, date, bankRef, amount}`); identical replayed events are idempotent noops.
|
Each applied event is appended to a `history` list attribute (`{event, date, bankRef, amount}`); identical replayed events are idempotent noops.
|
||||||
|
|
||||||
**Replay.** The handler accepts an optional payload `{"fromDate": "YYYY-MM-DD", "toDate": "YYYY-MM-DD"}` (strictly validated; `toDate` defaults to `fromDate`) for weekend/outage gap replays and BAI-code enumeration runs. With no payload it queries the trailing 7-day window (today−7 .. today−1) — self-healing across missed runs and holiday gaps; overlapping days are idempotent (event identity `{event, date, amount}`), and multi-day responses were verified pagination-free up to 9-day windows. Feed rows without a valid posting date (`asOfDate`, with `valueDate` as a fixture-era fallback) are never applied with a substituted date — they go to unmatched for review.
|
**Replay.** The handler accepts an optional payload `{"fromDate": "YYYY-MM-DD", "toDate": "YYYY-MM-DD"}` (strictly validated; `toDate` defaults to `fromDate`) for weekend/outage gap replays and BAI-code enumeration runs. With no payload it queries the trailing 3-day window (today−3 .. today−1), so the Monday run covers Friday–Sunday; overlapping days are idempotent (event identity `{event, date, amount}`). Feed rows without a valid `valueDate` are never applied with a substituted date — they go to unmatched for review.
|
||||||
|
|
||||||
**Run summary.** Each run writes an append-only `boa_recon#<fromDate>_<toDate>#<runAt>` item (90-day TTL) with counts per classified event type, matched/applied/redeposit/voided-and-bounced/write-conflict totals, the unmatched check numbers and amounts (list capped at 50; full count kept), unknown BAI codes (capped at 20 distinct keys), and the staleness sweep results. Unmatched and unknown-code transactions also `console.error` (Slack alerting is tracked in #71). Payment writes are conditioned on the read snapshot's `status`/`clear_status` and retried once against a fresh read on conflict.
|
**Run summary.** Each run writes an append-only `boa_recon#<fromDate>_<toDate>#<runAt>` item (90-day TTL) with counts per classified event type, matched/applied/redeposit/voided-and-bounced/write-conflict totals, the unmatched check numbers and amounts (list capped at 50; full count kept), unknown BAI codes (capped at 20 distinct keys), and the staleness sweep results. Unmatched and unknown-code transactions also `console.error` (Slack alerting is tracked in #71). Payment writes are conditioned on the read snapshot's `status`/`clear_status` and retried once against a fresh read on conflict.
|
||||||
|
|
||||||
|
|
@ -136,8 +125,6 @@ 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).
|
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
|
## 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.
|
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.
|
||||||
|
|
|
||||||
196
src/boaRecon.js
196
src/boaRecon.js
|
|
@ -21,38 +21,18 @@ const cents = (v) => Math.round(Math.abs(parseAmount(v)) * 100);
|
||||||
// not pair with another blank amount).
|
// not pair with another blank amount).
|
||||||
export const amountsEqual = (a, b) => cents(a) > 0 && cents(a) === cents(b);
|
export const amountsEqual = (a, b) => cents(a) > 0 && cents(a) === cents(b);
|
||||||
|
|
||||||
// BAI transaction-code -> event map, enumerated empirically from replays
|
// BAI transaction-code -> event map. BoA's Previous Day feed uses standard
|
||||||
// over the known event windows (2/19-21, 3/30-4/1, 4/7-9, 6/15-23) plus
|
// BAI type codes; only 475 (check paid, debit) is empirically confirmed so
|
||||||
// live captures on 2026-07-22:
|
// far. The codes for ARP refer-to-maker return credits, return-of-posted-
|
||||||
// 475 "Check Paid" debit -> check_paid (these rows carry NO detailText;
|
// check credits (check-numbered and electronic variants), ACH CCD debits and
|
||||||
// the check number rides in customerReference)
|
// return credits, and the true meaning of 255 (the old filter assumed
|
||||||
// 255 "Check Posted and Returned CR" credit -> check_return (check # in
|
// "returned check", unverified) are pending the enumeration replay over
|
||||||
// customerReference; the ARP refer-to-maker family)
|
// known event dates (2/20, 3/31, 4/8, 6/16-6/23) — add them here as they
|
||||||
// 252 "Debit Reversal Credit" credit -> check_return (second return-credit
|
// are confirmed. Until then classification falls back to description text,
|
||||||
// code; check # in customerReference)
|
// and unmapped codes on check-shaped transactions are logged and counted,
|
||||||
// 266 "Return Item Credit" credit -> covers BOTH electronic check returns
|
// never silently dropped.
|
||||||
// AND ACH/bill-pay bounces (customerReference all zeros); the
|
|
||||||
// description text disambiguates, so its event is a FALLBACK used only
|
|
||||||
// when the text yields nothing
|
|
||||||
// 455 "Preauthorized ACH Debit" debit -> ours carry DES:PAYMENTS in
|
|
||||||
// detailText -> ach_debit via text; a DES-less 455 is a third-party
|
|
||||||
// autopay -> ignored (counted in the histogram, not alarmed as unknown)
|
|
||||||
// 170/201/470/481 -> statement noise (summary totals, funding transfers,
|
|
||||||
// loan payments) -> ignored
|
|
||||||
// A hard `event` wins over description text; `fallbackEvent` is consulted
|
|
||||||
// only when the description yields no event. Unmapped codes on check-shaped
|
|
||||||
// transactions still come back "unknown" — logged and counted, never
|
|
||||||
// silently dropped.
|
|
||||||
export const BAI_CODE_EVENTS = {
|
export const BAI_CODE_EVENTS = {
|
||||||
475: { event: "check_paid", direction: "debit" },
|
475: { event: "check_paid", direction: "debit" },
|
||||||
255: { event: "check_return", direction: "credit" },
|
|
||||||
252: { event: "check_return", direction: "credit" },
|
|
||||||
266: { fallbackEvent: "electronic_return", direction: "credit" },
|
|
||||||
455: { fallbackEvent: "ignored", direction: "debit" },
|
|
||||||
170: { event: "ignored" },
|
|
||||||
201: { event: "ignored" },
|
|
||||||
470: { event: "ignored" },
|
|
||||||
481: { event: "ignored" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Standard BAI ranges: 100-399 are credit type codes, 400-699 are debit
|
// Standard BAI ranges: 100-399 are credit type codes, 400-699 are debit
|
||||||
|
|
@ -81,13 +61,10 @@ export function directionOf(txn) {
|
||||||
|
|
||||||
// Statement description formats observed on the 2026-07-21 reconciliation.
|
// Statement description formats observed on the 2026-07-21 reconciliation.
|
||||||
const ARP_RETURN_RE = /^ARP RETURNED CHECK REFER TO MAKER CHECK #\s*(\d+)\b/i;
|
const ARP_RETURN_RE = /^ARP RETURNED CHECK REFER TO MAKER CHECK #\s*(\d+)\b/i;
|
||||||
// Live detailText prefixes the statement line with an MMDDYY token
|
|
||||||
// ("061626 RETURN OF POSTED CHECK / ITEM ..."); both anchors accept it so
|
|
||||||
// the return classifiers work on the real feed, not just statement exports.
|
|
||||||
const POSTED_RETURN_CHECK_RE =
|
const POSTED_RETURN_CHECK_RE =
|
||||||
/^(?:\d{6}\s+)?RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*CHECK #\s*(\d+)\b/i;
|
/^RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*CHECK #\s*(\d+)\b/i;
|
||||||
const POSTED_RETURN_ELECTRONIC_RE =
|
const POSTED_RETURN_ELECTRONIC_RE =
|
||||||
/^(?:\d{6}\s+)?RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*ELECTRONIC TRANSACTION\b/i;
|
/^RETURN OF POSTED CHECK \/ ITEM \(RECEIVED ON \d{2}-\d{2}\)\s*ELECTRONIC TRANSACTION\b/i;
|
||||||
const ACH_PMT_RE = /DES:PAYMENTS\s+ID:PMT\s*(\d+)/i;
|
const ACH_PMT_RE = /DES:PAYMENTS\s+ID:PMT\s*(\d+)/i;
|
||||||
const PMT_INFO_RE = /PMT INFO:\s*(.*)$/i;
|
const PMT_INFO_RE = /PMT INFO:\s*(.*)$/i;
|
||||||
const CHECK_PAID_RE = /^CHECK\s{0,10}#?\s{0,10}0*(\d{1,12})$/i;
|
const CHECK_PAID_RE = /^CHECK\s{0,10}#?\s{0,10}0*(\d{1,12})$/i;
|
||||||
|
|
@ -109,30 +86,13 @@ const stripLeadingZeros = (s) => String(s ?? "").replace(/^0+(?=\d)/, "");
|
||||||
// (transfers, misc bank activity) is "ignored".
|
// (transfers, misc bank activity) is "ignored".
|
||||||
export function classifyTransaction(txn) {
|
export function classifyTransaction(txn) {
|
||||||
const code = String(txn.transactionCode ?? "").trim();
|
const code = String(txn.transactionCode ?? "").trim();
|
||||||
// Substantive statement text: detailText is where the live API carries
|
const description = String(
|
||||||
// the rich line (ACH DES:PAYMENTS text, return descriptions); text /
|
txn.text ?? txn.description ?? txn.transactionDescription ?? ""
|
||||||
// description cover statement-derived fixtures. transactionDescription
|
)
|
||||||
// (the short code label, e.g. "Return Item Credit") is deliberately NOT
|
.slice(0, MAX_DESCRIPTION_LEN)
|
||||||
// substantive — a label is not evidence — and is used only as the display
|
.trim();
|
||||||
// fallback.
|
const customerReference = stripLeadingZeros(String(txn.customerReference ?? "").trim());
|
||||||
// First NON-EMPTY of the substantive fields — ?? alone would let an
|
const bankReference = String(txn.bankReference ?? "").trim();
|
||||||
// empty-string detailText shadow a populated text/description field.
|
|
||||||
const substantiveText = [txn.detailText, txn.text, txn.description]
|
|
||||||
.map((v) => String(v ?? "").slice(0, MAX_DESCRIPTION_LEN).trim())
|
|
||||||
.find(Boolean) ?? "";
|
|
||||||
const description =
|
|
||||||
substantiveText ||
|
|
||||||
String(txn.transactionDescription ?? "").slice(0, MAX_DESCRIPTION_LEN).trim();
|
|
||||||
// Reference fields are bank-generated and short (12 digits observed);
|
|
||||||
// bound them so a malformed feed can never balloon DDB items or matching.
|
|
||||||
const rawReference = stripLeadingZeros(
|
|
||||||
String(txn.customerReference ?? "").trim().slice(0, 64)
|
|
||||||
);
|
|
||||||
// An all-zeros reference ("000000000000" on 266 return credits) means "no
|
|
||||||
// reference", not check number 0 — stripLeadingZeros alone leaves "0",
|
|
||||||
// which would fabricate a checkNumber.
|
|
||||||
const customerReference = rawReference === "0" ? "" : rawReference;
|
|
||||||
const bankReference = String(txn.bankReference ?? "").trim().slice(0, 64);
|
|
||||||
const amount = Math.abs(parseAmount(txn.amount));
|
const amount = Math.abs(parseAmount(txn.amount));
|
||||||
const direction = BAI_CODE_EVENTS[code]?.direction ?? directionOf(txn);
|
const direction = BAI_CODE_EVENTS[code]?.direction ?? directionOf(txn);
|
||||||
|
|
||||||
|
|
@ -150,13 +110,6 @@ export function classifyTransaction(txn) {
|
||||||
checkShaped: false,
|
checkShaped: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Summary rows (balance/total lines, transactionType "Summary") are not
|
|
||||||
// events; bail before the description regexes so their labels ("Total
|
|
||||||
// Checks Paid Debit") can't pollute unknown/check-shaped counting.
|
|
||||||
if (String(txn.transactionType ?? "").trim().toLowerCase() === "summary") {
|
|
||||||
return { ...base, event: "summary" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description facts, extracted regardless of code so a code-mapped event
|
// Description facts, extracted regardless of code so a code-mapped event
|
||||||
// still carries the check number / PMT id it references.
|
// still carries the check number / PMT id it references.
|
||||||
let descriptionEvent = null;
|
let descriptionEvent = null;
|
||||||
|
|
@ -189,18 +142,7 @@ export function classifyTransaction(txn) {
|
||||||
base.checkNumber = stripLeadingZeros(m[1]);
|
base.checkNumber = stripLeadingZeros(m[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped = BAI_CODE_EVENTS[code];
|
const event = BAI_CODE_EVENTS[code]?.event ?? descriptionEvent;
|
||||||
let event = mapped?.event ?? descriptionEvent ?? null;
|
|
||||||
if (!event && mapped?.fallbackEvent) {
|
|
||||||
// "ignored" is a safe fallback anytime (455 third-party autopays carry
|
|
||||||
// substantive non-PMT text by design). A WRITE-capable fallback (266 ->
|
|
||||||
// electronic_return, which enters amount-only matching) applies only to
|
|
||||||
// a bare row: substantive text that matched no classifier must stay
|
|
||||||
// loud as "unknown", never silently become a Returned write.
|
|
||||||
if (mapped.fallbackEvent === "ignored" || !substantiveText) {
|
|
||||||
event = mapped.fallbackEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event === "check_paid" && !base.checkNumber) {
|
if (event === "check_paid" && !base.checkNumber) {
|
||||||
base.checkNumber = customerReference || null;
|
base.checkNumber = customerReference || null;
|
||||||
|
|
@ -211,11 +153,8 @@ export function classifyTransaction(txn) {
|
||||||
|
|
||||||
if (event) return { ...base, event };
|
if (event) return { ...base, event };
|
||||||
|
|
||||||
// Shape test uses the RAW reference: an all-zeros reference still means
|
|
||||||
// the bank posted a structured row (the zero-guard must not silence
|
|
||||||
// unmapped return-credit codes, which present exactly this way).
|
|
||||||
base.checkShaped =
|
base.checkShaped =
|
||||||
Boolean(rawReference) || /CHECK/i.test(description) || Boolean(base.pmtId);
|
Boolean(customerReference) || /CHECK/i.test(description) || Boolean(base.pmtId);
|
||||||
return { ...base, event: base.checkShaped ? "unknown" : "ignored" };
|
return { ...base, event: base.checkShaped ? "unknown" : "ignored" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,7 +372,7 @@ const RETURN_EVENTS = new Set(["check_return", "electronic_return", "ach_return"
|
||||||
// paid debit against a canceled record (a voided check the bank paid —
|
// paid debit against a canceled record (a voided check the bank paid —
|
||||||
// expected without Positive Pay), the cancel status is likewise preserved:
|
// expected without Positive Pay), the cancel status is likewise preserved:
|
||||||
// the ARP return that follows lands as terminal voided-and-bounced.
|
// the ARP return that follows lands as terminal voided-and-bounced.
|
||||||
export function applyEvent(payment, classified, eventDateISO, via = null) {
|
export function applyEvent(payment, classified, eventDateISO) {
|
||||||
const { event, amount, bankReference } = classified;
|
const { event, amount, bankReference } = classified;
|
||||||
const canceled = isCancelStatus(payment.status);
|
const canceled = isCancelStatus(payment.status);
|
||||||
const historyEvent = {
|
const historyEvent = {
|
||||||
|
|
@ -441,9 +380,6 @@ export function applyEvent(payment, classified, eventDateISO, via = null) {
|
||||||
date: eventDateISO,
|
date: eventDateISO,
|
||||||
bankRef: bankReference,
|
bankRef: bankReference,
|
||||||
amount,
|
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
|
// Replay idempotence: event identity is {event, date, amount} — bankRef
|
||||||
|
|
@ -518,40 +454,18 @@ export function isValidISODate(s) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional {fromDate, toDate} replay payload. The default is a trailing
|
// Optional {fromDate, toDate} replay payload. The default is a trailing
|
||||||
// 7-day window (today-7 .. today-1): weekend/holiday gaps and missed runs
|
// 3-day window (today-3 .. today-1): the previous-day feed only runs on
|
||||||
// self-heal inside a week without manual replays. Overlapping
|
// weekdays, so the Monday run must cover Friday through Sunday. Overlapping
|
||||||
// days are safe — event identity makes replays idempotent. Strings are
|
// days are safe — event identity makes replays idempotent. Strings are
|
||||||
// validated strictly so a malformed payload fails loudly instead of
|
// validated strictly so a malformed payload fails loudly instead of
|
||||||
// querying a garbage range.
|
// querying a garbage range.
|
||||||
// Endpoint allowlist: the value is interpolated into the request URL, so
|
export function resolveDateRange(event, now = new Date()) {
|
||||||
// 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 hasFrom = event?.fromDate != null;
|
||||||
const hasTo = event?.toDate != 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) {
|
if (!hasFrom && !hasTo) {
|
||||||
// Trailing 7 days ending yesterday: self-healing across missed runs,
|
|
||||||
// outages, and holiday gaps — replays are idempotent (event identity
|
|
||||||
// noops), so the overlap is free. Pagination-free responses verified
|
|
||||||
// empirically up to 9-day/420-row windows (2026-07-22 captures).
|
|
||||||
const day = 24 * 60 * 60 * 1000;
|
const day = 24 * 60 * 60 * 1000;
|
||||||
return {
|
return {
|
||||||
fromDate: new Date(now.getTime() - 7 * day).toISOString().split("T")[0],
|
fromDate: new Date(now.getTime() - 3 * day).toISOString().split("T")[0],
|
||||||
toDate: new Date(now.getTime() - day).toISOString().split("T")[0],
|
toDate: new Date(now.getTime() - day).toISOString().split("T")[0],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -635,61 +549,3 @@ export function buildEventUpdate(tableName, payment, applied) {
|
||||||
ExpressionAttributeValues: values,
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
17
src/dates.js
17
src/dates.js
|
|
@ -41,23 +41,6 @@ export function parseMDYLocal(value) {
|
||||||
return new Date(p.year, p.month - 1, p.day);
|
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
|
// Plausibility window for ingested send dates. Anything outside is treated
|
||||||
// as parser garbage and the row is rejected loudly rather than stored.
|
// 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
|
// Relative to the current year so it never expires (7 years back covers
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||||
import { DynamoDBDocumentClient, GetCommand, PutCommand, ScanCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
import { DynamoDBDocumentClient, GetCommand, PutCommand, ScanCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
||||||
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
||||||
|
|
@ -6,51 +5,23 @@ import {
|
||||||
applyEvent,
|
applyEvent,
|
||||||
buildEventUpdate,
|
buildEventUpdate,
|
||||||
classifyTransaction,
|
classifyTransaction,
|
||||||
extractBalances,
|
|
||||||
isValidISODate,
|
isValidISODate,
|
||||||
logSafe,
|
logSafe,
|
||||||
matchAchTransaction,
|
matchAchTransaction,
|
||||||
matchCheckTransaction,
|
matchCheckTransaction,
|
||||||
matchElectronicReturn,
|
matchElectronicReturn,
|
||||||
resolveDateRange,
|
resolveDateRange,
|
||||||
resolveEndpoint,
|
|
||||||
sweepStalePayments,
|
sweepStalePayments,
|
||||||
} from "./boaRecon.js";
|
} from "./boaRecon.js";
|
||||||
import { toISODate } from "./dates.js";
|
|
||||||
|
|
||||||
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
||||||
const secrets = new SecretsManagerClient();
|
const secrets = new SecretsManagerClient();
|
||||||
const s3 = new S3Client();
|
|
||||||
const TABLE_NAME = process.env.TABLE_NAME;
|
const TABLE_NAME = process.env.TABLE_NAME;
|
||||||
const BOA_BASE_URL = process.env.BOA_BASE_URL;
|
const BOA_BASE_URL = process.env.BOA_BASE_URL;
|
||||||
const BOA_RAW_BUCKET = process.env.BOA_RAW_BUCKET;
|
|
||||||
|
|
||||||
const UNMATCHED_LIST_CAP = 50;
|
const UNMATCHED_LIST_CAP = 50;
|
||||||
const UNKNOWN_CODES_CAP = 20;
|
const UNKNOWN_CODES_CAP = 20;
|
||||||
|
|
||||||
// Posting date for one feed row. The live API carries it in asOfDate (ISO,
|
|
||||||
// verified 2026-07-22); valueDate never appears in real responses but is
|
|
||||||
// kept as a fallback for statement-derived fixtures. toISODate normalizes a
|
|
||||||
// non-ISO capture (M/D/YY[YY]) and returns null on garbage — never
|
|
||||||
// substitute a date.
|
|
||||||
export const postingDate = (txn) => {
|
|
||||||
// First NON-EMPTY — an empty-string asOfDate must not shadow a populated
|
|
||||||
// valueDate (?? only skips null/undefined).
|
|
||||||
const raw = [txn.asOfDate, txn.valueDate]
|
|
||||||
.map((v) => String(v ?? "").trim())
|
|
||||||
.find(Boolean);
|
|
||||||
const iso = isValidISODate(raw) ? raw : toISODate(raw);
|
|
||||||
if (!iso) return null;
|
|
||||||
// Plausibility window mirroring the CSV path's isPlausibleSendYear (#65):
|
|
||||||
// epoch artifacts ("12/31/69" -> 2069 via the 2-digit-year fallback) and
|
|
||||||
// absurd years must go loud via the missing-posting-date path — a wrong
|
|
||||||
// far-future returned_date would permanently noop later real events.
|
|
||||||
const year = parseInt(iso.slice(0, 4), 10);
|
|
||||||
const nowYear = new Date().getFullYear();
|
|
||||||
if (year < nowYear - 7 || year > nowYear + 2) return null;
|
|
||||||
return iso;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cachedCreds;
|
let cachedCreds;
|
||||||
async function getReportingCreds() {
|
async function getReportingCreds() {
|
||||||
if (cachedCreds) return cachedCreds;
|
if (cachedCreds) return cachedCreds;
|
||||||
|
|
@ -62,13 +33,10 @@ async function getReportingCreds() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse a BoA response body without ever surfacing a body snippet — a
|
// Parse a BoA response body without ever surfacing a body snippet — a
|
||||||
// malformed 200 must not leak account data into logs/errors. Returns the
|
// malformed 200 must not leak account data into logs/errors.
|
||||||
// exact raw body text alongside the parse so the archive stores the bytes
|
|
||||||
// the bank actually sent.
|
|
||||||
async function parseJsonResponse(res, label) {
|
async function parseJsonResponse(res, label) {
|
||||||
const rawBody = await res.text();
|
|
||||||
try {
|
try {
|
||||||
return { data: JSON.parse(rawBody), rawBody };
|
return await res.json();
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`BoA ${label} response not JSON: HTTP ${res.status}`);
|
throw new Error(`BoA ${label} response not JSON: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
@ -88,25 +56,23 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
|
||||||
throw new Error(`OAuth token exchange failed: HTTP ${res.status}`);
|
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;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const directionRank = (d) => (d === "debit" ? 0 : d === "credit" ? 1 : 2);
|
const directionRank = (d) => (d === "debit" ? 0 : d === "credit" ? 1 : 2);
|
||||||
|
|
||||||
export const handler = async (event) => {
|
export const handler = async (event) => {
|
||||||
// Optional payload {fromDate, toDate, endpoint}; endpoint defaults to
|
// Optional replay payload {fromDate, toDate}; default is the trailing
|
||||||
// previous-day (the authoritative sweep). The intraday schedule passes
|
// 3-day window so the Monday run covers Friday through Sunday.
|
||||||
// {"endpoint":"current-day"} and defaults to today..today; previous-day
|
const { fromDate, toDate } = resolveDateRange(event ?? {});
|
||||||
// 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 { appId, clientId, token: clientSecret, accountNumber, bankId } = await getReportingCreds();
|
||||||
|
|
||||||
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
|
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
|
||||||
|
|
||||||
const res = await fetch(`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/${endpoint}`, {
|
// Call CashPro Previous Day Transaction Inquiry
|
||||||
|
const res = await fetch(`${BOA_BASE_URL}/cashpro/reporting/v1/transaction-inquiries/previous-day`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -123,35 +89,7 @@ export const handler = async (event) => {
|
||||||
throw new Error(`BoA API error: HTTP ${res.status}`);
|
throw new Error(`BoA API error: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive the exact response bytes BEFORE parsing or classification: even
|
const data = await parseJsonResponse(res, "reporting");
|
||||||
// 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: [...] }] }
|
// Response shape: { accountTransactions: [{ accountNumber, bankId, currency, transactions: [...] }] }
|
||||||
const allTransactions = (data.accountTransactions || []).flatMap(
|
const allTransactions = (data.accountTransactions || []).flatMap(
|
||||||
|
|
@ -167,7 +105,7 @@ export const handler = async (event) => {
|
||||||
}));
|
}));
|
||||||
classifiedTxns.sort(
|
classifiedTxns.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
String(postingDate(a.txn) ?? "").localeCompare(String(postingDate(b.txn) ?? "")) ||
|
String(a.txn.valueDate ?? "").localeCompare(String(b.txn.valueDate ?? "")) ||
|
||||||
directionRank(a.classified.direction) - directionRank(b.classified.direction)
|
directionRank(a.classified.direction) - directionRank(b.classified.direction)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -223,19 +161,13 @@ export const handler = async (event) => {
|
||||||
for (const { txn, classified } of classifiedTxns) {
|
for (const { txn, classified } of classifiedTxns) {
|
||||||
summary.classified[classified.event] = (summary.classified[classified.event] || 0) + 1;
|
summary.classified[classified.event] = (summary.classified[classified.event] || 0) + 1;
|
||||||
|
|
||||||
if (classified.event === "ignored" || classified.event === "summary") continue;
|
if (classified.event === "ignored") continue;
|
||||||
|
|
||||||
if (classified.event === "unknown") {
|
if (classified.event === "unknown") {
|
||||||
summary.unknown_count++;
|
summary.unknown_count++;
|
||||||
// Histogram keys must be inert: real BAI codes are short digit runs;
|
const codeKey = (classified.code || "?").slice(0, 16);
|
||||||
// anything else ("__proto__", "constructor", junk) gets a prefix so
|
|
||||||
// it can never collide with Object.prototype semantics or break SDK
|
|
||||||
// marshalling (a bare "constructor" own key crashes convertToAttr —
|
|
||||||
// Object.create(null) is no escape, it crashes marshalling outright).
|
|
||||||
const rawCode = classified.code || "?";
|
|
||||||
const codeKey = /^[0-9]{1,16}$/.test(rawCode) ? rawCode : "x" + rawCode.slice(0, 15);
|
|
||||||
if (
|
if (
|
||||||
Object.hasOwn(summary.unknown_codes, codeKey) ||
|
codeKey in summary.unknown_codes ||
|
||||||
Object.keys(summary.unknown_codes).length < UNKNOWN_CODES_CAP
|
Object.keys(summary.unknown_codes).length < UNKNOWN_CODES_CAP
|
||||||
) {
|
) {
|
||||||
summary.unknown_codes[codeKey] = (summary.unknown_codes[codeKey] || 0) + 1;
|
summary.unknown_codes[codeKey] = (summary.unknown_codes[codeKey] || 0) + 1;
|
||||||
|
|
@ -250,11 +182,11 @@ export const handler = async (event) => {
|
||||||
|
|
||||||
// Never substitute a date: event identity and transition ordering both
|
// Never substitute a date: event identity and transition ordering both
|
||||||
// key on the posting date, so a feed row without one is a human case.
|
// key on the posting date, so a feed row without one is a human case.
|
||||||
const eventDate = postingDate(txn);
|
if (!isValidISODate(txn.valueDate)) {
|
||||||
if (!eventDate) {
|
recordUnmatched(classified, "missing valueDate");
|
||||||
recordUnmatched(classified, "missing posting date");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const eventDate = txn.valueDate;
|
||||||
|
|
||||||
let match;
|
let match;
|
||||||
if (classified.event === "ach_debit" || classified.event === "ach_return") {
|
if (classified.event === "ach_debit" || classified.event === "ach_return") {
|
||||||
|
|
@ -280,7 +212,7 @@ export const handler = async (event) => {
|
||||||
|
|
||||||
summary.matched++;
|
summary.matched++;
|
||||||
const target = match.payment;
|
const target = match.payment;
|
||||||
let applied = applyEvent(target, classified, eventDate, endpoint);
|
let applied = applyEvent(target, classified, eventDate);
|
||||||
if (applied.kind === "noop") {
|
if (applied.kind === "noop") {
|
||||||
summary.already_applied++;
|
summary.already_applied++;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -305,7 +237,7 @@ export const handler = async (event) => {
|
||||||
// payments array) and re-derive the transition.
|
// payments array) and re-derive the transition.
|
||||||
for (const k of Object.keys(target)) delete target[k];
|
for (const k of Object.keys(target)) delete target[k];
|
||||||
Object.assign(target, fresh);
|
Object.assign(target, fresh);
|
||||||
applied = applyEvent(target, classified, eventDate, endpoint);
|
applied = applyEvent(target, classified, eventDate);
|
||||||
if (applied.kind === "noop") {
|
if (applied.kind === "noop") {
|
||||||
outcome = "noop";
|
outcome = "noop";
|
||||||
break;
|
break;
|
||||||
|
|
@ -340,59 +272,25 @@ export const handler = async (event) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Staleness sweep: payments the bank has never confirmed (#69). Gated to
|
// Staleness sweep: payments the bank has never confirmed (#69).
|
||||||
// the authoritative previous-day runs — intraday runs would re-alert the
|
const todayISO = new Date().toISOString().split("T")[0];
|
||||||
// same backlog several times a day with no new information.
|
const stale = sweepStalePayments(payments, todayISO);
|
||||||
let stale = null;
|
if (stale.staleAchCount) {
|
||||||
if (endpoint === "previous-day") {
|
console.error(
|
||||||
const todayISO = new Date().toISOString().split("T")[0];
|
`Stale ACH (no bank settlement, sent > 16 days ago): ${stale.staleAchCount} records, ` +
|
||||||
stale = sweepStalePayments(payments, todayISO);
|
`checks=${logSafe(stale.staleAch.map((s) => s.check_number).join(","))}`
|
||||||
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) {
|
||||||
// Balance snapshots: one boa_balance#<asOfDate>#<endpoint> item per date,
|
console.error(
|
||||||
// latest-wins (run_at conditioned so a stale replay never clobbers fresher
|
`Stale checks (no bank activity, issued > 60 days ago): ${stale.staleChecksCount} records`
|
||||||
// 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
|
// Run summary record for auditing/alerting (Slack wiring is #71). The pk
|
||||||
// is append-only (run timestamp suffix) so re-runs never overwrite a
|
// is append-only (run timestamp suffix) so re-runs never overwrite a
|
||||||
// prior run's record.
|
// prior run's record.
|
||||||
|
const runAt = new Date().toISOString();
|
||||||
const runKey = `boa_recon#${fromDate}_${toDate}#${runAt}`;
|
const runKey = `boa_recon#${fromDate}_${toDate}#${runAt}`;
|
||||||
await ddb.send(
|
await ddb.send(
|
||||||
new PutCommand({
|
new PutCommand({
|
||||||
|
|
@ -402,10 +300,6 @@ export const handler = async (event) => {
|
||||||
run_at: runAt,
|
run_at: runAt,
|
||||||
from_date: fromDate,
|
from_date: fromDate,
|
||||||
to_date: toDate,
|
to_date: toDate,
|
||||||
endpoint,
|
|
||||||
archive_error: archiveError,
|
|
||||||
balance_write_errors: balanceWriteErrors,
|
|
||||||
balance_dates_written: balanceDatesWritten,
|
|
||||||
transactions_seen: summary.transactions_seen,
|
transactions_seen: summary.transactions_seen,
|
||||||
classified: summary.classified,
|
classified: summary.classified,
|
||||||
matched: summary.matched,
|
matched: summary.matched,
|
||||||
|
|
@ -418,13 +312,9 @@ export const handler = async (event) => {
|
||||||
unmatched: summary.unmatched,
|
unmatched: summary.unmatched,
|
||||||
unknown_count: summary.unknown_count,
|
unknown_count: summary.unknown_count,
|
||||||
unknown_codes: summary.unknown_codes,
|
unknown_codes: summary.unknown_codes,
|
||||||
...(stale
|
stale_ach: stale.staleAch,
|
||||||
? {
|
stale_ach_count: stale.staleAchCount,
|
||||||
stale_ach: stale.staleAch,
|
stale_checks_count: stale.staleChecksCount,
|
||||||
stale_ach_count: stale.staleAchCount,
|
|
||||||
stale_checks_count: stale.staleChecksCount,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
|
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import crypto from "node:crypto";
|
||||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||||
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
|
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
|
||||||
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
||||||
import { parseISOLocal, parseMDYLocal } from "./dates.js";
|
import { parseMDYLocal } from "./dates.js";
|
||||||
import { isCancelStatus } from "./boaRecon.js";
|
|
||||||
|
|
||||||
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
||||||
const secrets = new SecretsManagerClient();
|
const secrets = new SecretsManagerClient();
|
||||||
|
|
@ -92,7 +91,7 @@ const byDate = (a, b) => {
|
||||||
|
|
||||||
// --- Categorize payments into buckets ---
|
// --- Categorize payments into buckets ---
|
||||||
|
|
||||||
export function categorizePayments(payments) {
|
function categorizePayments(payments) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24));
|
const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24));
|
||||||
|
|
@ -100,25 +99,9 @@ export function categorizePayments(payments) {
|
||||||
const scheduledChecks = [];
|
const scheduledChecks = [];
|
||||||
const scheduledACH = [];
|
const scheduledACH = [];
|
||||||
const outstandingChecks = [];
|
const outstandingChecks = [];
|
||||||
const returnedPayments = [];
|
|
||||||
|
|
||||||
for (const p of payments) {
|
for (const p of payments) {
|
||||||
if (p.method !== "ACH" && p.method !== "Check") continue;
|
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);
|
const dt = parseMDYLocal(p.send_payment_on);
|
||||||
if (!dt) continue;
|
if (!dt) continue;
|
||||||
const status = (p.status || "").toLowerCase();
|
const status = (p.status || "").toLowerCase();
|
||||||
|
|
@ -135,12 +118,6 @@ export function categorizePayments(payments) {
|
||||||
scheduledChecks.sort(byDate);
|
scheduledChecks.sort(byDate);
|
||||||
scheduledACH.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 bucketedOutstanding = ageBuckets.map((bucket) => {
|
||||||
const items = outstandingChecks.filter((p) => {
|
const items = outstandingChecks.filter((p) => {
|
||||||
const age = daysSince(parseMDYLocal(p.send_payment_on));
|
const age = daysSince(parseMDYLocal(p.send_payment_on));
|
||||||
|
|
@ -151,7 +128,7 @@ export function categorizePayments(payments) {
|
||||||
return { ...bucket, items, total };
|
return { ...bucket, items, total };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments };
|
return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main handler ---
|
// --- Main handler ---
|
||||||
|
|
@ -468,59 +445,10 @@ function buildBoABlocks(transactions) {
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHomeView(payments, metadata, boaTransactions = [], expanded = []) {
|
function buildHomeView(payments, metadata, boaTransactions = [], expanded = []) {
|
||||||
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding, returnedPayments } = categorizePayments(payments);
|
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding } = categorizePayments(payments);
|
||||||
const isExpanded = (key) => expanded.includes(key);
|
const isExpanded = (key) => expanded.includes(key);
|
||||||
|
|
||||||
// Always expanded, no toggle: these sat invisible for months once (#70),
|
|
||||||
// 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 buildScheduledBlocks = (title, emoji, items, sectionKey, methodKey) => {
|
||||||
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
|
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
const open = isExpanded(sectionKey);
|
const open = isExpanded(sectionKey);
|
||||||
|
|
@ -647,7 +575,6 @@ export function buildHomeView(payments, metadata, boaTransactions = [], expanded
|
||||||
const totalScheduled = scheduledChecks.length + scheduledACH.length;
|
const totalScheduled = scheduledChecks.length + scheduledACH.length;
|
||||||
const totalScheduledAmt = [...scheduledChecks, ...scheduledACH].reduce((sum, p) => sum + p.amount_usd, 0);
|
const totalScheduledAmt = [...scheduledChecks, ...scheduledACH].reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
const totalOutstandingAmt = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0);
|
const totalOutstandingAmt = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
const totalReturnedAmt = returnedPayments.reduce((sum, p) => sum + p.amount_usd, 0);
|
|
||||||
|
|
||||||
const staleness = formatStaleness(metadata?.last_updated);
|
const staleness = formatStaleness(metadata?.last_updated);
|
||||||
|
|
||||||
|
|
@ -689,18 +616,9 @@ export function buildHomeView(payments, metadata, boaTransactions = [], expanded
|
||||||
type: "mrkdwn",
|
type: "mrkdwn",
|
||||||
text: `:warning: *Outstanding*\n${outstandingChecks.length} checks · ${formatCurrency(totalOutstandingAmt)}`,
|
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" },
|
{ type: "divider" },
|
||||||
...buildReturnedBlocks(),
|
|
||||||
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "scheduled_checks", "checks"),
|
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "scheduled_checks", "checks"),
|
||||||
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "scheduled_ach", "ach"),
|
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "scheduled_ach", "ach"),
|
||||||
...buildOutstandingBlocks(),
|
...buildOutstandingBlocks(),
|
||||||
|
|
|
||||||
|
|
@ -168,58 +168,6 @@ Resources:
|
||||||
BlockPublicPolicy: true
|
BlockPublicPolicy: true
|
||||||
RestrictPublicBuckets: 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:
|
DashboardTable:
|
||||||
Type: AWS::DynamoDB::Table
|
Type: AWS::DynamoDB::Table
|
||||||
Properties:
|
Properties:
|
||||||
|
|
@ -1067,7 +1015,6 @@ Resources:
|
||||||
Variables:
|
Variables:
|
||||||
BOA_BASE_URL: https://api.bofa.com
|
BOA_BASE_URL: https://api.bofa.com
|
||||||
BOA_REPORTING_SECRET_NAME: payments-dashboard/boa-reporting
|
BOA_REPORTING_SECRET_NAME: payments-dashboard/boa-reporting
|
||||||
BOA_RAW_BUCKET: !Ref BoaRawBucket
|
|
||||||
Events:
|
Events:
|
||||||
DailySchedule:
|
DailySchedule:
|
||||||
Type: Schedule
|
Type: Schedule
|
||||||
|
|
@ -1075,28 +1022,9 @@ Resources:
|
||||||
Schedule: cron(0 13 ? * MON-FRI *)
|
Schedule: cron(0 13 ? * MON-FRI *)
|
||||||
Description: Fetch BoA previous day transactions at 9am ET (13:00 UTC)
|
Description: Fetch BoA previous day transactions at 9am ET (13:00 UTC)
|
||||||
Enabled: true
|
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:
|
Policies:
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref DashboardTable
|
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"
|
- Version: "2012-10-17"
|
||||||
Statement:
|
Statement:
|
||||||
- Effect: Allow
|
- Effect: Allow
|
||||||
|
|
|
||||||
|
|
@ -752,9 +752,9 @@ test("classifier: pathological long input is bounded, classified without hanging
|
||||||
|
|
||||||
// ------------------------------------------------------------- date range
|
// ------------------------------------------------------------- date range
|
||||||
|
|
||||||
test("resolveDateRange: default is the trailing 7-day window ending yesterday", () => {
|
test("resolveDateRange: default is the trailing 3-day window (Mon covers Fri-Sun)", () => {
|
||||||
const r = resolveDateRange({}, new Date("2026-07-21T13:00:00Z"));
|
const r = resolveDateRange({}, new Date("2026-07-21T13:00:00Z"));
|
||||||
assert.deepEqual(r, { fromDate: "2026-07-14", toDate: "2026-07-20" });
|
assert.deepEqual(r, { fromDate: "2026-07-18", toDate: "2026-07-20" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveDateRange: explicit valid range passes through", () => {
|
test("resolveDateRange: explicit valid range passes through", () => {
|
||||||
|
|
@ -782,251 +782,3 @@ test("isValidISODate rejects non-strings and bad calendar dates", () => {
|
||||||
assert.equal(isValidISODate("2026-13-01"), false);
|
assert.equal(isValidISODate("2026-13-01"), false);
|
||||||
assert.equal(isValidISODate(20260720), false);
|
assert.equal(isValidISODate(20260720), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Live-API field shapes (real captures, 2026-07-22; account/bank ids
|
|
||||||
// --- not part of transaction rows, amounts/refs/detailText verbatim) ---
|
|
||||||
|
|
||||||
test("live shape: 455 ACH debit classifies via detailText", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "ach_debit");
|
|
||||||
assert.equal(c.pmtId, "7854980");
|
|
||||||
assert.equal(c.embeddedPaymentNumber, "21222000280");
|
|
||||||
assert.equal(c.vendorText, "Alliance Sanitation LLC");
|
|
||||||
assert.equal(c.checkNumber, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("live shape: DES-less 455 is a third-party autopay, ignored not unknown", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: "455",
|
|
||||||
transactionDescription: "Preauthorized ACH Debit",
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "1850",
|
|
||||||
creditDebitIndicator: "Debit",
|
|
||||||
customerReference: "000000000000",
|
|
||||||
detailText: "SOME UTILITY CO DES:AUTOPAY ID:12345 INDN:Sea Haven Industries CO ID:999 WEB",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "ignored");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("live shape: 255 return credit maps to check_return with number from ref", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
asOfDate: "2026-02-20",
|
|
||||||
transactionCode: "255",
|
|
||||||
transactionDescription: "Check Posted and Returned CR",
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "1892.62",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
customerReference: "1122200030",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "check_return");
|
|
||||||
assert.equal(c.checkNumber, "1122200030");
|
|
||||||
assert.equal(c.direction, "credit");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("live shape: 252 debit reversal credit maps to check_return; text agrees", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
asOfDate: "2026-06-16",
|
|
||||||
transactionCode: "252",
|
|
||||||
transactionDescription: "Debit Reversal Credit",
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "6932.76",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
customerReference: "001122200802",
|
|
||||||
detailText: "061626 RETURN OF POSTED CHECK / ITEM (RECEIVED ON 06-16) CHECK #1122200802",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "check_return");
|
|
||||||
assert.equal(c.checkNumber, "1122200802");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("live shape: 266 with electronic-return text stays electronic_return, no fabricated check number", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
asOfDate: "2026-06-16",
|
|
||||||
transactionCode: "266",
|
|
||||||
transactionDescription: "Return Item Credit",
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "1555.13",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
customerReference: "000000000000",
|
|
||||||
detailText: "061626 RETURN OF POSTED CHECK / ITEM (RECEIVED ON 06-16) ELECTRONIC TRANSACTION",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "electronic_return");
|
|
||||||
assert.equal(c.checkNumber, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("266: ACH PMT text wins over the electronic_return fallback", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: "266",
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "4364.95",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
customerReference: "000000000000",
|
|
||||||
detailText:
|
|
||||||
"REDDI SERVICES DES:PAYMENTS ID:PMT 7712345 INDN:Sea Haven Industries CO ID:1811679038 CCD PMT INFO:Reddi Services 21222000211",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "ach_return");
|
|
||||||
assert.equal(c.pmtId, "7712345");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("266: bare row (code label only, no detailText) falls back to electronic_return", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: "266",
|
|
||||||
transactionDescription: "Return Item Credit",
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "360.00",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
customerReference: "000000000000",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "electronic_return");
|
|
||||||
assert.equal(c.checkNumber, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("266: substantive text matching no classifier stays loud as unknown, never a write event", () => {
|
|
||||||
// Security review F1: a write-capable fallback must not fire on a row the
|
|
||||||
// classifiers could not read — amount-only Returned writes were reachable.
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: "266",
|
|
||||||
transactionDescription: "Return Item Credit",
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "360.00",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
customerReference: "000000000000",
|
|
||||||
detailText: "DEPOSITED ITEM RETURNED 12345",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "unknown");
|
|
||||||
assert.equal(c.checkShaped, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("live-format return text with leading MMDDYY token classifies via regex", () => {
|
|
||||||
// Security review F2: the anchors must accept the live detailText prefix.
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: "699",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
amount: "6932.76",
|
|
||||||
detailText: "061626 RETURN OF POSTED CHECK / ITEM (RECEIVED ON 06-16) CHECK #1122200802",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "check_return");
|
|
||||||
assert.equal(c.checkNumber, "1122200802");
|
|
||||||
const e = classifyTransaction({
|
|
||||||
transactionCode: "699",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
amount: "360.00",
|
|
||||||
detailText: "061626 RETURN OF POSTED CHECK / ITEM (RECEIVED ON 06-16) ELECTRONIC TRANSACTION",
|
|
||||||
});
|
|
||||||
assert.equal(e.event, "electronic_return");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unmapped code with all-zeros reference stays unknown, not silently ignored", () => {
|
|
||||||
// Security review F4: the zero-guard must not remove the shape signal.
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: "275",
|
|
||||||
transactionType: "Detail",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
customerReference: "000000000000",
|
|
||||||
amount: "4364.95",
|
|
||||||
detailText: "VENDOR DES:REVERSAL ID:123 CCD",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "unknown");
|
|
||||||
assert.equal(c.checkNumber, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reference fields are length-bounded", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: "475",
|
|
||||||
customerReference: "9".repeat(500),
|
|
||||||
bankReference: "8".repeat(500),
|
|
||||||
amount: "1.00",
|
|
||||||
});
|
|
||||||
assert.ok(c.checkNumber.length <= 64);
|
|
||||||
assert.ok(c.bankReference.length <= 64);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("zero-reference guard: all-zeros customerReference never becomes check number 0", () => {
|
|
||||||
for (const ref of ["0", "0000", "000000000000"]) {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: "255",
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "100",
|
|
||||||
creditDebitIndicator: "Credit",
|
|
||||||
customerReference: ref,
|
|
||||||
});
|
|
||||||
assert.equal(c.checkNumber, null, `ref ${ref}`);
|
|
||||||
assert.equal(c.customerReference, "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("summary rows classify as summary before any text matching", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
asOfDate: "2026-07-22",
|
|
||||||
transactionCode: "470",
|
|
||||||
transactionDescription: "Total Checks Paid Debit",
|
|
||||||
transactionType: "Summary",
|
|
||||||
amount: "4547.62",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "summary");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("noise codes are ignored even when check-shaped", () => {
|
|
||||||
for (const [code, desc] of [
|
|
||||||
["170", "Total Other Check Deposits CR"],
|
|
||||||
["201", "Individual Auto Transfer CR"],
|
|
||||||
["470", "Total Checks Paid Debit"],
|
|
||||||
["481", "Individual Loan Payment Debit"],
|
|
||||||
]) {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: code,
|
|
||||||
transactionDescription: desc,
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "100",
|
|
||||||
customerReference: "197",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "ignored", `code ${code}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("live shape: 475 check paid has no detailText; number rides in customerReference", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
asOfDate: "2026-06-16",
|
|
||||||
transactionCode: "475",
|
|
||||||
transactionDescription: "Check Paid",
|
|
||||||
transactionType: "Detail",
|
|
||||||
amount: "3350.00",
|
|
||||||
creditDebitIndicator: "Debit",
|
|
||||||
customerReference: "0001122200236",
|
|
||||||
bankReference: "813312345",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "check_paid");
|
|
||||||
assert.equal(c.checkNumber, "1122200236");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("legacy statement-text fixtures still classify via the text fallback", () => {
|
|
||||||
const c = classifyTransaction({
|
|
||||||
text: "ARP RETURNED CHECK REFER TO MAKER CHECK # 1122200030 PAID DATE 02/19/26",
|
|
||||||
amount: "1,892.62",
|
|
||||||
transactionCode: "354",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "check_return");
|
|
||||||
assert.equal(c.checkNumber, "1122200030");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("empty-string detailText does not shadow populated text (substantive chain)", () => {
|
|
||||||
// Open SWE review: ?? alone would let detailText:"" hide the statement text.
|
|
||||||
const c = classifyTransaction({
|
|
||||||
transactionCode: "699",
|
|
||||||
detailText: "",
|
|
||||||
text: "ARP RETURNED CHECK REFER TO MAKER CHECK # 1122200030 PAID DATE 02/19/26",
|
|
||||||
amount: "1,892.62",
|
|
||||||
});
|
|
||||||
assert.equal(c.event, "check_return");
|
|
||||||
assert.equal(c.checkNumber, "1122200030");
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
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
538
tests/fixtures/current-day-range.json
vendored
|
|
@ -1,538 +0,0 @@
|
||||||
{
|
|
||||||
"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
449
tests/fixtures/current-day.json
vendored
|
|
@ -1,449 +0,0 @@
|
||||||
{
|
|
||||||
"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
1370
tests/fixtures/previous-day-returns.json
vendored
File diff suppressed because it is too large
Load diff
2130
tests/fixtures/previous-day-week.json
vendored
2130
tests/fixtures/previous-day-week.json
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +0,0 @@
|
||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { postingDate } from "../src/fetchBoaTransactions.js";
|
|
||||||
|
|
||||||
test("postingDate: ISO asOfDate passes; valueDate is the fallback", () => {
|
|
||||||
assert.equal(postingDate({ asOfDate: "2026-07-22" }), "2026-07-22");
|
|
||||||
assert.equal(postingDate({ valueDate: "2026-07-21" }), "2026-07-21");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("postingDate: non-ISO capture formats normalize", () => {
|
|
||||||
assert.equal(postingDate({ asOfDate: "7/21/2026" }), "2026-07-21");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("postingDate: implausible years reject to null (epoch artifacts, far future)", () => {
|
|
||||||
// Security review INJ-002: "12/31/69" would become 2069 via the 2-digit
|
|
||||||
// fallback and permanently noop later real events on that record.
|
|
||||||
assert.equal(postingDate({ asOfDate: "12/31/69" }), null);
|
|
||||||
assert.equal(postingDate({ asOfDate: "9999-12-31" }), null);
|
|
||||||
assert.equal(postingDate({ asOfDate: "1901-01-01" }), null);
|
|
||||||
assert.equal(postingDate({ asOfDate: "garbage" }), null);
|
|
||||||
assert.equal(postingDate({}), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("postingDate: empty-string asOfDate falls through to valueDate", () => {
|
|
||||||
// Open SWE review: ?? alone would return "" and reject the row.
|
|
||||||
assert.equal(postingDate({ asOfDate: "", valueDate: "2026-07-21" }), "2026-07-21");
|
|
||||||
});
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
Loading…
Add table
Reference in a new issue