diff --git a/pms_autoinvoice/__init__.py b/pms_autoinvoice/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/pms_autoinvoice/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/pms_autoinvoice/__manifest__.py b/pms_autoinvoice/__manifest__.py new file mode 100644 index 00000000..c8418692 --- /dev/null +++ b/pms_autoinvoice/__manifest__.py @@ -0,0 +1,21 @@ +{ + "name": "pms autoinvoice", + "version": "16.0.1.0.0", + "category": "", + "author": "Commitsun", + "license": "AGPL-3", + "depends": [ + "base", + "pms", + "queue_job", + ], + "data": [ + "data/ir_cron.xml", + "data/queue_data.xml", + "data/queue_job_function_data.xml", + "views/account_journal.xml", + "views/pms_property.xml", + "views/res_partner.xml", + "views/res_company.xml", + ], +} diff --git a/pms_autoinvoice/data/ir_cron.xml b/pms_autoinvoice/data/ir_cron.xml new file mode 100644 index 00000000..9bb4d388 --- /dev/null +++ b/pms_autoinvoice/data/ir_cron.xml @@ -0,0 +1,35 @@ + + + + Auto Invoicing Folios + 1 + + + days + -1 + + code + + + model.autoinvoicing() + + + Auto Invoicing DownPayments + 1 + + + days + -1 + + code + + + model.auto_invoice_downpayments(offset=1) + + diff --git a/pms_autoinvoice/data/queue_data.xml b/pms_autoinvoice/data/queue_data.xml new file mode 100644 index 00000000..8881324d --- /dev/null +++ b/pms_autoinvoice/data/queue_data.xml @@ -0,0 +1,7 @@ + + + + autoinvoicing folios + + + diff --git a/pms_autoinvoice/data/queue_job_function_data.xml b/pms_autoinvoice/data/queue_job_function_data.xml new file mode 100644 index 00000000..823f7c10 --- /dev/null +++ b/pms_autoinvoice/data/queue_job_function_data.xml @@ -0,0 +1,15 @@ + + + + + autoinvoice_folio + + + + + + autovalidate_folio_invoice + + + + diff --git a/pms_autoinvoice/models/__init__.py b/pms_autoinvoice/models/__init__.py new file mode 100644 index 00000000..cfce918d --- /dev/null +++ b/pms_autoinvoice/models/__init__.py @@ -0,0 +1,6 @@ +from . import account_payment +from . import folio_sale_line +from . import pms_property +from . import res_partner +from . import account_journal +from . import res_company diff --git a/pms_autoinvoice/models/account_journal.py b/pms_autoinvoice/models/account_journal.py new file mode 100644 index 00000000..6f0a8e24 --- /dev/null +++ b/pms_autoinvoice/models/account_journal.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + avoid_autoinvoice_downpayment = fields.Boolean( + help="Avoid autoinvoice downpayment", + default=False, + ) diff --git a/pms_autoinvoice/models/account_payment.py b/pms_autoinvoice/models/account_payment.py new file mode 100644 index 00000000..dea1a284 --- /dev/null +++ b/pms_autoinvoice/models/account_payment.py @@ -0,0 +1,90 @@ +from collections import defaultdict + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class AccountPayment(models.Model): + _inherit = "account.payment" + + @api.model + def auto_invoice_downpayments(self, offset=0): + """ + This method is called by a cron job to invoice the downpayments + based on the company settings. + """ + date_reference = fields.Date.today() - relativedelta(days=offset) + payments = self._get_downpayments_to_invoice(date_reference) + for payment in payments: + partner_id = ( + payment.partner_id.id or self.env.ref("pms.various_pms_partner").id + ) + self._create_downpayment_invoice( + payment=payment, + partner_id=partner_id, + ) + return True + + @api.model + def _get_downpayments_to_invoice(self, date_reference): + companys = self.env["res.company"].search([]) + payments = self.env["account.payment"] + for company in companys: + if company.pms_invoice_downpayment_policy == "all": + date_ref = fields.Date.today() + elif company.pms_invoice_downpayment_policy == "checkout_past_month": + date_ref = fields.Date.today().replace( + day=1, month=fields.Date.today().month + 1 + ) + else: + continue + payments += self.search( + [ + ("state", "=", "posted"), + ("partner_type", "=", "customer"), + ("company_id", "=", company.id), + ("journal_id.avoid_autoinvoice_downpayment", "=", False), + ("folio_ids", "!=", False), + ("folio_ids.last_checkout", ">=", date_ref), + ("date", "<=", date_reference), + ] + ) + payments = payments.filtered(lambda p: not p.reconciled_invoice_ids) + return payments + + @api.model + def _create_downpayment_invoice(self, payment, partner_id): + invoice_wizard = self.env["folio.advance.payment.inv"].create( + { + "partner_invoice_id": partner_id, + "advance_payment_method": "fixed", + "fixed_amount": payment.amount, + } + ) + move = invoice_wizard.with_context( + active_ids=payment.folio_ids.ids, + return_invoices=True, + ).create_invoices() + if payment.payment_type == "outbound": + move.action_switch_invoice_into_refund_credit_note() + move.action_post() + for invoice, payment_move in zip(move, payment.move_id, strict=True): + group = defaultdict(list) + for line in (invoice.line_ids + payment_move.line_ids).filtered( + lambda r: not r.reconciled + ): + group[(line.account_id, line.currency_id)].append(line.id) + for (account, _dummy), line_ids in group.items(): + if ( + account.reconcile or account.account_type == "liquidity" + ): # TODO: liquidity not in account.account_type + self.env["account.move.line"].browse(line_ids).reconcile() + # Set folio sale lines default_invoice_to to partner downpayment invoice + for folio in payment.folio_ids: + for sale_line in folio.sale_line_ids.filtered( + lambda r: not r.default_invoice_to + ): + sale_line.default_invoice_to = move.partner_id.id + + return move diff --git a/pms_autoinvoice/models/folio_sale_line.py b/pms_autoinvoice/models/folio_sale_line.py new file mode 100644 index 00000000..edc86e2b --- /dev/null +++ b/pms_autoinvoice/models/folio_sale_line.py @@ -0,0 +1,63 @@ +from datetime import timedelta + +from dateutil import relativedelta + +from odoo import api, fields, models + + +class FolioSaleLine(models.Model): + _inherit = "folio.sale.line" + + autoinvoice_date = fields.Date( + compute="_compute_autoinvoice_date", + store=True, + ) + + @api.depends( + "default_invoice_to", + "invoice_status", + "folio_id.last_checkout", + "reservation_id.checkout", + "service_id.reservation_id.checkout", + ) + def _compute_autoinvoice_date(self): + for record in self: + record.autoinvoice_date = record._get_to_invoice_date() + + def _get_to_invoice_date(self): + self.ensure_one() + partner = self.default_invoice_to + if self.reservation_id: + last_checkout = self.reservation_id.checkout + elif self.service_id and self.service_id.reservation_id: + last_checkout = self.service_id.reservation_id.checkout + else: + last_checkout = self.folio_id.last_checkout + if not last_checkout: + return False + invoicing_policy = ( + self.folio_id.pms_property_id.default_invoicing_policy + if not partner or partner.invoicing_policy == "property" + else partner.invoicing_policy + ) + if invoicing_policy == "manual": + return False + if invoicing_policy == "checkout": + margin_days = ( + self.folio_id.pms_property_id.margin_days_autoinvoice + if not partner or partner.invoicing_policy == "property" + else partner.margin_days_autoinvoice + ) + return last_checkout + timedelta(days=margin_days) + if invoicing_policy == "month_day": + month_day = ( + self.folio_id.pms_property_id.invoicing_month_day + if not partner or partner.invoicing_policy == "property" + else partner.invoicing_month_day + ) + if last_checkout.day <= month_day: + return last_checkout.replace(day=month_day) + else: + return (last_checkout + relativedelta.relativedelta(months=1)).replace( + day=month_day + ) diff --git a/pms_autoinvoice/models/pms_folio.py b/pms_autoinvoice/models/pms_folio.py new file mode 100644 index 00000000..e850151c --- /dev/null +++ b/pms_autoinvoice/models/pms_folio.py @@ -0,0 +1,62 @@ +import datetime + +from odoo import fields, models + + +class PmsFolio(models.Model): + _inherit = "pms.folio" + + def _get_lines_to_invoice(self, final=False): + self = self.with_context(lines_auto_add=True) + lines_to_invoice = dict() + res = super()._get_lines_to_invoice(final=final) + if not self._context.get("autoinvoice"): + return res + for line in self.sale_line_ids.filtered( + lambda r: r.qty_to_invoice > 0 + or (r.qty_to_invoice < 0 and final) + or r.display_type == "line_note" + ): + if line.autoinvoice_date and line.autoinvoice_date <= fields.Date.today(): + lines_to_invoice[line.id] = ( + 0 if line.display_type else line.qty_to_invoice + ) + return lines_to_invoice + + def _get_invoice_date(self, partner_invoice_id, lines_to_invoice, date=None): + partner_invoice = self.env["res.partner"].browse(partner_invoice_id) + partner_invoice_policy = self.pms_property_id.default_invoicing_policy + if partner_invoice and partner_invoice.invoicing_policy != "property": + partner_invoice_policy = partner_invoice.invoicing_policy + invoice_date = super()._get_invoice_date( + partner_invoice_id, lines_to_invoice, date=date + ) + if partner_invoice_policy == "checkout": + margin_days_autoinvoice = ( + self.pms_property_id.margin_days_autoinvoice + if partner_invoice.margin_days_autoinvoice == 0 + else partner_invoice.margin_days_autoinvoice + ) + invoice_date = max( + self.env["pms.reservation"] + .search([("sale_line_ids", "in", lines_to_invoice.keys())]) + .mapped("checkout") + ) + datetime.timedelta(days=margin_days_autoinvoice) + if partner_invoice_policy == "month_day": + month_day = ( + self.pms_property_id.invoicing_month_day + if partner_invoice.invoicing_month_day == 0 + else partner_invoice.invoicing_month_day + ) + invoice_date = datetime.date( + datetime.date.today().year, + datetime.date.today().month, + month_day, + ) + if invoice_date < datetime.date.today(): + invoice_date = datetime.date( + datetime.date.today().year, + datetime.date.today().month + 1, + month_day, + ) + return invoice_date diff --git a/pms_autoinvoice/models/pms_property.py b/pms_autoinvoice/models/pms_property.py new file mode 100644 index 00000000..1314697e --- /dev/null +++ b/pms_autoinvoice/models/pms_property.py @@ -0,0 +1,229 @@ +from dateutil.relativedelta import relativedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PmsProperty(models.Model): + _inherit = "pms.property" + + default_invoicing_policy = fields.Selection( + selection=[ + ("manual", "Manual"), + ("checkout", "Checkout"), + ("month_day", "Month Day Invoice"), + ], + default="manual", + ) + invoicing_month_day = fields.Integer( + help="The day of the month to invoice", + ) + margin_days_autoinvoice = fields.Integer( + string="Margin Days", + help="Days from Checkout to generate the invoice", + ) + + @api.model + def _get_folio_default_journal(self, partner_invoice_id, room_ids=False): + self.ensure_one() + partner = self.env["res.partner"].browse(partner_invoice_id) + if not not partner._check_enought_invoice_data() and self._context.get( + "autoinvoice" + ): + return self._get_journal(is_simplified_invoice=True, room_ids=room_ids) + return super()._get_folio_default_journal(partner_invoice_id, room_ids=room_ids) + + @api.model + def autoinvoicing(self, offset=0, with_delay=False, autocommit=False): + """ + This method is used to invoicing automatically the folios + and validate the draft invoices created by the folios + """ + date_reference = fields.Date.today() - relativedelta(days=offset) + # REVIEW: We clean the autoinvoice_date of the past draft invoices + # to avoid blocking the autoinvoicing + self.clean_date_on_past_draft_invoices(date_reference) + # 1- Invoicing the folios + folios = self.env["pms.folio"].search( + [ + ("sale_line_ids.autoinvoice_date", "=", date_reference), + ("invoice_status", "=", "to_invoice"), + ("amount_total", ">", 0), + ] + ) + paid_folios = folios.filtered(lambda f: f.pending_amount <= 0) + unpaid_folios = folios.filtered(lambda f: f.pending_amount > 0) + folios_to_invoice = paid_folios + # If the folio is unpaid we will auto invoice only the + # not cancelled lines + for folio in unpaid_folios: + if any([res.state != "cancel" for res in folio.reservation_ids]): + folios_to_invoice += folio + else: + folio.sudo().message_post( + body=_( + "Not invoiced due to pending amounts and cancelled reservations" + ) + ) + for folio in folios_to_invoice: + if with_delay: + self.with_delay().autoinvoice_folio(folio) + else: + self.autoinvoice_folio(folio) + # 2- Validate the draft invoices created by the folios + draft_invoices_to_post = self.env["account.move"].search( + [ + ("state", "=", "draft"), + ("invoice_date_due", "=", date_reference), + ("folio_ids", "!=", False), + ("amount_total", ">", 0), + ] + ) + for invoice in draft_invoices_to_post: + if with_delay: + self.with_delay().autovalidate_folio_invoice(invoice) + else: + self.autovalidate_folio_invoice(invoice) + + # 3- Reverse the downpayment invoices that not was included in final invoice + downpayments_invoices_to_reverse = self.env["account.move.line"].search( + [ + ("move_id.state", "=", "posted"), + ("folio_line_ids.is_downpayment", "=", True), + ("folio_line_ids.qty_invoiced", ">", 0), + ("folio_ids", "in", folios.ids), + ] + ) + downpayment_invoices = downpayments_invoices_to_reverse.mapped("move_id") + if downpayment_invoices: + for downpayment_invoice in downpayment_invoices: + default_values_list = [ + { + "ref": _(f'Reversal of: {move.name + " - " + move.ref}'), + } + for move in downpayment_invoice + ] + downpayment_invoice.with_context(sii_refund_type="I")._reverse_moves( + default_values_list, cancel=True + ) + downpayment_invoice.message_post( + body=_( + """ + The downpayment invoice has been reversed + because it was not included in the final invoice + """ + ) + ) + + return True + + @api.model + def clean_date_on_past_draft_invoices(self, date_reference): + """ + This method is used to clean the date on past draft invoices + """ + journal_ids = ( + self.env["account.journal"] + .search( + [ + ("type", "=", "sale"), + ("pms_property_ids", "!=", False), + ] + ) + .ids + ) + draft_invoices = self.env["account.move"].search( + [ + ("state", "=", "draft"), + ("invoice_date", "<", date_reference), + ("journal_id", "in", journal_ids), + ] + ) + if draft_invoices: + draft_invoices.write({"invoice_date": date_reference}) + return True + + def autovalidate_folio_invoice(self, invoice): + try: + with self.env.cr.savepoint(): + invoice.action_post() + except Exception as e: + raise ValidationError( + _("Error in autovalidate invoice: %s") % str(e) + ) from e + + def autoinvoice_folio(self, folio): + try: + with self.env.cr.savepoint(): + # REVIEW: folio sale line "_compute_auotinvoice_date" sometimes + # dont work in services (probably cache issue¿?), + # we ensure that the date is set or recompute this + for line in folio.sale_line_ids.filtered( + lambda r: not r.autoinvoice_date + ): + line._compute_autoinvoice_date() + invoices = folio.with_context(autoinvoice=True)._create_invoices( + grouped=True, + final=False, + ) + downpayments = folio.sale_line_ids.filtered( + lambda r: r.is_downpayment and r.qty_invoiced > 0 + ) + for invoice in invoices: + if ( + invoice.amount_total + > invoice.pms_property_id.max_amount_simplified_invoice + and invoice.journal_id.is_simplified_invoice + ): + hosts_to_invoice = ( + invoice.folio_ids.partner_invoice_ids.filtered( + lambda p: p._check_enought_invoice_data() + ).mapped("id") + ) + if hosts_to_invoice: + invoice.partner_id = hosts_to_invoice[0] + invoice.journal_id = ( + invoice.pms_property_id.journal_normal_invoice_id + ) + else: + mens = _( + "The total amount of the simplified invoice is " + "higher than the maximum amount allowed for " + "simplified invoices, and dont have enought data" + " in hosts to create a normal invoice." + ) + 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, + ) + invoice.write( + {"invoice_line_ids": [(0, 0, invoice_down_payment_vals)]} + ) + invoice.action_post() + # The downpayment invoices that not was included in final + # invoice, are reversed + downpayment_invoices = ( + downpayments.filtered( + lambda d: d.qty_invoiced > 0 + ).invoice_lines.mapped("move_id") + ).filtered(lambda i: i.is_simplified_invoice) + if downpayment_invoices: + default_values_list = [ + { + "ref": _(f'Reversal of: {move.name + " - " + move.ref}'), + } + for move in downpayment_invoices + ] + downpayment_invoices.with_context( + sii_refund_type="I" + )._reverse_moves(default_values_list, cancel=True) + except Exception as e: + raise ValidationError(_("Error in autoinvoicing folio: %s") % str(e)) from e diff --git a/pms_autoinvoice/models/res_company.py b/pms_autoinvoice/models/res_company.py new file mode 100644 index 00000000..4be54d58 --- /dev/null +++ b/pms_autoinvoice/models/res_company.py @@ -0,0 +1,21 @@ +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + pms_invoice_downpayment_policy = fields.Selection( + selection=[ + ("no", "Manual"), + ("all", "All"), + ("checkout_past_month", "Checkout past month"), + ], + string="Downpayment policy invoce", + help=""" + - Manual: Downpayment invoice will be created manually + - All: Downpayment invoice will be created automatically + - Current Month: Downpayment invoice will be created automatically + only for reservations with checkout date past of current month + """, + default="no", + ) diff --git a/pms_autoinvoice/models/res_partner.py b/pms_autoinvoice/models/res_partner.py new file mode 100644 index 00000000..0f2e3740 --- /dev/null +++ b/pms_autoinvoice/models/res_partner.py @@ -0,0 +1,23 @@ +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + invoicing_policy = fields.Selection( + help="""The invoicing policy of the partner, + set Property to user the policy configured in the Property""", + selection=[ + ("property", "Property Policy Invoice"), + ("manual", "Manual"), + ("checkout", "From Checkout"), + ("month_day", "Month Day Invoice"), + ], + default="property", + ) + invoicing_month_day = fields.Integer( + help="The day of the month to invoice", + ) + margin_days_autoinvoice = fields.Integer( + string="Days from Checkout", + help="Days from Checkout to generate the invoice", + ) diff --git a/pms_autoinvoice/tests/__init__.py b/pms_autoinvoice/tests/__init__.py new file mode 100644 index 00000000..ecb3ae88 --- /dev/null +++ b/pms_autoinvoice/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pms_folio_autoinvoice diff --git a/pms_autoinvoice/tests/test_pms_folio_autoinvoice.py b/pms_autoinvoice/tests/test_pms_folio_autoinvoice.py new file mode 100644 index 00000000..7c707e38 --- /dev/null +++ b/pms_autoinvoice/tests/test_pms_folio_autoinvoice.py @@ -0,0 +1,416 @@ +import datetime + +from odoo import fields +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 TestPmsFolioInvoice(TestPms, AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + user = cls.env["res.users"].browse(1) + cls.env = cls.env(user=user) + # create a room type availability + cls.room_type_availability = cls.env["pms.availability.plan"].create( + {"name": "Availability plan for TEST"} + ) + + # journal to simplified invoices + cls.simplified_journal = cls.env["account.journal"].create( + { + "name": "Simplified journal", + "code": "SMP", + "type": "sale", + "company_id": cls.env.ref("base.main_company").id, + } + ) + + # create a property + cls.property = cls.env["pms.property"].create( + { + "name": "MY PMS TEST", + "company_id": cls.env.ref("base.main_company").id, + "default_pricelist_id": cls.pricelist1.id, + "journal_simplified_invoice_id": cls.simplified_journal.id, + } + ) + + # create room type + cls.room_type_double = cls.env["pms.room.type"].create( + { + "pms_property_ids": [cls.property.id], + "name": "Double Test", + "default_code": "DBL_Test", + "class_id": cls.room_type_class1.id, + "list_price": 25, + } + ) + + # create rooms + cls.room1 = cls.env["pms.room"].create( + { + "pms_property_id": cls.property.id, + "name": "Double 101", + "room_type_id": cls.room_type_double.id, + "capacity": 2, + } + ) + + cls.room2 = cls.env["pms.room"].create( + { + "pms_property_id": cls.property.id, + "name": "Double 102", + "room_type_id": cls.room_type_double.id, + "capacity": 2, + } + ) + + cls.room3 = cls.env["pms.room"].create( + { + "pms_property_id": cls.property.id, + "name": "Double 103", + "room_type_id": cls.room_type_double.id, + "capacity": 2, + } + ) + + # res.partner + cls.partner_id = cls.env["res.partner"].create( + { + "name": "Miguel", + "vat": "45224522J", + "country_id": cls.env.ref("base.es").id, + "city": "Madrid", + "zip": "28013", + "street": "Calle de la calle", + } + ) + + # create a sale channel + cls.sale_channel_direct1 = cls.env["pms.sale.channel"].create( + { + "name": "Door", + "channel_type": "direct", + } + ) + + def create_configuration_accounting_scenario(self): + """ + Method to simplified scenario to payments and accounting: + # REVIEW: + - Use new property with odoo demo data company to avoid account configuration + - Emule SetUp with new property: + - create demo_room_type_double + - Create 2 rooms room_type_double + """ + self.pms_property_demo = self.env["pms.property"].create( + { + "name": "Property Based on Comapany Demo", + "company_id": self.env.ref("base.main_company").id, + "default_pricelist_id": self.env.ref("product.list0").id, + } + ) + # create room type + self.demo_room_type_double = self.env["pms.room.type"].create( + { + "pms_property_ids": [self.pms_property_demo.id], + "name": "Double Test", + "default_code": "Demo_DBL_Test", + "class_id": self.room_type_class1.id, + "list_price": 25, + } + ) + # create rooms + self.double1 = self.env["pms.room"].create( + { + "pms_property_id": self.pms_property_demo.id, + "name": "Double 101", + "room_type_id": self.demo_room_type_double.id, + "capacity": 2, + } + ) + self.double2 = self.env["pms.room"].create( + { + "pms_property_id": self.pms_property_demo.id, + "name": "Double 102", + "room_type_id": self.demo_room_type_double.id, + "capacity": 2, + } + ) + # make current journals payable + journals = self.env["account.journal"].search( + [ + ("type", "in", ["bank", "cash"]), + ] + ) + journals.allowed_pms_payments = True + + def test_autoinvoice_folio_checkout_property_policy(self): + """ + Test create and invoice the cron by property preconfig automation + -------------------------------------- + Set property default_invoicing_policy to checkout with 0 days with + margin, and check that the folio autoinvoice date is set to last checkout + folio date + """ + # ARRANGE + self.property.default_invoicing_policy = "checkout" + self.property.margin_days_autoinvoice = 0 + + # ACT + self.reservation1 = self.env["pms.reservation"].create( + { + "pms_property_id": self.property.id, + "checkin": datetime.date.today(), + "checkout": datetime.date.today() + datetime.timedelta(days=3), + "adults": 2, + "room_type_id": self.room_type_double.id, + "partner_id": self.partner_id.id, + "sale_channel_origin_id": self.sale_channel_direct1.id, + } + ) + + # ASSERT + self.assertIn( + datetime.date.today() + datetime.timedelta(days=3), + self.reservation1.folio_id.mapped("sale_line_ids.autoinvoice_date"), + "The autoinvoice date in folio with property checkout policy is wrong", + ) + + def test_autoinvoice_folio_checkout_partner_policy(self): + """ + Test create and invoice the cron by partner preconfig automation + -------------------------------------- + Set partner invoicing_policy to checkout with 2 days with + margin, and check that the folio autoinvoice date is set to last checkout + folio date + 2 days + """ + # ARRANGE + self.partner_id.invoicing_policy = "checkout" + self.partner_id.margin_days_autoinvoice = 2 + + # ACT + self.reservation1 = self.env["pms.reservation"].create( + { + "pms_property_id": self.property.id, + "checkin": datetime.date.today(), + "checkout": datetime.date.today() + datetime.timedelta(days=3), + "adults": 2, + "room_type_id": self.room_type_double.id, + "partner_id": self.partner_id.id, + "sale_channel_origin_id": self.sale_channel_direct1.id, + } + ) + self.reservation1.reservation_line_ids.default_invoice_to = self.partner_id + + # ASSERT + self.assertEqual( + datetime.date.today() + datetime.timedelta(days=5), + self.reservation1.folio_id.sale_line_ids.filtered( + lambda r: r.invoice_status == "to_invoice" + )[0].autoinvoice_date, + "The autoinvoice date in folio with property checkout policy is wrong", + ) + + def test_autoinvoice_paid_folio_overnights_partner_policy(self): + """ + Test create and invoice the cron by partner preconfig automation + with partner setted as default invoiced to in reservation lines + -------------------------------------- + Set partner invoicing_policy to checkout, create a reservation + with room, board service and normal service, run autoinvoicing + method and check that only room and board service was invoiced + in partner1, the folio must be paid + + """ + # ARRANGE + self.create_configuration_accounting_scenario() + self.partner_id2 = self.env["res.partner"].create( + { + "name": "Sara", + "vat": "54235544A", + "country_id": self.env.ref("base.es").id, + "city": "Madrid", + "zip": "28013", + "street": "Street 321", + } + ) + self.partner_id.invoicing_policy = "checkout" + self.partner_id.margin_days_autoinvoice = 0 + self.product1 = self.env["product.product"].create( + { + "name": "Test Product 1", + } + ) + + self.product2 = self.env["product.product"].create( + { + "name": "Test Product 2", + "lst_price": 100, + } + ) + + self.board_service1 = self.env["pms.board.service"].create( + { + "name": "Test Board Service 1", + "default_code": "CB1", + "amount": 10, + } + ) + + self.board_service_line1 = self.env["pms.board.service.line"].create( + { + "product_id": self.product1.id, + "pms_board_service_id": self.board_service1.id, + "amount": 10, + "adults": True, + } + ) + + self.board_service_room_type1 = self.env["pms.board.service.room.type"].create( + { + "pms_room_type_id": self.demo_room_type_double.id, + "pms_board_service_id": self.board_service1.id, + "pms_property_id": self.pms_property_demo.id, + } + ) + # ACT + self.reservation1 = self.env["pms.reservation"].create( + { + "pms_property_id": self.pms_property_demo.id, + "checkin": datetime.date.today() - datetime.timedelta(days=3), + "checkout": datetime.date.today(), + "adults": 2, + "room_type_id": self.demo_room_type_double.id, + "partner_id": self.partner_id2.id, + "board_service_room_id": self.board_service_room_type1.id, + "sale_channel_origin_id": self.sale_channel_direct1.id, + } + ) + self.service = self.env["pms.service"].create( + { + "is_board_service": False, + "product_id": self.product2.id, + "reservation_id": self.reservation1.id, + } + ) + folio = self.reservation1.folio_id + reservation1 = self.reservation1 + reservation1.reservation_line_ids.default_invoice_to = self.partner_id + reservation1.service_ids.filtered( + "is_board_service" + ).default_invoice_to = self.partner_id + + folio.do_payment( + journal=self.env["account.journal"].browse( + reservation1.folio_id.pms_property_id._get_payment_methods().ids[0] + ), + receivable_account=self.env["account.journal"] + .browse(reservation1.folio_id.pms_property_id._get_payment_methods().ids[0]) + .suspense_account_id, + user=self.env.user, + amount=reservation1.folio_id.pending_amount, + folio=folio, + partner=reservation1.partner_id, + date=fields.date.today(), + ) + self.pms_property_demo.autoinvoicing() + + # ASSERT + overnight_sale_lines = self.reservation1.folio_id.sale_line_ids.filtered( + lambda line: line.reservation_line_ids or line.is_board_service + ) + partner_invoice = self.reservation1.folio_id.move_ids.filtered( + lambda inv: inv.partner_id == self.partner_id + ) + self.assertEqual( + partner_invoice.mapped("line_ids.folio_line_ids.id"), + overnight_sale_lines.ids, + "Billed services and overnights invoicing wrong compute", + ) + + def test_not_autoinvoice_unpaid_cancel_folio_partner_policy(self): + """ + Test create and invoice the cron by partner preconfig automation + -------------------------------------- + Set partner invoicing_policy to checkout, create a reservation + with room, board service and normal service, run autoinvoicing + method and check that not invoice was created becouse + the folio is cancel and not paid + """ + # ARRANGE + self.partner_id.invoicing_policy = "checkout" + self.partner_id.margin_days_autoinvoice = 0 + self.product1 = self.env["product.product"].create( + { + "name": "Test Product 1", + } + ) + + self.product2 = self.env["product.product"].create( + { + "name": "Test Product 2", + "lst_price": 100, + } + ) + + self.board_service1 = self.env["pms.board.service"].create( + { + "name": "Test Board Service 1", + "default_code": "CB1", + "amount": 10, + } + ) + + self.board_service_line1 = self.env["pms.board.service.line"].create( + { + "product_id": self.product1.id, + "pms_board_service_id": self.board_service1.id, + "amount": 10, + "adults": True, + } + ) + + self.board_service_room_type1 = self.env["pms.board.service.room.type"].create( + { + "pms_room_type_id": self.room_type_double.id, + "pms_board_service_id": self.board_service1.id, + "pms_property_id": self.property.id, + } + ) + # ACT + self.reservation1 = self.env["pms.reservation"].create( + { + "pms_property_id": self.property.id, + "checkin": datetime.date.today() - datetime.timedelta(days=3), + "checkout": datetime.date.today(), + "adults": 2, + "room_type_id": self.room_type_double.id, + "partner_id": self.partner_id.id, + "board_service_room_id": self.board_service_room_type1.id, + "sale_channel_origin_id": self.sale_channel_direct1.id, + } + ) + self.service = self.env["pms.service"].create( + { + "is_board_service": False, + "product_id": self.product2.id, + "reservation_id": self.reservation1.id, + } + ) + self.reservation1.action_cancel() + self.property.autoinvoicing() + + # ASSERT + partner_invoice = self.reservation1.folio_id.move_ids.filtered( + lambda inv: inv.partner_id == self.partner_id + ) + self.assertEqual( + partner_invoice.mapped("line_ids.folio_line_ids.id"), + [], + "Billed services and overnights invoicing wrong compute", + ) diff --git a/pms_autoinvoice/views/account_journal.xml b/pms_autoinvoice/views/account_journal.xml new file mode 100644 index 00000000..a3a11f66 --- /dev/null +++ b/pms_autoinvoice/views/account_journal.xml @@ -0,0 +1,16 @@ + + + + + account.journal + + + + + + + + diff --git a/pms_autoinvoice/views/pms_property.xml b/pms_autoinvoice/views/pms_property.xml new file mode 100644 index 00000000..418b79bc --- /dev/null +++ b/pms_autoinvoice/views/pms_property.xml @@ -0,0 +1,27 @@ + + + + pms.property.inherit.view.form + pms.property + + + + + + + + {'required': [('default_invoicing_policy', '!=', 'manual')]} + + + {'required': [('default_invoicing_policy', '!=', 'manual')]} + + + + diff --git a/pms_autoinvoice/views/res_company.xml b/pms_autoinvoice/views/res_company.xml new file mode 100644 index 00000000..f2d58c87 --- /dev/null +++ b/pms_autoinvoice/views/res_company.xml @@ -0,0 +1,12 @@ + + + + res.company + + + + + + + + diff --git a/pms_autoinvoice/views/res_partner.xml b/pms_autoinvoice/views/res_partner.xml new file mode 100644 index 00000000..a74da757 --- /dev/null +++ b/pms_autoinvoice/views/res_partner.xml @@ -0,0 +1,23 @@ + + + + view.partner.property.form + res.partner + + + + + + + + + + + + diff --git a/setup/pms_autoinvoice/odoo/addons/pms_autoinvoice b/setup/pms_autoinvoice/odoo/addons/pms_autoinvoice new file mode 120000 index 00000000..ea60e490 --- /dev/null +++ b/setup/pms_autoinvoice/odoo/addons/pms_autoinvoice @@ -0,0 +1 @@ +../../../../pms_autoinvoice \ No newline at end of file diff --git a/setup/pms_autoinvoice/setup.py b/setup/pms_autoinvoice/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/pms_autoinvoice/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)