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,
+)