Fixed PayPal chargebacks are not handled correctly

DragonByte Tech

Well-known member
Affected version
2.1.7
If a chargeback is initiated via a buyer's bank or credit card company, and PayPal is not able to argue on your behalf, the resulting chargeback is not correctly tracked in XenForo.

Email from PayPal:
STlYOUG.png


Purchase log entry:
ocm0VbT.png


Transaction data:
Code:
array(18) {
  ["txn_type"] => string(10) "adjustment"
  ["payment_date"] => string(25) "13:50:52 Feb 29, 2020 PST"
  ["payment_gross"] => string(6) "-94.95"
  ["mc_currency"] => string(3) "USD"
  ["verify_sign"] => string(56) "xxx"
  ["payer_status"] => string(8) "verified"
  ["payer_email"] => string(28) "payments@dragonbyte-tech.com"
  ["txn_id"] => string(17) "xxx"
  ["parent_txn_id"] => string(17) "xxx"
  ["payer_id"] => string(13) "xxx"
  ["reason_code"] => string(21) "chargeback_settlement"
  ["payment_status"] => string(9) "Completed"
  ["payment_fee"] => string(6) "-20.00"
  ["mc_gross"] => string(6) "-94.95"
  ["custom"] => string(32) "xxx"
  ["charset"] => string(12) "windows-1252"
  ["notify_version"] => string(3) "3.9"
  ["ipn_track_id"] => string(13) "xxx"
}
 
Fix:
Diff:
--- a/src/XF/Payment/PayPal.php    2020-02-29 22:17:55.000000000
+++ b/src/XF/Payment/PayPal.php    2020-02-29 22:18:36.000000000
@@ -136,12 +136,13 @@
     public function setupCallback(\XF\Http\Request $request)
     {
         $state = new CallbackState();
 
         $state->business = $request->filter('business', 'str');
         $state->receiverEmail = $request->filter('receiver_email', 'str');
+        $state->payerEmail = $request->filter('payer_email', 'str');
         $state->transactionType = $request->filter('txn_type', 'str');
         $state->parentTransactionId = $request->filter('parent_txn_id', 'str');
         $state->transactionId = $request->filter('txn_id', 'str');
         $state->subscriberId = $request->filter('subscr_id', 'str');
         $state->paymentCountry = $request->filter('residence_country', 'str');
         $state->costAmount = $request->filter('mc_gross', 'unum');
@@ -331,22 +332,23 @@
     public function validatePurchasableData(CallbackState $state)
     {
         $paymentProfile = $state->getPaymentProfile();
 
         $business = strtolower($state->business);
         $receiverEmail = strtolower($state->receiverEmail);
+        $payerEmail = strtolower($state->payerEmail);
 
         $options = $paymentProfile->options;
         $accounts = Arr::stringToArray($options['alternate_accounts'], '#\r?\n#');
         $accounts[] = $options['primary_account'];
 
         $matched = false;
         foreach ($accounts AS $account)
         {
             $account = trim(strtolower($account));
-            if ($account && ($business == $account || $receiverEmail == $account))
+            if ($account && ($business == $account || $receiverEmail == $account || $payerEmail == $account))
             {
                 $matched = true;
                 break;
             }
         }
         if (!$matched)
@@ -468,11 +470,18 @@
                 if ($state->paymentStatus == 'Completed')
                 {
                     $state->paymentResult = CallbackState::PAYMENT_RECEIVED;
                 }
                 break;
+            case 'adjustment':
+                if ($state->paymentStatus == 'Completed')
+                {
+                    $state->paymentResult = CallbackState::PAYMENT_REVERSED;
+                }
+                break;
         }
 
         if ($state->paymentStatus == 'Refunded' || $state->paymentStatus == 'Reversed')
         {
             $state->paymentResult = CallbackState::PAYMENT_REVERSED;
         }

This fix has been tested and confirmed to work. Having scanned the payment provider log, there is no instance of txn_type being adjustment other than in this particular case (it has happened once before).
 
I don’t particularly have an issue with suppressing or otherwise making the adjustment callback log clearer but I’m not sure dealing with this as a reversal is correct.

I think no further action is required because there should have been callbacks prior to this one which should have already reversed the payment. Are you able to check for all related logs to see if that was the case?
 
Are you able to check for all related logs to see if that was the case?
There is no other record for this purchase request key other than the initial payment, followed by the "Invalid business or receiver_email." message prior to me patching PayPal.php.

This is because PayPal does not remove the money from your account while a dispute is being resolved, if you have PayPal Funds Now. If a chargeback is opened and you have PayPal Funds Now, a notification is not sent to XF about the opening of this chargeback.

The chargeback flow for businesses with PayPal Funds Now:
  1. Initial payment
  2. Complaint resolved in buyer's favour (txn_type = adjustment)

The dispute flow for businesses without PayPal Funds Now:
  1. Initial payment
  2. Complaint opened (payment_status = Reversed)
  3. Complaint resolved in your favour (payment_status = Canceled_Reversal)
The dispute flow for businesses with PayPal Funds Now:
  1. Initial payment
  2. Complaint opened (txn_type = new_case) - this is currently ignored by XF with "Information: Transaction already processed. Skipping."
  3. Complaint resolved in buyer's favour (payment_status = Reversed)
Here's the data for a complaint opened:
Code:
array(18) {
  ["txn_type"] => string(8) "new_case"
  ["payment_date"] => string(25) "13:20:12 May 05, 2019 PDT"
  ["case_id"] => string(13) "xxx"
  ["case_type"] => string(7) "dispute"
  ["business"] => string(28) "payments@dragonbyte-tech.com"
  ["verify_sign"] => string(56) "xxx"
  ["payer_email"] => string(21) "customer@xxx.xxx"
  ["txn_id"] => string(17) "xxx"
  ["case_creation_date"] => string(25) "04:22:00 May 17, 2019 PDT"
  ["receiver_email"] => string(28) "payments@dragonbyte-tech.com"
  ["buyer_additional_information"] => string(82) "xxx"
  ["payer_id"] => string(13) "xxx"
  ["receiver_id"] => string(13) "xxx"
  ["reason_code"] => string(11) "non_receipt"
  ["custom"] => string(32) "xxx"
  ["charset"] => string(12) "windows-1252"
  ["notify_version"] => string(3) "3.9"
  ["ipn_track_id"] => string(13) "xxx"
}

In the case of a complaint being resolved in your favour with PayPal Funds Now, then PayPal does not send any data. Therefore, the current behaviour ("Transaction already processed") is correct (though an argument could be made the information message should be clearer).
 
Thank you for reporting this issue, it has now been resolved. We are aiming to include any changes that have been made in a future XF release (2.1.10).

Change log:
Correctly handle chargebacks for PayPal Funds Now accounts
There may be a delay before changes are rolled out to the XenForo Community.
 
Is this the same reason why when I downgrade a member's paid upgrade they do not receive a refund ?

I also use PayPal Funds Now on a business account.
 
You want to give the user a refund automatically when you downgrade their account in XF?

That's just not something we attempt to do with any of the payment handlers, nor do I believe it's possible with the type of PayPal integration that we use.
 
Top Bottom