phacility/phabricator · critical · Exception

Paypal checkout does not match Phortune charge!

Error message

Paypal checkout does not match Phortune charge!

What it means

Exception thrown in the PayPal 'charge' callback when GetExpressCheckoutDetails returns a CUSTOM field that does not equal the active charge's PHID. Phortune sets PAYMENTREQUEST_0_CUSTOM to the charge PHID when creating the Express Checkout token, so a mismatch proves the PayPal token in the request URL belongs to a different checkout attempt than the charge Phortune currently has active. It is a payment-integrity guard against applying one checkout's payment to another charge.

Source

Thrown at src/applications/phortune/provider/PhortunePayPalPaymentProvider.php:387

      case 'charge':
        if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) {
          return id(new AphrontRedirectResponse())
            ->setURI($cart->getCheckoutURI());
        }

        $token = $request->getStr('token');

        $params = array(
          'TOKEN' => $token,
        );

        $result = $this
          ->newPaypalAPICall()
          ->setRawPayPalQuery('GetExpressCheckoutDetails', $params)
          ->resolve();

        if ($result['CUSTOM'] !== $charge->getPHID()) {
          throw new Exception(
            pht('Paypal checkout does not match Phortune charge!'));
        }

        if ($result['CHECKOUTSTATUS'] !== 'PaymentActionNotInitiated') {
          return $controller->newDialog()
            ->setTitle(pht('Payment Already Processed'))
            ->appendParagraph(
              pht(
                'The payment response for this charge attempt has already '.
                'been processed.'))
            ->addCancelButton($cart->getCheckoutURI(), pht('Continue'));
        }

        $price = $cart->getTotalPriceAsCurrency();

        $params = array(
          'TOKEN' => $token,
          'PAYERID' => $result['PAYERID'],

View on GitHub (pinned to 5720a38cfe)

Solutions

  1. Return the user to the cart's checkout URI and start a fresh checkout so a new token/charge pair is created.
  2. Before processing, compare $request->getStr('token') with the active charge's stored 'paypal.token' metadata and redirect early on mismatch (cheaper and cleaner than catching the throw).
  3. Never reuse tokens across charge attempts; always initiate a new checkout after a failed/cancelled one.
  4. Log the mismatch (both PHIDs) — it can indicate user confusion or someone probing the payment flow.

Example fix

// before
// stale PayPal token in return URL -> Exception(
//   'Paypal checkout does not match Phortune charge!')

// after: pre-check the token against the charge's stored token
$token = $request->getStr('token');
if ($token !== $charge->getMetadataValue('paypal.token')) {
  return id(new AphrontRedirectResponse())
    ->setURI($cart->getCheckoutURI()); // restart checkout cleanly
}
// proceed to GetExpressCheckoutDetails / DoExpressCheckoutPayment
Defensive patterns

Strategy: try-catch

Validate before calling

// Cheap pre-check: the return URL token must match the token stored on
// the active charge when the checkout was initiated.
$token = $request->getStr('token');
if ($token !== (string) $charge->getMetadataValue('paypal.token')) {
  // Stale token from an older checkout attempt: restart cleanly.
  return id(new AphrontRedirectResponse())
    ->setURI($cart->getCheckoutURI());
}
// token matches: proceed to GetExpressCheckoutDetails (the provider still
// verifies CUSTOM === charge PHID as the authoritative integrity check).

Try / catch

try {
  // ... GetExpressCheckoutDetails + DoExpressCheckoutPayment flow ...
} catch (Exception $ex) {
  if (strpos($ex->getMessage(), 'does not match Phortune charge') !== false) {
    // Integrity violation: do NOT retry with the same token.
    phlog(pht(
      'PayPal token/charge mismatch for cart %s; possible replay.',
      $cart->getID()));
    return id(new AphrontRedirectResponse())
      ->setURI($cart->getCheckoutURI()); // force a fresh checkout
  }
  throw $ex;
}

Prevention

When it happens

Trigger: A buyer completes PayPal approval, then re-opens an older PayPal return URL (or a second tab) whose token was issued for a previous charge attempt on the same cart: GetExpressCheckoutDetails returns the old CUSTOM (previous charge PHID), which differs from the current active charge's PHID. Also reproducible with manually crafted token parameters or PayPal sandbox accounts with multiple parallel checkouts.

Common situations: Multiple checkout attempts on the same cart (previous one failed, user retried) and a stale tab/redirect returning with the old token; browser history navigation; shared or replayed return URLs; testing with hardcoded tokens.

Related errors


AI-assisted analysis of phacility/phabricator@5720a38cfe (2026-08-21). Data as JSON: /api/errors/c8aa151dd010a439. Report an issue: GitHub.