The Dispute Resolution System is a comprehensive, event-driven, and financially-safe module for handling split payment disputes. It implements a full dispute lifecycle with automatic split freezing, participant notifications, evidence management, and multi-level review workflows.
Core Features:
- ✅ Automatic split freezing when disputes are created
- ✅ Event-driven architecture for scalable notifications
- ✅ Complete audit trail for compliance
- ✅ State machine-based workflow validation
- ✅ Appeal mechanism with limited time window
- ✅ Atomic transactions ensuring financial consistency
- ✅ Role-based access control (admin-only resolution)
- ✅ Idempotent operations for reliability
id: UUID
splitId: UUID (FK → Split)
raisedBy: string (wallet address)
disputeType: enum
- incorrect_amount
- missing_payment
- wrong_items
- other
description: text
status: enum
- open
- evidence_collection
- under_review
- resolved
- rejected
- appealed
evidence: JSONB (array of evidence references)
resolution: text nullable
resolvedBy: string nullable (admin wallet/ID)
resolvedAt: timestamp nullable
resolutionOutcome: JSONB nullable
- outcome: adjust_balances | refund | cancel_split | no_change
- details: transaction details
- executedAt: timestamp
originalDisputeId: UUID nullable (if appealed)
appealReason: text nullable
appealedAt: timestamp nullable
auditTrail: JSONB (array of audit entries)
splitFrozen: boolean
createdAt: timestamp
updatedAt: timestamp
deletedAt: timestamp (soft delete)id: UUID
disputeId: UUID (FK → Dispute)
uploadedBy: string (wallet address)
fileKey: string (S3/Azure Blob reference)
fileName: string
mimeType: string
size: bigint (bytes)
description: text nullable
metadata: JSONB (checksums, IP address, etc.)
createdAt: timestampisFrozen: boolean (new field)
- When true: blocks new payments, withdrawals, distributions
- Set by dispute creation
- Cleared by dispute resolution/rejection/cancellationValid dispute status transitions:
┌─────────────────────────────────────────────────────────┐
│ Dispute State Machine │
├─────────────────────────────────────────────────────────┤
│ │
│ OPEN ─────────────→ EVIDENCE_COLLECTION │
│ │ │
│ ↓ │
│ UNDER_REVIEW ─────→ RESOLVED │
│ │ │ │
│ ├─────────→ REJECTED │ │
│ │ │ │
│ ↓ ↓ │
│ APPEALED ←──────┘
│ │ │
│ ↓ │
│ UNDER_REVIEW (restart) │
│ │
└─────────────────────────────────────────────────────────┘
Valid Transitions:
- OPEN → EVIDENCE_COLLECTION
- EVIDENCE_COLLECTION → UNDER_REVIEW
- UNDER_REVIEW → RESOLVED or REJECTED
- RESOLVED or REJECTED → APPEALED
- APPEALED → UNDER_REVIEW (new review cycle)
Invalid Transitions (throw BadRequestException):
- Any reverse transitions
- Skipping intermediate states
- Transitions from OPEN directly to RESOLVED
Endpoint: POST /disputes
Request:
{
"splitId": "550e8400-e29b-41d4-a716-446655440000",
"disputeType": "incorrect_amount",
"description": "The amount charged exceeds the itemized list by $25"
}Actions (Single Transaction):
- Verify split exists and has no active disputes
- Create dispute in
OPENstatus - Add audit trail entry
- FREEZE split (
isFrozen = true) - Emit
DisputeCreatedEvent(triggers notifications) - Emit
SplitFrozenEvent(audit trail)
Response: Dispute object with full audit trail
Transactional Safety:
- If split freeze fails, entire operation rolls back
- Dispute and freeze are atomic
Endpoint: POST /disputes/:disputeId/evidence
Constraints:
- Only allowed in
OPENorEVIDENCE_COLLECTIONstatus - Multiple evidence items supported
- File references stored (not binary data in DB)
- Metadata tracked (uploader, timestamp, size, mime type)
Request:
{
"fileKey": "s3://bucket/evidence/receipt-123.jpg",
"fileName": "receipt.jpg",
"mimeType": "image/jpeg",
"size": 2048,
"description": "Original receipt",
"metadata": {
"ipAddress": "192.168.1.1",
"uploadedFrom": "Mobile App v2.1"
}
}Actions:
- Validate dispute allows evidence
- Create
DisputeEvidencerecord - Add to dispute's evidence array
- Add audit trail entry
- Emit
DisputeEvidenceAddedEvent
Response: Evidence object with ID and timestamps
Endpoint: POST /disputes/:disputeId/submit-review
State Transition: EVIDENCE_COLLECTION → UNDER_REVIEW
Actions:
- Validate state transition
- Update dispute status
- Add audit trail entry
- Emit
DisputeUnderReviewEvent(triggers admin notifications)
Endpoint: POST /disputes/:disputeId/resolve (Admin only)
Request:
{
"outcome": "adjust_balances",
"resolution": "Participant verified to have overpaid. Credit $25.",
"details": {
"creditAmount": 25,
"currency": "USD",
"recipientWallet": "GXXXXX"
}
}Possible Outcomes:
adjust_balances: Update participant balancesrefund: Issue refund to participantcancel_split: Cancel entire split (rare)no_change: Dismiss dispute, no changes
Actions (Atomic Transaction):
- Validate state transition (
UNDER_REVIEW→RESOLVED) - Update dispute with resolution details
- Add audit trail entry
- UNFREEZE split (
isFrozen = false) - Emit
DisputeResolvedEvent(triggers notifications & payment processing) - Emit
SplitUnfrozenEvent(audit)
Transactional Safety:
- If financial update fails, dispute resolution is rolled back
- Split remains frozen until successful resolution
Endpoint: POST /disputes/:disputeId/reject (Admin only)
Request:
{
"reason": "Insufficient evidence provided. Claim lacks receipt or proof of overpayment."
}Actions (Atomic Transaction):
- Validate state transition (
UNDER_REVIEW→REJECTED) - Update dispute status and reason
- Add audit trail entry
- UNFREEZE split
- Emit
DisputeRejectedEvent - Emit
SplitUnfrozenEvent
Result: Split returns to normal, dispute cannot be re-opened (but can be appealed)
Endpoint: POST /disputes/:disputeId/appeal
Constraints:
- Only dispute creator can appeal
- Limited to 30 days from resolution
- Creates new review cycle
Request:
{
"appealReason": "The resolution was unfair. Additional evidence was ignored during review."
}Actions (Atomic Transaction):
- Verify appealer is dispute creator
- Verify within 30-day window
- Update dispute to
APPEALEDstatus - FREEZE split again (
isFrozen = true) - Record appeal reason and timestamp
- Add audit trail entry
- Emit
DisputeAppealedEvent - Emit
SplitFrozenEvent
Result: Dispute returns to UNDER_REVIEW for re-evaluation by another admin
Endpoint: POST /disputes/:disputeId/request-evidence (Admin only)
Request:
{
"evidenceRequest": "Please provide photos of items received, packing slip, and shipping documentation"
}Actions:
- Add audit trail entry
- Emit
MoreEvidenceRequestedEvent - Notify dispute participants
Result: Participants notified to submit additional evidence
The system ensures that the following operations are atomic (all-or-nothing):
-
Dispute Creation + Split Freeze
- If either fails, both rollback
- No orphaned disputes
- No partially frozen splits
-
Dispute Resolution + Financial Updates + Split Unfreeze
- All three succeed or all rollback
- Ledger consistency guaranteed
- No double-spending
-
Dispute Appeal + Split Re-freeze
- Both succeed or both rollback
- No disputes stuck in limbo
// Example: Atomic dispute creation
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. Create dispute
const dispute = await queryRunner.manager.save(Dispute, {...});
// 2. Freeze split in same transaction
await queryRunner.manager.update(Split,
{ id: splitId },
{ isFrozen: true }
);
await queryRunner.commitTransaction();
// 3. Emit events (after transaction succeeds)
this.eventEmitter.emit('dispute.created', ...);
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
}- ✅ Dispute active (any non-resolved status)
- ✅ Block new payments from any participant
- ✅ Block withdrawals/distributions
- ✅ Block modification of split terms
- ✅ Allow viewing split details
- ✅ Allow adding evidence
- ✅ Dispute resolved, rejected, or dismissed
- ✅ Normal operations resume
- ✅ Financial updates (credits, refunds) applied
- ✅ Participants can resume payments
// In payments service - before creating payment
async createPayment(dto: CreatePaymentDto) {
const split = await this.splitRepository.findOne(dto.splitId);
if (split.isFrozen) {
throw new BadRequestException(
'Cannot create payment: split is frozen due to active dispute'
);
}
// ... continue with payment creation
}| Event | When | Listeners | Action |
|---|---|---|---|
dispute.created |
Dispute filed | Notification Queue | Notify all split participants & admins |
dispute.evidence_added |
Evidence uploaded | Admin Notifications | Notify reviewers of new evidence |
dispute.under_review |
Submitted for review | Admin Notifications | Route to review queue |
dispute.resolved |
Admin resolves | Notification Queue | Notify all parties with outcome |
dispute.rejected |
Admin rejects | Notification Queue | Notify participants |
dispute.appealed |
Participant appeals | Admin Notifications | Route to admin for re-review |
more_evidence_requested |
Admin requests | Notification Queue | Notify participants to submit evidence |
split.frozen |
When split frozen | Audit Logger | Log freeze event |
split.unfrozen |
When split unfrozen | Audit Logger | Log unfreeze event |
// Event listener queues notifications
@OnEvent('dispute.created')
async handleDisputeCreated(event: DisputeCreatedEvent) {
await this.notificationQueue.add('DISPUTE_CREATED', {
disputeId: event.dispute.id,
splitId: event.dispute.splitId,
participants: [...split participants...],
template: 'dispute_created.html'
});
}
// Worker processes queue asynchronously
@Process('DISPUTE_CREATED')
async processDisputeNotification(job: Job) {
const { disputeId, participants, template } = job.data;
for (const participant of participants) {
await this.emailService.send({
to: participant.email,
template,
subject: `Dispute filed for your split`
});
}
}Every dispute action is recorded with full context:
auditTrail: [
{
action: "dispute_created",
performedBy: "GXXXXX",
performedAt: "2024-02-20T10:30:00Z",
details: {
type: "incorrect_amount",
description: "..."
}
},
{
action: "evidence_added",
performedBy: "GXXXXX",
performedAt: "2024-02-20T10:35:00Z",
details: {
evidenceId: "evidence-123",
fileName: "receipt.jpg"
}
},
{
action: "submitted_for_review",
performedBy: "GXXXXX",
performedAt: "2024-02-20T10:40:00Z",
details: {
evidenceCount: 2
}
},
{
action: "dispute_resolved",
performedBy: "admin-wallet",
performedAt: "2024-02-20T11:20:00Z",
details: {
outcome: "adjust_balances",
resolution: "..."
}
}
]Endpoint: GET /disputes/:disputeId/audit-trail
Response:
[
{
"action": "dispute_created",
"performedBy": "GXXXXX",
"performedAt": "2024-02-20T10:30:00Z",
"details": {...}
},
...
]| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /disputes |
File new dispute | Any participant |
| GET | /disputes/:disputeId |
Get dispute details | Public |
| GET | /disputes/split/:splitId |
Get disputes for split | Public |
| GET | /disputes |
List all disputes | Admin only |
| POST | /disputes/:disputeId/evidence |
Add evidence | Participant |
| GET | /disputes/:disputeId/evidence |
List evidence | Public |
| POST | /disputes/:disputeId/submit-review |
Submit for review | Participant |
| POST | /disputes/:disputeId/resolve |
Resolve dispute | Admin only |
| POST | /disputes/:disputeId/reject |
Reject dispute | Admin only |
| POST | /disputes/:disputeId/appeal |
Appeal decision | Dispute creator |
| POST | /disputes/:disputeId/request-evidence |
Request more evidence | Admin only |
| GET | /disputes/:disputeId/audit-trail |
Get audit history | Public |
npm test -- disputes.service.spec.tsTests covered:
- ✅ Dispute creation freezes split
- ✅ Invalid state transitions rejected
- ✅ Evidence upload linked correctly
- ✅ Resolution updates status and unfreezes split
- ✅ Appeal restarts review flow
- ✅ State machine validation
- ✅ Audit trail accuracy
npm test -- disputes.integration.spec.tsTests covered:
- ✅ Full dispute lifecycle
- ✅ Event emission
- ✅ Database transactions
- ✅ API endpoints
- ✅ Error handling
- ✅ Admin operations
- ✅ Query filtering
| Status | Error | Cause | Solution |
|---|---|---|---|
| 400 | Invalid state transition | Wrong dispute status | Check current status first |
| 400 | Cannot add evidence | Dispute in wrong status | Move to evidence collection |
| 404 | Dispute not found | Invalid dispute ID | Verify dispute ID |
| 409 | Active dispute exists | Split already has dispute | Resolve existing dispute first |
| 403 | Forbidden | Insufficient permissions | Use admin account for resolution |
| 422 | Validation error | Missing required fields | Check DTO validation |
-
Split Freeze Enforcement
- Always check
isFrozenbefore accepting payments - Cannot bypass freeze except through resolution
- Always check
-
Admin-Only Operations
- Resolution and rejection require admin role
- Use role-based guards on controllers
-
Soft Deletes
- Disputes use soft deletes for compliance
- Preserves full audit trail
-
Evidence Storage
- Only file references stored in DB
- Use object storage (S3, Azure Blob) for files
- Enforce size limits (max 50MB per file)
-
Appeal Window
- Hardcoded 30-day limit prevents indefinite disputes
- Returns
BadRequestExceptionif exceeded
-
Idempotency
- All operations can be safely retried
- No double-operations due to duplicate requests
Run migration to create tables:
npm run typeorm migration:runMigration creates:
disputestable with indexesdispute_evidencetable with foreign keyssplits.isFrozencolumn (default: false)
- Automated evidence validation (OCR, fraud detection)
- Multi-level admin review (escalation chain)
- Financial escrow during disputes
- Integration with dispute resolution partners
- Machine learning for pattern detection
- Blockchain-backed audit trail
- Real-time dispute notifications (WebSocket)
- Video evidence support
- Legal document templates
- Payments: Blocked when split is frozen
- Email: Sends dispute notifications
- Analytics: Tracks dispute metrics
- Security: Role-based access control
- Webhooks: External dispute updates
For questions or issues:
- Check audit trail for detailed action history
- Review state machine transitions
- Verify split freeze status
- Check event logs for notifications