drop-dispute-handling-spec

Drop Dispute & Chargeback Handling — Implementation Spec

Project: Drop Fintech App MC Task: #1190 Created: 2026-02-17 Author: John (Software Architect Agent) Status: DRAFT — Awaiting Alem approval


Executive Summary

This specification defines comprehensive dispute and chargeback handling for Drop's PSD2 pass-through payment system. Drop operates as a PISP (Payment Initiation Service Provider) — we initiate payments from users' bank accounts but never hold customer money.

Unique challenges for PSD2 PISP:

Core principles:

  1. Transparency — User always knows status of dispute
  2. Speed — Respond within SLA (1 day unauthorized, 13 months time limit)
  3. Documentation — Every dispute fully documented for compliance
  4. Bank coordination — Work with user's bank and recipient bank
  5. User protection — Unauthorized transactions refunded immediately (PSD2 requirement)

1. Regulatory Context (PSD2)

1.1 PSD2 Requirements for PISP Disputes

Source: Payment Services Directive 2 (EU 2015/2366), Articles 71-74

Requirement Value Enforcement
Unauthorized transaction refund Within 1 business day Mandatory — user's bank must refund
Dispute window Up to 13 months from transaction date User can dispute any time within window
Complaint response Within 15 business days PISP must respond with decision
Escalation to external body Finansklagenemnda (FinKN) If user not satisfied with resolution
Documentation retention 5 years Audit trail of all disputes

Key differences from card chargebacks:

1.2 Drop's Liability Model

Unauthorized transactions (fraud):

Authorized but disputed (service issue):

Technical failure (Drop's fault):


2. Dispute Types

2.1 Classification

Type User Claim Drop's Role Time Limit Outcome
Unauthorized "I didn't make this payment" Facilitate bank refund 13 months Bank refunds (1 day)
Incorrect amount "Wrong amount was sent" Investigate + refund if Drop error 13 months Refund if Drop fault
Duplicate payment "Charged twice" Check idempotency logs 13 months Refund duplicate
Service not received "Recipient didn't deliver" Provide evidence only 60 days (informal) Commercial dispute
Technical failure "Payment stuck/failed but money gone" Investigate + reconcile 13 months Refund if Drop fault
Refund request "Recipient agreed to refund" Facilitate reverse transfer No limit Process reverse payment

2.2 Priority Levels

Priority Definition Response SLA Examples
Critical Unauthorized transaction, large amount (>10,000 NOK) 4 hours Fraud, account takeover
High Unauthorized <10k NOK, technical failure 24 hours Wrong amount sent, duplicate charge
Normal Service not received, refund request 5 business days Merchant didn't deliver, user wants refund
Low Informational, general inquiry 15 business days "How do I dispute?", status check

3. Database Schema

3.1 disputes Table

CREATE TABLE IF NOT EXISTS disputes (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  transaction_id TEXT NOT NULL REFERENCES transactions(id),
  dispute_type TEXT NOT NULL CHECK(dispute_type IN (
    'unauthorized',
    'incorrect_amount',
    'duplicate',
    'service_not_received',
    'technical_failure',
    'refund_request'
  )),
  status TEXT DEFAULT 'submitted' CHECK(status IN (
    'submitted',       -- User filed dispute
    'under_review',    -- Drop investigating
    'evidence_requested', -- Need more info from user
    'bank_contacted',  -- Sent to bank (unauthorized cases)
    'resolved_approved', -- Dispute valid, refund issued
    'resolved_denied', -- Dispute invalid, no refund
    'escalated',       -- Sent to FinKN (external complaint)
    'withdrawn'        -- User withdrew dispute
  )),
  priority TEXT DEFAULT 'normal' CHECK(priority IN ('low','normal','high','critical')),

  -- Dispute details
  claimed_amount INTEGER NOT NULL, -- øre (amount user claims is wrong)
  actual_amount INTEGER NOT NULL,  -- øre (actual transaction amount)
  reason TEXT NOT NULL,            -- User's explanation (free text)

  -- Evidence
  evidence_files TEXT,             -- JSON array of file URLs (future)
  user_statement TEXT,             -- Detailed statement from user
  recipient_response TEXT,         -- If recipient contacted

  -- Resolution
  resolution_type TEXT CHECK(resolution_type IN (
    'refund_full',
    'refund_partial',
    'no_refund',
    'reversed_payment'
  )),
  refund_amount INTEGER,           -- øre (actual refund issued)
  refund_reference TEXT,           -- Bank reference or external_id
  resolution_reason TEXT,          -- Why resolved this way

  -- Timeline
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now')),
  responded_at TEXT,               -- When Drop first responded
  resolved_at TEXT,
  escalated_at TEXT,

  -- Compliance
  sla_deadline TEXT,               -- When response is due (24h for unauthorized)
  breach_sla INTEGER DEFAULT 0,    -- Did we miss deadline?
  external_case_id TEXT            -- FinKN case number if escalated
);

CREATE INDEX IF NOT EXISTS idx_disputes_user ON disputes(user_id);
CREATE INDEX IF NOT EXISTS idx_disputes_transaction ON disputes(transaction_id);
CREATE INDEX IF NOT EXISTS idx_disputes_status ON disputes(status);
CREATE INDEX IF NOT EXISTS idx_disputes_priority ON disputes(priority);
CREATE INDEX IF NOT EXISTS idx_disputes_sla ON disputes(sla_deadline, status);
CREATE INDEX IF NOT EXISTS idx_disputes_created ON disputes(created_at);

PostgreSQL version:

3.2 dispute_messages Table

CREATE TABLE IF NOT EXISTS dispute_messages (
  id TEXT PRIMARY KEY,
  dispute_id TEXT NOT NULL REFERENCES disputes(id) ON DELETE CASCADE,
  sender_type TEXT NOT NULL CHECK(sender_type IN ('user','admin','system')),
  sender_id TEXT,                  -- user_id or admin_id (NULL for system)
  message TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now')),

  -- Attachments (future)
  attachments TEXT                 -- JSON array of file URLs
);

CREATE INDEX IF NOT EXISTS idx_dispute_messages_dispute ON dispute_messages(dispute_id);
CREATE INDEX IF NOT EXISTS idx_dispute_messages_created ON dispute_messages(created_at);

PostgreSQL version:

3.3 dispute_actions Table (Audit Trail)

CREATE TABLE IF NOT EXISTS dispute_actions (
  id TEXT PRIMARY KEY,
  dispute_id TEXT NOT NULL REFERENCES disputes(id) ON DELETE CASCADE,
  action_type TEXT NOT NULL,       -- 'status_change', 'evidence_uploaded', 'bank_contacted', etc.
  performed_by TEXT,               -- user_id or admin_id
  performed_by_type TEXT CHECK(performed_by_type IN ('user','admin','system')),
  details TEXT,                    -- JSON details of action
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_dispute_actions_dispute ON dispute_actions(dispute_id);
CREATE INDEX IF NOT EXISTS idx_dispute_actions_type ON dispute_actions(action_type);
CREATE INDEX IF NOT EXISTS idx_dispute_actions_created ON dispute_actions(created_at);

PostgreSQL version:


4. Dispute Lifecycle

4.1 State Machine

┌──────────────┐
│  submitted   │────────────────┐
└──────┬───────┘                │
       │                        │ (withdraw)
       ▼                        ▼
┌──────────────┐          ┌───────────┐
│ under_review │          │ withdrawn │ (terminal)
└──────┬───────┘          └───────────┘
       │
       ├───────────────────┬───────────────────┬─────────────────┐
       │                   │                   │                 │
       ▼                   ▼                   ▼                 ▼
┌─────────────────┐  ┌──────────────┐  ┌──────────────────┐  ┌──────────────┐
│evidence_requested│  │bank_contacted│  │resolved_approved │  │resolved_denied│
└─────────┬───────┘  └──────┬───────┘  │   (terminal)     │  │  (terminal)  │
       │                   │            └──────────────────┘  └──────────────┘
       │                   │
       │                   ▼
       │            ┌──────────────┐
       └───────────▶│ under_review │
                    └──────┬───────┘
                           │
                           ▼
                    ┌──────────────┐
                    │  escalated   │ (terminal — sent to FinKN)
                    └──────────────┘

Valid transitions:

const VALID_TRANSITIONS = {
  submitted: ["under_review", "withdrawn"],
  under_review: ["evidence_requested", "bank_contacted", "resolved_approved", "resolved_denied", "escalated"],
  evidence_requested: ["under_review", "withdrawn"],
  bank_contacted: ["under_review", "resolved_approved", "resolved_denied"],
  resolved_approved: [],  // terminal
  resolved_denied: ["escalated"],  // can only escalate after denial
  escalated: [],  // terminal
  withdrawn: [],  // terminal
};

4.2 SLA Deadlines

Dispute Type Response SLA Resolution SLA
Unauthorized (critical) 4 hours 1 business day (bank)
Unauthorized (high) 24 hours 1 business day (bank)
Technical failure 24 hours 5 business days
Incorrect amount 24 hours 5 business days
Duplicate 24 hours 5 business days
Service not received 5 business days No formal SLA
Refund request 5 business days Depends on recipient

SLA calculation:

function calculateSlaDeadline(disputeType: string, priority: string, createdAt: Date): Date {
  const now = new Date(createdAt);

  // Business hours: Mon-Fri 09:00-17:00 CET
  // Skip weekends and Norwegian public holidays

  if (disputeType === 'unauthorized') {
    if (priority === 'critical') {
      return addBusinessHours(now, 4);  // 4 hours
    } else {
      return addBusinessHours(now, 24); // 1 business day
    }
  }

  if (['technical_failure', 'incorrect_amount', 'duplicate'].includes(disputeType)) {
    return addBusinessHours(now, 24);   // 1 business day
  }

  return addBusinessDays(now, 5);       // 5 business days
}

5. API Endpoints

5.1 User Endpoints

POST /api/disputes

Create new dispute.

Request:

{
  "transactionId": "tx_rem_123",
  "disputeType": "unauthorized",
  "reason": "I did not authorize this payment. My BankID was stolen.",
  "claimedAmount": 50000  // øre (500 NOK)
}

Response (201):

{
  "data": {
    "id": "dsp_abc123",
    "transactionId": "tx_rem_123",
    "disputeType": "unauthorized",
    "status": "submitted",
    "priority": "high",
    "claimedAmount": 50000,
    "createdAt": "2026-02-17T10:30:00Z",
    "slaDeadline": "2026-02-18T10:30:00Z"
  }
}

Validation:

Auto-priority logic:

if (disputeType === 'unauthorized' && amount > 1000000) priority = 'critical';  // >10k NOK
else if (disputeType === 'unauthorized') priority = 'high';
else if (['technical_failure', 'incorrect_amount', 'duplicate'].includes(disputeType)) priority = 'normal';
else priority = 'low';

Side effects:


GET /api/disputes

List user's disputes.

Query params:

Response:

{
  "data": [
    {
      "id": "dsp_abc123",
      "transactionId": "tx_rem_123",
      "disputeType": "unauthorized",
      "status": "under_review",
      "priority": "high",
      "claimedAmount": 50000,
      "createdAt": "2026-02-17T10:30:00Z",
      "slaDeadline": "2026-02-18T10:30:00Z",
      "breachSla": false,
      "unreadMessages": 2  // count of admin/system messages since last user visit
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 5,
    "totalPages": 1
  }
}

GET /api/disputes/[id]

Get dispute detail + conversation.

Response:

{
  "data": {
    "dispute": {
      "id": "dsp_abc123",
      "transactionId": "tx_rem_123",
      "disputeType": "unauthorized",
      "status": "bank_contacted",
      "priority": "high",
      "claimedAmount": 50000,
      "actualAmount": 50000,
      "reason": "I did not authorize this payment...",
      "createdAt": "2026-02-17T10:30:00Z",
      "slaDeadline": "2026-02-18T10:30:00Z",
      "breachSla": false
    },
    "transaction": {
      "id": "tx_rem_123",
      "type": "remittance",
      "amount": 50000,
      "currency": "NOK",
      "recipientName": "Mama Jasmina",
      "createdAt": "2026-02-10T14:00:00Z",
      "completedAt": "2026-02-10T14:01:23Z"
    },
    "messages": [
      {
        "id": "msg_1",
        "senderType": "user",
        "message": "I did not authorize this payment...",
        "createdAt": "2026-02-17T10:30:00Z"
      },
      {
        "id": "msg_2",
        "senderType": "system",
        "message": "We have contacted your bank to initiate a refund. You should receive the refund within 1 business day.",
        "createdAt": "2026-02-17T10:31:00Z"
      }
    ],
    "actions": [
      {
        "id": "act_1",
        "actionType": "status_change",
        "performedBy": "system",
        "details": "{\"from\":\"submitted\",\"to\":\"bank_contacted\"}",
        "createdAt": "2026-02-17T10:31:00Z"
      }
    ]
  }
}

Authorization:


POST /api/disputes/[id]/messages

Add user message (provide additional evidence).

Request:

{
  "message": "I have contacted my bank and they confirmed my BankID was compromised on Feb 9."
}

Response (201):

{
  "data": {
    "id": "msg_3",
    "disputeId": "dsp_abc123",
    "senderType": "user",
    "message": "I have contacted my bank...",
    "createdAt": "2026-02-17T12:00:00Z"
  }
}

Side effects:


POST /api/disputes/[id]/withdraw

User withdraws dispute.

Request:

{
  "reason": "Resolved directly with recipient"
}

Response:

{
  "data": {
    "id": "dsp_abc123",
    "status": "withdrawn",
    "withdrawnAt": "2026-02-17T13:00:00Z"
  }
}

Side effects:


5.2 Admin Endpoints

All admin endpoints require requireAdmin() middleware.

GET /api/admin/disputes

List ALL disputes with filters.

Query params:

Response:

{
  "data": [
    {
      "id": "dsp_abc123",
      "userId": "usr_demo1",
      "userName": "Amir Hadžić",
      "userEmail": "amir@example.com",
      "transactionId": "tx_rem_123",
      "disputeType": "unauthorized",
      "status": "bank_contacted",
      "priority": "high",
      "claimedAmount": 50000,
      "createdAt": "2026-02-17T10:30:00Z",
      "slaDeadline": "2026-02-18T10:30:00Z",
      "breachSla": false
    }
  ],
  "pagination": { ... },
  "summary": {
    "total": 15,
    "byStatus": {
      "submitted": 3,
      "under_review": 5,
      "bank_contacted": 2,
      "resolved_approved": 4,
      "resolved_denied": 1
    },
    "breachSla": 0
  }
}

PATCH /api/admin/disputes/[id]

Update dispute status or priority.

Request:

{
  "status": "under_review",
  "priority": "critical",
  "notes": "Escalating due to large amount"
}

Response:

{
  "data": { /* updated dispute */ }
}

Side effects:


POST /api/admin/disputes/[id]/messages

Admin reply to user.

Request:

{
  "message": "We have reviewed your case. Your bank has confirmed the refund will be processed within 24 hours.",
  "changeStatus": "resolved_approved"  // optional
}

Response (201):

{
  "data": {
    "id": "msg_4",
    "disputeId": "dsp_abc123",
    "senderType": "admin",
    "message": "We have reviewed your case...",
    "createdAt": "2026-02-17T13:00:00Z"
  }
}

Side effects:


POST /api/admin/disputes/[id]/resolve

Manually resolve dispute.

Request:

{
  "resolutionType": "refund_full",
  "refundAmount": 50000,  // øre
  "refundReference": "bank_ref_12345",
  "resolutionReason": "Bank confirmed unauthorized transaction. Refund processed.",
  "status": "resolved_approved"
}

Response:

{
  "data": {
    "id": "dsp_abc123",
    "status": "resolved_approved",
    "resolutionType": "refund_full",
    "refundAmount": 50000,
    "resolvedAt": "2026-02-17T14:00:00Z"
  }
}

Side effects:


POST /api/admin/disputes/[id]/escalate

Escalate to Finansklagenemnda (FinKN).

Request:

{
  "reason": "User not satisfied with our decision to deny refund",
  "externalCaseId": "FINKN-2026-12345"  // optional, if already filed
}

Response:

{
  "data": {
    "id": "dsp_abc123",
    "status": "escalated",
    "escalatedAt": "2026-02-17T15:00:00Z",
    "externalCaseId": "FINKN-2026-12345"
  }
}

Side effects:


6. Integration with Banking System

6.1 Unauthorized Transaction Flow

Scenario: User claims they didn't authorize payment (fraud).

Drop's actions:

  1. Immediate response (within 4 hours for critical, 24h for high):

    • Transition dispute to bank_contacted
    • Notify user's bank via API (if integrated) or email (if manual)
    • System message: "We have contacted your bank to initiate a refund."
  2. Provide evidence to bank:

    • Transaction timestamp
    • SCA (BankID) authentication logs
    • IP address, user agent, device ID
    • User's account activity (login history)
    • Any previous disputes from this user
  3. Bank investigation:

    • Bank verifies SCA was used correctly
    • If SCA valid → bank liable, refunds user within 1 business day
    • If SCA compromised → bank investigates further
  4. Drop receives bank decision:

    • If refund approved → transition to resolved_approved
    • If refund denied → transition to resolved_denied
    • Update dispute with bank's resolution_reason

API integration (future):

// lib/services/bank-dispute.ts
export async function notifyBankOfDispute(params: {
  disputeId: string;
  transactionId: string;
  userId: string;
  bankId: string;
  evidence: {
    scaLogs: string;
    ipAddress: string;
    deviceId: string;
  };
}): Promise<{ caseId: string }> {
  // Call bank's dispute API (e.g., DNB, SpareBank1)
  // For MVP: send email to bank's fraud department

  const bankEmail = BANK_FRAUD_EMAILS[params.bankId] || "fraud@bank.no";

  await email.send({
    to: bankEmail,
    subject: `Drop PISP Dispute Notification - Transaction ${params.transactionId}`,
    template: "bank-dispute-notification",
    data: {
      disputeId: params.disputeId,
      transactionId: params.transactionId,
      userId: params.userId,
      evidence: params.evidence,
    },
  });

  return { caseId: `DROP-${params.disputeId}` };
}

6.2 Technical Failure Flow

Scenario: Payment failed due to Drop system error (e.g., wrong amount sent).

Drop's liability: 100% — immediate refund required.

Actions:

  1. Immediate acknowledgment (within 4 hours):

    • Transition to under_review
    • System message: "We are investigating this issue."
  2. Investigation:

    • Check transaction logs, audit trail
    • Verify actual vs claimed amount
    • Check if idempotency key was reused (duplicate)
    • Review PISP provider logs
  3. If Drop's fault confirmed:

    • Transition to resolved_approved
    • Issue refund from Drop's operational account (not via bank)
    • Resolution type: refund_full or refund_partial
    • Create incident report in admin_alerts table
    • Notify ops team (Slack webhook)
  4. If not Drop's fault:

    • Transition to resolved_denied
    • Explain to user what happened (e.g., "Bank declined payment, no money was charged")
    • Provide evidence (transaction status logs)

Refund implementation (MVP):

// lib/services/refund.ts
export async function issueRefund(params: {
  disputeId: string;
  transactionId: string;
  amount: number;  // øre
  reason: string;
}): Promise<{ refundId: string; reference: string }> {
  // For MVP: Manual bank transfer
  // Future: Integrate with bank's refund API or PISP reverse payment

  const refundId = randomId("rfnd");

  // Create refund record
  await run(
    `INSERT INTO refunds (id, dispute_id, transaction_id, amount, reason, status, created_at)
     VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'))`,
    [refundId, params.disputeId, params.transactionId, params.amount, params.reason]
  );

  // TODO: Call bank API to initiate reverse PISP payment
  // For MVP: Create admin task to process manual refund
  await createAdminTask({
    type: "manual_refund",
    priority: "high",
    title: `Process refund for dispute ${params.disputeId}`,
    description: `Amount: ${params.amount / 100} NOK\nReason: ${params.reason}`,
    assignee: "finance_team",
  });

  return { refundId, reference: `DROP-REFUND-${refundId}` };
}

6.3 Service Not Received Flow

Scenario: User authorized payment but recipient didn't deliver goods/service.

Drop's liability: €0 — commercial dispute between user and recipient.

Actions:

  1. Acknowledge (within 5 business days):

    • Transition to under_review
    • System message: "We are reviewing your case."
  2. Provide transaction evidence:

    • Recipient details (name, bank account, country)
    • Transaction timestamp and amount
    • Payment confirmation from PISP
    • Recommendation: "Contact recipient directly to request refund"
  3. Resolution:

    • Transition to resolved_denied
    • Resolution reason: "This is a commercial dispute between you and the recipient. Drop cannot issue a refund as we do not hold funds. Please contact the recipient directly or seek legal advice."
    • Provide recipient contact info (if available)
    • Inform user of right to escalate to FinKN

No refund from Drop — user must resolve with recipient or pursue legal action.


7. UI Pages

7.1 /disputes — Dispute Center (User)

Layout:

Empty state:


7.2 /disputes/new — Create Dispute (User)

Step 1: Select Transaction

Step 2: Dispute Type

Step 3: Details

Step 4: Review & Submit


7.3 /disputes/[id] — Dispute Detail (User)

Layout:


7.4 External Complaint (FinKN)

Route: /disputes/[id]/escalate

Content:


7.5 /admin/disputes — Admin Dashboard

Layout:


7.6 /admin/disputes/[id] — Admin Detail

Layout:


8. Email Notifications

8.1 Dispute Submitted (Auto-Confirmation)

Subject: "Tvist opprettet - #{dispute_id}"

Body (Norwegian):

Hei,

Vi har mottatt din tvist angående transaksjon #{transaction_id}.

Tvisttype: {dispute_type_label}
Beløp: {claimed_amount} NOK
Status: Under behandling

{type_specific_message}

Du kan følge statusen her:
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}

Forventet responstid: {sla_deadline}

Vennlig hilsen,
Drop Kundestøtte

Type-specific messages:


8.2 Admin Response

Subject: "Svar på din tvist #{dispute_id}"

Body:

Hei,

Vi har svart på din tvist:

{admin_message}

Logg inn på Drop for å se hele samtalen:
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}

Hvis du har flere spørsmål, kan du svare direkte i tvisten.

Vennlig hilsen,
Drop Kundestøtte

8.3 Dispute Resolved

Subject (approved): "Tvist godkjent - Refusjon behandles" Subject (denied): "Tvist avslått - Se forklaring"

Body (approved):

Hei,

Din tvist har blitt godkjent.

Refusjon: {refund_amount} NOK
Referanse: {refund_reference}
Forklaring: {resolution_reason}

{refund_timeline_message}

Se detaljer:
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}

Vennlig hilsen,
Drop Kundestøtte

Body (denied):

Hei,

Din tvist har blitt avslått.

Forklaring: {resolution_reason}

Hvis du ikke er fornøyd med avgjørelsen, kan du sende klagen til Finansklagenemnda (FinKN):
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}/escalate

Vennlig hilsen,
Drop Kundestøtte

9. Integration with Existing Systems

Spec: /Users/makinja/system/specs/drop-transaction-failure-spec.md

Integration points:

  1. Transaction stuck detection:

    • If transaction stuck in processing or timeout for >24h → auto-create dispute with type=technical_failure
    • Priority: high
    • Reason: "Transaction failed to complete after 24 hours"
  2. Failed transaction with money deducted:

    • If transaction status=failed BUT bank debited user's account → user can dispute
    • Dispute type: technical_failure
    • Drop investigates via reconciliation-worker.ts
  3. Partial failure compensation:

    • If transaction has compensation_status=failed → auto-create dispute
    • Dispute type: technical_failure
    • Priority: critical
    • Reason: "Refund failed after partial payment"

Implementation:

// In reconciliation-worker.ts (from transaction failure spec)
if (tx.status === 'processing' && hoursSinceCreated > 24) {
  await createAutoDispute({
    transactionId: tx.id,
    userId: tx.user_id,
    disputeType: 'technical_failure',
    priority: 'high',
    reason: 'Transaction failed to complete after 24 hours',
    claimedAmount: tx.amount,
  });
}

Spec: /Users/makinja/system/specs/drop-customer-support-spec.md

Integration points:

  1. Dispute from support ticket:

    • If support ticket category=dispute → create dispute automatically
    • Copy ticket description to dispute reason
    • Link ticket to dispute (bidirectional)
  2. Support ticket from dispute:

    • User can open support ticket from dispute detail page
    • "Trenger du hjelp?" button → creates ticket with context
  3. Shared message thread:

    • Admin can view both support tickets and disputes in unified inbox
    • User's conversation history visible to support team

Implementation:

// In support ticket creation (from customer support spec)
if (ticketCategory === 'dispute') {
  const dispute = await createDispute({
    transactionId: ticketData.transactionId,
    userId: ticketData.userId,
    disputeType: 'service_not_received',  // default, user can change
    reason: ticketData.description,
    claimedAmount: ticketData.amount,
  });

  // Link ticket to dispute
  await run(
    "UPDATE support_tickets SET dispute_id = ? WHERE id = ?",
    [dispute.id, ticketId]
  );
}

Table: complaints (already exists in db.ts)

Relationship:

Integration:

  1. Complaint → Dispute:

    • If complaint category=transaction → suggest creating dispute
    • "Opprett formell tvist" button in complaint detail page
  2. Dispute → Complaint:

    • If user is unsatisfied with resolved_denied → can file general complaint
    • Complaint linked to original dispute for context

10. Acceptance Criteria

10.1 Database

10.2 API Endpoints

10.3 Authorization

10.4 SLA Management

10.5 UI Pages

10.6 Integration

10.7 Validation

10.8 Edge Cases


11. Implementation Order

Phase 1: Database + Types (Day 1)

  1. Add schemas to src/lib/db.ts (SQLite + PostgreSQL versions)
  2. Create src/types/dispute.ts
  3. Create src/lib/dispute-utils.ts (SLA calculation, status validation)
  4. Add requireAdmin() to src/lib/middleware.ts (if not exists)

Phase 2: User API (Day 1-2)

  1. POST /api/disputes (create)
  2. GET /api/disputes (list)
  3. GET /api/disputes/[id] (detail)
  4. POST /api/disputes/[id]/messages (add message)
  5. POST /api/disputes/[id]/withdraw (withdraw)

Phase 3: User UI (Day 2-3)

  1. /disputes (list page)
  2. /disputes/new (multi-step creation form)
  3. /disputes/[id] (conversation view)
  4. /disputes/[id]/escalate (FinKN escalation page)
  5. Components: dispute-card, dispute-badge, message-bubble, sla-indicator

Phase 4: Admin API (Day 3)

  1. GET /api/admin/disputes (list all)
  2. PATCH /api/admin/disputes/[id] (update status/priority)
  3. POST /api/admin/disputes/[id]/messages (admin reply)
  4. POST /api/admin/disputes/[id]/resolve (manual resolution)
  5. POST /api/admin/disputes/[id]/escalate (escalate to FinKN)

Phase 5: Admin UI (Day 4)

  1. /admin/disputes (dashboard with filters)
  2. /admin/disputes/[id] (detail + action panel)

Phase 6: Integration (Day 4-5)

  1. Audit logging for all dispute actions
  2. Email notifications (submitted, admin reply, resolved)
  3. Push notifications for status changes
  4. Link to transaction failure spec (auto-dispute for stuck transactions)
  5. Link to support ticket spec (dispute from ticket)

Phase 7: Bank Integration (Day 5)

  1. Unauthorized transaction flow (notify bank, provide evidence)
  2. Technical failure flow (refund from Drop operational account)
  3. Service not received flow (provide documentation, no refund)

Phase 8: Testing + Refinement (Day 5-6)

  1. Manual testing of all flows (user + admin)
  2. Edge case validation
  3. UI polish (spacing, colors, responsive)
  4. SLA calculation accuracy test
  5. Email template review

12. Dependencies

12.1 Existing Infrastructure

12.2 New Dependencies

None. All features use existing infrastructure.

12.3 External Services (Future)


13. Testing Checklist

13.1 User Flows

  1. Create dispute (unauthorized):

    • Select transaction
    • Choose "unauthorized" type
    • Submit reason
    • Verify auto-transition to bank_contacted
    • Verify email sent with SLA deadline
  2. Create dispute (service not received):

    • Select transaction
    • Choose "service not received" type
    • Submit reason
    • Verify status = submitted
    • Verify email sent
  3. Add message to dispute:

    • Open dispute detail
    • Add message
    • Verify status changes to under_review (if was evidence_requested)
  4. Withdraw dispute:

    • Open dispute detail
    • Click "Trekk tilbake"
    • Confirm
    • Verify status = withdrawn (terminal)
  5. Escalate to FinKN:

    • Open resolved_denied dispute
    • Click "Send til FinKN"
    • Verify status = escalated
    • Verify email with FinKN contact info

13.2 Admin Flows

  1. View all disputes dashboard:

    • Filter by status, priority, type
    • Sort by SLA deadline
    • Verify SLA breach indicator
  2. Reply to dispute:

    • Open dispute detail
    • Add admin message
    • Change status to under_review
    • Verify email sent to user
  3. Resolve dispute (approved):

    • Open dispute detail
    • Fill resolution panel (refund_full, amount, reference, reason)
    • Click "Resolve Dispute"
    • Verify status = resolved_approved
    • Verify email sent to user
  4. Resolve dispute (denied):

    • Open dispute detail
    • Fill resolution panel (no_refund, reason)
    • Click "Resolve Dispute"
    • Verify status = resolved_denied
    • Verify email sent to user with FinKN escalation option
  5. Escalate to FinKN (admin):

    • Open dispute detail
    • Click "Escalate to FinKN"
    • Enter external case ID + reason
    • Verify status = escalated

13.3 Edge Cases

  1. Duplicate dispute:

    • Try to create second dispute for same transaction
    • Verify 409 Conflict error
  2. Expired dispute window:

    • Try to create dispute for transaction >13 months old
    • Verify 400 Bad Request error
  3. SLA deadline calculation:

    • Create dispute on Friday 16:00
    • Verify SLA deadline is Monday 10:00 (skip weekend)
  4. Authorization:

    • User A tries to view User B's dispute
    • Verify 404 Not Found
  5. Status transition validation:

    • Try to transition from resolved_approved to under_review
    • Verify 400 Bad Request error

14. Future Enhancements (Out of Scope)

  1. File attachments — Allow users to upload evidence (screenshots, receipts)
  2. Video evidence — Record screen for fraud proof
  3. Multi-language support — English, Bosnian translations
  4. AI dispute classification — Auto-detect dispute type from user's description
  5. Automated refund triggers — For specific patterns (e.g., duplicate transactions)
  6. Bank API integration — Direct API calls instead of email for unauthorized disputes
  7. FinKN API integration — Automated case filing
  8. Dispute templates — Pre-filled forms for common issues
  9. Internal notes — Admin-only notes not visible to user
  10. Dispute analytics — Dashboard showing dispute trends, resolution rates, SLA performance

15. Compliance Notes

15.1 PSD2 Article 71 (Unauthorized Transactions)

User's rights:

Drop's obligations:

Liability shift:

15.2 PSD2 Article 74 (Complaint Handling)

Requirements:

Drop's implementation:

15.3 GDPR Compliance

Data retention:

User rights:

Data minimization:


16. Monitoring & Alerting

16.1 Metrics to Track

Metric Threshold Alert If
Active disputes (count) 10 > 50
SLA breaches (count) 0 > 0
Average resolution time (days) 3 > 7
Unauthorized disputes (%) 5% > 15%
Dispute approval rate (%) 70% < 50%
Escalations to FinKN (count) 1/month > 5/month

16.2 Dashboard Queries

Active disputes:

SELECT COUNT(*) FROM disputes
WHERE status NOT IN ('resolved_approved', 'resolved_denied', 'escalated', 'withdrawn');

SLA breaches:

SELECT COUNT(*) FROM disputes
WHERE breach_sla = 1
  AND status NOT IN ('resolved_approved', 'resolved_denied', 'escalated', 'withdrawn');

Average resolution time:

SELECT AVG(julianday(resolved_at) - julianday(created_at)) AS days
FROM disputes
WHERE resolved_at IS NOT NULL
  AND resolved_at > datetime('now', '-30 days');

16.3 Slack Alerts

When to send:

  1. SLA breach (any dispute misses deadline)

    • Channel: #ops
    • Priority: high
    • Message: "Dispute #{id} missed SLA deadline (type: {type}, priority: {priority}, user: {email})"
  2. Critical unauthorized dispute (>10k NOK)

    • Channel: #fraud
    • Priority: critical
    • Message: "High-value unauthorized dispute created: #{id} ({amount} NOK, user: {email})"
  3. Escalation to FinKN

    • Channel: #ops
    • Priority: normal
    • Message: "Dispute #{id} escalated to FinKN by {user_email}. Case ID: {external_case_id}"

17. Open Questions (For Alem)

Q1: Refund Implementation

Question: How should we handle refunds for technical failures?

Options:

Recommendation: Option A for MVP, migrate to Option B when bank APIs available.


Q2: Admin Role

Question: Who should have admin access to dispute dashboard?

Options:

Recommendation: Option C — support team needs access to respond quickly.


Q3: FinKN Escalation Process

Question: Should we automate FinKN escalation or keep it manual?

Options:

Recommendation: Option A for MVP. Check if FinKN has API.


Q4: Dispute Notification Channels

Question: Email + push notifications both? Or only one?

Options:

Recommendation: Option C. Email is fallback if user disabled push.


Q5: Dispute Evidence (Future)

Question: Should we allow file uploads (screenshots, receipts)?

Options:

Recommendation: Option A for MVP. Add file uploads in Phase 2 when we have S3/Cloudflare R2 storage.


18. Next Steps

  1. Review this spec with Alem
  2. Approve/reject sections (or request changes)
  3. Answer open questions (Q1-Q5)
  4. Prioritize phases (which to implement first?)
  5. Assign to builder agent (one phase at a time)
  6. Validation after each phase (validator agent checks implementation)

Estimated timeline: 6 days for Phases 1-6, Phase 7-8 can run in parallel.


Appendix A: State Transition Diagram (ASCII)

┌──────────────┐
│  submitted   │──────────────────────────────────┐
└──────┬───────┘                                  │
       │                                          │ (withdraw)
       ▼                                          ▼
┌──────────────┐                            ┌───────────┐
│ under_review │◄───────────┐               │ withdrawn │ (terminal)
└──────┬───────┘            │               └───────────┘
       │                    │
       ├────────────────────┼───────────────┬────────────────┐
       │                    │               │                │
       ▼                    │               ▼                ▼
┌─────────────────┐         │        ┌──────────────┐ ┌──────────────┐
│evidence_requested├─────────┘        │bank_contacted│ │resolved_denied│
└─────────┬───────┘                  └──────┬───────┘ └──────┬───────┘
       │                                   │                │
       │ (withdraw)                        │                │ (escalate)
       └──────────────────────┐            │                ▼
                              │            │          ┌──────────────┐
                              ▼            ▼          │  escalated   │ (terminal)
                        ┌───────────┐ ┌──────────────┐└──────────────┘
                        │ withdrawn │ │resolved_approved│
                        └───────────┘ │  (terminal)  │
                                      └──────────────┘

Appendix B: Norwegian Translations

English Norwegian Context
Dispute Tvist Formal complaint
Claim Krav Amount claimed
Unauthorized Uautorisert Fraud
Chargeback Tilbakeføring Refund
Resolution Avgjørelse Final decision
Escalate Eskalere Send to FinKN
Evidence Dokumentasjon Proof
Refund Refusjon Money back
Complaint Klage General issue

Appendix C: PSD2 Regulatory Sources

Primary sources:

  1. PSD2: Impacts and Compliance for Merchants
  2. What is PSD2 everything to know for compliance - Adyen
  3. The Payment Services Contract: PSD2 Requirements and PSD3 Perspectives - ILP Abogados
  4. What is PSD2? How it Impacts Banks, Businesses & Chargebacks911
  5. How European merchants can reduce chargebacks and protect revenue in 2026 | GR4VY

Regulatory bodies:


END OF SPEC


Revision #4
Created 2026-02-18 08:44:44 UTC by John
Updated 2026-06-07 20:00:21 UTC by John