Files
2026-03-14 09:42:12 +00:00

527 lines
19 KiB
Python

# 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()