19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,9 +1,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_http
from . import payment_method
from . import payment_provider
from . import payment_icon
from . import payment_token
from . import payment_transaction
from . import res_company
from . import res_country
from . import res_partner

View file

@ -8,5 +8,5 @@ class IrHttp(models.AbstractModel):
@classmethod
def _get_translation_frontend_modules_name(cls):
mods = super(IrHttp, cls)._get_translation_frontend_modules_name()
mods = super()._get_translation_frontend_modules_name()
return mods + ['payment']

View file

@ -1,21 +0,0 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class PaymentIcon(models.Model):
_name = 'payment.icon'
_description = 'Payment Icon'
_order = 'sequence, name'
name = fields.Char(string="Name")
provider_ids = fields.Many2many(
string="Providers", comodel_name='payment.provider',
help="The list of providers supporting this payment icon")
image = fields.Image(
string="Image", max_width=64, max_height=64,
help="This field holds the image used for this payment icon, limited to 64x64 px")
image_payment_form = fields.Image(
string="Image displayed on the payment form", related='image', store=True, max_width=45,
max_height=30)
sequence = fields.Integer('Sequence', default=1)

View file

@ -0,0 +1,341 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.fields import Command, Domain
from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment.const import REPORT_REASONS_MAPPING
class PaymentMethod(models.Model):
_name = 'payment.method'
_description = "Payment Method"
_order = 'active desc, sequence, name'
name = fields.Char(string="Name", required=True, translate=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',
index='btree_not_null',
)
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",
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",
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_manual_capture = fields.Selection(
string="Manual Capture",
help="The payment is authorized and captured in two steps instead of one.",
selection=[
('none', "Unsupported"),
('full_only', "Full Only"),
('partial', "Full & Partial"),
],
required=True,
default='none'
)
support_refund = fields.Selection(
string="Refund",
help="Refund is a feature allowing to refund customers directly from the payment in Odoo.",
selection=[
('none', "Unsupported"),
('full_only', "Full Only"),
('partial', "Full & Partial"),
],
required=True,
default='none',
)
supported_country_ids = fields.Many2many(
string="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="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.",
context={'active_test': False},
)
# === 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 not in ('in', 'not in'):
return NotImplemented
return [('primary_payment_method_id', operator, [False])]
# === ONCHANGE METHODS === #
@api.onchange('active', 'provider_ids', 'support_tokenization')
def _onchange_warn_before_disabling_tokens(self):
""" Display a warning about the consequences of archiving the payment method, detaching it
from a provider, or removing its support for tokenization.
Let the user know that the related tokens will be archived.
:return: A client action with the warning message, if any.
:rtype: dict
"""
disabling = self._origin.active and not self.active
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.
blocking_tokenization = self._origin.support_tokenization and not self.support_tokenization
if disabling or detached_providers or blocking_tokenization:
related_tokens = self.env['payment.token'].with_context(active_test=True).search(
Domain('payment_method_id', 'in', (self._origin + self._origin.brand_ids).ids)
& (Domain('provider_id', 'in', detached_providers.ids) if detached_providers else Domain.TRUE),
) # 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.", 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'))
)
}
}
# === CONSTRAINT METHODS === #
@api.constrains('active', 'support_manual_capture')
def _check_manual_capture_supported_by_providers(self):
incompatible_pms = self.filtered(
lambda pm:
pm.active
and (pm.primary_payment_method_id or pm).support_manual_capture == 'none'
and any(provider.capture_manually for provider in pm.provider_ids),
)
if incompatible_pms:
raise ValidationError(_(
"The following payment methods cannot be enabled because their payment provider has"
" manual capture activated: %s", ", ".join(incompatible_pms.mapped('name'))
))
# === CRUD METHODS === #
def write(self, vals):
# Handle payment methods being archived, detached from providers, or blocking tokenization.
archiving = vals.get('active') is False
detached_provider_ids = [
v[0] for command, *v in vals['provider_ids'] if command == Command.UNLINK
] if 'provider_ids' in vals else []
blocking_tokenization = vals.get('support_tokenization') is False
if archiving or detached_provider_ids or blocking_tokenization:
linked_tokens = self.env['payment.token'].with_context(active_test=True).search(
Domain('payment_method_id', 'in', (self + self.brand_ids).ids)
& (Domain('provider_id', 'in', detached_provider_ids) if detached_provider_ids else Domain.TRUE),
) # 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 vals.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(vals)
@api.ondelete(at_uninstall=False)
def _unlink_if_not_default_payment_method(self):
payment_method_unknown = self.env.ref('payment.payment_method_unknown')
if payment_method_unknown in self:
raise UserError(_("You cannot delete the default payment method."))
# === BUSINESS METHODS === #
def _get_compatible_payment_methods(
self, provider_ids, partner_id, currency_id=None, force_tokenization=False,
is_express_checkout=False, report=None, **kwargs
):
""" 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.
:param dict report: The report in which each provider's availability status and reason must
be logged.
:param dict kwargs: Optional data. This parameter is not used here.
:return: The compatible payment methods.
:rtype: payment.method
"""
# Search compatible payment methods with the base domain.
payment_methods = self.env['payment.method'].search([('is_primary', '=', True)])
payment_utils.add_to_report(report, payment_methods)
# Filter by compatible providers.
unfiltered_pms = payment_methods
payment_methods = payment_methods.filtered(
lambda pm: any(p in provider_ids for p in pm.provider_ids.ids)
)
payment_utils.add_to_report(
report,
unfiltered_pms - payment_methods,
available=False,
reason=REPORT_REASONS_MAPPING['provider_not_available'],
)
# 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.
unfiltered_pms = payment_methods
payment_methods = payment_methods.filtered(
lambda pm: (
not pm.supported_country_ids
or partner.country_id.id in pm.supported_country_ids.ids
)
)
payment_utils.add_to_report(
report,
unfiltered_pms - payment_methods,
available=False,
reason=REPORT_REASONS_MAPPING['incompatible_country'],
)
# Handle the supported currencies; allow all currencies if the list is empty.
if currency_id:
unfiltered_pms = payment_methods
payment_methods = payment_methods.filtered(
lambda pm: (
not pm.supported_currency_ids
or currency_id in pm.supported_currency_ids.ids
)
)
payment_utils.add_to_report(
report,
unfiltered_pms - payment_methods,
available=False,
reason=REPORT_REASONS_MAPPING['incompatible_currency'],
)
# Handle tokenization support requirements.
if force_tokenization:
unfiltered_pms = payment_methods
payment_methods = payment_methods.filtered('support_tokenization')
payment_utils.add_to_report(
report,
unfiltered_pms - payment_methods,
available=False,
reason=REPORT_REASONS_MAPPING['tokenization_not_supported'],
)
# Handle express checkout.
if is_express_checkout:
unfiltered_pms = payment_methods
payment_methods = payment_methods.filtered('support_express_checkout')
payment_utils.add_to_report(
report,
unfiltered_pms - payment_methods,
available=False,
reason=REPORT_REASONS_MAPPING['express_checkout_not_supported'],
)
return 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.
:rtype: 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)

View file

@ -1,37 +1,53 @@
# 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
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
_rec_names_search = ['payment_details', 'partner_id', 'provider_id']
provider_id = fields.Many2one(string="Provider", comodel_name='payment.provider', required=True)
provider_code = fields.Selection(related='provider_id.code')
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)
company_id = fields.Many2one( # Indexed to speed-up ORM searches (from ir_rule or others)
related='provider_id.company_id', store=True, index=True)
partner_id = fields.Many2one(string="Partner", comodel_name='res.partner', required=True, index=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.
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')
verified = fields.Boolean(string="Verified")
string="Payment Transactions", comodel_name='payment.transaction', inverse_name='token_id'
)
active = fields.Boolean(string="Active", default=True)
#=== CRUD METHODS ===#
# === 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:
def create(self, vals_list):
for values in vals_list:
if 'provider_id' in values:
provider = self.env['payment.provider'].browse(values['provider_id'])
@ -40,7 +56,7 @@ class PaymentToken(models.Model):
else:
pass # Let psycopg warn about the missing required field.
return super().create(values_list)
return super().create(vals_list)
@api.model
def _get_specific_create_values(self, provider_code, values):
@ -57,22 +73,36 @@ class PaymentToken(models.Model):
"""
return dict()
def write(self, values):
def write(self, vals):
""" 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."))
if 'active' in vals:
if vals['active']:
if any(
not token.payment_method_id.active
or token.provider_id.state == 'disabled'
for token in self
):
raise UserError(_(
"You can't unarchive tokens linked to inactive payment methods or disabled"
" providers."
))
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)
return super().write(vals)
@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.
@ -84,10 +114,32 @@ class PaymentToken(models.Model):
"""
return
def name_get(self):
return [(token.id, token._build_display_name()) for token in self]
# === BUSINESS METHODS === #
#=== 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`.
@ -112,6 +164,9 @@ class PaymentToken(models.Model):
"""
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')

View file

@ -1,72 +1,24 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import api, models
class ResCompany(models.Model):
_inherit = 'res.company'
payment_provider_onboarding_state = fields.Selection(
string="State of the onboarding payment provider step",
selection=[('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")],
default='not_done')
payment_onboarding_payment_method = fields.Selection(
string="Selected onboarding payment method",
selection=[
('paypal', "PayPal"),
('stripe', "Stripe"),
('manual', "Manual"),
('other', "Other"),
])
@api.model_create_multi
def create(self, vals_list):
companies = super().create(vals_list)
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', 'account_payment'])
# 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
default_journal = new_env['account.journal'].search(
[('type', '=', 'bank'), ('company_id', '=', new_env.company.id)], limit=1
# Duplicate installed providers in the new companies.
providers_sudo = self.env['payment.provider'].sudo().search(
[('company_id', '=', self.env.user.company_id.id), ('module_state', '=', 'installed')]
)
for company in companies:
if company.parent_id: # The company is a branch.
continue # Only consider top-level companies for provider duplication.
stripe_provider = new_env['payment.provider'].search(
[('company_id', '=', self.env.company.id), ('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().with_context(
stripe_connect_onboarding=True,
).copy(default={'company_id': self.env.company.id})
stripe_provider.journal_id = stripe_provider.journal_id or default_journal
for provider_sudo in providers_sudo:
provider_sudo.copy({'company_id': 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()
def _mark_payment_onboarding_step_as_done(self):
""" Mark the payment onboarding step as done.
:return: None
"""
self.set_onboarding_step_done('payment_provider_onboarding_state')
def get_account_invoice_onboarding_steps_states_names(self):
""" Override of account. """
steps = super().get_account_invoice_onboarding_steps_states_names()
return steps + ['payment_provider_onboarding_state']
return companies

View file

@ -0,0 +1,32 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
# Import the payment provider modules with aliases to avoid circular import errors.
try:
import odoo.addons.payment_mercado_pago as mercado_pago
import odoo.addons.payment_stripe as stripe
except ModuleNotFoundError:
mercado_pago = None
stripe = None
class ResCountry(models.Model):
_inherit = 'res.country'
is_mercado_pago_supported_country = fields.Boolean(compute='_compute_provider_support')
is_stripe_supported_country = fields.Boolean(compute='_compute_provider_support')
@api.depends('code')
def _compute_provider_support(self):
for country in self:
country.is_stripe_supported_country = (
stripe is not None
and stripe.const.COUNTRY_MAPPING.get(
country.code, country.code
) in stripe.const.SUPPORTED_COUNTRIES
)
country.is_mercado_pago_supported_country = (
mercado_pago
and country.code in mercado_pago.const.SUPPORTED_COUNTRIES
)

View file

@ -14,9 +14,8 @@ class ResPartner(models.Model):
@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'], ['partner_id']
[('partner_id', 'in', self.ids)], ['partner_id'], ['__count'],
)
partners_data = {payment_data['partner_id'][0]: payment_data['partner_id_count']
for payment_data in payments_data}
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)