diff --git a/CHANGELOG b/CHANGELOG index be85262bf4..df854654cc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,8 @@ +* Show invoice payment on Move Line +* Remove payment lines which actually do not pay the Invoice +* Ensure payment lines are linked to only one Invoice +* Ensure payments on Invoice does not exceed invoice amount +* Add methods to add and remove payments on Invoice * Check pending invoice to erase party * Add invoice usage to party's contact mechanisms * Show reconciled date on invoice diff --git a/__init__.py b/__init__.py index a65bb71c95..422837c506 100644 --- a/__init__.py +++ b/__init__.py @@ -33,6 +33,7 @@ def register(): Period, FiscalYear, Move, + MoveLine, Reconciliation, module='account_invoice', type_='model') Pool.register( diff --git a/account.py b/account.py index 4754fc83e2..b81099ec38 100644 --- a/account.py +++ b/account.py @@ -7,13 +7,15 @@ from sql.conditionals import Coalesce from trytond.model import (fields, ModelView, ModelSQL, MatchMixin, sequence_ordered) -from trytond.pyson import Eval +from trytond.pyson import Eval, If, Bool from trytond.pool import Pool, PoolMeta +from trytond.tools import grouped_slice from trytond.transaction import Transaction from trytond import backend __all__ = ['FiscalYear', - 'Period', 'Move', 'Reconciliation', 'InvoiceSequence', 'RenewFiscalYear'] + 'Period', 'Move', 'MoveLine', 'Reconciliation', 'InvoiceSequence', + 'RenewFiscalYear'] class FiscalYear: @@ -195,6 +197,62 @@ class Move: return super(Move, cls)._get_origin() + ['account.invoice'] +class MoveLine: + __metaclass__ = PoolMeta + __name__ = 'account.move.line' + + invoice_payment = fields.Function(fields.Many2One( + 'account.invoice', "Invoice Payment", + domain=[ + ('account', '=', Eval('account', -1)), + If(Bool(Eval('party')), + ('party', '=', Eval('party')), + (), + ), + ], + states={ + 'invisible': Bool(Eval('reconciliation')), + }, + depends=['account', 'party', 'reconciliation']), + 'get_invoice_payment', + setter='set_invoice_payment', + searcher='search_invoice_payment') + invoice_payments = fields.Many2Many( + 'account.invoice-account.move.line', 'line', 'invoice', + "Invoice Payments", readonly=True) + + @classmethod + def __setup__(cls): + super(MoveLine, cls).__setup__() + cls._check_modify_exclude.add('invoice_payment') + + @classmethod + def get_invoice_payment(cls, lines, name): + pool = Pool() + InvoicePaymentLine = pool.get('account.invoice-account.move.line') + + ids = map(int, lines) + result = dict.fromkeys(ids, None) + for sub_ids in grouped_slice(ids): + payment_lines = InvoicePaymentLine.search([ + ('line', 'in', list(sub_ids)), + ]) + result.update({p.line.id: p.invoice.id for p in payment_lines}) + return result + + @classmethod + def set_invoice_payment(cls, lines, name, value): + pool = Pool() + Invoice = pool.get('account.invoice') + Invoice.remove_payment_lines(lines) + if value: + Invoice.add_payment_lines({Invoice(value): lines}) + + @classmethod + def search_invoice_payment(cls, name, domain): + return [('invoice_payments',) + tuple(domain[1:])] + + class Reconciliation: __metaclass__ = PoolMeta __name__ = 'account.move.reconciliation' diff --git a/account.xml b/account.xml index 3b7e8c1f56..7436924e62 100644 --- a/account.xml +++ b/account.xml @@ -9,6 +9,30 @@ this repository contains the full copyright notices and license terms. --> fiscalyear_form + + account.move.line + + move_line_form + + + + account.move.line + + move_line_form + + + + account.move.line + + move_line_list + + + + account.move.line + + move_line_list + + account.move.line tree diff --git a/invoice.py b/invoice.py index 7531a3dc13..7eb4d80209 100644 --- a/invoice.py +++ b/invoice.py @@ -12,7 +12,7 @@ from sql.conditionals import Coalesce, Case from sql.functions import Round from trytond.model import Workflow, ModelView, ModelSQL, fields, Check, \ - sequence_ordered + sequence_ordered, Unique from trytond.report import Report from trytond.wizard import Wizard, StateView, StateTransition, StateAction, \ Button @@ -184,14 +184,27 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): lines_to_pay = fields.Function(fields.One2Many('account.move.line', None, 'Lines to Pay'), 'get_lines_to_pay') payment_lines = fields.Many2Many('account.invoice-account.move.line', - 'invoice', 'line', readonly=True, string='Payment Lines', + 'invoice', 'line', string='Payment Lines', domain=[ - ('move.company', '=', Eval('company', -1)), + ('account', '=', Eval('account', -1)), + ('party', 'in', [None, Eval('party', -1)]), + ['OR', + ('invoice_payment', '=', None), + ('invoice_payment', '=', Eval('id', -1)), + ], + If(Eval('type') == 'out', + If(Eval('total_amount', 0) >= 0, + ('debit', '=', 0), + ('credit', '=', 0)), + If(Eval('total_amount', 0) >= 0, + ('credit', '=', 0), + ('debit', '=', 0))), ], states={ - 'invisible': (Eval('state') == 'paid') | ~Eval('payment_lines'), + 'invisible': Eval('state') == 'paid', + 'readonly': Eval('state') != 'posted', }, - depends=['state', 'company']) + depends=['state', 'account', 'party', 'id', 'type', 'total_amount']) amount_to_pay_today = fields.Function(fields.Numeric('Amount to Pay Today', digits=(16, Eval('currency_digits', 2)), depends=['currency_digits']), 'get_amount_to_pay') @@ -248,6 +261,12 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): 'customer_invoice_cancel_move': ( 'Customer invoice/credit note ' '"%s" can not be cancelled once posted.'), + 'payment_lines_greater_amount': ( + 'The payment lines on invoice "%(invoice)s" can not be ' + 'greater than the invoice amount.'), + 'modify_payment_lines_invoice_paid': ( + 'Payment lines can not be modified ' + 'on a paid invoice "%(invoice)s"'), }) cls._transitions |= set(( ('draft', 'validated'), @@ -1161,6 +1180,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): for invoice in invoices: invoice.check_same_account() invoice.check_cancel_move() + invoice.check_payment_lines() def check_same_account(self): for line in self.lines: @@ -1177,6 +1197,14 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): self.raise_user_error('customer_invoice_cancel_move', self.rec_name) + def check_payment_lines(self): + amount = sum(l.debit - l.credit for l in self.lines_to_pay) + payment_amount = sum(l.debit - l.credit for l in self.payment_lines) + if abs(amount) < abs(payment_amount): + self.raise_user_error('payment_lines_greater_amount', { + 'invoice': self.rec_name, + }) + def get_reconcile_lines_for_amount(self, amount): ''' Return list of lines and the remainder to make reconciliation. @@ -1257,12 +1285,50 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): for line in move.lines: if line.account == self.account: - self.write([self], { - 'payment_lines': [('add', [line.id])], - }) + self.add_payment_lines({self: [line]}) return line raise Exception('Missing account') + @classmethod + def add_payment_lines(cls, payments): + "Add value lines to the key invoice from the payment dictionary." + to_write = [] + for invoice, lines in payments.iteritems(): + if invoice.state == 'paid': + cls.raise_user_error('modify_payment_lines_invoice_paid', { + 'invoice': invoice.rec_name, + }) + to_write.append([invoice]) + to_write.append({'payment_lines': [('add', lines)]}) + if to_write: + cls.write(*to_write) + + @classmethod + def remove_payment_lines(cls, lines): + "Remove payment lines from their invoices." + pool = Pool() + PaymentLine = pool.get('account.invoice-account.move.line') + + payments = defaultdict(list) + ids = map(int, lines) + for sub_ids in grouped_slice(ids): + payment_lines = PaymentLine.search([ + ('line', 'in', list(sub_ids)), + ]) + for payment_line in payment_lines: + payments[payment_line.invoice].append(payment_line.line) + + to_write = [] + for invoice, lines in payments.iteritems(): + if invoice.state == 'paid': + cls.raise_user_error('modify_payment_lines_invoice_paid', { + 'invoice': invoice.rec_name, + }) + to_write.append([invoice]) + to_write.append({'payment_lines': [('remove', lines)]}) + if to_write: + cls.write(*to_write) + def print_invoice(self): ''' Generate invoice report and store it in invoice_report field. @@ -1385,7 +1451,19 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): @classmethod @Workflow.transition('paid') def paid(cls, invoices): - pass + # Remove links to lines which actually do not pay the invoice + to_write = [] + for invoice in invoices: + to_remove = [] + reconciliations = [l.reconciliation for l in invoice.lines_to_pay] + for payment_line in invoice.payment_lines: + if payment_line.reconciliation not in reconciliations: + to_remove.append(payment_line) + if to_remove: + to_write.append([invoice]) + to_write.append([('remove', to_remove)]) + if to_write: + cls.write(*to_write) @classmethod @ModelView.button @@ -1437,8 +1515,47 @@ class InvoicePaymentLine(ModelSQL): __name__ = 'account.invoice-account.move.line' invoice = fields.Many2One('account.invoice', 'Invoice', ondelete='CASCADE', select=True, required=True) - line = fields.Many2One('account.move.line', 'Payment Line', - ondelete='CASCADE', select=True, required=True) + invoice_account = fields.Function( + fields.Many2One('account.account', "Invoice Account"), + 'get_invoice') + invoice_party = fields.Function( + fields.Many2One('party.party', "Invoice Party"), 'get_invoice') + line = fields.Many2One( + 'account.move.line', 'Payment Line', ondelete='CASCADE', + select=True, required=True, + domain=[ + ('account', '=', Eval('invoice_account')), + ('party', '=', Eval('invoice_party')), + ], + depends=['invoice_account', 'invoice_party']) + + @classmethod + def __setup__(cls): + super(InvoicePaymentLine, cls).__setup__() + t = cls.__table__() + cls._sql_constraints = [ + ('line_unique', Unique(t, t.line), + "A payment line can be linked to only one invoice."), + ] + + @classmethod + def get_invoice(cls, records, names): + result = {} + for name in names: + result[name] = {} + invoice_account = 'invoice_account' in result + invoice_party = 'invoice_party' in result + for record in records: + if invoice_account: + result['invoice_account'][record.id] = ( + record.invoice.account.id) + if invoice_party: + if record.invoice.account.party_required: + party = record.invoice.party.id + else: + party = None + result['invoice_party'][record.id] = party + return result class InvoiceLine(sequence_ordered(), ModelSQL, ModelView, TaxableMixin): diff --git a/tests/scenario_invoice.rst b/tests/scenario_invoice.rst index e04bf5c2fa..ddde378aa8 100644 --- a/tests/scenario_invoice.rst +++ b/tests/scenario_invoice.rst @@ -36,6 +36,7 @@ Create fiscal year:: ... create_fiscalyear(company)) >>> fiscalyear.click('create_period') >>> period = fiscalyear.periods[0] + >>> period_ids = [p.id for p in fiscalyear.periods] Create chart of accounts:: @@ -49,6 +50,7 @@ Create chart of accounts:: Create tax:: + >>> TaxCode = Model.get('account.tax.code') >>> tax = set_tax_code(create_tax(Decimal('.10'))) >>> tax.save() >>> invoice_base_code = tax.invoice_base_code @@ -168,17 +170,21 @@ Post invoice:: Decimal('0.00') >>> account_tax.credit Decimal('20.00') - >>> invoice_base_code.reload() - >>> invoice_base_code.sum + >>> with config.set_context(periods=period_ids): + ... invoice_base_code = TaxCode(invoice_base_code.id) + ... invoice_base_code.sum Decimal('200.00') - >>> invoice_tax_code.reload() - >>> invoice_tax_code.sum + >>> with config.set_context(periods=period_ids): + ... invoice_tax_code = TaxCode(invoice_tax_code.id) + ... invoice_tax_code.sum Decimal('20.00') - >>> credit_note_base_code.reload() - >>> credit_note_base_code.sum + >>> with config.set_context(periods=period_ids): + ... credit_note_base_code = TaxCode(credit_note_base_code.id) + ... credit_note_base_code.sum Decimal('0.00') - >>> credit_note_tax_code.reload() - >>> credit_note_tax_code.sum + >>> with config.set_context(periods=period_ids): + ... credit_note_tax_code = TaxCode(credit_note_tax_code.id) + ... credit_note_tax_code.sum Decimal('0.00') Credit invoice with refund:: @@ -206,17 +212,21 @@ Credit invoice with refund:: Decimal('20.00') >>> account_tax.credit Decimal('20.00') - >>> invoice_base_code.reload() - >>> invoice_base_code.sum + >>> with config.set_context(periods=period_ids): + ... invoice_base_code = TaxCode(invoice_base_code.id) + ... invoice_base_code.sum Decimal('200.00') - >>> invoice_tax_code.reload() - >>> invoice_tax_code.sum + >>> with config.set_context(periods=period_ids): + ... invoice_tax_code = TaxCode(invoice_tax_code.id) + ... invoice_tax_code.sum Decimal('20.00') - >>> credit_note_base_code.reload() - >>> credit_note_base_code.sum + >>> with config.set_context(periods=period_ids): + ... credit_note_base_code = TaxCode(credit_note_base_code.id) + ... credit_note_base_code.sum Decimal('200.00') - >>> credit_note_tax_code.reload() - >>> credit_note_tax_code.sum + >>> with config.set_context(periods=period_ids): + ... credit_note_tax_code = TaxCode(credit_note_tax_code.id) + ... credit_note_tax_code.sum Decimal('20.00') Pay invoice:: diff --git a/tests/scenario_invoice_supplier.rst b/tests/scenario_invoice_supplier.rst index 08b3a56d0c..e7e9c9b64f 100644 --- a/tests/scenario_invoice_supplier.rst +++ b/tests/scenario_invoice_supplier.rst @@ -31,6 +31,7 @@ Create fiscal year:: >>> fiscalyear = set_fiscalyear_invoice_sequences( ... create_fiscalyear(company)) >>> fiscalyear.click('create_period') + >>> period_ids = [p.id for p in fiscalyear.periods] Create chart of accounts:: @@ -43,6 +44,7 @@ Create chart of accounts:: Create tax:: + >>> TaxCode = Model.get('account.tax.code') >>> tax = set_tax_code(create_tax(Decimal('.10'))) >>> tax.save() >>> invoice_base_code = tax.invoice_base_code @@ -145,17 +147,21 @@ Create invoice:: Decimal('10.00') >>> account_tax.credit Decimal('0.00') - >>> invoice_base_code.reload() - >>> invoice_base_code.sum + >>> with config.set_context(periods=period_ids): + ... invoice_base_code = TaxCode(invoice_base_code.id) + ... invoice_base_code.sum Decimal('100.00') - >>> invoice_tax_code.reload() - >>> invoice_tax_code.sum + >>> with config.set_context(periods=period_ids): + ... invoice_tax_code = TaxCode(invoice_tax_code.id) + ... invoice_tax_code.sum Decimal('10.00') - >>> credit_note_base_code.reload() - >>> credit_note_base_code.sum + >>> with config.set_context(periods=period_ids): + ... credit_note_base_code = TaxCode(credit_note_base_code.id) + ... credit_note_base_code.sum Decimal('0.00') - >>> credit_note_tax_code.reload() - >>> credit_note_tax_code.sum + >>> with config.set_context(periods=period_ids): + ... credit_note_tax_code = TaxCode(credit_note_tax_code.id) + ... credit_note_tax_code.sum Decimal('0.00') Credit invoice:: diff --git a/view/move_line_form.xml b/view/move_line_form.xml new file mode 100644 index 0000000000..10abad66c9 --- /dev/null +++ b/view/move_line_form.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/view/move_line_list.xml b/view/move_line_list.xml new file mode 100644 index 0000000000..1dd7127d5e --- /dev/null +++ b/view/move_line_list.xml @@ -0,0 +1,8 @@ + + + + + + +