phacility/phabricator · error · Exception

Trying to refund an uncharged charge!

Error message

Trying to refund an uncharged charge!

What it means

willRefundCharge() only accepts charges whose status is STATUS_CHARGED or STATUS_HOLD; any other status throws 'Trying to refund an uncharged charge!'. Money must have been captured (or is being held pending review) before Phortune can refund it. Authorized-but-unpaid, pending, and failed charges are all rejected.

Source

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

    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());

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

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

View on GitHub (pinned to 5720a38cfe)

Solutions

  1. Wait until the charge reaches STATUS_CHARGED (or STATUS_HOLD for held charges) before exposing refund actions.
  2. Gate refund UI on $charge->getStatus() being PhortuneCharge::STATUS_CHARGED or STATUS_HOLD.
  3. For held charges, remember that a refund releases the hold rather than moving money back.

Example fix

// before
$refund = $cart->willRefundCharge($actor, $provider, $charge, $amount); // throws unless CHARGED/HOLD

// after
$refundable = in_array(
  $charge->getStatus(),
  array(PhortuneCharge::STATUS_CHARGED, PhortuneCharge::STATUS_HOLD));
if ($refundable) {
  $refund = $cart->willRefundCharge($actor, $provider, $charge, $amount);
} else {
  $errors[] = pht('This charge has not been captured yet and cannot be refunded.');
}
Defensive patterns

Strategy: validation

Validate before calling

$ok = in_array(
  $charge->getStatus(),
  array(PhortuneCharge::STATUS_CHARGED, PhortuneCharge::STATUS_HOLD));
if (!$ok) {
  // charge has not been captured; do not offer or perform a refund
}

Type guard

function isRefundableCharge(PhortuneCharge $charge) {
  return in_array(
      $charge->getStatus(),
      array(PhortuneCharge::STATUS_CHARGED, PhortuneCharge::STATUS_HOLD))
    && $charge->getRefundedChargePHID() === null;
}

Prevention

When it happens

Trigger: Calling willRefundCharge() on a charge that is still STATUS_AUTHORIZED/pending because the provider flow has not completed, or on a STATUS_FAILED charge, or from stale data where the charge never reached CHARGED.

Common situations: Refund buttons rendered too early in an asynchronous payment flow; test charges that never succeeded; retries of a failed charge being mistaken for a completed one.

Related errors


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