phacility/phabricator · error · Exception

Trying to refund a refund!

Error message

Trying to refund a refund!

What it means

willRefundCharge() refuses its target because $charge->getRefundedChargePHID() is set: the object passed is itself a refund record, not an original charge. Refunds of refunds are forbidden because currency signs and refunded totals only stay consistent when a refund attaches to the original charge. Always resolve back to the original charge.

Source

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

  public function willRefundCharge(
    PhabricatorUser $actor,
    PhortunePaymentProvider $provider,
    PhortuneCharge $charge,
    PhortuneCurrency $amount) {

    if (!$amount->isPositive()) {
      throw new Exception(
        pht('Trying to refund non-positive amount of money!'));
    }

    if ($amount->isGreaterThan($charge->getAmountRefundableAsCurrency())) {
      throw new Exception(
        pht('Trying to refund more money than remaining on charge!'));
    }

    if ($charge->getRefundedChargePHID()) {
      throw new Exception(
        pht('Trying to refund a refund!'));
    }

    if (($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) &&
        ($charge->getStatus() !== PhortuneCharge::STATUS_HOLD)) {
      throw new Exception(
        pht('Trying to refund an uncharged charge!'));
    }

    $refund_charge = PhortuneCharge::initializeNewCharge()
      ->setAccountPHID($this->getAccount()->getPHID())
      ->setCartPHID($this->getPHID())
      ->setAuthorPHID($actor->getPHID())
      ->setMerchantPHID($this->getMerchant()->getPHID())
      ->setProviderPHID($provider->getProviderConfig()->getPHID())
      ->setPaymentMethodPHID($charge->getPaymentMethodPHID())
      ->setRefundedChargePHID($charge->getPHID())
      ->setAmountAsCurrency($amount->negate());

View on GitHub (pinned to 5720a38cfe)

Solutions

  1. Follow $charge->getRefundedChargePHID() (up the chain) to the original charge and refund that instead.
  2. Filter refund records out of any pick-list of refundable charges.
  3. Validate getRefundedChargePHID() === null on the target before calling willRefundCharge().

Example fix

// before
$refund = $cart->willRefundCharge($actor, $provider, $target, $amount); // $target may itself be a refund row

// after
while ($target->getRefundedChargePHID()) {
  $target = id(new PhortuneChargeQuery())
    ->setViewer($viewer)
    ->withPHIDs(array($target->getRefundedChargePHID()))
    ->executeOne();
}
$refund = $cart->willRefundCharge($actor, $provider, $target, $amount);
Defensive patterns

Strategy: validation

Validate before calling

if ($charge->getRefundedChargePHID()) {
  // this row is itself a refund; resolve the original charge first
  $charge = id(new PhortuneChargeQuery())
    ->setViewer($viewer)
    ->withPHIDs(array($charge->getRefundedChargePHID()))
    ->executeOne();
}

Type guard

function isRefundCharge(PhortuneCharge $charge) {
  return $charge->getRefundedChargePHID() !== null;
}

Prevention

When it happens

Trigger: Passing the negative-amount PhortuneCharge created by an earlier willRefundCharge() back in as the refund target; a refund UI that lists all charges (including refunds) and uses the selected row without filtering.

Common situations: Charge tables mixing charges and their refund rows; double-refund attempts that grab the wrong row; generic 'refund any charge' tooling that ignores record type.

Related errors


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