Forhopp

ForhoppPay Connect — Disputes

Feature Reference: FR-29 | Service: ConnectDisputeService


1. Overview

When a customer disputes a charge with their bank (chargeback), ForhoppPay creates a dispute record, holds the disputed funds from payouts, and notifies the merchant via webhook. Merchants can submit evidence through the dashboard. Disputes resolve as WON (merchant keeps funds) or LOST (funds returned to customer).


2. Dispute ID Format

dp_[a-zA-Z0-9]{32}

Example: dp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6


3. Dispute Lifecycle

Customer files dispute
    │
    ▼
┌────────┐
│  OPEN  │ ← Funds held, merchant notified
└───┬────┘
    │ Merchant submits evidence
    ▼
┌──────────────┐
│ UNDER_REVIEW │ ← Evidence being reviewed
└───┬──────┬───┘
    │      │
    ▼      ▼
┌─────┐  ┌──────┐
│ WON │  │ LOST │
└─────┘  └──────┘
  │          │
  │          └─ Funds returned to customer
  └─ Funds released to merchant

4. Dispute Statuses

Status Description Funds Terminal
OPEN Dispute opened, evidence needed Held No
UNDER_REVIEW Evidence submitted, being reviewed Held No
WON Resolved in merchant's favor Released to merchant Yes
LOST Resolved in customer's favor Returned to customer Yes

5. Fund Holds

When a dispute is created:

  1. The charge status changes to DISPUTED
  2. The disputed amount is excluded from payout calculations
  3. disputeRepository.sumActiveDisputedAmount(merchantAccountId) tracks total held amount

Active disputes (OPEN or UNDER_REVIEW) have their amounts held. The payout service deducts this from available balance:

Payout = Captured - Fees - Refunds - ActiveDisputedAmounts

6. Dispute Creation

6.1 Internal Flow

ConnectDisputeService.createDispute(chargeId, merchantAccountId, reason, disputedAmountCents)

6.2 Validation Rules

Rule Constraint
Charge status Must be CAPTURED or PARTIALLY_REFUNDED
Existing dispute Charge must not have an active dispute
Disputed amount > 0 and ≤ charge amount
Default amount Full charge amount if not specified

6.3 Side Effects

  • Charge status → DISPUTED
  • Charge disputed_at timestamp set
  • Charge dispute reference set
  • charge.disputed webhook dispatched

7. Evidence Submission

ConnectDisputeService.submitEvidence(disputeId, merchantAccountId, evidence)

Evidence is a Map<String, Object> that can include:

  • Proof of delivery tracking numbers
  • Customer communication records
  • Terms of service references
  • Product/service descriptions
  • Shipping confirmation

Submitting evidence automatically moves the dispute from OPEN to UNDER_REVIEW.


8. Dispute Resolution

8.1 Merchant Wins

ConnectDisputeService.resolveWon(disputeId, merchantAccountId, resolutionDetails)
  • Dispute status → WON
  • Charge status → CAPTURED (restored)
  • Funds released back to merchant's available balance
  • Dispute fee ($15) refunded

8.2 Merchant Loses

ConnectDisputeService.resolveLost(disputeId, merchantAccountId, resolutionDetails)
  • Dispute status → LOST
  • Charge remains DISPUTED
  • Funds returned to customer
  • Dispute fee ($15) retained

9. Dispute Fee

$15.00 (1500 cents) per dispute
  • Charged when dispute is opened
  • Refunded if merchant wins
  • Retained if merchant loses

10. Querying Disputes

Method Description
getDispute(disputeId, merchantAccountId) Get single dispute
listDisputes(merchantAccountId, pageable) List all disputes
listDisputesByStatus(merchantAccountId, status, pageable) Filter by status
getActiveDisputes(merchantAccountId) Get OPEN + UNDER_REVIEW
getHeldDisputeAmount(merchantAccountId) Total held amount in cents
hasActiveDispute(chargeId) Check if charge has active dispute

11. Database Schema

CREATE TABLE forhopppay.merchant_disputes (
    id                   BIGSERIAL PRIMARY KEY,
    dispute_id           VARCHAR(50) NOT NULL UNIQUE,
    charge_id            BIGINT NOT NULL REFERENCES forhopppay.merchant_charges(id),
    merchant_account_id  BIGINT NOT NULL REFERENCES forhopppay.forhopppay_accounts(id),
    amount_cents         BIGINT NOT NULL,
    currency             VARCHAR(3) NOT NULL DEFAULT 'USD',
    reason               VARCHAR(100),
    status               VARCHAR(30) NOT NULL DEFAULT 'OPEN'
        CHECK (status IN ('OPEN', 'UNDER_REVIEW', 'WON', 'LOST')),
    evidence_details     JSONB,
    resolution_details   JSONB,
    evidence_due_by      TIMESTAMP,
    evidence_submitted_at TIMESTAMP,
    resolved_at          TIMESTAMP,
    created_at           TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at           TIMESTAMP
);

CREATE INDEX idx_merchant_disputes_charge ON forhopppay.merchant_disputes(charge_id);
CREATE INDEX idx_merchant_disputes_merchant ON forhopppay.merchant_disputes(merchant_account_id);
CREATE INDEX idx_merchant_disputes_status ON forhopppay.merchant_disputes(status);

12. Webhook Events

Event When
charge.disputed Dispute opened

The webhook payload includes the full charge object with status: "disputed".


13. Correctness Properties

# Property Validates
38 Disputed amount held until resolution FR-29.4
39 Status is one of {open, under_review, won, lost} FR-29.3

Last Updated: March 2026

Was this page helpful?

Edit this page