Merge pull request #20424 from eileenmcnaughton/ppp3
[civicrm-core.git] / CRM / Core / Payment / PayPalProIPN.php
index 68019a34035748aa7436fa9c9ae889981f13bd37..f8afbb1cd594e19c3f7463e1423fe21e1b465f12 100644 (file)
@@ -51,6 +51,19 @@ class CRM_Core_Payment_PayPalProIPN extends CRM_Core_Payment_BaseIPN {
    */
   protected $contributionRecurObject;
 
+  /**
+   * Contribution object.
+   *
+   * @var \CRM_Contribute_BAO_Contribution
+   */
+  protected $contributionObject;
+  /**
+   * Contribution ID.
+   *
+   * @var int
+   */
+  protected $contributionID;
+
   /**
    * Get the recurring contribution ID, if any.
    *
@@ -65,6 +78,23 @@ class CRM_Core_Payment_PayPalProIPN extends CRM_Core_Payment_BaseIPN {
     return $this->contributionRecurID;
   }
 
+  /**
+   * Get the relevant contribution ID.
+   *
+   * This is the contribution being paid or the original in the
+   * recurring series.
+   *
+   * @return int
+   *
+   * @throws \CRM_Core_Exception
+   */
+  protected function getContributionID(): int {
+    if (!$this->contributionID && $this->getValue('b', TRUE)) {
+      $this->contributionID = (int) $this->getValue('b', TRUE);
+    }
+    return $this->contributionID;
+  }
+
   /**
    * @param int|null $contributionRecurID
    */
@@ -72,6 +102,15 @@ class CRM_Core_Payment_PayPalProIPN extends CRM_Core_Payment_BaseIPN {
     $this->contributionRecurID = $contributionRecurID;
   }
 
+  /**
+   * Set contribution ID.
+   *
+   * @param int $contributionID
+   */
+  public function setContributionID(int $contributionID): void {
+    $this->contributionID = $contributionID;
+  }
+
   /**
    * Component.
    *
@@ -182,17 +221,16 @@ class CRM_Core_Payment_PayPalProIPN extends CRM_Core_Payment_BaseIPN {
    * Process recurring contributions.
    *
    * @param array $input
-   * @param array $ids
-   * @param \CRM_Contribute_BAO_ContributionRecur $recur
-   * @param \CRM_Contribute_BAO_Contribution $contribution
-   * @param bool $first
    *
    * @throws \API_Exception
    * @throws \CRM_Core_Exception
    * @throws \CiviCRM_API3_Exception
    * @throws \Civi\API\Exception\UnauthorizedException
    */
-  public function recur($input, $ids, $recur, $contribution, $first) {
+  public function recur(array $input): void {
+    // check if first contribution is completed, else complete first contribution
+    $first = !$this->isContributionCompleted();
+    $recur = $this->getContributionRecurObject();
     if (!isset($input['txnType'])) {
       Civi::log()->debug('PayPalProIPN: Could not find txn_type in input request.');
       echo 'Failure: Invalid parameters<p>';
@@ -262,8 +300,8 @@ class CRM_Core_Payment_PayPalProIPN extends CRM_Core_Payment_BaseIPN {
           // Also consider accepting 'Failed' like other processors.
           $input['contribution_status_id'] = $contributionStatuses['Completed'];
           $input['invoice_id'] = md5(uniqid(rand(), TRUE));
-          $input['original_contribution_id'] = $ids['contribution'];
-          $input['contribution_recur_id'] = $ids['contributionRecur'];
+          $input['original_contribution_id'] = $this->getContributionID();
+          $input['contribution_recur_id'] = $this->getContributionRecurID();
 
           civicrm_api3('Contribution', 'repeattransaction', $input);
           return;
@@ -294,7 +332,7 @@ class CRM_Core_Payment_PayPalProIPN extends CRM_Core_Payment_BaseIPN {
     if ($sendNotification) {
       //send recurring Notification email for user
       CRM_Contribute_BAO_ContributionPage::recurringNotify(
-        $ids['contribution'],
+        $this->getContributionID(),
         $subscriptionPaymentStatus,
         $recur
       );
@@ -304,60 +342,36 @@ class CRM_Core_Payment_PayPalProIPN extends CRM_Core_Payment_BaseIPN {
       return;
     }
 
-    // CRM-13737 - am not aware of any reason why payment_date would not be set - this if is a belt & braces
-    $contribution->receive_date = !empty($input['payment_date']) ? date('YmdHis', strtotime($input['payment_date'])) : $now;
-
-    $this->single($input, [
-      'participant' => $ids['participant'] ?? NULL,
-      'contributionRecur' => $recur->id ?? NULL,
-    ], $contribution, TRUE, $first);
+    $this->single($input);
   }
 
   /**
    * @param array $input
-   * @param array $ids
-   * @param \CRM_Contribute_BAO_Contribution $contribution
-   * @param bool $recur
-   * @param bool $first
    *
    * @return void
    * @throws \API_Exception
    * @throws \CRM_Core_Exception
    * @throws \CiviCRM_API3_Exception
-   * @throws \Civi\API\Exception\UnauthorizedException
    */
-  public function single($input, $ids, $contribution, $recur = FALSE, $first = FALSE) {
+  protected function single(array $input): void {
 
     // make sure the invoice is valid and matches what we have in the contribution record
-    if ((!$recur) || ($recur && $first)) {
-      if ($contribution->invoice_id != $input['invoice']) {
-        Civi::log()->debug('PayPalProIPN: Invoice values dont match between database and IPN request.');
-        echo "Failure: Invoice values dont match between database and IPN request<p>contribution is" . $contribution->invoice_id . " and input is " . $input['invoice'];
-        return;
+    if (!$this->isContributionCompleted()) {
+      if ($this->getContributionObject()->invoice_id !== $input['invoice']) {
+        throw new CRM_Core_Exception('PayPalProIPN: Invoice values dont match between database and IPN request.');
       }
-    }
-    else {
-      $contribution->invoice_id = md5(uniqid(rand(), TRUE));
-    }
-
-    if (!$recur) {
-      if ($contribution->total_amount != $input['amount']) {
-        Civi::log()->debug('PayPalProIPN: Amount values dont match between database and IPN request.');
-        echo "Failure: Amount values dont match between database and IPN request<p>";
-        return;
+      if (!$this->getContributionRecurID() && $this->getContributionObject()->total_amount != $input['amount']) {
+        throw new CRM_Core_Exception('PayPalProIPN: Amount values dont match between database and IPN request.');
       }
     }
-    else {
-      $contribution->total_amount = $input['amount'];
-    }
 
     $status = $input['paymentStatus'];
     if ($status === 'Denied' || $status === 'Failed' || $status === 'Voided') {
       Contribution::update(FALSE)->setValues([
         'cancel_date' => 'now',
         'contribution_status_id:name' => 'Failed',
-      ])->addWhere('id', '=', $contribution->id)->execute();
-      Civi::log()->debug("Setting contribution status to Failed");
+      ])->addWhere('id', '=', $this->getContributionID())->execute();
+      Civi::log()->debug('Setting contribution status to Failed');
       return;
     }
     if ($status === 'Pending') {
@@ -368,24 +382,22 @@ class CRM_Core_Payment_PayPalProIPN extends CRM_Core_Payment_BaseIPN {
       Contribution::update(FALSE)->setValues([
         'cancel_date' => 'now',
         'contribution_status_id:name' => 'Cancelled',
-      ])->addWhere('id', '=', $contribution->id)->execute();
-      Civi::log()->debug("Setting contribution status to Cancelled");
+      ])->addWhere('id', '=', $this->getContributionID())->execute();
+      Civi::log()->debug('Setting contribution status to Cancelled');
       return;
     }
-    elseif ($status !== 'Completed') {
+    if ($status !== 'Completed') {
       Civi::log()->debug('Returning since contribution status is not handled');
       return;
     }
 
-    // check if contribution is already completed, if so we ignore this ipn
-    $completedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
-    if ($contribution->contribution_status_id == $completedStatusId) {
+    if ($this->isContributionCompleted()) {
       Civi::log()->debug('PayPalProIPN: Returning since contribution has already been handled.');
       echo 'Success: Contribution has already been handled<p>';
       return;
     }
 
-    CRM_Contribute_BAO_Contribution::completeOrder($input, $ids['contributionRecur'] ?? NULL, $contribution->id ?? NULL);
+    CRM_Contribute_BAO_Contribution::completeOrder($input, $this->getContributionRecurID(), $this->getContributionID());
   }
 
   /**
@@ -421,110 +433,38 @@ class CRM_Core_Payment_PayPalProIPN extends CRM_Core_Payment_BaseIPN {
    * @todo the references to POST throughout this class need to be removed
    * @return void
    */
-  public function main() {
+  public function main(): void {
     CRM_Core_Error::debug_var('GET', $_GET, TRUE, TRUE);
     CRM_Core_Error::debug_var('POST', $_POST, TRUE, TRUE);
-    $ids = $input = [];
+    $input = [];
     try {
       if ($this->_isPaymentExpress) {
         $this->handlePaymentExpress();
         return;
       }
-      $this->_component = $input['component'] = self::getValue('m');
-      $input['invoice'] = self::getValue('i', TRUE);
-      // get the contribution and contact ids from the GET params
-      $ids['contact'] = self::getValue('c', TRUE);
-      $ids['contribution'] = self::getValue('b', TRUE);
-
-      $this->getInput($input);
-
-      if ($this->_component == 'event') {
-        $ids['event'] = self::getValue('e', TRUE);
-        $ids['participant'] = self::getValue('p', TRUE);
-        $ids['contributionRecur'] = $this->getContributionRecurID();
+      if ($this->getValue('m') === 'event') {
+        // Validate required params.
+        $this->getValue('e');
+        $this->getValue('p');
       }
-      else {
-        // get the optional ids
-        //@ how can this not be broken retrieving from GET as we are dealing with a POST request?
-        // copy & paste? Note the retrieve function now uses data from _REQUEST so this will be included
-        $ids['membership'] = self::retrieve('membershipID', 'Integer', 'GET', FALSE);
-        $ids['contributionRecur'] = $this->getContributionRecurID();
-        $ids['contributionPage'] = self::getValue('p', FALSE);
-        $ids['related_contact'] = self::retrieve('relatedContactID', 'Integer', 'GET', FALSE);
-        $ids['onbehalf_dupe_alert'] = self::retrieve('onBehalfDupeAlert', 'Integer', 'GET', FALSE);
-      }
-
-      if (!$ids['membership'] && $ids['contributionRecur']) {
-        $sql = "
-    SELECT m.id
-      FROM civicrm_membership m
-INNER JOIN civicrm_membership_payment mp ON m.id = mp.membership_id AND mp.contribution_id = %1
-     WHERE m.contribution_recur_id = %2
-     LIMIT 1";
-        $sqlParams = [
-          1 => [$ids['contribution'], 'Integer'],
-          2 => [$ids['contributionRecur'], 'Integer'],
-        ];
-        if ($membershipId = CRM_Core_DAO::singleValueQuery($sql, $sqlParams)) {
-          $ids['membership'] = $membershipId;
-        }
-      }
-
-      $paymentProcessorID = CRM_Utils_Array::value('processor_id', $this->_inputParameters);
-      if (!$paymentProcessorID) {
-        $paymentProcessorID = self::getPayPalPaymentProcessorID();
-      }
-
-      // Check if the contribution exists
-      // make sure contribution exists and is valid
-      $contribution = new CRM_Contribute_BAO_Contribution();
-      $contribution->id = $ids['contribution'];
-      if (!$contribution->find(TRUE)) {
-        throw new CRM_Core_Exception('Failure: Could not find contribution record for ' . (int) $contribution->id, NULL, ['context' => "Could not find contribution record: {$contribution->id} in IPN request: " . print_r($input, TRUE)]);
-      }
-
-      // make sure contact exists and is valid
-      // use the contact id from the contribution record as the id in the IPN may not be valid anymore.
-      $contact = new CRM_Contact_BAO_Contact();
-      $contact->id = $contribution->contact_id;
-      $contact->find(TRUE);
-      if ($contact->id != $ids['contact']) {
+      $input['invoice'] = $this->getValue('i');
+      if ($this->getContributionObject()->contact_id !== $this->getContactID()) {
         // If the ids do not match then it is possible the contact id in the IPN has been merged into another contact which is why we use the contact_id from the contribution
-        CRM_Core_Error::debug_log_message("Contact ID in IPN {$ids['contact']} not found but contact_id found in contribution {$contribution->contact_id} used instead");
-        echo "WARNING: Could not find contact record: {$ids['contact']}<p>";
-        $ids['contact'] = $contribution->contact_id;
-      }
-
-      // CRM-19478: handle oddity when p=null is set in place of contribution page ID,
-      if (!empty($ids['contributionPage']) && !is_numeric($ids['contributionPage'])) {
-        // We don't need to worry if about removing contribution page id as it will be set later in
-        //  CRM_Contribute_BAO_Contribution::loadRelatedObjects(..) using $objects['contribution']->contribution_page_id
-        unset($ids['contributionPage']);
+        CRM_Core_Error::debug_log_message('Contact ID in IPN ' . $this->getContactID() . ' not found but contact_id found in contribution ' . $this->getContributionID() . ' used instead');
+        echo 'WARNING: Could not find contact record: ' . $this->getContactID() . '<p>';
       }
 
-      $ids['paymentProcessor'] = $paymentProcessorID;
-      $contribution->loadRelatedObjects($input, $ids);
-
-      $input['payment_processor_id'] = $paymentProcessorID;
+      $this->getInput($input);
+      $input['payment_processor_id'] = $this->_inputParameters['processor_id'] ?? $this->getPayPalPaymentProcessorID();
 
       if ($this->getContributionRecurID()) {
-        $contributionRecur = $this->getContributionRecurObject();
-        // check if first contribution is completed, else complete first contribution
-        $first = TRUE;
-        $completedStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
-        if ($contribution->contribution_status_id == $completedStatusId) {
-          $first = FALSE;
-        }
-        $this->recur($input, $ids, $contributionRecur, $contribution, $first);
+        $this->recur($input);
         return;
       }
 
-      $this->single($input, [
-        'participant' => $ids['participant'] ?? NULL,
-        'contributionRecur' => $ids['contributionRecur'] ?? NULL,
-      ], $contribution, FALSE, FALSE);
+      $this->single($input);
     }
-    catch (CRM_Core_Exception $e) {
+    catch (Exception $e) {
       Civi::log()->debug($e->getMessage() . ' input {input}', ['input' => $input]);
       echo 'Invalid or missing data';
     }
@@ -572,23 +512,20 @@ INNER JOIN civicrm_membership_payment mp ON m.id = mp.membership_id AND mp.contr
    *
    * For one off IPNS no actual response is required
    * Recurring is more difficult as we have limited confirmation material
-   * lets look up invoice id in recur_contribution & rely on the unique transaction id to ensure no
-   * duplicated
-   * this may not be acceptable to all sites - e.g. if they are shipping or delivering something in return
-   * then the quasi security of the ids array might be required - although better to
+   * lets look up invoice id in recur_contribution & rely on the unique
+   * transaction id to ensure no duplicated this may not be acceptable to all
+   * sites - e.g. if they are shipping or delivering something in return then
+   * the quasi security of the ids array might be required - although better to
    * http://stackoverflow.com/questions/4848227/validate-that-ipn-call-is-from-paypal
-   * but let's assume knowledge on invoice id & schedule is enough for now esp for donations
-   * only contribute is handled
+   * but let's assume knowledge on invoice id & schedule is enough for now esp
+   * for donations only contribute is handled
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   * @throws \CiviCRM_API3_Exception
    */
-  public function handlePaymentExpress() {
-    //@todo - loads of copy & paste / code duplication but as this not going into core need to try to
-    // keep discreet
-    // also note that a lot of the complexity above could be removed if we used
-    // http://stackoverflow.com/questions/4848227/validate-that-ipn-call-is-from-paypal
-    // as membership id etc can be derived by the load objects fn
-    $objects = $ids = $input = [];
-    $isFirst = FALSE;
-    $input['invoice'] = self::getValue('i', FALSE);
+  public function handlePaymentExpress(): void {
+    $input = ['invoice' => $this->getValue('i', FALSE)];
     //Avoid return in case of unit test.
     if (empty($input['invoice']) && empty($this->_inputParameters['is_unit_test'])) {
       return;
@@ -604,58 +541,14 @@ INNER JOIN civicrm_membership_payment mp ON m.id = mp.membership_id AND mp.contr
       throw new CRM_Core_Exception('Paypal IPNS not handled other than recurring_payments');
     }
 
-    $this->getInput($input, $ids);
+    $this->getInput($input);
     if ($input['txnType'] === 'recurring_payment' && $this->transactionExists($input['trxn_id'])) {
       throw new CRM_Core_Exception('This transaction has already been processed');
     }
-
-    $ids['contact'] = $contributionRecur['contact_id'];
-    $ids['contributionRecur'] = $this->getContributionRecurID();
     $result = civicrm_api3('contribution', 'getsingle', ['invoice_id' => $input['invoice'], 'contribution_test' => '']);
-
-    $ids['contribution'] = $result['id'];
-    //@todo hardcoding 'pending' for now
-    $pendingStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
-    if ($result['contribution_status_id'] == $pendingStatusId) {
-      $isFirst = TRUE;
-    }
-    // arg api won't get this - fix it
-    $ids['contributionPage'] = CRM_Core_DAO::singleValueQuery("SELECT contribution_page_id FROM civicrm_contribution WHERE invoice_id = %1", [
-      1 => [
-        $ids['contribution'],
-        'Integer',
-      ],
-    ]);
-    // only handle component at this stage - not terribly sure how a recurring event payment would arise
-    // & suspec main function may be a victom of copy & paste
-    // membership would be an easy add - but not relevant to my customer...
-    $this->_component = $input['component'] = 'contribute';
-    $input['trxn_date'] = date('Y-m-d H:i:s', strtotime(self::retrieve('time_created', 'String')));
-    $paymentProcessorID = $contributionRecur['payment_processor_id'];
-
-    // Check if the contribution exists
-    // make sure contribution exists and is valid
-    $contribution = new CRM_Contribute_BAO_Contribution();
-    $contribution->id = $ids['contribution'];
-    if (!$contribution->find(TRUE)) {
-      throw new CRM_Core_Exception('Failure: Could not find contribution record for ' . (int) $contribution->id, NULL, ['context' => "Could not find contribution record: {$contribution->id} in IPN request: " . print_r($input, TRUE)]);
-    }
-
-    $objects['contribution'] = &$contribution;
-
-    // CRM-19478: handle oddity when p=null is set in place of contribution page ID,
-    if (!empty($ids['contributionPage']) && !is_numeric($ids['contributionPage'])) {
-      // We don't need to worry if about removing contribution page id as it will be set later in
-      //  CRM_Contribute_BAO_Contribution::loadRelatedObjects(..) using $objects['contribution']->contribution_page_id
-      unset($ids['contributionPage']);
-    }
-
-    $contribution = &$objects['contribution'];
-    $ids['paymentProcessor'] = $paymentProcessorID;
-    $contribution->loadRelatedObjects($input, $ids);
-    $objects = array_merge($objects, $contribution->_relatedObjects);
-
-    $this->recur($input, $ids, $this->getContributionRecurObject(), $objects['contribution'], $isFirst);
+    $this->setContributionID((int) $result['id']);
+    $input['trxn_date'] = date('Y-m-d H:i:s', strtotime($this->retrieve('time_created', 'String')));
+    $this->recur($input);
   }
 
   /**
@@ -691,4 +584,45 @@ INNER JOIN civicrm_membership_payment mp ON m.id = mp.membership_id AND mp.contr
     return $this->contributionRecurObject;
   }
 
+  /**
+   * @return \CRM_Contribute_BAO_Contribution
+   * @throws \CRM_Core_Exception
+   */
+  protected function getContributionObject(): CRM_Contribute_BAO_Contribution {
+    if (!$this->contributionObject) {
+      // Check if the contribution exists
+      // make sure contribution exists and is valid
+      $contribution = new CRM_Contribute_BAO_Contribution();
+      $contribution->id = $this->getContributionID();
+      if (!$contribution->find(TRUE)) {
+        throw new CRM_Core_Exception('Failure: Could not find contribution record');
+      }
+      // The DAO types it as int but doesn't return it as int.
+      $contribution->contact_id = (int) $contribution->contact_id;
+      $this->contributionObject = $contribution;
+    }
+    return $this->contributionObject;
+  }
+
+  /**
+   * Get the relevant contact ID.
+   *
+   * @return int
+   * @throws \CRM_Core_Exception
+   */
+  protected function getContactID(): int {
+    return $this->getValue('c', TRUE);
+  }
+
+  /**
+   * Is the original contribution completed.
+   *
+   * @return bool
+   * @throws \CRM_Core_Exception
+   */
+  private function isContributionCompleted(): bool {
+    $status = CRM_Core_PseudoConstant::getName('CRM_Contribute_BAO_Contribution', 'contribution_status_id', $this->getContributionObject()->contribution_status_id);
+    return $status === 'Completed';
+  }
+
 }