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/account_payment/wizards/__init__.py b/addons/account_payment/wizards/__init__.py new file mode 100644 index 00000000000..e70f90e09ce --- /dev/null +++ b/addons/account_payment/wizards/__init__.py @@ -0,0 +1,5 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_payment_register +from . import payment_link_wizard +from . import payment_refund_wizard diff --git a/addons/payment/wizards/account_payment_register.py b/addons/account_payment/wizards/account_payment_register.py similarity index 100% rename from addons/payment/wizards/account_payment_register.py rename to addons/account_payment/wizards/account_payment_register.py diff --git a/addons/payment/wizards/account_payment_register_views.xml b/addons/account_payment/wizards/account_payment_register_views.xml similarity index 100% rename from addons/payment/wizards/account_payment_register_views.xml rename to addons/account_payment/wizards/account_payment_register_views.xml diff --git a/addons/account_payment/wizards/payment_link_wizard.py b/addons/account_payment/wizards/payment_link_wizard.py new file mode 100644 index 00000000000..b7fa42c3347 --- /dev/null +++ b/addons/account_payment/wizards/payment_link_wizard.py @@ -0,0 +1,17 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class PaymentLinkWizard(models.TransientModel): + _inherit = 'payment.link.wizard' + + def _get_additional_link_values(self): + res = super()._get_additional_link_values() + if self.res_model != 'account.move': + return res + + # Invoice-related fields are retrieved in the controller. + return { + 'invoice_id': self.res_id, + } diff --git a/addons/account_payment/wizards/payment_link_wizard_views.xml b/addons/account_payment/wizards/payment_link_wizard_views.xml new file mode 100644 index 00000000000..7215161c841 --- /dev/null +++ b/addons/account_payment/wizards/payment_link_wizard_views.xml @@ -0,0 +1,14 @@ + + + + + Generate a Payment Link + payment.link.wizard + form + + new + + form + + + diff --git a/addons/payment/wizards/payment_refund_wizard.py b/addons/account_payment/wizards/payment_refund_wizard.py similarity index 100% rename from addons/payment/wizards/payment_refund_wizard.py rename to addons/account_payment/wizards/payment_refund_wizard.py diff --git a/addons/payment/wizards/payment_refund_wizard_views.xml b/addons/account_payment/wizards/payment_refund_wizard_views.xml similarity index 100% rename from addons/payment/wizards/payment_refund_wizard_views.xml rename to addons/account_payment/wizards/payment_refund_wizard_views.xml diff --git a/addons/payment/__init__.py b/addons/payment/__init__.py index 4ae08884582..3c720f37f45 100644 --- a/addons/payment/__init__.py +++ b/addons/payment/__init__.py @@ -8,10 +8,11 @@ from . import wizards from odoo import api, SUPERUSER_ID +def setup_provider(cr, registry, provider_code): + env = api.Environment(cr, SUPERUSER_ID, {}) + env['payment.acquirer']._setup_provider(provider_code) + + def reset_payment_acquirer(cr, registry, provider): env = api.Environment(cr, SUPERUSER_ID, {}) - acquirers = env['payment.acquirer'].search([('provider', '=', provider)]) - acquirers.write({ - 'provider': 'none', - 'state': 'disabled', - }) + env['payment.acquirer']._remove_provider(provider) diff --git a/addons/payment/__manifest__.py b/addons/payment/__manifest__.py index 42108fa8045..e503a75623b 100644 --- a/addons/payment/__manifest__.py +++ b/addons/payment/__manifest__.py @@ -6,7 +6,7 @@ 'category': 'Hidden', 'summary': 'Base Module for Payment Acquirers', 'description': """Payment Acquirer Base Module""", - 'depends': ['account'], + 'depends': ['portal'], 'data': [ 'data/payment_icon_data.xml', 'data/payment_acquirer_data.xml', @@ -15,9 +15,6 @@ 'views/payment_portal_templates.xml', 'views/payment_templates.xml', - 'views/account_invoice_views.xml', - 'views/account_journal_views.xml', - 'views/account_payment_views.xml', 'views/payment_acquirer_views.xml', 'views/payment_icon_views.xml', 'views/payment_transaction_views.xml', @@ -27,12 +24,12 @@ 'security/ir.model.access.csv', 'security/payment_security.xml', - 'wizards/account_payment_register_views.xml', 'wizards/payment_link_wizard_views.xml', 'wizards/payment_onboarding_views.xml', - 'wizards/payment_refund_wizard_views.xml', ], - 'auto_install': True, + 'demo': [ + 'data/payment_demo.xml', + ], 'assets': { 'web.assets_frontend': [ 'payment/static/src/scss/portal_payment.scss', diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index 08d0207f5bc..13714f85f67 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -39,7 +39,7 @@ class PaymentPortal(portal.CustomerPortal): ) def payment_pay( self, reference=None, amount=None, currency_id=None, partner_id=None, company_id=None, - acquirer_id=None, access_token=None, invoice_id=None, **kwargs + acquirer_id=None, access_token=None, **kwargs ): """ Display the payment form with optional filtering of payment options. @@ -60,15 +60,14 @@ class PaymentPortal(portal.CustomerPortal): :param str company_id: The related company, as a `res.company` id :param str acquirer_id: The desired acquirer, as a `payment.acquirer` id :param str access_token: The access token used to authenticate the partner - :param str invoice_id: The account move for which a payment id made, as a `account.move` id :param dict kwargs: Optional data passed to helper methods. :return: The rendered checkout form :rtype: str :raise: werkzeug.exceptions.NotFound if the access token is invalid """ # Cast numeric parameters as int or float and void them if their str value is malformed - currency_id, acquirer_id, partner_id, company_id, invoice_id = tuple(map( - self._cast_as_int, (currency_id, acquirer_id, partner_id, company_id, invoice_id) + currency_id, acquirer_id, partner_id, company_id = tuple(map( + self._cast_as_int, (currency_id, acquirer_id, partner_id, company_id) )) amount = self._cast_as_float(amount) @@ -103,15 +102,6 @@ class PaymentPortal(portal.CustomerPortal): company = request.env['res.company'].sudo().browse(company_id) currency_id = currency_id or company.currency_id.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.")) - - # Interrupt the payment flow if the invoice has been canceled. - if invoice_sudo.state == 'cancel': - amount = 0.0 - # Make sure that the company passed as parameter matches the partner's company. PaymentPortal._ensure_matching_companies(partner_sudo, company) @@ -155,7 +145,6 @@ class PaymentPortal(portal.CustomerPortal): 'landing_route': '/payment/confirmation', 'res_company': company, # Display the correct logo in a multi-company environment 'partner_is_different': partner_is_different, - 'invoice_id': invoice_id, **self._get_custom_rendering_context_values(**kwargs), } return request.render(self._get_payment_page_template_xmlid(**kwargs), rendering_context) @@ -257,7 +246,7 @@ class PaymentPortal(portal.CustomerPortal): def _create_transaction( self, payment_option_id, reference_prefix, amount, currency_id, partner_id, flow, - tokenization_requested, landing_route, is_validation=False, invoice_id=None, + tokenization_requested, landing_route, is_validation=False, custom_create_values=None, **kwargs ): """ Create a draft transaction based on the payment context and return it. @@ -274,7 +263,6 @@ class PaymentPortal(portal.CustomerPortal): :param bool tokenization_requested: Whether the user requested that a token is created :param str landing_route: The route the user is redirected to after the transaction :param bool is_validation: Whether the operation is a validation - :param int invoice_id: The account move 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: Locally unused data passed to `_is_tokenization_required` and `_compute_reference` @@ -310,11 +298,6 @@ class PaymentPortal(portal.CustomerPortal): _("The payment should either be direct, with redirection, or made by a token.") ) - if invoice_id: - if custom_create_values is None: - custom_create_values = {} - custom_create_values['invoice_ids'] = [Command.set([int(invoice_id)])] - reference = request.env['payment.transaction']._compute_reference( acquirer_sudo.provider, prefix=reference_prefix, diff --git a/addons/payment/data/payment_demo.xml b/addons/payment/data/payment_demo.xml new file mode 100644 index 00000000000..479c5fa083d --- /dev/null +++ b/addons/payment/data/payment_demo.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/addons/payment/models/__init__.py b/addons/payment/models/__init__.py index 5fb64772c28..7489c9b9c3f 100644 --- a/addons/payment/models/__init__.py +++ b/addons/payment/models/__init__.py @@ -1,8 +1,5 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import account_payment -from . import account_payment_method -from . import account_move from . import ir_http from . import ir_ui_view from . import payment_acquirer @@ -11,4 +8,3 @@ from . import payment_token from . import payment_transaction from . import res_company from . import res_partner -from . import account_journal diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index a6ae0482a19..edec41b3b0e 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -81,11 +81,6 @@ class PaymentAcquirer(models.Model): help="The template rendering the express payment methods' form.", domain=[('type', '=', 'qweb')], ) - journal_id = fields.Many2one( - string="Payment Journal", comodel_name='account.journal', - compute='_compute_journal_id', inverse='_inverse_journal_id', - help="The journal in which the successful transactions are posted", - domain="[('type', '=', 'bank'), ('company_id', '=', company_id)]") # Availability fields. available_country_ids = fields.Many2many( @@ -219,40 +214,6 @@ class PaymentAcquirer(models.Model): 'show_cancel_msg': True, }) - 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() - 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() - def _compute_feature_support_fields(self): """ Compute the feature support fields. @@ -268,10 +229,6 @@ class PaymentAcquirer(models.Model): 'support_tokenization', ), None)) - def _get_default_payment_method_id(self): - self.ensure_one() - return self.env.ref('account.account_payment_method_manual_in').id - #=== ONCHANGE METHODS ===# @api.onchange('state') @@ -558,7 +515,7 @@ class PaymentAcquirer(models.Model): :rtype: recordset of `res.currency` """ self.ensure_one() - return self.journal_id.currency_id or self.company_id.currency_id + return self.company_id.currency_id def _get_redirect_form_view(self, is_validation=False): """ Return the view of the template used to render the redirect form. @@ -575,6 +532,30 @@ class PaymentAcquirer(models.Model): self.ensure_one() return self.redirect_form_view_id + @api.model + def _setup_provider(self, provider_code): + """ Prepare module-specific data for a given provider. + + This method is called after a new provider module is installed and also for all existing + providers when `account_payment` is installed. + + :param str provider_code: The code of the provider to setup. + :return: None + """ + + @api.model + def _remove_provider(self, provider_code): + """ Clean module-specific data for a given provider. + + :param str provider_code: The code of the provider to setup. + :return: None + """ + acquirers = self.search([('provider', '=', provider_code)]) + acquirers.write({ + 'provider': 'none', + 'state': 'disabled', + }) + def _neutralize(self): super()._neutralize() self.flush_model() diff --git a/addons/payment/models/payment_token.py b/addons/payment/models/payment_token.py index 82f38414370..84e93f3c5a1 100644 --- a/addons/payment/models/payment_token.py +++ b/addons/payment/models/payment_token.py @@ -5,8 +5,6 @@ import logging from odoo import _, api, fields, models from odoo.exceptions import UserError -_logger = logging.getLogger(__name__) - class PaymentToken(models.Model): _name = 'payment.token' diff --git a/addons/payment/models/payment_transaction.py b/addons/payment/models/payment_transaction.py index e5b08fcc5a0..894006e5e33 100644 --- a/addons/payment/models/payment_transaction.py +++ b/addons/payment/models/payment_transaction.py @@ -75,7 +75,6 @@ class PaymentTransaction(models.Model): 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', @@ -90,11 +89,6 @@ class PaymentTransaction(models.Model): 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, - domain=[('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))]) - invoices_count = fields.Integer(string="Invoices Count", compute='_compute_invoices_count') # Fields used for user redirection & payment post-processing is_post_processed = fields.Boolean( @@ -135,21 +129,6 @@ class PaymentTransaction(models.Model): #=== 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) - def _compute_refunds_count(self): rg_data = self.env['payment.transaction']._read_group( domain=[('source_transaction_id', 'in', self.ids), ('operation', '=', 'refund')], @@ -256,33 +235,6 @@ class PaymentTransaction(models.Model): #=== 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 - def action_view_refunds(self): """ Return the action for the views of the refund transactions linked to the transaction. @@ -423,26 +375,14 @@ class PaymentTransaction(models.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 + :param dict values: The transaction values used to compute the reference prefix. + :return: an empty string :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 '' @api.model @@ -689,57 +629,67 @@ class PaymentTransaction(models.Model): """ Update the transactions' state to 'pending'. :param str state_message: The reason for which the transaction is set in 'pending' state - :return: None + :return: updated transactions + :rtype: `payment.transaction` recordset """ allowed_states = ('draft',) target_state = 'pending' txs_to_process = self._update_state(allowed_states, target_state, state_message) txs_to_process._log_received_message() + return txs_to_process def _set_authorized(self, state_message=None): """ Update the transactions' state to 'authorized'. :param str state_message: The reason for which the transaction is set in 'authorized' state - :return: None + :return: updated transactions + :rtype: `payment.transaction` recordset """ allowed_states = ('draft', 'pending') target_state = 'authorized' txs_to_process = self._update_state(allowed_states, target_state, state_message) txs_to_process._log_received_message() + return txs_to_process def _set_done(self, state_message=None): """ Update the transactions' state to 'done'. - :return: None + :param str state_message: The reason for which the transaction is set in 'done' state + :return: updated transactions + :rtype: `payment.transaction` recordset """ allowed_states = ('draft', 'pending', 'authorized', 'error') target_state = 'done' txs_to_process = self._update_state(allowed_states, target_state, state_message) txs_to_process._log_received_message() + return txs_to_process 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: None + :return: updated transactions + :rtype: `payment.transaction` recordset """ allowed_states = ('draft', 'pending', 'authorized', 'done') # 'done' for Authorize refunds. target_state = 'cancel' txs_to_process = self._update_state(allowed_states, target_state, state_message) # Cancel the existing payments - txs_to_process.mapped('payment_id').action_cancel() txs_to_process._log_received_message() + return txs_to_process def _set_error(self, state_message): """ Update the transactions' state to 'error'. :param str state_message: The reason for which the transaction is set in 'error' state - :return: None + :return: updated transactions + :rtype: `payment.transaction` recordset """ allowed_states = ('draft', 'pending', 'authorized', 'done') # 'done' for Stripe refunds. target_state = 'error' txs_to_process = self._update_state(allowed_states, target_state, state_message) txs_to_process._log_received_message() + return txs_to_process def _update_state(self, allowed_states, target_state, state_message): """ Update the transactions' state to the target state if the current state allows it. @@ -938,57 +888,7 @@ class PaymentTransaction(models.Model): :return: None """ - # 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 + return #=== BUSINESS METHODS - LOGGING ===# @@ -1014,7 +914,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 payment and the invoices linked to the transaction. + """ Log a message on the records 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. @@ -1025,12 +925,6 @@ 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) #=== BUSINESS METHODS - GETTERS ===# @@ -1099,11 +993,6 @@ class PaymentTransaction(models.Model): "(%(acq_name)s).", ref=self.reference, amount=formatted_amount, acq_name=self.acquirer_id.name ) - if self.payment_id: - message += "
" + _( - "The related payment is posted: %s", - self.payment_id._get_html_link(), - ) elif self.state == 'error': message = _( "The transaction with reference %(ref)s for %(amount)s encountered an error" diff --git a/addons/payment/security/ir.model.access.csv b/addons/payment/security/ir.model.access.csv index 34ebfe50a30..d4e36771387 100644 --- a/addons/payment/security/ir.model.access.csv +++ b/addons/payment/security/ir.model.access.csv @@ -1,10 +1,9 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_payment_link_wizard,access_payment_link_wizard,payment.model_payment_link_wizard,base.group_user,0,0,0,0 payment_acquirer_onboarding_wizard,payment.acquirer.onboarding.wizard,model_payment_acquirer_onboarding_wizard,base.group_system,1,1,1,0 payment_acquirer_system,payment.acquirer.system,model_payment_acquirer,base.group_system,1,1,1,1 payment_icon_all,payment.icon.all,model_payment_icon,,1,0,0,0 payment_icon_system,payment.icon.system,model_payment_icon,base.group_system,1,1,1,1 -payment_link_wizard,payment.link.wizard,model_payment_link_wizard,account.group_account_user,1,1,1,0 -payment_refund_wizard,payment.refund.wizard,model_payment_refund_wizard,account.group_account_invoice,1,1,1,0 payment_token_all,payment.token.all,model_payment_token,,1,0,0,0 payment_token_portal,payment.token.portal,model_payment_token,base.group_portal,1,1,1,1 payment_token_system,payment.token.system,model_payment_token,base.group_system,1,1,1,1 diff --git a/addons/payment/security/payment_security.xml b/addons/payment/security/payment_security.xml index d091cf8d794..30c93907f31 100644 --- a/addons/payment/security/payment_security.xml +++ b/addons/payment/security/payment_security.xml @@ -15,15 +15,7 @@ Access own transactions only ['|', ('partner_id', '=', False), ('partner_id', '=', user.partner_id.id)] - - - - - Access every transaction - - - [(1, '=', 1)] - + @@ -41,14 +33,6 @@ - - Access every token - - - [(1, '=', 1)] - - - Access tokens in own companies only diff --git a/addons/payment/static/src/js/payment_form_mixin.js b/addons/payment/static/src/js/payment_form_mixin.js index 50fdf44de16..ea84b31b4e2 100644 --- a/addons/payment/static/src/js/payment_form_mixin.js +++ b/addons/payment/static/src/js/payment_form_mixin.js @@ -299,8 +299,6 @@ odoo.define('payment.payment_form_mixin', require => { 'currency_id': this.txContext.currencyId ? parseInt(this.txContext.currencyId) : null, 'partner_id': parseInt(this.txContext.partnerId), - 'invoice_id': this.txContext.invoiceId - ? parseInt(this.txContext.invoiceId) : null, 'flow': flow, 'tokenization_requested': this.txContext.tokenizationRequested, 'landing_route': this.txContext.landingRoute, diff --git a/addons/payment/tests/__init__.py b/addons/payment/tests/__init__.py index 61d0926039d..ffe0a5454dd 100644 --- a/addons/payment/tests/__init__.py +++ b/addons/payment/tests/__init__.py @@ -2,7 +2,6 @@ from . import common from . import http_common -from . import test_account_payment from . import test_flows from . import test_multicompany_flows from . import test_payment_acquirer diff --git a/addons/payment/tests/common.py b/addons/payment/tests/common.py index f5911183881..334be91d13e 100644 --- a/addons/payment/tests/common.py +++ b/addons/payment/tests/common.py @@ -6,26 +6,17 @@ from unittest.mock import patch from lxml import objectify from odoo.fields import Command +from odoo.tests.common import TransactionCase from odoo.tools.misc import hmac as hmac_tool -from odoo.addons.account.models.account_payment_method import AccountPaymentMethod -from odoo.addons.account.tests.common import AccountTestInvoicingCommon - _logger = logging.getLogger(__name__) -class PaymentCommon(AccountTestInvoicingCommon): +class PaymentCommon(TransactionCase): @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) - - 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 + def setUpClass(cls): + super().setUpClass() cls.currency_euro = cls._prepare_currency('EUR') cls.currency_usd = cls._prepare_currency('USD') @@ -81,12 +72,6 @@ class PaymentCommon(AccountTestInvoicingCommon): 'arch': arch, }) - 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 = cls.env['payment.acquirer'].create({ 'name': "Dummy Acquirer", 'provider': 'none', @@ -94,7 +79,6 @@ class PaymentCommon(AccountTestInvoicingCommon): 'is_published': True, 'allow_tokenization': True, 'redirect_form_view_id': redirect_form.id, - 'journal_id': cls.company_data['default_journal_bank'].id, }) cls.acquirer = cls.dummy_acquirer @@ -104,27 +88,26 @@ class PaymentCommon(AccountTestInvoicingCommon): cls.currency = cls.currency_euro cls.partner = cls.default_partner cls.reference = "Test Transaction" - cls.account = cls.company.account_journal_payment_credit_account_id - cls.invoice = cls.env['account.move'].create({ - 'move_type': 'entry', - 'date': '2019-01-01', - 'line_ids': [ - (0, 0, { - 'account_id': cls.account.id, - 'currency_id': cls.currency_euro.id, - 'debit': 100.0, - 'credit': 0.0, - 'amount_currency': 200.0, - }), - (0, 0, { - 'account_id': cls.account.id, - 'currency_id': cls.currency_euro.id, - 'debit': 0.0, - 'credit': 100.0, - 'amount_currency': -200.0, - }), - ], - }) + + account_payment_module = cls.env['ir.module.module']._get('account_payment') + cls.account_payment_installed = account_payment_module.state in ('installed', 'to upgrade') + + def setUp(self): + def stop_patcher_without_fail(): + """ Magic hack: we start the patcher even if it was started already so that we do not + call stop on a non started patcher. """ + self.reconcile_after_done_patcher.start() + self.reconcile_after_done_patcher.stop() + + super().setUp() + if self.account_payment_installed: + # disable account payment generation if account_payment is installed + # because the accounting setup of acquirers is not managed in this common + self.reconcile_after_done_patcher = patch( + 'odoo.addons.account_payment.models.payment_transaction.PaymentTransaction._reconcile_after_done', + ) + self.reconcile_after_done_patcher.start() + self.addCleanup(stop_patcher_without_fail) #=== Utils ===# @@ -166,13 +149,8 @@ class PaymentCommon(AccountTestInvoicingCommon): else: acquirer = base_acquirer.copy({'company_id': company.id}) + update_values['state'] = 'test' acquirer.write(update_values) - if not acquirer.journal_id: - acquirer.journal_id = cls.env['account.journal'].search([ - ('company_id', '=', company.id), - ('type', '=', 'bank') - ], limit=1) - acquirer.state = 'test' return acquirer def _create_transaction(self, flow, sudo=True, **values): @@ -201,31 +179,6 @@ class PaymentCommon(AccountTestInvoicingCommon): ('reference', '=', reference), ]) - def _prepare_transaction_values(self, payment_option_id, flow): - """ Prepare the basic payment/transaction route values. - - :param int payment_option_id: The payment option handling the transaction, as a - `payment.acquirer` id or a `payment.token` id - :param str flow: The payment flow - :return: The route values - :rtype: dict - """ - return { - 'amount': self.amount, - 'currency_id': self.currency.id, - 'partner_id': self.partner.id, - 'access_token': self._generate_test_access_token( - self.partner.id, self.amount, self.currency.id - ), - 'payment_option_id': payment_option_id, - 'reference_prefix': 'test', - 'tokenization_requested': True, - 'landing_route': 'Test', - 'is_validation': False, - 'invoice_id': self.invoice.id, - 'flow': flow, - } - def _generate_test_access_token(self, *values): """ Generate an access token based on the provided values for testing purposes. diff --git a/addons/payment/tests/http_common.py b/addons/payment/tests/http_common.py index 2efbe083a9e..e2dbc420104 100644 --- a/addons/payment/tests/http_common.py +++ b/addons/payment/tests/http_common.py @@ -194,6 +194,31 @@ class PaymentHttpCommon(PaymentCommon, HttpCase): # payment/transaction # ####################### + + def _prepare_transaction_values(self, payment_option_id, flow): + """ Prepare the basic payment/transaction route values. + + :param int payment_option_id: The payment option handling the transaction, as a + `payment.acquirer` id or a `payment.token` id + :param str flow: The payment flow + :return: The route values + :rtype: dict + """ + return { + 'amount': self.amount, + 'currency_id': self.currency.id, + 'partner_id': self.partner.id, + 'access_token': self._generate_test_access_token( + self.partner.id, self.amount, self.currency.id + ), + 'payment_option_id': payment_option_id, + 'reference_prefix': 'test', + 'tokenization_requested': True, + 'landing_route': 'Test', + 'is_validation': False, + 'flow': flow, + } + def _portal_transaction(self, **route_kwargs): """/payment/transaction feedback diff --git a/addons/payment/tests/test_flows.py b/addons/payment/tests/test_flows.py index 51020f7f588..363b7f84547 100644 --- a/addons/payment/tests/test_flows.py +++ b/addons/payment/tests/test_flows.py @@ -277,43 +277,6 @@ class TestFlows(PaymentHttpCommon): response = self._portal_pay(**route_values) self.assertEqual(response.status_code, 404) - 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 - route_values['company_id'] = self.company.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) - def test_transaction_wrong_flow(self): transaction_values = self._prepare_pay_values() transaction_values.update({ diff --git a/addons/payment/tests/test_multicompany_flows.py b/addons/payment/tests/test_multicompany_flows.py index 54dc5bd87aa..7352c630863 100644 --- a/addons/payment/tests/test_multicompany_flows.py +++ b/addons/payment/tests/test_multicompany_flows.py @@ -11,11 +11,11 @@ from odoo.addons.payment.tests.http_common import PaymentHttpCommon class TestMultiCompanyFlows(PaymentHttpCommon): @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) + def setUpClass(cls): + super().setUpClass() - cls.company_a = cls.company_data['company'] - cls.company_b = cls.company_data_2['company'] + cls.company_a = cls.env.company # cls.company_data['company'] + cls.company_b = cls.env.company.create({'name': "Payment Test Company"}) # cls.company_data_2['company'] cls.user_company_a = cls.internal_user cls.user_company_b = cls.env['res.users'].create({ diff --git a/addons/payment/tests/test_payment_acquirer.py b/addons/payment/tests/test_payment_acquirer.py index 493df3f6c01..97da744d863 100644 --- a/addons/payment/tests/test_payment_acquirer.py +++ b/addons/payment/tests/test_payment_acquirer.py @@ -43,8 +43,8 @@ class TestPaymentAcquirer(PaymentCommon): set to 0. """ self.acquirer.maximum_amount = 0. - compatible_acquirers = self.acquirer._get_compatible_acquirers( - self.company.id, self.partner.id, self.amount, currency_id=self.currency.id + compatible_acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + self.company.id, self.partner.id, self.amount, currency_id=self.env.company.currency_id.id, ) self.assertIn(self.acquirer, compatible_acquirers) @@ -53,8 +53,8 @@ class TestPaymentAcquirer(PaymentCommon): amount. """ self.acquirer.maximum_amount = self.amount + 10.0 - compatible_acquirers = self.acquirer._get_compatible_acquirers( - self.company.id, self.partner.id, self.amount, currency_id=self.currency.id + compatible_acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + self.company.id, self.partner.id, self.amount, currency_id=self.env.company.currency_id.id, ) self.assertIn(self.acquirer, compatible_acquirers) @@ -63,7 +63,7 @@ class TestPaymentAcquirer(PaymentCommon): amount. """ self.acquirer.maximum_amount = self.amount - 10.0 - compatible_acquirers = self.acquirer._get_compatible_acquirers( - self.company.id, self.partner.id, self.amount, currency_id=self.currency.id + compatible_acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + self.company.id, self.partner.id, self.amount, currency_id=self.env.company.currency_id.id, ) self.assertNotIn(self.acquirer, compatible_acquirers) diff --git a/addons/payment/tests/test_payment_transaction.py b/addons/payment/tests/test_payment_transaction.py index 702208131c2..25e8dcd5057 100644 --- a/addons/payment/tests/test_payment_transaction.py +++ b/addons/payment/tests/test_payment_transaction.py @@ -11,7 +11,6 @@ class TestPaymentTransaction(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') ): @@ -32,7 +31,6 @@ class TestPaymentTransaction(PaymentCommon): 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() @@ -82,11 +80,3 @@ class TestPaymentTransaction(PaymentCommon): 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, msg="validation transactions should not create payments") diff --git a/addons/payment/views/payment_acquirer_views.xml b/addons/payment/views/payment_acquirer_views.xml index d51625de59d..e97d614a903 100644 --- a/addons/payment/views/payment_acquirer_views.xml +++ b/addons/payment/views/payment_acquirer_views.xml @@ -89,10 +89,7 @@ placeholder="Select countries. Leave empty to make available everywhere." options="{'no_open': True, 'no_create': True}"/> - - - + @@ -225,9 +222,4 @@ - - 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 -->
+ t-att-data-allow-token-selection="True"> @@ -174,7 +172,6 @@ t-att-data-reference-prefix="reference_prefix" t-att-data-partner-id="partner_id" t-att-data-access-token="access_token" - t-att-data-invoice-id="invoice_id" t-att-data-transaction-route="transaction_route" t-att-data-assign-token-route="assign_token_route" t-att-data-landing-route="landing_route" diff --git a/addons/payment/views/payment_token_views.xml b/addons/payment/views/payment_token_views.xml index 69c6cb86d06..f83a6102b69 100644 --- a/addons/payment/views/payment_token_views.xml +++ b/addons/payment/views/payment_token_views.xml @@ -71,9 +71,4 @@ - - diff --git a/addons/payment/views/payment_transaction_views.xml b/addons/payment/views/payment_transaction_views.xml index 2242960417c..c123f5ec4bc 100644 --- a/addons/payment/views/payment_transaction_views.xml +++ b/addons/payment/views/payment_transaction_views.xml @@ -14,11 +14,6 @@
-