# PRD: Mercury UPI PSP — NPCI-Aligned Settlement System (`upi_settlement`)

**Status**: Awaiting Implementation Approval  
**Author**: Engineering  
**Date**: 2026-05-25  
**Version**: 1.0

---

## 1. Background & Problem Statement

The current settlement handling lives inside `upi_core` and has the following critical compliance failures against NPCI's UPI International Settlement Guidelines:

| Defect | Impact |
|--------|--------|
| T-day window calculated as `00:00–23:59 UTC` | Transactions near the real 23:00 IST cutover are misassigned to the wrong settlement date |
| Deemed Approved (`RB`) transactions excluded from settlement | Revenue loss; NPCI penalties for non-reconciliation |
| Net settlement formula only deducts PSP fee + GST | Missing interchange fee, switching fee, chargebacks, representments — NTSL figure is incorrect |
| No NIPL→PSP settlement tracking | We cannot verify what NPCI actually credited to our bank account |
| Scheduler uses polling interval, not wall-clock IST triggers | Batch generation is non-deterministic relative to NPCI's 23:00 IST cutover |
| No NPCI file parser | Cannot ingest Raw Data, Adjustment Report, NTSL, or Deem Debit files delivered by NPCI |
| No dispute lifecycle | Chargebacks, Pre-Arbitration, and Arbitration cases have no tracking or deadline enforcement |
| No bulk upload generator | Cannot respond to NPCI aURCS dispute items programmatically |

Additionally, settlement code is embedded in a generic `upi_core` library app alongside QR validation, crypto, FX rates, and other unrelated concerns — making it difficult to test, deploy, or reason about in isolation.

---

## 2. Goals

- **G1**: Extract all settlement concerns into a dedicated `upi_settlement` Elixir umbrella app.
- **G2**: Correct all NPCI compliance gaps identified in the grill-with-docs review.
- **G3**: Implement the full NPCI settlement lifecycle: T-day aggregation → NPCI file ingestion → reconciliation → dispute management → bulk upload.
- **G4**: Add the missing NIPL→PSP inbound settlement tracking layer.
- **G5**: Replace the polling scheduler with wall-clock IST cron triggers.

## 3. Non-Goals

- Changes to transaction processing logic (ReqPay, RespPay, ChkTxn).
- Changes to `upi_core` Repo or database connection pooling.
- Implementing new API endpoints for external partners (out of scope for this PRD).
- Multi-currency INR netting at the NPCI interbank level (handled by RBI RTGS externally).

---

## 4. Umbrella App Architecture

### 4.1 New App: `upi_settlement`

Added to `apps/upi_settlement/` as a first-class umbrella member.

**Dependency direction**:
```
upi_settlement  ──depends on──▶  upi_core   (Repo, Transaction, Partner, Merchant schemas)
upi_web         ──depends on──▶  upi_settlement
upi_dynamic     ──depends on──▶  upi_settlement
```

**`upi_static`** has no settlement callers — no changes required.

### 4.2 File Layout

```
apps/upi_settlement/
├── mix.exs
└── lib/
    ├── upi_settlement.ex                      # Public facade
    └── upi_settlement/
        ├── application.ex                     # Supervision tree
        ├── window.ex                          # IST T-day window calculation
        ├── holiday_calendar.ex                # Schema
        ├── holiday_calendar/
        │   └── context.ex                     # working_day?/2, next_working_day/2
        ├── settlement.ex                      # Schema (extended)
        ├── settlements.ex                     # Context
        ├── settlement_service.ex              # PSP→Merchant batch logic
        ├── npci_inbound_settlement.ex         # Schema: NIPL→PSP layer
        ├── npci_inbound_settlement/
        │   └── context.ex
        ├── npci_file/
        │   ├── parser.ex                      # 8 file types, amounts ÷100, cycle extraction
        │   ├── ingestion.ex                   # SFTP poll + dispatch
        │   └── record.ex                      # NpciSettlementRecord schema
        ├── dispute.ex                         # Schema
        ├── dispute_service.ex                 # Lifecycle state machine
        ├── bulk_upload.ex                     # IRP→NIPL CSV generator
        └── scheduler.ex                       # Wall-clock IST GenServer
```

### 4.3 `mix.exs` Dependencies

```elixir
defp deps do
  [
    {:upi_core,    in_umbrella: true},
    {:timex,       "~> 3.7"},       # IST timezone arithmetic
    {:nimble_csv,  "~> 1.2"},       # CSV file parsing
    {:sftp_client, "~> 1.4"},       # SFTP file retrieval
  ]
end
```

Add `{:upi_settlement, in_umbrella: true}` to `upi_web/mix.exs` and `upi_dynamic/mix.exs`.

---

## 5. Functional Requirements

### 5.1 IST-Aware T-Day Window (`Window`)

| ID | Requirement |
|----|-------------|
| FR-W1 | `Window.t_day_window(date)` returns `{start_utc, end_utc}` where `start = 17:30:00 UTC (T-1)` and `end = 17:29:59 UTC (T)`, corresponding to `23:00:00 IST (T-1) – 22:59:59 IST (T)` per NPCI spec |
| FR-W2 | This function is the **single authoritative source** of the settlement window — used by both `Settlements.aggregate_eligible_transactions/2` and stored as `settlement_window_start / settlement_window_end` on the settlement record |
| FR-W3 | `schedule_time_for/2` for T+0 merchants stores `scheduled_at = 17:30:00 UTC` (23:00 IST) |

### 5.2 Holiday Calendar

| ID | Requirement |
|----|-------------|
| FR-H1 | `holiday_calendars` table with fields: `date :date`, `name :string`, `country :string` |
| FR-H2 | `country` accepts three values: `"IN"` (Indian bank holiday), `"US"` (US public holiday), `"WEEKLY_OFF"` (Saturday/Sunday) |
| FR-H3 | `HolidayCalendar.Context.working_day?(date)` returns `false` if the date has any entry in `holiday_calendars` |
| FR-H4 | `HolidayCalendar.Context.next_working_day(date)` returns the next date that has no holiday entry across all countries |
| FR-H5 | T+2 fund transfer date is calculated as `next_working_day(next_working_day(settlement_date))` and stored on the settlement record as `fund_transfer_date` |

### 5.3 PSP→Merchant Settlement (Moved + Fixed)

| ID | Requirement |
|----|-------------|
| FR-S1 | `Settlements.aggregate_eligible_transactions/2` queries `(status = "success" OR deemed = true) AND settlement_id IS NULL AND inserted_at WITHIN t_day_window(date)` |
| FR-S2 | Before creating a batch, `batch_exists?(merchant_id, date)` is checked — if a batch already exists, return `{:ok, :already_settled}` without inserting |
| FR-S3 | Net settlement formula (full NTSL): `net = gross_amount + representment_amount - chargeback_amount - refund_amount - interchange_fee - switching_fee - psp_fee - gst_on_psp_fee` |
| FR-S4 | PSP fee (0.5%) and GST (18% on PSP fee) are **separate from** NPCI interchange and switching fees |
| FR-S5 | Settlement record stores all deduction components individually (not just a single `fee_amount`) |
| FR-S6 | `fund_transfer_date` is computed on batch creation using `HolidayCalendar.Context.next_working_day/1` applied twice |

### 5.4 NPCI File Parser

| ID | Requirement |
|----|-------------|
| FR-P1 | Parser handles 8 file types identified by filename prefix: `raw_data`, `psp_raw_data`, `merchant_raw_data`, `ntsl`, `adjustment`, `international_summary`, `deem_debit_report`, `penalty_report` |
| FR-P2 | All integer amount fields in CSV files are divided by 100 via a shared `parse_amount/1` function to yield the actual decimal value |
| FR-P3 | The HT (header) row is parsed to extract `cycle_name` (e.g., `"1C"`, `"10C"`) and `settlement_date` |
| FR-P4 | Filename pattern `UPIGLOBALRAWDATAISS{CODE}{DDMMYY}_{cycle}C.csv` is parsed to extract `cycle_name` and `date` as a secondary source |
| FR-P5 | Each parsed TX row produces an `NpciSettlementRecord` struct with at minimum: `utxn_id`, `rrn`, `response_code`, `set_amount_inr`, `fcy_amount`, `currency_code`, `cycle_name`, `file_type` |
| FR-P6 | Only response codes `"00"` (Approved) and `"RB"` (Deemed Approved) produce settlement records that are linked to the NIPL→PSP inbound settlement. `"S9"` and all other codes are stored as-is with `status: "declined"` |
| FR-P7 | FT (footer) row is parsed to validate record count — parser returns `{:error, :record_count_mismatch}` if TX row count ≠ FT record count |

### 5.5 NPCI File Ingestion

| ID | Requirement |
|----|-------------|
| FR-I1 | `NpciFile.Ingestion` polls the configured SFTP endpoint at **02:05 IST daily** (20:35 UTC) |
| FR-I2 | Files are matched by filename pattern — only files for the current settlement date are processed |
| FR-I3 | After parsing, each `NpciSettlementRecord` is inserted; a unique constraint on `(utxn_id, file_type, cycle_name)` ensures idempotent ingestion |
| FR-I4 | Each `NpciSettlementRecord.utxn_id` is matched against `transactions.partner_txn_id`; on match, `transaction_id` FK is populated |
| FR-I5 | Records with no matching transaction are stored with `status: "unmatched"` and surface in the LiveView for manual review |
| FR-I6 | After ingesting an `international_summary` file, an `NpciInboundSettlement` record is created capturing the NIPL→PSP net settlement amount |

### 5.6 NIPL→PSP Inbound Settlement

| ID | Requirement |
|----|-------------|
| FR-N1 | `npci_inbound_settlements` table tracks what NPCI credits to Mercury's settlement bank account per cycle |
| FR-N2 | Fields: `cycle_name`, `settlement_date`, `total_txn_count`, `gross_inr`, `switching_fee_inr`, `interchange_fee_inr`, `chargeback_debit_inr`, `net_inr`, `npci_file_name`, `status` (`pending` / `confirmed` / `disputed`) |
| FR-N3 | When the actual bank credit is confirmed, status transitions to `confirmed` |
| FR-N4 | If `net_inr` from the file does not match the actual bank receipt within `±0.01`, status is set to `disputed` and a PubSub alert is emitted |

### 5.7 Dispute Lifecycle

| ID | Requirement |
|----|-------------|
| FR-D1 | Dispute types: `chargeback`, `pre_arbitration`, `arbitration`, `refund_reversal` |
| FR-D2 | `deadline_at` is set on dispute creation: `90 days from (transaction_date + 1)` for chargebacks/refunds |
| FR-D3 | State machine transitions: `raised → responded → escalated_to_pre_arb → pre_arb_responded → escalated_to_arb → arb_responded → closed` (plus `reversed` terminal) |
| FR-D4 | Chargeback TAT: IRP must respond within **7 days** of chargeback receipt; Pre-Arb within **15 days**; Arbitration verdict within **60 days** |
| FR-D5 | `DisputeService.confirm_rrc/2` is called after CBS processes the credit; sets `rrc_confirmed_at` and advances to `closed` state |
| FR-D6 | Attempting to create a dispute past `deadline_at` returns `{:error, :dispute_window_expired}` |
| FR-D7 | Deemed transactions (`response_code = "RB"`) that have no linked dispute action by **T+1 17:30 UTC** emit a `{:deemed_deadline_alert, records}` PubSub event |

### 5.8 Bulk Upload Generator

| ID | Requirement |
|----|-------------|
| FR-B1 | `BulkUpload.generate/1` accepts a list of dispute response records and produces a CSV in the NPCI aURCS bulk upload format |
| FR-B2 | Supported action codes: `TCC102` (credit confirmation), `TCC103` (credit denial), `RET` (return/refund) |
| FR-B3 | Each row includes `utxn_id`, `rrn`, `action_code`, `amount`, `reason_code`, `remark` |
| FR-B4 | Generator sets `bulk_upload_ref` on each `Dispute` record when a file is generated, enabling idempotency |

### 5.9 Scheduler (Wall-Clock IST)

| ID | Requirement |
|----|-------------|
| FR-SC1 | Scheduler is a GenServer that uses `Process.send_after(self(), :tick, ms_until_next_trigger())` where `ms_until_next_trigger` calculates milliseconds until the **next upcoming IST trigger time** |
| FR-SC2 | Two trigger times: **17:30 UTC** (23:00 IST) — opens new settlement window; **20:35 UTC** (02:05 IST) — triggers file ingestion |
| FR-SC3 | At 17:30 UTC trigger: calls `run_settlement_pass/0` for all merchants with `batch_exists?` idempotency guard |
| FR-SC4 | At 20:35 UTC trigger: calls `NpciFile.Ingestion.run/0` |
| FR-SC5 | After each trigger fires, immediately schedules the **next** trigger (whichever of the two times comes next) |
| FR-SC6 | At 20:35 UTC trigger, `check_deemed_deadlines/0` runs: queries `NpciSettlementRecord` for `response_code = "RB"` on settlement_date = T-1 with no linked open dispute; emits PubSub alert |

---

## 6. Data Models

### 6.1 `holiday_calendars`

```
id           :bigint PK
date         :date NOT NULL
name         :string NOT NULL
country      :string NOT NULL  -- "IN" | "US" | "WEEKLY_OFF"
inserted_at  :utc_datetime
updated_at   :utc_datetime

INDEX: (date, country) UNIQUE
```

### 6.2 `settlements` (altered — new columns added)

New columns added via migration (existing columns unchanged):

```
interchange_fee      :decimal(15,2)
switching_fee        :decimal(15,2)
chargeback_amount    :decimal(15,2)
representment_amount :decimal(15,2)
refund_amount        :decimal(15,2)
fund_transfer_date   :date
fcy_amount           :decimal(15,4)
exchange_rate        :decimal(12,6)
settlement_currency  :string(3)
npci_cycle_name      :string
npci_settlement_date :date
```

### 6.3 `npci_inbound_settlements`

```
id                    :bigint PK
cycle_name            :string NOT NULL
settlement_date       :date NOT NULL
total_txn_count       :integer
gross_inr             :decimal(20,2)
switching_fee_inr     :decimal(15,2)
interchange_fee_inr   :decimal(15,2)
chargeback_debit_inr  :decimal(15,2)
net_inr               :decimal(20,2)
npci_file_name        :string
status                :string  -- "pending" | "confirmed" | "disputed"
inserted_at           :utc_datetime
updated_at            :utc_datetime

INDEX: (settlement_date, cycle_name) UNIQUE
```

### 6.4 `npci_settlement_records`

```
id               :bigint PK
utxn_id          :string(35) NOT NULL     -- TItxnId, reconciliation key
rrn              :string
response_code    :string(4)               -- "00" | "RB" | "S9" | ...
set_amount_inr   :decimal(15,2)
fcy_amount       :decimal(15,4)
currency_code    :string(3)
cycle_name       :string NOT NULL
file_type        :string NOT NULL         -- "raw_data" | "psp_raw_data" | ...
settlement_date  :date
transaction_id   :bigint FK (nullable)    -- populated after reconciliation match
status           :string  -- "matched" | "unmatched" | "declined"
inserted_at      :utc_datetime
updated_at       :utc_datetime

INDEX: (utxn_id, file_type, cycle_name) UNIQUE
INDEX: (transaction_id)
INDEX: (settlement_date, response_code)
```

### 6.5 `disputes`

```
id                :bigint PK
transaction_id    :bigint FK NOT NULL
utxn_id           :string(35)
dispute_type      :string   -- "chargeback" | "pre_arbitration" | "arbitration" | "refund_reversal"
status            :string   -- see state machine FR-D3
tcc_code          :string   -- "TCC102" | "TCC103" | "RET"
deadline_at       :utc_datetime NOT NULL
raised_at         :utc_datetime
responded_at      :utc_datetime
rrc_confirmed_at  :utc_datetime
bulk_upload_ref   :string
notes             :text
inserted_at       :utc_datetime
updated_at        :utc_datetime

INDEX: (transaction_id)
INDEX: (utxn_id)
INDEX: (deadline_at)  -- for deadline enforcement queries
```

---

## 7. Module Responsibilities

| Module | Responsibility |
|--------|---------------|
| `UpiSettlement.Window` | Converts any `Date` to the correct UTC settlement window boundaries |
| `UpiSettlement.HolidayCalendar.Context` | `working_day?/1`, `next_working_day/1` using two-country calendar |
| `UpiSettlement.Settlements` | CRUD + `aggregate_eligible_transactions/2` (IST window + deemed) |
| `UpiSettlement.SettlementService` | Batch generation (NTSL formula), state transitions, reconciliation |
| `UpiSettlement.NpciInboundSettlement.Context` | CRUD for NIPL→PSP credits |
| `UpiSettlement.NpciFile.Parser` | Parses all 8 NPCI file types; returns typed structs per row |
| `UpiSettlement.NpciFile.Ingestion` | SFTP polling, file dispatch, DB upsert, reconciliation matching |
| `UpiSettlement.NpciFile.Record` | Ecto schema for `npci_settlement_records` |
| `UpiSettlement.DisputeService` | State machine transitions, deadline validation, RRC confirmation |
| `UpiSettlement.BulkUpload` | Generates aURCS bulk upload CSV from dispute response records |
| `UpiSettlement.Scheduler` | Wall-clock IST GenServer; fires `run_settlement_pass` at 17:30 UTC and `NpciFile.Ingestion.run` + `check_deemed_deadlines` at 20:35 UTC |

---

## 8. Migration Plan

Five migrations, all in `upi_core/priv/repo/migrations/`, in this order:

| Order | Migration Name | Type |
|-------|---------------|------|
| 1 | `create_holiday_calendars` | Create |
| 2 | `alter_settlements_add_npci_fields` | Alter (add 11 columns) |
| 3 | `create_npci_inbound_settlements` | Create |
| 4 | `create_npci_settlement_records` | Create |
| 5 | `create_disputes` | Create |

---

## 9. Backward Compatibility & Migration of Callers

### 9.1 Callers to Update

| File | Current Reference | New Reference |
|------|------------------|---------------|
| `upi_web/.../settlements_live.ex` | `UpiCore.Settlements`, `UpiCore.Settlements.SettlementService` | `UpiSettlement.Settlements`, `UpiSettlement.SettlementService` |
| `upi_dynamic/.../upi_controller.ex` | `UpiCore.Settlements.SettlementService` | `UpiSettlement.SettlementService` |
| `upi_core/monitoring.ex` | `UpiCore.Settlements` | `UpiSettlement.Settlements` |
| `upi_core/application.ex` | `UpiCore.Settlements.SettlementScheduler` (supervised here) | Removed — supervised by `UpiSettlement.Application` |

### 9.2 Deprecation Shims

`UpiCore.Settlements` and `UpiCore.Settlements.SettlementService` are kept as thin `defdelegate` shims to `UpiSettlement.*` during the transition to prevent a hard cutover. Shims are annotated `@deprecated` and removed in a follow-up PR after all callers are updated.

---

## 10. Build Order

| Step | Deliverable | Depends On |
|------|------------|------------|
| 1 | Scaffold `upi_settlement` app (`mix.exs`, `application.ex`, `upi_settlement.ex`) | — |
| 2 | `Window` module | Step 1 |
| 3 | `HolidayCalendar` schema + context + migration 1 | Step 1 |
| 4 | `Settlement` schema (extended) + `Settlements` context + migration 2 | Steps 2, 3 |
| 5 | `SettlementService` (full NTSL formula, idempotency guard) | Step 4 |
| 6 | `NpciInboundSettlement` schema + context + migration 3 | Step 1 |
| 7 | `NpciFile.Parser` (all 8 types, ÷100, cycle extraction) | Step 1 |
| 8 | `NpciFile.Record` schema + migration 4 | Step 7 |
| 9 | `NpciFile.Ingestion` (SFTP + dispatch + reconciliation match) | Steps 6, 7, 8 |
| 10 | `Dispute` schema + `DisputeService` + `BulkUpload` + migration 5 | Step 4 |
| 11 | `Scheduler` (wall-clock IST, idempotent, deemed deadline check) | Steps 5, 9, 10 |
| 12 | Deprecation shims in `upi_core` | Step 5 |
| 13 | Update `upi_web` and `upi_dynamic` callers | Step 12 |
| 14 | Remove `SettlementScheduler` from `UpiCore.Application` | Step 11 |

---

## 11. NPCI Compliance Checklist

| NPCI Requirement | Implementation | Status |
|-----------------|---------------|--------|
| T-day = 23:00 IST (T-1) to 22:59:59 IST (T) | `Window.t_day_window/1` → 17:30–17:29:59 UTC | Planned |
| Include Deemed Approved (`RB`) in settlement | `aggregate_eligible_transactions` OR clause | Planned |
| Net settlement = Gross − Chargebacks − Refunds − Interchange − Switching + Representments | Full NTSL formula in `SettlementService` | Planned |
| T+2 working day (excl. US + India holidays + weekly off) | `HolidayCalendar` two-country table + `next_working_day/1` | Planned |
| Raw Data / PSP Raw Data / NTSL file ingestion | `NpciFile.Parser` + `Ingestion` | Planned |
| Adjustment Report (TCC/RET actions) | `NpciFile.Parser` `adjustment` type | Planned |
| Deem Debit Report → T+1 deadline enforcement | Scheduler `check_deemed_deadlines/0` + PubSub alert | Planned |
| Penalty Report ingestion | `NpciFile.Parser` `penalty_report` type | Planned |
| Dispute lifecycle (Chargeback → Pre-Arb → Arb) | `Dispute` schema + `DisputeService` state machine | Planned |
| RRC after CBS credit | `DisputeService.confirm_rrc/2` | Planned |
| Bulk Upload File (IRP→NIPL aURCS) | `BulkUpload.generate/1` | Planned |
| Amount parsing: integer ÷ 100 | `NpciFile.Parser.parse_amount/1` | Planned |
| Cycle name tracking | `cycle_name` on `NpciSettlementRecord` + extracted from file HT row | Planned |
| UTXN ID reconciliation key | `NpciSettlementRecord.utxn_id` matched to `transactions.partner_txn_id` | Planned |

---

## 12. Acceptance Criteria

- [ ] `mix test` passes with > 90% coverage on `upi_settlement`
- [ ] `Window.t_day_window(~D[2026-05-25])` returns `{~U[2026-05-24 17:30:00Z], ~U[2026-05-25 17:29:59Z]}`
- [ ] Deemed transaction with `response_code = "RB"` is included in settlement batch
- [ ] Settlement batch for merchant+date is idempotent (second call returns `{:ok, :already_settled}`)
- [ ] Full NTSL net amount matches: `gross - chargeback - refund - interchange_fee - switching_fee + representment - psp_fee - gst`
- [ ] `NpciFile.Parser.parse/2` correctly divides all amount fields by 100
- [ ] `NpciFile.Parser.parse/2` extracts `cycle_name` from HT row
- [ ] `NpciFile.Parser.parse/2` returns `{:error, :record_count_mismatch}` when TX count ≠ FT count
- [ ] `DisputeService.create_dispute/2` returns `{:error, :dispute_window_expired}` after 90-day deadline
- [ ] `Scheduler` triggers fire within 60 seconds of 17:30 UTC and 20:35 UTC in integration test
- [ ] `UpiCore.Settlements` shim delegates correctly (backward-compat test)
- [ ] No settlement code remains in `UpiCore.Application` supervision tree
