diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index 6e4f3b31120..ed8cc4826dc 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -142,7 +142,10 @@ class PaymentPortal(portal.CustomerPortal): 'invoice_id': invoice_id, **self._get_custom_rendering_context_values(**kwargs), } - return request.render('payment.pay', rendering_context) + return request.render(self._get_payment_page_template_xmlid(**kwargs), rendering_context) + + def _get_payment_page_template_xmlid(self, **kwargs): + return 'payment.pay' @http.route('/my/payment_method', type='http', methods=['GET'], auth='user', website=True) def payment_method(self, **kwargs): @@ -205,16 +208,9 @@ class PaymentPortal(portal.CustomerPortal): tx_sudo = self._create_transaction( amount=amount, currency_id=currency_id, partner_id=partner_id, **kwargs ) - - # The generic landing route require the tx id and access token to be provided, since there - # is no document to rely on. The access token is recomputed in case we are dealing with a - # validation transaction (acquirer-specific amount and currency). - access_token = payment_utils.generate_access_token( - tx_sudo.partner_id.id, tx_sudo.amount, tx_sudo.currency_id.id - ) - tx_sudo.landing_route = f'{tx_sudo.landing_route}' \ - f'?tx_id={tx_sudo.id}&access_token={access_token}' - + if tx_sudo.operation == 'validation': + # The amount and currency have been chosen for the validation, recompute the access token + self._update_landing_route(tx_sudo) return tx_sudo._get_processing_values() def _create_transaction( @@ -308,6 +304,24 @@ class PaymentPortal(portal.CustomerPortal): return tx_sudo + @staticmethod + def _update_landing_route(tx_sudo): + """ Recompute the access token stored in the landing route of validation transactions. + + The generic landing route require the tx id and access token to be provided, since there is + no document to rely on. The access token is recomputed in case we are dealing with a + validation transaction (acquirer-specific amount and currency). + + :param recordset tx_sudo: The transaction whose landing routes to update, as a + `payment.transaction` record. + :return: None + """ + access_token = payment_utils.generate_access_token( + tx_sudo.partner_id.id, tx_sudo.amount, tx_sudo.currency_id.id + ) + tx_sudo.landing_route = f'{tx_sudo.landing_route}' \ + f'?tx_id={tx_sudo.id}&access_token={access_token}' + @http.route('/payment/confirmation', type='http', methods=['GET'], auth='public', website=True) def payment_confirm(self, tx_id, access_token, **kwargs): """ Display the payment confirmation page with the appropriate status message to the user. diff --git a/addons/website_payment/__init__.py b/addons/website_payment/__init__.py index e9917144f69..7d34c7c054a 100644 --- a/addons/website_payment/__init__.py +++ b/addons/website_payment/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import models \ No newline at end of file +from . import controllers +from . import models diff --git a/addons/website_payment/__manifest__.py b/addons/website_payment/__manifest__.py index 731f83ef34b..91e6892ba2f 100644 --- a/addons/website_payment/__manifest__.py +++ b/addons/website_payment/__manifest__.py @@ -14,7 +14,9 @@ This is a bridge module that adds multi-website support for payment acquirers. 'portal', ], 'data': [ + 'data/donation_data.xml', 'views/payment_acquirer.xml', + 'views/donation_templates.xml', 'views/snippets/snippets.xml', 'views/snippets/s_donation.xml', ], @@ -23,6 +25,10 @@ This is a bridge module that adds multi-website support for payment acquirers. 'website.assets_wysiwyg': [ 'website_payment/static/src/snippets/s_donation/options.js', ], + 'web.assets_frontend': [ + 'website_payment/static/src/js/website_payment_donation.js', + 'website_payment/static/src/js/website_payment_form.js', + ], }, 'license': 'LGPL-3', } diff --git a/addons/website_payment/controllers/__init__.py b/addons/website_payment/controllers/__init__.py new file mode 100644 index 00000000000..903b755e71e --- /dev/null +++ b/addons/website_payment/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import portal diff --git a/addons/website_payment/controllers/portal.py b/addons/website_payment/controllers/portal.py new file mode 100644 index 00000000000..ec275a52462 --- /dev/null +++ b/addons/website_payment/controllers/portal.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http, _ +from odoo.exceptions import ValidationError +from odoo.http import request +from odoo.tools.json import scriptsafe as json_safe + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment.controllers import portal as payment_portal + + +class PaymentPortal(payment_portal.PaymentPortal): + @http.route('/donation/pay', type='http', methods=['GET', 'POST'], auth='public', website=True, sitemap=False) + def donation_pay(self, **kwargs): + """ Behaves like PaymentPortal.payment_pay but for donation + + :param dict kwargs: As the parameters of in payment_pay, with the additional: + - str donation_options: The options settled in the donation snippet + - str donation_descriptions: The descriptions for all prefilled amounts + :return: The rendered donation form + :rtype: str + :raise: werkzeug.exceptions.NotFound if the access token is invalid + """ + kwargs['is_donation'] = True + kwargs['currency_id'] = int(kwargs.get('currency', request.env.company.currency_id.id)) + kwargs['amount'] = float(kwargs.get('amount', 25)) + kwargs['donation_options'] = kwargs.get('donation_options', json_safe.dumps(dict(customAmount="freeAmount"))) + + if request.env.user._is_public(): + kwargs['partner_id'] = request.env.user.partner_id.id + kwargs['access_token'] = payment_utils.generate_access_token(kwargs['partner_id'], kwargs['amount'], kwargs['currency_id']) + + return self.payment_pay(**kwargs) + + @http.route('/donation/transaction/', type='json', auth='public', website=True, sitemap=False) + def donation_transaction(self, amount, currency_id, partner_id, access_token, minimum_amount=0, **kwargs): + if float(amount) < float(minimum_amount): + raise ValidationError(_('Donation amount must be at least %.2f.', float(minimum_amount))) + use_public_partner = request.env.user._is_public() or not partner_id + if use_public_partner: + details = kwargs['partner_details'] + if not details.get('name'): + raise ValidationError(_('Name is required.')) + if not details.get('email'): + raise ValidationError(_('Email is required.')) + if not details.get('country_id'): + raise ValidationError(_('Country is required.')) + partner_id = request.website.user_id.partner_id.id + del kwargs['partner_details'] + + kwargs.pop('custom_create_values', None) # Don't allow passing arbitrary create values + tx_sudo = self._create_transaction( + amount=amount, currency_id=currency_id, partner_id=partner_id, **kwargs + ) + tx_sudo.is_donation = True + if use_public_partner: + tx_sudo.update({ + 'partner_name': details['name'], + 'partner_email': details['email'], + 'partner_country_id': details['country_id'], + }) + elif not tx_sudo.partner_country_id: + tx_sudo.partner_country_id = kwargs['partner_details']['country_id'] + self._update_landing_route(tx_sudo) + + # Send a notification to warn that a donation has been made + recipient_email = kwargs['donation_recipient_email'] + comment = kwargs['donation_comment'] + tx_sudo._send_donation_email(True, comment, recipient_email) + + return tx_sudo._get_processing_values() + + def _get_custom_rendering_context_values(self, donation_options=None, donation_descriptions=None, is_donation=False, **kwargs): + rendering_context = super()._get_custom_rendering_context_values(**kwargs) + if is_donation: + user_sudo = request.env.user + logged_in = not user_sudo._is_public() + # If the user is logged in, take their partner rather than the partner set in the params. + # This is something that we want, since security rules are based on the partner, and created + # tokens should not be assigned to the public user. This should have no impact on the + # transaction itself besides making reconciliation possibly more difficult (e.g. The + # transaction and invoice partners are different). + partner_sudo = user_sudo.partner_id + partner_details = {} + countries = request.env['res.country'] + if logged_in: + partner_details = { + 'name': partner_sudo.name, + 'email': partner_sudo.email, + 'country_id': partner_sudo.country_id.id, + } + + countries = request.env['res.country'].sudo().search([]) + descriptions = request.httprequest.form.getlist('donation_descriptions') + + donation_options = json_safe.loads(donation_options) if donation_options else {} + donation_amounts = json_safe.loads(donation_options.get('donationAmounts', '[]')) + + rendering_context.update({ + 'is_donation': True, + 'partner': partner_sudo, + 'transaction_route': '/donation/transaction/%s' % donation_options.get('minimumAmount', 0), + 'partner_details': partner_details, + 'error': {}, + 'countries': countries, + 'donation_options': donation_options, + 'donation_amounts': donation_amounts, + 'donation_descriptions': descriptions, + }) + return rendering_context + + def _get_payment_page_template_xmlid(self, **kwargs): + if kwargs.get('is_donation'): + return 'website_payment.donation_pay' + return super()._get_payment_page_template_xmlid(**kwargs) diff --git a/addons/website_payment/data/donation_data.xml b/addons/website_payment/data/donation_data.xml new file mode 100644 index 00000000000..283e8ac304a --- /dev/null +++ b/addons/website_payment/data/donation_data.xml @@ -0,0 +1,67 @@ + + + + + Donation + + ${object.partner_id.lang} + + + + + diff --git a/addons/website_payment/models/__init__.py b/addons/website_payment/models/__init__.py index 0c52563d00c..0f8b5b238f7 100644 --- a/addons/website_payment/models/__init__.py +++ b/addons/website_payment/models/__init__.py @@ -1 +1,6 @@ -from . import payment_acquirer \ No newline at end of file +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_payment +from . import payment_acquirer +from . import payment_transaction diff --git a/addons/website_payment/models/account_payment.py b/addons/website_payment/models/account_payment.py new file mode 100644 index 00000000000..59460ed1ddf --- /dev/null +++ b/addons/website_payment/models/account_payment.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class AccountPayment(models.Model): + _inherit = 'account.payment' + + is_donation = fields.Boolean(string="Is Donation", related="payment_transaction_id.is_donation", help="Is the payment a donation") diff --git a/addons/website_payment/models/payment_transaction.py b/addons/website_payment/models/payment_transaction.py new file mode 100644 index 00000000000..b4f4144153c --- /dev/null +++ b/addons/website_payment/models/payment_transaction.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, fields, models + + +class PaymentTransaction(models.Model): + _inherit = "payment.transaction" + + is_donation = fields.Boolean(string="Is donation", help="Is the payment a donation") + + def _finalize_post_processing(self): + super()._finalize_post_processing() + for tx in self.filtered('is_donation'): + tx._send_donation_email() + msg = [_('Payment received from donation with following details:')] + for field in ['company_id', 'partner_id', 'partner_name', 'partner_country_id', 'partner_email']: + field_name = tx._fields[field].string + value = tx[field] + if value: + if 'name' in value: + value = value.name + msg.append('
- %s: %s' % (field_name, value)) + tx.payment_id._message_log(body=''.join(msg)) + + def _send_donation_email(self, is_internal_notification=False, comment=None, recipient_email=None): + self.ensure_one() + if is_internal_notification or self.state == 'done': + subject = _('A donation has been made on your website') if is_internal_notification else _('Donation confirmation') + body = self.env.ref('website_payment.donation_mail_body')._render({ + 'is_internal_notification': is_internal_notification, + 'tx': self, + 'comment': comment, + }, engine='ir.qweb', minimal_qcontext=True) + self.env.ref('website_payment.mail_template_donation').send_mail( + self.id, notif_layout="mail.mail_notification_light", + force_send=True, + email_values={ + 'email_to': recipient_email if is_internal_notification else self.partner_email, + 'email_from': self.company_id.email_formatted, + 'author_id': self.partner_id.id, + 'subject': subject, + 'body_html': body, + }, + ) diff --git a/addons/website_payment/static/src/js/website_payment_donation.js b/addons/website_payment/static/src/js/website_payment_donation.js new file mode 100644 index 00000000000..158194ab7fa --- /dev/null +++ b/addons/website_payment/static/src/js/website_payment_donation.js @@ -0,0 +1,35 @@ +/** @odoo-module **/ + +import publicWidget from 'web.public.widget'; + +publicWidget.registry.WebsitePaymentDonation = publicWidget.Widget.extend({ + selector: '.o_donation_payment_form', + events: { + 'focus .o_amount_input': '_onFocusAmountInput', + 'change #donation_comment_checkbox': '_onChangeDonationComment' + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onFocusAmountInput(ev) { + this.$target.find('#other_amount').prop("checked", true); + }, + /** + * @private + * @param {Event} ev + */ + _onChangeDonationComment(ev) { + const $donationComment = this.$target.find('#donation_comment'); + const checked = $(ev.currentTarget).is(':checked'); + $donationComment.toggleClass('d-none', !checked); + if (!checked) { + $donationComment.val(''); + } + }, +}); diff --git a/addons/website_payment/static/src/js/website_payment_form.js b/addons/website_payment/static/src/js/website_payment_form.js new file mode 100644 index 00000000000..4c3c9485fbc --- /dev/null +++ b/addons/website_payment/static/src/js/website_payment_form.js @@ -0,0 +1,78 @@ +/** @odoo-module **/ + +import {_t} from 'web.core'; +import checkoutForm from 'payment.checkout_form'; + +checkoutForm.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Perform some validations for donations before performing payment + * + * @override method from payment.payment_form_mixin + * @private + * @param {string} provider - The provider of the payment option's acquirer + * @param {number} paymentOptionId - The id of the payment option handling the transaction + * @param {string} flow - The online payment flow of the transaction + * @return {Promise} + */ + _processPayment: function (provider, paymentOptionId, flow) { + if ($('.o_donation_payment_form').length) { + const errorFields = {}; + if (!this.$('input[name="email"]')[0].checkValidity()) { + errorFields['email'] = _t("Email is invalid"); + } + const mandatoryFields = { + 'name': _t('Name'), + 'email': _t('Email'), + 'country_id': _t('Country'), + }; + for (const id in mandatoryFields) { + const $field = this.$('input[name="' + id + '"],select[name="' + id + '"]'); + $field.removeClass('is-invalid').popover('dispose'); + if (!$field.val().trim()) { + errorFields[id] = _.str.sprintf(_t("Field '%s' is mandatory"), mandatoryFields[id]); + } + } + if (Object.keys(errorFields).length) { + for (const id in errorFields) { + const $field = this.$('input[name="' + id + '"],select[name="' + id + '"]'); + $field.addClass('is-invalid'); + $field.popover({content: errorFields[id], trigger: 'hover', container: 'body', placement: 'top'}); + $field.data("bs.popover").config.content = errorFields[id]; + } + this._displayError( + _t("Validation Error"), + _t("Some information is missing to process your payment.") + ); + return Promise.resolve(); + } + } + return this._super(...arguments); + }, + /** + * Add params used by the donation snippet to the transaction route params. + * + * @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 $('.o_donation_payment_form').length ? { + ...transactionRouteParams, + 'partner_details': { + 'name': this.$('input[name="name"]').val(), + 'email': this.$('input[name="email"]').val(), + 'country_id': this.$('select[name="country_id"]').val(), + }, + 'donation_comment': this.$('#donation_comment').val(), + 'donation_recipient_email': this.$('input[name="donation_recipient_email"]').val(), + } : transactionRouteParams; + }, +}); diff --git a/addons/website_payment/static/src/snippets/s_donation/000.js b/addons/website_payment/static/src/snippets/s_donation/000.js index 067d3f68aff..97f30e9eb03 100644 --- a/addons/website_payment/static/src/snippets/s_donation/000.js +++ b/addons/website_payment/static/src/snippets/s_donation/000.js @@ -8,6 +8,7 @@ publicWidget.registry.DonationSnippet = publicWidget.Widget.extend({ disabledInEditableMode: false, events: { 'click .s_donation_btn': '_onClickPrefilledButton', + 'click .s_donation_donate_btn': '_onClickDonateNowButton', 'input #s_donation_range_slider': '_onInputRangeSlider', }, @@ -17,8 +18,9 @@ publicWidget.registry.DonationSnippet = publicWidget.Widget.extend({ async start() { await this._super(...arguments); this.$rangeSlider = this.$('#s_donation_range_slider'); + this.defaultAmount = this.$target[0].dataset.defaultAmount; if (this.$rangeSlider.length) { - this.$rangeSlider.val(this.$target[0].dataset.defaultAmount); + this.$rangeSlider.val(this.defaultAmount); this._setBubble(this.$rangeSlider); } await this._displayCurrencies(); @@ -29,6 +31,7 @@ publicWidget.registry.DonationSnippet = publicWidget.Widget.extend({ destroy() { this.$target.find('.s_donation_currency').remove(); this._deselectPrefilledButtons(); + this.$('.alert-danger').remove(); this._super(...arguments); }, @@ -100,6 +103,50 @@ publicWidget.registry.DonationSnippet = publicWidget.Widget.extend({ this._setBubble(this.$rangeSlider); } }, + /** + * @private + */ + _onClickDonateNowButton(ev) { + if (this.editableMode) { + return; + }; + this.$('.alert-danger').remove(); + const $buttons = this.$('.s_donation_btn'); + const $selectedButton = $buttons.filter('.active'); + let amount = $selectedButton.length ? $selectedButton[0].dataset.donationValue : 0; + if (this.$target[0].dataset.displayOptions && !amount) { + if (this.$rangeSlider.length) { + amount = this.$rangeSlider.val(); + } else if ($buttons.length) { + amount = parseFloat(this.$('#s_donation_amount_input').val()); + let errorMessage = ''; + const minAmount = this.$target[0].dataset.minimumAmount; + if (!amount) { + errorMessage = _t("Please select or enter an amount"); + } else if (amount < parseFloat(minAmount)) { + const before = this.currency.position === "before" ? this.currency.symbol : ""; + const after = this.currency.position === "after" ? this.currency.symbol : ""; + errorMessage = _.str.sprintf(_t("The minimum donation amount is %s%s%s"), before, minAmount, after); + } + if (errorMessage) { + $(ev.currentTarget).before($('

', { + class: 'alert alert-danger', + text: errorMessage, + })); + return; + } + } + } + if (!amount) { + amount = this.defaultAmount; + } + const $form = this.$('.s_donation_form'); + $('').attr({type: 'hidden', name: 'amount', value: amount}).appendTo($form); + $('').attr({type: 'hidden', name: 'currency_id', value: this.currency.id}).appendTo($form); + $('').attr({type: 'hidden', name: 'csrf_token', value: odoo.csrf_token}).appendTo($form); + $('').attr({type: 'hidden', name: 'donation_options', value: JSON.stringify(this.el.dataset)}).appendTo($form); + $form.submit(); + }, /** * @private */ diff --git a/addons/website_payment/static/src/snippets/s_donation/000.xml b/addons/website_payment/static/src/snippets/s_donation/000.xml index e0493534d66..3a800872fbf 100644 --- a/addons/website_payment/static/src/snippets/s_donation/000.xml +++ b/addons/website_payment/static/src/snippets/s_donation/000.xml @@ -4,11 +4,12 @@

@@ -18,13 +19,14 @@
-
diff --git a/addons/website_payment/views/donation_templates.xml b/addons/website_payment/views/donation_templates.xml new file mode 100644 index 00000000000..7341664bdaa --- /dev/null +++ b/addons/website_payment/views/donation_templates.xml @@ -0,0 +1,134 @@ + + + +