diff --git a/pms_autoinvoice/models/pms_property.py b/pms_autoinvoice/models/pms_property.py index 56bb7853..3b1b068c 100644 --- a/pms_autoinvoice/models/pms_property.py +++ b/pms_autoinvoice/models/pms_property.py @@ -194,6 +194,60 @@ def _try_populate_fiscal_data_from_id_numbers(self, partners): for id_number in mappable_id_numbers: id_number.set_partner_id_field() + def _autoinvoice_deduct_downpayments( + self, invoices, downpayments, switched_partners + ): + """Deduct each down payment of the folio from at most one of the invoices. + + A down payment goes to the largest invoice of its own customer, as long + as that invoice does not end up negative. Adding it to every invoice of + the customer, as this used to do, deducts it twice (or fails with + "Expected singleton") when the folio is split in two invoices for the + same customer. + + It is not deducted anywhere, and is left to the final invoice to + settle (pms_downpayment_lifecycle rectifies and transfers it when that + invoice is posted), when: + + - the lines of its customer were moved to a named host because the + simplified invoice went over the limit: the host's invoice bills + the stay the down payment paid for; + - it does not fit in any invoice of its customer without leaving it + negative, e.g. the stay was already invoiced by hand and only some + extras are left. + + Returns the down payments of the second kind, the ones a person has + to look at. + """ + not_fitting = self.env["folio.sale.line"] + for downpayment in downpayments: + partner = downpayment.default_invoice_to + if partner in switched_partners: + continue + candidates = invoices.filtered(lambda inv, p=partner: inv.partner_id == p) + if not candidates: + continue + for invoice in candidates.sorted("amount_total", reverse=True): + vals = downpayment._prepare_invoice_line( + sequence=max(invoice.invoice_line_ids.mapped("sequence")) + 1, + # pms maps the taxes through the invoice fiscal position + # and has no default for it. + invoice_fpos=invoice.fiscal_position_id, + ) + invoice.write({"invoice_line_ids": [(0, 0, vals)]}) + if invoice.currency_id.compare_amounts(invoice.amount_total, 0.0) >= 0: + break + # It would leave the invoice negative: take it back out. + deduction = invoice.invoice_line_ids.filtered( + lambda line, d=downpayment: d in line.folio_line_ids + ) + invoice.write( + {"invoice_line_ids": [(2, line.id) for line in deduction]} + ) + else: + not_fitting |= downpayment + return not_fitting + def autoinvoice_folio(self, folio, delay_post=False): try: with self.env.cr.savepoint(): @@ -239,6 +293,10 @@ def autoinvoice_folio(self, folio, delay_post=False): downpayments = folio.sale_line_ids.filtered( lambda r: r.is_downpayment and r.qty_invoiced > 0 ) + # Move every simplified invoice over the limit to a host with + # full fiscal data before deducting any down payment, so that + # the deduction knows which customers were moved away. + switched_partners = self.env["res.partner"] for invoice in invoices: if ( invoice.amount_total @@ -257,6 +315,7 @@ def autoinvoice_folio(self, folio, delay_post=False): lambda p: p._check_enought_invoice_data() ).mapped("id") if hosts_to_invoice: + switched_partners |= invoice.partner_id invoice.partner_id = hosts_to_invoice[0] invoice.journal_id = ( invoice.pms_property_id.journal_normal_invoice_id @@ -270,19 +329,23 @@ def autoinvoice_folio(self, folio, delay_post=False): ) folio.sudo().message_post(body=mens) raise ValidationError(mens) - for downpayment in downpayments.filtered( - lambda d, i=invoice: d.default_invoice_to == i.partner_id - ): - # If the downpayment invoice partner is the same that the - # folio partner, we include the downpayment in the - # normal invoice - invoice_down_payment_vals = downpayment._prepare_invoice_line( - sequence=max(invoice.invoice_line_ids.mapped("sequence")) - + 1, + not_fitting = self._autoinvoice_deduct_downpayments( + invoices, downpayments, switched_partners + ) + if not_fitting: + folio.sudo().message_post( + body=_( + "Down payments not deducted in the automatic invoice, " + "because they are larger than what it bills: %s. " + "They are left for review." ) - invoice.write( - {"invoice_line_ids": [(0, 0, invoice_down_payment_vals)]} + % ", ".join( + not_fitting.invoice_lines.move_id.filtered( + lambda m: m.move_type == "out_invoice" + ).mapped("name") ) + ) + for invoice in invoices: if delay_post: eta = datetime.now() + timedelta(minutes=15) self.with_delay(eta=eta).autovalidate_folio_invoice(invoice) diff --git a/pms_autoinvoice/tests/__init__.py b/pms_autoinvoice/tests/__init__.py index ecb3ae88..37588f22 100644 --- a/pms_autoinvoice/tests/__init__.py +++ b/pms_autoinvoice/tests/__init__.py @@ -1 +1,2 @@ from . import test_pms_folio_autoinvoice +from . import test_autoinvoice_downpayments diff --git a/pms_autoinvoice/tests/test_autoinvoice_downpayments.py b/pms_autoinvoice/tests/test_autoinvoice_downpayments.py new file mode 100644 index 00000000..bbb1e30e --- /dev/null +++ b/pms_autoinvoice/tests/test_autoinvoice_downpayments.py @@ -0,0 +1,260 @@ +import datetime + +from odoo.tests import tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.pms.tests.common import TestPms + + +@tagged("post_install", "-at_install") +class TestAutoinvoiceDownpayments(TestPms, AccountTestInvoicingCommon): + """How the automatic invoice deducts the down payments of a folio.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(user=cls.env["res.users"].browse(1)) + cls.company = cls.env.ref("base.main_company") + cls.anonymous = cls.env.ref("pms.various_pms_partner") + income = cls.env["account.account"].search( + [("account_type", "=", "income"), ("company_id", "=", cls.company.id)], + limit=1, + ) + cls.normal_journal = cls.env["account.journal"].create( + { + "name": "Autoinvoice normal", + "code": "AINV", + "type": "sale", + "company_id": cls.company.id, + "default_account_id": income.id, + } + ) + cls.simplified_journal = cls.env["account.journal"].create( + { + "name": "Autoinvoice simplified", + "code": "AISI", + "type": "sale", + "company_id": cls.company.id, + "default_account_id": income.id, + "is_simplified_invoice": True, + } + ) + cls.property = cls.env["pms.property"].create( + { + "name": "Autoinvoice down payments", + "company_id": cls.company.id, + "default_pricelist_id": cls.pricelist1.id, + "journal_normal_invoice_id": cls.normal_journal.id, + "journal_simplified_invoice_id": cls.simplified_journal.id, + "max_amount_simplified_invoice": 1000.0, + "default_invoicing_policy": "checkout", + "margin_days_autoinvoice": 0, + } + ) + cls.room_type = cls.env["pms.room.type"].create( + { + "pms_property_ids": [cls.property.id], + "name": "Double Autoinvoice", + "default_code": "DBL_AI", + "class_id": cls.room_type_class1.id, + "list_price": 100, + } + ) + cls.env["pms.room"].create( + { + "pms_property_id": cls.property.id, + "name": "Autoinvoice 101", + "room_type_id": cls.room_type.id, + "capacity": 2, + } + ) + cls.sale_channel = cls.env["pms.sale.channel"].create( + {"name": "Direct Autoinvoice", "channel_type": "direct"} + ) + + def _folio(self): + """A stay that checks out today, billed to the anonymous customer like + the walk-in guests whose down payments were issued as simplified + invoices.""" + today = datetime.date.today() + reservation = self.env["pms.reservation"].create( + { + "pms_property_id": self.property.id, + "checkin": today - datetime.timedelta(days=2), + "checkout": today, + "adults": 1, + "room_type_id": self.room_type.id, + "partner_id": self.anonymous.id, + "sale_channel_origin_id": self.sale_channel.id, + } + ) + reservation.reservation_line_ids.default_invoice_to = self.anonymous + return reservation.folio_id + + def _downpayment(self, folio, amount): + """The shape the down payment wizard produces: a posted invoice whose + line points at a down payment folio.sale.line, taxed like its product + (pms builds the deduction line from the product taxes too).""" + product = self.room_type.product_id + taxes = product.taxes_id.filtered(lambda t: t.company_id == self.company) + sale_line = self.env["folio.sale.line"].create( + { + "folio_id": folio.id, + "name": "Down payment", + "is_downpayment": True, + "product_id": product.id, + "product_uom": product.uom_id.id, + "product_uom_qty": 0, + "price_unit": amount, + "default_invoice_to": self.anonymous.id, + } + ) + invoice = self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.anonymous.id, + "journal_id": self.simplified_journal.id, + "invoice_date": datetime.date.today(), + "folio_ids": [(6, 0, folio.ids)], + "pms_property_id": self.property.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Down payment", + "product_id": product.id, + "quantity": 1, + "price_unit": amount, + "tax_ids": [(6, 0, taxes.ids)], + "folio_line_ids": [(6, 0, sale_line.ids)], + }, + ) + ], + } + ) + invoice.action_post() + return invoice, sale_line + + def _draft(self, folio, amount): + """A draft invoice of the folio for the anonymous customer, as the + automatic invoice leaves them before deducting down payments.""" + return self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.anonymous.id, + "journal_id": self.simplified_journal.id, + "folio_ids": [(6, 0, folio.ids)], + "pms_property_id": self.property.id, + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Stay", + "quantity": 1, + "price_unit": amount, + "tax_ids": [(6, 0, [])], + }, + ) + ], + } + ) + + def _automatic_invoices(self, folio, downpayment): + return self.env["account.move"].search( + [ + ("folio_ids", "in", folio.ids), + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("id", "!=", downpayment.id), + ] + ) + + @staticmethod + def _deductions(invoices, sale_line): + return invoices.invoice_line_ids.filtered( + lambda line: sale_line in line.folio_line_ids + ) + + def test_downpayment_is_deducted_from_the_automatic_invoice(self): + """The deduction used to raise AttributeError ('NoneType' object has no + attribute 'map_tax'): pms maps the taxes of the down payment line + through the invoice fiscal position, and the call did not pass it.""" + folio = self._folio() + downpayment, sale_line = self._downpayment(folio, 50.0) + stay = folio.amount_total + + self.property.autoinvoice_folio(folio) + + invoice = self._automatic_invoices(folio, downpayment) + self.assertEqual(len(invoice), 1) + self.assertEqual(len(self._deductions(invoice, sale_line)), 1) + self.assertAlmostEqual(invoice.amount_total, stay - downpayment.amount_total) + self.assertEqual(sale_line.qty_invoiced, 0) + + def test_downpayment_larger_than_the_invoice_is_not_deducted(self): + """Deducting it would leave the invoice negative and unpostable: the + invoice bills its lines and the down payment is left for review.""" + folio = self._folio() + _downpayment, sale_line = self._downpayment(folio, 5000.0) + stay = folio.amount_total + + self.property.autoinvoice_folio(folio) + + invoice = self._automatic_invoices(folio, _downpayment) + self.assertEqual(len(invoice), 1) + self.assertFalse(self._deductions(invoice, sale_line)) + self.assertAlmostEqual(invoice.amount_total, stay) + self.assertTrue( + any(_downpayment.name in (msg.body or "") for msg in folio.message_ids), + "The folio must say which down payment was left for review", + ) + + def test_downpayment_is_deducted_once_from_the_largest_invoice(self): + """A folio split in two invoices for the same customer used to get the + down payment added to both: deducted twice, or "Expected singleton".""" + folio = self._folio() + downpayment, sale_line = self._downpayment(folio, 100.0) + large = self._draft(folio, 300.0) + small = self._draft(folio, 50.0) + + not_fitting = self.property._autoinvoice_deduct_downpayments( + large | small, sale_line, self.env["res.partner"] + ) + + self.assertFalse(not_fitting) + self.assertEqual(len(self._deductions(large, sale_line)), 1) + self.assertFalse(self._deductions(small, sale_line)) + self.assertAlmostEqual(large.amount_total, 300.0 - downpayment.amount_total) + + def test_downpayment_that_fits_nowhere_is_returned(self): + folio = self._folio() + _downpayment, sale_line = self._downpayment(folio, 1000.0) + large = self._draft(folio, 300.0) + small = self._draft(folio, 50.0) + + not_fitting = self.property._autoinvoice_deduct_downpayments( + large | small, sale_line, self.env["res.partner"] + ) + + self.assertEqual(not_fitting, sale_line) + self.assertFalse(self._deductions(large | small, sale_line)) + self.assertAlmostEqual(large.amount_total, 300.0) + self.assertAlmostEqual(small.amount_total, 50.0) + + def test_downpayment_is_not_deducted_when_its_customer_was_moved(self): + """When the simplified invoice went over the limit and was moved to a + named host, the host's invoice bills the stay the down payment paid + for. Deducting it from what is left for the anonymous customer is what + left negative drafts and down payments refunded twice.""" + folio = self._folio() + _downpayment, sale_line = self._downpayment(folio, 100.0) + leftover = self._draft(folio, 300.0) + + not_fitting = self.property._autoinvoice_deduct_downpayments( + leftover, sale_line, self.anonymous + ) + + self.assertFalse(not_fitting) + self.assertFalse(self._deductions(leftover, sale_line)) diff --git a/pms_downpayment_lifecycle/models/account_move.py b/pms_downpayment_lifecycle/models/account_move.py index c5128f57..f40e1e0c 100644 --- a/pms_downpayment_lifecycle/models/account_move.py +++ b/pms_downpayment_lifecycle/models/account_move.py @@ -1,4 +1,5 @@ import logging +from datetime import date from markupsafe import Markup @@ -390,12 +391,15 @@ def _rectify_downpayment(self, date, amount=None, ref=None): by unreconciling every line of the original, which would release the payment -- the one thing this whole module exists to avoid. - Nothing about the SII is passed on purpose. Its own module computes - ``sii_refund_type`` as 'I' for any ``out_refund``, so the value is right - without this module knowing that the SII exists. + The SII refund type goes through the context, as every other caller of + ``_reverse_moves`` does. ``sii_refund_type`` is a stored computed field + that can be edited, so the reversal copies the down payment's empty + value onto the credit note and the compute never runs: without the + context the AEAT rejects the credit note (TipoRectificativa). The + context key means nothing when the SII module is not installed. """ self.ensure_one() - credit_note = self._reverse_moves( + credit_note = self.with_context(sii_refund_type="I")._reverse_moves( default_values_list=[ { "date": date, @@ -553,3 +557,31 @@ def _create_transfer_entry(self, downpayment, amount, date): ) transfer.action_post() return transfer + + # ------------------------------------------------------------------ + # pms_autoinvoice + # ------------------------------------------------------------------ + def _reverse_downpayment_invoices(self): + """Keep the down payments of a locked period out of pms_autoinvoice's + reversal, and leave them to the transfer above. + + pms_autoinvoice reverses the down payments that a final invoice did not + deduct with ``cancel=True``, which starts by unreconciling them from + their payment: in a locked period that rewrites the customer balances + of a month that is already closed. The transfer settles the same down + payments when the final invoice is posted, rectifying them with + ``cancel=False`` and moving the balance with a transfer entry. + """ + locked = self.filtered(lambda move: move._is_in_locked_period()) + return super(AccountMove, self - locked)._reverse_downpayment_invoices() + + def _is_in_locked_period(self): + """Read from the company, not from ``_get_user_fiscal_lock_date``: the + cron runs as a user that only sees the fiscal year lock, and the + closed month is locked through ``period_lock_date``.""" + self.ensure_one() + lock_date = max( + self.company_id.period_lock_date or date.min, + self.company_id.fiscalyear_lock_date or date.min, + ) + return self.date <= lock_date diff --git a/pms_downpayment_lifecycle/tests/__init__.py b/pms_downpayment_lifecycle/tests/__init__.py index dc3dafbd..feb563ef 100644 --- a/pms_downpayment_lifecycle/tests/__init__.py +++ b/pms_downpayment_lifecycle/tests/__init__.py @@ -3,3 +3,5 @@ from . import test_downpayment_transfer_e2e from . import test_downpayment_lifecycle from . import test_downpayment_proforma +from . import test_autoinvoice_reversal +from . import test_rectification_sii_refund_type diff --git a/pms_downpayment_lifecycle/tests/test_autoinvoice_reversal.py b/pms_downpayment_lifecycle/tests/test_autoinvoice_reversal.py new file mode 100644 index 00000000..6d139b2b --- /dev/null +++ b/pms_downpayment_lifecycle/tests/test_autoinvoice_reversal.py @@ -0,0 +1,39 @@ +from odoo.tests import tagged + +from .common import DownpaymentCase + + +@tagged("post_install", "-at_install") +class TestAutoinvoiceReversal(DownpaymentCase): + """pms_autoinvoice reverses the down payments that a final invoice did not + deduct with cancel=True, which unreconciles them from their payment. In a + locked period they are left to the transfer instead.""" + + def _reversals(self, downpayment): + return self.env["account.move"].search( + [("reversed_entry_id", "=", downpayment.id)] + ) + + def test_downpayment_of_a_locked_period_is_not_reversed(self): + folio = self._folio() + downpayment, _line = self._downpayment(folio, self.anonymous, 50.0) + payment = self._collect(downpayment) + self.company.period_lock_date = downpayment.date + + reversed_moves = downpayment._reverse_downpayment_invoices() + + self.assertFalse(reversed_moves) + self.assertFalse(self._reversals(downpayment)) + # The closed month keeps its picture: the payment never moved. + self.assertEqual(downpayment.payment_state, "paid") + self.assertTrue(payment.is_reconciled) + + def test_downpayment_of_an_open_period_is_still_reversed(self): + folio = self._folio() + downpayment, _line = self._downpayment(folio, self.anonymous, 50.0) + self._collect(downpayment) + + reversed_moves = downpayment._reverse_downpayment_invoices() + + self.assertEqual(reversed_moves, self._reversals(downpayment)) + self.assertEqual(len(reversed_moves), 1) diff --git a/pms_downpayment_lifecycle/tests/test_rectification_sii_refund_type.py b/pms_downpayment_lifecycle/tests/test_rectification_sii_refund_type.py new file mode 100644 index 00000000..fdf4e268 --- /dev/null +++ b/pms_downpayment_lifecycle/tests/test_rectification_sii_refund_type.py @@ -0,0 +1,34 @@ +from unittest.mock import patch + +from odoo import fields +from odoo.tests import tagged + +from .common import DownpaymentCase + + +@tagged("post_install", "-at_install") +class TestRectificationSiiRefundType(DownpaymentCase): + """Every credit note of this module goes through _rectify_downpayment, so + that is where the SII refund type has to be given.""" + + def test_rectification_passes_the_sii_refund_type(self): + """The credit note copies the down payment, whose sii_refund_type is + empty, so the SII module only sets 'I' when the context carries it. + Without it the AEAT rejects the credit note (TipoRectificativa).""" + folio = self._folio() + downpayment, _line = self._downpayment(folio, self.anonymous, 50.0) + move_class = type(self.env["account.move"]) + reverse_moves = move_class._reverse_moves + seen = [] + + def spy(moves, *args, **kwargs): + seen.append(moves.env.context.get("sii_refund_type")) + return reverse_moves(moves, *args, **kwargs) + + with patch.object(move_class, "_reverse_moves", spy): + credit_note = downpayment._rectify_downpayment(fields.Date.today()) + + self.assertEqual(seen, ["I"]) + self.assertEqual(credit_note.reversed_entry_id, downpayment) + if "sii_refund_type" in credit_note._fields: + self.assertEqual(credit_note.sii_refund_type, "I")