diff --git a/CRM/Core/Payment/OmnipayMultiProcessor.php b/CRM/Core/Payment/OmnipayMultiProcessor.php index 9eceff23f..a6a937122 100644 --- a/CRM/Core/Payment/OmnipayMultiProcessor.php +++ b/CRM/Core/Payment/OmnipayMultiProcessor.php @@ -156,7 +156,9 @@ public function doPayment(&$params, $component = 'contribute') { if (!empty($params['token'])) { $response = $this->doTokenPayment($params); } - elseif (!empty($params['is_recur'])) { + // 'create_card_action' is a bit of a sagePay hack - see https://github.com/thephpleague/omnipay-sagepay/issues/157 + // don't rely on it being unchanged - tests & comments are your friend. + elseif (!empty($params['is_recur']) && $this->getProcessorTypeMetadata('create_card_action') !== 'purchase') { $response = $this->gateway->createCard($this->getCreditCardOptions(array_merge($params, ['action' => 'Purchase']), $this->_component))->send(); } else { @@ -187,6 +189,12 @@ public function doPayment(&$params, $component = 'contribute') { Contribution::update(FALSE) ->addWhere('id', '=', $params['contributionID']) ->setValues(['trxn_id' => $response->getTransactionReference()])->execute(); + // Save the transaction details for recurring if is-recur as a token + // @todo - consider always saving these & not updating the contribution at all. + if (!empty($params['is_recur'])) { + // Ideally this would be getToken - see https://github.com/thephpleague/omnipay-sagepay/issues/157 + $this->storePaymentToken($params, (int) $params['contributionRecurID'], $response->getTransactionReference()); + } } $isTransparentRedirect = ($response->isTransparentRedirect() || !empty($this->gateway->transparentRedirect)); $this->cleanupClassForSerialization(TRUE); @@ -812,11 +820,15 @@ public function processPaymentNotification($params) { if ($this->getLock() && $this->contribution['contribution_status_id:name'] !== 'Completed') { $this->gatewayConfirmContribution($response); + $trxnReference = $response->getTransactionReference(); civicrm_api3('contribution', 'completetransaction', [ 'id' => $this->transaction_id, - 'trxn_id' => $response->getTransactionReference(), + 'trxn_id' => $trxnReference, 'payment_processor_id' => $params['processor_id'], ]); + if (!empty($this->contribution['contribution_recur_id']) && $trxnReference) { + $this->updatePaymentTokenWithAnyExtraData($trxnReference); + } } if (!empty($this->contribution['contribution_recur_id']) && ($tokenReference = $response->getCardReference()) != FALSE) { $this->storePaymentToken(array_merge($params, ['contact_id' => $contribution['contact_id']]), $this->contribution['contribution_recur_id'], $tokenReference); @@ -1494,6 +1506,9 @@ protected function doTokenPayment(&$params) { if (method_exists($this->gateway, 'completePurchase') && !isset($params['payment_action']) && empty($params['is_recur'])) { $action = 'completePurchase'; } + elseif ($this->getProcessorTypeMetadata('token_pay_action')) { + $action = $this->getProcessorTypeMetadata('token_pay_action'); + } $params['transactionReference'] = ($params['token']); $response = $this->gateway->$action($this->getCreditCardOptions(array_merge($params, ['cardTransactionType' => 'continuous']))) @@ -1669,5 +1684,38 @@ protected function getContactID() { ->first()['contact_id']; } + /** + * If the notification contains additional token information store it. + * + * This updates the payment token but only if that token is a json-encoded + * array, in which case it is potentially added to. + * + * In practice this means sagepay can add the 'txAuthNo' to the token. + * + * @param string $trxnReference + */ + protected function updatePaymentTokenWithAnyExtraData(string $trxnReference) { + try { + $paymentToken = civicrm_api3('PaymentToken', 'get', [ + 'contribution_recur_id' => $this->contribution['contribution_recur_id'], + 'options' => ['limit' => 1, 'sort' => 'id DESC'], + 'sequential' => TRUE, + ]); + if (!empty($paymentToken['values'])) { + // Hmm this check is a bit unclear - sagepay is a json array + // but it'a also probably the only other with a reference at this point... + // comments & tests are your friends. + if (is_array(json_decode($trxnReference, TRUE))) { + civicrm_api3('PaymentToken', 'create', [ + 'id' => $paymentToken['id'], + 'token' => $trxnReference + ]); + } + } + } catch (CiviCRM_API3_Exception $e) { + $this->log('possible error saving token', ['error' => $e->getMessage()]); + } + } + } diff --git a/Metadata/omnipay_Sagepay_Server.mgd.php b/Metadata/omnipay_Sagepay_Server.mgd.php index 1f863bca4..c527f2e62 100644 --- a/Metadata/omnipay_Sagepay_Server.mgd.php +++ b/Metadata/omnipay_Sagepay_Server.mgd.php @@ -77,6 +77,8 @@ // Hopefully temporary fix. // https://github.com/thephpleague/omnipay-sagepay/pull/158 'is_pass_null_for_empty_card' => TRUE, + 'create_card_action' => 'purchase', + 'token_pay_action' => 'repeatPurchase', ], 'params' => [ @@ -89,6 +91,7 @@ 'class_name' => 'Payment_OmnipayMultiProcessor', 'billing_mode' => 4, 'payment_type' => 3, + 'is_recur' => TRUE, ], ], ]; diff --git a/tests/phpunit/Mock/SagepayRepeatAuthorize.txt b/tests/phpunit/Mock/SagepayRepeatAuthorize.txt new file mode 100644 index 000000000..f821c7dd8 --- /dev/null +++ b/tests/phpunit/Mock/SagepayRepeatAuthorize.txt @@ -0,0 +1,12 @@ +HTTP/1.1 200 OK +Date: Thu, 20 Feb 2020 10:25:00 GMT + +VPSProtocol=3.00 +Status=OK +StatusDetail=0000 : The Authorisation was Successful. +VPSTxId={ + B4453DF4-E7D1-1CF3-ED60-6DA4AEA78D08 +} +SecurityKey=BEY5QUAYGL +TxAuthNo=8365828 +BankAuthCode=999777" diff --git a/tests/phpunit/SagepayTest.php b/tests/phpunit/SagepayTest.php index 051e59952..279dc4275 100644 --- a/tests/phpunit/SagepayTest.php +++ b/tests/phpunit/SagepayTest.php @@ -7,6 +7,8 @@ use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; use Civi\Test\Api3TestTrait; +use Civi\Api4\ContributionRecur; +use Civi\Api4\Contribution; /** * Sage pay tests for one-off payment. @@ -96,6 +98,71 @@ public function testDoSinglePayment(): void { 'contribution_id' => $this->_contribution['id'], ]); + $contribution = $this->callAPISuccess('Contribution', 'get', [ + 'return' => ['trxn_id'], + 'contact_id' => $this->ids['Contact']['id'], + 'sequential' => 1, + ]); + + // Reset session as this would come in from the sage server. + CRM_Core_Session::singleton()->reset(); + $ipnParams = $this->getSagepayPaymentConfirmation($this->paymentProcessorID, $contribution['id']); + $this->signRequest($ipnParams); + try { + CRM_Core_Payment_OmnipayMultiProcessor::processPaymentResponse(['processor_id' => $this->paymentProcessorID]); + } + catch (CRM_Core_Exception_PrematureExitException $e) { + // Check we didn't try to redirect the server. + $this->assertArrayNotHasKey('url', $e->errorData); + $contribution = \Civi\Api4\Contribution::get(FALSE) + ->addWhere('id', '=', $contribution['id']) + ->addSelect('contribution_status_id:name', 'trxn_id')->execute()->first(); + $this->assertEquals('Completed', $contribution['contribution_status_id:name']); + } + } + + + /** + * When a payment is made, the Sagepay transaction identifier `VPSTxId`, + * a secret security key `SecurityKey` and the corresponding `qfKey` + * must be saved as part of the `trxn_id` JSON. + * + * @throws \CRM_Core_Exception + * @throws \CiviCRM_API3_Exception + */ + public function testDoRecurPayment(): void { + $this->setMockHttpResponse([ + 'SagepayOneOffPaymentSecret.txt', + 'SagepayRepeatAuthorize.txt', + ]); + Civi::$statics['Omnipay_Test_Config'] = [ 'client' => $this->getHttpClient() ]; + + $contributionRecur = ContributionRecur::create(FALSE)->setValues([ + 'contact_id' => $this->ids['Contact'], + 'amount' => 5, + 'currency' => 'GBP', + 'frequency_interval' => 1, + 'start_date' => 'now', + 'payment_processor_id' => $this->paymentProcessorID, + ])->execute()->first(); + + Contribution::update(FALSE)->addWhere('id', '=', $this->_contribution['id'])->setValues(['contribution_recur_id' => $contributionRecur['id']])->execute(); + $transactionSecret = $this->getSagepayTransactionSecret(); + + $payment = $this->callAPISuccess('PaymentProcessor', 'pay', [ + 'payment_processor_id' => $this->paymentProcessorID, + 'amount' => $this->_new['amount'], + 'qfKey' => $this->getQfKey(), + 'currency' => $this->_new['currency'], + 'component' => 'contribute', + 'email' => $this->_new['card']['email'], + 'contactID' => $this->ids['Contact']['id'], + 'contributionID' => $this->_contribution['id'], + 'contribution_id' => $this->_contribution['id'], + 'contributionRecurID' => $contributionRecur['id'], + 'is_recur' => TRUE, + ]); + $contribution = $this->callAPISuccess('Contribution', 'get', [ 'return' => ['trxn_id'], 'contact_id' => $this->ids['Contact'], @@ -117,6 +184,30 @@ public function testDoSinglePayment(): void { ->addSelect('contribution_status_id:name', 'trxn_id')->execute()->first(); $this->assertEquals('Completed', $contribution['contribution_status_id:name']); } + $recur = ContributionRecur::get(FALSE) + ->addSelect('payment_token_id') + ->addSelect('payment_processor_id') + ->addWhere('id', '=', $contributionRecur['id']) + ->execute()->first(); + $this->assertNotEmpty($recur['payment_token_id']); + $contribution = $this->callAPISuccess('Contribution', 'repeattransaction', [ + 'contribution_recur_id' => $contributionRecur['id'], + 'payment_processor_id' => $this->paymentProcessorID, + ]); + $this->callAPISuccess('PaymentProcessor', 'pay', [ + 'amount' => $this->_new['amount'], + 'currency' => $this->_new['currency'], + 'payment_processor_id' => $this->paymentProcessorID, + 'contribution_id' => $contribution['id'], + 'token' => civicrm_api3('PaymentToken', 'getvalue', [ + 'id' => $recur['payment_token_id'], + 'return' => 'token', + ]), + 'payment_action' => 'purchase', + ]); + + $sent = $this->getRequestBodies(); + $this->assertContains('RelatedTxAuthNo=4898041', $sent[1]); } }