diff --git a/CRM/Core/Payment/Faps.php b/CRM/Core/Payment/Faps.php index 5719b25b..2409d854 100644 --- a/CRM/Core/Payment/Faps.php +++ b/CRM/Core/Payment/Faps.php @@ -191,7 +191,7 @@ public function buildForm(&$form) { $markup = ''; // '; CRM_Core_Region::instance('billing-block')->add(array( 'markup' => $markup, - )); + )); // the cryptojs above is the one on the 1pay server, now I load and invoke the extension's crypto.js $myCryptoJs = $resources->getUrl('com.iatspayments.civicrm', 'js/crypto.js'); // after manually doing what addVars('iats', $jsVariables) would normally do @@ -422,6 +422,46 @@ public function doPayment(&$params, $component = 'contribute') { } } + /** + * Does this payment processor support refund? + * + * @return bool + */ + public function supportsRefund() { + return TRUE; + } + + // might become a supported core function but for now just create our own function name + public function doRefund($params = []) { + $request = [ + 'refNumber' => $params['trxn_id'], + 'transactionAmount' => sprintf('%01.2f', CRM_Utils_Rule::cleanMoney($params['total_amount'])), + ]; + + $options = [ + 'action' => 'Credit', + 'test' => $this->is_test, + ]; + $token_request = new CRM_Iats_FapsRequest($options); + // CRM_Core_Error::debug_var('token request', $request); + $credentials = [ + 'merchantKey' => $this->_paymentProcessor['signature'], + 'processorId' => $this->_paymentProcessor['user_name'] + ]; + $result = $token_request->request($credentials, $request); + + $this->error($result); + + if (!empty($result['authResponse'] == 'ACCEPTED')) { + $refundParams = [ + 'refund_trxn_id' => $result['referenceNumber'], + 'refund_status_id' => CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed'), + 'refund_status_name' => 'Completed', + ]; + return $refundParams; + } + } + /** * Support corresponding CiviCRM method */ @@ -462,6 +502,64 @@ public function getRecurringScheduleUpdateHelpText() { return 'Use this form to change the amount or number of installments for this recurring contribution.'; } + /* + * Implement the ability to update the billing info for recurring contributions, + * This functionality will apply to back-end and front-end, + * so it's only enabled when configured as on via the iATS admin settings. + * The default isSupported method is overridden above to achieve this. + * + * Return TRUE on success or an error. + */ + public function updateSubscriptionBillingInfo(&$message = '', $params = array()) { + // Fix billing form update bug https://github.com/iATSPayments/com.iatspayments.civicrm/issues/252 by getting crid from _POST + if (empty($params['crid'])) { + $params['crid'] = !empty($_POST['crid']) ? (int) $_POST['crid'] : (!empty($_GET['crid']) ? (int) $_GET['crid'] : 0); + if (empty($params['crid']) && !empty($params['entryURL'])) { + $components = parse_url($params['entryURL']); + parse_str(html_entity_decode($components['query']), $entryURLquery); + $params['crid'] = $entryURLquery['crid']; + } + } + // updatedBillingInfo array changed sometime after 4.7.27 + $crid = !empty($params['crid']) ? $params['crid'] : $params['recur_id']; + if (empty($crid)) { + $alert = ts('This system is unable to perform self-service updates to credit cards. Please contact the administrator of this site.'); + throw new Exception($alert); + } + $contribution_recur = civicrm_api3('ContributionRecur', 'getsingle', ['id' => $crid]); + $payment_token = $result = civicrm_api3('PaymentToken', 'getsingle', ['id' => $contribution_recur['payment_token_id']]); + $params['token'] = $payment_token['token']; + // construct the array of data that I'll submit to the iATS Payments server. + $options = [ + 'action' => 'VaultUpdateCCRecord', + ]; + $vault_request = new CRM_Iats_FapsRequest($options); + + $request = $this->convertParams($params, $options['action']); + $result = CRM_Iats_FapsRequest::credentials($contribution_recur['payment_processor_id']); + $credentials = [ + 'merchantKey' => $result['signature'], + 'processorId' => $result['user_name'], + ]; + + // Make the soap request. + try { + $response = $vault_request->request($credentials, $request); + // note: don't log this to the iats_response table. + // CRM_Core_Error::debug_var('faps result', $response); + if (!empty($response['recordsUpdated'])) { + return TRUE; + } + else { + return self::error($response); + } + } + catch (Exception $error) { // what could go wrong? + $message = $error->getMessage(); + throw new PaymentProcessorException($message, '9002'); + } + } + /** * Convert the values in the civicrm params to the request array with keys as expected by FAPS * @@ -471,15 +569,50 @@ public function getRecurringScheduleUpdateHelpText() { * @return array */ protected function convertParams($params, $method) { + $convert = array( + 'ownerEmail' => 'email', + 'ownerStreet' => 'street_address', + 'ownerCity' => 'city', + 'ownerState' => 'state_province', + 'ownerZip' => 'postal_code', + 'ownerCountry' => 'country', + 'orderId' => 'invoiceID', + 'cardNumber' => 'credit_card_number', + 'cardExpYear' => 'year', + 'cardExpMonth' => 'month', + 'cVV' => 'cvv2', + 'ownerName' => [ + 'billing_first_name', + 'billing_last_name', + ], + ); + if (in_array($method, ['GenerateTokenFromCreditCard', 'VaultCreateCCRecord'])) { + $convert = array_merge($convert, [ + 'creditCardCryptogram' => 'cryptogram', + 'transactionAmount' => 'amount', + ]); + } + if ($method == 'VaultUpdateCCRecord') { + $convert = array_merge($convert, [ + 'cardtype' => 'credit_card_type', + 'ownerName' => [ + 'first_name', + 'middle_name', + 'last_name', + ], + 'vaultKey' => 'token', + ]); + } + if (empty($params['country']) && !empty($params['country_id'])) { try { $result = civicrm_api3('Country', 'get', [ 'sequential' => 1, 'return' => ['name'], - 'id' => $params['country_id'], + 'id' => $params['country_id'], 'options' => ['limit' => 1], ]); - $params['country'] = $result['values'][0]['name']; + $params['country'] = $result['values'][0]['name']; } catch (CiviCRM_API3_Exception $e) { Civi::log()->info('Unexpected error from api3 looking up countries/states/provinces'); @@ -490,32 +623,51 @@ protected function convertParams($params, $method) { $result = civicrm_api3('StateProvince', 'get', [ 'sequential' => 1, 'return' => ['name'], - 'id' => $params['state_province_id'], + 'id' => $params['state_province_id'], 'options' => ['limit' => 1], ]); - $params['state_province'] = $result['values'][0]['name']; + $params['state_province'] = $result['values'][0]['name']; } catch (CiviCRM_API3_Exception $e) { Civi::log()->info('Unexpected error from api3 looking up countries/states/provinces'); } } $request = array(); - $convert = array( - 'ownerEmail' => 'email', - 'ownerStreet' => 'street_address', - 'ownerCity' => 'city', - 'ownerState' => 'state_province', - 'ownerZip' => 'postal_code', - 'ownerCountry' => 'country', - 'orderId' => 'invoiceID', - 'cardNumber' => 'credit_card_number', -// 'cardtype' => 'credit_card_type', - 'cVV' => 'cvv2', - 'creditCardCryptogram' => 'cryptogram', - ); foreach ($convert as $r => $p) { + if ($r == 'ownerName') { + $request[$r] = ''; + foreach ($p as $namePart) { + $request[$r] .= !empty($params[$namePart]) ? $params[$namePart] . ' ' : ''; + } + continue; + } if (isset($params[$p])) { - $request[$r] = htmlspecialchars($params[$p]); + if ($r == 'transactionAmount') { + $request[$r] = sprintf('%01.2f', CRM_Utils_Rule::cleanMoney($params[$p])); + } + elseif ($r == 'cardExpYear') { + $request[$r] = sprintf('%02d', $params[$p] % 100); + } + elseif ($r == 'cardExpMonth') { + $request[$r] = sprintf('%02d', $params[$p]); + } + elseif ($r == 'cardtype') { + $mop = [ + 'Visa' => 'VISA', + 'MasterCard' => 'MC', + 'Amex' => 'AMX', + 'Discover' => 'DSC', + ]; + $request[$r] = $mop[$params[$p]]; + } + elseif ($r == 'vaultKey') { + $matches = explode(':', $params[$p]); + $request['id'] = $matches[1]; + $request[$r] = $matches[0]; + } + else { + $request[$r] = htmlspecialchars($params[$p]); + } } } if (empty($params['email'])) { @@ -526,14 +678,7 @@ protected function convertParams($params, $method) { $request['ownerEmail'] = $params['email-Primary']; } } - $request['ownerName'] = $params['billing_first_name'].' '.$params['billing_last_name']; - if (!empty($params['month'])) { - $request['cardExpMonth'] = sprintf('%02d', $params['month']); - } - if (!empty($params['year'])) { - $request['cardExpYear'] = sprintf('%02d', $params['year'] % 100); - } - $request['transactionAmount'] = sprintf('%01.2f', CRM_Utils_Rule::cleanMoney($params['amount'])); + // additional method-specific values (none!) //CRM_Core_Error::debug_var('params for conversion', $params); //CRM_Core_Error::debug_var('method', $method); @@ -550,9 +695,6 @@ public function &error($error = NULL) { if (is_object($error)) { throw new PaymentProcessorException(ts('Error %1', [1 => $error->getMessage()]), $error_code); } - elseif ($error && is_numeric($error)) { - throw new PaymentProcessorException(ts('Error %1', [1 => $this->errorString($error)]), $error_code); - } elseif (is_array($error)) { $errors = array(); if ($error['isError']) { @@ -572,7 +714,7 @@ public function &error($error = NULL) { else { /* in the event I'm handling an unexpected argument */ throw new PaymentProcessorException(ts('Unknown System Error.'), 'process_1stpay_extension'); } - return $e; + return $error; } /* @@ -654,6 +796,3 @@ protected function updateContribution($params, $update = array()) { } - - - diff --git a/CRM/Core/Payment/iATSServiceACHEFT.php b/CRM/Core/Payment/iATSServiceACHEFT.php index 5332d44c..f297e90f 100644 --- a/CRM/Core/Payment/iATSServiceACHEFT.php +++ b/CRM/Core/Payment/iATSServiceACHEFT.php @@ -197,6 +197,47 @@ protected function buildForm_CAD(&$form) { )); } + /** + * Does this payment processor support refund? + * + * @return bool + */ + public function supportsRefund() { + return TRUE; + } + + // might become a supported core function but for now just create our own function name + public function doRefund($params = []) { + $iats = new CRM_Iats_iATSServiceRequest([ + 'type' => 'process', + 'method' => 'acheft_refund', + 'iats_domain' => $this->_profile['iats_domain'], + ]); + $request = [ + 'transactionId' => $params['trxn_id'], + 'total' => (-1 * sprintf('%01.2f', CRM_Utils_Rule::cleanMoney($params['total_amount']))), + 'customerIPAddress' => (function_exists('ip_address') ? ip_address() : $_SERVER['REMOTE_ADDR']), + ]; + $credentials = [ + 'agentCode' => $this->_paymentProcessor['user_name'], + 'password' => $this->_paymentProcessor['password'], + ]; + // Make the soap request. + $response = $iats->request($credentials, $request); + + $result = $iats->result($response); + if ($result['status']) { + $refundParams = [ + 'refund_trxn_id' => trim($result['remote_id']) . ':' . time(), + 'refund_status_id' => CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed'), + 'refund_status_name' => 'Completed', + ]; + return $refundParams; + } + else { + return self::error($result['reasonMessage']); + } + } /** * diff --git a/CRM/Iats/FapsRequest.php b/CRM/Iats/FapsRequest.php index 2542fdd9..0b8a5a24 100644 --- a/CRM/Iats/FapsRequest.php +++ b/CRM/Iats/FapsRequest.php @@ -130,4 +130,14 @@ public function request($credentials, $request_params, $log_failure = TRUE) { return $e->getMessage(); } } + + public static function credentials($payment_processor_id) { + static $credentials = []; + if (empty($credentials[$payment_processor_id])) { + $credentials[$payment_processor_id] = civicrm_api3('PaymentProcessor', 'get', [ + 'id' => $payment_processor_id, + ])['values'][$payment_processor_id]; + } + return $credentials[$payment_processor_id]; + } } diff --git a/CRM/Iats/Form/Refund.php b/CRM/Iats/Form/Refund.php new file mode 100644 index 00000000..7fa88443 --- /dev/null +++ b/CRM/Iats/Form/Refund.php @@ -0,0 +1,116 @@ +_id = CRM_Utils_Request::retrieve('id', 'Positive', $this, TRUE); + $this->_contactID = CRM_Utils_Request::retrieve('cid', 'Positive', $this, TRUE); + + $this->_paymentProcessorID = E::getPaymentProcessorByContributionID($this->_id); + if (!$this->_paymentProcessorID) { + CRM_Core_Error::statusBounce(ts('Payment processor not found')); + } + parent::preProcess(); + + $this->_isTest = 0; + if ($this->_action & CRM_Core_Action::PREVIEW) { + $this->_isTest = 1; + } + } + + public function buildQuickForm() { + $this->addButtons( + array( + array( + 'type' => 'next', + 'name' => ts('Refund'), + 'spacing' => '         ', + 'isDefault' => TRUE, + ), + array( + 'type' => 'cancel', + 'name' => ts('Cancel'), + ), + ) + ); + } + + public function postProcess() { + // find the token for this contribution + try { + $contribution = civicrm_api3('Contribution', 'getsingle', array('id' => $this->_id)); + } + catch (CiviCRM_API3_Exception $e) { + // FIXME: display an error message or something ? + throw new \Civi\Payment\Exception\PaymentProcessorException($e->getMessage()); + } + + try { + $refundParams = [ + 'payment_processor_id' => $this->_paymentProcessorID, + 'amount' => $contribution['total_amount'], + 'currency' => $contribution['currency'], + 'trxn_id' => $contribution['trxn_id'], + ]; + $refund = civicrm_api3('PaymentProcessor', 'Refund', $refundParams)['values']; + if ($refund['refund_status_name'] === 'Completed') { + $payments = civicrm_api3('Payment', 'get', ['entity_id' => $params['contribution_id']]); + if (!empty($payments['count']) && !empty($payments['values'])) { + foreach ($payments['values'] as $payment) { + civicrm_api3('Payment', 'cancel', [ + 'id' => $payment['id'], + 'trxn_date' = date('Y-m-d H:i:s'), + ]); + } + } + } + $refundPaymentParams = [ + 'contribution_id' => $this->_id, + 'trxn_id' => $refund['refund_trxn_id'], + 'total_amount' => (-1 * $contribution['total_amount']), + 'payment_processor_id' => $this->_paymentProcessorID, + ]; + $trxn = CRM_Financial_BAO_Payment::create($refundPaymentParams); + + CRM_Core_Session::setStatus(E::ts('Refund was processed successfully.'), 'Refund processed', 'success'); + + CRM_Core_Session::singleton()->replaceUserContext(CRM_Utils_System::url('civicrm/contact/view', + "reset=1&cid={$this->_contactID}&selectedChild=contribute" + )); + } catch (Exception $e) { + CRM_Core_Error::statusBounce($e->getMessage(), NULL, 'Refund failed'); + } + } + +} diff --git a/CRM/Iats/iATSServiceRequest.php b/CRM/Iats/iATSServiceRequest.php index 13700124..c9263cee 100644 --- a/CRM/Iats/iATSServiceRequest.php +++ b/CRM/Iats/iATSServiceRequest.php @@ -481,6 +481,13 @@ public function methodInfo($type = '', $method = '') { 'message' => 'ProcessACHEFTWithCustomerCode', 'response' => 'ProcessACHEFTWithCustomerCodeResult', ), + 'acheft_refund' => array( + 'title' => 'Refund a specific ACH / EFT transaction', + 'description' => $desc . 'ProcessACHEFTRefundWithTransactionId', + 'method' => 'ProcessACHEFTRefundWithTransactionId', + 'message' => 'ProcessACHEFTRefundWithTransactionId', + 'response' => 'ProcessACHEFTRefundWithTransactionIdResult', + ), ); break; case 'report': diff --git a/README.md b/README.md index db73818c..5fb5fc9b 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ Some issues may be related to core CiviCRM issues, and may not have an immediate Below is a list of some of the most common issues: +Unexpected failures. If you get an unexpectedly large number of failures for your recurring contributions, please review this page to understand how the extension does it's best to handle them and what administrators can do: https://github.com/iATSPayments/com.iatspayments.civicrm/wiki/Recurring-Contribution-Failure-Handling + 9002 Error - if you get this when trying to make a contribution, then you're getting that error back from the iATS server due to an account misconfiguration. When this happens and your using a 'legacy' iATS account -> check if you have special characters in your password (and remove them). If you're using '1st Pay' contact iATS Customer Service to ensure your account is configured properly. CiviCRM core assigns Membership status (=new) and extends Membership End date as well as Event status (=registered) as soon as ACH/EFT is submitted (so while payment is still pending - this could be several days for ACH/EFT). If the contribution receives a Ok:BankAccept -> the extension will mark the contribution in CiviCRM as completed. If the contribution does NOT receive a Ok:BankAccept -> the extension will mark the contribution in CiviCRM as rejected - however - associated existing Membership and Event records may need to be updated manually. diff --git a/iats.civix.php b/iats.civix.php index 575aa5a5..872183f0 100644 --- a/iats.civix.php +++ b/iats.civix.php @@ -75,6 +75,37 @@ public static function findClass($suffix) { return self::CLASS_PREFIX . '_' . str_replace('\\', '_', $suffix); } + /** + * Get iATS payment processor ID + * + * @param int $contributionID + * + * @return int|void + */ + public static function getPaymentProcessorByContributionID($contributionID) { + $paymentProcessorID = civicrm_api3('EntityFinancialTrxn', 'get', [ + 'return' => ['financial_trxn_id.payment_processor_id'], + 'entity_table' => 'civicrm_contribution', + 'entity_id' => $contributionID, + 'sequential' => 1, + 'financial_trxn_id.is_payment' => TRUE, + 'options' => ['sort' => 'financial_trxn_id DESC', 'limit' => 1], + ])['values'][0]['financial_trxn_id.payment_processor_id'] ?? NULL; + + if ($paymentProcessorId) { + $className = civicrm_api3('PaymentProcessor', 'getvalue', [ + 'sequential' => 1, + 'id' => $paymentProcessorID, + 'return' => 'class_name', + ]); + if (!in_array($className, ['Payment_iATSServiceACHEFT', 'Payment_Faps'])) { + return NULL; + } + } + + return $paymentProcessorID; + } + } use CRM_Iats_ExtensionUtil as E; diff --git a/iats.php b/iats.php index 8f253aa1..af536313 100644 --- a/iats.php +++ b/iats.php @@ -339,9 +339,9 @@ function iats_civicrm_buildForm($formName, &$form) { * Modifications to a (public/frontend) contribution financial forms for iATS * procesors. * 1. enable public selection of future recurring contribution start date. - * + * * We're only handling financial payment class forms here. Note that we can no - * longer test for whether the page has/is recurring or not. + * longer test for whether the page has/is recurring or not. */ function iats_civicrm_buildForm_CRM_Financial_Form_Payment(&$form) { @@ -355,7 +355,7 @@ function iats_civicrm_buildForm_CRM_Financial_Form_Payment(&$form) { return; } - // If enabled provide a way to set future contribution dates. + // If enabled provide a way to set future contribution dates. // Uses javascript to hide/reset unless they have recurring contributions checked. $settings = Civi::settings()->get('iats_settings'); if (!empty($settings['enable_public_future_recurring_start']) @@ -400,7 +400,7 @@ function iats_civicrm_pageRun(&$page) { * link to iATS CustomerLink display and editing pages. */ function iats_civicrm_pageRun_CRM_Contribute_Page_ContributionRecur($page) { - // Get the corresponding (most recently created) iATS customer code record + // Get the corresponding (most recently created) iATS customer code record // we'll also get the expiry date and last four digits (at least, our best information about that). $extra = array(); $crid = CRM_Utils_Request::retrieve('id', 'Integer', $page, FALSE); @@ -502,7 +502,7 @@ function iats_civicrm_pre($op, $objectName, $objectId, &$params) { function iats_get_setting($key = NULL) { static $settings; - if (empty($settings)) { + if (empty($settings)) { $settings = Civi::settings()->get('iats_settings'); } return empty($key) ? $settings : (isset($settings[$key]) ? $settings[$key] : ''); @@ -598,7 +598,7 @@ function _iats_get_form_payment_processors($form) { $id = $form->_paymentProcessor['id']; return array($id => $form->_paymentProcessor); } - else { + else { // Handle the legacy: event and contribution page forms if (empty($form->_paymentProcessors)) { if (empty($form->_paymentProcessorIDs)) { @@ -717,7 +717,7 @@ function iats_civicrm_buildForm_CRM_Contribute_Form_UpdateSubscription(&$form) { } $allow_days = empty($settings['days']) ? array('-1') : $settings['days']; if (0 < max($allow_days)) { - $userAlert = ts('Your next scheduled contribution date will automatically be updated to the next allowable day of the month: %1', + $userAlert = ts('Your next scheduled contribution date will automatically be updated to the next allowable day of the month: %1', array(1 => implode(', ', $allow_days))); CRM_Core_Session::setStatus($userAlert, ts('Warning'), 'alert'); } @@ -750,7 +750,7 @@ function iats_civicrm_buildForm_CRM_Contribute_Form_UpdateSubscription(&$form) { 'is_email_receipt' => 'Email receipt for each Contribution in this Recurring Series', ); $dupe_fields = array(); - // To be a good citizen, I check if core or another extension hasn't already added these fields + // To be a good citizen, I check if core or another extension hasn't already added these fields // and don't add them again if they have. foreach (array_keys($edit_fields) as $fid) { if ($form->elementExists($fid)) { @@ -819,3 +819,16 @@ function iats_civicrm_buildForm_CRM_Contribute_Form_UpdateBilling(&$form) { $form->addElement('hidden', 'crid', $crid); } } + +function iats_civicrm_links($op, $objectName, $objectId, &$links, &$mask, &$values) { + if ($objectName == 'Contribution' && $op == 'contribution.selector.row') { + $links[] = array( + 'name' => 'refund', + 'url' => 'civicrm/iats/refund', + 'qs' => 'reset=1&id=%%contribId%%&cid=%%cid%%', + 'title' => 'Refund Live Payment', + //'class' => 'no-popup', + ); + $values['contribId'] = $objectId; + } +} diff --git a/templates/CRM/Iats/Refund.tpl b/templates/CRM/Iats/Refund.tpl new file mode 100644 index 00000000..a462ed25 --- /dev/null +++ b/templates/CRM/Iats/Refund.tpl @@ -0,0 +1,11 @@ + +
+
+
+ {ts}You are about to refund the total amount of this contribution using iATS.{/ts} {ts}Do you want to continue?{/ts} +
+
+ +
+{include file="CRM/common/formButtons.tpl"} +
\ No newline at end of file diff --git a/xml/Menu/iats.xml b/xml/Menu/iats.xml index bf2c127d..fb4b7ba3 100644 --- a/xml/Menu/iats.xml +++ b/xml/Menu/iats.xml @@ -34,4 +34,10 @@ CRM_Iats_Form_IATSOneTimeCharge IATSOneTimeCharge + + civicrm/iats/refund + CRM_Iats_Form_Refund + Refund + access CiviCRM +