Before this commit, the /payment/pay page would not filter out acquirers configured to allow tokenization when it was although required for the payment. For example, for sales order with a subscription product (which requires tokenization), acquirers that don't allow tokenization would be shown to the customer if they pay from the /payment/pay page, but not if they pay from the portal view of the SO. This commit makes sure the sales order id is correctly propagated to the Subscription app which then flags the payment as requiring tokenization. closes odoo/odoo#86202 X-original-commit: 9ab7b2447f2ece46f1a25335ab76f06323f03d22 Signed-off-by: Antoine Vandevenne (anv) <anv@odoo.com>
426 lines
21 KiB
Python
426 lines
21 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import urllib.parse
|
|
import werkzeug
|
|
|
|
from odoo import _, http
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.fields import Command
|
|
from odoo.http import request
|
|
|
|
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
|
|
|
|
|
|
class PaymentPortal(portal.CustomerPortal):
|
|
|
|
""" This controller contains the foundations for online payments through the portal.
|
|
|
|
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.
|
|
"""
|
|
|
|
@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, invoice_id=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 str invoice_id: The account move for which a payment id made, as a `account.move` id
|
|
: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, invoice_id = tuple(map(
|
|
self.cast_as_int, (currency_id, acquirer_id, partner_id, company_id, invoice_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:
|
|
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)}'
|
|
)
|
|
|
|
# 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
|
|
company = request.env['res.company'].sudo().browse(company_id)
|
|
currency_id = currency_id or company.currency_id.id
|
|
|
|
# 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
|
|
|
|
# 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, **kwargs
|
|
) # In sudo mode to read the fields of acquirers and partner (if not logged in)
|
|
if acquirer_id in acquirers_sudo.ids: # Only keep the desired acquirer if it's suitable
|
|
acquirers_sudo = acquirers_sudo.browse(acquirer_id)
|
|
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']
|
|
|
|
# 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')
|
|
}
|
|
|
|
# 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)
|
|
|
|
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',
|
|
'res_company': company, # Display the correct logo in a multi-company environment
|
|
'partner_is_different': partner_is_different,
|
|
'invoice_id': invoice_id,
|
|
**self._get_custom_rendering_context_values(**kwargs),
|
|
}
|
|
return request.render(self._get_payment_page_template_xmlid(**kwargs), rendering_context)
|
|
|
|
def _get_payment_page_template_xmlid(self, **kwargs):
|
|
return 'payment.pay'
|
|
|
|
@http.route('/my/payment_method', type='http', methods=['GET'], auth='user', website=True)
|
|
def payment_method(self, **kwargs):
|
|
""" 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_sudo = request.env.user.partner_id # env.user is always sudoed
|
|
acquirers_sudo = request.env['payment.acquirer'].sudo()._get_compatible_acquirers(
|
|
request.env.company.id, partner_sudo.id, force_tokenization=True, is_validation=True
|
|
)
|
|
|
|
# Get all partner's tokens for which acquirers are not disabled.
|
|
tokens_sudo = request.env['payment.token'].sudo().search([
|
|
('partner_id', 'in', [partner_sudo.id, partner_sudo.commercial_partner_id.id]),
|
|
('acquirer_id.state', 'in', ['enabled', 'test']),
|
|
])
|
|
|
|
access_token = payment_utils.generate_access_token(partner_sudo.id, None, None)
|
|
rendering_context = {
|
|
'acquirers': acquirers_sudo,
|
|
'tokens': tokens_sudo,
|
|
'reference_prefix': payment_utils.singularize_reference_prefix(prefix='validation'),
|
|
'partner_id': partner_sudo.id,
|
|
'access_token': access_token,
|
|
'transaction_route': '/payment/transaction',
|
|
'landing_route': '/my/payment_method',
|
|
**self._get_custom_rendering_context_values(**kwargs),
|
|
}
|
|
return request.render('payment.payment_methods', rendering_context)
|
|
|
|
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
|
|
"""
|
|
return {}
|
|
|
|
@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.
|
|
|
|
: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
|
|
"""
|
|
# 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."))
|
|
|
|
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
|
|
)
|
|
self._update_landing_route(tx_sudo, access_token) # Add the required parameters to the route
|
|
return tx_sudo._get_processing_values()
|
|
|
|
def _create_transaction(
|
|
self, payment_option_id, reference_prefix, amount, currency_id, partner_id, flow,
|
|
tokenization_requested, landing_route, is_validation=False, invoice_id=None,
|
|
custom_create_values=None, **kwargs
|
|
):
|
|
""" Create a draft transaction based on the payment context and return it.
|
|
|
|
: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 landing_route: The route the user is redirected to after the transaction
|
|
:param bool is_validation: Whether the operation is a validation
|
|
:param int invoice_id: The account move for which a payment id made, as an `account.move` id
|
|
: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 = request.env['payment.token'].browse(payment_option_id)
|
|
acquirer_sudo = token.acquirer_id.sudo()
|
|
token_id = payment_option_id
|
|
tokenize = False
|
|
else:
|
|
raise UserError(
|
|
_("The payment should either be direct, with redirection, or made by a token.")
|
|
)
|
|
|
|
if invoice_id:
|
|
if custom_create_values is None:
|
|
custom_create_values = {}
|
|
custom_create_values['invoice_ids'] = [Command.set([int(invoice_id)])]
|
|
|
|
reference = request.env['payment.transaction']._compute_reference(
|
|
acquirer_sudo.provider,
|
|
prefix=reference_prefix,
|
|
**(custom_create_values or {}),
|
|
**kwargs
|
|
)
|
|
if is_validation: # Acquirers determine the amount and currency in validation operations
|
|
amount = acquirer_sudo._get_validation_amount()
|
|
currency_id = acquirer_sudo._get_validation_currency().id
|
|
|
|
# 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,
|
|
'token_id': token_id,
|
|
'operation': f'online_{flow}' if not is_validation else 'validation',
|
|
'tokenize': tokenize,
|
|
'landing_route': landing_route,
|
|
**(custom_create_values or {}),
|
|
}) # In sudo mode to allow writing on callback fields
|
|
|
|
if flow == 'token':
|
|
tx_sudo._send_payment_request() # Payments by token process transactions immediately
|
|
else:
|
|
tx_sudo._log_sent_message()
|
|
|
|
# Monitor the transaction to make it available in the portal
|
|
PaymentPostProcessing.monitor_transactions(tx_sudo)
|
|
|
|
return tx_sudo
|
|
|
|
@staticmethod
|
|
def _update_landing_route(tx_sudo, access_token):
|
|
""" Add the mandatory parameters to the route and recompute the access token if needed.
|
|
|
|
The generic landing route requires the tx id and access token to be provided since there is
|
|
no document to rely on. The access token is recomputed in case we are dealing with a
|
|
validation transaction (acquirer-specific amount and currency).
|
|
|
|
:param recordset tx_sudo: The transaction whose landing routes to update, as a
|
|
`payment.transaction` record.
|
|
:param str access_token: The access token used to authenticate the partner
|
|
:return: None
|
|
"""
|
|
if tx_sudo.operation == 'validation':
|
|
access_token = payment_utils.generate_access_token(
|
|
tx_sudo.partner_id.id, tx_sudo.amount, tx_sudo.currency_id.id
|
|
)
|
|
tx_sudo.landing_route = f'{tx_sudo.landing_route}' \
|
|
f'?tx_id={tx_sudo.id}&access_token={access_token}'
|
|
|
|
@http.route('/payment/confirmation', type='http', methods=['GET'], auth='public', website=True)
|
|
def payment_confirm(self, tx_id, access_token, **kwargs):
|
|
""" Display the payment confirmation page with the appropriate status message to the user.
|
|
|
|
: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:
|
|
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_sudo.acquirer_id.pending_msg
|
|
elif tx_sudo.state in ('authorized', 'done'):
|
|
status = 'success'
|
|
message = tx_sudo.acquirer_id.done_msg
|
|
elif tx_sudo.state == 'cancel':
|
|
status = 'danger'
|
|
message = tx_sudo.acquirer_id.cancel_msg
|
|
else:
|
|
status = 'danger'
|
|
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/archive_token', type='json', auth='user')
|
|
def archive_token(self, token_id):
|
|
""" Check that a user has write access on a token and archive the token if so.
|
|
|
|
:param int token_id: The token to archive, as a `payment.token` id
|
|
:return: None
|
|
"""
|
|
partner_sudo = request.env.user.partner_id
|
|
token_sudo = request.env['payment.token'].sudo().search([
|
|
('id', '=', token_id),
|
|
# Check that the user owns the token before letting them archive anything
|
|
('partner_id', 'in', [partner_sudo.id, partner_sudo.commercial_partner_id.id])
|
|
])
|
|
if token_sudo:
|
|
token_sudo.active = False
|
|
|
|
@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
|