Files
tradon/modules/bank/bank.py
2025-12-26 13:11:43 +00:00

308 lines
10 KiB
Python
Executable File

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import stdnum.exceptions
from sql import Literal, operators
from sql.operators import Equal
from stdnum import bic, iban
try:
from schwifty import BIC, IBAN
except ImportError:
BIC = IBAN = None
from trytond.i18n import gettext
from trytond.model import (
DeactivableMixin, Exclude, ModelSQL, ModelView, fields, sequence_ordered)
from trytond.pool import Pool
from trytond.pyson import Eval, If
from trytond.tools import is_full_text, lstrip_wildcard
from .exceptions import AccountValidationError, IBANValidationError, InvalidBIC
class Bank(ModelSQL, ModelView):
'Bank'
__name__ = 'bank'
party = fields.Many2One('party.party', 'Party', required=True,
ondelete='CASCADE')
bic = fields.Char('BIC', size=11, help="Bank/Business Identifier Code.")
def get_rec_name(self, name):
return self.party.rec_name
@classmethod
def search_rec_name(cls, name, clause):
_, operator, operand, *extra = clause
if operator.startswith('!') or operator.startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
bic_value = operand
if operator.endswith('like') and is_full_text(operand):
bic_value = lstrip_wildcard(operand)
return [bool_op,
('party.rec_name', operator, operand, *extra),
('bic', operator, bic_value, *extra),
]
@fields.depends('bic')
def on_change_with_bic(self):
try:
return bic.compact(self.bic)
except stdnum.exceptions.ValidationError:
pass
return self.bic
def pre_validate(self):
super().pre_validate()
self.check_bic()
@fields.depends('bic')
def check_bic(self):
if self.bic and not bic.is_valid(self.bic):
raise InvalidBIC(gettext('bank.msg_invalid_bic', bic=self.bic))
@classmethod
def from_bic(cls, bic):
"Return or create bank from BIC instance"
pool = Pool()
Party = pool.get('party.party')
if IBAN:
assert isinstance(bic, BIC)
banks = cls.search([
('bic', '=', bic.compact),
], limit=1)
if banks:
bank, = banks
return bank
cls.lock()
names = bic.bank_names
if names:
name = names[0]
else:
name = None
bank = cls(party=Party(name=name), bic=bic.compact)
bank.save()
return bank
class Account(DeactivableMixin, ModelSQL, ModelView):
'Bank Account'
__name__ = 'bank.account'
bank = fields.Many2One(
'bank', "Bank",
help="The bank where the account is open.")
owners = fields.Many2Many('bank.account-party.party', 'account', 'owner',
'Owners')
currency = fields.Many2One('currency.currency', 'Currency')
numbers = fields.One2Many(
'bank.account.number', 'account', 'Numbers',
domain=[
If(~Eval('active'), ('active', '=', False), ()),
],
help="Add the numbers which identify the bank account.")
def get_rec_name(self, name):
for number in self.numbers:
if number.number:
name = number.number
break
else:
name = '(%s)' % self.id
if self.bank:
name += ' @ %s' % self.bank.rec_name
if self.currency:
name += ' [%s]' % self.currency.code
return name
@classmethod
def search_rec_name(cls, name, clause):
if clause[1].startswith('!') or clause[1].startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
return [bool_op,
('bank.rec_name',) + tuple(clause[1:]),
('currency.rec_name',) + tuple(clause[1:]),
('numbers.rec_name',) + tuple(clause[1:]),
]
@property
def iban(self):
for number in self.numbers:
if number.type == 'iban':
return number.number
@classmethod
def validate(cls, accounts):
super().validate(accounts)
for account in accounts:
account.check_bank()
def check_bank(self):
if not self.bank or not self.bank.bic:
return
if IBAN and BIC and self.iban:
iban = IBAN(self.iban)
bic = BIC(self.bank.bic)
if (iban.bic
and iban.bic != bic
and (
iban.country_code != bic.country_code
or (iban.bank_code or iban.branch_code)
not in bic.domestic_bank_codes)):
raise AccountValidationError(
gettext('bank.msg_account_iban_invalid_bic',
account=self.rec_name,
bic=iban.bic))
@classmethod
def create(cls, vlist):
accounts = super().create(vlist)
for account in accounts:
if not account.bank:
bank = account.guess_bank()
if bank:
account.bank = bank
cls.save(accounts)
return accounts
def guess_bank(self):
pool = Pool()
Bank = pool.get('bank')
if IBAN and self.iban:
iban = IBAN(self.iban)
if iban.bic:
return Bank.from_bic(iban.bic)
class AccountNumber(DeactivableMixin, sequence_ordered(), ModelSQL, ModelView):
'Bank Account Number'
__name__ = 'bank.account.number'
_rec_name = 'number'
account = fields.Many2One(
'bank.account', "Account", required=True, ondelete='CASCADE',
domain=[
If(Eval('active'), ('active', '=', True), ()),
],
help="The bank account which is identified by the number.")
type = fields.Selection([
('iban', 'IBAN'),
('other', 'Other'),
], 'Type', required=True)
number = fields.Char('Number')
number_compact = fields.Char('Number Compact', readonly=True)
@classmethod
def __setup__(cls):
cls.number.search_unaccented = False
cls.number_compact.search_unaccented = False
super().__setup__()
table = cls.__table__()
cls._sql_constraints += [
('number_iban_active_exclude',
Exclude(table, (table.number_compact, Equal),
where=(table.type == 'iban') & table.active),
'bank.msg_number_iban_unique'),
('account_iban_active_exclude',
Exclude(table, (table.account, Equal),
where=(table.type == 'iban') & table.active),
'bank.msg_account_iban_unique'),
]
cls.__access__.add('account')
cls._order.insert(0, ('account', 'ASC'))
@classmethod
def __register__(cls, module):
table_h = cls.__table_handler__(module)
super().__register__(module)
# Migration from 7.0: replace iban exclude
table_h.drop_constraint('number_iban_exclude')
table_h.drop_constraint('account_iban_exclude')
@classmethod
def default_type(cls):
return 'iban'
@classmethod
def domain_number(cls, domain, tables):
table, _ = tables[None]
name, operator, value = domain
Operator = fields.SQL_OPERATORS[operator]
result = None
for field in (cls.number, cls.number_compact):
column = field.sql_column(table)
expression = Operator(column, field._domain_value(operator, value))
if isinstance(expression, operators.In) and not expression.right:
expression = Literal(False)
elif (isinstance(expression, operators.NotIn)
and not expression.right):
expression = Literal(True)
expression = field._domain_add_null(
column, operator, value, expression)
if result:
result |= expression
else:
result = expression
return result
@property
def compact_iban(self):
return (iban.compact(self.number) if self.type == 'iban'
else self.number)
@classmethod
def create(cls, vlist):
vlist = [v.copy() for v in vlist]
for values in vlist:
if values.get('type') == 'iban' and 'number' in values:
values['number'] = iban.format(values['number'])
values['number_compact'] = iban.compact(values['number'])
return super().create(vlist)
@classmethod
def write(cls, *args):
actions = iter(args)
args = []
for numbers, values in zip(actions, actions):
values = values.copy()
if values.get('type') == 'iban' and 'number' in values:
values['number'] = iban.format(values['number'])
values['number_compact'] = iban.compact(values['number'])
args.extend((numbers, values))
super().write(*args)
to_write = []
for number in sum(args[::2], []):
if number.type == 'iban':
formated_number = iban.format(number.number)
compacted_number = iban.compact(number.number)
if ((formated_number != number.number)
or (compacted_number != number.number_compact)):
to_write.extend(([number], {
'number': formated_number,
'number_compact': compacted_number,
}))
if to_write:
cls.write(*to_write)
@fields.depends('type', 'number')
def pre_validate(self):
super().pre_validate()
if (self.type == 'iban' and self.number
and not iban.is_valid(self.number)):
raise IBANValidationError(
gettext('bank.msg_invalid_iban',
number=self.number))
class AccountParty(ModelSQL):
'Bank Account - Party'
__name__ = 'bank.account-party.party'
account = fields.Many2One(
'bank.account', "Account", ondelete='CASCADE', required=True)
owner = fields.Many2One(
'party.party', "Owner", ondelete='CASCADE', required=True)