# Branch context: `fix/tsd-v16-compliance-26oct`

Complete record of the work done on this branch: what changed, why, where, how it is tested, and the rules the
work followed. Written so that an engineer (or an agent) picking the branch up cold can continue without
re-deriving anything.

- **Base branch for PRs:** `tsd-doc-changes`
- **Spec:** NPCI UPI International Technical Specification Document (TSD) **v16**. Local copy: [docs/guides/UPI-Specififcation-Document.md](guides/UPI-Specififcation-Document.md)
- **Delivery format:** a series of numbered patches in [tsd-patches/](../tsd-patches/) (01 to 09), each applied to the index and committed separately
- **Status of this document:** reflects the working tree after patch 09 (all nine patches present)

---

## 1. Goal

Make every message the PSP sends to NPCI conform to the TSD v16 (precertification findings), and stop the
platform from sending **invented values**. The recurring defect class that the NPCI precert reports found:

| Defect class | Example (before) | Rule now |
|---|---|---|
| Made-up defaults | `5411` MCC, `MERC0000001` IFSC, `CURRENT`, `UNKNOWN`, `Mercury PSP` | Echo the request / QR / stored record, or send nothing |
| Generated values where NPCI expects an echo | generated `custRef`, `refId = REF<ms>`, `TXN...` ids | Echo exactly what NPCI sent |
| Wrong time format | UTC with microseconds and `Z` | IST, whole seconds, `+05:30` |
| Wrong structure | `seqNum` in RespChkTxn, `<Merchant>/<Institution>` in RespPay, `<Fx>` inside `<Merchant>` | Only what the TSD table lists |
| Wrong identity | request's `orgId="NPCI"` echoed; `MSW102` orgId | PSP orgId (dynamic `MER101`, static `MER102`) |

**Core principle:** a response attribute is either (a) an echo of the request, (b) read from the QR string, (c) read
from a stored record (ReqPay / merchant), or (d) omitted. It is never invented.

---

## 2. Working rules used for every patch (do not break these)

1. **Never delete code.** Replace by commenting the old lines out with an `# OLD:` prefix next to a tagged
   explanation comment. Whole removed functions are kept commented out under a `REMOVED:` tag.
2. **Tag every change** with `# [<Message> <Field>] ADDED|CHANGED|REMOVED: <reason>`, e.g.
   `# [RespValQr Payee.Ac] CHANGED: ...`. This makes every change greppable:
   `grep -rn "\[RespValQr" apps/`.
3. **No git commands run by the assistant.** Patches are produced by diffing a snapshot of the file against the
   edited file (`diff -u`, `a/` `b/` labels). The user runs `git apply --cached <patch>` and `git commit`.
4. **Each patch is based on the previous ones.** Patch N applies on top of 01 to N-1; apply in order.
5. **Run the test suite per patch**; report pass/fail per app and the warning count.
6. Keep the new code in the style of the surrounding code (comment density, naming).

---

## 3. Patch series

| # | File | Scope | Files touched |
|---|---|---|---|
| 01 | `01-RespValQr.patch` | RespValQr (QR validation response) | 14 |
| 02 | `02-RespPay.patch` | RespPay (payment response) | 12 |
| 03 | `03-RespPay-REVERSAL.patch` | RespPay for REVERSAL (ACC_IP_09 / ACC_IP_10) | 11 |
| 04 | `04-RespChkTxn.patch` | RespChkTxn (status check response) | 10 |
| 05 | `05-RespHbt.patch` | RespHbt (heartbeat response) | 4 |
| 06 | `06-ACK.patch` | `<Ack>` for every inbound request | 16 |
| 07 | `07-Signature.patch` | XMLDSig signature on RespPay | 4 |
| 08 | `08-Runtime.patch` | Startup/runtime: settlement account config, timeout handler wiring | 7 |
| 09 | `09-PayeeAc-restore.patch` | Payee/Ac from merchant DB, no startup block | 3 |

Apps involved: `upi_core` (shared XML builders, parsing, helpers), `upi_dynamic` (dynamic-QR PSP, orgId `MER101`),
`upi_static` (static-QR PSP, orgId `MER102`).

The central file for all response XML is
[apps/upi_core/lib/upi_core/upi_xml_schema.ex](../apps/upi_core/lib/upi_core/upi_xml_schema.ex). The two
transaction managers
([upi_transaction_manager.ex](../apps/upi_dynamic/lib/upi_dynamic/upi_transaction_manager.ex),
[static_qr_transaction_manager.ex](../apps/upi_static/lib/upi_static/static_qr_transaction_manager.ex)) and the two
controllers (`.../controllers/api/v1/upi_controller.ex`) build the data maps passed into it.

---

## 4. Shared helpers: `UpiCore.NpciFormat`

File: [apps/upi_core/lib/upi_core/npci_format.ex](../apps/upi_core/lib/upi_core/npci_format.ex)
(introduced in commit `512980e`, extended in patch 06). Tests: `apps/upi_core/test/upi_core/npci_format_test.exs`.

| Function | Purpose |
|---|---|
| `ist_now/0` | Timestamp in IST, whole seconds, `+05:30` (replaces UTC-with-microseconds everywhere) |
| `new_msg_id/0` | NPCI-style message id: `MER` + 32 hex characters |
| `qr_param/2` | Read a parameter **raw** from the QR string (no URL-decode, `+` kept) |
| `qr_text/2` | Same, but percent-decodes free text (`pn`, `brand`, `invoiceName`); `+` stays `+` |
| `format_amount/1` | Amount to exactly two decimals; `nil`/invalid gives `nil` |
| `xml_attr/1` | XML-escape an attribute value; `nil` gives `""` |
| `ack_xml/2` | The one standard `<Ack>` document (added in patch 06) |

---

## 5. What each patch changes

### Patch 01: RespValQr

Spec: TSD Table 2.

**Generator** (`UpiXmlSchema.generate_resp_val_qr/1`):
- Every QR-derived field is read **raw from the QR string** NPCI sent (`NpciFormat.qr_param`).
- `Head.orgId` is the PSP orgId; `Head.msgId` via `new_msg_id/0`.
- `Txn`: `note`, `refId`, `ts`, `custRef`, `purpose`, `initiationMode` are **echoed from ReqValQr**
  (new `:txn_ts` kept at parse time so `Txn@ts` can be echoed). Generated custRef (padded to 12), purpose
  rewrite (62/66 to 11) and `initiationMode` guessing (`determine_initiation_mode`, 16/01) are gone.
- `QR`: `qVer`, `qrMedium`, `expireTs`, `ts` come from the QR string (`ver`, `qrMedium`, `QRexpire`, `QRts`).
- `Payee`: `addr=pa`, `name=pn`, `code=mc`. The fallback to a made-up `merchant@defaultbank` is gone.
- `Payee/Amount` and `FxList/Fx`: `format_amount(bAm)` + `bCurr` on every path (modes 15/16/22/23, also error paths); never empty.
- `Merchant/Identifier`: `subCode=mc`, `mid`, `sid=msid`, `tid=mtid`, `merchantType=mType`, `merchantGenre=mGr`,
  `onBoardingType=mOnboarding`, `merchantLoc=mLoc`; `merchantInstCode` from the merchant DB record.
- `Merchant/Invoice`: `invoiceName`, `invoiceNo`, `invoiceDate` from the QR as-is (no repair, no "now" fallback).
- `<Fx>` inside `<Merchant>` removed (not in TSD Table 2; Fx exists only under `FxList`).
- `Institution`: `netInstId` echoes the ReqValQr value, else config `:psp_net_inst_id` (no `MER1010001` literal);
  `conCode=cc`.
- `is_static_qr` no longer drives Amount/Fx/Identifier.

**Expiry (PE) check** (both controllers): decided only by `now > QRexpire` (was `QRts + qr_validity_minutes`,
a 30-minute window). The static manager's year-based `2025-` hack was removed; an unparsable `QRexpire` is not guessed.
The static `invoiceName` no longer crashes when absent (`URI.decode_query` turned `+` into a space; now raw).

**Error paths** (controllers and both managers): PE and other error responses echo the request and read payee,
merchant, amount and invoice from the QR string. Removed: payer address/name used as payee (`Mercury PSP`),
dummy `0000`/`IN`, `QR Payload expired` note, `STORE001`/`TERM001`/`RETAIL`, `REG001`/`560001`/`M2`/`Bangalore`,
invented MCC `5411`.

**QM1** (`UpiXmlSchema.validate_qr_mcc/2`): FAILURE `errCode="QM1"` when the QR's `mc` is missing, not 4 digits,
in the blocked list, or different from the merchant's registered MCC (`business_category`). Blocked list:
`config :upi_core, :blocked_mccs, ["7995"]` ([config/config.exs](../config/config.exs)). Applied in the dynamic
lookup, the static-QR-table lookup and the static manager. The transaction row is created only when QM1 passes;
an already-built error XML is sent as-is instead of being wrapped in a generic `02`.

**Routing** (static manager): the static flow is for `initiationMode` **01 / 02** only (was 02 / 15). Modes
15 / 16 / 22 / 23 always go to the dynamic flow.

**Config:** `:blocked_mccs` (config.exs); per-PSP `psp_org_id` / `psp_net_inst_id`
(dynamic `MER101` / `MER1010001`, static `MER102` / `MER1020002`).

Tests: `resp_val_qr_test.exs` (core), `resp_val_qr_flow_test.exs` (dynamic, static; they drive the real managers with a
sandboxed DB merchant), plus `test_helper.exs` for both apps.

### Patch 02: RespPay

Spec: TSD Table 4. `<Resp>` contains `Txn`, `QR`, and `Ref` (no `<Merchant>` or `<Institution>`).
- `generate_credit_response` (old builder: no `<Resp>` open tag, `8999 / MSWI0000000 / CURRENT` defaults, generated
  custRef/seqNum/approvalNum/timestamps) is retired; **all callers moved to `generate_resp_pay`**, which builds
  `Txn` / `QR` / `Ref` **from the ReqPay**. Its helper functions are commented out under `REMOVED`.
- `Txn.id` echoed exactly (no trim, no cut to 35, no generated id). `QR.expireTs / qVer / qrMedium / ts` echo the
  ReqPay QR element (generated now+5min, `2.0/01`, `04/03` fallbacks gone).
- `Resp.errCode` equals `Ref.respCode` on FAILURE, absent on SUCCESS (no `"02"` default).
- `Ref.addr` is the ReqPay Payee addr as received. `Ref.orgAmount = format_amount(ReqPay Payee Amount)`;
  `settAmount = orgAmount` on SUCCESS, `0.00` on FAILURE. `settCurrency` always `INR` (static manager used the ReqPay currency).
- Callers pass the **raw parsed ReqPay** (`normalize_parsed_data` drops the Payee/QR keys the Ref and QR need).
- `Head.orgId` is the PSP's own (`MER101` dynamic, `MER102` static); `MSW102` was not ours.
- "UNKNOWN" placeholders removed from the error paths.
- QR defaults removed in `acc_ip_05` scenarios (core, dynamic, static), `yh_error_handler`, `static_qr_yh_error_handler`,
  `static_pe_error_scenario`.
- **Known limit:** the generic error with **no readable ReqPay** (request unparsable) has nothing to echo, so its Txn/Ref stay empty or `N/A`.

Tests: `resp_pay_test.exs`.

### Patch 03: RespPay REVERSAL (ACC_IP_09 / ACC_IP_10)

- Reversal responses are now built by `generate_resp_pay` from the full reversal ReqPay (plus the **stored original ReqPay**,
  found via the reversal's `orgTxnId`) instead of hand-built XML in the scenario handlers.
- `Ref.reversalRespCode` added right after `respCode`: `CS` (credit stands, ACC_IP_10) or `00` (reversed); `Ref.respCode` carries the same value.
- `Ref.addr / code / regName / IFSC / acNum / accType` from the request, else from the stored original ReqPay.
  Invented `Test Merchant / AEBK0098765 / AE987654321 / CURRENT / SBIN0000001 / accip10@mercury / 5912` removed.
- `settCurrency` always `INR` (was `AED`); `initiationMode` echoed (was fixed 16/02); QR attributes echoed (no generated UTC `...Z` expiry).
- `Head.msgId` for REVERSAL uses `new_msg_id/0`; other RespPay keep their msgId logic.
- `generate_resp_pay_accip10` / `generate_resp_pay_static_accip10`: `defp` to `def` with `@doc false` so they can be unit-tested; callers unchanged.
- Bugs fixed on the way: ACC_IP_09 called `UpiDynamic.UpiXmlSchema` (does not exist; `UndefinedFunctionError`), corrected to `UpiCore.UpiXmlSchema`;
  the static async RespPay sent the `{:ok, xml}` tuple as the body; stored-ReqPay lookup also catches `:exit` (a Repo call without a DB owner exits rather than raises).
- Dead code from the baseline (`generate_success_resp_pay`, `generate_fallback_resp_pay_xml`, hand-built template helpers) commented out.
- The timeout simulation handlers (core/dynamic/static) are also part of this patch (file list in section 3); see the `RespPay-REVERSAL` tags in them for the exact changes.

Tests: `resp_pay_reversal_test.exs` in core, dynamic and static.

### Patch 04: RespChkTxn

Spec: TSD Table 6.
- `Txn`: `id`, `orgTxnId`, `note`, `refId`, `custRef`, `orgMsgId`, `initiationMode`, `subType`, `purpose` **echoed** from ReqChkTxn
  and XML-escaped (no `orgTxnId <- txnId` fallback, no generated `TXN...`, no `ACCIP08` note, no `16/02/11/CREDIT` defaults, no
  35-char padding with random hex). **`seqNum` removed** (not in the TSD Txn for RespChkTxn).
- `Ref` built from the **stored ReqPay** with the transaction's **real respCode**. Previous bugs: respCode was `"00"` always;
  transaction status is lowercase (`success`/`failure`) so the uppercase match always fell to `U69`; `reg_name` vs `regName` key
  mismatch left `regName` empty. Now: credited gives `00`, failed gives the stored failure code.
- `Ref.code` only from the stored ReqPay MCC (merchant_code/mid are internal ids, not an MCC); `Ref.regName` from the stored ReqPay
  Payee name, not the merchant legal/brand name; `Ref.IFSC/acNum/accType` from the stored ReqPay Payee account (merchant DB only as fallback).
- `orgAmount` two decimals; `settAmount = orgAmount` when `respCode == 00`, else `0.00`; `settCurrency` always `INR`.
- Static RespChkTxn gets `ref_data` (previously missing, so `Ref` was empty), built from the stored ReqPay found by `orgTxnId`.
- ACC_IP_08 scenario handlers (core/dynamic/static) and `req_pay_service.ex` adjusted accordingly.

Tests: `resp_chk_txn_test.exs` in core, dynamic and static.

### Patch 05: RespHbt

- **Bug:** Floki lowercases attribute names, so `Floki.attribute(doc, "txn", "refId")` never matched; `ref_id` was always nil and the manager
  sent a generated `REF<ms>` (precert: `REF1787303895328`). Now also looks up lowercase `refid`, `refurl`, `custref`.
- `RespHbt` echoes `refId` and `refUrl` (no generated `REF<ms>`, no invented `https://mercury.upi.in`).
- `Head.ts` / `Txn.ts` in IST via `NpciFormat.ist_now/0`.

Tests: `resp_hbt_test.exs`.

#### Follow-up (2026-11-04, not yet a numbered patch): RespHbt `Txn.refId` is generated, not echoed

- **Finding (NFinite precert, test case MT_125, ReqHbt):** execution Failed, response code
  `U07:TXN.REFID IS REQUIRED OR MUST BE 35 ALPHANUMERIC CHARACTERS`. NPCI's own ReqHbt carries a 12-digit `refId`
  (e.g. `017629623413`); patch 05 echoed it back in RespHbt, which NPCI's validator rejects.
- **Change:** `ref_id` in the RespHbt data map is now `NpciFormat.new_msg_id()` (`MER` + 32 lowercase hex = 35 alphanumeric).
  `refUrl` is still echoed. Tag: `# [RespHbt Txn.refId] CHANGED`; the old line (`ref_id: parsed_req_data.ref_id`) was not kept as an `# OLD:` comment, at the user's request.
  - [upi_transaction_manager.ex](../apps/upi_dynamic/lib/upi_dynamic/upi_transaction_manager.ex), `process_heartbeat_response_async/1`
  - [static_qr_transaction_manager.ex](../apps/upi_static/lib/upi_static/static_qr_transaction_manager.ex), RespHbt data map (same defect, fixed identically; not exercised against NPCI)
- **Exception to the "echo, never invent" principle (section 1):** this is a deliberate, spec-validator-driven exception for
  RespHbt `refId` only. Do not revert to echoing it. Other messages still echo `refId`.
- **Verified:** dynamic app, re-run against precert at 15:19 IST. RespHbt sent with `refId="MER7081eea43e20a44b294c08469d27d938"`;
  NPCI's Ack for it had no `err` attribute (previous run's Ack had the U07 error).
- **Not verified:** the static-QR heartbeat path. Unit tests (`resp_hbt_test.exs`) were not re-run after this change and may still
  assert the echoed `refId`.
- **Log noise, not a bug:** `Sending RespHbt to NPCI endpoint: .../urn:txnid:` is logged before the txn id is appended; the request URL itself is correct.

### Patch 06: ACK

- One standard `<Ack>` (`NpciFormat.ack_xml/2`) for ReqPay, ReqValQr, ReqChkTxn, ReqHbt and RespHbt: IST whole-second `ts`, the request's msgId as
  `reqMsgId`. Gone: UTC `ts` with microseconds, extra `orgTxnId`/`txnId` attributes, the XML prolog, extra `err=""`.
- ACC_IP_08's fake `<Ack><Head>...<Resp result="OK">` document (orgId `MERCURY001`, generated msgId) replaced by the standard Ack.
- The error Ack keeps the `err` attribute (it carries the reason) but with an IST ts.
- Timeout simulation `generate_ack_response/0` (dead code that invented `reqMsgId="TIMEOUT_SIM"`, `err="00"`) removed.
- `npci_adapter.ex`, `npci_heartbeat_service.ex`, the three controllers and the ACC_IP_05/08 scenarios in all three apps were touched.

Tests: `ack_test.exs` in core, dynamic and static.

### Patch 07: Signature (XMLDSig on RespPay)

Spec: TSD §7.2.5. New module [UpiCore.XmlDsig](../apps/upi_core/lib/upi_core/xml_dsig.ex):
- `c14n/1` **inclusive C14N 1.0** (`http://www.w3.org/TR/2001/REC-xml-c14n-20010315`), the URI the TSD sample declares (an exclusive variant `exc_c14n/1` is kept; the first revision used it).
- `signature_block/2`: `DigestValue` = SHA-256 of the canonical document **without** the `<Signature>` (enveloped-signature transform);
  `SignatureValue` = RSA-SHA256 (PKCS#1 v1.5) over the canonical `<SignedInfo>`.
  SignedInfo matches the TSD sample: `c14n-20010315`, `rsa-sha256`, **one** `enveloped-signature` Transform, `xmlenc#sha256`, `KeyInfo/RSAKeyValue`.
  Inclusive mode renders every in-scope namespace on the SignedInfo apex, so the document root's `xmlns:upi` is added next to the Signature's default `xmlns`.
- `load_private_key!/1`; `UpiXmlSchema.generate_signature_block` now delegates to it.
- **Config:** the private key path comes from `config :upi_core, :xml_signing_priv_key_path` = env **`XML_SIGNING_PRIV_KEY_PATH`**
  ([config/runtime.exs](../config/runtime.exs)). The old hard-coded `/var/www/internaltesting/madhoolika/...` default is gone; a missing path raises a clear error.

Tests: `xml_dsig_test.exs`.

### Patch 08: Runtime

- **TimeoutSimulationHandler wiring:** the controllers of upi_dynamic and upi_static call `UpiCore.SpecialHandlers.TimeoutSimulationHandler`, an Agent
  that was never started, so ReqChkTxn **exited**. It is now a child of the `UpiCore.Supervisor` tree (one instance, no name clash when both apps run in one VM).
  The dynamic/static `ack_test.exs` no longer start it themselves and gain a "ReqChkTxn does not exit" test.
- **Settlement account config (later partly reverted by 09):** `:psp_settlement_account` (`PSP_SETTLEMENT_AC_TYPE / _AC_NUM / _IFSC`) read from the environment in
  every env except `:test`; `config/test.exs` carries obviously fake values; startup check `validate_settlement_account!` (made `def`/`@doc false` for testing);
  `generate_resp_val_qr` raised when the account was missing.

Tests: `runtime_test.exs`.

### Patch 09: Payee/Ac restore (latest)

Fixes the patch-08 side effect that **blocked app startup** without the `PSP_SETTLEMENT_*` variables.
- `RespValQr Payee/Ac` (`ACTYPE`, `ACNUM`, `IFSC`) now resolves in this order:
  1. `PSP_SETTLEMENT_*` (`config :upi_core, :psp_settlement_account`) **when all three are set**;
  2. else the **merchant's DB record**, looked up by the QR `pa` via `UpiCore.Merchants.get_merchant_by_vpa/1`
     (`settlement_account_type`, `settlement_account_number`, `settlement_account_ifsc`), as in the earlier precert reports
     (e.g. `CURRENT` / `AE123456789` / `AEBK0001234`); each field falls back individually;
  3. else the old `response_data` values with the old defaults (`SAVINGS` / `1234567890` / `MERC0000001`).
  The lookup (`merchant_payee_ac/1`, private) rescues and catches everything: **it never raises**.
- The `validate_settlement_account!()` call in `UpiCore.Application.start/2` and the `raise` in `generate_resp_val_qr` are commented out
  (`[RespValQr Payee.Ac]`). The function itself stays, so its tests still pass.
- `runtime_test.exs`: the "never built with empty Ac" test is commented out; three tests added (env wins; no config and no merchant does not raise; merchant DB account used).

Result at patch 09: `upi_core` 112, `upi_static` 12, `upi_dynamic` 10, `upi_settlement` 133 tests, all passing.

---

## 6. Configuration and environment reference

| Setting | Where | Meaning |
|---|---|---|
| `:upi_core, :blocked_mccs` | config.exs | MCCs answered with `QM1` (default `["7995"]`) |
| `:upi_dynamic, :psp_org_id / :psp_net_inst_id` | config.exs | `MER101` / `MER1010001` |
| `:upi_static, :psp_org_id / :psp_net_inst_id` | config.exs | `MER102` / `MER1020002` |
| `XML_SIGNING_PRIV_KEY_PATH` | runtime.exs | RSA private key (PEM) for RespPay signing; **no default**, raises if missing when signing |
| `PSP_SETTLEMENT_AC_TYPE / _AC_NUM / _IFSC` | runtime.exs (not `:test`) | Optional override for RespValQr Payee/Ac; **not required** since patch 09 |
| `:upi_core, :psp_settlement_account` | config/test.exs | Fake values (`CURRENT`, `TESTACNUM001`, `TEST0000001`) so tests are deterministic |

Note: because `config/test.exs` sets the account, env wins in tests; DB-lookup tests set the config to `[]` first (see `with_account/2` in `runtime_test.exs`).

---

## 7. How to verify

```bash
# whole umbrella
mix test

# one app / one file
cd apps/upi_core && mix test test/upi_core/runtime_test.exs

# find all changes for one message / field
grep -rn "\[RespPay Ref" apps/
grep -rn "\[RespValQr Payee.Ac\]" apps/ config/
```

Per-message test files (each app): `resp_val_qr_*`, `resp_pay*`, `resp_pay_reversal_test`, `resp_chk_txn_test`, `resp_hbt_test` (core), `ack_test`, `xml_dsig_test` (core), `runtime_test` (core), `npci_format_test` (core).

Applying the series on a clean checkout (in order):

```bash
for p in tsd-patches/0*.patch; do git apply --cached "$p" && git commit -m "$(basename $p .patch)"; done
```

---

## 8. Known issues and open points

- **`settlement_simulator` tests fail (5 of 5)** with `Plug.Test.put_req_header/3 is undefined` (Plug 1.18). Pre-existing and unrelated to this branch.
- **Compiler warnings:** about 498 in a full `mix test` run (mostly pre-existing, e.g. `Logger.warn/1` deprecation, unused variables, `Endpoint.init/2`
  deprecation). None come from the lines changed in patch 09. No earlier baseline count was recorded, so it is not known whether the branch moved the number.
- **Generic error RespPay with no readable ReqPay** (patches 02): Txn/Ref stay empty or `N/A` because there is nothing to echo.
- **Patch 09 fallback defaults:** the third-level fallback (`SAVINGS` / `1234567890` / `MERC0000001`) is the *old behaviour* and is only reached when
  there is neither an env account nor a DB merchant. Whether this dummy fallback is acceptable for precert should be confirmed.
- **Patch 09 field mix:** if the DB merchant has only some of the three account fields, the missing ones fall back individually (could mix a real
  `ACNUM` with the dummy `IFSC`). Consider all-or-nothing if that matters.
- **Dead code** is intentionally kept as commented-out `# OLD:` blocks (rule 1). `upi_xml_schema.ex` is large because of it.

### Not part of this branch (separate, environment issue)

The admin dashboard `http://mercurypay.aritic.app:4040/dashboard` does not load:
- `UpiWeb.Endpoint` listens on **4050** in `config/dev.exs` (the comment there still says 4040; the other apps' port comments are stale too).
- `.app` domains are on Chrome's HSTS preload list, so the browser forces `https://`; Phoenix on 4050 serves **plain HTTP** and logs
  `Connection that looks like TLS received on a clear channel` (browser shows `ERR_CONNECTION_RESET`).
- Options being considered: an Apache path proxy on the existing `mercurypay.aritic.app` TLS vhost (existing pattern: `/iupi` to 4049, `/pn` to 4056,
  `/pg` to 4065; LiveView needs `url: [path: ...]` on the endpoint and a websocket `ProxyPass`), or an `https:` listener on 4050 using the Let's Encrypt cert.
  An SSH tunnel was explicitly ruled out. No change has been made yet.
