# Umbrella Architecture Refactoring — Phases 1, 2 & 3

**Branch:** `Upi_umbrella-architecture`  
**Date completed:** March 2026  
**Commits:** `61bb8ff`, `86e25e3` (Phase 1) · `68c2875`, `359edca` (Phase 2) · `cce80e3` (Phase 3)

---

## Overview

The UPI PSP Platform was migrated from a monolithic Phoenix application into a
structured Elixir **umbrella project** across three focused phases. The goal was
to enable independent deployment of NPCI-facing transaction processing and the
admin/partner UI, while progressively eliminating duplicated logic and tightening
the internal module boundaries.

```
Before (monolith)              After (umbrella, Phase 3)
─────────────────              ──────────────────────────
da_product_app  (everything)   da_product_app  (core: DB, schemas, handlers, NpciClient)
                                   ↑                ↑
                               upi_dynamic      upi_static
                                   ↑                ↑
                               upi_web  (admin UI + partner API)   port 4041
                               upi_gateway  (NPCI direct APIs)     port 4042
```

---

## Phase 1 — Monolith → Umbrella Conversion

### Goal
Split the single `da_product_app` into multiple `apps/` sub-projects so that
business logic, dynamic-QR handling, static-QR handling, and the web layer can
evolve and be deployed independently.

### What was done

| Sub-app created | Owns |
|---|---|
| `apps/da_product_app` | Core domain: Ecto schemas, Repo, transactions, FX rates, merchants, adapters, special handlers, NpciHeartbeatService |
| `apps/upi_core` | Seed data and shared migration scripts (no `mix.exs` — intentionally a plain directory used by aliases) |
| `apps/upi_dynamic` | Dynamic-QR transaction flow (`UpiTransactionManager`, dynamic error handlers) |
| `apps/upi_static` | Static-QR transaction flow (`StaticQrTransactionManager`, static error handlers) |
| `apps/upi_web` | Phoenix web layer: controllers, LiveViews, router, endpoint, admin UI |

### Dependency graph established

```
da_product_app   (no web deps)
      ↑                ↑
upi_dynamic       upi_static
      ↑                ↑
           upi_web
```

### Key technical decisions
- `otp_app:` for `DaProductAppWeb.Endpoint` kept as `:da_product_app` so existing
  config keys (`config :da_product_app, DaProductAppWeb.Endpoint`) needed no change.
- Root `mix.exs` at umbrella root coordinates shared `deps`, `config`, and `lockfile`.
- Build artefacts, deps, and lockfile are all shared via umbrella paths.

---

## Phase 2 — Shared NpciClient + QrFlowBehaviour + Thin Delegates

### Goal
Eliminate the eight copy-pasted `Req.post` blocks scattered across controllers and
transaction managers, and enforce a single consistent contract for the two QR
processing paths (dynamic vs. static).

### What was done

#### 1. `DaProductApp.NpciClient`
A centralised HTTP client module added to `da_product_app`.

- Single `post/3` function wraps `Req.post` with:
  - Standard XML `content-type` / `accept` headers
  - Configurable `receive_timeout` (default 30 s)
  - Structured `{:ok, body}` / `{:error, reason}` return
- All eight previous `Req.post` call-sites replaced with `NpciClient.post/3`.

#### 2. `DaProductApp.QrFlowBehaviour`
An Elixir `@behaviour` that defines the shared contract both QR managers must
satisfy:

```elixir
@callback process_qr_validation(xml_body :: binary()) ::
            {:ok, binary()} | {:error, term()}
@callback process_payment_request(xml_body :: binary()) ::
            {:ok, binary()} | {:error, term()}
@callback process_status_check(xml_body :: binary(), initiation_mode :: binary()) ::
            {:ok, binary()} | {:error, term()}
```

Both `UpiTransactionManager` (dynamic) and `StaticQrTransactionManager` (static)
now declare `@behaviour DaProductApp.QrFlowBehaviour` and implement all callbacks.

#### 3. Shared handlers extracted to `da_product_app`

| Module | Moved from | Purpose |
|---|---|---|
| `DaProductApp.Handlers.X7Handler` | duplicated in both managers | Sends X7 (system error) response to NPCI |
| `DaProductApp.Handlers.YhHandler` | duplicated in both managers | Sends YH (payment failure) response to NPCI |
| `DaProductApp.Handlers.MerchantCodeValidator` | duplicated in both managers | Validates merchant codes against NPCI rules |

#### 4. Error handlers rewritten as thin delegates
Six error handler modules in `upi_dynamic` and `upi_static` that previously
contained duplicated logic were rewritten to delegate to the shared handlers above,
reducing each to ~10 lines.

### Files changed (summary)
- `apps/da_product_app/lib/da_product_app/npci_client.ex` — **new**
- `apps/da_product_app/lib/da_product_app/qr_flow_behaviour.ex` — **new**
- `apps/da_product_app/lib/da_product_app/handlers/x7_handler.ex` — **new**
- `apps/da_product_app/lib/da_product_app/handlers/yh_handler.ex` — **new**
- `apps/da_product_app/lib/da_product_app/handlers/merchant_code_validator.ex` — **new**
- `apps/upi_dynamic/lib/…/upi_transaction_manager.ex` — 8 `Req.post` blocks replaced
- `apps/upi_static/lib/…/static_qr_transaction_manager.ex` — 8 `Req.post` blocks replaced
- 6 error handler files in `upi_dynamic` + `upi_static` — rewritten as delegates

---

## Phase 3 — upi_gateway: Isolated NPCI Endpoint + Independent Releases

### Goal
Create a dedicated `upi_gateway` OTP application that owns **all NPCI direct-format
routes** (`/ReqPay/*path`, `/ReqValQr/*path`, etc.) and runs on its own port,
completely separate from the admin/UI web app. Define two Mix releases so each
half of the system can be deployed and scaled independently.

### What was done

#### 1. New app: `apps/upi_gateway/`

```
apps/upi_gateway/
├── mix.exs                          # app: :upi_gateway
└── lib/upi_gateway/
    ├── application.ex               # Starts UpiGateway.Endpoint only
    ├── endpoint.ex                  # Minimal Phoenix endpoint (no LiveView, no static files)
    ├── router.ex                    # 20 NPCI direct-format routes
    └── dispatch_controller.ex       # Thin delegate to UpiController
```

**`UpiGateway.Endpoint`** — stripped-down Phoenix endpoint:
- `DaProductAppWeb.Plugs.ConditionalBodyReader` (raw body preservation for NPCI routes)
- `Plug.Parsers` with XML pass-through (`pass: ["text/xml", "application/xml", "*/*"]`)
- No `socket "/live"`, no `Plug.Static`, no `LiveReloader`, no session
- `otp_app: :upi_gateway` — completely independent config namespace

**`UpiGateway.Router`** — `:npci_upi` pipeline, all 20 NPCI routes:
```
/ReqValQr/*path   /ReqValQr
/ReqPay/*path     /ReqPay
/ReqChkTxn/*path  /ReqChkTxn
/ReqHbt/*path     /ReqHbt
/RespHbt/*path    /RespHbt
/ReqRegMob/*path  /ReqRegMob
/ReqOtp/*path     /ReqOtp
/ReqSetCre/*path  /ReqSetCre
/ReqMandateConf/*path          /ReqMandateConf
/ReqTxnConfirmation/*path      /ReqTxnConfirmation
```
(wildcard + plain variants for both NPCI URL formats)

**`UpiGateway.DispatchController`** — zero business logic:
```elixir
def validate_qr(conn, params),
  do: UpiController.validate_qr(conn, params)
# … same pattern for all 8 implemented actions
```

#### 2. NPCI routes removed from `upi_web`
The entire NPCI direct-format `scope "/"` block was removed from
`apps/upi_web/lib/da_product_app_web/router.ex`. Those routes now live
**exclusively** in `upi_gateway/router.ex`. A comment was left in `upi_web`
pointing to the new location.

#### 3. Two Mix releases defined in root `mix.exs`

| Release | Starts | Loads (modules available, supervisor not started) | Port |
|---|---|---|---|
| `gateway` | `da_product_app`, `upi_dynamic`, `upi_static`, `upi_gateway` | `upi_web` | `GATEWAY_PORT` (default 4001 prod / 4042 dev) |
| `admin` | `da_product_app`, `upi_dynamic`, `upi_static`, `upi_web` | `upi_gateway` | `PORT` (default 4000 prod / 4041 dev) |

Using `:load` instead of `:permanent` for the non-active app means all shared
modules (e.g. `UpiController`) are available in the beam without starting the
supervisor tree — so `upi_gateway/dispatch_controller.ex` can call `UpiController`
functions in the `gateway` release without `upi_web` binding a port.

#### 4. Configuration wired across all environments

**`config/config.exs`** (compile-time):
```elixir
config :upi_gateway, UpiGateway.Endpoint,
  url: [host: "localhost"],
  adapter: Bandit.PhoenixAdapter,
  pubsub_server: DaProductApp.PubSub
```

**`config/dev.exs`** (development):
```elixir
config :upi_gateway, UpiGateway.Endpoint,
  http: [ip: {0, 0, 0, 0}, port: 4042],
  check_origin: false,
  debug_errors: true,
  secret_key_base: "…"
```

**`config/runtime.exs`** (production):
```elixir
if System.get_env("PHX_SERVER") do
  config :da_product_app, DaProductAppWeb.Endpoint, server: true
  config :upi_gateway, UpiGateway.Endpoint, server: true
end

# prod block:
config :upi_gateway, UpiGateway.Endpoint,
  http: [ip: {0,0,0,0,0,0,0,0}, port: String.to_integer(System.get_env("GATEWAY_PORT") || "4001")],
  secret_key_base: secret_key_base
```

### Port layout

| Environment | App | Port | Traffic |
|---|---|---|---|
| Dev | `upi_web` | **4041** | Admin UI, partner API, LiveDashboard |
| Dev | `upi_gateway` | **4042** | All NPCI direct calls |
| Prod | `admin` release | `PORT` (default 4000) | Admin UI, partner API |
| Prod | `gateway` release | `GATEWAY_PORT` (default 4001) | All NPCI direct calls |

### Deployment commands

```bash
# Build both releases
MIX_ENV=prod mix release gateway
MIX_ENV=prod mix release admin

# Run NPCI gateway (NPCI traffic only)
PHX_SERVER=true GATEWAY_PORT=4001 DATABASE_URL=... SECRET_KEY_BASE=... \
  _build/prod/rel/gateway/bin/gateway start

# Run admin app (UI + partner API)
PHX_SERVER=true PORT=4000 DATABASE_URL=... SECRET_KEY_BASE=... \
  _build/prod/rel/admin/bin/admin start
```

---

## Updated Dependency Graph (after Phase 3)

```
da_product_app   (core: DB, schemas, NpciClient, QrFlowBehaviour, shared handlers)
      ↑                       ↑
upi_dynamic             upi_static
      ↑                       ↑
           upi_web   ←────────────── upi_gateway
           (admin UI)                (NPCI router)
              ↑                           ↑
        admin release              gateway release
        PORT / 4041                GATEWAY_PORT / 4042
```

`upi_gateway` depends on `upi_web` (to call `UpiController` functions); in the
`gateway` release `upi_web` is started with `:load` so its modules are available
without its endpoint binding a port.

---

## What Has Not Changed

- All NPCI business logic remains in `DaProductAppWeb.Api.V1.UpiController`
  (in `upi_web`). No logic was moved or rewritten in Phase 3 — only routing.
- Database schema, migrations, and Ecto repos are untouched.
- All existing API endpoints under `/api/v1/upi/…` remain in `upi_web` (admin
  release) and are unaffected.
- Test suite structure is unchanged; all existing tests still pass.
