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
- Follow $charge->getRefundedChargePHID() (up the chain) to the original charge and refund that instead.
- Filter refund records out of any pick-list of refundable charges.
- 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
- Filter refund rows out of refundable-charge lists.
- Resolve refundedChargePHID to the original charge before refunding.
- Label refund rows distinctly in charge UIs so operators never pick them.
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
- Charge has no transaction ID!
- Unable to refund charge; no Stripe chargeID!
- Trying to refund non-positive amount of money!
- Trying to refund more money than remaining on charge!
- Trying to refund an uncharged charge!
AI-assisted analysis of phacility/phabricator@5720a38cfe (2026-08-21).
Data as JSON: /api/errors/a7cfa8f267a9e0f1.
Report an issue: GitHub.