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
- Implement the refund as a strict sequence: willRefundCharge() -> provider refund -> didRefundCharge() or didFailRefund(), reusing the exact objects willRefundCharge() returned.
- Never re-run did* steps on retry; only the provider call may be retried.
- 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
- Always pair willRefundCharge() with exactly one didRefundCharge() or didFailRefund() using the same objects.
- Never re-run completion steps on retry; retry only the provider call.
- Recover crashed refunds by inspecting refundingPHID before finishing them.
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
- 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 a refund!
AI-assisted analysis of phacility/phabricator@5720a38cfe (2026-08-21).
Data as JSON: /api/errors/0cb3327c435995ed.
Report an issue: GitHub.