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');
188 // check for application errors
190 // AVS, CVV2, CAVV, and other verification results
191 switch ($response_fields[0]) {
192 case self
::AUTH_REVIEW
:
193 $result = $this->setStatusPaymentPending($result);
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 $result['trxn_id'] = !empty($response_fields[6]) ?
$response_fields[6] : $this->getTestTrxnID();
207 $result = $this->setStatusPaymentCompleted($result);
215 * Submit an Automated Recurring Billing subscription.
217 public function doRecurPayment() {
218 $template = CRM_Core_Smarty
::singleton();
220 $intervalLength = $this->_getParam('frequency_interval');
221 $intervalUnit = $this->_getParam('frequency_unit');
222 if ($intervalUnit === 'week') {
223 $intervalLength *= 7;
224 $intervalUnit = 'days';
226 elseif ($intervalUnit === 'year') {
227 $intervalLength *= 12;
228 $intervalUnit = 'months';
230 elseif ($intervalUnit === 'day') {
231 $intervalUnit = 'days';
232 // interval cannot be less than 7 days or more than 1 year
233 if ($intervalLength < 7) {
234 throw new PaymentProcessorException('Payment interval must be at least one week', 9001);
236 if ($intervalLength > 365) {
237 throw new PaymentProcessorException('Payment interval may not be longer than one year', 9001);
240 elseif ($intervalUnit === 'month') {
241 $intervalUnit = 'months';
242 if ($intervalLength < 1) {
243 throw new PaymentProcessorException('Payment interval must be at least one week', 9001);
245 if ($intervalLength > 12) {
246 throw new PaymentProcessorException('Payment interval may not be longer than one year', 9001);
250 $template->assign('intervalLength', $intervalLength);
251 $template->assign('intervalUnit', $intervalUnit);
253 $template->assign('apiLogin', $this->_getParam('apiLogin'));
254 $template->assign('paymentKey', $this->_getParam('paymentKey'));
255 $template->assign('refId', substr($this->_getParam('invoiceID'), 0, 20));
257 //for recurring, carry first contribution id
258 $template->assign('invoiceNumber', $this->_getParam('contributionID'));
259 $firstPaymentDate = $this->_getParam('receive_date');
260 if (!empty($firstPaymentDate)) {
261 //allow for post dated payment if set in form
262 $startDate = date_create($firstPaymentDate);
265 $startDate = date_create();
267 /* Format start date in Mountain Time to avoid Authorize.net error E00017
268 * we do this only if the day we are setting our start time to is LESS than the current
269 * day in mountaintime (ie. the server time of the A-net server). A.net won't accept a date
270 * earlier than the current date on it's server so if we are in PST we might need to use mountain
271 * time to bring our date forward. But if we are submitting something future dated we want
272 * the date we entered to be respected
274 $minDate = date_create('now', new DateTimeZone(self
::TIMEZONE
));
275 if (strtotime($startDate->format('Y-m-d')) < strtotime($minDate->format('Y-m-d'))) {
276 $startDate->setTimezone(new DateTimeZone(self
::TIMEZONE
));
279 $template->assign('startDate', $startDate->format('Y-m-d'));
281 $installments = $this->_getParam('installments');
283 // for open ended subscription totalOccurrences has to be 9999
284 $installments = empty($installments) ?
9999 : $installments;
285 $template->assign('totalOccurrences', $installments);
287 $template->assign('amount', $this->_getParam('amount'));
289 $template->assign('cardNumber', $this->_getParam('credit_card_number'));
290 $exp_month = str_pad($this->_getParam('month'), 2, '0', STR_PAD_LEFT
);
291 $exp_year = $this->_getParam('year');
292 $template->assign('expirationDate', $exp_year . '-' . $exp_month);
294 // name rather than description is used in the tpl - see http://www.authorize.net/support/ARB_guide.pdf
295 $template->assign('name', $this->_getParam('description', TRUE));
297 $template->assign('email', $this->_getParam('email'));
298 $template->assign('contactID', $this->_getParam('contactID'));
299 $template->assign('billingFirstName', $this->_getParam('billing_first_name'));
300 $template->assign('billingLastName', $this->_getParam('billing_last_name'));
301 $template->assign('billingAddress', $this->_getParam('street_address', TRUE));
302 $template->assign('billingCity', $this->_getParam('city', TRUE));
303 $template->assign('billingState', $this->_getParam('state_province'));
304 $template->assign('billingZip', $this->_getParam('postal_code', TRUE));
305 $template->assign('billingCountry', $this->_getParam('country'));
306 // Required to be set for s
307 $template->ensureVariablesAreAssigned(['subscriptionType']);
308 $arbXML = $template->fetch('CRM/Contribute/Form/Contribution/AuthorizeNetARB.tpl');
310 // Submit to authorize.net
311 $response = $this->getGuzzleClient()->post($this->_paymentProcessor
['url_recur'], [
313 'Content-Type' => 'text/xml; charset=UTF8',
317 CURLOPT_RETURNTRANSFER
=> TRUE,
318 CURLOPT_SSL_VERIFYPEER
=> Civi
::settings()->get('verifySSL'),
321 $responseFields = $this->_ParseArbReturn((string) $response->getBody());
323 if ($responseFields['resultCode'] === 'Error') {
324 throw new PaymentProcessorException($responseFields['text'], $responseFields['code']);
327 // update recur processor_id with subscriptionId
328 CRM_Core_DAO
::setFieldValue('CRM_Contribute_DAO_ContributionRecur', $this->_getParam('contributionRecurID'),
329 'processor_id', $responseFields['subscriptionId']
331 //only impact of assigning this here is is can be used to cancel the subscription in an automated test
332 // if it isn't cancelled a duplicate transaction error occurs
333 if (!empty($responseFields['subscriptionId'])) {
334 $this->_setParam('subscriptionId', $responseFields['subscriptionId']);
341 public function _getAuthorizeNetFields() {
342 //Total amount is from the form contribution field
343 $amount = $this->_getParam('total_amount');
344 //CRM-9894 would this ever be the case??
345 if (empty($amount)) {
346 $amount = $this->_getParam('amount');
349 $fields['x_login'] = $this->_getParam('apiLogin');
350 $fields['x_tran_key'] = $this->_getParam('paymentKey');
351 $fields['x_email_customer'] = $this->_getParam('emailCustomer');
352 $fields['x_first_name'] = $this->_getParam('billing_first_name');
353 $fields['x_last_name'] = $this->_getParam('billing_last_name');
354 $fields['x_address'] = $this->_getParam('street_address');
355 $fields['x_city'] = $this->_getParam('city');
356 $fields['x_state'] = $this->_getParam('state_province');
357 $fields['x_zip'] = $this->_getParam('postal_code');
358 $fields['x_country'] = $this->_getParam('country');
359 $fields['x_customer_ip'] = $this->_getParam('ip_address');
360 $fields['x_email'] = $this->_getParam('email');
361 $fields['x_invoice_num'] = $this->_getParam('invoiceID');
362 $fields['x_amount'] = $amount;
363 $fields['x_currency_code'] = $this->_getParam('currencyID');
364 $fields['x_description'] = $this->_getParam('description');
365 $fields['x_cust_id'] = $this->_getParam('contactID');
366 if ($this->_getParam('paymentType') == 'AIM') {
367 $fields['x_relay_response'] = 'FALSE';
368 // request response in CSV format
369 $fields['x_delim_data'] = 'TRUE';
370 $fields['x_delim_char'] = ',';
371 $fields['x_encap_char'] = '"';
373 $fields['x_card_num'] = $this->_getParam('credit_card_number');
374 $fields['x_card_code'] = $this->_getParam('cvv2');
375 $exp_month = str_pad($this->_getParam('month'), 2, '0', STR_PAD_LEFT
);
376 $exp_year = $this->_getParam('year');
377 $fields['x_exp_date'] = "$exp_month/$exp_year";
380 if ($this->_mode
!= 'live') {
381 $fields['x_test_request'] = 'TRUE';
388 * Split a CSV file. Requires , as delimiter and " as enclosure.
389 * Based off notes from http://php.net/fgetcsv
391 * @param string $data
397 public function explode_csv($data) {
399 //make it easier to parse fields with quotes in them
400 $data = str_replace('""', "''", $data);
403 while ($data != '') {
405 if ($data[0] == '"') {
406 // handle quoted fields
407 preg_match('/^"(([^"]|\\")*?)",?(.*)$/', $data, $matches);
409 $fields[] = str_replace("''", '"', $matches[1]);
413 preg_match('/^([^,]*),?(.*)$/', $data, $matches);
415 $fields[] = $matches[1];
423 * Extract variables from returned XML.
425 * Function is from Authorize.Net sample code, and used
426 * to prevent the requirement of XML functions.
428 * @param string $content
429 * XML reply from Authorize.Net.
432 * refId, resultCode, code, text, subscriptionId
434 public function _parseArbReturn($content) {
435 $refId = $this->_substring_between($content, '<refId>', '</refId>');
436 $resultCode = $this->_substring_between($content, '<resultCode>', '</resultCode>');
437 $code = $this->_substring_between($content, '<code>', '</code>');
438 $text = $this->_substring_between($content, '<text>', '</text>');
439 $subscriptionId = $this->_substring_between($content, '<subscriptionId>', '</subscriptionId>');
442 'resultCode' => $resultCode,
445 'subscriptionId' => $subscriptionId,
450 * Helper function for _parseArbReturn.
452 * Function is from Authorize.Net sample code, and used to avoid using
455 * @param string $haystack
456 * @param string $start
459 * @return bool|string
461 public function _substring_between(&$haystack, $start, $end) {
462 if (strpos($haystack, $start) === FALSE ||
strpos($haystack, $end) === FALSE) {
466 $start_position = strpos($haystack, $start) +
strlen($start);
467 $end_position = strpos($haystack, $end);
468 return substr($haystack, $start_position, $end_position - $start_position);
473 * Get the value of a field if set.
475 * @param string $field
478 * @param bool $xmlSafe
480 * value of the field, or empty string if the field is
483 public function _getParam($field, $xmlSafe = FALSE) {
484 $value = CRM_Utils_Array
::value($field, $this->_params
, '');
486 $value = str_replace(['&', '"', "'", '<', '>'], '', $value);
492 * Set a field to the specified value. Value must be a scalar (int,
493 * float, string, or boolean)
495 * @param string $field
496 * @param mixed $value
499 * false if value is not a scalar, true if successful
501 public function _setParam($field, $value) {
502 if (!is_scalar($value)) {
506 $this->_params
[$field] = $value;
511 * This function checks to see if we have the right config values.
514 * the error message if any
516 public function checkConfig() {
518 if (empty($this->_paymentProcessor
['user_name'])) {
519 $error[] = ts('APILogin is not set for this payment processor');
522 if (empty($this->_paymentProcessor
['password'])) {
523 $error[] = ts('Key is not set for this payment processor');
526 if (!empty($error)) {
527 return implode('<p>', $error);
537 public function accountLoginURL() {
538 return ($this->_mode
== 'test') ?
'https://test.authorize.net' : 'https://authorize.net';
542 * @param string $message
543 * @param \Civi\Payment\PropertyBag $params
545 * @return bool|object
546 * @throws \Civi\Payment\Exception\PaymentProcessorException
548 public function cancelSubscription(&$message = '', $params = []) {
549 $template = CRM_Core_Smarty
::singleton();
551 $template->assign('subscriptionType', 'cancel');
553 $template->assign('apiLogin', $this->_getParam('apiLogin'));
554 $template->assign('paymentKey', $this->_getParam('paymentKey'));
555 $template->assign('subscriptionId', $params->getRecurProcessorID());
557 $arbXML = $template->fetch('CRM/Contribute/Form/Contribution/AuthorizeNetARB.tpl');
559 $response = (string) $this->getGuzzleClient()->post($this->_paymentProcessor
['url_recur'], [
561 'Content-Type' => 'text/xml; charset=UTF8',
565 CURLOPT_RETURNTRANSFER
=> TRUE,
566 CURLOPT_SSL_VERIFYPEER
=> Civi
::settings()->get('verifySSL'),
570 $responseFields = $this->_ParseArbReturn($response);
571 $message = "{$responseFields['code']}: {$responseFields['text']}";
573 if ($responseFields['resultCode'] === 'Error') {
574 throw new PaymentProcessorException($responseFields['text'], $responseFields['code']);
580 * Update payment details at Authorize.net.
582 * @param string $message
583 * @param array $params
585 * @return bool|object
587 * @throws \Civi\Payment\Exception\PaymentProcessorException
589 public function updateSubscriptionBillingInfo(&$message = '', $params = []) {
590 $template = CRM_Core_Smarty
::singleton();
591 $template->assign('subscriptionType', 'updateBilling');
593 $template->assign('apiLogin', $this->_getParam('apiLogin'));
594 $template->assign('paymentKey', $this->_getParam('paymentKey'));
595 $template->assign('subscriptionId', $params['subscriptionId']);
597 $template->assign('cardNumber', $params['credit_card_number']);
598 $exp_month = str_pad($params['month'], 2, '0', STR_PAD_LEFT
);
599 $exp_year = $params['year'];
600 $template->assign('expirationDate', $exp_year . '-' . $exp_month);
602 $template->assign('billingFirstName', $params['first_name']);
603 $template->assign('billingLastName', $params['last_name']);
604 $template->assign('billingAddress', $params['street_address']);
605 $template->assign('billingCity', $params['city']);
606 $template->assign('billingState', $params['state_province']);
607 $template->assign('billingZip', $params['postal_code']);
608 $template->assign('billingCountry', $params['country']);
610 $arbXML = $template->fetch('CRM/Contribute/Form/Contribution/AuthorizeNetARB.tpl');
612 $response = (string) $this->getGuzzleClient()->post($this->_paymentProcessor
['url_recur'], [
614 'Content-Type' => 'text/xml; charset=UTF8',
618 CURLOPT_RETURNTRANSFER
=> TRUE,
619 CURLOPT_SSL_VERIFYPEER
=> Civi
::settings()->get('verifySSL'),
623 // submit to authorize.net
624 $responseFields = $this->_ParseArbReturn($response);
625 $message = "{$responseFields['code']}: {$responseFields['text']}";
627 if ($responseFields['resultCode'] === 'Error') {
628 throw new PaymentProcessorException($responseFields['text'], $responseFields['code']);
634 * Process incoming notification.
636 public function handlePaymentNotification() {
637 $ipnClass = new CRM_Core_Payment_AuthorizeNetIPN(array_merge($_GET, $_REQUEST));
642 * Change the amount of the recurring payment.
644 * @param string $message
645 * @param array $params
647 * @return bool|object
649 * @throws \Civi\Payment\Exception\PaymentProcessorException
651 public function changeSubscriptionAmount(&$message = '', $params = []) {
652 $template = CRM_Core_Smarty
::singleton();
654 $template->assign('subscriptionType', 'update');
656 $template->assign('apiLogin', $this->_getParam('apiLogin'));
657 $template->assign('paymentKey', $this->_getParam('paymentKey'));
659 $template->assign('subscriptionId', $params['subscriptionId']);
661 // for open ended subscription totalOccurrences has to be 9999
662 $installments = empty($params['installments']) ?
9999 : $params['installments'];
663 $template->assign('totalOccurrences', $installments);
665 $template->assign('amount', $params['amount']);
667 $arbXML = $template->fetch('CRM/Contribute/Form/Contribution/AuthorizeNetARB.tpl');
669 $response = (string) $this->getGuzzleClient()->post($this->_paymentProcessor
['url_recur'], [
671 'Content-Type' => 'text/xml; charset=UTF8',
675 CURLOPT_RETURNTRANSFER
=> TRUE,
676 CURLOPT_SSL_VERIFYPEER
=> Civi
::settings()->get('verifySSL'),
680 $responseFields = $this->_parseArbReturn($response);
681 $message = "{$responseFields['code']}: {$responseFields['text']}";
683 if ($responseFields['resultCode'] === 'Error') {
684 throw new PaymentProcessorException($responseFields['text'], $responseFields['code']);
690 * Get an appropriate test trannsaction id.
694 protected function getTestTrxnID() {
695 // test mode always returns trxn_id = 0
696 // also live mode in CiviCRM with test mode set in
697 // Authorize.Net return $response_fields[6] = 0
698 // hence treat that also as test mode transaction
700 $query = "SELECT MAX(trxn_id) FROM civicrm_contribution WHERE trxn_id RLIKE 'test[0-9]+'";
701 $trxn_id = (string) (CRM_Core_DAO
::singleValueQuery($query));
702 $trxn_id = str_replace('test', '', $trxn_id);
703 $trxn_id = (int) ($trxn_id) +
1;
704 return sprintf('test%08d', $trxn_id);