phacility/phabricator · error · Exception
Charge has no transaction ID!
Error message
Charge has no transaction ID!
What it means
Exception thrown by PhortunePayPalPaymentProvider::executeRefund() when the charge being refunded has no 'paypal.transactionID' metadata value. That metadata is only written after DoExpressCheckoutPayment succeeds (the controller stores PAYMENTINFO_0_TRANSACTIONID on the charge), so its absence means Phortune has no PayPal transaction reference to issue a RefundTransaction API call against. Refunding without it is impossible, hence the hard stop before any API call is made.
Source
Thrown at src/applications/phortune/provider/PhortunePayPalPaymentProvider.php:170
}
public function getPaymentMethodProviderDescription() {
return 'PayPal';
}
protected function executeCharge(
PhortunePaymentMethod $payment_method,
PhortuneCharge $charge) {
throw new Exception('!');
}
protected function executeRefund(
PhortuneCharge $charge,
PhortuneCharge $refund) {
$transaction_id = $charge->getMetadataValue('paypal.transactionID');
if (!$transaction_id) {
throw new Exception(pht('Charge has no transaction ID!'));
}
$refund_amount = $refund->getAmountAsCurrency()->negate();
$refund_currency = $refund_amount->getCurrency();
$refund_value = $refund_amount->formatBareValue();
$params = array(
'TRANSACTIONID' => $transaction_id,
'REFUNDTYPE' => 'Partial',
'AMT' => $refund_value,
'CURRENCYCODE' => $refund_currency,
);
$result = $this
->newPaypalAPICall()
->setRawPayPalQuery('RefundTransaction', $params)
->resolve();
View on GitHub (pinned to 5720a38cfe)
Solutions
- Only refund charges in CHARGED status that were paid through this PayPal provider — check status before initiating the refund.
- Inspect the charge metadata (paypal.transactionID, paypal.token) to confirm the charge actually completed the Express Checkout flow.
- If a real PayPal payment exists but metadata is missing, recover the transaction ID from the PayPal account's transaction history and restore the metadata before retrying.
- For charges that never completed, fail/void the charge instead of refunding — there is nothing at PayPal to return.
Example fix
// before
$provider->executeRefund($charge, $refund);
// after
$txn = $charge->getMetadataValue('paypal.transactionID');
if ($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED || !strlen((string)$txn)) {
throw new Exception(
pht('Charge %s never completed via PayPal; void it instead of refunding.',
$charge->getPHID()));
}
$provider->executeRefund($charge, $refund); Defensive patterns
Strategy: validation
Validate before calling
// Preconditions for a PayPal refund.
function paypal_charge_is_refundable(PhortuneCharge $charge) {
if ($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) {
return false;
}
return strlen((string) $charge->getMetadataValue('paypal.transactionID')) > 0;
}
if (!paypal_charge_is_refundable($charge)) {
// skip or surface 'nothing at PayPal to refund' instead of calling executeRefund
} Type guard
function charge_has_paypal_transaction_id(PhortuneCharge $charge) {
$txn = $charge->getMetadataValue('paypal.transactionID');
return is_string($txn) && strlen($txn) > 0;
} Try / catch
try {
$provider->executeRefund($charge, $refund);
} catch (Exception $ex) {
// Leave the refund charge unapplied and log enough context to reconcile:
// the charge PHID tells you which PayPal record to inspect.
phlog(pht('Refund failed for charge %s: %s', $charge->getPHID(), $ex->getMessage()));
throw $ex;
} Prevention
- Only refund CHARGED charges that were paid through the same provider.
- Never refund HOLD/FAIL charges — void/fail them instead; no money moved at PayPal.
- Build refunds through the charge/cart API so provider routing and status checks are automatic.
- If you must repair data, restore 'paypal.transactionID' from PayPal's transaction history before retrying.
When it happens
Trigger: Calling refund on a PhortuneCharge whose status is HOLD or FAIL (payment never completed, transaction ID never stored); a charge that was created by a different provider (e.g. Stripe) but refunded through the PayPal provider; or metadata lost by manual DB edits / an interrupted save during the charge flow.
Common situations: Issuing refunds on carts stuck in 'purchasing'/'hold' after a sandbox PayPal outage; admin scripts that iterate all charges including uncharged ones; partial data after a crash between DoExpressCheckoutPayment and charge->save().
Related errors
- Unable to refund charge; no Stripe chargeID!
- Unable to update 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/c5b6fc985da268df.
Report an issue: GitHub.