# Settlement Pipeline — Bugs Found During E2E Test (2026-09-01)

**Branch:** `test/settlement-summary-e2e`
**Context:** Found while running the held-transaction-visibility E2E test end to end
(seed → recon → adjustments → MIS generate → approve → payout → MPR email), documented
in `SETTLEMENT_E2E_TEST_PLAN.md`. These are new findings from *this* session — separate
from the pre-existing Excel/Details-popup bug tracker in `SETTLEMENT_MIS_BUGS.md`.

None of these were fixed as part of this session except where noted (interchange lookup
was worked around with a data patch, not a code fix).

---

### Bug A — Payout generation silently drops merchants with adjustments-only (no matched sales) 🔴 CRITICAL
- **Symptom:** A merchant whose only activity on a settlement date is an approved
  adjustment (debit or credit) — zero matched transactions — appears correctly in the
  MIS report (its adjustment is folded into the grand `Net Payable` total), but is
  **completely absent** from the generated `PayoutBatch`/`PayoutItem`s. The adjustment's
  amount is counted in what the approver signs off on, but never actually moves in the
  payout.
- **Root cause:** `PayoutGenerator.do_generate/2` determines its entire merchant universe
  from `load_mis_items/1` (`settlement_mis_items` rows only) via
  `Enum.group_by(&1.merchant_mid)` — a merchant with no matched transactions has no
  `settlement_mis_items` row, so `build_payout_item/5` is never called for them.
  `mark_adjustments_applied/1` compounds this: it restricts itself to the same
  `settlement_mis_items`-derived `mids` list, so the orphaned adjustment isn't just
  skipped for payout — it also silently flips to `status="applied"` (looking resolved)
  without ever being linked to a real payout (`settlement_mis_id` stays `NULL` if applied
  via the Adjustment Manager UI's manual "Apply" action instead).
- **Location:** `apps/settlement_core/lib/settlement_core/payout_generator.ex` —
  `do_generate/2` (~L157, L185), `mark_adjustments_applied/1` (~L322-349).
- **Evidence (this session, MIS #326, 2026-08-31):** Approved MIS `Net Payable` =
  **2,326.44** (includes a merchant with zero matched sales and a −120.00 approved debit
  adjustment). Generated `PayoutBatch` id=178/179 `total_amount` = **2,446.44** — the
  −120.00 never applied. Confirmed twice on separate approval runs, same discrepancy both
  times.
  **Traced the full downstream impact too**: since the merchant never got a `PayoutItem`,
  it had nothing for the bank-confirmation upload to match against, so approving the bank
  confirmation batch produced only **2** settlement advice emails, **2** VAT invoices, and
  **2** VAT MIS documents — not 3. The −120.00 adjustment doesn't just fail to reduce the
  payout total; it generates zero paper trail anywhere past MIS approval. The merchant
  MPR email is the *only* place this adjustment is ever visible to anyone (see its
  "NET PAYOUT AMOUNT AED -120.00" banner) — every later stage (payout, bank confirmation,
  settlement advice, VAT invoice, VAT MIS) is silently missing it.
- **Fix direction:** `PayoutGenerator`'s merchant universe needs to be the union of (a)
  merchants with `settlement_mis_items` rows and (b) merchants with an approved
  `MerchantAdjustment` for `mis.mis_date` — not (a) alone. An adjustment-only merchant
  still needs a `PayoutItem` created (zero gross/MDR/VAT, just the adjustment amount) so
  the money is actually accounted for downstream.

---

### Bug B — `interchange_rates` table structure doesn't match any code that reads/writes it 🔴 CRITICAL
- **Symptom:** MIS generation crashes (`** (MyXQL.Error) (1054) Unknown column
  'i0.card_type_id'`) whenever a matched transaction group's summed `interchange_fee` is
  `0`/`NULL`, which triggers the fallback `lookup_interchange_fee/4` lookup against
  `interchange_rates`.
- **Root cause:** Three different, mutually inconsistent versions of "what this table
  looks like" exist in the codebase, and the live table matches none of them:
  - Original migration (`20260305000009_create_interchange_rates.exs`) + Ecto schema
    (`SettlementCore.InterchangeRate`) + the generator's query all agree on:
    `scheme_name`, `rate_percentage`, `fixed_fee`, `card_type_id`, `status:integer`.
  - `mix load_interchange_rates` (the CSV loader task) uses yet a *third* set of attrs
    keys (`interchange_percentage`, `effective_date`, `is_active`) matching neither of
    the above — this task has likely never successfully run against the real schema.
  - **The live table** has `scheme_code` (lowercase values, e.g. `"visa"`),
    `interchange_percentage_rate`, `interchange_fixed_fee`, `status:varchar`, no
    `card_type_id` at all — 3 rows, all labeled "Illustrative..." (placeholder/demo data,
    not real YSP data). Nothing else in the codebase reads or writes it in this shape.
- **Location:** `apps/settlement_core/lib/settlement_core/interchange_rate.ex`,
  `apps/settlement_core/lib/settlement_core/settlement_mis_generator.ex`
  (`lookup_interchange_fee/4`, ~L245-260), `apps/settlement_core/lib/mix/tasks/load_interchange_rates.ex`.
- **Evidence:** confirmed via direct `DESCRIBE interchange_rates` vs. the Ecto schema's
  field list; confirmed the live table has zero other referrers via
  `grep -rn "scheme_code\|interchange_percentage_rate\|program_code"`.
- **Impact:** blocks MIS generation for **any** date where a matched transaction's
  `interchange_fee` wasn't already populated at sync time — not specific to this test.
- **Fix direction:** realign the live table to the original migration/schema (it's the
  one every code path already agrees on, and the live data is just placeholder rows, safe
  to restructure): rename `scheme_code`→`scheme_name` (uppercase the values),
  `interchange_percentage_rate`→`rate_percentage`, `interchange_fixed_fee`→`fixed_fee`,
  add `card_type_id`, convert `status` varchar→integer (1=active), fold/drop
  `program_code`/`minimum_fee`/`maximum_fee`/`description`. Also fix
  `load_interchange_rates`'s attrs keys to match the schema so the loader task actually
  works.
- **This session's workaround (not a fix):** directly populated `interchange_fee` on the
  3 matched test rows so the buggy fallback path was never triggered — done to keep the
  E2E test moving, not a resolution.

---

### Bug E — Risk Hold Review's single-release action uses the wrong ID space ✅ FIXED (2026-09-03)
- **Symptom:** Clicking "Release" on a single row in `/admin/settlements/risk-holds`
  looks up a `CoreTransaction` by an ID that was never a `core_transactions.id` in the
  first place.
- **Root cause:** The page's `load_risk_held/0` sources rows entirely from
  `risk_rule_hits` (`WHERE status == "Hold"`), not from `core_transactions.risk_hold`
  directly. `enrich_with_transaction_details/2` sets the row's `id` field to
  `hit.transaction_id` (~L547) — but per `core_transaction_sync.ex`'s own join logic
  (`rrh.transaction_id = ct.source_ref_id` for QR, `rrh.transaction_id = ps.pos_transaction_id`
  for POS), `risk_rule_hits.transaction_id` is the **original source-table ID**
  (`pos_transaction.id` / the QR transaction's id), not `core_transactions.id`. The
  single-release handler then did `Repo.get!(CoreTransaction, socket.assigns.confirm_target)`
  (~L399) treating that source-table ID as if it were `core_transactions`' primary key —
  two different ID spaces. This would raise `Ecto.NoResultsError` (or, worse, silently act
  on an unrelated row if the IDs happen to coincide) for **real** risk-held transactions
  too, not just synthetic test data.
  Confirmed via the `core_transactions` migration itself: `source_ref_id` (the table's
  only source-linking column) is explicitly commented `"pos_settlements.id OR
  transactions.id — audit ref only"`, and the moduledoc says outright *"never JOIN for
  business logic"* — `core_transactions.id` is a plain independent auto-increment, never
  copied from any source table. For POS this is a **3-hop chain**
  (`pos_transaction.id → pos_settlements.id (= core_transactions.source_ref_id) →
  core_transactions.id`), confirmed live against the dev DB (e.g. `core_transactions.id
  116` has `source_ref_id 4000000000103` — three distinct numbers for one transaction).
  **Bulk release is unaffected** — its handler queries `CoreTransaction` directly by
  `merchant_mid` + `risk_hold == true` (~L406-409), the correct approach.
  **A second, related inconsistency found while fixing this:** the *manual*-hold path
  (`core_transaction_live/index.ex`'s `create_manual_risk_hit/3`) wrote
  `transaction_id: transaction.id` where `transaction` was already a `core_transactions`
  row — i.e. it wrote `core_transactions.id` into the very column the automated evaluator
  path uses for the source-table id. Same column, two disagreeing conventions depending on
  hold origin. This meant manual holds *happened* to "work" under the old buggy release
  handler (by accident — the wrong id there coincidentally matched what the handler
  wrongly expected), while automated holds crashed. Fixing only the release handler
  without also fixing this would have flipped which kind of hold was broken, not fixed
  the underlying bug.
- **Location:** `apps/platform_web/lib/platform_web/live/settlement_live/risk_hold_review.ex`
  — `enrich_with_transaction_details/2` (~L540-559), `"release_single"` handler (~L366),
  `"single"` branch of `do_confirm` (~L399+, now uses the resolver). Also
  `apps/platform_web/lib/platform_web/live/core_transaction_live/index.ex` —
  `create_manual_risk_hit/3` (~L245+, now writes the correct source id).
- **Fix applied:** new `SettlementCore.Context.resolve_core_transaction_for_hit/2` walks
  the real id chain instead of misusing `transaction_id` as a primary key — direct lookup
  by `source_ref_id` for QR, bridged through `pos_settlements` for POS, reusing exactly the
  join `CoreTransactionSync.enrich_risk_hold_for_batch/2` already uses at sync time (not
  reinvented). Returns `{:error, :not_yet_settled}` for a hit whose transaction hasn't
  reached EOD sync yet — a real, non-error state — rather than crashing.
  `do_confirm`'s `"single"` branch now looks up the row from `socket.assigns.transactions`
  (which already carries `transaction_type` from `enrich_with_transaction_details/2`) and
  calls the resolver instead of `Repo.get!(CoreTransaction, confirm_target)`. Failure
  reasons now surface as a clear flash message (`release_error_message/1`) instead of an
  unhandled exception.
  `create_manual_risk_hit/3` now calls a new `resolve_source_transaction_id/1` (mirroring
  the same QR-direct / POS-bridged logic in reverse) so manual holds write the same
  source-table-id convention automated holds already do — the two paths now agree on what
  `risk_rule_hits.transaction_id` means.
- **Not yet verified live** — compiles clean, but no risk-held test data currently exists
  with a correctly-chained `risk_rule_hits` row to click through in the browser. See
  `SETTLEMENT_E2E_TEST_PLAN.md`'s new "Risk-hold rows need a real, chain-resolvable
  `risk_rule_hits` row (Bug E)" section — the next seed round seeds the minimal
  `pos_transaction`/`pos_settlements`/`transactions` stub chain plus a real `risk_rule_hits`
  row for every risk-held test row, specifically so this fix (and the page's visibility gap
  below) can be exercised through the real UI instead of bypassed via
  `RiskReleaseProcessor.release/2` in a script.
- **Separate, related gap (not itself a bug, but caused this session friction) — now
  addressed via seeding, not code:** this page is unusable for any risk hold with no
  corresponding `risk_rule_hits` row — a manually-flagged or test-seeded `risk_hold=true`
  row with nothing in `risk_rule_hits` is completely invisible here, with no fallback query
  against `core_transactions` directly. This session's earlier 4 test rows were released via
  `RiskReleaseProcessor.release/2` directly instead, bypassing the UI entirely. Not
  code-fixed (the page's design — sourcing from `risk_rule_hits` — is intentional); instead
  the test plan now always seeds a matching `risk_rule_hits` row alongside any seeded
  `risk_hold=true` row, which is what real automated/manual holds always have anyway.

---

### Bug C — Bank SFTP transmission is unconfigured and crashes instead of failing cleanly 🟠 MEDIUM
- **Symptom:** `PayoutTransmissionWorker` discards after 3 attempts. Erlang crash:
  `** (ArgumentError) ... unicode.erl:895: :unicode.characters_to_binary({:error, :enotsup})`
  inside `:ssh_auth.password_msg/1`, during SSH password authentication.
- **Root cause:** `config :settlement_core, :bank_sftp` is **never set** anywhere in the
  codebase (`config.exs`/`dev.exs`/`runtime.exs` — grepped, zero hits). `SftpClient.connect/1`
  falls back to its hardcoded default (`host: "localhost", user: "payout", upload_dir:
  "/incoming"` — no `:password`, no `:key_path`). With neither auth option present,
  `auth_opts` ends up `[]`, so `:ssh.connect/4` is called with **no authentication method
  at all**. When the server then demands password auth, Erlang's `:ssh` client falls back
  to interactive-prompt handling (since `user_interaction` was never explicitly disabled
  outside the key-path branch), which — in this headless/daemon context — returns
  `{:error, :enotsup}` instead of a password, and that malformed value gets fed straight
  into the SSH protocol encoder, crashing on `unicode:characters_to_binary/1`.
- **Location:** `apps/settlement_core/lib/settlement_core/bank/sftp_client.ex` — `connect/1`
  (~L105-141), specifically the `auth_opts` `cond` (~L116-128) and missing `:bank_sftp` config.
- **Fix direction:** two parts —
  1. **Configure `:bank_sftp`** wherever it should live per environment (a local test SFTP
     for dev, matching the `:ysp_sftp` pattern already established; real bank credentials
     via env vars for UAT/prod) — this feature appears to have never been wired up to any
     real or test credentials.
  2. **Defensively**, `connect/1` should check that `:password` or `:key_path` is actually
     present and return a clear `{:error, :no_auth_configured}` *before* calling
     `:ssh.connect`, instead of letting the Erlang ssh client crash with a cryptic badarg
     when given zero credentials.

---

### Bug D — `PayoutTransmitter.mark_failed/2` crashes while trying to record a failure, masking the real error 🟡 LOW (but compounds Bug C's debuggability)
- **Symptom:** When transmission fails, the code that's supposed to *record* the failure
  reason on `payout_batches` itself throws: `** (MyXQL.Error) (1406) Data too long for
  column 'failure_reason' at row 1`.
- **Root cause:** `mark_failed/2` does `failure_reason: inspect(reason)` with no length
  cap, writing into a `varchar(255)` column. Erlang SSH error terms (stack traces, nested
  tuples) routinely exceed 255 characters, so the UPDATE itself errors — meaning
  `payout_batches.failure_reason` never actually gets the real error persisted; it's only
  visible by grepping raw application logs.
- **Location:** `apps/settlement_core/lib/settlement_core/bank/payout_transmitter.ex` —
  `mark_failed/2` (~L120-124).
- **Fix direction:** `String.slice(inspect(reason), 0, 250)` before writing, or widen the
  column to `text`.

---

### Bug F — Approved MIS's Details popup is not a stable historical record — it re-queries live hold state ✅ FIXED (2026-09-02)
- **Symptom:** Reopening an *already-approved* MIS days later can show fewer merchant rows
  than it did at approval time, with the header's frozen total no longer reconciling to
  the sum of visible rows — and no indication in the UI that anything changed.
- **Root cause:** `Context.list_held_only_merchants/2` (context.ex ~L1130), which supplies
  the "held-only" merchant rows (merchants with zero matched sales that day, shown so
  they're not silently invisible), queries `core_transactions` **live**:
  `where: ct.risk_hold == true or ct.settlement_status == "exception_hold"`. This is not a
  snapshot of hold state as of MIS generation/approval — it reflects whatever is true at
  the moment the popup is opened. `settlement_mis.total_net_payable` (the header figure),
  by contrast, is computed once at generation time and stored — it does not change.
  So: release a merchant's held transactions after their MIS was approved (an entirely
  separate, later action, e.g. testing the next-day pickup flow), and that merchant's row
  — including any adjustment sub-row riding on it — disappears from the *already-approved*
  MIS's popup, while the header total stays exactly as it was, now unexplained.
- **Location:** `apps/settlement_core/lib/settlement_core/context.ex` —
  `list_held_only_merchants/2` (~L1130-1148). Consumed by
  `apps/platform_web/lib/platform_web/live/settlement_live/mis_approval.ex` (~L300-302).
- **Evidence:** MIS #326 (2026-08-31, `approval_status=approved`). At approval time it
  showed 3 merchant rows (M1, M2, and M3 with a −120.00 "Adjustment (Debit)" row + a
  "Held" row), header `Net Payable: 2,326.44` — matching the visible rows. After M3's 3
  held transactions were released the next day (for the separate next-day-MIS-pickup
  test), reopening the **same, still-approved** MIS #326 now shows only 2 rows (M1, M2),
  summing to 1,815.21 + 631.23 = **2,446.44** — but the header still reads **2,326.44**, a
  120.00 gap with no row to explain it.
- **Fix applied:** a new table, `settlement_mis_hold_snapshots` (migrations
  `20260902000001`/`20260902000002`), captures one frozen row per held transaction —
  merchant, rrn/tid, scheme, card type, gross amount, `hold_type`
  (`risk_hold`/`exception_hold`), transaction datetime — at the moment an MIS is L2
  approved. `SettlementCore.Context.snapshot_mis_holds/1` writes it (called from
  `l2_approve_settlement_mis/3`, idempotent — a MIS that already has a snapshot is left
  untouched). The freeze point is **L2 approval**, not generation — deliberately, so the
  popup still reflects live reality while an MIS is a pending draft (correct, expected
  behavior — only once it's the record of what was actually approved does it need to
  stop changing).
  Two new read functions mirror the live ones exactly:
  `Context.list_held_only_merchants_snapshot/1` (parent rows) and
  `Context.fetch_held_transactions_snapshot/2` (per-merchant child rows on expand).
  `mis_approval.ex`'s `"mis_row_clicked"` and `"expand_merchant"` handlers now branch on
  `mis.approval_status == "approved"`: pending MIS's keep using the original live queries
  unchanged; approved ones read from the snapshot.
- **Not yet verified live** — by the time this was fixed, every held transaction from this
  session's test data had already been released (both the M3 scenario and the
  next-day-pickup test consumed them), so there was nothing left in a
  "held-then-about-to-be-released" state to demonstrate the fix against. Will be exercised
  naturally by the next full seed → generate → approve → release → reopen-popup cycle.
- **Adjustments half — also fixed (2026-09-02):** the same class of drift existed for
  adjustments, not just held rows — every merchant's displayed `net_payable` (via
  `build_parent_row_from_totals`, which folds live credits/debits in) and every
  "Adjustment (Credit/Debit)" child row, for *any* merchant, not just adjustment-only ones,
  all read `list_approved_adjustments_by_date/1` live. An adjustment approved *after* its
  MIS was already approved would newly appear on next popup-open.
  Fixed without a new table — `merchant_adjustments.settlement_mis_id` already exists (used
  later, at payout time, to mark an adjustment `"applied"`); `Context.link_adjustments_to_mis/1`
  now stamps that link **immediately at L2 approval** (called alongside `snapshot_mis_holds/1`
  in `l2_approve_settlement_mis/3`), before payout generation's own linking runs
  asynchronously. `Context.list_adjustments_by_settlement_mis/1` reads by that frozen link
  for an approved MIS; `mis_approval.ex`'s two handlers branch on `approval_status` exactly
  like the held-rows half. Not yet verified live, same reason as above — all our test
  adjustments were already linked/applied by the time this was written; will be exercised by
  the next fresh seed → approve → (create a new adjustment for the same date) → reopen-popup
  cycle.

---

### Bug G — MIS Details popup header summary undercounts merchants/transactions, and held-only rows hide a real adjustment amount ✅ FIXED (2026-09-03)
- **Symptom:** Found while live-retesting Bug F's fix on an MIS with held-only and
  adjustment-only merchants: the popup's summary strip read "Merchants: 2" while the table
  below it showed 3-4 rows. Unlike Bug F, this reproduces on a **pending** MIS too — it has
  nothing to do with live-vs-frozen state.
- **Root cause (two parts):**
  1. The header's `Transactions:`/`Merchants:` figures were read straight off
     `settlement_mis.total_transactions`/`total_merchants` — columns written **once**, at
     generation time, by `SettlementMisGenerator.build_mis_header/4`, counting only
     matched-sales items (`fetch_and_group_transactions/2` filters to
     `settlement_status == "matched", risk_hold == false, is_nil(exception_type)`).
     Held-only and adjustment-only merchants never produce an `item`, so they were never
     counted here — even though the popup's row list (via Bug A/F's fixes) had already
     grown to include them as top-level rows.
  2. While tracing this, found `build_held_only_parent_row/1` hardcoded `net_payable: 0.0`
     for every held-only merchant, regardless of whether that merchant also had an approved
     adjustment that date. `total_net_payable` (the header total) already correctly folds in
     **every** approved adjustment for the date (`fetch_approved_adjustments/1` has no hold
     filter) — so a held-only merchant with an adjustment had their adjustment amount counted
     in the header total but displayed as a flat, unexplained `0.00` row with no way to see it.
- **Location:** `apps/platform_web/lib/platform_web/live/settlement_live/mis_approval.ex` —
  summary strip render (~L194-210), `build_held_only_parent_row/3` (~L603+, was `/1`), new
  `build_mis_summary/6` helper, `"mis_row_clicked"` handler.
- **Evidence:** header merchant count observed live at less than the popup's own row count
  on an MIS with held-only/adjustment-only merchants present (this session). The held-only
  hardcoded-`0.0` finding was by code inspection while tracing the header gap, not
  independently reproduced with a matching live number — worth confirming on the next MIS
  where a held-only merchant also carries an approved same-date adjustment.
- **Fix applied:** the header strip no longer reads the static `total_transactions`/
  `total_merchants` columns for the popup display (those columns are left alone — the main
  MIS **list** grid outside the popup still uses them, and "matched transaction count" is
  arguably still the right meaning there). Instead, `build_mis_summary/6` computes a fresh
  breakdown at popup-open time from the same three row sources already assembled for the
  table (matched / held-only / adjustment-only), plus a fresh full-date held-transactions
  fetch (so a matched merchant with a *partial* hold is counted too). `total_merchants` is
  now the union of all three row categories, so it always equals the number of rows actually
  rendered below it. The strip is laid out as a self-proving walk-down:
  `Total (all txns/gross) − Held − Fees(MDR+VAT) ± Adjustments = Net Payable` — verified
  algebraically to reduce to exactly the existing, unchanged `total_net_payable` formula.
  `build_held_only_parent_row` now takes `(credits, debits)` and shows the real adjustment
  net instead of hardcoded `0.0`.
- **Not yet verified live** — compiles clean, but not exercised through the browser against
  a real MIS with a held-only-merchant-plus-adjustment combination. Worth checking on the
  next full E2E cycle, ideally with a merchant like M3 (some held txns + a debit adjustment).

---

### Bug H — Adjustment "Apply" button never links to a real MIS (hardcoded `mis_id: nil`) 🟡 LOW (covered by an automatic fallback in the common case)
- **Symptom:** Clicking "Apply" on an approved adjustment in the Adjustment Manager UI
  flips its `status` to `"applied"` but never links it to any MIS —
  `settlement_mis_id` stays `NULL`. The button visually looks like it did something,
  but its actual purpose (attaching the adjustment to a specific settlement run) never
  happens.
- **Root cause:** `adjustment_manager.ex:551` calls `Context.apply_adjustment(adj, nil)`
  unconditionally — there is no MIS picker anywhere in the UI, and a code comment at
  that call site literally reads *"mis_id will come from UI selection later"*,
  confirming this was left unfinished. `MerchantAdjustment.apply_changeset/2`
  (`merchant_adjustment.ex` ~L82-89) correctly accepts a `mis_id` argument and would set
  `settlement_mis_id` properly if given a real one — only the LiveView caller is broken.
  The JSON API controller (`merchant_adjustment_controller.ex:206`,
  `PATCH /adjustments/:id/apply`) passes a real `params["settlement_mis_id"]` and works
  correctly, but nothing in the UI calls that endpoint.
- **Why this mostly doesn't bite:** in the common case — an adjustment approved
  *before* its date's MIS/payout has been generated — this doesn't matter at all.
  `PayoutGenerator.mark_adjustments_applied/2` (`payout_generator.ex` ~L430-457)
  automatically sets `status="applied"` (with the correct MIS link) for every approved
  adjustment during payout generation, with zero user action needed. Confirmed while
  verifying whether this session's 3 approved E2E test adjustments needed a manual
  "Apply" before MIS generation — they don't; `SettlementMisGenerator.fetch_approved_adjustments/1`
  (`settlement_mis_generator.ex:277-286`) includes any adjustment with
  `approval_status == "approved"`, `status` is irrelevant at generation time.
- **Where it actually matters:** `SettlementMisGenerator.generate/1` and
  `PayoutGenerator.generate_for_mis/1` are both idempotent per date (short-circuit and
  return the existing record if already generated — `settlement_mis_generator.ex:59-63`,
  `payout_generator.ex:53-63`). So an adjustment that gets L1+L2 approved *after* its
  date's MIS/payout has already been generated is never revisited by the automatic
  flow — it's a genuine orphan. The UI's Apply button is the only in-app path meant to
  retroactively attach such a late adjustment to an already-generated MIS, and as
  implemented it cannot do that (`mis_id` always `nil`).
- **Location:** `apps/platform_web/lib/platform_web/live/settlement_live/adjustment_manager.ex`
  (~L551, the apply-adjustment event handler). Correct reference implementation for
  comparison: `apps/platform_web/lib/platform_web/controllers/merchant_adjustment_controller.ex`
  (~L206). Also `apps/settlement_core/lib/settlement_core/merchant_adjustment.ex` —
  `apply_changeset/2` (~L82-89); `apps/settlement_core/lib/settlement_core/payout_generator.ex`
  — `mark_adjustments_applied/2` (~L430-457, the automatic path that covers the common
  case).
- **Fix direction:** add a real MIS picker to the Apply UI (e.g. a dropdown of MIS rows
  matching the adjustment's `adjustment_date`, or auto-resolve the single MIS for that
  date when exactly one exists) and pass its id into `Context.apply_adjustment/2`
  instead of hardcoding `nil` — mirroring what the JSON API controller already does
  correctly.
- **Not yet fixed.**

---

### Bug I — MIS Excel export silently drops merchants whose only activity is an approved adjustment ✅ FIXED (2026-09-03)
- **Symptom:** A merchant with zero matched transactions and zero held transactions for
  the date — an approved adjustment is its *only* activity — is completely absent from
  the downloaded MIS Excel. Not zeroed, not flagged: the merchant and its adjustment
  amount simply never appear anywhere in the workbook.
- **Root cause:** `settlement_mis_controller.ex`'s `download_xlsx/2` (~L162) builds its
  row universe via `fetch_mis_transactions/1` (~L184-216) purely from `CoreTransaction`
  (matched rows) plus `SettlementMisGenerator.fetch_held_transactions/2` (held rows) —
  it never consults `settlement_mis_items`, and unlike the MIS Details popup
  (`mis_approval.ex`'s `"mis_row_clicked"` handler, fixed this session as Bug G with a
  genuine third `adjustment_only_rows` category via `build_adjustment_only_parent_row/4`)
  there is **no adjustment-only fallback here at all**. `build_xlsx/3` (~L353-368) groups
  rows by `merchant_mid` (`Enum.group_by(transactions, & &1.merchant_mid)`, ~L357) and
  only attaches that merchant's adjustment to `idx == 0` — the comment at ~L363 says it
  outright: *"Only apply adjustments to the first settled transaction for this
  merchant."* A merchant with no entry in `transactions` never produces a group key, so
  `flat_map` never visits it, regardless of having a real, approved row waiting in
  `adjustments_map` (built independently in `build_adjustments_map/1`, ~L280-351, keyed
  purely on `adjustment_date` + `approval_status == "approved"` — it doesn't care
  whether the merchant has any `CoreTransaction` rows).
- **Evidence (this session):** merchant `301510300000000` — zero `core_transactions`
  rows for `2026-09-02`, one approved `+100.00` credit adjustment — correctly appears as
  its own row in the MIS Details popup (Bug G's `adjustment_only_rows` fix, confirmed
  live) but is entirely missing from the Excel download for the same MIS/date.
- **Location:** `apps/platform_web/lib/platform_web/controllers/settlement_mis_controller.ex`
  — `download_xlsx/2` (~L162), `fetch_mis_transactions/1` (~L184-216),
  `build_adjustments_map/1` (~L280-351), `build_xlsx/3` (~L353-368, the `idx == 0`
  first-row-only attachment plus the `Enum.group_by` iteration that skips zero-row
  merchants entirely).
- **Fix direction:** mirror `mis_approval.ex`'s Bug G fix — after building the matched +
  held row groups, compute `covered_mids` (merchants already present via matched or held
  rows) and add a genuine adjustment-only pass: for every `merchant_mid` in
  `adjustments_map` not in `covered_mids`, emit one synthetic row (zero gross/MDR/VAT,
  just the adjustment) so the merchant and its amount are actually visible in the
  workbook instead of silently vanishing. Same underlying gap as Bug A (payout
  generation) and the pre-fix state of Bug G (popup) — this is the third of three
  independent places that needed the same "adjustment-only merchant" universe fix, and
  is the only one of the three not yet addressed.
- **Fix applied:** `build_xlsx/3` (`settlement_mis_controller.ex`) now computes
  `covered_mids` from `transactions` and `adjustment_only_mids =
  Map.keys(adjustments_map) -- covered_mids`, fetches `TidMaster` rows for those mids,
  and appends one synthetic row per adjustment-only merchant (a bare `%CoreTransaction{}`
  with just `merchant_mid`, `settlement_status: "adjustment_only"`, and
  `net_settlement_amount` set to `credit_adj - debit_adj`) through the same
  `SettlementMisRowBuilder.build_71_field_row/3` path, so it gets a real, correctly
  labeled row instead of vanishing.
- **Not yet verified live** — compiles clean; needs a re-download of the MIS Excel to
  confirm the merchant now appears.
- **Refined after first live check (2026-09-03):** the first pass set the synthetic
  row's `net_settlement_amount` to the net adjustment (`credit_adj - debit_adj`), which
  surfaced a separate, real inconsistency — matched rows never fold their adjustment
  into "Total Net Payable Amount"/"Gross Payable to Merchant" either (those columns are
  purely transaction-level; adjustments live only in the Debit/Credit Adjustment
  columns). Fixed for consistency: the synthetic row's `net_settlement_amount` is now
  `0.00`, and `get_is_settled/1` explicitly returns `"Y"` for `settlement_status ==
  "adjustment_only"` (an approved adjustment is headed for the merchant's account same
  as a matched transaction — "N" would misleadingly suggest otherwise). Consequence,
  not a bug: summing "Total Net Payable Amount" alone will never equal the MIS's true
  total — the Debit/Credit Adjustment columns must be summed in too, same as the
  popup's own header walk-down needs its adjustment line to reconcile.

---

### Bug J — MIS Excel export never zeros held rows' money columns (Total Net Payable, Gross Payable) ✅ FIXED (2026-09-03)
- **Symptom:** A held transaction (Risk Status column correctly shows `RISK_HOLD`) still
  carries its real, non-zero dollar amount in the "Total Net Payable Amount" column of
  the downloaded MIS Excel — and in "Gross Payable to Merchant" too. Held rows are
  visible (correct), but not neutralized (wrong) — the whole point of the
  held-visibility feature is informational-only rows that contribute nothing to any
  money total, and this column defeats that for anyone reading the sheet directly.
- **Root cause:** `PlatformWeb.Helpers.SettlementMisRowBuilder.build_71_field_row/3`
  (`apps/platform_web/lib/platform_web/helpers/settlement_mis_row_builder.ex:92`) sets
  the "Total Net Payable Amount" field (#41) to
  `format_decimal(core_txn.net_settlement_amount)` — pulled straight off the
  `CoreTransaction` struct, no conditional at all. "Gross Payable to Merchant" (field
  #27, ~L72) has the identical unconditional expression. The row builder has exactly
  two hold-aware functions — `get_risk_status/1` (~L192-199) and
  `get_reason_for_hold/1` (~L201-203) — and both are display-only text columns. No
  money column (Gross, MDR, VAT, Total Transaction Fees, Net Income, Total Net Payable)
  has any zeroing logic keyed on `risk_hold`/`chargeback_hold`/`exception_hold` — every
  dollar figure is computed unconditionally from the transaction's real stored amounts.
- **Contrast with the popup (correct behavior):** `MisApproval.build_held_summary_row/3`
  (`apps/platform_web/lib/platform_web/live/settlement_live/mis_approval.ex:812-825`)
  explicitly hardcodes a held row's money fields to zero (`mdr_amount: 0.0,
  vat_amount: 0.0, interchange_fee: 0.0, net_payable: 0.0`), with a comment right above
  it (~L808-811) stating held transactions "never contribute to net_payable... not a
  real settleable amount." The Excel builder was never given the equivalent treatment.
- **Evidence (this session):** live screenshot of the downloaded MIS Excel for
  `2026-09-02` — `RISK_HOLD` rows (e.g. `E2ED001`, `E2ED007`, `E2ED017`) show their full
  original transaction amount under Total Net Payable Amount instead of `0.00`.
- **Open question, not yet checked:** if the Excel has any grand-total/summary row that
  sums the "Total Net Payable Amount" column down the sheet, held-row amounts would leak
  straight into that total too — worth confirming once this is fixed, since it would
  mean the sheet's own footer total currently disagrees with `settlement_mis.total_net_payable`.
- **Location:** `apps/platform_web/lib/platform_web/helpers/settlement_mis_row_builder.ex`
  — `build_71_field_row/3` (~L72 Gross Payable, ~L92 Total Net Payable Amount,
  ~L192-199 `get_risk_status/1`, ~L201-203 `get_reason_for_hold/1`). Correct reference
  implementation: `apps/platform_web/lib/platform_web/live/settlement_live/mis_approval.ex`
  — `build_held_summary_row/3` (~L808-825).
- **Fix direction:** in `build_71_field_row/3`, branch the money columns (at minimum
  Gross Payable and Total Net Payable Amount, arguably MDR/VAT/interchange too for
  consistency with the popup) on the transaction's hold status — zero them out exactly
  like `build_held_summary_row/3` already does — while leaving `get_risk_status/1`'s
  informational text untouched so the row stays visible and correctly labeled.
- **Fix applied:** `build_71_field_row/3` now computes `held?` (risk_hold,
  chargeback_hold, or `settlement_status == "exception_hold"`) up front, and zeros
  `mdr_amount`, `vat_amount`, `interchange_fee`, and `net_settlement_amount` for held
  rows before any derived math (`total_mdr_fees`, `revenue`, `net_income`) or field
  emission — every downstream use of those four values (fields 22/24/25/26/27/41/54/53/55)
  now reads the zeroed local variable instead of the raw `core_txn` field.
  `gross_amount` (field 19) is deliberately left untouched, matching the popup's
  behavior of keeping the original transaction amount visible as informational context.
- **Not yet verified live** — compiles clean; needs a re-download of the MIS Excel to
  confirm held rows now show `0.00` in Total Net Payable/Gross Payable.

---

### Bug K — MIS Excel export never used the frozen-at-approval snapshot (Bug F's fix applied to the popup only, not the Excel) ✅ FIXED (2026-09-03)
- **Symptom:** Downloading the MIS Excel for an already-approved MIS could show
  different data on a later re-download than what was true at approval time —
  the exact symptom Bug F fixed for the popup, but the popup's fix was never
  extended to the Excel export. Concretely: a held transaction released *after*
  its MIS was approved would silently vanish from a re-downloaded Excel (or
  worse — since the export re-derives everything live, it could start showing
  that transaction's *real* money figures instead of the zeroed/held treatment,
  risking silent double-counting against whatever later MIS picks it up via
  `released_for_settlement_date`). Adjustments had the same gap: approved
  *after* this MIS was already approved would newly appear on a later
  re-download, since the export always re-derived "approved adjustments for
  this date" live rather than reading the frozen `settlement_mis_id` link.
- **Root cause:** `settlement_mis_controller.ex`'s `download_xlsx/2` /
  `fetch_mis_transactions/1` / `build_adjustments_map/1` never checked
  `mis.approval_status` at all — every row (matched, held, adjustment) was
  always derived from live queries, unlike `mis_approval.ex`'s popup handlers,
  which branch on `approval_status == "approved"` and read from
  `settlement_mis_hold_snapshots` / the frozen adjustment link instead.
- **Scope decision:** matched-row data (from a live `CoreTransaction` query,
  not `settlement_mis_items`, which only holds scheme/type rollups, not
  per-transaction rows) was deliberately left live — matched transactions
  rarely mutate after matching, and fully snapshotting them would need a new
  table/migration. Held rows and adjustments were the two concretely
  reproducible drift paths (both already had frozen infrastructure built for
  the popup by Bug F), so those are what got fixed here.
- **Evidence:** found while discussing this exact scenario — "download today,
  check everything, approve it, download again tomorrow and it shows something
  else, that's not right" — before any drift had actually been reproduced live.
- **Location:** `apps/platform_web/lib/platform_web/controllers/settlement_mis_controller.ex`
  — `fetch_mis_transactions/1`, `build_adjustments_map/1`, `build_xlsx/4`.
  `apps/platform_web/lib/platform_web/helpers/settlement_mis_row_builder.ex` —
  `build_71_field_row/4`, `get_risk_status/2`.
- **Fix applied:** for an approved MIS, held rows are now sourced via
  `Context.fetch_held_transactions_snapshot/2` — a frozen "guest list" of which
  `core_transaction_id`s were held at approval time, each paired with its
  original `hold_type`. Those ids are then live-fetched from `CoreTransaction`
  for full 71-column richness (the snapshot itself only carries a handful of
  display fields, not enough for the whole export), but `build_71_field_row/4`
  now accepts `opts` — `force_held?: true` forces the money-zeroing treatment
  (mirroring Bug J's fix) regardless of the transaction's *current* live hold
  state, and `force_held_status:` (the snapshot's `hold_type`, e.g.
  `"RISK_HOLD"`) forces `get_risk_status/2`'s label so a force-zeroed row never
  shows a self-contradicting `"CLEAR"`. Adjustments now read
  `Context.list_adjustments_by_settlement_mis/1` (the frozen `settlement_mis_id`
  link) instead of re-deriving "approved for this date" live, once approved.
  Matched rows are unchanged (still live, per the scope decision above).
- **Not yet verified live** — compiles clean; needs an approve → release-a-hold
  → re-download cycle to confirm a released transaction still shows as held
  and zeroed in the already-approved MIS's Excel.

---

### Bug L — MPR dispatch enqueued twice at L2 approval; the fast, premature one always wins the race and permanently blocks the correctly-sequenced one ✅ FIXED (2026-09-03)
- **Symptom:** A merchant whose only activity on a settlement date is an approved
  adjustment (no matched sales, no held transactions — e.g. mid `301510300000000`
  in this session's data) never receives its MPR email, even after Bug A's fix
  correctly gave it a real `PayoutItem`. Reproduced twice in a row against MIS
  330, including after a full clean reset and regeneration — not a stale-data
  fluke, a live, deterministic bug.
- **Root cause:** `Context.l2_approve_settlement_mis/3` (`context.ex` ~L1100-1101,
  before this fix) enqueued **two** `MprDispatchWorker` jobs for the same MIS at
  approval time:
  1. Directly, immediately, in parallel with payout generation.
  2. Indirectly, via `PayoutTransmissionWorker.enqueue/1` → that worker's own
     `enqueue_mpr_if_needed/1` (`payout_transmission_worker.ex` ~L48/87-111) —
     correctly sequenced *after* `PayoutGenerator.generate_for_mis/1` creates the
     payout/recovery `PayoutItem`s, and deliberately *before* the actual bank
     transmission step (~L49), so it was never meant to depend on transmission
     succeeding.
  `MprDispatchWorker` (light — just DB reads + emails) always finished long
  before `PayoutTransmissionWorker` (heavy — payout generation + bank I/O) even
  started; confirmed via `oban_jobs` timestamps: the MPR job completed a full 32
  seconds before the payout job's first attempt began. At the moment the
  premature dispatch ran, `301510300000000` had no `PayoutItem` yet (payout
  generation hadn't happened), so `MprDispatchWorker`'s own adjustment-only
  fallback (`by_mid` built from `batch_ref_by_mid`, itself sourced from existing
  `PayoutItem`s — see `mpr_dispatch_worker.ex` ~L90-99) found nothing for it and
  skipped it. `MprDispatchWorker`'s Oban `unique` constraint
  (`period: 82_800, fields: [:args, :worker]`) then silently dropped the later,
  correctly-timed enqueue from `payout_transmission_worker.ex:96` as a duplicate
  — so the well-designed sequenced trigger could never actually fire a fresh
  dispatch, on any approval, ever.
  **Not related to Bug C** (bank SFTP unconfigured) despite both symptoms
  showing up around the same log lines — MPR dispatch was already correctly
  positioned *before* the bank-transmission step in `PayoutTransmissionWorker`,
  so a broken bank connection was never the actual blocker here.
- **Location:** `apps/settlement_core/lib/settlement_core/context.ex` —
  `l2_approve_settlement_mis/3` (~L1084-1107). Correct trigger (unchanged):
  `apps/settlement_core/lib/settlement_core/workers/payout_transmission_worker.ex`
  — `enqueue_mpr_if_needed/1` (~L87-111).
- **Fix applied:** removed the direct `MprDispatchWorker.enqueue/1` call from
  `l2_approve_settlement_mis/3`. MPR dispatch now has exactly one trigger —
  `PayoutTransmissionWorker`'s own post-generation, pre-transmission enqueue —
  guaranteeing payout records always exist before any MPR email goes out, with
  zero dependency on bank-transmission success.
- **Not yet verified live** — compiles clean; needs a full approve → payout
  generate → MPR dispatch cycle to confirm all 5 merchants (including the
  adjustment-only one) now receive their MPR in one pass.

---

### Bug M — Corrupted cached ACS key never gets invalidated, so every subsequent email send keeps failing until the server restarts ✅ FIXED (2026-09-04)
- **Symptom:** `VatMisDistributionWorker` jobs exhausted all Oban retries and got
  permanently `discarded` with `{:acs_key_unavailable, {:missing_config, :tenant_id}}`,
  while `SettlementAdviceWorker`/`VatInvoiceWorker` mostly succeeded — even though all
  three share the exact same credential-fetching code
  (`SettlementNotifier` → `Swoosh.Adapters.AzureCommunicationServices` →
  `DaProductApp.KeyVault`, one shared GenServer/cache for the whole app).
- **Root cause:** `AzureCommunicationServices.send_with_key/10`
  (`apps/da_product_app/lib/swoosh/adapters/azure_communication_services.ex`) has two
  failure branches for a bad cached ACS key. The 401/403 branch ("ACS rejected the
  cached key as unauthorized") correctly calls `KeyVault.invalidate_acs_key/1` and
  retries once with a fresh key. The `Base.decode64(acs_key)` failure branch
  (cached key isn't valid base64) did **not** — it just logged
  `:invalid_acs_key_format` and returned, leaving the corrupted value sitting in the
  shared cache. Every subsequent call from *any* worker that reused that same poisoned
  cache kept hitting the identical decode failure, with no self-healing, until the
  whole server restarted and wiped the GenServer's in-memory state.
- **Why VAT MIS took the brunt of it:** all three workers share one cache, so this
  isn't specific to VAT MIS's code — it's purely a timing accident.
  `SettlementAdviceWorker`/`VatInvoiceWorker` run in the `:mailers` queue and fire
  almost immediately after enqueue; `VatMisDistributionWorker` runs in a separate
  `:vat_mis` queue and consistently completes later in the sequence (confirmed in
  logs). Whichever calls happened to land *after* the cache got poisoned kept failing
  — disproportionately the later-running VAT MIS jobs.
- **Separate, smaller finding along the way — not itself a bug:** confirmed
  `AZURE_TENANT_ID`/`CLIENT_ID`/`CLIENT_SECRET` are correctly present in `.env`, and
  are loaded once at server boot via `config/runtime.exs` — a Phoenix hot-reload never
  re-reads this file, so a long-running dev server that booted before `.env` had
  correct values would keep a stale/missing `tenant_id` baked in for its whole
  lifetime regardless of the file's current contents. Worth a full restart (not just
  a hot reload) if this class of error recurs after this fix.
- **Location:** `apps/da_product_app/lib/swoosh/adapters/azure_communication_services.ex`
  — `send_with_key/10`, the `Base.decode64/1` `:error` branch (was ~L139-141).
- **Fix applied, then refined after live retesting exposed a limitation:** the first
  version mirrored the 401/403 branch's single-retry pattern exactly. Live retesting
  showed a single retry isn't enough — Key Vault/ACS was observed serving 2-3 bad
  values in a row during a bad stretch, not just one, so a single retry can still land
  on another bad value and leave the *next* caller to inherit that poisoned cache with
  only its own one-shot retry to escape it. Refined to a bounded retry loop
  (`@max_key_attempts 3`, `@key_retry_backoff_ms 250` — both the 401/403 and decode64
  branches now share one `retry_with_fresh_key/9` helper) instead of a single retry.
  Also added `key_fingerprint/1` (a non-reversible `byte_size + SHA-256 prefix` of the
  key, logged on every attempt) and a matching fingerprint in
  `DaProductApp.KeyVault`'s existing secret diagnostics, so a recurrence can be
  diagnosed precisely — same fingerprint recurring means a poisoned cache still isn't
  clearing; different fingerprints each time means Key Vault itself is intermittently
  unreliable, which no amount of client-side retry logic can fully paper over.
  Confirmed via live testing that concurrency is *not* the trigger (a concurrency=1
  diagnostic run made VAT Invoice fail 4/4, worse than concurrent runs) — ruled out,
  not pursued further as a fix direction.
- **Not yet verified live** — compiles clean; needs a fresh bank-confirmation
  upload/approve cycle to confirm all three report types now arrive reliably for every
  merchant in one pass.

---

## Status (updated 2026-09-03)

**Fixed this session (code-complete, compiled clean):** Bug A, Bug F, Bug G, Bug E, Bug I, Bug J, Bug K, Bug L, Bug M.
  - Bug M: not yet exercised live — needs a fresh bank-confirmation upload/approve
    cycle to confirm all three report types arrive for every merchant in one pass.
  - Bug A: verified live against MIS #326.
  - Bug F: not yet exercised live (no held-transaction-about-to-be-released test data left
    by the time the fix landed).
  - Bug G: not yet exercised live (found and fixed after Bug F's fix, on top of it).
  - Bug E: not yet exercised live (no correctly-chained `risk_rule_hits` test data exists
    yet — see the test plan update below).
  - Bug I: not yet exercised live — found and fixed live against MIS #330's Excel export
    (merchant `301510300000000`, adjustment-only); needs a re-download to confirm.
  - Bug J: not yet exercised live — found and fixed live against MIS #330's Excel export
    (held rows `E2ED001`/`007`/`017`); needs a re-download to confirm.
  - Bug K: not yet exercised live — needs an approve → release-a-hold → re-download
    cycle to confirm a released transaction still renders as held/zeroed instead of
    drifting.
  - Bug L: not yet exercised live — needs a full approve → payout → MPR cycle to
    confirm the adjustment-only merchant now receives its MPR alongside the other 4.
**Deferred to later:** Bug B, Bug D, Bug C, Bug H.

**Next step:** a fresh seed → recon → adjustments → MIS generate → approve → release-a-hold
(via the real Risk Hold Review UI, per the updated test plan) → reopen-popup cycle to
live-verify Bug E, Bug F, and Bug G together in one pass:
- Seed each RISK_HOLD row with its minimal source chain (`pos_transaction`+`pos_settlements`
  or `transactions`) and a matching `risk_rule_hits` row (`SETTLEMENT_E2E_TEST_PLAN.md`'s new
  "Risk-hold rows need a real, chain-resolvable `risk_rule_hits` row (Bug E)" section) —
  otherwise the row won't even appear on `/admin/settlements/risk-holds` to click Release on.
- Use a merchant that has both held transactions and an approved same-date adjustment
  (e.g. the M3 pattern from Bug F's original evidence) so releasing it through the real UI
  exercises the frozen-snapshot reads, the adjustment link, the header walk-down math, and
  the held-only-row net_payable fix all at once.

## Priority order (my suggestion)
1. **Bug F** ✅ fixed (approved MIS popup re-queries live hold state) — broke the audit
   trail on every approved MIS that had any held-only or adjustment-only merchant, the
   moment any of its holds got released later.
2. **Bug A** ✅ fixed (adjustment-only merchants dropped from payout) — was a silent money
   discrepancy between approved total and actual payout, no error raised.
3. **Bug G** ✅ fixed (popup header undercounts merchants/transactions; held-only rows hid
   a real adjustment amount) — found while retesting Bug F; same "money not visible where
   it should be" class of issue as Bug A, scoped to the popup's summary display.
4. **Bug E** ✅ fixed (Risk Hold Review single-release wrong ID space, plus the manual-hold
   write path's disagreeing id convention found while fixing it) — a finance user clicking
   "Release" on a real, automated risk hold would have hit this crash at any time.
5. **Bug B** (interchange_rates) — blocks MIS generation entirely for real transaction
   data, highest blast radius. *(deferred)*
6. **Bug D** (failure_reason too small) — quick fix, currently hiding Bug C's real error
   from anyone who isn't grepping raw logs. *(deferred)*
7. **Bug C** (bank SFTP unconfigured) — likely already known/expected as incomplete
   (no real bank credentials exist yet), but the defensive check is cheap and prevents a
   confusing crash for the next person who hits it. *(deferred)*
8. **Bug I** ✅ fixed (Excel export dropped adjustment-only merchants entirely) — same
   class as Bug A/Bug G, but for the Excel path specifically: real approved money was
   completely invisible in the downloaded workbook, not just miscounted.
9. **Bug J** ✅ fixed (Excel export never zeroed held rows' money columns) — a held
   transaction's real amount was leaking straight into Total Net Payable/Gross Payable
   in the sheet, defeating the entire held-visibility premise for anyone reading the
   Excel directly. Found in the same live testing pass as Bug I.
10. **Bug K** ✅ fixed (Excel export never used the frozen-at-approval snapshot) — same
    audit-trail-integrity class as Bug F, but for the Excel path: an approved MIS's
    export could silently drift (or worse, double-count) once a held transaction was
    later released. Found while discussing the exact "download, approve, download
    again tomorrow" scenario, before it had actually been reproduced live.
11. **Bug H** (Apply button never links a real MIS) — narrow blast radius, only bites
    for adjustments approved after their date's MIS/payout is already generated; the
    common case is covered automatically by `mark_adjustments_applied/2`. *(deferred)*
