Dispute Resolution

How buyers open disputes and admins resolve them

What is Dispute Resolution?

Dispute resolution allows a buyer to flag a problem with a delivered or completed offer. An admin then resolves the dispute with one of three outcomes: full payment to talent, full refund to brand, or a partial resolution.

Dispute Lifecycle

DELIVERED or COMPLETED
        ↓ Buyer opens dispute
    DISPUTED
        ↓ Admin resolves
    COMPLETED (with resolution note)

A dispute can be opened from either DELIVERED (before the buyer has approved) or COMPLETED (after approval, if a problem is discovered later).

Opening a Dispute

Who: The buyer (offer's buyerAccountId)

Endpoint: POST /api/offers/dispute

Required fields: offerId and reason

What happens:

  1. Validates the caller is the buyer and the offer status is DELIVERED or COMPLETED
  2. Transitions the offer to DISPUTED via updateOfferStatus
  3. Creates a DISPUTE_OPENED notification for the seller (routed through agency if applicable)
  4. Creates a DISPUTE_OPENED notification for each admin account listed in OFFER_ADMIN_NOTIFY_ACCOUNT_IDS

Implementation: apps/zooly-app/app/api/offers/dispute/route.ts

Admin Notification Routing

Admin account IDs are read from the OFFER_ADMIN_NOTIFY_ACCOUNT_IDS environment variable (comma-separated). Each admin receives a separate notification.

Resolving a Dispute

Who: Admin only (via assertAdmin)

Endpoint: POST /api/offers/admin/resolve-dispute

Required fields: offerId, resolution

Resolution types:

ResolutionWhat happensFinancial outcome
talent_winsCalls releaseOfferEscrowForOfferFull payment released to talent
brand_winsCalls processRefund on paymentStripeIdFull refund to brand
partialCalls processRefund, then transitions with partial amount noteFull refund + record of partial amount owed to talent

talent_wins

Releases the escrow by calling releaseOfferEscrowForOffer(offerId, buyerAccountId) from @zooly/srv-stripe-payment. This creates share tracking records and the daily payout daemon will handle the actual Stripe Connect transfer.

brand_wins

Requires offer.paymentStripeId to exist. Calls processRefund(paymentStripeId) from @zooly/srv-stripe-payment for a full refund. Transitions the offer to COMPLETED with a note indicating full refund.

partial

Requires partialAmountCent in the request body. Processes a full refund via processRefund, then transitions the offer to COMPLETED with a note recording the partial amount. The actual partial payment to talent is handled out-of-band in V1.

Notifications

All three resolution types create DISPUTE_RESOLVED notifications for both the seller and buyer with resolution-specific messages.

Implementation: apps/zooly-app/app/api/offers/admin/resolve-dispute/route.ts

DB Access Functions Used

FunctionPackagePurpose
getOfferById@zooly/dbLoad offer for validation
updateOfferStatus@zooly/dbTransition to DISPUTED or COMPLETED
createNotification@zooly/dbCreate notifications for all parties
getNotificationRecipient@zooly/dbAgency routing for seller notifications
releaseOfferEscrowForOffer@zooly/srv-stripe-paymentRelease escrowed funds to talent
processRefund@zooly/srv-stripe-paymentStripe refund processing