Compare commits

..

5 commits

Author SHA1 Message Date
Adam Moussa
1c7a19703f
Merge b0f72f2b8c into 77fb8e4ad0 2026-07-21 20:30:32 -04:00
b0f72f2b8c
fix(fetchboa): close review findings — coverage gap, matcher corroboration, replay identity (#66)
Security-review gate (detector fan-out + verifier + GPT-4.1 cross-family)
blocked on F6 and confirmed six lower findings; all are closed here.

- F6 (HIGH): the default run now queries a trailing 3-day window
  (today-3..today-1) so the Monday run covers Friday-Sunday postings the
  previous-day cadence silently skipped. A per-run staleness sweep flags
  never-bank-confirmed payments (ACH > 16 days, checks > 60 days) in the
  summary and via console.error.
- F2: replay identity is {event, date, amount} — bankRef excluded (feeds
  omit/reformat it between runs); a paid event dated on/before the
  latest return in history is a replayed original, never a redeposit;
  rows without a valid valueDate are routed to unmatched instead of
  being applied under a substituted date; within a day, debits sort
  before credits.
- F1: a check-number match with the wrong amount no longer falls
  through to the amount fallback (altered-check/collapsed-posting is a
  human-review case); the amount fallback requires digit-subsequence
  corroboration between posting and candidate numbers; check_return
  fallback requires a bank-confirmed candidate.
- F4: the ach_return amount fallback requires cleared_date within 45
  days before the credit; an unattributable PMT id is an unmatched
  alert, never an amount guess.
- F5: the pmt_id rung requires amount equality; mismatch is the
  partial-reversal human case.
- F3: vendor prefix matching requires >= 10 normalized chars (short
  names must match exactly); candidates with a conflicting stored
  pmt_id are excluded; vendor+amount matches are audit-logged.
- F7: payment writes are conditioned on the snapshot's
  status/clear_status and retried once against a fresh read; residual
  conflicts are counted, not silently interleaved.
- F9/summary bounds: run summary pk is append-only
  (boa_recon#<from>_<to>#<runAt>); unmatched list capped at 50, unknown
  codes at 20 keys/16 chars, stale list at 50 (full counts kept).
- ReDoS: description bounded to 500 chars before classification;
  CHECK/embedded-number regexes use bounded quantifiers.
- FBOA2-1: both BoA res.json() parses are wrapped so a malformed 200
  throws a status-only error and cannot leak a body snippet.

Handler event contract extended (optional fromDate/toDate); cross-family
review required before merge.
2026-07-21 20:30:03 -04:00
a49b91a25f
feat(ach): bank-confirm ACH, drop CSV-date auto-clear (#69)
processPaymentCsv auto-cleared ACH the moment send_payment_on passed,
but the bank disagrees often enough to matter: settlement lags the send
date by up to 8 days, settled ACH can bounce and re-debit (Uline
$19,281.12, Heights $10,000.00 on the 5/26 overdrawn week), and voided
ACH that settled anyway returned via electronic credits invisible to a
Check-only feed. Move ACH to bank confirmation:

- processPaymentCsv: ACH rows keep their Stampli status; the send-date
  auto-clear is removed. The bankConfirmed protection is unchanged, so
  bank-cleared records still cannot be moved backward by the CSV.
- fetchBoaTransactions scans all payments (method filter dropped) and
  processes ACH CCD lines: match by stored pmt_id first (reversals
  reuse the original PMT id), then by the Stampli payment number
  embedded in PMT INFO (internal spaces stripped, amount must agree),
  then vendor + exact amount within send_payment_on -2..+14 days.
  Settled debit -> Cleared/Cleared with dates, pmt_id persisted on the
  record; credit reusing the pmt_id (or a unique same-amount return
  credit against a bank-confirmed ACH) -> clear_status=Returned +
  returned_date + history event. Ambiguity is unmatched, no write.

Existing DDB ACH records already Cleared by the old auto-clear are
unaffected: bank confirmation is additive and the CSV path never moves
a record backward.

Handler event contract extended (optional fromDate/toDate); cross-family
review required before merge.
2026-07-21 20:30:03 -04:00
f3772861d0
feat(fetchboa): v2 return/redeposit-aware reconciliation (#66)
The two-code 475/255 filter missed every return on the Jan-Jul statement
(29 ARP refer-to-maker credits, 24 electronic return credits, and the
redeposit cycles), leaving 5 paid-then-returned checks stuck at Cleared
($5,256.62, vendors unpaid). Rewrite fetchBoaTransactions around a pure
reconciliation module (src/boaRecon.js) covered by node:test:

- BAI transaction-code event map (only 475 = check paid is confirmed;
  remaining codes pend the enumeration replay) with a description-text
  fallback classifier for ARP refer-to-maker, return-of-posted-check
  (check-numbered and electronic) and ACH DES:PAYMENTS formats. Unknown
  check-shaped transactions are logged and counted, never dropped.
- Matching on check number AND amount over all candidates; wrong-amount
  or collapsed-number postings fall back to a unique exact-amount match
  within checks issued in the last 120 days; ambiguity means unmatched
  with no write.
- Transitions always set BOTH status and clear_status (the missed
  returns slipped through the divergence between them). clear_status is
  bank truth: returns apply even to Cleared records, a second paid debit
  on a Returned check is a redeposit back to Cleared, and a return on a
  voided check is terminal voided-and-bounced. Every applied event is
  appended to a history list; identical replayed events are noops.
- Optional {fromDate, toDate} replay payload (strictly validated) for
  gap replays and the BAI-code enumeration runs; default stays
  yesterday.
- Each run writes a boa_recon#<runDate> summary item (90-day TTL) with
  per-event counts, unmatched check numbers/amounts, and unknown codes;
  unmatched/unknown also console.error (Slack alerting is #71).

ACH transactions are classified but not yet acted on; bank-confirming
ACH lands with #69.
2026-07-21 20:30:03 -04:00
Adam Moussa
77fb8e4ad0
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
Some checks are pending
Deploy / deploy (push) Waiting to run
* fix(csv): parse M/D/YY dates with loud rejects; stop canceled rows zeroing stored fields (#65, #68)

Stampli exports switched from MM/DD/YYYY to M/D/YY, which toISODate
mangled into garbage ISO strings and slackAppHome's parseMDYLocal turned
into year-1926 dates. Canceled rows also blank 'Amount in USD' and can
blank dates, which the unconditional upsert wrote over previously stored
real values (116 records at amount_usd=0 as of the 2026-07-21
reconciliation).

- src/dates.js: shared strict parser for M/D/YY + MM/DD/YYYY (real-date
  validation, 2-digit years 2000-based, plausibility window helper)
- processPaymentCsv: canonicalize send_payment_on to MM/DD/YYYY; reject
  rows with unparseable/implausible dates and fail the invocation after
  valid rows are processed (surfaces via errors alarm + async DLQ;
  retry-safe since upserts are idempotent and existing checks are not
  re-offered to BoA); only overwrite amount_usd/send_payment_on with
  real values, falling back to the 'Amount' column on cancels
- BoA add_Issue/cancel_Issue now use stored record values when the CSV
  row is blank (cancel_Issue previously sent amount 0.00 for voids) and
  skip registration on missing date/amount instead of sending garbage;
  same guard on the backfill path
- slackAppHome: use the shared parser (fixes 1926 aging)

* fix(csv): validate BoA registration data before writes; harden logging (security review)

Hardening from the /sh-security-review pass on this branch:

- BoA eligibility (resolvable amount + issue date) is now decided and
  validated BEFORE the DDB upsert: a cancel-transition row we cannot act
  on is rejected with the record untouched, so the transition gate stays
  open for a later clean file instead of silently losing the cancel
  forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
  offered to add_Issue — registering a voided check as an active issue
  created a live issue with no cancel to follow (worsened by the Amount
  fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
  of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
  interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
  forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
  response body (aligns with the PR #63 log-scrub posture)

Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.

* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)

Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-22 00:28:13 +00:00

Diff content is not available