Before this commit, it was not possible to partially capture a transaction from Odoo, and doing so in the provider backend would often result in a full capture in Odoo when capture was supported. With this commit, partial captures are made available in Odoo directly from the sales order or invoice, for providers that support them. Provider can either only support full capture or also support partial ones. It also optionally managed the automatic void of the remaining amount at the user request when multiple captures are supported by the provider. As of now, the only acquirer allowing partial capture is Adyen. task-2728768 closes odoo/odoo#87251 Related: odoo/enterprise#35205 Related: odoo/documentation#2063 Signed-off-by: Antoine Vandevenne (anv) <anv@odoo.com>
153 lines
7.2 KiB
Python
153 lines
7.2 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.tools import format_amount
|
|
|
|
|
|
class PaymentCaptureWizard(models.TransientModel):
|
|
_name = 'payment.capture.wizard'
|
|
_description = "Payment Capture Wizard"
|
|
|
|
transaction_ids = fields.Many2many( # All the source txs related to the capture request
|
|
comodel_name='payment.transaction',
|
|
default=lambda self: self.env.context.get('active_ids'),
|
|
readonly=True,
|
|
)
|
|
authorized_amount = fields.Monetary(
|
|
string="Authorized Amount", compute='_compute_authorized_amount'
|
|
)
|
|
captured_amount = fields.Monetary(string="Already Captured", compute='_compute_captured_amount')
|
|
voided_amount = fields.Monetary(string="Already Voided", compute='_compute_voided_amount')
|
|
available_amount = fields.Monetary(
|
|
string="Maximum Capture Allowed", compute='_compute_available_amount'
|
|
)
|
|
amount_to_capture = fields.Monetary(
|
|
compute='_compute_amount_to_capture', store=True, readonly=False
|
|
)
|
|
is_amount_to_capture_valid = fields.Boolean(compute='_compute_is_amount_to_capture_valid')
|
|
void_remaining_amount = fields.Boolean()
|
|
currency_id = fields.Many2one(related='transaction_ids.currency_id')
|
|
support_partial_capture = fields.Boolean(
|
|
help="Whether each of the transactions' provider supports the partial capture.",
|
|
compute='_compute_support_partial_capture',
|
|
compute_sudo=True,
|
|
)
|
|
has_draft_children = fields.Boolean(compute='_compute_has_draft_children')
|
|
has_remaining_amount = fields.Boolean(compute='_compute_has_remaining_amount')
|
|
|
|
#=== COMPUTE METHODS ===#
|
|
|
|
@api.depends('transaction_ids')
|
|
def _compute_authorized_amount(self):
|
|
for wizard in self:
|
|
wizard.authorized_amount = sum(wizard.transaction_ids.mapped('amount'))
|
|
|
|
@api.depends('transaction_ids')
|
|
def _compute_captured_amount(self):
|
|
for wizard in self:
|
|
full_capture_txs = wizard.transaction_ids.filtered(
|
|
lambda tx: tx.state == 'done' and not tx.child_transaction_ids
|
|
) # Transactions that have been fully captured in a single capture operation.
|
|
partial_capture_child_txs = wizard.transaction_ids.child_transaction_ids.filtered(
|
|
lambda tx: tx.state == 'done'
|
|
) # Transactions that represent a partial capture of their source transaction.
|
|
wizard.captured_amount = sum(
|
|
(full_capture_txs | partial_capture_child_txs).mapped('amount')
|
|
)
|
|
|
|
@api.depends('transaction_ids')
|
|
def _compute_voided_amount(self):
|
|
for wizard in self:
|
|
void_child_txs = wizard.transaction_ids.child_transaction_ids.filtered(
|
|
lambda tx: tx.state == 'cancel'
|
|
)
|
|
wizard.voided_amount = sum(void_child_txs.mapped('amount'))
|
|
|
|
@api.depends('authorized_amount', 'captured_amount', 'voided_amount')
|
|
def _compute_available_amount(self):
|
|
for wizard in self:
|
|
wizard.available_amount = wizard.authorized_amount \
|
|
- wizard.captured_amount \
|
|
- wizard.voided_amount
|
|
|
|
@api.depends('available_amount')
|
|
def _compute_amount_to_capture(self):
|
|
""" Set the default amount to capture to the amount available for capture. """
|
|
for wizard in self:
|
|
wizard.amount_to_capture = wizard.available_amount
|
|
|
|
@api.depends('amount_to_capture', 'available_amount')
|
|
def _compute_is_amount_to_capture_valid(self):
|
|
for wizard in self:
|
|
is_valid = 0 < wizard.amount_to_capture <= wizard.available_amount
|
|
wizard.is_amount_to_capture_valid = is_valid
|
|
|
|
@api.depends('transaction_ids')
|
|
def _compute_support_partial_capture(self):
|
|
for wizard in self:
|
|
wizard.support_partial_capture = all(
|
|
tx.provider_id.support_manual_capture == 'partial' for tx in wizard.transaction_ids
|
|
)
|
|
|
|
@api.depends('transaction_ids')
|
|
def _compute_has_draft_children(self):
|
|
for wizard in self:
|
|
wizard.has_draft_children = bool(wizard.transaction_ids.child_transaction_ids.filtered(
|
|
lambda tx: tx.state == 'draft'
|
|
))
|
|
|
|
@api.depends('available_amount', 'amount_to_capture')
|
|
def _compute_has_remaining_amount(self):
|
|
for wizard in self:
|
|
wizard.has_remaining_amount = wizard.amount_to_capture < wizard.available_amount
|
|
if not wizard.has_remaining_amount:
|
|
wizard.void_remaining_amount = False
|
|
|
|
#=== CONSTRAINT METHODS ===#
|
|
|
|
@api.constrains('amount_to_capture')
|
|
def _check_amount_to_capture_within_boundaries(self):
|
|
for wizard in self:
|
|
if not wizard.is_amount_to_capture_valid:
|
|
formatted_amount = format_amount(
|
|
self.env, wizard.available_amount, wizard.currency_id
|
|
)
|
|
raise ValidationError(_(
|
|
"The amount to capture must be positive and cannot be superior to %s.",
|
|
formatted_amount
|
|
))
|
|
if not wizard.support_partial_capture \
|
|
and wizard.amount_to_capture != wizard.available_amount:
|
|
raise ValidationError(_(
|
|
"Some of the transactions you intend to capture can only be captured in full. "
|
|
"Handle the transactions individually to capture a partial amount."
|
|
))
|
|
|
|
#=== ACTION METHODS ===#
|
|
|
|
def action_capture(self):
|
|
for wizard in self:
|
|
remaining_amount_to_capture = wizard.amount_to_capture
|
|
for source_tx in wizard.transaction_ids.filtered(lambda tx: tx.state == 'authorized'):
|
|
partial_capture_child_txs = wizard.transaction_ids.child_transaction_ids.filtered(
|
|
lambda tx: tx.source_transaction_id == source_tx and tx.state == 'done'
|
|
) # We can void all the remaining amount only at once => don't check cancel state.
|
|
source_tx_remaining_amount = source_tx.currency_id.round(
|
|
source_tx.amount - sum(partial_capture_child_txs.mapped('amount'))
|
|
)
|
|
if remaining_amount_to_capture:
|
|
amount_to_capture = min(source_tx_remaining_amount, remaining_amount_to_capture)
|
|
# In sudo mode because we need to be able to read on provider fields.
|
|
source_tx.sudo()._send_capture_request(amount_to_capture=amount_to_capture)
|
|
remaining_amount_to_capture -= amount_to_capture
|
|
source_tx_remaining_amount -= amount_to_capture
|
|
|
|
if source_tx_remaining_amount and wizard.void_remaining_amount:
|
|
# The source tx isn't fully captured and the user wants to void the remaining.
|
|
# In sudo mode because we need to be able to read on provider fields.
|
|
source_tx.sudo()._send_void_request(amount_to_void=source_tx_remaining_amount)
|
|
elif not remaining_amount_to_capture and not wizard.void_remaining_amount:
|
|
# The amount to capture has been completely captured.
|
|
break # Skip the remaining transactions.
|