dev/membership#13 Add handling for missing MembershipPayment record.
authoreileen <emcnaughton@wikimedia.org>
Fri, 16 Aug 2019 23:00:32 +0000 (11:00 +1200)
committereileen <emcnaughton@wikimedia.org>
Wed, 4 Sep 2019 23:14:57 +0000 (11:14 +1200)
We have an intermittent regression on extending memberships off paypal payments. This adds debug messages
and handling / tests for the scenario I suspect may be in play.

The 'decision' the code makes about how to create recurring transactions is all dependent on the 'template transaction'
- if that is not correct the follow on payments will not be.

This PR allows the membershipID input from paypal to be passed through and used.  This will override any
intended changes - but that just restores previous behaviour and we do not have good handling for these

https://lab.civicrm.org/dev/membership/issues/13

CRM/Contribute/BAO/Contribution.php
CRM/Core/Payment/PayPalIPN.php
api/v3/Contribution.php
tests/phpunit/CRM/Core/Payment/PayPalIPNTest.php

index 3cd20902e756530713e6dda04a12570e16d1ad2a..33a350ef6ae8a7cb681e0d680d8ee762975bb9f1 100644 (file)
@@ -1206,6 +1206,27 @@ class CRM_Contribute_BAO_Contribution extends CRM_Contribute_DAO_Contribution {
     return [TRUE, $isARefund];
   }
 
+  /**
+   * It is possible to override the membership id that is updated from the payment processor.
+   *
+   * Historically Paypal does this & it still does if it determines data is messed up - see
+   * https://lab.civicrm.org/dev/membership/issues/13
+   *
+   * Read the comment block on repeattransaction for more information
+   * about how things should  work.
+   *
+   * @param int $contributionID
+   * @param array $input
+   *
+   * @throws \CiviCRM_API3_Exception
+   */
+  protected static function handleMembershipIDOverride($contributionID, $input) {
+    if (!empty($input['membership_id'])) {
+      Civi::log()->debug('The related membership id has been overridden - this may impact data - see  https://github.com/civicrm/civicrm-core/pull/15053');
+      civicrm_api3('MembershipPayment', 'create', ['contribution_id' => $contributionID, 'membership_id' => $input['membership_id']]);
+    }
+  }
+
   /**
    * @inheritDoc
    */
@@ -2401,11 +2422,35 @@ LEFT JOIN  civicrm_contribution contribution ON ( componentPayment.contribution_
   /**
    * Repeat a transaction as part of a recurring series.
    *
-   * Only call this via the api as it is being refactored. The intention is that the repeatTransaction function
-   * (possibly living on the ContributionRecur BAO) would be called first to create a pending contribution with a
-   * subsequent call to the contribution.completetransaction api.
-   *
-   * The completeTransaction functionality has historically been overloaded to both complete and repeat payments.
+   * The ideal flow is
+   * 1) Processor calls contribution.repeattransaction with contribution_status_id = Pending
+   * 2) The repeattransaction loads the 'template contribution' and calls a hook to allow altering of it .
+   * 3) Repeat transaction calls order.create to create the pending contribution with correct line items
+   *   and associated entities.
+   * 4) The  calling code calls Payment.create which in turn calls CompleteOrder (if completing)
+   *   which updates the various entities and sends appropriate emails.
+   *
+   * Gaps in the above (@todo)
+   *  1) many processors still call repeattransaction with contribution_status_id = Completed
+   *  2) repeattransaction code is current munged into completeTransaction code for historical bad coding reasons
+   *  3) Repeat transaction duplicates rather than calls Order.create
+   *  4) Use of payment.create still limited - completetransaction is more common.
+   *  5) the template transaction is tricky - historically we used the first contribution
+   *    linked to a recurring contribution. More recently that was changed to be the most recent.
+   *    Ideally it would be an actual template - not a contribution used as a template which
+   *    would give more appropriate flexibility. Note line_items have an entity so that table
+   *    could be used for the line item template - the difficulty is the custom fields...
+   * 6) the determination of the membership to be linked is tricksy. The prioritised method is
+   *   to load the membership(s) referred to via line items in the template transactions. Any other
+   *   method is likely to lead to incorrect line items & related entities being created (as the line_item
+   *   link is a required part of 'correct data'). However there are 3 other methods to determine it
+   *   - membership_payment record
+   *   - civicrm_membership.contribution_recur_id
+   *   - input override.
+   *   Passing in an input override WILL ensure the membership is extended to prevent regressions
+   *   of historical processors since this has been handled 'forever' - specifically for paypal.
+   *   albeit by an even nastier mechanism than the current input override.
+   *   The count is out on how correct related entities wind up in this case.
    *
    * @param CRM_Contribute_BAO_Contribution $contribution
    * @param array $input
@@ -2469,6 +2514,7 @@ LEFT JOIN  civicrm_contribution contribution ON ( componentPayment.contribution_
       $createContribution = civicrm_api3('Contribution', 'create', $contributionParams);
       $contribution->id = $createContribution['id'];
       CRM_Contribute_BAO_ContributionRecur::copyCustomValues($contributionParams['contribution_recur_id'], $contribution->id);
+      self::handleMembershipIDOverride($contribution->id, $input);
       return TRUE;
     }
   }
index 3ad4c74d27597804fffa4dc7c95db0f2dc055270..d7a13692b65f29a8d05c2d5cc21780c9467f2c2c 100644 (file)
@@ -313,7 +313,9 @@ class CRM_Core_Payment_PayPalIPN extends CRM_Core_Payment_BaseIPN {
     $input['component'] = $component;
 
     $ids['contact'] = $this->retrieve('contactID', 'Integer', TRUE);
-    $ids['contribution'] = $this->retrieve('contributionID', 'Integer', TRUE);
+    $contributionID = $ids['contribution'] = $this->retrieve('contributionID', 'Integer', TRUE);
+    $membershipID = $this->retrieve('membershipID', 'Integer', FALSE);
+    $contributionRecurID = $this->retrieve('contributionRecurID', 'Integer', FALSE);
 
     $this->getInput($input, $ids);
 
@@ -323,17 +325,48 @@ class CRM_Core_Payment_PayPalIPN extends CRM_Core_Payment_BaseIPN {
     }
     else {
       // get the optional ids
-      $ids['membership'] = $this->retrieve('membershipID', 'Integer', FALSE);
-      $ids['contributionRecur'] = $this->retrieve('contributionRecurID', 'Integer', FALSE);
+      $ids['membership'] = $membershipID;
+      $ids['contributionRecur'] = $contributionRecurID;
       $ids['contributionPage'] = $this->retrieve('contributionPageID', 'Integer', FALSE);
       $ids['related_contact'] = $this->retrieve('relatedContactID', 'Integer', FALSE);
       $ids['onbehalf_dupe_alert'] = $this->retrieve('onBehalfDupeAlert', 'Integer', FALSE);
     }
 
-    $paymentProcessorID = self::getPayPalPaymentProcessorID($input, $ids);
+    $paymentProcessorID = $this->getPayPalPaymentProcessorID($input, $ids);
 
     Civi::log()->debug('PayPalIPN: Received (ContactID: ' . $ids['contact'] . '; trxn_id: ' . $input['trxn_id'] . ').');
 
+    if ($this->retrieve('membershipID', 'Integer', FALSE)) {
+      $templateContribution = CRM_Contribute_BAO_ContributionRecur::getTemplateContribution($contributionRecurID);
+      $membershipPayment = civicrm_api3('MembershipPayment', 'get', [
+        'contribution_id' => $templateContribution['id'],
+        'membership_id' => $membershipID,
+      ]);
+      $lineItems  = civicrm_api3('LineItem', 'get', [
+        'contribution_id' => $templateContribution['id'],
+        'entity_id' => $membershipID,
+        'entity_table' => 'civicrm_membership',
+      ]);
+      Civi::log()->debug('PayPalIPN: Received payment for membership ' . (int) $membershipID
+        . '. Original contribution was ' . (int) $contributionID . '. The template for this contribution is '
+        . $templateContribution['id'] . ' it is linked to ' . $membershipPayment['count']
+        . 'payments for this membership. It has ' . $lineItems['count'] . ' line items linked to  this membership.'
+        . '  it is  expected the original contribution will be linked by both entities to the membership.'
+      );
+      if (empty($membershipPayment['count']) && empty($lineItems['count'])) {
+        Civi::log()->debug('PayPalIPN: Will attempt to compensate');
+        $input['membership_id'] = $this->retrieve('membershipID', 'Integer', FALSE);
+      }
+      if ($contributionRecurID) {
+        $recurLinks = civicrm_api3('ContributionRecur', 'get', [
+          'membership_id' => $membershipID,
+          'contribution_recur_id' => $contributionRecurID,
+        ]);
+        Civi::log()->debug('PayPalIPN: Membership should be  linked to  contribution recur  record ' . $contributionRecurID
+          . ' ' . $recurLinks['count'] . 'links found'
+        );
+      }
+    }
     if (!$this->validateData($input, $ids, $objects, TRUE, $paymentProcessorID)) {
       return;
     }
index 5bcd435b577954192fe46249528c85846d76078f..f190f3eb3222994ed2e745eb924a73b852553250 100644 (file)
@@ -678,6 +678,7 @@ function civicrm_api3_contribution_repeattransaction($params) {
       'fee_amount',
       'financial_type_id',
       'contribution_status_id',
+      'membership_id',
     ];
     $input = array_intersect_key($params, array_fill_keys($passThroughParams, NULL));
 
index 05d1694d13e1926bc4f5ec7b06a8ecd8858dc00b..71d93aa8049eea794596ccc7bb0ceca018fbf80c 100644 (file)
@@ -157,6 +157,9 @@ class CRM_Core_Payment_PayPalIPNTest extends CiviUnitTestCase {
 
   /**
    * Test IPN response updates contribution_recur & contribution for first & second contribution.
+   *
+   * @throws \CRM_Core_Exception
+   * @throws \CiviCRM_API3_Exception
    */
   public function testIPNPaymentMembershipRecurSuccess() {
     $durationUnit = 'year';
@@ -169,7 +172,7 @@ class CRM_Core_Payment_PayPalIPNTest extends CiviUnitTestCase {
     $this->assertEquals(1, $contribution['contribution_status_id']);
     $this->assertEquals('8XA571746W2698126', $contribution['trxn_id']);
     // source gets set by processor
-    $this->assertTrue(substr($contribution['contribution_source'], 0, 20) == "Online Contribution:");
+    $this->assertTrue(substr($contribution['contribution_source'], 0, 20) === "Online Contribution:");
     $contributionRecur = $this->callAPISuccess('contribution_recur', 'getsingle', ['id' => $this->_contributionRecurID]);
     $this->assertEquals(5, $contributionRecur['contribution_status_id']);
     $paypalIPN = new CRM_Core_Payment_PaypalIPN($this->getPaypalRecurSubsequentTransaction());
@@ -191,7 +194,48 @@ class CRM_Core_Payment_PayPalIPNTest extends CiviUnitTestCase {
       'entity_table' => 'civicrm_membership',
     ]);
     $this->callAPISuccessGetSingle('membership_payment', ['contribution_id' => $contribution['values'][1]['id']]);
+  }
 
+  /**
+   * Test IPN that we can force membership when the membership payment has been deleted.
+   *
+   * https://lab.civicrm.org/dev/membership/issues/13
+   *
+   * In this scenario the membership payment record was deleted (or not created) for the first contribution but we
+   * 'recover' by using the input  membership id.
+   *
+   * @throws \CRM_Core_Exception
+   * @throws \CiviCRM_API3_Exception
+   */
+  public function testIPNPaymentInputMembershipRecurSuccess() {
+    $durationUnit = 'year';
+    $this->setupMembershipRecurringPaymentProcessorTransaction(['duration_unit' => $durationUnit, 'frequency_unit' => $durationUnit]);
+    $membershipPayment = $this->callAPISuccessGetSingle('membership_payment', []);
+    $paypalIPN = new CRM_Core_Payment_PayPalIPN(array_merge($this->getPaypalRecurTransaction(), ['membershipID' => $membershipPayment['membership_id']]));
+    $paypalIPN->main();
+    $membershipEndDate = $this->callAPISuccessGetValue('membership', ['return' => 'end_date']);
+    CRM_Core_DAO::executeQuery('DELETE FROM civicrm_membership_payment WHERE id = ' . $membershipPayment['id']);
+    CRM_Core_DAO::executeQuery("UPDATE civicrm_line_item SET entity_table = 'civicrm_contribution' WHERE entity_table = 'civicrm_membership'");
+
+    $paypalIPN = new CRM_Core_Payment_PaypalIPN(array_merge($this->getPaypalRecurSubsequentTransaction(), ['membershipID' => $membershipPayment['membership_id']]));
+    $paypalIPN->main();
+    $renewedMembershipEndDate = $this->membershipRenewalDate($durationUnit, $membershipEndDate);
+    $this->assertEquals($renewedMembershipEndDate, $this->callAPISuccessGetValue('membership', ['return' => 'end_date']));
+    $contribution = $this->callAPISuccess('contribution', 'get', [
+      'contribution_recur_id' => $this->_contributionRecurID,
+      'sequential' => 1,
+    ]);
+    $this->assertEquals(2, $contribution['count']);
+    $this->assertEquals('secondone', $contribution['values'][1]['trxn_id']);
+    $this->callAPISuccessGetCount('line_item', [
+      'entity_id' => $this->ids['membership'],
+      'entity_table' => 'civicrm_membership',
+    ], 1);
+    $this->callAPISuccessGetSingle('line_item', [
+      'contribution_id' => $contribution['values'][1]['id'],
+      'entity_table' => 'civicrm_membership',
+    ]);
+    $this->callAPISuccessGetSingle('membership_payment', ['contribution_id' => $contribution['values'][1]['id']]);
   }
 
   /**