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:
- No merchant account — Drop doesn't process card payments (no Visa/Mastercard chargebacks)
- Bank-to-bank transfers — Disputes go through banking system, not card networks
- Regulatory framework — PSD2 requires timely dispute resolution (1 business day for unauthorized, 8 weeks for authorized)
- Limited control — Bank makes final decision on refunds, not Drop
- Trust is everything — Users trust us with their bank access, disputes must be handled perfectly
Core principles:
- Transparency — User always knows status of dispute
- Speed — Respond within SLA (1 day unauthorized, 13 months time limit)
- Documentation — Every dispute fully documented for compliance
- Bank coordination — Work with user's bank and recipient bank
- 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:
- No chargeback network (no Visa/Mastercard dispute system)
- Bank-initiated refunds — PISP requests refund from user's bank, not from merchant
- Strong Customer Authentication (SCA) — If SCA was used, liability shifted to bank (not PISP)
- No recurring transaction liability — PISP not liable for subscription fraud if SCA used on initial transaction
1.2 Drop's Liability Model
- User claims they did NOT authorize the payment
- Drop's liability: €0 if SCA (BankID) was used correctly
- Bank's liability: Refund user within 1 business day
- Drop's action: Facilitate dispute with bank, provide transaction proof (SCA logs)
Technical failure (Drop's fault):
- Payment failed due to Drop system error (e.g., wrong amount sent)
- Drop's liability: 100% — immediate refund + compensation
- Drop's action: Refund from Drop's operational account, file incident report
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:
- Replace
datetime('now')withCURRENT_TIMESTAMP - No other changes needed
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:
- Replace
datetime('now')withCURRENT_TIMESTAMP
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:
- Replace
datetime('now')withCURRENT_TIMESTAMP
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:
- Transaction must exist and belong to user (404 if not found)
- Transaction must be completed (can't dispute pending/failed transactions)
- Dispute window: max 13 months since transaction (400 if expired)
- No duplicate dispute for same transaction (409 if exists)
- Reason: 20-2000 chars, sanitized
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:
- Calculate SLA deadline based on type + priority
- Create initial message in dispute_messages (sender_type=user, message=reason)
- Audit log:
dispute.created - If unauthorized → auto-transition to
bank_contacted+ notify bank (see Section 6.1)
GET /api/disputes
List user's disputes.
Query params:
page(default: 1)limit(default: 10, max: 50)status(optional filter)sort(created_at_desc, created_at_asc, sla_deadline_asc)
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"
}
]
}
}
- User can only view their own disputes (404 if not theirs)
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:
- Update dispute.updated_at
- If status was
evidence_requested, transition tounder_review - Audit log:
dispute.message_added
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:
- Transition to
withdrawnstatus (terminal) - Audit log:
dispute.withdrawn - System message: "User withdrew dispute: [reason]"
5.2 Admin Endpoints
All admin endpoints require requireAdmin() middleware.
GET /api/admin/disputes
List ALL disputes with filters.
Query params:
page,limitstatus(filter)priority(filter)disputeType(filter)breachSla(boolean filter: show only SLA breaches)sort(created_at_desc, sla_deadline_asc, priority_desc)
Response:
{
"data": [
{
"id": "dsp_abc123",
"userId": "usr_demo1",
"userName": "Amir Hadžić",
"userEmail": "[email protected]",
"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:
- Update dispute.updated_at
- If status changed to
resolved_approvedorresolved_denied, set resolved_at - Audit log:
dispute.status_changed - System message added to dispute_messages
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:
- Update dispute.updated_at
- If changeStatus provided, update dispute.status
- Set dispute.responded_at (first admin response)
- Audit log:
dispute.admin_reply - Email notification to user (subject: "Svar på din dispute #{id}")
- Push notification: "Drop har svart på din dispute"
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:
- Update all resolution fields
- Set resolved_at
- Audit log:
dispute.resolved - System message: "Dispute resolved: [resolutionReason]"
- Email + push notification to user
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:
- Transition to
escalated(terminal) - Set escalated_at + external_case_id
- Audit log:
dispute.escalated - Email user with FinKN contact info
6. Integration with Banking System
6.1 Unauthorized Transaction Flow
Scenario: User claims they didn't authorize payment (fraud).
Drop's actions:
-
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."
- Transition dispute to
-
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
-
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
-
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
- If refund approved → transition to
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] || "[email protected]";
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:
-
Immediate acknowledgment (within 4 hours):
- Transition to
under_review - System message: "We are investigating this issue."
- Transition to
-
Investigation:
- Check transaction logs, audit trail
- Verify actual vs claimed amount
- Check if idempotency key was reused (duplicate)
- Review PISP provider logs
-
If Drop's fault confirmed:
- Transition to
resolved_approved - Issue refund from Drop's operational account (not via bank)
- Resolution type:
refund_fullorrefund_partial - Create incident report in
admin_alertstable - Notify ops team (Slack webhook)
- Transition to
-
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)
- Transition to
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:
-
Acknowledge (within 5 business days):
- Transition to
under_review - System message: "We are reviewing your case."
- Transition to
-
Provide transaction evidence:
- Recipient details (name, bank account, country)
- Transaction timestamp and amount
- Payment confirmation from PISP
- Recommendation: "Contact recipient directly to request refund"
-
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
- Transition to
No refund from Drop — user must resolve with recipient or pursue legal action.
7. UI Pages
7.1 /disputes — Dispute Center (User)
Layout:
- Header: "Mine tvister"
- Filter tabs: All | Active | Resolved | Escalated
- Dispute list:
- Transaction summary (recipient/merchant, amount, date)
- Dispute type badge
- Status badge (color-coded)
- SLA indicator (red if breached)
- Created date
- Unread indicator if admin replied
Empty state:
- "Ingen tvister"
- "Har du et problem med en betaling? Opprett en tvist"
- CTA button: "Opprett tvist"
7.2 /disputes/new — Create Dispute (User)
Step 1: Select Transaction
- Show user's completed transactions (last 13 months)
- Filter: Remittance | QR Payment
- Each transaction shows:
- Recipient/merchant name
- Amount + date
- Status (completed)
- "Tvist denne betalingen" button
Step 2: Dispute Type
Step 3: Details
- Reason (textarea, 20-2000 chars, required)
- Claimed amount (prefilled with transaction amount, editable for incorrect_amount)
- Character count indicator
- Upload evidence (future — buttons disabled for MVP)
Step 4: Review & Submit
- Summary of dispute
- Transaction details
- Disclaimer (based on type):
- Unauthorized: "Vi vil kontakte banken din umiddelbart. Du skal få refusjon innen 1 virkedag."
- Service not received: "Dette er en kommersiell tvist mellom deg og mottakeren. Drop kan ikke refundere, men vi vil gi deg dokumentasjon."
- Technical failure: "Vi vil undersøke dette og refundere hvis det er vår feil."
- "Send tvist" button
7.3 /disputes/[id] — Dispute Detail (User)
Layout:
-
Dispute header:
- Status badge + priority badge
- Transaction summary (link to /transactions/[id])
- Dispute type
- Amount claimed
- Created date
- SLA deadline (if not resolved) — show countdown timer if < 24h
- SLA breach indicator (if missed deadline)
-
Timeline:
- Initial submission
- Status changes
- Admin responses
- Resolution (if applicable)
-
Message thread (chat-style):
- User messages (left-aligned)
- Admin messages (right-aligned, green accent)
- System messages (centered, gray)
- Timestamps
-
Action panel:
- If status =
evidence_requested: "Gi mer informasjon" button → message form - If status =
resolved_denied: "Send til Finansklagenemnda" button (see Section 7.4) - If status =
submittedorunder_review: "Trekk tilbake tvist" button - If status = terminal: No actions
- If status =
7.4 External Complaint (FinKN)
Route: /disputes/[id]/escalate
Content:
-
Heading: "Send klage til Finansklagenemnda"
-
Explanation:
- "Hvis du ikke er fornøyd med vår avgjørelse, kan du sende klagen til Finansklagenemnda (FinKN)."
- "FinKN er en uavhengig tvisteløsningsinstans for finansielle tjenester."
- "Drop vil samarbeide fullt ut med FinKN i deres undersøkelse."
-
FinKN contact info:
- Address: Postboks 53 Skøyen, 0212 Oslo
- Website: finansklagenemnda.no
- Email: [email protected]
- Phone: +47 23 13 19 60
-
"Send til FinKN" button:
- Transitions dispute to
escalatedstatus - Sends email to user with FinKN contact info + case summary
- Creates admin alert for ops team
- Transitions dispute to
7.5 /admin/disputes — Admin Dashboard
Layout:
-
Header: "Dispute Management"
-
Summary cards:
- Active disputes (count)
- SLA breaches (count, red if > 0)
- Resolved last 7 days (count)
- Average resolution time
-
Filter bar:
- Status dropdown (all, submitted, under_review, etc.)
- Priority dropdown (all, critical, high, normal, low)
- Dispute type dropdown (all, unauthorized, etc.)
- SLA breach toggle (show only breached)
- Sort dropdown (Newest, SLA Deadline, Priority)
-
Dispute table:
- ID (clickable)
- User (name + email)
- Transaction (ID + amount)
- Dispute type badge
- Status badge
- Priority badge
- Created
- SLA deadline (color-coded: green >6h, yellow 1-6h, red <1h or breached)
- Actions: "View", "Resolve"
7.6 /admin/disputes/[id] — Admin Detail
Layout:
-
Dispute header (same as user view)
-
User info: email, phone, KYC status, account created date
-
Transaction info: type, amount, recipient, bank account, completed date
-
Dispute details: reason, claimed amount, evidence
-
Status & Priority controls:
- Status dropdown (editable)
- Priority dropdown (editable)
- "Lagre endringer" button
-
Message thread (same as user view)
-
Admin action panel:
- Text area for admin reply (10 rows)
- "Change status to:" dropdown (optional)
- "Send svar" button
-
Resolution panel (if not yet resolved):
- Resolution type: dropdown (refund_full, refund_partial, no_refund, reversed_payment)
- Refund amount: input (øre)
- Refund reference: input (bank ref)
- Resolution reason: textarea
- "Resolve Dispute" button
-
Escalation panel:
- "Escalate to FinKN" button
- External case ID: input (optional)
- Reason: textarea
-
Audit trail (bottom):
- All actions from dispute_actions table
- Expandable JSON details
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
9.1 Link to Transaction Failure Spec
Spec: /Users/makinja/system/specs/drop-transaction-failure-spec.md
Integration points:
-
Transaction stuck detection:
- If transaction stuck in
processingortimeoutfor >24h → auto-create dispute with type=technical_failure - Priority: high
- Reason: "Transaction failed to complete after 24 hours"
- If transaction stuck in
-
Failed transaction with money deducted:
- If transaction status=
failedBUT bank debited user's account → user can dispute - Dispute type:
technical_failure - Drop investigates via reconciliation-worker.ts
- If transaction status=
-
Partial failure compensation:
- If transaction has
compensation_status=failed→ auto-create dispute - Dispute type:
technical_failure - Priority: critical
- Reason: "Refund failed after partial payment"
- If transaction has
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,
});
}
9.2 Link to Customer Support Spec
Spec: /Users/makinja/system/specs/drop-customer-support-spec.md
Integration points:
-
Dispute from support ticket:
- If support ticket category=
dispute→ create dispute automatically - Copy ticket description to dispute reason
- Link ticket to dispute (bidirectional)
- If support ticket category=
-
Support ticket from dispute:
- User can open support ticket from dispute detail page
- "Trenger du hjelp?" button → creates ticket with context
-
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]
);
}
9.3 Link to Complaints System
Table: complaints (already exists in db.ts)
Relationship:
- Disputes are structured (transaction-specific, formal resolution)
- Complaints are unstructured (general feedback, service quality)
Integration:
-
Complaint → Dispute:
- If complaint category=
transaction→ suggest creating dispute - "Opprett formell tvist" button in complaint detail page
- If complaint category=
-
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
- disputes table created with all fields and constraints
- dispute_messages table created with foreign key to disputes
- dispute_actions table created for audit trail
- Indexes created for performance (user_id, transaction_id, status, sla_deadline)
- Schema works for both SQLite (dev) and PostgreSQL (production)
10.2 API Endpoints
- POST /api/disputes creates dispute + calculates SLA deadline
- GET /api/disputes returns user's disputes with pagination
- GET /api/disputes/[id] returns dispute + messages + transaction + actions
- POST /api/disputes/[id]/messages adds user message
- POST /api/disputes/[id]/withdraw marks dispute withdrawn
- GET /api/admin/disputes returns all disputes (admin only)
- PATCH /api/admin/disputes/[id] updates status/priority (admin only)
- POST /api/admin/disputes/[id]/messages adds admin reply + sends email
- POST /api/admin/disputes/[id]/resolve resolves dispute with refund details
- POST /api/admin/disputes/[id]/escalate escalates to FinKN
10.3 Authorization
- Users can only view their own disputes (404 on unauthorized access)
- Admin endpoints require admin role (403 if not admin)
- Transaction ownership validated (can't dispute someone else's transaction)
- CSRF protection via validateOrigin() middleware
10.4 SLA Management
- SLA deadline calculated correctly based on dispute type + priority
- Business hours calculation (Mon-Fri 09:00-17:00, skip weekends + holidays)
- SLA breach flag set if deadline missed
- Admin dashboard shows SLA breaches prominently
10.5 UI Pages
- /disputes shows user's disputes with status filter
- /disputes/new shows multi-step dispute creation form
- /disputes/[id] shows conversation thread + action buttons
- /disputes/[id]/escalate shows FinKN contact info + escalation button
- /admin/disputes shows all disputes with filters (status, priority, type, SLA breach)
- /admin/disputes/[id] shows detail + admin action panel
10.6 Integration
- All dispute actions logged to audit_log table
- Disputes linked to transactions (bidirectional)
- Transaction detail page shows dispute status (if exists)
- Email notifications sent for dispute events (submitted, admin reply, resolved)
- Push notifications sent for status changes
- Auto-dispute created for stuck transactions (>24h)
- Support tickets can create disputes (if category=dispute)
10.7 Validation
- Transaction must be completed (can't dispute pending/failed)
- Dispute window: max 13 months since transaction
- No duplicate dispute for same transaction
- Reason: 20-2000 chars, sanitized
- Status transitions validated (finite state machine)
10.8 Edge Cases
- User can't create multiple disputes for same transaction
- Resolved disputes can't be reopened (only escalated if denied)
- Withdrawn disputes are final (can't un-withdraw)
- SLA deadline doesn't count weekends or Norwegian public holidays
- Email notifications handle missing user email gracefully
11. Implementation Order
Phase 1: Database + Types (Day 1)
- Add schemas to
src/lib/db.ts(SQLite + PostgreSQL versions) - Create
src/types/dispute.ts - Create
src/lib/dispute-utils.ts(SLA calculation, status validation) - Add
requireAdmin()tosrc/lib/middleware.ts(if not exists)
Phase 2: User API (Day 1-2)
- POST /api/disputes (create)
- GET /api/disputes (list)
- GET /api/disputes/[id] (detail)
- POST /api/disputes/[id]/messages (add message)
- POST /api/disputes/[id]/withdraw (withdraw)
Phase 3: User UI (Day 2-3)
- /disputes (list page)
- /disputes/new (multi-step creation form)
- /disputes/[id] (conversation view)
- /disputes/[id]/escalate (FinKN escalation page)
- Components: dispute-card, dispute-badge, message-bubble, sla-indicator
Phase 4: Admin API (Day 3)
- GET /api/admin/disputes (list all)
- PATCH /api/admin/disputes/[id] (update status/priority)
- POST /api/admin/disputes/[id]/messages (admin reply)
- POST /api/admin/disputes/[id]/resolve (manual resolution)
- POST /api/admin/disputes/[id]/escalate (escalate to FinKN)
Phase 5: Admin UI (Day 4)
- /admin/disputes (dashboard with filters)
- /admin/disputes/[id] (detail + action panel)
Phase 6: Integration (Day 4-5)
- Audit logging for all dispute actions
- Email notifications (submitted, admin reply, resolved)
- Push notifications for status changes
- Link to transaction failure spec (auto-dispute for stuck transactions)
- Link to support ticket spec (dispute from ticket)
Phase 7: Bank Integration (Day 5)
Phase 8: Testing + Refinement (Day 5-6)
- Manual testing of all flows (user + admin)
- Edge case validation
- UI polish (spacing, colors, responsive)
- SLA calculation accuracy test
- Email template review
12. Dependencies
12.1 Existing Infrastructure
- Database:
src/lib/db.ts(SQLite/PostgreSQL dual driver) - Auth:
src/lib/auth.ts(getCurrentUser, JWT validation) - Middleware:
src/lib/middleware.ts(requireAuth, sanitizeText, auditLog) - Utils:
src/lib/utils-server.ts(randomId) - Email: Existing email service (to be determined)
- Push notifications: FCM (Firebase Cloud Messaging) or APNS
12.2 New Dependencies
None. All features use existing infrastructure.
12.3 External Services (Future)
- Bank dispute API — For automated refund requests (unauthorized transactions)
- FinKN API — For automated case escalation (if available)
- SMS notifications — For critical dispute updates (optional)
13. Testing Checklist
13.1 User Flows
-
Create dispute (unauthorized):
- Select transaction
- Choose "unauthorized" type
- Submit reason
- Verify auto-transition to
bank_contacted - Verify email sent with SLA deadline
-
Create dispute (service not received):
- Select transaction
- Choose "service not received" type
- Submit reason
- Verify status =
submitted - Verify email sent
-
Add message to dispute:
- Open dispute detail
- Add message
- Verify status changes to
under_review(if wasevidence_requested)
-
Withdraw dispute:
- Open dispute detail
- Click "Trekk tilbake"
- Confirm
- Verify status =
withdrawn(terminal)
-
Escalate to FinKN:
- Open resolved_denied dispute
- Click "Send til FinKN"
- Verify status =
escalated - Verify email with FinKN contact info
13.2 Admin Flows
-
View all disputes dashboard:
- Filter by status, priority, type
- Sort by SLA deadline
- Verify SLA breach indicator
-
Reply to dispute:
- Open dispute detail
- Add admin message
- Change status to
under_review - Verify email sent to user
-
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
-
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
-
Escalate to FinKN (admin):
- Open dispute detail
- Click "Escalate to FinKN"
- Enter external case ID + reason
- Verify status =
escalated
13.3 Edge Cases
-
Duplicate dispute:
- Try to create second dispute for same transaction
- Verify 409 Conflict error
-
Expired dispute window:
- Try to create dispute for transaction >13 months old
- Verify 400 Bad Request error
-
SLA deadline calculation:
- Create dispute on Friday 16:00
- Verify SLA deadline is Monday 10:00 (skip weekend)
-
Authorization:
- User A tries to view User B's dispute
- Verify 404 Not Found
-
Status transition validation:
- Try to transition from
resolved_approvedtounder_review - Verify 400 Bad Request error
- Try to transition from
14. Future Enhancements (Out of Scope)
- File attachments — Allow users to upload evidence (screenshots, receipts)
- Video evidence — Record screen for fraud proof
- Multi-language support — English, Bosnian translations
- AI dispute classification — Auto-detect dispute type from user's description
- Automated refund triggers — For specific patterns (e.g., duplicate transactions)
- Bank API integration — Direct API calls instead of email for unauthorized disputes
- FinKN API integration — Automated case filing
- Dispute templates — Pre-filled forms for common issues
- Internal notes — Admin-only notes not visible to user
- 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:
- Notify user's bank immediately (within 24 hours)
- Provide transaction evidence (SCA logs, IP, device ID)
- Keep audit trail for 5 years
- Do NOT delay refund process
Liability shift:
- If SCA was performed correctly → bank liable (not Drop)
- If SCA was not performed → Drop liable (refund from operational account)
- If user was grossly negligent (shared BankID) → user liable (no refund)
15.2 PSD2 Article 74 (Complaint Handling)
Requirements:
- Respond to complaint within 15 business days
- Provide clear explanation of decision
- Inform user of right to escalate to FinKN
- FinKN contact info must be easily accessible
Drop's implementation:
- SLA: 5 business days for normal, 1 business day for unauthorized
- Email notification with resolution reason
- Escalation button in UI after
resolved_denied - FinKN contact info on
/disputes/[id]/escalatepage
15.3 GDPR Compliance
Data retention:
- Dispute records: 5 years (regulatory requirement)
- User messages: 5 years
- Audit trail: 5 years
- After 5 years: Archive to cold storage or delete (per GDPR)
User rights:
- Right to access: User can view all disputes and messages
- Right to rectification: User can add messages to correct information
- Right to erasure: Limited (regulatory retention overrides)
- Right to data portability: User can export dispute data (future)
Data minimization:
- Only collect necessary information (reason, amount, transaction ID)
- No excessive evidence requests
- File attachments limited to 5MB each (future)
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:
-
SLA breach (any dispute misses deadline)
- Channel: #ops
- Priority: high
- Message: "Dispute #{id} missed SLA deadline (type: {type}, priority: {priority}, user: {email})"
-
Critical unauthorized dispute (>10k NOK)
- Channel: #fraud
- Priority: critical
- Message: "High-value unauthorized dispute created: #{id} ({amount} NOK, user: {email})"
-
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:
- Option A: Manual bank transfer (MVP) — Admin processes refund via bank UI
- Option B: PISP reverse payment (future) — Integrate with bank API for automatic refund
- Option C: Drop holds refund balance (NOT ALLOWED — breaks pass-through model)
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:
- Option A: CEO (Alem) only
- Option B: CEO + finance team
- Option C: CEO + finance + support team
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:
- Option A: Manual (user clicks button, we send email)
- Option B: Semi-automated (user clicks button, we pre-fill FinKN web form)
- Option C: Fully automated (API integration if available)
Recommendation: Option A for MVP. Check if FinKN has API.
Q4: Dispute Notification Channels
Question: Email + push notifications both? Or only one?
Options:
- Option A: Email only (simpler, no push infra needed)
- Option B: Push only (faster, modern)
- Option C: Both (redundancy, user preference)
Recommendation: Option C. Email is fallback if user disabled push.
Q5: Dispute Evidence (Future)
Question: Should we allow file uploads (screenshots, receipts)?
Options:
- Option A: No (text only, simpler MVP)
- Option B: Yes (better evidence, but needs file storage + moderation)
Recommendation: Option A for MVP. Add file uploads in Phase 2 when we have S3/Cloudflare R2 storage.
18. Next Steps
- Review this spec with Alem
- Approve/reject sections (or request changes)
- Answer open questions (Q1-Q5)
- Prioritize phases (which to implement first?)
- Assign to builder agent (one phase at a time)
- 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:
- PSD2: Impacts and Compliance for Merchants
- What is PSD2 everything to know for compliance - Adyen
- The Payment Services Contract: PSD2 Requirements and PSD3 Perspectives - ILP Abogados
- What is PSD2? How it Impacts Banks, Businesses & Chargebacks911
- How European merchants can reduce chargebacks and protect revenue in 2026 | GR4VY
Regulatory bodies:
- Finanstilsynet (Norway) — Financial regulator
- Finansklagenemnda (FinKN) — External dispute resolution
END OF SPEC