Support invoice payment with currency exchange

Closes #11500
This commit is contained in:
Cédric Krier
2023-02-16 18:59:06 +01:00
parent 5092923d0d
commit 51b7fe72f8
8 changed files with 237 additions and 148 deletions
+1
View File
@@ -1,3 +1,4 @@
* Support payment with currency exchange
* Remove support for Python 3.7
* Add support for Python 3.11
* Warn when cancelling a move related to an invoice
+90 -102
View File
@@ -237,6 +237,10 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin):
'invoice', 'line', string='Payment Lines',
domain=[
('account', '=', Eval('account', -1)),
['OR',
('currency', '=', Eval('currency', -1)),
('second_currency', '=', Eval('currency', -1)),
],
['OR',
('party', 'in', [None, Eval('party', -1)]),
('party', 'in', Eval('alternative_payees', [])),
@@ -1350,15 +1354,22 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin):
invoice.check_payment_lines()
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)
def balance(line):
if self.currency == line.second_currency:
return line.amount_second_currency
elif self.currency == self.company.currency:
return line.debit - line.credit
else:
return 0
amount = sum(map(balance, self.lines_to_pay))
payment_amount = sum(map(balance, self.payment_lines))
if abs(amount) < abs(payment_amount):
raise InvoiceValidationError(
gettext('account_invoice'
'.msg_invoice_payment_lines_greater_amount',
invoice=self.rec_name))
def get_reconcile_lines_for_amount(self, amount, party=None):
def get_reconcile_lines_for_amount(self, amount, currency, party=None):
'''
Return list of lines and the remainder to make reconciliation.
'''
@@ -1367,6 +1378,16 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin):
if party is None:
party = self.party
assert currency in [self.currency, self.company.currency]
def balance(line):
if currency == line.second_currency:
return line.amount_second_currency
elif currency == self.company.currency:
return line.debit - line.credit
else:
return 0
lines = [
l for l in self.payment_lines + self.lines_to_pay
if not l.reconciliation
@@ -1375,18 +1396,18 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin):
best = Result([], self.total_amount)
for n in range(len(lines), 0, -1):
for comb_lines in combinations(lines, n):
remainder = sum((l.debit - l.credit) for l in comb_lines)
remainder = sum(map(balance, comb_lines))
remainder -= amount
result = Result(list(comb_lines), remainder)
if self.currency.is_zero(remainder):
if currency.is_zero(remainder):
return result
if abs(remainder) < abs(best.remainder):
best = result
return best
def pay_invoice(self, amount, payment_method, date, description=None,
amount_second_currency=None, second_currency=None, overpayment=0,
overpayment_second_currency=0, party=None):
def pay_invoice(
self, amount, payment_method, date, description=None,
overpayment=0, party=None):
'''
Adds a payment of amount to an invoice using the journal, date and
description.
@@ -1395,6 +1416,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin):
Returns the payment lines.
'''
pool = Pool()
Currency = pool.get('currency.currency')
Move = pool.get('account.move')
Line = pool.get('account.move.line')
Period = pool.get('account.period')
@@ -1407,9 +1429,20 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin):
lines = [pay_line, counterpart_line]
pay_amount = amount - overpayment
if amount_second_currency is not None:
pay_amount_second_currency = (
amount_second_currency - overpayment_second_currency)
if self.currency != self.company.currency:
amount_second_currency = pay_amount
second_currency = self.currency
overpayment_second_currency = overpayment
with Transaction().set_context(date=date):
amount = Currency.compute(
self.currency, amount, self.company.currency)
overpayment = Currency.compute(
self.currency, overpayment, self.company.currency)
pay_amount = amount - overpayment
else:
amount_second_currency = None
second_currency = None
overpayment_second_currency = None
if pay_amount >= 0:
if self.type == 'out':
pay_line.debit, pay_line.credit = 0, pay_amount
@@ -1422,7 +1455,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin):
pay_line.debit, pay_line.credit = 0, -pay_amount
if amount_second_currency is not None:
pay_line.amount_second_currency = (
pay_amount_second_currency.copy_sign(
amount_second_currency.copy_sign(
pay_line.debit - pay_line.credit))
pay_line.second_currency = second_currency
@@ -1433,7 +1466,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin):
abs(overpayment) if pay_line.debit else 0)
overpayment_line.credit = (
abs(overpayment) if pay_line.credit else 0)
if amount_second_currency is not None:
if overpayment_second_currency is not None:
overpayment_line.amount_second_currency = (
overpayment_second_currency.copy_sign(
overpayment_line.debit - overpayment_line.credit))
@@ -3099,7 +3132,7 @@ class PayInvoiceStart(ModelView):
depends=['company'])
amount = Monetary(
"Amount", currency='currency', digits='currency', required=True)
currency = fields.Many2One('currency.currency', 'Currency', required=True)
currency = fields.Many2One('currency.currency', 'Currency', readonly=True)
description = fields.Char('Description', size=None)
company = fields.Many2One('company.company', "Company", readonly=True)
invoice_account = fields.Many2One(
@@ -3140,19 +3173,14 @@ class PayInvoiceAsk(ModelView):
amount = Monetary(
"Payment Amount",
currency='currency', digits='currency', readonly=True)
currency = fields.Many2One('currency.currency', 'Payment Currency',
readonly=True)
currency = fields.Many2One('currency.currency', "Currency", readonly=True)
amount_writeoff = Monetary(
"Write-Off Amount",
currency='currency_writeoff', digits='currency_writeoff',
currency='currency', digits='currency',
readonly=True,
states={
'invisible': Eval('type') != 'writeoff',
})
currency_writeoff = fields.Many2One('currency.currency',
'Write-Off Currency', readonly=True, states={
'invisible': Eval('type') != 'writeoff',
})
lines_to_pay = fields.Many2Many('account.move.line', None, None,
'Lines to Pay', readonly=True)
lines = fields.Many2Many('account.move.line', None, None, 'Lines',
@@ -3170,35 +3198,34 @@ class PayInvoiceAsk(ModelView):
})
company = fields.Many2One('company.company', 'Company', readonly=True)
invoice = fields.Many2One('account.invoice', 'Invoice', readonly=True)
date = fields.Date('Date', readonly=True)
@staticmethod
def default_type():
return 'partial'
@fields.depends('lines', 'amount', 'currency', 'currency_writeoff',
'invoice', 'payment_lines', 'date')
@fields.depends(
'lines', 'amount', 'currency', 'invoice', 'payment_lines', 'company')
def on_change_lines(self):
Currency = Pool().get('currency.currency')
if self.currency and self.currency_writeoff:
with Transaction().set_context(date=self.date):
amount = Currency.compute(self.currency, self.amount,
self.currency_writeoff)
else:
amount = self.amount
self.amount_writeoff = Decimal('0.0')
if not self.invoice:
return
def balance(line):
if self.currency == line.second_currency:
return line.amount_second_currency
elif self.currency == self.company.currency:
return line.debit - line.credit
else:
return 0
for line in self.lines:
self.amount_writeoff += line.debit - line.credit
self.amount_writeoff += balance(line)
for line in self.payment_lines:
self.amount_writeoff += line.debit - line.credit
self.amount_writeoff += balance(line)
if self.invoice.type == 'in':
self.amount_writeoff = - self.amount_writeoff - amount
self.amount_writeoff = - self.amount_writeoff - self.amount
else:
self.amount_writeoff = self.amount_writeoff - amount
self.amount_writeoff = self.amount_writeoff - self.amount
class PayInvoice(Wizard):
@@ -3222,11 +3249,11 @@ class PayInvoice(Wizard):
super(PayInvoice, cls).__setup__()
cls.__rpc__['create'].fresh_session = True
def get_reconcile_lines_for_amount(self, invoice, amount):
def get_reconcile_lines_for_amount(self, invoice, amount, currency):
if invoice.type == 'in':
amount *= -1
return invoice.get_reconcile_lines_for_amount(
amount, party=self.start.payee)
amount, currency, party=self.start.payee)
def default_start(self, fields):
default = {}
@@ -3248,46 +3275,32 @@ class PayInvoice(Wizard):
return default
def transition_choice(self):
pool = Pool()
Currency = pool.get('currency.currency')
invoice = self.record
with Transaction().set_context(date=self.start.date):
amount = Currency.compute(self.start.currency,
self.start.amount, invoice.company.currency)
amount_invoice = Currency.compute(
self.start.currency, self.start.amount, invoice.currency)
_, remainder = self.get_reconcile_lines_for_amount(invoice, amount)
if (remainder == Decimal('0.0')
and amount_invoice <= invoice.amount_to_pay):
amount = self.start.amount
currency = self.start.currency
_, remainder = self.get_reconcile_lines_for_amount(
invoice, amount, currency)
if remainder == Decimal('0.0') and amount <= invoice.amount_to_pay:
return 'pay'
return 'ask'
def default_ask(self, fields):
pool = Pool()
Currency = pool.get('currency.currency')
default = {}
invoice = self.record
amount = self.start.amount
currency = self.start.currency
default['lines_to_pay'] = [x.id for x in invoice.lines_to_pay
if not x.reconciliation]
default['amount'] = self.start.amount
default['date'] = self.start.date
default['currency'] = self.start.currency.id
default['amount'] = amount
default['currency'] = currency.id
default['company'] = invoice.company.id
with Transaction().set_context(date=self.start.date):
amount = Currency.compute(self.start.currency,
self.start.amount, invoice.company.currency)
amount_invoice = Currency.compute(
self.start.currency, self.start.amount, invoice.currency)
if invoice.company.currency.is_zero(amount):
if currency.is_zero(amount):
lines = invoice.lines_to_pay
else:
lines, _ = self.get_reconcile_lines_for_amount(invoice, amount)
lines, _ = self.get_reconcile_lines_for_amount(
invoice, amount, currency)
default['lines'] = [x.id for x in lines]
for line_id in default['lines'][:]:
@@ -3297,41 +3310,27 @@ class PayInvoice(Wizard):
default['payment_lines'] = [x.id for x in invoice.payment_lines
if not x.reconciliation]
default['currency_writeoff'] = invoice.company.currency.id
default['invoice'] = invoice.id
if (amount_invoice >= invoice.amount_to_pay
or invoice.company.currency.is_zero(amount)):
if amount >= invoice.amount_to_pay or currency.is_zero(amount):
default['type'] = 'writeoff'
return default
def transition_pay(self):
pool = Pool()
Currency = pool.get('currency.currency')
MoveLine = pool.get('account.move.line')
Lang = pool.get('ir.lang')
invoice = self.record
amount = self.start.amount
currency = self.start.currency
with Transaction().set_context(date=self.start.date):
amount = Currency.compute(self.start.currency,
self.start.amount, invoice.company.currency)
amount_invoice = Currency.compute(
self.start.currency, self.start.amount, invoice.currency)
reconcile_lines, remainder = \
self.get_reconcile_lines_for_amount(invoice, amount)
amount_second_currency = None
second_currency = None
if self.start.currency != invoice.company.currency:
amount_second_currency = self.start.amount
second_currency = self.start.currency
reconcile_lines, remainder = (
self.get_reconcile_lines_for_amount(invoice, amount, currency))
overpayment = 0
overpayment_second_currency = 0
if (0 <= invoice.amount_to_pay < amount_invoice
or amount_invoice < invoice.amount_to_pay <= 0):
if (0 <= invoice.amount_to_pay < amount
or amount < invoice.amount_to_pay <= 0):
if self.ask.type == 'partial':
lang = Lang.get()
raise PayInvoiceError(
@@ -3345,24 +3344,13 @@ class PayInvoice(Wizard):
raise PayInvoiceError(
gettext('account_invoice.msg_invoice_overpay_paid',
invoice=invoice.rec_name))
with Transaction().set_context(date=self.start.date):
overpayment = Currency.compute(
invoice.currency,
amount_invoice - invoice.amount_to_pay,
invoice.company.currency)
if second_currency:
overpayment_second_currency = Currency.compute(
invoice.currency,
amount_invoice - invoice.amount_to_pay,
second_currency)
overpayment = amount - invoice.amount_to_pay
lines = []
if not invoice.company.currency.is_zero(amount):
lines = invoice.pay_invoice(amount,
self.start.payment_method, self.start.date,
self.start.description, amount_second_currency,
second_currency, overpayment, overpayment_second_currency,
party=self.start.payee)
if not currency.is_zero(amount):
lines = invoice.pay_invoice(
amount, self.start.payment_method, self.start.date,
self.start.description, overpayment, party=self.start.payee)
if remainder:
if self.ask.type != 'partial':
@@ -154,29 +154,6 @@ Create invoice with alternate currency::
>>> invoice.total_amount
Decimal('460.00')
Pay the invoice with rate change::
>>> pay = invoice.click('pay')
>>> pay.form.amount
Decimal('460.00')
>>> pay.form.payment_method = payment_method
>>> pay.form.date = tomorrow
>>> pay.execute('choice')
>>> pay.form.type
'writeoff'
>>> pay.form.writeoff = writeoff
>>> pay.form.amount
Decimal('460.00')
>>> pay.form.currency == eur
True
>>> pay.form.amount_writeoff
Decimal('46.00')
>>> pay.form.currency_writeoff == currency
True
>>> pay.execute('pay')
>>> invoice.state
'paid'
Create negative tax::
>>> negative_tax = create_tax(Decimal('-.10'))
@@ -0,0 +1,119 @@
===============================================
Invoice Scenario Alternate Currency Rate Change
===============================================
Imports::
>>> import datetime as dt
>>> from decimal import Decimal
>>> from proteus import Model, Wizard
>>> from trytond.tests.tools import activate_modules
>>> from trytond.modules.currency.tests.tools import get_currency
>>> from trytond.modules.company.tests.tools import create_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)
>>> today = dt.date.today()
>>> tomorrow = today + dt.timedelta(days=1)
Activate modules::
>>> config = activate_modules('account_invoice')
>>> Configuration = Model.get('account.configuration')
>>> Invoice = Model.get('account.invoice')
>>> Journal = Model.get('account.journal')
>>> Party = Model.get('party.party')
>>> PaymentMethod = Model.get('account.invoice.payment.method')
Create company::
>>> currency = get_currency('USD')
>>> eur = get_currency('EUR')
>>> _ = create_company(currency=currency)
Set alternate currency rates::
>>> rate = eur.rates.new()
>>> rate.date = today
>>> rate.rate = Decimal('1.20')
>>> rate = eur.rates.new()
>>> rate.date = tomorrow
>>> rate.rate = Decimal('1.10')
>>> eur.save()
Create fiscal year::
>>> fiscalyear = set_fiscalyear_invoice_sequences(
... create_fiscalyear())
>>> fiscalyear.click('create_period')
Create chart of accounts::
>>> _ = create_chart()
>>> accounts = get_accounts()
Configure currency exchange::
>>> currency_exchange_account, = (
... accounts['revenue'].duplicate(
... default={'name': "Currency Exchange"}))
>>> configuration = Configuration(1)
>>> configuration.currency_exchange_credit_account = (
... currency_exchange_account)
>>> configuration.save()
Create payment method::
>>> journal_cash, = Journal.find([('type', '=', '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 party::
>>> party = Party(name='Party')
>>> party.save()
Create invoice with alternate currency::
>>> invoice = Invoice()
>>> invoice.party = party
>>> invoice.currency = eur
>>> line = invoice.lines.new()
>>> line.description = "Line"
>>> line.account = accounts['revenue']
>>> line.quantity = 5
>>> line.unit_price = Decimal('80')
>>> invoice.click('post')
>>> invoice.state
'posted'
>>> invoice.total_amount
Decimal('400.00')
Pay the invoice::
>>> pay = Wizard('account.invoice.pay', [invoice])
>>> pay.form.amount
Decimal('400.00')
>>> pay.form.currency == eur
True
>>> pay.form.payment_method = payment_method
>>> pay.form.date = tomorrow
>>> pay.execute('choice')
>>> pay.state
'end'
>>> invoice.state
'paid'
>>> accounts['receivable'].reload()
>>> abs(accounts['receivable'].balance)
Decimal('0.00')
>>> currency_exchange_account.reload()
>>> currency_exchange_account.balance
Decimal('-30.31')
@@ -4,21 +4,20 @@ this repository contains the full copyright notices and license terms. -->
<form>
<label name="type"/>
<field name="type" colspan="3"/>
<label name="amount"/>
<field name="amount" symbol=""/>
<label name="currency"/>
<field name="currency"/>
<field name="amount"/>
<newline/>
<label name="amount_writeoff"/>
<field name="amount_writeoff"/>
<label name="currency_writeoff"/>
<field name="currency_writeoff" symbol=""/>
<label name="writeoff"/>
<field name="writeoff" widget="selection"/>
<field name="lines" colspan="4"
view_ids="account_invoice.move_line_view_list_to_pay"/>
<field name="payment_lines" colspan="4"
view_ids="account_invoice.move_line_view_list_payment"/>
<field name="company" colspan="4" invisible="1"/>
<field name="invoice" colspan="4" invisible="1"/>
<field name="date" colspan="4" invisible="1"/>
</form>
@@ -4,12 +4,9 @@ this repository contains the full copyright notices and license terms. -->
<form cursor="amount">
<label name="payee"/>
<field name="payee"/>
<newline/>
<label name="amount"/>
<field name="amount" symbol=""/>
<label name="currency"/>
<field name="currency"/>
<field name="amount"/>
<label name="description"/>
<field name="description" colspan="3"/>
<label name="payment_method"/>
+11 -11
View File
@@ -284,20 +284,12 @@ class Statement(Workflow, ModelSQL, ModelView):
origin.lines = lines
self.origins = origins
@fields.depends('lines', 'journal', 'company')
@fields.depends('lines')
def on_change_lines(self):
pool = Pool()
Line = pool.get('account.statement.line')
if not self.journal or not self.lines or not self.company:
return
if self.journal.currency != self.company.currency:
return
invoices = set()
for line in self.lines:
if (line.invoice
and line.invoice.currency == self.company.currency):
invoices.add(line.invoice)
invoices = {l.invoice for l in self.lines if l.invoice}
invoice_id2amount_to_pay = {}
for invoice in invoices:
if invoice.type == 'out':
@@ -758,6 +750,7 @@ class Line(origin_mixin(_states), sequence_ordered(), ModelSQL, ModelView):
domain={
'account.invoice': [
('company', '=', Eval('company', -1)),
('currency', '=', Eval('currency', -1)),
If(Bool(Eval('party')),
['OR',
('party', '=', Eval('party', -1)),
@@ -991,8 +984,15 @@ class Line(origin_mixin(_states), sequence_ordered(), ModelSQL, ModelView):
MoveLine.reconcile(*to_reconcile)
to_reconcile.clear()
if move_line.second_currency:
amount = move_line.amount_second_currency
currency = move_line.second_currency
else:
amount = move_line.credit - move_line.debit
currency = line.company.currency
reconcile_lines = line.invoice.get_reconcile_lines_for_amount(
move_line.credit - move_line.debit, party=line.party)
amount, currency, party=line.party)
assert move_line.account == line.invoice.account
+9 -1
View File
@@ -139,6 +139,14 @@ class Invoice(metaclass=PoolMeta):
Line = pool.get('account.move.line')
if not hasattr(Payment, 'clearing_move'):
return
def balance(line):
if self.currency == line.second_currency:
return line.amount_second_currency
elif self.currency == self.company.currency:
return line.debit - line.credit
else:
return 0
to_reconcile = []
for line in self.lines_to_pay:
if line.reconciliation:
@@ -150,7 +158,7 @@ class Invoice(metaclass=PoolMeta):
if (pline.account == line.account
and not pline.reconciliation):
lines.append(pline)
if not sum(l.debit - l.credit for l in lines):
if not sum(map(balance, lines)):
to_reconcile.append(lines)
for lines in to_reconcile:
Line.reconcile(lines)