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. -->
-
+
@@ -75,7 +75,10 @@ this repository contains the full copyright notices and license terms. -->
-
+
+
+
+
diff --git a/view/move_line_list_payment.xml b/view/move_line_list_payment.xml
index fbf3be3c2e..cd67db6755 100644
--- a/view/move_line_list_payment.xml
+++ b/view/move_line_list_payment.xml
@@ -2,6 +2,7 @@
+
diff --git a/view/move_line_list_to_pay.xml b/view/move_line_list_to_pay.xml
index a78fe76e77..a21d0e048d 100644
--- a/view/move_line_list_to_pay.xml
+++ b/view/move_line_list_to_pay.xml
@@ -2,6 +2,7 @@
+
diff --git a/view/pay_start_form.xml b/view/pay_start_form.xml
index 20020da154..1357492ab2 100644
--- a/view/pay_start_form.xml
+++ b/view/pay_start_form.xml
@@ -1,7 +1,11 @@
-