[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) <AntoineVDV@users.noreply.github.com>
This commit is contained in:
Demesmaeker
2021-08-23 10:54:17 +00:00
committed by Antoine Vandevenne (anv)
parent c90ab7b664
commit e0233a1010
32 changed files with 842 additions and 208 deletions
+1
View File
@@ -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': {
+100 -30
View File
@@ -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'<a href=# data-oe-model=account.payment data-oe-id={self.id}>{self.name}</a>'
@@ -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
+5 -1
View File
@@ -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(
+152 -51
View File
@@ -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).",
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
4 payment_icon_all payment.icon.all model_payment_icon 1 0 0 0
5 payment_icon_system payment.icon.system model_payment_icon base.group_system 1 1 1 1
6 payment_link_wizard payment.link.wizard model_payment_link_wizard account.group_account_user 1 1 1 0
7 payment_refund_wizard payment.refund.wizard model_payment_refund_wizard account.group_account_invoice 1 1 1 0
8 payment_token_all payment.token.all model_payment_token 1 0 0 0
9 payment_token_portal payment.token.portal model_payment_token base.group_portal 1 1 1 1
10 payment_token_system payment.token.system model_payment_token base.group_system 1 1 1 1
+1
View File
@@ -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
+117
View File
@@ -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'."
)
+76 -1
View File
@@ -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")
@@ -6,7 +6,26 @@
<field name="model">account.payment</field>
<field name="inherit_id" ref="account.view_account_payment_form"/>
<field name="arch" type="xml">
<xpath expr="//header/button[@name='action_draft']" position="after">
<field name="amount_available_for_refund" invisible="1"/>
<button type="object"
name="action_refund_wizard"
string="Refund"
groups="account.group_account_invoice"
attrs="{'invisible': [('amount_available_for_refund', '&lt;=', 0)]}"
class="btn-secondary"/>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_refunds"
type="object"
class="oe_stat_button"
icon="fa-money"
attrs="{'invisible': [('refunds_count', '=', 0)]}">
<field name="refunds_count" widget="statinfo" string="Refunds"/>
</button>
</xpath>
<xpath expr='//group[2]' position="inside">
<field name="source_payment_id" attrs="{'invisible': [('source_payment_id', '=', False)]}"/>
<field name="payment_transaction_id" groups="base.group_no_one" attrs="{'invisible': [('use_electronic_payment_method', '!=', True)]}"/>
</xpath>
<field name="payment_method_line_id" position="after">
@@ -19,11 +19,20 @@
attrs="{'invisible': [('invoices_count', '=', 0)]}">
<field name="invoices_count" widget="statinfo" string="Invoice(s)"/>
</button>
<button name="action_view_refunds"
type="object"
class="oe_stat_button"
icon="fa-money"
attrs="{'invisible': [('refunds_count', '=', 0)]}">
<field name="refunds_count" widget="statinfo" string="Refunds"/>
</button>
</div>
<group>
<group name="transaction_details">
<field name="reference"/>
<field name="payment_id"/>
<field name="source_transaction_id"
attrs="{'invisible': [('source_transaction_id', '=', False)]}"/>
<field name="amount"/>
<field name="fees" attrs="{'invisible': [('fees', '=', 0.0)]}"/>
<field name="currency_id" invisible="1"/>
+2 -1
View File
@@ -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
@@ -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)
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_refund_wizard_view_form" model="ir.ui.view">
<field name="name">payment.refund.wizard.form</field>
<field name="model">payment.refund.wizard</field>
<field name="arch" type="xml">
<form string="Refund">
<field name="has_pending_refund" invisible="1"/>
<div class="alert alert-warning"
id="alert_draft_refund_tx"
role="alert"
attrs="{'invisible': [('has_pending_refund', '=', False)]}">
<p>
<strong>Warning!</strong> There is a refund pending for this payment.
Wait a moment for it to be processed. If the refund is still pending in a
few minutes, please check your payment acquirer configuration.
</p>
</div>
<group>
<group>
<field name="payment_id" invisible="1"/>
<field name="transaction_id" invisible="1"/>
<field name="currency_id" invisible="1"/>
<field name="support_refund" invisible="1"/>
<field name="payment_amount"/>
<field name="refunded_amount"
attrs="{'invisible': [('refunded_amount', '&lt;=', 0)]}"/>
<field name="amount_available_for_refund"/>
<field name="amount_to_refund"
attrs="{'readonly': [('support_refund', '=', 'full_only')]}"/>
</group>
</group>
<footer>
<button string="Refund" type="object" name="action_refund" class="btn-primary"/>
<button string="Close" special="cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>
+6 -5
View File
@@ -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.
+9 -9
View File
@@ -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")
@@ -6,6 +6,7 @@
<field name="inline_form_view_id" ref="inline_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">False</field>
<field name="support_refund">partial</field>
<field name="support_tokenization">True</field>
<field name="allow_tokenization">True</field>
</record>
@@ -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:
@@ -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)
+27
View File
@@ -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."
)
@@ -6,6 +6,7 @@
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">True</field>
<field name="support_refund"></field>
<field name="support_tokenization">False</field>
</record>
@@ -6,6 +6,7 @@
<field name="inline_form_view_id" ref="inline_form"/>
<field name="support_authorization">True</field>
<field name="support_fees_computation">False</field>
<field name="support_refund"></field>
<field name="support_tokenization">True</field>
<field name="allow_tokenization">True</field>
</record>
@@ -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
)
@@ -6,6 +6,7 @@
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">False</field>
<field name="support_refund"></field>
<field name="support_tokenization">False</field>
</record>
@@ -6,6 +6,7 @@
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">False</field>
<field name="support_refund"></field>
<field name="support_tokenization">True</field>
<field name="allow_tokenization">True</field>
</record>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment.payment_acquirer_ogone" model="payment.acquirer">
<field name="provider">ogone</field>
<field name="redirect_form_view_id" ref="redirect_form"/>
@@ -8,6 +8,7 @@
<field name="support_fees_computation">False</field>
<field name="support_tokenization">True</field>
<field name="allow_tokenization">True</field>
<field name="support_refund"></field>
</record>
<record id="payment_method_ogone" model="account.payment.method">
@@ -15,5 +16,5 @@
<field name="code">ogone</field>
<field name="payment_type">inbound</field>
</record>
</odoo>
@@ -6,6 +6,7 @@
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">True</field>
<field name="support_refund"></field>
<field name="support_tokenization">False</field>
</record>
@@ -6,6 +6,7 @@
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">False</field>
<field name="support_refund"></field>
<field name="support_tokenization">False</field>
</record>
@@ -6,6 +6,7 @@
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">False</field>
<field name="support_refund"></field>
<field name="support_tokenization">False</field>
</record>
@@ -6,6 +6,7 @@
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">False</field>
<field name="support_refund"></field>
<field name="support_tokenization">False</field>
</record>
@@ -5,6 +5,7 @@
<field name="provider">stripe</field>
<field name="support_authorization">False</field>
<field name="support_fees_computation">False</field>
<field name="support_refund"></field>
<field name="support_tokenization">True</field>
<field name="allow_tokenization">True</field>
</record>
@@ -6,6 +6,7 @@
<field name="inline_form_view_id" ref="inline_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">False</field>
<field name="support_refund"></field>
<field name="support_tokenization">True</field>
<field name="allow_tokenization">True</field>
</record>
@@ -7,6 +7,7 @@
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="support_authorization">False</field>
<field name="support_fees_computation">False</field>
<field name="support_refund"></field>
<field name="support_tokenization">False</field>
<!-- Clear the default value to trigger the computation of the message -->
<field name="pending_msg" eval="False"/>