[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:
committed by
Antoine Vandevenne (anv)
parent
c90ab7b664
commit
e0233a1010
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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'."
|
||||
)
|
||||
@@ -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', '<=', 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"/>
|
||||
|
||||
@@ -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', '<=', 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user