From e4c63126b45854b10f08ab14dee5eb1d4ed98bb0 Mon Sep 17 00:00:00 2001 From: Horacio Tellez Date: Tue, 2 Nov 2021 10:48:34 +0000 Subject: [PATCH] [ADD] payment_authorize: add refund for Authorize. Users can now ask for a refund from Odoo for their transactions done through Authorize.net. A refund will be triggered from Odoo when necessary. Only full refunds are possible. Note that unsettled transactions will be voided as they cannot be refunded. Task - 2678757 closes odoo/odoo#92279 Signed-off-by: Antoine Vandevenne (anv) --- addons/payment/models/payment_transaction.py | 2 +- addons/payment_authorize/const.py | 10 +++ .../models/authorize_request.py | 18 +---- .../models/payment_acquirer.py | 1 + .../models/payment_transaction.py | 78 ++++++++++++++---- addons/payment_authorize/tests/__init__.py | 1 + .../tests/test_refund_flows.py | 79 +++++++++++++++++++ 7 files changed, 158 insertions(+), 31 deletions(-) create mode 100644 addons/payment_authorize/const.py create mode 100644 addons/payment_authorize/tests/test_refund_flows.py diff --git a/addons/payment/models/payment_transaction.py b/addons/payment/models/payment_transaction.py index 4e316829d6c..aae1d290a27 100644 --- a/addons/payment/models/payment_transaction.py +++ b/addons/payment/models/payment_transaction.py @@ -697,7 +697,7 @@ class PaymentTransaction(models.Model): :param str state_message: The reason for which the transaction is set in 'cancel' state :return: None """ - allowed_states = ('draft', 'pending', 'authorized') + allowed_states = ('draft', 'pending', 'authorized', 'done') # 'done' for Authorize refunds. target_state = 'cancel' txs_to_process = self._update_state(allowed_states, target_state, state_message) # Cancel the existing payments diff --git a/addons/payment_authorize/const.py b/addons/payment_authorize/const.py new file mode 100644 index 00000000000..78d850f07ae --- /dev/null +++ b/addons/payment_authorize/const.py @@ -0,0 +1,10 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# Mapping of payment status on Authorize side to transaction statuses. +# See https://developer.authorize.net/api/reference/index.html#transaction-reporting-get-transaction-details. +TRANSACTION_STATUS_MAPPING = { + 'authorized': ['authorizedPendingCapture', 'capturedPendingSettlement'], + 'captured': ['settledSuccessfully'], + 'voided': ['voided'], + 'refunded': ['refundPendingSettlement', 'refundSettledSuccessfully'], +} diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index 619d09572de..36cfa8a75ba 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -259,7 +259,7 @@ class AuthorizeAPI: } } - def _get_transaction_details(self, transaction_id): + def get_transaction_details(self, transaction_id): """ Return detailed information about a specific transaction. Useful to issue refunds. :param str transaction_id: transaction id @@ -306,29 +306,17 @@ class AuthorizeAPI: }) return self._format_response(response, 'void') - def refund(self, transaction_id, amount): + def refund(self, transaction_id, amount, tx_details): """Refund a previously authorized payment. If the transaction is not settled yet, it will be voided. :param str transaction_id: the id of the authorized transaction in the Authorize.net backend :param float amount: transaction amount to refund + :param dict tx_details: The transaction details from `get_transaction_details()`. :return: a dict containing the response code, transaction id and transaction type :rtype: dict """ - tx_details = self._get_transaction_details(transaction_id) - - if tx_details and tx_details.get('err_code'): - return { - 'x_response_code': self.AUTH_ERROR_STATUS, - 'x_response_reason_text': tx_details.get('err_msg') - } - - # Void transaction not yet settled instead of issuing a refund - # (spoiler alert: a refund on a non settled transaction will throw an error) - if tx_details.get('transaction', {}).get('transactionStatus') in ['authorizedPendingCapture', 'capturedPendingSettlement']: - return self.void(transaction_id) - card = tx_details.get('transaction', {}).get('payment', {}).get('creditCard', {}).get('cardNumber') response = self._make_request('createTransactionRequest', { 'transactionRequest': { diff --git a/addons/payment_authorize/models/payment_acquirer.py b/addons/payment_authorize/models/payment_acquirer.py index a5d9886767a..312070ca3c7 100644 --- a/addons/payment_authorize/models/payment_acquirer.py +++ b/addons/payment_authorize/models/payment_acquirer.py @@ -58,6 +58,7 @@ class PaymentAcquirer(models.Model): super()._compute_feature_support_fields() self.filtered(lambda acq: acq.provider == 'authorize').update({ 'support_manual_capture': True, + 'support_refund': 'full_only', 'support_tokenization': True, }) diff --git a/addons/payment_authorize/models/payment_transaction.py b/addons/payment_authorize/models/payment_transaction.py index f75b0970916..cbce1681255 100644 --- a/addons/payment_authorize/models/payment_transaction.py +++ b/addons/payment_authorize/models/payment_transaction.py @@ -3,11 +3,13 @@ import logging import pprint -from odoo import _, api, models +from odoo import _, models from odoo.exceptions import UserError, ValidationError -from .authorize_request import AuthorizeAPI from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment_authorize.models.authorize_request import AuthorizeAPI +from odoo.addons.payment_authorize.const import TRANSACTION_STATUS_MAPPING + _logger = logging.getLogger(__name__) @@ -90,24 +92,66 @@ class PaymentTransaction(models.Model): :return: The refund transaction if any :rtype: recordset of `payment.transaction` """ + self.ensure_one() + if self.provider != 'authorize': return super()._send_refund_request( amount_to_refund=amount_to_refund, create_refund_transaction=create_refund_transaction, ) - refund_tx = super()._send_refund_request( - amount_to_refund=amount_to_refund, create_refund_transaction=False - ) + authorize_api = AuthorizeAPI(self.acquirer_id) + tx_details = authorize_api.get_transaction_details(self.acquirer_reference) + if 'err_code' in tx_details: # Could not retrieve the transaction details. + raise ValidationError("Authorize.Net: " + _( + "Could not retrieve the transaction details. (error code: %s; error_details: %s)", + tx_details['err_code'], tx_details.get('err_msg') + )) - authorize_API = AuthorizeAPI(self.acquirer_id) - rounded_amount = round(self.amount, self.currency_id.decimal_places) - res_content = authorize_API.refund(self.acquirer_reference, rounded_amount) - _logger.info( - "refund request response for transaction with reference %s:\n%s", - self.reference, pprint.pformat(res_content) - ) - self._handle_notification_data('authorize', {'response': res_content}) + refund_tx = self.env['payment.transaction'] + tx_status = tx_details.get('transaction', {}).get('transactionStatus') + if tx_status in TRANSACTION_STATUS_MAPPING['voided']: + # The payment has been voided from Authorize.net side before we could refund it. + self._set_canceled() + elif tx_status in TRANSACTION_STATUS_MAPPING['refunded']: + # The payment has been refunded from Authorize.net side before we could refund it. We + # create a refund tx on Odoo to reflect the move of the funds. + refund_tx = super()._send_refund_request( + amount_to_refund=amount_to_refund, + create_refund_transaction=True, + ) + refund_tx._set_done() + # Immediately post-process the transaction as the post-processing will not be + # triggered by a customer browsing the transaction from the portal. + self.env.ref('payment.cron_post_process_payment_tx')._trigger() + elif any(tx_status in TRANSACTION_STATUS_MAPPING[k] for k in ('authorized', 'captured')): + if tx_status in TRANSACTION_STATUS_MAPPING['authorized']: + # The payment has not been settle on Authorize.net yet. It must be voided rather + # than refunded. Since the funds have not moved yet, we don't create a refund tx. + res_content = authorize_api.void(self.acquirer_reference) + tx_to_process = self + else: + # The payment has been settled on Authorize.net side. We can refund it. + refund_tx = super()._send_refund_request( + amount_to_refund=amount_to_refund, + create_refund_transaction=True, + ) + rounded_amount = round(amount_to_refund, self.currency_id.decimal_places) + res_content = authorize_api.refund( + self.acquirer_reference, rounded_amount, tx_details + ) + tx_to_process = refund_tx + _logger.info( + "refund request response for transaction with reference %s:\n%s", + self.reference, pprint.pformat(res_content) + ) + data = {'reference': tx_to_process.reference, 'response': res_content} + tx_to_process._handle_notification_data('authorize', data) + else: + raise ValidationError("Authorize.net: " + _( + "The transaction is not in a status to be refunded. (status: %s, details: %s)", + tx_status, tx_details.get('messages', {}).get('message') + )) return refund_tx def _send_capture_request(self): @@ -196,13 +240,17 @@ class PaymentTransaction(models.Model): if self.tokenize and not self.token_id: self._authorize_tokenize() if self.operation == 'validation': - # Void the transaction. In last step because it calls _handle_notification_data. - self._send_refund_request(create_refund_transaction=False) + self._send_void_request() # In last step because it processes the response. elif status_type == 'void': if self.operation == 'validation': # Validation txs are authorized and then voided self._set_done() # If the refund went through, the validation tx is confirmed else: self._set_canceled() + elif status_type == 'refund' and self.operation == 'refund': + self._set_done() + # Immediately post-process the transaction as the post-processing will not be + # triggered by a customer browsing the transaction from the portal. + self.env.ref('payment.cron_post_process_payment_tx')._trigger() elif status_code == '2': # Declined self._set_canceled() elif status_code == '4': # Held for Review diff --git a/addons/payment_authorize/tests/__init__.py b/addons/payment_authorize/tests/__init__.py index 42fdb572a81..a31b815e712 100644 --- a/addons/payment_authorize/tests/__init__.py +++ b/addons/payment_authorize/tests/__init__.py @@ -2,3 +2,4 @@ from . import common from . import test_authorize +from . import test_refund_flows diff --git a/addons/payment_authorize/tests/test_refund_flows.py b/addons/payment_authorize/tests/test_refund_flows.py new file mode 100644 index 00000000000..1c6ce099054 --- /dev/null +++ b/addons/payment_authorize/tests/test_refund_flows.py @@ -0,0 +1,79 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch + +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.payment_authorize.tests.common import AuthorizeCommon + + +@tagged('post_install', '-at_install') +class TestRefundFlows(AuthorizeCommon): + + def test_refunding_voided_tx_cancels_it(self): + """ Test that refunding a transaction that has been voided from Authorize.net side cancels + it on Odoo. """ + source_tx = self._create_transaction('direct', state='done') + with patch( + 'odoo.addons.payment_authorize.models.authorize_request.AuthorizeAPI' + '.get_transaction_details', + return_value={'transaction': {'transactionStatus': 'voided'}}, + ): + source_tx._send_refund_request(amount_to_refund=source_tx.amount) + self.assertEqual(source_tx.state, 'cancel') + + def test_refunding_refunded_tx_creates_refund_tx(self): + """ Test that refunding a transaction that has been refunded from Authorize.net side creates + a refund transaction on Odoo. """ + source_tx = self._create_transaction('direct', state='done') + with patch( + 'odoo.addons.payment_authorize.models.authorize_request.AuthorizeAPI' + '.get_transaction_details', + return_value={'transaction': {'transactionStatus': 'refundSettledSuccessfully'}}, + ): + source_tx._send_refund_request(amount_to_refund=source_tx.amount) + refund_tx = self.env['payment.transaction'].search( + [('source_transaction_id', '=', source_tx.id)] + ) + self.assertTrue(refund_tx) + + @mute_logger('odoo.addons.payment_authorize.models.payment_transaction') + def test_refunding_authorized_tx_voids_it(self): + """ Test that refunding a transaction that is still authorized on Authorize.net side voids + it instead. """ + source_tx = self._create_transaction('direct', state='done') + with patch( + 'odoo.addons.payment_authorize.models.authorize_request.AuthorizeAPI' + '.get_transaction_details', + return_value={'transaction': {'transactionStatus': 'authorizedPendingCapture'}}, + ), patch( + 'odoo.addons.payment_authorize.models.authorize_request.AuthorizeAPI.void' + ) as void_mock, patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._handle_notification_data' + ): + source_tx._send_refund_request(amount_to_refund=source_tx.amount) + self.assertEqual(void_mock.call_count, 1) + + @mute_logger('odoo.addons.payment_authorize.models.payment_transaction') + def test_refunding_captured_tx_refunds_it_and_creates_refund_tx(self): + """ Test that refunding a transaction that is captured on Authorize.net side captures it and + create a refund transaction on Odoo. """ + source_tx = self._create_transaction('direct', state='done') + with patch( + 'odoo.addons.payment_authorize.models.authorize_request.AuthorizeAPI' + '.get_transaction_details', + return_value={'transaction': {'transactionStatus': 'settledSuccessfully'}}, + ), patch( + 'odoo.addons.payment_authorize.models.authorize_request.AuthorizeAPI.refund' + ) as refund_mock, patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._handle_notification_data' + ): + source_tx._send_refund_request(amount_to_refund=source_tx.amount) + self.assertEqual(refund_mock.call_count, 1) + refund_tx = self.env['payment.transaction'].search( + [('source_transaction_id', '=', source_tx.id)] + ) + self.assertTrue(refund_tx)