# Dispute workflow — roles, escalation, approvals, compliance

What the Disputes module does on **our** (Acquiring/Dispute portal) side, how a
dispute gets raised from this portal, and where SFTP fits. Covers Phase 0 and
Phase B. The merchant-raised dispute module (Module A) and the MMS integration
are separate and not described here.

## 1. How a case enters this portal

Every path validates the same way before a case exists.

| Channel | Who | Status |
|---|---|---|
| Scheme feed (Mastercom API, scheme CSV import) | System / ops | Exists. Mastercom is sandbox-only until the production cutover checklist is done. |
| **Log New Dispute** (Dashboard) | Ops/support, on behalf of a merchant | Exists; now validated (below). |
| `POST /api/v1/chargebacks` | Internal callers (protected upstream) | Exists, unchanged. |
| Merchant dispute via MMS | Merchant | Module A — not built yet. |

Cardholders have no login here. A cardholder dispute reaches us as a scheme
chargeback through the issuer and the card scheme.

**Log New Dispute now requires** (`DisputeCore.Context.create_dispute/2`):

- a real transaction: a `core_transactions` row matching the RRN **and** Merchant
  MID, which is linked to the case (`core_transaction_id`, auth number, date,
  currency, card type, merchant id are filled from it);
- a disputed amount no greater than the original transaction amount;
- no other *open* case for the same merchant and RRN (a closed one does not block).

No hold is placed on the transaction at this pre-chargeback stage; the existing
chargeback hold still applies when a scheme chargeback is imported.

## 2. SFTP

No SFTP is needed for disputes today, and none was added. Disputes arrive by
API (MMS, later), by the portal screen, by CSV, or from the scheme API.

SFTP clients already exist for clearing/settlement (Mastercard IPM, JCB, Alipay+,
bank, YSP) and are disabled until a host is configured. If a counterparty ever
wants to drop dispute files by SFTP, the shape is: a disabled-by-default client
copying `ClearingCore.Mastercard.Ipm.SftpClient`, feeding the existing CSV
importer. `dispute_core` may not depend on `clearing_core`
(docs/adr/0002-dependency-direction.md), so it needs its own client rather than
reusing that module directly.

## 3. Status lifecycle (`DisputeCore.Transitions`)

```
dispute ──► received ──► representment ──► pre_arbitration ──► arbitration
                │             │  ▲               │  │              │
                │             │  └───────────────┘  │              │
                ▼             ▼                     ▼              ▼
              resolved | written_off   (from any open status; both are final)
```

Every entry point — dashboard, REST API, workers — goes through one check, so a
resolved case cannot be resolved again and stages cannot be skipped. `resolved`
and `written_off` are final; there is no reopen path. The status change and its
timeline event are written in one transaction on a row locked `FOR UPDATE`, so
two people acting on one case cannot both succeed.

## 4. Roles and tiers (`PlatformWeb.DisputeAccess`)

| Tier | Permission | Seeded role |
|---|---|---|
| Analyst (L1) | `dispute.case.work` | Dispute Analyst |
| Supervisor (L2) | `dispute.case.supervise` (legacy `dispute.case.manage` counts) | Dispute Supervisor |
| Head (L3) | `dispute.case.approve_high`; superusers count | Dispute Head |
| Compliance | `dispute.case.compliance` | Dispute Compliance |
| Config | `dispute.config.manage` | Dispute Head |

The dispute pages now live in their own `:dispute_admin` live_session gated on
`dispute.case.view`. They used to sit inside the settlement session, which
required `settlement.dashboard.view` and so locked the dispute roles out.

## 5. Escalation

`chargeback_cases.escalation_level`: `0` none, `1` Supervisor, `2` Head, plus
`escalated_to` (a named person, or empty for "whoever holds that role"),
`escalation_reason` and `escalated_at` (when the current level was reached).

- Anyone who works cases may escalate to the Supervisor; reaching the Head takes
  a Supervisor or Head. A reason is always required.
- Clearing an escalation is a Supervisor/Head action and needs a reason.
- Supervisors and Heads get a **My Escalations** queue tab.
- `EscalationRuleWorker` (rules at *Automation Rules*): `days_past_due` /
  `days_unassigned` → Supervisor; `head_min_amount` → straight to Head;
  `head_after_hours` (default: the *Hours at Supervisor* parameter, 24) → Head.
- Each escalation raises an alert through `DisputeCore.Notifier`.

## 6. Approvals (maker-checker)

Gated actions: write-off, resolve-as-written-off (Mercury absorbs the amount),
file pre-arbitration, file arbitration. Resolving as *recovered* debits the
merchant and is not gated.

- Limits live in `dispute_approval_limits`, editable on **Dispute Parameters**
  (needs `dispute.config.manage`): per role, optionally per scheme, in AED. A role
  with **no** enabled limit has **no** authority — never "unlimited". A case in a
  different currency from the limit is treated the same way.
- Within the limit the action goes straight through. Above it, a request is
  created for the lowest tier that can do it alone (Supervisor, else Head). The
  request changes nothing on the case until it is approved.
- The approver must be senior enough and must not be the requester. Approving
  runs the stored action as the approver. If it then fails (case moved on,
  compliance not cleared, scheme call failed), the request returns to pending.
- A request is superseded if the case closes another way.
- Callers with no logged-in user (REST API, workers) are not subject to amount
  limits; they still obey transitions and compliance.

Default limits (placeholders — Finance to confirm): Analyst 500 AED, Supervisor
10,000 AED and may file pre-arbitration; Head unlimited.

## 7. Compliance

Setting `compliance_review_mode` on Dispute Parameters: `all` (default),
`flagged`, `off`.

- Gated actions: resolve, write-off, pre-arbitration, arbitration. Representment
  is not gated (time-critical).
- In `all` mode the first gated attempt requests the review automatically and
  changes nothing. `flagged` mode reviews cases at or above the amount threshold,
  fraud-category reason codes, or any case someone requested a review for.
- The reviewer cannot be the requester. **Cleared** lets the case proceed.
  **Concern** needs findings, blocks the case and escalates it to the Head; a new
  review must be cleared before it can proceed.
- Workers never create review requests as a by-product, and a Mastercom-confirmed
  outcome (a network fact) bypasses compliance and approval but not the
  transition check.

## 8. Screens

- **Dashboard** — escalation and compliance panels in the case drawer, approval
  notice, *My Escalations* tab, pending-approval/compliance messages after an
  action.
- **Approval Queue** — *Pending approvals* (approve / reject with reason /
  cancel own) and the original *Pre-arb / Arbitration cases* list.
- **Compliance Queue** (new) — pending reviews and recent decisions.
- **Dispute Parameters** — workflow rules and the approval-limit editor.
- **Automation Rules** — head-escalation conditions.

## 8a. Later additions

- **Merchant disputes** (a separate module, merchants raise them through MMS): see `docs/Dispute-Management/DISPUTE_MODULE_GUIDE.md`.
- **Money rule — debit on arrival, credit back on a win** (`DisputeCore.ChargebackMoney`): a setting on Dispute Parameters, off by default. Posts approved settlement adjustments when a chargeback arrives and trues them up when it is decided. Columns `arrival_*` on `chargeback_cases`.
- **Scheme simulator** (`DisputeCore.Simulator`) and sample scheme files: `docs/Dispute-Management/SIMULATION_DEMO.md`, `docs/Dispute-Management/samples/`.
- **Normalisation** (`DisputeCore.Normalize`): RRNs are trimmed and numeric currency codes (`784`) become letters (`AED`) wherever they are compared.

## 9. Setup

Migrations (additive): `20261022100001`–`20261022100004`. Seeds, idempotent:

```
mix run priv/repo/seeds/navigation_permissions_seed.exs   # adds dispute.case.approve_high
mix run priv/repo/seeds/navigation_roles_seed.exs         # adds the Dispute Head role
mix run priv/repo/seeds/dispute_approval_limits_seed.exs  # default limits (placeholders)
mix run priv/repo/seeds/dispute_workflow_alert_rule_seed.exs  # in-app "Dispute Workflow" alerts
```

Until the alert-rule seed runs, escalations and approvals still work; the
notification is skipped with a warning.
