{"record":{"id":"b1c9dfc685e2f668","repo":"phacility/phabricator","slug":"trying-to-refund-a-charge-which-is-already-refundi","errorCode":null,"errorMessage":"Trying to refund a charge which is already refunding!","messagePattern":"Trying to refund a charge which is already refunding!","errorType":"exception","errorClass":"Exception","httpStatus":null,"severity":"error","filePath":"src/applications/phortune/storage/PhortuneCart.php","lineNumber":352,"sourceCode":"\n    $refund_charge = PhortuneCharge::initializeNewCharge()\n      ->setAccountPHID($this->getAccount()->getPHID())\n      ->setCartPHID($this->getPHID())\n      ->setAuthorPHID($actor->getPHID())\n      ->setMerchantPHID($this->getMerchant()->getPHID())\n      ->setProviderPHID($provider->getProviderConfig()->getPHID())\n      ->setPaymentMethodPHID($charge->getPaymentMethodPHID())\n      ->setRefundedChargePHID($charge->getPHID())\n      ->setAmountAsCurrency($amount->negate());\n\n    $charge->openTransaction();\n      $charge->beginReadLocking();\n\n        $copy = clone $charge;\n        $copy->reload();\n\n        if ($copy->getRefundingPHID() !== null) {\n          throw new Exception(\n            pht('Trying to refund a charge which is already refunding!'));\n        }\n\n        $refund_charge->save();\n        $charge->setRefundingPHID($refund_charge->getPHID());\n        $charge->save();\n\n      $charge->endReadLocking();\n    $charge->saveTransaction();\n\n    return $refund_charge;\n  }\n\n  public function didRefundCharge(\n    PhortuneCharge $charge,\n    PhortuneCharge $refund) {\n\n    $refund->setStatus(PhortuneCharge::STATUS_CHARGED);","sourceCodeStart":334,"sourceCodeEnd":370,"githubUrl":"https://github.com/phacility/phabricator/blob/5720a38cfe95b00ca4be5016dd0d2f3195f4fa04/src/applications/phortune/storage/PhortuneCart.php#L334-L370","documentation":"Inside a read-locked transaction, willRefundCharge() reloaded the charge and found refundingPHID already set, meaning another refund is already in flight for this charge. This is the single-flight concurrency guard for refund initiation. A second refund may only start after didRefundCharge() or didFailRefund() clears the flag.","triggerScenarios":"Double-submitting a refund request (double-click, retry after a timeout); two admins or workers calling willRefundCharge() for the same charge before the first refund completes; retrying the whole refund sequence instead of just the provider step.","commonSituations":"Flaky network causing the browser to retry the refund POST; background jobs and UI issuing refunds concurrently; queue retries of a task that already opened a refund.","solutions":["Catch the exception and reload the charge: if refundingPHID is set, reuse or wait for the in-flight refund rather than retrying immediately.","Make refund submission idempotent in the UI (disable after click, deduplicate requests).","Only retry the provider apply step on failure, never the willRefundCharge() call."],"exampleFix":"// before\n$refund = $cart->willRefundCharge($actor, $provider, $charge, $amount);\n// on retry/timeout this throws 'Trying to refund a charge which is already refunding!'\n\n// after\ntry {\n  $refund = $cart->willRefundCharge($actor, $provider, $charge, $amount);\n} catch (Exception $ex) {\n  $fresh = id(new PhortuneChargeQuery())\n    ->setViewer($viewer)\n    ->withPHIDs(array($charge->getPHID()))\n    ->executeOne();\n  if ($fresh->getRefundingPHID()) {\n    return; // refund already in flight; do not retry\n  }\n  throw $ex;\n}","handlingStrategy":"try-catch","validationCode":"$fresh = id(new PhortuneChargeQuery())\n  ->setViewer($viewer)\n  ->withPHIDs(array($charge->getPHID()))\n  ->executeOne();\nif ($fresh->getRefundingPHID() !== null) {\n  // a refund is already in flight; skip or wait for it\n}","typeGuard":"function isRefundInFlight(PhortuneCharge $charge) {\n  return $charge->getRefundingPHID() !== null;\n}","tryCatchPattern":"try {\n  $refund = $cart->willRefundCharge($actor, $provider, $charge, $amount);\n} catch (Exception $ex) {\n  if (strpos($ex->getMessage(), 'already refunding') !== false) {\n    // another refund holds the lock: reload and resync with its outcome\n  } else {\n    throw $ex;\n  }\n}","preventionTips":["Never blind-retry willRefundCharge(); the first attempt may still be completing.","Make refund submission single-flight in the UI (disable on submit, dedupe requests).","Retry only the provider apply step after failures, not the whole sequence."],"tags":["phortune","phabricator","php","payments","refund","concurrency","locking"],"backgroundTag":"concurrent-refund","analyzedSha":"5720a38cfe95b00ca4be5016dd0d2f3195f4fa04","analyzedAt":"2026-08-21T05:07:25.672Z","schemaVersion":2},"datasetVersion":"2026-08-21T11:28:35.574Z"}