# This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. from collections import defaultdict from decimal import Decimal from itertools import groupby import stdnum.exceptions from sql.aggregate import Count, Sum from sql.functions import CharLength from sql.operators import Abs from stdnum import iso11649 from trytond import backend from trytond.i18n import gettext from trytond.model import ( ChatMixin, DeactivableMixin, Index, ModelSQL, ModelView, Workflow, fields) from trytond.model.exceptions import AccessError from trytond.modules.company.model import ( employee_field, reset_employee, set_employee) from trytond.modules.currency.fields import Monetary from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval, If from trytond.rpc import RPC from trytond.tools import ( cursor_dict, grouped_slice, reduce_ids, sortable_values, sqlite_apply_types) from trytond.transaction import Transaction from trytond.wizard import StateAction, Wizard from .exceptions import ( OverpayWarning, PaymentValidationError, ReconciledWarning) KINDS = [ ('payable', 'Payable'), ('receivable', 'Receivable'), ] class Journal(DeactivableMixin, ModelSQL, ModelView): __name__ = 'account.payment.journal' name = fields.Char('Name', required=True) currency = fields.Many2One('currency.currency', 'Currency', required=True) company = fields.Many2One('company.company', "Company", required=True) process_method = fields.Selection([ ('manual', 'Manual'), ], 'Process Method', required=True) @classmethod def __setup__(cls): super().__setup__() cls._order.insert(0, ('name', 'ASC')) @staticmethod def default_currency(): if Transaction().context.get('company'): Company = Pool().get('company.company') company = Company(Transaction().context['company']) return company.currency.id @staticmethod def default_company(): return Transaction().context.get('company') class Group(ModelSQL, ModelView, ChatMixin): __name__ = 'account.payment.group' _rec_name = 'number' number = fields.Char('Number', required=True, readonly=True) company = fields.Many2One( 'company.company', "Company", required=True, readonly=True) journal = fields.Many2One('account.payment.journal', 'Journal', required=True, readonly=True, domain=[ ('company', '=', Eval('company', -1)), ]) kind = fields.Selection(KINDS, 'Kind', required=True, readonly=True) payments = fields.One2Many( 'account.payment', 'group', 'Payments', readonly=True, domain=[ ('company', '=', Eval('company', -1)), ('journal', '=', Eval('journal', -1)), ], order=[('date', 'ASC'), ('id', 'ASC')]) currency = fields.Function(fields.Many2One( 'currency.currency', "Currency"), 'on_change_with_currency', searcher='search_currency') payment_count = fields.Function(fields.Integer( "Payment Count", help="The number of payments in the group."), 'get_payment_aggregated') payment_amount = fields.Function(Monetary( "Payment Total Amount", currency='currency', digits='currency', help="The sum of all payment amounts."), 'get_payment_aggregated') payment_amount_succeeded = fields.Function(Monetary( "Payment Succeeded", currency='currency', digits='currency', help="The sum of the amounts of the successful payments."), 'get_payment_aggregated') payment_complete = fields.Function(fields.Boolean( "Payment Complete", help="All the payments in the group are complete."), 'get_payment_aggregated', searcher='search_complete') process_method = fields.Function( fields.Selection('get_process_methods', "Process Method"), 'on_change_with_process_method', searcher='search_process_method') @classmethod def __setup__(cls): super().__setup__() cls._buttons.update( succeed={ 'invisible': Eval('payment_complete', False), 'depends': ['payment_complete', 'process_method'], }, ) @classmethod def order_number(cls, tables): table, _ = tables[None] return [CharLength(table.number), table.number] @staticmethod def default_company(): return Transaction().context.get('company') @classmethod def get_process_methods(cls): pool = Pool() Journal = pool.get('account.payment.journal') field_name = 'process_method' return Journal.fields_get([field_name])[field_name]['selection'] @fields.depends('journal') def on_change_with_process_method(self, name=None): if self.journal: return self.journal.process_method @classmethod def search_process_method(cls, name, clause): return [('journal.' + clause[0],) + tuple(clause[1:])] def process_manual(self): pass @classmethod def preprocess_values(cls, mode, values): pool = Pool() Configuration = pool.get('account.configuration') values = super().preprocess_values(mode, values) if mode == 'create' and not values.get('number'): configuration = Configuration(1) company_id = values.get('company', cls.default_company()) if company_id is not None: if sequence := configuration.get_multivalue( 'payment_group_sequence', company=company_id): values['number'] = sequence.get() return values @classmethod def copy(cls, groups, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('number', None) default.setdefault('payments', None) return super().copy(groups, default=default) @classmethod @ModelView.button def succeed(cls, groups): pool = Pool() Payment = pool.get('account.payment') payments = sum((g.payments for g in groups), ()) Payment.succeed(payments) @classmethod def _get_complete_states(cls): return ['succeeded', 'failed'] @classmethod def get_payment_aggregated(cls, groups, names): pool = Pool() Payment = pool.get('account.payment') cursor = Transaction().connection.cursor() payment = Payment.__table__() # initialize result and columns result = defaultdict(lambda: defaultdict(lambda: None)) columns = [ payment.group.as_('group_id'), Count(payment.group).as_('payment_count'), Sum(payment.amount).as_('payment_amount'), Sum(payment.amount, filter_=(payment.state == 'succeeded'), ).as_('payment_amount_succeeded'), Count(payment.group, filter_=(~payment.state.in_(cls._get_complete_states())), ).as_('payment_not_complete'), ] for sub_ids in grouped_slice(groups): query = payment.select(*columns, where=reduce_ids(payment.group, sub_ids), group_by=payment.group) if backend.name == 'sqlite': sqlite_apply_types( query, [None, None, 'NUMERIC', 'NUMERIC', None]) cursor.execute(*query) for row in cursor_dict(cursor): group = cls(row['group_id']) result['payment_count'][group.id] = row['payment_count'] result['payment_complete'][group.id] = \ not row['payment_not_complete'] amount = row['payment_amount'] succeeded = row['payment_amount_succeeded'] if amount is not None and backend.name == 'sqlite': amount = group.company.currency.round(amount) result['payment_amount'][group.id] = amount if succeeded is not None and backend.name == 'sqlite': succeeded = group.company.currency.round(succeeded) result['payment_amount_succeeded'][group.id] = succeeded for key in list(result.keys()): if key not in names: del result[key] return result @classmethod def search_complete(cls, name, clause): pool = Pool() Payment = pool.get('account.payment') payment = Payment.__table__() query_not_completed = payment.select(payment.group, where=~payment.state.in_(cls._get_complete_states()), group_by=payment.group) operators = { '=': 'not in', '!=': 'in', } reverse = { '=': 'in', '!=': 'not in', } if clause[1] in operators: if clause[2]: return [('id', operators[clause[1]], query_not_completed)] else: return [('id', reverse[clause[1]], query_not_completed)] else: return [] @fields.depends('journal') def on_change_with_currency(self, name=None): return self.journal.currency if self.journal else None @classmethod def search_currency(cls, name, clause): return [('journal.' + clause[0],) + tuple(clause[1:])] _STATES = { 'readonly': Eval('state') != 'draft', } class Payment(Workflow, ModelSQL, ModelView, ChatMixin): __name__ = 'account.payment' _rec_name = 'number' number = fields.Char("Number", required=True, readonly=True) reference = fields.Char("Reference", states=_STATES) reference_type = fields.Selection([ (None, ""), ('creditor_reference', "Creditor Reference"), ], "Reference Type") reference_type_string = reference_type.translated('reference_type') company = fields.Many2One( 'company.company', "Company", required=True, states=_STATES) journal = fields.Many2One('account.payment.journal', 'Journal', required=True, states=_STATES, domain=[ ('company', '=', Eval('company', -1)), ]) currency = fields.Function(fields.Many2One('currency.currency', 'Currency'), 'on_change_with_currency', searcher='search_currency') kind = fields.Selection(KINDS, 'Kind', required=True, states=_STATES) party = fields.Many2One( 'party.party', "Party", required=True, states=_STATES, context={ 'company': Eval('company', -1), }, depends={'company'}) date = fields.Date('Date', required=True, states=_STATES) amount = Monetary( "Amount", currency='currency', digits='currency', required=True, domain=[('amount', '>=', 0)], states={ 'readonly': ~Eval('state').in_( If(Eval('process_method') == 'manual', ['draft', 'processing'], ['draft'])), }) line = fields.Many2One('account.move.line', 'Line', ondelete='RESTRICT', domain=[ ('move.company', '=', Eval('company', -1)), If(Eval('kind') == 'receivable', ['OR', ('debit', '>', 0), ('credit', '<', 0)], ['OR', ('credit', '>', 0), ('debit', '<', 0)], ), ['OR', ('account.type.receivable', '=', True), ('account.type.payable', '=', True), ], ('party', 'in', [Eval('party', None), None]), If(Eval('state') == 'draft', [ ('reconciliation', '=', None), ('maturity_date', '!=', None), ], []), ['OR', ('second_currency', '=', Eval('currency', None)), [ ('account.company.currency', '=', Eval('currency', None)), ('second_currency', '=', None), ], ], ('move_state', '=', 'posted'), ], states=_STATES) origin = fields.Reference( "Origin", selection='get_origin', states={ 'readonly': Eval('state') != 'draft', }) group = fields.Many2One('account.payment.group', 'Group', readonly=True, ondelete='RESTRICT', states={ 'required': Eval('state').in_(['processing', 'succeeded']), }, domain=[ ('company', '=', Eval('company', -1)), ('journal', '=', Eval('journal', -1)), ('kind', '=', Eval('kind')), ]) process_method = fields.Function( fields.Selection('get_process_methods', "Process Method"), 'on_change_with_process_method', searcher='search_process_method') submitted_by = employee_field( "Submitted by", states=['submitted', 'processing', 'succeeded', 'failed']) approved_by = employee_field( "Approved by", states=['approved', 'processing', 'succeeded', 'failed']) succeeded_by = employee_field( "Success Noted by", states=['succeeded', 'processing']) failed_by = employee_field( "Failure Noted by", states=['failed', 'processing']) state = fields.Selection([ ('draft', 'Draft'), ('submitted', "Submitted"), ('approved', 'Approved'), ('processing', 'Processing'), ('succeeded', 'Succeeded'), ('failed', 'Failed'), ], "State", readonly=True, sort=False, domain=[ If(Eval('kind') == 'receivable', ('state', '!=', 'approved'), ()), ]) @property def amount_line_paid(self): if self.state != 'failed': if self.line.second_currency: payment_amount = abs(self.line.amount_second_currency) else: payment_amount = abs(self.line.credit - self.line.debit) return max(min(self.amount, payment_amount), 0) return Decimal(0) @classmethod def __setup__(cls): cls.number.search_unaccented = False cls.reference.search_unaccented = False super().__setup__() t = cls.__table__() cls._sql_indexes.update({ Index( t, (t.state, Index.Equality(cardinality='low')), where=t.state.in_([ 'draft', 'submitted', 'approved', 'processing'])), Index( t, (t.line, Index.Range()), where=t.state != 'failed'), }) cls._order.insert(0, ('date', 'DESC')) cls._transitions |= set(( ('draft', 'submitted'), ('submitted', 'approved'), ('submitted', 'processing'), ('approved', 'processing'), ('processing', 'succeeded'), ('processing', 'failed'), ('submitted', 'draft'), ('approved', 'draft'), ('succeeded', 'failed'), ('succeeded', 'processing'), ('failed', 'succeeded'), ('failed', 'processing'), )) cls._buttons.update({ 'draft': { 'invisible': ~Eval('state').in_(['submitted', 'approved']), 'icon': 'tryton-back', 'depends': ['state'], }, 'submit': { 'invisible': Eval('state') != 'draft', 'icon': 'tryton-forward', 'depends': ['state'], }, 'approve': { 'invisible': ( (Eval('state') != 'submitted') | (Eval('kind') == 'receivable')), 'icon': 'tryton-forward', 'depends': ['state', 'kind'], }, 'process_wizard': { 'invisible': ~( (Eval('state') == 'approved') | ((Eval('state') == 'submitted') & (Eval('kind') == 'receivable'))), 'icon': 'tryton-launch', 'depends': ['state', 'kind'], }, 'proceed': { 'invisible': ( ~Eval('state').in_(['succeeded', 'failed']) | (Eval('process_method') != 'manual')), 'icon': 'tryton-back', 'depends': ['state', 'process_method'], }, 'succeed': { 'invisible': ~Eval('state').in_( ['processing', 'failed']), 'icon': 'tryton-ok', 'depends': ['state'], }, 'fail': { 'invisible': ~Eval('state').in_( ['processing', 'succeeded']), 'icon': 'tryton-cancel', 'depends': ['state'], }, }) cls.__rpc__.update({ 'approve': RPC( readonly=False, instantiate=0, fresh_session=True), }) cls.group.states['required'] &= Eval('process_method').in_( cls.process_method_with_group()) @classmethod def __register__(cls, module): cursor = Transaction().connection.cursor() table = cls.__table__() table_h = cls.__table_handler__(module) number_exist = table_h.column_exist('number') # Migration from 7.4: rename description into reference if table_h.column_exist('description'): table_h.column_rename('description', 'reference') super().__register__(module) # Migration from 7.2: add number if not number_exist: cursor.execute(*table.update([table.number], [table.id])) @classmethod def order_number(cls, tables): table, _ = tables[None] return [CharLength(table.number), table.number] @fields.depends('reference_type', 'reference') def on_change_with_reference(self): if (reference := self.reference) and (type := self.reference_type): reference = getattr(self, f'_format_reference_{type}')(reference) return reference @classmethod def _format_reference_creditor_reference(cls, reference): try: return iso11649.format(reference) except stdnum.exceptions.ValidationError: return reference @property def reference_used(self): reference = self.reference if not reference and self.line and self.line.move_origin: reference = getattr(self.line.move_origin, 'rec_name', None) return reference @staticmethod def default_company(): return Transaction().context.get('company') @staticmethod def default_kind(): return 'payable' @staticmethod def default_date(): pool = Pool() Date = pool.get('ir.date') return Date.today() @staticmethod def default_state(): return 'draft' @fields.depends('journal') def on_change_with_currency(self, name=None): return self.journal.currency if self.journal else None @classmethod def search_currency(cls, name, clause): return [('journal.' + clause[0],) + tuple(clause[1:])] @classmethod def order_amount(cls, tables): table, _ = tables[None] context = Transaction().context column = cls.amount.sql_column(table) if isinstance(context.get('amount_order'), Decimal): return [Abs(column - abs(context['amount_order']))] else: return [column] @fields.depends('kind') def on_change_kind(self): self.line = None @fields.depends('party') def on_change_party(self): self.line = None @fields.depends('line', '_parent_line.maturity_date', '_parent_line.payment_amount') def on_change_line(self): if self.line: self.date = self.line.maturity_date self.amount = self.line.payment_amount @classmethod def _get_origin(cls): 'Return list of Model names for origin Reference' return [] @classmethod def get_origin(cls): Model = Pool().get('ir.model') get_name = Model.get_name models = cls._get_origin() return [(None, '')] + [(m, get_name(m)) for m in models] @fields.depends('journal') def on_change_with_process_method(self, name=None): if self.journal: return self.journal.process_method @classmethod def search_process_method(cls, name, clause): return [('journal.' + clause[0],) + tuple(clause[1:])] @classmethod def get_process_methods(cls): pool = Pool() Journal = pool.get('account.payment.journal') field_name = 'process_method' return Journal.fields_get([field_name])[field_name]['selection'] def get_rec_name(self, name): items = [self.number] if self.reference: items.append(f'[{self.reference}]') return ' '.join(items) @classmethod def search_rec_name(cls, name, clause): _, operator, value = clause if operator.startswith('!') or operator.startswith('not '): bool_op = 'AND' else: bool_op = 'OR' return [bool_op, ('number', *clause[1:]), ('reference', *clause[1:]), ] def chat_language(self, audience='internal'): language = super().chat_language(audience=audience) if audience == 'public': language = self.party.lang.code if self.party.lang else None return language @classmethod def view_attributes(cls): context = Transaction().context attributes = super().view_attributes() if context.get('kind') == 'receivable': attributes.append( ('/tree//button[@name="approve"]', 'tree_invisible', True)) return attributes @classmethod def copy(cls, payments, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('number', None) default.setdefault('reference') default.setdefault('reference_type') default.setdefault('group', None) default.setdefault('approved_by') default.setdefault('succeeded_by') default.setdefault('failed_by') return super().copy(payments, default=default) @classmethod def preprocess_values(cls, mode, values): pool = Pool() Configuration = pool.get('account.configuration') values = super().preprocess_values(mode, values) if mode == 'create' and not values.get('number'): configuration = Configuration(1) company_id = values.get('company', cls.default_company()) if company_id is not None: if sequence := configuration.get_multivalue( 'payment_sequence', company=company_id): values['number'] = sequence.get() return values @classmethod def check_modification(cls, mode, payments, values=None, external=False): super().check_modification( mode, payments, values=values, external=external) if mode == 'delete': for payment in payments: if payment.state != 'draft': raise AccessError(gettext( 'account_payment.msg_payment_delete_draft', payment=payment.rec_name)) @classmethod def on_modification(cls, mode, payments, field_names=None): pool = Pool() Line = pool.get('account.move.line') super().on_modification(mode, payments, field_names=field_names) if mode in {'create', 'write'}: if field_names is None or 'line' in field_names: if lines := Line.browse({p.line for p in payments if p.line}): Line.set_payment_amount(lines) @classmethod def on_write(cls, payments, values): pool = Pool() Line = pool.get('account.move.line') callback = super().on_write(payments, values) if values.keys() & {'line', 'amount', 'state'}: if lines := Line.browse({p.line for p in payments if p.line}): callback.append(lambda: Line.set_payment_amount(lines)) return callback @classmethod def on_delete(cls, payments): pool = Pool() Line = pool.get('account.move.line') callback = super().on_delete(payments) if lines := Line.browse({p.line for p in payments if p.line}): callback.append(lambda: Line.set_payment_amount(lines)) return callback @classmethod def validate_fields(cls, payments, field_names): super().validate_fields(payments, field_names) cls.check_reference(payments, field_names) @classmethod def check_reference(cls, payments, field_names): if field_names and not (field_names & {'reference', 'reference_type'}): return for payment in payments: if type := payment.reference_type: method = getattr(cls, f'_check_reference_{type}') if not method(payment): reference = payment.reference type = payment.reference_type_string raise PaymentValidationError(gettext( 'account_payment.msg_payment_reference_invalid', type=type, reference=reference, payment=payment.rec_name)) def _check_reference_creditor_reference(self): return iso11649.is_valid(self.reference) @classmethod @ModelView.button @Workflow.transition('draft') @reset_employee('submitted_by', 'approved_by', 'succeeded_by', 'failed_by') def draft(cls, payments): pass @classmethod @ModelView.button @Workflow.transition('submitted') @set_employee('submitted_by') def submit(cls, payments): cls._check_reconciled(payments) @classmethod @ModelView.button @Workflow.transition('approved') @set_employee('approved_by') def approve(cls, payments): cls._check_reconciled(payments) @classmethod @ModelView.button_action('account_payment.act_process_payments') def process_wizard(cls, payments): pass @classmethod def process_method_with_group(cls): return ['manual'] @classmethod @Workflow.transition('processing') def process(cls, payments, group=None): if payments: if group: group = group() cls.write(payments, { 'group': group.id, }) # Set state before calling process method # as it may set a different state directly cls.proceed(payments) if group: getattr(group, f'process_{group.process_method}')() else: for payment in payments: getattr(payment, f'process_{payment.process_method}')() return group @classmethod @ModelView.button @Workflow.transition('processing') def proceed(cls, payments): cls._check_reconciled( [p for p in payments if p.state not in {'succeeded', 'failed'}]) @classmethod @ModelView.button @Workflow.transition('succeeded') @set_employee('succeeded_by') def succeed(cls, payments): pass @classmethod @ModelView.button @Workflow.transition('failed') @set_employee('failed_by') def fail(cls, payments): pass @classmethod def _check_reconciled(cls, payments): pool = Pool() Warning = pool.get('res.user.warning') for payment in payments: if payment.line and payment.line.reconciliation: key = Warning.format('submit_reconciled', [payment]) if Warning.check(key): raise ReconciledWarning( key, gettext( 'account_payment.msg_payment_reconciled', payment=payment.rec_name, line=payment.line.rec_name)) class Payment_Invoice(metaclass=PoolMeta): __name__ = 'account.payment' @property def reference_used(self): pool = Pool() Invoice = pool.get('account.invoice') reference = super().reference_used if (not self.reference and self.line and isinstance(self.line.move_origin, Invoice)): invoice = self.line.move_origin if self.kind == 'payable': reference = invoice.supplier_payment_reference elif self.kind == 'receivable': reference = invoice.customer_payment_reference return reference class ProcessPayment(Wizard): __name__ = 'account.payment.process' start_state = 'process' process = StateAction('account_payment.act_payment_group_form') def _group_payment_key(self, payment): return ( ('company', payment.company), ('journal', payment.journal), ('kind', payment.kind), ) def _new_group(self, values): pool = Pool() Group = pool.get('account.payment.group') return Group(**values) def do_process(self, action): pool = Pool() Payment = pool.get('account.payment') Warning = pool.get('res.user.warning') payments = self.records payments = [ p for p in payments if p.state == 'approved' or (p.state == 'submitted' and p.kind == 'receivable')] for payment in payments: if payment.line and payment.line.payment_amount < 0: if Warning.check(str(payment)): raise OverpayWarning(str(payment), gettext('account_payment.msg_payment_overpay', payment=payment.rec_name, line=payment.line.rec_name)) process_method_with_group = Payment.process_method_with_group() groups = [] payments = sorted( payments, key=sortable_values(self._group_payment_key)) for key, grouped_payments in groupby(payments, key=self._group_payment_key): def group(): group = self._new_group(key) group.save() return group key = dict(key) process_method = key['journal'].process_method group = Payment.process( list(grouped_payments), group if process_method in process_method_with_group else None) if group: groups.append(group) if groups: return action, { 'res_id': [g.id for g in groups], }