Начальное наполнение
This commit is contained in:
parent
729d8ef1ee
commit
10d1c5bc7f
14
__init__.py
Normal file
14
__init__.py
Normal file
@ -0,0 +1,14 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import utils
|
||||
from . import wizards
|
||||
|
||||
|
||||
def setup_provider(env, code):
|
||||
env['payment.provider']._setup_provider(code)
|
||||
|
||||
|
||||
def reset_payment_provider(env, code):
|
||||
env['payment.provider']._remove_provider(code)
|
47
__manifest__.py
Normal file
47
__manifest__.py
Normal file
@ -0,0 +1,47 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': "Payment Engine",
|
||||
'version': '2.0',
|
||||
'category': 'Hidden',
|
||||
'summary': "The payment engine used by payment provider modules.",
|
||||
'depends': ['onboarding', 'portal'],
|
||||
'data': [
|
||||
# Record data.
|
||||
'data/onboarding_data.xml',
|
||||
'data/payment_method_data.xml',
|
||||
'data/payment_provider_data.xml',
|
||||
'data/payment_cron.xml',
|
||||
|
||||
# QWeb templates.
|
||||
'views/express_checkout_templates.xml',
|
||||
'views/payment_form_templates.xml',
|
||||
'views/portal_templates.xml',
|
||||
|
||||
# Model views.
|
||||
'views/payment_provider_views.xml',
|
||||
'views/payment_method_views.xml', # Depends on `action_payment_provider`.
|
||||
'views/payment_transaction_views.xml',
|
||||
'views/payment_token_views.xml', # Depends on `action_payment_transaction_linked_to_token`.
|
||||
'views/res_partner_views.xml',
|
||||
|
||||
# Security.
|
||||
'security/ir.model.access.csv',
|
||||
'security/payment_security.xml',
|
||||
|
||||
# Wizard views.
|
||||
'wizards/payment_capture_wizard_views.xml',
|
||||
'wizards/payment_link_wizard_views.xml',
|
||||
'wizards/payment_onboarding_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'payment/static/lib/jquery.payment/jquery.payment.js',
|
||||
'payment/static/src/**/*',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'payment/static/src/scss/payment_provider.scss',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
3
controllers/__init__.py
Normal file
3
controllers/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import portal
|
499
controllers/portal.py
Normal file
499
controllers/portal.py
Normal file
@ -0,0 +1,499 @@
|
||||
# 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 AccessError, ValidationError
|
||||
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 through 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,
|
||||
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 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 access_token: The access token used to authenticate the partner.
|
||||
:param dict kwargs: Optional data passed to helper methods.
|
||||
:return: The rendered payment 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, partner_id, company_id = tuple(map(
|
||||
self._cast_as_int, (currency_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 information about ids.
|
||||
|
||||
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 the payment methods and tokens that match the payment context.
|
||||
providers_sudo = request.env['payment.provider'].sudo()._get_compatible_providers(
|
||||
company_id, partner_sudo.id, amount, currency_id=currency.id, **kwargs
|
||||
) # In sudo mode to read the fields of providers and partner (if logged out).
|
||||
payment_methods_sudo = request.env['payment.method'].sudo()._get_compatible_payment_methods(
|
||||
providers_sudo.ids,
|
||||
partner_sudo.id,
|
||||
currency_id=currency.id,
|
||||
) # In sudo mode to read the fields of providers.
|
||||
tokens_sudo = request.env['payment.token'].sudo()._get_available_tokens(
|
||||
providers_sudo.ids, partner_sudo.id
|
||||
) # In sudo mode to be able to read tokens of other partners and the fields of providers.
|
||||
|
||||
# Make sure that the partner's company matches the company passed as parameter.
|
||||
company_mismatch = not PaymentPortal._can_partner_pay_in_company(partner_sudo, company)
|
||||
|
||||
# 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)
|
||||
|
||||
portal_page_values = {
|
||||
'res_company': company, # Display the correct logo in a multi-company environment.
|
||||
'company_mismatch': company_mismatch,
|
||||
'expected_company': company,
|
||||
'partner_is_different': partner_is_different,
|
||||
}
|
||||
payment_form_values = {
|
||||
'show_tokenize_input_mapping': PaymentPortal._compute_show_tokenize_input_mapping(
|
||||
providers_sudo, **kwargs
|
||||
),
|
||||
}
|
||||
payment_context = {
|
||||
'reference_prefix': reference,
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'partner_id': partner_sudo.id,
|
||||
'providers_sudo': providers_sudo,
|
||||
'payment_methods_sudo': payment_methods_sudo,
|
||||
'tokens_sudo': tokens_sudo,
|
||||
'transaction_route': '/payment/transaction',
|
||||
'landing_route': '/payment/confirmation',
|
||||
'access_token': access_token,
|
||||
}
|
||||
rendering_context = {
|
||||
**portal_page_values,
|
||||
**payment_form_values,
|
||||
**payment_context,
|
||||
**self._get_extra_payment_form_values(
|
||||
**payment_context, currency_id=currency.id, **kwargs
|
||||
), # Pass the payment context to allow overriding modules to check document access.
|
||||
}
|
||||
return request.render(self._get_payment_page_template_xmlid(**kwargs), rendering_context)
|
||||
|
||||
@staticmethod
|
||||
def _compute_show_tokenize_input_mapping(providers_sudo, **kwargs):
|
||||
""" Determine for each provider whether the tokenization input should be shown or not.
|
||||
|
||||
:param recordset providers_sudo: The providers for which to determine whether the
|
||||
tokenization input should be shown or not, as a sudoed
|
||||
`payment.provider` recordset.
|
||||
:param dict kwargs: The optional data passed to the helper methods.
|
||||
:return: The mapping of the computed value for each provider id.
|
||||
:rtype: dict
|
||||
"""
|
||||
show_tokenize_input_mapping = {}
|
||||
for provider_sudo in providers_sudo:
|
||||
show_tokenize_input = provider_sudo.allow_tokenization \
|
||||
and not provider_sudo._is_tokenization_required(**kwargs)
|
||||
show_tokenize_input_mapping[provider_sudo.id] = show_tokenize_input
|
||||
return show_tokenize_input_mapping
|
||||
|
||||
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
|
||||
|
||||
# Select all the payment methods and tokens that match the payment context.
|
||||
providers_sudo = request.env['payment.provider'].sudo()._get_compatible_providers(
|
||||
request.env.company.id,
|
||||
partner_sudo.id,
|
||||
0., # There is no amount to pay with validation transactions.
|
||||
force_tokenization=True,
|
||||
is_validation=True,
|
||||
**kwargs,
|
||||
) # In sudo mode to read the fields of providers and partner (if logged out).
|
||||
payment_methods_sudo = request.env['payment.method'].sudo()._get_compatible_payment_methods(
|
||||
providers_sudo.ids,
|
||||
partner_sudo.id,
|
||||
force_tokenization=True,
|
||||
) # In sudo mode to read the fields of providers.
|
||||
tokens_sudo = request.env['payment.token'].sudo()._get_available_tokens(
|
||||
None, partner_sudo.id, is_validation=True
|
||||
) # In sudo mode to read the commercial partner's and providers' fields.
|
||||
|
||||
access_token = payment_utils.generate_access_token(partner_sudo.id, None, None)
|
||||
|
||||
payment_form_values = {
|
||||
'mode': 'validation',
|
||||
'allow_token_selection': False,
|
||||
'allow_token_deletion': True,
|
||||
}
|
||||
payment_context = {
|
||||
'reference_prefix': payment_utils.singularize_reference_prefix(prefix='V'),
|
||||
'partner_id': partner_sudo.id,
|
||||
'providers_sudo': providers_sudo,
|
||||
'payment_methods_sudo': payment_methods_sudo,
|
||||
'tokens_sudo': tokens_sudo,
|
||||
'transaction_route': '/payment/transaction',
|
||||
'landing_route': '/my/payment_method',
|
||||
'access_token': access_token,
|
||||
}
|
||||
rendering_context = {
|
||||
**payment_form_values,
|
||||
**payment_context,
|
||||
**self._get_extra_payment_form_values(**kwargs),
|
||||
}
|
||||
return request.render('payment.payment_methods', rendering_context)
|
||||
|
||||
def _get_extra_payment_form_values(self, **kwargs):
|
||||
""" Return a dict of extra payment form values to include in the rendering context.
|
||||
|
||||
:param dict kwargs: Optional data. This parameter is not used here.
|
||||
:return: The dict of extra payment form 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."))
|
||||
|
||||
self._validate_transaction_kwargs(kwargs, additional_allowed_keys=('reference_prefix',))
|
||||
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 params to the route.
|
||||
return tx_sudo._get_processing_values()
|
||||
|
||||
def _create_transaction(
|
||||
self, provider_id, payment_method_id, token_id, amount, currency_id, partner_id, flow,
|
||||
tokenization_requested, landing_route, reference_prefix=None, is_validation=False,
|
||||
custom_create_values=None, **kwargs
|
||||
):
|
||||
""" Create a draft transaction based on the payment context and return it.
|
||||
|
||||
:param int provider_id: The provider of the provider payment method or token, as a
|
||||
`payment.provider` id.
|
||||
:param int|None payment_method_id: The payment method, if any, as a `payment.method` id.
|
||||
:param int|None token_id: The token, if any, as a `payment.token` id.
|
||||
:param float|None amount: The amount to pay, or `None` if in a validation operation.
|
||||
:param int|None currency_id: The currency of the amount, as a `res.currency` id, or `None`
|
||||
if in a 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 str reference_prefix: The custom prefix to compute the full reference.
|
||||
:param bool is_validation: Whether the operation is a validation.
|
||||
: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: payment.transaction
|
||||
:raise UserError: If the flow is invalid.
|
||||
"""
|
||||
# Prepare create values
|
||||
if flow in ['redirect', 'direct']: # Direct payment or payment with redirection
|
||||
provider_sudo = request.env['payment.provider'].sudo().browse(provider_id)
|
||||
token_id = None
|
||||
tokenize = bool(
|
||||
# Don't tokenize if the user tried to force it through the browser's developer tools
|
||||
provider_sudo.allow_tokenization
|
||||
# Token is only created if required by the flow or requested by the user
|
||||
and (provider_sudo._is_tokenization_required(**kwargs) or tokenization_requested)
|
||||
)
|
||||
elif flow == 'token': # Payment by token
|
||||
token_sudo = request.env['payment.token'].sudo().browse(token_id)
|
||||
|
||||
# Prevent from paying with a token that doesn't belong to the current partner (either
|
||||
# the current user's partner if logged in, or the partner on behalf of whom the payment
|
||||
# is being made).
|
||||
partner_sudo = request.env['res.partner'].sudo().browse(partner_id)
|
||||
if partner_sudo.commercial_partner_id != token_sudo.partner_id.commercial_partner_id:
|
||||
raise AccessError(_("You do not have access to this payment token."))
|
||||
|
||||
provider_sudo = token_sudo.provider_id
|
||||
payment_method_id = token_sudo.payment_method_id.id
|
||||
tokenize = False
|
||||
else:
|
||||
raise ValidationError(
|
||||
_("The payment should either be direct, with redirection, or made by a token.")
|
||||
)
|
||||
|
||||
reference = request.env['payment.transaction']._compute_reference(
|
||||
provider_sudo.code,
|
||||
prefix=reference_prefix,
|
||||
**(custom_create_values or {}),
|
||||
**kwargs
|
||||
)
|
||||
if is_validation: # Providers determine the amount and currency in validation operations
|
||||
amount = provider_sudo._get_validation_amount()
|
||||
currency_id = provider_sudo._get_validation_currency().id
|
||||
|
||||
# Create the transaction
|
||||
tx_sudo = request.env['payment.transaction'].sudo().create({
|
||||
'provider_id': provider_sudo.id,
|
||||
'payment_method_id': payment_method_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_transaction(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 (provider-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 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 information about ids.
|
||||
|
||||
# Display the payment confirmation page to the user
|
||||
return request.render('payment.confirm', qcontext={'tx': tx_sudo})
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _can_partner_pay_in_company(partner, document_company):
|
||||
""" Return whether the provided partner can pay in the provided company.
|
||||
|
||||
The payment is allowed either if the partner's company is not set or if the companies match.
|
||||
|
||||
:param recordset partner: The partner on behalf on which the payment is made, as a
|
||||
`res.partner` record.
|
||||
:param recordset document_company: The company of the document being paid, as a
|
||||
`res.company` record.
|
||||
:return: Whether the payment is allowed.
|
||||
:rtype: str
|
||||
"""
|
||||
return not partner.company_id or partner.company_id == document_company
|
||||
|
||||
@staticmethod
|
||||
def _validate_transaction_kwargs(kwargs, additional_allowed_keys=()):
|
||||
""" Verify that the keys of a transaction route's kwargs are all whitelisted.
|
||||
|
||||
The whitelist consists of all the keys that are expected to be passed to a transaction
|
||||
route, plus optional contextually allowed keys.
|
||||
|
||||
This method must be called in all transaction routes to ensure that no undesired kwarg can
|
||||
be passed as param and then injected in the create values of the transaction.
|
||||
|
||||
:param dict kwargs: The transaction route's kwargs to verify.
|
||||
:param tuple additional_allowed_keys: The keys of kwargs that are contextually allowed.
|
||||
:return: None
|
||||
:raise ValidationError: If some kwargs keys are rejected.
|
||||
"""
|
||||
whitelist = {
|
||||
'provider_id',
|
||||
'payment_method_id',
|
||||
'token_id',
|
||||
'amount',
|
||||
'flow',
|
||||
'tokenization_requested',
|
||||
'landing_route',
|
||||
'is_validation',
|
||||
'csrf_token',
|
||||
}
|
||||
whitelist.update(additional_allowed_keys)
|
||||
rejected_keys = set(kwargs.keys()) - whitelist
|
||||
if rejected_keys:
|
||||
raise ValidationError(
|
||||
_("The following kwargs are not whitelisted: %s", ', '.join(rejected_keys))
|
||||
)
|
88
controllers/post_processing.py
Normal file
88
controllers/post_processing.py
Normal file
@ -0,0 +1,88 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
import psycopg2
|
||||
|
||||
from odoo import 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_ID_KEY = '__payment_monitored_tx_id__'
|
||||
|
||||
@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, **_kwargs):
|
||||
""" Fetch the transaction to display on the status page and finalize its post-processing.
|
||||
|
||||
:return: The post-processing values of the transaction.
|
||||
:rtype: dict
|
||||
"""
|
||||
# Retrieve the last user's transaction from the session.
|
||||
monitored_tx = request.env['payment.transaction'].sudo().browse(
|
||||
self.get_monitored_transaction_id()
|
||||
).exists()
|
||||
if not monitored_tx: # The session might have expired, or the tx has never existed.
|
||||
raise Exception('tx_not_found')
|
||||
|
||||
# Finalize the post-processing of the transaction before redirecting the user to the landing
|
||||
# route and its document.
|
||||
if monitored_tx.state == 'done' and not monitored_tx.is_post_processed:
|
||||
try:
|
||||
monitored_tx._finalize_post_processing()
|
||||
except psycopg2.OperationalError: # The database cursor could not be committed.
|
||||
request.env.cr.rollback() # Rollback and try later.
|
||||
raise Exception('retry')
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
_logger.exception(
|
||||
"Encountered an error while post-processing transaction with id %s:\n%s",
|
||||
monitored_tx.id, e
|
||||
)
|
||||
raise
|
||||
|
||||
# Return the post-processing values to display the transaction summary to the customer.
|
||||
return monitored_tx._get_post_processing_values()
|
||||
|
||||
@classmethod
|
||||
def monitor_transaction(cls, transaction):
|
||||
""" Make the provided transaction id monitored.
|
||||
|
||||
:param payment.transaction transaction: The transaction to monitor.
|
||||
:return: None
|
||||
"""
|
||||
request.session[cls.MONITORED_TX_ID_KEY] = transaction.id
|
||||
|
||||
@classmethod
|
||||
def get_monitored_transaction_id(cls):
|
||||
""" Return the id of transaction being monitored.
|
||||
|
||||
Only the id and not the recordset itself is returned to allow the caller browsing the
|
||||
recordset with sudo privileges, and using the id in a custom query.
|
||||
|
||||
:return: The id of transactions being monitored
|
||||
:rtype: list
|
||||
"""
|
||||
return request.session.get(cls.MONITORED_TX_ID_KEY)
|
4
data/neutralize.sql
Normal file
4
data/neutralize.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- disable generic payment provider
|
||||
UPDATE payment_provider
|
||||
SET state = 'disabled'
|
||||
WHERE state NOT IN ('test', 'disabled');
|
10
data/onboarding_data.xml
Normal file
10
data/onboarding_data.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- ONBOARDING STEPS (WITHOUT PANEL) -->
|
||||
<record id="onboarding_onboarding_step_payment_provider" model="onboarding.onboarding.step">
|
||||
<field name="title">Online Payments</field>
|
||||
<field name="sequence">99</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
15
data/payment_cron.xml
Normal file
15
data/payment_cron.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<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>
|
3931
data/payment_method_data.xml
Normal file
3931
data/payment_method_data.xml
Normal file
File diff suppressed because it is too large
Load Diff
434
data/payment_provider_data.xml
Normal file
434
data/payment_provider_data.xml
Normal file
@ -0,0 +1,434 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="payment_provider_adyen" model="payment.provider">
|
||||
<field name="name">Adyen</field>
|
||||
<field name="image_128" type="base64" file="payment_adyen/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_adyen"/>
|
||||
<!-- https://www.adyen.com/payment-methods -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_ach_direct_debit'),
|
||||
ref('payment.payment_method_affirm'),
|
||||
ref('payment.payment_method_afterpay'),
|
||||
ref('payment.payment_method_alipay'),
|
||||
ref('payment.payment_method_alipay_hk'),
|
||||
ref('payment.payment_method_alma'),
|
||||
ref('payment.payment_method_amazon_pay'),
|
||||
ref('payment.payment_method_bacs_direct_debit'),
|
||||
ref('payment.payment_method_bancontact'),
|
||||
ref('payment.payment_method_benefit'),
|
||||
ref('payment.payment_method_bizum'),
|
||||
ref('payment.payment_method_blik'),
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_cash_app_pay'),
|
||||
ref('payment.payment_method_clearpay'),
|
||||
ref('payment.payment_method_dana'),
|
||||
ref('payment.payment_method_duitnow'),
|
||||
ref('payment.payment_method_elo'),
|
||||
ref('payment.payment_method_eps'),
|
||||
ref('payment.payment_method_fpx'),
|
||||
ref('payment.payment_method_gcash'),
|
||||
ref('payment.payment_method_giropay'),
|
||||
ref('payment.payment_method_gopay'),
|
||||
ref('payment.payment_method_hipercard'),
|
||||
ref('payment.payment_method_ideal'),
|
||||
ref('payment.payment_method_kakaopay'),
|
||||
ref('payment.payment_method_klarna'),
|
||||
ref('payment.payment_method_klarna_paynow'),
|
||||
ref('payment.payment_method_klarna_pay_over_time'),
|
||||
ref('payment.payment_method_knet'),
|
||||
ref('payment.payment_method_mbway'),
|
||||
ref('payment.payment_method_mobile_pay'),
|
||||
ref('payment.payment_method_momo'),
|
||||
ref('payment.payment_method_multibanco'),
|
||||
ref('payment.payment_method_napas_card'),
|
||||
ref('payment.payment_method_online_banking_czech_republic'),
|
||||
ref('payment.payment_method_online_banking_india'),
|
||||
ref('payment.payment_method_online_banking_slovakia'),
|
||||
ref('payment.payment_method_online_banking_thailand'),
|
||||
ref('payment.payment_method_open_banking'),
|
||||
ref('payment.payment_method_p24'),
|
||||
ref('payment.payment_method_paybright'),
|
||||
ref('payment.payment_method_paysafecard'),
|
||||
ref('payment.payment_method_paynow'),
|
||||
ref('payment.payment_method_paypal'),
|
||||
ref('payment.payment_method_paytm'),
|
||||
ref('payment.payment_method_paytrail'),
|
||||
ref('payment.payment_method_pix'),
|
||||
ref('payment.payment_method_promptpay'),
|
||||
ref('payment.payment_method_ratepay'),
|
||||
ref('payment.payment_method_samsung_pay'),
|
||||
ref('payment.payment_method_sepa_direct_debit'),
|
||||
ref('payment.payment_method_sofort'),
|
||||
ref('payment.payment_method_swish'),
|
||||
ref('payment.payment_method_touch_n_go'),
|
||||
ref('payment.payment_method_trustly'),
|
||||
ref('payment.payment_method_twint'),
|
||||
ref('payment.payment_method_upi'),
|
||||
ref('payment.payment_method_vipps'),
|
||||
ref('payment.payment_method_wallets_india'),
|
||||
ref('payment.payment_method_walley'),
|
||||
ref('payment.payment_method_wechat_pay'),
|
||||
ref('payment.payment_method_zip'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_aps" model="payment.provider">
|
||||
<field name="name">Amazon Payment Services</field>
|
||||
<field name="image_128" type="base64" file="payment_aps/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_aps"/>
|
||||
<!-- https://paymentservices.amazon.com/docs/EN/24.html -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_mada'),
|
||||
ref('payment.payment_method_knet'),
|
||||
ref('payment.payment_method_meeza'),
|
||||
ref('payment.payment_method_naps'),
|
||||
ref('payment.payment_method_omannet'),
|
||||
ref('payment.payment_method_benefit'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_asiapay" model="payment.provider">
|
||||
<field name="name">Asiapay</field>
|
||||
<field name="image_128" type="base64" file="payment_asiapay/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_asiapay"/>
|
||||
<!-- See https://www.asiapay.com/payment.html#option -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_alipay'),
|
||||
ref('payment.payment_method_wechat_pay'),
|
||||
ref('payment.payment_method_poli'),
|
||||
ref('payment.payment_method_afterpay'),
|
||||
ref('payment.payment_method_clearpay'),
|
||||
ref('payment.payment_method_humm'),
|
||||
ref('payment.payment_method_zip'),
|
||||
ref('payment.payment_method_paypal'),
|
||||
ref('payment.payment_method_atome'),
|
||||
ref('payment.payment_method_pace'),
|
||||
ref('payment.payment_method_shopback'),
|
||||
ref('payment.payment_method_grabpay'),
|
||||
ref('payment.payment_method_samsung_pay'),
|
||||
ref('payment.payment_method_hoolah'),
|
||||
ref('payment.payment_method_boost'),
|
||||
ref('payment.payment_method_duitnow'),
|
||||
ref('payment.payment_method_touch_n_go'),
|
||||
ref('payment.payment_method_bancnet'),
|
||||
ref('payment.payment_method_gcash'),
|
||||
ref('payment.payment_method_paynow'),
|
||||
ref('payment.payment_method_linepay'),
|
||||
ref('payment.payment_method_bangkok_bank'),
|
||||
ref('payment.payment_method_krungthai_bank'),
|
||||
ref('payment.payment_method_uob'),
|
||||
ref('payment.payment_method_scb'),
|
||||
ref('payment.payment_method_bank_of_ayudhya'),
|
||||
ref('payment.payment_method_kasikorn_bank'),
|
||||
ref('payment.payment_method_rabbit_line_pay'),
|
||||
ref('payment.payment_method_truemoney'),
|
||||
ref('payment.payment_method_fpx'),
|
||||
ref('payment.payment_method_fps'),
|
||||
ref('payment.payment_method_hd'),
|
||||
ref('payment.payment_method_maybank'),
|
||||
ref('payment.payment_method_pay_id'),
|
||||
ref('payment.payment_method_promptpay'),
|
||||
ref('payment.payment_method_techcom'),
|
||||
ref('payment.payment_method_tienphong'),
|
||||
ref('payment.payment_method_ttb'),
|
||||
ref('payment.payment_method_upi'),
|
||||
ref('payment.payment_method_vietcom'),
|
||||
ref('payment.payment_method_tendopay'),
|
||||
ref('payment.payment_method_alipay_hk'),
|
||||
ref('payment.payment_method_bharatqr'),
|
||||
ref('payment.payment_method_momo'),
|
||||
ref('payment.payment_method_octopus'),
|
||||
ref('payment.payment_method_maya'),
|
||||
ref('payment.payment_method_uatp'),
|
||||
ref('payment.payment_method_tenpay'),
|
||||
ref('payment.payment_method_enets'),
|
||||
ref('payment.payment_method_jkopay'),
|
||||
ref('payment.payment_method_payme'),
|
||||
ref('payment.payment_method_tmb'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_authorize" model="payment.provider">
|
||||
<field name="name">Authorize.net</field>
|
||||
<field name="image_128"
|
||||
type="base64"
|
||||
file="payment_authorize/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_authorize"/>
|
||||
<!-- https://www.authorize.net/solutions/merchantsolutions/onlinemerchantaccount/ -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_ach_direct_debit'),
|
||||
ref('payment.payment_method_card'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_buckaroo" model="payment.provider">
|
||||
<field name="name">Buckaroo</field>
|
||||
<field name="image_128"
|
||||
type="base64"
|
||||
file="payment_buckaroo/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_buckaroo"/>
|
||||
<!-- https://www.buckaroo-payments.com/products/payment-methods/ -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_bancontact'),
|
||||
ref('payment.payment_method_bank_reference'),
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_paypal'),
|
||||
ref('payment.payment_method_ideal'),
|
||||
ref('payment.payment_method_afterpay_riverty'),
|
||||
ref('payment.payment_method_sepa_direct_debit'),
|
||||
ref('payment.payment_method_alipay'),
|
||||
ref('payment.payment_method_wechat_pay'),
|
||||
ref('payment.payment_method_klarna'),
|
||||
ref('payment.payment_method_trustly'),
|
||||
ref('payment.payment_method_sofort'),
|
||||
ref('payment.payment_method_in3'),
|
||||
ref('payment.payment_method_tinka'),
|
||||
ref('payment.payment_method_billink'),
|
||||
ref('payment.payment_method_kbc_cbc'),
|
||||
ref('payment.payment_method_belfius'),
|
||||
ref('payment.payment_method_giropay'),
|
||||
ref('payment.payment_method_p24'),
|
||||
ref('payment.payment_method_poste_pay'),
|
||||
ref('payment.payment_method_eps'),
|
||||
ref('payment.payment_method_cartes_bancaires'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_demo" model="payment.provider">
|
||||
<field name="name">Demo</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="image_128" type="base64" file="payment_demo/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_demo"/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_flutterwave" model="payment.provider">
|
||||
<field name="name">Flutterwave</field>
|
||||
<field name="image_128"
|
||||
type="base64"
|
||||
file="payment_flutterwave/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_flutterwave"/>
|
||||
<!-- https://developer.flutterwave.com/docs/collecting-payments/payment-methods/ -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_mpesa'),
|
||||
ref('payment.payment_method_mobile_money'),
|
||||
ref('payment.payment_method_bank_transfer'),
|
||||
ref('payment.payment_method_bank_account'),
|
||||
ref('payment.payment_method_credit'),
|
||||
ref('payment.payment_method_paypal'),
|
||||
ref('payment.payment_method_ussd'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_mercado_pago" model="payment.provider">
|
||||
<field name="name">Mercado Pago</field>
|
||||
<field name="image_128"
|
||||
type="base64"
|
||||
file="payment_mercado_pago/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_mercado_pago"/>
|
||||
|
||||
<!-- Payment methods must be fetched from the API. See
|
||||
https://www.mercadopago.com.ar/developers/en/reference/payment_methods/_payment_methods/
|
||||
-->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_bank_transfer'),
|
||||
ref('payment.payment_method_paypal'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_mollie" model="payment.provider">
|
||||
<field name="name">Mollie</field>
|
||||
<field name="image_128" type="base64" file="payment_mollie/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_mollie"/>
|
||||
<!-- https://www.mollie.com/en/payments -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_bancontact'),
|
||||
ref('payment.payment_method_bank_transfer'),
|
||||
ref('payment.payment_method_belfius'),
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_eps'),
|
||||
ref('payment.payment_method_giropay'),
|
||||
ref('payment.payment_method_ideal'),
|
||||
ref('payment.payment_method_paypal'),
|
||||
ref('payment.payment_method_paysafecard'),
|
||||
ref('payment.payment_method_p24'),
|
||||
ref('payment.payment_method_sofort'),
|
||||
])]"
|
||||
/>
|
||||
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_paypal" model="payment.provider">
|
||||
<field name="name">PayPal</field>
|
||||
<field name="image_128" type="base64" file="payment_paypal/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_paypal"/>
|
||||
<!-- https://www.paypal.com/us/selfhelp/article/Which-credit-cards-can-I-accept-with-PayPal-Merchant-Services-FAQ1525#business -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_paypal'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_razorpay" model="payment.provider">
|
||||
<field name="name">Razorpay</field>
|
||||
<field name="image_128" type="base64" file="payment_razorpay/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_razorpay"/>
|
||||
<!-- https://razorpay.com/docs/payments/payment-methods/#supported-payment-methods -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_netbanking'),
|
||||
ref('payment.payment_method_upi'),
|
||||
ref('payment.payment_method_wallets_india'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_sepa_direct_debit" model="payment.provider">
|
||||
<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="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_sepa_direct_debit'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_sips" model="payment.provider">
|
||||
<field name="name">Sips</field>
|
||||
<field name="image_128" type="base64" file="payment_sips/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_sips"/>
|
||||
<!-- See http://sips.worldline.com/en-us/home/features/payment-types-and-acquirers.html -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_ideal'),
|
||||
ref('payment.payment_method_sofort'),
|
||||
ref('payment.payment_method_giropay'),
|
||||
ref('payment.payment_method_kbc_cbc'),
|
||||
ref('payment.payment_method_paypal'),
|
||||
ref('payment.payment_method_samsung_pay'),
|
||||
ref('payment.payment_method_bancontact'),
|
||||
ref('payment.payment_method_lyfpay'),
|
||||
ref('payment.payment_method_lydia'),
|
||||
ref('payment.payment_method_floa_bank'),
|
||||
ref('payment.payment_method_cofidis'),
|
||||
ref('payment.payment_method_frafinance'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_stripe" model="payment.provider">
|
||||
<field name="name">Stripe</field>
|
||||
<field name="image_128" type="base64" file="payment_stripe/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_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_method_ids"
|
||||
eval="[Command.set([
|
||||
ref('payment.payment_method_ach_direct_debit'),
|
||||
ref('payment.payment_method_acss_debit'),
|
||||
ref('payment.payment_method_affirm'),
|
||||
ref('payment.payment_method_afterpay'),
|
||||
ref('payment.payment_method_alipay'),
|
||||
ref('payment.payment_method_bacs_direct_debit'),
|
||||
ref('payment.payment_method_bancontact'),
|
||||
ref('payment.payment_method_becs_direct_debit'),
|
||||
ref('payment.payment_method_blik'),
|
||||
ref('payment.payment_method_boleto'),
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_cash_app_pay'),
|
||||
ref('payment.payment_method_clearpay'),
|
||||
ref('payment.payment_method_eps'),
|
||||
ref('payment.payment_method_fpx'),
|
||||
ref('payment.payment_method_giropay'),
|
||||
ref('payment.payment_method_grabpay'),
|
||||
ref('payment.payment_method_ideal'),
|
||||
ref('payment.payment_method_klarna'),
|
||||
ref('payment.payment_method_mobile_pay'),
|
||||
ref('payment.payment_method_multibanco'),
|
||||
ref('payment.payment_method_p24'),
|
||||
ref('payment.payment_method_paynow'),
|
||||
ref('payment.payment_method_paypal'),
|
||||
ref('payment.payment_method_pix'),
|
||||
ref('payment.payment_method_promptpay'),
|
||||
ref('payment.payment_method_revolut_pay'),
|
||||
ref('payment.payment_method_sepa_direct_debit'),
|
||||
ref('payment.payment_method_sofort'),
|
||||
ref('payment.payment_method_upi'),
|
||||
ref('payment.payment_method_wechat_pay'),
|
||||
ref('payment.payment_method_zip'),
|
||||
])]"
|
||||
/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_transfer" model="payment.provider">
|
||||
<field name="name">Wire Transfer</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="image_128" type="base64" file="payment_custom/static/description/icon.png"/>
|
||||
<field name="module_id" ref="base.module_payment_custom"/>
|
||||
</record>
|
||||
|
||||
<record id="payment_provider_xendit" model="payment.provider">
|
||||
<field name="name">Xendit</field>
|
||||
<field name="image_128"
|
||||
type="base64"
|
||||
file="payment_xendit/static/description/icon.png"
|
||||
/>
|
||||
<field name="module_id" ref="base.module_payment_xendit"/>
|
||||
<!-- See https://docs.xendit.co/payment-link/payment-channels for payment methods. -->
|
||||
<field name="payment_method_ids"
|
||||
eval="[(6, 0, [
|
||||
ref('payment.payment_method_7eleven'),
|
||||
ref('payment.payment_method_akulaku'),
|
||||
ref('payment.payment_method_bank_bca'),
|
||||
ref('payment.payment_method_bank_permata'),
|
||||
ref('payment.payment_method_billease'),
|
||||
ref('payment.payment_method_bni'),
|
||||
ref('payment.payment_method_bri'),
|
||||
ref('payment.payment_method_bsi'),
|
||||
ref('payment.payment_method_card'),
|
||||
ref('payment.payment_method_cashalo'),
|
||||
ref('payment.payment_method_cebuana'),
|
||||
ref('payment.payment_method_cimb_niaga'),
|
||||
ref('payment.payment_method_dana'),
|
||||
ref('payment.payment_method_gcash'),
|
||||
ref('payment.payment_method_grabpay'),
|
||||
ref('payment.payment_method_jeniuspay'),
|
||||
ref('payment.payment_method_kredivo'),
|
||||
ref('payment.payment_method_linkaja'),
|
||||
ref('payment.payment_method_mandiri'),
|
||||
ref('payment.payment_method_maya'),
|
||||
ref('payment.payment_method_ovo'),
|
||||
ref('payment.payment_method_qris'),
|
||||
ref('payment.payment_method_shopeepay'),
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
2315
i18n/af.po
Normal file
2315
i18n/af.po
Normal file
File diff suppressed because it is too large
Load Diff
2311
i18n/am.po
Normal file
2311
i18n/am.po
Normal file
File diff suppressed because it is too large
Load Diff
2336
i18n/ar.po
Normal file
2336
i18n/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
2316
i18n/az.po
Normal file
2316
i18n/az.po
Normal file
File diff suppressed because it is too large
Load Diff
2250
i18n/bg.po
Normal file
2250
i18n/bg.po
Normal file
File diff suppressed because it is too large
Load Diff
2316
i18n/bs.po
Normal file
2316
i18n/bs.po
Normal file
File diff suppressed because it is too large
Load Diff
2316
i18n/ca.po
Normal file
2316
i18n/ca.po
Normal file
File diff suppressed because it is too large
Load Diff
2269
i18n/cs.po
Normal file
2269
i18n/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
2246
i18n/da.po
Normal file
2246
i18n/da.po
Normal file
File diff suppressed because it is too large
Load Diff
2387
i18n/de.po
Normal file
2387
i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
2319
i18n/el.po
Normal file
2319
i18n/el.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/en_GB.po
Normal file
2314
i18n/en_GB.po
Normal file
File diff suppressed because it is too large
Load Diff
2368
i18n/es.po
Normal file
2368
i18n/es.po
Normal file
File diff suppressed because it is too large
Load Diff
2369
i18n/es_419.po
Normal file
2369
i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/es_BO.po
Normal file
2314
i18n/es_BO.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/es_CL.po
Normal file
2314
i18n/es_CL.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/es_CO.po
Normal file
2314
i18n/es_CO.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/es_CR.po
Normal file
2314
i18n/es_CR.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/es_DO.po
Normal file
2314
i18n/es_DO.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/es_EC.po
Normal file
2314
i18n/es_EC.po
Normal file
File diff suppressed because it is too large
Load Diff
2313
i18n/es_PA.po
Normal file
2313
i18n/es_PA.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/es_PE.po
Normal file
2314
i18n/es_PE.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/es_PY.po
Normal file
2314
i18n/es_PY.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/es_VE.po
Normal file
2314
i18n/es_VE.po
Normal file
File diff suppressed because it is too large
Load Diff
2280
i18n/et.po
Normal file
2280
i18n/et.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/eu.po
Normal file
2314
i18n/eu.po
Normal file
File diff suppressed because it is too large
Load Diff
2255
i18n/fa.po
Normal file
2255
i18n/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
2374
i18n/fi.po
Normal file
2374
i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/fo.po
Normal file
2314
i18n/fo.po
Normal file
File diff suppressed because it is too large
Load Diff
2387
i18n/fr.po
Normal file
2387
i18n/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
2313
i18n/fr_BE.po
Normal file
2313
i18n/fr_BE.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/fr_CA.po
Normal file
2314
i18n/fr_CA.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/gl.po
Normal file
2314
i18n/gl.po
Normal file
File diff suppressed because it is too large
Load Diff
2315
i18n/gu.po
Normal file
2315
i18n/gu.po
Normal file
File diff suppressed because it is too large
Load Diff
2257
i18n/he.po
Normal file
2257
i18n/he.po
Normal file
File diff suppressed because it is too large
Load Diff
2317
i18n/hi.po
Normal file
2317
i18n/hi.po
Normal file
File diff suppressed because it is too large
Load Diff
2328
i18n/hr.po
Normal file
2328
i18n/hr.po
Normal file
File diff suppressed because it is too large
Load Diff
2246
i18n/hu.po
Normal file
2246
i18n/hu.po
Normal file
File diff suppressed because it is too large
Load Diff
2313
i18n/hy.po
Normal file
2313
i18n/hy.po
Normal file
File diff suppressed because it is too large
Load Diff
2368
i18n/id.po
Normal file
2368
i18n/id.po
Normal file
File diff suppressed because it is too large
Load Diff
2315
i18n/is.po
Normal file
2315
i18n/is.po
Normal file
File diff suppressed because it is too large
Load Diff
2375
i18n/it.po
Normal file
2375
i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
2289
i18n/ja.po
Normal file
2289
i18n/ja.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/ka.po
Normal file
2314
i18n/ka.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/kab.po
Normal file
2314
i18n/kab.po
Normal file
File diff suppressed because it is too large
Load Diff
2313
i18n/kk.po
Normal file
2313
i18n/kk.po
Normal file
File diff suppressed because it is too large
Load Diff
2320
i18n/km.po
Normal file
2320
i18n/km.po
Normal file
File diff suppressed because it is too large
Load Diff
2298
i18n/ko.po
Normal file
2298
i18n/ko.po
Normal file
File diff suppressed because it is too large
Load Diff
2315
i18n/lb.po
Normal file
2315
i18n/lb.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/lo.po
Normal file
2314
i18n/lo.po
Normal file
File diff suppressed because it is too large
Load Diff
2246
i18n/lt.po
Normal file
2246
i18n/lt.po
Normal file
File diff suppressed because it is too large
Load Diff
2249
i18n/lv.po
Normal file
2249
i18n/lv.po
Normal file
File diff suppressed because it is too large
Load Diff
2314
i18n/mk.po
Normal file
2314
i18n/mk.po
Normal file
File diff suppressed because it is too large
Load Diff
2324
i18n/mn.po
Normal file
2324
i18n/mn.po
Normal file
File diff suppressed because it is too large
Load Diff
2321
i18n/nb.po
Normal file
2321
i18n/nb.po
Normal file
File diff suppressed because it is too large
Load Diff
2311
i18n/ne.po
Normal file
2311
i18n/ne.po
Normal file
File diff suppressed because it is too large
Load Diff
2380
i18n/nl.po
Normal file
2380
i18n/nl.po
Normal file
File diff suppressed because it is too large
Load Diff
2235
i18n/payment.pot
Normal file
2235
i18n/payment.pot
Normal file
File diff suppressed because it is too large
Load Diff
2326
i18n/pl.po
Normal file
2326
i18n/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
2243
i18n/pt.po
Normal file
2243
i18n/pt.po
Normal file
File diff suppressed because it is too large
Load Diff
2372
i18n/pt_BR.po
Normal file
2372
i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load Diff
2326
i18n/ro.po
Normal file
2326
i18n/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
2372
i18n/ru.po
Normal file
2372
i18n/ru.po
Normal file
File diff suppressed because it is too large
Load Diff
2235
i18n/sk.po
Normal file
2235
i18n/sk.po
Normal file
File diff suppressed because it is too large
Load Diff
2252
i18n/sl.po
Normal file
2252
i18n/sl.po
Normal file
File diff suppressed because it is too large
Load Diff
2315
i18n/sq.po
Normal file
2315
i18n/sq.po
Normal file
File diff suppressed because it is too large
Load Diff
2303
i18n/sr.po
Normal file
2303
i18n/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
2317
i18n/sr@latin.po
Normal file
2317
i18n/sr@latin.po
Normal file
File diff suppressed because it is too large
Load Diff
2251
i18n/sv.po
Normal file
2251
i18n/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
2315
i18n/ta.po
Normal file
2315
i18n/ta.po
Normal file
File diff suppressed because it is too large
Load Diff
2342
i18n/th.po
Normal file
2342
i18n/th.po
Normal file
File diff suppressed because it is too large
Load Diff
2324
i18n/tr.po
Normal file
2324
i18n/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
2309
i18n/uk.po
Normal file
2309
i18n/uk.po
Normal file
File diff suppressed because it is too large
Load Diff
2351
i18n/vi.po
Normal file
2351
i18n/vi.po
Normal file
File diff suppressed because it is too large
Load Diff
2286
i18n/zh_CN.po
Normal file
2286
i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/zh_TW.po
Normal file
2284
i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load Diff
10
models/__init__.py
Normal file
10
models/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import ir_http
|
||||
from . import onboarding_step
|
||||
from . import payment_method
|
||||
from . import payment_provider
|
||||
from . import payment_token
|
||||
from . import payment_transaction
|
||||
from . import res_company
|
||||
from . import res_partner
|
12
models/ir_http.py
Normal file
12
models/ir_http.py
Normal file
@ -0,0 +1,12 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@classmethod
|
||||
def _get_translation_frontend_modules_name(cls):
|
||||
mods = super(IrHttp, cls)._get_translation_frontend_modules_name()
|
||||
return mods + ['payment']
|
12
models/onboarding_step.py
Normal file
12
models/onboarding_step.py
Normal file
@ -0,0 +1,12 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class OnboardingStep(models.Model):
|
||||
_inherit = 'onboarding.onboarding.step'
|
||||
|
||||
@api.model
|
||||
def action_validate_step_payment_provider(self):
|
||||
""" Override of `onboarding` to validate other steps as well. """
|
||||
return self.action_validate_step('payment.onboarding_onboarding_step_payment_provider')
|
257
models/payment_method.py
Normal file
257
models/payment_method.py
Normal file
@ -0,0 +1,257 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import Command, _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
class PaymentMethod(models.Model):
|
||||
_name = 'payment.method'
|
||||
_description = "Payment Method"
|
||||
_order = 'active desc, sequence, name'
|
||||
|
||||
name = fields.Char(string="Name", required=True)
|
||||
code = fields.Char(
|
||||
string="Code", help="The technical code of this payment method.", required=True
|
||||
)
|
||||
sequence = fields.Integer(string="Sequence", default=1)
|
||||
primary_payment_method_id = fields.Many2one(
|
||||
string="Primary Payment Method",
|
||||
help="The primary payment method of the current payment method, if the latter is a brand."
|
||||
"\nFor example, \"Card\" is the primary payment method of the card brand \"VISA\".",
|
||||
comodel_name='payment.method',
|
||||
)
|
||||
brand_ids = fields.One2many(
|
||||
string="Brands",
|
||||
help="The brands of the payment methods that will be displayed on the payment form.",
|
||||
comodel_name='payment.method',
|
||||
inverse_name='primary_payment_method_id',
|
||||
)
|
||||
is_primary = fields.Boolean(
|
||||
string="Is Primary Payment Method",
|
||||
compute='_compute_is_primary',
|
||||
search='_search_is_primary',
|
||||
)
|
||||
provider_ids = fields.Many2many(
|
||||
string="Providers",
|
||||
help="The list of providers supporting this payment method.",
|
||||
comodel_name='payment.provider',
|
||||
)
|
||||
active = fields.Boolean(string="Active", default=True)
|
||||
image = fields.Image(
|
||||
string="Image",
|
||||
help="The base image used for this payment method; in a 64x64 px format.",
|
||||
max_width=64,
|
||||
max_height=64,
|
||||
required=True,
|
||||
)
|
||||
image_payment_form = fields.Image(
|
||||
string="The resized image displayed on the payment form.",
|
||||
related='image',
|
||||
store=True,
|
||||
max_width=45,
|
||||
max_height=30,
|
||||
)
|
||||
|
||||
# Feature support fields.
|
||||
support_tokenization = fields.Boolean(
|
||||
string="Tokenization Supported",
|
||||
help="Tokenization is the process of saving the payment details as a token that can later"
|
||||
" be reused without having to enter the payment details again.",
|
||||
)
|
||||
support_express_checkout = fields.Boolean(
|
||||
string="Express Checkout Supported",
|
||||
help="Express checkout allows customers to pay faster by using a payment method that"
|
||||
" provides all required billing and shipping information, thus allowing to skip the"
|
||||
" checkout process.",
|
||||
)
|
||||
support_refund = fields.Selection(
|
||||
string="Type of Refund Supported",
|
||||
selection=[('full_only', "Full Only"), ('partial', "Partial")],
|
||||
help="Refund is a feature allowing to refund customers directly from the payment in Odoo.",
|
||||
)
|
||||
supported_country_ids = fields.Many2many(
|
||||
string="Supported Countries",
|
||||
comodel_name='res.country',
|
||||
help="The list of countries in which this payment method can be used (if the provider"
|
||||
" allows it). In other countries, this payment method is not available to customers."
|
||||
)
|
||||
supported_currency_ids = fields.Many2many(
|
||||
string="Supported Currencies",
|
||||
comodel_name='res.currency',
|
||||
help="The list of currencies for that are supported by this payment method (if the provider"
|
||||
" allows it). When paying with another currency, this payment method is not available "
|
||||
"to customers.",
|
||||
)
|
||||
|
||||
#=== COMPUTE METHODS ===#
|
||||
|
||||
def _compute_is_primary(self):
|
||||
for payment_method in self:
|
||||
payment_method.is_primary = not payment_method.primary_payment_method_id
|
||||
|
||||
def _search_is_primary(self, operator, value):
|
||||
if operator == '=' and value is True:
|
||||
return [('primary_payment_method_id', '=', False)]
|
||||
elif operator == '=' and value is False:
|
||||
return [('primary_payment_method_id', '!=', False)]
|
||||
else:
|
||||
raise NotImplementedError(_("Operation not supported."))
|
||||
|
||||
#=== ONCHANGE METHODS ===#
|
||||
|
||||
@api.onchange('provider_ids')
|
||||
def _onchange_provider_ids_warn_before_disabling_tokens(self):
|
||||
""" Display a warning about the consequences of detaching a payment method from a provider.
|
||||
|
||||
Let the user know that tokens related to a provider get archived if it is detached from the
|
||||
payment methods associated with those tokens.
|
||||
|
||||
:return: A client action with the warning message, if any.
|
||||
:rtype: dict
|
||||
"""
|
||||
detached_providers = self._origin.provider_ids.filtered(
|
||||
lambda p: p.id not in self.provider_ids.ids
|
||||
) # Cannot use recordset difference operation because self.provider_ids is a set of NewIds.
|
||||
if detached_providers:
|
||||
related_tokens = self.env['payment.token'].with_context(active_test=True).search([
|
||||
('payment_method_id', 'in', (self._origin + self._origin.brand_ids).ids),
|
||||
('provider_id', 'in', detached_providers.ids),
|
||||
]) # Fix `active_test` in the context forwarded by the view.
|
||||
if related_tokens:
|
||||
return {
|
||||
'warning': {
|
||||
'title': _("Warning"),
|
||||
'message': _(
|
||||
"This action will also archive %s tokens that are registered with this "
|
||||
"payment method. Archiving tokens is irreversible.", len(related_tokens)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@api.onchange('provider_ids')
|
||||
def _onchange_provider_ids_warn_before_attaching_payment_method(self):
|
||||
""" Display a warning before attaching a payment method to a provider.
|
||||
|
||||
:return: A client action with the warning message, if any.
|
||||
:rtype: dict
|
||||
"""
|
||||
attached_providers = self.provider_ids.filtered(
|
||||
lambda p: p.id.origin not in self._origin.provider_ids.ids
|
||||
)
|
||||
if attached_providers:
|
||||
return {
|
||||
'warning': {
|
||||
'title': _("Warning"),
|
||||
'message': _(
|
||||
"Please make sure that %(payment_method)s is supported by %(provider)s.",
|
||||
payment_method=self.name,
|
||||
provider=', '.join(attached_providers.mapped('name'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#=== CRUD METHODS ===#
|
||||
|
||||
def write(self, values):
|
||||
# Handle payment methods being detached from providers.
|
||||
if 'provider_ids' in values:
|
||||
detached_provider_ids = [
|
||||
vals[0] for command, *vals in values['provider_ids'] if command == Command.UNLINK
|
||||
]
|
||||
if detached_provider_ids:
|
||||
linked_tokens = self.env['payment.token'].with_context(active_test=True).search([
|
||||
('provider_id', 'in', detached_provider_ids),
|
||||
('payment_method_id', 'in', (self + self.brand_ids).ids),
|
||||
]) # Fix `active_test` in the context forwarded by the view.
|
||||
linked_tokens.active = False
|
||||
|
||||
# Prevent enabling a payment method if it is not linked to an enabled provider.
|
||||
if values.get('active'):
|
||||
for pm in self:
|
||||
primary_pm = pm if pm.is_primary else pm.primary_payment_method_id
|
||||
if (
|
||||
not primary_pm.active # Don't bother for already enabled payment methods.
|
||||
and all(p.state == 'disabled' for p in primary_pm.provider_ids)
|
||||
):
|
||||
raise UserError(_(
|
||||
"This payment method needs a partner in crime; you should enable a payment"
|
||||
" provider supporting this method first."
|
||||
))
|
||||
|
||||
return super().write(values)
|
||||
|
||||
# === BUSINESS METHODS === #
|
||||
|
||||
def _get_compatible_payment_methods(
|
||||
self, provider_ids, partner_id, currency_id=None, force_tokenization=False,
|
||||
is_express_checkout=False
|
||||
):
|
||||
""" Search and return the payment methods matching the compatibility criteria.
|
||||
|
||||
The compatibility criteria are that payment methods must: be supported by at least one of
|
||||
the providers; support the country of the partner if it exists; be primary payment methods
|
||||
(not a brand). If provided, the optional keyword arguments further refine the criteria.
|
||||
|
||||
:param list provider_ids: The list of providers by which the payment methods must be at
|
||||
least partially supported to be considered compatible, as a list
|
||||
of `payment.provider` ids.
|
||||
:param int partner_id: The partner making the payment, as a `res.partner` id.
|
||||
:param int currency_id: The payment currency, if known beforehand, as a `res.currency` id.
|
||||
:param bool force_tokenization: Whether only payment methods supporting tokenization can be
|
||||
matched.
|
||||
:param bool is_express_checkout: Whether the payment is made through express checkout.
|
||||
:return: The compatible payment methods.
|
||||
:rtype: payment.method
|
||||
"""
|
||||
# Compute the base domain for compatible payment methods.
|
||||
domain = [('provider_ids', 'in', provider_ids), ('is_primary', '=', True)]
|
||||
|
||||
# Handle the partner country; allow all countries if the list is empty.
|
||||
partner = self.env['res.partner'].browse(partner_id)
|
||||
if partner.country_id: # The partner country must either not be set or be supported.
|
||||
domain = expression.AND([
|
||||
domain, [
|
||||
'|',
|
||||
('supported_country_ids', '=', False),
|
||||
('supported_country_ids', 'in', [partner.country_id.id]),
|
||||
]
|
||||
])
|
||||
|
||||
# Handle the supported currencies; allow all currencies if the list is empty.
|
||||
if currency_id:
|
||||
domain = expression.AND([
|
||||
domain, [
|
||||
'|',
|
||||
('supported_currency_ids', '=', False),
|
||||
('supported_currency_ids', 'in', [currency_id]),
|
||||
]
|
||||
])
|
||||
|
||||
# Handle tokenization support requirements.
|
||||
if force_tokenization:
|
||||
domain = expression.AND([domain, [('support_tokenization', '=', True)]])
|
||||
|
||||
# Handle express checkout.
|
||||
if is_express_checkout:
|
||||
domain = expression.AND([domain, [('support_express_checkout', '=', True)]])
|
||||
|
||||
# Search the payment methods matching the compatibility criteria.
|
||||
compatible_payment_methods = self.env['payment.method'].search(domain)
|
||||
return compatible_payment_methods
|
||||
|
||||
def _get_from_code(self, code, mapping=None):
|
||||
""" Get the payment method corresponding to the given provider-specific code.
|
||||
|
||||
If a mapping is given, the search uses the generic payment method code that corresponds to
|
||||
the given provider-specific code.
|
||||
|
||||
:param str code: The provider-specific code of the payment method to get.
|
||||
:param dict mapping: A non-exhaustive mapping of generic payment method codes to
|
||||
provider-specific codes.
|
||||
:return: The corresponding payment method, if any.
|
||||
:type: payment.method
|
||||
"""
|
||||
generic_to_specific_mapping = mapping or {}
|
||||
specific_to_generic_mapping = {v: k for k, v in generic_to_specific_mapping.items()}
|
||||
return self.search([('code', '=', specific_to_generic_mapping.get(code, code))], limit=1)
|
703
models/payment_provider.py
Normal file
703
models/payment_provider.py
Normal file
@ -0,0 +1,703 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from psycopg2 import sql
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.osv import expression
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentProvider(models.Model):
|
||||
_name = 'payment.provider'
|
||||
_description = 'Payment Provider'
|
||||
_order = 'module_state, state desc, sequence, name'
|
||||
_check_company_auto = True
|
||||
|
||||
def _valid_field_parameter(self, field, name):
|
||||
return name == 'required_if_provider' or super()._valid_field_parameter(field, name)
|
||||
|
||||
# Configuration fields
|
||||
name = fields.Char(string="Name", required=True, translate=True)
|
||||
sequence = fields.Integer(string="Sequence", help="Define the display order")
|
||||
code = fields.Selection(
|
||||
string="Code",
|
||||
help="The technical code of this payment provider.",
|
||||
selection=[('none', "No Provider Set")],
|
||||
default='none',
|
||||
required=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
string="State",
|
||||
help="In test mode, a fake payment is processed through a test payment interface.\n"
|
||||
"This mode is advised when setting up the provider.",
|
||||
selection=[('disabled', "Disabled"), ('enabled', "Enabled"), ('test', "Test Mode")],
|
||||
default='disabled', required=True, copy=False)
|
||||
is_published = fields.Boolean(
|
||||
string="Published",
|
||||
help="Whether the provider is visible on the website or not. Tokens remain functional but "
|
||||
"are only visible on manage forms.",
|
||||
)
|
||||
company_id = fields.Many2one( # Indexed to speed-up ORM searches (from ir_rule or others)
|
||||
string="Company", comodel_name='res.company', default=lambda self: self.env.company.id,
|
||||
required=True, index=True)
|
||||
main_currency_id = fields.Many2one(
|
||||
related='company_id.currency_id',
|
||||
help="The main currency of the company, used to display monetary fields.",
|
||||
)
|
||||
payment_method_ids = fields.Many2many(
|
||||
string="Supported Payment Methods", comodel_name='payment.method'
|
||||
)
|
||||
allow_tokenization = fields.Boolean(
|
||||
string="Allow Saving Payment Methods",
|
||||
help="This controls whether customers can save their payment methods as payment tokens.\n"
|
||||
"A payment token is an anonymous link to the payment method details saved in the\n"
|
||||
"provider's database, allowing the customer to reuse it for a next purchase.")
|
||||
capture_manually = fields.Boolean(
|
||||
string="Capture Amount Manually",
|
||||
help="Capture the amount from Odoo, when the delivery is completed.\n"
|
||||
"Use this if you want to charge your customers cards only when\n"
|
||||
"you are sure you can ship the goods to them.")
|
||||
allow_express_checkout = fields.Boolean(
|
||||
string="Allow Express Checkout",
|
||||
help="This controls whether customers can use express payment methods. Express checkout "
|
||||
"enables customers to pay with Google Pay and Apple Pay from which address "
|
||||
"information is collected at payment.",
|
||||
)
|
||||
redirect_form_view_id = fields.Many2one(
|
||||
string="Redirect Form Template", comodel_name='ir.ui.view',
|
||||
help="The template rendering a form submitted to redirect the user when making a payment",
|
||||
domain=[('type', '=', 'qweb')],
|
||||
ondelete='restrict',
|
||||
)
|
||||
inline_form_view_id = fields.Many2one(
|
||||
string="Inline Form Template", comodel_name='ir.ui.view',
|
||||
help="The template rendering the inline payment form when making a direct payment",
|
||||
domain=[('type', '=', 'qweb')],
|
||||
ondelete='restrict',
|
||||
)
|
||||
token_inline_form_view_id = fields.Many2one(
|
||||
string="Token Inline Form Template",
|
||||
comodel_name='ir.ui.view',
|
||||
help="The template rendering the inline payment form when making a payment by token.",
|
||||
domain=[('type', '=', 'qweb')],
|
||||
ondelete='restrict',
|
||||
)
|
||||
express_checkout_form_view_id = fields.Many2one(
|
||||
string="Express Checkout Form Template",
|
||||
comodel_name='ir.ui.view',
|
||||
help="The template rendering the express payment methods' form.",
|
||||
domain=[('type', '=', 'qweb')],
|
||||
ondelete='restrict',
|
||||
)
|
||||
|
||||
# Availability fields
|
||||
available_country_ids = fields.Many2many(
|
||||
string="Countries",
|
||||
comodel_name='res.country',
|
||||
help="The countries in which this payment provider is available. Leave blank to make it "
|
||||
"available in all countries.",
|
||||
relation='payment_country_rel',
|
||||
column1='payment_id',
|
||||
column2='country_id',
|
||||
)
|
||||
available_currency_ids = fields.Many2many(
|
||||
string="Currencies",
|
||||
help="The currencies available with this payment provider. Leave empty not to restrict "
|
||||
"any.",
|
||||
comodel_name='res.currency',
|
||||
relation='payment_currency_rel',
|
||||
column1="payment_provider_id",
|
||||
column2="currency_id",
|
||||
compute='_compute_available_currency_ids',
|
||||
store=True,
|
||||
readonly=False,
|
||||
context={'active_test': False},
|
||||
)
|
||||
maximum_amount = fields.Monetary(
|
||||
string="Maximum Amount",
|
||||
help="The maximum payment amount that this payment provider is available for. Leave blank "
|
||||
"to make it available for any payment amount.",
|
||||
currency_field='main_currency_id',
|
||||
)
|
||||
|
||||
# Message fields
|
||||
pre_msg = fields.Html(
|
||||
string="Help Message", help="The message displayed to explain and help the payment process",
|
||||
translate=True)
|
||||
pending_msg = fields.Html(
|
||||
string="Pending Message",
|
||||
help="The message displayed if the order pending after the payment process",
|
||||
default=lambda self: _(
|
||||
"Your payment has been successfully processed but is waiting for approval."
|
||||
), translate=True)
|
||||
auth_msg = fields.Html(
|
||||
string="Authorize Message", help="The message displayed if payment is authorized",
|
||||
default=lambda self: _("Your payment has been authorized."), translate=True)
|
||||
done_msg = fields.Html(
|
||||
string="Done Message",
|
||||
help="The message displayed if the order is successfully done after the payment process",
|
||||
default=lambda self: _("Your payment has been successfully processed."),
|
||||
translate=True)
|
||||
cancel_msg = fields.Html(
|
||||
string="Canceled Message",
|
||||
help="The message displayed if the order is canceled during the payment process",
|
||||
default=lambda self: _("Your payment has been cancelled."), translate=True)
|
||||
|
||||
# Feature support fields
|
||||
support_tokenization = fields.Boolean(
|
||||
string="Tokenization Supported", compute='_compute_feature_support_fields'
|
||||
)
|
||||
support_manual_capture = fields.Selection(
|
||||
string="Manual Capture Supported",
|
||||
selection=[('full_only', "Full Only"), ('partial', "Partial")],
|
||||
compute='_compute_feature_support_fields',
|
||||
)
|
||||
support_express_checkout = fields.Boolean(
|
||||
string="Express Checkout Supported", compute='_compute_feature_support_fields'
|
||||
)
|
||||
support_refund = fields.Selection(
|
||||
string="Type of Refund Supported",
|
||||
selection=[('full_only', "Full Only"), ('partial', "Partial")],
|
||||
compute='_compute_feature_support_fields',
|
||||
)
|
||||
|
||||
# Kanban view fields
|
||||
image_128 = fields.Image(string="Image", max_width=128, max_height=128)
|
||||
color = fields.Integer(
|
||||
string="Color", help="The color of the card in kanban view", compute='_compute_color',
|
||||
store=True)
|
||||
|
||||
# Module-related fields
|
||||
module_id = fields.Many2one(string="Corresponding Module", comodel_name='ir.module.module')
|
||||
module_state = fields.Selection(
|
||||
string="Installation State", related='module_id.state', store=True) # Stored for sorting.
|
||||
module_to_buy = fields.Boolean(string="Odoo Enterprise Module", related='module_id.to_buy')
|
||||
|
||||
# View configuration fields
|
||||
show_credentials_page = fields.Boolean(compute='_compute_view_configuration_fields')
|
||||
show_allow_tokenization = fields.Boolean(compute='_compute_view_configuration_fields')
|
||||
show_allow_express_checkout = fields.Boolean(compute='_compute_view_configuration_fields')
|
||||
show_pre_msg = fields.Boolean(compute='_compute_view_configuration_fields')
|
||||
show_pending_msg = fields.Boolean(compute='_compute_view_configuration_fields')
|
||||
show_auth_msg = fields.Boolean(compute='_compute_view_configuration_fields')
|
||||
show_done_msg = fields.Boolean(compute='_compute_view_configuration_fields')
|
||||
show_cancel_msg = fields.Boolean(compute='_compute_view_configuration_fields')
|
||||
require_currency = fields.Boolean(compute='_compute_view_configuration_fields')
|
||||
|
||||
#=== COMPUTE METHODS ===#
|
||||
|
||||
@api.depends('code')
|
||||
def _compute_available_currency_ids(self):
|
||||
""" Compute the available currencies based on their support by the providers.
|
||||
|
||||
If the provider does not filter out any currency, the field is left empty for UX reasons.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
all_currencies = self.env['res.currency'].with_context(active_test=False).search([])
|
||||
for provider in self:
|
||||
supported_currencies = provider._get_supported_currencies()
|
||||
if supported_currencies < all_currencies: # Some currencies have been filtered out.
|
||||
provider.available_currency_ids = supported_currencies
|
||||
else:
|
||||
provider.available_currency_ids = None
|
||||
|
||||
@api.depends('state', 'module_state')
|
||||
def _compute_color(self):
|
||||
""" Update the color of the kanban card based on the state of the provider.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
for provider in self:
|
||||
if provider.module_id and not provider.module_state == 'installed':
|
||||
provider.color = 4 # blue
|
||||
elif provider.state == 'disabled':
|
||||
provider.color = 3 # yellow
|
||||
elif provider.state == 'test':
|
||||
provider.color = 2 # orange
|
||||
elif provider.state == 'enabled':
|
||||
provider.color = 7 # green
|
||||
|
||||
@api.depends('code')
|
||||
def _compute_view_configuration_fields(self):
|
||||
""" Compute the view configuration fields based on the provider.
|
||||
|
||||
View configuration fields are used to hide specific elements (notebook pages, fields, etc.)
|
||||
from the form view of payment providers. These fields are set to `True` by default and are
|
||||
as follows:
|
||||
|
||||
- `show_credentials_page`: Whether the "Credentials" notebook page should be shown.
|
||||
- `show_allow_tokenization`: Whether the `allow_tokenization` field should be shown.
|
||||
- `show_allow_express_checkout`: Whether the `allow_express_checkout` field should be shown.
|
||||
- `show_pre_msg`: Whether the `pre_msg` field should be shown.
|
||||
- `show_pending_msg`: Whether the `pending_msg` field should be shown.
|
||||
- `show_auth_msg`: Whether the `auth_msg` field should be shown.
|
||||
- `show_done_msg`: Whether the `done_msg` field should be shown.
|
||||
- `show_cancel_msg`: Whether the `cancel_msg` field should be shown.
|
||||
- `require_currency`: Whether the `available_currency_ids` field shoud be required.
|
||||
|
||||
For a provider to hide specific elements of the form view, it must override this method and
|
||||
set the related view configuration fields to `False` on the appropriate `payment.provider`
|
||||
records.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.update({
|
||||
'show_credentials_page': True,
|
||||
'show_allow_tokenization': True,
|
||||
'show_allow_express_checkout': True,
|
||||
'show_pre_msg': True,
|
||||
'show_pending_msg': True,
|
||||
'show_auth_msg': True,
|
||||
'show_done_msg': True,
|
||||
'show_cancel_msg': True,
|
||||
'require_currency': False,
|
||||
})
|
||||
|
||||
@api.depends('code')
|
||||
def _compute_feature_support_fields(self):
|
||||
""" Compute the feature support fields based on the provider.
|
||||
|
||||
Feature support fields are used to specify which additional features are supported by a
|
||||
given provider. These fields are as follows:
|
||||
|
||||
- `support_express_checkout`: Whether the "express checkout" feature is supported. `False`
|
||||
by default.
|
||||
- `support_manual_capture`: Whether the "manual capture" feature is supported. `False` by
|
||||
default.
|
||||
- `support_refund`: Which type of the "refunds" feature is supported: `None`,
|
||||
`'full_only'`, or `'partial'`. `None` by default.
|
||||
- `support_tokenization`: Whether the "tokenization feature" is supported. `False` by
|
||||
default.
|
||||
|
||||
For a provider to specify that it supports additional features, it must override this method
|
||||
and set the related feature support fields to the desired value on the appropriate
|
||||
`payment.provider` records.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.update(dict.fromkeys((
|
||||
'support_express_checkout',
|
||||
'support_manual_capture',
|
||||
'support_refund',
|
||||
'support_tokenization',
|
||||
), None))
|
||||
|
||||
#=== ONCHANGE METHODS ===#
|
||||
|
||||
@api.onchange('state')
|
||||
def _onchange_state_switch_is_published(self):
|
||||
""" Automatically publish or unpublish the provider depending on its state.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.is_published = self.state == 'enabled'
|
||||
|
||||
@api.onchange('state')
|
||||
def _onchange_state_warn_before_disabling_tokens(self):
|
||||
""" Display a warning about the consequences of disabling a provider.
|
||||
|
||||
Let the user know that tokens related to a provider get archived if it is disabled or if its
|
||||
state is changed from 'test' to 'enabled', and vice versa.
|
||||
|
||||
:return: A client action with the warning message, if any.
|
||||
:rtype: dict
|
||||
"""
|
||||
if self._origin.state in ('test', 'enabled') and self._origin.state != self.state:
|
||||
related_tokens = self.env['payment.token'].search(
|
||||
[('provider_id', '=', self._origin.id)]
|
||||
)
|
||||
if related_tokens:
|
||||
return {
|
||||
'warning': {
|
||||
'title': _("Warning"),
|
||||
'message': _(
|
||||
"This action will also archive %s tokens that are registered with this "
|
||||
"provider. Archiving tokens is irreversible.", len(related_tokens)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#=== CRUD METHODS ===#
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, values_list):
|
||||
providers = super().create(values_list)
|
||||
providers._check_required_if_provider()
|
||||
return providers
|
||||
|
||||
def write(self, values):
|
||||
# Handle provider state changes.
|
||||
deactivated_providers = self.env['payment.provider']
|
||||
activated_providers = self.env['payment.provider']
|
||||
if 'state' in values:
|
||||
state_changed_providers = self.filtered(
|
||||
lambda p: p.state not in ('disabled', values['state'])
|
||||
) # Don't handle providers being enabled or whose state is not updated.
|
||||
state_changed_providers._archive_linked_tokens()
|
||||
if values['state'] == 'disabled':
|
||||
deactivated_providers = state_changed_providers
|
||||
else: # 'enabled' or 'test'
|
||||
activated_providers = self.filtered(lambda p: p.state == 'disabled')
|
||||
|
||||
result = super().write(values)
|
||||
self._check_required_if_provider()
|
||||
|
||||
deactivated_providers._deactivate_unsupported_payment_methods()
|
||||
activated_providers._activate_default_pms()
|
||||
|
||||
return result
|
||||
|
||||
def _check_required_if_provider(self):
|
||||
""" Check that provider-specific required fields have been filled.
|
||||
|
||||
The fields that have the `required_if_provider='<provider_code>'` attribute are made
|
||||
required for all `payment.provider` records with the `code` field equal to `<provider_code>`
|
||||
and with the `state` field equal to `'enabled'` or `'test'`.
|
||||
|
||||
Provider-specific views should make the form fields required under the same conditions.
|
||||
|
||||
:return: None
|
||||
:raise ValidationError: If a provider-specific required field is empty.
|
||||
"""
|
||||
field_names = []
|
||||
enabled_providers = self.filtered(lambda p: p.state in ['enabled', 'test'])
|
||||
for field_name, field in self._fields.items():
|
||||
required_for_provider_code = getattr(field, 'required_if_provider', None)
|
||||
if required_for_provider_code and any(
|
||||
required_for_provider_code == provider.code and not provider[field_name]
|
||||
for provider in enabled_providers
|
||||
):
|
||||
ir_field = self.env['ir.model.fields']._get(self._name, field_name)
|
||||
field_names.append(ir_field.field_description)
|
||||
if field_names:
|
||||
raise ValidationError(
|
||||
_("The following fields must be filled: %s", ", ".join(field_names))
|
||||
)
|
||||
|
||||
def _archive_linked_tokens(self):
|
||||
""" Archive all the payment tokens linked to the providers.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.env['payment.token'].search([('provider_id', 'in', self.ids)]).write({'active': False})
|
||||
|
||||
def _deactivate_unsupported_payment_methods(self):
|
||||
""" Deactivate payment methods linked to only disabled providers.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
unsupported_pms = self.payment_method_ids.filtered(
|
||||
lambda pm: all(p.state == 'disabled' for p in pm.provider_ids)
|
||||
)
|
||||
(unsupported_pms + unsupported_pms.brand_ids).active = False
|
||||
|
||||
def _activate_default_pms(self):
|
||||
""" Activate the default payment methods of the provider.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
for provider in self:
|
||||
pm_codes = provider._get_default_payment_method_codes()
|
||||
pms = provider.with_context(active_test=False).payment_method_ids
|
||||
(pms + pms.brand_ids).filtered(lambda pm: pm.code in pm_codes).active = True
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_master_data(self):
|
||||
""" Prevent the deletion of the payment provider if it has an xmlid. """
|
||||
external_ids = self.get_external_id()
|
||||
for provider in self:
|
||||
external_id = external_ids[provider.id]
|
||||
if external_id and not external_id.startswith('__export__'):
|
||||
raise UserError(_(
|
||||
"You cannot delete the payment provider %s; disable it or uninstall it"
|
||||
" instead.", provider.name
|
||||
))
|
||||
|
||||
#=== ACTION METHODS ===#
|
||||
|
||||
def button_immediate_install(self):
|
||||
""" Install the module and reload the page.
|
||||
|
||||
Note: `self.ensure_one()`
|
||||
|
||||
:return: The action to reload the page.
|
||||
:rtype: dict
|
||||
"""
|
||||
if self.module_id and self.module_state != 'installed':
|
||||
self.module_id.button_immediate_install()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'reload',
|
||||
}
|
||||
|
||||
def action_toggle_is_published(self):
|
||||
""" Toggle the field `is_published`.
|
||||
|
||||
:return: None
|
||||
:raise UserError: If the provider is disabled.
|
||||
"""
|
||||
if self.state != 'disabled':
|
||||
self.is_published = not self.is_published
|
||||
else:
|
||||
raise UserError(_("You cannot publish a disabled provider."))
|
||||
|
||||
def action_view_payment_methods(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Payment Methods"),
|
||||
'res_model': 'payment.method',
|
||||
'view_mode': 'tree,kanban,form',
|
||||
'domain': [('id', 'in', self.with_context(active_test=False).payment_method_ids.ids)],
|
||||
'context': {'active_test': False},
|
||||
}
|
||||
|
||||
#=== BUSINESS METHODS ===#
|
||||
|
||||
@api.model
|
||||
def _get_compatible_providers(
|
||||
self, company_id, partner_id, amount, currency_id=None, force_tokenization=False,
|
||||
is_express_checkout=False, is_validation=False, **kwargs
|
||||
):
|
||||
""" Search and return the providers matching the compatibility criteria.
|
||||
|
||||
The compatibility criteria are that providers must: not be disabled; be in the company that
|
||||
is provided; support the country of the partner if it exists; be compatible with the
|
||||
currency if provided. If provided, the optional keyword arguments further refine the
|
||||
criteria.
|
||||
|
||||
:param int company_id: The company to which providers must belong, as a `res.company` id.
|
||||
:param int partner_id: The partner making the payment, as a `res.partner` id.
|
||||
:param float amount: The amount to pay. `0` for validation transactions.
|
||||
:param int currency_id: The payment currency, if known beforehand, as a `res.currency` id.
|
||||
:param bool force_tokenization: Whether only providers allowing tokenization can be matched.
|
||||
:param bool is_express_checkout: Whether the payment is made through express checkout.
|
||||
:param bool is_validation: Whether the operation is a validation.
|
||||
:param dict kwargs: Optional data. This parameter is not used here.
|
||||
:return: The compatible providers.
|
||||
:rtype: payment.provider
|
||||
"""
|
||||
# Compute the base domain for compatible providers.
|
||||
domain = [
|
||||
*self.env['payment.provider']._check_company_domain(company_id),
|
||||
('state', 'in', ['enabled', 'test']),
|
||||
]
|
||||
|
||||
# Handle the is_published state.
|
||||
if not self.env.user._is_internal():
|
||||
domain = expression.AND([domain, [('is_published', '=', True)]])
|
||||
|
||||
# Handle the partner country; allow all countries if the list is empty.
|
||||
partner = self.env['res.partner'].browse(partner_id)
|
||||
if partner.country_id: # The partner country must either not be set or be supported.
|
||||
domain = expression.AND([
|
||||
domain, [
|
||||
'|',
|
||||
('available_country_ids', '=', False),
|
||||
('available_country_ids', 'in', [partner.country_id.id]),
|
||||
]
|
||||
])
|
||||
|
||||
# Handle the maximum amount.
|
||||
currency = self.env['res.currency'].browse(currency_id).exists()
|
||||
if not is_validation and currency: # The currency is required to convert the amount.
|
||||
company = self.env['res.company'].browse(company_id).exists()
|
||||
date = fields.Date.context_today(self)
|
||||
converted_amount = currency._convert(amount, company.currency_id, company, date)
|
||||
domain = expression.AND([
|
||||
domain, [
|
||||
'|', '|',
|
||||
('maximum_amount', '>=', converted_amount),
|
||||
('maximum_amount', '=', False),
|
||||
('maximum_amount', '=', 0.),
|
||||
]
|
||||
])
|
||||
|
||||
# Handle the available currencies; allow all currencies if the list is empty.
|
||||
if currency:
|
||||
domain = expression.AND([
|
||||
domain, [
|
||||
'|',
|
||||
('available_currency_ids', '=', False),
|
||||
('available_currency_ids', 'in', [currency.id]),
|
||||
]
|
||||
])
|
||||
|
||||
# Handle tokenization support requirements.
|
||||
if force_tokenization or self._is_tokenization_required(**kwargs):
|
||||
domain = expression.AND([domain, [('allow_tokenization', '=', True)]])
|
||||
|
||||
# Handle express checkout.
|
||||
if is_express_checkout:
|
||||
domain = expression.AND([domain, [('allow_express_checkout', '=', True)]])
|
||||
|
||||
# Search the providers matching the compatibility criteria.
|
||||
compatible_providers = self.env['payment.provider'].search(domain)
|
||||
return compatible_providers
|
||||
|
||||
def _get_supported_currencies(self):
|
||||
""" Return the supported currencies for the payment provider.
|
||||
|
||||
By default, all currencies are considered supported, including the inactive ones. For a
|
||||
provider to filter out specific currencies, it must override this method and return the
|
||||
subset of supported currencies.
|
||||
|
||||
Note: `self.ensure_one()`
|
||||
|
||||
:return: The supported currencies.
|
||||
:rtype: res.currency
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.env['res.currency'].with_context(active_test=False).search([])
|
||||
|
||||
def _is_tokenization_required(self, **kwargs):
|
||||
""" Return whether tokenizing the transaction is required given its context.
|
||||
|
||||
For a module to make the tokenization required based on the payment context, it must
|
||||
override this method and return whether it is required.
|
||||
|
||||
:param dict kwargs: The payment context. This parameter is not used here.
|
||||
:return: Whether tokenizing the transaction is required.
|
||||
:rtype: bool
|
||||
"""
|
||||
return False
|
||||
|
||||
def _should_build_inline_form(self, is_validation=False):
|
||||
""" Return whether the inline payment form should be instantiated.
|
||||
|
||||
For a provider to handle both direct payments and payments with redirection, it must
|
||||
override this method and return whether the inline payment form should be instantiated (i.e.
|
||||
if the payment should be direct) based on the operation (online payment or validation).
|
||||
|
||||
:param bool is_validation: Whether the operation is a validation.
|
||||
:return: Whether the inline form should be instantiated.
|
||||
:rtype: bool
|
||||
"""
|
||||
return True
|
||||
|
||||
def _get_validation_amount(self):
|
||||
""" Return the amount to use for validation operations.
|
||||
|
||||
For a provider to support tokenization, it must override this method and return the
|
||||
validation amount. If it is `0`, it is not necessary to create the override.
|
||||
|
||||
Note: `self.ensure_one()`
|
||||
|
||||
:return: The validation amount.
|
||||
:rtype: float
|
||||
"""
|
||||
self.ensure_one()
|
||||
return 0.0
|
||||
|
||||
def _get_validation_currency(self):
|
||||
""" Return the currency to use for validation operations.
|
||||
|
||||
For a provider to support tokenization, it must override this method and return the
|
||||
validation currency. If the validation amount is `0`, it is not necessary to create the
|
||||
override.
|
||||
|
||||
Note: `self.ensure_one()`
|
||||
|
||||
:return: The validation currency.
|
||||
:rtype: recordset of `res.currency`
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.company_id.currency_id
|
||||
|
||||
def _get_redirect_form_view(self, is_validation=False):
|
||||
""" Return the view of the template used to render the redirect form.
|
||||
|
||||
For a provider to return a different view depending on whether the operation is a
|
||||
validation, it must override this method and return the appropriate view.
|
||||
|
||||
Note: `self.ensure_one()`
|
||||
|
||||
:param bool is_validation: Whether the operation is a validation.
|
||||
:return: The view of the redirect form template.
|
||||
:rtype: record of `ir.ui.view`
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.redirect_form_view_id
|
||||
|
||||
@api.model
|
||||
def _setup_provider(self, provider_code):
|
||||
""" Perform module-specific setup steps for the provider.
|
||||
|
||||
This method is called after the module of a provider is installed, with its code passed as
|
||||
`provider_code`.
|
||||
|
||||
:param str provider_code: The code of the provider to setup.
|
||||
:return: None
|
||||
"""
|
||||
return
|
||||
|
||||
@api.model
|
||||
def _get_removal_domain(self, provider_code):
|
||||
return [('code', '=', provider_code)]
|
||||
|
||||
@api.model
|
||||
def _remove_provider(self, provider_code):
|
||||
""" Remove the module-specific data of the given provider.
|
||||
|
||||
:param str provider_code: The code of the provider whose data to remove.
|
||||
:return: None
|
||||
"""
|
||||
providers = self.search(self._get_removal_domain(provider_code))
|
||||
providers.write(self._get_removal_values())
|
||||
|
||||
def _get_removal_values(self):
|
||||
""" Return the values to update a provider with when its module is uninstalled.
|
||||
|
||||
For a module to specify additional removal values, it must override this method and complete
|
||||
the generic values with its specific values.
|
||||
|
||||
:return: The removal values to update the removed provider with.
|
||||
:rtype: dict
|
||||
"""
|
||||
return {
|
||||
'code': 'none',
|
||||
'state': 'disabled',
|
||||
'is_published': False,
|
||||
'redirect_form_view_id': None,
|
||||
'inline_form_view_id': None,
|
||||
'token_inline_form_view_id': None,
|
||||
'express_checkout_form_view_id': None,
|
||||
}
|
||||
|
||||
def _get_provider_name(self):
|
||||
""" Return the translated name of the provider.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:return: The translated name of the provider.
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
return dict(self._fields['code']._description_selection(self.env))[self.code]
|
||||
|
||||
def _get_code(self):
|
||||
""" Return the code of the provider.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:return: The code of the provider.
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.code
|
||||
|
||||
def _get_default_payment_method_codes(self):
|
||||
""" Return the default payment methods for this provider.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:return: The default payment method codes.
|
||||
:rtype: list
|
||||
"""
|
||||
self.ensure_one()
|
||||
return []
|
196
models/payment_token.py
Normal file
196
models/payment_token.py
Normal file
@ -0,0 +1,196 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class PaymentToken(models.Model):
|
||||
_name = 'payment.token'
|
||||
_order = 'partner_id, id desc'
|
||||
_description = 'Payment Token'
|
||||
_check_company_auto = True
|
||||
|
||||
provider_id = fields.Many2one(string="Provider", comodel_name='payment.provider', required=True)
|
||||
provider_code = fields.Selection(string="Provider Code", related='provider_id.code')
|
||||
company_id = fields.Many2one(
|
||||
related='provider_id.company_id', store=True, index=True
|
||||
) # Indexed to speed-up ORM searches (from ir_rule or others).
|
||||
payment_method_id = fields.Many2one(
|
||||
string="Payment Method", comodel_name='payment.method', readonly=True, required=True
|
||||
)
|
||||
payment_method_code = fields.Char(
|
||||
string="Payment Method Code", related='payment_method_id.code'
|
||||
)
|
||||
payment_details = fields.Char(
|
||||
string="Payment Details", help="The clear part of the payment method's payment details.",
|
||||
)
|
||||
partner_id = fields.Many2one(string="Partner", comodel_name='res.partner', required=True)
|
||||
provider_ref = fields.Char(
|
||||
string="Provider Reference",
|
||||
help="The provider reference of the token of the transaction.",
|
||||
required=True,
|
||||
) # This is not the same thing as the provider reference of the transaction.
|
||||
transaction_ids = fields.One2many(
|
||||
string="Payment Transactions", comodel_name='payment.transaction', inverse_name='token_id'
|
||||
)
|
||||
active = fields.Boolean(string="Active", default=True)
|
||||
|
||||
#=== COMPUTE METHODS ===#
|
||||
|
||||
@api.depends('payment_details', 'create_date')
|
||||
def _compute_display_name(self):
|
||||
for token in self:
|
||||
token.display_name = token._build_display_name()
|
||||
|
||||
#=== CRUD METHODS ===#
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, values_list):
|
||||
for values in values_list:
|
||||
if 'provider_id' in values:
|
||||
provider = self.env['payment.provider'].browse(values['provider_id'])
|
||||
|
||||
# Include provider-specific create values
|
||||
values.update(self._get_specific_create_values(provider.code, 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_code, values):
|
||||
""" Complete the values of the `create` method with provider-specific values.
|
||||
|
||||
For a provider to add its own create values, it must overwrite this method and return a
|
||||
dict of values. Provider-specific values take precedence over those of the dict of generic
|
||||
create values.
|
||||
|
||||
:param str provider_code: The code of the provider managing the token.
|
||||
:param dict values: The original create values.
|
||||
:return: The dict of provider-specific create values.
|
||||
:rtype: dict
|
||||
"""
|
||||
return dict()
|
||||
|
||||
def write(self, values):
|
||||
""" Prevent unarchiving tokens and handle their archiving.
|
||||
|
||||
:return: The result of the call to the parent method.
|
||||
:rtype: bool
|
||||
:raise UserError: If at least one token is being unarchived.
|
||||
"""
|
||||
if 'active' in values:
|
||||
if values['active']:
|
||||
if any(not token.active for token in self):
|
||||
raise UserError(_("A token cannot be unarchived once it has been archived."))
|
||||
else:
|
||||
# Call the handlers in sudo mode because this method might have been called by RPC.
|
||||
self.filtered('active').sudo()._handle_archiving()
|
||||
|
||||
return super().write(values)
|
||||
|
||||
@api.constrains('partner_id')
|
||||
def _check_partner_is_never_public(self):
|
||||
""" Check that the partner associated with the token is never public. """
|
||||
for token in self:
|
||||
if token.partner_id.is_public:
|
||||
raise ValidationError(_("No token can be assigned to the public partner."))
|
||||
|
||||
def _handle_archiving(self):
|
||||
""" Handle the archiving of tokens.
|
||||
|
||||
For a module to perform additional operations when a token is archived, it must override
|
||||
this method.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
return
|
||||
|
||||
#=== BUSINESS METHODS ===#
|
||||
|
||||
def _get_available_tokens(self, providers_ids, partner_id, is_validation=False, **kwargs):
|
||||
""" Return the available tokens linked to the given providers and partner.
|
||||
|
||||
For a module to retrieve the available tokens, it must override this method and add
|
||||
information in the kwargs to define the context of the request.
|
||||
|
||||
:param list providers_ids: The ids of the providers available for the transaction.
|
||||
:param int partner_id: The id of the partner.
|
||||
:param bool is_validation: Whether the transaction is a validation operation.
|
||||
:param dict kwargs: Locally unused keywords arguments.
|
||||
:return: The available tokens.
|
||||
:rtype: payment.token
|
||||
"""
|
||||
if not is_validation:
|
||||
return self.env['payment.token'].search(
|
||||
[('provider_id', 'in', providers_ids), ('partner_id', '=', partner_id)]
|
||||
)
|
||||
else:
|
||||
# Get all the tokens of the partner and of their commercial partner, regardless of
|
||||
# whether the providers are available.
|
||||
partner = self.env['res.partner'].browse(partner_id)
|
||||
return self.env['payment.token'].search(
|
||||
[('partner_id', 'in', [partner.id, partner.commercial_partner_id.id])]
|
||||
)
|
||||
|
||||
def _build_display_name(self, *args, max_length=34, should_pad=True, **kwargs):
|
||||
""" Build a token name of the desired maximum length with the format `•••• 1234`.
|
||||
|
||||
The payment details are padded on the left with up to four padding characters. The padding
|
||||
is only added if there is enough room for it. If not, it is either reduced or not added at
|
||||
all. If there is not enough room for the payment details either, they are trimmed from the
|
||||
left.
|
||||
|
||||
For a module to customize the display name of a token, it must override this method and
|
||||
return the customized display name.
|
||||
|
||||
Note: `self.ensure_one()`
|
||||
|
||||
:param list args: The arguments passed by QWeb when calling this method.
|
||||
:param int max_length: The desired maximum length of the token name. The default is `34` to
|
||||
fit the largest IBANs.
|
||||
:param bool should_pad: Whether the token should be padded.
|
||||
:param dict kwargs: Optional data used in overrides of this method.
|
||||
:return: The padded token name.
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.create_date:
|
||||
return ''
|
||||
|
||||
padding_length = max_length - len(self.payment_details or '')
|
||||
if not self.payment_details:
|
||||
create_date_str = self.create_date.strftime('%Y/%m/%d')
|
||||
display_name = _("Payment details saved on %(date)s", date=create_date_str)
|
||||
elif padding_length >= 2: # Enough room for padding.
|
||||
padding = '•' * min(padding_length - 1, 4) + ' ' if should_pad else ''
|
||||
display_name = ''.join([padding, self.payment_details])
|
||||
elif padding_length > 0: # Not enough room for padding.
|
||||
display_name = self.payment_details
|
||||
else: # Not enough room for neither padding nor the payment details.
|
||||
display_name = self.payment_details[-max_length:] if max_length > 0 else ''
|
||||
return display_name
|
||||
|
||||
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 document 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 the linked document records.
|
||||
:rtype: list
|
||||
"""
|
||||
self.ensure_one()
|
||||
return []
|
1149
models/payment_transaction.py
Normal file
1149
models/payment_transaction.py
Normal file
File diff suppressed because it is too large
Load Diff
50
models/res_company.py
Normal file
50
models/res_company.py
Normal file
@ -0,0 +1,50 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
payment_onboarding_payment_method = fields.Selection(
|
||||
string="Selected onboarding payment method",
|
||||
selection=[
|
||||
('paypal', "PayPal"),
|
||||
('stripe', "Stripe"),
|
||||
('manual', "Manual"),
|
||||
('other', "Other"),
|
||||
])
|
||||
|
||||
def _run_payment_onboarding_step(self, menu_id):
|
||||
""" Install the suggested payment modules and configure the providers.
|
||||
|
||||
It's checked that the current company has a Chart of Account.
|
||||
|
||||
:param int menu_id: The menu from which the user started the onboarding step, as an
|
||||
`ir.ui.menu` id
|
||||
:return: The action returned by `action_stripe_connect_account`
|
||||
:rtype: dict
|
||||
"""
|
||||
self.env.company.get_chart_of_accounts_or_fail()
|
||||
|
||||
self._install_modules(['payment_stripe'])
|
||||
|
||||
# Create a new env including the freshly installed module(s)
|
||||
new_env = api.Environment(self.env.cr, self.env.uid, self.env.context)
|
||||
|
||||
# Configure Stripe
|
||||
stripe_provider = new_env['payment.provider'].search([
|
||||
*self.env['payment.provider']._check_company_domain(self.env.company),
|
||||
('code', '=', 'stripe')
|
||||
], limit=1)
|
||||
if not stripe_provider:
|
||||
base_provider = self.env.ref('payment.payment_provider_stripe')
|
||||
# Use sudo to access payment provider record that can be in different company.
|
||||
stripe_provider = base_provider.sudo().copy(default={'company_id': self.env.company.id})
|
||||
|
||||
return stripe_provider.action_stripe_connect_account(menu_id=menu_id)
|
||||
|
||||
def _install_modules(self, module_names):
|
||||
modules_sudo = self.env['ir.module.module'].sudo().search([('name', 'in', module_names)])
|
||||
STATES = ['installed', 'to install', 'to upgrade']
|
||||
modules_sudo.filtered(lambda m: m.state not in STATES).button_immediate_install()
|
21
models/res_partner.py
Normal file
21
models/res_partner.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
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):
|
||||
payments_data = self.env['payment.token']._read_group(
|
||||
[('partner_id', 'in', self.ids)], ['partner_id'], ['__count'],
|
||||
)
|
||||
partners_data = {partner.id: count for partner, count in payments_data}
|
||||
for partner in self:
|
||||
partner.payment_token_count = partners_data.get(partner.id, 0)
|
14
security/ir.model.access.csv
Normal file
14
security/ir.model.access.csv
Normal file
@ -0,0 +1,14 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_payment_link_wizard,access_payment_link_wizard,payment.model_payment_link_wizard,base.group_user,0,0,0,0
|
||||
payment_capture_wizard_user,payment.capture.wizard,model_payment_capture_wizard,base.group_user,1,1,1,0
|
||||
payment_provider_onboarding_wizard,payment.provider.onboarding.wizard,model_payment_provider_onboarding_wizard,base.group_system,1,1,1,0
|
||||
payment_provider_system,payment.provider.system,model_payment_provider,base.group_system,1,1,1,1
|
||||
payment_method_public,payment.method.all,model_payment_method,base.group_public,1,0,0,0
|
||||
payment_method_portal,payment.method.all,model_payment_method,base.group_portal,1,0,0,0
|
||||
payment_method_employee,payment.method.all,model_payment_method,base.group_user,1,0,0,0
|
||||
payment_method_system,payment.method.system,model_payment_method,base.group_system,1,1,1,1
|
||||
payment_token_public,payment.token.all,model_payment_token,base.group_public,1,0,0,0
|
||||
payment_token_portal,payment.token.all,model_payment_token,base.group_portal,1,0,0,0
|
||||
payment_token_employee,payment.token.all,model_payment_token,base.group_user,1,0,0,0
|
||||
payment_token_system,payment.token.system,model_payment_token,base.group_system,1,1,1,1
|
||||
payment_transaction_system,payment.transaction.system,model_payment_transaction,base.group_system,1,1,1,1
|
|
45
security/payment_security.xml
Normal file
45
security/payment_security.xml
Normal file
@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- Providers -->
|
||||
|
||||
<record id="payment_provider_company_rule" model="ir.rule">
|
||||
<field name="name">Access providers in own companies only</field>
|
||||
<field name="model_id" ref="payment.model_payment_provider"/>
|
||||
<field name="domain_force">[('company_id', 'parent_of', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<!-- Transactions -->
|
||||
|
||||
<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">Users can access only their own tokens</field>
|
||||
<field name="model_id" ref="payment.model_payment_token"/>
|
||||
<field name="domain_force">[('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_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">[('company_id', 'parent_of', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<!-- Wizards -->
|
||||
|
||||
<record id="payment_capture_wizard_rule" model="ir.rule">
|
||||
<field name="name">Payment Capture Wizard</field>
|
||||
<field name="model_id" ref="model_payment_capture_wizard"/>
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
BIN
static/description/icon.png
Normal file
BIN
static/description/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 606 B |
1
static/description/icon.svg
Normal file
1
static/description/icon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M0 12h50v26a4 4 0 0 1-4 4H0V12Z" fill="#985184"/><path d="M4 21a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4h46v9a4 4 0 0 1-4 4H4Z" fill="#FBB945"/><path d="M0 16h50v4a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4v-4Z" fill="#F78613"/></svg>
|
After Width: | Height: | Size: 304 B |
BIN
static/img/ach_direct_debit.png
Normal file
BIN
static/img/ach_direct_debit.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user