1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337
|
# -*- coding: utf-8 -*-
import base64
from collections import defaultdict
import werkzeug
import werkzeug.exceptions
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools.image import image_data_uri
class ResPartnerBank(models.Model):
_name = 'res.partner.bank'
_inherit = ['res.partner.bank', 'mail.thread', 'mail.activity.mixin']
journal_id = fields.One2many(
'account.journal', 'bank_account_id', domain=[('type', '=', 'bank')], string='Account Journal', readonly=True,
check_company=True,
help="The accounting journal corresponding to this bank account.")
has_iban_warning = fields.Boolean(
compute='_compute_display_account_warning',
help='Technical field used to display a warning if the IBAN country is different than the holder country.',
store=True,
)
partner_country_name = fields.Char(related='partner_id.country_id.name')
has_money_transfer_warning = fields.Boolean(
compute='_compute_display_account_warning',
help='Technical field used to display a warning if the account is a transfer service account.',
store=True,
)
money_transfer_service = fields.Char(compute='_compute_money_transfer_service_name')
partner_supplier_rank = fields.Integer(related='partner_id.supplier_rank')
partner_customer_rank = fields.Integer(related='partner_id.customer_rank')
related_moves = fields.One2many('account.move', inverse_name='partner_bank_id')
# Add tracking to the base fields
bank_id = fields.Many2one(tracking=True)
active = fields.Boolean(tracking=True)
acc_number = fields.Char(tracking=True)
acc_holder_name = fields.Char(tracking=True)
partner_id = fields.Many2one(tracking=True)
user_has_group_validate_bank_account = fields.Boolean(compute='_compute_user_has_group_validate_bank_account')
allow_out_payment = fields.Boolean(
tracking=True,
help='Sending fake invoices with a fraudulent account number is a common phishing practice. '
'To protect yourself, always verify new bank account numbers, preferably by calling the vendor, as phishing '
'usually happens when their emails are compromised. Once verified, you can activate the ability to send money.'
)
currency_id = fields.Many2one(tracking=True)
lock_trust_fields = fields.Boolean(compute='_compute_lock_trust_fields')
@api.constrains('journal_id')
def _check_journal_id(self):
for bank in self:
if len(bank.journal_id) > 1:
raise ValidationError(_('A bank account can belong to only one journal.'))
@api.constrains('allow_out_payment')
def _check_allow_out_payment(self):
""" Block enabling the setting, but it can be set to false without the group. (For example, at creation) """
for bank in self:
if bank.allow_out_payment:
if not self.env.user.has_group('account.group_validate_bank_account'):
raise ValidationError(_('You do not have the right to trust or un-trust a bank account.'))
@api.depends('partner_id.country_id', 'sanitized_acc_number', 'allow_out_payment', 'acc_type')
def _compute_display_account_warning(self):
for bank in self:
if bank.allow_out_payment or not bank.sanitized_acc_number or bank.acc_type != 'iban':
bank.has_iban_warning = False
bank.has_money_transfer_warning = False
continue
bank_country = bank.sanitized_acc_number[:2]
bank.has_iban_warning = bank.partner_id.country_id and bank_country != bank.partner_id.country_id.code
bank_institution_code = bank.sanitized_acc_number[4:7]
bank.has_money_transfer_warning = bank_institution_code in bank._get_money_transfer_services()
@api.depends('sanitized_acc_number', 'allow_out_payment')
def _compute_money_transfer_service_name(self):
for bank in self:
if bank.sanitized_acc_number:
bank_institution_code = bank.sanitized_acc_number[4:7]
bank.money_transfer_service = bank._get_money_transfer_services().get(bank_institution_code, False)
else:
bank.money_transfer_service = False
def _get_money_transfer_services(self):
return {
'967': 'Wise',
'977': 'Paynovate',
'974': 'PPS EU SA',
}
@api.depends('acc_number')
@api.depends_context('uid')
def _compute_user_has_group_validate_bank_account(self):
user_has_group_validate_bank_account = self.env.user.has_group('account.group_validate_bank_account')
for bank in self:
bank.user_has_group_validate_bank_account = user_has_group_validate_bank_account
@api.depends('allow_out_payment')
def _compute_lock_trust_fields(self):
for bank in self:
if not bank._origin or not bank.allow_out_payment:
bank.lock_trust_fields = False
elif bank._origin and bank.allow_out_payment:
bank.lock_trust_fields = True
def _build_qr_code_vals(self, amount, free_communication, structured_communication, currency, debtor_partner, qr_method=None, silent_errors=True):
""" Returns the QR-code vals needed to generate the QR-code report link to pay this account with the given parameters,
or None if no QR-code could be generated.
:param amount: The amount to be paid
:param free_communication: Free communication to add to the payment when generating one with the QR-code
:param structured_communication: Structured communication to add to the payment when generating one with the QR-code
:param currency: The currency in which amount is expressed
:param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
:param qr_method: The QR generation method to be used to make the QR-code. If None, the first one giving a result will be used.
:param silent_errors: If true, forbids errors to be raised if some tested QR-code format can't be generated because of incorrect data.
"""
if not self:
return None
self.ensure_one()
if not currency:
raise UserError(_("Currency must always be provided in order to generate a QR-code"))
available_qr_methods = self.get_available_qr_methods_in_sequence()
candidate_methods = qr_method and [(qr_method, dict(available_qr_methods)[qr_method])] or available_qr_methods
for candidate_method, candidate_name in candidate_methods:
error_message = self._get_error_messages_for_qr(candidate_method, debtor_partner, currency)
if not error_message:
error_message = self._check_for_qr_code_errors(candidate_method, amount, currency, debtor_partner, free_communication, structured_communication)
if not error_message:
return {
'qr_method': candidate_method,
'amount': amount,
'currency': currency,
'debtor_partner': debtor_partner,
'free_communication': free_communication,
'structured_communication': structured_communication,
}
if not silent_errors:
error_header = _("The following error prevented '%s' QR-code to be generated though it was detected as eligible: ", candidate_name)
raise UserError(error_header + error_message)
return None
def build_qr_code_url(self, amount, free_communication, structured_communication, currency, debtor_partner, qr_method=None, silent_errors=True):
vals = self._build_qr_code_vals(amount, free_communication, structured_communication, currency, debtor_partner, qr_method, silent_errors)
if vals:
return self._get_qr_code_url(**vals)
return None
def build_qr_code_base64(self, amount, free_communication, structured_communication, currency, debtor_partner, qr_method=None, silent_errors=True):
vals = self._build_qr_code_vals(amount, free_communication, structured_communication, currency, debtor_partner, qr_method, silent_errors)
if vals:
return self._get_qr_code_base64(**vals)
return None
def _get_qr_vals(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
return None
def _get_qr_code_generation_params(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
raise NotImplementedError()
def _get_qr_code_url(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
""" Hook for extension, to support the different QR generation methods.
This function uses the provided qr_method to try generation a QR-code for
the given data. It it succeeds, it returns the report URL to make this
QR-code; else None.
:param qr_method: The QR generation method to be used to make the QR-code.
:param amount: The amount to be paid
:param currency: The currency in which amount is expressed
:param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
:param free_communication: Free communication to add to the payment when generating one with the QR-code
:param structured_communication: Structured communication to add to the payment when generating one with the QR-code
"""
params = self._get_qr_code_generation_params(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
return '/report/barcode/?' + werkzeug.urls.url_encode(params) if params else None
def _get_qr_code_base64(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
""" Hook for extension, to support the different QR generation methods.
This function uses the provided qr_method to try generation a QR-code for
the given data. It it succeeds, it returns QR code in base64 url; else None.
:param qr_method: The QR generation method to be used to make the QR-code.
:param amount: The amount to be paid
:param currency: The currency in which amount is expressed
:param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
:param free_communication: Free communication to add to the payment when generating one with the QR-code
:param structured_communication: Structured communication to add to the payment when generating one with the QR-code
"""
params = self._get_qr_code_generation_params(qr_method, amount, currency, debtor_partner, free_communication, structured_communication)
if params:
try:
barcode = self.env['ir.actions.report'].barcode(**params)
except (ValueError, AttributeError):
raise werkzeug.exceptions.HTTPException(description='Cannot convert into barcode.')
return image_data_uri(base64.b64encode(barcode))
return None
@api.model
def _get_available_qr_methods(self):
""" Returns the QR-code generation methods that are available on this db,
in the form of a list of (code, name, sequence) elements, where
'code' is a unique string identifier, 'name' the name to display
to the user to designate the method, and 'sequence' is a positive integer
indicating the order in which those mehtods need to be checked, to avoid
shadowing between them (lower sequence means more prioritary).
"""
return []
@api.model
def get_available_qr_methods_in_sequence(self):
""" Same as _get_available_qr_methods but without returning the sequence,
and using it directly to order the returned list.
"""
all_available = self._get_available_qr_methods()
all_available.sort(key=lambda x: x[2])
return [(code, name) for (code, name, sequence) in all_available]
def _get_error_messages_for_qr(self, qr_method, debtor_partner, currency):
""" Tells whether or not the criteria to apply QR-generation
method qr_method are met for a payment on this account, in the
given currency, by debtor_partner. This does not impeach generation errors,
it only checks that this type of QR-code *should be* possible to generate.
If not, returns an adequate error message to be displayed to the user if need be.
Consistency of the required field needs then to be checked by _check_for_qr_code_errors().
:returns: None if the qr method is eligible, or the error message
"""
return None
def _check_for_qr_code_errors(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
""" Checks the data before generating a QR-code for the specified qr_method
(this method must have been checked for eligbility by _get_error_messages_for_qr() first).
Returns None if no error was found, or a string describing the first error encountered
so that it can be reported to the user.
"""
return None
@api.model_create_multi
def create(self, vals_list):
# EXTENDS base res.partner.bank
if not self.env.user.has_group('account.group_validate_bank_account'):
for vals in vals_list:
# force the allow_out_payment field to False in order to prevent scam payments on newly created bank accounts
vals['allow_out_payment'] = False
res = super().create(vals_list)
for account in res:
msg = _("Bank Account %s created", account._get_html_link(title=f"#{account.id}"))
account.partner_id._message_log(body=msg)
return res
def write(self, vals):
# EXTENDS base res.partner.bank
# Track and log changes to partner_id, heavily inspired from account_move
account_initial_values = defaultdict(dict)
# Get all tracked fields (without related fields because these fields must be managed on their own model)
tracking_fields = []
for field_name in vals:
field = self._fields[field_name]
if not (hasattr(field, 'related') and field.related) and hasattr(field, 'tracking') and field.tracking:
tracking_fields.append(field_name)
fields_definition = self.env['res.partner.bank'].fields_get(tracking_fields)
# Get initial values for each account
for account in self:
for field in tracking_fields:
# Group initial values by partner_id
account_initial_values[account][field] = account[field]
# Some fields should not be editable based on conditions. It is enforced in the view, but not in python which
# leaves them vulnerable to edits via the shell/... So we need to ensure that the user has the rights to edit
# these fields when writing too.
# While we do lock changes if the account is trusted, we still want to allow to change them if we go from not trusted -> trusted or from trusted -> not trusted.
any_trusted_accounts = any(account.lock_trust_fields for account in self)
if not any_trusted_accounts:
should_allow_changes = True # If we were on a non-trusted account, we will allow to change (setting/... one last time before trusting)
else:
# If we were on a trusted account, we only allow changes if the account is moving to untrusted.
should_allow_changes = ('allow_out_payment' in vals and vals['allow_out_payment'] is False)
if ('acc_number' in vals or 'partner_id' in vals) and not should_allow_changes:
raise UserError(_("You cannot modify the account number or partner of an account that has been trusted."))
if 'allow_out_payment' in vals and not self.env.user.has_group('account.group_validate_bank_account'):
raise UserError(_("You do not have the rights to trust or un-trust accounts."))
res = super().write(vals)
# Log changes to move lines on each move
for account, initial_values in account_initial_values.items():
tracking_value_ids = account._mail_track(fields_definition, initial_values)[1]
if tracking_value_ids:
msg = _("Bank Account %s updated", account._get_html_link(title=f"#{account.id}"))
account.partner_id._message_log(body=msg, tracking_value_ids=tracking_value_ids)
if 'partner_id' in initial_values: # notify previous partner as well
initial_values['partner_id']._message_log(body=msg, tracking_value_ids=tracking_value_ids)
return res
def unlink(self):
# EXTENDS base res.partner.bank
for account in self:
msg = _("Bank Account %(link)s with number %(number)s deleted", link=account._get_html_link(title=f"#{account.id}"), number=account.acc_number)
account.partner_id._message_log(body=msg)
return super().unlink()
def default_get(self, fields_list):
if 'acc_number' not in fields_list:
return super().default_get(fields_list)
# When create & edit, `name` could be used to pass (in the context) the
# value input by the user. However, we want to set the default value of
# `acc_number` variable instead.
default_acc_number = self._context.get('default_acc_number', False) or self._context.get('default_name', False)
return super(ResPartnerBank, self.with_context(default_acc_number=default_acc_number)).default_get(fields_list)
@api.depends('allow_out_payment', 'acc_number', 'bank_id')
@api.depends_context('display_account_trust')
def _compute_display_name(self):
super()._compute_display_name()
if self.env.context.get('display_account_trust'):
for acc in self:
trusted_label = _('trusted') if acc.allow_out_payment else _('untrusted')
if acc.bank_id:
name = f'{acc.acc_number} - {acc.bank_id.name} ({trusted_label})'
else:
name = f'{acc.acc_number} ({trusted_label})'
acc.display_name = name
|