# 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, Index, 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): __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.party.save() bank.save() return bank class Account(DeactivableMixin, ModelSQL, ModelView): __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') owners_sequence = fields.Integer("Owners Sequence") 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.") @classmethod def __setup__(cls): super().__setup__() table = cls.__table__() cls._sql_indexes.add( Index(table, (table.owners_sequence, Index.Range(order='ASC NULLS FIRST')), (table.id, Index.Range(order='ASC')))) 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 on_modification(cls, mode, accounts, field_names=None): super().on_modification(mode, accounts, field_names=field_names) if mode == 'create': for account in accounts: if not account.bank: bank = account.guess_bank() if bank: account.bank = bank cls.save(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): __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", required=True) number_compact = fields.Char( "Number Compact", readonly=True, states={ 'required': Eval('type').in_(['iban']), }) @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 preprocess_values(cls, mode, values): values = super().preprocess_values(mode, values) if mode == 'create': if (values.get('type') == 'iban' and (number := values.get('number'))): values['number'] = iban.format(number) values['number_compact'] = iban.compact(number) return values def compute_fields(self, field_names=None): values = super().compute_fields(field_names=field_names) if ((field_names is None or {'type', 'number'} & field_names) and getattr(self, 'type', None) == 'iban'): number = getattr(self, 'number', None) if number: number = iban.format(number) number_compact = iban.compact(number) if getattr(self, 'number') != number: values['number'] = number if getattr(self, 'number_compact', None) != number_compact: values['number_compact'] = number_compact return values @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): __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)