[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) <anv@odoo.com>
This commit is contained in:
Horacio Tellez
2022-06-01 13:13:55 +02:00
committed by Antoine Vandevenne (anv)
parent 3a4cb6f59a
commit e4c63126b4
7 changed files with 158 additions and 31 deletions
+1 -1
View File
@@ -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
+10
View File
@@ -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'],
}
@@ -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': {
@@ -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,
})
@@ -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
@@ -2,3 +2,4 @@
from . import common
from . import test_authorize
from . import test_refund_flows
@@ -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)