# Static vs Dynamic QR: Fee Structure, Settlement & Chargeback Analysis

**Date**: May 26, 2026  
**Status**: Implementation Guide  
**Audience**: Engineering, Product, Finance

---

## Table of Contents

1. [Fee Structure Analysis](#1-fee-structure-analysis)
2. [Settlement Eligibility](#2-settlement-eligibility)
3. [NPCI File Format Distinction](#3-npci-file-format-distinction)
4. [Representment & Chargeback Scenarios](#4-representment--chargeback-scenarios)
5. [Recommendations by QR Type](#5-recommendations-by-qr-type)
6. [Implementation Roadmap](#6-implementation-roadmap)

---

## 1. Fee Structure Analysis

### Current Implementation

The Mercury PSP platform uses a **unified fee formula** that applies equally to both static and dynamic QR types:

```
net_amount = gross_amount
           + representment_amount
           - chargeback_amount
           - refund_amount
           - interchange_fee
           - switching_fee
           - psp_fee
           - gst_on_psp_fee
```

**Source Code**: [UpiSettlement.SettlementService](../apps/upi_settlement/lib/upi_settlement/settlement_service.ex#L1-L190)

### Fee Breakdown

| Fee Component | Rate | Base | Calculation | Static QR | Dynamic QR |
|---------------|------|------|-------------|-----------|-----------|
| **Interchange** | 0.15% | Gross Amount | `gross × 0.0015` | ✅ Applied | ✅ Applied |
| **Switching** | ₹0.25 | Per Transaction | `0.25 × transaction_count` | ✅ Applied | ✅ Applied |
| **PSP Fee** | 0.50% | Gross Amount | `gross × 0.005` | ✅ Applied | ✅ Applied |
| **GST** | 18% | PSP Fee | `psp_fee × 0.18` | ✅ Applied | ✅ Applied |

### Code Example

From [SettlementService.do_generate/2](../apps/upi_settlement/lib/upi_settlement/settlement_service.ex):

```elixir
# Fee calculations (identical for both QR types)
interchange_fee = Decimal.mult(gross, @interchange_rate) |> Decimal.round(4)
switching_fee   = Decimal.mult(@switching_flat, txn_count) |> Decimal.round(4)
psp_fee         = Decimal.mult(gross, @psp_fee_rate) |> Decimal.round(4)
gst             = Decimal.mult(psp_fee, @gst_rate) |> Decimal.round(4)

# Full NTSL formula
net = gross
    |> Decimal.add(representment_amount)
    |> Decimal.sub(chargeback_amount)
    |> Decimal.sub(refund_amount)
    |> Decimal.sub(interchange_fee)
    |> Decimal.sub(switching_fee)
    |> Decimal.sub(psp_fee)
    |> Decimal.sub(gst)
```

### Key Constants

```elixir
@interchange_rate    Decimal.new("0.0015")   # 0.15%
@switching_flat      Decimal.new("0.25")     # ₹0.25 per transaction
@psp_fee_rate        Decimal.new("0.005")    # 0.50%
@gst_rate            Decimal.new("0.18")     # 18% on PSP fee
```

**Location**: [SettlementService](../apps/upi_settlement/lib/upi_settlement/settlement_service.ex#L37-L40)

### Current Gap: No QR-Type-Based Fee Differentiation

**Current State**: ❌ No differentiation by QR type  
**Stored Fields**: All fee components stored individually in `settlements` table:
- `interchange_fee`
- `switching_fee`
- `fee_amount` (PSP fee)
- `tax_amount` (GST)
- `chargeback_amount`
- `representment_amount`
- `refund_amount`

---

## 2. Settlement Eligibility

### Current Implementation

Both static and dynamic QRs use **identical settlement eligibility criteria**.

**Source**: [SETTLEMENT_PRD.md § 5.3](../docs/SETTLEMENT_PRD.md) and [SettlementService](../apps/upi_settlement/lib/upi_settlement/settlement_service.ex)

### Eligibility Query

```elixir
# From Settlements.aggregate_eligible_transactions/2
query = from(t in Transaction,
  where: (t.status == "success" or t.deemed == true),
  where: is_nil(t.settlement_id),
  where: t.inserted_at >= ^window_start and t.inserted_at <= ^window_end,
  select: t
)
```

### Settlement Timeline (Both QR Types)

| Stage | Timing | Details |
|-------|--------|---------|
| **Settlement Window** | IST 23:00 (T-1) → 22:59 (T) | Eligible transactions: those with `inserted_at` in this window |
| **Batch Creation** | 17:30 UTC (23:00 IST T-day) | `SettlementService.generate_merchant_settlement_batch/2` triggered by Scheduler |
| **Scheduled At** | 17:30 UTC same day | Settlement marked `scheduled_at` for T+0 merchants |
| **Fund Transfer Date** | T+2 working days | Holiday calendar applied, `fund_transfer_date` computed |
| **Idempotency** | `batch_exists?(merchant_id, date)` | Duplicate calls return `{:ok, :already_exists}` |

**Source**: [SETTLEMENT_PRD.md § 5.2, 5.3](../docs/SETTLEMENT_PRD.md)

### Eligibility Criteria (Identical)

| Criterion | Static QR | Dynamic QR | Notes |
|-----------|-----------|-----------|-------|
| **Transaction Status** | `success` OR `deemed=true` | `success` OR `deemed=true` | Failed/pending excluded |
| **Settlement State** | `settlement_id IS NULL` | `settlement_id IS NULL` | Not yet settled |
| **Time Window** | T-day IST 23:00–22:59 | T-day IST 23:00–22:59 | Per `Window.t_day_window/1` |
| **Settlement Frequency** | T+1 working day | T+1 working day | Default for all merchants |
| **Fund Transfer** | T+2 working day | T+2 working day | Holiday calendar applied |

### Holiday Calendar

**Purpose**: Adjust fund transfer date for bank holidays (India, US, Singapore, etc.)

```elixir
# From HolidayCalendar.Context
fund_transfer_date = Context.next_working_day(Date.add(settlement_date, 1))
# Result: skip weekends + holidays, find next valid business day
```

---

## 3. NPCI File Format Distinction

### Key Finding: NPCI Does NOT Differentiate Static vs Dynamic

**Source**: [UpiSettlement.NpciFile.Parser](../apps/upi_settlement/lib/upi_settlement/npci_file/parser.ex)

#### NPCI File Structure

NPCI settlement files use a pipe-delimited format with three row types:

```
HT|NPCI|PSP_ID|DATE|TIME|VERSION|CYCLE_NAME|...
DT|utxn_id|payer_vpa|payee_vpa|rrn|amount_paise|status|value_date|psp_id|bank_id|interchange_paise|switching_paise
FT|record_count|checksum|...
```

#### NTSL Record Layout (Main Settlement File)

| Field | Type | Example | Notes |
|-------|------|---------|-------|
| `utxn_id` | String | `UPI20260525ABC123` | Unique transaction ID |
| `payer_vpa` | String | `user@upi` | Payer address |
| `payee_vpa` | String | `merchant@mercury` | Payee/merchant address |
| `rrn` | String | `123456789` | Retrieval Reference Number |
| `amount_paise` | Integer | `100050` | Amount in paise (100ths of rupee) |
| `status` | String | `S` (Success) | Transaction status |
| `value_date` | Date | `20260525` | Settlement date |
| `interchange_paise` | Integer | `150` | Interchange fee in paise |
| `switching_paise` | Integer | `25` | Switching fee in paise |

#### Amount Conversion

```elixir
# From Parser.parse_amount/1
# NPCI stores amounts as integer paise, must convert to decimal rupees
paise_str = "100050"
rupees = Decimal.div(Decimal.new(100050), Decimal.new(100))
# Result: ₹1000.50
```

### Supported NPCI File Types

| File Type | Prefix | Purpose | QR Type Info |
|-----------|--------|---------|--------------|
| **NTSL** | `UPIGLOBAL_*` | Net Transaction Settlement List (primary) | None (all QR types) |
| **TCC102** | `TCC102_*` | Chargeback initiation (pre-arb) | None |
| **TCC103** | `TCC103_*` | Chargeback representment response | None |
| **RET** | `RET_*` | Return/refund advice | None |
| **DISPUTE** | `DISPUTE_*` | Dispute status update | None |
| **DEEMED** | `DEEMED_*` | Deemed success notification | None |
| **REVERSAL** | `REVERSAL_*` | Transaction reversal advice | None |
| **FX_RATE** | `FX_*` | FX rate broadcast (intl corridors) | None |

**Conclusion**: ❌ No `initiation_mode` or `qr_type` field in any NPCI file

### Enrichment Strategy (Recommended)

To enable static-vs-dynamic analytics on NPCI data:

```elixir
# In NpciFile.Ingestion.reconcile_transaction/1
defp reconcile_transaction(%Record{utxn_id: nil}), do: :ok

defp reconcile_transaction(%Record{} = record) do
  txn = Repo.get_by(Transaction, partner_txn_id: record.utxn_id)
  
  if txn do
    # Enrich NPCI record with QR type from transaction
    qr_type = case txn.initiation_mode do
      "02" -> "STATIC_SECURE"
      "15" -> "DYNAMIC_OFFLINE"
      "16" -> "DYNAMIC_SECURE"
      mode -> mode
    end
    
    record
    |> Record.changeset(%{
      reconciled: true,
      reconciled_transaction_id: txn.id,
      metadata: %{"qr_type" => qr_type}  # Store for reporting
    })
    |> Repo.update()
  end
end
```

---

## 4. Representment & Chargeback Scenarios

### State Machine

Both static and dynamic QRs follow the **same dispute lifecycle**:

```
raised
  ↓
responded
  ↓
escalated_to_pre_arb
  ↓
pre_arb_responded
  ↓
escalated_to_arb
  ↓
arb_responded
  ↓
closed ⟵⟵⟵ (terminal state, or reversed)
```

**Source**: [SETTLEMENT_PRD.md § 5.7](../docs/SETTLEMENT_PRD.md)

### Dispute Types and TATs

| Scenario | NPCI File | Type | TAT | IRP Action | Applies To |
|----------|-----------|------|-----|-----------|-----------|
| **Chargeback** | TCC102 | Chargeback initiated | 7 days | Submit evidence | Static ✅ / Dynamic ✅ |
| **Pre-Arbitration** | TCC102 escalated | Escalation | 15 days | Resubmit evidence | Static ✅ / Dynamic ✅ |
| **Arbitration** | NPCI escalation | Final decision | 60 days | Arbitration verdict | Static ✅ / Dynamic ✅ |
| **Representment** | TCC103 | Outcome of chargeback | Immediate | Accept/deny | Static ✅ / Dynamic ✅ |
| **Return/Refund** | RET | Refund advice | Immediate | Reconcile amount | Static ✅ / Dynamic ✅ |

### Code References

#### Chargeback Ingestion (TCC102)

From [Parser.parse_record/2](../apps/upi_settlement/lib/upi_settlement/npci_file/parser.ex#L220-L320):

```elixir
defp parse_record(line, :tcc102) do
  fields = String.split(line, "|")

  with [_, utxn_id, rrn, amount_raw, reason_code, dispute_ref, deadline_raw | _] <- fields do
    {:ok, %{
      utxn_id:      String.trim(utxn_id),
      rrn:          String.trim(rrn),
      amount:       parse_amount(amount_raw),        # Convert paise to rupees
      reason_code:  String.trim(reason_code),
      dispute_ref:  String.trim(dispute_ref),
      deadline_at:  parse_date(deadline_raw)         # 7-day deadline
    }}
  end
end
```

#### Representment Response (TCC103)

```elixir
defp parse_record(line, :tcc103) do
  fields = String.split(line, "|")

  with [_, utxn_id, rrn, amount_raw, outcome, dispute_ref | _] <- fields do
    {:ok, %{
      utxn_id:     String.trim(utxn_id),
      rrn:         String.trim(rrn),
      amount:      parse_amount(amount_raw),
      outcome:     String.trim(outcome),             # "ACCEPTED" or "REJECTED"
      dispute_ref: String.trim(dispute_ref)
    }}
  end
end
```

#### Return/Refund (RET)

```elixir
defp parse_record(line, :ret) do
  fields = String.split(line, "|")

  with [_, utxn_id, rrn, amount_raw, return_reason, orig_date | _] <- fields do
    {:ok, %{
      utxn_id:       String.trim(utxn_id),
      rrn:           String.trim(rrn),
      amount:        parse_amount(amount_raw),       # Refund amount
      return_reason: String.trim(return_reason),
      original_date: parse_date(orig_date)
    }}
  end
end
```

### Dispute Record Schema

**Source**: [Dispute schema](../apps/upi_settlement/lib/upi_settlement/dispute.ex)

```elixir
schema "disputes" do
  field :dispute_type,    :string      # "chargeback", "pre_arbitration", "arbitration", "refund_reversal"
  field :status,          :string      # "raised", "responded", "escalated_to_pre_arb", etc.
  field :reason_code,     :string      # NPCI reason code
  field :amount,          :decimal
  field :deadline_at,     :utc_datetime
  field :raised_at,       :utc_datetime
  field :responded_at,    :utc_datetime
  field :rrc_confirmed_at, :utc_datetime  # When bank confirmed credit
  
  belongs_to :transaction, Transaction
  belongs_to :settlement,  Settlement
end
```

---

## 5. Recommendations by QR Type

### 5.1 Static QR-Specific Considerations

Static QRs have **unique characteristics** that should be incorporated into settlement and dispute handling:

#### A. Fee Structure Differentiation (Optional)

**Rationale**: Static QRs are pre-validated, potentially lower risk

**Recommendation**: Implement fee mode based on QR type:

```elixir
# Pseudo-code
fee_config = case qr.initiation_mode do
  "02" ->  # Static secure
    %{
      interchange_rate: Decimal.new("0.0010"),    # 0.10% (lower than dynamic)
      switching_flat: Decimal.new("0.15"),        # ₹0.15 (lower than dynamic)
      psp_fee_rate: Decimal.new("0.0045"),        # 0.45% (margin increase)
      gst_rate: Decimal.new("0.18")
    }
  "16" ->  # Dynamic secure
    %{
      interchange_rate: Decimal.new("0.0015"),    # 0.15% (standard)
      switching_flat: Decimal.new("0.25"),        # ₹0.25 (standard)
      psp_fee_rate: Decimal.new("0.0050"),        # 0.50% (standard)
      gst_rate: Decimal.new("0.18")
    }
  _ -> :standard_rates
end
```

**Implementation**: Add `fee_mode` enum to `Settlement` schema and modify `SettlementService.do_generate/2`.

#### B. Usage-Limit Protection

**Rationale**: Static QRs have `max_usage_count`; enforce during settlement

```elixir
# Before settlement batch creation
defp validate_static_qr_usage(transaction) do
  case QRValidation.get_static_qr_by_transaction(transaction.id) do
    %StaticQr{} = qr ->
      if qr.usage_count > qr.max_usage_count do
        {:error, "Static QR usage limit exceeded"}
      else
        :ok
      end
    nil ->
      :ok  # Not a static QR, skip validation
  end
end
```

#### C. Expiry Before Settlement

**Rationale**: Static QRs can expire before settlement window closes

```elixir
# In settlement eligibility query
defp validate_static_qr_expiry(transaction) do
  case QRValidation.get_static_qr_by_transaction(transaction.id) do
    %StaticQr{expires_at: expires_at} when expires_at != nil ->
      if DateTime.compare(DateTime.utc_now(), expires_at) == :gt do
        {:error, "Static QR expired; ineligible for settlement"}
      else
        :ok
      end
    _ ->
      :ok
  end
end
```

### 5.2 Chargeback Handling for Static QRs

**Key Scenarios**:

| Chargeback Reason | NPCI Code | Static QR Behavior | Notes |
|-------------------|-----------|-------------------|-------|
| QR Expired | PE | ❌ **Auto-reject** | QR has fixed expiry; transaction invalid |
| QR Inactive | U30 | ❌ **Auto-reject** | QR was deactivated; invalid |
| Usage Limit Exceeded | Custom | ❌ **Auto-reject** | Max_usage_count exhausted |
| Invalid Merchant | 05 | ⚠️ **Investigate** | Merchant mismatch in static QR payload |
| Fraud/Other | Standard | ✅ **Normal flow** | Treat as dynamic QR chargeback |

**Implementation Example**:

```elixir
# In DisputeService.create_dispute/2
defp validate_static_qr_chargeback(transaction, dispute_attrs) do
  case QRValidation.get_static_qr_by_transaction(transaction.id) do
    nil ->
      :ok  # Not a static QR

    %StaticQr{} = qr ->
      cond do
        # Check if QR has expired
        qr.expires_at && DateTime.compare(DateTime.utc_now(), qr.expires_at) == :gt ->
          {:error, :chargeback_invalid_qr_expired, "Static QR expired at #{qr.expires_at}"}

        # Check if QR is inactive
        qr.status != "active" ->
          {:error, :chargeback_invalid_qr_inactive, "Static QR status: #{qr.status}"}

        # Check usage limit
        qr.max_usage_count && qr.usage_count >= qr.max_usage_count ->
          {:error, :chargeback_invalid_usage_limit, "Usage limit exceeded: #{qr.usage_count}/#{qr.max_usage_count}"}

        # Chargeback valid for static QR
        true ->
          :ok
      end
  end
end
```

### 5.3 Should Static QRs Include Representment?

**Summary Table**:

| Feature | Include? | Rationale |
|---------|----------|-----------|
| **Chargebacks** | ✅ YES | Pre-validated QR doesn't prevent payer fraud |
| **Representment** | ✅ YES | Standard NPCI process applies to all UPI transactions |
| **Refunds** | ✅ YES | Cannot prevent valid payer refund requests |
| **Reduced TAT** | ❓ CONSIDER | Pre-validated QR → faster evidence gathering possible |
| **Auto-reject invalid** | ✅ YES | Reject chargebacks where QR is expired/inactive/over-limit |
| **Separate chargeback pool** | ✅ YES | Report static QR chargebacks separately from dynamic |

**Recommendation**: ✅ **Include full representment flow** but add static QR-specific validations and auto-reject logic.

---

## 6. Implementation Roadmap

### Phase 1: Enrichment (Low Effort, High Value)

**Objective**: Enable static-vs-dynamic analytics on existing data

**Tasks**:

1. **Enhance NPCI record reconciliation**
   - File: [Ingestion.reconcile_transaction/1](../apps/upi_settlement/lib/upi_settlement/npci_file/ingestion.ex#L220-L295)
   - Add `metadata` field to store `qr_type` from linked transaction
   - Update schema migration

2. **Add reporting views**
   - Query: Group settlements by `qr_type` (from transaction metadata)
   - Visualize fee breakdown by QR type
   - Identify chargeback trends

**Effort**: 2-3 days  
**Risk**: Low

---

### Phase 2: Fee Differentiation (Medium Effort)

**Objective**: Implement optional fee mode for static QRs

**Tasks**:

1. **Extend Settlement schema**
   - Add `fee_mode` enum: `:standard`, `:static_secure`, `:custom`
   - Add `effective_fee_config` JSONB field

2. **Modify SettlementService**
   - Detect QR type during batch creation
   - Load fee config based on mode
   - Apply conditional rates

3. **Add config management**
   - `config/dev.exs`, `config/prod.exs`: fee rate overrides
   - Example:
     ```elixir
     config :upi_settlement, :fee_modes,
       static_secure: %{
         interchange_rate: "0.0010",
         switching_flat: "0.15",
         psp_fee_rate: "0.0045"
       }
     ```

4. **Test & migration**
   - Update existing settlements to `fee_mode: :standard`
   - Test settlement calculations with new rates

**Effort**: 4-5 days  
**Risk**: Medium (financial impact)

---

### Phase 3: Static QR-Specific Validations (Medium Effort)

**Objective**: Add usage, expiry, and chargeback protections

**Tasks**:

1. **Settlement eligibility**
   - Add validators for `max_usage_count`, `expires_at`
   - Update query in [Settlements.aggregate_eligible_transactions/2](../apps/upi_settlement/lib/upi_settlement/settlements.ex)

2. **Dispute handling**
   - Implement chargeback validation logic (see § 5.2)
   - Add reason code mapping for static QR scenarios
   - Auto-reject chargebacks for expired/inactive QRs

3. **Monitoring**
   - PubSub event: `{:static_qr_chargeback_rejected, dispute_id, reason}`
   - Dashboard: Track auto-rejected chargebacks

**Effort**: 3-4 days  
**Risk**: Low (additions to existing flow)

---

### Phase 4: Reporting & Dashboard (Low Effort)

**Objective**: Provide static-vs-dynamic insights

**Tasks**:

1. **Analytics queries**
   - Settlement volume by QR type
   - Chargeback rate by QR type
   - Fee breakdown by QR type
   - Revenue impact analysis

2. **Merchant dashboard**
   - Add QR type filter to settlement history
   - Show static QR usage metrics (max_usage, remaining)

3. **Finance reporting**
   - NPCI reconciliation report (include QR type enrichment)
   - Chargeback dispute report by QR type

**Effort**: 2-3 days  
**Risk**: Low

---

## Summary of Answers

| Question | Answer |
|----------|--------|
| **Do static & dynamic QRs have different fees?** | ❌ Currently NO (same rates apply). ⚠️ Recommend optional differentiation in Phase 2. |
| **Are there different settlement rules?** | ❌ Currently NO (same eligibility, window, TAT). ⚠️ Consider static-QR-specific cadence. |
| **Do NPCI files distinguish them?** | ❌ NO (no `initiation_mode` or `qr_type` field). ✅ Recommend enrichment via transaction metadata. |
| **Should static QRs include representments?** | ✅ YES (all UPI transactions eligible). ⚠️ Add validation to auto-reject invalid chargebacks. |

---

## Related Documents

- [SETTLEMENT_PRD.md](../docs/SETTLEMENT_PRD.md) — Full settlement specification
- [UPI-Specification-Document.md](../docs/guides/UPI-Specififcation-Document.md) — NPCI UPI spec (initiation modes)
- [Copilot Instructions](../.github/copilot-instructions.md) — Architecture overview

---

## Appendix: Code Locations

| Component | File | Lines |
|-----------|------|-------|
| Settlement Service | `apps/upi_settlement/lib/upi_settlement/settlement_service.ex` | 1–190 |
| Settlement Schema | `apps/upi_settlement/lib/upi_settlement/settlement.ex` | 1–205 |
| NPCI File Parser | `apps/upi_settlement/lib/upi_settlement/npci_file/parser.ex` | 1–320 |
| File Ingestion | `apps/upi_settlement/lib/upi_settlement/npci_file/ingestion.ex` | 1–295 |
| Dispute Service | `apps/upi_settlement/lib/upi_settlement/dispute_service.ex` | 1–220 |
| Static QR Schema | `apps/upi_core/lib/upi_core/qr_validation/static_qr.ex` | — |
| Static QR Service | `apps/upi_core/lib/upi_core/qr_validation/static_qr_service.ex` | 1–220 |

