From e0233a1010618450cf53b997c50964bed2bb1b1a Mon Sep 17 00:00:00 2001 From: Demesmaeker Date: Tue, 27 Jul 2021 10:08:33 +0000 Subject: [PATCH] [IMP] payment(_adyen): allow to refund confirmed transactions Before this commit, it was not possible to refund a payment from Odoo. Users had to go through the payment acquirer's backend and update the payment accordingly in Odoo. With this commit, refunds are made available in Odoo directly from the payment form, for acquirers that support them. Acquirer can either only support full refunds or also support partial refunds. As of now, the only acquirer allowing refunds is Adyen, with partial refund support. task-2527891 closes odoo/odoo#70881 Related: odoo/upgrade#2689 Related: odoo/enterprise#19829 Signed-off-by: Antoine Vandevenne (anv) --- addons/payment/__manifest__.py | 1 + addons/payment/models/account_payment.py | 130 ++++++++--- addons/payment/models/payment_acquirer.py | 6 +- addons/payment/models/payment_transaction.py | 203 ++++++++++++----- addons/payment/security/ir.model.access.csv | 1 + addons/payment/tests/__init__.py | 1 + addons/payment/tests/test_payments.py | 117 ++++++++++ addons/payment/tests/test_transactions.py | 77 ++++++- .../payment/views/account_payment_views.xml | 19 ++ .../views/payment_transaction_views.xml | 9 + addons/payment/wizards/__init__.py | 3 +- .../payment/wizards/payment_refund_wizard.py | 66 ++++++ .../wizards/payment_refund_wizard_views.xml | 42 ++++ addons/payment_adyen/const.py | 11 +- addons/payment_adyen/controllers/main.py | 18 +- .../data/payment_acquirer_data.xml | 1 + .../payment_adyen/models/payment_acquirer.py | 14 +- .../models/payment_transaction.py | 82 ++++++- addons/payment_adyen/tests/test_adyen.py | 27 +++ .../data/payment_acquirer_data.xml | 1 + .../data/payment_acquirer_data.xml | 1 + .../models/payment_transaction.py | 206 +++++++++--------- .../data/payment_acquirer_data.xml | 1 + .../data/payment_acquirer_data.xml | 1 + .../data/payment_acquirer_data.xml | 5 +- .../data/payment_acquirer_data.xml | 1 + .../data/payment_acquirer_data.xml | 1 + .../data/payment_acquirer_data.xml | 1 + .../data/payment_acquirer_data.xml | 1 + .../data/payment_acquirer_data.xml | 1 + .../data/payment_acquirer_data.xml | 1 + .../data/payment_acquirer_data.xml | 1 + 32 files changed, 842 insertions(+), 208 deletions(-) create mode 100644 addons/payment/tests/test_payments.py create mode 100644 addons/payment/wizards/payment_refund_wizard.py create mode 100644 addons/payment/wizards/payment_refund_wizard_views.xml diff --git a/addons/payment/__manifest__.py b/addons/payment/__manifest__.py index 6d710ce21ba..0fdb29b9a22 100644 --- a/addons/payment/__manifest__.py +++ b/addons/payment/__manifest__.py @@ -30,6 +30,7 @@ 'wizards/account_payment_register_views.xml', 'wizards/payment_acquirer_onboarding_templates.xml', 'wizards/payment_link_wizard_views.xml', + 'wizards/payment_refund_wizard_views.xml', ], 'auto_install': True, 'assets': { diff --git a/addons/payment/models/account_payment.py b/addons/payment/models/account_payment.py index 3b22dfc20ad..94b08289b01 100644 --- a/addons/payment/models/account_payment.py +++ b/addons/payment/models/account_payment.py @@ -9,12 +9,17 @@ class AccountPayment(models.Model): # == Business fields == payment_transaction_id = fields.Many2one( - string="Payment Transaction", comodel_name='payment.transaction', readonly=True) + string="Payment Transaction", + comodel_name='payment.transaction', + readonly=True, + auto_join=True, # No access rule bypass since access to payments means access to txs too + ) payment_token_id = fields.Many2one( string="Saved Payment Token", comodel_name='payment.token', domain="""[ ('id', 'in', suitable_payment_token_ids), ]""", help="Note that only tokens from acquirers allowing to capture the amount are available.") + amount_available_for_refund = fields.Monetary(compute='_compute_amount_available_for_refund') # == Display purpose fields == suitable_payment_token_ids = fields.Many2many( @@ -26,6 +31,30 @@ class AccountPayment(models.Model): help='Technical field used to hide or show the payment_token_id if needed.' ) + # == Fields used for traceability == + source_payment_id = fields.Many2one( + string="Source Payment", + comodel_name='account.payment', + help="The source payment of related refund payments", + related='payment_transaction_id.source_transaction_id.payment_id', + readonly=True, + store=True, # Stored for the group by in `_compute_refunds_count` + ) + refunds_count = fields.Integer(string="Refunds Count", compute='_compute_refunds_count') + + def _compute_amount_available_for_refund(self): + for payment in self: + if payment.payment_transaction_id.sudo().acquirer_id.support_refund: + # Only consider refund transactions that are confirmed by summing the amounts of + # payments linked to such refund transactions. Indeed, should a refund transaction + # be stuck forever in a transient state (due to webhook failure, for example), the + # user would never be allowed to refund the source transaction again. + refund_payments = self.search([('source_payment_id', '=', self.id)]) + refunded_amount = abs(sum(refund_payments.mapped('amount'))) + payment.amount_available_for_refund = payment.amount - refunded_amount + else: + payment.amount_available_for_refund = 0 + @api.depends('payment_method_line_id') def _compute_suitable_payment_token_ids(self): for payment in self: @@ -53,6 +82,19 @@ class AccountPayment(models.Model): codes = [key for key in dict(self.env['payment.acquirer']._fields['provider']._description_selection(self.env))] payment.use_electronic_payment_method = payment.payment_method_code in codes + def _compute_refunds_count(self): + rg_data = self.env['account.payment'].read_group( + domain=[ + ('source_payment_id', 'in', self.ids), + ('payment_transaction_id.operation', '=', 'refund') + ], + fields=['source_payment_id'], + groupby=['source_payment_id'] + ) + data = {x['source_payment_id'][0]: x['source_payment_id_count'] for x in rg_data} + for payment in self: + payment.refunds_count = data.get(payment.id, 0) + @api.onchange('partner_id', 'payment_method_line_id', 'journal_id') def _onchange_set_payment_token_id(self): codes = [key for key in dict(self.env['payment.acquirer']._fields['provider']._description_selection(self.env))] @@ -73,6 +115,63 @@ class AccountPayment(models.Model): ('acquirer_id', '=', self.payment_method_line_id.payment_acquirer_id.id), ], limit=1) + def action_post(self): + # Post the payments "normally" if no transactions are needed. + # If not, let the acquirer update the state. + + payments_need_tx = self.filtered( + lambda p: p.payment_token_id and not p.payment_transaction_id + ) + # creating the transaction require to access data on payment acquirers, not always accessible to users + # able to create payments + transactions = payments_need_tx.sudo()._create_payment_transaction() + + res = super(AccountPayment, self - payments_need_tx).action_post() + + for tx in transactions: # Process the transactions with a payment by token + tx._send_payment_request() + + # Post payments for issued transactions + transactions._finalize_post_processing() + payments_tx_done = payments_need_tx.filtered( + lambda p: p.payment_transaction_id.state == 'done' + ) + super(AccountPayment, payments_tx_done).action_post() + payments_tx_not_done = payments_need_tx.filtered( + lambda p: p.payment_transaction_id.state != 'done' + ) + payments_tx_not_done.action_cancel() + + return res + + def action_refund_wizard(self): + self.ensure_one() + return { + 'name': _("Refund"), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'payment.refund.wizard', + 'target': 'new', + } + + def action_view_refunds(self): + self.ensure_one() + action = { + 'name': _("Refund"), + 'res_model': 'account.payment', + 'type': 'ir.actions.act_window', + } + if self.refunds_count == 1: + refund_tx = self.env['account.payment'].search([ + ('source_payment_id', '=', self.id) + ], limit=1) + action['res_id'] = refund_tx.id + action['view_mode'] = 'form' + else: + action['view_mode'] = 'tree,form' + action['domain'] = [('source_payment_id', '=', self.id)] + return action + def _get_payment_chatter_link(self): self.ensure_one() return f'{self.name}' @@ -108,32 +207,3 @@ class AccountPayment(models.Model): 'payment_id': self.id, **extra_create_values, } - - def action_post(self): - # Post the payments "normally" if no transactions are needed. - # If not, let the acquirer update the state. - - payments_need_tx = self.filtered( - lambda p: p.payment_token_id and not p.payment_transaction_id - ) - # creating the transaction require to access data on payment acquirers, not always accessible to users - # able to create payments - transactions = payments_need_tx.sudo()._create_payment_transaction() - - res = super(AccountPayment, self - payments_need_tx).action_post() - - for tx in transactions: # Process the transactions with a payment by token - tx._send_payment_request() - - # Post payments for issued transactions - transactions._finalize_post_processing() - payments_tx_done = payments_need_tx.filtered( - lambda p: p.payment_transaction_id.state == 'done' - ) - super(AccountPayment, payments_tx_done).action_post() - payments_tx_not_done = payments_need_tx.filtered( - lambda p: p.payment_transaction_id.state != 'done' - ) - payments_tx_not_done.action_cancel() - - return res diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 9e22c5222c5..2e3080397df 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -99,7 +99,11 @@ class PaymentAcquirer(models.Model): # Feature support fields support_authorization = fields.Boolean(string="Authorize Mechanism Supported") support_fees_computation = fields.Boolean(string="Fees Computation Supported") - support_tokenization = fields.Boolean(string="Tokenization supported") + support_tokenization = fields.Boolean(string="Tokenization Supported") + support_refund = fields.Selection( + string="Type of Refund Supported", + selection=[('full_only', "Full Only"), ('partial', "Partial")], + ) # Kanban view fields description = fields.Html( diff --git a/addons/payment/models/payment_transaction.py b/addons/payment/models/payment_transaction.py index d3f7420e9da..2e2540c8292 100644 --- a/addons/payment/models/payment_transaction.py +++ b/addons/payment/models/payment_transaction.py @@ -11,8 +11,8 @@ from dateutil import relativedelta from odoo import _, api, fields, models from odoo.exceptions import ValidationError -from odoo.tools import consteq, ustr -from odoo.tools.misc import formatLang, hmac as hmac_tool +from odoo.tools import consteq, format_amount, ustr +from odoo.tools.misc import hmac as hmac_tool from odoo.addons.payment import utils as payment_utils @@ -54,7 +54,7 @@ class PaymentTransaction(models.Model): string="Status", selection=[('draft', "Draft"), ('pending', "Pending"), ('authorized', "Authorized"), ('done', "Confirmed"), ('cancel', "Canceled"), ('error', "Error")], - default='draft', readonly=True, required=True, copy=False) + default='draft', readonly=True, required=True, copy=False, index=True) state_message = fields.Text( string="Message", help="The complementary information message about the state", readonly=True) @@ -64,13 +64,25 @@ class PaymentTransaction(models.Model): # Fields used for traceability operation = fields.Selection( # This should not be trusted if the state is 'draft' or 'pending' string="Operation", - selection=[('online_redirect', "Online payment with redirection"), - ('online_direct', "Online direct payment"), - ('online_token', "Online payment by token"), - ('validation', "Validation of the payment method"), - ('offline', "Offline payment by token")], - readonly=True) + selection=[ + ('online_redirect', "Online payment with redirection"), + ('online_direct', "Online direct payment"), + ('online_token', "Online payment by token"), + ('validation', "Validation of the payment method"), + ('offline', "Offline payment by token"), + ('refund', "Refund") + ], + readonly=True, + index=True, + ) payment_id = fields.Many2one(string="Payment", comodel_name='account.payment', readonly=True) + source_transaction_id = fields.Many2one( + string="Source Transaction", + comodel_name='payment.transaction', + help="The source transaction of related refund transactions", + readonly=True + ) + refunds_count = fields.Integer(string="Refunds Count", compute='_compute_refunds_count') invoice_ids = fields.Many2many( string="Invoices", comodel_name='account.move', relation='account_invoice_transaction_rel', column1='transaction_id', column2='invoice_id', readonly=True, copy=False, @@ -131,6 +143,16 @@ class PaymentTransaction(models.Model): for tx in self: tx.invoices_count = tx_data.get(tx.id, 0) + def _compute_refunds_count(self): + rg_data = self.env['payment.transaction'].read_group( + domain=[('source_transaction_id', 'in', self.ids), ('operation', '=', 'refund')], + fields=['source_transaction_id'], + groupby=['source_transaction_id'], + ) + data = {x['source_transaction_id'][0]: x['source_transaction_id_count'] for x in rg_data} + for record in self: + record.refunds_count = data.get(record.id, 0) + #=== CONSTRAINT METHODS ===# @api.constrains('state') @@ -245,6 +267,32 @@ class PaymentTransaction(models.Model): action['domain'] = [('id', 'in', invoice_ids)] return action + def action_view_refunds(self): + """ Return the action for the views of the refund transactions linked to the transaction. + + Note: self.ensure_one() + + :return: The action + :rtype: dict + """ + self.ensure_one() + + action = { + 'name': _("Refund"), + 'res_model': 'payment.transaction', + 'type': 'ir.actions.act_window', + } + if self.refunds_count == 1: + refund_tx = self.env['payment.transaction'].search([ + ('source_transaction_id', '=', self.id), + ])[0] + action['res_id'] = refund_tx.id + action['view_mode'] = 'form' + else: + action['view_mode'] = 'tree,form' + action['domain'] = [('source_transaction_id', '=', self.id)] + return action + def action_capture(self): """ Check the state of the transactions and request their capture. """ if any(tx.state != 'authorized' for tx in self): @@ -261,6 +309,18 @@ class PaymentTransaction(models.Model): for tx in self: tx._send_void_request() + def action_refund(self, refund_amount=None): + """ Check the state of the transactions and request their refund. + + :param float refund_amount: The amount to be refunded + :return: None + """ + if any(tx.state != 'done' for tx in self): + raise ValidationError(_("Only confirmed transactions can be refunded.")) + + for tx in self: + tx._send_refund_request(refund_amount) + #=== BUSINESS METHODS - PAYMENT FLOW ===# @api.model @@ -375,7 +435,7 @@ class PaymentTransaction(models.Model): of the callback model :param str callback_method: The name of the callback method :return: The callback hash - :retype: str + :rtype: str """ if callback_model_id and callback_res_id and callback_method: model_name = self.env['ir.model'].sudo().browse(callback_model_id).model @@ -476,6 +536,72 @@ class PaymentTransaction(models.Model): self.ensure_one() self._log_sent_message() + def _send_refund_request(self, refund_amount=None, create_refund_transaction=True): + """ Request the provider of the acquirer handling the transaction to refund it. + + For an acquirer to support refunds, it must override this method and request a refund + to its provider. + + Note: self.ensure_one() + + :param float refund_amount: The amount to be refunded + :param bool create_refund_transaction: Whether a refund transaction should be created + :return: The refund transaction if any, or `None` + :rtype: recordset of `payment.transaction` + """ + self.ensure_one() + + refund_tx = None + if create_refund_transaction: + refund_tx = self._create_refund_transaction(refund_amount=refund_amount) + refund_tx._log_sent_message() + return refund_tx + + def _send_capture_request(self): + """ Request the provider of the acquirer handling the transaction to capture it. + + For an acquirer to support authorization, it must override this method and request a capture + to its provider. + + Note: self.ensure_one() + + :return: None + """ + self.ensure_one() + + def _send_void_request(self): + """ Request the provider of the acquirer handling the transaction to void it. + + For an acquirer to support authorization, it must override this method and request the + transaction to be voided to its provider. + + Note: self.ensure_one() + + :return: None + """ + self.ensure_one() + + def _create_refund_transaction(self, refund_amount=None, **custom_create_values): + """ Create a new transaction with operation 'refund' and link it to the current transaction. + + :param float refund_amount: The strictly positive amount to refund, in the same currency as + the source transaction + :return: The refund transaction + :rtype: recordset of `payment.transaction` + """ + self.ensure_one + + return self.create({ + 'acquirer_id': self.acquirer_id.id, + 'reference': self._compute_reference(self.provider, prefix=f'R-{self.reference}'), + 'amount': -(refund_amount or self.amount), + 'currency_id': self.currency_id.id, + 'operation': 'refund', + 'source_transaction_id': self.id, + 'partner_id': self.partner_id.id, + **custom_create_values, + }) + @api.model def _handle_feedback_data(self, provider, data): """ Match the transaction with the feedback data, update its state and return it. @@ -671,42 +797,6 @@ class PaymentTransaction(models.Model): success = getattr(record, method)(tx) # Execute the callback tx.callback_is_done = success or success is None # Missing returns are successful - def _send_refund_request(self): - """ Request the provider of the acquirer handling the transaction to refund it. - - For an acquirer to support refunds, it must override this method and request a refund - to its provider. - - Note: self.ensure_one() - - :return: None - """ - self.ensure_one() - - def _send_capture_request(self): - """ Request the provider of the acquirer handling the transaction to capture it. - - For an acquirer to support authorization, it must override this method and request a capture - to its provider. - - Note: self.ensure_one() - - :return: None - """ - self.ensure_one() - - def _send_void_request(self): - """ Request the provider of the acquirer handling the transaction to void it. - - For an acquirer to support authorization, it must override this method and request the - transaction to be voided to its provider. - - Note: self.ensure_one() - - :return: None - """ - self.ensure_one() - #=== BUSINESS METHODS - POST-PROCESSING ===# def _get_post_processing_values(self): @@ -774,7 +864,8 @@ class PaymentTransaction(models.Model): txs_to_post_process = self.search([ ('state', '=', 'done'), ('is_post_processed', '=', False), - ('last_state_change', '<=', client_handling_limit_date), + '|', ('last_state_change', '<=', client_handling_limit_date), + ('operation', '=', 'refund'), ('last_state_change', '>=', retry_limit_date), ]) for tx in txs_to_post_process: @@ -822,7 +913,7 @@ class PaymentTransaction(models.Model): payment_method_line = self.acquirer_id.journal_id.inbound_payment_method_line_ids\ .filtered(lambda l: l.code == self.provider) payment_values = { - 'amount': self.amount, + 'amount': abs(self.amount), # A tx may have a negative amount, but a payment must >= 0 'payment_type': 'inbound' if self.amount > 0 else 'outbound', 'currency_id': self.currency_id.id, 'partner_id': self.partner_id.commercial_partner_id.id, @@ -875,7 +966,7 @@ class PaymentTransaction(models.Model): tx._log_message_on_linked_documents(message) def _log_message_on_linked_documents(self, message): - """ Log a message on the invoices linked to the transaction. + """ Log a message on the payment and the invoices linked to the transaction. For a module to implement payments and link documents to a transaction, it must override this method and call super, then log the message on documents linked to the transaction. @@ -886,7 +977,10 @@ class PaymentTransaction(models.Model): :return: None """ self.ensure_one() - + if self.source_transaction_id.payment_id: + self.source_transaction_id.payment_id.message_post(body=message) + for invoice in self.source_transaction_id.invoice_ids: + invoice.message_post(body=message) for invoice in self.invoice_ids: invoice.message_post(body=message) @@ -908,6 +1002,13 @@ class PaymentTransaction(models.Model): "A transaction with reference %(ref)s has been initiated (%(acq_name)s).", ref=self.reference, acq_name=self.acquirer_id.name ) + elif self.operation == 'refund': + formatted_amount = format_amount(self.env, -self.amount, self.currency_id) + message = _( + "A refund request of %(amount)s has been sent. The payment will be created soon. " + "Refund transaction reference: %(ref)s (%(acq_name)s).", + amount=formatted_amount, ref=self.reference, acq_name=self.acquirer_id.name + ) else: # 'online_token' message = _( "A transaction with reference %(ref)s has been initiated using the payment method " @@ -923,7 +1024,7 @@ class PaymentTransaction(models.Model): """ self.ensure_one() - formatted_amount = formatLang(self.env, self.amount, currency_obj=self.currency_id) + formatted_amount = format_amount(self.env, self.amount, self.currency_id) if self.state == 'pending': message = _( "The transaction with reference %(ref)s for %(amount)s is pending (%(acq_name)s).", diff --git a/addons/payment/security/ir.model.access.csv b/addons/payment/security/ir.model.access.csv index 6bd201a51a9..34ebfe50a30 100644 --- a/addons/payment/security/ir.model.access.csv +++ b/addons/payment/security/ir.model.access.csv @@ -4,6 +4,7 @@ payment_acquirer_system,payment.acquirer.system,model_payment_acquirer,base.grou payment_icon_all,payment.icon.all,model_payment_icon,,1,0,0,0 payment_icon_system,payment.icon.system,model_payment_icon,base.group_system,1,1,1,1 payment_link_wizard,payment.link.wizard,model_payment_link_wizard,account.group_account_user,1,1,1,0 +payment_refund_wizard,payment.refund.wizard,model_payment_refund_wizard,account.group_account_invoice,1,1,1,0 payment_token_all,payment.token.all,model_payment_token,,1,0,0,0 payment_token_portal,payment.token.portal,model_payment_token,base.group_portal,1,1,1,1 payment_token_system,payment.token.system,model_payment_token,base.group_system,1,1,1,1 diff --git a/addons/payment/tests/__init__.py b/addons/payment/tests/__init__.py index 9ae472bf3b4..e01fb97459f 100644 --- a/addons/payment/tests/__init__.py +++ b/addons/payment/tests/__init__.py @@ -5,4 +5,5 @@ from . import http_common from . import multicompany_common from . import test_flows from . import test_multicompany_flows +from . import test_payments from . import test_transactions diff --git a/addons/payment/tests/test_payments.py b/addons/payment/tests/test_payments.py new file mode 100644 index 00000000000..737db7c4e85 --- /dev/null +++ b/addons/payment/tests/test_payments.py @@ -0,0 +1,117 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged + +from odoo.addons.payment.tests.common import PaymentCommon + + +@tagged('-at_install', 'post_install') +class TestPayments(PaymentCommon): + + def test_no_amount_available_for_refund_when_not_supported(self): + self.acquirer.support_refund = False + tx = self.create_transaction('redirect', state='done') + tx._reconcile_after_done() # Create the payment + self.assertEqual( + tx.payment_id.amount_available_for_refund, + 0, + msg="The value of `amount_available_for_refund` should be 0 when the acquirer doesn't " + "support refunds." + ) + + def test_full_amount_available_for_refund_when_not_yet_refunded(self): + self.acquirer.support_refund = 'full_only' # Should simply not be False + tx = self.create_transaction('redirect', state='done') + tx._reconcile_after_done() # Create the payment + self.assertAlmostEqual( + tx.payment_id.amount_available_for_refund, + tx.amount, + places=2, + msg="The value of `amount_available_for_refund` should be that of `total` when there " + "are no linked refunds." + ) + + def test_full_amount_available_for_refund_when_refunds_are_pending(self): + self.acquirer.write({ + 'support_refund': 'full_only', # Should simply not be False + 'support_authorization': True, # To create transaction in the 'authorized' state + }) + tx = self.create_transaction('redirect', state='done') + tx._reconcile_after_done() # Create the payment + for reference_index, state in enumerate(('draft', 'pending', 'authorized')): + self.create_transaction( + 'dummy', + amount=-tx.amount, + reference=f'R-{tx.reference}-{reference_index + 1}', + state=state, + operation='refund', # Override the computed flow + source_transaction_id=tx.id, + ) + self.assertAlmostEqual( + tx.payment_id.amount_available_for_refund, + tx.payment_id.amount, + places=2, + msg="The value of `amount_available_for_refund` should be that of `total` when all the " + "linked refunds are pending (not in the state 'done')." + ) + + def test_no_amount_available_for_refund_when_fully_refunded(self): + self.acquirer.support_refund = 'full_only' # Should simply not be False + tx = self.create_transaction('redirect', state='done') + tx._reconcile_after_done() # Create the payment + self.create_transaction( + 'dummy', + amount=-tx.amount, + reference=f'R-{tx.reference}', + state='done', + operation='refund', # Override the computed flow + source_transaction_id=tx.id, + )._reconcile_after_done() + self.assertEqual( + tx.payment_id.amount_available_for_refund, + 0, + msg="The value of `amount_available_for_refund` should be 0 when there is a linked " + "refund of the full amount that is confirmed (state 'done')." + ) + + def test_no_full_amount_available_for_refund_when_partially_refunded(self): + self.acquirer.support_refund = 'partial' + tx = self.create_transaction('redirect', state='done') + tx._reconcile_after_done() # Create the payment + self.create_transaction( + 'dummy', + amount=-(tx.amount / 10), + reference=f'R-{tx.reference}', + state='done', + operation='refund', # Override the computed flow + source_transaction_id=tx.id, + )._reconcile_after_done() + self.assertAlmostEqual( + tx.payment_id.amount_available_for_refund, + tx.payment_id.amount - (tx.amount / 10), + places=2, + msg="The value of `amount_available_for_refund` should be equal to the total amount " + "minus the sum of the absolute amount of the refunds that are confirmed (state " + "'done')." + ) + + def test_refunds_count(self): + self.acquirer.support_refund = 'full_only' # Should simply not be False + tx = self.create_transaction('redirect', state='done') + tx._reconcile_after_done() # Create the payment + for reference_index, operation in enumerate( + ('online_redirect', 'online_direct', 'online_token', 'validation', 'refund') + ): + self.create_transaction( + 'dummy', + reference=f'R-{tx.reference}-{reference_index + 1}', + state='done', + operation=operation, # Override the computed flow + source_transaction_id=tx.id, + )._reconcile_after_done() + + self.assertEqual( + tx.payment_id.refunds_count, + 1, + msg="The refunds count should only consider transactions with operation 'refund'." + ) diff --git a/addons/payment/tests/test_transactions.py b/addons/payment/tests/test_transactions.py index 3bcb97ec4e9..957e117d4d6 100644 --- a/addons/payment/tests/test_transactions.py +++ b/addons/payment/tests/test_transactions.py @@ -8,10 +8,85 @@ from odoo.addons.payment.tests.common import PaymentCommon @tagged('-at_install', 'post_install') class TestTransactions(PaymentCommon): + def test_refunds_count(self): + self.acquirer.support_refund = 'full_only' # Should simply not be False + tx = self.create_transaction('redirect', state='done') + tx._reconcile_after_done() # Create the payment + for reference_index, operation in enumerate( + ('online_redirect', 'online_direct', 'online_token', 'validation', 'refund') + ): + self.create_transaction( + 'dummy', + reference=f'R-{tx.reference}-{reference_index + 1}', + state='done', + operation=operation, # Override the computed flow + source_transaction_id=tx.id, + )._reconcile_after_done() + + self.assertEqual( + tx.refunds_count, + 1, + msg="The refunds count should only consider transactions with operation 'refund'." + ) + + def test_refund_transaction_values(self): + self.acquirer.support_refund = 'partial' + tx = self.create_transaction('redirect', state='done') + tx._reconcile_after_done() # Create the payment + + # Test the default values of a full refund transaction + refund_tx = tx._create_refund_transaction() + self.assertEqual( + refund_tx.reference, + f'R-{tx.reference}', + msg="The reference of the refund transaction should be the prefixed reference of the " + "source transaction." + ) + self.assertLess( + refund_tx.amount, 0, msg="The amount of a refund transaction should always be negative." + ) + self.assertAlmostEqual( + refund_tx.amount, + -tx.amount, + places=2, + msg="The amount of the refund transaction should be taken from the amount of the " + "source transaction." + ) + self.assertEqual( + refund_tx.currency_id, + tx.currency_id, + msg="The currency of the refund transaction should that of the source transaction." + ) + self.assertEqual( + refund_tx.operation, + 'refund', + msg="The operation of the refund transaction should be 'refund'." + ) + self.assertEqual( + tx, + refund_tx.source_transaction_id, + msg="The refund transaction should be linked to the source transaction." + ) + self.assertEqual( + refund_tx.partner_id, + tx.partner_id, + msg="The partner of the refund transaction should that of the source transaction." + ) + + # Test the values of a partial refund transaction with custom refund amount + partial_refund_tx = tx._create_refund_transaction(refund_amount=11.11) + self.assertAlmostEqual( + partial_refund_tx.amount, + -11.11, + places=2, + msg="The amount of the refund transaction should be the negative value of the amount " + "to refund." + ) + def test_no_payment_for_validations(self): tx = self.create_transaction(flow='dummy', operation='validation') # Overwrite the flow tx._reconcile_after_done() payment_count = self.env['account.payment'].search_count( [('payment_transaction_id', '=', tx.id)] ) - self.assertEqual(payment_count, 0, "validation transactions should not create payments") + self.assertEqual(payment_count, 0, msg="validation transactions should not create payments") diff --git a/addons/payment/views/account_payment_views.xml b/addons/payment/views/account_payment_views.xml index d3bff2b3569..da613dcf30b 100644 --- a/addons/payment/views/account_payment_views.xml +++ b/addons/payment/views/account_payment_views.xml @@ -6,7 +6,26 @@ account.payment + + + + + diff --git a/addons/payment/views/payment_transaction_views.xml b/addons/payment/views/payment_transaction_views.xml index dba58ce5b6b..499033c5075 100644 --- a/addons/payment/views/payment_transaction_views.xml +++ b/addons/payment/views/payment_transaction_views.xml @@ -19,11 +19,20 @@ attrs="{'invisible': [('invoices_count', '=', 0)]}"> + + diff --git a/addons/payment/wizards/__init__.py b/addons/payment/wizards/__init__.py index 6cd6fa0579a..58f56810673 100644 --- a/addons/payment/wizards/__init__.py +++ b/addons/payment/wizards/__init__.py @@ -1,5 +1,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import account_payment_register from . import payment_acquirer_onboarding_wizard from . import payment_link_wizard -from . import account_payment_register +from . import payment_refund_wizard diff --git a/addons/payment/wizards/payment_refund_wizard.py b/addons/payment/wizards/payment_refund_wizard.py new file mode 100644 index 00000000000..a05abffa63f --- /dev/null +++ b/addons/payment/wizards/payment_refund_wizard.py @@ -0,0 +1,66 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PaymentRefundWizard(models.TransientModel): + _name = 'payment.refund.wizard' + _description = "Payment Refund Wizard" + + payment_id = fields.Many2one( + string="Payment", + comodel_name='account.payment', + readonly=True, + default=lambda self: self.env.context.get('active_id'), + ) + transaction_id = fields.Many2one( + string="Payment Transaction", related='payment_id.payment_transaction_id' + ) + payment_amount = fields.Monetary(string="Payment Amount", related='payment_id.amount') + refunded_amount = fields.Monetary(string="Refunded Amount", compute='_compute_refunded_amount') + amount_available_for_refund = fields.Monetary( + string="Maximum Refund Allowed", related='payment_id.amount_available_for_refund' + ) + amount_to_refund = fields.Monetary( + string="Refund Amount", compute='_compute_amount_to_refund', store=True, readonly=False + ) + currency_id = fields.Many2one(string="Currency", related='transaction_id.currency_id') + support_refund = fields.Selection(related='transaction_id.acquirer_id.support_refund') + has_pending_refund = fields.Boolean( + string="Has a pending refund", compute='_compute_has_pending_refund' + ) + + @api.constrains('amount_to_refund') + def _check_amount_to_refund_within_boundaries(self): + for wizard in self: + if not 0 < wizard.amount_to_refund <= wizard.amount_available_for_refund: + raise ValidationError(_( + "The amount to be refunded must be positive and cannot be superior to %s.", + wizard.amount_available_for_refund + )) + + @api.depends('amount_available_for_refund') + def _compute_refunded_amount(self): + for wizard in self: + wizard.refunded_amount = wizard.payment_amount - wizard.amount_available_for_refund + + @api.depends('amount_available_for_refund') + def _compute_amount_to_refund(self): + """ Set the default amount to refund to the amount available for refund. """ + for wizard in self: + wizard.amount_to_refund = wizard.amount_available_for_refund + + @api.depends('payment_id') # To always trigger the compute + def _compute_has_pending_refund(self): + for wizard in self: + pending_refunds_count = self.env['payment.transaction'].search_count([ + ('source_transaction_id', '=', wizard.payment_id.payment_transaction_id.id), + ('operation', '=', 'refund'), + ('state', 'in', ['draft', 'pending', 'authorized']), + ]) + wizard.has_pending_refund = pending_refunds_count > 0 + + def action_refund(self): + for wizard in self: + wizard.transaction_id.action_refund(amount_to_refund=wizard.amount_to_refund) diff --git a/addons/payment/wizards/payment_refund_wizard_views.xml b/addons/payment/wizards/payment_refund_wizard_views.xml new file mode 100644 index 00000000000..3b66d711b81 --- /dev/null +++ b/addons/payment/wizards/payment_refund_wizard_views.xml @@ -0,0 +1,42 @@ + + + + + payment.refund.wizard.form + payment.refund.wizard + +
+ + + + + + + + + + + + + + +
+
+ +
+
+ +
diff --git a/addons/payment_adyen/const.py b/addons/payment_adyen/const.py index 8e8efbb11dd..83325654431 100644 --- a/addons/payment_adyen/const.py +++ b/addons/payment_adyen/const.py @@ -4,11 +4,12 @@ # See https://docs.adyen.com/api-explorer/#/CheckoutService/v53/overview for Checkout API # See https://docs.adyen.com/api-explorer/#/Recurring/v49/overview for Recurring API API_ENDPOINT_VERSIONS = { - '/disable': 49, # Recurring API - '/originKeys': 53, # Checkout API - '/payments': 53, # Checkout API - '/payments/details': 53, # Checkout API - '/paymentMethods': 53, # Checkout API + '/disable': 49, # Recurring API + '/originKeys': 53, # Checkout API + '/payments': 53, # Checkout API + '/payments/details': 53, # Checkout API + '/payments/{}/refunds': 67, # Checkout API + '/paymentMethods': 53, # Checkout API } # Adyen-specific mapping of currency codes in ISO 4217 format to the number of decimals. diff --git a/addons/payment_adyen/controllers/main.py b/addons/payment_adyen/controllers/main.py index 9c8e9a29d60..85511e1e8dc 100644 --- a/addons/payment_adyen/controllers/main.py +++ b/addons/payment_adyen/controllers/main.py @@ -270,22 +270,22 @@ class AdyenController(http.Controller): ): continue - # Check whether the event of the notification succeeded + # Check whether the event of the notification succeeded and reshape the notification + # data for parsing _logger.info("notification received:\n%s", pprint.pformat(notification_data)) - if notification_data['success'] != 'true': - continue # Don't handle failed events - - # Reshape the notification data for parsing + success = notification_data['success'] == 'true' event_code = notification_data['eventCode'] - if event_code == 'AUTHORISATION': + if event_code == 'AUTHORISATION' and success: notification_data['resultCode'] = 'Authorised' - elif event_code == 'CANCELLATION': + elif event_code == 'CANCELLATION' and success: notification_data['resultCode'] = 'Cancelled' + elif event_code == 'REFUND': + notification_data['resultCode'] = 'Authorised' if success else 'Error' else: - continue # Don't handle unsupported event codes + continue # Don't handle unsupported event codes and failed events # Handle the notification data as a regular feedback - PaymentTransaction._handle_feedback_data('adyen', notification_data) + PaymentTransaction.sudo()._handle_feedback_data('adyen', notification_data) except ValidationError: # Acknowledge the notification to avoid getting spammed _logger.exception("unable to handle the notification data; skipping to acknowledge") diff --git a/addons/payment_adyen/data/payment_acquirer_data.xml b/addons/payment_adyen/data/payment_acquirer_data.xml index e6c95454230..e235cc1c07e 100644 --- a/addons/payment_adyen/data/payment_acquirer_data.xml +++ b/addons/payment_adyen/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ False False + partial True True diff --git a/addons/payment_adyen/models/payment_acquirer.py b/addons/payment_adyen/models/payment_acquirer.py index b2bc375a79a..329dd4f82eb 100644 --- a/addons/payment_adyen/models/payment_acquirer.py +++ b/addons/payment_adyen/models/payment_acquirer.py @@ -60,16 +60,21 @@ class PaymentAcquirer(models.Model): #=== BUSINESS METHODS ===# - def _adyen_make_request(self, url_field_name, endpoint, payload=None, method='POST'): + def _adyen_make_request( + self, url_field_name, endpoint, endpoint_param=None, payload=None, method='POST' + ): """ Make a request to Adyen API at the specified endpoint. Note: self.ensure_one() :param str url_field_name: The name of the field holding the base URL for the request :param str endpoint: The endpoint to be reached by the request + :param str endpoint_param: A variable required by some endpoints which are interpolated with + it if provided. For example, the acquirer reference of the source + transaction for the '/payments/{}/refunds' endpoint. :param dict payload: The payload of the request :param str method: The HTTP method of the request - :return The JSON-formatted content of the response + :return: The JSON-formatted content of the response :rtype: dict :raise: ValidationError if an HTTP error occurs """ @@ -77,7 +82,7 @@ class PaymentAcquirer(models.Model): def _build_url(_base_url, _version, _endpoint): """ Build an API URL by appending the version and endpoint to a base URL. - The final URL follows this pattern : `<_base>/V<_version>/<_endpoint>`. + The final URL follows this pattern: `<_base>/V<_version>/<_endpoint>`. :param str _base_url: The base of the url prefixed with `https://` :param int _version: The version of the endpoint @@ -91,8 +96,9 @@ class PaymentAcquirer(models.Model): self.ensure_one() - version = API_ENDPOINT_VERSIONS[endpoint] base_url = self[url_field_name] # Restrict request URL to the stored API URL fields + version = API_ENDPOINT_VERSIONS[endpoint] + endpoint = endpoint if not endpoint_param else endpoint.format(endpoint_param) url = _build_url(base_url, version, endpoint) headers = {'X-API-Key': self.adyen_api_key} try: diff --git a/addons/payment_adyen/models/payment_transaction.py b/addons/payment_adyen/models/payment_transaction.py index 7e0b994b5b5..e9bd9d4c0fd 100644 --- a/addons/payment_adyen/models/payment_transaction.py +++ b/addons/payment_adyen/models/payment_transaction.py @@ -93,6 +93,54 @@ class PaymentTransaction(models.Model): _logger.info("payment request response:\n%s", pprint.pformat(response_content)) self._handle_feedback_data('adyen', response_content) + def _send_refund_request(self, refund_amount=None, create_refund_transaction=True): + """ Override of payment to send a refund request to Adyen. + + Note: self.ensure_one() + + :param float refund_amount: The amount to refund + :param bool create_refund_transaction: Whether a refund transaction should be created or not + :return: None + """ + if self.provider != 'adyen': + return super()._send_refund_request( + refund_amount=refund_amount, create_refund_transaction=create_refund_transaction + ) + refund_tx = super()._send_refund_request( + refund_amount=refund_amount, create_refund_transaction=True + ) + + # Make the refund request to Adyen + converted_amount = payment_utils.to_minor_currency_units( + -refund_tx.amount, # The amount is negative for refund transactions + refund_tx.currency_id, + arbitrary_decimal_number=CURRENCY_DECIMALS.get(refund_tx.currency_id.name) + ) + data = { + 'merchantAccount': self.acquirer_id.adyen_merchant_account, + 'amount': { + 'value': converted_amount, + 'currency': refund_tx.currency_id.name, + }, + 'reference': refund_tx.reference, + } + response_content = refund_tx.acquirer_id._adyen_make_request( + url_field_name='adyen_checkout_api_url', + endpoint='/payments/{}/refunds', + endpoint_param=self.acquirer_reference, + payload=data, + method='POST' + ) + _logger.info("refund request response:\n%s", pprint.pformat(response_content)) + + # Handle the refund request response + psp_reference = response_content.get('pspReference') + status = response_content.get('status') + if psp_reference and status == 'received': + # The PSP reference associated with this /refunds request is different from the psp + # reference associated with the original payment request. + refund_tx.acquirer_reference = psp_reference + @api.model def _get_tx_from_feedback_data(self, provider, data): """ Override of payment to find the transaction based on Adyen data. @@ -111,14 +159,44 @@ class PaymentTransaction(models.Model): reference = data.get('merchantReference') if not reference: raise ValidationError("Adyen: " + _("Received data with missing merchant reference")) + event_code = data.get('eventCode') tx = self.search([('reference', '=', reference), ('provider', '=', 'adyen')]) + if event_code == 'REFUND' and tx and tx.operation != 'refund': + # If a refund is initiated from Adyen, the merchant reference in the feedback data is + # that of the source transaction. We need to manually create the refund transaction. + tx = self._adyen_create_refund_tx_from_feedback_data(tx, data) + if not tx: raise ValidationError( "Adyen: " + _("No transaction found matching reference %s.", reference) ) return tx + def _adyen_create_refund_tx_from_feedback_data(self, source_tx, data): + """ Create a refund transaction based on Adyen data. + + :param recordset source_tx: The source transaction for which a refund is initiated, as a + `payment.transaction` recordset + :param dict data: The feedback data sent by the provider + :return: The created refund transaction + :rtype: recordset of `payment.transaction` + :raise: ValidationError if inconsistent data were received + """ + refund_acquirer_reference = data.get('pspReference') + refund_amount = data.get('amount', {}).get('value') + if not refund_acquirer_reference or not refund_amount: + raise ValidationError( + "Adyen: " + _("Received refund data with missing transaction values") + ) + + converted_amount = payment_utils.to_major_currency_units( + refund_amount, source_tx.currency_id + ) + return source_tx._create_refund_transaction( + refund_amount=converted_amount, acquirer_reference=refund_acquirer_reference + ) + def _process_feedback_data(self, data): """ Override of payment to process the transaction based on Adyen data. @@ -138,7 +216,7 @@ class PaymentTransaction(models.Model): # Handle the payment state payment_state = data.get('resultCode') - refusal_reason = data.get('refusalReason') + refusal_reason = data.get('refusalReason') or data.get('reason') if not payment_state: raise ValidationError("Adyen: " + _("Received data with missing payment state.")) @@ -149,6 +227,8 @@ class PaymentTransaction(models.Model): if self.tokenize and has_token_data: self._adyen_tokenize_from_feedback_data(data) self._set_done() + if self.operation == 'refund': + self.env.ref('payment.cron_post_process_payment_tx')._trigger() elif payment_state in RESULT_CODES_MAPPING['cancel']: _logger.warning("The transaction with reference %s was cancelled (reason: %s)", self.reference, refusal_reason) diff --git a/addons/payment_adyen/tests/test_adyen.py b/addons/payment_adyen/tests/test_adyen.py index 0c0eb0dbf2d..73447b4eead 100644 --- a/addons/payment_adyen/tests/test_adyen.py +++ b/addons/payment_adyen/tests/test_adyen.py @@ -41,3 +41,30 @@ class AdyenForm(AdyenCommon): token = self.create_token(active=False) with self.assertRaises(UserError): token._handle_reactivation_request() + + @mute_logger('odoo.addons.payment_adyen.models.payment_transaction') + def test_send_refund_request(self): + self.acquirer.support_refund = 'full_only' # Should simply not be False + tx = self.create_transaction( + 'redirect', state='done', acquirer_reference='source_reference' + ) + tx._reconcile_after_done() # Create the payment + + # Send the refund request + with patch( + 'odoo.addons.payment_adyen.models.payment_acquirer.PaymentAcquirer._adyen_make_request', + new=lambda *args, **kwargs: {'pspReference': "refund_reference", 'status': "received"} + ): + tx._send_refund_request() + + refund_tx = self.env['payment.transaction'].search([('source_transaction_id', '=', tx.id)]) + self.assertTrue( + refund_tx, + msg="Refunding an Adyen transaction should always create a refund transaction." + ) + self.assertNotEqual( + refund_tx.acquirer_reference, + tx.acquirer_reference, + msg="The acquirer reference of the refund transaction should different from that of " + "the source transaction." + ) diff --git a/addons/payment_alipay/data/payment_acquirer_data.xml b/addons/payment_alipay/data/payment_acquirer_data.xml index d25bc887e16..de3bb829326 100644 --- a/addons/payment_alipay/data/payment_acquirer_data.xml +++ b/addons/payment_alipay/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ False True + False diff --git a/addons/payment_authorize/data/payment_acquirer_data.xml b/addons/payment_authorize/data/payment_acquirer_data.xml index 222bdb45f85..a97be86e255 100644 --- a/addons/payment_authorize/data/payment_acquirer_data.xml +++ b/addons/payment_authorize/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ True False + True True diff --git a/addons/payment_authorize/models/payment_transaction.py b/addons/payment_authorize/models/payment_transaction.py index 5b55e357fad..215aa3e0d78 100644 --- a/addons/payment_authorize/models/payment_transaction.py +++ b/addons/payment_authorize/models/payment_transaction.py @@ -84,115 +84,14 @@ class PaymentTransaction(models.Model): feedback_data = {'reference': self.reference, 'response': res_content} self._handle_feedback_data('authorize', feedback_data) - @api.model - def _get_tx_from_feedback_data(self, provider, data): - """ Find the transaction based on the feedback data. - - :param str provider: The provider of the acquirer that handled the transaction - :param dict data: The feedback data sent by the acquirer - :return: The transaction if found - :rtype: recordset of `payment.transaction` - """ - tx = super()._get_tx_from_feedback_data(provider, data) - if provider != 'authorize': - return tx - - reference = data.get('reference') - tx = self.search([('reference', '=', reference), ('provider', '=', 'authorize')]) - if not tx: - raise ValidationError( - "Authorize.Net: " + _("No transaction found matching reference %s.", reference) - ) - return tx - - def _process_feedback_data(self, data): - """ Override of payment to process the transaction based on Authorize data. - - Note: self.ensure_one() - - :param dict data: The feedback data sent by the provider - :return: None - """ - super()._process_feedback_data(data) - if self.provider != 'authorize': - return - - response_content = data.get('response') - - self.acquirer_reference = response_content.get('x_trans_id') - status_code = response_content.get('x_response_code', '3') - if status_code == '1': # Approved - status_type = response_content.get('x_type').lower() - if status_type in ('auth_capture', 'prior_auth_capture'): - self._set_done() - if self.tokenize and not self.token_id: - self._authorize_tokenize() - elif status_type == 'auth_only': - self._set_authorized() - if self.tokenize and not self.token_id: - self._authorize_tokenize() - self._send_refund_request() # In last step because it calls _handle_feedback_data() - 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_code == '2': # Declined - self._set_canceled() - elif status_code == '4': # Held for Review - self._set_pending() - else: # Error / Unknown code - error_code = response_content.get('x_response_reason_text') - _logger.info( - "received data with invalid status code %s and error code %s", - status_code, error_code - ) - self._set_error( - "Authorize.Net: " + _( - "Received data with status code \"%(status)s\" and error code \"%(error)s\"", - status=status_code, error=error_code - ) - ) - - def _authorize_tokenize(self): - """ Create a token for the current transaction. - - Note: self.ensure_one() - - :return: None - """ - self.ensure_one() - - authorize_API = AuthorizeAPI(self.acquirer_id) - cust_profile = authorize_API.create_customer_profile( - self.partner_id, self.acquirer_reference - ) - _logger.info("create_customer_profile request response:\n%s", pprint.pformat(cust_profile)) - if cust_profile: - token = self.env['payment.token'].create({ - 'acquirer_id': self.acquirer_id.id, - 'name': cust_profile.get('name'), - 'partner_id': self.partner_id.id, - 'acquirer_ref': cust_profile.get('payment_profile_id'), - 'authorize_profile': cust_profile.get('profile_id'), - 'verified': True, - }) - self.write({ - 'token_id': token.id, - 'tokenize': False, - }) - _logger.info( - "created token with id %s for partner with id %s", token.id, self.partner_id.id - ) - - def _send_refund_request(self): + def _send_refund_request(self, refund_amount=None, create_refund_transaction=True): """ Override of payment to send a refund request to Authorize. Note: self.ensure_one() :return: None """ - super()._send_refund_request() + super()._send_refund_request(refund_amount=refund_amount, create_refund_transaction=False) if self.provider != 'authorize': return @@ -246,3 +145,104 @@ class PaymentTransaction(models.Model): # data in order to go through the centralized `_handle_feedback_data` method. feedback_data = {'reference': self.reference, 'response': res_content} self._handle_feedback_data('authorize', feedback_data) + + @api.model + def _get_tx_from_feedback_data(self, provider, data): + """ Find the transaction based on the feedback data. + + :param str provider: The provider of the acquirer that handled the transaction + :param dict data: The feedback data sent by the acquirer + :return: The transaction if found + :rtype: recordset of `payment.transaction` + """ + tx = super()._get_tx_from_feedback_data(provider, data) + if provider != 'authorize': + return tx + + reference = data.get('reference') + tx = self.search([('reference', '=', reference), ('provider', '=', 'authorize')]) + if not tx: + raise ValidationError( + "Authorize.Net: " + _("No transaction found matching reference %s.", reference) + ) + return tx + + def _process_feedback_data(self, data): + """ Override of payment to process the transaction based on Authorize data. + + Note: self.ensure_one() + + :param dict data: The feedback data sent by the provider + :return: None + """ + super()._process_feedback_data(data) + if self.provider != 'authorize': + return + + response_content = data.get('response') + + self.acquirer_reference = response_content.get('x_trans_id') + status_code = response_content.get('x_response_code', '3') + if status_code == '1': # Approved + status_type = response_content.get('x_type').lower() + if status_type in ('auth_capture', 'prior_auth_capture'): + self._set_done() + if self.tokenize and not self.token_id: + self._authorize_tokenize() + elif status_type == 'auth_only': + self._set_authorized() + if self.tokenize and not self.token_id: + self._authorize_tokenize() + self._send_refund_request(create_refund_transaction=False) # In last step because it calls _handle_feedback_data() + 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_code == '2': # Declined + self._set_canceled() + elif status_code == '4': # Held for Review + self._set_pending() + else: # Error / Unknown code + error_code = response_content.get('x_response_reason_text') + _logger.info( + "received data with invalid status code %s and error code %s", + status_code, error_code + ) + self._set_error( + "Authorize.Net: " + _( + "Received data with status code \"%(status)s\" and error code \"%(error)s\"", + status=status_code, error=error_code + ) + ) + + def _authorize_tokenize(self): + """ Create a token for the current transaction. + + Note: self.ensure_one() + + :return: None + """ + self.ensure_one() + + authorize_API = AuthorizeAPI(self.acquirer_id) + cust_profile = authorize_API.create_customer_profile( + self.partner_id, self.acquirer_reference + ) + _logger.info("create_customer_profile request response:\n%s", pprint.pformat(cust_profile)) + if cust_profile: + token = self.env['payment.token'].create({ + 'acquirer_id': self.acquirer_id.id, + 'name': cust_profile.get('name'), + 'partner_id': self.partner_id.id, + 'acquirer_ref': cust_profile.get('payment_profile_id'), + 'authorize_profile': cust_profile.get('profile_id'), + 'verified': True, + }) + self.write({ + 'token_id': token.id, + 'tokenize': False, + }) + _logger.info( + "created token with id %s for partner with id %s", token.id, self.partner_id.id + ) diff --git a/addons/payment_buckaroo/data/payment_acquirer_data.xml b/addons/payment_buckaroo/data/payment_acquirer_data.xml index e25e09cec15..8a5ea041eab 100644 --- a/addons/payment_buckaroo/data/payment_acquirer_data.xml +++ b/addons/payment_buckaroo/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ False False + False diff --git a/addons/payment_odoo/data/payment_acquirer_data.xml b/addons/payment_odoo/data/payment_acquirer_data.xml index f2bf5b2ccd0..ef740073485 100644 --- a/addons/payment_odoo/data/payment_acquirer_data.xml +++ b/addons/payment_odoo/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ False False + True True diff --git a/addons/payment_ogone/data/payment_acquirer_data.xml b/addons/payment_ogone/data/payment_acquirer_data.xml index 4d3cf79f467..cb79a0173f0 100644 --- a/addons/payment_ogone/data/payment_acquirer_data.xml +++ b/addons/payment_ogone/data/payment_acquirer_data.xml @@ -1,6 +1,6 @@ - + ogone @@ -8,6 +8,7 @@ False True True + @@ -15,5 +16,5 @@ ogone inbound - + diff --git a/addons/payment_paypal/data/payment_acquirer_data.xml b/addons/payment_paypal/data/payment_acquirer_data.xml index f3ed608369f..2df7ac173e4 100644 --- a/addons/payment_paypal/data/payment_acquirer_data.xml +++ b/addons/payment_paypal/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ False True + False diff --git a/addons/payment_payulatam/data/payment_acquirer_data.xml b/addons/payment_payulatam/data/payment_acquirer_data.xml index 5d6d5283ba8..a2d30f902a2 100644 --- a/addons/payment_payulatam/data/payment_acquirer_data.xml +++ b/addons/payment_payulatam/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ False False + False diff --git a/addons/payment_payumoney/data/payment_acquirer_data.xml b/addons/payment_payumoney/data/payment_acquirer_data.xml index faaf9d2b73b..63b8536225e 100644 --- a/addons/payment_payumoney/data/payment_acquirer_data.xml +++ b/addons/payment_payumoney/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ False False + False diff --git a/addons/payment_sips/data/payment_acquirer_data.xml b/addons/payment_sips/data/payment_acquirer_data.xml index 95c766fd3ef..fd226c0d550 100644 --- a/addons/payment_sips/data/payment_acquirer_data.xml +++ b/addons/payment_sips/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ False False + False diff --git a/addons/payment_stripe/data/payment_acquirer_data.xml b/addons/payment_stripe/data/payment_acquirer_data.xml index 7e76bdcc091..bd314d7d886 100644 --- a/addons/payment_stripe/data/payment_acquirer_data.xml +++ b/addons/payment_stripe/data/payment_acquirer_data.xml @@ -5,6 +5,7 @@ stripe False False + True True diff --git a/addons/payment_test/data/payment_acquirer_data.xml b/addons/payment_test/data/payment_acquirer_data.xml index 3aee823aee6..33b6bbd9065 100644 --- a/addons/payment_test/data/payment_acquirer_data.xml +++ b/addons/payment_test/data/payment_acquirer_data.xml @@ -6,6 +6,7 @@ False False + True True diff --git a/addons/payment_transfer/data/payment_acquirer_data.xml b/addons/payment_transfer/data/payment_acquirer_data.xml index 86eebe54d61..e66f0302aa4 100644 --- a/addons/payment_transfer/data/payment_acquirer_data.xml +++ b/addons/payment_transfer/data/payment_acquirer_data.xml @@ -7,6 +7,7 @@ False False + False