diff --git a/modules/account_invoice/CHANGELOG b/modules/account_invoice/CHANGELOG
index 6bd07bd43a..04f8040402 100644
--- a/modules/account_invoice/CHANGELOG
+++ b/modules/account_invoice/CHANGELOG
@@ -1,3 +1,5 @@
+* Warn when cancelling a move related to an invoice
+
Version 6.6.0 - 2022-10-31
--------------------------
* Bug fixes (see mercurial logs for details)
diff --git a/modules/account_invoice/__init__.py b/modules/account_invoice/__init__.py
index 275fda6026..9920570489 100644
--- a/modules/account_invoice/__init__.py
+++ b/modules/account_invoice/__init__.py
@@ -51,6 +51,7 @@ def register():
account.RenewFiscalYear,
account.RescheduleLines,
account.DelegateLines,
+ account.CancelMoves,
module='account_invoice', type_='wizard')
Pool.register(
invoice.InvoiceReport,
diff --git a/modules/account_invoice/account.py b/modules/account_invoice/account.py
index 35a55736b0..6b70818e53 100644
--- a/modules/account_invoice/account.py
+++ b/modules/account_invoice/account.py
@@ -1,6 +1,7 @@
# 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 OrderedDict
+from itertools import islice
from sql import Literal
from sql.conditionals import Coalesce
@@ -15,6 +16,8 @@ from trytond.pyson import Bool, Eval, Id, If
from trytond.tools import grouped_slice
from trytond.transaction import Transaction
+from .exceptions import CancelInvoiceMoveWarning
+
class Configuration(metaclass=PoolMeta):
__name__ = 'account.configuration'
@@ -461,3 +464,34 @@ class DelegateLines(metaclass=PoolMeta):
'additional_moves': [('add', [move.id])],
})
return move
+
+
+class CancelMoves(metaclass=PoolMeta):
+ __name__ = 'account.move.cancel'
+
+ def transition_cancel(self):
+ pool = Pool()
+ Invoice = pool.get('account.invoice')
+ Warning = pool.get('res.user.warning')
+
+ moves_w_invoices = {
+ m: m.origin for m in self.records
+ if (isinstance(m.origin, Invoice)
+ and m.origin.state not in {'paid', 'cancelled'})}
+ if moves_w_invoices:
+ move_names = ', '.join(m.rec_name
+ for m in islice(moves_w_invoices, None, 5))
+ invoice_names = ', '.join(i.rec_name
+ for i in islice(moves_w_invoices.values(), None, 5))
+ if len(moves_w_invoices) > 5:
+ move_names += '...'
+ invoice_names += '...'
+ key = Warning.format('cancel_invoice_move', moves_w_invoices)
+ if Warning.check(key):
+ raise CancelInvoiceMoveWarning(key,
+ gettext('account_invoice.msg_cancel_invoice_move',
+ moves=move_names, invoices=invoice_names),
+ gettext(
+ 'account_invoice.msg_cancel_invoice_move_description'))
+
+ return super().transition_cancel()
diff --git a/modules/account_invoice/exceptions.py b/modules/account_invoice/exceptions.py
index 8e540c69fd..3aacd58b94 100644
--- a/modules/account_invoice/exceptions.py
+++ b/modules/account_invoice/exceptions.py
@@ -43,3 +43,7 @@ class InvoiceFutureWarning(UserWarning):
class InvoiceSimilarWarning(UserWarning):
pass
+
+
+class CancelInvoiceMoveWarning(UserWarning):
+ pass
diff --git a/modules/account_invoice/message.xml b/modules/account_invoice/message.xml
index 3a74e9ea3e..ec98e0130e 100644
--- a/modules/account_invoice/message.xml
+++ b/modules/account_invoice/message.xml
@@ -84,6 +84,12 @@ this repository contains the full copyright notices and license terms. -->
The invoice "%(invoice)s" is similar to invoice "%(similar)s".
+
+ The moves "%(moves)s" have the posted invoices "%(invoices)s" as origin.
+
+
+ Cancelling them will reconcile the move lines thus paying the invoices. You might want to cancel the invoices first.
+
diff --git a/modules/account_invoice/tests/scenario_cancelling_invoice_move.rst b/modules/account_invoice/tests/scenario_cancelling_invoice_move.rst
new file mode 100644
index 0000000000..67b3146dab
--- /dev/null
+++ b/modules/account_invoice/tests/scenario_cancelling_invoice_move.rst
@@ -0,0 +1,106 @@
+================================
+Cancelling Invoice Move 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.exceptions import (
+ ... CancelInvoiceMoveWarning)
+ >>> from trytond.modules.account_invoice.tests.tools import (
+ ... set_fiscalyear_invoice_sequences)
+
+Activate modules::
+
+ >>> config = activate_modules('account_invoice')
+ >>> Party = Model.get('party.party')
+ >>> ProductCategory = Model.get('product.category')
+ >>> ProductUom = Model.get('product.uom')
+ >>> ProductTemplate = Model.get('product.template')
+ >>> Invoice = Model.get('account.invoice')
+ >>> Warning = Model.get('res.user.warning')
+
+Create company::
+
+ >>> _ = create_company()
+ >>> company = get_company()
+
+Create fiscal year::
+
+ >>> fiscalyear = set_fiscalyear_invoice_sequences(
+ ... create_fiscalyear(company))
+ >>> fiscalyear.click('create_period')
+
+Create chart of accounts::
+
+ >>> _ = create_chart(company)
+ >>> accounts = get_accounts(company)
+
+Create party::
+
+ >>> party = Party(name="Party")
+ >>> party.save()
+
+Create account category::
+
+ >>> account_category = ProductCategory(name="Account Category")
+ >>> account_category.accounting = True
+ >>> account_category.account_expense = accounts['expense']
+ >>> account_category.account_revenue = accounts['revenue']
+ >>> account_category.save()
+
+Create product::
+
+ >>> unit, = ProductUom.find([('name', '=', 'Unit')])
+ >>> template = ProductTemplate()
+ >>> template.name = 'product'
+ >>> template.default_uom = unit
+ >>> template.type = 'service'
+ >>> template.list_price = Decimal('40')
+ >>> template.account_category = account_category
+ >>> template.save()
+ >>> product, = template.products
+
+Create invoice::
+
+ >>> invoice = Invoice()
+ >>> invoice.party = party
+ >>> line = invoice.lines.new()
+ >>> line.product = product
+ >>> line.quantity = 5
+ >>> line.unit_price = Decimal('40')
+
+Post invoice and cancel the created move::
+
+ >>> invoice.click('post')
+ >>> invoice.state
+ 'posted'
+ >>> cancel_move = Wizard('account.move.cancel', [invoice.move])
+ >>> cancel_move.form.description = 'Cancel'
+ >>> cancel_move.execute('cancel') # doctest: +IGNORE_EXCEPTION_DETAIL
+ Traceback (most recent call last):
+ ...
+ CancelInvoiceMoveWarning: ...
+
+Bypass the warning and cancel the move::
+
+ >>> try:
+ ... cancel_move.execute('cancel')
+ ... except CancelInvoiceMoveWarning as e:
+ ... Warning(user=config.user, name=e.name).save()
+ >>> cancel_move.execute('cancel')
+ >>> cancel_move.state
+ 'end'
+
+ >>> invoice.reload()
+ >>> [bool(l.reconciliation) for l in invoice.move.lines
+ ... if l.account == accounts['receivable']]
+ [True]
+ >>> invoice.state
+ 'paid'