Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 74 additions & 11 deletions pms_autoinvoice/models/pms_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pms_autoinvoice/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_pms_folio_autoinvoice
from . import test_autoinvoice_downpayments
260 changes: 260 additions & 0 deletions pms_autoinvoice/tests/test_autoinvoice_downpayments.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading