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

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

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

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