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