diff --git a/CHANGELOG b/CHANGELOG index 221affe94f..d513e32bba 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Support alternative payees on invoice + Version 6.4.0 - 2022-05-02 * Bug fixes (see mercurial logs for details) * Use invoice date to enforce sequence on customer invoice diff --git a/__init__.py b/__init__.py index ba8934f9ce..275fda6026 100644 --- a/__init__.py +++ b/__init__.py @@ -15,6 +15,7 @@ def register(): payment_term.TestPaymentTermViewResult, invoice.Invoice, invoice.InvoiceAdditionalMove, + invoice.AlternativePayee, invoice.InvoicePaymentLine, invoice.InvoiceLine, invoice.InvoiceLineTax, @@ -44,10 +45,12 @@ def register(): invoice.PayInvoice, invoice.CreditInvoice, invoice.RescheduleLinesToPay, + invoice.DelegateLinesToPay, party.Replace, party.Erase, account.RenewFiscalYear, account.RescheduleLines, + account.DelegateLines, module='account_invoice', type_='wizard') Pool.register( invoice.InvoiceReport, diff --git a/account.py b/account.py index 23dbeb8bde..35a55736b0 100644 --- a/account.py +++ b/account.py @@ -440,3 +440,24 @@ class RescheduleLines(metaclass=PoolMeta): 'additional_moves': [('add', [move.id])], }) return move, balance_line + + +class DelegateLines(metaclass=PoolMeta): + __name__ = 'account.move.line.delegate' + + @classmethod + def delegate_lines(cls, lines, party, journal, date=None): + pool = Pool() + Invoice = pool.get('account.invoice') + move = super().delegate_lines(lines, party, journal, date=None) + + move_ids = list({l.move.id for l in lines}) + invoices = Invoice.search(['OR', + ('move', 'in', move_ids), + ('additional_moves', 'in', move_ids), + ]) + Invoice.write(invoices, { + 'alternative_payees': [('add', [party.id])], + 'additional_moves': [('add', [move.id])], + }) + return move diff --git a/invoice.py b/invoice.py index cf48b2c681..3382757f01 100644 --- a/invoice.py +++ b/invoice.py @@ -166,6 +166,14 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): }) payment_term = fields.Many2One('account.invoice.payment_term', 'Payment Term', states=_states) + alternative_payees = fields.Many2Many( + 'account.invoice.alternative_payee', 'invoice', 'party', + "Alternative Payee", states=_states, + size=If(~Eval('move'), 1, None), + context={ + 'company': Eval('company', -1), + }, + depends=['company']) lines = fields.One2Many('account.invoice.line', 'invoice', 'Lines', domain=[ ('company', '=', Eval('company', -1)), @@ -210,7 +218,10 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): 'invoice', 'line', string='Payment Lines', domain=[ ('account', '=', Eval('account', -1)), - ('party', 'in', [None, Eval('party', -1)]), + ['OR', + ('party', 'in', [None, Eval('party', -1)]), + ('party', 'in', Eval('alternative_payees', [])), + ], ['OR', ('invoice_payment', '=', None), ('invoice_payment', '=', Eval('id', -1)), @@ -253,9 +264,10 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): super(Invoice, cls).__setup__() cls.create_date.select = True cls._check_modify_exclude = { - 'state', 'payment_lines', 'move', 'cancel_move', - 'additional_moves', 'invoice_report_cache', - 'invoice_report_format', 'lines'} + 'state', 'alternative_payees', 'payment_lines', + 'move', 'cancel_move', 'additional_moves', + 'invoice_report_cache', 'invoice_report_format', + 'lines'} cls._order = [ ('number', 'DESC NULLS FIRST'), ('id', 'DESC'), @@ -324,6 +336,11 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): ~Eval('lines_to_pay') | Eval('reconciled', False)), 'depends': ['lines_to_pay', 'reconciled'], }, + 'delegate_lines_to_pay': { + 'invisible': ( + ~Eval('lines_to_pay') | Eval('reconciled', False)), + 'depends': ['lines_to_pay', 'reconciled'], + }, 'process': { 'invisible': ~Eval('state').in_( ['posted', 'paid']), @@ -977,7 +994,10 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): line.debit - line.credit)) line.account = self.account if self.account.party_required: - line.party = self.party + if self.alternative_payees: + line.party, = self.alternative_payees + else: + line.party = self.party line.maturity_date = date line.description = self.description return line @@ -1307,14 +1327,19 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): '.msg_invoice_payment_lines_greater_amount', invoice=self.rec_name)) - def get_reconcile_lines_for_amount(self, amount): + def get_reconcile_lines_for_amount(self, amount, party=None): ''' Return list of lines and the remainder to make reconciliation. ''' Result = namedtuple('Result', ['lines', 'remainder']) - lines = [l for l in self.payment_lines + self.lines_to_pay - if not l.reconciliation] + if party is None: + party = self.party + + lines = [ + l for l in self.payment_lines + self.lines_to_pay + if not l.reconciliation + and (not self.account.party_required or l.party == party)] best = Result([], self.total_amount) for n in range(len(lines), 0, -1): @@ -1330,7 +1355,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): def pay_invoice(self, amount, payment_method, date, description=None, amount_second_currency=None, second_currency=None, overpayment=0, - overpayment_second_currency=0): + overpayment_second_currency=0, party=None): ''' Adds a payment of amount to an invoice using the journal, date and description. @@ -1343,6 +1368,9 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): Line = pool.get('account.move.line') Period = pool.get('account.period') + if party is None: + party = self.party + pay_line = Line(account=self.account) counterpart_line = Line() lines = [pay_line, counterpart_line] @@ -1396,7 +1424,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): for line in lines: if line.account.party_required: - line.party = self.party + line.party = party period_id = Period.find(self.company.id, date=date) @@ -1516,6 +1544,9 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): moves.append(invoice.move) if invoice.additional_moves: moves.extend(invoice.additional_moves) + if len(invoice.alternative_payees) > 1: + invoice.alternative_payees = [] + cls.save(invoices) if moves: Move.delete(moves) @@ -1685,6 +1716,12 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin): def reschedule_lines_to_pay(cls, invoices): pass + @classmethod + @ModelView.button_action( + 'account_invoice.act_delegate_lines_to_pay_wizard') + def delegate_lines_to_pay(cls, invoices): + pass + @classmethod @ModelView.button def process(cls, invoices): @@ -1793,6 +1830,17 @@ class InvoiceAdditionalMove(ModelSQL): 'account.move', "Additional Move", ondelete='CASCADE') +class AlternativePayee(ModelSQL): + "Invoice Alternative Payee" + __name__ = 'account.invoice.alternative_payee' + + invoice = fields.Many2One( + 'account.invoice', "Invoice", + ondelete='CASCADE', required=True, select=True) + party = fields.Many2One( + 'party.party', "Payee", ondelete='RESTRICT', required=True) + + class InvoicePaymentLine(ModelSQL): 'Invoice - Payment Line' __name__ = 'account.invoice-account.move.line' @@ -1803,12 +1851,19 @@ class InvoicePaymentLine(ModelSQL): 'get_invoice') invoice_party = fields.Function( fields.Many2One('party.party', "Invoice Party"), 'get_invoice') + invoice_alternative_payees = fields.Function( + fields.Many2Many( + 'party.party', None, None, "Invoice Alternative Payees"), + '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')), + ['OR', + ('party', '=', Eval('invoice_party', -1)), + ('party', 'in', Eval('invoice_alternative_payees', [])), + ], ]) @classmethod @@ -1827,6 +1882,7 @@ class InvoicePaymentLine(ModelSQL): result[name] = {} invoice_account = 'invoice_account' in result invoice_party = 'invoice_party' in result + invoice_alternative_payees = 'invoice_alternative_payees' in result for record in records: if invoice_account: result['invoice_account'][record.id] = ( @@ -1837,6 +1893,9 @@ class InvoicePaymentLine(ModelSQL): else: party = None result['invoice_party'][record.id] = party + if invoice_alternative_payees: + result['invoice_alternative_payees'][record.id] = [ + p.id for p in record.invoice.alternative_payees] return result @@ -2976,6 +3035,22 @@ class InvoiceReport(Report): class PayInvoiceStart(ModelView): 'Pay Invoice' __name__ = 'account.invoice.pay.start' + + payee = fields.Many2One( + 'party.party', "Payee", required=True, + domain=[ + ('id', 'in', Eval('payees', [])) + ], + context={ + 'company': Eval('company', -1), + }, + depends=['company']) + payees = fields.Many2Many( + 'party.party', None, None, "Payees", readonly=True, + context={ + 'company': Eval('company', -1), + }, + depends=['company']) amount = Monetary( "Amount", currency='currency', digits='currency', required=True) currency = fields.Many2One('currency.currency', 'Currency', required=True) @@ -3104,11 +3179,21 @@ class PayInvoice(Wizard): def get_reconcile_lines_for_amount(self, invoice, amount): if invoice.type == 'in': amount *= -1 - return invoice.get_reconcile_lines_for_amount(amount) + return invoice.get_reconcile_lines_for_amount( + amount, party=self.start.payee) def default_start(self, fields): default = {} invoice = self.record + if not invoice.alternative_payees: + default['payee'] = invoice.party.id + else: + try: + default['payee'], = invoice.alternative_payees + except ValueError: + pass + default['payees'] = ( + [invoice.party.id] + [p.id for p in invoice.alternative_payees]) default['company'] = invoice.company.id default['currency'] = invoice.currency.id default['amount'] = (invoice.amount_to_pay_today @@ -3230,14 +3315,17 @@ class PayInvoice(Wizard): lines = invoice.pay_invoice(amount, self.start.payment_method, self.start.date, self.start.description, amount_second_currency, - second_currency, overpayment, overpayment_second_currency) + second_currency, overpayment, overpayment_second_currency, + party=self.start.payee) if remainder: if self.ask.type != 'partial': to_reconcile = {l for l in self.ask.lines} to_reconcile.update( l for l in invoice.payment_lines - if not l.reconciliation) + if not l.reconciliation + and (not invoice.account.party_required + or l.party == self.start.payee)) if self.ask.type == 'writeoff': to_reconcile.update(lines) if to_reconcile: @@ -3318,3 +3406,17 @@ class RescheduleLinesToPay(Wizard): if not l.reconciliation], 'model': 'account.move.line', } + + +class DelegateLinesToPay(Wizard): + "Delegate Lines to Pay" + __name__ = 'account.invoice.lines_to_pay.delegate' + start = StateAction('account.act_delegate_lines_wizard') + + def do_start(self, action): + return action, { + 'ids': [ + l.id for l in self.record.lines_to_pay + if not l.reconciliation], + 'model': 'account.move.line', + } diff --git a/invoice.xml b/invoice.xml index c0337a7d63..262085f5cf 100644 --- a/invoice.xml +++ b/invoice.xml @@ -224,6 +224,18 @@ this repository contains the full copyright notices and license terms. --> + + + delegate_lines_to_pay + Modify Payee + + + + + + + process Process @@ -431,5 +443,11 @@ this repository contains the full copyright notices and license terms. --> account.invoice.lines_to_pay.reschedule account.invoice + + + Delegate Lines to Pay + account.invoice.lines_to_pay.delegate + account.invoice + diff --git a/party.py b/party.py index 536ed2e593..888c92acd4 100644 --- a/party.py +++ b/party.py @@ -88,6 +88,7 @@ class Replace(metaclass=PoolMeta): return super().fields_to_replace() + [ ('account.invoice', 'party'), ('account.invoice.line', 'party'), + ('account.invoice.alternative_payee', 'party'), ] @@ -100,7 +101,10 @@ class Erase(metaclass=PoolMeta): super().check_erase_company(party, company) invoices = Invoice.search([ - ('party', '=', party.id), + ['OR', + ('party', '=', party.id), + ('alternative_payees', '=', party.id), + ], ('company', '=', company.id), ('state', 'not in', ['paid', 'cancelled']), ]) diff --git a/tests/scenario_invoice_alternative_payee.rst b/tests/scenario_invoice_alternative_payee.rst new file mode 100644 index 0000000000..b4c4a0789f --- /dev/null +++ b/tests/scenario_invoice_alternative_payee.rst @@ -0,0 +1,131 @@ +================================== +Invoice Alternative Payee Scenario +================================== + +Imports:: + + >>> from decimal import Decimal + + >>> from proteus import Model, Wizard + >>> from trytond.tests.tools import activate_modules + >>> from trytond.modules.company.tests.tools import ( + ... create_company, get_company) + >>> from trytond.modules.account.tests.tools import ( + ... create_fiscalyear, create_chart, get_accounts) + >>> from trytond.modules.account_invoice.tests.tools import ( + ... set_fiscalyear_invoice_sequences) + +Activate modules:: + + >>> config = activate_modules('account_invoice') + + >>> Invoice = Model.get('account.invoice') + >>> Journal = Model.get('account.journal') + >>> PaymentMethod = Model.get('account.invoice.payment.method') + +Create company:: + + >>> _ = create_company() + >>> company = get_company() + +Create fiscal year:: + + >>> fiscalyear = set_fiscalyear_invoice_sequences( + ... create_fiscalyear(company)) + >>> fiscalyear.click('create_period') + >>> period = fiscalyear.periods[0] + +Create chart of accounts:: + + >>> _ = create_chart(company) + >>> accounts = get_accounts(company) + + >>> journal_cash, = Journal.find([ + ... ('code', '=', 'CASH'), + ... ]) + + >>> payment_method = PaymentMethod() + >>> payment_method.name = "Cash" + >>> payment_method.journal = journal_cash + >>> payment_method.credit_account = accounts['cash'] + >>> payment_method.debit_account = accounts['cash'] + >>> payment_method.save() + +Create parties:: + + >>> Party = Model.get('party.party') + >>> party1 = Party(name="Party 1") + >>> party1.save() + >>> party2 = Party(name="Party 2") + >>> party2.save() + >>> party3 = Party(name="Party 3") + >>> party3.save() + +Post customer invoice:: + + >>> invoice = Invoice() + >>> invoice.party = party1 + >>> invoice.alternative_payees.append(Party(party2.id)) + >>> line = invoice.lines.new() + >>> line.account = accounts['revenue'] + >>> line.quantity = 1 + >>> line.unit_price = Decimal(10) + >>> invoice.click('post') + >>> invoice.state + 'posted' + >>> len(invoice.lines_to_pay) + 1 + >>> invoice.amount_to_pay + Decimal('10.00') + + >>> party1.reload() + >>> party1.receivable + Decimal('0.0') + >>> party2.reload() + >>> party2.receivable + Decimal('10.00') + >>> party3.reload() + >>> party3.receivable + Decimal('0.0') + +Set another payee:: + + >>> delegate = Wizard( + ... 'account.invoice.lines_to_pay.delegate', [invoice]) + >>> delegate_lines, = delegate.actions + >>> delegate_lines.form.party = party3 + >>> delegate_lines.execute('delegate') + + >>> invoice.reload() + >>> invoice.state + 'posted' + >>> len(invoice.lines_to_pay) + 3 + >>> invoice.amount_to_pay + Decimal('10.00') + + >>> party1.reload() + >>> party1.receivable + Decimal('0.0') + >>> party2.reload() + >>> party2.receivable + Decimal('0.0') + >>> party3.reload() + >>> party3.receivable + Decimal('10.00') + +Pay the invoice:: + + >>> pay = Wizard('account.invoice.pay', [invoice]) + >>> pay.form.payee = party3 + >>> pay.form.amount = Decimal('10.00') + >>> pay.form.payment_method = payment_method + >>> pay.execute('choice') + >>> pay.state + 'end' + >>> invoice.state + 'paid' + >>> len(invoice.payment_lines) + 1 + >>> len(invoice.reconciliation_lines) + 1 diff --git a/view/invoice_form.xml b/view/invoice_form.xml index 74d03112f7..d8cf6e4645 100644 --- a/view/invoice_form.xml +++ b/view/invoice_form.xml @@ -57,7 +57,7 @@ this repository contains the full copyright notices and license terms. -->