[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>
@@ -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)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import portal
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
@@ -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"><i>Pending</i>... 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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;
|
||||
});
|
||||
@@ -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> </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> </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> </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/>
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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&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': ['&', ('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 && 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&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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 < 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 > 1 and pms_count==0 and acquirers[0]!=acq) else 'd-none' if pms_count >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 < 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>
|
||||
@@ -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&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': ['&', ('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 && 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&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>
|
||||
@@ -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,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), '&', ('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), '&', ('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
|
||||
|
||||
|
||||
|
||||
@@ -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¤cy_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'¤cy_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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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¤cy_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,
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
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"/>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||