Skip to content

fix(accounting): log designed-transient BankTxConsumer states at verbose instead of error#4293

Merged
Danswar merged 7 commits into
developfrom
fix/ledger-banktx-transient-log-levels
Jul 24, 2026
Merged

fix(accounting): log designed-transient BankTxConsumer states at verbose instead of error#4293
Danswar merged 7 commits into
developfrom
fix/ledger-banktx-transient-log-levels

Conversation

@Danswar

@Danswar Danswar commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Problem

A designed-transient state in the ledger BankTxConsumer was logged at ERROR on every cron cycle although it self-heals:

AML pricing race: a fresh BUY_CRYPTO bank_tx is scanned before doAmlCheck has priced the linked buy_crypto (amountInChf still null). Resolves within minutes once pricing lands.

Change

The guard now throws the typed LedgerGateBlockedException instead of a plain Error, and the forward-loop catch logs a gate-block at verbose (Booking gate-blocked on bank_tx N) while every other throw stays at ERROR. The content-change scan already handles this exception type at verbose since #4262 and needs no change.

The gate covers only the provably transient predicate:

  • BUY_CRYPTO: linked and BuyCrypto.isAmlPricingPending — a new entity getter mirroring doAmlCheck's selection branch exactly (amlCheck null, amlReason null, input fields present, chargebackAllowedDateUser null, not complete), with a reciprocal keep-in-sync comment at the doAmlCheck query. A row with amlCheck == null that is NOT in doAmlCheck's selection (e.g. amlReason set, chargeback allowed, completed) stays unpriced forever and keeps its fail-loud Error.
  • BUY_CRYPTO_RETURN: unchanged from develop — every unpriced state stays a fail-loud Error. An earlier revision gate-blocked the unlinked state as a chargebackFillUp link race, but an admin-classified return can legitimately stay unlinked forever (generic type-set path in updateInternal creates no chargeback), so "unlinked" is not provably transient; review round 2 reverted it.

An unclassified bank_tx (type null) deliberately keeps its ERROR: the content-change scan cannot fresh-book a row the forward watermark already passed (reverseAndRebookIfChanged no-ops when nothing is booked), so that error flags a genuinely lost booking, not noise.

Why retry semantics are untouched

A gate-block still breaks the forward loop and leaves the watermark unchanged. Pricing lands on the buy_crypto table and does not bump bank_tx.updated, so the head-of-line retry in the forward loop is the only path that books the row — the throw+break mechanism is preserved exactly; only the exception type and log level change.

Tests

  • Linked + pricing-pending → verbose gate-block, no ERROR, watermark held; plus a two-run recovery case (priced before run 2 → booked, watermark advances).
  • Each ineligible dimension (amlReason set / chargebackAllowedDateUser set / isComplete / inputAmount null) → ERROR, watermark held (parameterized). Getter truth table pinned on a real BuyCrypto instance (base + all six single-dimension flips).
  • Linked + AML verdict + null CHF → ERROR. Unlinked BUY_CRYPTO → ERROR. Unlinked BUY_CRYPTO_RETURN → ERROR.
  • type null and unmapped types → ERROR retained, skip-and-advance unchanged.

Danswar added 6 commits July 20, 2026 12:38
…ose instead of error

Two designed-transient states in the ledger BankTxConsumer spammed the error
logs on every scan cycle:

- a fresh BUY_CRYPTO / BUY_CRYPTO_RETURN bank_tx scanned before the async
  pricing job has set amountInChf on the linked buy_crypto row — now thrown
  as LedgerGateBlockedException, logged verbose by the forward loop and the
  content-change scan (which already handles it since #4262)
- a bank_tx whose type is still null (not yet classified) hitting the
  default switch branch — now logged verbose; a NON-null unhandled type
  stays at error (genuine enum gap, fail-loud)

Retry and watermark semantics are unchanged: the gate-block still breaks the
forward loop head-of-line (pricing does not bump bank_tx.updated, so that
retry is the only booking path), and the null-type skip still advances the
watermark (classification bumps updated, the content-change scan re-books).

Extends the quiet-retry pattern established in #4262 for the sibling
consumers.
…keep fail-loud defaults

Narrow the previous commit to the states that provably self-heal:

- BUY_CRYPTO: gate-block only while the linked buy_crypto has no amlCheck
  result yet (doAmlCheck prices amlCheck==null rows). A linked row WITH an
  amlCheck result and no amountInChf (FAIL via chargebackFillUp or
  resetAmlCheck) is never re-priced — that stays a fail-loud Error.
- BUY_CRYPTO_RETURN: gate-block only the unlinked link race (the
  chargebackFillUp cron links the return bank_tx). A LINKED chargeback with
  no amountInChf is permanently unpriced — fail-loud Error.
- Revert the type-null default-branch downgrade: reconcileBooking no-ops on
  never-booked rows, so a skipped null-type row is never booked later — the
  gap must stay visible at error level for all unhandled types.

The forward-loop catch keeps logging LedgerGateBlockedException at verbose;
retry and watermark semantics are unchanged throughout. A two-run test pins
the head-of-line-retry recovery the gate-block relies on.
… in spec

- forward-loop catch comment now names both gate-block feeders (AML pricing
  pending, chargeback link pending) instead of only the unpriced linked row
- drop the resetAmlCheck attribution from the fail-loud comments: it nulls
  amlCheck and amountInChf together, so it produces the gate-block state,
  never the fail-loud state
- spec uses CheckStatus.FAIL instead of a string literal
- the exception doc now names both forward-path feeders precisely
  (BUY_CRYPTO linked but not yet AML-priced; BUY_CRYPTO_RETURN not yet
  linked to its chargeback) instead of 'linked row'
- the type-null test comment no longer attributes reconcileBooking to the
  forward loop: the watermark passed the row and the content-change scan's
  reconcileBooking no-ops on never-booked rows
- 'is never re-priced' overgeneralized: a manual FAIL→PENDING re-trigger
  (allowed while the buy_crypto is not complete) re-prices via doAmlCheck's
  PENDING selector — the comments now say 'not re-priced by the cron'
- wrap the forward-catch and exception doc comments to the 120-column
  convention
…pricing claim

The RETURN-side guard comment still carried the categorical 'FAIL rows are
never re-priced' phrase that the previous commit corrected on the BUY_CRYPTO
side. It now names the actual freezing mechanism: the linking cron completes
the row, freezing amlCheck — so a linked chargeback with no CHF is not
re-priced by the cron. The mirrored spec comment is aligned the same way.
@Danswar

Danswar commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Ready after 6 review passes. Fixed along the way: narrowed the gate-block predicates to provably transient states (AML pricing pending / chargeback link pending), reverted an initially proposed type-null log downgrade after establishing the content-change scan cannot re-book forward-skipped rows, plus several comment-accuracy corrections.

@Danswar
Danswar marked this pull request as ready for review July 20, 2026 17:01
@TaprootFreak

Copy link
Copy Markdown
Collaborator

Review

The intent is good — the two genuine happy-race states keep the watermark + retry and other exceptions stay at ERROR, and the code comments show the nuance was considered. CI is green. But I'd tighten the gate before merge, because a wrongly-verbose case hides a stalled accounting forward-stream (fail-closed territory):

[high] bank-tx.consumer.ts:261 and :313 — the verbose gate is wider than "transient race".

  • amlCheck == null doesn't prove a self-healing pricing race: doAmlCheck additionally requires amlReason IS NULL, present input fields, chargebackAllowedDateUser IS NULL, isComplete=false (buy-crypto-preparation.service.ts:112-128) and continues past further cases. A row that is amlCheck == null but not doAmlCheck-eligible stays unpriced forever, holds the forward watermark via break, and now only logs verbose.
  • buyCryptoChargeback == null similarly doesn't prove the link race: the admin path can set BUY_CRYPTO_RETURN via the generic default branch without linking a chargeback (bank-tx.service.ts:353-380), and chargebackFillUp only processes FAIL/incomplete orders with a fully-executed chargebackOutput (:624-648). An orphaned return blocks permanently and logs only verbose.

Suggested: bind the verbose gate to an explicit "pricing-pending" invariant defined together with doAmlCheck (not just == null); keep everything not provably in-progress at ERROR; add negative tests for "amlCheck null but not doAmlCheck-eligible" and "unlinked/manually-classified return".

(These are static derivations — I couldn't run the suite or a real Postgres, so worth confirming against the actual eligibility logic, but the risk is concrete.)

…evert the RETURN-leg downgrade

BUY_CRYPTO: amlCheck == null alone does not prove the pricing race -
doAmlCheck only prices rows that also have amlReason null, input
amount/asset set, no user chargeback date and isComplete false. An
ineligible unpriced row would wedge the forward watermark at verbose.
The gate now uses BuyCrypto.isAmlPricingPending, a mirror of the
doAmlCheck selection, with reciprocal keep-in-sync comments.

BUY_CRYPTO_RETURN: buyCryptoChargeback == null does not prove the
fillUp link race - a bank_tx can be classified BUY_CRYPTO_RETURN
without any chargeback ever linking, and chargebackFillUp only links
FAIL/incomplete orders with an executed chargebackOutput. No cheap
provable invariant exists on the consumer side, so the downgrade is
reverted: the leg fails loud again for both sub-cases.
@Danswar

Danswar commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review addressed in 63ee52e: the gate now uses a new BuyCrypto.isAmlPricingPending getter mirroring doAmlCheck's selection branch exactly (with a reciprocal keep-in-sync note at the query), so only rows the AML cron will still price gate-block; every other unpriced state stays a fail-loud error. The BUY_CRYPTO_RETURN downgrade is reverted entirely — an admin-classified return can stay unlinked forever, so unlinked is not provably transient. Negative tests cover each ineligible dimension plus the unlinked return; the getter truth table is pinned on a real entity instance. PR body updated to the final state.

@TaprootFreak

Copy link
Copy Markdown
Collaborator

Reviewed — LGTM to merge. Only the designed-transient states are downgraded to verbose, genuine errors stay at error, the new ledger-gate-blocked.exception is thrown/caught correctly, and the buy-crypto entity additions are consistent. Tests cover the state distinction.

One nit: the getter/query sync is enforced by comment only — no test would catch them drifting apart. Not a blocker.

@TaprootFreak TaprootFreak left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The nit above is non-blocking.

@Danswar
Danswar merged commit bd8bc85 into develop Jul 24, 2026
7 checks passed
@Danswar
Danswar deleted the fix/ledger-banktx-transient-log-levels branch July 24, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants