[MERGE] payment, *: refactor online payments API and implementations
PURPOSE Before this commit, the "payment from odoo" flow (direct) required tokenizing a payment method before processing a payment, rather than directly processing the payment, and eventually tokenizing the payment method later. In practice, this often meant that a 'validation' transaction had to be processed and immediately refunded in order to generate a temporary token. Then, a second "payment by token" flow had to be executed to process the intended payment. This implementation was too rigid for most modern payment providers' APIs. Indeed, they mostly expect single processing for a given payment and usually offer the possibility to tokenize the payment method after the payment, rarely before. Because of this, the implementation of many providers was either difficult, limited, or even impossible. Among the various incompatibilities, we find: support for direct payments but not tokenization, lack of support for authentication during tokenization, absence of hosted page dedicated to tokenization, etc. As another cascading consequence of this implementation choice, several providers could not be migrated to newer APIs after that the ones implemented in Odoo were deprecated. The goal here is thus to 'invert' the payment flow implemented in Odoo. As this means re-writing most of the payment module and of its provider implementations, the opportunity is taken to deeply clean and document the code of the impacted modules. SPECIFICATIONS - Lift the limitations listed above by inverting the generic direct payment flow to first create and process a transaction, then tokenize it if requested. - Remove the `payment_flow` field on `payment.acquirer` and let the acquirer choose the appropriate flow according to the use-case. - Filter acquirers offered to the customer based on the use-case. - Add overridable hooks at key steps of the payment flow to allow implementing new providers with minimal effort (both in Python and JavaScript). - Move all module-specific logic and fields to where they belong (e.g. let Subscriptions filter out acquirers based on their support for tokenization, move `qr_code` in `payment_transfer`, ...). - Homogenize the inheritance strategy of acquirer modules. - Standardize the implementation logic in other modules' controllers. - Improve the traceability of payments through stored fields and logs. - Remove the public read right on `payment.acquirer` and enforce the use of access tokens in all modules' flows. - Fix all bugs that were inherent to the old implementation. - Replace the previous testing suite (which was mostly commented-out for years) with a new one that allows testing of both routes and flows with different test configurations (user, transaction context, ...). LINKS Enterprise PR: https://github.com/odoo/enterprise/pull/12528 Upgrade PR: https://github.com/odoo/upgrade/pull/2291 task-2085989 closes odoo/odoo#56187 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com> Co-authored-by: Antoine Vandevenne <anv@odoo.com> Co-authored-by: Victor Feyens <vfe@odoo.com> Co-authored-by: Arnaud Joset <arj@odoo.com> Co-authored-by: Kevin Baptiste <kba@odoo.com> Co-authored-by: Barad Mahendrasinh <mba@odoo.com> Co-authored-by: Prakash Prajapati <ppr@odoo.com> Co-authored-by: Adrien Horgnies <aho@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,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>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
from odoo.addons.payment.models.payment_acquirer import create_missing_journal_for_acquirers
|
||||
from odoo.addons.payment import reset_payment_provider
|
||||
from . import models
|
||||
|
||||
from odoo.addons.payment import reset_payment_acquirer
|
||||
from odoo.addons.payment.models.payment_acquirer import create_missing_journals # post-init hook
|
||||
|
||||
|
||||
def uninstall_hook(cr, registry):
|
||||
reset_payment_provider(cr, registry, 'adyen')
|
||||
reset_payment_acquirer(cr, registry, 'adyen')
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Adyen Payment Acquirer',
|
||||
'version': '2.0',
|
||||
'category': 'Accounting/Payment Acquirers',
|
||||
'sequence': 340,
|
||||
'summary': 'Payment Acquirer: Adyen Implementation',
|
||||
'version': '1.0',
|
||||
'description': """Adyen Payment Acquirer""",
|
||||
'depends': ['payment'],
|
||||
'data': [
|
||||
'views/payment_views.xml',
|
||||
'views/assets.xml',
|
||||
'views/payment_adyen_templates.xml',
|
||||
'data/payment_acquirer_data.xml',
|
||||
'views/payment_views.xml',
|
||||
'data/payment_acquirer_data.xml', # Depends on views/payment_adyen_templates.xml
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'post_init_hook': 'create_missing_journal_for_acquirers',
|
||||
'post_init_hook': 'create_missing_journals',
|
||||
'uninstall_hook': 'uninstall_hook',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
# Endpoints of the API.
|
||||
# See https://docs.adyen.com/api-explorer/#/CheckoutService/v53/overview for Checkout API
|
||||
# See https://docs.adyen.com/api-explorer/#/Recurring/v49/overview for Recurring API
|
||||
API_ENDPOINT_VERSIONS = {
|
||||
'/disable': 49, # Recurring API
|
||||
'/originKeys': 53, # Checkout API
|
||||
'/payments': 53, # Checkout API
|
||||
'/payments/details': 53, # Checkout API
|
||||
'/paymentMethods': 53, # Checkout API
|
||||
}
|
||||
|
||||
# Adyen-specific mapping of currency codes in ISO 4217 format to the number of decimals.
|
||||
# Only currencies for which Adyen does not follow the ISO 4217 norm are listed here.
|
||||
# See https://docs.adyen.com/development-resources/currency-codes
|
||||
CURRENCY_DECIMALS = {
|
||||
'CLP': 2,
|
||||
'CVE': 0,
|
||||
'IDR': 0,
|
||||
'ISK': 2,
|
||||
}
|
||||
|
||||
# Mapping of transaction states to Adyen result codes.
|
||||
# See https://docs.adyen.com/checkout/payment-result-codes for the exhaustive list of result codes.
|
||||
RESULT_CODES_MAPPING = {
|
||||
'pending': (
|
||||
'ChallengeShopper', 'IdentifyShopper', 'Pending', 'PresentToShopper', 'Received',
|
||||
'RedirectShopper'
|
||||
),
|
||||
'done': ('Authorised',),
|
||||
'cancel': ('Cancelled',),
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
|
||||
@@ -1,37 +1,359 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import pprint
|
||||
import werkzeug
|
||||
|
||||
from odoo import http
|
||||
import werkzeug
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
from odoo.tools.pycompat import to_text
|
||||
|
||||
from odoo.addons.payment import utils as payment_utils
|
||||
from odoo.addons.payment_adyen.const import CURRENCY_DECIMALS
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdyenController(http.Controller):
|
||||
_return_url = '/payment/adyen/return/'
|
||||
|
||||
@http.route([
|
||||
'/payment/adyen/return',
|
||||
], type='http', auth='public', csrf=False)
|
||||
def adyen_return(self, **post):
|
||||
_logger.info('Beginning Adyen form_feedback with post data %s', pprint.pformat(post)) # debug
|
||||
if post.get('authResult') not in ['CANCELLED']:
|
||||
request.env['payment.transaction'].sudo().form_feedback(post, 'adyen')
|
||||
return werkzeug.utils.redirect('/payment/process')
|
||||
@http.route('/payment/adyen/acquirer_state', type='json', auth='public')
|
||||
def adyen_acquirer_state(self, acquirer_id):
|
||||
""" Return the state of the acquirer.
|
||||
|
||||
@http.route([
|
||||
'/payment/adyen/notification',
|
||||
], type='http', auth='public', methods=['POST'], csrf=False)
|
||||
def adyen_notification(self, **post):
|
||||
tx = post.get('merchantReference') and request.env['payment.transaction'].sudo().search([('reference', 'in', [post.get('merchantReference')])], limit=1)
|
||||
if post.get('eventCode') in ['AUTHORISATION'] and tx:
|
||||
states = (post.get('merchantReference'), post.get('success'), tx.state)
|
||||
if (post.get('success') == 'true' and tx.state == 'done') or (post.get('success') == 'false' and tx.state in ['cancel', 'error']):
|
||||
_logger.info('Notification from Adyen for the reference %s: received %s, state is %s', states)
|
||||
:param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id
|
||||
:return: The state of the acquirer: 'enabled' or 'test'
|
||||
:rtype: str
|
||||
"""
|
||||
acquirer_sudo = request.env['payment.acquirer'].sudo().browse(acquirer_id).exists()
|
||||
return acquirer_sudo.state
|
||||
|
||||
@http.route('/payment/adyen/payment_methods', type='json', auth='public')
|
||||
def adyen_payment_methods(self, acquirer_id, amount=None, currency_id=None, partner_id=None):
|
||||
""" Query the available payment methods based on the transaction context.
|
||||
|
||||
:param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id
|
||||
:param float amount: The transaction amount
|
||||
:param int currency_id: The transaction currency, as a `res.currency` id
|
||||
:param int partner_id: The partner making the transaction, as a `res.partner` id
|
||||
:return: The JSON-formatted content of the response
|
||||
:rtype: dict
|
||||
"""
|
||||
acquirer_sudo = request.env['payment.acquirer'].sudo().browse(acquirer_id)
|
||||
currency = request.env['res.currency'].browse(currency_id)
|
||||
currency_code = currency_id and currency.name
|
||||
converted_amount = amount and currency_code and payment_utils.to_minor_currency_units(
|
||||
amount, currency, CURRENCY_DECIMALS.get(currency_code)
|
||||
)
|
||||
partner_sudo = partner_id and request.env['res.partner'].sudo().browse(partner_id).exists()
|
||||
# The lang is taken from the context rather than from the partner because it is not required
|
||||
# to be logged in to make a payment, and because the lang is not always set on the partner.
|
||||
# Adyen only supports a limited set of languages but, instead of looking for the closest
|
||||
# match in https://docs.adyen.com/checkout/components-web/localization-components, we simply
|
||||
# provide the lang string as is (after adapting the format) and let Adyen find the best fit.
|
||||
lang_code = (request.context.get('lang') or 'en-US').replace('_', '-')
|
||||
shopper_reference = partner_sudo and f'ODOO_PARTNER_{partner_sudo.id}'
|
||||
data = {
|
||||
'merchantAccount': acquirer_sudo.adyen_merchant_account,
|
||||
'amount': converted_amount,
|
||||
'countryCode': partner_sudo.country_id.code or None, # ISO 3166-1 alpha-2 (e.g.: 'BE')
|
||||
'shopperLocale': lang_code, # IETF language tag (e.g.: 'fr-BE')
|
||||
'shopperReference': shopper_reference,
|
||||
'channel': 'Web',
|
||||
}
|
||||
response_content = acquirer_sudo._adyen_make_request(
|
||||
url_field_name='adyen_checkout_api_url',
|
||||
endpoint='/paymentMethods',
|
||||
payload=data,
|
||||
method='POST'
|
||||
)
|
||||
_logger.info("paymentMethods request response:\n%s", pprint.pformat(response_content))
|
||||
return response_content
|
||||
|
||||
@http.route('/payment/adyen/origin_key', type='json', auth='public')
|
||||
def adyen_origin_key(self, acquirer_id):
|
||||
""" Request an origin key based on the current domain.
|
||||
|
||||
:param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id
|
||||
:return: The JSON-formatted content of the response
|
||||
:rtype: dict
|
||||
"""
|
||||
acquirer_sudo = request.env['payment.acquirer'].browse(acquirer_id).sudo()
|
||||
domain = acquirer_sudo._get_base_url()
|
||||
data = {
|
||||
'originDomains': [domain],
|
||||
}
|
||||
response_content = acquirer_sudo._adyen_make_request(
|
||||
url_field_name='adyen_checkout_api_url',
|
||||
endpoint='/originKeys',
|
||||
payload=data,
|
||||
method='POST'
|
||||
)
|
||||
_logger.info("originKeys request response:\n%s", pprint.pformat(response_content))
|
||||
return response_content
|
||||
|
||||
@http.route('/payment/adyen/payments', type='json', auth='public')
|
||||
def adyen_payments(
|
||||
self, acquirer_id, reference, converted_amount, currency_id, partner_id, payment_method,
|
||||
access_token, browser_info=None
|
||||
):
|
||||
""" Make a payment request and handle the response.
|
||||
|
||||
:param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id
|
||||
:param str reference: The reference of the transaction
|
||||
:param int converted_amount: The amount of the transaction in minor units of the currency
|
||||
:param int currency_id: The currency of the transaction, as a `res.currency` id
|
||||
:param int partner_id: The partner making the transaction, as a `res.partner` id
|
||||
:param dict payment_method: The details of the payment method used for the transaction
|
||||
:param str access_token: The access token used to verify the provided values
|
||||
:param dict browser_info: The browser info to pass to Adyen
|
||||
:return: The JSON-formatted content of the response
|
||||
:rtype: dict
|
||||
"""
|
||||
# Check that the transaction details have not been altered. This allows preventing users
|
||||
# from validating transactions by paying less than agreed upon.
|
||||
if not payment_utils.check_access_token(
|
||||
access_token, reference, converted_amount, partner_id
|
||||
):
|
||||
raise ValidationError("Adyen: " + _("Received tampered payment request data."))
|
||||
|
||||
# Make the payment request to Adyen
|
||||
acquirer_sudo = request.env['payment.acquirer'].sudo().browse(acquirer_id).exists()
|
||||
tx_sudo = request.env['payment.transaction'].sudo().search([('reference', '=', reference)])
|
||||
data = {
|
||||
'merchantAccount': acquirer_sudo.adyen_merchant_account,
|
||||
'amount': {
|
||||
'value': converted_amount,
|
||||
'currency': request.env['res.currency'].browse(currency_id).name, # ISO 4217
|
||||
},
|
||||
'reference': reference,
|
||||
'paymentMethod': payment_method,
|
||||
'shopperReference': acquirer_sudo._adyen_compute_shopper_reference(partner_id),
|
||||
'recurringProcessingModel': 'CardOnFile', # Most susceptible to trigger a 3DS check
|
||||
'shopperIP': payment_utils.get_customer_ip_address(),
|
||||
'shopperInteraction': 'Ecommerce',
|
||||
'storePaymentMethod': tx_sudo.tokenize, # True by default on Adyen side
|
||||
'additionalData': {
|
||||
'allow3DS2': True
|
||||
},
|
||||
'channel': 'web', # Required to support 3DS
|
||||
'origin': acquirer_sudo._get_base_url(), # Required to support 3DS
|
||||
'browserInfo': browser_info, # Required to support 3DS
|
||||
'returnUrl': urls.url_join(
|
||||
acquirer_sudo._get_base_url(),
|
||||
# Include the reference in the return url to be able to match it after redirection.
|
||||
# The key 'merchantReference' is chosen on purpose to be the same than that returned
|
||||
# by the /payments endpoint of Adyen.
|
||||
f'/payment/adyen/return?merchantReference={reference}'
|
||||
),
|
||||
}
|
||||
response_content = acquirer_sudo._adyen_make_request(
|
||||
url_field_name='adyen_checkout_api_url',
|
||||
endpoint='/payments',
|
||||
payload=data,
|
||||
method='POST'
|
||||
)
|
||||
|
||||
# Handle the payment request response
|
||||
_logger.info("payment request response:\n%s", pprint.pformat(response_content))
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data(
|
||||
'adyen', dict(response_content, merchantReference=reference), # Match the transaction
|
||||
)
|
||||
if 'action' in response_content and response_content['action']['type'] == 'redirect':
|
||||
tx_sudo.adyen_payment_data = response_content['paymentData']
|
||||
|
||||
return response_content
|
||||
|
||||
@http.route('/payment/adyen/payment_details', type='json', auth='public')
|
||||
def adyen_payment_details(self, acquirer_id, reference, details, payment_data):
|
||||
""" Query the status of a transaction that required additional actions and process it.
|
||||
|
||||
The additional actions can have been performed both from the inline form or during a
|
||||
redirection.
|
||||
|
||||
:param int acquirer_id: The acquirer handling the transaction, as a `payment.acquirer` id
|
||||
:param str reference: The reference of the transaction
|
||||
:param dict details: The specification of the additional actions
|
||||
:param str payment_data: The encrypted payment data of the transaction
|
||||
:return: The JSON-formatted content of the response
|
||||
:rtype: dict
|
||||
"""
|
||||
# Make the payment details request to Adyen
|
||||
acquirer_sudo = request.env['payment.acquirer'].browse(acquirer_id).sudo()
|
||||
data = {
|
||||
'details': details,
|
||||
'paymentData': payment_data,
|
||||
}
|
||||
response_content = acquirer_sudo._adyen_make_request(
|
||||
url_field_name='adyen_checkout_api_url',
|
||||
endpoint='/payments/details',
|
||||
payload=data,
|
||||
method='POST'
|
||||
)
|
||||
|
||||
# Handle the payment details request response
|
||||
_logger.info("payment details request response:\n%s", pprint.pformat(response_content))
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data(
|
||||
'adyen', dict(response_content, merchantReference=reference), # Match the transaction
|
||||
)
|
||||
|
||||
return response_content
|
||||
|
||||
@http.route('/payment/adyen/return', type='http', auth='public', csrf=False)
|
||||
def adyen_return_from_redirect(self, **data):
|
||||
""" Process the data returned by Adyen after redirection.
|
||||
|
||||
:param dict data: Feedback data. May include custom params sent to Adyen in the request to
|
||||
allow matching the transaction when redirected here.
|
||||
"""
|
||||
# Retrieve the transaction based on the reference included in the return url
|
||||
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data(
|
||||
'adyen', data
|
||||
)
|
||||
|
||||
# Overwrite the operation to force the flow to 'redirect'. This is necessary because even
|
||||
# thought Adyen is implemented as a direct payment provider, it will redirect the user out
|
||||
# of Odoo in some cases. For instance, when a 3DS1 authentication is required, or for
|
||||
# special payment methods that are not handled by the drop-in (e.g. Sofort).
|
||||
tx_sudo.operation = 'online_redirect'
|
||||
|
||||
# Query and process the result of the additional actions that have been performed
|
||||
_logger.info("handling redirection from Adyen with data:\n%s", pprint.pformat(data))
|
||||
self.adyen_payment_details(
|
||||
tx_sudo.acquirer_id.id,
|
||||
data['merchantReference'],
|
||||
{detail: value for detail, value in data.items() if detail != 'merchantReference'},
|
||||
tx_sudo.adyen_payment_data,
|
||||
)
|
||||
|
||||
# Redirect the user to the status page
|
||||
return werkzeug.utils.redirect('/payment/status')
|
||||
|
||||
@http.route('/payment/adyen/notification', type='json', auth='public')
|
||||
def adyen_notification(self):
|
||||
""" Process the data sent by Adyen to the webhook based on the event code.
|
||||
|
||||
See https://docs.adyen.com/development-resources/webhooks/understand-notifications for the
|
||||
exhaustive list of event codes.
|
||||
|
||||
:return: The '[accepted]' string to acknowledge the notification
|
||||
:rtype: str
|
||||
"""
|
||||
data = json.loads(request.httprequest.data)
|
||||
for notification_item in data['notificationItems']:
|
||||
notification_data = notification_item['NotificationRequestItem']
|
||||
|
||||
# Check the source and integrity of the notification
|
||||
received_signature = notification_data.get('additionalData', {}).get('hmacSignature')
|
||||
acquirer_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data(
|
||||
'adyen', notification_data
|
||||
).acquirer_id # Find the acquirer based on the transaction
|
||||
if not self._verify_notification_signature(
|
||||
received_signature, notification_data, acquirer_sudo.adyen_hmac_key
|
||||
):
|
||||
continue
|
||||
|
||||
# Check whether the event of the notification succeeded
|
||||
_logger.info("notification received:\n%s", pprint.pformat(notification_data))
|
||||
if notification_data['success'] != 'true':
|
||||
continue # Don't handle failed events
|
||||
|
||||
# Reshape the notification data for parsing
|
||||
event_code = notification_data['eventCode']
|
||||
if event_code == 'AUTHORISATION':
|
||||
notification_data['resultCode'] = 'Authorised'
|
||||
elif event_code == 'CANCELLATION':
|
||||
notification_data['resultCode'] = 'Cancelled'
|
||||
else:
|
||||
_logger.warning('Notification from Adyen for the reference %s: received %s but state is %s', states)
|
||||
return '[accepted]'
|
||||
continue # Don't handle unsupported event codes
|
||||
|
||||
# Handle the notification data as a regular feedback
|
||||
try:
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data(
|
||||
'adyen', notification_data
|
||||
)
|
||||
except ValidationError: # Acknowledge the notification to avoid getting spammed
|
||||
_logger.exception("unable to handle the notification data; skipping to acknowledge")
|
||||
|
||||
return '[accepted]' # Acknowledge the notification
|
||||
|
||||
def _verify_notification_signature(self, received_signature, payload, hmac_key):
|
||||
""" Check that the signature computed from the payload matches the received one.
|
||||
|
||||
See https://docs.adyen.com/development-resources/webhooks/verify-hmac-signatures
|
||||
|
||||
:param str received_signature: The signature sent with the notification
|
||||
:param dict payload: The notification payload
|
||||
:param str hmac_key: The HMAC key of the acquirer handling the transaction
|
||||
:return: Whether the signatures match
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
def _flatten_dict(_value, _path_base='', _separator='.'):
|
||||
""" Recursively generate a flat representation of a dict.
|
||||
|
||||
:param Object _value: The value to flatten. A dict or an already flat value
|
||||
:param str _path_base: They base path for keys of _value, including preceding separators
|
||||
:param str _separator: The string to use as a separator in the key path
|
||||
"""
|
||||
if isinstance(_value, dict): # The inner value is a dict, flatten it
|
||||
_path_base = _path_base if not _path_base else _path_base + _separator
|
||||
for _key in _value:
|
||||
yield from _flatten_dict(_value[_key], _path_base + str(_key))
|
||||
else: # The inner value cannot be flattened, yield it
|
||||
yield _path_base, _value
|
||||
|
||||
def _to_escaped_string(_value):
|
||||
""" Escape payload values that are using illegal symbols and cast them to string.
|
||||
|
||||
String values containing `\\` or `:` are prefixed with `\\`.
|
||||
Empty values (`None`) are replaced by an empty string.
|
||||
|
||||
:param Object _value: The value to escape
|
||||
:return: The escaped value
|
||||
:rtype: string
|
||||
"""
|
||||
if isinstance(_value, str):
|
||||
return _value.replace('\\', '\\\\').replace(':', '\\:')
|
||||
elif _value is None:
|
||||
return ''
|
||||
else:
|
||||
return str(_value)
|
||||
|
||||
if not received_signature:
|
||||
_logger.warning("ignored notification with missing signature")
|
||||
return False
|
||||
|
||||
# Compute the signature from the payload
|
||||
signature_keys = [
|
||||
'pspReference', 'originalReference', 'merchantAccountCode', 'merchantReference',
|
||||
'amount.value', 'amount.currency', 'eventCode', 'success'
|
||||
]
|
||||
# Flatten the payload to allow accessing inner dicts naively
|
||||
flattened_payload = {k: v for k, v in _flatten_dict(payload)}
|
||||
# Build the list of signature values as per the list of required signature keys
|
||||
signature_values = [flattened_payload.get(key) for key in signature_keys]
|
||||
# Escape values using forbidden symbols
|
||||
escaped_values = [_to_escaped_string(value) for value in signature_values]
|
||||
# Concatenate values together with ':' as delimiter
|
||||
signing_string = ':'.join(escaped_values)
|
||||
# Convert the HMAC key to the binary representation
|
||||
binary_hmac_key = binascii.a2b_hex(hmac_key.encode('ascii'))
|
||||
# Calculate the HMAC with the binary representation of the signing string with SHA-256
|
||||
binary_hmac = hmac.new(binary_hmac_key, signing_string.encode('utf-8'), hashlib.sha256)
|
||||
# Calculate the signature by encoding the result with Base64
|
||||
expected_signature = base64.b64encode(binary_hmac.digest())
|
||||
|
||||
# Compare signatures
|
||||
if received_signature != to_text(expected_signature):
|
||||
_logger.warning("ignored event with invalid signature")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="payment.payment_acquirer_adyen" model="payment.acquirer">
|
||||
<field name="name">Adyen</field>
|
||||
<field name="image_128" type="base64" file="payment_adyen/static/src/img/adyen_icon.png"/>
|
||||
<field name="provider">adyen</field>
|
||||
<field name="company_id" ref="base.main_company"/>
|
||||
<field name="view_template_id" ref="adyen_form"/>
|
||||
</record>
|
||||
<record id="payment.payment_acquirer_adyen" model="payment.acquirer">
|
||||
<field name="provider">adyen</field>
|
||||
<field name="inline_form_view_id" ref="inline_form"/>
|
||||
<field name="support_authorization">False</field>
|
||||
<field name="support_fees_computation">False</field>
|
||||
<field name="support_tokenization">True</field>
|
||||
<field name="allow_tokenization">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import payment
|
||||
from . import payment_acquirer
|
||||
from . import payment_token
|
||||
from . import payment_transaction
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
import base64
|
||||
import json
|
||||
import binascii
|
||||
from collections import OrderedDict
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from itertools import chain
|
||||
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
from odoo.addons.payment.models.payment_acquirer import ValidationError
|
||||
from odoo.addons.payment_adyen.controllers.main import AdyenController
|
||||
from odoo.tools.pycompat import to_text
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# https://docs.adyen.com/developers/development-resources/currency-codes
|
||||
CURRENCY_CODE_MAPS = {
|
||||
"BHD": 3,
|
||||
"CVE": 0,
|
||||
"DJF": 0,
|
||||
"GNF": 0,
|
||||
"IDR": 0,
|
||||
"JOD": 3,
|
||||
"JPY": 0,
|
||||
"KMF": 0,
|
||||
"KRW": 0,
|
||||
"KWD": 3,
|
||||
"LYD": 3,
|
||||
"OMR": 3,
|
||||
"PYG": 0,
|
||||
"RWF": 0,
|
||||
"TND": 3,
|
||||
"UGX": 0,
|
||||
"VND": 0,
|
||||
"VUV": 0,
|
||||
"XAF": 0,
|
||||
"XOF": 0,
|
||||
"XPF": 0,
|
||||
}
|
||||
|
||||
|
||||
class AcquirerAdyen(models.Model):
|
||||
_inherit = 'payment.acquirer'
|
||||
|
||||
provider = fields.Selection(selection_add=[
|
||||
('adyen', 'Adyen')
|
||||
], ondelete={'adyen': 'set default'})
|
||||
adyen_merchant_account = fields.Char('Merchant Account', required_if_provider='adyen', groups='base.group_user')
|
||||
adyen_skin_code = fields.Char('Skin Code', required_if_provider='adyen', groups='base.group_user')
|
||||
adyen_skin_hmac_key = fields.Char('Skin HMAC Key', required_if_provider='adyen', groups='base.group_user')
|
||||
|
||||
@api.model
|
||||
def _adyen_convert_amount(self, amount, currency):
|
||||
"""
|
||||
Adyen requires the amount to be multiplied by 10^k,
|
||||
where k depends on the currency code.
|
||||
"""
|
||||
k = CURRENCY_CODE_MAPS.get(currency.name, 2)
|
||||
paymentAmount = int(tools.float_round(amount, k) * (10**k))
|
||||
return paymentAmount
|
||||
|
||||
@api.model
|
||||
def _get_adyen_urls(self, environment):
|
||||
""" Adyen URLs: yhpp: hosted payment page: pay.shtml for single, select.shtml for multiple """
|
||||
return {
|
||||
'adyen_form_url': 'https://%s.adyen.com/hpp/pay.shtml' % ('live' if environment == 'prod' else environment),
|
||||
}
|
||||
|
||||
def _adyen_generate_merchant_sig_sha256(self, inout, values):
|
||||
""" Generate the shasign for incoming or outgoing communications., when using the SHA-256
|
||||
signature.
|
||||
|
||||
:param string inout: 'in' (odoo contacting adyen) or 'out' (adyen
|
||||
contacting odoo). In this last case only some
|
||||
fields should be contained (see e-Commerce basic)
|
||||
:param dict values: transaction values
|
||||
:return string: shasign
|
||||
"""
|
||||
def escapeVal(val):
|
||||
return val.replace('\\', '\\\\').replace(':', '\\:')
|
||||
|
||||
def signParams(parms):
|
||||
signing_string = ':'.join(
|
||||
escapeVal(v)
|
||||
for v in chain(parms.keys(), parms.values())
|
||||
)
|
||||
hm = hmac.new(hmac_key, signing_string.encode('utf-8'), hashlib.sha256)
|
||||
return base64.b64encode(hm.digest())
|
||||
|
||||
assert inout in ('in', 'out')
|
||||
assert self.provider == 'adyen'
|
||||
|
||||
if inout == 'in':
|
||||
# All the fields sent to Adyen must be included in the signature. ALL the fucking
|
||||
# fields, despite what is claimed in the documentation. For example, in
|
||||
# https://docs.adyen.com/developers/hpp-manual, it is stated: "The resURL parameter does
|
||||
# not need to be included in the signature." It's a trap, it must be included as well!
|
||||
keys = [
|
||||
'merchantReference', 'paymentAmount', 'currencyCode', 'shipBeforeDate', 'skinCode',
|
||||
'merchantAccount', 'sessionValidity', 'merchantReturnData', 'shopperEmail',
|
||||
'shopperReference', 'allowedMethods', 'blockedMethods', 'offset',
|
||||
'shopperStatement', 'recurringContract', 'billingAddressType',
|
||||
'deliveryAddressType', 'brandCode', 'countryCode', 'shopperLocale', 'orderData',
|
||||
'offerEmail', 'resURL',
|
||||
]
|
||||
else:
|
||||
keys = [
|
||||
'authResult', 'merchantReference', 'merchantReturnData', 'paymentMethod',
|
||||
'pspReference', 'shopperLocale', 'skinCode',
|
||||
]
|
||||
|
||||
hmac_key = binascii.a2b_hex(self.adyen_skin_hmac_key.encode('ascii'))
|
||||
raw_values = {k: values.get(k, '') for k in keys if k in values}
|
||||
raw_values_ordered = OrderedDict(sorted(raw_values.items(), key=lambda t: t[0]))
|
||||
|
||||
return signParams(raw_values_ordered)
|
||||
|
||||
def _adyen_generate_merchant_sig(self, inout, values):
|
||||
""" Generate the shasign for incoming or outgoing communications, when using the SHA-1
|
||||
signature (deprecated by Adyen).
|
||||
|
||||
:param string inout: 'in' (odoo contacting adyen) or 'out' (adyen
|
||||
contacting odoo). In this last case only some
|
||||
fields should be contained (see e-Commerce basic)
|
||||
:param dict values: transaction values
|
||||
|
||||
:return string: shasign
|
||||
"""
|
||||
assert inout in ('in', 'out')
|
||||
assert self.provider == 'adyen'
|
||||
|
||||
if inout == 'in':
|
||||
keys = "paymentAmount currencyCode shipBeforeDate merchantReference skinCode merchantAccount sessionValidity shopperEmail shopperReference recurringContract allowedMethods blockedMethods shopperStatement merchantReturnData billingAddressType deliveryAddressType offset".split()
|
||||
else:
|
||||
keys = "authResult pspReference merchantReference skinCode merchantReturnData".split()
|
||||
|
||||
def get_value(key):
|
||||
if values.get(key):
|
||||
return values[key]
|
||||
return ''
|
||||
|
||||
sign = ''.join('%s' % get_value(k) for k in keys).encode('ascii')
|
||||
key = self.adyen_skin_hmac_key.encode('ascii')
|
||||
return base64.b64encode(hmac.new(key, sign, hashlib.sha1).digest())
|
||||
|
||||
def adyen_form_generate_values(self, values):
|
||||
base_url = self.get_base_url()
|
||||
# tmp
|
||||
import datetime
|
||||
from dateutil import relativedelta
|
||||
|
||||
paymentAmount = self._adyen_convert_amount(values['amount'], values['currency'])
|
||||
if self.provider == 'adyen' and len(self.adyen_skin_hmac_key) == 64:
|
||||
tmp_date = datetime.datetime.today() + relativedelta.relativedelta(days=1)
|
||||
|
||||
values.update({
|
||||
'merchantReference': values['reference'],
|
||||
'paymentAmount': '%d' % paymentAmount,
|
||||
'currencyCode': values['currency'] and values['currency'].name or '',
|
||||
'shipBeforeDate': tmp_date.strftime('%Y-%m-%d'),
|
||||
'skinCode': self.adyen_skin_code,
|
||||
'merchantAccount': self.adyen_merchant_account,
|
||||
'shopperLocale': values.get('partner_lang', ''),
|
||||
'sessionValidity': tmp_date.isoformat('T')[:19] + "Z",
|
||||
'resURL': urls.url_join(base_url, AdyenController._return_url),
|
||||
'merchantReturnData': json.dumps({'return_url': '%s' % values.pop('return_url')}) if values.get('return_url', '') else False,
|
||||
'shopperEmail': values.get('partner_email') or values.get('billing_partner_email') or '',
|
||||
})
|
||||
values['merchantSig'] = self._adyen_generate_merchant_sig_sha256('in', values)
|
||||
|
||||
else:
|
||||
tmp_date = datetime.date.today() + relativedelta.relativedelta(days=1)
|
||||
|
||||
values.update({
|
||||
'merchantReference': values['reference'],
|
||||
'paymentAmount': '%d' % paymentAmount,
|
||||
'currencyCode': values['currency'] and values['currency'].name or '',
|
||||
'shipBeforeDate': tmp_date,
|
||||
'skinCode': self.adyen_skin_code,
|
||||
'merchantAccount': self.adyen_merchant_account,
|
||||
'shopperLocale': values.get('partner_lang'),
|
||||
'sessionValidity': tmp_date,
|
||||
'resURL': urls.url_join(base_url, AdyenController._return_url),
|
||||
'merchantReturnData': json.dumps({'return_url': '%s' % values.pop('return_url')}) if values.get('return_url') else False,
|
||||
})
|
||||
values['merchantSig'] = self._adyen_generate_merchant_sig('in', values)
|
||||
|
||||
return values
|
||||
|
||||
def adyen_get_form_action_url(self):
|
||||
self.ensure_one()
|
||||
environment = 'prod' if self.state == 'enabled' else 'test'
|
||||
return self._get_adyen_urls(environment)['adyen_form_url']
|
||||
|
||||
|
||||
class TxAdyen(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
# --------------------------------------------------
|
||||
# FORM RELATED METHODS
|
||||
# --------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _adyen_form_get_tx_from_data(self, data):
|
||||
reference, pspReference = data.get('merchantReference'), data.get('pspReference')
|
||||
if not reference or not pspReference:
|
||||
error_msg = _('Adyen: received data with missing reference (%s) or missing pspReference (%s)') % (reference, pspReference)
|
||||
_logger.info(error_msg)
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
# find tx -> @TDENOTE use pspReference ?
|
||||
tx = self.env['payment.transaction'].search([('reference', '=', reference)])
|
||||
if not tx or len(tx) > 1:
|
||||
error_msg = _('Adyen: received data for reference %s') % (reference)
|
||||
if not tx:
|
||||
error_msg += _('; no order found')
|
||||
else:
|
||||
error_msg += _('; multiple order found')
|
||||
_logger.info(error_msg)
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
# verify shasign
|
||||
if len(tx.acquirer_id.adyen_skin_hmac_key) == 64:
|
||||
shasign_check = tx.acquirer_id._adyen_generate_merchant_sig_sha256('out', data)
|
||||
else:
|
||||
shasign_check = tx.acquirer_id._adyen_generate_merchant_sig('out', data)
|
||||
if to_text(shasign_check) != to_text(data.get('merchantSig')):
|
||||
error_msg = _('Adyen: invalid merchantSig, received %s, computed %s') % (data.get('merchantSig'), shasign_check)
|
||||
_logger.warning(error_msg)
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
return tx
|
||||
|
||||
def _adyen_form_get_invalid_parameters(self, data):
|
||||
invalid_parameters = []
|
||||
|
||||
# reference at acquirer: pspReference
|
||||
if self.acquirer_reference and data.get('pspReference') != self.acquirer_reference:
|
||||
invalid_parameters.append(('pspReference', data.get('pspReference'), self.acquirer_reference))
|
||||
# seller
|
||||
if data.get('skinCode') != self.acquirer_id.adyen_skin_code:
|
||||
invalid_parameters.append(('skinCode', data.get('skinCode'), self.acquirer_id.adyen_skin_code))
|
||||
# result
|
||||
if not data.get('authResult'):
|
||||
invalid_parameters.append(('authResult', data.get('authResult'), 'something'))
|
||||
|
||||
return invalid_parameters
|
||||
|
||||
def _adyen_form_validate(self, data):
|
||||
status = data.get('authResult', 'PENDING')
|
||||
if status == 'AUTHORISED':
|
||||
self.write({'acquirer_reference': data.get('pspReference')})
|
||||
self._set_transaction_done()
|
||||
return True
|
||||
elif status == 'PENDING':
|
||||
self.write({'acquirer_reference': data.get('pspReference')})
|
||||
self._set_transaction_pending()
|
||||
return True
|
||||
else:
|
||||
error = _('Adyen: feedback error')
|
||||
_logger.info(error)
|
||||
self.write({'state_message': error})
|
||||
self._set_transaction_cancel()
|
||||
return False
|
||||
@@ -0,0 +1,119 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from odoo.addons.payment_adyen.const import API_ENDPOINT_VERSIONS
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentAcquirer(models.Model):
|
||||
_inherit = 'payment.acquirer'
|
||||
|
||||
provider = fields.Selection(
|
||||
selection_add=[('adyen', "Adyen")], ondelete={'adyen': 'set default'})
|
||||
adyen_merchant_account = fields.Char(
|
||||
string="Merchant Account",
|
||||
help="The code of the merchant account to use with this acquirer",
|
||||
required_if_provider='adyen', groups='base.group_system')
|
||||
adyen_api_key = fields.Char(
|
||||
string="API Key", help="The API key of the user account", required_if_provider='adyen',
|
||||
groups='base.group_system')
|
||||
adyen_hmac_key = fields.Char(
|
||||
string="HMAC Key", help="The HMAC key of the webhook", required_if_provider='adyen',
|
||||
groups='base.group_system')
|
||||
adyen_checkout_api_url = fields.Char(
|
||||
string="Checkout API URL", help="The base URL for the Checkout API endpoints",
|
||||
required_if_provider='adyen')
|
||||
adyen_recurring_api_url = fields.Char(
|
||||
string="Recurring API URL", help="The base URL for the Recurring API endpoints",
|
||||
required_if_provider='adyen')
|
||||
|
||||
#=== CRUD METHODS ===#
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, values_list):
|
||||
for values in values_list:
|
||||
self._adyen_trim_api_urls(values)
|
||||
return super().create(values_list)
|
||||
|
||||
def write(self, values):
|
||||
self._adyen_trim_api_urls(values)
|
||||
return super().write(values)
|
||||
|
||||
@api.model
|
||||
def _adyen_trim_api_urls(self, values):
|
||||
""" Remove the version and the endpoint from the url of Adyen API fields.
|
||||
|
||||
:param dict values: The create or write values
|
||||
:return: None
|
||||
"""
|
||||
for field_name in ('adyen_checkout_api_url', 'adyen_recurring_api_url'):
|
||||
if values.get(field_name): # Test the value in case we're duplicating an acquirer
|
||||
values[field_name] = re.sub(r'[vV]\d+(/.*)?', '', values[field_name])
|
||||
|
||||
#=== BUSINESS METHODS ===#
|
||||
|
||||
def _adyen_make_request(self, url_field_name, endpoint, payload=None, method='POST'):
|
||||
""" Make a request to Adyen API at the specified endpoint.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:param str url_field_name: The name of the field holding the base URL for the request
|
||||
:param str endpoint: The endpoint to be reached by the request
|
||||
:param dict payload: The payload of the request
|
||||
:param str method: The HTTP method of the request
|
||||
:return The JSON-formatted content of the response
|
||||
:rtype: dict
|
||||
:raise: ValidationError if an HTTP error occurs
|
||||
"""
|
||||
|
||||
def _build_url(_base_url, _version, _endpoint):
|
||||
""" Build an API URL by appending the version and endpoint to a base URL.
|
||||
|
||||
The final URL follows this pattern : `<_base>/V<_version>/<_endpoint>`.
|
||||
|
||||
:param str _base_url: The base of the url prefixed with `https://`
|
||||
:param int _version: The version of the endpoint
|
||||
:param str _endpoint: The endpoint of the URL.
|
||||
:return: The final URL
|
||||
:rtype: str
|
||||
"""
|
||||
_base = _base_url.rstrip('/') # Remove potential trailing slash
|
||||
_endpoint = _endpoint.lstrip('/') # Remove potential leading slash
|
||||
return f'{_base}/V{_version}/{_endpoint}'
|
||||
|
||||
self.ensure_one()
|
||||
|
||||
version = API_ENDPOINT_VERSIONS[endpoint]
|
||||
base_url = self[url_field_name] # Restrict request URL to the stored API URL fields
|
||||
url = _build_url(base_url, version, endpoint)
|
||||
headers = {'X-API-Key': self.adyen_api_key}
|
||||
try:
|
||||
response = requests.request(method, url, json=payload, headers=headers, timeout=60)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.ConnectionError:
|
||||
_logger.exception("unable to reach endpoint at %s", url)
|
||||
raise ValidationError("Adyen: " + _("Could not establish the connection to the API."))
|
||||
except requests.exceptions.HTTPError:
|
||||
_logger.exception("invalid API request at %s with data %s", url, payload)
|
||||
raise ValidationError("Adyen: " + _("The communication with the API failed."))
|
||||
return response.json()
|
||||
|
||||
def _adyen_compute_shopper_reference(self, partner_id):
|
||||
""" Compute a unique reference of the partner for Adyen.
|
||||
|
||||
This is used for the `shopperReference` field in communications with Adyen and stored in the
|
||||
`adyen_shopper_reference` field on `payment.token` if the payment method is tokenized.
|
||||
|
||||
:param recordset partner_id: The partner making the transaction, as a `res.partner` id
|
||||
:return: The unique reference for the partner
|
||||
:rtype: str
|
||||
"""
|
||||
return f'ODOO_PARTNER_{partner_id}'
|
||||
@@ -0,0 +1,54 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class PaymentToken(models.Model):
|
||||
_inherit = 'payment.token'
|
||||
|
||||
adyen_shopper_reference = fields.Char(
|
||||
string="Shopper Reference", help="The unique reference of the partner owning this token",
|
||||
readonly=True)
|
||||
|
||||
#=== BUSINESS METHODS ===#
|
||||
|
||||
def _handle_deactivation_request(self):
|
||||
""" Override of payment to request request Adyen to delete the token.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:return: None
|
||||
"""
|
||||
super()._handle_deactivation_request()
|
||||
if self.provider != 'adyen':
|
||||
return
|
||||
|
||||
data = {
|
||||
'merchantAccount': self.acquirer_id.adyen_merchant_account,
|
||||
'shopperReference': self.adyen_shopper_reference,
|
||||
'recurringDetailReference': self.acquirer_ref,
|
||||
}
|
||||
try:
|
||||
self.acquirer_id._adyen_make_request(
|
||||
url_field_name='adyen_recurring_api_url',
|
||||
endpoint='/disable',
|
||||
payload=data,
|
||||
method='POST'
|
||||
)
|
||||
except ValidationError:
|
||||
pass # Deactivating the token in Odoo is more important than in Adyen
|
||||
|
||||
def _handle_reactivation_request(self):
|
||||
""" Override of payment to raise an error informing that Adyen tokens cannot be restored.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:return: None
|
||||
:raise: UserError if the token is managed by Adyen
|
||||
"""
|
||||
super()._handle_reactivation_request()
|
||||
if self.provider != 'adyen':
|
||||
return
|
||||
|
||||
raise UserError(_("Saved payment methods cannot be restored once they have been deleted."))
|
||||
@@ -0,0 +1,183 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import pprint
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.payment import utils as payment_utils
|
||||
from odoo.addons.payment_adyen.const import CURRENCY_DECIMALS, RESULT_CODES_MAPPING
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
adyen_payment_data = fields.Char(
|
||||
string="Saved Payment Data",
|
||||
help="Data that must be passed back to Adyen when returning from redirect", readonly=True,
|
||||
groups='base.group_system')
|
||||
|
||||
#=== BUSINESS METHODS ===#
|
||||
|
||||
def _get_specific_processing_values(self, processing_values):
|
||||
""" Override of payment to return Adyen-specific processing values.
|
||||
|
||||
Note: self.ensure_one() from `_get_processing_values`
|
||||
|
||||
:param dict processing_values: The generic processing values of the transaction
|
||||
:return: The dict of acquirer-specific processing values
|
||||
:rtype: dict
|
||||
"""
|
||||
res = super()._get_specific_processing_values(processing_values)
|
||||
if self.provider != 'adyen':
|
||||
return res
|
||||
|
||||
converted_amount = payment_utils.to_minor_currency_units(
|
||||
self.amount, self.currency_id, CURRENCY_DECIMALS.get(self.currency_id.name)
|
||||
)
|
||||
return {
|
||||
'converted_amount': converted_amount,
|
||||
'access_token': payment_utils.generate_access_token(
|
||||
processing_values['reference'],
|
||||
converted_amount,
|
||||
processing_values['partner_id']
|
||||
)
|
||||
}
|
||||
|
||||
def _send_payment_request(self):
|
||||
""" Override of payment to send a payment request to Adyen.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:return: None
|
||||
:raise: UserError if the transaction is not linked to a token
|
||||
"""
|
||||
super()._send_payment_request()
|
||||
if self.provider != 'adyen':
|
||||
return
|
||||
|
||||
# Make the payment request to Adyen
|
||||
if not self.token_id:
|
||||
raise UserError("Adyen: " + _("The transaction is not linked to a token."))
|
||||
|
||||
converted_amount = payment_utils.to_minor_currency_units(
|
||||
self.amount, self.currency_id, CURRENCY_DECIMALS.get(self.currency_id.name)
|
||||
)
|
||||
data = {
|
||||
'merchantAccount': self.acquirer_id.adyen_merchant_account,
|
||||
'amount': {
|
||||
'value': converted_amount,
|
||||
'currency': self.currency_id.name,
|
||||
},
|
||||
'reference': self.reference,
|
||||
'paymentMethod': {
|
||||
'recurringDetailReference': self.token_id.acquirer_ref
|
||||
}, # Required by Adyen although it is also provided with 'storedPaymentMethodId'
|
||||
'storedPaymentMethodId': self.token_id.acquirer_ref,
|
||||
'shopperReference': self.token_id.adyen_shopper_reference,
|
||||
'recurringProcessingModel': 'Subscription',
|
||||
'shopperIP': payment_utils.get_customer_ip_address(),
|
||||
'shopperInteraction': 'ContAuth',
|
||||
}
|
||||
response_content = self.acquirer_id._adyen_make_request(
|
||||
url_field_name='adyen_checkout_api_url',
|
||||
endpoint='/payments',
|
||||
payload=data,
|
||||
method='POST'
|
||||
)
|
||||
|
||||
# Handle the payment request response
|
||||
_logger.info("payment request response:\n%s", pprint.pformat(response_content))
|
||||
self._handle_feedback_data('adyen', response_content)
|
||||
|
||||
@api.model
|
||||
def _get_tx_from_feedback_data(self, provider, data):
|
||||
""" Override of payment to find the transaction based on Adyen data.
|
||||
|
||||
: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 if found
|
||||
:rtype: recordset of `payment.transaction`
|
||||
:raise: ValidationError if inconsistent data were received
|
||||
:raise: ValidationError if the data match no transaction
|
||||
"""
|
||||
tx = super()._get_tx_from_feedback_data(provider, data)
|
||||
if provider != 'adyen':
|
||||
return tx
|
||||
|
||||
reference = data.get('merchantReference')
|
||||
if not reference:
|
||||
raise ValidationError("Adyen: " + _("Received data with missing merchant reference"))
|
||||
|
||||
tx = self.search([('reference', '=', reference), ('provider', '=', 'adyen')])
|
||||
if not tx:
|
||||
raise ValidationError(
|
||||
"Adyen: " + _("No transaction found matching reference %s.", reference)
|
||||
)
|
||||
return tx
|
||||
|
||||
def _process_feedback_data(self, data):
|
||||
""" Override of payment to process the transaction based on Adyen data.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:param dict data: The feedback data sent by the provider
|
||||
:return: None
|
||||
:raise: ValidationError if inconsistent data were received
|
||||
"""
|
||||
super()._process_feedback_data(data)
|
||||
if self.provider != 'adyen':
|
||||
return
|
||||
|
||||
# Handle the acquirer reference
|
||||
if 'pspReference' in data:
|
||||
self.acquirer_reference = data.get('pspReference')
|
||||
|
||||
# Handle the payment state
|
||||
payment_state = data.get('resultCode')
|
||||
if not payment_state:
|
||||
raise ValidationError("Adyen: " + _("Received data with missing payment state."))
|
||||
|
||||
if payment_state in RESULT_CODES_MAPPING['pending']:
|
||||
self._set_pending()
|
||||
elif payment_state in RESULT_CODES_MAPPING['done']:
|
||||
has_token_data = 'recurring.recurringDetailReference' in data.get('additionalData', {})
|
||||
if self.tokenize and has_token_data:
|
||||
self._adyen_tokenize_from_feedback_data(data)
|
||||
self._set_done()
|
||||
elif payment_state in RESULT_CODES_MAPPING['cancel']:
|
||||
self._set_canceled()
|
||||
else: # Classify unsupported payment state as `error` tx state
|
||||
_logger.warning("received data with invalid payment state: %s", payment_state)
|
||||
self._set_error(
|
||||
"Adyen: " + _("Received data with invalid payment state: %s", payment_state)
|
||||
)
|
||||
|
||||
def _adyen_tokenize_from_feedback_data(self, data):
|
||||
""" Create a new token based on the feedback data.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:param dict data: The feedback data sent by the provider
|
||||
:return: None
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
token = self.env['payment.token'].create({
|
||||
'acquirer_id': self.acquirer_id.id,
|
||||
'name': payment_utils.build_token_name(data['additionalData'].get('cardSummary')),
|
||||
'partner_id': self.partner_id.id,
|
||||
'acquirer_ref': data['additionalData']['recurring.recurringDetailReference'],
|
||||
'adyen_shopper_reference': data['additionalData']['recurring.shopperReference'],
|
||||
'verified': True, # The payment is authorized, so the payment method is valid
|
||||
})
|
||||
self.write({
|
||||
'token_id': token,
|
||||
'tokenize': False,
|
||||
})
|
||||
_logger.info(
|
||||
"created token with id %s for partner with id %s", token.id, self.partner_id.id
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
odoo.define('payment_adyen.payment_form', require => {
|
||||
'use strict';
|
||||
|
||||
const core = require('web.core');
|
||||
const checkoutForm = require('payment.checkout_form');
|
||||
const manageForm = require('payment.manage_form');
|
||||
|
||||
const _t = core._t;
|
||||
|
||||
const adyenMixin = {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle the additional details event of the Adyen drop-in.
|
||||
*
|
||||
* @private
|
||||
* @param {object} state - The state of the drop-in
|
||||
* @param {object} dropin - The drop-in
|
||||
* @return {Promise}
|
||||
*/
|
||||
_dropinOnAdditionalDetails: function (state, dropin) {
|
||||
this._hideInputs(); // Only the inputs of the inline form should be used
|
||||
return this._rpc({
|
||||
route: '/payment/adyen/payment_details',
|
||||
params: {
|
||||
'acquirer_id': dropin.acquirerId,
|
||||
'reference': this.adyenDropin.reference,
|
||||
'details': state.data.details,
|
||||
'payment_data': state.data.paymentData,
|
||||
},
|
||||
}).then(paymentDetails => {
|
||||
if (paymentDetails.action) { // Additional action required from the shopper
|
||||
dropin.handleAction(paymentDetails.action);
|
||||
} else { // The payment reached a final state, redirect to the status page
|
||||
window.location = '/payment/status';
|
||||
}
|
||||
}).guardedCatch((error) => {
|
||||
error.event.preventDefault();
|
||||
this._displayError(
|
||||
_t("Server Error"),
|
||||
_t("We are not able to process your payment."),
|
||||
error.message.data.message
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the error event of the Adyen drop-in.
|
||||
*
|
||||
* @private
|
||||
* @param {object} error - The error in the drop-in
|
||||
* @return {undefined}
|
||||
*/
|
||||
_dropinOnError: function (error) {
|
||||
this._displayError(
|
||||
_t("Incorrect Payment Details"),
|
||||
_t("Please verify your payment details.")
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the submit event of the Adyen drop-in and initiate the payment.
|
||||
*
|
||||
* @private
|
||||
* @param {object} state - The state of the drop-in
|
||||
* @param {object} dropin - The drop-in
|
||||
* @return {Promise}
|
||||
*/
|
||||
_dropinOnSubmit: function (state, dropin) {
|
||||
// Create the transaction and retrieve the processing values
|
||||
return this._rpc({
|
||||
route: this.txContext.transactionRoute,
|
||||
params: this._prepareTransactionRouteParams('adyen', dropin.acquirerId, 'direct'),
|
||||
}).then(processingValues => {
|
||||
this.adyenDropin.reference = processingValues.reference; // Store final reference
|
||||
// Initiate the payment
|
||||
return this._rpc({
|
||||
route: '/payment/adyen/payments',
|
||||
params: {
|
||||
'acquirer_id': dropin.acquirerId,
|
||||
'reference': processingValues.reference,
|
||||
'converted_amount': processingValues.converted_amount,
|
||||
'currency_id': processingValues.currency_id,
|
||||
'partner_id': processingValues.partner_id,
|
||||
'payment_method': state.data.paymentMethod,
|
||||
'access_token': processingValues.access_token,
|
||||
'browser_info': state.data.browserInfo,
|
||||
},
|
||||
});
|
||||
}).then(paymentResponse => {
|
||||
if (paymentResponse.action) { // Additional action required from the shopper
|
||||
dropin.handleAction(paymentResponse.action);
|
||||
} else { // The payment reached a final state, redirect to the status page
|
||||
window.location = '/payment/status';
|
||||
}
|
||||
}).guardedCatch((error) => {
|
||||
error.event.preventDefault();
|
||||
this._displayError(
|
||||
_t("Server Error"),
|
||||
_t("We are not able to process your payment."),
|
||||
error.message.data.message
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Prepare the inline form of Adyen for direct payment.
|
||||
*
|
||||
* @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 {Promise}
|
||||
*/
|
||||
_prepareInlineForm: function (provider, paymentOptionId, flow) {
|
||||
if (provider !== 'adyen') {
|
||||
return this._super(...arguments);
|
||||
}
|
||||
|
||||
// Check if instantiation of the drop-in is needed
|
||||
if (flow === 'token') {
|
||||
return Promise.resolve(); // No drop-in for tokens
|
||||
} else if (this.adyenDropin && this.adyenDropin.acquirerId === paymentOptionId) {
|
||||
this._setPaymentFlow('direct'); // Overwrite the flow even if no re-instantiation
|
||||
return Promise.resolve(); // Don't re-instantiate if already done for this acquirer
|
||||
}
|
||||
|
||||
// Overwrite the flow of the select payment option
|
||||
this._setPaymentFlow('direct');
|
||||
|
||||
// Get the acquirer state ("enabled" or "test")
|
||||
return this._rpc({
|
||||
route: '/payment/adyen/acquirer_state',
|
||||
params: {
|
||||
'acquirer_id': paymentOptionId,
|
||||
},
|
||||
}).then(acquirerState => {
|
||||
// Get the available payment methods
|
||||
return this._rpc({
|
||||
route: '/payment/adyen/payment_methods',
|
||||
params: {
|
||||
'acquirer_id': paymentOptionId,
|
||||
'partner_id': parseInt(this.txContext.partnerId),
|
||||
'amount': this.txContext.amount
|
||||
? parseFloat(this.txContext.amount)
|
||||
: undefined,
|
||||
'currency_id': this.txContext.currencyId
|
||||
? parseInt(this.txContext.currencyId)
|
||||
: undefined,
|
||||
},
|
||||
}).then(paymentMethodsResult => {
|
||||
// Generate the origin key
|
||||
return this._rpc({
|
||||
route: '/payment/adyen/origin_key',
|
||||
params: {
|
||||
'acquirer_id': paymentOptionId,
|
||||
},
|
||||
}).then(originKeyResult => {
|
||||
// Extract the origin key from the returned object
|
||||
const originKeyResultKeys = Object.keys(originKeyResult.originKeys);
|
||||
const originKey = originKeyResult.originKeys[originKeyResultKeys[0]];
|
||||
|
||||
// Instantiate the drop-in
|
||||
const configuration = {
|
||||
paymentMethodsResponse: paymentMethodsResult,
|
||||
originKey: originKey,
|
||||
locale: (this._getContext().lang || 'en-US').replace('_', '-'),
|
||||
environment: acquirerState === 'enabled' ? 'live' : 'test',
|
||||
openFirstPaymentMethod: true,
|
||||
openFirstStoredPaymentMethod: false,
|
||||
showStoredPaymentMethods: false,
|
||||
showPaymentMethods: true,
|
||||
showPayButton: false,
|
||||
onAdditionalDetails: this._dropinOnAdditionalDetails.bind(this),
|
||||
onError: this._dropinOnError.bind(this),
|
||||
onSubmit: this._dropinOnSubmit.bind(this),
|
||||
};
|
||||
const checkout = new AdyenCheckout(configuration);
|
||||
this.adyenDropin = checkout.create('dropin').mount(
|
||||
`#o_adyen_dropin_container_${paymentOptionId}`
|
||||
);
|
||||
this.adyenDropin.acquirerId = paymentOptionId;
|
||||
});
|
||||
});
|
||||
}).guardedCatch((error) => {
|
||||
error.event.preventDefault();
|
||||
this._displayError(
|
||||
_t("Server Error"),
|
||||
_t("An error occurred when displayed this payment form."),
|
||||
error.message.data.message
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger the payment processing by submitting the drop-in.
|
||||
*
|
||||
* @override method from payment.payment_form_mixin
|
||||
* @private
|
||||
* @param {string} provider - The provider of the payment option's acquirer
|
||||
* @param {number} paymentOptionId - The id of the payment option handling the transaction
|
||||
* @param {string} flow - The online payment flow of the transaction
|
||||
* @return {Promise}
|
||||
*/
|
||||
_processPayment: function (provider, paymentOptionId, flow) {
|
||||
if (provider !== 'adyen' || flow === 'token') {
|
||||
return this._super(...arguments); // Tokens are handled by the generic flow
|
||||
}
|
||||
return this.adyenDropin.submit();
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
checkoutForm.include(adyenMixin);
|
||||
manageForm.include(adyenMixin);
|
||||
});
|
||||
@@ -1 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import common
|
||||
from . import test_adyen
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo.addons.payment.tests.common import PaymentCommon
|
||||
|
||||
|
||||
class AdyenCommon(PaymentCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.adyen = cls._prepare_acquirer('adyen', update_values={
|
||||
'adyen_merchant_account': 'dummy',
|
||||
'adyen_api_key': 'dummy',
|
||||
'adyen_hmac_key': 'dummy',
|
||||
'adyen_checkout_api_url': 'https://this.is.an.url',
|
||||
'adyen_recurring_api_url': 'https://this.is.an.url',
|
||||
})
|
||||
|
||||
# Override default values
|
||||
cls.acquirer = cls.adyen
|
||||
@@ -1,79 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from lxml import objectify
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.addons.payment.tests.common import PaymentAcquirerCommon
|
||||
from odoo.addons.payment_adyen.controllers.main import AdyenController
|
||||
from werkzeug import urls
|
||||
import odoo.tests
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
from odoo.addons.payment import utils as payment_utils
|
||||
|
||||
from .common import AdyenCommon
|
||||
|
||||
|
||||
class AdyenCommon(PaymentAcquirerCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls, chart_template_ref=None):
|
||||
super().setUpClass(chart_template_ref=chart_template_ref)
|
||||
|
||||
# some CC (always use expiration date 06 / 2016, cvc 737, cid 7373 (amex))
|
||||
cls.amex = (('370000000000002', '7373'))
|
||||
cls.dinersclub = (('36006666333344', '737'))
|
||||
cls.discover = (('6011601160116611', '737'), ('644564456445644', '737'))
|
||||
cls.jcb = (('3530111333300000', '737'))
|
||||
cls.mastercard = (('5555444433331111', '737'), ('5555555555554444', '737'))
|
||||
cls.visa = (('4111 1111 1111 1111', '737'), ('4444333322221111', '737'))
|
||||
cls.mcdebit = (('5500000000000004', '737'))
|
||||
cls.visadebit = (('4400000000000008', '737'))
|
||||
cls.maestro = (('6731012345678906', '737'))
|
||||
cls.laser = (('630495060000000000', '737'))
|
||||
cls.hipercard = (('6062828888666688', '737'))
|
||||
cls.dsmastercard = (('521234567890 1234', '737', 'user', 'password'))
|
||||
cls.dsvisa = (('4212345678901237', '737', 'user', 'password'))
|
||||
cls.mistercash = (('6703444444444449', None, 'user', 'password'))
|
||||
cls.adyen = cls.env.ref('payment.payment_acquirer_adyen')
|
||||
cls.adyen.write({
|
||||
'adyen_merchant_account': 'dummy',
|
||||
'adyen_skin_code': 'dummy',
|
||||
'adyen_skin_hmac_key': 'dummy',
|
||||
'state': 'test',
|
||||
})
|
||||
|
||||
|
||||
@odoo.tests.tagged('post_install', '-at_install', 'external', '-standard')
|
||||
@tagged('post_install', '-at_install')
|
||||
class AdyenForm(AdyenCommon):
|
||||
|
||||
def test_10_adyen_form_render(self):
|
||||
# be sure not to do stupid things
|
||||
adyen = self.adyen
|
||||
self.assertEqual(adyen.state, 'test', 'test without test environment')
|
||||
def test_processing_values(self):
|
||||
tx = self.create_transaction(flow='direct')
|
||||
with mute_logger('odoo.addons.payment.models.payment_transaction'), \
|
||||
patch(
|
||||
'odoo.addons.payment.utils.generate_access_token',
|
||||
new=self._generate_test_access_token
|
||||
):
|
||||
processing_values = tx._get_processing_values()
|
||||
|
||||
# ----------------------------------------
|
||||
# Test: button direct rendering
|
||||
# ----------------------------------------
|
||||
converted_amount = 111111
|
||||
self.assertEqual(
|
||||
payment_utils.to_minor_currency_units(self.amount, self.currency),
|
||||
converted_amount,
|
||||
)
|
||||
self.assertEqual(processing_values['converted_amount'], converted_amount)
|
||||
with patch(
|
||||
'odoo.addons.payment.utils.generate_access_token', new=self._generate_test_access_token
|
||||
):
|
||||
self.assertTrue(payment_utils.check_access_token(
|
||||
processing_values['access_token'], self.reference, converted_amount, self.partner.id
|
||||
))
|
||||
|
||||
base_url = self.env['ir.config_parameter'].get_param('web.base.url')
|
||||
form_values = {
|
||||
'merchantAccount': 'OpenERPCOM',
|
||||
'merchantReference': 'test_ref0',
|
||||
'skinCode': 'cbqYWvVL',
|
||||
'paymentAmount': '1',
|
||||
'currencyCode': 'EUR',
|
||||
'resURL': urls.url_join(base_url, AdyenController._return_url),
|
||||
}
|
||||
|
||||
# render the button
|
||||
res = adyen.render(
|
||||
'test_ref0', 0.01, self.currency_euro.id,
|
||||
partner_id=None,
|
||||
partner_values=self.buyer_values)
|
||||
|
||||
# check form result
|
||||
tree = objectify.fromstring(res)
|
||||
self.assertEqual(tree.get('action'), 'https://test.adyen.com/hpp/pay.shtml', 'adyen: wrong form POST url')
|
||||
for form_input in tree.input:
|
||||
if form_input.get('name') in ['submit', 'shipBeforeDate', 'sessionValidity', 'shopperLocale', 'merchantSig']:
|
||||
continue
|
||||
self.assertEqual(
|
||||
form_input.get('value'),
|
||||
form_values[form_input.get('name')],
|
||||
'adyen: wrong value for input %s: received %s instead of %s' % (form_input.get('name'), form_input.get('value'), form_values[form_input.get('name')])
|
||||
)
|
||||
def test_token_activation(self):
|
||||
"""Activation of disabled adyen tokens is forbidden"""
|
||||
token = self.create_token(active=False)
|
||||
with self.assertRaises(UserError):
|
||||
token._handle_reactivation_request()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?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"
|
||||
href="https://checkoutshopper-live.adyen.com/checkoutshopper/sdk/3.9.4/adyen.css"
|
||||
integrity="sha384-6qrXvoxlnBlrflZQ9g5Yf5oZapUSSXctPxacP9oRcEukbEO7lXisuSyMKG8pDX8V"
|
||||
crossorigin="anonymous"/>
|
||||
</xpath>
|
||||
<xpath expr="script[last()]" position="after">
|
||||
<script src="https://checkoutshopper-live.adyen.com/checkoutshopper/sdk/3.9.4/adyen.js"
|
||||
integrity="sha384-/rpftrZptnl7EIuIvqz4PAEehQoysKXmwqUAGyH9lK3WHgcvY0lWj/S5LH7xrbf+"
|
||||
crossorigin="anonymous"/>
|
||||
<!-- Load after fetching Adyen SDK -->
|
||||
<script type="text/javascript" src="/payment_adyen/static/src/js/payment_form.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -1,27 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<template id="adyen_form">
|
||||
<input type="hidden" name="data_set" t-att-data-action-url="tx_url" data-remove-me=""/>
|
||||
<input type="hidden" name="merchantReference" t-att-value="merchantReference"/>
|
||||
<input type="hidden" name="paymentAmount" t-att-value="paymentAmount"/>
|
||||
<input type="hidden" name="currencyCode" t-att-value="currencyCode"/>
|
||||
<input type="hidden" name="shipBeforeDate" t-att-value="shipBeforeDate"/>
|
||||
<input type="hidden" name="skinCode" t-att-value="skinCode"/>
|
||||
<input type="hidden" name="merchantAccount" t-att-value="merchantAccount"/>
|
||||
<input type="hidden" name="shopperLocale" t-att-value="shopperLocale"/>
|
||||
<input type="hidden" name="sessionValidity" t-att-value="sessionValidity"/>
|
||||
<input type="hidden" name="merchantSig" t-att-value="merchantSig"/>
|
||||
<!-- URLs -->
|
||||
<input type="hidden" name='resURL'
|
||||
t-att-value="resURL"/>
|
||||
<!-- custom -->
|
||||
<input t-if="merchantReturnData" type="hidden" name='merchantReturnData'
|
||||
t-att-value="merchantReturnData"/>
|
||||
<!-- shopperEmail is not included for SHA-1. To avoid breaking compatibility,
|
||||
include only if filled in -->
|
||||
<input t-if="shopperEmail"
|
||||
type="hidden" name='shopperEmail' t-att-value="shopperEmail"/>
|
||||
</template>
|
||||
</data>
|
||||
|
||||
<template id="inline_form">
|
||||
<div t-attf-id="o_adyen_dropin_container_{{acquirer_id}}"/>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="acquirer_form_adyen" model="ir.ui.view">
|
||||
<field name="name">acquirer.form.adyen</field>
|
||||
<field name="model">payment.acquirer</field>
|
||||
<field name="inherit_id" ref="payment.acquirer_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr='//group[@name="acquirer"]' position='inside'>
|
||||
<group attrs="{'invisible': [('provider', '!=', 'adyen')]}">
|
||||
<field name="adyen_merchant_account" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_skin_code" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_skin_hmac_key" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<a colspan="2" href="https://www.adyen.com/home/payment-services/online-payments" target="_blank">How to configure your Adyen account?</a>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="payment_acquirer_form" model="ir.ui.view">
|
||||
<field name="name">Adyen Acquirer Form</field>
|
||||
<field name="model">payment.acquirer</field>
|
||||
<field name="inherit_id" ref="payment.payment_acquirer_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr='//group[@name="acquirer"]' position='inside'>
|
||||
<group attrs="{'invisible': [('provider', '!=', 'adyen')]}">
|
||||
<field name="adyen_merchant_account" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_api_key" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_hmac_key" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_checkout_api_url" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="adyen_recurring_api_url" attrs="{'required':[('provider', '=', 'adyen'), ('state', '!=', 'disabled')]}"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from odoo.addons.payment.models.payment_acquirer import create_missing_journal_for_acquirers
|
||||
|
||||
from odoo.addons.payment import reset_payment_acquirer
|
||||
from odoo.addons.payment.models.payment_acquirer import create_missing_journals # post-init hook
|
||||
|
||||
|
||||
def uninstall_hook(cr, registry):
|
||||
reset_payment_acquirer(cr, registry, 'alipay')
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Alipay Payment Acquirer',
|
||||
'category': 'Accounting/Payment Acquirers',
|
||||
'version': '2.0',
|
||||
'sequence': 345,
|
||||
'summary': 'Payment Acquirer: Alipay Implementation',
|
||||
'description': """Alipay Payment Acquirer""",
|
||||
'depends': ['payment'],
|
||||
'data': [
|
||||
'views/alipay_views.xml',
|
||||
'views/payment_alipay_templates.xml',
|
||||
'views/payment_views.xml',
|
||||
'data/payment_acquirer_data.xml',
|
||||
],
|
||||
'application': True,
|
||||
'post_init_hook': 'create_missing_journal_for_acquirers',
|
||||
'post_init_hook': 'create_missing_journals',
|
||||
'uninstall_hook': 'uninstall_hook',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
|
||||
@@ -1,63 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import pprint
|
||||
|
||||
import requests
|
||||
import werkzeug
|
||||
|
||||
from odoo import http
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlipayController(http.Controller):
|
||||
_notify_url = '/payment/alipay/notify'
|
||||
_return_url = '/payment/alipay/return'
|
||||
_notify_url = '/payment/alipay/notify'
|
||||
|
||||
def _alipay_validate_data(self, **post):
|
||||
resp = post.get('trade_status')
|
||||
if resp:
|
||||
if resp in ['TRADE_FINISHED', 'TRADE_SUCCESS']:
|
||||
_logger.info('Alipay: validated data')
|
||||
elif resp == 'TRADE_CLOSED':
|
||||
_logger.warning('Alipay: payment refunded to user and closed the transaction')
|
||||
else:
|
||||
_logger.warning('Alipay: unrecognized alipay answer, received %s instead of TRADE_FINISHED/TRADE_SUCCESS and TRADE_CLOSED' % (post['trade_status']))
|
||||
if post.get('out_trade_no') and post.get('trade_no'):
|
||||
post['reference'] = request.env['payment.transaction'].sudo().search([('reference', '=', post['out_trade_no'])]).reference
|
||||
return request.env['payment.transaction'].sudo().form_feedback(post, 'alipay')
|
||||
return False
|
||||
|
||||
def _alipay_validate_notification(self, **post):
|
||||
if post.get('out_trade_no'):
|
||||
alipay = request.env['payment.transaction'].sudo().search([('reference', '=', post.get('out_trade_no'))]).acquirer_id
|
||||
else:
|
||||
alipay = request.env['payment.acquirer'].sudo().search([('provider', '=', 'alipay')])
|
||||
val = {
|
||||
'service': 'notify_verify',
|
||||
'partner': alipay.alipay_merchant_partner_id,
|
||||
'notify_id': post['notify_id']
|
||||
}
|
||||
response = requests.post(alipay.alipay_get_form_action_url(), val)
|
||||
response.raise_for_status()
|
||||
_logger.info('Validate alipay Notification %s' % response.text)
|
||||
# After program is executed, the page must print “success” (without quote). If not, Alipay server would keep re-sending notification, until over 24 hour 22 minutes Generally, there are 8 notifications within 25 hours (Frequency: 2m,10m,15m,1h,2h,6h,15h)
|
||||
if response.text == 'true':
|
||||
self._alipay_validate_data(**post)
|
||||
return 'success'
|
||||
return ""
|
||||
|
||||
@http.route('/payment/alipay/return', type='http', auth="public", methods=['GET', 'POST'])
|
||||
def alipay_return(self, **post):
|
||||
@http.route(_return_url, type='http', auth="public", methods=['GET'])
|
||||
def alipay_return_from_redirect(self, **data):
|
||||
""" Alipay return """
|
||||
_logger.info('Beginning Alipay form_feedback with post data %s', pprint.pformat(post))
|
||||
self._alipay_validate_data(**post)
|
||||
return werkzeug.utils.redirect('/payment/process')
|
||||
_logger.info("received Alipay return data:\n%s", pprint.pformat(data))
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data('alipay', data)
|
||||
return werkzeug.utils.redirect('/payment/status')
|
||||
|
||||
@http.route('/payment/alipay/notify', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
@http.route(_notify_url, type='http', auth='public', methods=['POST'], csrf=False)
|
||||
def alipay_notify(self, **post):
|
||||
""" Alipay Notify """
|
||||
_logger.info('Beginning Alipay notification form_feedback with post data %s', pprint.pformat(post))
|
||||
return self._alipay_validate_notification(**post)
|
||||
_logger.info("received Alipay notification data:\n%s", pprint.pformat(post))
|
||||
self._alipay_validate_notification(**post)
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data('alipay', post)
|
||||
return 'success' # Return 'success' to stop receiving notifications for this tx
|
||||
|
||||
def _alipay_validate_notification(self, **post):
|
||||
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data(
|
||||
'alipay', post
|
||||
)
|
||||
if not tx_sudo:
|
||||
raise ValidationError(
|
||||
"Alipay: " + _(
|
||||
"Received notification data with unknown reference:\n%s", pprint.pformat(post)
|
||||
)
|
||||
)
|
||||
|
||||
# Ensure that the notification was sent by Alipay
|
||||
# See https://global.alipay.com/docs/ac/wap/async
|
||||
acquirer_sudo = tx_sudo.acquirer_id
|
||||
val = {
|
||||
'service': 'notify_verify',
|
||||
'partner': acquirer_sudo.alipay_merchant_partner_id,
|
||||
'notify_id': post['notify_id']
|
||||
}
|
||||
response = requests.post(acquirer_sudo._alipay_get_api_url(), val, timeout=60)
|
||||
response.raise_for_status()
|
||||
if response.text != 'true':
|
||||
raise ValidationError(
|
||||
"Alipay: " + _(
|
||||
"Received notification data not acknowledged by Alipay:\n%s",
|
||||
pprint.pformat(post)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="payment.payment_acquirer_alipay" model="payment.acquirer">
|
||||
<field name="name">Alipay</field>
|
||||
<field name="provider">alipay</field>
|
||||
<field name="company_id" ref="base.main_company"/>
|
||||
<field name="view_template_id" ref="alipay_form"/>
|
||||
<field name="redirect_form_view_id" ref="redirect_form"/>
|
||||
<field name="support_authorization">False</field>
|
||||
<field name="support_fees_computation">True</field>
|
||||
<field name="support_tokenization">False</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import payment
|
||||
from . import payment_acquirer
|
||||
from . import payment_transaction
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from hashlib import md5
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools.float_utils import float_compare
|
||||
from odoo.addons.payment_alipay.controllers.main import AlipayController
|
||||
from odoo.addons.payment.models.payment_acquirer import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentAcquirer(models.Model):
|
||||
_inherit = 'payment.acquirer'
|
||||
|
||||
provider = fields.Selection(selection_add=[
|
||||
('alipay', 'Alipay')
|
||||
], ondelete={'alipay': 'set default'})
|
||||
alipay_payment_method = fields.Selection([
|
||||
('express_checkout', 'Express Checkout (only for Chinese Merchant)'),
|
||||
('standard_checkout', 'Cross-border'),
|
||||
], string='Account', default='express_checkout',
|
||||
help=" * Cross-border: For the Overseas seller \n * Express Checkout: For the Chinese Seller")
|
||||
alipay_merchant_partner_id = fields.Char(
|
||||
string='Merchant Partner ID', required_if_provider='alipay', groups='base.group_user',
|
||||
help='The Merchant Partner ID is used to ensure communications coming from Alipay are valid and secured.')
|
||||
alipay_md5_signature_key = fields.Char(
|
||||
string='MD5 Signature Key', required_if_provider='alipay', groups='base.group_user',
|
||||
help="The MD5 private key is the 32-byte string which is composed of English letters and numbers.")
|
||||
alipay_seller_email = fields.Char(string='Alipay Seller Email', groups='base.group_user')
|
||||
|
||||
def _get_feature_support(self):
|
||||
res = super(PaymentAcquirer, self)._get_feature_support()
|
||||
res['fees'].append('alipay')
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_alipay_urls(self, environment):
|
||||
""" Alipay URLS """
|
||||
if environment == 'prod':
|
||||
return 'https://mapi.alipay.com/gateway.do'
|
||||
return 'https://openapi.alipaydev.com/gateway.do'
|
||||
|
||||
def alipay_compute_fees(self, amount, currency_id, country_id):
|
||||
""" Compute alipay fees.
|
||||
|
||||
:param float amount: the amount to pay
|
||||
:param integer country_id: an ID of a res.country, or None. This is
|
||||
the customer's country, to be compared to
|
||||
the acquirer company country.
|
||||
:return float fees: computed fees
|
||||
"""
|
||||
fees = 0.0
|
||||
if self.fees_active:
|
||||
country = self.env['res.country'].browse(country_id)
|
||||
if country and self.company_id.sudo().country_id.id == country.id:
|
||||
percentage = self.fees_dom_var
|
||||
fixed = self.fees_dom_fixed
|
||||
else:
|
||||
percentage = self.fees_int_var
|
||||
fixed = self.fees_int_fixed
|
||||
fees = (percentage / 100.0 * amount + fixed) / (1 - percentage / 100.0)
|
||||
return fees
|
||||
|
||||
def _build_sign(self, val):
|
||||
# Rearrange parameters in the data set alphabetically
|
||||
data_to_sign = sorted(val.items())
|
||||
# Exclude parameters that should not be signed
|
||||
data_to_sign = ["{}={}".format(k, v) for k, v in data_to_sign if k not in ['sign', 'sign_type', 'reference']]
|
||||
# And connect rearranged parameters with &
|
||||
data_string = '&'.join(data_to_sign)
|
||||
data_string += self.alipay_md5_signature_key
|
||||
return md5(data_string.encode('utf-8')).hexdigest()
|
||||
|
||||
def _get_alipay_tx_values(self, values):
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
|
||||
alipay_tx_values = ({
|
||||
'_input_charset': 'utf-8',
|
||||
'notify_url': urls.url_join(base_url, AlipayController._notify_url),
|
||||
'out_trade_no': values.get('reference'),
|
||||
'partner': self.alipay_merchant_partner_id,
|
||||
'return_url': urls.url_join(base_url, AlipayController._return_url),
|
||||
'subject': values.get('reference'),
|
||||
'total_fee': values.get('amount') + values.get('fees'),
|
||||
})
|
||||
if self.alipay_payment_method == 'standard_checkout':
|
||||
alipay_tx_values.update({
|
||||
'service': 'create_forex_trade',
|
||||
'product_code': 'NEW_OVERSEAS_SELLER',
|
||||
'currency': values.get('currency').name,
|
||||
})
|
||||
else:
|
||||
alipay_tx_values.update({
|
||||
'service': 'create_direct_pay_by_user',
|
||||
'payment_type': 1,
|
||||
'seller_email': self.alipay_seller_email,
|
||||
})
|
||||
sign = self._build_sign(alipay_tx_values)
|
||||
alipay_tx_values.update({
|
||||
'sign_type': 'MD5',
|
||||
'sign': sign,
|
||||
})
|
||||
return alipay_tx_values
|
||||
|
||||
def alipay_form_generate_values(self, values):
|
||||
values.update(self._get_alipay_tx_values(values))
|
||||
return values
|
||||
|
||||
def alipay_get_form_action_url(self):
|
||||
self.ensure_one()
|
||||
environment = 'prod' if self.state == 'enabled' else 'test'
|
||||
return self._get_alipay_urls(environment)
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
def _check_alipay_configuration(self, vals):
|
||||
acquirer_id = int(vals.get('acquirer_id'))
|
||||
acquirer = self.env['payment.acquirer'].sudo().browse(acquirer_id)
|
||||
if acquirer and acquirer.provider == 'alipay' and acquirer.alipay_payment_method == 'express_checkout':
|
||||
currency_id = int(vals.get('currency_id'))
|
||||
if currency_id:
|
||||
currency = self.env['res.currency'].sudo().browse(currency_id)
|
||||
if currency and currency.name != 'CNY':
|
||||
_logger.info("Only CNY currency is allowed for Alipay Express Checkout")
|
||||
raise ValidationError(_("""
|
||||
Only transactions in Chinese Yuan (CNY) are allowed for Alipay Express Checkout.\n
|
||||
If you wish to use another currency than CNY for your transactions, switch your
|
||||
configuration to a Cross-border account on the Alipay payment acquirer in Odoo.
|
||||
"""))
|
||||
return True
|
||||
|
||||
def write(self, vals):
|
||||
if vals.get('currency_id') or vals.get('acquirer_id'):
|
||||
for payment in self:
|
||||
check_vals = {
|
||||
'acquirer_id': vals.get('acquirer_id', payment.acquirer_id.id),
|
||||
'currency_id': vals.get('currency_id', payment.currency_id.id)
|
||||
}
|
||||
payment._check_alipay_configuration(check_vals)
|
||||
return super(PaymentTransaction, self).write(vals)
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
self._check_alipay_configuration(vals)
|
||||
return super(PaymentTransaction, self).create(vals)
|
||||
|
||||
# --------------------------------------------------
|
||||
# FORM RELATED METHODS
|
||||
# --------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _alipay_form_get_tx_from_data(self, data):
|
||||
reference, txn_id, sign = data.get('reference'), data.get('trade_no'), data.get('sign')
|
||||
if not reference or not txn_id:
|
||||
_logger.info('Alipay: received data with missing reference (%s) or txn_id (%s)' % (reference, txn_id))
|
||||
raise ValidationError(_('Alipay: received data with missing reference (%s) or txn_id (%s)') % (reference, txn_id))
|
||||
|
||||
txs = self.env['payment.transaction'].search([('reference', '=', reference)])
|
||||
if not txs or len(txs) > 1:
|
||||
error_msg = _('Alipay: received data for reference %s') % (reference)
|
||||
logger_msg = 'Alipay: received data for reference %s' % (reference)
|
||||
if not txs:
|
||||
error_msg += _('; no order found')
|
||||
logger_msg += '; no order found'
|
||||
else:
|
||||
error_msg += _('; multiple order found')
|
||||
logger_msg += '; multiple order found'
|
||||
_logger.info(logger_msg)
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
# verify sign
|
||||
sign_check = txs.acquirer_id._build_sign(data)
|
||||
if sign != sign_check:
|
||||
_logger.info('Alipay: invalid sign, received %s, computed %s, for data %s' % (sign, sign_check, data))
|
||||
raise ValidationError(_('Alipay: invalid sign, received %s, computed %s, for data %s') % (sign, sign_check, data))
|
||||
|
||||
return txs
|
||||
|
||||
def _alipay_form_get_invalid_parameters(self, data):
|
||||
invalid_parameters = []
|
||||
|
||||
if float_compare(float(data.get('total_fee', '0.0')), (self.amount + self.fees), 2) != 0:
|
||||
invalid_parameters.append(('total_fee', data.get('total_fee'), '%.2f' % (self.amount + self.fees))) # mc_gross is amount + fees
|
||||
if self.acquirer_id.alipay_payment_method == 'standard_checkout':
|
||||
if data.get('currency') != self.currency_id.name:
|
||||
invalid_parameters.append(('currency', data.get('currency'), self.currency_id.name))
|
||||
else:
|
||||
if data.get('seller_email') != self.acquirer_id.alipay_seller_email:
|
||||
invalid_parameters.append(('seller_email', data.get('seller_email'), self.acquirer_id.alipay_seller_email))
|
||||
return invalid_parameters
|
||||
|
||||
def _alipay_form_validate(self, data):
|
||||
if self.state in ['done']:
|
||||
_logger.info('Alipay: trying to validate an already validated tx (ref %s)', self.reference)
|
||||
return True
|
||||
|
||||
status = data.get('trade_status')
|
||||
res = {
|
||||
'acquirer_reference': data.get('trade_no'),
|
||||
}
|
||||
if status in ['TRADE_FINISHED', 'TRADE_SUCCESS']:
|
||||
_logger.info('Validated Alipay payment for tx %s: set as done' % (self.reference))
|
||||
date_validate = fields.Datetime.now()
|
||||
res.update(date=date_validate)
|
||||
self._set_transaction_done()
|
||||
self.write(res)
|
||||
self.execute_callback()
|
||||
return True
|
||||
elif status == 'TRADE_CLOSED':
|
||||
_logger.info('Received notification for Alipay payment %s: set as Canceled' % (self.reference))
|
||||
res.update(state_message=data.get('close_reason', ''))
|
||||
self._set_transaction_cancel()
|
||||
return self.write(res)
|
||||
else:
|
||||
error = 'Received unrecognized status for Alipay payment %s: %s, set as error' % (self.reference, status)
|
||||
_logger.info(error)
|
||||
res.update(state_message=error)
|
||||
self._set_transaction_error()
|
||||
return self.write(res)
|
||||
@@ -0,0 +1,60 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
from hashlib import md5
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentAcquirer(models.Model):
|
||||
_inherit = 'payment.acquirer'
|
||||
|
||||
provider = fields.Selection(
|
||||
selection_add=[('alipay', "Alipay")], ondelete={'alipay': 'set default'})
|
||||
alipay_payment_method = fields.Selection(
|
||||
string="Account",
|
||||
help="* Cross-border: For the overseas seller \n* Express Checkout: For the Chinese Seller",
|
||||
selection=[
|
||||
('express_checkout', 'Express Checkout (only for Chinese merchants)'),
|
||||
('standard_checkout', 'Cross-border')
|
||||
], default='express_checkout', required_if_provider='alipay')
|
||||
alipay_merchant_partner_id = fields.Char(
|
||||
string="Merchant Partner ID",
|
||||
help="The public partner ID solely used to identify the account with Alipay",
|
||||
required_if_provider='alipay')
|
||||
alipay_md5_signature_key = fields.Char(
|
||||
string="MD5 Signature Key", required_if_provider='alipay', groups='base.group_system')
|
||||
alipay_seller_email = fields.Char(
|
||||
string="Alipay Seller Email", help="The public Alipay partner email")
|
||||
|
||||
@api.model
|
||||
def _get_compatible_acquirers(self, *args, currency_id=None, **kwargs):
|
||||
""" Override of payment to unlist Alipay acquirers for unsupported currencies. """
|
||||
acquirers = super()._get_compatible_acquirers(*args, currency_id=currency_id, **kwargs)
|
||||
|
||||
currency = self.env['res.currency'].browse(currency_id).exists()
|
||||
if currency and currency.name != 'CNY':
|
||||
acquirers = acquirers.filtered(
|
||||
lambda a: a.provider != 'alipay' or a.alipay_payment_method != 'express_checkout'
|
||||
)
|
||||
|
||||
return acquirers
|
||||
|
||||
def _alipay_build_sign(self, val):
|
||||
# Rearrange parameters in the data set alphabetically
|
||||
data_to_sign = sorted(val.items())
|
||||
# Format key-value pairs of parameters that should be signed
|
||||
data_to_sign = [f"{k}={v}" for k, v in data_to_sign
|
||||
if k not in ['sign', 'sign_type', 'reference']]
|
||||
# Build the data string of &-separated key-value pairs
|
||||
data_string = '&'.join(data_to_sign)
|
||||
data_string += self.alipay_md5_signature_key
|
||||
return md5(data_string.encode('utf-8')).hexdigest()
|
||||
|
||||
def _alipay_get_api_url(self):
|
||||
if self.state == 'enabled':
|
||||
return 'https://mapi.alipay.com/gateway.do'
|
||||
else: # test environment
|
||||
return 'https://openapi.alipaydev.com/gateway.do'
|
||||
@@ -0,0 +1,168 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import _, api, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools.float_utils import float_compare
|
||||
|
||||
from odoo.addons.payment_alipay.controllers.main import AlipayController
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
def _get_specific_rendering_values(self, processing_values):
|
||||
""" Override of payment to return Alipay-specific rendering values.
|
||||
|
||||
Note: self.ensure_one() from `_get_processing_values`
|
||||
|
||||
:param dict processing_values: The generic and specific processing values of the transaction
|
||||
:return: The dict of acquirer-specific processing values
|
||||
:rtype: dict
|
||||
"""
|
||||
res = super()._get_specific_rendering_values(processing_values)
|
||||
if self.provider != 'alipay':
|
||||
return res
|
||||
|
||||
base_url = self.acquirer_id._get_base_url()
|
||||
if self.fees:
|
||||
# Similarly to what is done in `payment::payment.transaction.create`, we need to round
|
||||
# the sum of the amount and of the fees to avoid inconsistent string representations.
|
||||
# E.g., str(1111.11 + 7.09) == '1118.1999999999998'
|
||||
total_fee = self.currency_id.round(self.amount + self.fees)
|
||||
else:
|
||||
total_fee = self.amount
|
||||
rendering_values = {
|
||||
'_input_charset': 'utf-8',
|
||||
'notify_url': urls.url_join(base_url, AlipayController._notify_url),
|
||||
'out_trade_no': self.reference,
|
||||
'partner': self.acquirer_id.alipay_merchant_partner_id,
|
||||
'return_url': urls.url_join(base_url, AlipayController._return_url),
|
||||
'subject': self.reference,
|
||||
'total_fee': total_fee,
|
||||
}
|
||||
if self.acquirer_id.alipay_payment_method == 'standard_checkout':
|
||||
# https://global.alipay.com/docs/ac/global/create_forex_trade
|
||||
rendering_values.update({
|
||||
'service': 'create_forex_trade',
|
||||
'product_code': 'NEW_OVERSEAS_SELLER',
|
||||
'currency': self.currency_id.name,
|
||||
})
|
||||
else:
|
||||
rendering_values.update({
|
||||
'service': 'create_direct_pay_by_user',
|
||||
'payment_type': 1,
|
||||
'seller_email': self.acquirer_id.alipay_seller_email,
|
||||
})
|
||||
|
||||
sign = self.acquirer_id._alipay_build_sign(rendering_values)
|
||||
rendering_values.update({
|
||||
'sign_type': 'MD5',
|
||||
'sign': sign,
|
||||
'api_url': self.acquirer_id._alipay_get_api_url(),
|
||||
})
|
||||
return rendering_values
|
||||
|
||||
@api.model
|
||||
def _get_tx_from_feedback_data(self, provider, data):
|
||||
""" Override of payment to find the transaction based on Alipay data.
|
||||
|
||||
: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 if found
|
||||
:rtype: recordset of `payment.transaction`
|
||||
:raise: ValidationError if inconsistent data were received
|
||||
:raise: ValidationError if the data match no transaction
|
||||
"""
|
||||
tx = super()._get_tx_from_feedback_data(provider, data)
|
||||
if provider != 'alipay':
|
||||
return tx
|
||||
|
||||
reference = data.get('reference') or data.get('out_trade_no')
|
||||
txn_id = data.get('trade_no')
|
||||
if not reference or not txn_id:
|
||||
raise ValidationError(
|
||||
"Alipay: " + _(
|
||||
"Received data with missing reference %(r)s or txn_id %(t)s.",
|
||||
r=reference, t=txn_id
|
||||
)
|
||||
)
|
||||
|
||||
tx = self.search([('reference', '=', reference), ('provider', '=', 'alipay')])
|
||||
if not tx:
|
||||
raise ValidationError(
|
||||
"Alipay: " + _("No transaction found matching reference %s.", reference)
|
||||
)
|
||||
|
||||
# Verify signature (done here because we need the reference to get the acquirer)
|
||||
sign_check = tx.acquirer_id._alipay_build_sign(data)
|
||||
sign = data.get('sign')
|
||||
if sign != sign_check:
|
||||
raise ValidationError(
|
||||
"Alipay: " + _(
|
||||
"Expected signature %(sc) but received %(sign)s.", sc=sign_check, sign=sign
|
||||
)
|
||||
)
|
||||
|
||||
return tx
|
||||
|
||||
def _process_feedback_data(self, data):
|
||||
""" Override of payment to process the transaction based on Alipay data.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:param dict data: The feedback data sent by the provider
|
||||
:return: None
|
||||
:raise: ValidationError if inconsistent data were received
|
||||
"""
|
||||
super()._process_feedback_data(data)
|
||||
if self.provider != 'alipay':
|
||||
return
|
||||
|
||||
if float_compare(float(data.get('total_fee', '0.0')), (self.amount + self.fees), 2) != 0:
|
||||
# mc_gross is amount + fees
|
||||
logging_values = {
|
||||
'amount': data.get('total_fee', '0.0'),
|
||||
'total': self.amount,
|
||||
'fees': self.fees,
|
||||
'reference': self.reference,
|
||||
}
|
||||
_logger.error(
|
||||
"the paid amount (%(amount)s) does not match the total + fees (%(total)s + "
|
||||
"%(fees)s) for the transaction with reference %(reference)s", logging_values
|
||||
)
|
||||
raise ValidationError("Alipay: " + _("The amount does not match the total + fees."))
|
||||
if self.acquirer_id.alipay_payment_method == 'standard_checkout':
|
||||
if data.get('currency') != self.currency_id.name:
|
||||
raise ValidationError(
|
||||
"Alipay: " + _(
|
||||
"The currency returned by Alipay %(rc)s does not match the transaction "
|
||||
"currency %(tc)s.", rc=data.get('currency'), tc=self.currency_id.name
|
||||
)
|
||||
)
|
||||
elif data.get('seller_email') != self.acquirer_id.alipay_seller_email:
|
||||
_logger.error(
|
||||
"the seller email (%s) does not match the configured Alipay account (%s).",
|
||||
data.get('seller_email'), self.acquirer_id.alipay_seller_email
|
||||
)
|
||||
raise ValidationError(
|
||||
"Alipay: " + _("The seller email does not match the configured Alipay account.")
|
||||
)
|
||||
|
||||
self.acquirer_reference = data.get('trade_no')
|
||||
status = data.get('trade_status')
|
||||
if status in ['TRADE_FINISHED', 'TRADE_SUCCESS']:
|
||||
self._set_done()
|
||||
elif status == 'TRADE_CLOSED':
|
||||
self._set_canceled()
|
||||
else:
|
||||
_logger.info(
|
||||
"received invalid transaction status for transaction with reference %s: %s",
|
||||
self.reference, status
|
||||
)
|
||||
self._set_error("Alipay: " + _("received invalid transaction status: %s", status))
|
||||
@@ -1,4 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import common
|
||||
from . import test_alipay
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.payment.tests.common import PaymentCommon
|
||||
|
||||
|
||||
class AlipayCommon(PaymentCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.currency_yuan = cls._prepare_currency('CNY')
|
||||
cls.alipay = cls._prepare_acquirer('alipay', update_values={
|
||||
'alipay_merchant_partner_id': 'dummy',
|
||||
'alipay_md5_signature_key': 'dummy',
|
||||
'alipay_seller_email': 'dummy',
|
||||
'fees_active': False, # Only activate fees in dedicated tests
|
||||
})
|
||||
|
||||
# override defaults for helpers
|
||||
cls.acquirer = cls.alipay
|
||||
cls.currency = cls.currency_yuan
|
||||
@@ -1,87 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from werkzeug import urls
|
||||
from lxml import objectify
|
||||
|
||||
import odoo
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tools import mute_logger
|
||||
from odoo.addons.payment.models.payment_acquirer import ValidationError
|
||||
from odoo.addons.payment.tests.common import PaymentAcquirerCommon
|
||||
from odoo.addons.payment_alipay.controllers.main import AlipayController
|
||||
|
||||
@odoo.tests.tagged('post_install', '-at_install', 'external', '-standard')
|
||||
class AlipayTest(PaymentAcquirerCommon):
|
||||
from .common import AlipayCommon
|
||||
from ..controllers.main import AlipayController
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls, chart_template_ref=None):
|
||||
super().setUpClass(chart_template_ref=chart_template_ref)
|
||||
|
||||
cls.currency_yuan = cls.env['res.currency'].search([('name', '=', 'CNY'),
|
||||
'|',
|
||||
('active', '=', True),
|
||||
('active', '=', False)], limit=1)
|
||||
cls.alipay = cls.env.ref('payment.payment_acquirer_alipay')
|
||||
cls.alipay.write({
|
||||
'alipay_merchant_partner_id': 'dummy',
|
||||
'alipay_md5_signature_key': 'dummy',
|
||||
'alipay_seller_email': 'dummy',
|
||||
'state': 'test',
|
||||
})
|
||||
@tagged('post_install', '-at_install')
|
||||
class AlipayTest(AlipayCommon):
|
||||
|
||||
def test_10_alipay_form_render(self):
|
||||
base_url = self.env['ir.config_parameter'].get_param('web.base.url')
|
||||
self.assertEqual(self.alipay.state, 'test', 'test without test environment')
|
||||
def test_compatible_acquirers(self):
|
||||
self.alipay.alipay_payment_method = 'express_checkout'
|
||||
acquirers = self.env['payment.acquirer']._get_compatible_acquirers(
|
||||
partner_id=self.partner.id,
|
||||
currency_id=self.currency_yuan.id, # 'CNY'
|
||||
company_id=self.company.id,
|
||||
)
|
||||
self.assertIn(self.alipay, acquirers)
|
||||
acquirers = self.env['payment.acquirer']._get_compatible_acquirers(
|
||||
partner_id=self.partner.id,
|
||||
currency_id=self.currency_euro.id,
|
||||
company_id=self.company.id,
|
||||
)
|
||||
self.assertNotIn(self.alipay, acquirers)
|
||||
|
||||
# ----------------------------------------
|
||||
# Test: button direct rendering
|
||||
# ----------------------------------------
|
||||
self.alipay.alipay_payment_method = 'standard_checkout'
|
||||
acquirers = self.env['payment.acquirer']._get_compatible_acquirers(
|
||||
partner_id=self.partner.id,
|
||||
currency_id=self.currency_yuan.id, # 'CNY'
|
||||
company_id=self.company.id,
|
||||
)
|
||||
self.assertIn(self.alipay, acquirers)
|
||||
acquirers = self.env['payment.acquirer']._get_compatible_acquirers(
|
||||
partner_id=self.partner.id,
|
||||
currency_id=self.currency_euro.id,
|
||||
company_id=self.company.id,
|
||||
)
|
||||
self.assertIn(self.alipay, acquirers)
|
||||
|
||||
# render the button
|
||||
res = self.alipay.render(
|
||||
'test_ref0', 0.01, self.currency_euro.id,
|
||||
values=self.buyer_values)
|
||||
def test_01_redirect_form_standard_checkout(self):
|
||||
self.alipay.alipay_payment_method = 'standard_checkout'
|
||||
self._test_alipay_redirect_form()
|
||||
|
||||
form_values = {
|
||||
def test_02_redirect_form_express_checkout(self):
|
||||
self.alipay.alipay_payment_method = 'express_checkout'
|
||||
self._test_alipay_redirect_form()
|
||||
|
||||
def _test_alipay_redirect_form(self):
|
||||
tx = self.create_transaction(flow='redirect') # Only flow implemented
|
||||
|
||||
expected_values = {
|
||||
'_input_charset': 'utf-8',
|
||||
'notify_url': urls.url_join(base_url, AlipayController._notify_url),
|
||||
'out_trade_no': 'SO12345-1',
|
||||
'notify_url': self._build_url(AlipayController._notify_url),
|
||||
'out_trade_no': self.reference,
|
||||
'partner': self.alipay.alipay_merchant_partner_id,
|
||||
'return_url': urls.url_join(base_url, AlipayController._return_url),
|
||||
'subject': 'test_ref0',
|
||||
'total_fee': '0.01',
|
||||
'return_url': self._build_url(AlipayController._return_url),
|
||||
'subject': self.reference,
|
||||
'total_fee': str(self.amount), # Fees disabled by default
|
||||
}
|
||||
|
||||
if self.alipay.alipay_payment_method == 'standard_checkout':
|
||||
form_values.update({
|
||||
expected_values.update({
|
||||
'service': 'create_forex_trade',
|
||||
'currency': 'EUR',
|
||||
'product_code': 'NEW_OVERSEAS_SELLER',
|
||||
'currency': self.currency_yuan.name,
|
||||
})
|
||||
else:
|
||||
form_values.update({
|
||||
'payment_type': '1',
|
||||
expected_values.update({
|
||||
'service': 'create_direct_pay_by_user',
|
||||
'payment_type': str(1),
|
||||
'seller_email': self.alipay.alipay_seller_email,
|
||||
'service': 'create_direct_pay_by_user'
|
||||
})
|
||||
sign = self.alipay._build_sign(form_values)
|
||||
sign = self.alipay._alipay_build_sign(expected_values)
|
||||
|
||||
form_values.update({'sign': sign, 'sign_type': 'MD5'})
|
||||
# check form result
|
||||
tree = objectify.fromstring(res)
|
||||
with mute_logger('odoo.addons.payment.models.payment_transaction'):
|
||||
processing_values = tx._get_processing_values()
|
||||
redirect_form_data = self._extract_values_from_html_form(processing_values['redirect_form_html'])
|
||||
|
||||
data_set = tree.xpath("//input[@name='data_set']")
|
||||
self.assertEqual(len(data_set), 1, 'Alipay: Found %d "data_set" input instead of 1' % len(data_set))
|
||||
self.assertEqual(data_set[0].get('data-action-url'), 'https://openapi.alipaydev.com/gateway.do', 'alipay: wrong form POST url')
|
||||
for form_input in tree.input:
|
||||
if form_input.get('name') in ['submit', 'data_set', 'sign', 'out_trade_no']:
|
||||
continue
|
||||
self.assertEqual(form_input.get('value'), form_values[form_input.get('name')], 'alipay: wrong value for input %s: received %s instead of %s' % (form_input.get('name'), form_input.get('value'), form_values[form_input.get('name')]))
|
||||
expected_values.update({
|
||||
'sign': sign,
|
||||
'sign_type': 'MD5',
|
||||
})
|
||||
|
||||
def test_11_alipay_form_with_fees(self):
|
||||
self.assertEqual(self.alipay.state, 'test', 'test without test environment')
|
||||
self.assertEqual(
|
||||
redirect_form_data['action'],
|
||||
'https://openapi.alipaydev.com/gateway.do',
|
||||
)
|
||||
self.assertDictEqual(
|
||||
expected_values,
|
||||
redirect_form_data['inputs'],
|
||||
"Alipay: invalid inputs specified in the redirect form.",
|
||||
)
|
||||
|
||||
def test_03_redirect_form_with_fees(self):
|
||||
# update acquirer: compute fees
|
||||
self.alipay.write({
|
||||
'fees_active': True,
|
||||
@@ -91,34 +104,41 @@ class AlipayTest(PaymentAcquirerCommon):
|
||||
'fees_int_var': 0.50,
|
||||
})
|
||||
|
||||
# render the button
|
||||
res = self.alipay.render(
|
||||
'test_ref0', 12.50, self.currency_euro.id,
|
||||
values=self.buyer_values)
|
||||
transaction_fees = self.currency.round(
|
||||
self.alipay._compute_fees(
|
||||
self.amount,
|
||||
self.currency,
|
||||
self.partner.country_id,
|
||||
)
|
||||
)
|
||||
self.assertEqual(transaction_fees, 7.09)
|
||||
total_fee = self.currency.round(self.amount + transaction_fees)
|
||||
self.assertEqual(total_fee, 1118.2)
|
||||
|
||||
tree = objectify.fromstring(res)
|
||||
tx = self.create_transaction(flow='redirect')
|
||||
self.assertEqual(tx.fees, 7.09)
|
||||
with mute_logger('odoo.addons.payment.models.payment_transaction'):
|
||||
processing_values = tx._get_processing_values()
|
||||
redirect_form_data = self._extract_values_from_html_form(processing_values['redirect_form_html'])
|
||||
|
||||
data_set = tree.xpath("//input[@name='data_set']")
|
||||
self.assertEqual(len(data_set), 1, 'alipay: Found %d "data_set" input instead of 1' % len(data_set))
|
||||
self.assertEqual(data_set[0].get('data-action-url'), 'https://openapi.alipaydev.com/gateway.do', 'alipay: wrong form POST url')
|
||||
for form_input in tree.input:
|
||||
if form_input.get('name') in ['total_fee']:
|
||||
self.assertEqual(form_input.get('value'), '14.07', 'alipay: wrong computed fees') # total amount = amount + fees
|
||||
self.assertEqual(redirect_form_data['inputs']['total_fee'], str(total_fee))
|
||||
|
||||
@mute_logger('odoo.addons.payment_alipay.models.payment', 'ValidationError')
|
||||
def test_20_alipay_form_management(self):
|
||||
def test_21_standard_checkout_feedback(self):
|
||||
self.alipay.alipay_payment_method = 'standard_checkout'
|
||||
self._test_20_alipay_form_management()
|
||||
self.currency = self.currency_euro
|
||||
self._test_alipay_feedback_processing()
|
||||
|
||||
def test_22_express_checkout_feedback(self):
|
||||
self.alipay.alipay_payment_method = 'express_checkout'
|
||||
self._test_20_alipay_form_management()
|
||||
|
||||
def _test_20_alipay_form_management(self):
|
||||
self.assertEqual(self.alipay.state, 'test', 'test without test environment')
|
||||
self.currency = self.currency_yuan
|
||||
self._test_alipay_feedback_processing()
|
||||
|
||||
def _test_alipay_feedback_processing(self):
|
||||
# typical data posted by alipay after client has successfully paid
|
||||
custom_reference = 'test_ref_' + self.alipay.alipay_payment_method
|
||||
alipay_post_data = {
|
||||
'trade_no': '2017112321001003690200384552',
|
||||
'reference': 'test_ref_' + self.alipay.alipay_payment_method,
|
||||
'reference': custom_reference,
|
||||
'total_fee': 1.95,
|
||||
'trade_status': 'TRADE_CLOSED',
|
||||
}
|
||||
@@ -132,33 +152,25 @@ class AlipayTest(PaymentAcquirerCommon):
|
||||
'currency': 'EUR',
|
||||
})
|
||||
|
||||
alipay_post_data['sign'] = self.alipay._build_sign(alipay_post_data)
|
||||
# should raise error about unknown tx
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['payment.transaction'].form_feedback(alipay_post_data, 'alipay')
|
||||
alipay_post_data['sign'] = self.alipay._alipay_build_sign(alipay_post_data)
|
||||
with self.assertRaises(ValidationError): # unknown transactiion
|
||||
self.env['payment.transaction']._handle_feedback_data('alipay', alipay_post_data)
|
||||
|
||||
if self.alipay.alipay_payment_method == 'express_checkout':
|
||||
currency = self.currency_yuan
|
||||
else:
|
||||
currency = self.currency_euro
|
||||
|
||||
# create tx
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'amount': 1.95,
|
||||
'acquirer_id': self.alipay.id,
|
||||
'currency_id': currency.id,
|
||||
'reference': 'test_ref_' + self.alipay.alipay_payment_method,
|
||||
'partner_name': 'Norbert Buyer',
|
||||
'partner_country_id': self.country_france.id
|
||||
'currency_id': self.currency.id,
|
||||
'reference': custom_reference,
|
||||
'partner_id': self.partner.id
|
||||
})
|
||||
|
||||
# validate tx
|
||||
tx.form_feedback(alipay_post_data, 'alipay')
|
||||
# check tx
|
||||
self.assertEqual(tx.state, 'cancel', 'alipay: wrong state after receiving a valid pending notification')
|
||||
self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552', 'alipay: wrong txn_id after receiving a valid pending notification')
|
||||
self.env['payment.transaction']._handle_feedback_data('alipay', alipay_post_data)
|
||||
self.assertEqual(tx.state, 'cancel',
|
||||
'Alipay: wrong state after receiving a valid pending notification')
|
||||
self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552',
|
||||
'Alipay: wrong txn_id after receiving a valid pending notification')
|
||||
|
||||
# update tx
|
||||
# reset the transaction
|
||||
tx.write({'state': 'draft', 'acquirer_reference': False})
|
||||
|
||||
# update notification from alipay should not go through since it has already been set as 'done'
|
||||
@@ -166,37 +178,16 @@ class AlipayTest(PaymentAcquirerCommon):
|
||||
alipay_post_data['trade_status'] = 'TRADE_FINISHED'
|
||||
else:
|
||||
alipay_post_data['trade_status'] = 'TRADE_SUCCESS'
|
||||
alipay_post_data['sign'] = self.alipay._build_sign(alipay_post_data)
|
||||
# validate tx
|
||||
tx.form_feedback(alipay_post_data, 'alipay')
|
||||
# check tx
|
||||
self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552', 'alipay: notification should not go throught since it has already been validated')
|
||||
alipay_post_data['sign'] = self.alipay._alipay_build_sign(alipay_post_data)
|
||||
|
||||
self.env['payment.transaction']._handle_feedback_data('alipay', alipay_post_data)
|
||||
self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552',
|
||||
'Alipay: notification should not go throught since it has already been validated')
|
||||
|
||||
# this time it should go through since the transaction is not validated yet
|
||||
tx.write({'state': 'draft', 'acquirer_reference': False})
|
||||
tx.form_feedback(alipay_post_data, 'alipay')
|
||||
self.assertEqual(tx.state, 'done', 'alipay: wrong state after receiving a valid pending notification')
|
||||
self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552', 'alipay: wrong txn_id after receiving a valid pending notification')
|
||||
|
||||
@mute_logger('odoo.addons.payment_alipay.models.payment', 'ValidationError')
|
||||
def test_30_alipay_bad_configuration(self):
|
||||
self.alipay.alipay_payment_method = 'express_checkout'
|
||||
|
||||
# should raise error since `express_checkout` must only be used with CNY currency
|
||||
with self.assertRaises(ValidationError):
|
||||
# create tx
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'acquirer_id': self.alipay.id,
|
||||
'amount': 4,
|
||||
'currency_id': self.currency_euro.id,
|
||||
'reference': 'test_ref_2',
|
||||
'partner_country_id': self.country_france.id
|
||||
})
|
||||
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'acquirer_id': self.alipay.id,
|
||||
'amount': 4,
|
||||
'currency_id': self.currency_yuan.id,
|
||||
'reference': 'test_ref_2',
|
||||
'partner_country_id': self.country_france.id
|
||||
})
|
||||
self.env['payment.transaction']._handle_feedback_data('alipay', alipay_post_data)
|
||||
self.assertEqual(tx.state, 'done',
|
||||
'Alipay: wrong state after receiving a valid pending notification')
|
||||
self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552',
|
||||
'Alipay: wrong txn_id after receiving a valid pending notification')
|
||||
|
||||