diff --git a/CRM/Core/Payment/OmnipayMultiProcessor.php b/CRM/Core/Payment/OmnipayMultiProcessor.php index d74a23395..cc63f9d44 100644 --- a/CRM/Core/Payment/OmnipayMultiProcessor.php +++ b/CRM/Core/Payment/OmnipayMultiProcessor.php @@ -141,9 +141,15 @@ public function doPayment(&$params, $component = 'contribute') { if (!empty($params['token'])) { $response = $this->doTokenPayment($params); } - elseif (!empty($params['is_recur'])) { + elseif (!empty($params['is_recur']) && (!$this->getProcessorTypeMetadata('continuous_authority'))) { $response = $this->gateway->createCard($this->getCreditCardOptions(array_merge($params, array('action' => 'Purchase')), $this->_component))->send(); } + elseif ($params['continuous_authority_repeat'] && $this->getProcessorTypeMetadata('continuous_authority')) { + $repeat = $this->getCreditCardOptions($params); + unset($repeat['card']); + $repeat['transactionReference'] = $params['original_contribution_trxn_id']; + $response = $this->gateway->repeatAuthorize($repeat)->send(); + } else { $response = $this->gateway->purchase($this->getCreditCardOptions($params)) ->send(); @@ -176,6 +182,9 @@ public function doPayment(&$params, $component = 'contribute') { return $params; } elseif ($response->isRedirect()) { + if ($this->getProcessorTypeMetadata('notification_from_different_session')) { + $this->saveTransactionReference($params['contributionID'], $response); + } $isTransparentRedirect = ($response->isTransparentRedirect() || !empty($this->gateway->transparentRedirect)); $this->cleanupClassForSerialization(TRUE); CRM_Core_Session::storeSessionObjects(FALSE); @@ -202,6 +211,68 @@ public function doPayment(&$params, $component = 'contribute') { } } + /** + * Sagepay requires us remembering the transaction reference before redirecting, + * as it contains the `SecurityKey` field, that will be necessary in order + * to verify a successful payment notification. + * + * Also, their call to notify payments is made by Sagepay servers, not by + * the user who started the transaction. Therefore, as session variables + * are not going to be available, we need to store the `qfKey` also. + */ + protected function saveTransactionReference($contributionID, $response) { + civicrm_api3('Contribution', 'create', [ + 'id' => $contributionID, + 'trxn_id' => $this->addQfKeyToTransactionReference($response), + ]); + } + + /** + * Save the qfKey as part of the transaction reference so it can be accessed + * from both sessions: the user one and the Sagepay notification service. + */ + protected function addQfKeyToTransactionReference($response) { + try { + $reference = json_decode($response->getTransactionReference(), TRUE); + } catch(Exception $e) {} + + $reference['qfKey'] = $this->getQfKey(); + return json_encode($reference); + } + + /** + * During Sagepay payment notifications, we need to read both the + * `SecurityKey` and the `qfKey` from the database. The first one + * to actually validate the notification. The second one to obtain + * the redirection URLs. + */ + protected function getSavedParameter($contributionID, $field) { + $trxnId = CRM_Core_DAO::singleValueQuery('SELECT trxn_id + FROM civicrm_contribution + WHERE id = %1', [ + 1 => [ $contributionID, 'Integer' ] + ] + ); + + if ($trxnId) { + try { + $reference = json_decode($trxnId, TRUE); + } catch(Exception $e) {} + + if (array_key_exists($field, $reference)) { + return $reference[$field]; + } + } + } + + protected function getSavedSecurityKey($contributionID) { + return $this->getSavedParameter($contributionID, 'SecurityKey'); + } + + protected function getSavedQfKey($contributionID) { + return $this->getSavedParameter($contributionID, 'qfKey'); + } + /** * Initialize class variables. * @@ -512,6 +583,8 @@ private function getCreditCardObjectParams($params) { $cardFields['billingState'] = CRM_Core_PseudoConstant::stateProvinceAbbreviation($cardFields['billingState']); } + $this->copyShippingFieldsFromBillingIfEmpty($cardFields); + if (empty($cardFields['email'])) { if (!empty($params['email-' . $billingID])) { $cardFields['email'] = $params['email-' . $billingID]; @@ -530,6 +603,21 @@ private function getCreditCardObjectParams($params) { return $cardFields; } + /** + * To prevent errors like Sagepay's "3140 : The DeliveryCountry value is invalid", + * we copy billing values where shipping values are empty + */ + protected function copyShippingFieldsFromBillingIfEmpty(&$cardFields) { + $fieldSuffixes = [ 'Address1', 'Address2', 'City', 'Postcode', 'State', 'Country' ]; + foreach($fieldSuffixes as $fieldSuffix) { + $billingField = 'billing' . $fieldSuffix; + $shippingField = 'shipping' . $fieldSuffix; + if (empty($cardFields[$shippingField]) && isset($cardFields[$billingField])) { + $cardFields[$shippingField] = $cardFields[$billingField]; + } + } + } + /** * Get sensitive credit card fields. * @@ -737,6 +825,24 @@ public function getBillingAddressFields($billingLocationID = NULL) { return $billingFields; } + /** + * Get details of CiviCRM mandatory and optional address fields. + * + * @param int $billingLocationID + * + * @return array + */ + public function getBillingAddressFieldsMetadata($billingLocationID = NULL) { + $metadata = parent::getBillingAddressFieldsMetadata($billingLocationID); + $fields = $this->getProcessorTypeMetadata('fields'); + $fieldDetails = $this->getProcessorTypeMetadata('field_details'); + foreach($fieldDetails as $key => $value) { + $metaKey = $fields['billing_fields'][$key]; + $metadata[$metaKey] = array_merge($metadata[$metaKey], $value); + } + return $metadata; + } + /** * Handle response from processor. * @@ -781,6 +887,13 @@ public function processPaymentNotification($params) { $response = $this->gateway->completePurchase($params)->send(); } if ($response->getTransactionId()) { + if ($this->getProcessorTypeMetadata('notification_from_different_session')) { + $this->setQfKey($this->getSavedQfKey($response->getTransactionId())); + $securityKey = $this->getSavedSecurityKey($response->getTransactionId()); + if ($securityKey) { + $response->setSecurityKey($securityKey); + } + } $this->setContributionReference($response->getTransactionId(), 'strip'); } } @@ -808,9 +921,14 @@ public function processPaymentNotification($params) { )); if ($this->getLock() && CRM_Core_PseudoConstant::getName('CRM_Contribute_BAO_Contribution', 'contribution_status_id', $contribution['contribution_status_id']) !== 'Completed') { + if ($this->getProcessorTypeMetadata('notification_from_different_session')) { + $reference = $this->addQfKeyToTransactionReference($response); + } else { + $reference = $response->getTransactionReference(); + } civicrm_api3('contribution', 'completetransaction', array( 'id' => $this->transaction_id, - 'trxn_id' => $response->getTransactionReference(), + 'trxn_id' => $reference, 'payment_processor_id' => $params['processor_id'], )); } @@ -931,7 +1049,12 @@ protected function redirectOrExit($outcome, $response = NULL) { CRM_Core_Session::setStatus($userMsg); $redirectUrl = $this->getStoredUrl('fail'); if (!$redirectUrl && method_exists($response, 'invalid')) { - $response->invalid($redirectUrl, $userMsg); + if ($this->getProcessorTypeMetadata('notification_from_different_session')) { + $qfKey = $this->getSavedQfKey($this->transaction_id); + $response->invalid($this->getReturnFailUrl($qfKey), $userMsg); + } else { + $response->invalid($redirectUrl, $userMsg); + } } break; @@ -943,7 +1066,12 @@ protected function redirectOrExit($outcome, $response = NULL) { CRM_Core_Session::setStatus($userMsg); $redirectUrl = $this->getStoredUrl('fail'); if ($response && method_exists($response, 'error')) { - $response->error($redirectUrl, $userMsg); + if ($this->getProcessorTypeMetadata('notification_from_different_session')) { + $qfKey = $this->getSavedQfKey($this->transaction_id); + $response->error($this->getReturnFailUrl($qfKey), $userMsg); + } else { + $response->error($redirectUrl, $userMsg); + } } try { $this->handleError('error', $this->transaction_id . ' ' . $response->getMessage(), array('processor_error', $response->getMessage()), 9002, $userMsg); @@ -956,7 +1084,12 @@ protected function redirectOrExit($outcome, $response = NULL) { $userMsg = NULL; $redirectUrl = $this->getStoredUrl('success'); if (!$redirectUrl && method_exists($response, 'confirm')) { - $output = $response->confirm($redirectUrl, $userMsg); + if ($this->getProcessorTypeMetadata('notification_from_different_session')) { + $qfKey = $this->getSavedQfKey($this->transaction_id); + $response->confirm($this->getReturnSuccessUrl($qfKey), $userMsg); + } else { + $output = $response->confirm($redirectUrl, $userMsg); + } if ($output) { echo $output; } diff --git a/Metadata/omnipay_Sagepay_Server.mgd.php b/Metadata/omnipay_Sagepay_Server.mgd.php index 5af7605e2..587a66880 100644 --- a/Metadata/omnipay_Sagepay_Server.mgd.php +++ b/Metadata/omnipay_Sagepay_Server.mgd.php @@ -56,7 +56,14 @@ 'postal_code' => "billing_postal_code-{$billingLocationID}", ), ), + 'field_details' => array( + 'state_province' => array( + 'is_required' => FALSE, + ) + ), 'ipn_processing_delay' => 0, + 'notification_from_different_session' => TRUE, + 'continuous_authority' => TRUE, ), 'params' => array( @@ -67,10 +74,13 @@ 'user_name_label' => 'Vendor', 'password_label' => 'unused', 'signature_label' => 'unused', - 'site_url' => '', + 'site_url' => 'unused', 'class_name' => 'Payment_OmnipayMultiProcessor', 'billing_mode' => 4, 'payment_type' => 3, + 'url_recur_default' => 'http://unused.com', + 'url_recur_test_default' => 'http://unused.com', + 'is_recur' => 0, ), ), ); diff --git a/api/v3/Job/ProcessRecurring.php b/api/v3/Job/ProcessRecurring.php index 3f8ad0ab3..28d0c4bff 100644 --- a/api/v3/Job/ProcessRecurring.php +++ b/api/v3/Job/ProcessRecurring.php @@ -35,29 +35,51 @@ function civicrm_api3_job_process_recurring($params) { 'is_email_receipt' => FALSE, )); - $payment = civicrm_api3('PaymentProcessor', 'pay', array( - 'amount' => $originalContribution['total_amount'], - 'currency' => $originalContribution['currency'], - 'payment_processor_id' => $paymentProcessorID, - 'contributionID' => $pending['id'], - 'contribution_id' => $pending['id'], - 'contactID' => $originalContribution['contact_id'], - 'description' => ts('Repeat payment, original was ' . $originalContribution['id']), - 'token' => civicrm_api3('PaymentToken', 'getvalue', [ - 'id' => $recurringPayment['payment_token_id'], - 'return' => 'token', - ]), - 'payment_action' => 'purchase', - )); - $payment = reset($payment['values']); + if (!is_continuous_authority($omnipayProcessors['values'][$paymentProcessorID])) { + $payment = civicrm_api3('PaymentProcessor', 'pay', array( + 'amount' => $originalContribution['total_amount'], + 'currency' => $originalContribution['currency'], + 'payment_processor_id' => $paymentProcessorID, + 'contributionID' => $pending['id'], + 'contribution_id' => $pending['id'], + 'contactID' => $originalContribution['contact_id'], + 'description' => ts('Repeat payment, original was ' . $originalContribution['id']), + 'token' => civicrm_api3('PaymentToken', 'getvalue', [ + 'id' => $recurringPayment['payment_token_id'], + 'return' => 'token', + ]), + 'payment_action' => 'purchase', + )); + $payment = reset($payment['values']); - civicrm_api3('Contribution', 'completetransaction', array( - 'id' => $pending['id'], - 'trxn_id' => $payment['trxn_id'], - 'payment_processor_id' => $paymentProcessorID, - )); - $result['success']['ids'] = $recurringPayment['id']; + civicrm_api3('Contribution', 'completetransaction', array( + 'id' => $pending['id'], + 'trxn_id' => $payment['trxn_id'], + 'payment_processor_id' => $paymentProcessorID, + )); + $result['success']['ids'] = $recurringPayment['id']; + } else { + $payment = civicrm_api3('PaymentProcessor', 'pay', array( + 'amount' => $originalContribution['total_amount'], + 'currency' => $originalContribution['currency'], + 'payment_processor_id' => $paymentProcessorID, + 'contributionID' => $pending['id'], + 'contribution_id' => $pending['id'], + 'original_contribution_trxn_id' => $originalContribution['trxn_id'], + 'continuous_authority_repeat' => TRUE, + 'contactID' => $originalContribution['contact_id'], + 'description' => ts('Repeat payment, original was ' . $originalContribution['id']), + 'payment_action' => 'purchase', + )); + $payment = reset($payment['values']); + civicrm_api3('Contribution', 'completetransaction', array( + 'id' => $pending['id'], + 'trxn_id' => $payment['trxn_id'], + 'payment_processor_id' => $paymentProcessorID, + )); + $result['success']['ids'] = $recurringPayment['id']; + } } catch (CiviCRM_API3_Exception $e) { // Failed - what to do? @@ -84,3 +106,44 @@ function civicrm_api3_job_process_recurring($params) { */ function _civicrm_api3_job_process_recurring_spec(&$params) { } + +function is_continuous_authority($paymentProcessor) { + $paymentProcessorType = civicrm_api3('PaymentProcessorType', 'get', array( + 'id' => $paymentProcessor['payment_processor_type_id'], + 'sequential' => 1, + )); + + $property = get_processor_type_property($paymentProcessorType['values'][0]['name'], 'continuous_authority'); + + if($property['found']) { + return $property['value']; + } else { + return FALSE; + } +} + +function get_processor_type_by_name($processorTypeName, $entity) { + if($entity['entity'] != 'payment_processor_type') { return FALSE; } + if(!array_key_exists('params', $entity)) { return FALSE; } + if(!array_key_exists('name', $entity['params'])) { return FALSE; } + return $entity['params']['name'] == $processorTypeName; +} + +function get_processor_type_metadata($processorTypeName) { + $entities = []; + omnipaymultiprocessor_civicrm_managed($entities); + $filter_by_name = function($entity) use($processorTypeName) { + return get_processor_type_by_name($processorTypeName, $entity); + }; + return array_values(array_filter($entities, $filter_by_name))[0]; +} + +function get_processor_type_property($processorTypeName, $propertyName) { + $processorTypeMetadata = get_processor_type_metadata($processorTypeName); + if(!array_key_exists('metadata', $processorTypeMetadata)) { return array('found' => FALSE); } + if(!array_key_exists($propertyName, $processorTypeMetadata['metadata'])) { return array('found' => FALSE); } + return array( + 'found' => TRUE, + 'value' => $processorTypeMetadata['metadata'][$propertyName], + ); +}