Concorbit HelpAll guides →

Disputes

Disputes

A dispute is the Stripe Connect lifecycle around a chargeback: the cardholder's bank is asking for the money back, and Stripe gives you a window to respond with evidence. concorbit's dispute workflow records every charge.dispute.* event Stripe delivers and freezes the invoice's collection while the dispute is open. Money movement is handled separately by charge.refunded, the dispute workflow is workflow-only by design.

The architectural rule: there is exactly one authority for moving money on an invoice, and it is charge.refunded. The dispute workflow never writes a Payment row of its own. This is the v2-onwards decision that prevents double-debit.

What a dispute does

When a dispute event lands for a PaymentIntent that maps to a concorbit invoice:

  • A Dispute row is upserted (per-dispute lockForUpdate inside a DB transaction, savepoint for 23505 unique-violation recovery).

  • The invoice's collection is frozen:

    • AutopayService::isEligibleForAutopay returns false.

    • RecurringBillingAgreementService::assertChargeable throws InvoiceUnderDisputeException.

    • StripeConnectService::createInvoicePaymentIntent throws on the portal pay-now path.

    • StripeConnectService::refund throws (no admin refund via the concorbit UI while a dispute is open, the Stripe Dashboard is the authoritative surface).

  • No automatic refund is written. Stripe handles the money movement via charge.refunded.

The freeze is intentional. While the customer's bank is asking for the money back, pushing another charge through (or a concorbit-side refund) would compound the mess.

The status lifecycle

Stripe's vocabulary is mirrored on disputes.status:

StatusMeaning
warning_needs_responseEarly-warning, evidence required.
warning_under_reviewEarly-warning, Stripe is reviewing.
warning_closedEarly-warning resolved, no formal dispute opened.
needs_responseFormal dispute opened, evidence required.
under_reviewEvidence submitted, Stripe and the bank are reviewing.
wonDispute won, funds retained.
lostDispute lost, funds clawed back.
charge_refundedOperator refunded before dispute closed.

Terminal states (won, lost, charge_refunded, warning_closed) are recorded with the timestamp. The freeze drops on terminal-won; on terminal-lost the money has already moved through charge.refunded and the ledger reflects that.

Out-of-order events

Stripe occasionally delivers events out of order (network blip, re-queue). The dispute workflow guards against this with a (last_stripe_event_at, last_stripe_event_id) tuple on the dispute row. A later-event-id with the same timestamp still applies; an earlier-timestamp event is dropped. The terminal-after-different-terminal case (rare, e.g. won after lost) surfaces an operator notification rather than re-writing silently, Stripe almost never reopens disputes and a contradictory terminal is a signal something needs human review.

The concorbit dispute UI shows a deep link to the Stripe Dashboard for the dispute id. This is deliberate, the Stripe Dashboard is the authoritative surface for:

  • Uploading evidence.

  • Adjusting the contestable amount.

  • Accepting the dispute (giving up early to save Stripe fees).

concorbit does not embed a Stripe evidence form. The dashboard is much richer and more current than anything we could embed.

Portal copy

When a customer views an invoice that is under dispute in the client portal, the page renders a neutral message. No accusatory language, no concorbit-side narrative about the dispute outcome, just a calm "this invoice is currently under review with your card issuer". The concorbit-side narrative would risk getting ahead of Stripe's actual determination.

Operator notifications

Three notification events fire from the dispute workflow:

EventTrigger
billing.dispute.external_governanceA dispute landed for an invoice that is AT-governed or QB-governed. concorbit records the dispute row but does not own the lifecycle; the operator needs to handle it in AT or QB.
billing.dispute.terminal_reopenA different terminal status arrived after a terminal one (e.g. won after lost). Needs human review.
billing.refund.repair_requiredcharge.refunded arrived but the original settlement row could not be found. The ledger could not write the signed-negative row; an operator needs to repair the lineage.

These route through the standard notification stack. Operators with billing permissions see them in the platform admin notifications surface.

Governance interaction

Disputes are recorded for all invoices, regardless of governance:

  • concorbit-governed: full workflow + freeze applies.

  • Autotask-governed or QuickBooks-governed: persist a dispute row with the frozen external_governance value and emit a billing.dispute.external_governance notification. The Stripe money movement still flows through charge.refunded, but concorbit's lifecycle does not drive AT's or QB's invoice. Resolve in the system that owns the invoice.

The external_governance value is frozen on the dispute row at insert time, so even if the invoice's governance later flips, the dispute row keeps its original context.

Why no auto-refund

A dispute.lost does not trigger a concorbit refund. The money has already left the connected account, Stripe took it back, and the charge.refunded event that accompanies the lost dispute writes the signed-negative ledger row (idempotent on the refund id). Doing it again from the dispute path would be the double-debit defect.

This is the single-authority rule in one sentence: the dispute workflow records the lifecycle; charge.refunded moves money.