{"record":{"id":"c4d9d7a6a7862e3a","repo":"phacility/phabricator","slug":"trying-to-refund-more-money-than-remaining-on-char","errorCode":null,"errorMessage":"Trying to refund more money than remaining on charge!","messagePattern":"Trying to refund more money than remaining on charge!","errorType":"exception","errorClass":"Exception","httpStatus":null,"severity":"error","filePath":"src/applications/phortune/storage/PhortuneCart.php","lineNumber":320,"sourceCode":"    $this->saveTransaction();\n\n    return $this;\n  }\n\n\n  public function willRefundCharge(\n    PhabricatorUser $actor,\n    PhortunePaymentProvider $provider,\n    PhortuneCharge $charge,\n    PhortuneCurrency $amount) {\n\n    if (!$amount->isPositive()) {\n      throw new Exception(\n        pht('Trying to refund non-positive amount of money!'));\n    }\n\n    if ($amount->isGreaterThan($charge->getAmountRefundableAsCurrency())) {\n      throw new Exception(\n        pht('Trying to refund more money than remaining on charge!'));\n    }\n\n    if ($charge->getRefundedChargePHID()) {\n      throw new Exception(\n        pht('Trying to refund a refund!'));\n    }\n\n    if (($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) &&\n        ($charge->getStatus() !== PhortuneCharge::STATUS_HOLD)) {\n      throw new Exception(\n        pht('Trying to refund an uncharged charge!'));\n    }\n\n    $refund_charge = PhortuneCharge::initializeNewCharge()\n      ->setAccountPHID($this->getAccount()->getPHID())\n      ->setCartPHID($this->getPHID())\n      ->setAuthorPHID($actor->getPHID())","sourceCodeStart":302,"sourceCodeEnd":338,"githubUrl":"https://github.com/phacility/phabricator/blob/5720a38cfe95b00ca4be5016dd0d2f3195f4fa04/src/applications/phortune/storage/PhortuneCart.php#L302-L338","documentation":"willRefundCharge() throws when the requested amount exceeds $charge->getAmountRefundableAsCurrency(), the original charge amount minus all completed refunds. This prevents refunding more money than was ever captured on that charge. It is the main over-refund guard for partial refunds.","triggerScenarios":"Refunding the full charge amount again after an earlier partial or full refund; computing the refund from getAmountAsCurrency() instead of getAmountRefundableAsCurrency(); two concurrent refund flows each reading the same refundable balance.","commonSituations":"Support staff issuing repeated partial refunds; refund forms prefilled with the original price rather than remaining refundable; race between two admins refunding the same charge.","solutions":["Cap the requested amount at $charge->getAmountRefundableAsCurrency() and prefill refund forms with that value.","Reload the charge immediately before refunding so prior refunds are reflected in the refundable total.","Serialize refunds per charge (single refund button, lockstep flow) so the refundable balance cannot be double-spent."],"exampleFix":"// before\n$amount = $charge->getAmountAsCurrency(); // ignores earlier partial refund\n$cart->willRefundCharge($actor, $provider, $charge, $amount); // throws: exceeds refundable\n\n// after\n$amount = $charge->getAmountRefundableAsCurrency();\nif ($requested->isGreaterThan($amount)) {\n  $requested = $amount; // clamp to remaining refundable\n}\nif ($requested->isPositive()) {\n  $cart->willRefundCharge($actor, $provider, $charge, $requested);\n}","handlingStrategy":"validation","validationCode":"$refundable = $charge->getAmountRefundableAsCurrency();\nif (!$amount->isPositive()) {\n  $errors[] = pht('Refund amount must be greater than zero.');\n} else if ($amount->isGreaterThan($refundable)) {\n  $amount = $refundable; // or reject with an error\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Always compute refund ceilings from getAmountRefundableAsCurrency(), never the original amount.","Reload the charge before refunding so prior refunds are counted.","Serialize refunds per charge so the refundable balance cannot be double-spent."],"tags":["phortune","phabricator","php","payments","refund","balance-check"],"backgroundTag":"refund-exceeds-available-balance","analyzedSha":"5720a38cfe95b00ca4be5016dd0d2f3195f4fa04","analyzedAt":"2026-08-21T05:07:25.672Z","schemaVersion":2},"datasetVersion":"2026-08-21T11:28:35.574Z"}