diff --git a/addons/account_payment/__init__.py b/addons/account_payment/__init__.py
index dd15b5c32e5..c722f591c88 100644
--- a/addons/account_payment/__init__.py
+++ b/addons/account_payment/__init__.py
@@ -1,4 +1,26 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
+from . import models
+from . import wizards
+
+from odoo import api, SUPERUSER_ID
+
+
+def post_init_hook(cr, registry):
+ """ Create `account.payment.method` records for the installed payment providers. """
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ PaymentAcquirer = env['payment.acquirer']
+ installed_providers = PaymentAcquirer.search([('module_id.state', '=', 'installed')])
+ for provider_code in set(installed_providers.mapped('provider')):
+ PaymentAcquirer._setup_payment_method(provider_code)
+
+
+def uninstall_hook(cr, registry):
+ """ Delete `account.payment.method` records created for the installed payment providers. """
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ installed_providers = env['payment.acquirer'].search([('module_id.state', '=', 'installed')])
+ env['account.payment.method'].search([
+ ('code', 'in', installed_providers.mapped('provider')),
+ ('payment_type', '=', 'inbound'),
+ ]).unlink()
diff --git a/addons/account_payment/__manifest__.py b/addons/account_payment/__manifest__.py
index c4a467e2993..91f8825b3b0 100644
--- a/addons/account_payment/__manifest__.py
+++ b/addons/account_payment/__manifest__.py
@@ -1,21 +1,35 @@
-# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
- 'name': 'Payment - Account',
+ 'name': "Payment - Account",
'category': 'Accounting/Accounting',
- 'summary': 'Account and Payment Link and Portal',
- 'version': '1.0',
- 'description': """Link Account and Payment and add Portal Payment
-
-Provide tools for account-related payment as well as portal options to
-enable payment.
-
- * UPDATE ME
-""",
- 'depends': ['payment'],
+ 'summary': "Enable customers to pay invoices on the portal and post payments when transactions are processed.",
+ 'version': '2.0',
+ 'depends': ['account', 'payment'],
+ 'auto_install': ['account'],
'data': [
+ 'security/ir.model.access.csv',
+ 'security/ir_rules.xml',
+
+ 'views/account_payment_menus.xml',
'views/account_portal_templates.xml',
+ 'views/payment_templates.xml',
+ 'views/account_move_views.xml',
+ 'views/account_journal_views.xml',
+ 'views/account_payment_views.xml',
+ 'views/payment_acquirer_views.xml',
+ 'views/payment_transaction_views.xml',
+
+ 'wizards/account_payment_register_views.xml',
+ 'wizards/payment_link_wizard_views.xml',
+ 'wizards/payment_refund_wizard_views.xml',
],
- 'installable': True,
+ 'assets': {
+ 'web.assets_frontend': [
+ 'account_payment/static/src/js/payment_form.js',
+ ],
+ },
+ 'post_init_hook': 'post_init_hook',
+ 'uninstall_hook': 'uninstall_hook',
'license': 'LGPL-3',
}
diff --git a/addons/account_payment/controllers/payment.py b/addons/account_payment/controllers/payment.py
index 23a71f8e840..8553b61ae2b 100644
--- a/addons/account_payment/controllers/payment.py
+++ b/addons/account_payment/controllers/payment.py
@@ -1,10 +1,11 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import _
from odoo.exceptions import AccessError, MissingError, ValidationError
from odoo.fields import Command
-from odoo.http import route
+from odoo.http import request, route
+from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment.controllers import portal as payment_portal
@@ -36,3 +37,79 @@ class PaymentPortal(payment_portal.PaymentPortal):
)
return tx_sudo._get_processing_values()
+
+ # Payment overrides
+
+ @route()
+ def payment_pay(self, *args, amount=None, invoice_id=None, access_token=None, **kwargs):
+ """ Override of `payment` to replace the missing transaction values by that of the invoice.
+
+ This is necessary for the reconciliation as all transaction values, excepted the amount,
+ need to match exactly that of the invoice.
+
+ :param str amount: The (possibly partial) amount to pay used to check the access token.
+ :param str invoice_id: The invoice for which a payment id made, as an `account.move` id.
+ :param str access_token: The access token used to authenticate the partner.
+ :return: The result of the parent method.
+ :rtype: str
+ :raise ValidationError: If the invoice id is invalid.
+ """
+ # Cast numeric parameters as int or float and void them if their str value is malformed.
+ amount = self._cast_as_float(amount)
+ invoice_id = self._cast_as_int(invoice_id)
+ if invoice_id:
+ invoice_sudo = request.env['account.move'].sudo().browse(invoice_id).exists()
+ if not invoice_sudo:
+ raise ValidationError(_("The provided parameters are invalid."))
+
+ # Check the access token against the invoice values. Done after fetching the invoice
+ # as we need the invoice fields to check the access token.
+ if not payment_utils.check_access_token(
+ access_token, invoice_sudo.partner_id.id, amount, invoice_sudo.currency_id.id
+ ):
+ raise ValidationError(_("The provided parameters are invalid."))
+
+ kwargs.update({
+ 'currency_id': invoice_sudo.currency_id.id,
+ 'partner_id': invoice_sudo.partner_id.id,
+ 'company_id': invoice_sudo.company_id.id,
+ 'invoice_id': invoice_id,
+ })
+ return super().payment_pay(*args, amount=amount, access_token=access_token, **kwargs)
+
+ def _get_custom_rendering_context_values(self, invoice_id=None, **kwargs):
+ """ Override of `payment` to add the invoice id in the custom rendering context values.
+
+ :param int invoice_id: The invoice for which a payment id made, as an `account.move` id.
+ :param dict kwargs: Optional data. This parameter is not used here.
+ :return: The extended rendering context values.
+ :rtype: dict
+ """
+ rendering_context_values = super()._get_custom_rendering_context_values(**kwargs)
+ if invoice_id:
+ rendering_context_values['invoice_id'] = invoice_id
+
+ # Interrupt the payment flow if the invoice has been canceled.
+ invoice_sudo = request.env['account.move'].sudo().browse(invoice_id)
+ if invoice_sudo.state == 'cancel':
+ rendering_context_values['amount'] = 0.0
+
+ return rendering_context_values
+
+ def _create_transaction(self, *args, invoice_id=None, custom_create_values=None, **kwargs):
+ """ Override of `payment` to add the invoice id in the custom create values.
+
+ :param int invoice_id: The invoice for which a payment id made, as an `account.move` id.
+ :param dict custom_create_values: Additional create values overwriting the default ones.
+ :param dict kwargs: Optional data. This parameter is not used here.
+ :return: The result of the parent method.
+ :rtype: recordset of `payment.transaction`
+ """
+ if invoice_id:
+ if custom_create_values is None:
+ custom_create_values = {}
+ custom_create_values['invoice_ids'] = [Command.set([int(invoice_id)])]
+
+ return super()._create_transaction(
+ *args, invoice_id=invoice_id, custom_create_values=custom_create_values, **kwargs
+ )
diff --git a/addons/account_payment/models/__init__.py b/addons/account_payment/models/__init__.py
new file mode 100644
index 00000000000..034d2f7c8b0
--- /dev/null
+++ b/addons/account_payment/models/__init__.py
@@ -0,0 +1,9 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import account_journal
+from . import account_move
+from . import account_payment
+from . import account_payment_method
+from . import account_payment_method_line
+from . import payment_acquirer
+from . import payment_transaction
diff --git a/addons/payment/models/account_journal.py b/addons/account_payment/models/account_journal.py
similarity index 85%
rename from addons/payment/models/account_journal.py
rename to addons/account_payment/models/account_journal.py
index 4dad7fb1702..e9ebcbf8e81 100644
--- a/addons/payment/models/account_journal.py
+++ b/addons/account_payment/models/account_journal.py
@@ -22,12 +22,16 @@ class AccountJournal(models.Model):
self._cr.execute('''
SELECT acquirer.id
- FROM payment_acquirer acquirer
- JOIN account_payment_method apm ON apm.code = acquirer.provider
- LEFT JOIN account_payment_method_line apml ON apm.id = apml.payment_method_id AND apml.journal_id IS NOT NULL
- WHERE acquirer.state IN ('enabled', 'test') AND apm.payment_type = 'inbound'
- AND apml.id IS NULL
- AND acquirer.company_id IN %(company_ids)s
+ FROM payment_acquirer acquirer
+ JOIN account_payment_method apm
+ ON apm.code = acquirer.provider
+ LEFT JOIN account_payment_method_line apml
+ ON apm.id = apml.payment_method_id AND apml.journal_id IS NOT NULL
+ WHERE acquirer.state IN ('enabled', 'test')
+ AND acquirer.provider != 'custom'
+ AND apm.payment_type = 'inbound'
+ AND apml.id IS NULL
+ AND acquirer.company_id IN %(company_ids)s
''', {'company_ids': tuple(self.company_id.ids)})
ids = [r[0] for r in self._cr.fetchall()]
if ids:
diff --git a/addons/payment/models/account_move.py b/addons/account_payment/models/account_move.py
similarity index 88%
rename from addons/payment/models/account_move.py
rename to addons/account_payment/models/account_move.py
index bde2e8dad5c..e14454a47a3 100644
--- a/addons/payment/models/account_move.py
+++ b/addons/account_payment/models/account_move.py
@@ -65,3 +65,13 @@ class AccountMove(models.Model):
action['domain'] = [('id', 'in', self.transaction_ids.ids)]
return action
+
+ def _get_default_payment_link_values(self):
+ self.ensure_one()
+ return {
+ 'description': self.payment_reference,
+ 'amount': self.amount_residual,
+ 'currency_id': self.currency_id.id,
+ 'partner_id': self.partner_id.id,
+ 'amount_max': self.amount_residual,
+ }
diff --git a/addons/payment/models/account_payment.py b/addons/account_payment/models/account_payment.py
similarity index 95%
rename from addons/payment/models/account_payment.py
rename to addons/account_payment/models/account_payment.py
index 869305c7160..1ffbce501ca 100644
--- a/addons/payment/models/account_payment.py
+++ b/addons/account_payment/models/account_payment.py
@@ -43,6 +43,8 @@ class AccountPayment(models.Model):
)
refunds_count = fields.Integer(string="Refunds Count", compute='_compute_refunds_count')
+ #=== COMPUTE METHODS ===#
+
def _compute_amount_available_for_refund(self):
for payment in self:
tx_sudo = payment.payment_transaction_id.sudo()
@@ -97,6 +99,8 @@ class AccountPayment(models.Model):
for payment in self:
payment.refunds_count = data.get(payment.id, 0)
+ #=== ONCHANGE METHODS ===#
+
@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))]
@@ -117,6 +121,8 @@ class AccountPayment(models.Model):
('acquirer_id', '=', self.payment_method_line_id.payment_acquirer_id.id),
], limit=1)
+ #=== ACTION METHODS ===#
+
def action_post(self):
# Post the payments "normally" if no transactions are needed.
# If not, let the acquirer update the state.
@@ -174,6 +180,8 @@ class AccountPayment(models.Model):
action['domain'] = [('source_payment_id', '=', self.id)]
return action
+ #=== BUSINESS METHODS - PAYMENT FLOW ===#
+
def _create_payment_transaction(self, **extra_create_values):
for payment in self:
if payment.payment_transaction_id:
@@ -205,3 +213,11 @@ class AccountPayment(models.Model):
'payment_id': self.id,
**extra_create_values,
}
+
+ def _get_payment_refund_wizard_values(self):
+ self.ensure_one()
+ return {
+ 'transaction_id': self.payment_transaction_id.id,
+ 'payment_amount': self.amount,
+ 'amount_available_for_refund': self.amount_available_for_refund,
+ }
diff --git a/addons/payment_adyen/models/account_payment_method.py b/addons/account_payment/models/account_payment_method.py
similarity index 54%
rename from addons/payment_adyen/models/account_payment_method.py
rename to addons/account_payment/models/account_payment_method.py
index e5344413810..b6f35aefe8a 100644
--- a/addons/payment_adyen/models/account_payment_method.py
+++ b/addons/account_payment/models/account_payment_method.py
@@ -10,5 +10,11 @@ class AccountPaymentMethod(models.Model):
@api.model
def _get_payment_method_information(self):
res = super()._get_payment_method_information()
- res['adyen'] = {'mode': 'unique', 'domain': [('type', '=', 'bank')]}
+ for provider, _desc in self.env['payment.acquirer']._fields['provider'].selection:
+ if provider in ('none', 'transfer'):
+ continue
+ res[provider] = {
+ 'mode': 'unique',
+ 'domain': [('type', '=', 'bank')],
+ }
return res
diff --git a/addons/payment/models/account_payment_method.py b/addons/account_payment/models/account_payment_method_line.py
similarity index 100%
rename from addons/payment/models/account_payment_method.py
rename to addons/account_payment/models/account_payment_method_line.py
diff --git a/addons/account_payment/models/payment_acquirer.py b/addons/account_payment/models/payment_acquirer.py
new file mode 100644
index 00000000000..11031b7545c
--- /dev/null
+++ b/addons/account_payment/models/payment_acquirer.py
@@ -0,0 +1,90 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class PaymentAcquirer(models.Model):
+ _inherit = 'payment.acquirer'
+
+ journal_id = fields.Many2one(
+ string="Payment Journal",
+ help="The journal in which the successful transactions are posted.",
+ comodel_name='account.journal',
+ compute='_compute_journal_id',
+ inverse='_inverse_journal_id',
+ domain='[("type", "=", "bank"), ("company_id", "=", company_id)]',
+ )
+
+ #=== COMPUTE METHODS ===#
+
+ @api.depends('provider')
+ def _compute_journal_id(self):
+ for acquirer in self:
+ payment_method = self.env['account.payment.method.line'].search([
+ ('journal_id.company_id', '=', acquirer.company_id.id),
+ ('code', '=', acquirer.provider)
+ ], limit=1)
+ if payment_method:
+ acquirer.journal_id = payment_method.journal_id
+ else:
+ acquirer.journal_id = False
+
+ def _inverse_journal_id(self):
+ for acquirer in self:
+ payment_method_line = self.env['account.payment.method.line'].search([
+ ('journal_id.company_id', '=', acquirer.company_id.id),
+ ('code', '=', acquirer.provider),
+ ], limit=1)
+ if acquirer.journal_id:
+ if not payment_method_line:
+ default_payment_method_id = acquirer._get_default_payment_method_id(
+ acquirer.provider
+ )
+ existing_payment_method_line = self.env['account.payment.method.line'].search([
+ ('payment_method_id', '=', default_payment_method_id),
+ ('journal_id', '=', acquirer.journal_id.id),
+ ], limit=1)
+ if not existing_payment_method_line:
+ self.env['account.payment.method.line'].create({
+ 'payment_method_id': default_payment_method_id,
+ 'journal_id': acquirer.journal_id.id,
+ })
+ else:
+ payment_method_line.journal_id = acquirer.journal_id
+ elif payment_method_line:
+ payment_method_line.unlink()
+
+ @api.model
+ def _get_default_payment_method_id(self, provider):
+ provider_payment_method = self._get_provider_payment_method(provider)
+ if provider_payment_method:
+ return provider_payment_method.id
+ return self.env.ref('account.account_payment_method_manual_in').id
+
+ @api.model
+ def _get_provider_payment_method(self, provider):
+ return self.env['account.payment.method'].search([('code', '=', provider)], limit=1)
+
+ #=== BUSINESS METHODS ===#
+
+ @api.model
+ def _setup_provider(self, provider_code):
+ """ Override of `payment` to create the payment method of the provider. """
+ super()._setup_provider(provider_code)
+ self._setup_payment_method(provider_code)
+
+ @api.model
+ def _setup_payment_method(self, provider):
+ if provider not in ('none', 'transfer') and not self._get_provider_payment_method(provider):
+ providers_description = dict(self._fields['provider']._description_selection(self.env))
+ self.env['account.payment.method'].create({
+ 'name': providers_description[provider],
+ 'code': provider,
+ 'payment_type': 'inbound',
+ })
+
+ @api.model
+ def _remove_provider(self, provider):
+ """ Override of `payment` to delete the payment method of the provider. """
+ super()._remove_provider(provider)
+ self._get_provider_payment_method(provider).unlink()
diff --git a/addons/account_payment/models/payment_transaction.py b/addons/account_payment/models/payment_transaction.py
new file mode 100644
index 00000000000..e06d935b080
--- /dev/null
+++ b/addons/account_payment/models/payment_transaction.py
@@ -0,0 +1,205 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+
+
+class PaymentTransaction(models.Model):
+ _inherit = 'payment.transaction'
+
+ payment_id = fields.Many2one(
+ string="Payment", comodel_name='account.payment', readonly=True)
+
+ 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,
+ domain=[('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))])
+ invoices_count = fields.Integer(string="Invoices Count", compute='_compute_invoices_count')
+
+ #=== COMPUTE METHODS ===#
+
+ @api.depends('invoice_ids')
+ def _compute_invoices_count(self):
+ self.env.cr.execute(
+ '''
+ SELECT transaction_id, count(invoice_id)
+ FROM account_invoice_transaction_rel
+ WHERE transaction_id IN %s
+ GROUP BY transaction_id
+ ''',
+ [tuple(self.ids)]
+ )
+ tx_data = dict(self.env.cr.fetchall()) # {id: count}
+ for tx in self:
+ tx.invoices_count = tx_data.get(tx.id, 0)
+
+ #=== ACTION METHODS ===#
+
+ def action_view_invoices(self):
+ """ Return the action for the views of the invoices linked to the transaction.
+
+ Note: self.ensure_one()
+
+ :return: The action
+ :rtype: dict
+ """
+ self.ensure_one()
+
+ action = {
+ 'name': _("Invoices"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ 'target': 'current',
+ }
+ invoice_ids = self.invoice_ids.ids
+ if len(invoice_ids) == 1:
+ invoice = invoice_ids[0]
+ action['res_id'] = invoice
+ action['view_mode'] = 'form'
+ action['views'] = [(self.env.ref('account.view_move_form').id, 'form')]
+ else:
+ action['view_mode'] = 'tree,form'
+ action['domain'] = [('id', 'in', invoice_ids)]
+ return action
+
+ #=== BUSINESS METHODS - PAYMENT FLOW ===#
+
+ @api.model
+ def _compute_reference_prefix(self, provider, separator, **values):
+ """ Compute the reference prefix from the transaction values.
+
+ If the `values` parameter has an entry with 'invoice_ids' as key and a list of (4, id, O) or
+ (6, 0, ids) X2M command as value, the prefix is computed based on the invoice name(s).
+ Otherwise, an empty string is returned.
+
+ Note: This method should be called in sudo mode to give access to documents (INV, SO, ...).
+
+ :param str provider: The provider of the acquirer handling the transaction
+ :param str separator: The custom separator used to separate data references
+ :param dict values: The transaction values used to compute the reference prefix. It should
+ have the structure {'invoice_ids': [(X2M command), ...], ...}.
+ :return: The computed reference prefix if invoice ids are found, an empty string otherwise
+ :rtype: str
+ """
+ command_list = values.get('invoice_ids')
+ if command_list:
+ # Extract invoice id(s) from the X2M commands
+ invoice_ids = self._fields['invoice_ids'].convert_to_cache(command_list, self)
+ invoices = self.env['account.move'].browse(invoice_ids).exists()
+ if len(invoices) == len(invoice_ids): # All ids are valid
+ return separator.join(invoices.mapped('name'))
+ return super()._compute_reference_prefix(provider, separator, **values)
+
+ def _set_canceled(self, state_message=None):
+ """ Update the transactions' state to 'cancel'.
+
+ :param str state_message: The reason for which the transaction is set in 'cancel' state
+ :return: updated transactions
+ :rtype: `payment.transaction` recordset
+ """
+ processed_txs = super()._set_canceled(state_message)
+ # Cancel the existing payments
+ processed_txs.payment_id.action_cancel()
+ return processed_txs
+
+ #=== BUSINESS METHODS - POST-PROCESSING ===#
+
+ def _reconcile_after_done(self):
+ """ Post relevant fiscal documents and create missing payments.
+
+ As there is nothing to reconcile for validation transactions, no payment is created for
+ them. This is also true for validations with a validity check (transfer of a small amount
+ with immediate refund) because validation amounts are not included in payouts.
+
+ :return: None
+ """
+ super()._reconcile_after_done()
+
+ # Validate invoices automatically once the transaction is confirmed
+ self.invoice_ids.filtered(lambda inv: inv.state == 'draft').action_post()
+
+ # Create and post missing payments for transactions requiring reconciliation
+ for tx in self.filtered(lambda t: t.operation != 'validation' and not t.payment_id):
+ tx._create_payment()
+
+ def _create_payment(self, **extra_create_values):
+ """Create an `account.payment` record for the current transaction.
+
+ If the transaction is linked to some invoices, their reconciliation is done automatically.
+
+ Note: self.ensure_one()
+
+ :param dict extra_create_values: Optional extra create values
+ :return: The created payment
+ :rtype: recordset of `account.payment`
+ """
+ self.ensure_one()
+
+ payment_method_line = self.acquirer_id.journal_id.inbound_payment_method_line_ids\
+ .filtered(lambda l: l.code == self.provider)
+ payment_values = {
+ '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,
+ 'partner_type': 'customer',
+ 'journal_id': self.acquirer_id.journal_id.id,
+ 'company_id': self.acquirer_id.company_id.id,
+ 'payment_method_line_id': payment_method_line.id,
+ 'payment_token_id': self.token_id.id,
+ 'payment_transaction_id': self.id,
+ 'ref': self.reference,
+ **extra_create_values,
+ }
+ payment = self.env['account.payment'].create(payment_values)
+ payment.action_post()
+
+ # Track the payment to make a one2one.
+ self.payment_id = payment
+
+ if self.invoice_ids:
+ self.invoice_ids.filtered(lambda inv: inv.state == 'draft').action_post()
+
+ (payment.line_ids + self.invoice_ids.line_ids).filtered(
+ lambda line: line.account_id == payment.destination_account_id
+ and not line.reconciled
+ ).reconcile()
+
+ return payment
+
+ #=== BUSINESS METHODS - LOGGING ===#
+
+ def _log_message_on_linked_documents(self, message):
+ """ 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.
+
+ Note: self.ensure_one()
+
+ :param str message: The message to be logged
+ :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)
+
+ #=== BUSINESS METHODS - GETTERS ===#
+
+ def _get_received_message(self):
+ """ Return the message stating that the transaction has been received by the provider.
+
+ Note: self.ensure_one()
+ """
+ message = super()._get_received_message()
+
+ if self.state == 'done' and self.payment_id:
+ message += "
" + _(
+ "The related payment is posted: %s",
+ self.payment_id._get_html_link()
+ )
+
+ return message
diff --git a/addons/account_payment/security/ir.model.access.csv b/addons/account_payment/security/ir.model.access.csv
new file mode 100644
index 00000000000..258607caf6d
--- /dev/null
+++ b/addons/account_payment/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+payment_link_wizard,payment.link.wizard,payment.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
diff --git a/addons/account_payment/security/ir_rules.xml b/addons/account_payment/security/ir_rules.xml
new file mode 100644
index 00000000000..c3f4dd9c812
--- /dev/null
+++ b/addons/account_payment/security/ir_rules.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ Access every transaction
+
+
+ [(1, '=', 1)]
+
+
+
+
+
+
+ Access every token
+
+
+ [(1, '=', 1)]
+
+
+
+
diff --git a/addons/account_payment/static/src/js/payment_form.js b/addons/account_payment/static/src/js/payment_form.js
new file mode 100644
index 00000000000..1680562384b
--- /dev/null
+++ b/addons/account_payment/static/src/js/payment_form.js
@@ -0,0 +1,36 @@
+odoo.define('account_payment.payment_form', require => {
+ 'use strict';
+
+ const checkoutForm = require('payment.checkout_form');
+ const manageForm = require('payment.manage_form');
+
+ const PaymentMixin = {
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add `invoice_id` to the transaction route params if it is provided.
+ *
+ * @override method from payment.payment_form_mixin
+ * @private
+ * @param {string} provider - The provider of the selected payment option's acquirer.
+ * @param {number} paymentOptionId - The id of the selected payment option.
+ * @param {string} flow - The online payment flow of the selected payment option.
+ * @return {object} The extended transaction route params.
+ */
+ _prepareTransactionRouteParams: function (provider, paymentOptionId, flow) {
+ const transactionRouteParams = this._super(...arguments);
+ return {
+ ...transactionRouteParams,
+ 'invoice_id': this.txContext.invoiceId ? parseInt(this.txContext.invoiceId) : null,
+ };
+ },
+
+ };
+
+ checkoutForm.include(PaymentMixin);
+ manageForm.include(PaymentMixin);
+
+});
diff --git a/addons/account_payment/tests/__init__.py b/addons/account_payment/tests/__init__.py
new file mode 100644
index 00000000000..1d76b89d55c
--- /dev/null
+++ b/addons/account_payment/tests/__init__.py
@@ -0,0 +1,4 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_account_payment
+from . import test_payment_flows
diff --git a/addons/account_payment/tests/common.py b/addons/account_payment/tests/common.py
new file mode 100644
index 00000000000..3529aa7e274
--- /dev/null
+++ b/addons/account_payment/tests/common.py
@@ -0,0 +1,82 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from unittest.mock import patch
+
+from odoo.addons.account.models.account_payment_method import AccountPaymentMethod
+from odoo.addons.account.tests.common import AccountTestInvoicingCommon
+from odoo.addons.payment.tests.common import PaymentCommon
+
+
+class AccountPaymentCommon(PaymentCommon, AccountTestInvoicingCommon):
+
+ @classmethod
+ def setUpClass(cls, *kw):
+ # chart_template_ref is dropped on purpose because not needed for account_payment tests.
+ super().setUpClass()
+
+ Method_get_payment_method_information = AccountPaymentMethod._get_payment_method_information
+
+ def _get_payment_method_information(self):
+ res = Method_get_payment_method_information(self)
+ res['none'] = {'mode': 'multi', 'domain': [('type', '=', 'bank')]}
+ return res
+
+ with patch.object(AccountPaymentMethod, '_get_payment_method_information', _get_payment_method_information):
+ cls.env['account.payment.method'].create({
+ 'name': 'Dummy method',
+ 'code': 'none',
+ 'payment_type': 'inbound'
+ })
+
+ cls.dummy_acquirer.journal_id = cls.company_data['default_journal_bank'].id,
+
+ cls.account = cls.company.account_journal_payment_credit_account_id
+ cls.invoice = cls.env['account.move'].create({
+ 'move_type': 'entry',
+ 'date': '2019-01-01',
+ 'currency_id': cls.currency_euro.id,
+ 'partner_id': cls.partner.id,
+ 'line_ids': [
+ (0, 0, {
+ 'account_id': cls.account.id,
+ 'debit': 100.0,
+ 'credit': 0.0,
+ 'amount_currency': 200.0,
+ }),
+ (0, 0, {
+ 'account_id': cls.account.id,
+ 'debit': 0.0,
+ 'credit': 100.0,
+ 'amount_currency': -200.0,
+ }),
+ ],
+ })
+
+ def setUp(self):
+ super().setUp()
+ # Disable _reconcile_after_done patcher
+ self.reconcile_after_done_patcher.stop()
+
+ #=== Utils ===#
+
+ @classmethod
+ def _prepare_acquirer(cls, provider='none', company=None, update_values=None):
+ """ Override of `payment` to prepare and return the first acquirer matching the given
+ provider and company.
+
+ If no acquirer is found in the given company, we duplicate the one from the base company.
+ All other acquirers belonging to the same company are disabled to avoid any interferences.
+
+ :param str provider: The provider of the acquirer to prepare.
+ :param recordset company: The company of the acquirer to prepare, as a `res.company` record.
+ :param dict update_values: The values used to update the acquirer.
+ :return: The acquirer to prepare, if found.
+ :rtype: recordset of `payment.acquirer`
+ """
+ acquirer = super()._prepare_acquirer(provider, company, update_values)
+ if not acquirer.journal_id:
+ acquirer.journal_id = cls.env['account.journal'].search(
+ [('company_id', '=', acquirer.company_id.id), ('type', '=', 'bank')],
+ limit=1,
+ )
+ return acquirer
diff --git a/addons/payment/tests/test_account_payment.py b/addons/account_payment/tests/test_account_payment.py
similarity index 91%
rename from addons/payment/tests/test_account_payment.py
rename to addons/account_payment/tests/test_account_payment.py
index 47dce31381c..1734eb2fd3a 100644
--- a/addons/payment/tests/test_account_payment.py
+++ b/addons/account_payment/tests/test_account_payment.py
@@ -4,11 +4,11 @@ from unittest.mock import patch
from odoo.tests import tagged
-from odoo.addons.payment.tests.common import PaymentCommon
+from odoo.addons.account_payment.tests.common import AccountPaymentCommon
@tagged('-at_install', 'post_install')
-class TestAccountPayment(PaymentCommon):
+class TestAccountPayment(AccountPaymentCommon):
def test_no_amount_available_for_refund_when_not_supported(self):
self.acquirer.support_refund = False
@@ -141,3 +141,11 @@ class TestAccountPayment(PaymentCommon):
patched.assert_not_called()
payment_with_token.action_post()
patched.assert_called_once()
+
+ 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, msg="validation transactions should not create payments")
diff --git a/addons/account_payment/tests/test_payment_flows.py b/addons/account_payment/tests/test_payment_flows.py
new file mode 100644
index 00000000000..b5c76706d1d
--- /dev/null
+++ b/addons/account_payment/tests/test_payment_flows.py
@@ -0,0 +1,47 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import tagged
+from odoo.tools import mute_logger
+
+from odoo.addons.payment.tests.http_common import PaymentHttpCommon
+from odoo.addons.account_payment.tests.common import AccountPaymentCommon
+
+
+@tagged('post_install', '-at_install')
+class TestFlows(AccountPaymentCommon, PaymentHttpCommon):
+
+ def test_invoice_payment_flow(self):
+ """Test the payment of an invoice through the payment/pay route"""
+
+ # Pay for this invoice (no impact even if amounts do not match)
+ route_values = self._prepare_pay_values()
+ route_values['invoice_id'] = self.invoice.id
+ tx_context = self._get_tx_checkout_context(**route_values)
+ self.assertEqual(tx_context['invoice_id'], self.invoice.id)
+
+ # payment/transaction
+ route_values = {
+ k: tx_context[k]
+ for k in [
+ 'amount',
+ 'currency_id',
+ 'reference_prefix',
+ 'partner_id',
+ 'access_token',
+ 'landing_route',
+ 'invoice_id',
+ ]
+ }
+ route_values.update({
+ 'flow': 'direct',
+ 'payment_option_id': self.acquirer.id,
+ 'tokenization_requested': False,
+ })
+ with mute_logger('odoo.addons.payment.models.payment_transaction'):
+ processing_values = self._get_processing_values(**route_values)
+ tx_sudo = self._get_tx(processing_values['reference'])
+ # Note: strangely, the check
+ # self.assertEqual(tx_sudo.invoice_ids, invoice)
+ # doesn't work, and cache invalidation doesn't work either.
+ self.invoice.invalidate_recordset(['transaction_ids'])
+ self.assertEqual(self.invoice.transaction_ids, tx_sudo)
diff --git a/addons/payment/views/account_journal_views.xml b/addons/account_payment/views/account_journal_views.xml
similarity index 100%
rename from addons/payment/views/account_journal_views.xml
rename to addons/account_payment/views/account_journal_views.xml
diff --git a/addons/payment/views/account_invoice_views.xml b/addons/account_payment/views/account_move_views.xml
similarity index 100%
rename from addons/payment/views/account_invoice_views.xml
rename to addons/account_payment/views/account_move_views.xml
diff --git a/addons/account_payment/views/account_payment_menus.xml b/addons/account_payment/views/account_payment_menus.xml
new file mode 100644
index 00000000000..ed3eba41bd4
--- /dev/null
+++ b/addons/account_payment/views/account_payment_menus.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
diff --git a/addons/payment/views/account_payment_views.xml b/addons/account_payment/views/account_payment_views.xml
similarity index 96%
rename from addons/payment/views/account_payment_views.xml
rename to addons/account_payment/views/account_payment_views.xml
index da613dcf30b..e8ff1031f76 100644
--- a/addons/payment/views/account_payment_views.xml
+++ b/addons/account_payment/views/account_payment_views.xml
@@ -8,8 +8,8 @@
-
-
-
diff --git a/addons/payment/views/payment_icon_views.xml b/addons/payment/views/payment_icon_views.xml
index 1e745d5932e..ab9feaecd3d 100644
--- a/addons/payment/views/payment_icon_views.xml
+++ b/addons/payment/views/payment_icon_views.xml
@@ -43,9 +43,4 @@
-
-
diff --git a/addons/payment/views/payment_templates.xml b/addons/payment/views/payment_templates.xml
index 718287b6f04..db473bba442 100644
--- a/addons/payment/views/payment_templates.xml
+++ b/addons/payment/views/payment_templates.xml
@@ -17,7 +17,6 @@
- 'transaction_route' - The route used to create a transaction when the user clicks Pay
- 'landing_route' - The route the user is redirected to after the transaction
- 'footer_template_id' - The template id for the submit button. Optional
- - 'invoice_id' - The id of the account move being paid. Optional
-->