diff --git a/website_sale_sepa_dd_payment/__manifest__.py b/website_sale_sepa_dd_payment/__manifest__.py index a02b5fbc..f7a6f432 100644 --- a/website_sale_sepa_dd_payment/__manifest__.py +++ b/website_sale_sepa_dd_payment/__manifest__.py @@ -15,12 +15,19 @@ "application": False, "depends": [ "website_sale", + "payment_custom", ], "excludes": [], "data": [ "views/product_views.xml", "views/templates.xml", + "data/payment_provider_data.xml", ], + "assets": { + "web.assets_frontend": [ + "website_sale_sepa_dd_payment/static/src/js/payment_form.js", + ], + }, "demo": [], "qweb": [], } diff --git a/website_sale_sepa_dd_payment/controllers/main.py b/website_sale_sepa_dd_payment/controllers/main.py index ce731864..f321cabe 100644 --- a/website_sale_sepa_dd_payment/controllers/main.py +++ b/website_sale_sepa_dd_payment/controllers/main.py @@ -2,36 +2,34 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -from odoo import http +from odoo import _ from odoo.exceptions import ValidationError from odoo.http import request -from odoo.addons.website_sale.controllers.main import WebsiteSale +from odoo.addons.website_sale.controllers.main import PaymentPortal -class WebsiteSaleSEPADirectDebit(WebsiteSale): - @http.route( - "/shop/payment", type="http", auth="public", website=True, sitemap=False - ) - def shop_payment(self, **post): - sepa_dd_errors = [] - sepa_dd_iban = "" - # Set default values - if request.env.user.partner_id.bank_ids: - sepa_dd_iban = request.env.user.partner_id.bank_ids[0].acc_number - # Process form - if request.httprequest.method == "POST": - order = request.website.sale_get_order() - order.sepa_dd_iban = request.params.get("sepa-dd-iban", "") +class PaymentPortalSEPADD(PaymentPortal): + def _validate_transaction_for_order(self, transaction, sale_order_id): + """Throws a ValidationError if IBAN is not correct""" + if ( + transaction.provider_id.code == "custom" + and transaction.provider_id.custom_mode == "sepa_dd" + ): + sepa_dd_accept = request.params.get("sepa_dd_accept") + if not sepa_dd_accept: + raise ValidationError( + _("You must accept to give a SEPA Direct Debit Mandate.") + ) + sepa_dd_iban = request.params.get("sepa_dd_iban", "") + if not sepa_dd_iban: + raise ValidationError( + _("IBAN must be provided for SEPA Direct Debit payment") + ) + transaction.sepa_dd_iban = sepa_dd_iban + + # Set SEPA Direct Debit payment_mode_id + order = request.env["sale.order"].sudo().browse(sale_order_id).exists() order.payment_mode_id = order.get_sepa_dd_payment_mode() - try: - order.with_context(send_email=True).action_confirm() - except ValidationError as err: - sepa_dd_errors.append(str(err)) - else: - return request.redirect(order.get_portal_url()) - result = super().shop_payment(**post) - result.qcontext["sepa_dd_errors"] = sepa_dd_errors - result.qcontext["sepa_dd_iban"] = sepa_dd_iban - return result + return super()._validate_transaction_for_order(transaction, sale_order_id) diff --git a/website_sale_sepa_dd_payment/data/payment_provider_data.xml b/website_sale_sepa_dd_payment/data/payment_provider_data.xml new file mode 100644 index 00000000..f24190eb --- /dev/null +++ b/website_sale_sepa_dd_payment/data/payment_provider_data.xml @@ -0,0 +1,19 @@ + + + + + SEPA Direct Debit + + custom + sepa_dd + enabled + + +

+ Your order has been saved. + Your payment by SEPA Dierct Debit will be processed soon. +

+
+
+ +
diff --git a/website_sale_sepa_dd_payment/models/__init__.py b/website_sale_sepa_dd_payment/models/__init__.py index 4a80475a..e7ef587c 100644 --- a/website_sale_sepa_dd_payment/models/__init__.py +++ b/website_sale_sepa_dd_payment/models/__init__.py @@ -3,3 +3,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from . import sale_order from . import product_template +from . import payment_provider +from . import payment_transaction diff --git a/website_sale_sepa_dd_payment/models/payment_provider.py b/website_sale_sepa_dd_payment/models/payment_provider.py new file mode 100644 index 00000000..5117ba80 --- /dev/null +++ b/website_sale_sepa_dd_payment/models/payment_provider.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2024 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from odoo import api, fields, models + + +class PaymentProvider(models.Model): + _inherit = "payment.provider" + + custom_mode = fields.Selection(selection_add=[("sepa_dd", "SEPA Direct Debit")]) + + @api.model + def _get_compatible_providers( + self, *args, sale_order_id=None, website_id=None, **kwargs + ): + """Override of payment to exclude onsite providers if the delivery doesn't match. + + :param int sale_order_id: The sale order to be paid, if any, as a `sale.order` id + :param int website_id: The provided website, as a `website` id + :return: The compatible providers + :rtype: recordset of `payment.provider` + """ + compatible_providers = super()._get_compatible_providers( + *args, sale_order_id=sale_order_id, website_id=website_id, **kwargs + ) + order = self.env["sale.order"].browse(sale_order_id).exists() + # Allow SEPA Direct Debit only if all products allow SEPA DD payment + allow_sepa_dd_payment = all( + order.order_line.mapped("product_id").mapped("allow_sepa_dd_payment") + ) + if not allow_sepa_dd_payment: + compatible_providers = compatible_providers.filtered( + lambda p: p.code != "custom" or p.custom_mode != "sepa_dd" + ) + + return compatible_providers diff --git a/website_sale_sepa_dd_payment/models/payment_transaction.py b/website_sale_sepa_dd_payment/models/payment_transaction.py new file mode 100644 index 00000000..b70b9d7b --- /dev/null +++ b/website_sale_sepa_dd_payment/models/payment_transaction.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2024 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import datetime + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from odoo.addons.base.models.res_bank import sanitize_account_number +from odoo.addons.base_iban.models.res_partner_bank import validate_iban + + +class PaymentTransaction(models.Model): + _inherit = "payment.transaction" + + sepa_dd_iban = fields.Char(string="IBAN (for SEPA Direct Debit)") + + @api.constrains("sepa_dd_iban", "partner_id") + def _check_sepa_dd_iban(self): + """Raise ValidationError if sepa_dd_iban is not correct""" + if self.sepa_dd_iban: + validate_iban(self.sepa_dd_iban) + sanitized_sepa_dd_iban = sanitize_account_number(self.sepa_dd_iban) + res_partner_bank = self.env["res.partner.bank"].search( + [("sanitized_acc_number", "=", sanitized_sepa_dd_iban)], + limit=1, + ) + if res_partner_bank and res_partner_bank.partner_id != self.partner_id: + raise ValidationError( + f"The account number {sanitized_sepa_dd_iban} does not belongs " + f"to {self.partner_id.name}." + ) + return res_partner_bank + + def _set_pending(self, state_message=None): + txs_to_process = super()._set_pending(state_message=state_message) + txs_to_process.create_sepa_dd_mandate() + return txs_to_process + + def get_res_partner_bank(self): + """Return res.partner.bank that match the sepa_dd_iban + account. + """ + self.ensure_one() + res_partner_bank = self.env["res.partner.bank"] + if self.sepa_dd_iban: + sanitized_sepa_dd_iban = sanitize_account_number(self.sepa_dd_iban) + res_partner_bank = self.env["res.partner.bank"].search( + [("sanitized_acc_number", "=", sanitized_sepa_dd_iban)], + limit=1, + ) + return res_partner_bank + + def sepa_dd_mandate_vals(self): + self.ensure_one() + return { + "format": "sepa", + "type": "recurrent", + "scheme": "CORE", + "recurrent_sequence_type": "recurring", + "signature_date": datetime.datetime.now(), + "state": "valid", + } + + def create_sepa_dd_mandate(self): + for tx in self: + res_partner_bank = tx.get_res_partner_bank() + if not res_partner_bank: + res_partner_bank = self.env["res.partner.bank"].create( + { + "partner_id": tx.partner_id.id, + "acc_number": tx.sepa_dd_iban, + } + ) + res_partner_bank.write( + { + "mandate_ids": [ + (0, 0, tx.sepa_dd_mandate_vals()), + ], + } + ) diff --git a/website_sale_sepa_dd_payment/models/sale_order.py b/website_sale_sepa_dd_payment/models/sale_order.py index bf6b76e1..454f799a 100644 --- a/website_sale_sepa_dd_payment/models/sale_order.py +++ b/website_sale_sepa_dd_payment/models/sale_order.py @@ -2,20 +2,14 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -import datetime from odoo import api, fields, models -from odoo.exceptions import ValidationError - -from odoo.addons.base.models.res_bank import sanitize_account_number -from odoo.addons.base_iban.models.res_partner_bank import validate_iban class SaleOrder(models.Model): _inherit = "sale.order" allow_sepa_dd_payment = fields.Boolean(compute="_compute_allow_sepa_dd_payment") - sepa_dd_iban = fields.Char() @api.depends("order_line", "payment_mode_id") def _compute_allow_sepa_dd_payment(self): @@ -30,55 +24,3 @@ def get_sepa_dd_payment_mode(self): [("payment_method_id.code", "=", "sepa_direct_debit")], limit=1, ) - - def action_confirm(self): - sepa_dd_payment_mode = self.get_sepa_dd_payment_mode() - for order in self: - if order.payment_mode_id == sepa_dd_payment_mode: - order.create_sepa_dd_mandate() - return super().action_confirm() - - def validate_sepa_dd_iban(self): - """Raise ValidationError if sepa_dd_iban is not correct""" - self.ensure_one() - validate_iban(self.sepa_dd_iban) - sanitized_sepa_dd_iban = sanitize_account_number(self.sepa_dd_iban) - res_partner_bank = self.env["res.partner.bank"].search( - [("sanitized_acc_number", "=", sanitized_sepa_dd_iban)], - limit=1, - ) - if res_partner_bank and res_partner_bank.partner_id != self.partner_id: - raise ValidationError( - f"The account number {sanitized_sepa_dd_iban} does not belongs " - f"to {self.partner_id.name}." - ) - return res_partner_bank - - def sepa_dd_mandate_vals(self): - self.ensure_one() - return { - "format": "sepa", - "type": "recurrent", - "scheme": "CORE", - "recurrent_sequence_type": "recurring", - "signature_date": datetime.datetime.now(), - "state": "valid", - } - - def create_sepa_dd_mandate(self): - for order in self: - res_partner_bank = order.validate_sepa_dd_iban() - if not res_partner_bank: - res_partner_bank = self.env["res.partner.bank"].create( - { - "partner_id": order.partner_id.id, - "acc_number": order.sepa_dd_iban, - } - ) - res_partner_bank.write( - { - "mandate_ids": [ - (0, 0, order.sepa_dd_mandate_vals()), - ], - } - ) diff --git a/website_sale_sepa_dd_payment/static/src/js/payment_form.js b/website_sale_sepa_dd_payment/static/src/js/payment_form.js new file mode 100644 index 00000000..c50a413e --- /dev/null +++ b/website_sale_sepa_dd_payment/static/src/js/payment_form.js @@ -0,0 +1,83 @@ +odoo.define("website_sale_sepa_dd_payment.payment_form", (require) => { + "use strict"; + + const checkoutForm = require("payment.checkout_form"); + const manageForm = require("payment.manage_form"); + + const sepaDDMixin = { + /** + * Return all relevant inline form inputs + * + * @private + * @param {Number} providerId - The id of the selected provider + * @returns {Object} - An object mapping the name of inline form inputs + * to their DOM element + */ + _getInlineFormInputs: function (providerId) { + return { + sepa_dd_iban: document.getElementById(`o_sepa_dd_iban_${providerId}`), + sepa_dd_accept: document.getElementById( + `o_sepa_dd_accept_${providerId}` + ), + }; + }, + + /** + * Return values to pass to the default transaction validation + * route + * + * @private + * @param {String} code - The code of the payment option's provider + * @param {Number} paymentOptionId - The id of the selected provider + * @param {String} flow - The online payment flow of the transaction + * @returns {Object} - The params for the default transaction route + */ + _prepareTransactionRouteParams: function (code, paymentOptionId, flow) { + const values = this._super(code, paymentOptionId, flow); + const inputs = this._getInlineFormInputs(paymentOptionId); + values.sepa_dd_iban = inputs.sepa_dd_iban.value; + values.sepa_dd_accept = inputs.sepa_dd_accept.value; + return values; + }, + + /** + * Ensure that inputs for the form are valid (more validation + * are done in python by the backend). + * + * @override method from payment.payment_form_mixin + * @private + * @param {String} code - The code of the payment option's provider + * @param {Number} paymentOptionId - The id of the payment option handling the transaction + * @param {String} flow - The online payment flow of the transaction + * @returns {Promise} + */ + _processPayment: function (code, paymentOptionId, flow) { + if (code !== "custom" || flow === "token") { + return this._super(...arguments); + } + + if (!this._validateFormInputs(paymentOptionId)) { + this._enableButton(); + $("body").unblock(); + return Promise.resolve(); + } + + return this._super(...arguments); + }, + + /** + * Checks that all payment inputs adhere to the DOM validation constraints. + * + * @private + * @param {Number} providerId - The id of the selected provider + * @returns {Boolean} - Whether all elements pass the validation constraints + */ + _validateFormInputs: function (providerId) { + const inputs = Object.values(this._getInlineFormInputs(providerId)); + return inputs.every((element) => element.reportValidity()); + }, + }; + + checkoutForm.include(sepaDDMixin); + manageForm.include(sepaDDMixin); +}); diff --git a/website_sale_sepa_dd_payment/views/templates.xml b/website_sale_sepa_dd_payment/views/templates.xml index c1189736..29c4fb77 100644 --- a/website_sale_sepa_dd_payment/views/templates.xml +++ b/website_sale_sepa_dd_payment/views/templates.xml @@ -1,73 +1,32 @@ -