[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:
committed by
Antoine Vandevenne (anv)
parent
3a4cb6f59a
commit
e4c63126b4
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user