phacility/phabricator · critical · Exception

Charge is in the wrong refunding state!

Error message

Charge is in the wrong refunding state!

What it means

didRefundCharge() is the completion half of Phortune's two-phase refund: it requires the charge to still point at this refund (refundingPHID === refund PHID), a pairing that willRefundCharge() establishes. A mismatch means didRefundCharge() was called without a matching willRefundCharge(), with different objects, twice, or after the state was already cleared - an integration bug in the refund flow that must be fixed, not retried.

Source

Thrown at src/applications/phortune/storage/PhortuneCart.php:379

    $charge->saveTransaction();

    return $refund_charge;
  }

  public function didRefundCharge(
    PhortuneCharge $charge,
    PhortuneCharge $refund) {

    $refund->setStatus(PhortuneCharge::STATUS_CHARGED);

    $this->openTransaction();
      $this->beginReadLocking();

        $copy = clone $charge;
        $copy->reload();

        if ($charge->getRefundingPHID() !== $refund->getPHID()) {
          throw new Exception(
            pht('Charge is in the wrong refunding state!'));
        }

        $charge->setRefundingPHID(null);

        // NOTE: There's some trickiness here to get the signs right. Both
        // these values are positive but the refund has a negative value.
        $total_refunded = $charge
          ->getAmountRefundedAsCurrency()
          ->add($refund->getAmountAsCurrency()->negate());

        $charge->setAmountRefundedAsCurrency($total_refunded);
        $charge->save();
        $refund->save();

      $this->endReadLocking();
    $this->saveTransaction();

View on GitHub (pinned to 5720a38cfe)

Solutions

  1. Implement the refund as a strict sequence: willRefundCharge() -> provider refund -> didRefundCharge() or didFailRefund(), reusing the exact objects willRefundCharge() returned.
  2. Never re-run did* steps on retry; only the provider call may be retried.
  3. When recovering half-finished state, inspect the charge's refundingPHID to decide whether to complete or clean up before calling did*.

Example fix

// before
$cart->didRefundCharge($charge, $refund); // called directly, may not match refundingPHID

// after
$fresh = clone $charge;
$fresh->reload();
if ($fresh->getRefundingPHID() === $refund->getPHID()) {
  $cart->didRefundCharge($charge, $refund);
} else {
  // state already completed or belongs to another refund; do not call did* again
}
Defensive patterns

Strategy: validation

Validate before calling

$fresh = clone $charge;
$fresh->reload();
if ($fresh->getRefundingPHID() !== $refund->getPHID()) {
  // wrong pairing or already completed; do not call didRefundCharge()
}

Type guard

function refundMatchesCharge(PhortuneCharge $charge, PhortuneCharge $refund) {
  return $charge->getRefundingPHID() === $refund->getPHID();
}

Prevention

When it happens

Trigger: Calling didRefundCharge($charge, $refund) for a refund never opened by willRefundCharge(); calling it a second time after a first completion; passing charge/refund objects from different operations; a provider implementation that retries the completion step after an error.

Common situations: Custom payment providers hand-writing the refund sequence instead of pairing will/did calls; queue retries that re-run the whole sequence; state left half-finished by an earlier crash being finished with mismatched objects.

Related errors


AI-assisted analysis of phacility/phabricator@5720a38cfe (2026-08-21). Data as JSON: /api/errors/0cb3327c435995ed. Report an issue: GitHub.