4 * Licensed to CiviCRM under the Academic Free License version 3.0.
6 * Written and contributed by Ideal Solution, LLC (http://www.idealso.com)
13 * @author Marshal Newrock <marshal@idealso.com>
16 use Civi\Payment\Exception\PaymentProcessorException
;
20 * When looking up response codes in the Authorize.Net API, they
21 * begin at one, so always delete one from the "Position in Response"
23 class CRM_Core_Payment_AuthorizeNet
extends CRM_Core_Payment
{
24 const CHARSET
= 'iso-8859-1';
25 const AUTH_APPROVED
= 1;
26 const AUTH_DECLINED
= 2;
28 const AUTH_REVIEW
= 4;
29 const TIMEZONE
= 'America/Denver';
31 protected $_mode = NULL;
33 protected $_params = [];
36 * @var GuzzleHttp\Client
38 protected $guzzleClient;
41 * @return \GuzzleHttp\Client
43 public function getGuzzleClient(): \GuzzleHttp\Client
{
44 return $this->guzzleClient ??
new \GuzzleHttp\
Client();
48 * @param \GuzzleHttp\Client $guzzleClient
50 public function setGuzzleClient(\GuzzleHttp\Client
$guzzleClient) {
51 $this->guzzleClient
= $guzzleClient;
58 * The mode of operation: live or test.
60 * @param $paymentProcessor
62 * @return \CRM_Core_Payment_AuthorizeNet
64 public function __construct($mode, &$paymentProcessor) {
66 $this->_paymentProcessor
= $paymentProcessor;
68 $this->_setParam('apiLogin', $paymentProcessor['user_name']);
69 $this->_setParam('paymentKey', $paymentProcessor['password']);
70 $this->_setParam('paymentType', 'AIM');
74 * Should the first payment date be configurable when setting up back office recurring payments.
75 * In the case of Authorize.net this is an option
78 protected function supportsFutureRecurStartDate() {
83 * Can recurring contributions be set against pledges.
85 * In practice all processors that use the baseIPN function to finish transactions or
86 * call the completetransaction api support this by looking up previous contributions in the
87 * series and, if there is a prior contribution against a pledge, and the pledge is not complete,
88 * adding the new payment to the pledge.
90 * However, only enabling for processors it has been tested against.
94 protected function supportsRecurContributionsForPledges() {
99 * Submit a payment using Advanced Integration Method.
101 * @param array|\Civi\Payment\PropertyBag $params
103 * @param string $component
106 * Result array (containing at least the key payment_status_id)
108 * @throws \Civi\Payment\Exception\PaymentProcessorException
110 public function doPayment(&$params, $component = 'contribute') {
111 $propertyBag = \Civi\Payment\PropertyBag
::cast($params);
112 $this->_component
= $component;
113 $statuses = CRM_Contribute_BAO_Contribution
::buildOptions('contribution_status_id', 'validate');
115 // If we have a $0 amount, skip call to processor and set payment_status to Completed.
116 // Conceivably a processor might override this - perhaps for setting up a token - but we don't
117 // have an example of that at the moment.
118 if ($propertyBag->getAmount() == 0) {
119 $result['payment_status_id'] = array_search('Completed', $statuses);
120 $result['payment_status'] = 'Completed';
124 if (!defined('CURLOPT_SSLCERT')) {
125 // Note that guzzle doesn't necessarily require CURL, although it prefers it. But we should leave this error
126 // here unless someone suggests it is not required since it's likely helpful.
127 throw new PaymentProcessorException('Authorize.Net requires curl with SSL support', 9001);
131 * recurpayment function does not compile an array & then process it -
132 * - the tpl does the transformation so adding call to hook here
133 * & giving it a change to act on the params array
135 $newParams = $params;
136 if (!empty($params['is_recur']) && !empty($params['contributionRecurID'])) {
137 CRM_Utils_Hook
::alterPaymentProcessorParams($this,
142 foreach ($newParams as $field => $value) {
143 $this->_setParam($field, $value);
146 if (!empty($params['is_recur']) && !empty($params['contributionRecurID'])) {
147 $this->doRecurPayment();
148 $params['payment_status_id'] = array_search('Pending', $statuses);
149 $params['payment_status'] = 'Pending';
154 $authorizeNetFields = $this->_getAuthorizeNetFields();
156 // Set up our call for hook_civicrm_paymentProcessor,
157 // since we now have our parameters as assigned for the AIM back end.
158 CRM_Utils_Hook
::alterPaymentProcessorParams($this,
163 foreach ($authorizeNetFields as $field => $value) {
164 // CRM-7419, since double quote is used as enclosure while doing csv parsing
165 $value = ($field == 'x_description') ?
str_replace('"', "'", $value) : $value;
166 $postFields[] = $field . '=' . urlencode($value);
169 // Authorize.Net will not refuse duplicates, so we should check if the user already submitted this transaction
170 if ($this->checkDupe($authorizeNetFields['x_invoice_num'], $params['contributionID'] ??
NULL)) {
171 throw new PaymentProcessorException('It appears that this transaction is a duplicate. Have you already submitted the form once? If so there may have been a connection problem. Check your email for a receipt from Authorize.net. If you do not receive a receipt within 2 hours you can try your transaction again. If you continue to have problems please contact the site administrator.', 9004);
174 $response = (string) $this->getGuzzleClient()->post($this->_paymentProcessor
['url_site'], [
175 'body' => implode('&', $postFields),
177 CURLOPT_RETURNTRANSFER
=> TRUE,
178 CURLOPT_SSL_VERIFYPEER
=> Civi
::settings()->get('verifySSL'),
182 $response_fields = $this->explode_csv($response);
184 // fetch available contribution statuses
185 $contributionStatus = CRM_Contribute_PseudoConstant
::contributionStatus(NULL, 'name');
187 // check for application errors
189 // AVS, CVV2, CAVV, and other verification results
190 switch ($response_fields[0]) {
191 case self
::AUTH_REVIEW
:
192 $params['payment_status_id'] = array_search('Pending', $contributionStatus);
193 $params['payment_status'] = 'Pending';
196 case self
::AUTH_ERROR
:
197 $errormsg = $response_fields[2] . ' ' . $response_fields[3];
198 throw new PaymentProcessorException($errormsg, $response_fields[1]);
200 case self
::AUTH_DECLINED
:
201 $errormsg = $response_fields[2] . ' ' . $response_fields[3];
202 throw new PaymentProcessorException($errormsg, $response_fields[1]);
206 $params['trxn_id'] = !empty($response_fields[6]) ?
$response_fields[6] : $this->getTestTrxnID();
207 $params['payment_status_id'] = array_search('Completed', $statuses);
208 $params['payment_status'] = 'Completed';
216 * Submit an Automated Recurring Billing subscription.
218 public function doRecurPayment() {
219 $template = CRM_Core_Smarty
::singleton();
221 $intervalLength = $this->_getParam('frequency_interval');
222 $intervalUnit = $this->_getParam('frequency_unit');
223 if ($intervalUnit === 'week') {
224 $intervalLength *= 7;
225 $intervalUnit = 'days';
227 elseif ($intervalUnit === 'year') {
228 $intervalLength *= 12;
229 $intervalUnit = 'months';
231 elseif ($intervalUnit === 'day') {
232 $intervalUnit = 'days';
233 // interval cannot be less than 7 days or more than 1 year
234 if ($intervalLength < 7) {
235 throw new PaymentProcessorException('Payment interval must be at least one week', 9001);
237 if ($intervalLength > 365) {
238 throw new PaymentProcessorException('Payment interval may not be longer than one year', 9001);
241 elseif ($intervalUnit === 'month') {
242 $intervalUnit = 'months';
243 if ($intervalLength < 1) {
244 throw new PaymentProcessorException('Payment interval must be at least one week', 9001);
246 if ($intervalLength > 12) {
247 throw new PaymentProcessorException('Payment interval may not be longer than one year', 9001);
251 $template->assign('intervalLength', $intervalLength);
252 $template->assign('intervalUnit', $intervalUnit);
254 $template->assign('apiLogin', $this->_getParam('apiLogin'));
255 $template->assign('paymentKey', $this->_getParam('paymentKey'));
256 $template->assign('refId', substr($this->_getParam('invoiceID'), 0, 20));
258 //for recurring, carry first contribution id
259 $template->assign('invoiceNumber', $this->_getParam('contributionID'));
260 $firstPaymentDate = $this->_getParam('receive_date');
261 if (!empty($firstPaymentDate)) {
262 //allow for post dated payment if set in form
263 $startDate = date_create($firstPaymentDate);
266 $startDate = date_create();
268 /* Format start date in Mountain Time to avoid Authorize.net error E00017
269 * we do this only if the day we are setting our start time to is LESS than the current
270 * day in mountaintime (ie. the server time of the A-net server). A.net won't accept a date
271 * earlier than the current date on it's server so if we are in PST we might need to use mountain
272 * time to bring our date forward. But if we are submitting something future dated we want
273 * the date we entered to be respected
275 $minDate = date_create('now', new DateTimeZone(self
::TIMEZONE
));
276 if (strtotime($startDate->format('Y-m-d')) < strtotime($minDate->format('Y-m-d'))) {
277 $startDate->setTimezone(new DateTimeZone(self
::TIMEZONE
));
280 $template->assign('startDate', $startDate->format('Y-m-d'));
282 $installments = $this->_getParam('installments');
284 // for open ended subscription totalOccurrences has to be 9999
285 $installments = empty($installments) ?
9999 : $installments;
286 $template->assign('totalOccurrences', $installments);
288 $template->assign('amount', $this->_getParam('amount'));
290 $template->assign('cardNumber', $this->_getParam('credit_card_number'));
291 $exp_month = str_pad($this->_getParam('month'), 2, '0', STR_PAD_LEFT
);
292 $exp_year = $this->_getParam('year');
293 $template->assign('expirationDate', $exp_year . '-' . $exp_month);
295 // name rather than description is used in the tpl - see http://www.authorize.net/support/ARB_guide.pdf
296 $template->assign('name', $this->_getParam('description', TRUE));
298 $template->assign('email', $this->_getParam('email'));
299 $template->assign('contactID', $this->_getParam('contactID'));
300 $template->assign('billingFirstName', $this->_getParam('billing_first_name'));
301 $template->assign('billingLastName', $this->_getParam('billing_last_name'));
302 $template->assign('billingAddress', $this->_getParam('street_address', TRUE));
303 $template->assign('billingCity', $this->_getParam('city', TRUE));
304 $template->assign('billingState', $this->_getParam('state_province'));
305 $template->assign('billingZip', $this->_getParam('postal_code', TRUE));
306 $template->assign('billingCountry', $this->_getParam('country'));
307 // Required to be set for s
308 $template->ensureVariablesAreAssigned(['subscriptionType']);
309 $arbXML = $template->fetch('CRM/Contribute/Form/Contribution/AuthorizeNetARB.tpl');
311 // Submit to authorize.net
312 $response = $this->getGuzzleClient()->post($this->_paymentProcessor
['url_recur'], [
314 'Content-Type' => 'text/xml; charset=UTF8',
318 CURLOPT_RETURNTRANSFER
=> TRUE,
319 CURLOPT_SSL_VERIFYPEER
=> Civi
::settings()->get('verifySSL'),
322 $responseFields = $this->_ParseArbReturn((string) $response->getBody());
324 if ($responseFields['resultCode'] === 'Error') {
325 throw new PaymentProcessorException($responseFields['text'], $responseFields['code']);
328 // update recur processor_id with subscriptionId
329 CRM_Core_DAO
::setFieldValue('CRM_Contribute_DAO_ContributionRecur', $this->_getParam('contributionRecurID'),
330 'processor_id', $responseFields['subscriptionId']
332 //only impact of assigning this here is is can be used to cancel the subscription in an automated test
333 // if it isn't cancelled a duplicate transaction error occurs
334 if (!empty($responseFields['subscriptionId'])) {
335 $this->_setParam('subscriptionId', $responseFields['subscriptionId']);
342 public function _getAuthorizeNetFields() {
343 //Total amount is from the form contribution field
344 $amount = $this->_getParam('total_amount');
345 //CRM-9894 would this ever be the case??
346 if (empty($amount)) {
347 $amount = $this->_getParam('amount');
350 $fields['x_login'] = $this->_getParam('apiLogin');
351 $fields['x_tran_key'] = $this->_getParam('paymentKey');
352 $fields['x_email_customer'] = $this->_getParam('emailCustomer');
353 $fields['x_first_name'] = $this->_getParam('billing_first_name');
354 $fields['x_last_name'] = $this->_getParam('billing_last_name');
355 $fields['x_address'] = $this->_getParam('street_address');
356 $fields['x_city'] = $this->_getParam('city');
357 $fields['x_state'] = $this->_getParam('state_province');
358 $fields['x_zip'] = $this->_getParam('postal_code');
359 $fields['x_country'] = $this->_getParam('country');
360 $fields['x_customer_ip'] = $this->_getParam('ip_address');
361 $fields['x_email'] = $this->_getParam('email');
362 $fields['x_invoice_num'] = $this->_getParam('invoiceID');
363 $fields['x_amount'] = $amount;
364 $fields['x_currency_code'] = $this->_getParam('currencyID');
365 $fields['x_description'] = $this->_getParam('description');
366 $fields['x_cust_id'] = $this->_getParam('contactID');
367 if ($this->_getParam('paymentType') == 'AIM') {
368 $fields['x_relay_response'] = 'FALSE';
369 // request response in CSV format
370 $fields['x_delim_data'] = 'TRUE';
371 $fields['x_delim_char'] = ',';
372 $fields['x_encap_char'] = '"';
374 $fields['x_card_num'] = $this->_getParam('credit_card_number');
375 $fields['x_card_code'] = $this->_getParam('cvv2');
376 $exp_month = str_pad($this->_getParam('month'), 2, '0', STR_PAD_LEFT
);
377 $exp_year = $this->_getParam('year');
378 $fields['x_exp_date'] = "$exp_month/$exp_year";
381 if ($this->_mode
!= 'live') {
382 $fields['x_test_request'] = 'TRUE';
389 * Split a CSV file. Requires , as delimiter and " as enclosure.
390 * Based off notes from http://php.net/fgetcsv
392 * @param string $data
398 public function explode_csv($data) {
400 //make it easier to parse fields with quotes in them
401 $data = str_replace('""', "''", $data);
404 while ($data != '') {
406 if ($data[0] == '"') {
407 // handle quoted fields
408 preg_match('/^"(([^"]|\\")*?)",?(.*)$/', $data, $matches);
410 $fields[] = str_replace("''", '"', $matches[1]);
414 preg_match('/^([^,]*),?(.*)$/', $data, $matches);
416 $fields[] = $matches[1];
424 * Extract variables from returned XML.
426 * Function is from Authorize.Net sample code, and used
427 * to prevent the requirement of XML functions.
429 * @param string $content
430 * XML reply from Authorize.Net.
433 * refId, resultCode, code, text, subscriptionId
435 public function _parseArbReturn($content) {
436 $refId = $this->_substring_between($content, '<refId>', '</refId>');
437 $resultCode = $this->_substring_between($content, '<resultCode>', '</resultCode>');
438 $code = $this->_substring_between($content, '<code>', '</code>');
439 $text = $this->_substring_between($content, '<text>', '</text>');
440 $subscriptionId = $this->_substring_between($content, '<subscriptionId>', '</subscriptionId>');
443 'resultCode' => $resultCode,
446 'subscriptionId' => $subscriptionId,
451 * Helper function for _parseArbReturn.
453 * Function is from Authorize.Net sample code, and used to avoid using
456 * @param string $haystack
457 * @param string $start
460 * @return bool|string
462 public function _substring_between(&$haystack, $start, $end) {
463 if (strpos($haystack, $start) === FALSE ||
strpos($haystack, $end) === FALSE) {
467 $start_position = strpos($haystack, $start) +
strlen($start);
468 $end_position = strpos($haystack, $end);
469 return substr($haystack, $start_position, $end_position - $start_position);
474 * Get the value of a field if set.
476 * @param string $field
479 * @param bool $xmlSafe
481 * value of the field, or empty string if the field is
484 public function _getParam($field, $xmlSafe = FALSE) {
485 $value = CRM_Utils_Array
::value($field, $this->_params
, '');
487 $value = str_replace(['&', '"', "'", '<', '>'], '', $value);
493 * Set a field to the specified value. Value must be a scalar (int,
494 * float, string, or boolean)
496 * @param string $field
497 * @param mixed $value
500 * false if value is not a scalar, true if successful
502 public function _setParam($field, $value) {
503 if (!is_scalar($value)) {
507 $this->_params
[$field] = $value;
512 * This function checks to see if we have the right config values.
515 * the error message if any
517 public function checkConfig() {
519 if (empty($this->_paymentProcessor
['user_name'])) {
520 $error[] = ts('APILogin is not set for this payment processor');
523 if (empty($this->_paymentProcessor
['password'])) {
524 $error[] = ts('Key is not set for this payment processor');
527 if (!empty($error)) {
528 return implode('<p>', $error);
538 public function accountLoginURL() {
539 return ($this->_mode
== 'test') ?
'https://test.authorize.net' : 'https://authorize.net';
543 * @param string $message
544 * @param \Civi\Payment\PropertyBag $params
546 * @return bool|object
547 * @throws \Civi\Payment\Exception\PaymentProcessorException
549 public function cancelSubscription(&$message = '', $params = []) {
550 $template = CRM_Core_Smarty
::singleton();
552 $template->assign('subscriptionType', 'cancel');
554 $template->assign('apiLogin', $this->_getParam('apiLogin'));
555 $template->assign('paymentKey', $this->_getParam('paymentKey'));
556 $template->assign('subscriptionId', $params->getRecurProcessorID());
558 $arbXML = $template->fetch('CRM/Contribute/Form/Contribution/AuthorizeNetARB.tpl');
560 $response = (string) $this->getGuzzleClient()->post($this->_paymentProcessor
['url_recur'], [
562 'Content-Type' => 'text/xml; charset=UTF8',
566 CURLOPT_RETURNTRANSFER
=> TRUE,
567 CURLOPT_SSL_VERIFYPEER
=> Civi
::settings()->get('verifySSL'),
571 $responseFields = $this->_ParseArbReturn($response);
572 $message = "{$responseFields['code']}: {$responseFields['text']}";
574 if ($responseFields['resultCode'] === 'Error') {
575 throw new PaymentProcessorException($responseFields['text'], $responseFields['code']);
581 * Update payment details at Authorize.net.
583 * @param string $message
584 * @param array $params
586 * @return bool|object
588 * @throws \Civi\Payment\Exception\PaymentProcessorException
590 public function updateSubscriptionBillingInfo(&$message = '', $params = []) {
591 $template = CRM_Core_Smarty
::singleton();
592 $template->assign('subscriptionType', 'updateBilling');
594 $template->assign('apiLogin', $this->_getParam('apiLogin'));
595 $template->assign('paymentKey', $this->_getParam('paymentKey'));
596 $template->assign('subscriptionId', $params['subscriptionId']);
598 $template->assign('cardNumber', $params['credit_card_number']);
599 $exp_month = str_pad($params['month'], 2, '0', STR_PAD_LEFT
);
600 $exp_year = $params['year'];
601 $template->assign('expirationDate', $exp_year . '-' . $exp_month);
603 $template->assign('billingFirstName', $params['first_name']);
604 $template->assign('billingLastName', $params['last_name']);
605 $template->assign('billingAddress', $params['street_address']);
606 $template->assign('billingCity', $params['city']);
607 $template->assign('billingState', $params['state_province']);
608 $template->assign('billingZip', $params['postal_code']);
609 $template->assign('billingCountry', $params['country']);
611 $arbXML = $template->fetch('CRM/Contribute/Form/Contribution/AuthorizeNetARB.tpl');
613 $response = (string) $this->getGuzzleClient()->post($this->_paymentProcessor
['url_recur'], [
615 'Content-Type' => 'text/xml; charset=UTF8',
619 CURLOPT_RETURNTRANSFER
=> TRUE,
620 CURLOPT_SSL_VERIFYPEER
=> Civi
::settings()->get('verifySSL'),
624 // submit to authorize.net
625 $responseFields = $this->_ParseArbReturn($response);
626 $message = "{$responseFields['code']}: {$responseFields['text']}";
628 if ($responseFields['resultCode'] === 'Error') {
629 throw new PaymentProcessorException($responseFields['text'], $responseFields['code']);
635 * Process incoming notification.
637 public function handlePaymentNotification() {
638 $ipnClass = new CRM_Core_Payment_AuthorizeNetIPN(array_merge($_GET, $_REQUEST));
643 * Change the amount of the recurring payment.
645 * @param string $message
646 * @param array $params
648 * @return bool|object
650 * @throws \Civi\Payment\Exception\PaymentProcessorException
652 public function changeSubscriptionAmount(&$message = '', $params = []) {
653 $template = CRM_Core_Smarty
::singleton();
655 $template->assign('subscriptionType', 'update');
657 $template->assign('apiLogin', $this->_getParam('apiLogin'));
658 $template->assign('paymentKey', $this->_getParam('paymentKey'));
660 $template->assign('subscriptionId', $params['subscriptionId']);
662 // for open ended subscription totalOccurrences has to be 9999
663 $installments = empty($params['installments']) ?
9999 : $params['installments'];
664 $template->assign('totalOccurrences', $installments);
666 $template->assign('amount', $params['amount']);
668 $arbXML = $template->fetch('CRM/Contribute/Form/Contribution/AuthorizeNetARB.tpl');
670 $response = (string) $this->getGuzzleClient()->post($this->_paymentProcessor
['url_recur'], [
672 'Content-Type' => 'text/xml; charset=UTF8',
676 CURLOPT_RETURNTRANSFER
=> TRUE,
677 CURLOPT_SSL_VERIFYPEER
=> Civi
::settings()->get('verifySSL'),
681 $responseFields = $this->_parseArbReturn($response);
682 $message = "{$responseFields['code']}: {$responseFields['text']}";
684 if ($responseFields['resultCode'] === 'Error') {
685 throw new PaymentProcessorException($responseFields['text'], $responseFields['code']);
691 * Get an appropriate test trannsaction id.
695 protected function getTestTrxnID() {
696 // test mode always returns trxn_id = 0
697 // also live mode in CiviCRM with test mode set in
698 // Authorize.Net return $response_fields[6] = 0
699 // hence treat that also as test mode transaction
701 $query = "SELECT MAX(trxn_id) FROM civicrm_contribution WHERE trxn_id RLIKE 'test[0-9]+'";
702 $trxn_id = (string) (CRM_Core_DAO
::singleValueQuery($query));
703 $trxn_id = str_replace('test', '', $trxn_id);
704 $trxn_id = (int) ($trxn_id) +
1;
705 return sprintf('test%08d', $trxn_id);