diff --git a/pms_autoreconcile_folio_payments/__init__.py b/pms_autoreconcile_folio_payments/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/pms_autoreconcile_folio_payments/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/pms_autoreconcile_folio_payments/__manifest__.py b/pms_autoreconcile_folio_payments/__manifest__.py new file mode 100644 index 00000000..350da0ab --- /dev/null +++ b/pms_autoreconcile_folio_payments/__manifest__.py @@ -0,0 +1,10 @@ +{ + "name": "PMS autoreconcile folio payments", + "version": "16.0.1.0.0", + "category": "Generic Modules/Property Management System", + "author": "Commit [Sun], Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": [ + "pms", + ], +} diff --git a/pms_autoreconcile_folio_payments/models/__init__.py b/pms_autoreconcile_folio_payments/models/__init__.py new file mode 100644 index 00000000..957e570b --- /dev/null +++ b/pms_autoreconcile_folio_payments/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_move +from . import pms_folio diff --git a/pms_autoreconcile_folio_payments/models/account_move.py b/pms_autoreconcile_folio_payments/models/account_move.py new file mode 100644 index 00000000..d4b9e7fb --- /dev/null +++ b/pms_autoreconcile_folio_payments/models/account_move.py @@ -0,0 +1,55 @@ +from odoo import _, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + def _autoreconcile_folio_payments(self): + """ + Reconcile payments with the invoice + """ + for move in self.filtered(lambda m: m.state == "posted"): + if move.is_invoice(include_receipts=True) and move.folio_ids: + to_reconcile_payments_widget_vals = ( + move.invoice_outstanding_credits_debits_widget + ) + if not to_reconcile_payments_widget_vals: + continue + current_amounts = { + vals["move_id"]: vals["amount"] + for vals in to_reconcile_payments_widget_vals["content"] + } + pay_term_lines = move.line_ids.filtered( + lambda line: line.account_type + in ("asset_receivable", "liability_payable") + ) + to_propose = ( + self.env["account.move"] + .browse(list(current_amounts.keys())) + .line_ids.filtered( + lambda line, + pay_term_lines=pay_term_lines, + move=move: line.account_id == pay_term_lines.account_id + and line.payment_id.folio_ids in move.folio_ids + ) + ) + to_reconcile = to_propose.filtered( + lambda line, move=move: abs(line.balance) == move.amount_residual + ) + if to_reconcile: + try: + (pay_term_lines + to_reconcile).reconcile() + except Exception as e: + message = _( + """ + An error occurred while reconciling + the invoice with the payments: %s + """ + ) % str(e) + move.message_post(body=message) + return True + + def _post(self, soft=True): + res = super()._post(soft) + self._autoreconcile_folio_payments() + return res diff --git a/pms_autoreconcile_folio_payments/models/pms_folio.py b/pms_autoreconcile_folio_payments/models/pms_folio.py new file mode 100644 index 00000000..0cff77c1 --- /dev/null +++ b/pms_autoreconcile_folio_payments/models/pms_folio.py @@ -0,0 +1,36 @@ +from odoo import models + + +class PmsFolio(models.Model): + _inherit = "pms.folio" + + def do_payment( + self, + journal, + receivable_account, + user, + amount, + folio, + reservations=False, + services=False, + partner=False, + date=False, + pay_type=False, + ref=False, + ): + res = super().do_payment( + journal, + receivable_account, + user, + amount, + folio, + reservations=reservations, + services=services, + partner=partner, + date=date, + pay_type=pay_type, + ref=ref, + ) + for move in folio.move_ids: + move.sudo()._autoreconcile_folio_payments() + return res diff --git a/pms_autoreconcile_folio_payments/tests/__init__.py b/pms_autoreconcile_folio_payments/tests/__init__.py new file mode 100644 index 00000000..f0331500 --- /dev/null +++ b/pms_autoreconcile_folio_payments/tests/__init__.py @@ -0,0 +1 @@ +from . import test_pms_folio_autoreconcile diff --git a/pms_autoreconcile_folio_payments/tests/test_pms_folio_autoreconcile.py b/pms_autoreconcile_folio_payments/tests/test_pms_folio_autoreconcile.py new file mode 100644 index 00000000..8305e63e --- /dev/null +++ b/pms_autoreconcile_folio_payments/tests/test_pms_folio_autoreconcile.py @@ -0,0 +1,175 @@ +from datetime import datetime, timedelta + +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 TestPmsFolioAutoreconcile(TestPms, AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + user = cls.env["res.users"].browse(1) + cls.env = cls.env(user=user) + cls.payment_method_manual_in = cls.env.ref( + "account.account_payment_method_manual_in" + ) + cls.company_reconcile = cls.company_data_2["company"] + cls.payment_journal = cls.env["account.journal"].create( + { + "name": "Test Payment Journal", + "type": "bank", + "company_id": cls.company_reconcile.id, + "inbound_payment_method_line_ids": [ + (6, 0, [cls.payment_method_manual_in.id]) + ], + } + ) + cls.invoice_journal = cls.env["account.journal"].create( + { + "name": "Test Invoice Journal", + "code": "TEST_INV", + "type": "sale", + "company_id": cls.company_reconcile.id, + } + ) + + cls.property = cls.env["pms.property"].create( + { + "name": "MY PMS TEST", + "company_id": cls.company_reconcile.id, + "journal_simplified_invoice_id": cls.invoice_journal.id, + "default_pricelist_id": cls.pricelist1.id, + } + ) + 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", + } + ) + cls.sale_channel_direct1 = cls.env["pms.sale.channel"].create( + { + "name": "Door", + "channel_type": "direct", + } + ) + 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, + } + ) + + 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, + } + ) + + def test_autoreconcile_folio_payments_on_post(self): + reservation = self.env["pms.reservation"].create( + { + "checkin": datetime.now(), + "checkout": datetime.now() + timedelta(days=1), + "adults": 2, + "pms_property_id": self.property.id, + "room_type_id": self.room_type_double.id, + "partner_id": self.partner_id.id, + "sale_channel_origin_id": self.sale_channel_direct1.id, + } + ) + + invoice = reservation.folio_id._create_invoices() + payment = self.env["account.payment"].create( + { + "payment_type": "inbound", + "payment_method_id": self.payment_method_manual_in.id, + "journal_id": self.payment_journal.id, + "amount": reservation.folio_id.amount_total, + "currency_id": reservation.folio_id.currency_id.id, + "partner_id": reservation.folio_id.partner_id.id, + "folio_ids": [(4, reservation.folio_id.id)], + } + ) + payment.action_post() + invoice.action_post() + self.assertEqual( + invoice.payment_state, + "paid", + "The invoice should be marked as paid after posting the payment.", + ) + + def test_autoreconcile_folio_payments_do_payment(self): + reservation = self.env["pms.reservation"].create( + { + "checkin": datetime.now(), + "checkout": datetime.now() + timedelta(days=1), + "adults": 2, + "pms_property_id": self.property.id, + "room_type_id": self.room_type_double.id, + "partner_id": self.partner_id.id, + "sale_channel_origin_id": self.sale_channel_direct1.id, + } + ) + + invoice = reservation.folio_id._create_invoices() + invoice.action_post() + invoice.invalidate_recordset() + reservation.folio_id.do_payment( + journal=self.payment_journal, + receivable_account=reservation.folio_id.partner_id.property_account_receivable_id, + user=self.env.user, + amount=reservation.folio_id.amount_total, + folio=reservation.folio_id, + partner=reservation.folio_id.partner_id, + ) + self.assertEqual( + invoice.payment_state, + "paid", + "The invoice should be marked as paid after do_payment is called.", + ) + + def test_no_autoreconcile_partial_payment(self): + reservation = self.env["pms.reservation"].create( + { + "checkin": datetime.now(), + "checkout": datetime.now() + timedelta(days=1), + "adults": 2, + "pms_property_id": self.property.id, + "room_type_id": self.room_type_double.id, + "partner_id": self.partner_id.id, + "sale_channel_origin_id": self.sale_channel_direct1.id, + } + ) + + invoice = reservation.folio_id._create_invoices() + invoice.action_post() + + partial_amount = reservation.folio_id.amount_total / 2 + reservation.folio_id.do_payment( + journal=self.payment_journal, + receivable_account=reservation.folio_id.partner_id.property_account_receivable_id, + user=self.env.user, + amount=partial_amount, + folio=reservation.folio_id, + partner=reservation.folio_id.partner_id, + ) + + self.assertEqual( + invoice.payment_state, + "not_paid", + "The invoice should not be marked as paid after a partial payment.", + )