Skip to main content

ADR-016: Dispute workflow for contested money movements

  • Status: Proposed
  • Date: 2026-05-31
  • Deciders: Principal Architect, Engineering Lead, Finance/Ops Lead, Compliance Lead (pending counter-sign)
  • Relates to: ADR-006, ADR-009, ADR-014, ADR-015
  • Closes: docs/SYSTEM_GAP.md §1.H bullet "No dispute workflow."

Context

A dispute is a contested money movement: a customer says "I did not authorise this", "this amount is wrong", "I did not receive this", or a partner bank claims a transfer should not have happened. Today DemozPay has no defined intake, investigation, decision, or remediation path. Every disputed event would be handled ad-hoc by whichever engineer is on call.

Disputes are different from drift (ADR-015):

DriftDispute
TriggerRecon-runner finds ledger ≠ bankA customer or partner asserts a movement is wrong
Reality is known?Yes — the bank statement is authoritativeNo — under investigation
Resolution shapePost an adjustmentInvestigate → decide → either close-no-action OR post adjustment OR open chargeback
Customer-facing?NoYES — communications, possibly refund
Regulatory clock?Internal (soak target)YES — NBE consumer-protection windows, partner-bank chargeback windows

The two flows share a terminal step (a ledger adjustment) but have very different intake, investigation, evidence, and communication surfaces. This ADR defines the dispute side.

Worked example

  • 2026-06-20: employee e_7f3 opens a complaint via the employee app: "I did not receive my 80,000 santim EWA disbursement on 2026-06-19".
  • The ledger shows transaction tx_42a POSTED on 2026-06-19, Dashen pool → e_7f3 wallet.
  • Investigation requires: (i) a copy of the partner-side disbursement record, (ii) confirmation from Dashen's API that the destination account was credited, (iii) confirmation from the employee that the wallet they're looking at is the one we sent to (wallet-account-id mis-binding has happened before).
  • If the partner record agrees with our ledger and the employee mis-identified the destination → close-no-action with explanation.
  • If the partner record disagrees → open a chargeback request with Dashen and provisionally mark tx_42a as DISPUTED in the dispute table; the ledger transaction itself stays POSTED until Dashen returns a resolution.
  • If Dashen agrees the destination was not credited → adjust: Reverse(tx_42a) if the funds never left Dashen's pool, OR PostAdjustment(DR misc-loss / CR receivable-from-employee) if Dashen confirms the funds are unrecoverable and the loss is ours.

The shape of the workflow is the same; only the terminal step varies.

Decision

Disputes are tracked in a new aggregate Dispute in a new bounded context packages/dispute/. The dispute aggregate is independent of any single domain (EWA, lending, BNPL) because the same case shape applies to all of them — it references the disputed ledger transaction by id, not by domain type.

Lifecycle

OPENED → UNDER_INVESTIGATION → one of:
• CLOSED_NO_ACTION (claim unfounded; no money moves)
• RESOLVED_ADJUSTMENT (adjustment posted via ADR-015)
• RESOLVED_REVERSAL (Reverse posted on the original tx)
• ESCALATED_TO_PARTNER (chargeback opened with partner bank;
terminal-in-DemozPay until partner replies)
• REOPENED (only from CLOSED_NO_ACTION;
new evidence)

Forward-only except the explicit REOPENED transition, which is itself a NEW dispute row referencing the prior one (ADR-009 — no UPDATE).

Storage

New Prisma model Dispute and DisputeEvent:

model Dispute {
id String @id @default(cuid())
tenantId String
// What's being disputed.
ledgerTransactionId String // FK semantics (no Prisma FK — ledger is in Go)
ledgerAccountId String // The customer-attributable account (receivable, wallet pool)
amountSantim Decimal @db.Decimal(20, 0)
currency String @default("ETB")
// Who raised it.
raisedByType DisputeRaiser // EMPLOYEE | PARTNER_BANK | INTERNAL
raisedBySubjectId String // employee id, partner id, or operator user id
// Status + decision.
status DisputeStatus @default(OPENED)
decision DisputeDecision? // populated on terminal transition
decisionReason String?
decisionBy String? // operator user id, when applicable
// Linked artefacts.
reversalTransactionId String? // populated on RESOLVED_REVERSAL
adjustmentTransactionId String? // populated on RESOLVED_ADJUSTMENT
partnerChargebackRef String? // populated on ESCALATED_TO_PARTNER
reopensDisputeId String? // populated when this row is a REOPEN
// Compliance clocks.
customerNotifiedAt DateTime?
partnerNotifiedAt DateTime?
slaDeadline DateTime // 7d default — see §SLA below.
// Bookkeeping.
openedAt DateTime @default(now())
closedAt DateTime?

events DisputeEvent[]
@@index([tenantId, status, slaDeadline])
@@index([ledgerTransactionId])
@@map("dispute")
}

model DisputeEvent {
id BigInt @id @default(autoincrement())
disputeId String
dispute Dispute @relation(fields: [disputeId], references: [id])
// Event-sourced trail. Each transition appends one row; the latest row
// determines the dispute's effective state. No UPDATEs — ADR-009.
eventType String // OPENED, EVIDENCE_ATTACHED, INVESTIGATION_STARTED, DECIDED, NOTIFIED, ...
payload Json
actorUserId String?
occurredAt DateTime @default(now())
@@index([disputeId, occurredAt])
@@map("dispute_event")
}

Both tables are tenant-scoped + FORCE RLS (ADR-013) with the standard tenant_isolation policy.

Terminal step → adjustment-journal

When a dispute resolves with a monetary outcome:

  • RESOLVED_ADJUSTMENT: the dispute service calls Ledger.PostAdjustment (ADR-015) with adjustment_source = "BANK_DISPUTE_OUTCOME", adjustment_reason = <dispute.id>:<decisionReason>, approved_by = decisionBy. The returned transaction id is stored as Dispute.adjustmentTransactionId. The two writes happen in the same outbox-emitting NestJS transaction so a half-resolved dispute is impossible.
  • RESOLVED_REVERSAL: the dispute service calls Ledger.Reverse on ledgerTransactionId. The returned reversal transaction id is stored as reversalTransactionId.
  • CLOSED_NO_ACTION / ESCALATED_TO_PARTNER: no ledger entry; only the dispute row + event trail changes.

SLA + clocks

Each dispute carries a slaDeadline:

  • EMPLOYEE-raised: default 7 calendar days from openedAt. (NBE consumer-protection guidance for digital-financial-services complaints — to be confirmed by Compliance Lead.)
  • PARTNER_BANK-raised: default 5 business days from openedAt. (Inter-bank chargeback window.)
  • INTERNAL-raised (operator-initiated, e.g. drift hunt that turned into a customer-facing question): 14 calendar days.

Per-tenant overrides allowed; recorded in a dispute_sla_policy table (mirrors payroll_auto_lock_policy pattern from the Payroll-E2 program).

The dispute-sla-poller worker (mirrors EqubEscrowReconciliationWorker) checks at start-of-day for disputes within 24h of breaching SLA and emits dispute.sla_breach_imminent.v1 outbox events.

Authorization

RoleAction
Employee (self)POST /api/me/disputes — open a dispute against a transaction visible on their account
Org admin (tenant)GET /api/disputes, POST /api/disputes/:id/evidence — read all disputes in their tenant; attach evidence
Operator (platform support)POST /api/disputes/:id/decisions — decide a dispute (any terminal transition)
Platform adminAll of the above + POST /api/admin/disputes/:id/reopen

Decisions that flip to RESOLVED_ADJUSTMENT further require a platform-admin signature inside the PostAdjustment call (ADR-015 §3) — i.e. two named humans sign off on every dispute that mints a money-moving adjustment.

Outbox events

dispute.opened.v1
dispute.evidence_attached.v1
dispute.decided.v1 // payload includes decision + linked tx ids
dispute.sla_breach_imminent.v1
dispute.reopened.v1

Notification consumers (separate program) subscribe to dispute.opened.v1 (acknowledge the customer) and dispute.decided.v1 (notify the customer of the outcome).

Alternatives considered

A — Track disputes inside the EWA / Lending domain aggregates

Add a disputes collection on EwaRequest and Loan. Rejected: disputes can target BNPL purchases, savings withdrawals (planned), or even adjustment transactions themselves — the natural key is the ledger transaction id, not a per-domain aggregate. Pinning to one domain forces awkward cross-domain references for the dispute resolution flow.

B — Use the existing audit log as the dispute trail

Audit rows already record actor + action. Rejected: audit is unstructured per-action; a dispute needs an SLA clock, a decision-vs-outcome distinction, partner-chargeback refs, and a queryable status. Forcing this into audit overloads it and makes the dispute view a JSON-mining exercise.

C — Buy-not-build: integrate a third-party complaint-management SaaS

E.g. Zendesk + a custom workflow. Rejected for the structured-finance core: the financial decision side (ledger adjustment posting, RBAC for monetary effect, RLS, compliance retention) is non-negotiable and tightly bound to the ledger. A SaaS can sit ON TOP of Dispute for customer-comms UX, but the aggregate stays in DemozPay.

D — No dispute aggregate; resolve everything through ad-hoc operator action

Status quo. Rejected: non-auditable, breaches NBE consumer-protection record-keeping requirements, no SLA tracking, no segregation-of-duties enforcement.

Consequences

Positive

  • Customer disputes flow through a defined channel with a measurable SLA from day 1 — addresses an NBE consumer-protection requirement we cannot meet today.
  • The dispute aggregate composes with ADR-015's adjustment-journal: dispute decisions that move money do so through the audited, platform-admin-only RPC.
  • Tenant-scoped + RLS-forced. Multi-tenant disputes do not leak.
  • Outbox-eventful → a notification consumer (separate program) can hook customer-comms without coupling to the dispute service.

Negative / open questions

  • Adds a new bounded context to maintain. Per ADR-002 (packages over libs) and ADR-003 (domain package shape) this is a small addition; the dispute aggregate is genuinely independent of EWA / lending. Net: cleaner than retrofitting into existing domains.
  • The SLA values above (7d employee, 5bd partner-bank) need Compliance Lead sign-off against the actual NBE guidance. Implementation can ship with the values configurable per tenant + a platform-default; the final numbers slot in.
  • The relationship between a disputed transaction's domain state (e.g. EwaRequest.status = DISBURSED) and the dispute outcome (e.g. RESOLVED_REVERSAL) needs a separate per-domain reconciliation. We don't auto-mutate domain state when a dispute resolves — the dispute service emits dispute.decided.v1 and the EWA / lending / BNPL contexts can choose how to react (mark the request DISPUTED_RESOLVED, etc.). Each domain decides; ADR-016 doesn't.

Implementation plan

StepOwnerEstimated effort
1. New packages/dispute/ skeleton: domain (Dispute aggregate, DisputeEvent, status enum, decision enum), application (OpenDispute / AttachEvidence / DecideDispute use cases), ports (LedgerAdjustmentPort, LedgerReversalPort, OutboxPort).Eng-A2 days
2. Prisma migration: dispute + dispute_event + dispute_sla_policy tables with FORCE RLS, tenant_isolation policies.Eng-A0.5 day
3. apps/api adapters: LedgerAdjustmentAdapter (calls PostAdjustment from ADR-015), LedgerReversalAdapter (calls existing Reverse), DisputeOutboxAdapter.Eng-A1 day
4. HTTP surface: apps/api/src/dispute/{me-disputes,disputes,admin-disputes}.controller.ts + dispute-sla-worker.service.ts (start-of-day SLA scanner).Eng-A1.5 days
5. Documentation: runbook docs/runbooks/disputes-investigation.md. SLA values land as env-default + per-tenant override following the payroll_auto_lock_policy pattern.Eng-A0.5 day

Total: ~5.5 engineer-days. Depends on ADR-015 landing first (the adjustment path) and the audit/outbox primitives that already exist.

Open follow-ups

  • Per-domain dispute reactions: EWA, lending, BNPL (planned) each need a consumer of dispute.decided.v1 that decides whether to flip their domain status. Tracked separately; not blocking ADR-016.
  • Customer-comms: a notification consumer for dispute.opened.v1 / dispute.decided.v1 is part of the notifications program. Out of scope here.
  • Partner-bank chargeback adapters: integrating Dashen's chargeback API for the ESCALATED_TO_PARTNER flow is partner-by-partner work; the gateway domain absorbs it (services/integration-gateway/internal/chargeback/...). Out of scope here.