[REF] payment, *: refactor online payments API

This commit replaces the old online payments API of the `payment`
module with the new one and adapts to it all the implementing modules.

See the merge commit for more details.

task-2085989
task-2119838
task-2165982
task-2289255

Co-authored-by: Victor Feyens <vfe@odoo.com>
This commit is contained in:
Antoine Vandevenne (anv)
2021-03-30 09:25:51 +02:00
co-authored by Victor Feyens
parent 2387a045ab
commit 573ed74c12
108 changed files with 6379 additions and 4254 deletions
-5
View File
@@ -99,11 +99,6 @@ class PortalAccount(CustomerPortal):
return self._show_report(model=invoice_sudo, report_type=report_type, report_ref='account.account_invoices', download=download)
values = self._invoice_get_page_view_values(invoice_sudo, access_token, **kw)
acquirers = values.get('acquirers')
if acquirers:
country_id = values.get('partner_id') and values.get('partner_id')[0].country_id.id
values['acq_extra_fees'] = acquirers.get_acquirer_extra_fees(invoice_sudo.amount_residual, invoice_sudo.currency_id, country_id)
return request.render("account.portal_invoice_page", values)
# ------------------------------------------------------------
-1
View File
@@ -2,4 +2,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
+26 -81
View File
@@ -1,93 +1,38 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.urls import url_encode
from odoo.exceptions import AccessError, MissingError, ValidationError
from odoo.fields import Command
from odoo.http import route
from odoo import http, _
from odoo.addons.portal.controllers.portal import _build_url_w_params
from odoo.addons.payment.controllers.portal import PaymentProcessing
from odoo.http import request, route
from odoo.addons.payment.controllers import portal as payment_portal
class PaymentPortal(http.Controller):
class PaymentPortal(payment_portal.PaymentPortal):
@route('/invoice/pay/<int:invoice_id>/form_tx', type='json', auth="public", website=True)
def invoice_pay_form(self, acquirer_id, invoice_id, save_token=False, access_token=None, **kwargs):
""" Json method that creates a payment.transaction, used to create a
transaction when the user clicks on 'pay now' button on the payment
form.
:return html: form containing all values related to the acquirer to
redirect customers to the acquirer website """
invoice_sudo = request.env['account.move'].sudo().browse(invoice_id)
if not invoice_sudo:
return False
@route('/invoice/transaction/<int:invoice_id>', type='json', auth='public')
def invoice_transaction(self, invoice_id, access_token, **kwargs):
""" Create a draft transaction and return its processing values.
:param int invoice_id: The invoice to pay, as an `account.move` id
:param str access_token: The access token used to authenticate the request
:param dict kwargs: Locally unused data passed to `_create_transaction`
:return: The mandatory values for the processing of the transaction
:rtype: dict
:raise: ValidationError if the invoice id or the access token is invalid
"""
# Check the invoice id and the access token
try:
acquirer_id = int(acquirer_id)
except:
return False
self._document_check_access('account.move', invoice_id, access_token)
except MissingError as error:
raise error
except AccessError:
raise ValidationError("The access token is invalid.")
if request.env.user._is_public():
save_token = False # we avoid to create a token for the public user
success_url = kwargs.get(
'success_url', "%s?%s" % (invoice_sudo.access_url, url_encode({'access_token': access_token}) if access_token else '')
)
vals = {
'acquirer_id': acquirer_id,
'return_url': success_url,
}
if save_token:
vals['type'] = 'form_save'
transaction = invoice_sudo._create_payment_transaction(vals)
PaymentProcessing.add_payment_transaction(transaction)
return transaction.render_invoice_button(
invoice_sudo,
submit_txt=_('Pay & Confirm'),
render_values={
'type': 'form_save' if save_token else 'form',
'alias_usage': _('If we store your payment information on our server, subscription payments will be made automatically.'),
}
kwargs['reference_prefix'] = None # Allow the reference to be computed based on the invoice
kwargs.pop('custom_create_values', None) # Don't allow passing arbitrary create values
tx_sudo = self._create_transaction(
custom_create_values={'invoice_ids': [Command.set([invoice_id])]}, **kwargs,
)
@http.route('/invoice/pay/<int:invoice_id>/s2s_token_tx', type='http', auth='public', website=True)
def invoice_pay_token(self, invoice_id, pm_id=None, **kwargs):
""" Use a token to perform a s2s transaction """
error_url = kwargs.get('error_url', '/my')
access_token = kwargs.get('access_token')
params = {}
if access_token:
params['access_token'] = access_token
invoice_sudo = request.env['account.move'].sudo().browse(invoice_id).exists()
if not invoice_sudo:
params['error'] = 'pay_invoice_invalid_doc'
return request.redirect(_build_url_w_params(error_url, params))
success_url = kwargs.get(
'success_url', "%s?%s" % (invoice_sudo.access_url, url_encode({'access_token': access_token}) if access_token else '')
)
try:
token = request.env['payment.token'].sudo().browse(int(pm_id))
except (ValueError, TypeError):
token = False
token_owner = invoice_sudo.partner_id if request.env.user._is_public() else request.env.user.partner_id
if not token or token.partner_id != token_owner:
params['error'] = 'pay_invoice_invalid_token'
return request.redirect(_build_url_w_params(error_url, params))
vals = {
'payment_token_id': token.id,
'type': 'server2server',
'return_url': _build_url_w_params(success_url, params),
}
tx = invoice_sudo._create_payment_transaction(vals)
PaymentProcessing.add_payment_transaction(tx)
params['success'] = 'pay_invoice'
return request.redirect('/payment/process')
return tx_sudo._get_processing_values()
+41 -16
View File
@@ -1,26 +1,51 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.account.controllers import portal
from odoo.http import request
from odoo.addons.account.controllers import portal
from odoo.addons.portal.controllers.portal import _build_url_w_params
class PortalAccount(portal.PortalAccount):
def _invoice_get_page_view_values(self, invoice, access_token, **kwargs):
values = super(PortalAccount, self)._invoice_get_page_view_values(invoice, access_token, **kwargs)
payment_inputs = request.env['payment.acquirer']._get_available_payment_input(partner=invoice.partner_id, company=invoice.company_id)
# if not connected (using public user), the method _get_available_payment_input will return public user tokens
is_public_user = request.env.user._is_public()
if is_public_user:
# we should not display payment tokens owned by the public user
payment_inputs.pop('pms', None)
token_count = request.env['payment.token'].sudo().search_count([('acquirer_id.company_id', '=', invoice.company_id.id),
('partner_id', '=', invoice.partner_id.id),
])
values['existing_token'] = token_count > 0
values.update(payment_inputs)
# if the current user is connected we set partner_id to his partner otherwise we set it as the invoice partner
# we do this to force the creation of payment tokens to the correct partner and avoid token linked to the public user
values['partner_id'] = invoice.partner_id if is_public_user else request.env.user.partner_id,
values = super()._invoice_get_page_view_values(invoice, access_token, **kwargs)
logged_in = not request.env.user._is_public()
# We set partner_id to the partner id of the current user if logged in, otherwise we set it
# to the invoice partner id. We do this to ensure that payment tokens are assigned to the
# correct partner and to avoid linking tokens to the public user.
partner_id = request.env.user.partner_id.id if logged_in else invoice.partner_id.id
acquirers_sudo = request.env['payment.acquirer'].sudo()._get_compatible_acquirers(
invoice.company_id.id or request.env.company.id,
partner_id,
currency_id=invoice.currency_id.id,
) # In sudo mode to read the fields of acquirers and partner (if not logged in)
tokens = request.env['payment.token'].search(
[('acquirer_id', 'in', acquirers_sudo.ids), ('partner_id', '=', partner_id)]
) # Tokens are cleared at the end if the user is not logged in
fees_by_acquirer = {
acq_sudo: acq_sudo._compute_fees(
invoice.amount_total, invoice.currency_id, invoice.company_id.country_id
) for acq_sudo in acquirers_sudo.filtered('fees_active')
}
values.update({
'acquirers': acquirers_sudo,
'tokens': tokens,
'fees_by_acquirer': fees_by_acquirer,
'show_tokenize_input': logged_in, # Prevent public partner from saving payment methods
'amount': invoice.amount_residual,
'currency': invoice.currency_id,
'partner_id': partner_id,
'access_token': access_token,
'transaction_route': f'/invoice/transaction/{invoice.id}/',
'landing_route': _build_url_w_params(invoice.access_url, {'access_token': access_token})
})
if not logged_in:
# Don't display payment tokens of the invoice partner if the user is not logged in, but
# inform that logging in will make them available.
values.update({
'existing_token': bool(tokens),
'tokens': request.env['payment.token'],
})
return values
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import payment
-26
View File
@@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import fields, models, _
from odoo.tools import float_compare
_logger = logging.getLogger(__name__)
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
def render_invoice_button(self, invoice, submit_txt=None, render_values=None):
values = {
'partner_id': invoice.partner_id.id,
}
if render_values:
values.update(render_values)
return self.acquirer_id.with_context(submit_class='btn btn-primary', submit_txt=submit_txt or _('Pay Now')).sudo().render(
self.reference,
invoice.amount_residual,
invoice.currency_id.id,
values=values,
)
@@ -6,7 +6,7 @@
<xpath expr="//t[@t-foreach='invoices']/tr/td[last()]" position="before">
<td class="text-center">
<t t-set="tx_ids" t-value="invoice.transaction_ids.filtered(lambda tx: tx.state in ('pending', 'authorized', 'done'))"/>
<t t-set="pending_manual_txs" t-value="tx_ids.filtered(lambda tx: tx.state == 'pending' and tx.acquirer_id.provider in ('transfer', 'manual'))"/>
<t t-set="pending_manual_txs" t-value="tx_ids.filtered(lambda tx: tx.state == 'pending' and tx.provider in ('none', 'transfer'))"/>
<a t-if="invoice.state == 'posted' and invoice.payment_state in ('not_paid', 'partial') and invoice.amount_total and invoice.move_type == 'out_invoice' and (pending_manual_txs or not tx_ids or invoice.amount_residual)"
t-att-href="invoice.get_portal_url(anchor='portal_pay')" title="Pay Now" aria-label="Pay now" class="btn btn-sm btn-primary" role="button">
<i class="fa fa-arrow-circle-right"/><span class='d-none d-md-inline'> Pay Now</span>
@@ -16,13 +16,13 @@
<xpath expr="//t[@t-foreach='invoices']/tr/td[hasclass('tx_status')]" position="replace">
<t t-set="last_tx" t-value="invoice.get_portal_last_transaction()"/>
<td class="tx_status text-center">
<t t-if="invoice.state == 'posted' and invoice.payment_state in ('not_paid', 'partial') and (last_tx.state not in ['pending', 'authorized', 'done', 'cancel'] or (last_tx.state == 'pending' and last_tx.acquirer_id.provider in ('transfer', 'manual')))">
<t t-if="invoice.state == 'posted' and invoice.payment_state in ('not_paid', 'partial') and (last_tx.state not in ['pending', 'authorized', 'done', 'cancel'] or (last_tx.state == 'pending' and last_tx.provider in ('none', 'transfer')))">
<span class="badge badge-pill badge-info"><i class="fa fa-fw fa-clock-o"></i><span class="d-none d-md-inline"> Waiting for Payment</span></span>
</t>
<t t-if="invoice.state == 'posted' and last_tx.state == 'authorized'">
<span class="badge badge-pill badge-primary"><i class="fa fa-fw fa-check"/><span class="d-none d-md-inline"> Authorized</span></span>
</t>
<t t-if="invoice.state == 'posted' and last_tx.state == 'pending' and last_tx.acquirer_id.provider not in ('transfer', 'manual')">
<t t-if="invoice.state == 'posted' and last_tx.state == 'pending' and last_tx.provider not in ('none', 'transfer')">
<span class="badge badge-pill badge-warning"><span class="d-none d-md-inline"> Pending</span></span>
</t>
<t t-if="invoice.state == 'posted' and invoice.payment_state in ('paid', 'in_payment') or last_tx.state == 'done'">
@@ -39,7 +39,7 @@
</template>
<template id="portal_invoice_payment" name="Invoice Payment">
<div class="row" t-if="(invoice.amount_residual or not tx_ids) and invoice.state == 'posted' and invoice.payment_state in ('not_paid', 'partial') and invoice.amount_total" id="portal_pay">
<div class="row">
<div class="modal fade" id="pay_with" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
@@ -48,19 +48,12 @@
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
</div>
<div class="modal-body">
<div t-if="pms or acquirers" id="payment_method" class="text-left col-md-13">
<t t-call="payment.payment_tokens_list">
<t t-set="mode" t-value="'payment'"/>
<t t-set="partner_id" t-value="invoice.partner_id.id if request.env.user._is_public() else request.env.user.partner_id.id"/>
<t t-set="success_url" t-value="invoice.get_portal_url()"/>
<t t-set="error_url" t-value="invoice.get_portal_url()"/>
<t t-set="access_token" t-value="access_token or ''"/>
<t t-set="callback_method" t-value="''"/>
<t t-set="form_action" t-value="'/invoice/pay/' + str(invoice.id) + '/s2s_token_tx/'"/>
<t t-set="prepare_tx_url" t-value="'/invoice/pay/' + str(invoice.id) + '/form_tx/'"/>
<t t-set="submit_txt">Pay Now</t>
<t t-set="icon_class" t-value="'fa-lock'"/>
</t>
<div t-if="acquirers or tokens" id="payment_method" class="text-left col-md-13">
<t t-call="payment.checkout"/>
</div>
<div t-else="" class="alert alert-warning">
<strong>No suitable payment option could be found.</strong><br/>
If you believe that it is an error, please contact the website administrator.
</div>
</div>
</div>
@@ -72,7 +65,7 @@
<template id="portal_invoice_page_inherit_payment" name="Payment on My Invoices" inherit_id="account.portal_invoice_page">
<xpath expr="//t[@t-call='portal.portal_record_sidebar']//div[hasclass('o_download_pdf')]" position="before">
<t t-set="tx_ids" t-value="invoice.transaction_ids.filtered(lambda tx: tx.state in ('pending', 'authorized', 'done'))"/>
<t t-set="pending_manual_txs" t-value="tx_ids.filtered(lambda tx: tx.state == 'pending' and tx.acquirer_id.provider in ('transfer', 'manual'))"/>
<t t-set="pending_manual_txs" t-value="tx_ids.filtered(lambda tx: tx.state == 'pending' and tx.provider in ('none', 'transfer'))"/>
<div>
<a href="#" t-if="invoice.state == 'posted' and invoice.payment_state in ('not_paid', 'partial') and invoice.amount_total and invoice.move_type == 'out_invoice' and (pending_manual_txs or not tx_ids or invoice.amount_residual)"
@@ -89,16 +82,13 @@
</xpath>
<xpath expr="//div[@id='invoice_content']//div[hasclass('o_portal_html_view')]" position="before">
<div t-if="invoice.transaction_ids and invoice.amount_total and not success and not error" class="o_account_payment_tx_status" t-att-data-invoice-id="invoice.id">
<t t-call="payment.payment_confirmation_status">
<t t-set="payment_tx_id" t-value="invoice.get_portal_last_transaction()"/>
<t t-set="reference" t-value="invoice.payment_reference"/>
<t t-call="payment.transaction_status">
<t t-set="tx" t-value="invoice.get_portal_last_transaction()"/>
</t>
</div>
<t t-set="tx_ids" t-value="invoice.transaction_ids.filtered(lambda tx: tx.state in ('authorized', 'done'))"/>
<div t-if="(invoice.amount_residual or not tx_ids) and invoice.state == 'posted' and invoice.payment_state in ('not_paid', 'partial') and invoice.amount_total" id="portal_pay">
<div t-if="pms or acquirers" id="payment_method">
<t t-call="account_payment.portal_invoice_payment"/>
</div>
<t t-call="account_payment.portal_invoice_payment"/>
</div>
<div class="panel-body" t-if="existing_token">
<div class="offset-lg-3 col-lg-6">
@@ -135,8 +125,8 @@
<xpath expr="//a[hasclass('close')]" position="after">
<t t-if="success == 'pay_invoice'">
<t t-set="payment_tx_id" t-value="invoice.get_portal_last_transaction()"/>
<span t-if='payment_tx_id.acquirer_id.done_msg' t-raw="payment_tx_id.acquirer_id.done_msg"/>
<div t-if="payment_tx_id.acquirer_id.pending_msg and payment_tx_id.acquirer_id.provider == 'transfer' and invoice.ref">
<span t-if='payment_tx_id.acquirer_id.sudo().done_msg' t-raw="payment_tx_id.acquirer_id.sudo().done_msg"/>
<div t-if="payment_tx_id.acquirer_id.sudo().pending_msg and payment_tx_id.provider == 'transfer' and invoice.ref">
<b>Communication: </b><span t-esc='invoice.ref'/>
</div>
</t>
+6 -6
View File
@@ -1,17 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, SUPERUSER_ID
from . import models
from . import controllers
from . import models
from . import utils
from . import wizards
from odoo import api, SUPERUSER_ID
def reset_payment_provider(cr, registry, provider):
def reset_payment_acquirer(cr, registry, provider):
env = api.Environment(cr, SUPERUSER_ID, {})
acquirers = env['payment.acquirer'].search([('provider', '=', provider)])
acquirers.write({
'view_template_id': acquirers._get_default_view_template_id().id,
'provider': 'manual',
'provider': 'none',
'state': 'disabled',
})
+15 -10
View File
@@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Payment Acquirer',
'version': '2.0',
'category': 'Hidden',
'summary': 'Base Module for Payment Acquirers',
'version': '1.0',
'description': """Payment Acquirer Base Module""",
'depends': ['account'],
'data': [
@@ -13,19 +12,25 @@
'data/payment_icon_data.xml',
'data/payment_acquirer_data.xml',
'data/payment_cron.xml',
'views/payment_views.xml',
'views/account_payment_views.xml',
'views/account_invoice_views.xml',
'views/payment_acquirer_onboarding_templates.xml',
'views/payment_templates.xml',
'views/payment_portal_templates.xml',
'views/assets.xml',
'views/payment_portal_templates.xml',
'views/payment_templates.xml',
'views/account_invoice_views.xml',
'views/account_payment_views.xml',
'views/payment_acquirer_views.xml',
'views/payment_icon_views.xml',
'views/payment_transaction_views.xml',
'views/payment_token_views.xml', # Depends on `action_payment_transaction_linked_to_token`
'views/res_partner_views.xml',
'security/ir.model.access.csv',
'security/payment_security.xml',
'wizards/payment_link_wizard_views.xml',
'wizards/account_payment_register_views.xml',
'wizards/payment_acquirer_onboarding_templates.xml',
'wizards/payment_link_wizard_views.xml',
],
'installable': True,
'auto_install': True,
}
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import portal
+374 -329
View File
@@ -1,376 +1,421 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import hmac
import logging
from unicodedata import normalize
import psycopg2
import urllib.parse
import werkzeug
from odoo import http, _
from odoo import _, http
from odoo.exceptions import UserError, ValidationError
from odoo.http import request
from odoo.osv import expression
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, consteq, ustr
from odoo.tools.float_utils import float_repr
from datetime import datetime, timedelta
from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment.controllers.post_processing import PaymentPostProcessing
from odoo.addons.portal.controllers import portal
_logger = logging.getLogger(__name__)
class PaymentPortal(portal.CustomerPortal):
class PaymentProcessing(http.Controller):
""" This controller contains the foundations for online payments through the portal.
@staticmethod
def remove_payment_transaction(transactions):
tx_ids_list = request.session.get("__payment_tx_ids__", [])
if transactions:
for tx in transactions:
if tx.id in tx_ids_list:
tx_ids_list.remove(tx.id)
It allows to complete a full payment flow without the need of going though a document-based flow
made available by another module's controller.
Such controllers should extend this one to gain access to the _create_transaction static method
that implements the creation of a transaction before its processing, or to override specific
routes and change their behavior globally (e.g. make the /pay route handle sale orders).
The following routes are exposed:
- `/payment/pay` allows for arbitrary payments.
- `/my/payment_method` allows the user to create and delete tokens. It's its own `landing_route`
- `/payment/transaction` is the `transaction_route` for the standard payment flow. It creates a
draft transaction, and return the processing values necessary for the completion of the
transaction.
- `/payment/confirmation` is the `landing_route` for the standard payment flow. It displays the
payment confirmation page to the user when the transaction is validated.
- `/payment/validation` is the `validation_route` for the standard payment method validation
flow. It redirects the user to `/my/payment_method` in order to display the result and allow
to start the flow over.
"""
@http.route(
'/payment/pay', type='http', methods=['GET'], auth='public', website=True, sitemap=False,
)
def payment_pay(
self, reference=None, amount=None, currency_id=None, partner_id=None, company_id=None,
acquirer_id=None, access_token=None, **kwargs
):
""" Display the payment form with optional filtering of payment options.
The filtering takes place on the basis of provided parameters, if any. If a parameter is
incorrect or malformed, it is skipped to avoid preventing the user from making the payment.
In addition to the desired filtering, a second one ensures that none of the following
rules is broken:
- Public users are not allowed to save their payment method as a token.
- Payments made by public users should either *not* be made on behalf of a specific
partner or have an access token validating the partner, amount and currency.
We let access rights and security rules do their job for logged in users.
:param str reference: The custom prefix to compute the full reference
:param str amount: The amount to pay
:param str currency_id: The desired currency, as a `res.currency` id
:param str partner_id: The partner making the payment, as a `res.partner` id
: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 dict kwargs: Optional data. This parameter is not used here
: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 = tuple(map(
self.cast_as_int, (currency_id, acquirer_id, partner_id, company_id)
))
amount = self.cast_as_float(amount)
# Raise an HTTP 404 if a partner is provided with an invalid access token
if partner_id:
if not payment_utils.check_access_token(access_token, partner_id, amount, currency_id):
raise werkzeug.exceptions.NotFound # Don't leak info about the existence of an id
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_is_different = False
if logged_in:
partner_is_different = partner_id and partner_id != user_sudo.partner_id.id
partner_sudo = user_sudo.partner_id
else:
return False
request.session["__payment_tx_ids__"] = tx_ids_list
return True
partner_sudo = request.env['res.partner'].sudo().browse(partner_id).exists()
if not partner_sudo:
return request.redirect(
# Escape special characters to avoid loosing original params when redirected
f'/web/login?redirect={urllib.parse.quote(request.httprequest.full_path)}'
)
@staticmethod
def add_payment_transaction(transactions):
if not transactions:
return False
tx_ids_list = set(request.session.get("__payment_tx_ids__", [])) | set(transactions.ids)
request.session["__payment_tx_ids__"] = list(tx_ids_list)
return True
# Instantiate transaction values to their default if not set in parameters
reference = reference or payment_utils.singularize_reference_prefix(prefix='tx')
amount = amount or 0.0 # If the amount is invalid, set it to 0 to stop the payment flow
company_id = company_id or partner_sudo.company_id.id or user_sudo.company_id.id
currency_id = currency_id or request.env['res.company'].browse(company_id).currency_id.id
@staticmethod
def get_payment_transaction_ids():
# return the ids and not the recordset, since we might need to
# sudo the browse to access all the record
# I prefer to let the controller chose when to access to payment.transaction using sudo
return request.session.get("__payment_tx_ids__", [])
# Make sure that the currency exists and is active
currency = request.env['res.currency'].browse(currency_id).exists()
if not currency or not currency.active:
raise werkzeug.exceptions.NotFound # The currency must exist and be active
@http.route(['/payment/process'], type="http", auth="public", website=True, sitemap=False)
def payment_status_page(self, **kwargs):
# When the customer is redirect to this website page,
# we retrieve the payment transaction list from his session
tx_ids_list = self.get_payment_transaction_ids()
payment_transaction_ids = request.env['payment.transaction'].sudo().browse(tx_ids_list).exists()
# Select all acquirers and tokens that match the constraints
acquirers_sudo = request.env['payment.acquirer'].sudo()._get_compatible_acquirers(
company_id, partner_sudo.id, currency_id=currency.id, preferred_acquirer_id=acquirer_id
) # In sudo mode to read the fields of acquirers and partner (if not logged in)
payment_tokens = request.env['payment.token'].search(
[('acquirer_id', 'in', acquirers_sudo.ids), ('partner_id', '=', partner_sudo.id)]
) if logged_in else request.env['payment.token'] #
render_ctx = {
'payment_tx_ids': payment_transaction_ids.ids,
# Compute the fees taken by acquirers supporting the feature
fees_by_acquirer = {
acq_sudo: acq_sudo._compute_fees(amount, currency, partner_sudo.country_id)
for acq_sudo in acquirers_sudo.filtered('fees_active')
}
return request.render("payment.payment_process_page", render_ctx)
@http.route(['/payment/process/poll'], type="json", auth="public")
def payment_status_poll(self):
# retrieve the transactions
tx_ids_list = self.get_payment_transaction_ids()
# Generate a new access token in case the partner id or the currency id was updated
access_token = payment_utils.generate_access_token(partner_sudo.id, amount, currency.id)
payment_transaction_ids = request.env['payment.transaction'].sudo().search([
('id', 'in', list(tx_ids_list)),
('date', '>=', (datetime.now() - timedelta(days=1)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)),
])
if not payment_transaction_ids:
return {
'success': False,
'error': 'no_tx_found',
}
processed_tx = payment_transaction_ids.filtered('is_processed')
self.remove_payment_transaction(processed_tx)
# create the returned dictionnary
result = {
'success': True,
'transactions': [],
rendering_context = {
'acquirers': acquirers_sudo,
'tokens': payment_tokens,
'fees_by_acquirer': fees_by_acquirer,
'show_tokenize_input': logged_in, # Prevent public partner from saving payment methods
'reference_prefix': reference,
'amount': amount,
'currency': currency,
'partner_id': partner_sudo.id,
'access_token': access_token,
'transaction_route': '/payment/transaction',
'landing_route': '/payment/confirmation',
'partner_is_different': partner_is_different,
**self._get_custom_rendering_context_values(**kwargs),
}
# populate the returned dictionnary with the transactions data
for tx in payment_transaction_ids:
message_to_display = tx.acquirer_id[tx.state + '_msg'] if tx.state in ['done', 'pending', 'cancel'] else None
tx_info = {
'reference': tx.reference,
'state': tx.state,
'return_url': tx.return_url,
'is_processed': tx.is_processed,
'state_message': tx.state_message,
'message_to_display': message_to_display,
'amount': tx.amount,
'currency': tx.currency_id.name,
'acquirer_provider': tx.acquirer_id.provider,
}
tx_info.update(tx._get_processing_info())
result['transactions'].append(tx_info)
return request.render('payment.pay', rendering_context)
tx_to_process = payment_transaction_ids.filtered(lambda x: x.state == 'done' and x.is_processed is False)
try:
tx_to_process._post_process_after_done()
except psycopg2.OperationalError as e:
request.env.cr.rollback()
result['success'] = False
result['error'] = "tx_process_retry"
except Exception as e:
request.env.cr.rollback()
result['success'] = False
result['error'] = str(e)
_logger.exception("Error while processing transaction(s) %s, exception \"%s\"", tx_to_process.ids, str(e))
return result
class WebsitePayment(http.Controller):
@http.route(['/my/payment_method'], type='http', auth="user", website=True)
@http.route('/my/payment_method', type='http', methods=['GET'], auth='user', website=True)
def payment_method(self, **kwargs):
acquirers = list(request.env['payment.acquirer'].search([
('state', 'in', ['enabled', 'test']), ('registration_view_template_id', '!=', False),
('payment_flow', '=', 's2s'), ('company_id', '=', request.env.company.id)
]))
""" Display the form to manage payment methods.
:param dict kwargs: Optional data. This parameter is not used here
:return: The rendered manage form
:rtype: str
"""
partner = request.env.user.partner_id
payment_tokens = partner.payment_token_ids
payment_tokens |= partner.commercial_partner_id.sudo().payment_token_ids
return_url = request.params.get('redirect', '/my/payment_method')
values = {
'pms': payment_tokens,
'acquirers': acquirers,
'error_message': [kwargs['error']] if kwargs.get('error') else False,
'return_url': return_url,
'bootstrap_formatting': True,
'partner_id': partner.id
acquirers_sudo = request.env['payment.acquirer'].sudo()._get_compatible_acquirers(
request.env.company.id, partner.id, force_tokenization=True
)
tokens = set(partner.payment_token_ids).union(
partner.commercial_partner_id.sudo().payment_token_ids
) # Show all partner's tokens, regardless of which acquirer is available
access_token = payment_utils.generate_access_token(partner.id, None, None)
rendering_context = {
'acquirers': acquirers_sudo,
'tokens': tokens,
'reference_prefix': payment_utils.singularize_reference_prefix(prefix='validation'),
'partner_id': partner.id,
'access_token': access_token,
'transaction_route': '/payment/transaction',
'validation_route': '/payment/validation',
'landing_route': '/my/payment_method',
**self._get_custom_rendering_context_values(**kwargs),
}
return request.render("payment.pay_methods", values)
return request.render('payment.payment_methods', rendering_context)
@http.route(['/website_payment/pay'], type='http', auth='public', website=True, sitemap=False)
def pay(self, reference='', order_id=None, amount=False, currency_id=None, acquirer_id=None, partner_id=False, access_token=None, **kw):
def _get_custom_rendering_context_values(self, **kwargs):
""" Return a dict of additional rendering context values.
:param dict kwargs: Optional data. This parameter is not used here
:return: The dict of additional rendering context values
:rtype: dict
"""
Generic payment page allowing public and logged in users to pay an arbitrary amount.
return {}
In the case of a public user access, we need to ensure that the payment is made anonymously - e.g. it should not be
possible to pay for a specific partner simply by setting the partner_id GET param to a random id. In the case where
a partner_id is set, we do an access_token check based on the payment.link.wizard model (since links for specific
partners should be created from there and there only). Also noteworthy is the filtering of s2s payment methods -
we don't want to create payment tokens for public users.
@http.route('/payment/transaction', type='json', auth='public')
def payment_transaction(self, amount, currency_id, partner_id, access_token, **kwargs):
""" Create a draft transaction and return its processing values.
In the case of a logged in user, then we let access rights and security rules do their job.
:param float|None amount: The amount to pay in the given currency.
None if in a payment method validation operation
:param int|None currency_id: The currency of the transaction, as a `res.currency` id.
None if in a payment method validation operation
:param int partner_id: The partner making the payment, as a `res.partner` id
:param str access_token: The access token used to authenticate the partner
:param dict kwargs: Locally unused data passed to `_create_transaction`
:return: The mandatory values for the processing of the transaction
:rtype: dict
:raise: ValidationError if the access token is invalid
"""
env = request.env
user = env.user.sudo()
reference = normalize('NFKD', reference).encode('ascii','ignore').decode('utf-8')
if partner_id and not access_token:
raise werkzeug.exceptions.NotFound
if partner_id and access_token:
token_ok = request.env['payment.link.wizard'].check_token(access_token, int(partner_id), float(amount), int(currency_id))
if not token_ok:
raise werkzeug.exceptions.NotFound
# Check the access token against the transaction values
amount = amount and float(amount) # Cast as float in case the JS stripped the '.0'
if not payment_utils.check_access_token(access_token, partner_id, amount, currency_id):
raise ValidationError(_("The access token is invalid."))
invoice_id = kw.get('invoice_id')
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
)
# Default values
values = {
'amount': 0.0,
'currency': user.company_id.currency_id,
}
# The generic validation and landing routes 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.validation_route = tx_sudo.validation_route \
and f'{tx_sudo.validation_route}&access_token={access_token}'
tx_sudo.landing_route = f'{tx_sudo.landing_route}' \
f'?tx_id={tx_sudo.id}&access_token={access_token}'
# Check sale order
if order_id:
try:
order_id = int(order_id)
if partner_id:
# `sudo` needed if the user is not connected.
# A public user woudn't be able to read the sale order.
# With `partner_id`, an access_token should be validated, preventing a data breach.
order = env['sale.order'].sudo().browse(order_id)
else:
order = env['sale.order'].browse(order_id)
values.update({
'currency': order.currency_id,
'amount': order.amount_total,
'order_id': order_id
})
except:
order_id = None
return tx_sudo._get_processing_values()
if invoice_id:
try:
values['invoice_id'] = int(invoice_id)
except ValueError:
invoice_id = None
def _create_transaction(
self, payment_option_id, reference_prefix, amount, currency_id, partner_id, flow,
tokenization_requested, validation_route, landing_route, custom_create_values=None, **kwargs
):
""" Create a draft transaction based on the payment context and return it.
# Check currency
if currency_id:
try:
currency_id = int(currency_id)
values['currency'] = env['res.currency'].browse(currency_id)
except:
pass
# Check amount
if amount:
try:
amount = float(amount)
values['amount'] = amount
except:
pass
# Check reference
reference_values = order_id and {'sale_order_ids': [(4, order_id)]} or {}
values['reference'] = env['payment.transaction']._compute_reference(values=reference_values, prefix=reference)
# Check acquirer
acquirers = None
if order_id and order:
cid = order.company_id.id
elif kw.get('company_id'):
try:
cid = int(kw.get('company_id'))
except:
cid = user.company_id.id
:param int payment_option_id: The payment option handling the transaction, as a
`payment.acquirer` id or a `payment.token` id
:param str reference_prefix: The custom prefix to compute the full reference
:param float|None amount: The amount to pay in the given currency.
None if in a payment method validation operation
:param int|None currency_id: The currency of the transaction, as a `res.currency` id.
None if in a payment method validation operation
:param int partner_id: The partner making the payment, as a `res.partner` id
:param str flow: The online payment flow of the transaction: 'redirect', 'direct' or 'token'
:param bool tokenization_requested: Whether the user requested that a token is created
:param str validation_route: The route the user is redirected to in order to refund a
validation transaction
:param str landing_route: The route the user is redirected to after the transaction
: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`
:return: The sudoed transaction that was created
:rtype: recordset of `payment.transaction`
:raise: UserError if the flow is invalid
"""
# Prepare create values
if flow in ['redirect', 'direct']: # Direct payment or payment with redirection
acquirer_sudo = request.env['payment.acquirer'].sudo().browse(payment_option_id)
token_id = None
tokenization_required_or_requested = acquirer_sudo._is_tokenization_required(
provider=acquirer_sudo.provider, **kwargs
) or tokenization_requested
tokenize = bool(
# Public users are not allowed to save tokens as their partner is unknown
not request.env.user._is_public()
# Don't tokenize if the user tried to force it through the browser's developer tools
and acquirer_sudo.allow_tokenization
# Token is only created if required by the flow or requested by the user
and tokenization_required_or_requested
)
elif flow == 'token': # Payment by token
token_sudo = request.env['payment.token'].sudo().browse(payment_option_id)
acquirer_sudo = token_sudo.acquirer_id
token_id = payment_option_id
tokenize = False
else:
cid = user.company_id.id
raise UserError(
_("The payment should either be direct, with redirection, or made by a token.")
)
reference = request.env['payment.transaction']._compute_reference(
acquirer_sudo.provider,
prefix=reference_prefix,
**(custom_create_values or {}),
**kwargs
)
if validation_route: # Acquirers determine the amount and currency in validation operations
amount = acquirer_sudo._get_validation_amount()
currency_id = acquirer_sudo._get_validation_currency().id
# Check partner
if not user._is_public():
# NOTE: this means that if the partner was set in the GET param, it gets overwritten here
# This is something we want, since security rules are based on the partner - assuming the
# access_token checked out at the start, this should have no impact on the payment itself
# existing besides making reconciliation possibly more difficult (if the payment partner is
# not the same as the invoice partner, for example)
partner_id = user.partner_id.id
elif partner_id:
partner_id = int(partner_id)
values.update({
# Create the transaction
tx_sudo = request.env['payment.transaction'].sudo().create({
'acquirer_id': acquirer_sudo.id,
'reference': reference,
'amount': amount,
'currency_id': currency_id,
'partner_id': partner_id,
'bootstrap_formatting': True,
'error_msg': kw.get('error_msg')
})
'token_id': token_id,
'operation': f'online_{flow}' if not validation_route else 'validation',
'tokenize': tokenize,
'validation_route': validation_route,
'landing_route': landing_route,
**(custom_create_values or {}),
}) # In sudo mode to allow writing on callback fields
# Validation routes require the transaction id
tx_sudo.validation_route = validation_route and f'{validation_route}?tx_id={tx_sudo.id}'
acquirer_domain = ['&', ('state', 'in', ['enabled', 'test']), ('company_id', '=', cid)]
if partner_id:
partner = request.env['res.partner'].browse([partner_id])
acquirer_domain = expression.AND([
acquirer_domain,
['|', ('country_ids', '=', False), ('country_ids', 'in', [partner.sudo().country_id.id])]
])
if acquirer_id:
acquirers = env['payment.acquirer'].browse(int(acquirer_id))
if order_id:
acquirers = env['payment.acquirer'].search(acquirer_domain)
if not acquirers:
acquirers = env['payment.acquirer'].search(acquirer_domain)
# s2s mode will always generate a token, which we don't want for public users
valid_flows = ['form', 's2s'] if not user._is_public() else ['form']
values['acquirers'] = [acq for acq in acquirers if acq.payment_flow in valid_flows]
if partner_id:
values['pms'] = request.env['payment.token'].search([
('acquirer_id', 'in', acquirers.ids),
('partner_id', 'child_of', partner.commercial_partner_id.id)
])
if flow == 'token':
tx_sudo._send_payment_request() # Payments by token process transactions immediately
else:
values['pms'] = []
tx_sudo._log_sent_message()
# Monitor the transaction to make it available in the portal
PaymentPostProcessing.monitor_transactions(tx_sudo)
return request.render('payment.pay', values)
return tx_sudo
@http.route(['/website_payment/transaction/<string:reference>/<string:amount>/<string:currency_id>',
'/website_payment/transaction/v2/<string:amount>/<string:currency_id>/<path:reference>',
'/website_payment/transaction/v2/<string:amount>/<string:currency_id>/<path:reference>/<int:partner_id>'], type='json', auth='public')
def transaction(self, acquirer_id, reference, amount, currency_id, partner_id=False, **kwargs):
acquirer = request.env['payment.acquirer'].browse(acquirer_id)
order_id = kwargs.get('order_id')
invoice_id = kwargs.get('invoice_id')
@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.
reference_values = order_id and {'sale_order_ids': [(4, order_id)]} or {}
reference = request.env['payment.transaction']._compute_reference(values=reference_values, prefix=reference)
values = {
'acquirer_id': int(acquirer_id),
'reference': reference,
'amount': float(amount),
'currency_id': int(currency_id),
'partner_id': partner_id,
'type': 'form_save' if acquirer.save_token != 'none' and partner_id else 'form',
}
if order_id:
values['sale_order_ids'] = [(6, 0, [order_id])]
elif invoice_id:
values['invoice_ids'] = [(6, 0, [invoice_id])]
reference_values = order_id and {'sale_order_ids': [(4, order_id)]} or {}
reference_values.update(acquirer_id=int(acquirer_id))
values['reference'] = request.env['payment.transaction']._compute_reference(values=reference_values, prefix=reference)
tx = request.env['payment.transaction'].sudo().with_context(lang=None).create(values)
secret = request.env['ir.config_parameter'].sudo().get_param('database.secret')
token_str = '%s%s%s' % (tx.id, tx.reference, float_repr(tx.amount, precision_digits=tx.currency_id.decimal_places))
token = hmac.new(secret.encode('utf-8'), token_str.encode('utf-8'), hashlib.sha256).hexdigest()
tx.return_url = '/website_payment/confirm?tx_id=%d&access_token=%s' % (tx.id, token)
PaymentProcessing.add_payment_transaction(tx)
render_values = {
'partner_id': partner_id,
}
return acquirer.sudo().render(tx.reference, float(amount), int(currency_id), values=render_values)
@http.route(['/website_payment/token/<string:reference>/<string:amount>/<string:currency_id>',
'/website_payment/token/v2/<string:amount>/<string:currency_id>/<path:reference>',
'/website_payment/token/v2/<string:amount>/<string:currency_id>/<path:reference>/<int:partner_id>'], type='http', auth='public', website=True)
def payment_token(self, pm_id, reference, amount, currency_id, partner_id=False, return_url=None, **kwargs):
token = request.env['payment.token'].browse(int(pm_id))
order_id = kwargs.get('order_id')
if not token:
return request.redirect('/website_payment/pay?error_msg=%s' % _('Cannot setup the payment.'))
values = {
'acquirer_id': token.acquirer_id.id,
'reference': reference,
'amount': float(amount),
'currency_id': int(currency_id),
'partner_id': int(partner_id),
'payment_token_id': int(pm_id),
'type': 'server2server',
'return_url': return_url,
}
if order_id:
values['sale_order_ids'] = [(6, 0, [int(order_id)])]
tx = request.env['payment.transaction'].sudo().with_context(lang=None).create(values)
PaymentProcessing.add_payment_transaction(tx)
try:
tx.s2s_do_transaction()
secret = request.env['ir.config_parameter'].sudo().get_param('database.secret')
token_str = '%s%s%s' % (tx.id, tx.reference, float_repr(tx.amount, precision_digits=tx.currency_id.decimal_places))
token = hmac.new(secret.encode('utf-8'), token_str.encode('utf-8'), hashlib.sha256).hexdigest()
tx.return_url = return_url or '/website_payment/confirm?tx_id=%d&access_token=%s' % (tx.id, token)
except Exception as e:
_logger.exception(e)
return request.redirect('/payment/process')
@http.route(['/website_payment/confirm'], type='http', auth='public', website=True, sitemap=False)
def confirm(self, **kw):
tx_id = int(kw.get('tx_id', 0))
access_token = kw.get('access_token')
:param str tx_id: The transaction to confirm, as a `payment.transaction` id
:param str access_token: The access token used to verify the user
:param dict kwargs: Optional data. This parameter is not used here
:raise: werkzeug.exceptions.NotFound if the access token is invalid
"""
tx_id = self.cast_as_int(tx_id)
if tx_id:
if access_token:
tx = request.env['payment.transaction'].sudo().browse(tx_id)
secret = request.env['ir.config_parameter'].sudo().get_param('database.secret')
valid_token_str = '%s%s%s' % (tx.id, tx.reference, float_repr(tx.amount, precision_digits=tx.currency_id.decimal_places))
valid_token = hmac.new(secret.encode('utf-8'), valid_token_str.encode('utf-8'), hashlib.sha256).hexdigest()
if not consteq(ustr(valid_token), access_token):
raise werkzeug.exceptions.NotFound
else:
tx = request.env['payment.transaction'].browse(tx_id)
if tx.state in ['done', 'authorized']:
status = 'success'
message = tx.acquirer_id.done_msg
elif tx.state == 'pending':
tx_sudo = request.env['payment.transaction'].sudo().browse(tx_id)
# Raise an HTTP 404 if the access token is invalid
if not payment_utils.check_access_token(
access_token, tx_sudo.partner_id.id, tx_sudo.amount, tx_sudo.currency_id.id
):
raise werkzeug.exceptions.NotFound # Don't leak info about existence of an id
# Fetch the appropriate status message configured on the acquirer
if tx_sudo.state == 'draft':
status = 'info'
message = tx_sudo.state_message \
or _("This payment has not been processed yet.")
elif tx_sudo.state == 'pending':
status = 'warning'
message = tx.acquirer_id.pending_msg
message = tx_sudo.acquirer_id.pending_msg
elif tx_sudo.state in ('authorized', 'done'):
status = 'success'
message = tx_sudo.acquirer_id.done_msg
else:
status = 'danger'
message = tx.state_message or _('An error occured during the processing of this payment')
PaymentProcessing.remove_payment_transaction(tx)
return request.render('payment.confirm', {'tx': tx, 'status': status, 'message': message})
message = tx_sudo.state_message \
or _("An error occurred during the processing of this payment.")
# Display the payment confirmation page to the user
PaymentPostProcessing.remove_transactions(tx_sudo)
render_values = {
'tx': tx_sudo,
'status': status,
'message': message
}
return request.render('payment.confirm', render_values)
else:
# Display the portal homepage to the user
return request.redirect('/my/home')
@http.route('/payment/validation', type='http', methods=['GET'], auth='user', website=True)
def payment_validation_transaction(self, tx_id, access_token):
""" Refund a validation transaction and redirect the user to the landing route.
:param str tx_id: The validation transaction, as a `payment.transaction` id
:param str access_token: The access token used to verify the user
"""
# Raise an HTTP 404 if the tx id or the access token is invalid
tx_id = self.cast_as_int(tx_id)
tx_sudo = request.env['payment.transaction'].sudo().browse(tx_id).exists()
if not tx_sudo or not payment_utils.check_access_token(
access_token, tx_sudo.partner_id.id, tx_sudo.amount, tx_sudo.currency_id.id
):
raise werkzeug.exceptions.NotFound # Don't leak info about existence of an id
self._refund_validation_transaction(tx_id)
return request.redirect(tx_sudo.landing_route)
def _refund_validation_transaction(self, tx_id):
""" Refund a validation transaction and remove it from post-processing.
:param str tx_id: The validation transaction to refund, as a `payment.transaction` id
:return: The refunded transaction
:rtype: recordset of `payment.transaction`
:raise: ValidationError if the transaction id is invalid
"""
tx_id = self.cast_as_int(tx_id)
tx_sudo = request.env['payment.transaction'].sudo().browse(tx_id).exists()
if not tx_sudo:
raise werkzeug.exceptions.NotFound
if tx_sudo.operation == 'validation': # Don't allow to refund non-validation transactions
tx_sudo._send_refund_request()
PaymentPostProcessing.remove_transactions(tx_sudo)
return tx_sudo
@staticmethod
def cast_as_int(str_value):
""" Cast a string as an `int` and return it.
If the conversion fails, `None` is returned instead.
:param str str_value: The value to cast as an `int`
:return: The casted value, possibly replaced by None if incompatible
:rtype: int|None
"""
try:
return int(str_value)
except (TypeError, ValueError, OverflowError):
return None
@staticmethod
def cast_as_float(str_value):
""" Cast a string as a `float` and return it.
If the conversion fails, `None` is returned instead.
:param str str_value: The value to cast as a `float`
:return: The casted value, possibly replaced by None if incompatible
:rtype: float|None
"""
try:
return float(str_value)
except (TypeError, ValueError, OverflowError):
return None
@@ -0,0 +1,139 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from datetime import timedelta
import psycopg2
from odoo import fields, http
from odoo.http import request
_logger = logging.getLogger(__name__)
class PaymentPostProcessing(http.Controller):
"""
This controller is responsible for the monitoring and finalization of the post-processing of
transactions.
It exposes the route `/payment/status`: All payment flows must go through this route at some
point to allow the user checking on the transactions' status, and to trigger the finalization of
their post-processing.
"""
MONITORED_TX_IDS_KEY = '__payment_monitored_tx_ids__'
@http.route('/payment/status', type='http', auth='public', website=True, sitemap=False)
def display_status(self, **kwargs):
""" Display the payment status page.
:param dict kwargs: Optional data. This parameter is not used here
:return: The rendered status page
:rtype: str
"""
return request.render('payment.payment_status')
@http.route('/payment/status/poll', type='json', auth='public')
def poll_status(self):
""" Fetch the transactions to display on the status page and finalize their post-processing.
:return: The post-processing values of the transactions
:rtype: dict
"""
# Retrieve recent user's transactions from the session
limit_date = fields.Datetime.now() - timedelta(days=1)
monitored_txs = request.env['payment.transaction'].sudo().search([
('id', 'in', self.get_monitored_transaction_ids()),
('last_state_change', '>=', limit_date)
])
if not monitored_txs: # The transaction was not correctly created
return {
'success': False,
'error': 'no_tx_found',
}
# Build the list of display values with the display message and post-processing values
display_values_list = []
for tx in monitored_txs:
display_message = None
if tx.state == 'pending':
display_message = tx.acquirer_id.pending_msg
elif tx.state == 'done':
display_message = tx.acquirer_id.done_msg
elif tx.state == 'cancel':
display_message = tx.acquirer_id.cancel_msg
display_values_list.append({
'display_message': display_message,
**tx._get_post_processing_values(),
})
# Stop monitoring already post-processed transactions
post_processed_txs = monitored_txs.filtered('is_post_processed')
self.remove_transactions(post_processed_txs)
# Finalize post-processing of transactions before displaying them to the user
txs_to_post_process = (monitored_txs - post_processed_txs).filtered(
lambda t: t.state == 'done'
)
success, error = True, None
try:
txs_to_post_process._finalize_post_processing()
except psycopg2.OperationalError: # A collision of accounting sequences occurred
request.env.cr.rollback() # Rollback and try later
success = False
error = 'tx_process_retry'
except Exception as e:
request.env.cr.rollback()
success = False
error = str(e)
_logger.exception(
"encountered an error while post-processing transactions with ids %s:\n%s",
', '.join([str(tx_id) for tx_id in txs_to_post_process.ids]), e
)
return {
'success': success,
'error': error,
'display_values_list': display_values_list,
}
@classmethod
def monitor_transactions(cls, transactions):
""" Add the ids of the provided transactions to the list of monitored transaction ids.
:param recordset transactions: The transactions to monitor, as a `payment.transaction`
recordset
:return: None
"""
if transactions:
monitored_tx_ids = request.session.get(cls.MONITORED_TX_IDS_KEY, [])
request.session[cls.MONITORED_TX_IDS_KEY] = list(
set(monitored_tx_ids).union(transactions.ids)
)
@classmethod
def get_monitored_transaction_ids(cls):
""" Return the ids of transactions being monitored.
Only the ids and not the recordset itself is returned to allow the caller browsing the
recordset with sudo privileges, and using the ids in a custom query.
:return: The ids of transactions being monitored
:rtype: list
"""
return request.session.get(cls.MONITORED_TX_IDS_KEY, [])
@classmethod
def remove_transactions(cls, transactions):
""" Remove the ids of the provided transactions from the list of monitored transaction ids.
:param recordset transactions: The transactions to remove, as a `payment.transaction`
recordset
:return: None
"""
if transactions:
monitored_tx_ids = request.session.get(cls.MONITORED_TX_IDS_KEY, [])
request.session[cls.MONITORED_TX_IDS_KEY] = [
tx_id for tx_id in monitored_tx_ids if tx_id not in transactions.ids
]
+2
View File
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_payment_method_electronic_in" model="account.payment.method">
<field name="name">Electronic</field>
<field name="code">electronic</field>
<field name="payment_type">inbound</field>
</record>
</odoo>
+269 -226
View File
@@ -1,105 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<template id="default_acquirer_button">
<input type="hidden" name="data_set" t-att-data-action-url="tx_url"/>
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<t t-if="return_url">
<input type="hidden" name="return_url" t-att-value="return_url"/>
</t>
<input type="hidden" name="reference" t-att-value="reference"/>
<input type="hidden" name="amount" t-att-value="amount"/>
<input type="hidden" name="currency" t-att-value="currency.name"/>
</template>
<record id="payment_acquirer_odoo_by_adyen" model="payment.acquirer">
<field name="name">Odoo Payments by Adyen</field>
<field name="display_as">Credit Card (powered by Odoo Payments)</field>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="image_128" type="base64" file="payment_odoo_by_adyen/static/src/img/odoo_icon.png"/>
<field name="module_id" ref="base.module_payment_odoo_by_adyen"/>
<field name="sequence">1</field>
<field name="description" type="html">
<p>
A payment gateway to accept online payments via credit cards, debit cards and bank transfers.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Subscriptions</li>
<li class="list-inline-item"><i class="fa fa-check"/>Save Cards</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- https://www.adyen.com/payment-methods -->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_bancontact"),
ref("payment.payment_icon_cc_maestro"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_visa"),
ref("payment.payment_icon_cc_discover"),
ref("payment.payment_icon_cc_diners_club_intl"),
ref("payment.payment_icon_cc_jcb"),
ref("payment.payment_icon_cc_unionpay")])]'/>
</record>
<record id="payment_acquirer_buckaroo" model="payment.acquirer">
<field name="name">Buckaroo</field>
<field name="display_as">Credit Card (powered by Buckaroo)</field>
<field name="image_128" type="base64" file="payment_buckaroo/static/src/img/buckaroo_icon.png"/>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="module_id" ref="base.module_payment_buckaroo"/>
<field name="description" type="html">
<p>
A payment gateway to accept online payments via credit cards.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- https://www.buckaroo-payments.com/products/payment-methods/ -->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_bancontact"),
ref("payment.payment_icon_cc_maestro"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_visa"),
ref("payment.payment_icon_cc_american_express")])]'/>
</record>
<record id="payment_acquirer_ingenico" model="payment.acquirer">
<field name="name">Ingenico</field>
<field name="display_as">Credit Card (powered by Ingenico)</field>
<field name="sequence">2</field>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="image_128" type="base64" file="payment_ingenico/static/src/img/ingenico_icon.png"/>
<field name="module_id" ref="base.module_payment_ingenico"/>
<field name="description" type="html">
<p>
Ingenico Payment Services (formerly Ogone) supports a wide range of payment methods: credit cards, debit cards, bank transfers, Bancontact, iDeal, Giropay.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
<li class="list-inline-item"><i class="fa fa-check"/>Subscriptions</li>
<li class="list-inline-item"><i class="fa fa-check"/>Save Cards</li>
<li class="list-inline-item"><i class="fa fa-check"/>Embedded Credit Card Form</li>
</ul>
</field>
<!-- https://payment-services.ingenico.com/~/media/files/130806_product_sheet_ingenico_collect_en.ashx?la=en -->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_ideal"),
ref("payment.payment_icon_cc_bancontact"),
ref("payment.payment_icon_cc_maestro"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_visa")])]'/>
</record>
<record id="payment_acquirer_adyen" model="payment.acquirer">
<field name="name">Adyen</field>
<field name="display_as">Credit Card (powered by Adyen)</field>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="image_128" type="base64" file="payment_adyen/static/src/img/adyen_icon.png"/>
<field name="module_id" ref="base.module_payment_adyen"/>
<field name="description" type="html">
<p>
A payment gateway to accept online payments via credit cards, debit cards and bank transfers.
A payment gateway to accept online payments via credit cards, debit cards and bank
transfers.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
@@ -107,22 +17,51 @@
</ul>
</field>
<!-- https://www.adyen.com/payment-methods -->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_bancontact"),
ref("payment.payment_icon_cc_maestro"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_visa"),
ref("payment.payment_icon_cc_discover"),
ref("payment.payment_icon_cc_diners_club_intl"),
ref("payment.payment_icon_cc_jcb"),
ref("payment.payment_icon_cc_unionpay")])]'/>
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_bancontact'),
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_visa'),
ref('payment.payment_icon_cc_discover'),
ref('payment.payment_icon_cc_diners_club_intl'),
ref('payment.payment_icon_cc_jcb'),
ref('payment.payment_icon_cc_unionpay'),
])]"/>
</record>
<record id="payment_acquirer_alipay" model="payment.acquirer">
<field name="name">Alipay</field>
<field name="display_as">Credit Card (powered by Alipay)</field>
<field name="image_128" type="base64" file="payment_alipay/static/description/icon.png"/>
<field name="module_id" ref="base.module_payment_alipay"/>
<field name="description" type="html">
<p>
Alipay is the most popular online payment platform in China. Chinese consumers can
buy online using their Alipay eWallet.
</p>
<ul class="list-inline">
<li><i class="fa fa-check"/>Online Payment</li>
<li><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- https://intl.alipay.com/ihome/home/about/buy.htm?topic=paymentMethods -->
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_jcb'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_western_union'),
ref('payment.payment_icon_cc_webmoney'),
ref('payment.payment_icon_cc_visa'),
])]"/>
</record>
<record id="payment_acquirer_authorize" model="payment.acquirer">
<field name="name">Authorize.net</field>
<field name="display_as">Credit Card (powered by Authorize)</field>
<field name="sequence">3</field>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="image_128" type="base64" file="payment_authorize/static/src/img/authorize_icon.png"/>
<field name="image_128"
type="base64"
file="payment_authorize/static/src/img/authorize_icon.png"/>
<field name="module_id" ref="base.module_payment_authorize"/>
<field name="description" type="html">
<p>
@@ -138,59 +77,108 @@
</ul>
</field>
<!-- https://www.authorize.net/solutions/merchantsolutions/onlinemerchantaccount/ -->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_maestro"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_discover"),
ref("payment.payment_icon_cc_diners_club_intl"),
ref("payment.payment_icon_cc_jcb"),
ref("payment.payment_icon_cc_visa")])]'/>
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_discover'),
ref('payment.payment_icon_cc_diners_club_intl'),
ref('payment.payment_icon_cc_jcb'),
ref('payment.payment_icon_cc_visa'),
])]"/>
</record>
<record id="payment_acquirer_transfer" model="payment.acquirer">
<field name="name">Wire Transfer</field>
<field name="sequence">2</field>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="image_128" type="base64" file="payment_transfer/static/src/img/transfer_icon.png"/>
<field name="module_id" ref="base.module_payment_transfer"/>
<field name="pending_msg">&lt;i&gt;Pending&lt;/i&gt;... The order will be validated after the payment.</field>
<record id="payment_acquirer_buckaroo" model="payment.acquirer">
<field name="name">Buckaroo</field>
<field name="display_as">Credit Card (powered by Buckaroo)</field>
<field name="image_128"
type="base64"
file="payment_buckaroo/static/src/img/buckaroo_icon.png"/>
<field name="module_id" ref="base.module_payment_buckaroo"/>
<field name="description" type="html">
<p>
Provide instructions to customers so that they can pay their orders manually.
</p>
</field>
</record>
<record id="payment_acquirer_sips" model="payment.acquirer">
<field name="name">Sips</field>
<field name="display_as">Credit Card (powered by Sips)</field>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="image_128" type="base64" file="payment_sips/static/src/img/sips_icon.png"/>
<field name="module_id" ref="base.module_payment_sips"/>
<field name="description" type="html">
<p>
A payment gateway from Atos Worldline to accept online payments via credit cards.
A payment gateway to accept online payments via credit cards.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- http://sips.worldline.com/en-us/home/features/payment-types-and-acquirers.html -->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_maestro"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_discover"),
ref("payment.payment_icon_cc_diners_club_intl"),
ref("payment.payment_icon_cc_jcb"),
ref("payment.payment_icon_cc_american_express"),
ref("payment.payment_icon_cc_bancontact"),
ref("payment.payment_icon_cc_unionpay"),
ref("payment.payment_icon_cc_visa")])]'/>
<!-- https://www.buckaroo-payments.com/products/payment-methods/ -->
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_bancontact'),
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_visa'),
ref('payment.payment_icon_cc_american_express'),
])]"/>
</record>
<record id="payment_acquirer_odoo" model="payment.acquirer">
<field name="name">Odoo Payments</field>
<field name="sequence">10</field>
<field name="display_as">Credit Card (powered by Odoo Payments)</field>
<field name="image_128" type="base64" file="payment_odoo/static/src/img/odoo_icon.png"/>
<field name="module_id" ref="base.module_payment_odoo"/>
<field name="description" type="html">
<p>
A payment gateway to accept online payments via credit cards, debit cards and bank
transfers.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Subscriptions</li>
<li class="list-inline-item"><i class="fa fa-check"/>Save Cards</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- https://www.adyen.com/payment-methods -->
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_bancontact'),
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_visa'),
ref('payment.payment_icon_cc_discover'),
ref('payment.payment_icon_cc_diners_club_intl'),
ref('payment.payment_icon_cc_jcb'),
ref('payment.payment_icon_cc_unionpay'),
])]"/>
</record>
<record id="payment_acquirer_ogone" model="payment.acquirer">
<field name="name">Ogone</field>
<field name="display_as">Credit Card (powered by Ogone)</field>
<field name="image_128"
type="base64"
file="payment_ogone/static/src/img/ingenico_icon.png"/>
<field name="module_id" ref="base.module_payment_ogone"/>
<field name="description" type="html">
<p>
Ogone supports a wide range of payment methods: credit cards, debit cards, bank
transfers, Bancontact, iDeal, Giropay.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
<li class="list-inline-item"><i class="fa fa-check"/>Subscriptions</li>
<li class="list-inline-item"><i class="fa fa-check"/>Save Cards</li>
<li class="list-inline-item"><i class="fa fa-check"/>Embedded Credit Card Form</li>
</ul>
</field>
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_ideal'),
ref('payment.payment_icon_cc_bancontact'),
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_visa'),
])]"/>
</record>
<record id="payment_acquirer_paypal" model="payment.acquirer">
<field name="name">PayPal</field>
<field name="sequence">1</field>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="image_128" type="base64" file="payment_paypal/static/src/img/paypal_icon.png"/>
<field name="module_id" ref="base.module_payment_paypal"/>
<field name="description" type="html">
@@ -203,20 +191,123 @@
</ul>
</field>
<!-- https://www.paypal.com/us/selfhelp/article/Which-credit-cards-can-I-accept-with-PayPal-Merchant-Services-FAQ1525#business -->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_maestro"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_discover"),
ref("payment.payment_icon_cc_diners_club_intl"),
ref("payment.payment_icon_cc_jcb"),
ref("payment.payment_icon_cc_american_express"),
ref("payment.payment_icon_cc_unionpay"),
ref("payment.payment_icon_cc_visa")])]'/>
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_discover'),
ref('payment.payment_icon_cc_diners_club_intl'),
ref('payment.payment_icon_cc_jcb'),
ref('payment.payment_icon_cc_american_express'),
ref('payment.payment_icon_cc_unionpay'),
ref('payment.payment_icon_cc_visa'),
])]"/>
</record>
<record id="payment_acquirer_payulatam" model="payment.acquirer">
<field name="name">PayU Latam</field>
<field name="display_as">Credit Card (powered by PayU Latam)</field>
<field name="image_128"
type="base64"
file="payment_payulatam/static/src/img/payulatam_icon.png"/>
<field name="module_id" ref="base.module_payment_payulatam"/>
<field name="description" type="html">
<p>
PayU is a leading financial services provider in Colombia, Argentina, Brazil, Chile,
Mexico, Panama, and Peru. It allows merchant to accept local payments with just one
account and integration.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- https://www.payulatam.com/medios-de-pago/ -->
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_diners_club_intl'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_american_express'),
ref('payment.payment_icon_cc_visa'),
ref('payment.payment_icon_cc_codensa_easy_credit'),
])]"/>
</record>
<record id="payment_acquirer_payumoney" model="payment.acquirer">
<field name="name">PayUmoney</field>
<field name="display_as">Credit Card (powered by PayUmoney)</field>
<field name="image_128"
type="base64"
file="payment_payumoney/static/src/img/payumoney_icon.png"/>
<field name="module_id" ref="base.module_payment_payumoney"/>
<field name="description" type="html">
<p>
PayUmoney is an online payments solutions company serving the Indian market.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- See https://www.payumoney.com/selfcare.html?userType=seller
> Banks & Cards > What options do you have in the Credit Card payment? -->
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_american_express'),
ref('payment.payment_icon_cc_visa'),
])]"/>
</record>
<record id="payment_acquirer_sepa_direct_debit" model="payment.acquirer">
<field name="name">SEPA Direct Debit</field>
<field name="sequence">20</field>
<field name="image_128"
type="base64"
file="base/static/img/icons/payment_sepa_direct_debit.png"/>
<field name="module_id" ref="base.module_payment_sepa_direct_debit"/>
<field name="description" type="html">
<p>
SEPA Direct Debit is a Europe-wide Direct Debit system that allows merchants to
collect Euro-denominated payments from accounts in the 34 SEPA countries and
associated territories.
</p>
</field>
</record>
<record id="payment_acquirer_sips" model="payment.acquirer">
<field name="name">Sips</field>
<field name="display_as">Credit Card (powered by Sips)</field>
<field name="image_128" type="base64" file="payment_sips/static/src/img/sips_icon.png"/>
<field name="module_id" ref="base.module_payment_sips"/>
<field name="description" type="html">
<p>
A payment gateway from Atos Worldline to accept online payments via credit cards.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- See http://sips.worldline.com/en-us/home/features/payment-types-and-acquirers.html -->
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_discover'),
ref('payment.payment_icon_cc_diners_club_intl'),
ref('payment.payment_icon_cc_jcb'),
ref('payment.payment_icon_cc_american_express'),
ref('payment.payment_icon_cc_bancontact'),
ref('payment.payment_icon_cc_unionpay'),
ref('payment.payment_icon_cc_visa'),
])]"/>
</record>
<record id="payment_acquirer_stripe" model="payment.acquirer">
<field name="name">Stripe</field>
<field name="display_as">Credit Card (powered by Stripe)</field>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="image_128" type="base64" file="payment_stripe/static/src/img/stripe_icon.png"/>
<field name="module_id" ref="base.module_payment_stripe"/>
<field name="description" type="html">
@@ -231,92 +322,44 @@
</ul>
</field>
<!--
https://stripe.com/payments/payment-methods-guide
https://support.goteamup.com/hc/en-us/articles/115002089349-Which-cards-and-payment-types-can-I-accept-with-Stripe-
See https://stripe.com/payments/payment-methods-guide
See https://support.goteamup.com/hc/en-us/articles/115002089349-Which-cards-and-payment-types-can-I-accept-with-Stripe-
-->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_maestro"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_discover"),
ref("payment.payment_icon_cc_diners_club_intl"),
ref("payment.payment_icon_cc_jcb"),
ref("payment.payment_icon_cc_american_express"),
ref("payment.payment_icon_cc_visa")])]'/>
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_discover'),
ref('payment.payment_icon_cc_diners_club_intl'),
ref('payment.payment_icon_cc_jcb'),
ref('payment.payment_icon_cc_american_express'),
ref('payment.payment_icon_cc_visa'),
])]"/>
</record>
<record id="payment_acquirer_payu" model="payment.acquirer">
<field name="name">PayUmoney</field>
<field name="display_as">Credit Card (powered by PayUmoney)</field>
<field name="image_128" type="base64" file="payment_payumoney/static/src/img/payumoney_icon.png"/>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="module_id" ref="base.module_payment_payumoney"/>
<record id="payment_acquirer_test" model="payment.acquirer">
<field name="name">Test</field>
<field name="sequence">40</field>
<field name="image_128" type="base64" file="payment_test/static/src/img/test_logo.jpg"/>
<field name="module_id" ref="base.module_payment_test"/>
<field name="description" type="html">
<p>
PayU India is an online payments solutions company serving the Indian market.
A testing payment gateway intended for demonstrating payment flows without the need
of creating a seller account or providing payment details.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- https://www.payumoney.com/selfcare.html?userType=seller
> Banks & Cards > What options do you have in the Credit Card payment?
-->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_maestro"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_american_express"),
ref("payment.payment_icon_cc_visa")])]'/>
</record>
<record id="payment_acquirer_payulatam" model="payment.acquirer">
<field name="name">PayU Latam</field>
<field name="display_as">Credit Card (powered by PayU Latam)</field>
<field name="image_128" type="base64" file="payment_payulatam/static/src/img/payulatam_icon.png"/>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="module_id" ref="base.module_payment_payulatam"/>
<record id="payment_acquirer_transfer" model="payment.acquirer">
<field name="name">Wire Transfer</field>
<field name="sequence">30</field>
<field name="image_128"
type="base64"
file="payment_transfer/static/src/img/transfer_icon.png"/>
<field name="module_id" ref="base.module_payment_transfer"/>
<field name="description" type="html">
<p>
PayU is a leading financial services provider in Colombia, Argentina, Brazil, Chile, Mexico, Panama, and Peru. It allows merchant to accept local payments with just one account and integration.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payment</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- https://www.payulatam.com/medios-de-pago/ -->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_diners_club_intl"),
ref("payment.payment_icon_cc_mastercard"),
ref("payment.payment_icon_cc_american_express"),
ref("payment.payment_icon_cc_visa"),
ref("payment.payment_icon_cc_codensa_easy_credit")])]'/>
</record>
<record id="payment_acquirer_alipay" model="payment.acquirer">
<field name="name">Alipay</field>
<field name="display_as">Credit Card (powered by Alipay)</field>
<field name="image_128" type="base64" file="payment_alipay/static/description/icon.png"/>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="module_id" ref="base.module_payment_alipay"/>
<field name="description" type="html">
<p>
Alipay is the most popular online payment platform in China. Chinese consumers can buy online using their Alipay eWallet.
</p>
<ul class="list-inline">
<li><i class="fa fa-check"/>Online Payment</li>
<li><i class="fa fa-check"/>Payment Status Tracking</li>
</ul>
</field>
<!-- https://intl.alipay.com/ihome/home/about/buy.htm?topic=paymentMethods -->
<field name="payment_icon_ids" eval='[(6, 0, [ref("payment.payment_icon_cc_jcb"),ref("payment.payment_icon_cc_mastercard"),ref("payment.payment_icon_cc_western_union"),ref("payment.payment_icon_cc_webmoney"),ref("payment.payment_icon_cc_visa")])]'/>
</record>
<record id="payment.payment_acquirer_sepa_direct_debit" model="payment.acquirer">
<field name="name">SEPA Direct Debit</field>
<field name="view_template_id" ref="default_acquirer_button"/>
<field name="image_128" type="base64" file="base/static/img/icons/payment_sepa_direct_debit.png"/>
<field name="module_id" ref="base.module_payment_sepa_direct_debit"/>
<field name="description" type="html">
<p>
SEPA Direct Debit is a Europe-wide Direct Debit system that allows merchants to collect Euro-denominated payments from accounts in the 34 SEPA countries and associated territories.
Provide instructions to customers so that they can pay their orders manually.
</p>
</field>
</record>
+12 -10
View File
@@ -1,13 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record model="ir.cron" id="cron_post_process_payment_tx">
<field name="name">Post process payment transactions</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="state">code</field>
<field name="code">model._cron_post_process_after_done()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">10</field> <!-- To decide clearly -->
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
</record>
<record model="ir.cron" id="cron_post_process_payment_tx">
<field name="name">payment: post-process transactions</field>
<field name="model_id" ref="payment.model_payment_transaction" />
<field name="state">code</field>
<field name="code">model._cron_finalize_post_processing()</field>
<field name="user_id" ref="base.user_root" />
<field name="interval_number">10</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
</record>
</odoo>
+8 -5
View File
@@ -1,9 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import payment_acquirer
from . import account_invoice
from . import res_partner
from . import account_chart_template
from . import account_payment
from . import chart_template
from . import account_move
from . import ir_http
from . import payment_acquirer
from . import payment_icon
from . import payment_token
from . import payment_transaction
from . import res_company
from . import res_partner
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo import models
class AccountChartTemplate(models.Model):
_inherit = 'account.chart.template'
def _create_bank_journals(self, company, acc_template_ref):
res = super(AccountChartTemplate, self)._create_bank_journals(company, acc_template_ref)
res = super()._create_bank_journals(company, acc_template_ref)
# Try to generate the missing journals
return res + self.env['payment.acquirer']._create_missing_journal_for_acquirers(company=company)
return res + self.env['payment.acquirer']._create_missing_journals(company=company)
-94
View File
@@ -1,94 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class AccountMove(models.Model):
_inherit = 'account.move'
transaction_ids = fields.Many2many('payment.transaction', 'account_invoice_transaction_rel', 'invoice_id', 'transaction_id',
string='Transactions', copy=False, readonly=True)
authorized_transaction_ids = fields.Many2many('payment.transaction', compute='_compute_authorized_transaction_ids',
string='Authorized Transactions', copy=False, readonly=True)
@api.depends('transaction_ids')
def _compute_authorized_transaction_ids(self):
for trans in self:
trans.authorized_transaction_ids = trans.transaction_ids.filtered(lambda t: t.state == 'authorized')
def get_portal_last_transaction(self):
self.ensure_one()
return self.with_context(active_test=False).transaction_ids.get_last_transaction()
def _create_payment_transaction(self, vals):
'''Similar to self.env['payment.transaction'].create(vals) but the values are filled with the
current invoices fields (e.g. the partner or the currency).
:param vals: The values to create a new payment.transaction.
:return: The newly created payment.transaction record.
'''
# Ensure the currencies are the same.
currency = self[0].currency_id
if any(inv.currency_id != currency for inv in self):
raise ValidationError(_('A transaction can\'t be linked to invoices having different currencies.'))
# Ensure the partner are the same.
partner = self[0].partner_id
if any(inv.partner_id != partner for inv in self):
raise ValidationError(_('A transaction can\'t be linked to invoices having different partners.'))
# Try to retrieve the acquirer. However, fallback to the token's acquirer.
acquirer_id = vals.get('acquirer_id')
acquirer = None
payment_token_id = vals.get('payment_token_id')
if payment_token_id:
payment_token = self.env['payment.token'].sudo().browse(payment_token_id)
# Check payment_token/acquirer matching or take the acquirer from token
if acquirer_id:
acquirer = self.env['payment.acquirer'].browse(acquirer_id)
if payment_token and payment_token.acquirer_id != acquirer:
raise ValidationError(_('Invalid token found! Token acquirer %s != %s') % (
payment_token.acquirer_id.name, acquirer.name))
if payment_token and payment_token.partner_id != partner:
raise ValidationError(_('Invalid token found! Token partner %s != %s') % (
payment_token.partner.name, partner.name))
else:
acquirer = payment_token.acquirer_id
# Check an acquirer is there.
if not acquirer_id and not acquirer:
raise ValidationError(_('A payment acquirer is required to create a transaction.'))
if not acquirer:
acquirer = self.env['payment.acquirer'].browse(acquirer_id)
# Check a journal is set on acquirer.
if not acquirer.journal_id:
raise ValidationError(_('A journal must be specified for the acquirer %s.', acquirer.name))
if not acquirer_id and acquirer:
vals['acquirer_id'] = acquirer.id
vals.update({
'amount': sum(self.mapped('amount_residual')),
'currency_id': currency.id,
'partner_id': partner.id,
'invoice_ids': [(6, 0, self.ids)],
})
transaction = self.env['payment.transaction'].create(vals)
# Process directly if payment_token
if transaction.payment_token_id:
transaction.s2s_do_transaction()
return transaction
def payment_action_capture(self):
self.authorized_transaction_ids.s2s_capture_transaction()
def payment_action_void(self):
self.authorized_transaction_ids.s2s_void_transaction()
+32
View File
@@ -0,0 +1,32 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class AccountMove(models.Model):
_inherit = 'account.move'
transaction_ids = fields.Many2many(
string="Transactions", comodel_name='payment.transaction',
relation='account_invoice_transaction_rel', column1='invoice_id', column2='transaction_id',
readonly=True, copy=False)
authorized_transaction_ids = fields.Many2many(
string="Authorized Transactions", comodel_name='payment.transaction',
compute='_compute_authorized_transaction_ids', readonly=True, copy=False)
@api.depends('transaction_ids')
def _compute_authorized_transaction_ids(self):
for invoice in self:
invoice.authorized_transaction_ids = invoice.transaction_ids.filtered(
lambda tx: tx.state == 'authorized'
)
def get_portal_last_transaction(self):
self.ensure_one()
return self.with_context(active_test=False).transaction_ids._get_last()
def payment_action_capture(self):
self.authorized_transaction_ids._send_capture_request()
def payment_action_void(self):
self.authorized_transaction_ids._send_void_request()
+58 -70
View File
@@ -1,6 +1,4 @@
# coding: utf-8
import datetime
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
@@ -9,30 +7,27 @@ from odoo.exceptions import ValidationError
class AccountPayment(models.Model):
_inherit = 'account.payment'
payment_transaction_id = fields.Many2one('payment.transaction', string='Payment Transaction', readonly=True)
payment_transaction_id = fields.Many2one(
string="Payment Transaction", comodel_name='payment.transaction', readonly=True)
payment_token_id = fields.Many2one(
'payment.token', string="Saved payment token",
domain="""[
string="Saved Payment Token", comodel_name='payment.token', domain="""[
(payment_method_code == 'electronic', '=', 1),
('company_id', '=', company_id),
('acquirer_id.capture_manually', '=', False),
('acquirer_id.journal_id', '=', journal_id),
('partner_id', 'in', related_partner_ids),
]""",
help="Note that tokens from acquirers set to only authorize transactions (instead of capturing the amount) are not available.")
related_partner_ids = fields.Many2many('res.partner', compute='_compute_related_partners')
def _get_payment_chatter_link(self):
self.ensure_one()
return '<a href=# data-oe-model=account.payment data-oe-id=%d>%s</a>' % (self.id, self.name)
help="Note that only tokens from acquirers allowing to capture the amount are available.")
related_partner_ids = fields.Many2many(
comodel_name='res.partner', compute='_compute_related_partners')
@api.depends('partner_id.commercial_partner_id.child_ids')
def _compute_related_partners(self):
for p in self:
p.related_partner_ids = (
p.partner_id
| p.partner_id.commercial_partner_id
| p.partner_id.commercial_partner_id.child_ids
for payment in self:
payment.related_partner_ids = (
payment.partner_id
| payment.partner_id.commercial_partner_id
| payment.partner_id.commercial_partner_id.child_ids
)._origin
@api.onchange('partner_id', 'payment_method_id', 'journal_id')
@@ -47,72 +42,65 @@ class AccountPayment(models.Model):
('acquirer_id.journal_id', '=', self.journal_id.id),
], limit=1)
def _prepare_payment_transaction_vals(self):
def _get_payment_chatter_link(self):
self.ensure_one()
return {
'amount': self.amount,
'reference': self.ref,
'currency_id': self.currency_id.id,
'partner_id': self.partner_id.id,
'partner_country_id': self.partner_id.country_id.id,
'payment_token_id': self.payment_token_id.id,
'acquirer_id': self.payment_token_id.acquirer_id.id,
'payment_id': self.id,
'type': 'server2server',
}
return f'<a href=# data-oe-model=account.payment data-oe-id={self.id}>{self.name}</a>'
def _create_payment_transaction(self, vals=None):
for pay in self:
if pay.payment_transaction_id:
raise ValidationError(_('A payment transaction already exists.'))
elif not pay.payment_token_id:
raise ValidationError(_('A token is required to create a new payment transaction.'))
def _create_payment_transaction(self, **extra_create_values):
for payment in self:
if payment.payment_transaction_id:
raise ValidationError(_(
"A payment transaction with reference %s already exists.",
payment.payment_transaction_id.reference
))
elif not payment.payment_token_id:
raise ValidationError(_("A token is required to create a new payment transaction."))
transactions = self.env['payment.transaction']
for pay in self:
transaction_vals = pay._prepare_payment_transaction_vals()
if vals:
transaction_vals.update(vals)
for payment in self:
transaction_vals = payment._prepare_payment_transaction_vals(**extra_create_values)
transaction = self.env['payment.transaction'].create(transaction_vals)
transactions += transaction
# Link the transaction to the payment.
pay.payment_transaction_id = transaction
payment.payment_transaction_id = transaction # Link the transaction to the payment
return transactions
def _prepare_payment_transaction_vals(self, **extra_create_values):
self.ensure_one()
return {
'acquirer_id': self.payment_token_id.acquirer_id.id,
'reference': self.ref,
'amount': self.amount,
'currency_id': self.currency_id.id,
'partner_id': self.partner_id.id,
'token_id': self.payment_token_id.id,
'operation': 'offline',
'payment_id': self.id,
**extra_create_values,
}
def action_post(self):
# Post the payments "normally" if no transactions are needed.
# If not, let the acquirer updates the state.
# __________ ______________
# | Payments | | Transactions |
# |__________| |______________|
# || | |
# || | |
# || | |
# __________ no s2s required __\/______ s2s required | | s2s_do_transaction()
# | Posted |<-----------------| post() |---------------- |
# |__________| |__________|<----- |
# | |
# OR---------------
# __________ __________ |
# | Cancelled|<-----------------| cancel() |<-----
# |__________| |__________|
# If not, let the acquirer update the state.
payments_need_trans = self.filtered(lambda pay: pay.payment_token_id and not pay.payment_transaction_id)
transactions = payments_need_trans._create_payment_transaction()
payments_need_tx = self.filtered(
lambda p: p.payment_token_id and not p.payment_transaction_id
)
transactions = payments_need_tx._create_payment_transaction()
res = super(AccountPayment, self - payments_need_trans).action_post()
res = super(AccountPayment, self - payments_need_tx).action_post()
transactions.s2s_do_transaction()
for tx in transactions: # Process the transactions with a payment by token
tx._send_payment_request()
# Post payments for issued transactions.
transactions._post_process_after_done()
payments_trans_done = payments_need_trans.filtered(lambda pay: pay.payment_transaction_id.state == 'done')
super(AccountPayment, payments_trans_done).action_post()
payments_trans_not_done = payments_need_trans.filtered(lambda pay: pay.payment_transaction_id.state != 'done')
payments_trans_not_done.action_cancel()
# Post payments for issued transactions
transactions._finalize_post_processing()
payments_tx_done = payments_need_tx.filtered(
lambda p: p.payment_transaction_id.state == 'done'
)
super(AccountPayment, payments_tx_done).action_post()
payments_tx_not_done = payments_need_tx.filtered(
lambda p: p.payment_transaction_id.state != 'done'
)
payments_tx_not_done.action_cancel()
return res
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class PaymentIcon(models.Model):
_name = 'payment.icon'
_description = 'Payment Icon'
name = fields.Char(string="Name")
acquirer_ids = fields.Many2many(
string="Acquirers", comodel_name='payment.acquirer',
help="The list of acquirers supporting this payment icon")
image = fields.Image(
string="Image", max_width=64, max_height=64,
help="This field holds the image used for this payment icon, limited to 64x64 px")
image_payment_form = fields.Image(
string="Image displayed on the payment form", related='image', store=True, max_width=45,
max_height=30)
+130
View File
@@ -0,0 +1,130 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class PaymentToken(models.Model):
_name = 'payment.token'
_order = 'partner_id, id desc'
_description = 'Payment Token'
acquirer_id = fields.Many2one(
string="Acquirer Account", comodel_name='payment.acquirer', required=True)
provider = fields.Selection(related='acquirer_id.provider')
name = fields.Char(
string="Name", help="The anonymized acquirer reference of the payment method",
required=True)
partner_id = fields.Many2one(string="Partner", comodel_name='res.partner', required=True)
company_id = fields.Many2one( # Indexed to speed-up ORM searches (from ir_rule or others)
related='acquirer_id.company_id', store=True, index=True)
acquirer_ref = fields.Char(
string="Acquirer Reference", help="The acquirer reference of the token of the transaction",
required=True) # This is not the same thing as the acquirer reference of the transaction
transaction_ids = fields.One2many(
string="Payment Transactions", comodel_name='payment.transaction', inverse_name='token_id')
verified = fields.Boolean(string="Verified")
active = fields.Boolean(string="Active", default=True)
#=== CRUD METHODS ===#
@api.model_create_multi
def create(self, values_list):
for values in values_list:
if 'acquirer_id' in values:
acquirer = self.env['payment.acquirer'].browse(values['acquirer_id'])
# Include acquirer-specific create values
values.update(self._get_specific_create_values(acquirer.provider, values))
else:
pass # Let psycopg warn about the missing required field
return super().create(values_list)
@api.model
def _get_specific_create_values(self, provider, values):
""" Complete the values of the `create` method with acquirer-specific values.
For an acquirer to add its own create values, it must overwrite this method and return a
dict of values. Acquirer-specific values take precedence over those of the dict of generic
create values.
:param str provider: The provider of the acquirer managing the token
:param dict values: The original create values
:return: The dict of acquirer-specific create values
:rtype: dict
"""
return dict()
def write(self, values):
""" Delegate the handling of active state switch to dedicated methods.
Unless an exception is raised in the handling methods, the toggling proceeds no matter what.
This is because allowing users to hide their saved payment methods comes before making sure
that the recorded payment details effectively get deleted.
:return: The result of the write
:rtype: bool
"""
# Let acquirers handle activation/deactivation requests
if 'active' in values:
for token in self:
# Call handlers in sudo mode because this method might have been called by RPC
if values['active'] and not token.active:
token.sudo()._handle_reactivation_request()
elif not values['active'] and token.active:
token.sudo()._handle_deactivation_request()
# Proceed with the toggling of the active state
return super().write(values)
#=== BUSINESS METHODS ===#
def _handle_deactivation_request(self):
""" Handle the request for deactivation of the token.
For an acquirer to support deactivation of tokens, or perform additional operations when a
token is deactivated, it must overwrite this method and raise an UserError if the token
cannot be deactivated.
Note: self.ensure_one()
:return: None
"""
self.ensure_one()
def _handle_reactivation_request(self):
""" Handle the request for reactivation of the token.
For an acquirer to support reactivation of tokens, or perform additional operations when a
token is reactivated, it must overwrite this method and raise an UserError if the token
cannot be reactivated.
Note: self.ensure_one()
:return: None
"""
self.ensure_one()
def get_linked_records_info(self):
""" Return a list of information about records linked to the current token.
For a module to implement payments and link documents to a token, it must override this
method and add information about linked records to the returned list.
The information must be structured as a dict with the following keys:
- description: The description of the record's model (e.g. "Subscription")
- id: The id of the record
- name: The name of the record
- url: The url to access the record.
Note: self.ensure_one()
:return: The list of information about linked documents
:rtype: list
"""
self.ensure_one()
return []
@@ -0,0 +1,970 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import pprint
import re
import unicodedata
from datetime import datetime
import psycopg2
from dateutil import relativedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import consteq, ustr
from odoo.tools.misc import formatLang, hmac as hmac_tool
from odoo.addons.payment import utils as payment_utils
_logger = logging.getLogger(__name__)
class PaymentTransaction(models.Model):
_name = 'payment.transaction'
_description = 'Payment Transaction'
_order = 'id desc'
_rec_name = 'reference'
@api.model
def _lang_get(self):
return self.env['res.lang'].get_installed()
acquirer_id = fields.Many2one(
string="Acquirer", comodel_name='payment.acquirer', readonly=True, required=True)
provider = fields.Selection(related='acquirer_id.provider')
company_id = fields.Many2one( # Indexed to speed-up ORM searches (from ir_rule or others)
related='acquirer_id.company_id', store=True, index=True)
reference = fields.Char(
string="Reference", help="The internal reference of the transaction", readonly=True,
required=True) # Already has an index from the UNIQUE SQL constraint
acquirer_reference = fields.Char(
string="Acquirer Reference", help="The acquirer reference of the transaction",
readonly=True) # This is not the same thing as the acquirer reference of the token
amount = fields.Monetary(
string="Amount", currency_field='currency_id', readonly=True, required=True)
currency_id = fields.Many2one(
string="Currency", comodel_name='res.currency', readonly=True, required=True)
fees = fields.Monetary(
string="Fees", currency_field='currency_id',
help="The fees amount; set by the system as it depends on the acquirer", readonly=True)
token_id = fields.Many2one(
string="Payment Token", comodel_name='payment.token', readonly=True,
domain='[("acquirer_id", "=", "acquirer_id")]', ondelete='restrict')
state = fields.Selection(
string="Status",
selection=[('draft', "Draft"), ('pending', "Pending"), ('authorized', "Authorized"),
('done', "Confirmed"), ('cancel', "Canceled"), ('error', "Error")],
default='draft', readonly=True, required=True, copy=False)
state_message = fields.Text(
string="Message", help="The complementary information message about the state",
readonly=True)
last_state_change = fields.Datetime(
string="Last State Change Date", readonly=True, default=fields.Datetime.now)
# Fields used for traceability
operation = fields.Selection( # This should not be trusted if the state is 'draft' or 'pending'
string="Operation",
selection=[('online_redirect', "Online payment with redirection"),
('online_direct', "Online direct payment"),
('online_token', "Online payment by token"),
('validation', "Validation of the payment method"),
('offline', "Offline payment by token")],
readonly=True)
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')
# Fields used for user redirection & payment post-processing
is_post_processed = fields.Boolean(
string="Is Post-processed", help="Has the payment been post-processed")
tokenize = fields.Boolean(
string="Create Token",
help="Whether a payment token should be created when post-processing the transaction")
validation_route = fields.Char(
string="Validation Route",
help="The route the user is redirected to in order to refund a validation transaction")
landing_route = fields.Char(
string="Landing Route",
help="The route the user is redirected to after the transaction")
callback_model_id = fields.Many2one(
string="Callback Document Model", comodel_name='ir.model', groups='base.group_system')
callback_res_id = fields.Integer(string="Callback Record ID", groups='base.group_system')
callback_method = fields.Char(string="Callback Method", groups='base.group_system')
# Hash for additional security on top of the callback fields' group in case a bug exposes a sudo
callback_hash = fields.Char(string="Callback Hash", groups='base.group_system')
callback_is_done = fields.Boolean(
string="Callback Done", help="Whether the callback has already been executed",
groups="base.group_system", readonly=True)
# Duplicated partner values allowing to keep a record of them, should they be later updated
partner_id = fields.Many2one(
string="Customer", comodel_name='res.partner', readonly=True, required=True,
ondelete='restrict')
partner_name = fields.Char(string="Partner Name")
partner_lang = fields.Selection(string="Language", selection=_lang_get)
partner_email = fields.Char(string="Email")
partner_address = fields.Char(string="Address")
partner_zip = fields.Char(string="Zip")
partner_city = fields.Char(string="City")
partner_state_id = fields.Many2one(string="State", comodel_name='res.country.state')
partner_country_id = fields.Many2one(string="Country", comodel_name='res.country')
partner_phone = fields.Char(string="Phone")
_sql_constraints = [
('reference_uniq', 'unique(reference)', "Reference must be unique!"),
]
#=== 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)
#=== CONSTRAINT METHODS ===#
@api.constrains('state')
def _check_state_authorized_supported(self):
""" Check that authorization is supported for a transaction in the 'authorized' state. """
illegal_authorize_state_txs = self.filtered(
lambda tx: tx.state == 'authorized' and not tx.acquirer_id.support_authorization
)
if illegal_authorize_state_txs:
raise ValidationError(_(
"Transaction authorization is not supported by the following payment acquirers: %s",
', '.join(set(illegal_authorize_state_txs.mapped('acquirer_id.name')))
))
#=== CRUD METHODS ===#
@api.model_create_multi
def create(self, values_list):
for values in values_list:
acquirer = self.env['payment.acquirer'].browse(values['acquirer_id'])
if not values.get('reference'):
values['reference'] = self._compute_reference(acquirer.provider, **values)
# Duplicate partner values
partner = self.env['res.partner'].browse(values['partner_id'])
values.update({
'partner_name': partner.name,
'partner_lang': partner.lang,
'partner_email': partner.email,
'partner_address': payment_utils.format_partner_address(
partner.street, partner.street2
),
'partner_zip': partner.zip,
'partner_city': partner.city,
'partner_state_id': partner.state_id.id,
'partner_country_id': partner.country_id.id,
'partner_phone': partner.phone,
})
# Compute fees
currency = self.env['res.currency'].browse(values.get('currency_id')).exists()
values['fees'] = acquirer._compute_fees(
values.get('amount', 0), currency, partner.country_id
)
# Include acquirer-specific create values
values.update(self._get_specific_create_values(acquirer.provider, values))
# Generate the hash for the callback if one has be configured on the tx
values['callback_hash'] = self._generate_callback_hash(
values.get('callback_model_id'),
values.get('callback_res_id'),
values.get('callback_method'),
)
txs = super().create(values_list)
# Monetary fields are rounded with the currency at creation time by the ORM. Sometimes, this
# can lead to inconsistent string representation of the amounts sent to the providers.
# E.g., tx.create(amount=1111.11) -> tx.amount == 1111.1100000000001
# To ensure a proper string representation, we invalidate this request's cache values of the
# `amount` and `fees` fields for the created transactions. This forces the ORM to read the
# values from the DB where there were stored using `float_repr`, which produces a result
# consistent with the format expected by providers.
# E.g., tx.create(amount=1111.11) ; tx.invalidate_cache() -> tx.amount == 1111.11
txs.invalidate_cache(['amount', 'fees'])
return txs
@api.model
def _get_specific_create_values(self, provider, values):
""" Complete the values of the `create` method with acquirer-specific values.
For an acquirer to add its own create values, it must overwrite this method and return a
dict of values. Acquirer-specific values take precedence over those of the dict of generic
create values.
:param str provider: The provider of the acquirer that handled the transaction
:param dict values: The original create values
:return: The dict of acquirer-specific create values
:rtype: dict
"""
return dict()
#=== 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_capture(self):
""" Check the state of the transactions and request their capture. """
if any(tx.state != 'authorized' for tx in self):
raise ValidationError(_("Only authorized transactions can be captured."))
for tx in self:
tx._send_capture_request()
def action_void(self):
""" Check the state of the transaction and request to have them voided. """
if any(tx.state != 'authorized' for tx in self):
raise ValidationError(_("Only authorized transactions can be voided."))
for tx in self:
tx._send_void_request()
#=== BUSINESS METHODS - PAYMENT FLOW ===#
@api.model
def _compute_reference(self, provider, prefix=None, separator='-', **kwargs):
""" Compute a unique reference for the transaction.
The reference either corresponds to the prefix if no other transaction with that prefix
already exists, or follows the pattern `{computed_prefix}{separator}{sequence_number}` where
- {computed_prefix} is:
- The provided custom prefix, if any.
- The computation result of `_compute_reference_prefix` if the custom prefix is not
filled but the kwargs are.
- 'tx-{datetime}', if neither the custom prefix nor the kwargs are filled.
- {separator} is a custom string also used in `_compute_reference_prefix`.
- {sequence_number} is the next integer in the sequence of references sharing the exact
same prefix, '1' if there is only one matching reference (hence without sequence number)
Examples:
- Given the custom prefix 'example' which has no match with an existing reference, the
full reference will be 'example'.
- Given the custom prefix 'example' which matches the existing reference 'example', and
the custom separator '-', the full reference will be 'example-1'.
- Given the kwargs {'invoice_ids': [1, 2]}, the custom separator '-' and no custom prefix,
the full reference will be 'INV1-INV2' (or similar) if no existing reference has the
same prefix, or 'INV1-INV2-n' if n existing references have the same prefix.
:param str provider: The provider of the acquirer handling the transaction
:param str prefix: The custom prefix used to compute the full reference
:param str separator: The custom separator used to separate the prefix from the suffix, and
passed to `_compute_reference_prefix` if it is called
:param dict kwargs: Optional values passed to `_compute_reference_prefix` if no custom
prefix is provided
:return: The unique reference for the transaction
:rtype: str
"""
# Compute the prefix
if prefix:
# Replace special characters by their ASCII alternative (é -> e ; ä -> a ; ...)
prefix = unicodedata.normalize('NFKD', prefix).encode('ascii', 'ignore').decode('utf-8')
if not prefix: # Prefix not provided or voided above, compute it based on the kwargs
prefix = self.sudo()._compute_reference_prefix(provider, separator, **kwargs)
if not prefix: # Prefix not computed from the kwargs, fallback on time-based value
prefix = payment_utils.singularize_reference_prefix()
# Compute the sequence number
reference = prefix # The first reference of a sequence has no sequence number
if self.sudo().search([('reference', '=', prefix)]): # The reference already has a match
# We now execute a second search on `payment.transaction` to fetch all the references
# starting with the given prefix. The load of these two searches is mitigated by the
# index on `reference`. Although not ideal, this solution allows for quickly knowing
# whether the sequence for a given prefix is already started or not, usually not. An SQL
# query wouldn't help either as the selector is arbitrary and doing that would be an
# open-door to SQL injections.
same_prefix_references = self.sudo().search(
[('reference', 'like', f'{prefix}{separator}%')]
).with_context(prefetch_fields=False).mapped('reference')
# A final regex search is necessary to figure out the next sequence number. The previous
# search could not rely on alphabetically sorting the reference to infer the largest
# sequence number because both the prefix and the separator are arbitrary. A given
# prefix could happen to be a substring of the reference from a different sequence.
# For instance, the prefix 'example' is a valid match for the existing references
# 'example', 'example-1' and 'example-ref', in that order. Trusting the order to infer
# the sequence number would lead to a collision with 'example-1'.
search_pattern = re.compile(rf'^{prefix}{separator}(\d+)$')
max_sequence_number = 0 # If no match is found, start the sequence with this reference
for existing_reference in same_prefix_references:
search_result = re.search(search_pattern, existing_reference)
if search_result: # The reference has the same prefix and is from the same sequence
# Find the largest sequence number, if any
current_sequence = int(search_result.group(1))
if current_sequence > max_sequence_number:
max_sequence_number = current_sequence
# Compute the full reference
reference = f'{prefix}{separator}{max_sequence_number + 1}'
return reference
@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 ''
@api.model
def _generate_callback_hash(self, callback_model_id, callback_res_id, callback_method):
""" Return the hash for the callback on the transaction.
:param int callback_model_id: The model on which the callback method is defined, as a
`res.model` id
:param int callback_res_id: The record on which the callback method must be called, as an id
of the callback model
:param str callback_method: The name of the callback method
:return: The callback hash
:retype: str
"""
if callback_model_id and callback_res_id and callback_method:
model_name = self.env['ir.model'].sudo().browse(callback_model_id).model
token = f'{model_name}|{callback_res_id}|{callback_method}'
callback_hash = hmac_tool(self.env(su=True), 'generate_callback_hash', token)
return callback_hash
return None
def _get_processing_values(self):
""" Return a dict of values used to process the transaction.
The returned dict contains the following entries:
- tx_id: The transaction, as a `payment.transaction` id
- acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id
- provider: The provider of the acquirer
- reference: The reference of the transaction
- amount: The rounded amount of the transaction
- currency_id: The currency of the transaction, as a res.currency id
- partner_id: The partner making the transaction, as a res.partner id
- Additional acquirer-specific entries
Note: self.ensure_one()
:return: The dict of processing values
:rtype: dict
"""
self.ensure_one()
processing_values = {
'acquirer_id': self.acquirer_id.id,
'provider': self.provider,
'reference': self.reference,
'amount': self.amount,
'currency_id': self.currency_id.id,
'partner_id': self.partner_id.id,
}
# Complete generic processing values with acquirer-specific values
processing_values.update(self._get_specific_processing_values(processing_values))
_logger.info(
"generic and acquirer-specific processing values for transaction with id %s:\n%s",
self.id, pprint.pformat(processing_values)
)
# Render the html form for the redirect flow if available
if self.operation in ('online_redirect', 'validation') \
and self.acquirer_id.redirect_form_view_id:
rendering_values = self._get_specific_rendering_values(processing_values)
_logger.info(
"acquirer-specific rendering values for transaction with id %s:\n%s",
self.id, pprint.pformat(rendering_values)
)
redirect_form_html = self.acquirer_id.redirect_form_view_id._render(
rendering_values, engine='ir.qweb'
)
processing_values.update(redirect_form_html=redirect_form_html)
return processing_values
def _get_specific_processing_values(self, processing_values):
""" Return a dict of acquirer-specific values used to process the transaction.
For an acquirer to add its own processing values, it must overwrite this method and return a
dict of acquirer-specific values based on the generic values returned by this method.
Acquirer-specific values take precedence over those of the dict of generic processing
values.
:param dict processing_values: The generic processing values of the transaction
:return: The dict of acquirer-specific processing values
:rtype: dict
"""
return dict()
def _get_specific_rendering_values(self, processing_values):
""" Return a dict of acquirer-specific values used to render the redirect form.
For an acquirer to add its own rendering values, it must overwrite this method and return a
dict of acquirer-specific values based on the processing values (acquirer-specific
processing values included).
:param dict processing_values: The processing values of the transaction
:return: The dict of acquirer-specific rendering values
:rtype: dict
"""
return dict()
def _send_payment_request(self):
""" Request the provider of the acquirer handling the transaction to execute the payment.
For an acquirer to support tokenization, it must override this method and call it to log the
'sent' message, then request a money transfer to its provider.
Note: self.ensure_one()
:return: None
"""
self.ensure_one()
self._log_sent_message()
@api.model
def _handle_feedback_data(self, provider, data):
""" Match the transaction with the feedback data, update its state and return it.
:param str provider: The provider of the acquirer that handled the transaction
:param dict data: The feedback data sent by the provider
:return: The transaction
:rtype: recordset of `payment.transaction`
"""
tx = self._get_tx_from_feedback_data(provider, data)
tx._process_feedback_data(data)
tx._execute_callback()
return tx
@api.model
def _get_tx_from_feedback_data(self, provider, data):
""" Find the transaction based on the feedback data.
For an acquirer to handle transaction post-processing, it must overwrite this method and
return the transaction matching the data.
:param str provider: The provider of the acquirer that handled the transaction
:param dict data: The feedback data sent by the acquirer
:return: The transaction if found
:rtype: recordset of `payment.transaction`
"""
return self
def _process_feedback_data(self, data):
""" Update the transaction state and the acquirer reference based on the feedback data.
For an acquirer to handle transaction post-processing, it must overwrite this method and
process the feedback data.
Note: self.ensure_one()
:param dict data: The feedback data sent by the acquirer
:return: None
"""
self.ensure_one()
def _set_pending(self, state_message=None):
""" Update the transactions' state to 'pending'.
:param str state_message: The reason for which the transaction is set in 'pending' state
:return: None
"""
allowed_states = ('draft',)
target_state = 'pending'
txs_to_process = self._update_state(allowed_states, target_state, state_message)
txs_to_process._log_received_message()
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
"""
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()
def _set_done(self, state_message=None):
""" Update the transactions' state to 'done'.
:return: None
"""
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()
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
"""
allowed_states = ('draft', 'pending', 'authorized')
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()
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
"""
allowed_states = ('draft', 'pending', 'authorized')
target_state = 'error'
txs_to_process = self._update_state(allowed_states, target_state, state_message)
txs_to_process._log_received_message()
def _update_state(self, allowed_states, target_state, state_message):
""" Update the transactions' state to the target state if the current state allows it.
If the current state is the same as the target state, the transaction is skipped.
:param tuple[str] allowed_states: The allowed source states for the target state
:param str target_state: The target state
:param str state_message: The message to set as `state_message`
:return: The recordset of transactions whose state was correctly updated
:rtype: recordset of `payment.transaction`
"""
def _classify_by_state(_transactions):
"""Classify the transactions according to their current state.
For each transaction of the current recordset, if:
- The state is an allowed state: the transaction is flagged as 'to process'.
- The state is equal to the target state: the transaction is flagged as 'processed'.
- The state matches none of above: the transaction is flagged as 'in wrong state'.
:param recordset _transactions: The transactions to classify, as a `payment.transaction`
recordset
:return: A 3-items tuple of recordsets of classified transactions, in this order:
transactions 'to process', 'processed', and 'in wrong state'
:rtype: tuple(recordset)
"""
_txs_to_process = _transactions.filtered(lambda _tx: _tx.state in allowed_states)
_txs_already_processed = _transactions.filtered(lambda _tx: _tx.state == target_state)
_txs_wrong_state = _transactions - _txs_to_process - _txs_already_processed
return _txs_to_process, _txs_already_processed, _txs_wrong_state
txs_to_process, txs_already_processed, txs_wrong_state = _classify_by_state(self)
for tx in txs_already_processed:
_logger.info(
"tried to write tx state with same value (ref: %s, state: %s)",
tx.reference, tx.state
)
for tx in txs_wrong_state:
logging_values = {
'reference': tx.reference,
'tx_state': tx.state,
'target_state': target_state,
'allowed_states': allowed_states,
}
_logger.warning(
"tried to write tx state with illegal value (ref: %(reference)s, previous state "
"%(tx_state)s, target state: %(target_state)s, expected previous state to be in: "
"%(allowed_states)s)", logging_values
)
txs_to_process.write({
'state': target_state,
'state_message': state_message,
'last_state_change': fields.Datetime.now(),
})
return txs_to_process
def _execute_callback(self):
""" Execute the callbacks defined on the transactions.
Callbacks that have already been executed are silently ignored. This case can happen when a
transaction is first authorized before being confirmed, for instance. In this case, both
status updates try to execute the callback.
Only successful callbacks are marked as done. This allows callbacks to reschedule themselves
should the conditions not be met in the present call.
:return: None
"""
for tx in self.filtered(lambda t: not t.sudo().callback_is_done):
# Only use sudo to check, not to execute
model_sudo = tx.sudo().callback_model_id
res_id = tx.sudo().callback_res_id
method = tx.sudo().callback_method
if not (model_sudo and res_id and method):
continue # Skip transactions with unset (or not properly defined) callbacks
valid_callback_hash = self._generate_callback_hash(model_sudo.id, res_id, method)
if not consteq(ustr(valid_callback_hash), tx.callback_hash):
_logger.warning("invalid callback signature for transaction with id %s", tx.id)
continue # Ignore tampered callbacks
record = self.env[model_sudo.model].browse(res_id).exists()
if not record:
logging_values = {
'model': model_sudo.model,
'record_id': res_id,
'tx_id': tx.id,
}
_logger.warning(
"invalid callback record %(model)s.%(record_id)s for transaction with id "
"%(tx_id)s", logging_values
)
continue # Ignore invalidated callbacks
success = getattr(record, method)(tx) # Execute the callback
tx.callback_is_done = success or success is None # Missing returns are successful
def _send_refund_request(self):
""" Request the provider of the acquirer handling the transaction to refund it.
For an acquirer to support tokenization, it must override this method and request a refund
to its provider *if the validation amount is not null*.
Note: self.ensure_one()
:return: None
"""
self.ensure_one()
def _send_capture_request(self):
""" Request the provider of the acquirer handling the transaction to capture it.
For an acquirer to support authorization, it must override this method and request a capture
to its provider.
Note: self.ensure_one()
:return: None
"""
self.ensure_one()
def _send_void_request(self):
""" Request the provider of the acquirer handling the transaction to void it.
For an acquirer to support authorization, it must override this method and request the
transaction to be voided to its provider.
Note: self.ensure_one()
:return: None
"""
self.ensure_one()
#=== BUSINESS METHODS - POST-PROCESSING ===#
def _get_post_processing_values(self):
""" Return a dict of values used to display the status of the transaction.
For an acquirer to handle transaction status display, it must override this method and
return a dict of values. Acquirer-specific values take precedence over those of the dict of
generic post-processing values.
The returned dict contains the following entries:
- provider: The provider of the acquirer
- reference: The reference of the transaction
- amount: The rounded amount of the transaction
- currency_id: The currency of the transaction, as a res.currency id
- state: The transaction state: draft, pending, authorized, done, cancel or error
- state_message: The information message about the state
- is_post_processed: Whether the transaction has already been post-processed
- landing_route: The route the user is redirected to after the transaction
- Additional acquirer-specific entries
Note: self.ensure_one()
:return: The dict of processing values
:rtype: dict
"""
self.ensure_one()
post_processing_values = {
'provider': self.provider,
'reference': self.reference,
'amount': self.amount,
'currency_code': self.currency_id.name,
'state': self.state,
'state_message': self.state_message,
'is_validation': self.operation == 'validation',
'is_post_processed': self.is_post_processed,
'validation_route': self.validation_route,
'landing_route': self.landing_route,
}
_logger.debug(
"post-processing values for acquirer with id %s:\n%s",
self.acquirer_id.id, pprint.pformat(post_processing_values)
) # DEBUG level because this can get spammy with transactions in non-final states
return post_processing_values
def _finalize_post_processing(self):
""" Trigger the final post-processing tasks and mark the transactions as post-processed.
:return: None
"""
self._reconcile_after_done()
self._log_received_message() # 2nd call to link the created account.payment in the chatter
self.is_post_processed = True
def _cron_finalize_post_processing(self):
""" Finalize the post-processing of recently done transactions not handled by the client.
:return: None
"""
txs_to_post_process = self
if not txs_to_post_process:
# Let the client post-process transactions so that they remain available in the portal
client_handling_limit_date = datetime.now() - relativedelta.relativedelta(minutes=1)
# Don't try forever to post-process a transaction that doesn't go through
retry_limit_date = datetime.now() - relativedelta.relativedelta(days=2)
# Retrieve all transactions matching the criteria for post-processing
txs_to_post_process = self.search([
('state', '=', 'done'),
('is_post_processed', '=', False),
('last_state_change', '<=', client_handling_limit_date),
('last_state_change', '>=', retry_limit_date),
])
for tx in txs_to_post_process:
try:
tx._finalize_post_processing()
self.env.cr.commit()
except psycopg2.OperationalError: # A collision of accounting sequences occurred
self.env.cr.rollback() # Rollback and try later
except Exception as e:
_logger.exception(
"encountered an error while post-processing transaction with id %s:\n%s",
tx.id, e
)
self.env.cr.rollback()
def _reconcile_after_done(self):
""" Post relevant fiscal documents and create missing payments.
: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 tx in self.filtered(lambda t: 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_values = {
'amount': self.amount,
'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_id': self.env.ref('payment.account_payment_method_electronic_in').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_sent_message(self):
""" Log in the chatter of relevant documents that the transactions have been initiated.
:return: None
"""
for tx in self:
message = tx._get_sent_message()
tx._log_message_on_linked_documents(message)
def _log_received_message(self):
""" Log in the chatter of relevant documents that the transactions have been received.
A transaction is 'received' when a response is received from the provider of the acquirer
handling the transaction.
:return: None
"""
for tx in self:
message = tx._get_received_message()
tx._log_message_on_linked_documents(message)
def _log_message_on_linked_documents(self, message):
""" Log a message on 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()
for invoice in self.invoice_ids:
invoice.message_post(body=message)
#=== BUSINESS METHODS - GETTERS ===#
def _get_sent_message(self):
""" Return the message stating that the transaction has been requested.
Note: self.ensure_one()
:return: The 'transaction sent' message
:rtype: str
"""
self.ensure_one()
# Choose the message based on the payment flow
if self.operation in ('online_redirect', 'online_direct'):
message = _(
"A transaction with reference %(ref)s has been initiated (%(acq_name)s).",
ref=self.reference, acq_name=self.acquirer_id.name
)
else: # 'online_token'
message = _(
"A transaction with reference %(ref)s has been initiated using the payment method "
"%(token_name)s (%(acq_name)s).",
ref=self.reference, token_name=self.token_id.name, acq_name=self.acquirer_id.name
)
return message
def _get_received_message(self):
""" Return the message stating that the transaction has been received by the provider.
Note: self.ensure_one()
"""
self.ensure_one()
formatted_amount = formatLang(self.env, self.amount, currency_obj=self.currency_id)
if self.state == 'pending':
message = _(
"The transaction with reference %(ref)s for %(amount)s is pending (%(acq_name)s).",
ref=self.reference, amount=formatted_amount, acq_name=self.acquirer_id.name
)
elif self.state == 'authorized':
message = _(
"The transaction with reference %(ref)s for %(amount)s has been authorized "
"(%(acq_name)s).", ref=self.reference, amount=formatted_amount,
acq_name=self.acquirer_id.name
)
elif self.state == 'done':
message = _(
"The transaction with reference %(ref)s for %(amount)s has been confirmed "
"(%(acq_name)s).", ref=self.reference, amount=formatted_amount,
acq_name=self.acquirer_id.name
)
if self.payment_id:
message += _(
"\nThe related payment is posted: %s",
self.payment_id._get_payment_chatter_link()
)
elif self.state == 'error':
message = _(
"The transaction with reference %(ref)s for %(amount)s encountered an error"
" (%(acq_name)s).",
ref=self.reference, amount=formatted_amount, acq_name=self.acquirer_id.name
)
if self.state_message:
message += _("\nError: %s", self.state_message)
else:
message = _(
"The transaction with reference %(ref)s for %(amount)s is canceled (%(acq_name)s).",
ref=self.reference, amount=formatted_amount, acq_name=self.acquirer_id.name
)
if self.state_message:
message += _("\nReason: %s", self.state_message)
return message
def _get_last(self):
""" Return the last transaction of the recordset.
:return: The last transaction of the recordset, sorted by id
:rtype: recordset of `payment.transaction`
"""
return self.filtered(lambda t: t.state != 'draft').sorted()[:1]
+18 -13
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
@@ -7,25 +6,31 @@ from odoo import api, fields, models
class ResCompany(models.Model):
_inherit = 'res.company'
payment_acquirer_onboarding_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the onboarding payment acquirer step", default='not_done')
# YTI FIXME: Check if it's really needed on the company. Should be enough on the wizard
payment_onboarding_payment_method = fields.Selection([
('paypal', "PayPal"),
('stripe', "Stripe"),
('manual', "Manual"),
('other', "Other"),
], string="Selected onboarding payment method")
payment_acquirer_onboarding_state = fields.Selection(
string="State of the onboarding payment acquirer step",
selection=[('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")],
default='not_done')
payment_onboarding_payment_method = fields.Selection(
string="Selected onboarding payment method",
selection=[
('paypal', "PayPal"),
('stripe', "Stripe"),
('manual', "Manual"),
('other', "Other"),
])
@api.model
def action_open_payment_onboarding_payment_acquirer(self):
""" Called by onboarding panel above the customer invoice list."""
""" Called by onboarding panel above the customer invoice list. """
# Fail if there are no existing accounts
self.env.company.get_chart_of_accounts_or_fail()
action = self.env["ir.actions.actions"]._for_xml_id("payment.action_open_payment_onboarding_payment_acquirer_wizard")
action = self.env['ir.actions.actions']._for_xml_id(
'payment.action_open_payment_onboarding_payment_acquirer_wizard'
)
return action
def get_account_invoice_onboarding_steps_states_names(self):
""" Override. """
steps = super(ResCompany, self).get_account_invoice_onboarding_steps_states_names()
""" Override of account. """
steps = super().get_account_invoice_onboarding_steps_states_names()
return steps + ['payment_acquirer_onboarding_state']
+12 -9
View File
@@ -1,19 +1,22 @@
# coding: utf-8
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class res_partner(models.Model):
_name = 'res.partner'
class ResPartner(models.Model):
_inherit = 'res.partner'
payment_token_ids = fields.One2many('payment.token', 'partner_id', 'Payment Tokens')
payment_token_count = fields.Integer('Count Payment Token', compute='_compute_payment_token_count')
payment_token_ids = fields.One2many(
string="Payment Tokens", comodel_name='payment.token', inverse_name='partner_id')
payment_token_count = fields.Integer(
string="Payment Token Count", compute='_compute_payment_token_count')
@api.depends('payment_token_ids')
def _compute_payment_token_count(self):
payment_data = self.env['payment.token'].read_group([
('partner_id', 'in', self.ids)], ['partner_id'], ['partner_id'])
mapped_data = dict([(payment['partner_id'][0], payment['partner_id_count']) for payment in payment_data])
payments_data = self.env['payment.token'].read_group(
[('partner_id', 'in', self.ids)], ['partner_id'], ['partner_id']
)
partners_data = {payment_data['partner_id'][0]: payment_data['partner_id_count']
for payment_data in payments_data}
for partner in self:
partner.payment_token_count = mapped_data.get(partner.id, 0)
partner.payment_token_count = partners_data.get(partner.id, 0)
+9 -11
View File
@@ -1,15 +1,13 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
payment_acquirer_all,payment.acquirer.all,model_payment_acquirer,,1,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_transaction_all,payment.transaction.all,model_payment_transaction,,1,0,0,0
payment_transaction_user,payment.transaction.user,model_payment_transaction,base.group_user,1,1,1,0
payment_transaction_system,payment.transaction.system,model_payment_transaction,base.group_system,1,1,1,1
payment_method_all,payment.token.all,model_payment_token,,1,0,0,0
payment_method_user,payment.token.user,model_payment_token,base.group_user,1,1,1,1
payment_method_portal,payment.token.portal,model_payment_token,base.group_portal,1,1,1,1
payment_method_system,payment.token.system,model_payment_token,base.group_system,1,1,1,1
payment_icon_all,payment.icon.all,model_payment_icon,,1,0,0,0
payment_icon_user,payment.icon.user,model_payment_icon,base.group_user,1,1,1,0
payment_icon_system,payment.icon.system,model_payment_icon,base.group_system,1,1,1,1
access_payment_acquirer_onboarding_wizard,access.payment.acquirer.onboarding.wizard,model_payment_acquirer_onboarding_wizard,base.group_system,1,1,1,0
access_payment_link_wizard,access.payment.link.wizard,model_payment_link_wizard,account.group_account_user,1,1,1,0
payment_link_wizard,payment.link.wizard,model_payment_link_wizard,account.group_account_user,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
payment_token_user,payment.token.user,model_payment_token,base.group_user,1,1,1,1
payment_transaction_all,payment.transaction.all,model_payment_transaction,,1,0,0,0
payment_transaction_system,payment.transaction.system,model_payment_transaction,base.group_system,1,1,1,1
payment_transaction_user,payment.transaction.user,model_payment_transaction,base.group_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 payment_acquirer_all payment_acquirer_onboarding_wizard payment.acquirer.all payment.acquirer.onboarding.wizard model_payment_acquirer model_payment_acquirer_onboarding_wizard base.group_system 1 0 1 0 1 0
3 payment_acquirer_system payment.acquirer.system model_payment_acquirer base.group_system 1 1 1 1
payment_transaction_all payment.transaction.all model_payment_transaction 1 0 0 0
payment_transaction_user payment.transaction.user model_payment_transaction base.group_user 1 1 1 0
payment_transaction_system payment.transaction.system model_payment_transaction base.group_system 1 1 1 1
payment_method_all payment.token.all model_payment_token 1 0 0 0
payment_method_user payment.token.user model_payment_token base.group_user 1 1 1 1
payment_method_portal payment.token.portal model_payment_token base.group_portal 1 1 1 1
payment_method_system payment.token.system model_payment_token base.group_system 1 1 1 1
4 payment_icon_all payment.icon.all model_payment_icon 1 0 0 0
payment_icon_user payment.icon.user model_payment_icon base.group_user 1 1 1 0
5 payment_icon_system payment.icon.system model_payment_icon base.group_system 1 1 1 1
6 access_payment_acquirer_onboarding_wizard payment_link_wizard access.payment.acquirer.onboarding.wizard payment.link.wizard model_payment_acquirer_onboarding_wizard model_payment_link_wizard base.group_system account.group_account_user 1 1 1 0
7 access_payment_link_wizard payment_token_all access.payment.link.wizard payment.token.all model_payment_link_wizard model_payment_token account.group_account_user 1 1 0 1 0 0
8 payment_token_portal payment.token.portal model_payment_token base.group_portal 1 1 1 1
9 payment_token_system payment.token.system model_payment_token base.group_system 1 1 1 1
10 payment_token_user payment.token.user model_payment_token base.group_user 1 1 1 1
11 payment_transaction_all payment.transaction.all model_payment_transaction 1 0 0 0
12 payment_transaction_system payment.transaction.system model_payment_transaction base.group_system 1 1 1 1
13 payment_transaction_user payment.transaction.user model_payment_transaction base.group_user 1 1 1 0
+37 -10
View File
@@ -1,31 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Acquirers -->
<record id="payment_acquirer_company_rule" model="ir.rule">
<field name="name">Access acquirers in own companies only</field>
<field name="model_id" ref="payment.model_payment_acquirer"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
</record>
<!-- Transactions -->
<record id="payment_transaction_user_rule" model="ir.rule">
<field name="name">Access own payment transaction only</field>
<field name="name">Access own transactions only</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="domain_force">['|',('partner_id','=',False), ('partner_id','=',user.partner_id.id) ]</field>
<field name="domain_force">['|', ('partner_id', '=', False), ('partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal')), (4, ref('base.group_public'))]"/>
</record>
<record id="payment_transaction_billing_rule" model="ir.rule">
<field name="name">Access every transaction</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<!-- Reset the domain defined by payment.transaction_user_rule -->
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('account.group_account_invoice'))]"/>
</record>
<record id="transaction_company_rule" model="ir.rule">
<field name="name">Access transactions in own companies only</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
</record>
<!-- Tokens -->
<record id="payment_token_user_rule" model="ir.rule">
<field name="name">Access own payment tokens only</field>
<field name="name">Access only tokens belonging to commercial partner</field>
<field name="model_id" ref="payment.model_payment_token"/>
<field name="domain_force">[('partner_id', 'child_of', user.partner_id.commercial_partner_id.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal')), (4, ref('base.group_public'))]"/>
</record>
<record id="payment_transaction_billing_rule" model="ir.rule">
<field name="name">Access every payment transaction</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<record id="payment_token_billing_rule" model="ir.rule">
<field name="name">Access every token</field>
<field name="model_id" ref="payment.model_payment_token"/>
<!-- Reset the domain defined by payment.token_user_rule -->
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('account.group_account_invoice'))]"/>
</record>
<record id="payment_token_salesman_rule" model="ir.rule">
<field name="name">Access every payment token</field>
<record id="payment_token_company_rule" model="ir.rule">
<field name="name">Access tokens in own companies only</field>
<field name="model_id" ref="payment.model_payment_token"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('account.group_account_invoice'))]"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
</record>
</odoo>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,98 @@
odoo.define('payment.checkout_form', require => {
'use strict';
const publicWidget = require('web.public.widget');
const paymentFormMixin = require('payment.payment_form_mixin');
publicWidget.registry.PaymentCheckoutForm = publicWidget.Widget.extend(paymentFormMixin, {
selector: 'form[name="o_payment_checkout"]',
events: Object.assign({}, publicWidget.Widget.prototype.events, {
'click div[name="o_payment_option_card"]': '_onClickPaymentOption',
'click a[name="o_payment_icon_more"]': '_onClickMorePaymentIcons',
'click a[name="o_payment_icon_less"]': '_onClickLessPaymentIcons',
'click button[name="o_payment_submit_button"]': '_onClickPay',
'submit': '_onSubmit',
}),
/**
* @constructor
*/
init: function () {
const preventDoubleClick = handlerMethod => {
return _.debounce(handlerMethod, 500, true);
};
this._super(...arguments);
// Prevent double-clicks and browser glitches on all inputs
this._onClickLessPaymentIcons = preventDoubleClick(this._onClickLessPaymentIcons);
this._onClickMorePaymentIcons = preventDoubleClick(this._onClickMorePaymentIcons);
this._onClickPay = preventDoubleClick(this._onClickPay);
this._onClickPaymentOption = preventDoubleClick(this._onClickPaymentOption);
this._onSubmit = preventDoubleClick(this._onSubmit);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Handle a direct payment, a payment with redirection, or a payment by token.
*
* Called when clicking on the 'Pay' button or when submitting the form.
*
* @private
* @param {Event} ev
* @return {undefined}
*/
_onClickPay: async function (ev) {
ev.stopPropagation();
ev.preventDefault();
// Check that the user has selected a payment option
const $checkedRadios = this.$('input[name="o_payment_radio"]:checked');
if (!this._ensureRadioIsChecked($checkedRadios)) {
return;
}
const checkedRadio = $checkedRadios[0];
// Extract contextual values from the radio button
const provider = this._getProviderFromRadio(checkedRadio);
const paymentOptionId = this._getPaymentOptionIdFromRadio(checkedRadio);
const flow = this._getPaymentFlowFromRadio(checkedRadio);
// Update the tx context with the value of the "Save my payment details" checkbox
if (flow !== 'token') {
const $tokenizeCheckbox = this.$(
`#o_payment_acquirer_inline_form_${paymentOptionId}` // Only match acq. radios
).find('input[name="o_payment_save_as_token"]');
this.txContext.tokenizationRequested = $tokenizeCheckbox.length === 1
&& $tokenizeCheckbox[0].checked;
} else {
this.txContext.tokenizationRequested = false;
}
// Make the payment
this._hideError(); // Don't keep the error displayed if the user is going through 3DS2
this._disableButton(true); // Disable until it is needed again
this._processPayment(provider, paymentOptionId, flow);
},
/**
* Delegate the handling of the payment request to `_onClickPay`.
*
* Called when submitting the form (e.g. through the Return key).
*
* @private
* @param {Event} ev
* @return {undefined}
*/
_onSubmit: function (ev) {
ev.stopPropagation();
ev.preventDefault();
this._onClickPay(ev);
},
});
return publicWidget.registry.PaymentCheckoutForm;
});
+235
View File
@@ -0,0 +1,235 @@
odoo.define('payment.manage_form', require => {
'use strict';
const core = require('web.core');
const publicWidget = require('web.public.widget');
const Dialog = require('web.Dialog');
const paymentFormMixin = require('payment.payment_form_mixin');
const _t = core._t;
publicWidget.registry.PaymentManageForm = publicWidget.Widget.extend(paymentFormMixin, {
selector: 'form[name="o_payment_manage"]',
events: Object.assign({}, publicWidget.Widget.prototype.events, {
'click div[name="o_payment_option_card"]': '_onClickPaymentOption',
'click a[name="o_payment_icon_more"]': '_onClickMorePaymentIcons',
'click a[name="o_payment_icon_less"]': '_onClickLessPaymentIcons',
'click button[name="o_payment_submit_button"]': '_onClickSaveToken',
'click button[name="o_payment_delete_token"]': '_onClickDeleteToken',
'submit': '_onSubmit',
}),
/**
* @constructor
*/
init: function () {
const preventDoubleClick = handlerMethod => {
return _.debounce(handlerMethod, 500, true);
};
this._super(...arguments);
// Prevent double-clicks and browser glitches on all inputs
this._onClickDeleteToken = preventDoubleClick(this._onClickDeleteToken);
this._onClickLessPaymentIcons = preventDoubleClick(this._onClickLessPaymentIcons);
this._onClickMorePaymentIcons = preventDoubleClick(this._onClickMorePaymentIcons);
this._onClickPaymentOption = preventDoubleClick(this._onClickPaymentOption);
this._onClickSaveToken = preventDoubleClick(this._onClickSaveToken);
this._onSubmit = preventDoubleClick(this._onSubmit);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Assign the token to a record.
*
* @private
* @param {number} tokenId - The id of the token to assign
* @return {undefined}
*/
_assignToken: function (tokenId) {
// Call the assign route to assign the token to a record
this._rpc({
route: this.txContext.assignTokenRoute,
params: {
'token_id': tokenId,
'csrf_token': core.csrf_token,
}
}).then(() => {
window.location = this.txContext.landingRoute;
}).guardedCatch(error => {
error.event.preventDefault();
this._displayError(
_t("Server Error"),
_t("We are not able to save your payment method."),
error.message.data.message
);
});
},
/**
* Search for documents linked to the token and ask the user for confirmation.
*
* If any such document is found, a confirmation dialog is shown.
*
* @private
* @param {number} tokenId - The id of the token to delete
* @return {undefined}
*/
_deleteToken: function (tokenId) {
const execute = () => {
this._rpc({
model: 'payment.token',
method: 'write',
args: [[tokenId], {active: false}],
}).then(result => {
if (result === true) { // Token successfully deleted, remove it from the view
const $tokenCard = this.$(
`input[name="o_payment_radio"][data-payment-option-id="${tokenId}"]` +
`[data-payment-option-type="token"]`
).closest('div[name="o_payment_option_card"]');
$tokenCard.siblings(`#o_payment_token_inline_form_${tokenId}`).remove();
$tokenCard.remove();
this._disableButton(false);
}
}).guardedCatch(error => {
this._displayError(
_t("Server Error"),
_t("We are not able to delete your payment method."),
error.message.data.message
);
});
};
// Fetch documents linked to the token
this._rpc({
model: 'payment.token',
method: 'get_linked_records_info',
args: [tokenId],
}).then(result => {
const $dialogContentMessage = $(
'<span>', {text: _("Are you sure you want to delete this payment method?")}
);
if (result.length > 0) { // There are documents linked to the token, list them
$dialogContentMessage.append($('<br>'));
$dialogContentMessage.append($(
'<span>', {text: _t("It is currently linked to the following documents:")}
));
const $documentInfoList = $('<ul>');
result.forEach(documentInfo => {
$documentInfoList.append($('<li>').append($(
'<a>', {
href: documentInfo.url,
target: '_blank',
title: documentInfo.description,
text: documentInfo.name
}
)));
});
$dialogContentMessage.append($documentInfoList);
}
new Dialog(this, {
title: _t("Warning!"),
size: 'medium',
$content: $('<div>').append($dialogContentMessage),
buttons: [
{
text: _t("Confirm Deletion"), classes: 'btn-primary', close: true,
click: execute
},
{
text: _t("Cancel"), close: true
},
],
}).open();
}).guardedCatch(error => {
this._displayError(
_t("Server Error"),
_t("We are not able to delete your payment method."),
error.message.data.message
);
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Find the radio button linked to the click 'Delete' button and trigger the token deletion.
*
* Let `_onClickPaymentOption` select the radio button and display the inline form.
*
* Called when clicking on the 'Delete' button of a token.
*
* @private
* @param {Event} ev
* @return {undefined}
*/
_onClickDeleteToken: function (ev) {
ev.preventDefault();
// Extract contextual values from the delete button
const linkedRadio = $(ev.target).siblings().find('input[name="o_payment_radio"]')[0];
const tokenId = this._getPaymentOptionIdFromRadio(linkedRadio);
// Delete the token
this._deleteToken(tokenId);
},
/**
* Handle the creation of a new token or the assignation of a token to a record.
*
* Called when clicking on the 'Save Payment Method' button of when submitting the form.
*
* @private
* @param {Event} ev
* @return {undefined}
*/
_onClickSaveToken: async function (ev) {
ev.stopPropagation();
ev.preventDefault();
// Check that the user has selected a payment option
const $checkedRadios = this.$('input[name="o_payment_radio"]:checked');
if (!this._ensureRadioIsChecked($checkedRadios)) {
return;
}
const checkedRadio = $checkedRadios[0];
// Extract contextual values from the radio button
const provider = this._getProviderFromRadio(checkedRadio);
const paymentOptionId = this._getPaymentOptionIdFromRadio(checkedRadio);
const flow = this._getPaymentFlowFromRadio(checkedRadio);
// Save the payment method
this._hideError(); // Don't keep the error displayed if the user is going through 3DS2
this._disableButton(true); // Disable until it is needed again
if (flow !== 'token') { // Creation of a new token
this.txContext.tokenizationRequested = true;
this._processPayment(provider, paymentOptionId, flow);
} else if (this.txContext.allowTokenSelection) { // Assignation of a token to a record
this._assignToken(paymentOptionId);
}
},
/**
* Delegate the handling of the token to `_onClickSaveToken`.
*
* Called when submitting the form (e.g. through the Return key).
*
* @private
* @param {Event} ev
* @return {undefined}
*/
_onSubmit: function (ev) {
ev.stopPropagation();
ev.preventDefault();
this._onClickSaveToken(ev);
},
});
return publicWidget.registry.PaymentManageForm;
});
@@ -1,582 +0,0 @@
odoo.define('payment.payment_form', function (require) {
"use strict";
var core = require('web.core');
var Dialog = require('web.Dialog');
var publicWidget = require('web.public.widget');
var _t = core._t;
publicWidget.registry.PaymentForm = publicWidget.Widget.extend({
selector: '.o_payment_form',
events: {
'submit': 'onSubmit',
'click #o_payment_form_pay': 'payEvent',
'click #o_payment_form_add_pm': 'addPmEvent',
'click button[name="delete_pm"]': 'deletePmEvent',
'click .o_payment_form_pay_icon_more': 'onClickMorePaymentIcon',
'click .o_payment_acquirer_select': 'radioClickEvent',
},
/**
* @override
*/
start: function () {
this._adaptPayButton();
var self = this;
return this._super.apply(this, arguments).then(function () {
self.options = _.extend(self.$el.data(), self.options);
self.updateNewPaymentDisplayStatus();
$('[data-toggle="tooltip"]').tooltip();
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {string} title
* @param {string} message
*/
displayError: function (title, message) {
var $checkedRadio = this.$('input[type="radio"]:checked'),
acquirerID = this.getAcquirerIdFromRadio($checkedRadio[0]);
var $acquirerForm;
if (this.isNewPaymentRadio($checkedRadio[0])) {
$acquirerForm = this.$('#o_payment_add_token_acq_' + acquirerID);
}
else if (this.isFormPaymentRadio($checkedRadio[0])) {
$acquirerForm = this.$('#o_payment_form_acq_' + acquirerID);
}
if ($checkedRadio.length === 0) {
return new Dialog(null, {
title: _t('Error: ') + _.str.escapeHTML(title),
size: 'medium',
$content: "<p>" + (_.str.escapeHTML(message) || "") + "</p>" ,
buttons: [
{text: _t('Ok'), close: true}]}).open();
} else {
// removed if exist error message
this.$('#payment_error').remove();
var messageResult = '<div class="alert alert-danger mb4" id="payment_error">';
if (title != '') {
messageResult = messageResult + '<b>' + _.str.escapeHTML(title) + ':</b><br/>';
}
messageResult = messageResult + _.str.escapeHTML(message) + '</div>';
$acquirerForm.append(messageResult);
}
},
hideError: function() {
this.$('#payment_error').remove();
},
/**
* @private
* @param {DOMElement} element
*/
getAcquirerIdFromRadio: function (element) {
return $(element).data('acquirer-id');
},
/**
* @private
* @param {jQuery} $form
*/
getFormData: function ($form) {
var unindexed_array = $form.serializeArray();
var indexed_array = {};
$.map(unindexed_array, function (n, i) {
indexed_array[n.name] = n.value;
});
return indexed_array;
},
/**
* @private
* @param {DOMElement} element
*/
isFormPaymentRadio: function (element) {
return $(element).data('form-payment') === 'True';
},
/**
* @private
* @param {DOMElement} element
*/
isNewPaymentRadio: function (element) {
return $(element).data('s2s-payment') === 'True';
},
/**
* @private
*/
updateNewPaymentDisplayStatus: function () {
var checked_radio = this.$('input[type="radio"]:checked');
// we hide all the acquirers form
this.$('[id*="o_payment_add_token_acq_"]').addClass('d-none');
this.$('[id*="o_payment_form_acq_"]').addClass('d-none');
if (checked_radio.length !== 1) {
return;
}
checked_radio = checked_radio[0];
var acquirer_id = this.getAcquirerIdFromRadio(checked_radio);
// if we clicked on an add new payment radio, display its form
if (this.isNewPaymentRadio(checked_radio)) {
this.$('#o_payment_add_token_acq_' + acquirer_id).removeClass('d-none');
}
else if (this.isFormPaymentRadio(checked_radio)) {
this.$('#o_payment_form_acq_' + acquirer_id).removeClass('d-none');
}
},
disableButton: function (button) {
$("body").block({overlayCSS: {backgroundColor: "#000", opacity: 0, zIndex: 1050}, message: false});
$(button).attr('disabled', true);
$(button).children('.fa-lock').removeClass('fa-lock');
$(button).prepend('<span class="o_loader"><i class="fa fa-refresh fa-spin"></i>&nbsp;</span>');
},
enableButton: function (button) {
$('body').unblock();
$(button).attr('disabled', false);
$(button).children('.fa').addClass('fa-lock');
$(button).find('span.o_loader').remove();
},
_parseError: function(e) {
if (e.message.data.arguments[1]) {
return e.message.data.arguments[0] + e.message.data.arguments[1];
}
return e.message.data.arguments[0];
},
_adaptPayButton: function () {
var $payButton = $("#o_payment_form_pay");
var disabledReasons = $payButton.data('disabled_reasons') || {};
$payButton.prop('disabled', _.contains(disabledReasons, true));
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {Event} ev
*/
payEvent: function (ev) {
ev.preventDefault();
var form = this.el;
var checked_radio = this.$('input[type="radio"]:checked');
var self = this;
if (ev.type === 'submit') {
var button = $(ev.target).find('*[type="submit"]')[0]
} else {
var button = ev.target;
}
// first we check that the user has selected a payment method
if (checked_radio.length === 1) {
checked_radio = checked_radio[0];
// we retrieve all the input inside the acquirer form and 'serialize' them to an indexed array
var acquirer_id = this.getAcquirerIdFromRadio(checked_radio);
var acquirer_form = false;
if (this.isNewPaymentRadio(checked_radio)) {
acquirer_form = this.$('#o_payment_add_token_acq_' + acquirer_id);
} else {
acquirer_form = this.$('#o_payment_form_acq_' + acquirer_id);
}
var inputs_form = $('input', acquirer_form);
var ds = $('input[name="data_set"]', acquirer_form)[0];
// if the user is adding a new payment
if (this.isNewPaymentRadio(checked_radio)) {
if (this.options.partnerId === undefined) {
console.warn('payment_form: unset partner_id when adding new token; things could go wrong');
}
var form_data = this.getFormData(inputs_form);
var wrong_input = false;
inputs_form.toArray().forEach(function (element) {
//skip the check of non visible inputs
if ($(element).attr('type') == 'hidden') {
return true;
}
$(element).closest('div.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
$(element).siblings( ".o_invalid_field" ).remove();
//force check of forms validity (useful for Firefox that refill forms automatically on f5)
$(element).trigger("focusout");
if (element.dataset.isRequired && element.value.length === 0) {
$(element).closest('div.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
$(element).closest('div.form-group').append('<div style="color: red" class="o_invalid_field" aria-invalid="true">' + _.str.escapeHTML("The value is invalid.") + '</div>');
wrong_input = true;
}
else if ($(element).closest('div.form-group').hasClass('o_has_error')) {
wrong_input = true;
$(element).closest('div.form-group').append('<div style="color: red" class="o_invalid_field" aria-invalid="true">' + _.str.escapeHTML("The value is invalid.") + '</div>');
}
});
if (wrong_input) {
return;
}
this.disableButton(button);
// do the call to the route stored in the 'data_set' input of the acquirer form, the data must be called 'create-route'
return this._rpc({
route: ds.dataset.createRoute,
params: form_data,
}).then(function (data) {
// if the server has returned true
if (data.result) {
// and it need a 3DS authentication
if (data['3d_secure'] !== false) {
// then we display the 3DS page to the user
$("body").html(data['3d_secure']);
}
else {
checked_radio.value = data.id; // set the radio value to the new card id
form.submit();
return new Promise(function () {});
}
}
// if the server has returned false, we display an error
else {
if (data.error) {
self.displayError(
'',
data.error);
} else { // if the server doesn't provide an error message
self.displayError(
_t('Server Error'),
_t('e.g. Your credit card details are wrong. Please verify.'));
}
}
// here we remove the 'processing' icon from the 'add a new payment' button
self.enableButton(button);
}).guardedCatch(function (error) {
error.event.preventDefault();
// if the rpc fails, pretty obvious
self.enableButton(button);
self.displayError(
_t('Server Error'),
_t("We are not able to add your payment method at the moment.") +
self._parseError(error)
);
});
}
// if the user is going to pay with a form payment, then
else if (this.isFormPaymentRadio(checked_radio)) {
this.disableButton(button);
var $tx_url = this.$el.find('input[name="prepare_tx_url"]');
// if there's a prepare tx url set
if ($tx_url.length === 1) {
// if the user wants to save his credit card info
var form_save_token = acquirer_form.find('input[name="o_payment_form_save_token"]').prop('checked');
// then we call the route to prepare the transaction
return this._rpc({
route: $tx_url[0].value,
params: {
'acquirer_id': parseInt(acquirer_id),
'save_token': form_save_token,
'access_token': self.options.accessToken,
'success_url': self.options.successUrl,
'error_url': self.options.errorUrl,
'callback_method': self.options.callbackMethod,
'order_id': self.options.orderId,
'invoice_id': self.options.invoiceId,
},
}).then(function (result) {
if (result) {
// if the server sent us the html form, we create a form element
var newForm = document.createElement('form');
newForm.setAttribute("method", self._get_redirect_form_method());
newForm.setAttribute("provider", checked_radio.dataset.provider);
newForm.hidden = true; // hide it
newForm.innerHTML = result; // put the html sent by the server inside the form
var action_url = $(newForm).find('input[name="data_set"]').data('actionUrl');
newForm.setAttribute("action", action_url); // set the action url
$(document.getElementsByTagName('body')[0]).append(newForm); // append the form to the body
$(newForm).find('input[data-remove-me]').remove(); // remove all the input that should be removed
if(action_url) {
newForm.submit(); // and finally submit the form
return new Promise(function () {});
}
}
else {
self.displayError(
_t('Server Error'),
_t("We are not able to redirect you to the payment form.")
);
self.enableButton(button);
}
}).guardedCatch(function (error) {
error.event.preventDefault();
self.displayError(
_t('Server Error'),
_t("We are not able to redirect you to the payment form.") + " " +
self._parseError(error)
);
});
}
else {
// we append the form to the body and send it.
this.displayError(
_t("Cannot setup the payment"),
_t("We're unable to process your payment.")
);
self.enableButton(button);
}
}
else { // if the user is using an old payment then we just submit the form
this.disableButton(button);
form.submit();
return new Promise(function () {});
}
}
else {
this.displayError(
_t('No payment method selected'),
_t('Please select a payment method.')
);
this.enableButton(button);
}
},
/**
* Return the HTTP method to be used by the redirect form.
*
* @private
* @return {string} The HTTP method, "post" by default
*/
_get_redirect_form_method: function () {
return "post";
},
/**
* Called when clicking on the button to add a new payment method.
*
* @private
* @param {Event} ev
*/
addPmEvent: function (ev) {
ev.stopPropagation();
ev.preventDefault();
var checked_radio = this.$('input[type="radio"]:checked');
var self = this;
if (ev.type === 'submit') {
var button = $(ev.target).find('*[type="submit"]')[0]
} else {
var button = ev.target;
}
// we check if the user has selected a 'add a new payment' option
if (checked_radio.length === 1 && this.isNewPaymentRadio(checked_radio[0])) {
// we retrieve which acquirer is used
checked_radio = checked_radio[0];
var acquirer_id = this.getAcquirerIdFromRadio(checked_radio);
var acquirer_form = this.$('#o_payment_add_token_acq_' + acquirer_id);
// we retrieve all the input inside the acquirer form and 'serialize' them to an indexed array
var inputs_form = $('input', acquirer_form);
var form_data = this.getFormData(inputs_form);
var ds = $('input[name="data_set"]', acquirer_form)[0];
var wrong_input = false;
inputs_form.toArray().forEach(function (element) {
//skip the check of non visible inputs
if ($(element).attr('type') == 'hidden') {
return true;
}
$(element).closest('div.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
$(element).siblings( ".o_invalid_field" ).remove();
//force check of forms validity (useful for Firefox that refill forms automatically on f5)
$(element).trigger("focusout");
if (element.dataset.isRequired && element.value.length === 0) {
$(element).closest('div.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
var message = '<div style="color: red" class="o_invalid_field" aria-invalid="true">' + _.str.escapeHTML("The value is invalid.") + '</div>';
$(element).closest('div.form-group').append(message);
wrong_input = true;
}
else if ($(element).closest('div.form-group').hasClass('o_has_error')) {
wrong_input = true;
var message = '<div style="color: red" class="o_invalid_field" aria-invalid="true">' + _.str.escapeHTML("The value is invalid.") + '</div>';
$(element).closest('div.form-group').append(message);
}
});
if (wrong_input) {
return;
}
// We add a 'processing' icon into the 'add a new payment' button
$(button).attr('disabled', true);
$(button).children('.fa-plus-circle').removeClass('fa-plus-circle');
$(button).prepend('<span class="o_loader"><i class="fa fa-refresh fa-spin"></i>&nbsp;</span>');
// do the call to the route stored in the 'data_set' input of the acquirer form, the data must be called 'create-route'
this._rpc({
route: ds.dataset.createRoute,
params: form_data,
}).then(function (data) {
// if the server has returned true
if (data.result) {
// and it need a 3DS authentication
if (data['3d_secure'] !== false) {
// then we display the 3DS page to the user
$("body").html(data['3d_secure']);
}
// if it doesn't require 3DS
else {
// we just go to the return_url or reload the page
if (form_data.return_url) {
window.location = form_data.return_url;
}
else {
window.location.reload();
}
}
}
// if the server has returned false, we display an error
else {
if (data.error) {
self.displayError(
'',
data.error);
} else { // if the server doesn't provide an error message
self.displayError(
_t('Server Error'),
_t('e.g. Your credit card details are wrong. Please verify.'));
}
}
// here we remove the 'processing' icon from the 'add a new payment' button
$(button).attr('disabled', false);
$(button).children('.fa').addClass('fa-plus-circle');
$(button).find('span.o_loader').remove();
}).guardedCatch(function (error) {
error.event.preventDefault();
// if the rpc fails, pretty obvious
$(button).attr('disabled', false);
$(button).children('.fa').addClass('fa-plus-circle');
$(button).find('span.o_loader').remove();
self.displayError(
_t('Server error'),
_t("We are not able to add your payment method at the moment.") +
self._parseError(error)
);
});
}
else {
this.displayError(
_t('No payment method selected'),
_t('Please select the option to add a new payment method.')
);
}
},
/**
* Called when submitting the form (e.g. through the Return key).
* We need to check whether we are paying or adding a new pm and dispatch
* to the correct method.
*
* @private
* @param {Event} ev
*/
onSubmit: function(ev) {
ev.stopPropagation();
ev.preventDefault();
var button = $(ev.target).find('*[type="submit"]')[0]
if (button.id === 'o_payment_form_pay') {
return this.payEvent(ev);
} else if (button.id === 'o_payment_form_add_pm') {
return this.addPmEvent(ev);
}
return;
},
/**
* Called when clicking on a button to delete a payment method.
*
* @private
* @param {Event} ev
*/
deletePmEvent: function (ev) {
ev.stopPropagation();
ev.preventDefault();
var self = this;
var pm_id = parseInt(ev.target.value);
var tokenDelete = function () {
self._rpc({
model: 'payment.token',
method: 'unlink',
args: [pm_id],
}).then(function (result) {
if (result === true) {
ev.target.closest('div').remove();
}
}, function () {
self.displayError(
_t('Server Error'),
_t("We are not able to delete your payment method at the moment.")
);
});
};
this._rpc({
model: 'payment.token',
method: 'get_linked_records',
args: [pm_id],
}).then(function (result) {
if (result[pm_id].length > 0) {
// if there's records linked to this payment method
var content = '';
result[pm_id].forEach(function (sub) {
content += '<p><a href="' + sub.url + '" title="' + sub.description + '">' + sub.name + '</a></p>';
});
content = $('<div>').html('<p>' + _t('This card is currently linked to the following records:') + '</p>' + content);
// Then we display the list of the records and ask the user if he really want to remove the payment method.
new Dialog(self, {
title: _t('Warning!'),
size: 'medium',
$content: content,
buttons: [
{text: _t('Confirm Deletion'), classes: 'btn-primary', close: true, click: tokenDelete},
{text: _t('Cancel'), close: true}]
}).open();
}
else {
// if there's no records linked to this payment method, then we delete it
tokenDelete();
}
}, function (err, event) {
self.displayError(
_t('Server Error'),
_t("We are not able to delete your payment method at the moment.") + err.data.message
);
});
},
/**
* Called when clicking on 'and more' to show more payment icon.
*
* @private
* @param {Event} ev
*/
onClickMorePaymentIcon: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var $listItems = $(ev.currentTarget).parents('ul').children('li');
var $moreItem = $(ev.currentTarget).parents('li');
$listItems.removeClass('d-none');
$moreItem.addClass('d-none');
},
/**
* Called when clicking on a radio button.
*
* @private
* @param {Event} ev
*/
radioClickEvent: function (ev) {
// radio button checked when we click on entire zone(body) of the payment acquirer
$(ev.currentTarget).find('input[type="radio"]').prop("checked", true);
this.updateNewPaymentDisplayStatus();
},
});
return publicWidget.registry.PaymentForm;
});
@@ -0,0 +1,525 @@
odoo.define('payment.payment_form_mixin', require => {
'use strict';
const core = require('web.core');
const Dialog = require('web.Dialog');
const _t = core._t;
return {
/**
* @override
*/
start: async function () {
await this._super(...arguments);
this.$('[data-toggle="tooltip"]').tooltip();
this.txContext = {};
Object.assign(this.txContext, this.$el.data());
const $checkedRadios = this.$('input[name="o_payment_radio"]:checked');
if ($checkedRadios.length === 1) {
const checkedRadio = $checkedRadios[0];
this._displayInlineForm(checkedRadio);
this._enableButton();
} else {
this._setPaymentFlow(); // Initialize the payment flow to let acquirers overwrite it
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Disable the submit button.
*
* The icons are updated to either show that an action is processing or that the button is
* not ready, depending on the value of `showLoadingAnimation`.
*
* @private
* @param {boolean} showLoadingAnimation - Whether a spinning loader should be shown
* @return {undefined}
*/
_disableButton: (showLoadingAnimation = true) => {
const $submitButton = this.$('button[name="o_payment_submit_button"]');
const iconClass = $submitButton.data('icon-class');
$submitButton.attr('disabled', true);
if (showLoadingAnimation) {
$submitButton.find('i').removeClass(iconClass);
$submitButton.prepend(
'<span class="o_loader"><i class="fa fa-refresh fa-spin"></i>&nbsp;</span>'
);
}
},
/**
* Display an error in the payment form.
*
* If no payment option is selected, the error is displayed in a dialog. If exactly one
* payment option is selected, the error is displayed in the inline form of that payment
* option and the view is focused on the error.
*
* @private
* @param {string} title - The title of the error
* @param {string} description - The description of the error
* @param {string} error - The raw error message
* @return {(Dialog|undefined)} A dialog showing the error if no payment option is selected,
* undefined otherwise.
*/
_displayError: function (title, description = '', error = '') {
const $checkedRadios = this.$('input[name="o_payment_radio"]:checked');
if ($checkedRadios.length !== 1) { // Cannot find selected payment option, show dialog
return new Dialog(null, {
title: _.str.sprintf(_t("Error: %s"), _.str.escapeHTML(title)),
size: 'medium',
$content: `<p>${_.str.escapeHTML(description) || ''}</p>`,
buttons: [{text: _t("Ok"), close: true}]
}).open();
} else { // Show error in inline form
this._hideError(); // Remove any previous error
// Build the html for the error
let errorHtml = `<div class="alert alert-danger mb4" name="o_payment_error">
<b>${_.str.escapeHTML(title)}</b>`;
if (description !== '') {
errorHtml += `</br>${_.str.escapeHTML(description)}`;
}
if (error !== '') {
errorHtml += `</br>${_.str.escapeHTML(error)}`;
}
errorHtml += '</div>';
// Append error to inline form and center the page on the error
const checkedRadio = $checkedRadios[0];
const paymentOptionId = this._getPaymentOptionIdFromRadio(checkedRadio);
const formType = $(checkedRadio).data('payment-option-type');
const $inlineForm = this.$(`#o_payment_${formType}_inline_form_${paymentOptionId}`);
$inlineForm.removeClass('d-none'); // Show the inline form even if it was empty
$inlineForm.append(errorHtml).find('div[name="o_payment_error"]')[0]
.scrollIntoView({behavior: 'smooth', block: 'center'});
}
this._enableButton(); // Enable button back after it was disabled before processing
},
/**
* Display the inline form of the selected payment option and hide others.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the payment option
* @return {undefined}
*/
_displayInlineForm: function (radio) {
this._hideInlineForms(); // Collapse previously opened inline forms
this._hideError(); // The error is only relevant until it is hidden with its inline form
this._setPaymentFlow(); // Reset the payment flow to let acquirers overwrite it
// Extract contextual values from the radio button
const provider = this._getProviderFromRadio(radio);
const paymentOptionId = this._getPaymentOptionIdFromRadio(radio);
const flow = this._getPaymentFlowFromRadio(radio);
// Prepare the inline form of the selected payment option and display it if not empty
this._prepareInlineForm(provider, paymentOptionId, flow);
const formType = $(radio).data('payment-option-type');
const $inlineForm = this.$(`#o_payment_${formType}_inline_form_${paymentOptionId}`);
if ($inlineForm.children().length > 0) {
$inlineForm.removeClass('d-none');
}
},
/**
* Check if the submit button can be enabled and do it if so.
*
* The icons are updated to show that the button is ready.
*
* @private
* @return {boolean} Whether the button was enabled.
*/
_enableButton: function () {
if (this._isButtonReady()) {
const $submitButton = this.$('button[name="o_payment_submit_button"]');
const iconClass = $submitButton.data('icon-class');
$submitButton.attr('disabled', false);
$submitButton.find('i').addClass(iconClass);
$submitButton.find('span.o_loader').remove();
return true;
}
return false;
},
/**
* Verify that exactly one radio button is checked and display an error otherwise.
*
* @private
* @param {jQuery} $checkedRadios - The currently check radio buttons
*
* @return {boolean} Whether exactly one radio button among the provided radios is checked
*/
_ensureRadioIsChecked: function ($checkedRadios) {
if ($checkedRadios.length === 0) {
this._displayError(
_t("No payment option selected"),
_t("Please select a payment option.")
);
return false;
} else if ($checkedRadios.length > 1) {
this._displayError(
_t("Multiple payment options selected"),
_t("Please select only one payment option.")
);
return false;
}
return true;
},
/**
* Determine and return the online payment flow of the selected payment option.
*
* As some acquirers implement both the direct payment and the payment with redirection, the
* flow cannot be inferred from the radio button only. The radio button only indicates
* whether the payment option is a token. If not, the transaction context is looked up to
* determine whether the flow is 'direct' or 'redirect'.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the payment option
* @return {string} The flow of the selected payment option. redirect, direct or token.
*/
_getPaymentFlowFromRadio: function (radio) {
if (
$(radio).data('payment-option-type') === 'token'
|| this.txContext.flow === 'token'
) {
return 'token';
} else if (this.txContext.flow === 'redirect') {
return 'redirect';
} else {
return 'direct';
}
},
/**
* Determine and return the id of the selected payment option.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the payment option
* @return {number} The acquirer id or the token id or of the payment option linked to the
* radio button.
*/
_getPaymentOptionIdFromRadio: radio => $(radio).data('payment-option-id'),
/**
* Determine and return the provider of the selected payment option.
*
* @private
* @param {HTMLInputElement} radio - The radio button linked to the payment option
* @return {number} The provider of the payment option linked to the radio button.
*/
_getProviderFromRadio: radio => $(radio).data('provider'),
/**
* Remove the error in the acquirer form.
*
* @private
* @return {jQuery} The removed error
*/
_hideError: () => this.$('div[name="o_payment_error"]').remove(),
/**
* Collapse all inline forms.
*
* @private
* @return {undefined}.
*/
_hideInlineForms: () => this.$('[name="o_payment_inline_form"]').addClass('d-none'),
/**
* Hide the "Save my payment details" label and checkbox, and the submit button.
*
* The inputs should typically be hidden when the customer has to perform additional actions
* in the inline form. All inputs are automatically shown again when the customer clicks on
* another inline form.
*
* @private
* @return {undefined}
*/
_hideInputs: function () {
const $submitButton = this.$('button[name="o_payment_submit_button"]');
const $tokenizeCheckboxes = this.$('input[name="o_payment_save_as_token"]');
$submitButton.addClass('d-none');
$tokenizeCheckboxes.closest('label').addClass('d-none');
},
/**
* Verify that the submit button is ready to be enabled.
*
* For a module to support a custom behavior for the submit button, it must override this
* method and only return true if the result of this method is true and if nothing prevents
* enabling the submit button for that custom behavior.
*
* @private
*
* @return {boolean} Whether the submit button can be enabled
*/
_isButtonReady: function () {
const $checkedRadios = this.$('input[name="o_payment_radio"]:checked');
if ($checkedRadios.length === 1) {
const checkedRadio = $checkedRadios[0];
const flow = this._getPaymentFlowFromRadio(checkedRadio);
return flow !== 'token' || this.txContext.allowTokenSelection;
} else {
return false;
}
},
/**
* Prepare the params to send to the transaction route.
*
* For an acquirer to overwrite generic params or to add acquirer-specific ones, it must
* override this method and return the extended transaction route params.
*
* @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 transaction route params
*/
_prepareTransactionRouteParams: function (provider, paymentOptionId, flow) {
return {
'payment_option_id': paymentOptionId,
'reference_prefix': this.txContext.referencePrefix,
'amount': this.txContext.amount !== undefined
? parseFloat(this.txContext.amount) : null,
'currency_id': this.txContext.currencyId
? parseInt(this.txContext.currencyId) : null,
'partner_id': parseInt(this.txContext.partnerId),
'flow': flow,
'tokenization_requested': this.txContext.tokenizationRequested,
'validation_route': this.txContext.validationRoute
? this.txContext.validationRoute : null,
'landing_route': this.txContext.landingRoute,
'access_token': this.txContext.accessToken
? this.txContext.accessToken : undefined,
'csrf_token': core.csrf_token,
};
},
/**
* Prepare the acquirer-specific inline form of the selected payment option.
*
* For an acquirer to manage an inline form, it must override this method. When the override
* is called, it must lookup the parameters to decide whether it is necessary to prepare its
* inline form. Otherwise, the call must be sent back to the parent method.
*
* @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 {Promise}
*/
_prepareInlineForm: (provider, paymentOptionId, flow) => Promise.resolve(),
/**
* Process the payment.
*
* For an acquirer to do pre-processing work on the transaction processing flow, or to
* define its entire own flow that requires re-scheduling the RPC to the transaction route,
* it must override this method.
* If only post-processing work is needed, an override of `_processRedirectPayment`,
* `_processDirectPayment` or `_processTokenPayment` might be more appropriate.
*
* @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) {
// Call the transaction route to create a tx and retrieve the processing values
return this._rpc({
route: this.txContext.transactionRoute,
params: this._prepareTransactionRouteParams(provider, paymentOptionId, flow),
}).then(processingValues => {
if (flow === 'redirect') {
return this._processRedirectPayment(
provider, paymentOptionId, processingValues
);
} else if (flow === 'direct') {
return this._processDirectPayment(provider, paymentOptionId, processingValues);
} else if (flow === 'token') {
return this._processTokenPayment(provider, paymentOptionId, processingValues);
}
}).guardedCatch(error => {
error.event.preventDefault();
this._displayError(
_t("Server Error"),
_t("We are not able to process your payment."),
error.message.data.message
);
});
},
/**
* Execute the acquirer-specific implementation of the direct payment flow.
*
* For an acquirer to redefine the processing of the direct payment flow, it must override
* this method.
*
* @private
* @param {string} provider - The provider of the acquirer
* @param {number} acquirerId - The id of the acquirer handling the transaction
* @param {object} processingValues - The processing values of the transaction
* @return {Promise}
*/
_processDirectPayment: (provider, acquirerId, processingValues) => Promise.resolve(),
/**
* Redirect the customer by submitting the redirect form included in the processing values.
*
* For an acquirer to redefine the processing of the payment with redirection flow, it must
* override this method.
*
* @private
* @param {string} provider - The provider of the acquirer
* @param {number} acquirerId - The id of the acquirer handling the transaction
* @param {object} processingValues - The processing values of the transaction
* @return {undefined}
*/
_processRedirectPayment: (provider, acquirerId, processingValues) => {
// Append the redirect form to the body
const $redirectForm = $(processingValues.redirect_form_html).attr(
'id', 'o_payment_redirect_form'
);
$(document.getElementsByTagName('body')[0]).append($redirectForm);
// Submit the form
$redirectForm.submit();
},
/**
* Redirect the customer to the status route.
*
* For an acquirer to redefine the processing of the payment by token flow, it must override
* this method.
*
* @private
* @param {string} provider - The provider of the token's acquirer
* @param {number} tokenId - The id of the token handling the transaction
* @param {object} processingValues - The processing values of the transaction
* @return {undefined}
*/
_processTokenPayment: (provider, tokenId, processingValues) => {
// The flow is already completed as payments by tokens are immediately processed
window.location = '/payment/status';
},
/**
* Set the online payment flow for the selected payment option.
*
* For an acquirer to manage direct payments, it must call this method from within its
* override of `_prepareInlineForm` to declare its payment flow for the selected payment
* option.
*
* @private
* @param {string} flow - The flow for the selected payment option. Either 'redirect',
* 'direct' or 'token'
* @return {undefined}
*/
_setPaymentFlow: function (flow = 'redirect') {
if (flow !== 'redirect' && flow !== 'direct' && flow !== 'token') {
console.warn(
`payment_form_mixin: method '_setPaymentFlow' was called with invalid flow:
${flow}. Falling back to 'redirect'.`
);
this.txContext.flow = 'redirect';
} else {
this.txContext.flow = flow;
}
},
/**
* Show the "Save my payment details" label and checkbox, and the submit button.
*
* @private
* @return {undefined}.
*/
_showInputs: function () {
const $submitButton = this.$('button[name="o_payment_submit_button"]');
const $tokenizeCheckboxes = this.$('input[name="o_payment_save_as_token"]');
$submitButton.removeClass('d-none');
$tokenizeCheckboxes.closest('label').removeClass('d-none');
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Hide all extra payment icons of the acquirer linked to the clicked button.
*
* Called when clicking on the "show less" button.
*
* @private
* @param {Event} ev
* @return {undefined}
*/
_onClickLessPaymentIcons: ev => {
ev.preventDefault();
ev.stopPropagation();
// Hide the extra payment icons, and the "show less" button
const $itemList = $(ev.currentTarget).parents('ul');
const maxIconNumber = $itemList.data('max-icons');
$itemList.children('li').slice(maxIconNumber).addClass('d-none');
// Show the "show more" button
$itemList.find('a[name="o_payment_icon_more"]').parents('li').removeClass('d-none');
},
/**
* Display all the payment icons of the acquirer linked to the clicked button.
*
* Called when clicking on the "show more" button.
*
* @private
* @param {Event} ev
* @return {undefined}
*/
_onClickMorePaymentIcons: ev => {
ev.preventDefault();
ev.stopPropagation();
// Display all the payment icons, and the "show less" button
$(ev.currentTarget).parents('ul').children('li').removeClass('d-none');
// Hide the "show more" button
$(ev.currentTarget).parents('li').addClass('d-none');
},
/**
* Mark the clicked card radio button as checked and open the inline form, if any.
*
* Called when clicking on the card of a payment option.
*
* @private
* @param {Event} ev
* @return {undefined}
*/
_onClickPaymentOption: function (ev) {
// Uncheck all radio buttons
this.$('input[name="o_payment_radio"]').prop('checked', false);
// Check radio button linked to selected payment option
const checkedRadio = $(ev.currentTarget).find('input[name="o_payment_radio"]')[0];
$(checkedRadio).prop('checked', true);
// Show the inputs in case they had been hidden
this._showInputs();
// Disable the submit button while building the content
this._disableButton(false);
// Unfold and prepare the inline form of selected payment option
this._displayInlineForm(checkedRadio);
// Re-enable the submit button
this._enableButton();
},
};
});
@@ -1,65 +0,0 @@
$(function () {
$('input#cc_number').payment('formatCardNumber');
$('input#cc_cvc').payment('formatCardCVC');
$('input#cc_expiry').payment('formatCardExpiry')
$('input#cc_number').on('focusout', function (e) {
var valid_value = $.payment.validateCardNumber(this.value);
var card_type = $.payment.cardType(this.value);
if (card_type) {
$(this).parent('.form-group').children('.card_placeholder').removeClass().addClass('card_placeholder ' + card_type);
$(this).parent('.form-group').children('input[name="cc_brand"]').val(card_type)
}
else {
$(this).parent('.form-group').children('.card_placeholder').removeClass().addClass('card_placeholder');
}
if (valid_value) {
$(this).parent('.form-group').addClass('o_has_success').find('.form-control, .custom-select').addClass('is-valid');
$(this).parent('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
$(this).siblings('.o_invalid_field').remove();
}
else {
$(this).parent('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
$(this).parent('.form-group').removeClass('o_has_success').find('.form-control, .custom-select').removeClass('is-valid');
}
});
$('input#cc_cvc').on('focusout', function (e) {
var cc_nbr = $(this).parents('.oe_cc').find('#cc_number').val();
var card_type = $.payment.cardType(cc_nbr);
var valid_value = $.payment.validateCardCVC(this.value, card_type);
if (valid_value) {
$(this).parent('.form-group').addClass('o_has_success').find('.form-control, .custom-select').addClass('is-valid');
$(this).parent('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
$(this).siblings('.o_invalid_field').remove();
}
else {
$(this).parent('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
$(this).parent('.form-group').removeClass('o_has_success').find('.form-control, .custom-select').removeClass('is-valid');
}
});
$('input#cc_expiry').on('focusout', function (e) {
var expiry_value = $.payment.cardExpiryVal(this.value);
var month = expiry_value.month || '';
var year = expiry_value.year || '';
var valid_value = $.payment.validateCardExpiry(month, year);
if (valid_value) {
$(this).parent('.form-group').addClass('o_has_success').find('.form-control, .custom-select').addClass('is-valid');
$(this).parent('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
$(this).siblings('.o_invalid_field').remove();
}
else {
$(this).parent('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
$(this).parent('.form-group').removeClass('o_has_success').find('.form-control, .custom-select').removeClass('is-valid');
}
});
$('select[name="pm_acquirer_id"]').on('change', function() {
var acquirer_id = $(this).val();
$('.acquirer').addClass('d-none');
$('.acquirer[data-acquirer-id="'+acquirer_id+'"]').removeClass('d-none');
});
});
@@ -1,4 +1,4 @@
odoo.define('payment.processing', function (require) {
odoo.define('payment.post_processing', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
@@ -11,9 +11,9 @@ odoo.define('payment.processing', function (require) {
$.blockUI.defaults.css["background-color"] = '';
$.blockUI.defaults.overlayCSS["opacity"] = '0.9';
publicWidget.registry.PaymentProcessing = publicWidget.Widget.extend({
selector: '.o_payment_processing',
xmlDependencies: ['/payment/static/src/xml/payment_processing.xml'],
publicWidget.registry.PaymentPostProcessing = publicWidget.Widget.extend({
selector: 'div[name="o_payment_status"]',
xmlDependencies: ['/payment/static/src/xml/payment_post_processing.xml'],
_pollCount: 0,
@@ -38,9 +38,14 @@ odoo.define('payment.processing', function (require) {
},
poll: function () {
var self = this;
ajax.jsonRpc('/payment/process/poll', 'call', {}).then(function(data) {
this._rpc({
route: '/payment/status/poll',
params: {
'csrf_token': core.csrf_token,
}
}).then(function(data) {
if(data.success === true) {
self.processPolledData(data.transactions);
self.processPolledData(data.display_values_list);
}
else {
switch(data.error) {
@@ -61,7 +66,7 @@ odoo.define('payment.processing', function (require) {
self.startPolling();
});
},
processPolledData: function (transactions) {
processPolledData: function (display_values_list) {
var render_values = {
'tx_draft': [],
'tx_pending': [],
@@ -71,16 +76,23 @@ odoo.define('payment.processing', function (require) {
'tx_error': [],
};
if (transactions.length > 0 && ['transfer', 'sepa_direct_debit'].indexOf(transactions[0].acquirer_provider) >= 0) {
window.location = transactions[0].return_url;
if (display_values_list.length > 0) {
// In almost every cases there will be a single transaction to display. If there are
// more than one transaction, the last one will most likely be the one that was
// confirmed. We use this one to redirect the user to the final page.
if (display_values_list[0].is_validation) {
window.location = display_values_list[0].validation_route;
} else {
window.location = display_values_list[0].landing_route;
}
return;
}
// group the transaction according to their state
transactions.forEach(function (tx) {
var key = 'tx_' + tx.state;
display_values_list.forEach(function (display_values) {
var key = 'tx_' + display_values.state;
if(key in render_values) {
render_values[key].push(tx);
render_values[key].push(display_values);
}
});
@@ -97,7 +109,7 @@ odoo.define('payment.processing', function (require) {
if(countTxInState(['tx_done', 'tx_error', 'tx_pending', 'tx_authorized']) === 1) {
var tx = render_values['tx_done'][0] || render_values['tx_authorized'][0] || render_values['tx_error'][0];
if (tx) {
window.location = tx.return_url;
window.location = tx.landing_route;
return;
}
}
@@ -107,7 +119,7 @@ odoo.define('payment.processing', function (require) {
displayContent: function (xmlid, render_values) {
var html = core.qweb.render(xmlid, render_values);
$.unblockUI();
this.$el.find('.o_payment_processing_content').html(html);
this.$el.find('div[name="o_payment_status_content"]').html(html);
},
displayLoading: function () {
var msg = _t("We are processing your payment, please wait ...");
@@ -17,12 +17,26 @@
margin-top: 5px;
}
.card-body:first-child {
border-top: 0px;
.card {
border-radius: 5px;
overflow: hidden;
}
.card {
border-radius: 10px;
.card-body {
&:not(:first-child) {
border-top: 1px solid #dddddd;
}
padding: 1.14em !important;
&.o_payment_option_card:hover {
cursor: pointer;
}
}
.card-footer {
padding: 0.5rem;
label {
margin-top: 15px;
}
}
.card-footer:last-child {
@@ -30,14 +44,6 @@
border-bottom-left-radius: 10px !important;
}
.card-body {
border-top: 1px solid #ddd;
padding: 1.14em !important;
&.o_payment_acquirer_select:hover {
cursor: pointer;
}
}
.payment_icon_list {
position: relative;
li {
@@ -4,7 +4,7 @@ input#cc_number {
}
div.card_placeholder {
background-image: url("/website_payment/static/src/img/placeholder.png");
background-image: url("/payment/static/src/img/placeholder.png");
background-repeat: no-repeat;
width: 32px;
height: 20px;
@@ -26,36 +26,36 @@ div.o_card_brand_detail {
}
div.amex {
background-image: url("/website_payment/static/src/img/amex.png");
background-image: url("/payment/static/src/img/amex.png");
background-repeat: no-repeat;
}
div.diners {
background-image: url("/website_payment/static/src/img/diners.png");
background-image: url("/payment/static/src/img/diners.png");
background-repeat: no-repeat;
}
div.discover {
background-image: url("/website_payment/static/src/img/discover.png");
background-image: url("/payment/static/src/img/discover.png");
background-repeat: no-repeat;
}
div.jcb {
background-image: url("/website_payment/static/src/img/jcb.png");
background-image: url("/payment/static/src/img/jcb.png");
background-repeat: no-repeat;
}
div.mastercard {
background-image: url("/website_payment/static/src/img/mastercard.png");
background-image: url("/payment/static/src/img/mastercard.png");
background-repeat: no-repeat;
}
div.visa {
background-image: url("/website_payment/static/src/img/visa.png");
background-image: url("/payment/static/src/img/visa.png");
background-repeat: no-repeat;
}
ul.payment_method_list img.rounded {
ul.checkout img.rounded {
max-width: 100px;
max-height: 40px;
}
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="payment" xml:space="preserve">
<!-- The templates here as rendered by 'payment_processing.js', you can also take
a look at payment_templates.xml (xmlid: payment_process_page) for more infos-->
<!-- The templates here as rendered by 'post_processing.js', you can also take
a look at payment_portal_templates.xml (xmlid: payment_status) for more infos-->
<t t-name="payment.display_tx_list">
<div>
<!-- Error transactions -->
@@ -12,10 +12,10 @@
<li class="list-group-item">
<h4 class="list-group-item-heading mb5">
<t t-esc="tx['reference']"/>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency_code']"/></span>
</h4>
<small class="list-group-item-text">
An error occured during the processing of this payment.<br/>
An error occurred during the processing of this payment.<br/>
<strong>Reason:</strong> <t t-esc="tx['state_message']"/>
</small>
</li>
@@ -27,13 +27,13 @@
<div class="list-group">
<!-- Done transactions -->
<t t-foreach="tx_done" t-as="tx">
<a t-att-href="tx['return_url']" class="list-group-item">
<a t-att-href="tx['landing_route']" class="list-group-item">
<h4 class="list-group-item-heading mb5">
<t t-esc="tx['reference']"/>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency_code']"/></span>
</h4>
<small class="list-group-item-text">
<t t-if="!tx['is_processed']">
<t t-if="!tx['is_post_processed']">
Your order is being processed, please wait ... <i class="fa fa-cog fa-spin"/>
</t>
<t t-else="">
@@ -45,14 +45,16 @@
</t>
<!-- Pending transactions -->
<t t-foreach="tx_pending" t-as="tx">
<a t-att-href="tx['return_url']" class="list-group-item">
<a t-att-href="tx['landing_route']" class="list-group-item">
<h4 class="list-group-item-heading mb5">
<t t-esc="tx['reference']"/>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency_code']"/></span>
</h4>
<small class="list-group-item-text">
<t t-if="tx['message_to_display']">
<t t-raw="tx['message_to_display']"/>
<t t-if="tx['display_message']">
<!-- display_message is the content of the HTML field associated
with the current transaction state, set on the acquirer. -->
<t t-raw="tx['display_message']"/>
</t>
<t t-else="">
Your payment is in pending state.<br/>
@@ -67,11 +69,13 @@
<li class="list-group-item">
<h4 class="list-group-item-heading mb5">
<t t-esc="tx['reference']"/>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency_code']"/></span>
</h4>
<small class="list-group-item-text">
<t t-if="tx['message_to_display']">
<t t-raw="tx['message_to_display']"/>
<t t-if="tx['display_message']">
<!-- display_message is the content of the HTML field associated
with the current transaction state, set on the acquirer. -->
<t t-raw="tx['display_message']"/>
</t>
<t t-else="">
Your payment has been received but need to be confirmed manually.<br/>
@@ -90,11 +94,13 @@
<li class="list-group-item">
<h4 class="list-group-item-heading mb5">
<t t-esc="tx['reference']"/>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency_code']"/></span>
</h4>
<small class="list-group-item-text">
<t t-if="tx['message_to_display']">
<t t-raw="tx['message_to_display']"/>
<t t-if="tx['display_message']">
<!-- display_message is the content of the HTML field associated
with the current transaction state, set on the acquirer. -->
<t t-raw="tx['display_message']"/>
</t>
<t t-else="">
We are waiting for the payment acquirer to confirm the payment.
@@ -112,7 +118,7 @@
<li class="list-group-item">
<h4 class="list-group-item-heading mb5">
<t t-esc="tx['reference']"/>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
<span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency_code']"/></span>
</h4>
<small class="list-group-item-text">
This transaction has been cancelled.<br/>
+5 -1
View File
@@ -1,3 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import http_common
from . import multicompany_common
from . import test_flows
from . import test_multicompany_flows
+143 -34
View File
@@ -1,44 +1,50 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo.fields import Command
from odoo.addons.payment.tests.utils import PaymentTestUtils
_logger = logging.getLogger(__name__)
class PaymentAcquirerCommon(AccountTestInvoicingCommon):
class PaymentCommon(PaymentTestUtils):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
def setUpClass(cls):
super().setUpClass()
cls.currency_euro = cls._prepare_currency('EUR')
cls.currency_usd = cls._prepare_currency('USD')
cls.currency_euro = cls.env.ref('base.EUR')
cls.country_belgium = cls.env.ref('base.be')
cls.country_france = cls.env.ref('base.fr')
cls.europe = cls.env.ref('base.europe')
# dict partner values
cls.buyer_values = {
'partner_name': 'Norbert Buyer',
'partner_lang': 'en_US',
'partner_email': 'norbert.buyer@example.com',
'partner_address': 'Huge Street 2/543',
'partner_phone': '0032 12 34 56 78',
'partner_city': 'Sin City',
'partner_zip': '1000',
'partner_country': cls.country_belgium,
'partner_country_id': cls.country_belgium.id,
'partner_country_name': 'Belgium',
'billing_partner_name': 'Norbert Buyer',
'billing_partner_commercial_company_name': 'Big Company',
'billing_partner_lang': 'en_US',
'billing_partner_email': 'norbert.buyer@example.com',
'billing_partner_address': 'Huge Street 2/543',
'billing_partner_phone': '0032 12 34 56 78',
'billing_partner_city': 'Sin City',
'billing_partner_zip': '1000',
'billing_partner_country': cls.country_belgium,
'billing_partner_country_id': cls.country_belgium.id,
'billing_partner_country_name': 'Belgium',
}
cls.group_user = cls.env.ref('base.group_user')
cls.group_portal = cls.env.ref('base.group_portal')
cls.group_public = cls.env.ref('base.group_public')
# test partner
cls.buyer = cls.env['res.partner'].create({
cls.admin_user = cls.env.ref('base.user_admin')
cls.internal_user = cls.env['res.users'].create({
'name': 'Internal User (Test)',
'login': 'internal',
'password': 'internal',
'groups_id': [Command.link(cls.group_user.id)]
})
cls.portal_user = cls.env['res.users'].create({
'name': 'Portal User (Test)',
'login': 'payment_portal',
'password': 'payment_portal',
'groups_id': [Command.link(cls.group_portal.id)]
})
cls.public_user = cls.env.ref('base.public_user')
cls.admin_partner = cls.admin_user.partner_id
cls.internal_partner = cls.internal_user.partner_id
cls.portal_partner = cls.portal_user.partner_id
cls.default_partner = cls.env['res.partner'].create({
'name': 'Norbert Buyer',
'lang': 'en_US',
'email': 'norbert.buyer@example.com',
@@ -47,5 +53,108 @@ class PaymentAcquirerCommon(AccountTestInvoicingCommon):
'phone': '0032 12 34 56 78',
'city': 'Sin City',
'zip': '1000',
'country_id': cls.country_belgium.id})
cls.buyer_id = cls.buyer.id
'country_id': cls.country_belgium.id,
})
# Create a dummy acquirer to allow basic tests without any specific acquirer implementation
arch = """
<form action="dummy" method="post">
<input type="hidden" name="view_id" t-att-value="viewid"/>
<input type="hidden" name="user_id" t-att-value="user_id.id"/>
</form>
""" # We exploit the default values `viewid` and `user_id` from QWeb's rendering context
redirect_form = cls.env['ir.ui.view'].create({
'name': "Dummy Redirect Form",
'type': 'qweb',
'arch': arch,
})
journal = cls.env['account.journal'].search(
[('company_id', '=', cls.env.company.id), ('type', 'in', ['bank', 'cash'])],
limit=1
)
cls.dummy_acquirer = cls.env['payment.acquirer'].create({
'name': "Dummy Acquirer",
'provider': 'none',
'state': 'test',
'allow_tokenization': True,
'redirect_form_view_id': redirect_form.id,
'journal_id': journal.id,
})
cls.acquirer = cls.dummy_acquirer
cls.amount = 1111.11
cls.company = cls.env.company
cls.currency = cls.currency_euro
cls.partner = cls.default_partner
cls.reference = "Test Transaction"
#=== Utils ===#
@classmethod
def _prepare_currency(cls, currency_code):
currency = cls.env['res.currency'].with_context(active_test=False).search(
[('name', '=', currency_code.upper())]
)
currency.action_unarchive()
return currency
@classmethod
def _prepare_acquirer(cls, provider='none', company=None, update_values=None):
""" 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`
"""
company = company or cls.env.company
update_values = update_values or {}
acquirer = cls.env['payment.acquirer'].sudo().search(
[('provider', '=', provider), ('company_id', '=', company.id)], limit=1
)
if not acquirer:
base_acquirer = cls.env['payment.acquirer'].sudo().search(
[('provider', '=', provider)], limit=1
)
if not base_acquirer:
_logger.error("no payment.acquirer found for provider %s", provider)
return cls.env['payment.acquirer']
else:
acquirer = base_acquirer.copy({'company_id': company.id})
acquirer.write({
'state': 'test',
**update_values
})
return acquirer
def create_transaction(self, flow, sudo=True, **values):
default_values = {
'amount': self.amount,
'currency_id': self.currency.id,
'acquirer_id': self.acquirer.id,
'reference': self.reference,
'operation': f'online_{flow}',
'partner_id': self.partner.id,
}
return self.env['payment.transaction'].sudo(sudo).create(dict(default_values, **values))
def create_token(self, sudo=True, **values):
default_values = {
'name': "XXXXXXXXXXXXXXX-2565 (TEST)",
'acquirer_id': self.acquirer.id,
'partner_id': self.partner.id,
'acquirer_ref': "Acquirer Ref (TEST)",
}
return self.env['payment.token'].sudo(sudo).create(dict(default_values, **values))
def _get_tx(self, reference):
return self.env['payment.transaction'].sudo().search([
('reference', '=', reference),
])
+196
View File
@@ -0,0 +1,196 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from uuid import uuid4
from lxml import etree, objectify
from odoo import http
from odoo.tests import HttpCase
from odoo.addons.payment.tests.utils import PaymentTestUtils
class PaymentHttpCommon(HttpCase, PaymentTestUtils):
""" HttpCase common to build and simulate requests going through payment controllers.
Only use if you effectively want to test controllers.
If you only want to test 'models' code, the PaymentCommon should be sufficient.
Note: This Common is expected to be used in parallel with the main PaymentCommon.
"""
# Helpers #
###########
def _build_jsonrpc_payload(self, params):
"""Helper to properly build jsonrpc payload"""
if not getattr(self, 'session', None):
# We need to create a session (public if no login & passwd)
# before generating a csrf token
self.authenticate('', '')
params['csrf_token'] = http.WebRequest.csrf_token(self)
return {
"jsonrpc": "2.0",
"method": "call",
"id": str(uuid4()),
"params": params,
}
def _make_http_get_request(self, url, params):
formatted_data = dict()
for k, v in params.items():
if isinstance(v, float):
formatted_data[k] = str(v)
else:
formatted_data[k] = v
return self.opener.get(url, params=formatted_data)
def _make_json_request(self, url, params):
data = self._build_jsonrpc_payload(params)
return self.opener.post(url, json=data)
def _get_tx_context(self, response, form_name):
"""Extracts txContext & other form info (acquirer & token ids)
from a payment response (with manage/checkout html form)
:param response: http Response, with a payment form as text
:param str form_name: o_payment_manage / o_payment_checkout
:return: Transaction context (+ acquirer_ids & token_ids)
:rtype: dict
"""
# Need to specify an HTML parser as parser
# Otherwise void elements (<img>, <link> without a closing / tag)
# are considered wrong and trigger a lxml.etree.XMLSyntaxError
html_tree = objectify.fromstring(
response.text,
parser=etree.HTMLParser(),
)
checkout_form = html_tree.xpath(f"//form[@name='{form_name}']")[0]
values = {}
for key, val in checkout_form.items():
if key.startswith("data-"):
formatted_key = key[5:].replace('-', '_')
if formatted_key.endswith('_id'):
formatted_val = int(val)
elif formatted_key == 'amount':
formatted_val = float(val)
else:
formatted_val = val
values[formatted_key] = formatted_val
payment_options_inputs = html_tree.xpath("//input[@name='o_payment_radio']")
acquirer_ids = []
token_ids = []
for p_o_input in payment_options_inputs:
data = dict()
for key, val in p_o_input.items():
if key.startswith('data-'):
data[key[5:]] = val
if data['payment-option-type'] == 'acquirer':
acquirer_ids.append(int(data['payment-option-id']))
else:
token_ids.append(int(data['payment-option-id']))
values.update({
'acquirer_ids': acquirer_ids,
'token_ids': token_ids,
})
return values
# payment/pay #
###############
def _prepare_pay_values(self, amount=0.0, currency=None, reference='', partner=None):
"""Prepare basic payment/pay route values
NOTE: needs PaymentCommon to enable fallback values.
:rtype: dict
"""
amount = amount or self.amount
currency = currency or self.currency
reference = reference or self.reference
partner = partner or self.partner
return {
'amount': amount,
'currency_id': currency.id,
'reference': reference,
'partner_id': partner.id,
'access_token': self._generate_test_access_token(partner.id, amount, currency.id),
}
def portal_pay(self, **route_kwargs):
"""/payment/pay txContext feedback
NOTE: must be authenticated before calling method.
Or an access_token should be specified in route_kwargs
"""
uri = '/payment/pay'
url = self._build_url(uri)
return self._make_http_get_request(url, route_kwargs)
def get_tx_checkout_context(self, **route_kwargs):
response = self.portal_pay(**route_kwargs)
self.assertEqual(response.status_code, 200)
return self._get_tx_context(response, 'o_payment_checkout')
# /my/payment_method #
######################
def portal_payment_method(self):
"""/my/payment_method txContext feedback
NOTE: must be authenticated before calling method
validation flow is restricted to logged users
"""
uri = '/my/payment_method'
url = self._build_url(uri)
return self._make_http_get_request(url, {})
def get_tx_manage_context(self, **route_kwargs):
response = self.portal_payment_method(**route_kwargs)
self.assertEqual(response.status_code, 200)
return self._get_tx_context(response, 'o_payment_manage')
# payment/transaction #
#######################
def portal_transaction(self, **route_kwargs):
"""/payment/transaction feedback
:returns: processing values for given route_kwargs
:rtype: dict
"""
uri = '/payment/transaction'
url = self._build_url(uri)
return self._make_json_request(url, route_kwargs)
def get_processing_values(self, **route_kwargs):
response = self.portal_transaction(**route_kwargs)
self.assertEqual(response.status_code, 200)
resp_content = json.loads(response.content)
return resp_content['result']
# payment/validation #
######################
def portal_validate_transaction(self, tx):
"""/payment/validation feedback, for a given tx (and its validation_route)
NOTE: the validation route is restricted to logged users
If you are not logged in, the route won't be called
but it won't raise/log, the response will be a redirection to the login page.
"""
uri = tx.validation_route
url = self._build_url(uri)
# No params since all GET arguments are already specified in the validation route.
# no returned value, validation response = redirect
return self._make_http_get_request(url, {})
@@ -0,0 +1,39 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo.fields import Command
from odoo.addons.payment.tests.common import PaymentCommon
_logger = logging.getLogger(__name__)
class PaymentMultiCompanyCommon(PaymentCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company_a = cls.env.company
cls.company_b = cls.env['res.company'].create({'name': "Odoo Inc (TEST)"})
cls.user_company_a = cls.internal_user
cls.user_company_b = cls.env['res.users'].create({
'name': f"{cls.company_b.name} User (TEST)",
'login': 'user_company_b',
'password': 'user_company_b',
'company_id': cls.company_b.id,
'company_ids': [Command.set(cls.company_b.ids)],
'groups_id': [Command.link(cls.group_user.id)],
})
cls.user_multi_company = cls.env['res.users'].create({
'name': "Multi Company User (TEST)",
'login': 'user_multi_company',
'password': 'user_multi_company',
'company_id': cls.company_a.id,
'company_ids': [Command.set([cls.company_a.id, cls.company_b.id])],
'groups_id': [Command.link(cls.group_user.id)],
})
cls.acquirer_company_b = cls._prepare_acquirer(company=cls.company_b)
+308
View File
@@ -0,0 +1,308 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import patch
from freezegun import freeze_time
from odoo.tests import tagged
from odoo.tools import mute_logger
from odoo.addons.payment.tests.common import PaymentCommon
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
@tagged('post_install', '-at_install')
class TestFlows(PaymentCommon, PaymentHttpCommon):
def _test_flow(self, flow):
""" Simulate the given online payment flow and tests the tx values at each step.
:param str flow: The online payment flow to test ('direct', 'redirect', or 'token')
:return: The transaction created by the payment flow
:rtype: recordset of `payment.transaction`
"""
self.reference = f"Test Transaction ({flow} - {self.partner.name})"
route_values = self._prepare_pay_values()
# /payment/pay
tx_context = self.get_tx_checkout_context(**route_values)
for key, val in tx_context.items():
if key in route_values:
self.assertEqual(val, route_values[key])
# Route values are taken from tx_context result of /pay route to correctly simulate the flow
route_values = {
k: tx_context[k]
for k in [
'amount',
'currency_id',
'reference_prefix',
'partner_id',
'access_token',
'landing_route',
]
}
route_values.update({
'flow': flow,
'payment_option_id': self.acquirer.id,
'tokenization_requested': False,
'validation_route': False,
})
if flow == 'token':
route_values['payment_option_id'] = self.create_token().id
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'])
# Tx values == given values
self.assertEqual(tx_sudo.acquirer_id.id, self.acquirer.id)
self.assertEqual(tx_sudo.amount, self.amount)
self.assertEqual(tx_sudo.currency_id.id, self.currency.id)
self.assertEqual(tx_sudo.partner_id.id, self.partner.id)
self.assertEqual(tx_sudo.reference, self.reference)
# processing_values == given values
self.assertEqual(processing_values['acquirer_id'], self.acquirer.id)
self.assertEqual(processing_values['amount'], self.amount)
self.assertEqual(processing_values['currency_id'], self.currency.id)
self.assertEqual(processing_values['partner_id'], self.partner.id)
self.assertEqual(processing_values['reference'], self.reference)
if flow == 'redirect':
# In redirect flow, we verify the rendering of the dummy test form
redirect_form_info = self._extract_values_from_html_form(
processing_values['redirect_form_html'])
# Test content of rendered dummy redirect form
self.assertEqual(redirect_form_info['action'], 'dummy')
# Public user since we didn't authenticate with a specific user
self.assertEqual(
redirect_form_info['inputs']['user_id'],
str(self.user.id))
self.assertEqual(
redirect_form_info['inputs']['view_id'],
str(self.dummy_acquirer.redirect_form_view_id.id))
return tx_sudo
def test_10_direct_checkout_public(self):
# No authentication needed, automatic fallback on public user
self.user = self.public_user
self._test_flow('direct')
def test_11_direct_checkout_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.user = self.portal_user
self.partner = self.portal_partner
self._test_flow('direct')
def test_12_direct_checkout_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.user = self.internal_user
self.partner = self.internal_partner
self._test_flow('direct')
def test_20_redirect_checkout_public(self):
self.partner = self.default_partner
self.user = self.env.ref('base.public_user')
self._test_flow('redirect')
def test_21_redirect_checkout_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.user = self.portal_user
self.partner = self.portal_partner
self._test_flow('redirect')
def test_22_redirect_checkout_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.user = self.internal_user
self.partner = self.internal_partner
self._test_flow('redirect')
# Payment by token #
####################
# NOTE: not tested as public user because a public user cannot save payment details
def test_31_tokenize_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.partner = self.portal_partner
self.user = self.portal_user
self._test_flow('token')
def test_32_tokenize_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.partner = self.internal_partner
self.user = self.internal_user
self._test_flow('token')
# VALIDATION #
##############
# NOTE: not tested as public user because the validation route & flow
# is only available for logged in users.
# freeze time for consistent singularize_prefix behavior during the test
@freeze_time("2011-11-02 12:00:21")
def _test_validation(self, flow):
# Fixed with freezegun
expected_reference = 'validation-20111102120021'
validation_amount = self.acquirer._get_validation_amount()
validation_currency = self.acquirer._get_validation_currency()
tx_context = self.get_tx_manage_context()
expected_values = {
'partner_id': self.partner.id,
'access_token': self._generate_test_access_token(self.partner.id, None, None),
'reference_prefix': expected_reference
}
for key, val in tx_context.items():
if key in expected_values:
self.assertEqual(val, expected_values[key])
transaction_values = {
'amount': None,
'currency_id': None,
'partner_id': tx_context['partner_id'],
'access_token': tx_context['access_token'],
'flow': flow,
'payment_option_id': self.acquirer.id,
'tokenization_requested': False,
'validation_route': tx_context['validation_route'],
'reference_prefix': tx_context['reference_prefix'],
'landing_route': tx_context['landing_route'],
}
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self.get_processing_values(**transaction_values)
tx_sudo = self._get_tx(processing_values['reference'])
# Tx values == given values
self.assertEqual(tx_sudo.acquirer_id.id, self.acquirer.id)
self.assertEqual(tx_sudo.amount, validation_amount)
self.assertEqual(tx_sudo.currency_id.id, validation_currency.id)
self.assertEqual(tx_sudo.partner_id.id, self.partner.id)
self.assertEqual(tx_sudo.reference, expected_reference)
# processing_values == given values
self.assertEqual(processing_values['acquirer_id'], self.acquirer.id)
self.assertEqual(processing_values['amount'], validation_amount)
self.assertEqual(processing_values['currency_id'], validation_currency.id)
self.assertEqual(processing_values['partner_id'], self.partner.id)
self.assertEqual(processing_values['reference'], expected_reference)
_send_refund_request = type(self.env['payment.transaction'])._send_refund_request
def do_sthg_on_refund(self):
self.state_message = "refund"
return _send_refund_request(self)
with patch.object(
type(self.env['payment.transaction']),
'_send_refund_request',
do_sthg_on_refund,
):
self.portal_validate_transaction(tx_sudo)
# Request was in another thread (for the request)
# We need to invalidate current cache to fetch latest value from db.
tx_sudo.invalidate_cache(['state_message'])
self.assertEqual(tx_sudo.state_message, 'refund')
def test_51_validation_direct_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.partner = self.portal_partner
self._test_validation(flow='direct')
def test_52_validation_direct_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.partner = self.internal_partner
self._test_validation(flow='direct')
def test_61_validation_redirect_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.partner = self.portal_partner
self._test_validation(flow='direct')
def test_62_validation_redirect_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.partner = self.internal_partner
self._test_validation(flow='direct')
# Specific flows #
##################
def test_pay_redirect_if_no_partner_exist(self):
route_values = self._prepare_pay_values()
route_values.pop('partner_id')
# Pay without a partner specified --> redirection to login page
response = self.portal_pay(**route_values)
self.assertTrue(response.url.startswith(self._build_url('/web/login?redirect=')))
# Pay without a partner specified (but loggged) --> pay with the partner of current user.
self.authenticate(self.portal_user.login, self.portal_user.login)
tx_context = self.get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['partner_id'], self.portal_partner.id)
def test_pay_no_token(self):
route_values = self._prepare_pay_values()
route_values.pop('partner_id')
route_values.pop('access_token')
# Pay without a partner specified --> redirection to login page
response = self.portal_pay(**route_values)
self.assertTrue(response.url.startswith(self._build_url('/web/login?redirect=')))
# Pay without a partner specified (but loggged) --> pay with the partner of current user.
self.authenticate(self.portal_user.login, self.portal_user.login)
tx_context = self.get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['partner_id'], self.portal_partner.id)
def test_pay_wrong_token(self):
route_values = self._prepare_pay_values()
route_values['access_token'] = "abcde"
# Pay with a wrong access token --> Not found (404)
response = self.portal_pay(**route_values)
self.assertEqual(response.status_code, 404)
def test_pay_wrong_currency(self):
# Pay with a wrong currency --> Not found (404)
self.currency = self.env['res.currency'].browse(self.env['res.currency'].search([], order='id desc', limit=1).id + 1000)
route_values = self._prepare_pay_values()
response = self.portal_pay(**route_values)
self.assertEqual(response.status_code, 404)
# Pay with an inactive currency --> Not found (404)
self.currency = self.env['res.currency'].search([('active', '=', False)], limit=1)
route_values = self._prepare_pay_values()
response = self.portal_pay(**route_values)
self.assertEqual(response.status_code, 404)
def test_transaction_wrong_flow(self):
transaction_values = self._prepare_pay_values()
transaction_values.update({
'flow': 'this flow does not exist',
'payment_option_id': self.acquirer.id,
'tokenization_requested': False,
'validation_route': 'whatever',
'reference_prefix': 'whatever',
'landing_route': 'whatever',
})
# Transaction step with a wrong flow --> UserError
with mute_logger('odoo.http'):
response = self.portal_transaction(**transaction_values)
self.assertIn(
"odoo.exceptions.UserError: The payment should either be direct, with redirection, or made by a token.",
response.text)
def test_transaction_wrong_token(self):
route_values = self._prepare_pay_values()
route_values['access_token'] = "abcde"
# Transaction step with a wrong access token --> ValidationError
with mute_logger('odoo.http'):
response = self.portal_transaction(**route_values)
self.assertIn(
"odoo.exceptions.ValidationError: The access token is invalid.",
response.text)
@@ -0,0 +1,72 @@
# 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.payment.tests.multicompany_common import PaymentMultiCompanyCommon
@tagged('post_install', '-at_install')
class TestMultiCompanyFlows(PaymentMultiCompanyCommon, PaymentHttpCommon):
def test_pay_logged_in_another_company(self):
"""User pays for an amount in another company."""
# for another res.partner than the user's one
route_values = self._prepare_pay_values(partner=self.user_company_b.partner_id)
# Log in as user from Company A
self.authenticate(self.user_company_a.login, self.user_company_a.login)
# Pay in company B
route_values['company_id'] = self.company_b.id
tx_context = self.get_tx_checkout_context(**route_values)
for key, val in tx_context.items():
if key in route_values:
if key == 'access_token':
continue # access_token was modified due to the change of partner.
elif key == 'partner_id':
# The partner is replaced by the partner of the user paying.
self.assertEqual(val, self.user_company_a.partner_id.id)
else:
self.assertEqual(val, route_values[key])
available_acquirers = self.env['payment.acquirer'].sudo().browse(tx_context['acquirer_ids'])
self.assertIn(self.acquirer_company_b, available_acquirers)
self.assertEqual(available_acquirers.company_id, self.company_b)
validation_values = {
k: tx_context[k]
for k in [
'amount',
'currency_id',
'reference_prefix',
'partner_id',
'access_token',
'landing_route',
]
}
validation_values.update({
'flow': 'direct',
'payment_option_id': self.acquirer_company_b.id,
'tokenization_requested': False,
'validation_route': False,
})
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self.get_processing_values(**validation_values)
tx_sudo = self._get_tx(processing_values['reference'])
# Tx values == given values
self.assertEqual(tx_sudo.acquirer_id.id, self.acquirer_company_b.id)
self.assertEqual(tx_sudo.amount, self.amount)
self.assertEqual(tx_sudo.currency_id.id, self.currency.id)
self.assertEqual(tx_sudo.partner_id.id, self.user_company_a.partner_id.id)
self.assertEqual(tx_sudo.reference, self.reference)
self.assertEqual(tx_sudo.company_id, self.company_b)
# processing_values == given values
self.assertEqual(processing_values['acquirer_id'], self.acquirer_company_b.id)
self.assertEqual(processing_values['amount'], self.amount)
self.assertEqual(processing_values['currency_id'], self.currency.id)
self.assertEqual(processing_values['partner_id'], self.user_company_a.partner_id.id)
self.assertEqual(processing_values['reference'], self.reference)
+55
View File
@@ -0,0 +1,55 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from lxml import objectify
from werkzeug import urls
from odoo.tests.common import TransactionCase
from odoo.tools.misc import hmac as hmac_tool
_logger = logging.getLogger(__name__)
class PaymentTestUtils(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.base_url = cls.env['ir.config_parameter'].get_param('web.base.url')
def _generate_test_access_token(self, *values):
""" Generate an access token based on the provided values for testing purposes.
This methods returns a token identical to that generated by
payment.utils.generate_access_token but uses the test class environment rather than the
environment of odoo.http.request.
See payment.utils.generate_access_token for additional details.
:param list values: The values to use for the generation of the token
:return: The generated access token
:rtype: str
"""
token_str = '|'.join(str(val) for val in values)
access_token = hmac_tool(self.env(su=True), 'generate_access_token', token_str)
return access_token
def _build_url(self, route):
return urls.url_join(self.base_url, route)
def _extract_values_from_html_form(self, html_form):
""" Extract the transaction rendering values from an HTML form.
:param str html_form: The HTML form
:return: The extracted information (action & inputs)
:rtype: dict[str:str]
"""
html_tree = objectify.fromstring(html_form)
action = html_tree.get('action')
inputs = {form_input.get('name'): form_input.get('value') for form_input in html_tree.input}
return {
'action': action,
'inputs': inputs,
}
+158
View File
@@ -0,0 +1,158 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields
from odoo.http import request
from odoo.tools import consteq, float_round, ustr
from odoo.tools.misc import hmac as hmac_tool
# Access token management
def generate_access_token(*values):
""" Generate an access token based on the provided values.
The token allows to later verify the validity of a request, based on a given set of values.
These will generally include the partner id, amount, currency id, transaction id or transaction
reference.
All values must be convertible to a string.
:param list values: The values to use for the generation of the token
:return: The generated access token
:rtype: str
"""
token_str = '|'.join(str(val) for val in values)
access_token = hmac_tool(request.env(su=True), 'generate_access_token', token_str)
return access_token
def check_access_token(access_token, *values):
""" Check the validity of the access token for the provided values.
The values must be provided in the exact same order as they were to `generate_access_token`.
All values must be convertible to a string.
:param str access_token: The access token used to verify the provided values
:param list values: The values to verify against the token
:return: True if the check is successful
:rtype: bool
"""
authentic_token = generate_access_token(*values)
return access_token and consteq(ustr(access_token), authentic_token)
# Transaction values formatting
def singularize_reference_prefix(prefix='tx', separator='-', max_length=None):
""" Make the prefix more unique by suffixing it with the current datetime.
When the prefix is a placeholder that would be part of a large sequence of references sharing
the same prefix, such as "tx" or "validation", singularizing it allows to make it part of a
single-element sequence of transactions. The computation of the full reference will then execute
faster by failing to find existing references with a matching prefix.
If the `max_length` argument is passed, the end of the prefix can be stripped before
singularizing to ensure that the result accounts for no more than `max_length` characters.
:param str prefix: The custom prefix to singularize
:param str separator: The custom separator used to separate the prefix from the suffix
:param int max_length: The maximum length of the singularized prefix
:return: The singularized prefix
:rtype: str
"""
if prefix is None:
prefix = 'tx'
if max_length:
DATETIME_LENGTH = 14
assert max_length >= 1 + len(separator) + DATETIME_LENGTH # 1 char + separator + datetime
prefix = prefix[:max_length-len(separator)-DATETIME_LENGTH]
return f'{prefix}{separator}{fields.Datetime.now().strftime("%Y%m%d%H%M%S")}'
def to_major_currency_units(minor_amount, currency, arbitrary_decimal_number=None):
""" Return the amount converted to the major units of its currency.
The conversion is done by dividing the amount by 10^k where k is the number of decimals of the
currency as per the ISO 4217 norm.
To force a different number of decimals, set it as the value of the `decimal_number` argument.
:param float minor_amount: The amount in minor units, to convert in major units
:param recordset currency: The currency of the amount, as a `res.currency` record
:param int arbitrary_decimal_number: The number of decimals to use instead of that of ISO 4217
:return: The amount in major units of its currency
:rtype: int
"""
currency.ensure_one()
if arbitrary_decimal_number is None:
decimal_number = currency.decimal_places
else:
decimal_number = arbitrary_decimal_number
return float_round(minor_amount, 0) / (10**decimal_number)
def to_minor_currency_units(major_amount, currency, arbitrary_decimal_number=None):
""" Return the amount converted to the minor units of its currency.
The conversion is done by multiplying the amount by 10^k where k is the number of decimals of
the currency as per the ISO 4217 norm.
To force a different number of decimals, set it as the value of the `decimal_number` argument.
Note: currency.ensure_one() if arbitrary_decimal_number is not provided
:param float major_amount: The amount in major units, to convert in minor units
:param recordset currency: The currency of the amount, as a `res.currency` record
:param int arbitrary_decimal_number: The number of decimals to use instead of that of ISO 4217
:return: The amount in minor units of its currency
:rtype: int
"""
if arbitrary_decimal_number is not None:
decimal_number = arbitrary_decimal_number
else:
currency.ensure_one()
decimal_number = currency.decimal_places
return int(float_round(major_amount, decimal_number) * (10**decimal_number))
# Token values formatting
def build_token_name(payment_details_short=None, final_length=16):
""" Pad plain payment details with leading X's to build a token name of the desired length.
:param str payment_details_short: The plain part of the payment details (usually last 4 digits)
:param int final_length: The desired final length of the token name (16 for a bank card)
:return: The padded token name
:rtype: str
"""
payment_details_short = payment_details_short or '????'
return f"{'X' * (final_length - len(payment_details_short))}{payment_details_short}"
# Partner values formatting
def format_partner_address(address1="", address2=""):
""" Format a two-parts partner address into a one-line address string.
:param str address1: The first part of the address, usually the `street1` field
:param str address2: The second part of the address, usually the `street2` field
:return: The formatted one-line address
:rtype: str
"""
address1 = address1 or "" # Avoid casting as "False"
address2 = address2 or "" # Avoid casting as "False"
return f"{address1} {address2}".strip()
def split_partner_name(partner_name):
""" Split a single-line partner name in a tuple of first name, last name.
:param str partner_name: The partner name
:return: The splitted first name and last name
:rtype: tuple
"""
return " ".join(partner_name.split()[:-1]), partner_name.split()[-1]
# Security
def get_customer_ip_address():
return request and request.httprequest.remote_addr
+25 -25
View File
@@ -1,28 +1,28 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="account_invoice_view_form_inherit_payment" model="ir.ui.view">
<field name="name">account.move.view.form.inherit.payment</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<!--
The user must capture/void the authorized transactions before registering a new payment.
-->
<xpath expr="//button[@id='account_invoice_payment_btn']" position="attributes">
<attribute name="attrs">{'invisible': ['|', '|', '|', ('state', '!=', 'posted'), ('payment_state', 'not in', ('partial', 'not_paid')), ('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')), ('authorized_transaction_ids', '!=', [])]}</attribute>
</xpath>
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
<field name="authorized_transaction_ids" invisible="1"/>
<button name="payment_action_capture" type="object"
string="Capture Transaction" class="oe_highlight"
attrs="{'invisible': ['|', '|', ('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')), ('state', '!=', 'posted'), ('authorized_transaction_ids', '=', [])]}"/>
<button name="payment_action_void" type="object"
string="Void Transaction"
confirm="Are you sure you want to void the authorized transaction? This action can't be undone."
attrs="{'invisible': ['|', '|', ('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')), ('state', '!=', 'posted'), ('authorized_transaction_ids', '=', [])]}"/>
</xpath>
</field>
</record>
</data>
<record id="account_invoice_view_form_inherit_payment" model="ir.ui.view">
<field name="name">account.move.view.form.inherit.payment</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<!--
The user must capture/void the authorized transactions before registering a new payment.
-->
<xpath expr="//button[@id='account_invoice_payment_btn']" position="attributes">
<attribute name="attrs">{'invisible': ['|', '|', '|', ('state', '!=', 'posted'), ('payment_state', 'not in', ('partial', 'not_paid')), ('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')), ('authorized_transaction_ids', '!=', [])]}</attribute>
</xpath>
<xpath expr="//button[@id='account_invoice_payment_btn']" position="after">
<field name="authorized_transaction_ids" invisible="1"/>
<button name="payment_action_capture" type="object"
string="Capture Transaction" class="oe_highlight"
attrs="{'invisible': ['|', '|', ('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')), ('state', '!=', 'posted'), ('authorized_transaction_ids', '=', [])]}"/>
<button name="payment_action_void" type="object"
string="Void Transaction"
confirm="Are you sure you want to void the authorized transaction? This action can't be undone."
attrs="{'invisible': ['|', '|', ('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')), ('state', '!=', 'posted'), ('authorized_transaction_ids', '=', [])]}"/>
</xpath>
</field>
</record>
</odoo>
+18 -18
View File
@@ -1,21 +1,21 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="view_account_payment_form_inherit_payment" model="ir.ui.view">
<field name="name">view.account.payment.form.inherit.payment</field>
<field name="model">account.payment</field>
<field name="inherit_id" ref="account.view_account_payment_form"/>
<field name="arch" type="xml">
<xpath expr='//group[2]' position="inside">
<field name="payment_transaction_id" groups="base.group_no_one" attrs="{'invisible': [('payment_method_code', '!=', 'electronic')]}"/>
</xpath>
<field name="payment_method_id" position="after">
<field name="payment_method_code" invisible="1"/>
<field name="related_partner_ids" invisible="1"/>
<field name="payment_token_id" options="{'no_create': True}"
attrs="{'invisible': [('payment_method_code', '!=', 'electronic')], 'readonly': [('state', '!=', 'draft')]}"/>
</field>
</field>
</record>
</data>
<record id="view_account_payment_form_inherit_payment" model="ir.ui.view">
<field name="name">view.account.payment.form.inherit.payment</field>
<field name="model">account.payment</field>
<field name="inherit_id" ref="account.view_account_payment_form"/>
<field name="arch" type="xml">
<xpath expr='//group[2]' position="inside">
<field name="payment_transaction_id" groups="base.group_no_one" attrs="{'invisible': [('payment_method_code', '!=', 'electronic')]}"/>
</xpath>
<field name="payment_method_id" position="after">
<field name="payment_method_code" invisible="1"/>
<field name="related_partner_ids" invisible="1"/>
<field name="payment_token_id" options="{'no_create': True}"
attrs="{'invisible': [('payment_method_code', '!=', 'electronic')], 'readonly': [('state', '!=', 'draft')]}"/>
</field>
</field>
</record>
</odoo>
+14 -3
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_frontend" inherit_id="web.assets_frontend">
<xpath expr="link[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/payment/static/src/scss/portal_payment.scss"/>
@@ -7,9 +8,19 @@
</xpath>
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/payment/static/lib/jquery.payment/jquery.payment.js"></script>
<script type="text/javascript" src="/payment/static/src/js/payment_portal.js"></script>
<script type="text/javascript" src="/payment/static/src/js/payment_form.js"></script>
<script type="text/javascript" src="/payment/static/src/js/payment_processing.js"></script>
<script type="text/javascript" src="/payment/static/src/js/checkout_form.js"></script>
<script type="text/javascript" src="/payment/static/src/js/manage_form.js"></script>
<script type="text/javascript" src="/payment/static/src/js/payment_form_mixin.js"></script>
<script type="text/javascript" src="/payment/static/src/js/post_processing.js"></script>
</xpath>
</template>
<template id="assets_backend" name="Payment Assets Backend" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet"
type="text/scss"
href="/payment/static/src/scss/payment_acquirer.scss"/>
</xpath>
</template>
</odoo>
@@ -0,0 +1,189 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_acquirer_form" model="ir.ui.view">
<field name="name">payment.acquirer.form</field>
<field name="model">payment.acquirer</field>
<field name="arch" type="xml">
<form string="Payment Acquirer">
<field name="support_authorization" invisible="1"/>
<field name="support_fees_computation" invisible="1"/>
<field name="support_tokenization" invisible="1"/>
<field name="module_id" invisible="1"/>
<field name="module_state" invisible="1"/>
<field name="module_to_buy" invisible="1"/>
<field name="show_credentials_page" invisible="1"/>
<field name="show_allow_tokenization" invisible="1"/>
<field name="show_payment_icon_ids" invisible="1"/>
<field name="show_pre_msg" invisible="1"/>
<field name="show_pending_msg" invisible="1"/>
<field name="show_auth_msg" invisible="1"/>
<field name="show_done_msg" invisible="1"/>
<field name="show_cancel_msg" invisible="1"/>
<sheet>
<field name="image_128" widget="image" class="oe_avatar"/>
<widget name="web_ribbon" title="Disabled" bg_color="bg-danger" attrs="{'invisible': [('state', '!=', 'disabled')]}"/>
<widget name="web_ribbon" title="Test Mode" bg_color="bg-warning" attrs="{'invisible': [('state', '!=', 'test')]}"/>
<div class="oe_title">
<h1><field name="name" placeholder="Name"/></h1>
<div attrs="{'invisible': ['|', ('module_state', '=', 'installed'), ('module_id', '=', False)]}">
<a attrs="{'invisible': [('module_to_buy', '=', False)]}" href="https://odoo.com/pricing?utm_source=db&amp;utm_medium=module" class="btn btn-info" role="button">Upgrade</a>
<button attrs="{'invisible': [('module_to_buy', '=', True)]}" type="object" class="btn btn-primary" name="button_immediate_install" string="Install"/>
</div>
</div>
<div attrs="{'invisible': ['|', ('module_state', '=', 'installed'), ('module_id', '=', False)]}">
<div class="o_payment_acquirer_desc">
<field name="description"/>
</div>
</div>
<div attrs="{'invisible': [('id', '!=', False)]}" class="alert alert-warning" role="alert">
<strong>Warning</strong> Creating a payment acquirer from the <em>CREATE</em> button is not supported.
Please use the <em>Duplicate</em> action instead.
</div>
<group>
<group name="payment_state">
<field name="provider" groups="base.group_no_one" attrs="{'readonly': [('id', '!=', False)], 'invisible': [('module_id', '!=', False), ('module_state', '!=', 'installed')]}"/>
<field name="state" widget="radio" attrs="{'invisible': [('module_state', '=', 'uninstalled')]}"/>
<field name="company_id" groups="base.group_multi_company" options='{"no_open":True}'/>
</group>
</group>
<notebook attrs="{'invisible': ['&amp;', ('module_id', '!=', False), ('module_state', '!=', 'installed')]}">
<page string="Credentials" name="acquirer_credentials" attrs="{'invisible': ['|', ('provider', '=', 'none'), ('show_credentials_page', '=', False)]}">
<group name="acquirer"/>
</page>
<page string="Configuration" name="configuration">
<group name="acquirer_config">
<group string="Payment Form" name="payment_form">
<field name="display_as" placeholder="If not defined, the acquirer name will be used."/>
<field name="payment_icon_ids" attrs="{'invisible': [('show_payment_icon_ids', '=', False)]}" widget="many2many_tags"/>
<field name="allow_tokenization" attrs="{'invisible': ['|', ('support_tokenization', '=', False), ('show_allow_tokenization', '=', False)]}"/>
<field name="capture_manually" attrs="{'invisible': [('support_authorization', '=', False)]}"/>
</group>
<group string="Availability" name="availability">
<field name="country_ids" widget="many2many_tags" placeholder="Select countries. Leave empty to use everywhere." options="{'no_open': True, 'no_create': True}"/>
</group>
<group string="Payment Followup" name="payment_followup">
<field name="journal_id" context="{'default_type': 'bank'}"
attrs="{'required': [('state', '!=', 'disabled'), ('provider', 'not in', ['none', 'transfer'])]}"/>
</group>
</group>
</page>
<page string="Fees" name="fees" attrs="{'invisible': [('support_fees_computation', '=', False)]}">
<group name="payment_fees">
<field name="fees_active"/>
<field name="fees_dom_fixed" attrs="{'invisible': [('fees_active', '=', False)]}"/>
<field name="fees_dom_var" attrs="{'invisible': [('fees_active', '=', False)]}"/>
<field name="fees_int_fixed" attrs="{'invisible': [('fees_active', '=', False)]}"/>
<field name="fees_int_var" attrs="{'invisible': [('fees_active', '=', False)]}"/>
</group>
</page>
<page string="Messages"
name="messages"
attrs="{'invisible': [('module_id', '=', True), ('module_state', '!=', 'installed')]}">
<group>
<field name="pre_msg" attrs="{'invisible': [('show_pre_msg', '=', False)]}"/>
<field name="pending_msg" attrs="{'invisible': [('show_pending_msg', '=', False)]}"/>
<field name="auth_msg" attrs="{'invisible': ['|', ('support_authorization', '=', False), ('show_auth_msg', '=', False)]}"/>
<field name="done_msg" attrs="{'invisible': [('show_done_msg', '=', False)]}"/>
<field name="cancel_msg" attrs="{'invisible': [('show_cancel_msg', '=', False)]}"/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="payment_acquirer_list" model="ir.ui.view">
<field name="name">payment.acquirer.list</field>
<field name="model">payment.acquirer</field>
<field name="arch" type="xml">
<tree string="Payment Acquirers" create="false">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="provider"/>
<field name="state"/>
<field name="country_ids" widget="many2many_tags" optional="hide"/>
<field name="company_id" groups="base.group_multi_company" optional="show"/>
</tree>
</field>
</record>
<record id="payment_acquirer_kanban" model="ir.ui.view">
<field name="name">payment.acquirer.kanban</field>
<field name="model">payment.acquirer</field>
<field name="arch" type="xml">
<kanban create="false" quick_create="false" class="o_kanban_payment_acquirer o_kanban_dashboard">
<field name="id"/>
<field name="name"/>
<field name="description"/>
<field name="provider"/>
<field name="module_id"/>
<field name="module_state"/>
<field name="module_to_buy"/>
<field name="color"/>
<templates>
<t t-name="kanban-box">
<t t-set="installed" t-value="!record.module_id.value || (record.module_id.value &amp;&amp; record.module_state.raw_value === 'installed')"/>
<t t-set="to_buy" t-value="record.module_to_buy.raw_value === true"/>
<div t-attf-class="oe_kanban_global_click #{kanban_color(record.color.raw_value)}">
<div class="o_payment_acquirer_desc">
<div class="o_kanban_image">
<img type="open" t-att-src="kanban_image('payment.acquirer', 'image_128', record.id.raw_value)" alt="Acquirer"/>
</div>
<h3 class="mt4"><t t-esc="record.name.value"/></h3>
<t t-if="record.description.raw_value" t-raw="record.description.raw_value"/>
</div>
<div class="o_payment_acquirer_bottom">
<t t-if="installed">
<field name="state" widget="label_selection" options="{'classes': {'enabled': 'success', 'test': 'warning', 'disabled' : 'danger'}}"/>
</t>
<button t-if="!installed and !selection_mode and !to_buy" type="object" class="btn btn-secondary float-right" name="button_immediate_install">Install</button>
<t t-if="!installed and to_buy">
<button href="https://odoo.com/pricing?utm_source=db&amp;utm_medium=module" class="btn btn-info btn-sm float_right">Upgrade</button>
<span class="badge badge-primary oe_inline o_enterprise_label">Enterprise</span>
</t>
<button t-if="installed and record.state.raw_value == 'disabled' and !selection_mode" type="edit" class="btn btn-primary float-right">Activate</button>
<button t-if="installed and record.state.raw_value in ['enabled', 'test'] and !selection_mode" type="edit" class="btn btn-primary float-right">Configure</button>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="payment_acquirer_search" model="ir.ui.view">
<field name="name">payment.acquirer.search</field>
<field name="model">payment.acquirer</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Acquirer" filter_domain="['|', ('name', 'ilike', self), ('description', 'ilike', self)]"/>
<field name="provider"/>
<filter name="acquirer_installed" string="Installed" domain="[('module_state', '!=', 'installed')]"/>
<group expand="0" string="Group By">
<filter string="Provider" name="provider" context="{'group_by': 'provider'}"/>
<filter string="State" name="state" context="{'group_by': 'state'}"/>
<filter string="Company" name="company" context="{'group_by': 'company_id'}" groups="base.group_multi_company"/>
</group>
</search>
</field>
</record>
<record id="action_payment_acquirer" model="ir.actions.act_window">
<field name="name">Payment Acquirers</field>
<field name="res_model">payment.acquirer</field>
<field name="view_mode">kanban,tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new payment acquirer
</p>
</field>
</record>
<menuitem action="action_payment_acquirer"
id="payment_acquirer_menu"
parent="account.root_payment_menu"
sequence="10"/>
</odoo>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_icon_form" model="ir.ui.view">
<field name="name">payment.icon.form</field>
<field name="model">payment.icon</field>
<field name="arch" type="xml">
<form string="Payment Icon">
<sheet>
<field name="image" widget="image" class="oe_avatar"/>
<div class="oe_title">
<h1><field name="name" placeholder="Name"/></h1>
</div>
<notebook>
<page string="Acquirers list" name="acquirers">
<field nolabel="1" name="acquirer_ids"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_payment_icon" model="ir.actions.act_window">
<field name="name">Payment Icons</field>
<field name="res_model">payment.icon</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a payment icon
</p>
</field>
</record>
<menuitem action="action_payment_icon"
id="payment_icon_menu"
parent="account.root_payment_menu"
groups="base.group_no_one"/>
</odoo>
+152 -108
View File
@@ -1,62 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="pay_methods" name="Payment Methods">
<t t-call="portal.frontend_layout">
<t t-set="additional_title">Payment Methods</t>
<div class="wrap">
<div class="container">
<div class="row">
<div class="col-md-6">
<ol class="breadcrumb mt8">
<li class="breadcrumb-item"><a href="/my/home"><i class="fa fa-home" role="img" aria-label="Home" title="Home"/></a></li>
<li class="breadcrumb-item">Payment Methods</li>
</ol>
</div>
</div>
<div class="clearfix">
<div class="row">
<div class="col-md-8">
<h3>Manage Payment Methods</h3>
<t t-call="payment.payment_tokens_list">
<t t-set="acquirers" t-value="acquirers"/>
<t t-set="mode" t-value="'manage'"/>
</t>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
<!-- TDE FIXME: was customize_show=True -->
<!-- Integration of a conditional "Manage payment methods" link in /my -->
<template id="pay_meth_link" inherit_id="portal.portal_layout">
<xpath expr="//div[hasclass('o_portal_my_details')]" position="inside">
<t t-if="request.env['payment.acquirer'].search([('state', 'in', ['enabled', 'test']), ('registration_view_template_id', '!=', False), ('payment_flow', '=', 's2s'), ('company_id', '=', request.env.company.id)])">
<div class='manage_payment_method mt16'><a href="/my/payment_method">Manage payment methods</a></div>
</t>
<t t-set="partner" t-value="request.env.user.partner_id"/>
<t t-set="acquirers_allowing_tokenization"
t-value="request.env['payment.acquirer'].sudo()._get_compatible_acquirers(request.env.company.id, partner.id, allow_tokenization=True)"/>
<t t-set="existing_tokens" t-value="partner.payment_token_ids + partner.commercial_partner_id.sudo().payment_token_ids"/>
<!-- Only show the link if a token can be created or if one already exists -->
<div t-if="acquirers_allowing_tokenization or existing_tokens"
class='manage_payment_method mt16'>
<a href="/my/payment_method">Manage payment methods</a>
</div>
</xpath>
</template>
<!-- Display of /payment/pay -->
<template id="pay">
<!-- Variables description:
- 'partner_is_different' - Whether the partner logged in is the one making the payment
-->
<t t-call="portal.frontend_layout">
<t t-set="additional_title">Payment</t>
<t t-set="page_title" t-value="'Payment'"/>
<t t-set="additional_title"><t t-esc="page_title"/></t>
<div class="wrap">
<div class="container o_website_payment">
<h1>Payment</h1>
<div class="row mt32 mb32">
<div class="container">
<!-- Portal breadcrumb -->
<t t-call="payment.portal_breadcrumb"/>
<!-- Payment page -->
<div class="row">
<div class="col-lg-7">
<p><b>Reference:</b> <t t-esc="reference"/></p>
<p><b>Amount:</b> <t t-esc="amount" t-options="{'widget': 'monetary', 'display_currency': currency}"/></p>
<t t-call="payment.payment_tokens_list" t-if="reference and amount and currency">
<t t-set="mode" t-value="'payment'"/>
<t t-set="prepare_tx_url" t-value="'/website_payment/transaction/v2/' + str(amount) + '/' + str(currency.id) + '/' + reference + (('/' + str(partner_id)) if partner_id else '')"/>
<t t-set="form_action" t-value="'/website_payment/token/v2/' + str(amount) + '/' + str(currency.id) + '/' + reference + (('/' + str(partner_id)) if partner_id else '')"/>
<div t-if="not amount" class="alert alert-info">
There is nothing to pay.
</div>
<div t-elif="not currency" class="alert alert-warning">
<strong>Warning</strong> The currency is missing or incorrect.
</div>
<div t-elif="not partner_id" class="alert alert-warning">
<strong>Warning</strong> You must be logged in to pay.
</div>
<div t-elif="not acquirers and not tokens" class="alert alert-warning">
<strong>No suitable payment option could be found.</strong><br/>
If you believe that it is an error, please contact the website administrator.
</div>
<t t-else="">
<div t-if="partner_is_different" class="alert alert-warning">
<strong>Warning</strong> Make sure your are logged in as the right partner before making this payment.
</div>
<t t-if="reference_prefix">
<b>Reference:</b>
<t t-esc="reference_prefix"/><br/>
</t>
<b>Amount:</b>
<t t-esc="amount"
t-options="{'widget': 'monetary', 'display_currency': currency}"/>
<t t-call="payment.checkout"/>
</t>
<div t-if="not acquirers" class="alert alert-danger" role="alert">
<p>No payment acquirer found.</p>
<p>Please configure a payment acquirer.</p>
</div>
</div>
</div>
</div>
</t>
</template>
<!-- Display of /my/payment_methods -->
<template id="payment_methods" name="Payment Methods">
<t t-call="portal.frontend_layout">
<t t-set="page_title" t-value="'Payment Methods'"/>
<t t-set="additional_title"><t t-esc="page_title"/></t>
<div class="wrap">
<div class="container">
<!-- Portal breadcrumb -->
<t t-call="payment.portal_breadcrumb"/>
<!-- Manage page -->
<div class="row">
<div class="col-lg-7">
<t t-if="acquirers" t-call="payment.manage"/>
<div t-else="" class="alert alert-warning">
<p><strong>No suitable payment acquirer could be found.</strong></p>
<p>If you believe that it is an error, please contact the website administrator.</p>
</div>
</div>
</div>
@@ -65,49 +88,93 @@
</t>
</template>
<template id="confirm">
<!-- Display of /payment/status -->
<template id="payment_status" name="Payment Status">
<t t-call="portal.frontend_layout">
<t t-set="additional_title">Payment</t>
<t t-set="page_title" t-value="'Payment Status'"/>
<t t-set="additional_title"><t t-esc="page_title"/></t>
<div class="wrap">
<div class="container o_website_payment">
<h1>Payment</h1>
<div class="container">
<!-- Portal breadcrumb -->
<t t-call="payment.portal_breadcrumb"/>
<!-- Status page -->
<div name="o_payment_status">
<div name="o_payment_status_content"
class="col-sm-6 col-sm-offset-3">
<!-- The content is generated in JavaScript -->
</div>
</div>
</div>
</div>
</t>
</template>
<!-- Display of /payment/confirmation -->
<template id="confirm">
<!-- Variables description:
- 'tx' - The transaction to display
- 'message' - The acquirer message configured for the given transaction state
- 'status' - The alert class to use for the message
-->
<t t-call="portal.frontend_layout">
<t t-set="page_title" t-value="'Payment Confirmation'"/>
<t t-set="additional_title"><t t-esc="page_title"/></t>
<div class="wrap">
<div class="container">
<!-- Portal breadcrumb -->
<t t-call="payment.portal_breadcrumb"/>
<!-- Confirmation page -->
<div class="row">
<div class="col-lg-6">
<div>
<div t-attf-class="alert alert-#{status}" t-raw="message" role="status"/>
<!-- message is the content of either the HTML field describing the
transaction state computed while processing feedback data, or the
HTML field associated with the transaction state, defined on the
acquirer. -->
<div t-attf-class="alert alert-#{status}"
t-raw="message"
role="status"/>
<div class="form-group row">
<label for="form_partner_name" class="col-md-3 col-form-label">From</label>
<label for="form_partner_name" class="col-md-3 col-form-label">
From
</label>
<div class="col-md-9">
<span name="form_partner_name" class="form-control" t-esc="tx.partner_name"/>
<span name="form_partner_name"
class="form-control"
t-esc="tx.partner_name"/>
</div>
</div>
<div class="form-group row">
<label for="form_reference" class="col-md-3 col-form-label">Reference</label>
<label for="form_reference" class="col-md-3 col-form-label">
Reference
</label>
<div class="col-md-9">
<span name="form_reference" class="form-control" t-esc="tx.reference"/>
<span name="form_reference"
class="form-control"
t-esc="tx.reference"/>
</div>
</div>
<div class="form-group row">
<label for="form_amount" class="col-md-3 col-form-label">Amount</label>
<label for="form_amount" class="col-md-3 col-form-label">
Amount
</label>
<div class="col-md-9">
<span name="form_amount" class="form-control" t-esc="tx.amount" t-options="{'widget': 'monetary', 'display_currency': tx.currency_id}"/>
</div>
</div>
<div t-if="tx.acquirer_id.qr_code">
<t t-set="qr_code" t-value="tx.acquirer_id.journal_id.bank_account_id.build_qr_code_url(tx.amount, tx.reference, None, tx.currency_id, tx.partner_id)"/>
<div class="card-body" t-if="qr_code">
<h3>Or scan me with your banking app.</h3>
<img class="border border-dark rounded" t-att-src="qr_code"/>
<span name="form_amount"
class="form-control"
t-esc="tx.amount"
t-options="{'widget': 'monetary', 'display_currency': tx.currency_id}"/>
</div>
</div>
<div class="row">
<div class="col-md-5 offset-md-3 text-muted">
<span t-field="tx.acquirer_id.image_128" t-att-title="tx.acquirer_id.name" role="img" t-att-aria-label="tx.acquirer_id.name" t-options='{"widget": "image", "style":"max-width: 60px; display: inline-block"}'/>
<div>Processed by <t t-esc="tx.acquirer_id.name"/>.</div>
Processed by <t t-esc="tx.acquirer_id.sudo().name"/>
</div>
<div class="col-md-4 mt-2 pl-0">
<a role="button" t-attf-class="btn btn-#{status} float-right" href="/my/home"><i class="fa fa-arrow-circle-right"/> Back to My Account</a>
<a role="button"
t-attf-class="btn btn-#{status} float-right"
href="/my/home">
<i class="fa fa-arrow-circle-right"/> Back to My Account
</a>
</div>
</div>
</div>
@@ -118,49 +185,26 @@
</t>
</template>
<template id="payment_confirmation_status">
<!--
Inform the portal user about the transaction status.
To be called with the 'payment_tx_id' value.
<!-- Breadcrumb for the portal -->
<template id="portal_breadcrumb">
<!-- Variables description:
- 'page_title' - The title of the breadcrumb item
-->
<div t-if="payment_tx_id and payment_tx_id.state == 'pending'" class="alert alert-warning alert-dismissable" role="status">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">×</button>
<span t-if='payment_tx_id.acquirer_id.pending_msg' t-raw="payment_tx_id.acquirer_id.pending_msg"/>
<span t-if='thanks_msg' t-raw="thanks_msg"/>
<div t-if="payment_tx_id.acquirer_id.provider == 'transfer' and reference">
<b>Communication: </b><span t-esc='reference'/>
<div class="row">
<div class="col-md-6">
<ol class="breadcrumb mt8">
<li class="breadcrumb-item">
<a href="/my/home">
<i class="fa fa-home"
role="img"
title="Home"
aria-label="Home"/>
</a>
</li>
<li class="breadcrumb-item"><t t-esc="page_title"/></li>
</ol>
</div>
<div t-if="payment_tx_id.acquirer_id.qr_code and (payment_tx_id.acquirer_id.provider == 'transfer')">
<t t-set="qr_code" t-value="payment_tx_id.acquirer_id.journal_id.bank_account_id.build_qr_code_url(payment_tx_id.amount,payment_tx_id.reference, None, payment_tx_id.currency_id, payment_tx_id.partner_id)"/>
<div class="card-body" t-if="qr_code">
<h3>Or scan me with your banking app.</h3>
<img class="border border-dark rounded" t-att-src="qr_code"/>
</div>
</div>
</div>
<div t-if="payment_tx_id and payment_tx_id.state == 'authorized' and payment_tx_id.acquirer_id.authorize_implemented" class="alert alert-success alert-dismissable" role="alert">
<button type="button" class="close" data-dismiss="alert" title="Dismiss" aria-label="Dismiss">×</button>
<!-- Your payment has been authorized. -->
<span t-if='payment_tx_id.acquirer_id.auth_msg' t-raw="payment_tx_id.acquirer_id.auth_msg"/>
</div>
<div t-if="payment_tx_id and payment_tx_id.state == 'done'" class="alert alert-success alert-dismissable">
<button type="button" class="close" data-dismiss="alert" title="Dismiss" aria-label="Dismiss">×</button>
<span t-if='payment_tx_id.acquirer_id.done_msg' t-raw="payment_tx_id.acquirer_id.done_msg"/>
<span t-if='thanks_msg' t-raw="thanks_msg"/>
<div t-if="thanks_msg and payment_tx_id.acquirer_id.provider == 'transfer' and reference">
<b>Communication: </b><span t-esc='reference'/>
</div>
</div>
<div t-if="payment_tx_id and payment_tx_id.state == 'cancel'" class="alert alert-danger alert-dismissable">
<button type="button" class="close" data-dismiss="alert" title="Dismiss" aria-label="Dismiss">×</button>
<span t-if='payment_tx_id.acquirer_id.cancel_msg' t-raw="payment_tx_id.acquirer_id.cancel_msg"/>
</div>
<div t-if="payment_tx_id and payment_tx_id.state_message">
<span t-esc="payment_tx_id.state_message"/>
</div>
</template>
</data>
</odoo>
+330 -160
View File
@@ -1,186 +1,356 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" name="Payment Assets Backend" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" type="text/scss" href="/payment/static/src/scss/payment_acquirer.scss"/>
</xpath>
</template>
<template id="payment_tokens_list" name="Payment Tokens list">
<!--
Variables description:
- 'submit_txt' the text displayed inside the submit button
- 'submit_class' the css classes to style the submit button
- 'icon_class' font awesome class (e.g. 'fa-trash', 'fa-lock')
- 'form_action' the URI to the page that will handle the form values given for server2server
- 'pms' the tokens
- 'checked_pm_id' the payment token that should be checked (for radio buttons)
- 'mode' can take two values, either 'payment' or 'manage'. 'manage' displays the add a new card and delete buttons. 'payment'
display a form that is used to pay and send the information to the form action url.
- 'acquirers' the list of both server2server and form payment acquirers
- 'verify_validity' if we need to verify if the payment method is valid when adding a new one
- 'prepare_tx_url' the url of the route which will handle the creation of a transaction for a form base payment (handles if the transaction is form or form_save)
- 'show_manage_btn' if True, a button is added in the footer to manage payment methods
<!-- Checkout form -->
<template id="checkout" name="Payment Checkout">
<!-- Variables description:
- 'acquirers' - The payment acquirers compatible with the current transaction
- 'tokens' - The payment tokens of the current partner and payment acquirers
- 'default_token_id' - The id of the token that should be pre-selected. Optional
- 'fees_by_acquirer' - The dict of transaction fees for each acquirer. Optional
- 'show_tokenize_input' - Whether the option to save the payment method is shown
- 'reference_prefix' - The custom prefix to compute the full transaction reference
- 'amount' - The amount to pay. Optional (sale_subscription)
- 'currency' - The currency of the transaction, as a `res.currency` record
- 'partner_id' - The id of the partner on behalf of whom the payment should be made
- 'access_token' - The access token used to authenticate the partner.
- '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
-->
<form t-if="pms or acquirers" method="post" class="o_payment_form mt-3 clearfix"
t-att-action="form_action if form_action else '#'"
t-att-data-success-url="success_url or ''"
t-att-data-error-url="error_url or ''"
t-att-data-access-token="access_token or ''"
t-att-data-partner-id="partner_id"
t-att-data-callback-method="callback_method or ''"
t-att-data-order-id="order_id or ''"
t-att-data-invoice-id="invoice_id or ''"
t-att-data-mode="mode">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" t-if="prepare_tx_url" name="prepare_tx_url" t-att-value="prepare_tx_url"/>
<input type="hidden" t-if="order_id" name="order_id" t-att-value="order_id"/>
<input type="hidden" t-if="invoice_id" name="invoice_id" t-att-value="invoice_id"/>
<!-- s2s form submission -->
<input type="hidden" t-if="access_token" name="access_token" t-att-value="access_token"/>
<input type="hidden" t-if="success_url" name="success_url" t-att-value="success_url"/>
<input type="hidden" t-if="error_url" name="error_url" t-att-value="error_url"/>
<input type="hidden" t-if="callback_method" name="callback_method" t-att-value="callback_method"/>
<form name="o_payment_checkout"
class="o_payment_form mt-3 clearfix"
t-att-data-reference-prefix="reference_prefix"
t-att-data-amount="amount"
t-att-data-currency-id="currency and currency.id"
t-att-data-partner-id="partner_id"
t-att-data-access-token="access_token"
t-att-data-transaction-route="transaction_route"
t-att-data-landing-route="landing_route"
t-att-data-allow-token-selection="True">
<t t-set="acquirer_count" t-value="len(acquirers) if acquirers else 0"/>
<t t-set="token_count" t-value="len(tokens) if tokens else 0"/>
<!-- Check the radio button of the default token, if set, or of the first acquirer if
it is the only payment option -->
<t t-set="default_payment_option_id"
t-value="default_token_id if default_token_id and token_count > 0
else acquirers[0].id if acquirer_count == 1 and token_count == 0
else None"/>
<t t-set="fees_by_acquirer" t-value="fees_by_acquirer or dict()"/>
<t t-set="footer_template_id"
t-value="footer_template_id or 'payment.footer'"/>
<div class="card">
<t t-set="acquirers_count" t-value="len(acquirers) if acquirers else 0"/>
<t t-set="pms_count" t-value="len(pms) if pms else 0"/>
<t t-set="MAX_BRAND_LINE" t-value="3"/>
<t t-foreach="acquirers" t-as="acq">
<div class="card-body o_payment_acquirer_select">
<!-- === Acquirers === -->
<t t-foreach="acquirers" t-as="acquirer">
<div name="o_payment_option_card" class="card-body o_payment_option_card">
<label>
<t t-if="acq.payment_flow == 'form'">
<input type="radio" t-att-data-acquirer-id="acq.id"
t-att-data-form-payment="true"
t-att-data-provider="acq.provider"
t-att-class="'d-none' if (acquirers_count==1 and pms_count==0) else ''"
name="pm_id" t-attf-value="form_{{acq.id}}"
t-att-checked="acquirers_count==1 and pms_count==0 or acquirers[0] == acq"/>
</t>
<t t-else="acq.payment_flow == 's2s'">
<input type="radio" t-att-data-acquirer-id="acq.id"
t-att-data-s2s-payment="true"
t-att-data-provider="acq.provider"
name="pm_id" t-attf-value="new_{{acq.id}}"
t-att-class="'d-none' if (acquirers_count==1 and pms_count==0) else ''"
t-att-checked="acquirers_count==1 and pms_count==0 or acquirers[0] == acq"/>
</t>
<!-- === Radio button === -->
<!-- Only shown if linked to the only payment option -->
<input name="o_payment_radio"
type="radio"
t-att-checked="acquirer.id == default_payment_option_id"
t-att-class="'' if acquirer_count + token_count > 1 else 'd-none'"
t-att-data-payment-option-id="acquirer.id"
t-att-data-provider="acquirer.provider"
data-payment-option-type="acquirer"/>
<!-- === Acquirer name === -->
<span class="payment_option_name">
<t t-esc="acq.display_as or acq.name"/>
<div t-if="acq.state == 'test'" class="badge-pill badge-warning float-right" style="margin-left:5px">
Test Mode
</div>
<b t-esc="acquirer.display_as or acquirer.name"/>
</span>
<t t-if="acq_extra_fees and acq_extra_fees.get(acq)">
<span class="badge badge-pill badge-secondary"> + <t t-esc="acq_extra_fees[acq]" t-options='{"widget": "monetary", "display_currency": acq_extra_fees["currency_id"]}'/> Fee </span>
</t>
<t t-elif="acq.fees_active">
<small class="text-muted">(Some fees may apply)</small>
<!-- === "Test Mode" badge === -->
<span t-if="acquirer.state == 'test'"
class="badge-pill badge-warning ml-1">
Test Mode
</span>
<!-- === Extra fees badge === -->
<t t-if="fees_by_acquirer.get(acquirer)">
<span class="badge-pill badge-secondary ml-1">
+ <t t-esc="fees_by_acquirer.get(acquirer)"
t-options="{'widget': 'monetary', 'display_currency': currency}"/>
Fees
</span>
</t>
</label>
<ul class="float-right list-inline payment_icon_list">
<t t-set="i" t-value="0"/>
<t t-foreach="acq.payment_icon_ids" t-as="pm_icon">
<li t-attf-class="list-inline-item#{'' if (i &lt; MAX_BRAND_LINE) else ' d-none'}">
<span t-field="pm_icon.image_payment_form"
t-options='{"widget": "image", "alt-field": "name"}'/>
</li>
<li t-if="i==MAX_BRAND_LINE" style="display:block;" class="list-inline-item">
<span class="float-right more_option text-info">
<a href="#" class="o_payment_form_pay_icon_more" data-toggle="tooltip" t-att-title="', '.join([opt.name for opt in acq.payment_icon_ids[MAX_BRAND_LINE:]])">and more</a>
</span>
</li>
<t t-set="i" t-value="i+1"/>
</t>
</ul>
<div t-raw="acq.pre_msg" class="text-muted ml-3"/>
<!-- === Payment icon list === -->
<t t-call="payment.icon_list"/>
<!-- === Help message === -->
<div t-if="acquirer.pre_msg"
t-raw="acquirer.pre_msg"
class="text-muted ml-3"/>
</div>
<t t-if="acq.payment_flow == 'form'">
<div t-attf-id="o_payment_form_acq_{{acq.id}}"
t-attf-class="d-none {{'card-footer' if acq.save_token == 'ask' else ''}}">
<label t-if="acq.save_token == 'ask'">
<input type="checkbox" name="o_payment_form_save_token" data-remove-me=""/>
Save my payment data
</label>
<t t-if="acq.save_token == 'always'">
<input type="checkbox" name="o_payment_form_save_token" checked="'checked'" class="o_hidden" data-remove-me=""/>
</t>
</div>
</t>
<t t-else="acq.payment_flow == 's2s'">
<div t-attf-id="o_payment_add_token_acq_{{acq.id}}"
t-attf-class="card-footer {{'d-none' if(acquirers_count &gt; 1 and pms_count==0 and acquirers[0]!=acq) else 'd-none' if pms_count &gt;0 else ''}}">
<div class="clearfix">
<input type="hidden" t-if="(verify_validity==True or mode == 'manage') and acq.check_validity" name="verify_validity" t-att-value="acq.check_validity"/>
<t t-call="{{acq.sudo().get_s2s_form_xml_id()}}">
<t t-set="id" t-value="acq.id"/>
<t t-set="partner_id" t-value="partner_id"/>
<t t-if="not return_url" t-set="return_url" t-value="''"/>
<!-- === Acquirer inline form === -->
<div t-attf-id="o_payment_acquirer_inline_form_{{acquirer.id}}"
name="o_payment_inline_form"
class="card-footer d-none">
<!-- === Inline form content (filled by acquirer) === -->
<t t-if="acquirer.sudo()._should_build_inline_form(is_validation=False)">
<t t-set="inline_form_xml_id"
t-value="acquirer.sudo().inline_form_view_id.xml_id"/>
<div t-if="inline_form_xml_id" class="clearfix">
<t t-call="{{inline_form_xml_id}}">
<t t-set="acquirer_id" t-value="acquirer.id"/>
</t>
</div>
</div>
</t>
</t>
<!-- === "Save my payment details" checkbox === -->
<!-- Only included if partner is known and if the choice is given -->
<t t-set="tokenization_required"
t-value="acquirer._is_tokenization_required(provider=acquirer.provider)"/>
<label t-if="show_tokenize_input and acquirer.allow_tokenization and not tokenization_required">
<input name="o_payment_save_as_token" type="checkbox"/>
Save my payment details
</label>
</div>
</t>
<t t-foreach="pms" t-as="pm">
<t t-if="not verify_validity or (pm.acquirer_id.check_validity and pm.verified) or not pm.acquirer_id.check_validity">
<div class="card-body o_payment_acquirer_select">
<label>
<input t-if="mode == 'payment'" type="radio" name="pm_id" t-att-value="pm.id" t-att-checked="checked_pm_id == pm.id"/>
<span class="payment_option_name" t-esc="pm.name"/>
<t t-if="pm.verified">
<i class="fa fa-check text-success" title="This payment method is verified by our system." role="img" aria-label="Ok"></i>
</t>
<t t-else="">
<i class="fa fa-check text-muted" title="This payment method has not been verified by our system." role="img" aria-label="Not verified"></i>
</t>
</label>
<button t-if="mode == 'manage'" name="delete_pm" t-att-value="pm.id" class="btn btn-primary btn-sm float-right">
<i class="fa fa-trash"></i> Delete
</button>
</div>
</t>
<!-- === Tokens === -->
<t t-foreach="tokens" t-as="token">
<div name="o_payment_option_card" class="card-body o_payment_option_card">
<label>
<!-- === Radio button === -->
<input name="o_payment_radio"
type="radio"
t-att-checked="token.id == default_payment_option_id"
t-att-data-payment-option-id="token.id"
t-att-data-provider="token.provider"
data-payment-option-type="token"/>
<!-- === Token name === -->
<span class="payment_option_name" t-esc="token.name"/>
<!-- === "V" check mark === -->
<i t-if="token.verified" class="fa fa-check text-success"
title="This payment method has been verified by our system."
role="img"
aria-label="Ok"/>
<i t-else="" class="fa fa-check text-muted"
title="This payment method has not been verified by our system."
role="img"
aria-label="Not verified"/>
</label>
</div>
<!-- === Token inline form === -->
<div t-attf-id="o_payment_token_inline_form_{{token.id}}"
name="o_payment_inline_form"
class="card-footer d-none"/>
</t>
</div>
<div t-if='back_button_txt' class="float-left mt-2">
<a role="button" t-att-href="back_button_link or '#'" t-att-class="back_button_class or 'btn btn-lg btn-secondary'">
<i t-if="back_button_icon_class" t-attf-class="fa {{back_button_icon_class}}"/>
<t t-esc="back_button_txt"/>
</a>
</div>
<div class="float-right mt-2">
<button t-if="mode == 'payment'" id="o_payment_form_pay" type="submit" t-att-class="submit_class if submit_class else 'btn btn-primary btn-lg mb8 mt8'" disabled="true">
<t t-if="submit_txt">
<i t-if="icon_class and not icon_right" t-attf-class="fa {{icon_class}}"/>
<t t-esc="submit_txt"/>
<i t-if="icon_class and icon_right" t-attf-class="fa {{icon_class}}"/>
</t>
<t t-else="">
<i class="fa fa-lock"/> Pay
</t>
</button>
<t t-if="show_manage_btn">
<a class="btn btn-link mb8 mt8" href="/my/payment_method">Manage your payment methods</a>
</t>
<button t-if="mode == 'manage' and list(filter(lambda x: x.payment_flow == 's2s', acquirers))" type="submit" id="o_payment_form_add_pm" class="btn btn-primary btn-lg mb8 mt8">
<i class="fa fa-plus-circle"/> Add new card
</button>
</div>
<!-- === "Pay" button === -->
<t t-call="{{footer_template_id}}">
<t t-set="label">Pay</t>
<t t-set="icon_class" t-value="'fa-lock'"/>
</t>
</form>
</template>
<template id="payment_process_page" name="Payment processing page">
<t t-call="portal.frontend_layout">
<div class="wrap">
<div class="container o_website_payment">
<div class="o_payment_processing">
<div class="row">
<div class="o_payment_processing_content col-sm-6 col-sm-offset-3">
<!-- The content here is generated in JS -->
<!-- Manage (token create and deletion) form -->
<template id="manage" name="Payment Manage">
<!-- Variables description:
- 'acquirers' - The payment acquirers supporting tokenization
- 'tokens' - The set of payment tokens of the current partner
- 'default_token_id' - The id of the token that should be pre-selected. Optional
- 'reference_prefix' - The custom prefix to compute the full transaction reference
- 'partner_id' - The id of the partner managing the tokens
- 'access_token' - The access token used to authenticate the partner.
- 'transaction_route' - The route used to create a validation transaction
- 'assign_token_route' - The route to call to assign a token to a record. If set, it
enables the token assignation mechanisms: creation of a new
token through a refunded transaction and assignation of an
existing token
- 'validation_route' - The route the user is redirected to in order to refund the
validation transaction
- 'landing_route' - The route the user is redirected to at then end of the flow
- 'footer_template_id' - The template id for the submit button. Optional
-->
<form name="o_payment_manage"
class="o_payment_form mt-3 clearfix"
t-att-data-reference-prefix="reference_prefix"
t-att-data-partner-id="partner_id"
t-att-data-access-token="access_token"
t-att-data-transaction-route="transaction_route"
t-att-data-assign-token-route="assign_token_route"
t-att-data-validation-route="validation_route"
t-att-data-landing-route="landing_route"
t-att-data-allow-token-selection="bool(assign_token_route)">
<t t-set="acquirer_count" t-value="len(acquirers) if acquirers else 0"/>
<t t-set="token_count" t-value="len(tokens) if tokens else 0"/>
<t t-set="default_payment_option_id"
t-value="default_token_id if default_token_id and token_count > 0
else acquirers[0].id if acquirer_count == 1 and token_count == 0
else None"/>
<t t-set="footer_template_id"
t-value="footer_template_id or 'payment.footer'"/>
<div class="card">
<!-- === Acquirers === -->
<t t-foreach="acquirers" t-as="acquirer">
<div name="o_payment_option_card" class="card-body o_payment_option_card">
<label>
<!-- === Radio button === -->
<!-- Only shown if linked to the only payment option -->
<input name="o_payment_radio"
type="radio"
t-att-checked="acquirer.id == default_payment_option_id"
t-att-class="'' if acquirer_count + token_count > 1 else 'd-none'"
t-att-data-payment-option-id="acquirer.id"
t-att-data-provider="acquirer.provider"
data-payment-option-type="acquirer"/>
<!-- === Acquirer name === -->
<span class="payment_option_name">
<b><t t-esc="acquirer.display_as or acquirer.name"/></b>
</span>
<!-- === "Test Mode" badge === -->
<span t-if="acquirer.state == 'test'"
class="badge-pill badge-warning"
style="margin-left:5px">
Test Mode
</span>
</label>
<!-- === Payment icon list === -->
<t t-call="payment.icon_list"/>
<!-- === Help message === -->
<div t-if="acquirer.pre_msg"
t-raw="acquirer.pre_msg"
class="text-muted ml-3"/>
</div>
<!-- === Acquirer inline form === -->
<t t-if="acquirer.sudo()._should_build_inline_form(is_validation=True)">
<div t-attf-id="o_payment_acquirer_inline_form_{{acquirer.id}}"
name="o_payment_inline_form"
class="card-footer d-none">
<!-- === Inline form content (filled by acquirer) === -->
<t t-set="inline_form_xml_id"
t-value="acquirer.sudo().inline_form_view_id.xml_id"/>
<div t-if="inline_form_xml_id" class="clearfix">
<t t-call="{{inline_form_xml_id}}">
<t t-set="acquirer_id" t-value="acquirer.id"/>
</t>
</div>
</div>
</t>
</t>
<!-- === Tokens === -->
<t t-foreach="tokens" t-as="token">
<div name="o_payment_option_card" class="card-body o_payment_option_card">
<label>
<!-- === Radio button === -->
<!-- Only shown if 'assign_token_route' is set -->
<input name="o_payment_radio"
type="radio"
t-att-checked="token.id == default_payment_option_id"
t-att-class="'' if bool(assign_token_route) else 'd-none'"
t-att-data-payment-option-id="token.id"
t-att-data-provider="token.provider"
data-payment-option-type="token"/>
<!-- === Token name === -->
<span class="payment_option_name" t-esc="token.name"/>
<!-- === "V" check mark === -->
<i t-if="token.verified" class="fa fa-check text-success"
title="This payment method has been verified by our system."
role="img"
aria-label="Ok"/>
<i t-else="" class="fa fa-check text-muted"
title="This payment method has not been verified by our system."
role="img"
aria-label="Not verified"/>
</label>
<!-- === "Delete" token button === -->
<button name="o_payment_delete_token"
class="btn btn-primary btn-sm float-right">
<i class="fa fa-trash"/> Delete
</button>
</div>
</div>
<!-- === Token inline form === -->
<div t-attf-id="o_payment_token_inline_form_{{token.id}}"
name="o_payment_inline_form"
class="card-footer d-none"/>
</t>
</div>
</t>
<!-- === "Save Payment Method" button === -->
<t t-call="{{footer_template_id}}">
<t t-set="label">Save Payment Method</t>
<t t-set="icon_class" t-value="'fa-plus-circle'"/>
</t>
</form>
</template>
<!-- Expandable payment icon list -->
<template id="icon_list" name="Payment Icon List">
<ul class="payment_icon_list float-right list-inline" data-max-icons="3">
<t t-set="icon_index" t-value="0"/>
<t t-set="MAX_ICONS" t-value="3"/>
<!-- === Icons === -->
<!-- Only shown if in the first 3 icons -->
<t t-foreach="acquirer.payment_icon_ids" t-as="icon">
<li t-attf-class="list-inline-item{{'' if (icon_index &lt; MAX_ICONS) else ' d-none'}}">
<span t-esc="icon.image_payment_form"
t-options="{'widget': 'image', 'alt-field': 'name'}"
data-toggle="tooltip"
t-att-title="icon.name"/>
</li>
<t t-set="icon_index" t-value="icon_index + 1"/>
</t>
<t t-if="icon_index >= MAX_ICONS">
<!-- === "show more" button === -->
<!-- Only displayed if too many payment icons -->
<li style="display:block;" class="list-inline-item">
<span class="float-right more_option text-info">
<a name="o_payment_icon_more"
data-toggle="tooltip"
t-att-title="', '.join([icon.name for icon in acquirer.payment_icon_ids[MAX_ICONS:]])">
show more
</a>
</span>
</li>
<!-- === "show less" button === -->
<!-- Only displayed when "show more" is clicked -->
<li style="display:block;" class="list-inline-item d-none">
<span class="float-right more_option text-info">
<a name="o_payment_icon_less">show less</a>
</span>
</li>
</t>
</ul>
</template>
<!-- Generic footer for payment forms -->
<template id="footer" name="Payment Footer">
<!-- Variables description:
- 'label' - The label for the submit button
- 'icon_class' - The Font Awesome icon class (e.g. 'fa-lock') for the submit button
-->
<div class="float-right mt-2">
<button name="o_payment_submit_button"
type="submit"
class="btn btn-primary btn-lg mb8 mt8"
disabled="true"
t-att-data-icon-class="icon_class">
<i t-attf-class="fa {{icon_class}}"/> <t t-esc="label"/>
</button>
</div>
</template>
<!-- Transaction status in portal -->
<template id="transaction_status">
<!-- Variables description:
- 'tx' - The transaction whose status must be displayed
-->
<div t-if="tx.state == 'pending'" class="alert alert-warning alert-dismissible">
<span t-if='tx.acquirer_id.sudo().pending_msg'
t-raw="tx.acquirer_id.sudo().pending_msg"/>
<button class="close" data-dismiss="alert" title="Dismiss">×</button>
</div>
<div t-elif="tx.state == 'authorized'" class="alert alert-success alert-dismissible">
<span t-if='tx.acquirer_id.sudo().auth_msg' t-raw="tx.acquirer_id.sudo().auth_msg"/>
<button class="close" data-dismiss="alert" title="Dismiss">×</button>
</div>
<div t-elif="tx.state == 'done'" class="alert alert-success alert-dismissible">
<span t-if='tx.acquirer_id.sudo().done_msg' t-raw="tx.acquirer_id.sudo().done_msg"/>
<button class="close" data-dismiss="alert" title="Dismiss">×</button>
</div>
<div t-elif="tx.state == 'cancel'" class="alert alert-danger alert-dismissible">
<span t-if='tx.acquirer_id.sudo().cancel_msg' t-raw="tx.acquirer_id.sudo().cancel_msg"/>
<button class="close" data-dismiss="alert" title="Dismiss">×</button>
</div>
<span t-if="tx.state_message" t-esc="tx.state_message"/>
</template>
</odoo>
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_token_form" model="ir.ui.view">
<field name="name">payment.token.form</field>
<field name="model">payment.token</field>
<field name="arch" type="xml">
<form string="Payment Tokens" create="false" editable="bottom">
<sheet>
<field name="active" invisible="1"/>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button"
name="%(action_payment_transaction_linked_to_token)d"
type="action" icon="fa-money" string="Payments">
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<group>
<field name="name"/>
<field name="partner_id" />
</group>
<group>
<field name="acquirer_id"/>
<field name="acquirer_ref"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="payment_token_list" model="ir.ui.view">
<field name="name">payment.token.list</field>
<field name="model">payment.token</field>
<field name="arch" type="xml">
<tree string="Payment Tokens">
<field name="name"/>
<field name="partner_id"/>
<field name="acquirer_id" readonly="1"/>
<field name="acquirer_ref" readonly="1"/>
<field name="company_id" groups="base.group_multi_company" optional="show"/>
</tree>
</field>
</record>
<record id="payment_token_search" model="ir.ui.view">
<field name="name">payment.token.search</field>
<field name="model">payment.token</field>
<field name="arch" type="xml">
<search string="Payment Tokens">
<field name="partner_id"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group expand="1" string="Group By">
<filter string="Acquirer" name="acquirer_id" context="{'group_by': 'acquirer_id'}"/>
<filter string="Partner" name="partner_id" context="{'group_by': 'partner_id'}"/>
<filter string="Company" name="company" context="{'group_by': 'company_id'}" groups="base.group_multi_company"/>
</group>
</search>
</field>
</record>
<record id="action_payment_token" model="ir.actions.act_window">
<field name="name">Payment Tokens</field>
<field name="res_model">payment.token</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new payment token
</p>
</field>
</record>
<menuitem action="action_payment_token"
id="payment_token_menu"
parent="account.root_payment_menu"
groups="base.group_no_one"/>
</odoo>
@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_transaction_form" model="ir.ui.view">
<field name="name">payment.transaction.form</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<form string="Payment Transactions" create="false" edit="false">
<header>
<button type="object" name="action_capture" states="authorized" string="Capture Transaction" class="oe_highlight"/>
<button type="object" name="action_void" states="authorized" string="Void Transaction"
confirm="Are you sure you want to void the authorized transaction? This action can't be undone."/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-money"
attrs="{'invisible': [('invoices_count', '=', 0)]}">
<field name="invoices_count" widget="statinfo" string="Invoice(s)"/>
</button>
</div>
<group>
<group name="transaction_details">
<field name="reference"/>
<field name="payment_id"/>
<field name="amount"/>
<field name="fees" attrs="{'invisible': [('fees', '=', 0.0)]}"/>
<field name="currency_id" invisible="1"/>
<field name="acquirer_id"/>
<field name="company_id" groups="base.group_multi_company"/>
<!-- Used by some acquirer-specific views -->
<field name="provider" invisible="1"/>
<field name="acquirer_reference"/>
<field name="token_id" attrs="{'invisible': [('token_id', '=', False)]}"/>
<field name="create_date"/>
<field name="last_state_change"/>
</group>
<group name="transaction_partner">
<field name="partner_id" widget="res_partner_many2one"/>
<label for="partner_address" string="Address"/>
<div class="o_address_format">
<field name="partner_address" placeholder="Address" class="o_address_street"/>
<field name="partner_city" placeholder="City" class="o_address_city"/>
<field name="partner_state_id" placeholder="State" class="o_address_state" options="{'no_open': True}"/>
<field name="partner_zip" placeholder="ZIP" class="o_address_zip"/>
<field name="partner_country_id" placeholder="Country" class="o_address_country" options="{'no_open': True}"/>
</div>
<field name="partner_email" widget="email"/>
<field name="partner_phone" widget="phone"/>
<field name="partner_lang"/>
</group>
</group>
<group string="Message" attrs="{'invisible': [('state_message', '=', False)]}">
<field name="state_message" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="payment_transaction_list" model="ir.ui.view">
<field name="name">payment.transaction.list</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<tree string="Payment Transactions" create="false">
<field name="reference"/>
<field name="create_date"/>
<field name="acquirer_id"/>
<field name="partner_id"/>
<field name="partner_name"/>
<!-- Needed to display the currency of the amounts -->
<field name="currency_id" invisible="1"/>
<field name="amount"/>
<field name="fees"/>
<field name="state"/>
<field name="company_id" groups="base.group_multi_company" optional="show"/>
</tree>
</field>
</record>
<record id="payment_transaction_kanban" model="ir.ui.view">
<field name="name">payment.transaction.kanban</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile" create="false">
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_content oe_kanban_global_click">
<div class="row">
<div class="col-6">
<strong><field name="reference"/></strong>
</div>
<div class="col-6">
<span><field name="partner_name"/></span>
</div>
<div class="col-6">
<span class="float-right">
<field name="amount"/>
<field name="currency_id" invisible="1"/>
</span>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="payment_transaction_search" model="ir.ui.view">
<field name="name">payment.transaction.search</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<search>
<field name="reference"/>
<field name="acquirer_id"/>
<field name="partner_id"/>
<field name="partner_name"/>
<group expand="1" string="Group By">
<filter string="Acquirer" name="acquirer_id" context="{'group_by': 'acquirer_id'}"/>
<filter string="Partner" name="partner_id" context="{'group_by': 'partner_id'}"/>
<filter string="Status" name="state" context="{'group_by': 'state'}"/>
<filter string="Company" name="company" context="{'group_by': 'company_id'}" groups="base.group_multi_company"/>
</group>
</search>
</field>
</record>
<record id="action_payment_transaction" model="ir.actions.act_window">
<field name="name">Payment Transactions</field>
<field name="res_model">payment.transaction</field>
<field name="view_mode">tree,kanban,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_neutral_face">
There are no transactions to show
</p>
</field>
</record>
<record id="action_payment_transaction_linked_to_token" model="ir.actions.act_window">
<field name="name">Payment Transactions Linked To Token</field>
<field name="res_model">payment.transaction</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('token_id','=', active_id)]</field>
<field name="context">{'create': False}</field>
</record>
<menuitem action="action_payment_transaction"
id="payment_transaction_menu"
parent="account.root_payment_menu"
groups="base.group_no_one"
sequence="20"/>
</odoo>
-439
View File
@@ -1,439 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="acquirer_form" model="ir.ui.view">
<field name="name">payment.acquirer.form</field>
<field name="model">payment.acquirer</field>
<field name="arch" type="xml">
<form string="Payment Acquirer">
<field name="fees_implemented" invisible='1'/>
<field name="token_implemented" invisible='1'/>
<field name="authorize_implemented" invisible="1"/>
<sheet>
<field name="module_id" invisible="1"/>
<field name="module_state" invisible="1"/>
<field name="module_to_buy" invisible="1"/>
<field name="inbound_payment_method_ids" invisible="1"/>
<field name="image_128" widget="image" class="oe_avatar"/>
<widget name="web_ribbon" title="Disabled" bg_color="bg-danger" attrs="{'invisible': [('state', '!=', 'disabled')]}"/>
<widget name="web_ribbon" title="Test Mode" bg_color="bg-warning" attrs="{'invisible': [('state', '!=', 'test')]}"/>
<div class="oe_title">
<h1><field name="name" placeholder="Name"/></h1>
<div attrs="{'invisible': ['|', ('module_state', '=', 'installed'), ('module_id', '=', False)]}">
<a attrs="{'invisible': [('module_to_buy', '=', False)]}" href="https://odoo.com/pricing?utm_source=db&amp;utm_medium=module" class="btn btn-info" role="button">Upgrade</a>
<button attrs="{'invisible': [('module_to_buy', '=', True)]}" type="object" class="btn btn-primary" name="button_immediate_install" string="Install"/>
</div>
</div>
<div attrs="{'invisible': ['|', ('module_state', '=', 'installed'), ('module_id', '=', False)]}">
<div class="o_payment_acquirer_desc">
<field name="description"/>
</div>
</div>
<group>
<group name="payment_state">
<field name="provider" groups="base.group_no_one" attrs="{'invisible': [('module_id', '!=', False), ('module_state', '!=', 'installed')]}"/>
<field name="state" widget="radio" attrs="{'invisible': [('module_state', '=', 'uninstalled')]}"/>
<field name="company_id" groups="base.group_multi_company" options='{"no_open":True}'/>
</group>
</group>
<notebook attrs="{'invisible': ['&amp;', ('module_id', '!=', False), ('module_state', '!=', 'installed')]}">
<page string="Credentials" name="acquirer_credentials" attrs="{'invisible': [('provider', '=', 'manual')]}">
<group name="acquirer">
</group>
</page>
<page string="Configuration" name="configuration">
<group name="acquirer_config">
<group string="Payment Form" name="payment_form">
<field name="display_as" placeholder="If not defined, the acquirer name will be used."/>
<field name="payment_icon_ids" widget="many2many_tags"/>
<field name="save_token" widget="radio" attrs="{'invisible': ['|', ('token_implemented', '=', False), ('payment_flow', '=', 's2s')]}"/>
<field name="capture_manually" attrs="{'invisible': [('authorize_implemented', '=', False)]}"/>
<field name="payment_flow" widget="radio" attrs="{'invisible': [('token_implemented', '=', False)]}"/>
<field name="view_template_id" groups="base.group_no_one"/>
<field name="registration_view_template_id" groups="base.group_no_one" attrs="{'invisible': [('payment_flow', '!=', 's2s')]}"/>
<field name="check_validity" attrs="{'invisible': [('payment_flow', '!=', 's2s')]}" groups="base.group_no_one"/>
<field name="qr_code" attrs="{'invisible': [('provider', '!=', 'transfer')]}"/>
</group>
<group string="Availability" name="availability">
<field name="country_ids" widget="many2many_tags" placeholder="Select countries. Leave empty to use everywhere." options="{'no_open': True, 'no_create': True}"/>
</group>
<group string="Payment Followup" name="payment_followup">
<field name="journal_id" context="{'default_type': 'bank'}"
attrs="{'required': [('state', '!=', 'disabled'), ('provider', 'not in', ['manual', 'transfer'])]}"/>
</group>
</group>
</page>
<page string="Fees" name="fees" attrs="{'invisible': [('fees_implemented', '=', False)]}">
<group name="payment_fees">
<field name="fees_active"/>
<field name="fees_dom_fixed" attrs="{'invisible': [('fees_active', '=', False)]}"/>
<field name="fees_dom_var" attrs="{'invisible': [('fees_active', '=', False)]}"/>
<field name="fees_int_fixed" attrs="{'invisible': [('fees_active', '=', False)]}"/>
<field name="fees_int_var" attrs="{'invisible': [('fees_active', '=', False)]}"/>
</group>
</page>
<page string="Messages"
name="messages"
attrs="{'invisible': [('module_id', '=', True), ('module_state', '!=', 'installed')]}">
<group>
<field name="pre_msg"/>
<field name="auth_msg" attrs="{'invisible': [('authorize_implemented', '=', False)]}"/>
<field name="pending_msg"/>
<field name="done_msg"/>
<field name="cancel_msg"/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Acquirer Kanban View -->
<record id="acquirer_kanban" model="ir.ui.view">
<field name="name">payment.acquirer.kanban</field>
<field name="model">payment.acquirer</field>
<field name="arch" type="xml">
<kanban quick_create="false" create="true" class="o_kanban_payment_acquirer o_kanban_dashboard">
<field name="id"/>
<field name="name"/>
<field name="description"/>
<field name="provider"/>
<field name="module_id"/>
<field name="module_state"/>
<field name="module_to_buy"/>
<field name="color"/>
<templates>
<t t-name="kanban-box">
<t t-set="installed" t-value="!record.module_id.value || (record.module_id.value &amp;&amp; record.module_state.raw_value === 'installed')"/>
<t t-set="to_buy" t-value="record.module_to_buy.raw_value === true"/>
<div t-attf-class="oe_kanban_global_click #{kanban_color(record.color.raw_value)}">
<div class="o_payment_acquirer_desc">
<div class="o_kanban_image">
<img type="open" t-att-src="kanban_image('payment.acquirer', 'image_128', record.id.raw_value)" alt="Acquirer"/>
</div>
<h3 class="mt4"><t t-esc="record.name.value"/></h3>
<t t-if="record.description.raw_value" t-raw="record.description.raw_value"/>
</div>
<div class="o_payment_acquirer_bottom">
<t t-if="installed">
<field name="state" widget="label_selection" options="{'classes': {'enabled': 'success', 'test': 'warning', 'disabled' : 'danger'}}"/>
</t>
<button t-if="!installed and !selection_mode and !to_buy" type="object" class="btn btn-secondary float-right" name="button_immediate_install">Install</button>
<t t-if="!installed and to_buy">
<button href="https://odoo.com/pricing?utm_source=db&amp;utm_medium=module" class="btn btn-info btn-sm float_right">Upgrade</button>
<span class="badge badge-primary oe_inline o_enterprise_label">Enterprise</span>
</t>
<button t-if="installed and record.state.raw_value == 'disabled' and !selection_mode" type="edit" class="btn btn-primary float-right">Activate</button>
<button t-if="installed and record.state.raw_value in ['enabled', 'test'] and !selection_mode" type="edit" class="btn btn-primary float-right">Configure</button>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="acquirer_list" model="ir.ui.view">
<field name="name">payment.acquirer.list</field>
<field name="model">payment.acquirer</field>
<field name="arch" type="xml">
<tree string="Payment Acquirers">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="provider"/>
<field name="state"/>
<field name="country_ids" widget="many2many_tags" optional="hide"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="acquirer_search" model="ir.ui.view">
<field name="name">payment.acquirer.search</field>
<field name="model">payment.acquirer</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Acquirer" filter_domain="['|', ('name','ilike',self), ('description','ilike',self)]"/>
<field name="provider"/>
<filter name="acquirer_installed" string="Installed" domain="[('provider', '!=', 'manual')]"/>
<group expand="0" string="Group By">
<filter string="Provider" name="provider" domain="[]" context="{'group_by': 'provider'}"/>
<filter string="State" name="state" domain="[]" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<record id="action_payment_acquirer" model="ir.actions.act_window">
<field name="name">Payment Acquirers</field>
<field name="res_model">payment.acquirer</field>
<field name='view_mode'>kanban,tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new payment acquirer
</p>
</field>
</record>
<menuitem
action='action_payment_acquirer'
id='payment_acquirer_menu'
parent='account.root_payment_menu'
sequence='10' />
<!-- Payment transactions -->
<record id="transaction_form" model="ir.ui.view">
<field name="name">payment.transaction.form</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<form string="Payment Transactions" create="false" edit="false">
<header>
<button type="object" name="action_capture" states="authorized" string="Capture Transaction" class="oe_highlight"/>
<button type="object" name="action_void" states="authorized" string="Void Transaction"
confirm="Are you sure you want to void the authorized transaction? This action can't be undone."/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-money"
attrs="{'invisible': [('invoice_ids_nbr', '=', 0)]}">
<field name="invoice_ids_nbr" widget="statinfo" string="Invoice(s)"/>
</button>
</div>
<group>
<group>
<field name="reference"/>
<field name="payment_id"/>
<label for="amount"/>
<div class="o_row">
<field name="amount"/>
<field name="currency_id" options="{'no_open': True, 'no_create': True}"/>
</div>
<field name="fees" readonly="1"/>
<label for="partner_id"/>
<div>
<field name="partner_id" context="{'show_address': 1}" options="{'always_reload': True}"/>
<div name="partner_details" class="o_address_format" attrs="{'invisible': [('partner_id', '!=', False)]}">
<field name="partner_name" placeholder="Name" attrs="{'readonly': [('partner_id', '!=', False)]}" class="o_address_street"/>
<field name="partner_address" placeholder="Address" attrs="{'readonly': [('partner_id', '!=', False)]}" class="o_address_street"/>
<field name="partner_city" placeholder="City" attrs="{'readonly': [('partner_id', '!=', False)]}" class="o_address_city"/>
<field name="partner_zip" placeholder="ZIP" attrs="{'readonly': [('partner_id', '!=', False)]}" class="o_address_zip"/>
<field name="partner_country_id" placeholder="Country" attrs="{'readonly': [('partner_id', '!=', False)]}" class="o_address_country"/>
<field name="partner_lang" attrs="{'readonly': [('partner_id', '!=', False)]}" class="o_address_street"/>
<field name="partner_email" placeholder="E-mail" attrs="{'readonly': [('partner_id', '!=', False)]}" class="o_address_street"/>
</div>
</div>
</group>
<group>
<field name="acquirer_id"/>
<field name="provider" invisible="1"/>
<field name="payment_token_id" options="{'no_create': True}"/>
<field name="acquirer_reference" readonly="1"/>
<field name="date"/>
</group>
</group>
<group string="Message">
<field name="state_message" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="transaction_list" model="ir.ui.view">
<field name="name">payment.transaction.list</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<tree string="Payment Transactions" create="false">
<field name="reference"/>
<field name="create_date"/>
<field name="acquirer_id"/>
<field name="partner_id"/>
<field name="partner_name"/>
<field name="amount"/>
<field name="fees"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="transaction_view_kanban" model="ir.ui.view">
<field name="name">payment.transaction.kanban</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile" create="false">
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_content oe_kanban_global_click">
<div class="row">
<div class="col-6">
<strong><field name="reference"/></strong>
</div>
<div class="col-6">
<span><field name="partner_name"/></span>
</div>
<div class="col-6">
<span class="float-right">
<field name="amount" widget="monetary"/>
<field name="currency_id" invisible="1"/>
</span>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="transaction" model="ir.ui.view">
<field name="name">payment.transaction.search</field>
<field name="model">payment.transaction</field>
<field name="arch" type="xml">
<search>
<field name="reference"/>
<field name="acquirer_id"/>
<field name="partner_id"/>
<field name="partner_name"/>
</search>
</field>
</record>
<record id="action_payment_transaction" model="ir.actions.act_window">
<field name="name">Payment Transactions</field>
<field name="res_model">payment.transaction</field>
<field name='view_mode'>tree,kanban,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new payment transaction
</p>
</field>
</record>
<menuitem
action='action_payment_transaction'
id='payment_transaction_menu'
parent='account.root_payment_menu'
groups="base.group_no_one"
sequence='20' />
<!-- Payment Tokens -->
<record model='ir.ui.view' id='payment_token_tree_view'>
<field name='name'>payment.token.tree</field>
<field name='model'>payment.token</field>
<field name='arch' type='xml'>
<tree string='Payment Tokens'>
<field name="name"/>
<field name="active"/>
<field name='partner_id' />
<field name='acquirer_id' readonly='1'/>
<field name='acquirer_ref' readonly='1'/>
</tree>
</field>
</record>
<record id='payment_token_view_search' model='ir.ui.view'>
<field name='name'>payment.token.search</field>
<field name='model'>payment.token</field>
<field name='arch' type='xml'>
<search string='Payment Tokens'>
<field name='partner_id'/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<record id="action_payment_tx_ids" model="ir.actions.act_window">
<field name="name">Payment Transactions</field>
<field name="res_model">payment.transaction</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('payment_token_id','=', active_id)]</field>
<field name="context">{'create': False}</field>
</record>
<record model='ir.ui.view' id='payment_token_form_view'>
<field name='name'>payment.token.form</field>
<field name='model'>payment.token</field>
<field name='arch' type='xml'>
<form string='Payment Tokens' create='false' editable='bottom'>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="%(action_payment_tx_ids)d"
type="action" icon="fa-money" string="Payments">
</button>
</div>
<group>
<field name="name"/>
<field name='partner_id' />
</group>
<group>
<field name="active" widget='boolean_toggle'/>
<field name='acquirer_id'/>
<field name='acquirer_ref'/>
</group>
</sheet>
</form>
</field>
</record>
<record model='ir.actions.act_window' id='payment_token_action'>
<field name='name'>Saved Payment Data</field>
<field name='res_model'>payment.token</field>
<field name='view_mode'>tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a saved payment data
</p>
</field>
</record>
<menuitem
action='payment_token_action'
id='payment_token_menu'
parent='account.root_payment_menu'
groups='base.group_no_one'/>
<!-- Payment icons -->
<record model="ir.ui.view" id="payment_icon_form_view">
<field name="name">payment.icon.form</field>
<field name="model">payment.icon</field>
<field name="arch" type="xml">
<form string="Payment Icon">
<sheet>
<field name="image" widget="image" class="oe_avatar"/>
<div class="oe_title">
<h1><field name="name" placeholder="Name"/></h1>
</div>
<notebook>
<page string="Acquirers list" name="acquirers">
<field nolabel="1" name="acquirer_ids"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_payment_icon" model="ir.actions.act_window">
<field name="name">Payment Icons</field>
<field name="res_model">payment.icon</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a payment icon
</p>
</field>
</record>
<menuitem
action="action_payment_icon"
id="payment_icon_menu"
parent="account.root_payment_menu"
groups="base.group_no_one"/>
</data>
</odoo>
+23 -24
View File
@@ -1,29 +1,28 @@
<?xml version="1.0"?>
<odoo>
<data>
<!-- Add creditcard to res.partner -->
<record id="view_partners_form_payment_defaultcreditcard" model="ir.ui.view">
<field name="name">view.res.partner.form.payment.defaultcreditcard</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="priority" eval="15"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button type="action" class="oe_stat_button"
icon="fa-credit-card"
name="%(payment.payment_token_action)d"
context="{'search_default_partner_id': active_id, 'create': False, 'edit': False}"
attrs="{'invisible': [('payment_token_count', '=', 0)]}">
<div class="o_form_field o_stat_info">
<span class="o_stat_value">
<field name="payment_token_count" widget="statinfo" nolabel="1"/>
</span>
<span class="o_stat_text">Credit Cards</span>
</div>
</button>
<!-- Add credit card to res.partner -->
<record id="view_partners_form_payment_defaultcreditcard" model="ir.ui.view">
<field name="name">view.res.partner.form.payment.defaultcreditcard</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="priority" eval="15"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button type="action" class="oe_stat_button"
icon="fa-credit-card"
name="%(payment.action_payment_token)d"
context="{'search_default_partner_id': active_id, 'create': False, 'edit': False}"
attrs="{'invisible': [('payment_token_count', '=', 0)]}">
<div class="o_form_field o_stat_info">
<span class="o_stat_value">
<field name="payment_token_count" widget="statinfo" nolabel="1"/>
</span>
<span class="o_stat_text">Saved Payment Methods</span>
</div>
</field>
</record>
</data>
</button>
</div>
</field>
</record>
</odoo>
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import payment_acquirer_onboarding_wizard
@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
@@ -1,23 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_account_payment_register_form_inherit_payment" model="ir.ui.view">
<field name="name">account.payment.register.form.inherit.payment</field>
<field name="model">account.payment.register</field>
<field name="inherit_id" ref="account.view_account_payment_register_form"/>
<field name="arch" type="xml">
<field name="payment_method_id" position="after">
<!-- Invisible fields -->
<field name="payment_method_code" invisible="1"/>
<field name="suitable_payment_token_partner_ids" invisible="1"/>
<field name="payment_token_id"
options="{'no_create': True}"
attrs="{'invisible': ['|', ('payment_method_code', '!=', 'electronic'), '|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"/>
</field>
<record id="view_account_payment_register_form_inherit_payment" model="ir.ui.view">
<field name="name">account.payment.register.form.inherit.payment</field>
<field name="model">account.payment.register</field>
<field name="inherit_id" ref="account.view_account_payment_register_form"/>
<field name="arch" type="xml">
<field name="payment_method_id" position="after">
<field name="payment_method_code" invisible="1"/>
<field name="suitable_payment_token_partner_ids" invisible="1"/>
<field name="payment_token_id"
options="{'no_create': True}"
attrs="{'invisible': ['|', ('payment_method_code', '!=', 'electronic'), '|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"/>
</field>
</record>
</field>
</record>
</data>
</odoo>
@@ -12,7 +12,7 @@
<t t-set="state" t-value="state.get('payment_acquirer_onboarding_state')" />
</t>
</template>
<!--PAYMENT ACQUIRER-->
<record model="ir.ui.view" id="payment_acquirer_onboarding_wizard_form">
<field name="name">payment.acquirer.onboarding.wizard.form</field>
<field name="model">payment.acquirer.onboarding.wizard</field>
@@ -81,6 +81,7 @@
</form>
</field>
</record>
<record id="action_open_payment_onboarding_payment_acquirer_wizard" model="ir.actions.act_window">
<field name="name">Choose a payment method</field>
<field name="type">ir.actions.act_window</field>
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields, _
from odoo import _, api, fields, models
from odoo.exceptions import UserError
+16 -32
View File
@@ -1,13 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import hmac
from werkzeug import urls
from odoo import api, fields, models, _
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import ustr, consteq, float_compare
from odoo.tools import float_compare
from odoo.addons.payment import utils as payment_utils
class PaymentLinkWizard(models.TransientModel):
@@ -53,10 +52,10 @@ class PaymentLinkWizard(models.TransientModel):
@api.depends('amount', 'description', 'partner_id', 'currency_id')
def _compute_values(self):
secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
for payment_link in self:
token_str = '%s%s%s' % (payment_link.partner_id.id, payment_link.amount, payment_link.currency_id.id)
payment_link.access_token = hmac.new(secret.encode('utf-8'), token_str.encode('utf-8'), hashlib.sha256).hexdigest()
payment_link.access_token = payment_utils.generate_access_token(
payment_link.partner_id.id, payment_link.amount, payment_link.currency_id.id
)
# must be called after token generation, obvsly - the link needs an up-to-date token
self._generate_link()
@@ -68,27 +67,12 @@ class PaymentLinkWizard(models.TransientModel):
def _generate_link(self):
for payment_link in self:
record = self.env[payment_link.res_model].browse(payment_link.res_id)
link = ('%s/website_payment/pay?reference=%s&amount=%s&currency_id=%s'
'&partner_id=%s&access_token=%s') % (
record.get_base_url(),
urls.url_quote(payment_link.description),
payment_link.amount,
payment_link.currency_id.id,
payment_link.partner_id.id,
payment_link.access_token
)
if payment_link.company_id:
link += '&company_id=%s' % payment_link.company_id.id
if payment_link.res_model == 'account.move':
link += '&invoice_id=%s' % payment_link.res_id
payment_link.link = link
@api.model
def check_token(self, access_token, partner_id, amount, currency_id):
secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
token_str = '%s%s%s' % (partner_id, amount, currency_id)
correct_token = hmac.new(secret.encode('utf-8'), token_str.encode('utf-8'), hashlib.sha256).hexdigest()
if consteq(ustr(access_token), correct_token):
return True
return False
related_document = self.env[payment_link.res_model].browse(payment_link.res_id)
base_url = related_document.get_base_url() # Don't generate links for the wrong website
payment_link.link = f'{base_url}/payment/pay' \
f'?reference={urls.url_quote(payment_link.description)}' \
f'&amount={payment_link.amount}' \
f'&currency_id={payment_link.currency_id.id}' \
f'&partner_id={payment_link.partner_id.id}' \
f'&company_id={payment_link.company_id.id}' \
f'&access_token={payment_link.access_token}'
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_link_wizard_view_form" model="ir.ui.view">
<field name="name">payment.link.wizard.form</field>
<field name="model">payment.link.wizard</field>
@@ -40,4 +41,5 @@
<field name="binding_model_id" ref="model_account_move"/>
<field name="binding_view_types">form</field>
</record>
</odoo>
+1
View File
@@ -34,6 +34,7 @@ This module contains all the common features of Sales Management and eCommerce.
'views/sale_portal_templates.xml',
'views/sale_onboarding_views.xml',
'views/res_config_settings_views.xml',
'views/payment_templates.xml',
'views/payment_views.xml',
'views/product_views.xml',
'views/utm_campaign_views.xml',
+156 -96
View File
@@ -4,13 +4,15 @@
import binascii
from odoo import fields, http, _
from odoo.exceptions import AccessError, MissingError
from odoo.exceptions import AccessError, MissingError, ValidationError
from odoo.fields import Command
from odoo.http import request
from odoo.addons.portal.controllers import portal
from odoo.addons.payment.controllers.portal import PaymentProcessing
from odoo.addons.payment.controllers import portal as payment_portal
from odoo.addons.payment import utils as payment_utils
from odoo.addons.portal.controllers.mail import _message_post_helper
from odoo.addons.portal.controllers import portal
from odoo.addons.portal.controllers.portal import pager as portal_pager, get_records_pager
from odoo.osv import expression
class CustomerPortal(portal.CustomerPortal):
@@ -33,39 +35,6 @@ class CustomerPortal(portal.CustomerPortal):
return values
def _order_get_page_view_values(self, order, access_token, **kwargs):
values = {
'sale_order': order,
'token': access_token,
'return_url': '/shop/payment/validate',
'bootstrap_formatting': True,
'partner_id': order.partner_id.id,
'report_type': 'html',
'action': order._get_portal_return_action(),
}
if order.company_id:
values['res_company'] = order.company_id
if order.has_to_be_paid():
domain = expression.AND([
['&', ('state', 'in', ['enabled', 'test']), ('company_id', '=', order.company_id.id)],
['|', ('country_ids', '=', False), ('country_ids', 'in', [order.partner_id.country_id.id])]
])
acquirers = request.env['payment.acquirer'].sudo().search(domain)
values['acquirers'] = acquirers.filtered(lambda acq: (acq.payment_flow == 'form' and acq.view_template_id) or
(acq.payment_flow == 's2s' and acq.registration_view_template_id))
values['pms'] = request.env['payment.token'].search([('partner_id', '=', order.partner_id.id)])
values['acq_extra_fees'] = acquirers.get_acquirer_extra_fees(order.amount_total, order.currency_id, order.partner_id.country_id.id)
if order.state in ('draft', 'sent', 'cancel'):
history = request.session.get('my_quotations_history', [])
else:
history = request.session.get('my_orders_history', [])
values.update(get_records_pager(history, order))
return values
#
# Quotations and Sales Orders
#
@@ -199,8 +168,63 @@ class CustomerPortal(portal.CustomerPortal):
partner_ids=order_sudo.user_id.sudo().partner_id.ids,
)
values = self._order_get_page_view_values(order_sudo, access_token, **kw)
values['message'] = message
values = {
'sale_order': order_sudo,
'message': message,
'token': access_token,
'landing_route': '/shop/payment/validate',
'bootstrap_formatting': True,
'partner_id': order_sudo.partner_id.id,
'report_type': 'html',
'action': order_sudo._get_portal_return_action(),
}
if order_sudo.company_id:
values['res_company'] = order_sudo.company_id
# Payment values
if order_sudo.has_to_be_paid():
logged_in = not request.env.user._is_public()
acquirers_sudo = request.env['payment.acquirer'].sudo()._get_compatible_acquirers(
order_sudo.company_id.id,
order_sudo.partner_id.id,
currency_id=order_sudo.currency_id.id,
sale_order_id=order_sudo.id,
) # In sudo mode to read the fields of acquirers and partner (if not logged in)
tokens = request.env['payment.token'].search([
('acquirer_id', 'in', acquirers_sudo.ids),
('partner_id', '=', order_sudo.partner_id.id)
]) if logged_in else request.env['payment.token']
fees_by_acquirer = {
acquirer: acquirer._compute_fees(
order_sudo.amount_total,
order_sudo.currency_id,
order_sudo.partner_id.country_id,
) for acquirer in acquirers_sudo.filtered('fees_active')
}
# Prevent public partner from saving payment methods but force it for logged in partners
# buying subscription products
show_tokenize_input = logged_in \
and not request.env['payment.acquirer'].sudo()._is_tokenization_required(
sale_order_id=order_sudo.id
)
values.update({
'acquirers': acquirers_sudo,
'tokens': tokens,
'fees_by_acquirer': fees_by_acquirer,
'show_tokenize_input': show_tokenize_input,
'amount': order_sudo.amount_total,
'currency': order_sudo.pricelist_id.currency_id,
'partner_id': order_sudo.partner_id.id,
'access_token': order_sudo.access_token,
'transaction_route': order_sudo.get_portal_url(suffix='/transaction'),
'landing_route': order_sudo.get_portal_url(),
})
if order_sudo.state in ('draft', 'sent', 'cancel'):
history = request.session.get('my_quotations_history', [])
else:
history = request.session.get('my_orders_history', [])
values.update(get_records_pager(history, order_sudo))
return request.render('sale.sale_order_portal_template', values)
@@ -265,70 +289,106 @@ class CustomerPortal(portal.CustomerPortal):
return request.redirect(order_sudo.get_portal_url(query_string=query_string))
# note: website_sale code
@http.route(['/my/orders/<int:order_id>/transaction/'], type='json', auth="public", website=True)
def payment_transaction_token(self, acquirer_id, order_id, save_token=False, access_token=None, **kwargs):
""" Json method that creates a payment.transaction, used to create a
transaction when the user clicks on 'pay now' button. After having
created the transaction, the event continues and the user is redirected
to the acquirer website.
:param int acquirer_id: id of a payment.acquirer record. If not set the
user is redirected to the checkout page
class PaymentPortal(payment_portal.PaymentPortal):
@http.route('/my/orders/<int:order_id>/transaction', type='json', auth='public')
def portal_order_transaction(self, order_id, access_token, **kwargs):
""" Create a draft transaction and return its processing values.
:param int order_id: The sales order to pay, as a `sale.order` id
:param str access_token: The access token used to authenticate the request
:param dict kwargs: Locally unused data passed to `_create_transaction`
:return: The mandatory values for the processing of the transaction
:rtype: dict
:raise: ValidationError if the invoice id or the access token is invalid
"""
# Ensure a payment acquirer is selected
if not acquirer_id:
return False
# Check the order id and the access token
try:
acquirer_id = int(acquirer_id)
except:
return False
self._document_check_access('sale.order', order_id, access_token)
except MissingError as error:
raise error
except AccessError:
raise ValidationError("The access token is invalid.")
order = request.env['sale.order'].sudo().browse(order_id)
if not order or not order.order_line or not order.has_to_be_paid():
return False
# Create transaction
vals = {
'acquirer_id': acquirer_id,
'type': order._get_payment_type(save_token),
'return_url': order.get_portal_url(),
}
transaction = order._create_payment_transaction(vals)
PaymentProcessing.add_payment_transaction(transaction)
return transaction.render_sale_button(
order,
submit_txt=_('Pay & Confirm'),
render_values={
'type': order._get_payment_type(save_token),
'alias_usage': _('If we store your payment information on our server, subscription payments will be made automatically.'),
}
kwargs.update({
'reference_prefix': None, # Allow the reference to be computed based on the order
'sale_order_id': order_id, # Include the SO to allow Subscriptions tokenizing the tx
})
kwargs.pop('custom_create_values', None) # Don't allow passing arbitrary create values
tx_sudo = self._create_transaction(
custom_create_values={'sale_order_ids': [Command.set([order_id])]}, **kwargs,
)
@http.route('/my/orders/<int:order_id>/transaction/token', type='http', auth='public', website=True)
def payment_token(self, order_id, pm_id=None, **kwargs):
return tx_sudo._get_processing_values()
order = request.env['sale.order'].sudo().browse(order_id)
if not order:
return request.redirect("/my/orders")
if not order.order_line or pm_id is None or not order.has_to_be_paid():
return request.redirect(order.get_portal_url())
# Payment overrides
# try to convert pm_id into an integer, if it doesn't work redirect the user to the quote
try:
pm_id = int(pm_id)
except ValueError:
return request.redirect(order.get_portal_url())
@http.route()
def payment_pay(self, *args, amount=None, sale_order_id=None, access_token=None, **kwargs):
""" Override of payment to replace the missing transaction values by that of the sale order.
# Create transaction
vals = {
'payment_token_id': pm_id,
'type': 'server2server',
'return_url': order.get_portal_url(),
}
This is necessary for the reconciliation as all transaction values, excepted the amount,
need to match exactly that of the sale order.
tx = order._create_payment_transaction(vals)
PaymentProcessing.add_payment_transaction(tx)
return request.redirect('/payment/process')
:param str amount: The (possibly partial) amount to pay used to check the access token
:param str sale_order_id: The sale order for which a payment id made, as a `sale.order` 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 order 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)
sale_order_id = self.cast_as_int(sale_order_id)
if sale_order_id:
order_sudo = request.env['sale.order'].sudo().browse(sale_order_id).exists()
if not order_sudo:
raise ValidationError(_("The provided parameters are invalid."))
# Check the access token against the order values. Done after fetching the order as we
# need the order fields to check the access token.
if not payment_utils.check_access_token(
access_token, order_sudo.partner_id.id, amount, order_sudo.currency_id.id
):
raise ValidationError(_("The provided parameters are invalid."))
kwargs.update({
'currency_id': order_sudo.currency_id.id,
'partner_id': order_sudo.partner_id.id,
'company_id': order_sudo.company_id.id,
'sale_order_id': sale_order_id,
})
return super().payment_pay(*args, amount=amount, access_token=access_token, **kwargs)
def _get_custom_rendering_context_values(self, sale_order_id=None, **kwargs):
""" Override of payment to add the sale order id in the custom rendering context values.
:param int sale_order_id: The sale order for which a payment id made, as a `sale.order` id
:return: The extended rendering context values
:rtype: dict
"""
rendering_context_values = super()._get_custom_rendering_context_values(**kwargs)
if sale_order_id:
rendering_context_values['sale_order_id'] = sale_order_id
return rendering_context_values
def _create_transaction(self, *args, sale_order_id=None, custom_create_values=None, **kwargs):
""" Override of payment to add the sale order id in the custom create values.
:param int sale_order_id: The sale order for which a payment id made, as a `sale.order` id
:param dict custom_create_values: Additional create values overwriting the default ones
:return: The result of the parent method
:rtype: recordset of `payment.transaction`
"""
if sale_order_id:
if custom_create_values is None:
custom_create_values = {}
# As this override is also called if the flow is initiated from sale or website_sale, we
# need not to override whatever value these modules could have already set
if 'sale_order_ids' not in custom_create_values: # We are in the payment module's flow
custom_create_values['sale_order_ids'] = [Command.set([int(sale_order_id)])]
return super()._create_transaction(
*args, sale_order_id=sale_order_id, custom_create_values=custom_create_values, **kwargs
)
+3 -3
View File
@@ -170,10 +170,10 @@
<tr>
<td>
<strong>Payment Method:</strong>
% if transaction.payment_token_id:
${transaction.payment_token_id.name}
% if transaction.token_id:
${transaction.token_id.name}
% else:
${transaction.acquirer_id.name}
${transaction.acquirer_id.sudo().name}
% endif
(${format_amount(transaction.amount, object.currency_id)})
</td>
+43 -52
View File
@@ -42,24 +42,9 @@ class PaymentTransaction(models.Model):
for trans in self:
trans.sale_order_ids_nbr = len(trans.sale_order_ids)
def _log_payment_transaction_sent(self):
super(PaymentTransaction, self)._log_payment_transaction_sent()
for trans in self:
post_message = trans._get_payment_transaction_sent_message()
for so in trans.sale_order_ids:
so.message_post(body=post_message)
def _log_payment_transaction_received(self):
super(PaymentTransaction, self)._log_payment_transaction_received()
for trans in self.filtered(lambda t: t.provider not in ('manual', 'transfer')):
post_message = trans._get_payment_transaction_received_message()
for so in trans.sale_order_ids:
so.message_post(body=post_message)
def _set_transaction_pending(self):
# Override of '_set_transaction_pending' in the 'payment' module
# to sent the quotations automatically.
super(PaymentTransaction, self)._set_transaction_pending()
def _set_pending(self, state_message=None):
""" Override of payment to send the quotations automatically. """
super(PaymentTransaction, self)._set_pending(state_message=state_message)
for record in self:
sales_orders = record.sale_order_ids.filtered(lambda so: so.state in ['draft', 'sent'])
@@ -91,10 +76,9 @@ class PaymentTransaction(models.Model):
)
)
def _set_transaction_authorized(self):
# Override of '_set_transaction_authorized' in the 'payment' module
# to confirm the quotations automatically.
super(PaymentTransaction, self)._set_transaction_authorized()
def _set_authorized(self, state_message=None):
""" Override of payment to confirm the quotations automatically. """
super()._set_authorized(state_message=state_message)
sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent'))
for tx in self:
tx._check_amount_and_confirm_order()
@@ -102,9 +86,20 @@ class PaymentTransaction(models.Model):
# send order confirmation mail
sales_orders._send_order_confirmation_mail()
def _reconcile_after_transaction_done(self):
# Override of '_set_transaction_done' in the 'payment' module
# to confirm the quotations automatically and to generate the invoices if needed.
def _log_message_on_linked_documents(self, message):
""" Override of payment to log a message on the sales orders linked to the transaction.
Note: self.ensure_one()
:param str message: The message to be logged
:return: None
"""
super()._log_message_on_linked_documents(message)
for order in self.sale_order_ids:
order.message_post(body=message)
def _reconcile_after_done(self):
""" Override of payment to automatically confirm quotations and generate invoices. """
sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent'))
for tx in self:
tx._check_amount_and_confirm_order()
@@ -112,7 +107,7 @@ class PaymentTransaction(models.Model):
sales_orders._send_order_confirmation_mail()
# invoice the sale orders if needed
self._invoice_sale_orders()
res = super(PaymentTransaction, self)._reconcile_after_transaction_done()
res = super()._reconcile_after_done()
if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'):
default_template = self.env['ir.config_parameter'].sudo().get_param('sale.default_email_template')
if default_template:
@@ -135,12 +130,28 @@ class PaymentTransaction(models.Model):
trans.invoice_ids = [(6, 0, invoices.ids)]
@api.model
def _compute_reference_prefix(self, values):
prefix = super(PaymentTransaction, self)._compute_reference_prefix(values)
if not prefix and values and values.get('sale_order_ids'):
sale_orders = self.new({'sale_order_ids': values['sale_order_ids']}).sale_order_ids
return ','.join(sale_orders.mapped('name'))
return prefix
def _compute_reference_prefix(self, provider, separator, **values):
""" Override of payment to compute the reference prefix based on Sales-specific values.
If the `values` parameter has an entry with 'sale_order_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 sales order name(s)
Otherwise, the computation is delegated to the super method.
: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 {'sale_order_ids': [(X2M command), ...], ...}.
:return: The computed reference prefix if order ids are found, the one of `super` otherwise
:rtype: str
"""
command_list = values.get('sale_order_ids')
if command_list:
# Extract sales order id(s) from the X2M commands
order_ids = self._fields['sale_order_ids'].convert_to_cache(command_list, self)
orders = self.env['sale.order'].browse(order_ids).exists()
if len(orders) == len(order_ids): # All ids are valid
return separator.join(orders.mapped('name'))
return super()._compute_reference_prefix(provider, separator, **values)
def action_view_sales_orders(self):
action = {
@@ -157,23 +168,3 @@ class PaymentTransaction(models.Model):
action['view_mode'] = 'tree,form'
action['domain'] = [('id', 'in', sale_order_ids)]
return action
# --------------------------------------------------
# Tools for payment
# --------------------------------------------------
def render_sale_button(self, order, submit_txt=None, render_values=None):
values = {
'partner_id': order.partner_id.id,
'type': self.type,
}
if render_values:
values.update(render_values)
# Not very elegant to do that here but no choice regarding the design.
self._log_payment_transaction_sent()
return self.acquirer_id.with_context(submit_class='btn btn-primary', submit_txt=submit_txt or _('Pay Now')).sudo().render(
self.reference,
order.amount_total,
order.pricelist_id.currency_id.id,
values=values,
)
+6 -76
View File
@@ -878,10 +878,10 @@ Reason(s) of this behavior could be:
def action_done(self):
for order in self:
tx = order.sudo().transaction_ids.get_last_transaction()
tx = order.sudo().transaction_ids._get_last()
if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer':
tx._set_transaction_done()
tx.write({'is_processed': True})
tx._set_done()
tx.write({'is_post_processed': True})
return self.write({'state': 'done'})
def action_unlock(self):
@@ -995,72 +995,6 @@ Reason(s) of this behavior could be:
return groups
def _create_payment_transaction(self, vals):
'''Similar to self.env['payment.transaction'].create(vals) but the values are filled with the
current sales orders fields (e.g. the partner or the currency).
:param vals: The values to create a new payment.transaction.
:return: The newly created payment.transaction record.
'''
# Ensure the currencies are the same.
currency = self[0].pricelist_id.currency_id
if any(so.pricelist_id.currency_id != currency for so in self):
raise ValidationError(_('A transaction can\'t be linked to sales orders having different currencies.'))
# Ensure the partner are the same.
partner = self[0].partner_id
if any(so.partner_id != partner for so in self):
raise ValidationError(_('A transaction can\'t be linked to sales orders having different partners.'))
# Try to retrieve the acquirer. However, fallback to the token's acquirer.
acquirer_id = vals.get('acquirer_id')
acquirer = False
payment_token_id = vals.get('payment_token_id')
if payment_token_id:
payment_token = self.env['payment.token'].sudo().browse(payment_token_id)
# Check payment_token/acquirer matching or take the acquirer from token
if acquirer_id:
acquirer = self.env['payment.acquirer'].browse(acquirer_id)
if payment_token and payment_token.acquirer_id != acquirer:
raise ValidationError(_('Invalid token found! Token acquirer %s != %s') % (
payment_token.acquirer_id.name, acquirer.name))
if payment_token and payment_token.partner_id != partner:
raise ValidationError(_('Invalid token found! Token partner %s != %s') % (
payment_token.partner.name, partner.name))
else:
acquirer = payment_token.acquirer_id
# Check an acquirer is there.
if not acquirer_id and not acquirer:
raise ValidationError(_('A payment acquirer is required to create a transaction.'))
if not acquirer:
acquirer = self.env['payment.acquirer'].browse(acquirer_id)
# Check a journal is set on acquirer.
if not acquirer.journal_id:
raise ValidationError(_('A journal must be specified for the acquirer %s.', acquirer.name))
if not acquirer_id and acquirer:
vals['acquirer_id'] = acquirer.id
vals.update({
'amount': sum(self.mapped('amount_total')),
'currency_id': currency.id,
'partner_id': partner.id,
'sale_order_ids': [(6, 0, self.ids)],
'type': self[0]._get_payment_type(vals.get('type')=='form_save'),
})
transaction = self.env['payment.transaction'].create(vals)
# Process directly if payment_token
if transaction.payment_token_id:
transaction.s2s_do_transaction()
return transaction
def preview_sale_order(self):
self.ensure_one()
return {
@@ -1077,14 +1011,14 @@ Reason(s) of this behavior could be:
line.qty_to_invoice = 0
def payment_action_capture(self):
self.authorized_transaction_ids.s2s_capture_transaction()
self.authorized_transaction_ids._send_capture_request()
def payment_action_void(self):
self.authorized_transaction_ids.s2s_void_transaction()
self.authorized_transaction_ids._send_void_request()
def get_portal_last_transaction(self):
self.ensure_one()
return self.transaction_ids.get_last_transaction()
return self.transaction_ids._get_last()
@api.model
def _get_customer_lead(self, product_tmpl_id):
@@ -1107,10 +1041,6 @@ Reason(s) of this behavior could be:
return self.get_portal_url(query_string='&%s' % auth_param)
return super(SaleOrder, self)._get_share_url(redirect, signup_partner, pid)
def _get_payment_type(self, tokenize=False):
self.ensure_one()
return 'form_save' if tokenize else 'form'
def _get_portal_return_action(self):
""" Return the action used to display orders when returning from customer portal. """
self.ensure_one()
+3 -15
View File
@@ -58,21 +58,6 @@
<field name="domain_force">['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
<!-- Payments -->
<record id="payment_transaction_salesman_rule" model="ir.rule">
<field name="name">Access every payment transaction</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>
<record id="payment_token_salesman_rule" model="ir.rule">
<field name="name">Access every payment token</field>
<field name="model_id" ref="payment.model_payment_token"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>
<!-- Portal Access Rules -->
<record id="sale_order_rule_portal" model="ir.rule">
<field name="name">Portal Personal Quotations/Sales Orders</field>
@@ -154,6 +139,7 @@
<record id="payment_transaction_salesman_rule" model="ir.rule">
<field name="name">Access every payment transaction</field>
<field name="model_id" ref="payment.model_payment_transaction"/>
<!-- Reset the domain defined by payment.transaction_user_rule -->
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>
@@ -161,6 +147,8 @@
<record id="payment_token_salesman_rule" model="ir.rule">
<field name="name">Access every payment token</field>
<field name="model_id" ref="payment.model_payment_token"/>
<!-- Reset the domain defined by payment.token_user_rule -->
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>
+37
View File
@@ -0,0 +1,37 @@
odoo.define('sale.payment_form', require => {
'use strict';
const checkoutForm = require('payment.checkout_form');
const manageForm = require('payment.manage_form');
const salePaymentMixin = {
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Add `sale_order_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,
'sale_order_id': this.txContext.saleOrderId
? parseInt(this.txContext.saleOrderId) : undefined,
};
},
};
checkoutForm.include(salePaymentMixin);
manageForm.include(salePaymentMixin);
});
+1 -1
View File
@@ -6,9 +6,9 @@ from . import test_sale_to_invoice
from . import test_sale_order
from . import test_sale_pricelist
from . import test_onchange
from . import test_payment_flow
from . import test_reinvoice
from . import test_access_rights
from . import test_sale_refund
from . import test_sale_signature
from . import test_sale_transaction
from . import test_sale_flow
+159
View File
@@ -0,0 +1,159 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import tagged
from odoo.tools import mute_logger
from odoo.addons.payment.tests.common import PaymentCommon
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
@tagged('-at_install', 'post_install')
class TestSalePayment(PaymentCommon, PaymentHttpCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.pricelist = cls.env['product.pricelist'].search([
('currency_id', '=', cls.currency.id)], limit=1)
if not cls.pricelist:
cls.pricelist = cls.env['product.pricelist'].create({
'name': 'Test Pricelist (%s)' % (cls.currency.name),
'currency_id': cls.currency.id,
})
cls.sale_product = cls.env['product.product'].create({
'sale_ok': True,
'name': "Test Product",
})
cls.order = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'pricelist_id': cls.pricelist.id,
'order_line': [Command.create({
'product_id': cls.sale_product.id,
'product_uom_qty': 5,
'price_unit': 20,
})],
})
def test_11_so_payment_link(self):
# test customized /payment/pay route with sale_order_id param
self.amount = self.order.amount_total
route_values = self._prepare_pay_values()
route_values['sale_order_id'] = self.order.id
tx_context = self.get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['currency_id'], self.order.currency_id.id)
self.assertEqual(tx_context['partner_id'], self.order.partner_id.id)
self.assertEqual(tx_context['amount'], self.order.amount_total)
self.assertEqual(tx_context['sale_order_id'], self.order.id)
route_values.update({
'flow': 'direct',
'payment_option_id': self.acquirer.id,
'tokenization_requested': False,
'validation_route': False,
'reference_prefix': None, # Force empty prefix to fallback on SO reference
'landing_route': tx_context['landing_route'],
'amount': tx_context['amount'],
'currency_id': tx_context['currency_id'],
})
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'])
self.assertEqual(tx_sudo.sale_order_ids, self.order)
self.assertEqual(tx_sudo.amount, self.amount)
self.assertEqual(tx_sudo.partner_id, self.order.partner_id)
self.assertEqual(tx_sudo.company_id, self.order.company_id)
self.assertEqual(tx_sudo.currency_id, self.order.currency_id)
self.assertEqual(tx_sudo.reference, self.order.name)
# Check validation of transaction correctly confirms the SO
self.assertEqual(self.order.state, 'draft')
tx_sudo._set_done()
tx_sudo._finalize_post_processing()
self.assertEqual(self.order.state, 'sale')
self.assertTrue(tx_sudo.payment_id)
self.assertEqual(tx_sudo.payment_id.state, 'posted')
def test_12_so_partial_payment_link(self):
# test customized /payment/pay route with sale_order_id param
# partial amount specified
self.amount = self.order.amount_total / 2.0
route_values = self._prepare_pay_values()
route_values['sale_order_id'] = self.order.id
tx_context = self.get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['reference_prefix'], self.reference)
self.assertEqual(tx_context['currency_id'], self.order.currency_id.id)
self.assertEqual(tx_context['partner_id'], self.order.partner_id.id)
self.assertEqual(tx_context['amount'], self.amount)
self.assertEqual(tx_context['sale_order_id'], self.order.id)
route_values.update({
'flow': 'direct',
'payment_option_id': self.acquirer.id,
'tokenization_requested': False,
'validation_route': False,
'reference_prefix': tx_context['reference_prefix'],
'landing_route': tx_context['landing_route'],
})
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'])
self.assertEqual(tx_sudo.sale_order_ids, self.order)
self.assertEqual(tx_sudo.amount, self.amount)
self.assertEqual(tx_sudo.partner_id, self.order.partner_id)
self.assertEqual(tx_sudo.company_id, self.order.company_id)
self.assertEqual(tx_sudo.currency_id, self.order.currency_id)
self.assertEqual(tx_sudo.reference, self.reference)
tx_sudo._set_done()
with mute_logger('odoo.addons.sale.models.payment'):
tx_sudo._finalize_post_processing()
self.assertEqual(self.order.state, 'draft') # Only a partial amount was paid
# Pay the remaining amount
route_values = self._prepare_pay_values()
route_values['sale_order_id'] = self.order.id
tx_context = self.get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['reference_prefix'], self.reference)
self.assertEqual(tx_context['currency_id'], self.order.currency_id.id)
self.assertEqual(tx_context['partner_id'], self.order.partner_id.id)
self.assertEqual(tx_context['amount'], self.amount)
self.assertEqual(tx_context['sale_order_id'], self.order.id)
route_values.update({
'flow': 'direct',
'payment_option_id': self.acquirer.id,
'tokenization_requested': False,
'validation_route': False,
'reference_prefix': tx_context['reference_prefix'],
'landing_route': tx_context['landing_route'],
})
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self.get_processing_values(**route_values)
tx2_sudo = self._get_tx(processing_values['reference'])
self.assertEqual(tx2_sudo.sale_order_ids, self.order)
self.assertEqual(tx2_sudo.amount, self.amount)
self.assertEqual(tx2_sudo.partner_id, self.order.partner_id)
self.assertEqual(tx2_sudo.company_id, self.order.company_id)
self.assertEqual(tx2_sudo.currency_id, self.order.currency_id)
# We are paying a second time with the same reference (prefix)
# a suffix is added to respect unique reference constraint
reference = self.reference + "-1"
self.assertEqual(tx2_sudo.reference, reference)
self.assertEqual(self.order.state, 'draft')
self.assertEqual(self.order.transaction_ids, tx_sudo + tx2_sudo)
@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import tests
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
from odoo.tools import mute_logger
@tests.tagged('post_install', '-at_install')
class TestSaleTransaction(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.company_data['company'].country_id = cls.env.ref('base.us')
cls.order = cls.env['sale.order'].create({
'partner_id': cls.partner_a.id,
'order_line': [
(0, False, {
'product_id': cls.product_a.id,
'name': '1 Product',
'price_unit': 100.0,
}),
],
})
cls.env.ref('payment.payment_acquirer_transfer').journal_id = cls.company_data['default_journal_cash']
cls.transaction = cls.order._create_payment_transaction({
'acquirer_id': cls.env.ref('payment.payment_acquirer_transfer').id,
})
def test_sale_invoicing_from_transaction(self):
''' Test the following scenario:
- Create a sale order
- Create a transaction for the sale order.
- Confirm the transaction but no invoice generated automatically.
- Create manually an invoice for this sale order.
=> The invoice must be paid.
'''
self.transaction._set_transaction_done()
self.transaction._post_process_after_done()
# Assert a posted payment has been generated at this point.
self.assertTrue(self.transaction.payment_id)
self.assertEqual(self.transaction.payment_id.state, 'posted')
# Doesn't work with stock installed.
# invoice = self.order._create_invoices()
# invoice.post()
#
# self.assertTrue(invoice.payment_state in ('in_payment', 'paid'), "Invoice should be paid")
def test_sale_transaction_mismatch(self):
"""Test that a transaction for the incorrect amount does not validate the SO."""
# modify order total
self.order.order_line[0].price_unit = 200.0
self.transaction._set_transaction_done()
with mute_logger('odoo.addons.sale.models.payment'):
self.transaction._post_process_after_done()
self.assertEqual(self.order.state, 'draft', 'a transaction for an incorrect amount should not validate a quote')
+1
View File
@@ -24,6 +24,7 @@
</xpath>
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/sale/static/src/js/sale_portal_sidebar.js"></script>
<script type="text/javascript" src="/sale/static/src/js/payment_form.js"/>
</xpath>
</template>
<template id="assets_tests" name="Sale Assets Tests" inherit_id="web.assets_tests">
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Include sale-related values in payment checkout form to pass them to the client -->
<template id="payment_checkout_inherit" inherit_id="payment.checkout">
<xpath expr="//form[@name='o_payment_checkout']" position="attributes">
<attribute name="t-att-data-sale-order-id">sale_order_id</attribute>
</xpath>
</template>
<!-- Include sale-related values in payment manage form to pass them to the client -->
<template id="payment_manage_inherit" inherit_id="payment.manage">
<xpath expr="//form[@name='o_payment_manage']" position="attributes">
<attribute name="t-att-data-sale-order-id">sale_order_id</attribute>
</xpath>
</template>
</odoo>
+3 -3
View File
@@ -4,9 +4,9 @@
<record id="acquirer_form_inherit_sale" model="ir.ui.view">
<field name="name">payment.acquirer.form.inherit.sale.payment</field>
<field name="model">payment.acquirer</field>
<field name="inherit_id" ref="payment.acquirer_form"/>
<field name="inherit_id" ref="payment.payment_acquirer_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='qr_code']" position="after">
<xpath expr="//group[@name='payment_form']" position="inside">
<field name="so_reference_type" attrs="{'invisible': [('provider', '!=', 'transfer')]}"/>
</xpath>
</field>
@@ -15,7 +15,7 @@
<record id="transaction_form_inherit_sale" model="ir.ui.view">
<field name="name">payment.transaction.form.inherit.sale.payment</field>
<field name="model">payment.transaction</field>
<field name="inherit_id" ref="payment.transaction_form"/>
<field name="inherit_id" ref="payment.payment_transaction_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_sales_orders" type="object"
+1 -1
View File
@@ -62,7 +62,7 @@
<field name="model">res.partner</field>
<field name="inherit_id" ref="payment.view_partners_form_payment_defaultcreditcard"/>
<field name="arch" type="xml">
<button name="%(payment.payment_token_action)d" position="attributes">
<button name="%(payment.action_payment_token)d" position="attributes">
<attribute name="groups">sales_team.group_sale_salesman</attribute>
</button>
</field>
+10 -15
View File
@@ -237,16 +237,13 @@
<li t-if="sale_order.payment_term_id"><span>With payment terms:</span> <b t-field="sale_order.payment_term_id.note"/></li>
</ul>
</p>
<div t-if="pms or acquirers" id="payment_method" class="text-left">
<div t-if="acquirers or tokens" id="payment_method" class="text-left">
<h3 class="mb24">Pay with</h3>
<t t-call="payment.payment_tokens_list">
<t t-set="mode" t-value="'payment'"/>
<t t-set="submit_txt">Pay &amp; Confirm</t>
<t t-set="icon_class" t-value="'fa-lock'"/>
<t t-set="form_action" t-value="sale_order.get_portal_url(suffix='/transaction/token')"/>
<t t-set="prepare_tx_url" t-value="sale_order.get_portal_url(suffix='/transaction/')"/>
<t t-set="access_token" t-value="sale_order.access_token"/>
</t>
<t t-call="payment.checkout"/>
</div>
<div t-else="" class="alert alert-warning">
<strong>No suitable payment option could be found.</strong><br/>
If you believe that it is an error, please contact the website administrator.
</div>
</main>
</div>
@@ -291,9 +288,8 @@
</div>
<t t-if="sale_order.transaction_ids">
<t t-call="payment.payment_confirmation_status">
<t t-set="payment_tx_id" t-value="sale_order.get_portal_last_transaction()"/>
<t t-set="reference" t-value="sale_order.reference"/>
<t t-call="payment.transaction_status">
<t t-set="tx" t-value="sale_order.get_portal_last_transaction()"/>
</t>
</t>
@@ -364,9 +360,8 @@
<div id="informations">
<div t-if="sale_order.transaction_ids and not invoices and sale_order.state in ('sent', 'sale') and portal_confirmation == 'pay' and not success and not error" t-att-data-order-id="sale_order.id">
<t t-if="sale_order.transaction_ids">
<t t-call="payment.payment_confirmation_status">
<t t-set="payment_tx_id" t-value="sale_order.get_portal_last_transaction()"/>
<t t-set="reference" t-value="sale_order.reference"/>
<t t-call="payment.transaction_status">
<t t-set="tx" t-value="sale_order.get_portal_last_transaction()"/>
</t>
</t>
</div>
+15 -23
View File
@@ -6,13 +6,13 @@ from werkzeug import urls
from odoo import api, models
class SalePaymentLink(models.TransientModel):
_inherit = "payment.link.wizard"
_description = "Generate Sales Payment Link"
class PaymentLinkWizard(models.TransientModel):
_inherit = 'payment.link.wizard'
_description = 'Generate Sales Payment Link'
@api.model
def default_get(self, fields):
res = super(SalePaymentLink, self).default_get(fields)
res = super().default_get(fields)
if res['res_id'] and res['res_model'] == 'sale.order':
record = self.env[res['res_model']].browse(res['res_id'])
res.update({
@@ -25,25 +25,17 @@ class SalePaymentLink(models.TransientModel):
return res
def _generate_link(self):
""" Override of the base method to add the order_id in the link. """
""" Override of payment to add the sale_order_id in the link. """
for payment_link in self:
# only add order_id for SOs,
# otherwise the controller might try to link it with an unrelated record
# NOTE: company_id is not necessary here, we have it in order_id
# however, should parsing of the id fail in the controller, let's include
# it anyway
# The sale_order_id field only makes sense if the document is a sales order
if payment_link.res_model == 'sale.order':
record = self.env[payment_link.res_model].browse(payment_link.res_id)
payment_link.link = ('%s/website_payment/pay?reference=%s&amount=%s&currency_id=%s'
'&partner_id=%s&order_id=%s&company_id=%s&access_token=%s') % (
record.get_base_url(),
urls.url_quote(payment_link.description),
payment_link.amount,
payment_link.currency_id.id,
payment_link.partner_id.id,
payment_link.res_id,
payment_link.company_id.id,
payment_link.access_token
)
related_document = self.env[payment_link.res_model].browse(payment_link.res_id)
base_url = related_document.get_base_url()
payment_link.link = f'{base_url}/payment/pay' \
f'?reference={urls.url_quote(payment_link.description)}' \
f'&amount={payment_link.amount}' \
f'&sale_order_id={payment_link.res_id}' \
f'&access_token={payment_link.access_token}'
# Order-related fields are retrieved in the controller
else:
super(SalePaymentLink, payment_link)._generate_link()
super(PaymentLinkWizard, payment_link)._generate_link()
@@ -45,29 +45,20 @@ var buyCertificationSteps = [{
trigger: 'a:contains("Process Checkout")'
}, {
content: 'eCommerce: select Test payment acquirer',
trigger: '.o_payment_acquirer_select:contains("Test")'
trigger: '.o_payment_option_card:contains("Test")'
}, {
content: 'eCommerce: add card number',
trigger: 'input[name="cc_number"]',
trigger: 'input[name="customer_input"]',
run: 'text 4242424242424242'
}, {
content: 'eCommerce: add card holder name',
trigger: 'input[name="cc_holder_name"]',
run: 'text Marc Demo'
}, {
content: 'eCommerce: add card expiry date',
trigger: 'input[name="cc_expiry"]',
run: 'text 11 / 50'
}, {
content: 'eCommerce: add card cvc',
trigger: 'input[name="cvc"]',
run: 'text 999'
content: "Accept the Terms & conditions",
trigger: '#checkbox_tc',
}, {
content: 'eCommerce: pay',
trigger: '#o_payment_form_pay'
trigger: 'button[name="o_payment_submit_button"]'
}, {
content: 'eCommerce: check that the payment is successful',
trigger: '.oe_website_sale_tx_status:contains("Your payment has been successfully processed")',
trigger: '.oe_website_sale_tx_status:contains("Your payment has been successfully processed.")',
run: function () {}
}, {
content: 'eCommerce: go back to e-learning home page',
@@ -81,15 +81,19 @@ tour.register('event_buy_tickets', {
content: "Select `Wire Transfer` payment method",
trigger: '#payment_method label:contains("Wire Transfer")',
},
{
content: "Accept the Terms & conditions",
trigger: '#checkbox_tc',
},
{
content: "Pay",
//Either there are multiple payment methods, and one is checked, either there is only one, and therefore there are no radio inputs
extra_trigger: '#payment_method label:contains("Wire Transfer") input:checked,#payment_method:not(:has("input:radio:visible"))',
trigger: 'button[id="o_payment_form_pay"]:visible',
// extra_trigger: '#payment_method input:checked,#payment_method:not(:has("input:radio:visible"))',
trigger: 'button[name="o_payment_submit_button"]:visible:not(:disabled)',
},
{
content: "Last step",
trigger: '.oe_website_sale:contains("Please make a payment to:")',
trigger: '.oe_website_sale_tx_status:contains("Please use the following transfer details")',
timeout: 30000,
}
]
+2 -2
View File
@@ -6,7 +6,7 @@
'summary': 'Payment integration with website',
'version': '1.0',
'description': """
This is a bridge module which integrates payment acquirers with Website app.
This is a bridge module that adds multi-website support for payment acquirers.
""",
'depends': [
'website',
@@ -16,5 +16,5 @@ This is a bridge module which integrates payment acquirers with Website app.
'data': [
'views/payment_acquirer.xml',
],
'auto_install': False,
'auto_install': True,
}
@@ -1,6 +1,7 @@
# coding: utf-8
from odoo import fields, models
from odoo.http import request
class PaymentAcquirer(models.Model):
@@ -11,3 +12,21 @@ class PaymentAcquirer(models.Model):
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
ondelete="restrict",
)
def _get_base_url(self):
""" Override of payment to take multi-website feature into account.
Note: self.ensure_one()
:return: The website base url
:rtype: str
"""
self.ensure_one()
url = ''
if request: # Give priority to url_root to handle multi-website cases
url = request.httprequest.url_root
if not url and self.website_id: # Secondly, use the website set on the acquirer
url = self.website_id._get_http_domain()
if not url: # Fallback to web.base.url
url = super()._get_base_url()
return url
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

@@ -5,7 +5,7 @@
<record id="acquirer_form_website" model="ir.ui.view">
<field name="name">acquirer.form.inherit.website</field>
<field name="model">payment.acquirer</field>
<field name="inherit_id" ref="payment.acquirer_form"/>
<field name="inherit_id" ref="payment.payment_acquirer_form"/>
<field name="arch" type="xml">
<field name='company_id' position='after'>
<field name="website_id" options="{'no_open': True, 'no_create_edit': True}" groups="website.group_multi_website"/>
+93 -124
View File
@@ -2,17 +2,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from datetime import datetime
from werkzeug.exceptions import Forbidden, NotFound
from odoo import fields, http, SUPERUSER_ID, tools, _
from odoo.fields import Command
from odoo.http import request
from odoo.addons.base.models.ir_qweb_fields import nl2br
from odoo.addons.http_routing.models.ir_http import slug
from odoo.addons.payment.controllers.portal import PaymentProcessing
from odoo.addons.payment.controllers import portal as payment_portal
from odoo.addons.payment.controllers.post_processing import PaymentPostProcessing
from odoo.addons.website.controllers.main import QueryURL
from odoo.addons.website.models.ir_http import sitemap_qs2dom
from odoo.exceptions import ValidationError
from odoo.exceptions import AccessError, MissingError, ValidationError
from odoo.addons.portal.controllers.portal import _build_url_w_params
from odoo.addons.website.controllers import main
from odoo.addons.website_form.controllers.main import WebsiteForm
@@ -826,36 +827,49 @@ class WebsiteSale(http.Controller):
# ------------------------------------------------------
def _get_shop_payment_values(self, order, **kwargs):
values = dict(
website_sale_order=order,
errors=[],
partner=order.partner_id.id,
order=order,
payment_action_id=request.env.ref('payment.action_payment_acquirer').id,
return_url= '/shop/payment/validate',
bootstrap_formatting= True
)
logged_in = not request.env.user._is_public()
acquirers_sudo = request.env['payment.acquirer'].sudo()._get_compatible_acquirers(
order.company_id.id,
order.partner_id.id,
currency_id=order.currency_id.id,
sale_order_id=order.id,
website_id=request.website.id,
) # In sudo mode to read the fields of acquirers, order and partner (if not logged in)
tokens = request.env['payment.token'].search(
[('acquirer_id', 'in', acquirers_sudo.ids), ('partner_id', '=', order.partner_id.id)]
) if logged_in else request.env['payment.token']
fees_by_acquirer = {
acq_sudo: acq_sudo._compute_fees(
order.amount_total, order.currency_id, order.partner_id.country_id
) for acq_sudo in acquirers_sudo.filtered('fees_active')
}
# Prevent public partner from saving payment methods but force it for logged in partners
# buying subscription products
show_tokenize_input = logged_in \
and not request.env['payment.acquirer'].sudo()._is_tokenization_required(
sale_order_id=order.id
)
return {
'website_sale_order': order,
'errors': [],
'partner': order.partner_id,
'order': order,
'payment_action_id': request.env.ref('payment.action_payment_acquirer').id,
# Payment form common (checkout and manage) values
'acquirers': acquirers_sudo,
'tokens': tokens,
'fees_by_acquirer': fees_by_acquirer,
'show_tokenize_input': show_tokenize_input,
'amount': order.amount_total,
'currency': order.currency_id,
'partner_id': order.partner_id.id,
'access_token': order._portal_ensure_token(),
'transaction_route': f'/shop/payment/transaction/{order.id}',
'landing_route': '/shop/payment/validate',
}
domain = expression.AND([
['&', ('state', 'in', ['enabled', 'test']), ('company_id', '=', order.company_id.id)],
['|', ('website_id', '=', False), ('website_id', '=', request.website.id)],
['|', ('country_ids', '=', False), ('country_ids', 'in', [order.partner_id.country_id.id])]
])
acquirers = request.env['payment.acquirer'].search(domain)
values['access_token'] = order.access_token
values['acquirers'] = [acq for acq in acquirers if (acq.payment_flow == 'form' and acq.view_template_id) or
(acq.payment_flow == 's2s' and acq.registration_view_template_id)]
values['tokens'] = request.env['payment.token'].search([
('acquirer_id', 'in', acquirers.ids),
('partner_id', 'child_of', order.partner_id.commercial_partner_id.id)])
if order:
values['acq_extra_fees'] = acquirers.get_acquirer_extra_fees(order.amount_total, order.currency_id, order.partner_id.country_id.id)
return values
@http.route(['/shop/payment'], type='http', auth="public", website=True, sitemap=False)
def payment(self, **post):
@http.route('/shop/payment', type='http', auth='public', website=True, sitemap=False)
def shop_payment(self, **post):
""" Payment step. This page proposes several payment means based on available
payment.acquirer. State at this point :
@@ -879,96 +893,8 @@ class WebsiteSale(http.Controller):
return request.render("website_sale.payment", render_values)
@http.route(['/shop/payment/transaction/',
'/shop/payment/transaction/<int:so_id>',
'/shop/payment/transaction/<int:so_id>/<string:access_token>'], type='json', auth="public", website=True)
def payment_transaction(self, acquirer_id, save_token=False, so_id=None, access_token=None, token=None, **kwargs):
""" Json method that creates a payment.transaction, used to create a
transaction when the user clicks on 'pay now' button. After having
created the transaction, the event continues and the user is redirected
to the acquirer website.
:param int acquirer_id: id of a payment.acquirer record. If not set the
user is redirected to the checkout page
"""
# Ensure a payment acquirer is selected
if not acquirer_id:
return False
try:
acquirer_id = int(acquirer_id)
except:
return False
# Retrieve the sale order
if so_id:
env = request.env['sale.order']
domain = [('id', '=', so_id)]
if access_token:
env = env.sudo()
domain.append(('access_token', '=', access_token))
order = env.search(domain, limit=1)
else:
order = request.website.sale_get_order()
# Ensure there is something to proceed
if not order or (order and not order.order_line):
return False
assert order.partner_id.id != request.website.partner_id.id
# Create transaction
vals = {'acquirer_id': acquirer_id,
'return_url': '/shop/payment/validate'}
if save_token:
vals['type'] = 'form_save'
if token:
vals['payment_token_id'] = int(token)
transaction = order._create_payment_transaction(vals)
# store the new transaction into the transaction list and if there's an old one, we remove it
# until the day the ecommerce supports multiple orders at the same time
last_tx_id = request.session.get('__website_sale_last_tx_id')
last_tx = request.env['payment.transaction'].browse(last_tx_id).sudo().exists()
if last_tx:
PaymentProcessing.remove_payment_transaction(last_tx)
PaymentProcessing.add_payment_transaction(transaction)
request.session['__website_sale_last_tx_id'] = transaction.id
return transaction.render_sale_button(order)
@http.route('/shop/payment/token', type='http', auth='public', website=True, sitemap=False)
def payment_token(self, pm_id=None, **kwargs):
""" Method that handles payment using saved tokens
:param int pm_id: id of the payment.token that we want to use to pay.
"""
order = request.website.sale_get_order()
# do not crash if the user has already paid and try to pay again
if not order:
return request.redirect('/shop/?error=no_order')
assert order.partner_id.id != request.website.partner_id.id
try:
pm_id = int(pm_id)
except ValueError:
return request.redirect('/shop/?error=invalid_token_id')
# We retrieve the token the user want to use to pay
if not request.env['payment.token'].sudo().search_count([('id', '=', pm_id)]):
return request.redirect('/shop/?error=token_not_found')
# Create transaction
vals = {'payment_token_id': pm_id, 'return_url': '/shop/payment/validate'}
tx = order._create_payment_transaction(vals)
PaymentProcessing.add_payment_transaction(tx)
return request.redirect('/payment/process')
@http.route('/shop/payment/get_status/<int:sale_order_id>', type='json', auth="public", website=True)
def payment_get_status(self, sale_order_id, **post):
def shop_payment_get_status(self, sale_order_id, **post):
order = request.env['sale.order'].sudo().browse(sale_order_id).exists()
if order.id != request.session.get('sale_last_order_id'):
# either something went wrong or the session is unbound
@@ -983,7 +909,7 @@ class WebsiteSale(http.Controller):
}
@http.route('/shop/payment/validate', type='http', auth="public", website=True, sitemap=False)
def payment_validate(self, transaction_id=None, sale_order_id=None, **post):
def shop_payment_validate(self, transaction_id=None, sale_order_id=None, **post):
""" Method that should be called by the server when receiving an update
for a transaction. State at this point :
@@ -1015,11 +941,11 @@ class WebsiteSale(http.Controller):
if tx and tx.state == 'draft':
return request.redirect('/shop')
PaymentProcessing.remove_payment_transaction(tx)
PaymentPostProcessing.remove_transactions(tx)
return request.redirect('/shop/confirmation')
@http.route(['/shop/confirmation'], type='http', auth="public", website=True, sitemap=False)
def payment_confirmation(self, **post):
def shop_payment_confirmation(self, **post):
""" End of checkout process controller. Confirmation is basically seing
the status of a sale.order. State at this point :
@@ -1221,3 +1147,46 @@ class WebsiteSale(http.Controller):
if visitor_sudo:
request.env['website.track'].sudo().search([('visitor_id', '=', visitor_sudo.id), ('product_id', '=', product_id)]).unlink()
return {}
class PaymentPortal(payment_portal.PaymentPortal):
@http.route(
'/shop/payment/transaction/<int:order_id>', type='json', auth='public', website=True
)
def shop_payment_transaction(self, order_id, access_token, **kwargs):
""" Create a draft transaction and return its processing values.
:param int order_id: The sales order to pay, as a `sale.order` id
:param str access_token: The access token used to authenticate the request
:param dict kwargs: Locally unused data passed to `_create_transaction`
:return: The mandatory values for the processing of the transaction
:rtype: dict
:raise: ValidationError if the invoice id or the access token is invalid
"""
# Check the order id and the access token
try:
self._document_check_access('sale.order', order_id, access_token)
except MissingError as error:
raise error
except AccessError:
raise ValidationError("The access token is invalid.")
kwargs.update({
'reference_prefix': None, # Allow the reference to be computed based on the order
'sale_order_id': order_id, # Include the SO to allow Subscriptions to tokenize the tx
})
kwargs.pop('custom_create_values', None) # Don't allow passing arbitrary create values
tx_sudo = self._create_transaction(
custom_create_values={'sale_order_ids': [Command.set([order_id])]}, **kwargs,
)
# Store the new transaction into the transaction list and if there's an old one, we remove
# it until the day the ecommerce supports multiple orders at the same time.
last_tx_id = request.session.get('__website_sale_last_tx_id')
last_tx = request.env['payment.transaction'].browse(last_tx_id).sudo().exists()
if last_tx:
PaymentPostProcessing.remove_transactions(last_tx)
request.session['__website_sale_last_tx_id'] = tx_sudo.id
return tx_sudo._get_processing_values()
+1
View File
@@ -5,6 +5,7 @@ from . import account_move
from . import crm_team
from . import ir_http
from . import mail_compose_message
from . import payment
from . import product_image
from . import product
from . import res_country
+26
View File
@@ -0,0 +1,26 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class PaymentAcquirer(models.Model):
_inherit = 'payment.acquirer'
@api.model
def _get_compatible_acquirers(self, *args, website_id=None, **kwargs):
""" Override of payment to only return acquirers matching website-specific criteria.
In addition to the base criteria, the website must either not be set or be the same as the
one provided in the kwargs.
:param int website_id: The provided website, as a `website` id
:return: The compatible acquirers
:rtype: recordset of `payment.acquirer`
"""
acquirers = super()._get_compatible_acquirers(*args, website_id=website_id, **kwargs)
if website_id:
acquirers = acquirers.filtered(
lambda a: not a.website_id or a.website_id.id == website_id
)
return acquirers
@@ -1,48 +1,116 @@
odoo.define('website_sale.payment', function (require) {
'use strict';
odoo.define('website_sale.payment', require => {
'use strict';
var publicWidget = require('web.public.widget');
const checkoutForm = require('payment.checkout_form');
const publicWidget = require('web.public.widget');
publicWidget.registry.WebsiteSalePayment = publicWidget.Widget.extend({
selector: '#wrapwrap:has(#checkbox_cgv)',
events: {
'change #checkbox_cgv': '_onCGVCheckboxClick',
},
const websiteSalePaymentMixin = {
/**
* @override
*/
start: function () {
this.$checkbox = this.$('#checkbox_cgv');
this.$payButton = $('button#o_payment_form_pay');
this.$checkbox.trigger('change');
return this._super.apply(this, arguments);
},
/**
* @override
*/
init: function () {
this._onClickTCCheckbox = _.debounce(this._onClickTCCheckbox, 100, true);
this._super(...arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
start: function () {
this.$checkbox = this.$('#checkbox_tc');
this.$submitButton = this.$('button[name="o_payment_submit_button"]');
this._adaptConfirmButton();
return this._super(...arguments);
},
/**
* @private
*/
_adaptPayButton: function () {
var disabledReasons = this.$payButton.data('disabled_reasons') || {};
disabledReasons.cgv = !this.$checkbox.prop('checked');
this.$payButton.data('disabled_reasons', disabledReasons);
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
this.$payButton.prop('disabled', _.contains(disabledReasons, true));
},
/**
* Update the data on the submit button with the status of the Terms and Conditions input.
*
* @private
* @return {undefined}
*/
_adaptConfirmButton: function () {
if (this.$checkbox.length > 0) {
const disabledReasons = this.$submitButton.data('disabled_reasons') || {};
disabledReasons.tc = !this.$checkbox.prop('checked');
this.$submitButton.data('disabled_reasons', disabledReasons);
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
};
/**
* @private
*/
_onCGVCheckboxClick: function () {
this._adaptPayButton();
},
});
checkoutForm.include(Object.assign({}, websiteSalePaymentMixin, {
events: Object.assign({}, checkoutForm.prototype.events, {
'change #checkbox_tc': '_onClickTCCheckbox',
}),
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Verify that the Terms and Condition checkbox is checked.
*
* @override method from payment.payment_form_mixin
* @private
* @return {boolean} Whether the submit button can be enabled
*/
_isButtonReady: function () {
const disabledReasonFound = _.contains(
this.$submitButton.data('disabled_reasons'), true
);
return !disabledReasonFound && this._super();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Enable the submit button if it all conditions are met.
*
* @private
* @return {undefined}
*/
_onClickTCCheckbox: function () {
this._adaptConfirmButton();
if (!this._enableButton()) {
this._disableButton(false);
}
},
}));
publicWidget.registry.WebsiteSalePayment = publicWidget.Widget.extend(
Object.assign({}, websiteSalePaymentMixin, {
selector: 'div[name="o_website_sale_free_cart"]',
events: {
'change #checkbox_tc': '_onClickTCCheckbox',
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Enable the submit button if it all conditions are met.
*
* @private
* @return {undefined}
*/
_onClickTCCheckbox: function () {
this._adaptConfirmButton();
const disabledReasonFound = _.contains(
this.$submitButton.data('disabled_reasons'), true
);
this.$submitButton.prop('disabled', disabledReasonFound);
},
}));
});
@@ -67,15 +67,19 @@ tour.register('shop_buy_product', {
content: "select payment",
trigger: '#payment_method label:contains("Wire Transfer")',
},
{
content: "Accept the Terms & conditions",
trigger: '#checkbox_tc',
},
{
content: "Pay Now",
//Either there are multiple payment methods, and one is checked, either there is only one, and therefore there are no radio inputs
extra_trigger: '#payment_method label:contains("Wire Transfer") input:checked,#payment_method:not(:has("input:radio:visible"))',
trigger: 'button[id="o_payment_form_pay"]:visible:not(:disabled)',
trigger: 'button[name="o_payment_submit_button"]:visible:not(:disabled)',
},
{
content: "finish",
trigger: '.oe_website_sale:contains("Please make a payment to:")',
trigger: '.oe_website_sale:contains("Please use the following transfer details")',
// Leave /shop/confirmation to prevent RPC loop to /shop/payment/get_status.
// The RPC could be handled in python while the tour is killed (and the session), leading to crashes
run: function () {
@@ -130,10 +130,14 @@ odoo.define('website_sale_tour.tour', function (require) {
content: "Select `Wire Transfer` payment method",
trigger: '#payment_method label:contains("Wire Transfer")',
},
{
content: "Accept the Terms & conditions",
trigger: '#checkbox_tc',
},
{
content: "Pay Now",
extra_trigger: '#payment_method label:contains("Wire Transfer") input:checked,#payment_method:not(:has("input:radio:visible"))',
trigger: 'button[id="o_payment_form_pay"]:visible:not(:disabled)',
// extra_trigger: '#payment_method label:contains("Wire Transfer") input:checked,#payment_method:not(:has("input:radio:visible"))',
trigger: 'button[name="o_payment_submit_button"]:visible:not(:disabled)',
},
{
content: "Sign up",
@@ -277,10 +281,14 @@ odoo.define('website_sale_tour.tour', function (require) {
content: "Select `Wire Transfer` payment method",
trigger: '#payment_method label:contains("Wire Transfer")',
},
{
content: "Accept the Terms & conditions",
trigger: '#checkbox_tc',
},
{
content: "Pay Now",
extra_trigger: '#payment_method label:contains("Wire Transfer") input:checked,#payment_method:not(:has("input:radio:visible"))',
trigger: 'button[id="o_payment_form_pay"]:visible:not(:disabled)',
trigger: 'button[name="o_payment_submit_button"]:visible:not(:disabled)',
},
{
content: "Open Dropdown for See quotation",
@@ -381,9 +389,13 @@ odoo.define('website_sale_tour.tour', function (require) {
content: "Select `Wire Transfer` payment method",
trigger: '#payment_method label:contains("Wire Transfer")',
},
{
content: "Accept the Terms & conditions",
trigger: '#checkbox_tc',
},
{
content: "Pay Now",
extra_trigger: '#payment_method label:contains("Wire Transfer") input:checked,#payment_method:not(:has("input:radio:visible"))',
trigger: 'button[id="o_payment_form_pay"]:visible',
trigger: 'button[name="o_payment_submit_button"]:visible',
}]);
});
@@ -237,7 +237,7 @@
action="payment.action_payment_acquirer"
parent="menu_ecommerce_settings" name="Payment Acquirers"/>
<menuitem id="menu_ecommerce_payment_tokens"
action="payment.payment_token_action"
action="payment.action_payment_token"
groups="base.group_no_one"
parent="menu_ecommerce_settings"/>
<menuitem id="menu_ecommerce_payment_icons"

Some files were not shown because too many files have changed in this diff Show More