From b589f8848e62594c360de9fa41113ed095702a53 Mon Sep 17 00:00:00 2001 From: adixon Date: Thu, 14 Feb 2019 16:53:57 -0500 Subject: [PATCH] Better handling of recurring monthlies initiating after the 28th --- CRM/Core/Payment/iATSService.php | 7 + iats.php | 318 +++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+) diff --git a/CRM/Core/Payment/iATSService.php b/CRM/Core/Payment/iATSService.php index d47c3e95..eb7fef0c 100644 --- a/CRM/Core/Payment/iATSService.php +++ b/CRM/Core/Payment/iATSService.php @@ -565,6 +565,13 @@ protected function updateRecurring($params, $update) { // By default, it's empty, unless we've got a future start date. if (empty($update['receive_date'])) { $next = strtotime('+' . $params['frequency_interval'] . ' ' . $params['frequency_unit']); + // handle the special case of monthly contributions made after the 28th + if ('month' == $params['frequency_unit']) { + $now = getdate(); + if ($now['mday'] > 28) { // pull it back to the 28th + $next = $next - (($now['mday'] - 28) * 24 * 60 * 60); + } + } $recur_update['next_sched_contribution_date'] = date('Ymd', $next) . '030000'; } else { diff --git a/iats.php b/iats.php index ee5a6023..5cbef2f8 100644 --- a/iats.php +++ b/iats.php @@ -819,3 +819,321 @@ function iats_civicrm_buildForm_CRM_Contribute_Form_UpdateBilling(&$form) { $form->addElement('hidden', 'crid', $crid); } } + +/** + * Implementation of hook_civicrm_alterSettingsFolders. + * + * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_alterSettingsFolders + */ +function iats_civicrm_alterSettingsFolders(&$metaDataFolders = NULL) { + _iats_civix_civicrm_alterSettingsFolders($metaDataFolders); +} + +/** + * For a recurring contribution, find a reasonable candidate for a template, where possible. + */ +function _iats_civicrm_getContributionTemplate($contribution) { + // Get the first contribution in this series that matches the same total_amount, if present. + $template = array(); + $get = array('contribution_recur_id' => $contribution['contribution_recur_id'], 'options' => array('sort' => ' id', 'limit' => 1)); + if (!empty($contribution['total_amount'])) { + $get['total_amount'] = $contribution['total_amount']; + } + $result = civicrm_api3('contribution', 'get', $get); + if (!empty($result['values'])) { + $contribution_ids = array_keys($result['values']); + $template = $result['values'][$contribution_ids[0]]; + $template['original_contribution_id'] = $contribution_ids[0]; + $template['line_items'] = array(); + $get = array('entity_table' => 'civicrm_contribution', 'entity_id' => $contribution_ids[0]); + $result = civicrm_api3('LineItem', 'get', $get); + if (!empty($result['values'])) { + foreach ($result['values'] as $initial_line_item) { + $line_item = array(); + foreach (array('price_field_id', 'qty', 'line_total', 'unit_price', 'label', 'price_field_value_id', 'financial_type_id') as $key) { + $line_item[$key] = $initial_line_item[$key]; + } + $template['line_items'][] = $line_item; + } + } + } + return $template; +} + +/** + * Function _iats_contributionrecur_next. + * + * @param $from_time: a unix time stamp, the function returns values greater than this + * @param $days: an array of allowable days of the month + * + * A utility function to calculate the next available allowable day, starting from $from_time. + * Strategy: increment the from_time by one day until the day of the month matches one of my available days of the month. + */ +function _iats_contributionrecur_next($from_time, $allow_mdays) { + $dp = getdate($from_time); + // So I don't get into an infinite loop somehow. + $i = 0; + while (($i++ < 60) && !in_array($dp['mday'], $allow_mdays)) { + $from_time += (24 * 60 * 60); + $dp = getdate($from_time); + } + return $from_time; +} + +/** + * Function _iats_contribution_payment + * + * @param $contribution an array of a contribution to be created (or in case of future start date, + possibly an existing pending contribution to recycle, if it already has a contribution id). + * @param $options must include customer code, subtype and iats_domain, may include a membership id + * @param $original_contribution_id if included, use as a template for a recurring contribution. + * + * A high-level utility function for making a contribution payment from an existing recurring schedule + * Used in the Iatsrecurringcontributions.php job and the one-time ('card on file') form. + * + * Since 4.7.12, we can are using the new repeattransaction api. + */ +function _iats_process_contribution_payment(&$contribution, $options, $original_contribution_id) { + // By default, don't use repeattransaction + $use_repeattransaction = FALSE; + $is_recurrence = !empty($original_contribution_id); + // First try and get the money with iATS Payments, using my cover function. + // TODO: convert this into an api job? + $result = _iats_process_transaction($contribution, $options); + + // Handle any case of a failure of some kind, either the card failed, or the system failed. + if (empty($result['status'])) { + /* set the failed transaction status, or pending if I had a server issue */ + $contribution['contribution_status_id'] = empty($result['auth_result']) ? 2 : 4; + /* and include the reason in the source field */ + $contribution['source'] .= ' ' . $result['reasonMessage']; + // Save my reject code here for processing by the calling function (a bit lame) + $contribution['iats_reject_code'] = $result['auth_result']; + } + else { + // I have a transaction id. + $trxn_id = $contribution['trxn_id'] = trim($result['remote_id']) . ':' . time(); + // Initialize the status to pending + $contribution['contribution_status_id'] = 2; + // We'll use the repeattransaction api for successful transactions under three conditions: + // 1. if we want it + // 2. if we don't already have a contribution id + // 3. if we trust it + $use_repeattransaction = $is_recurrence && empty($contribution['id']) && _iats_civicrm_use_repeattransaction(); + } + if ($use_repeattransaction) { + // We processed it successflly and I can try to use repeattransaction. + // Requires the original contribution id. + // Issues with this api call: + // 1. Always triggers an email and doesn't include trxn. + // 2. Date is wrong. + try { + // $status = $result['contribution_status_id'] == 1 ? 'Completed' : 'Pending'; + $contributionResult = civicrm_api3('Contribution', 'repeattransaction', array( + 'original_contribution_id' => $original_contribution_id, + 'contribution_status_id' => 'Pending', + 'is_email_receipt' => 0, + // 'invoice_id' => $contribution['invoice_id'], + ///'receive_date' => $contribution['receive_date'], + // 'campaign_id' => $contribution['campaign_id'], + // 'financial_type_id' => $contribution['financial_type_id'],. + // 'payment_processor_id' => $contribution['payment_processor'], + 'contribution_recur_id' => $contribution['contribution_recur_id'], + )); + + // watchdog('iats_civicrm','repeat transaction result
@params
',array('@params' => print_r($pending,TRUE)));. + $contribution['id'] = CRM_Utils_Array::value('id', $contributionResult); + } + catch (Exception $e) { + // Ignore this, though perhaps I should log it. + } + if (empty($contribution['id'])) { + // Assume I failed completely and I'll fall back to doing it the manual way. + $use_repeattransaction = FALSE; + } + else { + // If repeattransaction succeded. + // First restore/add various fields that the repeattransaction api may overwrite or ignore. + // TODO - fix this in core to allow these to be set above. + civicrm_api3('contribution', 'create', array('id' => $contribution['id'], + 'invoice_id' => $contribution['invoice_id'], + 'source' => $contribution['source'], + 'receive_date' => $contribution['receive_date'], + 'payment_instrument_id' => $contribution['payment_instrument_id'], + // '' => $contribution['receive_date'], + )); + // Save my status in the contribution array that was passed in. + $contribution['contribution_status_id'] = $result['contribution_status_id']; + if ($result['contribution_status_id'] == 1) { + // My transaction completed, so record that fact in CiviCRM, potentially sending an invoice. + try { + civicrm_api3('Contribution', 'completetransaction', array( + 'id' => $contribution['id'], + 'payment_processor_id' => $contribution['payment_processor'], + 'is_email_receipt' => (empty($options['is_email_receipt']) ? 0 : 1), + 'trxn_id' => $contribution['trxn_id'], + 'receive_date' => $contribution['receive_date'], + )); + } + catch (Exception $e) { + // log the error and continue + CRM_Core_Error::debug_var('Unexpected Exception', $e); + } + } + else { + // just save my trxn_id for ACH/EFT verification later + try { + civicrm_api3('Contribution', 'create', array( + 'id' => $contribution['id'], + 'trxn_id' => $contribution['trxn_id'], + )); + } + catch (Exception $e) { + // log the error and continue + CRM_Core_Error::debug_var('Unexpected Exception', $e); + } + } + } + } + if (!$use_repeattransaction) { + /* If I'm not using repeattransaction for any reason, I'll create the contribution manually */ + // This code assumes that the contribution_status_id has been set properly above, either pending or failed. + $contributionResult = civicrm_api3('contribution', 'create', $contribution); + // Pass back the created id indirectly since I'm calling by reference. + $contribution['id'] = CRM_Utils_Array::value('id', $contributionResult); + // Connect to a membership if requested. + if (!empty($options['membership_id'])) { + try { + civicrm_api3('MembershipPayment', 'create', array('contribution_id' => $contribution['id'], 'membership_id' => $options['membership_id'])); + } + catch (Exception $e) { + // Ignore. + } + } + /* And then I'm done unless it completed */ + if ($result['contribution_status_id'] == 1 && !empty($result['status'])) { + /* success, and the transaction has completed */ + $complete = array('id' => $contribution['id'], + 'payment_processor_id' => $contribution['payment_processor'], + 'trxn_id' => $trxn_id, + 'receive_date' => $contribution['receive_date'] + ); + $complete['is_email_receipt'] = empty($options['is_email_receipt']) ? 0 : 1; + try { + $contributionResult = civicrm_api3('contribution', 'completetransaction', $complete); + } + catch (Exception $e) { + // Don't throw an exception here, or else I won't have updated my next contribution date for example. + $contribution['source'] .= ' [with unexpected api.completetransaction error: ' . $e->getMessage() . ']'; + } + // Restore my source field that ipn code irritatingly overwrites, and make sure that the trxn_id is set also. + civicrm_api3('contribution', 'setvalue', array('id' => $contribution['id'], 'value' => $contribution['source'], 'field' => 'source')); + civicrm_api3('contribution', 'setvalue', array('id' => $contribution['id'], 'value' => $trxn_id, 'field' => 'trxn_id')); + $message = $is_recurrence ? ts('Successfully processed contribution in recurring series id %1: ', array(1 => $contribution['contribution_recur_id'])) : ts('Successfully processed one-time contribution: '); + return $message . $result['auth_result']; + } + } + // Now return the appropriate message. + if (empty($result['status'])) { + return ts('Failed to process recurring contribution id %1: ', array(1 => $contribution['contribution_recur_id'])) . $result['reasonMessage']; + } + elseif ($result['contribution_status_id'] == 1) { + return ts('Successfully processed recurring contribution in series id %1: ', array(1 => $contribution['contribution_recur_id'])) . $result['auth_result']; + } + else { + // I'm using ACH/EFT or a processor that doesn't complete. + return ts('Successfully processed pending recurring contribution in series id %1: ', array(1 => $contribution['contribution_recur_id'])) . $result['auth_result']; + } +} + +/** + * Function _iats_process_transaction. + * + * @param $contribution an array of properties of a contribution to be processed + * @param $options must include customer code, subtype and iats_domain + * + * A low-level utility function for triggering a transaction on iATS. + */ +function _iats_process_transaction($contribution, $options) { + require_once "CRM/iATS/iATSService.php"; + switch ($options['subtype']) { + case 'ACHEFT': + $method = 'acheft_with_customer_code'; + // Will not complete. + $contribution_status_id = 2; + break; + + default: + $method = 'cc_with_customer_code'; + $contribution_status_id = 1; + break; + } + $credentials = iATS_Service_Request::credentials($contribution['payment_processor'], $contribution['is_test']); + $iats_service_params = array('method' => $method, 'type' => 'process', 'iats_domain' => $credentials['domain']); + $iats = new iATS_Service_Request($iats_service_params); + // Build the request array. + $request = array( + 'customerCode' => $options['customer_code'], + 'invoiceNum' => $contribution['invoice_id'], + 'total' => $contribution['total_amount'], + ); + $request['customerIPAddress'] = (function_exists('ip_address') ? ip_address() : $_SERVER['REMOTE_ADDR']); + // remove the customerIPAddress if it's the internal loopback to prevent + // being locked out due to velocity checks + if ('127.0.0.1' == $request['customerIPAddress']) { + $request['customerIPAddress'] = ''; + } + + // Make the soap request. + $response = $iats->request($credentials, $request); + // Process the soap response into a readable result. + $result = $iats->result($response); + // pass back the anticipated status_id based on the method (i.e. 1 for CC, 2 for ACH/EFT) + $result['contribution_status_id'] = $contribution_status_id; + return $result; +} + +/** + * Function _iats_get_future_start_dates + * + * @param $start_date a timestamp, only return dates after this. + * @param $allow_days an array of allowable days of the month. + * + * A low-level utility function for triggering a transaction on iATS. + */ +function _iats_get_future_monthly_start_dates($start_date, $allow_days) { + // Future date options. + $start_dates = array(); + // special handling for today - it means immediately or now. + $today = date('Ymd').'030000'; + $allow_any_day = (max($allow_days) <= 0) ? TRUE : FALSE; + // If not set, only allow for the first 28 days of the month. + if ($allow_any_day) { + $allow_days = range(1,28); + } + for ($j = 0; $j < count($allow_days); $j++) { + // So I don't get into an infinite loop somehow .. + $i = 0; + $dp = getdate($start_date); + while (($i++ < 60) && !in_array($dp['mday'], $allow_days)) { + $start_date += (24 * 60 * 60); + $dp = getdate($start_date); + } + $key = date('Ymd', $start_date).'030000'; + if ($key == $today) { // special handling + $display = ts('Now'); + $key = ''; // date('YmdHis'); + } + else { + $display = strftime('%B %e, %Y', $start_date); + } + $start_dates[$key] = $display; + $start_date += (24 * 60 * 60); + } + // A weird special case: if today is > 28 and I haven't explicitly set allowable days + if ($allow_any_day && !isset($start_dates[''])) { + $start_dates[''] = ts('Now'); + ksort($start_dates); + } + return $start_dates; +}