# 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 functools import wraps from sql import Null from sql.aggregate import BoolAnd, Min from sql.conditionals import Coalesce from trytond import backend from trytond.i18n import gettext from trytond.model import ModelView, Workflow, fields from trytond.modules.account.exceptions import AccountMissing from trytond.pool import Pool, PoolMeta from trytond.pyson import Bool, Eval, If, TimeDelta from trytond.tools import grouped_slice, reduce_ids from trytond.transaction import Transaction from trytond.wizard import Button, StateTransition, StateView, Wizard class Journal(metaclass=PoolMeta): __name__ = 'account.payment.journal' clearing_account = fields.Many2One('account.account', 'Clearing Account', domain=[ ('company', '=', Eval('company', -1)), ('type', '!=', None), ('closed', '!=', True), ('party_required', '=', False), ], states={ 'required': Bool(Eval('clearing_journal')), }) clearing_journal = fields.Many2One('account.journal', 'Clearing Journal', states={ 'required': Bool(Eval('clearing_account')), }, context={ 'company': Eval('company', None), }) clearing_posting_delay = fields.TimeDelta( "Clearing Posting Delay", domain=['OR', ('clearing_posting_delay', '=', None), ('clearing_posting_delay', '>=', TimeDelta()), ], help="Post automatically the clearing moves after the delay.\n" "Leave empty for no posting.") @classmethod def cron_post_clearing_moves(cls, date=None): pool = Pool() Date = pool.get('ir.date') Move = pool.get('account.move') if date is None: date = Date.today() moves = [] journals = cls.search([ ('company', '=', Transaction().context.get('company')), ('clearing_posting_delay', '!=', None), ]) for journal in journals: move_date = date - journal.clearing_posting_delay moves.extend(Move.search([ ('date', '<=', move_date), ('origin.journal.id', '=', journal.id, 'account.payment'), ('state', '=', 'draft'), ('company', '=', journal.company.id), ])) Move.post(moves) def cancel_clearing_move(func): @wraps(func) def wrapper(cls, payments, *args, **kwargs): pool = Pool() Move = pool.get('account.move') Line = pool.get('account.move.line') Reconciliation = pool.get('account.move.reconciliation') result = func(cls, payments, *args, **kwargs) to_delete = [] to_reconcile = defaultdict(lambda: defaultdict(list)) to_unreconcile = [] for payment in payments: if payment.clearing_move: if payment.clearing_move.state == 'draft': to_delete.append(payment.clearing_move) for line in payment.clearing_move.lines: if line.reconciliation: to_unreconcile.append(line.reconciliation) else: cancel_move = payment.clearing_move.cancel() for line in (payment.clearing_move.lines + cancel_move.lines): if line.reconciliation: to_unreconcile.append(line.reconciliation) if line.account.reconcile: to_reconcile[payment.party][line.account].append( line) # Remove clearing_move before delete # in case reconciliation triggers use it. cls.write(payments, {'clearing_move': None}) if to_unreconcile: Reconciliation.delete(to_unreconcile) if to_delete: Move.delete(to_delete) for party in to_reconcile: for lines in to_reconcile[party].values(): Line.reconcile(lines) cls.update_reconciled(payments) return result return wrapper class Payment(metaclass=PoolMeta): __name__ = 'account.payment' account = fields.Many2One( 'account.account', "Account", ondelete='RESTRICT', domain=[ ('closed', '!=', True), ('company', '=', Eval('company', -1)), ['OR', ('second_currency', '=', Eval('currency', None)), [ ('company.currency', '=', Eval('currency', None)), ('second_currency', '=', None), ], ], If(Eval('line'), ('id', '=', None), ()), ], states={ 'readonly': Eval('state') != 'draft', 'invisible': Eval('line') & ~Eval('account'), }, help="Define the account to use for clearing move.") clearing_move = fields.Many2One('account.move', 'Clearing Move', readonly=True) clearing_reconciled = fields.Boolean( "Clearing Reconciled", readonly=True, states={ 'invisible': ~Eval('clearing_move'), }, help="Checked if clearing line is reconciled.") @property def amount_line_paid(self): amount = super().amount_line_paid if self.clearing_move: clearing_lines = [ l for l in self.clearing_move.lines if l.account == self.clearing_account] if clearing_lines: clearing_line = clearing_lines[0] if (not self.line.reconciliation and clearing_line.reconciliation): if self.line.second_currency: payment_amount = abs(self.line.amount_second_currency) else: payment_amount = abs( self.line.credit - self.line.debit) amount -= max(min(self.amount, payment_amount), 0) return amount @classmethod def __setup__(cls): super().__setup__() line_invisible = Eval('account') & ~Eval('line') if 'invisible' in cls.line.states: cls.line.states['invisible'] &= line_invisible else: cls.line.states['invisible'] = line_invisible cls._buttons.update({ 'succeed_wizard': cls._buttons['succeed'], }) cls.account.domain = [ cls.account.domain, cls._account_type_domain(), ] @classmethod def _account_type_domain(cls): return If(Eval('state') == 'draft', If(Eval('kind') == 'receivable', ('type.receivable', '=', True), ('type.payable', '=', True), ), ()) @fields.depends('party', 'kind', 'date') def on_change_party(self): super().on_change_party() if self.kind == 'receivable': if self.party: with Transaction().set_context(date=self.date): self.account = self.party.account_receivable_used else: self.account = None @classmethod @ModelView.button_action('account_payment_clearing.wizard_succeed') def succeed_wizard(cls, payments): pass @classmethod @ModelView.button @Workflow.transition('succeeded') def succeed(cls, payments): pool = Pool() Line = pool.get('account.move.line') super().succeed(payments) cls.set_clearing_move(payments) to_reconcile = [] for payment in payments: if (payment.line and not payment.line.reconciliation and payment.clearing_move): lines = [l for l in payment.clearing_move.lines if l.account == payment.line.account] + [payment.line] if not sum(l.debit - l.credit for l in lines): to_reconcile.append(lines) Line.reconcile(*to_reconcile) cls.reconcile_clearing(payments) cls.update_reconciled(payments) @property def clearing_account(self): transaction = Transaction() context = transaction.context with transaction.set_context(date=context.get('clearing_date')): if self.line: account = self.line.account.current() if not account: raise AccountMissing(gettext( 'account_payment_clearing' '.msg_payment_clearing_account_missing', payment=self.rec_name, account=self.line.account.rec_name)) elif self.account: account = self.account.current() if not account: raise AccountMissing(gettext( 'account_payment_clearing' '.msg_payment_clearing_account_missing', payment=self.rec_name, account=self.account.rec_name)) elif self.kind == 'payable': account = self.party.account_payable_used if not account: raise AccountMissing(gettext( 'account_payment_clearing' '.msg_payment_clearing_account_payable_missing', payment=self.rec_name, party=self.party.rec_name)) elif self.kind == 'receivable': account = self.party.account_receivable_used if not account: raise AccountMissing(gettext( 'account_payment_clearing' '.msg_payment_clearing_account_receivable_missing', payment=self.rec_name, party=self.party.rec_name)) return account @property def clearing_party(self): if self.line: return self.line.party else: return self.party @classmethod def set_clearing_move(cls, payments): pool = Pool() Move = pool.get('account.move') moves = [] for payment in payments: move = payment._get_clearing_move() if move and not payment.clearing_move: payment.clearing_move = move moves.append(move) if moves: Move.save(moves) cls.save(payments) def _get_clearing_move(self, date=None): pool = Pool() Move = pool.get('account.move') Line = pool.get('account.move.line') Currency = pool.get('currency.currency') Period = pool.get('account.period') Date = pool.get('ir.date') if (not self.journal.clearing_account or not self.journal.clearing_journal): return if self.clearing_move: return self.clearing_move if date is None: date = Transaction().context.get('clearing_date') if date is None: with Transaction().set_context(company=self.company.id): date = Date.today() period = Period.find(self.company, date=date) local_currency = self.journal.currency == self.company.currency if not local_currency: with Transaction().set_context(date=self.date): local_amount = Currency.compute( self.journal.currency, self.amount, self.company.currency) else: local_amount = self.amount move = Move(journal=self.journal.clearing_journal, origin=self, date=date, period=period, company=self.company) line = Line() if self.kind == 'payable': line.debit, line.credit = local_amount, 0 else: line.debit, line.credit = 0, local_amount line.account = self.clearing_account if not local_currency: line.amount_second_currency = self.amount.copy_sign( line.debit - line.credit) line.second_currency = self.journal.currency line.party = (self.clearing_party if line.account.party_required else None) counterpart = Line() if self.kind == 'payable': counterpart.debit, counterpart.credit = 0, local_amount else: counterpart.debit, counterpart.credit = local_amount, 0 counterpart.account = self.journal.clearing_account.current(date) if not counterpart.account: raise AccountMissing(gettext( 'account_payment_clearing' '.msg_payment_clearing_account_missing_journal', payment=self.rec_name, account=self.journal.clearing_account.rec_name, journal=self.journal.rec_name)) if not local_currency: counterpart.amount_second_currency = self.amount.copy_sign( counterpart.debit - counterpart.credit) counterpart.second_currency = self.journal.currency move.lines = (line, counterpart) return move @classmethod @ModelView.button @Workflow.transition('processing') @cancel_clearing_move def proceed(cls, payments): super().proceed(payments) @classmethod @ModelView.button @Workflow.transition('failed') @cancel_clearing_move def fail(cls, payments): super().fail(payments) @classmethod def update_reconciled(cls, payments): for payment in payments: if payment.clearing_move: payment.clearing_reconciled = all( l.reconciliation for l in payment.clearing_lines) else: payment.clearing_reconciled = False cls.save(payments) @classmethod def reconcile_clearing(cls, payments): pool = Pool() MoveLine = pool.get('account.move.line') Group = pool.get('account.payment.group') to_reconcile = [] for payment in payments: if not payment.clearing_move: continue clearing_account = payment.journal.clearing_account if not clearing_account or not clearing_account.reconcile: continue lines = [l for l in payment.clearing_lines if not l.reconciliation] if lines and not sum((l.debit - l.credit) for l in lines): to_reconcile.append(lines) if to_reconcile: MoveLine.reconcile(*to_reconcile) Group.reconcile_clearing( Group.browse(list({p.group for p in payments if p.group}))) @property def clearing_lines(self): clearing_account = self.journal.clearing_account if self.clearing_move: for line in self.clearing_move.lines: if line.account == clearing_account: yield line @classmethod def copy(cls, payments, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('clearing_move') return super().copy(payments, default=default) class Group(metaclass=PoolMeta): __name__ = 'account.payment.group' clearing_reconciled = fields.Function(fields.Boolean( "Clearing Reconciled", help="All payments in the group are reconciled."), 'get_reconciled', searcher='search_reconciled') @classmethod def __setup__(cls): super().__setup__() cls._buttons.update({ 'succeed_wizard': cls._buttons['succeed'], }) @classmethod def get_reconciled(cls, groups, name): pool = Pool() Payment = pool.get('account.payment') payment = Payment.__table__() cursor = Transaction().connection.cursor() result = defaultdict(lambda: None) column = Coalesce(payment.clearing_reconciled, False) if backend.name == 'sqlite': column = Min(column) else: column = BoolAnd(column) for sub_groups in grouped_slice(groups): cursor.execute(*payment.select( payment.group, column, where=reduce_ids(payment.group, sub_groups), group_by=payment.group)) result.update(cursor) return result @classmethod def search_reconciled(cls, name, clause): pool = Pool() Payment = pool.get('account.payment') payment = Payment.__table__() _, operator, value = clause Operator = fields.SQL_OPERATORS[operator] column = Coalesce(payment.clearing_reconciled, False) if backend.name == 'sqlite': column = Min(column) else: column = BoolAnd(column) query = payment.select( payment.group, where=payment.group != Null, having=Operator(column, value), group_by=payment.group) return [('id', 'in', query)] @classmethod @ModelView.button_action( 'account_payment_clearing.wizard_payment_group_succeed') def succeed_wizard(cls, groups): pass @classmethod def reconcile_clearing(cls, groups): pool = Pool() MoveLine = pool.get('account.move.line') to_reconcile = [] for group in groups: clearing_account = group.journal.clearing_account if not clearing_account or not clearing_account.reconcile: continue lines = [l for l in group.clearing_lines if not l.reconciliation] if lines and not sum((l.debit - l.credit) for l in lines): to_reconcile.append(lines) if to_reconcile: MoveLine.reconcile(*to_reconcile) @property def clearing_lines(self): for payment in self.payments: yield from payment.clearing_lines class Succeed(Wizard): __name__ = 'account.payment.succeed' start = StateView('account.payment.succeed.start', 'account_payment_clearing.succeed_start_view_form', [ Button('Cancel', 'end', 'tryton-cancel'), Button('Succeed', 'succeed', 'tryton-ok', default=True), ]) succeed = StateTransition() def transition_succeed(self): with Transaction().set_context(clearing_date=self.start.date): self.model.succeed(self.records) return 'end' class SucceedStart(ModelView): __name__ = 'account.payment.succeed.start' date = fields.Date("Date", required=True) @classmethod def default_date(cls): pool = Pool() Date = pool.get('ir.date') return Date.today()