diff --git a/billcom/tests/test_account_payment.py b/billcom/tests/test_account_payment.py new file mode 100644 index 00000000..fbbe5130 --- /dev/null +++ b/billcom/tests/test_account_payment.py @@ -0,0 +1,251 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from unittest.mock import patch + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestAccountPayment(BillcomTestCommon): + """Test account.payment Bill.com integration""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a payment for testing + cls.payment = cls.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": cls.vendor_billcom.id, + "amount": 100.0, + "journal_id": cls.bank_journal.id, + "date": fields.Date.today(), + } + ) + + def test_payment_billcom_fields(self): + """Test that payments have all required Bill.com fields""" + required_fields = [ + "billcom", + "billcom_id", + "is_sync_to_billcom", + "billcom_sync_status", + ] + + for field in required_fields: + self.assertTrue( + hasattr(self.payment, field), f"Payment should have field: {field}" + ) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._make_request" + ) + def test_sync_payment_to_billcom(self, mock_request): + """Test syncing payment to Bill.com""" + mock_request.return_value = { + "id": "payment_abc123", + "amount": 100.0, + "processDate": fields.Date.today().isoformat(), + "singleStatus": "SCHEDULED", + } + + # Trigger sync + self.payment.sync_to_billcom() + + # Verify billcom_id was updated + self.assertEqual(self.payment.billcom_id, "payment_abc123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._make_request" + ) + def test_payment_status_update_from_webhook(self, mock_request): + """Test payment status update from Bill.com webhook""" + self.payment.billcom_id = "payment_webhook_001" + + mock_request.return_value = { + "id": "payment_webhook_001", + "singleStatus": "PAID", + "paidDate": fields.Date.today().isoformat(), + } + + # Sync from Bill.com + self.payment.sync_from_billcom_by_id("payment_webhook_001") + + # Verify payment was synced + self.assertEqual(self.payment.billcom_id, "payment_webhook_001") + + def test_payment_for_vendor_bill(self): + """Test creating payment for vendor bill""" + # Post the vendor bill + self.vendor_bill.action_post() + + # Create payment from bill + payment = ( + self.env["account.payment"] + .with_context(active_ids=[self.vendor_bill.id], active_model="account.move") + .create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_billcom.id, + "amount": self.vendor_bill.amount_total, + "journal_id": self.bank_journal.id, + } + ) + ) + + self.assertEqual(payment.partner_id, self.vendor_billcom) + self.assertEqual(payment.amount, self.vendor_bill.amount_total) + + def test_payment_multi_currency(self): + """Test payment in different currency""" + eur = self.env["res.currency"].search([("name", "=", "EUR")], limit=1) + if not eur: + eur = self.env["res.currency"].create({"name": "EUR", "symbol": "€"}) + + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_billcom.id, + "amount": 100.0, + "currency_id": eur.id, + "journal_id": self.bank_journal.id, + } + ) + + self.assertEqual(payment.currency_id, eur) + + def test_payment_funding_account(self): + """Test payment with Bill.com funding account""" + # Create funding account + funding_account = self.env["billcom.funding.account"].create( + { + "name": "Test Bank Account", + "billcom_id": "funding_001", + "account_type": "Checking", + "last_four_digits": "1234", + } + ) + + # Payment with funding account (if field exists) + if hasattr(self.payment, "billcom_funding_account_id"): + self.payment.billcom_funding_account_id = funding_account + self.assertEqual(self.payment.billcom_funding_account_id, funding_account) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._make_request" + ) + def test_payment_sync_error_handling(self, mock_request): + """Test error handling during payment sync""" + mock_request.side_effect = UserError("Payment sync failed") + + with self.assertRaises(UserError): + self.payment.sync_to_billcom() + + def test_payment_date_validation(self): + """Test payment date is required""" + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_billcom.id, + "amount": 100.0, + "journal_id": self.bank_journal.id, + "date": fields.Date.today(), + } + ) + + self.assertTrue(payment.date) + + def test_customer_payment_not_synced_to_billcom(self): + """Test customer payments (inbound) sync behavior""" + customer_payment = self.env["account.payment"].create( + { + "payment_type": "inbound", + "partner_type": "customer", + "partner_id": self.customer_billcom.id, + "amount": 100.0, + "journal_id": self.bank_journal.id, + } + ) + + self.assertEqual(customer_payment.payment_type, "inbound") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_update_billcom_payment_status(self, mock_request): + """Test updating payment status from Bill.com""" + # Setup a payment that needs update + self.payment.billcom_id = "payment_to_update" + self.payment.billcom_payment_status = "scheduled" + self.payment.last_sync_date = fields.Datetime.now() - fields.timedelta(hours=2) + + # Mock response + mock_request.return_value = { + "id": "payment_to_update", + "singleStatus": "PROCESSED", + "confirmationNumber": "CONF123", + } + + # Run update + self.env["account.payment"].update_billcom_payment_status() + + # Verify status updated + self.assertEqual(self.payment.billcom_payment_status, "processed") + self.assertEqual(self.payment.billcom_confirmation_number, "CONF123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_action_cancel_billcom_payment(self, mock_request): + """Test canceling payment in Bill.com""" + self.payment.billcom_id = "payment_to_cancel" + self.payment.billcom_payment_status = "scheduled" + + mock_request.return_value = {"id": "payment_to_cancel", "status": "CANCELED"} + + self.payment.action_cancel_billcom_payment() + + self.assertEqual(self.payment.billcom_payment_status, "canceled") + self.assertEqual(self.payment.state, "cancel") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_action_get_payment_status(self, mock_request): + """Test manual status fetch""" + self.payment.billcom_id = "payment_status_check" + + mock_request.return_value = { + "id": "payment_status_check", + "singleStatus": "SENT", + } + + self.payment.action_get_payment_status() + + self.assertEqual(self.payment.billcom_payment_status, "sent") + + def test_prepare_payment_data_wallet(self): + """Test payment data preparation for WALLET funding type""" + self.payment.billcom_funding_account_type = "WALLET" + # Wallet requires process date + self.payment.billcom_process_date = fields.Date.today() + fields.timedelta( + days=1 + ) + + data = self.payment._prepare_payment_data() + + self.assertEqual(data["fundingAccount"]["type"], "WALLET") + self.assertIn("processDate", data) diff --git a/billcom/tests/test_billcom_service.py b/billcom/tests/test_billcom_service.py new file mode 100644 index 00000000..3ea4ccad --- /dev/null +++ b/billcom/tests/test_billcom_service.py @@ -0,0 +1,278 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.tests import tagged + +from .common import BillcomTestCommon + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomService(BillcomTestCommon): + """Tests for billcom.service core methods""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create funding account for payment tests + cls.funding_account = cls.env["billcom.funding.account"].create( + { + "billcom_id": "funding_acc_123", + "account_type": "CHECKING", + "is_default_payables": True, + } + ) + + # ===== sync_partner Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_partner_vendor_create_success(self, mock_request): + """Should create new vendor in Bill.com""" + mock_request.return_value = { + "id": "vendor_new_123", + "name": "New Test Vendor", + "email": "newvendor@test.com", + } + + # Create vendor without billcom_id + vendor = self.env["res.partner"].create( + { + "name": "New Test Vendor", + "supplier_rank": 1, + "is_sync_to_billcom": True, + "email": "newvendor@test.com", + } + ) + + service = self.env["billcom.service"] + result = service.sync_partner(vendor, partner_type="vendor") + + self.assertTrue(result) + self.assertEqual(vendor.billcom_id, "vendor_new_123") + self.assertTrue(vendor.last_sync_date) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_partner_vendor_update_success(self, mock_request): + """Should update existing vendor in Bill.com""" + mock_request.return_value = { + "id": "test_vendor_123", + "name": "Test Vendor Bill.com Updated", + } + + # Update vendor name + self.vendor_billcom.name = "Test Vendor Bill.com Updated" + + service = self.env["billcom.service"] + result = service.sync_partner(self.vendor_billcom, partner_type="vendor") + + self.assertTrue(result) + self.assertTrue(self.vendor_billcom.last_sync_date) + + def test_sync_partner_not_marked_for_sync(self): + """Should return False if partner not marked for sync""" + vendor = self.env["res.partner"].create( + { + "name": "No Sync Vendor", + "supplier_rank": 1, + "is_sync_to_billcom": False, # Not marked for sync + } + ) + + service = self.env["billcom.service"] + result = service.sync_partner(vendor, partner_type="vendor") + + self.assertFalse(result) + + # ===== get_funding_accounts Tests ===== + # Note: sync_item tests removed as product.product sync is not implemented in this module + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_get_funding_accounts_success(self, mock_request): + """Should retrieve funding accounts from Bill.com""" + mock_request.return_value = { + "results": [ + { + "id": "funding_001", + "bankName": "Primary Bank", + "type": "CHECKING", + "status": "VERIFIED", + }, + { + "id": "funding_002", + "bankName": "Credit Card", + "type": "CARD_ACCOUNT", + "status": "VERIFIED", + }, + ] + } + + service = self.env["billcom.service"] + accounts = service.get_funding_accounts() + + self.assertTrue(accounts) + self.assertEqual(len(accounts), 2) + self.assertEqual(accounts[0]["id"], "funding_001") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_get_default_funding_account_payables(self, mock_request): + """Should return default payables funding account from API""" + mock_request.return_value = { + "results": [ + { + "id": "funding_001", + "bankName": "Primary Bank", + "status": "VERIFIED", + "default": {"payables": True, "receivables": False}, + }, + { + "id": "funding_002", + "bankName": "Secondary Bank", + "status": "VERIFIED", + "default": {"payables": False, "receivables": True}, + }, + ] + } + + service = self.env["billcom.service"] + account = service.get_default_funding_account(account_type="payables") + + self.assertTrue(account) + self.assertEqual(account["id"], "funding_001") + self.assertEqual(account["default"]["payables"], True) + + # ===== Status Mapping Tests ===== + + def test_map_billcom_bill_status_to_odoo_state(self): + """Should correctly map Bill.com bill statuses to Odoo states""" + service = self.env["billcom.service"] + + # Test various status mappings to Odoo move state (draft/posted) + # Only UNDEFINED maps to draft + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("UNDEFINED"), "draft" + ) + + # All other statuses map to posted + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("APPROVING"), "posted" + ) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("SCHEDULED"), "posted" + ) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("PAID"), "posted" + ) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("CANCELLED"), "posted" + ) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("VOID"), "posted" + ) + # Unknown status defaults to posted (conservative approach) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("UNKNOWN"), "posted" + ) + + def test_map_billcom_invoice_status_to_odoo_state(self): + """Should correctly map Bill.com invoice statuses to Odoo states""" + service = self.env["billcom.service"] + + # Test various status mappings to Odoo move state (draft/posted) + # OPEN and UNDEFINED map to draft + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("OPEN"), "draft" + ) + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("UNDEFINED"), "draft" + ) + + # All other statuses map to posted + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("PAID_IN_FULL"), + "posted", + ) + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("PARTIAL_PAYMENT"), + "posted", + ) + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("SCHEDULED"), "posted" + ) + # Unknown status defaults to posted (conservative approach) + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("UNKNOWN"), "posted" + ) + + def test_map_billcom_payment_status_to_odoo_state(self): + """Should correctly map Bill.com payment statuses to Odoo states""" + service = self.env["billcom.service"] + + # Test various status mappings to Odoo payment state (draft/posted) + # UNDEFINED and UNPAID map to draft + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("UNDEFINED"), "draft" + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("UNPAID"), "draft" + ) + + # All other statuses map to posted + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("PAID"), "posted" + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("PARTIALLY_PAID"), + "posted", + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("SCHEDULED"), "posted" + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("IN_PROCESS"), "posted" + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("UNKNOWN"), "posted" + ) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_vendor_bank_account(self, mock_request): + """Test syncing vendor bank account""" + # Setup partner and bank + partner = self.vendor_billcom + bank = self.env["res.partner.bank"].create( + { + "acc_number": "123456789", + "partner_id": partner.id, + "bank_id": self.env["res.bank"] + .create( + { + "name": "Test Bank", + "routing_number": "987654321", + } + ) + .id, + } + ) + + mock_request.side_effect = [ + [], # Check existing + {"id": "bank_123", "status": "VERIFIED"}, # Create + ] + + service = self.env["billcom.service"] + result = service.sync_vendor_bank_account(partner) + + self.assertTrue(result) + self.assertEqual(bank.billcom_vendor_bank_id, "bank_123") diff --git a/billcom_integration/README.rst b/billcom_integration/README.rst new file mode 100644 index 00000000..9ca84e7f --- /dev/null +++ b/billcom_integration/README.rst @@ -0,0 +1,90 @@ +==================== +Bill.com Integration +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a9147ed086ad9a3a51df0c62b196fc9e2d49bd16732c29f8b1242b66864bbb38 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fl10n--usa-lightgray.png?logo=github + :target: https://github.com/OCA/l10n-usa/tree/16.0/billcom_integration + :alt: OCA/l10n-usa +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/l10n-usa-16-0/l10n-usa-16-0-billcom_integration + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/l10n-usa&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides integration with Bill.com API v3: + * Synchronize vendors between Odoo and Bill.com + * Synchronize bills between Odoo and Bill.com + * Synchronize vendor payments between Odoo and Bill.com + * Register payments directly from vendor bills with Bill.com integration + * Real-time payment status tracking with webhook support + * Advanced retry logic for API communication + * Configurable synchronization settings + * Manage Bill.com API credentials and configuration + * Automatic two-way synchronization of data + * Manual and automatic synchronization options + * Configurable scheduled actions from the interface + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Binhex +* Simple Solutions + +Contributors +~~~~~~~~~~~~ + +* Simple Solutions +* Binhex + - Antonio Ruban \ <\> + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/l10n-usa `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/billcom_integration/__init__.py b/billcom_integration/__init__.py new file mode 100644 index 00000000..8a580f89 --- /dev/null +++ b/billcom_integration/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models +from . import wizards diff --git a/billcom_integration/__manifest__.py b/billcom_integration/__manifest__.py new file mode 100644 index 00000000..e5b028ad --- /dev/null +++ b/billcom_integration/__manifest__.py @@ -0,0 +1,53 @@ +{ + "name": "Bill.com Integration", + "summary": "Integration with Bill.com API v3 for vendor and bill synchronization", + "author": "Binhex, Simple Solutions, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/l10n-usa", + "license": "AGPL-3", + "category": "Accounting", + "version": "16.0.1.0.0", + "depends": [ + "base", + "web", + "l10n_us", + "l10n_us_account_routing", + "contacts", + ], + "external_dependencies": { + "python": ["requests"], + }, + "assets": { + "web.assets_backend": [ + "billcom_integration/static/src/scss/billcom_dashboard.scss", + ], + }, + "data": [ + "data/ir_cron_data.xml", + "data/billcom_status_data.xml", + "security/billcom_security.xml", + "security/ir.model.access.csv", + "wizards/billcom_mfa_wizard_views.xml", + "wizards/billcom_partner_matching_wizard_views.xml", + "wizards/billcom_sync_wizard.xml", + "views/account_move_views.xml", + "views/account_payment_register_views.xml", + "views/account_payment_report.xml", + "views/account_payment_views.xml", + "views/account_tax_views.xml", + "views/billcom_config_kanban_dashboard.xml", + "views/billcom_config_views.xml", + "views/billcom_document_views.xml", + "views/billcom_from_billcom_actions.xml", + "views/billcom_funding_account_views.xml", + "views/billcom_item_views.xml", + "views/billcom_logger_views.xml", + "views/billcom_payment_purpose_views.xml", + "views/billcom_sync_queue_views.xml", + "views/billcom_webhook_log_views.xml", + "views/res_partner_bank_views.xml", + "views/res_partner_views.xml", + "views/menus.xml", + ], + "installable": True, + "application": True, +} diff --git a/billcom_integration/controllers/__init__.py b/billcom_integration/controllers/__init__.py new file mode 100644 index 00000000..e705bbf2 --- /dev/null +++ b/billcom_integration/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import billcom_controller diff --git a/billcom_integration/controllers/billcom_controller.py b/billcom_integration/controllers/billcom_controller.py new file mode 100644 index 00000000..dc59dba7 --- /dev/null +++ b/billcom_integration/controllers/billcom_controller.py @@ -0,0 +1,1042 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import hashlib +import hmac +import json +import logging + +from odoo import fields, http +from odoo.exceptions import UserError, ValidationError +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class BillComController(http.Controller): + def _handle_error(self, error): + """Common error handler for endpoints""" + _logger.error(str(error)) + return {"success": False, "error": str(error)} + + def _validate_webhook_signature(self, payload, signature, secret): + """Validate webhook signature from Bill.com + + Bill.com signature validation process: + 1. HMAC-SHA256 hash of minified JSON payload + 2. Encode hash as base64 (NOT hexadecimal) + 3. Compare with x-bill-sha-signature header + + Reference: https://developer.bill.com/docs/test-with-webhook-security + + Args: + payload (bytes): Raw webhook payload (minified JSON) + signature (str): Value from x-bill-sha-signature header + secret (str): securityKey from subscription response + + Returns: + bool: True if signature is valid, False otherwise + """ + if not secret: + _logger.warning( + "No webhook secret configured - skipping signature validation. " + "Configure webhook_secret in Bill.com settings for security." + ) + return True + + if not signature: + _logger.error("Missing x-bill-sha-signature header in webhook request") + return False + + try: + # Ensure payload is bytes + if isinstance(payload, str): + payload = payload.encode("utf-8") + + # Compute HMAC-SHA256 hash and encode as base64 + hash_digest = hmac.new( + secret.encode("utf-8"), payload, hashlib.sha256 + ).digest() + + expected_signature = base64.b64encode(hash_digest).decode("utf-8") + + # Secure comparison + is_valid = hmac.compare_digest(expected_signature, signature) + + if not is_valid: + _logger.error( + "⚠️ Webhook signature validation FAILED!\n" + "Expected (base64): %s...\n" + "Received: %s...\n" + "This may indicate a security issue or \ + incorrect webhook_secret configuration.", + expected_signature[:30], + signature[:30], + ) + else: + _logger.info("✓ Webhook signature validated successfully") + + return is_valid + + except Exception as e: + _logger.error( + "Error validating webhook signature: %s", str(e), exc_info=True + ) + return False + + def _get_config_from_organization_id(self, organization_id): + """Get Bill.com configuration by organization ID from webhook metadata""" + if not organization_id: + return None + + config = ( + request.env["billcom.config"] + .sudo() + .search( + [ + ("organization_id", "=", organization_id), + ("active", "=", True), + ("enable_webhooks", "=", True), + ], + limit=1, + ) + ) + + return config + + def _extract_webhook_data(self, data): + """Extract metadata and entity data from Bill.com webhook payload + + Bill.com API v3 webhook format: + { + "metadata": { + "eventId": "...", + "subscriptionId": "...", + "organizationId": "...", + "eventType": "bill.created", + "version": "1" + }, + "bill": { ... } / "vendor": { ... } / "payment": { ... } + } + """ + if not isinstance(data, dict): + raise ValueError("Invalid webhook data format") + + # Extract metadata + metadata = data.get("metadata", {}) + event_type = metadata.get("eventType") + organization_id = metadata.get("organizationId") + event_id = metadata.get("eventId") + + if not event_type: + raise ValueError("No event type in metadata") + if not organization_id: + raise ValueError("No organization ID in metadata") + + # Extract entity data based on event type + entity_data = None + entity_id = None + + if event_type.startswith("bill."): + entity_data = data.get("bill", {}) + entity_id = entity_data.get("id") + elif event_type.startswith("vendor."): + entity_data = data.get("vendor", {}) + entity_id = entity_data.get("id") + elif event_type.startswith("payment.") or event_type.startswith("autopay."): + entity_data = data.get("payment", {}) + entity_id = entity_data.get("id") + elif event_type.startswith("bank-account."): + entity_data = data.get("bankAccount", {}) + entity_id = entity_data.get("id") + elif event_type.startswith("card-account."): + entity_data = data.get("cardAccount", {}) + entity_id = entity_data.get("id") + + if not entity_id and entity_data: + # For some events like payment.failed, entity_id might be in different location + # or might not exist (bulk operations, failed operations, etc.) + # In these cases, use a combination of event metadata for idempotency + entity_id = f"event-{event_id}" + _logger.warning( + "No entity ID in payload for %s, using event_id: %s", + event_type, + event_id, + ) + + # Use event_id as idempotency key + idempotency_key = event_id or f"{event_type}: {entity_id}" + + return { + "event_type": event_type, + "organization_id": organization_id, + "entity_id": entity_id, + "entity_data": entity_data, + "idempotency_key": idempotency_key, + "metadata": metadata, + } + + def _handle_bill_webhook(self, event_type, entity_id, entity_data, config): + """Handle bill-related webhook events""" + if not config.sync_bills: + _logger.warning("Bill sync disabled, ignoring webhook") + return + + if event_type == "bill.archived": + move = ( + request.env["account.move"] + .sudo() + .search( + ["|", ("billcom_id", "=", entity_id), ("billcom", "=", entity_id)], + limit=1, + ) + ) + if move: + move.with_context(skip_billcom_sync=True).write({"active": False}) + _logger.info("Archived bill %s in Odoo", entity_id) + + elif event_type == "bill.restored": + move = ( + request.env["account.move"] + .sudo() + .with_context(active_test=False) + .search( + ["|", ("billcom_id", "=", entity_id), ("billcom", "=", entity_id)], + limit=1, + ) + ) + if move: + move.with_context(skip_billcom_sync=True).write({"active": True}) + _logger.info("Restored bill %s in Odoo", entity_id) + + else: + # For created/updated events, sync from Bill.com + request.env["account.move"].sudo().sync_from_billcom(entity_id) + _logger.info("Synced bill %s from Bill.com", entity_id) + + def _handle_vendor_webhook(self, event_type, entity_id, entity_data, config): + """Handle vendor-related webhook events + + Vendor webhook includes complete vendor data: + - Basic info: name, email, phone, address + - Network info: networkStatus, paymentNetworkId, rppsId + - Payment info: payByType, payeeName + - Financial: balance, autoPay + """ + if not config.sync_vendors: + _logger.warning("Vendor sync disabled, ignoring webhook") + return + + if event_type == "vendor.archived": + partner = ( + request.env["res.partner"] + .sudo() + .search( + ["|", ("billcom_id", "=", entity_id), ("billcom", "=", entity_id)], + limit=1, + ) + ) + if partner: + partner.with_context(skip_billcom_sync=True).write({"active": False}) + _logger.info("Archived vendor %s in Odoo", entity_id) + + elif event_type == "vendor.restored": + partner = ( + request.env["res.partner"] + .sudo() + .with_context(active_test=False) + .search( + ["|", ("billcom_id", "=", entity_id), ("billcom", "=", entity_id)], + limit=1, + ) + ) + if partner: + partner.with_context(skip_billcom_sync=True).write({"active": True}) + _logger.info("Restored vendor %s in Odoo", entity_id) + + else: + # For created/updated events, use the webhook entity data + # This is more efficient than fetching from API again + partner_model = request.env["res.partner"].sudo() + + # Find existing partner + existing_partner = partner_model.search( + ["|", ("billcom_id", "=", entity_id), ("billcom", "=", entity_id)], + limit=1, + ) + + # Prepare partner values from webhook data + address = entity_data.get("address", {}) + entity_data.get("paymentInformation", {}) + entity_data.get("additionalInfo", {}) + + partner_vals = { + "name": entity_data.get("name", "Unknown"), + "email": entity_data.get("email", ""), + "phone": entity_data.get("phone", ""), + "street": address.get("line1", ""), + "street2": address.get("line2", ""), + "city": address.get("city", ""), + "zip": address.get("zipOrPostalCode", ""), + "billcom_id": entity_id, + "billcom": entity_id, + "last_sync_date": fields.Datetime.now(), + "ref": entity_data.get("accountNumber") + or entity_data.get("shortName", ""), + "is_sync_to_billcom": True, + "billcom_sync_state": "synced", + "supplier_rank": 1, + "customer_rank": 0, + # Store additional Bill.com specific info in comment + "comment": self._format_vendor_comment(entity_data), + } + + # Set state/country + if address.get("stateOrProvince"): + state = ( + request.env["res.country.state"] + .sudo() + .search([("code", "=", address["stateOrProvince"])], limit=1) + ) + if state: + partner_vals["state_id"] = state.id + + if address.get("country"): + country = ( + request.env["res.country"] + .sudo() + .search([("code", "=", address["country"])], limit=1) + ) + if country: + partner_vals["country_id"] = country.id + + if existing_partner: + existing_partner.with_context(skip_billcom_sync=True).write( + partner_vals + ) + _logger.info("Updated vendor %s from webhook", entity_data.get("name")) + else: + partner_model.with_context(skip_billcom_sync=True).create(partner_vals) + _logger.info("Created vendor %s from webhook", entity_data.get("name")) + + def _format_vendor_comment(self, vendor_data): + """Format vendor Bill.com information for comment field""" + lines = ["=== Bill.com Vendor Info ==="] + + # Network status + network_status = vendor_data.get("networkStatus", "NOT_CONNECTED") + lines.append(f"Network Status: {network_status}") + + if vendor_data.get("paymentNetworkId"): + lines.append(f"Payment Network ID: {vendor_data['paymentNetworkId']}") + if vendor_data.get("rppsId"): + lines.append(f"RPPS ID: {vendor_data['rppsId']}") + + # Payment information + payment_info = vendor_data.get("paymentInformation", {}) + if payment_info: + pay_by_type = payment_info.get("payByType", "CHECK") + lines.append(f"Payment Type: {pay_by_type}") + if payment_info.get("lastPaymentDate"): + lines.append(f"Last Payment: {payment_info['lastPaymentDate'][:10]}") + + # Balance + balance = vendor_data.get("balance", {}) + if balance and balance.get("amount") is not None: + lines.append(f"Balance: ${balance['amount']: .2f}") + if balance.get("lastUpdatedDate"): + lines.append(f"Balance Updated: {balance['lastUpdatedDate'][:10]}") + + # AutoPay + auto_pay = vendor_data.get("autoPay", {}) + if auto_pay.get("enabled"): + lines.append("AutoPay: Enabled") + + # Additional info + additional_info = vendor_data.get("additionalInfo", {}) + if additional_info: + if additional_info.get("track1099"): + lines.append("Track 1099: Yes") + if additional_info.get("combinePayments"): + lines.append("Combine Payments: Yes") + + return "\n".join(lines) + + def _handle_payment_webhook(self, event_type, entity_id, entity_data, config): + """Handle payment-related webhook events + + Payment webhook includes complete payment data: + - payment.updated: Status changes (SCHEDULED, PROCESSED, etc.) + - payment.failed: Payment creation/processing failures + - autopay.failed: Automatic payment failures + + Payment Status Values: + - SCHEDULED: Payment scheduled for processing + - PROCESSED: Payment has been processed + - DELIVERED: Payment delivered to vendor + - RETURNED: Payment returned/failed + """ + if not config.sync_payments: + _logger.warning("Payment sync disabled, ignoring webhook") + return + + if event_type == "payment.failed": + # Handle payment failure + self._handle_payment_failed(entity_data) + elif event_type == "autopay.failed": + # Handle automatic payment failure (similar to payment.failed) + self._handle_autopay_failed(entity_data) + elif event_type == "payment.updated": + # Handle payment status update using webhook data directly + self._handle_payment_updated(entity_id, entity_data) + else: + _logger.warning("Unhandled payment event type: %s", event_type) + + def _handle_payment_failed(self, payment_data): + """Handle payment.failed webhook + + Payment failed includes: + - bills: List of bills that were in the failed payment + - vendor: Vendor information + - fundingAccount: Funding account used + - errors: List of error messages + + This handler updates payment status, creates activities, and posts to chatter + """ + vendor_info = payment_data.get("vendor", {}) + errors = payment_data.get("errors", []) + transaction_number = payment_data.get("transactionNumber") + bills = payment_data.get("bills", []) + + error_messages = [ + f"{err.get('message', 'Unknown error')} ({err.get('code', 'N/A')})" + for err in errors + ] + error_text = "; ".join(error_messages) + + _logger.error( + "Payment failed for vendor %s (transaction: %s). Errors: %s", + vendor_info.get("name", "Unknown"), + transaction_number, + error_text, + ) + + # Find related payments by bill IDs + if bills: + bill_ids = [bill.get("billId") for bill in bills if bill.get("billId")] + + if bill_ids: + # Find bills in Odoo + moves = ( + request.env["account.move"] + .sudo() + .search( + [ + "|", + ("billcom_id", "in", bill_ids), + ("billcom", "in", bill_ids), + ] + ) + ) + + for move in moves: + # Find related payments through reconciled move lines + payments = move.line_ids.filtered( + lambda line: line.account_id.account_type + in ("asset_receivable", "liability_payable") + ).mapped("matched_debit_ids.debit_move_id.payment_id") + + payments |= move.line_ids.filtered( + lambda line: line.account_id.account_type + in ("asset_receivable", "liability_payable") + ).mapped("matched_credit_ids.credit_move_id.payment_id") + + # Also check reconciled_bill_ids field if available + if hasattr(move, "payment_id") and move.payment_id: + payments |= move.payment_id + + for payment in payments.filtered( + lambda p: p.is_sync_to_billcom + and p.billcom_sync_status != "sync_failed" + ): + # Update payment status to failed + payment.with_context(skip_billcom_sync=True).write( + { + "billcom_payment_status": "failed", + "billcom_sync_status": "sync_failed", + "billcom_sync_error": error_text, + "billcom_transaction_number": transaction_number or "", + } + ) + + # Post error message to payment chatter + payment.message_post( + body=( + f"

Bill.com Payment Failed

" + f"

Vendor: " + f"{vendor_info.get('name', 'Unknown')}

" + f"

Transaction: " + f"{transaction_number or 'N/A'}

" + f"

Errors:

" + f"
    " + f"{''.join([f'
  • {msg}
  • ' for msg in error_messages])}" + f"
" + f"

Please review and retry the payment or " + f"contact Bill.com support.

" + ), + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + # Create activity for payment owner or accounting manager + activity_user = payment.create_uid + if not activity_user: + # Fallback to first user in Invoicing group + invoicing_group = request.env.ref( + "account.group_account_invoice", + raise_if_not_found=False, + ) + if invoicing_group and invoicing_group.users: + activity_user = invoicing_group.users[0] + + if activity_user: + try: + note = f"Bill.com payment failed with transaction\ + number {transaction_number or 'N/A'}." + payment.activity_schedule( + "mail.mail_activity_data_warning", + summary=f"Payment failed: \ + {vendor_info.get('name', 'Unknown')}", + note=note, + user_id=activity_user.id, + ) + _logger.info( + "Created activity for failed payment %s (user: %s)", + payment.name, + activity_user.name, + ) + except Exception as e: + _logger.warning( + "Could not create activity for failed payment %s: %s", + payment.name, + str(e), + ) + + _logger.info( + "Updated payment %s status to failed (Bill: %s)", + payment.name, + move.name, + ) + + # Also post message to bill + move.message_post( + body=( + f"

Bill.com Payment Failed

" + f"

Transaction: " + f"{transaction_number or 'N/A'}

" + f"

Errors:

" + f"
    {''.join([f'
  • {msg}
  • ' for msg in error_messages])}
" + ), + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + else: + _logger.warning( + "No bill IDs provided in payment.failed webhook for transaction %s", + transaction_number, + ) + + def _handle_autopay_failed(self, payment_data): + """Handle autopay.failed webhook + + Autopay failed has similar structure to payment.failed: + - bills: List of bills in the failed autopay request + - vendor: Vendor information + - errors: List of error messages + + Autopay failures are treated similarly to payment failures but with + specific messaging about the automatic payment feature. + """ + vendor_info = payment_data.get("vendor", {}) + errors = payment_data.get("errors", []) + transaction_number = payment_data.get("transactionNumber") + bills = payment_data.get("bills", []) + + error_messages = [ + f"{err.get('message', 'Unknown error')} ({err.get('code', 'N/A')})" + for err in errors + ] + error_text = "; ".join(error_messages) + + _logger.error( + "AutoPay failed for vendor %s (transaction: %s). Errors: %s", + vendor_info.get("name", "Unknown"), + transaction_number, + error_text, + ) + + # Find related payments by bill IDs (same logic as payment.failed) + if bills: + bill_ids = [bill.get("billId") for bill in bills if bill.get("billId")] + + if bill_ids: + # Find bills in Odoo + moves = ( + request.env["account.move"] + .sudo() + .search( + [ + "|", + ("billcom_id", "in", bill_ids), + ("billcom", "in", bill_ids), + ] + ) + ) + + for move in moves: + # Find related payments + payments = move.line_ids.filtered( + lambda line: line.account_id.account_type + in ("asset_receivable", "liability_payable") + ).mapped("matched_debit_ids.debit_move_id.payment_id") + + payments |= move.line_ids.filtered( + lambda line: line.account_id.account_type + in ("asset_receivable", "liability_payable") + ).mapped("matched_credit_ids.credit_move_id.payment_id") + + if hasattr(move, "payment_id") and move.payment_id: + payments |= move.payment_id + + for payment in payments.filtered( + lambda p: p.is_sync_to_billcom + and p.billcom_sync_status != "sync_failed" + ): + # Update payment status to failed + payment.with_context(skip_billcom_sync=True).write( + { + "billcom_payment_status": "failed", + "billcom_sync_status": "sync_failed", + "billcom_sync_error": f"AutoPay Failed: {error_text}", + "billcom_transaction_number": transaction_number or "", + } + ) + + # Post error message to payment chatter (with AutoPay context) + payment.message_post( + body=f"

Bill.com AutoPay Failed

" + f"

Vendor: " + f"{vendor_info.get('name', 'Unknown')}

" # noqa: E231 + f"

Transaction: " + f"{transaction_number or 'N/A'}

" # noqa: E231 + f"

Errors:

" # noqa: E231 + f"
    {''.join([f'
  • {msg}
  • ' for msg in error_messages])}
" + f"

Automatic payment failed. Please review autopay settings " + f"or manually process the payment.

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + # Create activity for payment owner + activity_user = payment.create_uid + if not activity_user: + invoicing_group = request.env.ref( + "account.group_account_invoice", + raise_if_not_found=False, + ) + if invoicing_group and invoicing_group.users: + activity_user = invoicing_group.users[0] + + if activity_user: + summary = ( + f"AutoPay failed: {vendor_info.get('name', 'Unknown')}" + ) + note = f"Bill.com automatic payment failed (transaction: \ + {transaction_number or 'N/A'})." + try: + payment.activity_schedule( + "mail.mail_activity_data_warning", + summary=summary, + note=note, + user_id=activity_user.id, + ) + _logger.info( + "Created activity for failed autopay %s (user: %s)", + payment.name, + activity_user.name, + ) + except Exception as e: + _logger.warning( + "Could not create activity for failed autopay %s: %s", + payment.name, + str(e), + ) + + _logger.info( + "Updated payment %s status to failed (AutoPay, Bill: %s)", + payment.name, + move.name, + ) + + # Post message to bill + move.message_post( + body=( + f"

Bill.com AutoPay Failed

" + f"

Transaction: " + f"{transaction_number or 'N/A'}

" + f"

Errors:

" + f"
    {''.join([f'
  • {msg}
  • ' for msg in error_messages])}
" + f"

Automatic payment failed." + f"Manual payment may be required.

" + ), + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + else: + _logger.warning( + "No bill IDs provided in autopay.failed webhook for transaction %s", + transaction_number, + ) + + def _handle_payment_updated(self, entity_id, payment_data): + """Handle payment.updated webhook + + Uses webhook data directly instead of making additional API call. + This improves performance and ensures data accuracy. + """ + payment_status = payment_data.get("status") + bill_ids = payment_data.get("billIds", []) + vendor_info = payment_data.get("vendor", {}) + funding = payment_data.get("funding", {}) + disbursement = payment_data.get("disbursement", {}) + + _logger.info( + "Payment %s updated - Status: %s, Vendor: %s, Bills: %s", + entity_id, + payment_status, + vendor_info.get("name"), + len(bill_ids), + ) + + # Log detailed payment information + if payment_status == "SCHEDULED": + arrives_by = disbursement.get("arrivesByDate") + _logger.info( + "Payment %s scheduled - Arrives by: %s, Amount: %s %s", + entity_id, + arrives_by, + funding.get("amount"), + funding.get("currency", "USD"), + ) + elif payment_status == "PROCESSED": + disbursement_account = disbursement.get("disbursementAccount", {}) + _logger.info( + "Payment %s processed - Disbursement type: %s, Account: %s", + entity_id, + disbursement_account.get("type"), + disbursement_account.get("accountNumber", "N/A"), + ) + + # Try to process with existing method if available + try: + result = ( + request.env["account.payment"] + .sudo() + .process_billcom_payment_webhook(payment_data) + ) + if result: + _logger.info( + "Successfully processed payment webhook for ID: %s", entity_id + ) + else: + _logger.warning( + "process_billcom_payment_webhook returned False for ID: %s", + entity_id, + ) + except AttributeError: + # Method doesn't exist yet, just log the webhook data + _logger.info( + "Payment webhook processed (logging only): %s - %s", + entity_id, + payment_status, + ) + + def _handle_card_account_webhook(self, event_type, entity_id, entity_data, config): + """Handle card-account-related webhook events + + Card account webhook includes (for BILL Spend Cards): + - id: Card account ID + - status: Status of the card account + - type: Card type + - cardNumber: Masked card number (last 4 digits) + - cardholder: Name on card + - expirationDate: Card expiration date + - default settings: Default for payables/receivables + + Events: + - card-account.created: New card account added + - card-account.updated: Card account modified (status, defaults, etc.) + """ + card_account_id = entity_data.get("id") + status = entity_data.get("status") + card_number = entity_data.get("cardNumber", "N/A") + cardholder = entity_data.get("cardholder", "Unknown") + expiration = entity_data.get("expirationDate", "N/A") + default_settings = entity_data.get("default", {}) + + _logger.info( + "Card Account webhook - Event: %s, ID: %s, Status: %s, Cardholder: %s, Card: %s", + event_type, + card_account_id, + status, + cardholder, + card_number, + ) + + if event_type == "card-account.created": + _logger.info( + "New card account created - Cardholder: %s, Card: %s, Expires: %s", + cardholder, + card_number, + expiration, + ) + + elif event_type == "card-account.updated": + _logger.info( + "Card account %s updated - Status: %s, Cardholder: %s", + card_account_id, + status, + cardholder, + ) + + # Log default settings changes + if default_settings.get("payables"): + _logger.info( + "Card account %s set as DEFAULT for PAYABLES (AP)", card_account_id + ) + if default_settings.get("receivables"): + _logger.info( + "Card account %s set as DEFAULT for RECEIVABLES (AR)", + card_account_id, + ) + + # TODO: Optionally sync to Odoo + # This would require: + # 1. Create billcom.card.account model to track cards + # 2. Store card account ID, masked number, status + # 3. Link to company/user + # 4. Track default settings + + def _handle_bank_account_webhook(self, event_type, entity_id, entity_data, config): + """Handle bank-account-related webhook events + + Bank account webhook includes: + - id: Bank account ID (starts with 'bac') + - status: NOT_VERIFIED, VERIFIED, PENDING, BLOCKED, EXPIRED, INVALID, UNDEFINED + - type: CHECKING or SAVINGS + - ownerType: BUSINESS or PERSONAL + - default.payables: Default for AP operations + - default.receivables: Default for AR operations + + Status Rules: + - VERIFIED + archived=false: Active verified account + - NOT_VERIFIED/PENDING/EXPIRED/BLOCKED + archived=true: Inactive account + """ + bank_account_id = entity_data.get("id") + status = entity_data.get("status") + archived = entity_data.get("archived", False) + account_number = entity_data.get("accountNumber", "N/A") + name_on_account = entity_data.get("nameOnAccount", "Unknown") + bank_name = entity_data.get("bankName", "Unknown") + account_type = entity_data.get("type", "CHECKING") + owner_type = entity_data.get("ownerType", "BUSINESS") + default_settings = entity_data.get("default", {}) + + _logger.info( + "Bank Account webhook - Event: %s, ID: %s, Status: %s, Archived: %s, Name: %s", + event_type, + bank_account_id, + status, + archived, + name_on_account, + ) + + if event_type == "bank-account.created": + _logger.info( + "New bank account created - Bank: %s, Type: %s, Owner: %s, Account: %s", + bank_name, + account_type, + owner_type, + account_number, + ) + + elif event_type == "bank-account.updated": + # Log status changes + if status == "VERIFIED" and not archived: + _logger.info( + "Bank account %s VERIFIED and activated - %s at %s", + bank_account_id, + name_on_account, + bank_name, + ) + elif status == "NOT_VERIFIED" and archived: + _logger.warning( + "Bank account %s verification FAILED - account archived", + bank_account_id, + ) + elif status in ("PENDING", "EXPIRED", "BLOCKED") and archived: + _logger.warning( + "Bank account %s status: %s - account archived", + bank_account_id, + status, + ) + + # Log default settings changes + if default_settings.get("payables"): + _logger.info( + "Bank account %s set as DEFAULT for PAYABLES (AP)", bank_account_id + ) + if default_settings.get("receivables"): + _logger.info( + "Bank account %s set as DEFAULT for RECEIVABLES (AR)", + bank_account_id, + ) + + # TODO: Optionally sync to Odoo res.partner.bank + # This would require: + # 1. Find/create partner bank account + # 2. Store Bill.com bank account ID + # 3. Update status and default settings + # 4. Handle archived state + + @http.route("/billcom/webhook", type="json", auth="none", csrf=False) + def webhook(self): # noqa: C901 + """Endpoint to receive webhooks from Bill.com + + Bill.com API v3 webhook format with metadata: + { + "metadata": { + "eventId": "unique-event-id", + "organizationId": "org-id", + "eventType": "bill.created", + "version": "1" + }, + "bill": { ... } + } + """ + webhook_log = None + try: + data = request.get_json_data() + _logger.info("Received Bill.com webhook: %s", json.dumps(data, indent=2)) + + # Extract webhook data (event type, organization ID, entity ID, etc.) + webhook_info = self._extract_webhook_data(data) + event_type = webhook_info["event_type"] + organization_id = webhook_info["organization_id"] + entity_id = webhook_info["entity_id"] + idempotency_key = webhook_info["idempotency_key"] + + # Get configuration by organization ID (not company) + config = self._get_config_from_organization_id(organization_id) + if not config: + error_msg = f"No active Bill.com configuration\ + found for organization {organization_id}" + _logger.error(error_msg) + return {"success": False, "error": error_msg} + + # Validate webhook signature if secret is configured + signature_valid = True + if config.webhook_secret: + # Bill.com uses x-bill-sha-signature header (official documentation) + signature = request.httprequest.headers.get("x-bill-sha-signature") + payload = request.httprequest.get_data() + signature_valid = self._validate_webhook_signature( + payload, signature, config.webhook_secret + ) + if not signature_valid: + _logger.error("Invalid webhook signature from Bill.com") + return {"success": False, "error": "Invalid signature"} + + # Check for idempotency - prevent duplicate processing + webhook_log_model = request.env["billcom.webhook.log"].sudo() + if webhook_log_model.check_duplicate(idempotency_key): + _logger.info( + "Duplicate webhook %s - already processed", idempotency_key + ) + return { + "success": True, + "message": f"Webhook {idempotency_key} already processed", + } + + # Create webhook log + webhook_log = webhook_log_model.log_webhook( + event_type=event_type, + entity_id=entity_id, + webhook_data=json.dumps(data), + signature_valid=signature_valid, + idempotency_key=idempotency_key, + ) + + if not webhook_log: + return {"success": True, "message": "Webhook already processed"} + + webhook_log.mark_processing() + + # Route to appropriate handler based on event type + if event_type.startswith("bill."): + self._handle_bill_webhook( + event_type, entity_id, webhook_info["entity_data"], config + ) + elif event_type.startswith("vendor."): + self._handle_vendor_webhook( + event_type, entity_id, webhook_info["entity_data"], config + ) + elif event_type.startswith("payment.") or event_type.startswith("autopay."): + self._handle_payment_webhook( + event_type, entity_id, webhook_info["entity_data"], config + ) + elif event_type.startswith("bank-account."): + self._handle_bank_account_webhook( + event_type, entity_id, webhook_info["entity_data"], config + ) + elif event_type.startswith("card-account."): + self._handle_card_account_webhook( + event_type, entity_id, webhook_info["entity_data"], config + ) + else: + error_msg = f"Unhandled event type: {event_type}" + _logger.warning(error_msg) + webhook_log.mark_error(error_msg) + return {"success": False, "error": error_msg} + + # Mark webhook as successfully processed + webhook_log.mark_success() + return {"success": True, "message": f"Successfully processed {event_type}"} + + except ValidationError as ve: + _logger.error("Validation error in webhook: %s", str(ve)) + if webhook_log: + webhook_log.mark_error(f"Validation error: {str(ve)}") + return self._handle_error(ve) + except UserError as ue: + _logger.error("User error in webhook: %s", str(ue)) + if webhook_log: + webhook_log.mark_error(f"User error: {str(ue)}") + return self._handle_error(ue) + except Exception as e: + _logger.error("Unexpected error in webhook: %s", str(e), exc_info=True) + if webhook_log: + webhook_log.mark_error(f"Unexpected error: {str(e)}") + return self._handle_error(e) + + @http.route("/billcom/api/sync", type="json", auth="user") + def sync(self): + """Sync data with Bill.com""" + try: + service = request.env["billcom.service"].sudo() + result = service.sync_all() + return {"success": True, "results": result} + except Exception as e: + return self._handle_error(e) diff --git a/billcom_integration/data/billcom_status_data.xml b/billcom_integration/data/billcom_status_data.xml new file mode 100644 index 00000000..c4c17b1a --- /dev/null +++ b/billcom_integration/data/billcom_status_data.xml @@ -0,0 +1,144 @@ + + + + + Paid + PAID + 10 + + + + Unpaid + UNPAID + 20 + + + + Partially Paid + PARTIALLY_PAID + 30 + + + + Scheduled + SCHEDULED + 40 + + + + In Process + IN_PROCESS + 50 + + + + Undefined + UNDEFINED + 60 + + + + + Unassigned + UNASSIGNED + 10 + + + + Assigned + ASSIGNED + 20 + + + + Approved + APPROVED + 30 + + + + Approving + APPROVING + 40 + + + + Denied + DENIED + 50 + + + + Undefined + UNDEFINED + 60 + + + + + Paid in Full + PAID_IN_FULL + 10 + + + + Open + OPEN + 20 + + + + Partial Payment + PARTIAL_PAYMENT + 30 + + + + Scheduled + SCHEDULED + 40 + + + + Undefined + UNDEFINED + 50 + + + + + Paid + PAID + 10 + + + + Void + VOID + 20 + + + + Scheduled + SCHEDULED + 30 + + + + Canceled + CANCELED + 40 + + + + Initiated + INITIATED + 50 + + + + Undefined + UNDEFINED + 60 + + diff --git a/billcom_integration/data/ir_cron_data.xml b/billcom_integration/data/ir_cron_data.xml new file mode 100644 index 00000000..423a0c92 --- /dev/null +++ b/billcom_integration/data/ir_cron_data.xml @@ -0,0 +1,139 @@ + + + + + + + Bill.com: Sync Vendors from Bill.com + + code + model.sync_partners_from_billcom(partner_type='vendor') + 2 + hours + -1 + + + + 15 + + + + + Bill.com: Sync Customers from Bill.com + + code + model.sync_partners_from_billcom(partner_type='customer') + 2 + hours + -1 + + + + 15 + + + + + + + Bill.com: Sync Bills from Bill.com + + code + model.sync_bills_from_billcom() + 30 + minutes + -1 + + + + 10 + + + + + + + Bill.com: Sync Invoices from Bill.com + + code + model.sync_invoices_from_billcom() + 30 + minutes + -1 + + + + 10 + + + + + + + Bill.com: Sync Payments from Bill.com + + code + model.sync_payments_from_billcom() + 15 + minutes + -1 + + + + 5 + + + + + Bill.com: Update Payment Status + + code + model.sync_payment_status() + 30 + minutes + -1 + + + + 8 + + + + diff --git a/billcom_integration/models/__init__.py b/billcom_integration/models/__init__.py new file mode 100644 index 00000000..b1c4e652 --- /dev/null +++ b/billcom_integration/models/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import billcom_abstract +from . import billcom_config +from . import billcom_service_abstract +from . import billcom_service +from . import billcom_logger +from . import billcom_sync_queue +from . import billcom_webhook_log +from . import billcom_funding_account +from . import billcom_document +from . import billcom_item +from . import billcom_status +from . import res_partner +from . import account_tax +from . import account_move +from . import account_payment +from . import account_payment_register +from . import billcom_payment_purpose diff --git a/billcom_integration/models/account_move.py b/billcom_integration/models/account_move.py new file mode 100644 index 00000000..885b2006 --- /dev/null +++ b/billcom_integration/models/account_move.py @@ -0,0 +1,1084 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class AccountMove(models.Model): + _name = "account.move" + _inherit = ["account.move", "billcom.abstract.model"] + + # Bill.com specific fields for bills/invoices + billcom_status = fields.Char( + string="Bill.com Status", + help="Payment status from Bill.com (PAID, UNPAID, PARTIALLY_PAID, etc.)", + readonly=True, + ) + billcom_document_ids = fields.One2many( + "billcom.document", + "bill_id", + string="Bill.com Documents", + help="Documents attached to this bill in Bill.com", + ) + billcom_invoice_number = fields.Char( + string="Bill.com Invoice Number", + ) + billcom_payment_link = fields.Char( + string="Bill.com Payment Link", + help="Payment link for customer to pay invoice online", + readonly=True, + copy=False, + ) + + active = fields.Boolean() + + def _prepare_bill_data(self, for_bulk=False): + """Prepare bill data for Bill.com API + + Args: + for_bulk: If True, includes additional fields required for bulk endpoint + """ + self.ensure_one() + if not self.is_sync_to_billcom or not self.partner_id.is_sync_to_billcom: + return False + + if self.move_type != "in_invoice": + return False + + # Get billcom.item model for tax mapping + item_model = self.env["billcom.item"] + + # Prepare line items + lines = [] + for line in self.invoice_line_ids: + line_data = { + "description": line.name or "", + "amount": line.price_total, # Tax included in amount + } + + # Add tax items if line has taxes + if line.tax_ids: + for tax in line.tax_ids: + # Get or create Bill.com item for this tax + tax_item_id = item_model.get_item_for_tax(tax) + if tax_item_id: + line_data["itemId"] = tax_item_id + _logger.info( + "Added tax item %s to bill line for tax %s", + tax_item_id, + tax.name, + ) + break # Use first tax item found + + lines.append(line_data) + + # Build bill data according to Bill.com API v3 format + bill_data = { + "vendorId": self.partner_id.billcom_id or self.partner_id.billcom, + "billLineItems": lines, + "invoice": { + "invoiceNumber": self.billcom_invoice_number or self.name or "", + "purchaseOrderNumber": self.invoice_origin or "", + "invoiceDate": ( + self.invoice_date.isoformat() if self.invoice_date else "" + ), + }, + } + + # Add dueDate if available + if self.invoice_date_due: + bill_data["dueDate"] = self.invoice_date_due.isoformat() + + # For bulk endpoint, add required fields + if for_bulk: + billcom_id = self.billcom_id or self.billcom + if not billcom_id: + return False # Bulk only supports existing bills + + bill_data["id"] = billcom_id + bill_data["archived"] = False # Default to not archived + + return bill_data + + def _prepare_invoice_data(self): + """Prepare invoice data for Bill.com API""" + self.ensure_one() + if not self.is_sync_to_billcom or not self.partner_id.is_sync_to_billcom: + return False + + if self.move_type != "out_invoice": + return False + + # Get billcom.item model for tax mapping + item_model = self.env["billcom.item"] + + # Prepare line items + lines = [] + for line in self.invoice_line_ids: + line_data = { + "description": line.name or "", + "quantity": line.quantity, + "price": line.price_unit, + } + + # Priority 1: include itemId if product has Bill.com reference + if ( + line.product_id + and hasattr(line.product_id, "billcom_id") + and line.product_id.billcom_id + ): + line_data["itemId"] = line.product_id.billcom_id + # Priority 2: Add tax items if line has taxes and no product item + elif line.tax_ids: + for tax in line.tax_ids: + # Get or create Bill.com item for this tax + tax_item_id = item_model.get_item_for_tax(tax) + if tax_item_id: + line_data["itemId"] = tax_item_id + _logger.info( + "Added tax item %s to invoice line for tax %s", + tax_item_id, + tax.name, + ) + break # Use first tax item found + + lines.append(line_data) + + # Build invoice data according to Bill.com API v3 format + # Only include required fields - API calculates totalAmount and assigns status + invoice_data = { + "customer": {"id": self.partner_id.billcom_id or self.partner_id.billcom}, + "invoiceLineItems": lines, + "invoiceNumber": self.name or "", + "processingOptions": {"sendEmail": False}, # Don't send email by default + } + + # Add dueDate if available + if self.invoice_date_due: + invoice_data["dueDate"] = self.invoice_date_due.isoformat() + + return invoice_data + + def _find_existing_billcom_document(self, endpoint, document_number): + """Search Bill.com for existing document by number + + Args: + endpoint: 'invoices' or 'bills' + document_number: The invoice/bill number to search for + + Returns: + Bill.com ID if found, None otherwise + """ + try: + # Build search parameters based on endpoint type + if endpoint == "invoices": + params = {"invoiceNumber": document_number} + elif endpoint == "bills": + params = {"invoiceNumber": document_number} + else: + return None + + # Search for existing document + result = self.env["billcom.service"]._make_request( + endpoint, method="GET", params=params + ) + + # Check if we found any results + if result and isinstance(result, list) and len(result) > 0: + return result[0].get("id") + elif result and isinstance(result, dict) and result.get("id"): + return result.get("id") + + return None + + except Exception as e: + _logger.warning( + "Error searching for existing %s with number %s: %s", + endpoint, + document_number, + str(e), + ) + return None + + def button_sync_to_billcom(self): + """Sync document(s) to Bill.com - supports single and bulk operations""" + # Handle multiple records with bulk endpoint + if len(self) > 1: + return self._sync_bulk_to_billcom() + + # Single record sync + self.ensure_one() + + if not self.is_sync_to_billcom or not self.partner_id.is_sync_to_billcom: + return False + + if self.move_type not in ["in_invoice", "out_invoice"]: + return False + + try: + endpoint = "" + data = {} + + if self.move_type == "in_invoice": + endpoint = "bills" + data = self._prepare_bill_data() + else: + endpoint = "invoices" + data = self._prepare_invoice_data() + + if not data: + return False + + # Determine if we should update or create + existing_id = self.billcom_id or self.billcom + + # If no ID in Odoo, check if document exists in Bill.com by number + if not existing_id: + existing_id = self._find_existing_billcom_document(endpoint, self.name) + if existing_id: + _logger.info( + "Found existing %s in Bill.com with number %s (ID: %s)", + endpoint, + self.name, + existing_id, + ) + + if existing_id: + # Update existing document + _logger.info( + "Updating existing %s with ID %s in Bill.com", endpoint, existing_id + ) + result = self.env["billcom.service"]._make_request( + f"{endpoint}/{existing_id}", method="PUT", data=data + ) + else: + # Create new document + _logger.info("Creating new %s in Bill.com", endpoint) + result = self.env["billcom.service"]._make_request( + endpoint, method="POST", data=data + ) + + if result and result.get("id"): + action = "updated" if existing_id else "created" + doc_type = "Bill" if self.move_type == "in_invoice" else "Invoice" + + self.with_context(skip_billcom_sync=True).write( + { + "billcom": result.get("id"), + "billcom_id": result.get("id"), + "last_sync_date": fields.Datetime.now(), + "billcom_sync_status": "synced", + "billcom_sync_error": False, + } + ) + _logger.info("Successfully synced %s with Bill.com", self.name) + + # Post success message to chatter + self.message_post( + body=f"

Bill.com Sync Successful

" + f"
    " + f"
  • Type: {doc_type}
  • " + f"
  • Action: {action.title()}
  • " + f"
  • Bill.com ID: {result.get('id')}
  • " + f"
  • Document Number: {self.name}
  • " + f"
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return result + + # Post error if no ID in response + error_msg = ( + f"Unexpected response format from Bill.com API. Response: {result}" + ) + self.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": error_msg, + } + ) + self.message_post( + body=f"

Bill.com Sync Failed

" + f"

Unexpected response format from Bill.com API

" + f"

Response: {result}

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + return False + except Exception as e: + error_detail = str(e) + + # Try to extract user-friendly error message + service = self.env["billcom.service"] + friendly_message = service._extract_friendly_error(e) + + _logger.error("Error syncing %s to Bill.com: %s", self.name, error_detail) + + # Set sync status to failed + self.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": friendly_message, + } + ) + + # Post detailed error to chatter + self.message_post( + body=f"

Bill.com Sync Error

" + f"

Failed to sync document to Bill.com

" + f"

Error:

" # noqa: E231 + f"
{friendly_message}
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + # Raise user-friendly error + raise UserError( + _("Failed to sync document to Bill.com:\n\n%s") % friendly_message + ) from e + + def _sync_bulk_to_billcom(self): # noqa: C901 + """Sync multiple documents to Bill.com + + - Bills (in_invoice) with billcom_id: Use bulk endpoint for updates + - Bills (in_invoice) without billcom_id: Create individually + - Invoices (out_invoice): Always sync individually (no bulk support) + """ + # Separate bills and invoices + bills = self.filtered(lambda m: m.move_type == "in_invoice") + invoices = self.filtered(lambda m: m.move_type == "out_invoice") + + total_success = 0 + total_errors = 0 + + # Process invoices individually (no bulk support) + if invoices: + _logger.info( + "Syncing %d invoice(s) individually (bulk not supported for invoices)...", + len(invoices), + ) + for invoice in invoices: + try: + invoice.button_sync_to_billcom() + total_success += 1 + except Exception as e: + total_errors += 1 + _logger.error("Error syncing invoice %s: %s", invoice.name, str(e)) + + if not bills: + # Only invoices were processed + if total_errors > 0: + raise UserError( + _( + "Invoice sync completed with errors:\n\n" + "✓ Succeeded: %(success)d\n" + "✗ Failed: %(errors)d" + ) + % {"success": total_success, "errors": total_errors} + ) + return True + + # Filter bills that should be synced + bills_to_sync = bills.filtered( + lambda b: b.is_sync_to_billcom and b.partner_id.is_sync_to_billcom + ) + + if not bills_to_sync: + if invoices: + # Already processed invoices + return True + raise UserError(_("No documents selected for synchronization to Bill.com")) + + # Separate existing bills (for bulk update) from new bills (for individual creation) + existing_bills = bills_to_sync.filtered(lambda b: b.billcom_id or b.billcom) + new_bills = bills_to_sync - existing_bills + + _logger.info( + "Processing %d bill(s): %d existing (bulk), %d new (individual)", + len(bills_to_sync), + len(existing_bills), + len(new_bills), + ) + + # Process new bills individually + if new_bills: + _logger.info("Creating %d new bill(s) individually...", len(new_bills)) + for bill in new_bills: + try: + bill.button_sync_to_billcom() + total_success += 1 + except Exception as e: + total_errors += 1 + _logger.error("Error creating bill %s: %s", bill.name, str(e)) + + # Process existing bills in bulk + if existing_bills: + try: + bulk_success, bulk_errors = self._sync_existing_bills_bulk( + existing_bills + ) + total_success += bulk_success + total_errors += bulk_errors + except Exception as e: + _logger.error("Error in bulk update: %s", str(e)) + total_errors += len(existing_bills) + + # Summary notification + _logger.info( + "Sync completed: %d succeeded, %d failed", total_success, total_errors + ) + + if total_errors > 0: + raise UserError( + _( + "Sync completed with errors:\n\n" + "✓ Succeeded: %(success)d\n" + "✗ Failed: %(errors)d\n\n" + "Check individual document notes for details." + ) + % {"success": total_success, "errors": total_errors} + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Sync Successful"), + "message": _("Successfully synced %d document(s) to Bill.com") + % total_success, + "type": "success", + "sticky": False, + }, + } + + def _sync_existing_bills_bulk(self, bills): + """Sync existing bills using bulk endpoint + + Args: + bills: Recordset of bills with billcom_id (existing in Bill.com) + + Returns: + tuple: (success_count, error_count) + """ + + _logger.info("Starting bulk update of %d existing bill(s)", len(bills)) + + # Prepare bulk data with required fields + bulk_data = [] + bill_mapping = {} # Map array index to bill record + + for idx, bill in enumerate(bills): + data = bill._prepare_bill_data(for_bulk=True) + if data: + bulk_data.append(data) + bill_mapping[idx] = bill + else: + _logger.warning( + "Bill %s skipped from bulk (missing billcom_id or invalid data)", + bill.name, + ) + + if not bulk_data: + _logger.warning("No valid bill data for bulk update") + return (0, len(bills)) + + try: + # Make bulk request + _logger.info("Sending %d bill(s) to Bill.com bulk endpoint", len(bulk_data)) + results = self.env["billcom.service"]._make_request( + "bills/bulk", method="POST", data=bulk_data + ) + + if not results or not isinstance(results, list): + _logger.error( + "Unexpected response from bulk API. Expected list, got: %s", + type(results), + ) + return (0, len(bulk_data)) + + # Process results + success_count = 0 + error_count = 0 + + for idx, result in enumerate(results): + bill = bill_mapping.get(idx) + if not bill: + continue + + try: + if result and result.get("id"): + # Successful sync + bill.with_context(skip_billcom_sync=True).write( + { + "billcom": result.get("id"), + "billcom_id": result.get("id"), + "last_sync_date": fields.Datetime.now(), + "billcom_sync_status": "synced", + "billcom_sync_error": False, + } + ) + + bill.message_post( + body=f"

Bill.com Bulk Update Successful

" + f"
    " + f"
  • Bill.com ID: {result.get('id')}
  • " + f"
  • Document Number: {bill.name}
  • " + f"
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + success_count += 1 + _logger.info("Successfully updated bill %s via bulk", bill.name) + else: + # Failed sync + error_msg = f"No ID in bulk response: {result}" + bill.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": error_msg, + } + ) + + bill.message_post( + body=f"

Bill.com Bulk Update Failed

" + f"

No ID returned in response

" + f"

Response: {result}

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + error_count += 1 + _logger.error( + "Failed to update bill %s: %s", bill.name, error_msg + ) + + except Exception as e: + error_msg = str(e) + bill.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": error_msg, + } + ) + + bill.message_post( + body=f"

Bill.com Bulk Update Error

" + f"

{error_msg}

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + error_count += 1 + _logger.error( + "Error processing bulk result for bill %s: %s", + bill.name, + error_msg, + ) + + return (success_count, error_count) + + except Exception as e: + error_detail = str(e) + service = self.env["billcom.service"] + friendly_message = service._extract_friendly_error(e) + + _logger.error("Error in bulk update: %s", error_detail) + + # Mark all bills as failed + for bill in bills: + bill.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": friendly_message, + } + ) + + return (0, len(bills)) + + @api.model + def _sync_documents_cron(self): + """Cron job to sync documents from Bill.com""" + try: + config = ( + self.env["billcom.config"] + .sudo() + .search( + [("active", "=", True), ("company_id", "=", self.env.company.id)], + limit=1, + ) + ) + if not config or not config.auto_sync_enabled: + _logger.info( + "Automatic sync is disabled or no active configuration found" + ) + return + + service = self.env["billcom.service"].sudo() + # Sync both invoices and bills + service.sync_invoices() + service.sync_bills() + except Exception as e: + _logger.error("Error in document sync cron: %s", str(e)) + + # def write(self, vals): + # """Override write to sync changes to Bill.com""" + # res = super().write(vals) + # if self.name == '/' or self.env.context.get("skip_billcom_sync"): + # return res + + # for record in self: + # # Only sync if record was created in Odoo (not from Bill.com) + # # Records from Bill.com have billcom_id set + # if ( + # record.is_sync_to_billcom + # and record.partner_id.is_sync_to_billcom + # and record.move_type in ["in_invoice", "out_invoice"] + # and not record.billcom_id # Skip if already synced from Bill.com + # ): + # try: + # record.with_context(skip_billcom_sync=True).button_sync_to_billcom() + # except Exception as e: + # _logger.error("Error syncing document to Bill.com: %s", str(e)) + + def button_sync_attachments_to_billcom(self): + """Create billcom.document records from existing ir.attachment records + + This button finds all attachments linked to this bill and creates + corresponding billcom.document records that can be uploaded to Bill.com + """ + self.ensure_one() + + if self.move_type != "in_invoice": + raise UserError(_("This action is only available for vendor bills")) + + # Find all attachments for this bill + attachments = self.env["ir.attachment"].search( + [ + ("res_model", "=", "account.move"), + ("res_id", "=", self.id), + ] + ) + + if not attachments: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("No Attachments Found"), + "message": _("This bill has no attachments to sync"), + "type": "info", + "sticky": False, + }, + } + + # Create billcom.document records for each attachment + created_count = 0 + existing_count = 0 + + for attachment in attachments: + try: + document = self.env["billcom.document"].create_from_attachment( + attachment + ) + if document: + # Check if it was newly created or already existed + if document.create_date == fields.Datetime.now(): + created_count += 1 + else: + existing_count += 1 + except Exception as e: + _logger.warning( + "Failed to create document from attachment %d: %s", + attachment.id, + str(e), + ) + + # Post to chatter + if created_count > 0 or existing_count > 0: + self.message_post( + body=f"

Attachments Synced to Bill.com Documents

" + f"
    " + f"
  • New documents created: {created_count}
  • " + f"
  • Documents already existed: {existing_count}
  • " + f"
  • Total attachments: {len(attachments)}
  • " + f"
" + f"

Use 'Upload to Bill.com' button on each\ + document to complete upload

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Attachments Synced"), + "message": _( + "Created %(created)d new Bill.com document(s) from %(total)d attachment(s)" + ) + % {"created": created_count, "total": len(attachments)}, + "type": "success", + "sticky": False, + }, + } + + def button_send_invoice_payment_reminder(self): + """Send invoice payment reminder to customer via Bill.com + + This button: + 1. Validates the invoice is ready (posted, synced, has customer email) + 2. Gets payment link from Bill.com API + 3. Sends payment reminder email via Bill.com + 4. Stores payment link in invoice record + """ + self.ensure_one() + + # Validation: Must be customer invoice + if self.move_type != "out_invoice": + raise UserError( + _("This action is only available for customer invoices (out_invoice)") + ) + + # Validation: Must be posted + if self.state != "posted": + raise UserError(_("Invoice must be posted before sending payment reminder")) + + # Validation: Must be synced to Bill.com + if not self.billcom_id and not self.billcom: + raise UserError( + _( + "Invoice must be synced to Bill.com first.\n\n" + "Please click 'Sync to Bill.com' button to sync this invoice." + ) + ) + + # Validation: Must not be fully paid + if self.payment_state == "paid": + raise UserError( + _( + "Cannot send payment reminder for fully paid invoice.\n\n" + "Payment Status: %s" + ) + % self.payment_state + ) + + # Validation: Partner must have email + if not self.partner_id.email: + raise UserError( + _( + "Customer must have an email address configured.\n\n" + "Customer: %s\n" + "Please add an email address to this customer's contact information." + ) + % self.partner_id.name + ) + + # Validation: Partner must be synced to Bill.com + if not self.partner_id.billcom_id and not self.partner_id.billcom: + raise UserError( + _( + "Customer must be synced to Bill.com first.\n\n" + "Customer: %s\n" + "Please sync the customer to Bill.com before sending payment reminder." + ) + % self.partner_id.name + ) + + try: + service = self.env["billcom.service"] + billcom_invoice_id = self.billcom_id or self.billcom + billcom_customer_id = self.partner_id.billcom_id or self.partner_id.billcom + customer_email = self.partner_id.email + + _logger.info( + "Sending payment reminder for invoice %s (Bill.com ID: %s)", + self.name, + billcom_invoice_id, + ) + + # Step 1: Get payment link from Bill.com + payment_link = service.get_invoice_payment_link( + invoice_id=billcom_invoice_id, + customer_id=billcom_customer_id, + customer_email=customer_email, + ) + + # Step 2: Send invoice email via Bill.com + # Bill.com will use default customer email if not specified + service.send_invoice_email( + invoice_id=billcom_invoice_id, recipient_emails=[customer_email] + ) + + # Step 3: Store payment link in invoice record + self.with_context(skip_billcom_sync=True).write( + {"billcom_payment_link": payment_link} + ) + + # Post success message to chatter + self.message_post( + body=f"

Payment Reminder Sent Successfully

" # noqa: E231 + f"
    " # noqa: E231 + f"
  • Customer: {self.partner_id.name}
  • " # noqa: E231 + f"
  • Email: {customer_email}
  • " # noqa: E231 + f"
  • Invoice: {self.name}
  • " # noqa: E231 + f"
  • Amount Due:\ + {self.currency_id.symbol}{self.amount_residual:.2f}
  • " # noqa: E231, E501 + f"
" # noqa: E231 + f"

Payment Link:

" # noqa: E231 + f"

{payment_link}

", # noqa: E231, E501 + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + _logger.info("Successfully sent payment reminder for invoice %s", self.name) + + # Return success notification + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Payment Reminder Sent"), + "message": _("Payment reminder email sent successfully to %s") + % customer_email, + "type": "success", + "sticky": False, + }, + } + + except Exception as e: + error_detail = str(e) + + # Extract user-friendly error message + service = self.env["billcom.service"] + friendly_message = service._extract_friendly_error(e) + + _logger.error( + "Error sending payment reminder for invoice %s: %s", + self.name, + error_detail, + ) + + # Post error to chatter + self.message_post( + body=f"

Payment Reminder Failed

" # noqa: E231 + f"

Failed to send payment reminder via Bill.com

" # noqa: E231 + f"

Error:

" # noqa: E231 + f"
{friendly_message}
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + # Raise user-friendly error + raise UserError( + _("Failed to send payment reminder:\n\n%s") % friendly_message + ) from e + + @api.model + def sync_from_billcom(self, billcom_id): # noqa: C901 + """Sync a bill/invoice from Bill.com by ID (called by webhook) + + Args: + billcom_id (str): Bill.com ID of the document to sync + + Returns: + bool: True if successful, False otherwise + """ + if not billcom_id: + _logger.error("No Bill.com ID provided for sync") + return False + + move = False + move_type = False + + try: + # Search for existing document with this Bill.com ID + move = self.search( + ["|", ("billcom_id", "=", billcom_id), ("billcom", "=", billcom_id)], + limit=1, + ) + + # Determine if it's a bill or invoice by trying both endpoints + service = self.env["billcom.service"].sudo() + document_data = None + endpoint = None + + # Try bills endpoint first + try: + document_data = service._make_request( + f"bills/{billcom_id}", method="GET" + ) + if document_data and document_data.get("id"): + endpoint = "bills" + move_type = "in_invoice" + except Exception as e: + _logger.debug( + "Document %s not found in bills endpoint: %s", billcom_id, str(e) + ) + + # If not a bill, try invoices endpoint + if not document_data: + try: + document_data = service._make_request( + f"invoices/{billcom_id}", method="GET" + ) + if document_data and document_data.get("id"): + endpoint = "invoices" + move_type = "out_invoice" + except Exception as e: + _logger.debug( + "Document %s not found in invoices endpoint: %s", + billcom_id, + str(e), + ) + + if not document_data: + _logger.error( + "Could not fetch document %s from\ + Bill.com - tried both bills and\ + invoices endpoints", + billcom_id, + ) + return False + + _logger.info("Syncing %s %s from Bill.com", endpoint, billcom_id) + + # Extract partner information + partner_id = None + if endpoint == "bills": + vendor_id = document_data.get("vendorId") + if vendor_id: + partner = ( + self.env["res.partner"] + .sudo() + .search( + [ + "|", + ("billcom_id", "=", vendor_id), + ("billcom", "=", vendor_id), + ], + limit=1, + ) + ) + if partner: + partner_id = partner.id + elif endpoint == "invoices": + customer_id = document_data.get("customerId") + if customer_id: + partner = ( + self.env["res.partner"] + .sudo() + .search( + [ + "|", + ("billcom_id", "=", customer_id), + ("billcom", "=", customer_id), + ], + limit=1, + ) + ) + if partner: + partner_id = partner.id + + if not partner_id: + _logger.warning("Partner not found for %s %s", endpoint, billcom_id) + return False + + # Prepare values for create/update + invoice_info = document_data.get("invoice", {}) + vals = { + "move_type": move_type, + "partner_id": partner_id, + "billcom_id": billcom_id, + "billcom": billcom_id, + "billcom_status": document_data.get("paymentStatus"), + "ref": invoice_info.get("invoiceNumber", ""), + "invoice_origin": invoice_info.get("purchaseOrderNumber", ""), + "invoice_date": invoice_info.get("invoiceDate"), + "invoice_date_due": document_data.get("dueDate"), + "last_sync_date": fields.Datetime.now(), + } + + # Create or update the move + if move: + # Update existing move + move.with_context(skip_billcom_sync=True).write(vals) + _logger.info("Updated existing %s in Odoo: %s", endpoint, move.name) + action = "updated" + else: + # Create new move + vals["is_sync_to_billcom"] = False # Prevent sync back to Bill.com + move = self.with_context(skip_billcom_sync=True).create(vals) + _logger.info("Created new %s in Odoo: %s", endpoint, move.name) + action = "created" + + # Apply status mapping from Bill.com to Odoo state + if endpoint == "bills": + billcom_payment_status = document_data.get("paymentStatus", "UNDEFINED") + target_state = service._map_billcom_bill_status_to_odoo_state( + billcom_payment_status + ) + else: # invoices + billcom_status = document_data.get("status", "UNDEFINED") + target_state = service._map_billcom_invoice_status_to_odoo_state( + billcom_status + ) + + if target_state == "posted" and move.state == "draft": + # Post the move if Bill.com status requires it + try: + move.with_context(skip_billcom_sync=True).action_post() + _logger.info( + "Posted %s %s based on Bill.com status (webhook sync)", + endpoint, + move.name, + ) + except Exception as e: + _logger.warning( + "Could not post %s %s from Bill.com status (webhook): %s", + endpoint, + move.name, + e, + ) + + # Post success message to chatter + doc_type = "Bill" if endpoint == "bills" else "Invoice" + move.message_post( + body=f"

Synced from Bill.com

" + f"
    " + f"
  • Type: {doc_type}
  • " + f"
  • Action: {action.title()}
  • " + f"
  • Bill.com ID: {billcom_id}
  • " + f"
  • Source: Webhook
  • " + f"
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return True + + except Exception as e: + error_detail = str(e) + _logger.error( + "Error syncing %s from Bill.com: %s", billcom_id, error_detail + ) + + # Try to post error to existing move if found + if move: + move.message_post( + body=f"

Bill.com Sync Error

" + f"

Failed to sync from Bill.com

" + f"

Bill.com ID: {billcom_id}

" # noqa: E231 + f"

Error: {error_detail}

", # noqa: E231 + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return False diff --git a/billcom_integration/models/account_payment.py b/billcom_integration/models/account_payment.py new file mode 100644 index 00000000..e9cb51bd --- /dev/null +++ b/billcom_integration/models/account_payment.py @@ -0,0 +1,1023 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import time +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class AccountPayment(models.Model): + _name = "account.payment" + _inherit = ["account.payment", "billcom.abstract.model"] + + billcom_funding_account_type = fields.Selection( + selection=[ + ("BANK_ACCOUNT", "Bank Account"), + ("CARD_ACCOUNT", "Credit/Debit Card"), + ("WALLET", "BILL Balance"), + ("AP_CARD", "AP Card"), + ], + string="Funding Account Type", + default="BANK_ACCOUNT", + help="Type of funding account for Bill.com payment", + ) + billcom_process_date = fields.Date( + string="Process Date", + help=( + "Date when Bill.com will process this payment (format: YYYY-MM-DD). " + "Required for WALLET and AP_CARD funding types. " + "For new vendor bank accounts, must be at least 2 business days from today." + ), + ) + is_process_date_sync = fields.Boolean(compute="_compute_process_sync_date") + billcom_pay_faster = fields.Boolean( + string="Pay Faster", + default=False, + help="Enable Pay Faster for expedited payment delivery", + ) + billcom_check_delivery_type = fields.Selection( + selection=[ + ("STANDARD", "Standard"), + ("RTP_DELIVERY", "Real-Time Payment (ACH)"), + ("UPS_1DAY", "UPS 1-Day Delivery"), + ("UPS_2DAY", "UPS 2-Days Delivery"), + ("UPS_3DAY", "UPS 3-Days Delivery"), + ("USPS_PRIORITY", "USPS Priority"), + ], + string="Check Delivery Type", + default="STANDARD", + help="Delivery method for check payments", + ) + billcom_payment_status = fields.Selection( + [ + ("draft", "Draft"), + ("scheduled", "Scheduled"), + ("processing", "Processing"), + ("processed", "Processed"), + ("sent", "Sent"), + ("canceled", "Canceled"), + ("failed", "Failed"), + ], + string="Bill.com Payment Status", + readonly=True, + copy=False, + default="draft", + ) + is_international_payment = fields.Boolean( + string="International Payment", + compute="_compute_is_international_payment", + store=True, + ) + billcom_confirmation_number = fields.Char( + string="Bill.com Confirmation Number", readonly=True, copy=False + ) + billcom_transaction_number = fields.Char( + string="Bill.com Transaction Number", readonly=True, copy=False + ) + billcom_exchange_rate = fields.Float( + string="Exchange Rate", digits=(16, 6), readonly=True + ) + billcom_funding_amount = fields.Monetary( + string="Funding Amount (USD)", readonly=True + ) + reconciled_bill_ids = fields.Many2many( + comodel_name="account.move", + relation="account_payment_bill_rel", + column1="payment_id", + column2="bill_id", + string="Reconciled Bills", + help="Bills reconciled with this payment for Bill.com sync", + domain="[('move_type', '=', 'in_invoice')]", + ) + + @api.depends("partner_bank_id.billcom_last_sync_date") + def _compute_process_sync_date(self): + for rec in self: + if self.partner_bank_id.billcom_last_sync_date: + diff = ( + fields.Datetime.today() + - self.partner_bank_id.billcom_last_sync_date + ) + rec.is_process_date_sync = diff >= timedelta(days=1) + else: + rec.is_process_date_sync = True + + @api.depends("partner_id", "partner_id.country_id") + def _compute_is_international_payment(self): + """Determine if payment is international based on vendor country""" + company_country = self.env.company.country_id + for payment in self: + if payment.partner_id and payment.partner_id.country_id: + payment.is_international_payment = ( + payment.partner_id.country_id.id != company_country.id + ) + else: + payment.is_international_payment = False + + def _calculate_business_days_ahead(self, days=2): + """Calculate a date N business days from today (excluding weekends) + + Args: + days (int): Number of business days to add (default: 2) + + Returns: + date: Date N business days from today + + Note: This is a simple calculation that only excludes weekends. + For accurate business day calculation with holidays, consider using + a proper business day calendar library. + """ + current_date = fields.Date.today() + business_days_added = 0 + + while business_days_added < days: + current_date += timedelta(days=1) + # Skip weekends (Saturday=5, Sunday=6) + if current_date.weekday() < 5: + business_days_added += 1 + + return current_date + + def action_set_process_date_for_new_vendor(self): + """Set process date to 2 business days ahead for new vendor bank accounts + + Use this action when paying a vendor for the first time with a bank account, + as Bill.com requires 2 business days for verification. + """ + self.ensure_one() + min_process_date = self._calculate_business_days_ahead(days=2) + self.billcom_process_date = min_process_date + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Process Date Updated"), + "message": _( + "Process date set to %s (2 business days ahead for new vendor verification)" + ) + % min_process_date, + "type": "success", + "sticky": False, + }, + } + + def _prepare_payment_data(self): # noqa: C901 + """Prepare payment data for Bill.com API""" + self.ensure_one() + if not self.is_sync_to_billcom or not self.partner_id.is_sync_to_billcom: + return False + + if self.payment_type != "outbound" or self.partner_type != "supplier": + return False + + # Get the bill ID if this payment is linked to a bill + bill_id = False + if self.reconciled_bill_ids: + for bill in self.reconciled_bill_ids: + if bill.billcom_id or bill.billcom: + bill_id = bill.billcom_id or bill.billcom + break + + # Get funding account - REQUIRED for payment + funding_account = False + funding_account_id = False + + # Try to get funding account from journal's bank account + if ( + self.journal_id.bank_account_id + and self.journal_id.bank_account_id.billcom_funding_account_id + ): + funding_account = self.journal_id.bank_account_id.billcom_funding_account_id + funding_account_id = funding_account.billcom_id + _logger.info( + "Using funding account from journal: %s (%s)", + funding_account.name, + funding_account_id, + ) + # If no funding account configured, try to get the default one + if not funding_account_id: + _logger.info( + "No funding account configured in journal, attempting to use default" + ) + + # Search for default payables funding account in database + funding_account = self.env["billcom.funding.account"].search( + [ + ("is_default_payables", "=", True), + ("status", "=", "VERIFIED"), + ("company_id", "=", self.env.company.id), + ], + limit=1, + ) + + if funding_account: + funding_account_id = funding_account.billcom_id + _logger.info( + "Using default payables funding account: %s (%s)", + funding_account.name, + funding_account_id, + ) + + # Validate funding account configuration + funding_type = self.billcom_funding_account_type or "BANK_ACCOUNT" + + # For WALLET type, id is not required + if funding_type != "WALLET" and not funding_account_id: + raise UserError( + _( + "No Bill.com funding account configured. " + "Please link a funding account in the journal's bank account, " + "or sync funding accounts from Bill.com (Configuration > Funding Accounts)." + ) + ) + + # Build fundingAccount object + funding_account_data = {"type": funding_type} + if funding_type != "WALLET": + funding_account_data["id"] = funding_account_id + + # Determine process date + # WALLET and AP_CARD types REQUIRE processDate + # BANK_ACCOUNT and CHECK: DO NOT send processDate (Bill.com sets it automatically) + # Format: "YYYY-MM-DD" (e.g., "2025-12-31") + # + # Important: Bill.com automatically calculates next available payment date + # considering bank verification times (2 business days for new accounts) + + process_date = None + requires_process_date = funding_type in ["WALLET", "AP_CARD"] + + # ONLY process date for WALLET and AP_CARD (required) + # For BANK_ACCOUNT and CHECK, let Bill.com set the date automatically + if requires_process_date or not self.is_process_date_sync: + # Use user-specified date or calculate default + if self.billcom_process_date: + date_obj = self.billcom_process_date + else: + # For WALLET/AP_CARD, default to today + # For new vendor bank accounts, should be +2 business days but we'll + # let Bill.com validate this (they return error if too soon) + date_obj = fields.Date.today() + + # Convert to string format "YYYY-MM-DD" + # CRITICAL: Bill.com requires exact format "YYYY-MM-DD" + if isinstance(date_obj, str): + # Already a string, validate format + process_date = date_obj + elif hasattr(date_obj, "strftime"): + # Date/datetime object, convert to string + process_date = date_obj.strftime("%Y-%m-%d") + else: + # Use Odoo's date conversion + process_date = fields.Date.to_string(date_obj) + + # Validate the format + if not process_date or not isinstance(process_date, str): + raise UserError( + _( + "Invalid process date format. Expected YYYY-MM-DD string, got: %s" + ) + % str(process_date) + ) + + _logger.info( + "Payment processDate set to '%s' (type: %s) " + "(funding_type=%s, required=%s)", + process_date, + type(process_date).__name__, + funding_type, + requires_process_date, + ) + # Determine if we should create a bill or pay an existing one + # If there's a linked bill with Bill.com ID, we pay it + # Otherwise, Bill.com will create the bill automatically + create_bill = not bill_id + + # Build payment data according to Bill.com API v3 format + payment_data = { + "vendorId": self.partner_id.billcom_id or self.partner_id.billcom, + "amount": self.amount, + "fundingAccount": funding_account_data, + "processingOptions": { + "createBill": create_bill, + "requestPayFaster": self.billcom_pay_faster or False, + "requestCheckDeliveryType": self.billcom_check_delivery_type + or "STANDARD", + }, + } + + # Add processDate if set (CRITICAL: Must be string in YYYY-MM-DD format) + if process_date and isinstance(process_date, str) and len(process_date) == 10: + payment_data["processDate"] = process_date + _logger.info(f"Adding processDate to payment payload: '{process_date}'") + elif requires_process_date: + # WALLET and AP_CARD REQUIRE processDate + raise UserError( + _( + "Process date is required for %s funding type but was not set correctly. " + "Please set a valid process date (YYYY-MM-DD format)." + ) + % funding_type + ) + + # Add bill ID if available (only when createBill is False) + if bill_id: + payment_data["billId"] = bill_id + + _logger.info( + "Preparing payment: createBill=%s, billId=%s, vendor=%s, amount=%s", + create_bill, + bill_id or "None", + self.partner_id.name, + self.amount, + ) + + # Add optional description + if self.ref: + payment_data["description"] = self.ref + + # Add international payment options if needed + if self.is_international_payment: + payment_data["internationalOptions"] = { + "paymentCurrency": self.currency_id.name, + } + # Add wire instructions if available + if self.partner_id.bank_ids and self.partner_id.bank_ids[0].bank_id.bic: + payment_data["internationalOptions"][ + "wireInstructions" + ] = self.partner_id.bank_ids[0].bank_id.bic + + # Final validation and logging + _logger.info("=" * 80) + _logger.info("FINAL PAYMENT DATA TO BILL.COM:") + _logger.info(f"Payment: {self.name}") + _logger.info(f"Vendor: {self.partner_id.name}") + _logger.info(f"Amount: {self.amount}") + _logger.info(f"Funding Type: {funding_type}") + _logger.info(f"Process Date: {payment_data.get('processDate', 'NOT SET')}") + _logger.info(f"Full payload: {payment_data}") + _logger.info("=" * 80) + + return payment_data + + def button_sync_to_billcom(self): + """Sync payment to Bill.com""" + self.ensure_one() + if not self.is_sync_to_billcom or not self.partner_id.is_sync_to_billcom: + return False + + if self.payment_type != "outbound" or self.partner_type != "supplier": + return False + + try: + # Prepare payment data + payment_data = self._prepare_payment_data() + if not payment_data: + return False + + # Make API request + if self.billcom_id or self.billcom: + # For existing payments, we can only get the status + # Bill.com API v3 doesn't support updating payments via PUT + payment_id = self.billcom_id or self.billcom + result = self.env["billcom.service"]._make_request( + f"payments/{payment_id}", method="GET" + ) + + # Log that we can't update the payment + _logger.info( + "Payment %s already exists in Bill.com (ID: %s). " + "Bill.com API does not support updating existing payments.", + self.name, + payment_id, + ) + + # Update local status from Bill.com + if result and result.get("id"): + self.with_context(skip_billcom_sync=True).write( + { + "billcom_payment_status": self._map_billcom_status( + result.get("singleStatus") + ), + "last_sync_date": fields.Datetime.now(), + } + ) + + # Post info message to chatter + self.message_post( + body=( + f"

Bill.com Payment Status Updated

" + f"
    " + f"
  • Bill.com ID: {payment_id}
  • " + f"
  • Status: {result.get('singleStatus')}
  • " + f"
  • Note: Payment already exists in Bill.com. " + f"API does not support updates.
  • " + f"
" + ), + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + else: + # Create new payment + result = self.env["billcom.service"]._make_request( + "payments", method="POST", data=payment_data + ) + + if result and result.get("id"): + is_new = not (self.billcom_id or self.billcom) + + # Update payment with Bill.com data + update_vals = { + "billcom": result.get("id"), + "billcom_id": result.get("id"), + "last_sync_date": fields.Datetime.now(), + "billcom_payment_status": self._map_billcom_status( + result.get("singleStatus") + ), + "billcom_confirmation_number": result.get("confirmationNumber", ""), + "billcom_transaction_number": result.get("transactionNumber", ""), + "billcom_sync_status": "synced", + "billcom_sync_error": False, # Clear any previous error + } + + # Update exchange rate and funding amount for international payments + if result.get("exchangeRate"): + update_vals["billcom_exchange_rate"] = result.get("exchangeRate") + if result.get("fundingAmount"): + update_vals["billcom_funding_amount"] = result.get("fundingAmount") + + self.with_context(skip_billcom_sync=True).write(update_vals) + _logger.info("Successfully synced payment %s with Bill.com", self.name) + + # Post success message to chatter (only for new payments) + if is_new: + self.message_post( + body=f"

Bill.com Payment Created

" + f"
    " + f"
  • Bill.com ID: {result.get('id')}
  • " + f"
  • Status: {result.get('singleStatus')}
  • " + f"
  • Confirmation #: {result.get('confirmationNumber', 'N/A')}
  • " + f"
  • Transaction #: {result.get('transactionNumber', 'N/A')}
  • " + f"
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return result + + # Post error if no ID in response + error_msg = ( + f"Unexpected response format from Bill.com API. Response: {result}" + ) + self.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": error_msg, + } + ) + self.message_post( + body=f"

Bill.com Payment Sync Failed

" + f"

Unexpected response format from Bill.com API

" + f"

Response: {result}

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + return False + except Exception as e: + error_detail = str(e) + + # Try to extract user-friendly error message + service = self.env["billcom.service"] + friendly_message = service._extract_friendly_error(e) + + _logger.error( + "Error syncing payment %s to Bill.com: %s", self.name, error_detail + ) + + # Set sync status to failed + self.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": friendly_message, + } + ) + + # Post detailed error to chatter + self.message_post( + body=f"

Bill.com Payment Sync Error

" + f"

Failed to sync payment to Bill.com

" + f"

Error:

" # noqa: E231 + f"
{friendly_message}
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + # Raise user-friendly error + raise UserError( + _("Failed to sync payment to Bill.com:\n\n%s") % friendly_message + ) from e + + def _map_billcom_status(self, billcom_status): + """Map Bill.com payment status to Odoo status""" + status_map = { + "SCHEDULED": "scheduled", + "PROCESSING": "processing", + "PROCESSED": "processed", + "SENT": "sent", + "CANCELED": "canceled", + "FAILED": "failed", + } + return status_map.get(billcom_status, "draft") + + # def write(self, vals): + # """Override write to sync changes to Bill.com""" + # res = super().write(vals) + # if self.env.context.get("skip_billcom_sync"): + # return res + + # for record in self: + # # Only sync if payment was created in Odoo (not from Bill.com) + # # Payments from Bill.com have billcom_id set + # if ( + # record.is_sync_to_billcom + # and record.partner_id.is_sync_to_billcom + # and record.payment_type == "outbound" + # and record.partner_type == "supplier" + # and not record.billcom_id # Skip if already synced from Bill.com + # ): + # try: + # record.with_context(skip_billcom_sync=True).button_sync_to_billcom() + # except Exception as e: + # _logger.error("Error syncing payment to Bill.com: %s", str(e)) + + # return res + + def action_get_payment_status(self): + """Get payment status from Bill.com""" + self.ensure_one() + payment_id = self.billcom_id or self.billcom + if not payment_id: + raise UserError(_("This payment has not been synced with Bill.com yet.")) + + try: + result = self.env["billcom.service"]._make_request( + f"payments/{payment_id}", method="GET" + ) + + if result and result.get("id"): + # Update payment with Bill.com data + self.with_context(skip_billcom_sync=True).write( + { + "billcom_payment_status": self._map_billcom_status( + result.get("singleStatus") + ), + "last_sync_date": fields.Datetime.now(), + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": _("Payment status updated from Bill.com: %s") + % result.get("singleStatus"), + "type": "success", + "sticky": False, + }, + } + + return False + except Exception as e: + _logger.error("Error getting payment status from Bill.com: %s", str(e)) + raise UserError( + _("Error getting payment status from Bill.com: %s") % str(e) + ) from e + + def action_cancel_billcom_payment(self): + """Cancel payment in Bill.com""" + self.ensure_one() + payment_id = self.billcom_id or self.billcom + if not payment_id: + raise UserError(_("This payment has not been synced with Bill.com yet.")) + + if self.billcom_payment_status not in ["draft", "scheduled"]: + raise UserError(_("Only draft or scheduled payments can be canceled.")) + + try: + result = self.env["billcom.service"]._make_request( + f"payments/{payment_id}/cancel", method="POST" + ) + + if result and result.get("id"): + # Update payment with Bill.com data + self.with_context(skip_billcom_sync=True).write( + { + "billcom_payment_status": "canceled", + "last_sync_date": fields.Datetime.now(), + } + ) + # Cancel payment + self.action_cancel() + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": _("Payment successfully canceled in Bill.com"), + "type": "success", + "sticky": False, + }, + } + + return False + except Exception as e: + _logger.error("Error canceling payment in Bill.com: %s", str(e)) + raise UserError( + _("Error canceling payment in Bill.com: %s") % str(e) + ) from e + + @api.model + def update_billcom_payment_status(self): + """Update payment status from Bill.com for all pending payments + This method is called by the scheduled action + + Enhanced with: + - Retry logic for failed API calls + - Better error handling and logging + - Prioritization based on payment age + """ + # Get configuration for sync settings + try: + config = self.env["billcom.config"].sudo().get_config() + if not config.sync_payments: + _logger.info("Payment synchronization is disabled in configuration") + return False + + max_retries = ( + config.api_max_retries if hasattr(config, "api_max_retries") else 3 + ) + retry_delay = ( + config.api_retry_delay if hasattr(config, "api_retry_delay") else 5 + ) + except Exception as e: + _logger.error("Error getting Bill.com configuration: %s", str(e)) + return False + + # Get all payments that have been synced with Bill.com and are not in a final state + # Order by last_sync_date to prioritize payments that haven't been updated recently + payments = self.search( + [ + ("billcom", "!=", False), + ( + "billcom_payment_status", + "not in", + ["processed", "sent", "canceled", "failed"], + ), + ], + order="last_sync_date asc, create_date asc", + ) + + if not payments: + _logger.info("No pending payments found for status update") + return True + + _logger.info("Found %s payments to update status from Bill.com", len(payments)) + + updated_count = 0 + error_count = 0 + skipped_count = 0 + + for payment in payments: + # Skip payments updated recently (within last hour) unless in processing state + if ( + payment.last_sync_date + and payment.billcom_payment_status != "processing" + ): + last_update_age = fields.Datetime.now() - payment.last_sync_date + # Skip if updated in the last hour (3600 seconds) + if last_update_age.total_seconds() < 3600: + _logger.debug( + "Skipping recent payment %s (updated %s ago)", + payment.name, + last_update_age, + ) + skipped_count += 1 + continue + + # Implement retry logic + retry_count = 0 + success = False + last_error = None + + while not success and retry_count < max_retries: + try: + # Add detailed logging + _logger.debug( + "Requesting status for payment %s (ID: %s, attempt %s/%s)", + payment.name, + payment.billcom, + retry_count + 1, + max_retries, + ) + + result = self.env["billcom.service"]._make_request( + f"payments/{payment.billcom}", method="GET" + ) + + if result and result.get("id"): + # Get the new status + new_status = payment._map_billcom_status( + result.get("singleStatus") + ) + old_status = payment.billcom_payment_status + + # Update payment with Bill.com data + payment.with_context(skip_billcom_sync=True).write( + { + "billcom_payment_status": new_status, + "last_sync_date": fields.Datetime.now(), + "billcom_confirmation_number": result.get( + "confirmationNumber", + payment.billcom_confirmation_number or "", + ), + "billcom_transaction_number": result.get( + "transactionNumber", + payment.billcom_transaction_number or "", + ), + "billcom_exchange_rate": result.get( + "exchangeRate", payment.billcom_exchange_rate or 0.0 + ), + "billcom_funding_amount": result.get( + "fundingAmount", + payment.billcom_funding_amount or 0.0, + ), + } + ) + + # Log status change if it occurred + if old_status != new_status: + _logger.info( + "Payment %s status changed: %s -> %s", + payment.name, + old_status, + new_status, + ) + + # Create a note on the payment for audit trail + payment.message_post( + body=_( + "Bill.com payment status changed from %(old)s to %(new)s" + ) + % {"old": old_status, "new": new_status}, + subtype_id=self.env.ref("mail.mt_note").id, + ) + + updated_count += 1 + success = True + else: + _logger.warning( + "No valid response for payment %s (attempt %s/%s)", + payment.name, + retry_count + 1, + max_retries, + ) + retry_count += 1 + time.sleep(retry_delay) # Wait before retrying + + except Exception as e: + last_error = str(e) + _logger.warning( + "Error updating payment %s (attempt %s/%s): %s", + payment.name, + retry_count + 1, + max_retries, + last_error, + ) + retry_count += 1 + time.sleep(retry_delay) # Wait before retrying + + # If all retries failed, log the error + if not success: + error_count += 1 + _logger.error( + "Failed to update payment %s after %s attempts: %s", + payment.name, + max_retries, + last_error or "Unknown error", + ) + + # Create a note on the payment for audit trail + payment.message_post( + body=_("Failed to update payment status from Bill.com: %s") + % (last_error or "Unknown error"), + subtype_id=self.env.ref("mail.mt_note").id, + ) + + _logger.info( + "Bill.com payment status update complete: %s updated, %s errors, %s skipped", + updated_count, + error_count, + skipped_count, + ) + return True + + @api.model + def process_billcom_payment_webhook(self, payment_data): + """Process payment status update from Bill.com webhook + + Args: + payment_data (dict): Payment data from Bill.com webhook + + Returns: + bool: True if successful, False otherwise + """ + if not payment_data or not isinstance(payment_data, dict): + _logger.error("Invalid payment data received from webhook") + return False + + payment_id = payment_data.get("id") + if not payment_id: + _logger.error("No payment ID in webhook data") + return False + + # Find the payment in Odoo (check billcom_id first, then billcom) + payment = self.search([("billcom_id", "=", payment_id)], limit=1) + if not payment: + payment = self.search([("billcom", "=", payment_id)], limit=1) + if not payment: + _logger.warning("Payment with Bill.com ID %s not found in Odoo", payment_id) + return False + + try: + # Get the new status + new_status = payment._map_billcom_status(payment_data.get("singleStatus")) + old_status = payment.billcom_payment_status + + # Update payment with Bill.com data + payment.with_context(skip_billcom_sync=True).write( + { + "billcom_payment_status": new_status, + "last_sync_date": fields.Datetime.now(), + "billcom_confirmation_number": payment_data.get( + "confirmationNumber", payment.billcom_confirmation_number or "" + ), + "billcom_transaction_number": payment_data.get( + "transactionNumber", payment.billcom_transaction_number or "" + ), + "billcom_exchange_rate": payment_data.get( + "exchangeRate", payment.billcom_exchange_rate or 0.0 + ), + "billcom_funding_amount": payment_data.get( + "fundingAmount", payment.billcom_funding_amount or 0.0 + ), + } + ) + + # Apply status mapping from Bill.com to Odoo state + billcom_payment_status = payment_data.get("paymentStatus", "UNDEFINED") + service = self.env["billcom.service"].sudo() + target_state = service._map_billcom_payment_status_to_odoo_state( + billcom_payment_status + ) + + if target_state == "posted" and payment.state == "draft": + # Post the payment if Bill.com status requires it + try: + payment.with_context(skip_billcom_sync=True).action_post() + _logger.info( + "Posted payment %s based on Bill.com status: %s (webhook)", + payment.name, + billcom_payment_status, + ) + except Exception as e: + _logger.warning( + "Could not post payment %s from Bill.com status %s " + "(webhook): %s", + payment.name, + billcom_payment_status, + e, + ) + + # Log status change if it occurred + if old_status != new_status: + _logger.info( + "Payment %s status changed via webhook: %s -> %s", + payment.name, + old_status, + new_status, + ) + + # Create a note on the payment for audit trail + payment.message_post( + body=_( + "Bill.com payment status changed from %(old)s to %(new)s (via webhook)" + ) + % {"old": old_status, "new": new_status}, + subtype_id=self.env.ref("mail.mt_note").id, + ) + + return True + except Exception as e: + _logger.error( + "Error processing payment webhook for %s: %s", payment.name, str(e) + ) + return False + + @api.model + def sync_payment_status(self): + # Get configuration + try: + config = self.env["billcom.config"].sudo().get_config() + # Only run if the interval is greater than 0 and payment sync is enabled + if ( + hasattr(config, "payment_status_check_interval") + and config.payment_status_check_interval > 0 + and config.sync_payments + ): + self.update_billcom_payment_status() + else: + _logger.info("Bill.com payment status check is disabled") + except Exception as e: + _logger.error("Error in Bill.com payment status update: %s", str(e)) + + def action_bulk_sync_to_billcom(self): + if not self: + raise UserError(_("No payments selected")) + + # Validate all payments before processing + for payment in self: + if ( + not payment.is_sync_to_billcom + or not payment.partner_id.is_sync_to_billcom + ): + raise UserError( + _( + f"Payment {payment.name}s or vendor {payment.partner_id.name}s" + f"is not marked for Bill.com synchronization" + ) + ) + + if payment.payment_type != "outbound" or payment.partner_type != "supplier": + raise UserError( + _( + f"Payment {payment.name} is not a vendor payment" + f" (must be outbound supplier payment)" + ) + ) + + # Check if payment has linked bill with Bill.com ID + has_billcom_bill = False + if payment.reconciled_bill_ids: + for bill in payment.reconciled_bill_ids: + if bill.billcom_id or bill.billcom: + has_billcom_bill = True + break + + if not has_billcom_bill: + raise UserError( + _( + "Payment %s does not have a linked bill with Bill.com ID. " + "Bulk payments can only pay existing bills. " + "Please sync the bill to Bill.com first or use single payment creation." + ) + % payment.name + ) + + # Call bulk payment creation + try: + result = self.env["billcom.service"].create_bulk_payments(self) + + if result.get("success"): + success_count = result.get("success_count", 0) + error_count = result.get("error_count", 0) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Bulk Payment Success"), + "message": _( + "Successfully processed %(success)d payments to Bill.com. " + "Errors: %(errors)d" + ) + % {"success": success_count, "errors": error_count}, + "type": "success" if error_count == 0 else "warning", + "sticky": False, + }, + } + else: + errors = result.get("errors", ["Unknown error"]) + error_message = "\n".join(errors) + raise UserError(_("Bulk payment failed:\n\n%s") % error_message) + + except Exception as e: + _logger.error("Bulk payment action error: %s", str(e)) + raise UserError(_("Bulk payment failed: %s") % str(e)) from e diff --git a/billcom_integration/models/account_payment_register.py b/billcom_integration/models/account_payment_register.py new file mode 100644 index 00000000..30417334 --- /dev/null +++ b/billcom_integration/models/account_payment_register.py @@ -0,0 +1,155 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class AccountPaymentRegister(models.TransientModel): + _inherit = "account.payment.register" + + # Bill.com fields for payment registration + is_sync_to_billcom = fields.Boolean(string="Sync to Bill.com", default=False) + billcom_funding_account_type = fields.Selection( + selection=[ + ("BANK_ACCOUNT", "Bank Account"), + ("CARD_ACCOUNT", "Credit/Debit Card"), + ("WALLET", "BILL Balance"), + ("AP_CARD", "AP Card"), + ], + string="Funding Account Type", + default="BANK_ACCOUNT", + help="Type of funding account for Bill.com payment", + ) + billcom_process_date = fields.Date( + string="Process Date", default=fields.Date.context_today + ) + billcom_pay_faster = fields.Boolean( + string="Pay Faster", + default=False, + help="Enable Pay Faster for expedited payment delivery", + ) + billcom_check_delivery_type = fields.Selection( + selection=[ + ("STANDARD", "Standard"), + ("RTP_DELIVERY", "Real-Time Payment (ACH)"), + ("UPS_1DAY", "UPS 1-Day Delivery"), + ("UPS_2DAY", "UPS 2-Days Delivery"), + ("UPS_3DAY", "UPS 3-Days Delivery"), + ("USPS_PRIORITY", "USPS Priority"), + ], + string="Check Delivery Type", + default="STANDARD", + help="Delivery method for check payments", + ) + is_international_payment = fields.Boolean( + string="International Payment", + compute="_compute_is_international_payment", + store=True, + ) + + @api.depends("partner_id", "partner_id.country_id") + def _compute_is_international_payment(self): + """Determine if payment is international based on vendor country""" + company_country = self.env.company.country_id + for wizard in self: + if wizard.partner_id and wizard.partner_id.country_id: + wizard.is_international_payment = ( + wizard.partner_id.country_id.id != company_country.id + ) + else: + wizard.is_international_payment = False + + @api.model + def default_get(self, fields_list): + # Call to the original method + res = super(AccountPaymentRegister, self).default_get(fields_list) + + # Only apply logic for vendor payments + active_ids = self._context.get("active_ids") or [] + active_model = self._context.get("active_model") + + if active_model == "account.move" and active_ids: + moves = self.env["account.move"].browse(active_ids) + # Check if they are vendor invoices + if moves and moves[0].move_type == "in_invoice": + # Check if the vendor is configured to sync with Bill.com + if moves[0].partner_id and hasattr( + moves[0].partner_id, "is_sync_to_billcom" + ): + res["is_sync_to_billcom"] = moves[0].partner_id.is_sync_to_billcom + + return res + + def _create_payment_vals_from_wizard(self, batch_result): + # Call to the original method + payment_vals = super( + AccountPaymentRegister, self + )._create_payment_vals_from_wizard(batch_result) + + # Only apply logic for vendor payments + if ( + payment_vals.get("partner_type") == "supplier" + and payment_vals.get("payment_type") == "outbound" + ): + # Add Bill.com fields + payment_vals.update( + { + "is_sync_to_billcom": self.is_sync_to_billcom, + "billcom_funding_account_type": self.billcom_funding_account_type, + "billcom_process_date": self.billcom_process_date, + "billcom_pay_faster": self.billcom_pay_faster, + "billcom_check_delivery_type": self.billcom_check_delivery_type, + } + ) + + return payment_vals + + def _create_payments(self): + # Call to the original method to create payments + payments = super()._create_payments() + + # If payments have been created and are configured to sync with Bill.com + if payments: + for payment in payments: + # Only sync vendor payments + if ( + payment.partner_type == "supplier" + and payment.payment_type == "outbound" + and payment.is_sync_to_billcom + ): + try: + # Try to sync the payment with Bill.com + payment.button_sync_to_billcom() + except Exception as e: + _logger.warning( + "Payment %s created but could not be synced to Bill.com: %s", + payment.name, + str(e), + ) + # Set sync status to failed + payment.sudo().write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": str(e), + } + ) + # Post warning to payment chatter + payment.message_post( + body=( + f"

Bill.com Sync Warning

" + f"

Payment created successfully but could not " + f"be synced to Bill.com.

" + f"

Error:

" # noqa: E231 + f"
{str(e)}
" + f"

You can manually sync this payment later " + f"from the payment form.

" + ), + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return payments diff --git a/billcom_integration/models/account_tax.py b/billcom_integration/models/account_tax.py new file mode 100644 index 00000000..dc694521 --- /dev/null +++ b/billcom_integration/models/account_tax.py @@ -0,0 +1,16 @@ +# Copyright 2025 Binhex. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AccountTax(models.Model): + _inherit = "account.tax" + + billcom_item_id = fields.Many2one( + "billcom.item", + string="Bill.com Item", + help="Bill.com item (SALES_TAX) mapped to this tax for synchronization", + ondelete="set null", + copy=False, + ) diff --git a/billcom_integration/models/billcom_abstract.py b/billcom_integration/models/billcom_abstract.py new file mode 100644 index 00000000..dce45119 --- /dev/null +++ b/billcom_integration/models/billcom_abstract.py @@ -0,0 +1,40 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class BillcomAbstractModel(models.AbstractModel): + _name = "billcom.abstract.model" + _description = "Bill.com Abstract Model" + + billcom = fields.Char(string="Bill.com Reference", copy=False) + billcom_id = fields.Char(string="Bill.com ID", copy=False) + is_sync_to_billcom = fields.Boolean( + string="Sync to Bill.com", default=True, copy=False + ) + billcom_sync_manual = fields.Boolean( + string="Manual Sync Required", + default=False, + help="Indicates if manual sync is required due to previous errors", + ) + + last_sync_date = fields.Datetime(copy=False) + billcom_sync_status = fields.Selection( + [ + ("not_synced", "Not Synced"), + ("synced", "Synced to Bill.com"), + ("sync_failed", "Sync Failed"), + ], + string="Bill.com Sync Status", + readonly=True, + copy=False, + default="not_synced", + help="Indicates whether this record was successfully synced to Bill.com", + ) + billcom_sync_error = fields.Text( + string="Bill.com Sync Error", + readonly=True, + copy=False, + help="Error message if sync to Bill.com failed", + ) diff --git a/billcom_integration/models/billcom_config.py b/billcom_integration/models/billcom_config.py new file mode 100644 index 00000000..7b6743ee --- /dev/null +++ b/billcom_integration/models/billcom_config.py @@ -0,0 +1,1416 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class BillcomConfig(models.Model): + _name = "billcom.config" + _description = "Bill.com API Configuration" + _inherit = ["mail.thread", "mail.activity.mixin"] + + name = fields.Char(required=True, default="Bill.com Configuration", tracking=True) + environment = fields.Selection( + [("sandbox", "Sandbox"), ("production", "Production")], + required=True, + default="sandbox", + tracking=True, + ) + username = fields.Char(required=True, tracking=True) + password = fields.Char(required=True) + user_id = fields.Many2one("res.users", required=True, tracking=True) + organization_id = fields.Char( + string="Organization ID", required=True, tracking=True + ) + dev_key = fields.Char(string="Developer Key", required=True) + + # MFA Configuration + enable_mfa = fields.Boolean( + default=False, + help="Enable Multi-Factor Authentication for Bill.com login", + tracking=True, + ) + mfa_device_id = fields.Char( + string="MFA Device ID", + help="Trusted Device ID from Bill.com for MFA-free payment creation. " + "Required for creating payments via API. " + "See MFA_PAYMENT_ISSUE.md for setup instructions.", + tracking=True, + ) + mfa_remember_me_id = fields.Char( + string="MFA Remember Me ID", + help="30-day MFA ID for step-up authentication. " + "Generated from MFA challenge/validate with rememberMe=true. " + "Use 'Setup MFA' button to obtain this automatically.", + tracking=True, + ) + mfa_device_name = fields.Char( + default="Odoo Integration", + help="Friendly name for this integration device used in MFA step-up", + ) + + active = fields.Boolean(default=True, tracking=True) + company_id = fields.Many2one( + "res.company", + required=True, + default=lambda self: self.env.company, + ) + state = fields.Selection( + [ + ("draft", "Not Connected"), + ("connected", "Connected"), + ("error", "Connection Error"), + ], + string="Connection Status", + default="draft", + tracking=True, + readonly=True, + ) + last_connection_test = fields.Datetime(readonly=True) + last_error_message = fields.Text(readonly=True) + last_sync_date = fields.Datetime( + string="Last Synchronization", readonly=True, tracking=True + ) + + # Sync Configuration + sync_invoices = fields.Boolean( + string="Sync Customer Invoices", + default=True, + help="Enable synchronization of customer invoices to Bill.com", + ) + sync_bills = fields.Boolean( + string="Sync Vendor Bills", + default=True, + help="Enable synchronization of vendor bills to Bill.com", + ) + sync_customers = fields.Boolean( + default=True, + help="Enable synchronization of customers to Bill.com", + ) + sync_vendors = fields.Boolean( + default=True, + help="Enable synchronization of vendors to Bill.com", + ) + sync_payments = fields.Boolean( + string="Sync Vendor Payments", + default=True, + help="Enable synchronization of vendor payments to Bill.com", + ) + + # Webhook Configuration + enable_webhooks = fields.Boolean( + default=False, + help="Enable webhook integration with Bill.com for real-time updates", + ) + webhook_secret = fields.Char( + help="Secret key for validating incoming webhooks from Bill.com", + ) + webhook_subscription_id = fields.Char( + string="Webhook Subscription ID", + readonly=True, + help="Bill.com webhook subscription ID (auto-filled when subscribed)", + ) + webhook_url = fields.Char( + compute="_compute_webhook_url", + store=True, + help="URL where Bill.com will send webhook notifications", + ) + webhook_subscription_state = fields.Selection( + [ + ("not_subscribed", "Not Subscribed"), + ("subscribed", "Subscribed"), + ("error", "Error"), + ], + default="not_subscribed", + readonly=True, + ) + webhook_last_error = fields.Text( + readonly=True, + ) + + # Webhook Events Selection - Only enabled events + webhook_event_bills = fields.Boolean( + string="Bill Events", + default=True, + help="Subscribe to bill.created, bill.updated, bill.archived, bill.restored", + ) + webhook_event_vendors = fields.Boolean( + string="Vendor Events", + default=True, + help="Subscribe to vendor.created, vendor.updated, vendor.archived, vendor.restored", + ) + webhook_event_payments = fields.Boolean( + string="Payment Events", + default=True, + help="Subscribe to payment.updated, payment.failed", + ) + webhook_event_bank_accounts = fields.Boolean( + string="Bank Account Events", + default=True, + help="Subscribe to bank-account.created, bank-account.updated", + ) + + # API Configuration + api_max_retries = fields.Integer( + default=3, + help="Maximum number of retry attempts for failed API calls", + ) + api_retry_delay = fields.Integer( + string="Retry Delay (seconds)", + default=5, + help="Delay in seconds between retry attempts", + ) + + # Scheduled Actions Configuration + full_sync_interval = fields.Integer( + string="Full Sync Interval (hours)", + default=1, + help="Interval in hours between full synchronization with Bill.com", + ) + payment_sync_interval = fields.Integer( + string="Payment Sync Interval (minutes)", + default=15, + help="Interval in minutes between payment synchronization with Bill.com", + ) + payment_status_check_interval = fields.Integer( + string="Payment Status Check Interval (minutes)", + default=60, + help="Interval in minutes between payment status checks (0 to disable)", + ) + default_payment_type = fields.Selection( + [ + ("ach", "ACH"), + ("check", "Check"), + ("virtual_card", "Virtual Card"), + ("wire", "Wire Transfer"), + ], + default="ach", + help="Default payment type for Bill.com payments", + ) + + # Funding Account Configuration + default_funding_account_id = fields.Char( + string="Default Funding Account ID", + help="Bill.com ID of the default funding account for payments", + tracking=True, + ) + default_funding_account_type = fields.Selection( + [ + ("BANK_ACCOUNT", "Bank Account"), + ("CARD_ACCOUNT", "Credit/Debit Card"), + ("WALLET", "BILL Balance"), + ("AP_CARD", "AP Card"), + ], + string="Funding Account Type", + default="BANK_ACCOUNT", + help="Type of funding account for Bill.com API", + tracking=True, + ) + token = fields.Char(string="API Token", copy=False, readonly=True) + token_expiry = fields.Datetime(copy=False, readonly=True) + auto_sync_enabled = fields.Boolean( + string="Enable Automatic Sync", + default=True, + help="Enable automatic synchronization via scheduled actions", + ) + sync_interval = fields.Integer( + string="Sync Interval (minutes)", + default=60, + help="Interval in minutes between automatic synchronizations", + ) + api_url = fields.Char(compute="_compute_api_url", store=True, readonly=False) + + # Dashboard computed fields + kanban_dashboard = fields.Text(compute="_compute_kanban_dashboard") + kanban_dashboard_graph = fields.Text(compute="_compute_kanban_dashboard_graph") + color = fields.Integer(string="Color Index", compute="_compute_color") + + # Dashboard metrics + total_partners = fields.Integer(compute="_compute_dashboard_metrics") + synced_vendors = fields.Integer(compute="_compute_dashboard_metrics") + synced_customers = fields.Integer(compute="_compute_dashboard_metrics") + pending_partners = fields.Integer( + compute="_compute_dashboard_metrics", + ) + + total_bills = fields.Integer(compute="_compute_dashboard_metrics") + synced_bills = fields.Integer(compute="_compute_dashboard_metrics") + pending_bills = fields.Integer(compute="_compute_dashboard_metrics") + + total_payments = fields.Integer(compute="_compute_dashboard_metrics") + synced_payments = fields.Integer(compute="_compute_dashboard_metrics") + pending_payments = fields.Integer(compute="_compute_dashboard_metrics") + + total_invoices = fields.Integer(compute="_compute_dashboard_metrics") + synced_invoices = fields.Integer(compute="_compute_dashboard_metrics") + pending_invoices = fields.Integer(compute="_compute_dashboard_metrics") + + queue_pending = fields.Integer(compute="_compute_dashboard_metrics") + queue_processing = fields.Integer(compute="_compute_dashboard_metrics") + queue_completed = fields.Integer(compute="_compute_dashboard_metrics") + queue_failed = fields.Integer(compute="_compute_dashboard_metrics") + + log_today = fields.Integer( + compute="_compute_dashboard_metrics", string="Logs Today" + ) + log_errors_today = fields.Integer( + compute="_compute_dashboard_metrics", string="Errors Today" + ) + log_warnings_today = fields.Integer( + compute="_compute_dashboard_metrics", string="Warnings Today" + ) + + last_sync_date_dashboard = fields.Datetime( + compute="_compute_dashboard_metrics", string="Last Sync Dashboard" + ) + + @api.depends("environment") + def _compute_api_url(self): + for record in self: + if record.environment == "sandbox": + record.api_url = "https://gateway.stage.bill.com/connect" + else: + record.api_url = "https://gateway.prod.bill.com/connect" + + def test_connection(self): + """Test the connection to Bill.com API""" + self.ensure_one() + try: + service = self.env["billcom.service"].sudo() + result = service._get_token() + + if result: + self.write( + { + "state": "connected", + "last_connection_test": fields.Datetime.now(), + "last_error_message": False, + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": _("Successfully connected to Bill.com API"), + "type": "success", + "sticky": False, + }, + } + except Exception as e: + error_msg = str(e) + _logger.error("Bill.com connection test failed: %s", error_msg) + + self.write( + { + "state": "error", + "last_connection_test": fields.Datetime.now(), + "last_error_message": error_msg, + } + ) + + raise UserError(_("Connection test failed: %s") % error_msg) from e + + def action_setup_mfa(self): + """Initiate MFA setup process - generates challenge and opens wizard""" + self.ensure_one() + + try: + service = self.env["billcom.service"].sudo() + + # Generate MFA challenge + challenge_data = service.generate_mfa_challenge(self) + + # Create wizard with challenge data + wizard = self.env["billcom.mfa.wizard"].create( + { + "config_id": self.id, + "challenge_id": challenge_data["challenge_id"], + "session_id": challenge_data["session_id"], + "phone_number": challenge_data.get("phone_number", "****"), + } + ) + + return { + "type": "ir.actions.act_window", + "name": _("Enter MFA Code"), + "res_model": "billcom.mfa.wizard", + "res_id": wizard.id, + "view_mode": "form", + "target": "new", + "context": {"active_id": self.id}, + } + + except Exception as e: + _logger.error("Failed to initiate MFA setup: %s", str(e)) + raise UserError(_("Failed to initiate MFA setup: %s") % str(e)) from e + + def button_sync_primary_data(self): + """Synchronize primary data from Bill.com (funding accounts, taxes/items, etc.)""" + self.ensure_one() + + try: + _logger.info("Starting sync of primary data from Bill.com") + + # Sync funding accounts from Bill.com + _logger.info("Syncing funding accounts from Bill.com...") + funding_account_model = self.env["billcom.funding.account"] + funding_result = funding_account_model.sync_funding_accounts_from_billcom() + + funding_synced = funding_result.get("synced", 0) + funding_total = funding_result.get("total", 0) + + # Import items from Bill.com (all types) + _logger.info("Importing items from Bill.com...") + item_model = self.env["billcom.item"] + import_result = item_model.sync_items_from_billcom() + + items_created = import_result.get("created", 0) + items_updated = import_result.get("updated", 0) + items_errors = import_result.get("errors", 0) + items_total = import_result.get("total", 0) + + # Sync Odoo taxes to Bill.com as items (SALES_TAX type) + _logger.info("Syncing Odoo taxes to Bill.com as items...") + tax_result = item_model.sync_from_odoo_taxes() + + tax_synced = tax_result.get("synced", 0) + tax_total = tax_result.get("total", 0) + tax_errors = tax_result.get("errors", 0) + + # Build summary message + message_parts = [] + message_parts.append( + _("✓ Funding Accounts: %(synced)d of %(total)d synchronized") + % {"synced": funding_synced, "total": funding_total} + ) + message_parts.append( + _( + f"✓ Items from Bill.com: {items_created} created, " + f"{items_updated} updated (total: {items_total})" + ) + ) + if items_errors > 0: + message_parts.append(_("⚠ Items Import Errors: %d") % items_errors) + message_parts.append( + _("✓ Tax Items to Bill.com: %(synced)d of %(total)d synchronized") + % {"synced": tax_synced, "total": tax_total} + ) + if tax_errors > 0: + message_parts.append(_("⚠ Tax Export Errors: %d") % tax_errors) + + message = "\n".join(message_parts) + has_errors = items_errors > 0 or tax_errors > 0 + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Primary Data Sync Complete"), + "message": message, + "type": "success" if not has_errors else "warning", + "sticky": False, + }, + } + + except Exception as e: + error_msg = str(e) + _logger.error("Primary data sync failed: %s", error_msg) + raise UserError(_("Primary data sync failed: %s") % error_msg) from e + + @api.model + def get_config(self): + """Get the active configuration for the current company""" + config = self.search( + [("active", "=", True), ("company_id", "=", self.env.company.id)], limit=1 + ) + + if not config: + raise UserError( + _("No active Bill.com configuration found for company %s") + % self.env.company.name + ) + + return config + + def update_scheduled_actions(self): + """Update scheduled actions with the intervals defined in the configuration""" + self.ensure_one() + + # Get the scheduled actions + ir_cron_obj = self.env["ir.cron"] + full_sync_cron = ir_cron_obj.search( + [("id", "=", self.env.ref("billcom.ir_cron_sync_billcom").id)] + ) + payment_sync_cron = ir_cron_obj.search( + [("id", "=", self.env.ref("billcom.ir_cron_sync_billcom_payments").id)] + ) + payment_status_cron = ir_cron_obj.search( + [("id", "=", self.env.ref("billcom.ir_cron_update_payment_status").id)] + ) + + # Update full sync cron + if full_sync_cron: + full_sync_cron.write( + { + "interval_number": self.full_sync_interval, + "interval_type": "hours", + "active": self.full_sync_interval > 0, + } + ) + + # Update payment sync cron + if payment_sync_cron: + payment_sync_cron.write( + { + "interval_number": self.payment_sync_interval, + "interval_type": "minutes", + "active": self.payment_sync_interval > 0 and self.sync_payments, + } + ) + + # Update payment status cron + if payment_status_cron: + payment_status_cron.write( + { + "interval_number": self.payment_status_check_interval, + "interval_type": "minutes", + "active": self.payment_status_check_interval > 0 + and self.sync_payments, + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": _("Scheduled actions updated successfully."), + "type": "success", + "sticky": False, + }, + } + + def refresh_token(self): + """Refresh the API token""" + self.ensure_one() + service = self.env["billcom.service"].sudo() + return service._get_token() + + @api.depends("state") + def _compute_color(self): + for config in self: + if config.state == "connected": + config.color = 10 # Green + elif config.state == "error": + config.color = 1 # Red + else: + config.color = 7 # Gray + + @api.depends( + "state", "total_partners", "synced_vendors", "pending_partners", "queue_failed" + ) + def _compute_dashboard_metrics(self): + for config in self: + # Partners metrics + partners = self.env["res.partner"].search( + [("company_id", "=", config.company_id.id)] + ) + config.total_partners = len(partners) + config.synced_vendors = len( + partners.filtered(lambda p: p.supplier_rank > 0 and p.billcom_id) + ) + config.synced_customers = len( + partners.filtered(lambda p: p.customer_rank > 0 and p.billcom_id) + ) + config.pending_partners = len( + partners.filtered( + lambda p: (p.supplier_rank > 0 or p.customer_rank > 0) + and not p.billcom_id + ) + ) + + # Bills metrics + bills = self.env["account.move"].search( + [ + ("company_id", "=", config.company_id.id), + ("move_type", "=", "in_invoice"), + ] + ) + config.total_bills = len(bills) + config.synced_bills = len(bills.filtered(lambda b: b.billcom_id)) + config.pending_bills = len(bills.filtered(lambda b: not b.billcom_id)) + + # Payments metrics + payments = self.env["account.payment"].search( + [ + ("company_id", "=", config.company_id.id), + ("payment_type", "=", "outbound"), + ] + ) + config.total_payments = len(payments) + config.synced_payments = len(payments.filtered(lambda p: p.billcom_id)) + config.pending_payments = len(payments.filtered(lambda p: not p.billcom_id)) + + # Invoices metrics + invoices = self.env["account.move"].search( + [ + ("company_id", "=", config.company_id.id), + ("move_type", "=", "out_invoice"), + ] + ) + config.total_invoices = len(invoices) + config.synced_invoices = len(invoices.filtered(lambda inv: inv.billcom_id)) + config.pending_invoices = len( + invoices.filtered(lambda inv: not inv.billcom_id) + ) + + # Queue metrics + queue_items = self.env["billcom.sync.queue"].search( + [("config_id", "=", config.id)] + ) + config.queue_pending = len( + queue_items.filtered(lambda q: q.state == "queued") + ) + config.queue_processing = len( + queue_items.filtered(lambda q: q.state == "processing") + ) + config.queue_completed = len( + queue_items.filtered(lambda q: q.state == "success") + ) + config.queue_failed = len( + queue_items.filtered(lambda q: q.state == "error") + ) + + # Logs metrics + today = fields.Date.today() + logs_today = self.env["billcom.logger"].search( + [("config_id", "=", config.id), ("create_date", ">=", today)] + ) + config.log_today = len(logs_today) + config.log_errors_today = len( + logs_today.filtered(lambda l: l.level == "error") + ) + config.log_warnings_today = len( + logs_today.filtered(lambda l: l.level == "warning") + ) + + # Last sync date + last_queue = queue_items.filtered(lambda q: q.state == "success").sorted( + "write_date", reverse=True + ) + config.last_sync_date_dashboard = ( + last_queue[0].write_date if last_queue else False + ) + + def _compute_kanban_dashboard(self): + for config in self: + config.kanban_dashboard = json.dumps( + { + "state": config.state, + "total_partners": config.total_partners, + "synced_vendors": config.synced_vendors, + "synced_customers": config.synced_customers, + "pending_partners": config.pending_partners, + "total_bills": config.total_bills, + "synced_bills": config.synced_bills, + "pending_bills": config.pending_bills, + "total_payments": config.total_payments, + "synced_payments": config.synced_payments, + "pending_payments": config.pending_payments, + "total_invoices": config.total_invoices, + "synced_invoices": config.synced_invoices, + "pending_invoices": config.pending_invoices, + "queue_pending": config.queue_pending, + "queue_processing": config.queue_processing, + "queue_completed": config.queue_completed, + "queue_failed": config.queue_failed, + "log_today": config.log_today, + "log_errors_today": config.log_errors_today, + "log_warnings_today": config.log_warnings_today, + "last_sync_date": ( + config.last_sync_date_dashboard.strftime("%Y-%m-%d %H:%M:%S") + if config.last_sync_date_dashboard + else False + ), + } + ) + + def _compute_kanban_dashboard_graph(self): + for config in self: + # Compute graph data for sync activity over the last 7 days + data = [] + for i in range(6, -1, -1): + date = fields.Date.today() - timedelta(days=i) + completed_count = self.env["billcom.sync.queue"].search_count( + [ + ("config_id", "=", config.id), + ("state", "=", "success"), + ("write_date", ">=", date), + ("write_date", "<", date + timedelta(days=1)), + ] + ) + data.append({"label": date.strftime("%m/%d"), "value": completed_count}) + + config.kanban_dashboard_graph = json.dumps( + [ + { + "values": data, + "title": "Sync Activity (7 days)", + "key": "Completed Syncs", + } + ] + ) + + def action_refresh_dashboard(self): + """Refresh dashboard data""" + self._compute_dashboard_metrics() + self._compute_kanban_dashboard() + self._compute_kanban_dashboard_graph() + return { + "type": "ir.actions.client", + "tag": "reload", + } + + def action_open_sync_wizard(self): + """Open synchronization wizard""" + return { + "type": "ir.actions.act_window", + "name": "Bill.com Synchronization", + "res_model": "billcom.sync.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_config_id": self.id}, + } + + def action_open_configurations(self): + """Open configurations list""" + return { + "type": "ir.actions.act_window", + "name": "Bill.com Configurations", + "res_model": "billcom.config", + "view_mode": "tree,form", + "target": "current", + } + + def action_open_sync_queue(self): + """Open sync queue for this configuration""" + return { + "type": "ir.actions.act_window", + "name": "Sync Queue", + "res_model": "billcom.sync.queue", + "view_mode": "tree,form", + "domain": [("config_id", "=", self.id)], + "target": "current", + } + + def action_open_logs(self): + """Open logs for this configuration""" + return { + "type": "ir.actions.act_window", + "name": "Bill.com Logs", + "res_model": "billcom.logger", + "view_mode": "tree,form", + "domain": [("config_id", "=", self.id)], + "target": "current", + } + + def action_quick_sync(self): + """Perform quick synchronization""" + try: + # Create sync wizard and execute + wizard = self.env["billcom.sync.wizard"].create( + { + "config_id": self.id, + "sync_vendors": True, + "sync_customers": False, + "sync_bills": True, + "sync_payments": True, + } + ) + wizard.action_sync() + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "Quick Sync Started", + "message": "Quick synchronization has been initiated", + "type": "success", + }, + } + except Exception as e: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "Sync Error", + "message": f"Error starting sync: {str(e)}", + "type": "danger", + }, + } + + def action_open_partners(self): + """Open partners list""" + return { + "type": "ir.actions.act_window", + "name": "Partners", + "res_model": "res.partner", + "view_mode": "tree,form", + "domain": [("company_id", "=", self.company_id.id)], + "target": "current", + } + + def action_open_bills(self): + """Open bills list""" + return { + "type": "ir.actions.act_window", + "name": "Vendor Bills", + "res_model": "account.move", + "view_mode": "tree,form", + "domain": [ + ("company_id", "=", self.company_id.id), + ("move_type", "=", "in_invoice"), + ], + "target": "current", + } + + def action_open_payments(self): + """Open payments list""" + return { + "type": "ir.actions.act_window", + "name": "Vendor Payments", + "res_model": "account.payment", + "view_mode": "tree,form", + "domain": [ + ("company_id", "=", self.company_id.id), + ("payment_type", "=", "outbound"), + ], + "target": "current", + } + + def action_open_invoices(self): + """Open invoices list""" + return { + "type": "ir.actions.act_window", + "name": "Customer Invoices", + "res_model": "account.move", + "view_mode": "tree,form", + "domain": [ + ("company_id", "=", self.company_id.id), + ("move_type", "=", "out_invoice"), + ], + "target": "current", + } + + def action_open_vendors_from_billcom(self): + """Open vendors synced from Bill.com""" + return { + "type": "ir.actions.act_window", + "name": "Vendors from Bill.com", + "res_model": "res.partner", + "view_mode": "tree,form", + "domain": [ + ("company_id", "=", self.company_id.id), + ("supplier_rank", ">", 0), + ("billcom_id", "!=", False), + ], + "target": "current", + "context": {"default_supplier_rank": 1}, + } + + def action_open_customers_from_billcom(self): + """Open customers synced from Bill.com""" + return { + "type": "ir.actions.act_window", + "name": "Customers from Bill.com", + "res_model": "res.partner", + "view_mode": "tree,form", + "domain": [ + ("company_id", "=", self.company_id.id), + ("customer_rank", ">", 0), + ("billcom_id", "!=", False), + ], + "target": "current", + "context": {"default_customer_rank": 1}, + } + + def action_open_bills_from_billcom(self): + """Open bills synced from Bill.com""" + return { + "type": "ir.actions.act_window", + "name": "Bills from Bill.com", + "res_model": "account.move", + "view_mode": "tree,form", + "domain": [ + ("company_id", "=", self.company_id.id), + ("move_type", "=", "in_invoice"), + ("billcom_id", "!=", False), + ], + "target": "current", + } + + def action_open_invoices_from_billcom(self): + """Open invoices synced from Bill.com""" + return { + "type": "ir.actions.act_window", + "name": "Invoices from Bill.com", + "res_model": "account.move", + "view_mode": "tree,form", + "domain": [ + ("company_id", "=", self.company_id.id), + ("move_type", "=", "out_invoice"), + ("billcom_id", "!=", False), + ], + "target": "current", + } + + def action_open_payments_from_billcom(self): + """Open payments synced from Bill.com""" + return { + "type": "ir.actions.act_window", + "name": "Payments from Bill.com", + "res_model": "account.payment", + "view_mode": "tree,form", + "domain": [ + ("company_id", "=", self.company_id.id), + ("payment_type", "=", "outbound"), + ("billcom_id", "!=", False), + ], + "target": "current", + } + + @api.depends("company_id") + def _compute_webhook_url(self): + """Compute the webhook URL for Bill.com + + Bill.com requires HTTPS URLs for webhooks + """ + for config in self: + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + # Force HTTPS as Bill.com requires it for webhooks + if base_url.startswith("http://"): + base_url = base_url.replace("http://", "https://") + config.webhook_url = f"{base_url}/billcom/webhook" + + def _get_webhook_events(self): + """Get list of webhook events based on selected checkboxes + + Only enabled event types: bills, vendors, payments, bank-accounts + """ + self.ensure_one() + events = [] + + if self.webhook_event_bills: + events.extend( + [ + "bill.created", + "bill.updated", + "bill.archived", + "bill.restored", + ] + ) + + if self.webhook_event_vendors: + events.extend( + [ + "vendor.created", + "vendor.updated", + "vendor.archived", + "vendor.restored", + ] + ) + + if self.webhook_event_payments: + events.extend( + [ + "payment.updated", + "payment.failed", + ] + ) + + if self.webhook_event_bank_accounts: + events.extend( + [ + "bank-account.created", + "bank-account.updated", + ] + ) + + return events + + def _get_webhook_event_objects(self): + """Get list of webhook event objects with type and version for Bill.com API v3 + + Only enabled event types: bills, vendors, payments, bank-accounts + """ + self.ensure_one() + event_objects = [] + + # Bill.com API v3 requires events in format: [{"type": "...", "version": "..."}] + # Using version "1" as per Bill.com API documentation + + if self.webhook_event_bills: + event_objects.extend( + [ + {"type": "bill.created", "version": "1"}, + {"type": "bill.updated", "version": "1"}, + {"type": "bill.archived", "version": "1"}, + {"type": "bill.restored", "version": "1"}, + ] + ) + + if self.webhook_event_vendors: + event_objects.extend( + [ + {"type": "vendor.created", "version": "1"}, + {"type": "vendor.updated", "version": "1"}, + {"type": "vendor.archived", "version": "1"}, + {"type": "vendor.restored", "version": "1"}, + ] + ) + + if self.webhook_event_payments: + event_objects.extend( + [ + {"type": "payment.updated", "version": "1"}, + {"type": "payment.failed", "version": "1"}, + ] + ) + + if self.webhook_event_bank_accounts: + event_objects.extend( + [ + {"type": "bank-account.created", "version": "1"}, + {"type": "bank-account.updated", "version": "1"}, + ] + ) + + return event_objects + + def button_subscribe_webhooks(self): + """Subscribe to Bill.com webhooks using API v3 format""" + self.ensure_one() + + if not self.enable_webhooks: + raise UserError(_("Please enable webhooks first")) + + if not self.webhook_url: + raise UserError(_("Webhook URL not configured")) + + try: + # Get selected events in Bill.com API v3 format + event_objects = self._get_webhook_event_objects() + + if not event_objects: + raise UserError(_("Please select at least one webhook event type")) + + # Generate idempotency key (UUID4) + import uuid + + idempotency_key = str(uuid.uuid4()) + + # Call Bill.com API to create subscription + service = self.env["billcom.service"].sudo() + + # Bill.com API v3 subscription format (correct format from API docs) + subscription_data = { + "name": f"Odoo Webhook - {self.company_id.name}", + "status": {"enabled": True}, + "events": event_objects, + "notificationUrl": self.webhook_url, + } + + _logger.info( + "Creating webhook subscription with %s events", len(event_objects) + ) + + # Make request with idempotency key header + # Note: Webhook API uses different base path (connect-events instead of connect) + # Use webhook: prefix to signal _build_api_url to use connect-events base + response = service._make_request( + "webhook:subscriptions", + method="POST", + data=subscription_data, + extra_headers={"X-Idempotent-Key": idempotency_key}, + ) + + if response and response.get("id"): + # Bill.com generates the securityKey (not we send it) + security_key = response.get("securityKey") + + self.write( + { + "webhook_subscription_id": response.get("id"), + "webhook_secret": security_key, # Store Bill.com's security key + "webhook_subscription_state": "subscribed", + "webhook_last_error": False, + } + ) + + _logger.info( + "Webhook subscription created successfully: %s", response.get("id") + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": _( + "Successfully subscribed to Bill.com webhooks. " + "Security key has been stored." + ), + "type": "success", + "sticky": False, + }, + } + else: + raise UserError(_("No subscription ID received from Bill.com")) + + except Exception as e: + error_msg = str(e) + _logger.error("Error subscribing to webhooks: %s", error_msg) + + self.write( + { + "webhook_subscription_state": "error", + "webhook_last_error": error_msg, + } + ) + + raise UserError(_("Failed to subscribe to webhooks: %s") % error_msg) from e + + def button_unsubscribe_webhooks(self): + """Unsubscribe from Bill.com webhooks""" + self.ensure_one() + + if not self.webhook_subscription_id: + raise UserError(_("No active webhook subscription found")) + + try: + # Call Bill.com API to delete subscription + service = self.env["billcom.service"].sudo() + + service._make_request( + f"webhook: subscriptions/{self.webhook_subscription_id}", + method="DELETE", + ) + + self.write( + { + "webhook_subscription_id": False, + "webhook_subscription_state": "not_subscribed", + "webhook_last_error": False, + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Success"), + "message": _("Successfully unsubscribed from Bill.com webhooks"), + "type": "success", + "sticky": False, + }, + } + + except Exception as e: + error_msg = str(e) + _logger.error("Error unsubscribing from webhooks: %s", error_msg) + + self.write( + { + "webhook_subscription_state": "error", + "webhook_last_error": error_msg, + } + ) + + raise UserError( + _("Failed to unsubscribe from webhooks: %s") % error_msg + ) from e + + def button_test_webhook(self): + """Send a test webhook from Bill.com""" + self.ensure_one() + + if not self.webhook_subscription_id: + raise UserError(_("Please subscribe to webhooks first")) + + try: + # Call Bill.com API to send test webhook + service = self.env["billcom.service"].sudo() + + test_data = { + "eventType": "bill.updated", # Example event type + } + + service._make_request( + f"webhook:subscriptions/{self.webhook_subscription_id}/test", # noqa E231 + method="POST", + data=test_data, + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Test Sent"), + "message": _( + "Test webhook sent. Check webhook logs for the result." + ), + "type": "info", + "sticky": False, + }, + } + + except Exception as e: + error_msg = str(e) + _logger.error("Error sending test webhook: %s", error_msg) + raise UserError(_("Failed to send test webhook: %s") % error_msg) from e + + def button_sync_webhook_status(self): + """Sync webhook subscription status with Bill.com""" + self.ensure_one() + + try: + service = self.env["billcom.service"].sudo() + + # Get all subscriptions from Bill.com + # Note: Webhook API uses different base path (connect-events instead of connect) + # Use webhook: prefix to signal _build_api_url to use connect-events base + response = service._make_request( + "webhook:subscriptions", + method="GET", + params={"max": 100}, # Get up to 100 subscriptions + ) + + if not response or not isinstance(response, dict): + raise UserError(_("Invalid response from Bill.com")) + + results = response.get("results", []) + + if not results: + # No subscriptions in Bill.com + if self.webhook_subscription_id: + # We think we have a subscription but Bill.com doesn't + _logger.warning( + "Subscription %s not found in Bill.com - marking as error", + self.webhook_subscription_id, + ) + self.write( + { + "webhook_subscription_state": "error", + "webhook_last_error": "Subscription not found in Bill.com. " + "It may have been deleted manually.", + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("No Subscriptions"), + "message": _("No webhook subscriptions found in Bill.com"), + "type": "warning", + "sticky": False, + }, + } + + # Find our subscription in the list + our_subscription = None + if self.webhook_subscription_id: + for sub in results: + if sub.get("id") == self.webhook_subscription_id: + our_subscription = sub + break + + if self.webhook_subscription_id and not our_subscription: + # Our subscription ID doesn't exist in Bill.com + self.write( + { + "webhook_subscription_state": "error", + "webhook_last_error": "Subscription ID not found in Bill.com. " + "It may have been deleted. Please unsubscribe and re-subscribe.", + } + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Subscription Not Found"), + "message": _( + "Your subscription was not found in Bill.com. " + "Please unsubscribe and re-subscribe." + ), + "type": "warning", + "sticky": True, + }, + } + + if our_subscription: + # Verify subscription details match + billcom_url = our_subscription.get("notificationUrl", "") + + # Check if URL matches + if billcom_url != self.webhook_url: + _logger.warning( + "Webhook URL mismatch. Odoo: %s, Bill.com: %s", + self.webhook_url, + billcom_url, + ) + + # Update state to subscribed if everything is OK + self.write( + { + "webhook_subscription_state": "subscribed", + "webhook_last_error": False, + } + ) + + message = _("Subscription verified successfully in Bill.com") + else: + # Check for orphaned subscriptions (URL matches but different ID) + orphaned = [] + for sub in results: + if sub.get("notificationUrl") == self.webhook_url: + orphaned.append(sub) + + if orphaned: + # Found subscription(s) with our URL + if len(orphaned) == 1: + orphan = orphaned[0] + _logger.info( + "Found orphaned subscription %s with our URL - adopting it", + orphan.get("id"), + ) + + self.write( + { + "webhook_subscription_id": orphan.get("id"), + "webhook_subscription_state": "subscribed", + "webhook_last_error": False, + } + ) + + message = _( + "Found and adopted orphaned subscription: %s" + ) % orphan.get("id") + else: + message = _( + "Found %s orphaned subscriptions with this URL. " + "Please clean them up manually in Bill.com." + ) % len(orphaned) + else: + message = _( + "No subscription found for this configuration. " + "Please subscribe to webhooks." + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Sync Complete"), + "message": message, + "type": "success", + "sticky": False, + }, + } + + except Exception as e: + error_msg = str(e) + _logger.error("Error syncing webhook status: %s", error_msg) + + self.write( + { + "webhook_subscription_state": "error", + "webhook_last_error": error_msg, + } + ) + + raise UserError(_("Failed to sync webhook status: %s") % error_msg) from e + + def button_view_all_subscriptions(self): + """View all webhook subscriptions from Bill.com""" + self.ensure_one() + + try: + service = self.env["billcom.service"].sudo() + + # Get all subscriptions + # Note: Webhook API uses different base path (connect-events instead of connect) + # Use webhook: prefix to signal _build_api_url to use connect-events base + response = service._make_request( + "webhook:subscriptions", + method="GET", + params={"max": 100}, + ) + + if not response or not isinstance(response, dict): + raise UserError(_("Invalid response from Bill.com")) + + results = response.get("results", []) + + if not results: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("No Subscriptions"), + "message": _("No webhook subscriptions found in Bill.com"), + "type": "info", + "sticky": False, + }, + } + + # Format subscriptions for display + message_lines = [ + _("Found %s webhook subscription(s) in Bill.com:") % len(results), + "", + ] + + for idx, sub in enumerate(results, 1): + sub_id = sub.get("id", "Unknown") + sub_name = sub.get("name", "Unnamed") + sub_url = sub.get("notificationUrl", "No URL") + events = sub.get("events", []) + event_count = len(events) if isinstance(events, list) else 0 + + is_ours = sub_id == self.webhook_subscription_id + marker = "✓ (OURS)" if is_ours else "" + + message_lines.append( + f"{idx}. {sub_name} {marker}\n" + f" ID: {sub_id}\n" + f" URL: {sub_url}\n" + f" Events: {event_count}\n" + ) + + message = "\n".join(message_lines) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Bill.com Webhook Subscriptions"), + "message": message, + "type": "info", + "sticky": True, + }, + } + + except Exception as e: + error_msg = str(e) + _logger.error("Error viewing subscriptions: %s", error_msg) + raise UserError(_("Failed to get subscriptions: %s") % error_msg) from e diff --git a/billcom_integration/models/billcom_document.py b/billcom_integration/models/billcom_document.py new file mode 100644 index 00000000..990f4a7c --- /dev/null +++ b/billcom_integration/models/billcom_document.py @@ -0,0 +1,620 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import logging +from datetime import datetime + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +def parse_billcom_datetime(date_string): + """Parse Bill.com datetime format to Odoo datetime + + Bill.com format: 2025-10-03T06:11:24.000+00:00 + Odoo format: YYYY-MM-DD HH:MM:SS + + Args: + date_string: ISO 8601 datetime string from Bill.com + + Returns: + datetime: Parsed datetime object or None + """ + if not date_string: + return None + + try: + # Remove milliseconds and timezone info for Odoo compatibility + # 2025-10-03T06:11:24.000+00:00 -> 2025-10-03T06:11:24 + if "." in date_string: + date_string = date_string.split(".")[0] + elif "+" in date_string: + date_string = date_string.split("+")[0] + elif "Z" in date_string: + date_string = date_string.replace("Z", "") + + # Parse ISO format + return datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S") + except (ValueError, AttributeError) as e: + _logger.warning(f"Failed to parse Bill.com datetime '{date_string}': {e}") + return None + + +class BillcomDocument(models.Model): + _name = "billcom.document" + _description = "Bill.com Document" + _order = "create_date desc" + _inherit = ["mail.thread", "mail.activity.mixin"] + + active = fields.Boolean(default=True) + name = fields.Char(string="Document Name", required=True) + billcom_id = fields.Char( + string="Bill.com Document ID", + help="Bill.com ID starting with 00h (upload complete) or 0du (upload in progress)", + readonly=True, + ) + billcom_upload_id = fields.Char( + string="Bill.com Upload ID", + help="Temporary ID during upload (starts with 0du)", + readonly=True, + ) + bill_id = fields.Many2one( + "account.move", + string="Bill", + required=True, + domain=[("move_type", "=", "in_invoice")], + ondelete="cascade", + ) + billcom_bill_id = fields.Char( + string="Bill.com Bill ID", + related="bill_id.billcom_id", + store=True, + readonly=True, + ) + file_data = fields.Binary(string="File Content", attachment=True) + file_size = fields.Integer(readonly=True) + download_link = fields.Char(readonly=True) + upload_status = fields.Selection( + [ + ("pending", "Pending Upload"), + ("in_progress", "Upload In Progress"), + ("uploaded", "Uploaded"), + ("failed", "Upload Failed"), + ], + default="pending", + readonly=True, + ) + error_message = fields.Text(readonly=True) + created_time = fields.Datetime( + string="Bill.com Created Time", + help="Document creation time in Bill.com", + readonly=True, + ) + attachment_id = fields.Many2one( + "ir.attachment", + string="Odoo Attachment", + help="Link to the ir.attachment record for this document", + readonly=True, + ) + + @api.constrains("file_data") + def _check_file_size(self): + """Validate file size is within Bill.com limit (6 MB)""" + for record in self: + if record.file_data: + file_size = len(base64.b64decode(record.file_data)) + if file_size > 6 * 1024 * 1024: # 6 MB limit + raise UserError( + _( + "File size exceeds Bill.com limit of 6 MB. Current size: %.2f MB" + ) + % (file_size / (1024 * 1024)) + ) + + def _prepare_upload_data(self): + """Prepare document data for upload to Bill.com""" + self.ensure_one() + + if not self.file_data: + raise UserError(_("No file content to upload")) + + if not self.billcom_bill_id: + raise UserError(_("Bill must be synced to Bill.com first")) + + # Decode file data to binary + file_binary = base64.b64decode(self.file_data) + + return file_binary + + def button_upload_to_billcom(self): + """Upload document to Bill.com""" + self.ensure_one() + + if not self.billcom_bill_id: + raise UserError( + _( + "Cannot upload document: Bill must be synced to Bill.com first.\n\n" + "Please sync the bill using the 'Sync to Bill.com' button." + ) + ) + + try: + # Update status + self.write({"upload_status": "in_progress", "error_message": False}) + + # Prepare file data + file_binary = self._prepare_upload_data() + + # Calculate file size + file_size = len(file_binary) + _logger.info( + "Uploading document '%s' (%d bytes) for bill %s", + self.name, + file_size, + self.billcom_bill_id, + ) + + # Upload to Bill.com + service = self.env["billcom.service"] + endpoint = f"documents/bills/{self.billcom_bill_id}" + + result = service._make_request( + endpoint, + method="POST", + data=file_binary, + params={"name": self.name}, + is_file_upload=True, + ) + + if result: + # Extract upload ID or document ID + upload_id = result.get("uploadId") or result.get("id") + document_id = ( + result.get("id") if result.get("id", "").startswith("00h") else None + ) + download_link = result.get("downloadLink") + created_time = result.get("createdTime") + + vals = { + "file_size": file_size, + "billcom_upload_id": upload_id, + } + + # Check upload status + if document_id: + # Upload complete + vals.update( + { + "billcom_id": document_id, + "upload_status": "uploaded", + "download_link": download_link, + "created_time": parse_billcom_datetime(created_time), + } + ) + status_msg = "completed" + else: + # Upload in progress + vals["upload_status"] = "in_progress" + status_msg = "in progress" + + self.write(vals) + + # Post to chatter + self.bill_id.message_post( + body=f"

Bill.com Document Upload" + f" {status_msg.title()}

" + f"
    " + f"
  • Document: {self.name}
  • " + f"
  • File Size: {file_size / 1024: .2f} KB
  • " + f"
  • Upload ID: {upload_id}
  • " + f"{f'
  • Document ID: {document_id}
  • ' if document_id else ''}" + f"
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + _logger.info( + "Document upload %s for bill %s (Upload ID: %s)", + status_msg, + self.billcom_bill_id, + upload_id, + ) + + if vals.get("upload_status") == "in_progress": + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Upload In Progress"), + "message": _( + "Document upload started. Use 'Check Upload " + "Status' to monitor progress." + ), + "type": "info", + "sticky": False, + }, + } + else: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Upload Complete"), + "message": _( + "Document uploaded successfully " "to Bill.com" + ), + "type": "success", + "sticky": False, + }, + } + + return False + + except Exception as e: + error_detail = str(e) + + # Extract friendly error message + service = self.env["billcom.service"] + friendly_message = service._extract_friendly_error(e) + + _logger.error( + "Error uploading document '%s' to Bill.com: %s", + self.name, + error_detail, + ) + + # Update status + self.write({"upload_status": "failed", "error_message": friendly_message}) + + # Post error to bill chatter + self.bill_id.message_post( + body=f"

Bill.com Document Upload Error

" + f"

Failed to upload document: {self.name}

" + f"

Error:

" # noqa: E231 + f"
{friendly_message}
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + # Raise user-friendly error + raise UserError( + _("Failed to upload document to Bill.com:\n\n%s") % friendly_message + ) from e + + def button_check_upload_status(self): + """Check upload status in Bill.com""" + self.ensure_one() + + if not self.billcom_upload_id: + raise UserError(_("No upload ID available to check status")) + + try: + service = self.env["billcom.service"] + result = service._make_request( + "documents/upload-status", + method="GET", + params={"ids": self.billcom_upload_id}, + ) + + if result and isinstance(result, list) and len(result) > 0: + status_info = result[0] + status = status_info.get("status") # IN_PROGRESS or UPLOADED + document_id = status_info.get("documentId") + + if status == "UPLOADED" and document_id: + # Upload complete - get document details + doc_result = service._make_request( + f"documents/{document_id}", method="GET" + ) + + if doc_result: + self.write( + { + "billcom_id": document_id, + "upload_status": "uploaded", + "download_link": doc_result.get("downloadLink"), + "created_time": parse_billcom_datetime( + doc_result.get("createdTime") + ), + } + ) + + # Post to chatter + self.bill_id.message_post( + body=f"

Bill.com Document Upload Complete

" + f"
    " + f"
  • Document: {self.name}
  • " + f"
  • Document ID: {document_id}
  • " + f"
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Upload Complete"), + "message": _("Document upload completed successfully"), + "type": "success", + "sticky": False, + }, + } + elif status == "IN_PROGRESS": + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Upload In Progress"), + "message": _( + "Document upload is still in progress. " + "Please check again in a few moments." + ), + "type": "info", + "sticky": False, + }, + } + + return False + + except Exception as e: + error_detail = str(e) + service = self.env["billcom.service"] + friendly_message = service._extract_friendly_error(e) + + _logger.error( + "Error checking upload status for document '%s': %s", + self.name, + error_detail, + ) + + raise UserError( + _("Failed to check upload status:\n\n%s") % friendly_message + ) from e + + def _create_or_update_attachment(self, file_data_encoded): + """Create or update ir.attachment for this document + + Args: + file_data_encoded: Base64-encoded file data + """ + self.ensure_one() + + # Get or determine mimetype + import mimetypes + + mimetype = mimetypes.guess_type(self.name)[0] or "application/octet-stream" + + # Prepare attachment values + attachment_vals = { + "name": self.name, + "datas": file_data_encoded, + "res_model": "account.move", + "res_id": self.bill_id.id, + "mimetype": mimetype, + "description": f'Bill.com Document: {self.billcom_id or "pending"}', + } + + if self.attachment_id: + # Update existing attachment + self.attachment_id.write(attachment_vals) + _logger.info( + "Updated ir.attachment %d for document %s", + self.attachment_id.id, + self.name, + ) + else: + # Create new attachment + attachment = self.env["ir.attachment"].create(attachment_vals) + self.write({"attachment_id": attachment.id}) + _logger.info( + "Created ir.attachment %d for document %s", + attachment.id, + self.name, + ) + + def button_download_from_billcom(self): + """Download document from Bill.com""" + self.ensure_one() + + if not self.download_link: + raise UserError( + _( + "No download link available. Document may not be uploaded yet.\n\n" + "Use 'Check Upload Status' if upload is in progress." + ) + ) + + try: + service = self.env["billcom.service"] + + # Download file + file_data = service._download_document(self.download_link) + + if file_data: + # Update file content + file_data_encoded = base64.b64encode(file_data) + self.write({"file_data": file_data_encoded}) + + # Create or update ir.attachment + self._create_or_update_attachment(file_data_encoded) + + _logger.info( + "Downloaded document '%s' from Bill.com (%d bytes)", + self.name, + len(file_data), + ) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Download Complete"), + "message": _( + "Document downloaded successfully from " "Bill.com" + ), + "type": "success", + "sticky": False, + }, + } + + return False + + except Exception as e: + error_detail = str(e) + service = self.env["billcom.service"] + friendly_message = service._extract_friendly_error(e) + + _logger.error( + "Error downloading document '%s' from Bill.com: %s", + self.name, + error_detail, + ) + + raise UserError( + _("Failed to download document from Bill.com:\n\n%s") % friendly_message + ) from e + + @api.model + def create_from_attachment(self, attachment): + """Create a billcom.document from an ir.attachment + + Args: + attachment: ir.attachment record + + Returns: + billcom.document: Created document record + """ + if not attachment.res_model == "account.move": + raise UserError( + _("Attachment must be linked to a vendor bill (account.move)") + ) + + bill = self.env["account.move"].browse(attachment.res_id) + if not bill.exists() or bill.move_type != "in_invoice": + raise UserError(_("Attachment must be linked to a valid vendor bill")) + + # Check if document already exists for this attachment + existing = self.search( + [("attachment_id", "=", attachment.id), ("bill_id", "=", bill.id)], limit=1 + ) + + if existing: + _logger.info( + "Document already exists for attachment %d: %s", + attachment.id, + existing.name, + ) + return existing + + # Create new document + vals = { + "name": attachment.name, + "bill_id": bill.id, + "file_data": attachment.datas, + "attachment_id": attachment.id, + "upload_status": "pending", + } + + document = self.create(vals) + _logger.info( + "Created document from attachment %d: %s", + attachment.id, + document.name, + ) + + return document + + @api.model + def sync_documents_from_billcom(self, bill): + """Sync all documents for a bill from Bill.com + + Args: + bill: account.move record (vendor bill) + + Returns: + int: Number of documents synced + """ + if not bill.billcom_id: + _logger.warning("Cannot sync documents: Bill not synced to Bill.com") + return 0 + + try: + service = self.env["billcom.service"] + result = service._make_request( + f"documents/bills/{bill.billcom_id}", method="GET" + ) + + if not result or not isinstance(result, list): + _logger.info("No documents found for bill %s", bill.billcom_id) + return 0 + + synced_count = 0 + for doc_data in result: + doc_id = doc_data.get("id") + if not doc_id: + continue + + # Check if document already exists + existing = self.search( + [("billcom_id", "=", doc_id), ("bill_id", "=", bill.id)], limit=1 + ) + + vals = { + "name": doc_data.get("name", "Unknown"), + "bill_id": bill.id, + "billcom_id": doc_id, + "download_link": doc_data.get("downloadLink"), + "created_time": parse_billcom_datetime(doc_data.get("createdTime")), + "upload_status": "uploaded", + } + + if existing: + existing.write(vals) + _logger.info("Updated document %s", doc_id) + else: + document = self.create(vals) + _logger.info("Created document %s", doc_id) + + # Auto-download and create attachment for newly synced documents + try: + if document.download_link: + file_data = service._download_document( + document.download_link + ) + if file_data: + file_data_encoded = base64.b64encode(file_data) + document.write({"file_data": file_data_encoded}) + document._create_or_update_attachment(file_data_encoded) + _logger.info( + "Auto-downloaded and attached document %s (%d bytes)", + doc_id, + len(file_data), + ) + except Exception as e: + _logger.warning( + "Failed to auto-download document %s: %s", doc_id, str(e) + ) + + synced_count += 1 + + # Post to chatter + if synced_count > 0: + bill.message_post( + body=f"

Synced {synced_count} document(s) " + f"from Bill.com

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return synced_count + + except Exception as e: + error_detail = str(e) + _logger.error( + "Error syncing documents for bill %s: %s", bill.billcom_id, error_detail + ) + return 0 diff --git a/billcom_integration/models/billcom_funding_account.py b/billcom_integration/models/billcom_funding_account.py new file mode 100644 index 00000000..826547ac --- /dev/null +++ b/billcom_integration/models/billcom_funding_account.py @@ -0,0 +1,289 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class BillcomFundingAccount(models.Model): + _name = "billcom.funding.account" + _description = "Bill.com Funding Account" + _order = "is_default_payables desc, is_default_receivables desc, name" + _rec_name = "name" + + # Basic Information + name = fields.Char( + compute="_compute_name", + store=True, + ) + billcom_id = fields.Char( + string="Bill.com ID", + required=True, + index=True, + readonly=True, + help="Unique identifier from Bill.com API", + ) + bank_name = fields.Char( + readonly=True, + ) + name_on_account = fields.Char( + readonly=True, + ) + account_number = fields.Char( + readonly=True, + help="Masked account number from Bill.com", + ) + routing_number = fields.Char( + readonly=True, + ) + + # Account Details + funding_type = fields.Selection( + [ + ("BANK_ACCOUNT", "Bank Account"), + ("CARD_ACCOUNT", "Credit/Debit Card"), + ("WALLET", "BILL Balance"), + ("AP_CARD", "AP Card"), + ], + readonly=True, + help="Type of funding account for Bill.com API", + ) + account_type = fields.Selection( + [ + ("CHECKING", "Checking"), + ("SAVINGS", "Savings"), + ], + readonly=True, + ) + owner_type = fields.Selection( + [ + ("BUSINESS", "Business"), + ("PERSONAL", "Personal"), + ], + readonly=True, + ) + status = fields.Selection( + [ + ("VERIFIED", "Verified"), + ("PENDING", "Pending"), + ("UNVERIFIED", "Unverified"), + ("FAILED", "Failed"), + ], + readonly=True, + ) + + # Default Settings + is_default_payables = fields.Boolean( + string="Default for Payables", + readonly=True, + help=( + "This is the default funding account for payables " + "(vendor payments) in Bill.com" + ), + ) + is_default_receivables = fields.Boolean( + string="Default for Receivables", + readonly=True, + help=( + "This is the default funding account for receivables " + "(customer invoices) in Bill.com" + ), + ) + + # System Fields + active = fields.Boolean( + default=True, + ) + + access_to_admins = fields.Boolean( + string="Access to Admins Only", + readonly=True, + ) + created_by = fields.Char( + string="Created By (Bill.com User ID)", + readonly=True, + ) + + # Timestamps + billcom_created_time = fields.Datetime( + string="Created in Bill.com", + readonly=True, + ) + billcom_updated_time = fields.Datetime( + string="Updated in Bill.com", + readonly=True, + ) + last_sync_date = fields.Datetime( + readonly=True, + ) + + # Relations + company_id = fields.Many2one( + "res.company", + default=lambda self: self.env.company, + required=True, + ) + + _sql_constraints = [ + ( + "billcom_id_company_uniq", + "unique(billcom_id, company_id)", + "Bill.com funding account must be unique per company!", + ) + ] + + @api.depends("bank_name", "name_on_account", "account_number") + def _compute_name(self): + """Compute display name from bank name and account details""" + for record in self: + parts = [] + if record.bank_name: + parts.append(record.bank_name) + if record.name_on_account: + parts.append(f"({record.name_on_account})") + if record.account_number: + parts.append(f"[{record.account_number}]") + + record.name = " ".join(parts) if parts else "Bill.com Funding Account" + + @api.model + def sync_funding_accounts_from_billcom(self): + """ + Import/update funding accounts from Bill.com API + + Returns: + dict: Statistics about the sync operation + """ + try: + _logger.info("Starting funding accounts sync from Bill.com") + + service = self.env["billcom.service"] + funding_accounts_data = service.get_funding_accounts() + + created = 0 + updated = 0 + errors = 0 + + for account_data in funding_accounts_data: + billcom_id = None + try: + billcom_id = account_data.get("id") + if not billcom_id: + _logger.warning("Funding account without ID, skipping") + errors += 1 + continue + + # Check if funding account already exists + existing = self.search( + [ + ("billcom_id", "=", billcom_id), + ("company_id", "=", self.env.company.id), + ], + limit=1, + ) + + # Prepare values + vals = self._prepare_funding_account_vals(account_data) + + if existing: + existing.write(vals) + updated += 1 + _logger.info( + f"Updated funding account: {vals.get('bank_name')}" + ) + else: + self.create(vals) + created += 1 + _logger.info( + f"Created funding account: {vals.get('bank_name')}" + ) + + except Exception as e: + _logger.error(f"Error processing funding account {billcom_id}: {e}") + errors += 1 + + result = { + "created": created, + "updated": updated, + "errors": errors, + "total": len(funding_accounts_data), + } + + _logger.info( + "Funding accounts sync completed: %s created, %s updated, %s errors", + created, + updated, + errors, + ) + + return result + + except Exception as e: + _logger.error(f"Error syncing funding accounts from Bill.com: {e}") + raise UserError( + f"Failed to sync funding accounts from Bill.com: {str(e)}" + ) from e + + @api.model + def _prepare_funding_account_vals(self, account_data): + """ + Prepare values for creating/updating funding account from Bill.com data + + Args: + account_data: Dictionary from Bill.com API + + Returns: + dict: Values for create/write + """ + # Extract default settings + default_settings = account_data.get("default", {}) + + vals = { + "billcom_id": account_data.get("id"), + "bank_name": account_data.get("bankName"), + "name_on_account": account_data.get("nameOnAccount"), + "account_number": account_data.get("accountNumber"), + "routing_number": account_data.get("routingNumber"), + "account_type": account_data.get("type"), + "owner_type": account_data.get("ownerType"), + "status": account_data.get("status"), + "active": not account_data.get("archived", False), + "access_to_admins": account_data.get("accessToAdmins", False), + "created_by": account_data.get("createdBy"), + "is_default_payables": default_settings.get("payables", False), + "is_default_receivables": default_settings.get("receivables", False), + "last_sync_date": fields.Datetime.now(), + } + + # Parse timestamps + # created_time = account_data.get("createdTime") + # if created_time: + # vals["billcom_created_time"] = created_time + + # updated_time = account_data.get("updatedTime") + # if updated_time: + # vals["billcom_updated_time"] = updated_time + + return vals + + def action_sync_from_billcom(self): + """Action to sync funding accounts from Bill.com (can be called from UI)""" + result = self.sync_funding_accounts_from_billcom() + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "Funding Accounts Synced", + "message": ( + f"Created: {result['created']}, Updated: {result['updated']}, " + f"Errors: {result['errors']}" + ), + "type": "success" if result["errors"] == 0 else "warning", + "sticky": False, + }, + } diff --git a/billcom_integration/models/billcom_item.py b/billcom_integration/models/billcom_item.py new file mode 100644 index 00000000..51ff30f5 --- /dev/null +++ b/billcom_integration/models/billcom_item.py @@ -0,0 +1,377 @@ +# Copyright 2025 Binhex. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from .billcom_document import parse_billcom_datetime + +_logger = logging.getLogger(__name__) + + +class BillcomItem(models.Model): + _name = "billcom.item" + _description = "Bill.com Item (Classifications)" + _inherit = ["billcom.abstract.model", "mail.thread", "mail.activity.mixin"] + + # Item type - focusing on SALES_TAX for tax mapping + type = fields.Selection( + [ + ("UNDEFINED", "Undefined"), + ("UNKNOWN", "Unknown"), + ("SERVICE", "Service"), + ("INVENTORY", "Inventory"), + ("NON_INVENTORY", "Non-Inventory"), + ("PAYMENT", "Payment"), + ("DISCOUNT", "Discount"), + ("SALES_TAX", "Sales Tax"), + ("SUBTOTAL", "Subtotal"), + ("OTHER_CHARGE", "Other Charge"), + ("INVENTORY_ASSEMBLY", "Inventory Assembly"), + ("GROUP", "Group"), + ("SALES_TAX_GROUP", "Sales Tax Group"), + ("FIXED_ASSET", "Fixed Asset"), + ("CATEGORY", "Category"), + ("EXPENSE", "Expense"), + ], + string="Item Type", + required=True, + default="SALES_TAX", + help="Type of item in Bill.com. SALES_TAX is used for tax mapping.", + ) + + short_name = fields.Char(help="Item short name in Bill.com") + + name = fields.Char( + string="Item Name", + required=True, + help="Item name in Bill.com (must be >= 1 character)", + ) + + description = fields.Text(help="Item description") + + archived = fields.Boolean( + default=False, + help="Set as true if the item is archived in Bill.com", + ) + + parent_id = fields.Many2one( + "billcom.item", + string="Parent Item", + help="Parent item if this item is a child object", + ) + + price = fields.Float(help="Item price") + + purchase_cost = fields.Float(help="Item purchase cost in accounting system") + + purchase_description = fields.Text( + help="Item description when used for bills/purchases", + ) + + taxable = fields.Boolean(help="Set as true if item is taxable") + + percentage = fields.Float( + string="Tax Percentage", + digits=(16, 4), + help="Tax percentage for SALES_TAX, DISCOUNT, or OTHER_CHARGE types", + ) + + # Odoo tax mapping (inverse relation - taxes reference items) + tax_ids = fields.One2many( + "account.tax", + "billcom_item_id", + string="Linked Odoo Taxes", + help="Odoo taxes that use this Bill.com item", + readonly=True, + ) + + # Chart of accounts references + chart_of_account_id = fields.Char( + string="Chart of Account ID", + help="Bill.com chart of accounts ID (begins with 0ca)", + ) + + expense_chart_of_account_id = fields.Char( + string="Expense Chart of Account ID", + help="Bill.com expense chart of accounts ID (begins with 0ca)", + ) + + # Timestamps from Bill.com + created_time = fields.Datetime(readonly=True, help="Created date/time in Bill.com") + + updated_time = fields.Datetime(readonly=True, help="Updated date/time in Bill.com") + + def button_sync_to_billcom(self): + """Sync item to Bill.com""" + self.ensure_one() + if not self.is_sync_to_billcom: + return False + + try: + service = self.env["billcom.service"] + return service.sync_item(self) + except Exception as e: + _logger.error("Error syncing item %s to Bill.com: %s", self.name, str(e)) + raise UserError(_("Error syncing to Bill.com: %s") % str(e)) from e + + def _prepare_item_data(self): + """Prepare item data for Bill.com API""" + self.ensure_one() + + data = { + "type": self.type, + "name": self.name or "Tax Item", + } + + # Optional fields + if self.short_name: + data["shortName"] = self.short_name + if self.description: + data["description"] = self.description + if self.parent_id and self.parent_id.billcom_id: + data["parentId"] = self.parent_id.billcom_id + if self.price: + data["price"] = self.price + if self.purchase_cost: + data["purchaseCost"] = self.purchase_cost + if self.purchase_description: + data["purchaseDescription"] = self.purchase_description + if self.chart_of_account_id: + data["chartOfAccountId"] = self.chart_of_account_id + if self.expense_chart_of_account_id: + data["expenseChartOfAccountId"] = self.expense_chart_of_account_id + + # Tax-specific fields + if self.type in ["SALES_TAX", "DISCOUNT", "OTHER_CHARGE"]: + if self.percentage: + data["percentage"] = self.percentage + if self.taxable is not False: # Include if explicitly set + data["taxable"] = self.taxable + + return data + + @api.model + def sync_from_odoo_taxes(self): + """Sync all Odoo taxes to Bill.com as SALES_TAX items + + This creates Bill.com items for Odoo taxes that don't have a mapping yet. + """ + # Get all active taxes + taxes = self.env["account.tax"].search( + [("active", "=", True), ("type_tax_use", "in", ["sale", "purchase"])] + ) + + synced_count = 0 + error_count = 0 + + for tax in taxes: + # Check if already mapped + if tax.billcom_item_id: + _logger.info( + "Tax %s already mapped to Bill.com item %s", + tax.name, + tax.billcom_item_id.billcom_id, + ) + continue + + # Create new item for this tax + try: + item_vals = { + "name": tax.name, + "type": "SALES_TAX", + "description": tax.description or tax.name, + "percentage": tax.amount, + "taxable": False, # Tax items themselves are not taxable + "is_sync_to_billcom": True, + } + + item = self.create(item_vals) + # Sync to Bill.com + item.button_sync_to_billcom() + + # Link tax to item + tax.write({"billcom_item_id": item.id}) + + synced_count += 1 + _logger.info("Created and synced Bill.com item for tax %s", tax.name) + except Exception as e: + error_count += 1 + _logger.error( + "Failed to create/sync Bill.com item for tax %s: %s", + tax.name, + str(e), + ) + + return { + "synced": synced_count, + "errors": error_count, + "total": len(taxes), + } + + @api.model + def get_item_for_tax(self, tax): + """Get Bill.com item ID for an Odoo tax + + Args: + tax: account.tax record + + Returns: + str: Bill.com item ID or False + """ + if not tax or not tax.billcom_item_id: + return False + + return tax.billcom_item_id.billcom_id or False + + @api.model + def sync_items_from_billcom(self, item_type=None): # noqa: C901 + """Import/update items from Bill.com API + + Args: + item_type (str, optional): Filter by item type (e.g., 'SALES_TAX') + + Returns: + dict: Statistics about the sync operation + """ + try: + _logger.info( + "Starting items sync from Bill.com (type: %s)", item_type or "all" + ) + + service = self.env["billcom.service"] + items_data = service.get_items(item_type=item_type) + + created = 0 + updated = 0 + errors = 0 + + for item_data in items_data: + billcom_id = None + try: + billcom_id = item_data.get("id") + if not billcom_id: + _logger.warning("Item without ID, skipping") + errors += 1 + continue + + # Check if item already exists + existing = self.search([("billcom_id", "=", billcom_id)], limit=1) + + # Prepare values from Bill.com data + vals = { + "billcom_id": billcom_id, + "billcom": billcom_id, + "name": item_data.get("name", "Unknown Item"), + "type": item_data.get("type", "UNKNOWN"), + "billcom_sync_status": "synced", + "last_sync_date": fields.Datetime.now(), + } + + # Optional fields + if item_data.get("shortName"): + vals["short_name"] = item_data.get("shortName") + if item_data.get("description"): + vals["description"] = item_data.get("description") + if item_data.get("percentage") is not None: + vals["percentage"] = item_data.get("percentage") + if item_data.get("price") is not None: + vals["price"] = item_data.get("price") + if item_data.get("purchaseCost") is not None: + vals["purchase_cost"] = item_data.get("purchaseCost") + if item_data.get("purchaseDescription"): + vals["purchase_description"] = item_data.get( + "purchaseDescription" + ) + if item_data.get("taxable") is not None: + vals["taxable"] = item_data.get("taxable") + if item_data.get("chartOfAccountId"): + vals["chart_of_account_id"] = item_data.get("chartOfAccountId") + if item_data.get("expenseChartOfAccountId"): + vals["expense_chart_of_account_id"] = item_data.get( + "expenseChartOfAccountId" + ) + if item_data.get("parentId"): + # Try to find parent item + parent = self.search( + [("billcom_id", "=", item_data.get("parentId"))], limit=1 + ) + if parent: + vals["parent_id"] = parent.id + if item_data.get("isActive") is not None: + vals["archived"] = not item_data.get("isActive") + + # Parse datetime fields from ISO format + if item_data.get("createdTime"): + created_dt = parse_billcom_datetime( + item_data.get("createdTime") + ) + if created_dt: + vals["created_time"] = created_dt + + if item_data.get("updatedTime"): + updated_dt = parse_billcom_datetime( + item_data.get("updatedTime") + ) + if updated_dt: + vals["updated_time"] = updated_dt + + if existing: + existing.write(vals) + item_record = existing + updated += 1 + _logger.info( + "Updated item: %s (ID: %s)", vals["name"], billcom_id + ) + else: + item_record = self.create(vals) + created += 1 + _logger.info( + "Created item: %s (ID: %s)", vals["name"], billcom_id + ) + + # Try to auto-map SALES_TAX items to Odoo taxes by percentage + if vals.get("type") == "SALES_TAX" and vals.get("percentage"): + # Search for matching tax by percentage + matching_tax = self.env["account.tax"].search( + [ + ("amount", "=", vals["percentage"]), + ("type_tax_use", "in", ["sale", "purchase"]), + ("active", "=", True), + ("billcom_item_id", "=", False), # Not already mapped + ], + limit=1, + ) + if matching_tax: + matching_tax.write({"billcom_item_id": item_record.id}) + _logger.info( + "Auto-mapped Bill.com item %s to Odoo tax %s", + vals["name"], + matching_tax.name, + ) + + except Exception as e: + errors += 1 + _logger.error( + "Error syncing item %s: %s", billcom_id or "unknown", str(e) + ) + + _logger.info( + "Items sync complete: %d created, %d updated, %d errors", + created, + updated, + errors, + ) + return { + "created": created, + "updated": updated, + "errors": errors, + "synced": created + updated, + "total": len(items_data), + } + + except Exception as e: + _logger.error("Failed to sync items from Bill.com: %s", str(e)) + raise diff --git a/billcom_integration/models/billcom_logger.py b/billcom_integration/models/billcom_logger.py new file mode 100644 index 00000000..49e5afac --- /dev/null +++ b/billcom_integration/models/billcom_logger.py @@ -0,0 +1,271 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import traceback +from datetime import timedelta + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class BillcomLogger(models.Model): + _name = "billcom.logger" + _description = "Bill.com Integration Logger" + _order = "create_date desc" + _rec_name = "operation_type" + + # Basic Information + operation_type = fields.Selection( + [ + ("sync_vendor", "Vendor Sync"), + ("sync_customer", "Customer Sync"), + ("sync_bill", "Bill Sync"), + ("sync_invoice", "Invoice Sync"), + ("sync_payment", "Payment Sync"), + ("sync_attachment", "Attachment Sync"), + ("webhook", "Webhook Processing"), + ("auth", "Authentication"), + ("api_request", "API Request"), + ("manual_sync", "Manual Sync"), + ("scheduled_sync", "Scheduled Sync"), + ], + required=True, + ) + + level = fields.Selection( + [ + ("debug", "Debug"), + ("info", "Info"), + ("warning", "Warning"), + ("error", "Error"), + ("critical", "Critical"), + ], + string="Log Level", + required=True, + default="info", + ) + + status = fields.Selection( + [ + ("pending", "Pending"), + ("processing", "Processing"), + ("success", "Success"), + ("warning", "Warning with Success"), + ("error", "Error"), + ("retry", "Retrying"), + ], + required=True, + default="pending", + ) + + # Context Information + record_model = fields.Char() + record_id = fields.Integer() + record_name = fields.Char() + billcom_id = fields.Char(string="Bill.com ID") + + # Request/Response Details + endpoint = fields.Char(string="API Endpoint") + http_method = fields.Char() + request_data = fields.Text() + response_data = fields.Text() + response_status_code = fields.Integer(string="HTTP Status Code") + + # Timing and Performance + start_time = fields.Datetime() + end_time = fields.Datetime() + duration = fields.Float( + string="Duration (seconds)", compute="_compute_duration", store=True + ) + retry_count = fields.Integer(default=0) + max_retries = fields.Integer(default=3) + + # Message and Error Details + message = fields.Text() + error_message = fields.Text() + error_traceback = fields.Text() + + # User and Company Context + user_id = fields.Many2one( + "res.users", string="User", default=lambda self: self.env.user + ) + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + config_id = fields.Many2one("billcom.config", string="Bill.com Configuration") + + # Sync Queue Reference + sync_queue_id = fields.Many2one("billcom.sync.queue", string="Sync Queue Item") + + @api.depends("start_time", "end_time") + def _compute_duration(self): + for record in self: + if record.start_time and record.end_time: + delta = record.end_time - record.start_time + record.duration = delta.total_seconds() + else: + record.duration = 0.0 + + @api.model + def log_operation(self, operation_type, level="info", status="pending", **kwargs): + """Create a log entry for Bill.com operations""" + values = { + "operation_type": operation_type, + "level": level, + "status": status, + "start_time": fields.Datetime.now(), + } + values.update(kwargs) + return self.create(values) + + @api.model + def log_api_request(self, endpoint, method="GET", status="pending", **kwargs): + """Log API request details""" + return self.log_operation( + "api_request", + endpoint=endpoint, + http_method=method, + status=status, + **kwargs, + ) + + @api.model + def log_sync_operation( + self, sync_type, record_model=None, record_id=None, **kwargs + ): + """Log synchronization operations""" + record_name = None + if record_model and record_id: + try: + record = self.env[record_model].browse(record_id) + record_name = ( + record.display_name if record.exists() else f"ID {record_id}" + ) + except Exception: + record_name = f"ID {record_id}" + + return self.log_operation( + sync_type, + record_model=record_model, + record_id=record_id, + record_name=record_name, + **kwargs, + ) + + @api.model + def log_webhook(self, event_type, entity_id=None, **kwargs): + """Log webhook processing""" + return self.log_operation( + "webhook", message=f"Webhook: {event_type}", billcom_id=entity_id, **kwargs + ) + + def update_status(self, status, message=None, error_message=None, **kwargs): + """Update log entry status and end time""" + values = { + "status": status, + "end_time": fields.Datetime.now(), + } + if message: + values["message"] = message + if error_message: + values["error_message"] = error_message + if status == "error" and not values.get("error_traceback"): + values["error_traceback"] = traceback.format_exc() + + values.update(kwargs) + self.write(values) + + def mark_success(self, message=None, **kwargs): + """Mark operation as successful""" + self.update_status("success", message=message, **kwargs) + + def mark_error(self, error_message, **kwargs): + """Mark operation as failed""" + self.update_status("error", error_message=error_message, **kwargs) + + def mark_retry(self, retry_count=None, **kwargs): + """Mark operation for retry""" + if retry_count is not None: + kwargs["retry_count"] = retry_count + self.update_status("retry", **kwargs) + + @api.model + def cleanup_old_logs(self, days=30): + """Clean up old log entries""" + cutoff_date = fields.Datetime.now() - timedelta(days=days) + old_logs = self.search([("create_date", "<", cutoff_date)]) + count = len(old_logs) + old_logs.unlink() + _logger.info(f"Cleaned up {count} old Bill.com log entries") + return count + + def action_view_related_record(self): + """Action to view the related record""" + self.ensure_one() + if not self.record_model or not self.record_id: + return False + + return { + "type": "ir.actions.act_window", + "name": f"Related {self.record_model}", + "res_model": self.record_model, + "res_id": self.record_id, + "view_mode": "form", + "target": "current", + } + + def action_retry_operation(self): + """Retry the failed operation""" + self.ensure_one() + if self.sync_queue_id: + return self.sync_queue_id.action_retry() + return False + + @api.model + def get_operation_stats(self, operation_type=None, days=7): + """Get operation statistics""" + domain = [("create_date", ">=", fields.Datetime.now() - timedelta(days=days))] + if operation_type: + domain.append(("operation_type", "=", operation_type)) + + logs = self.search(domain) + + stats = { + "total": len(logs), + "success": len(logs.filtered(lambda line: line.status == "success")), + "error": len(logs.filtered(lambda line: line.status == "error")), + "pending": len( + logs.filtered( + lambda line: line.status in ["pending", "processing", "retry"] + ) + ), + "avg_duration": sum(logs.mapped("duration")) / len(logs) if logs else 0, + } + + stats["success_rate"] = ( + (stats["success"] / stats["total"] * 100) if stats["total"] else 0 + ) + + return stats + + @api.model + def get_error_summary(self, days=7, limit=10): + """Get summary of recent errors""" + domain = [ + ("create_date", ">=", fields.Datetime.now() - timedelta(days=days)), + ("status", "=", "error"), + ] + + error_logs = self.search(domain, limit=limit) + + return [ + { + "id": log.id, + "operation_type": log.operation_type, + "record_name": log.record_name, + "error_message": log.error_message, + "create_date": log.create_date, + "duration": log.duration, + } + for log in error_logs + ] diff --git a/billcom_integration/models/billcom_payment_purpose.py b/billcom_integration/models/billcom_payment_purpose.py new file mode 100644 index 00000000..0d23bcc5 --- /dev/null +++ b/billcom_integration/models/billcom_payment_purpose.py @@ -0,0 +1,190 @@ +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class BillcomPaymentPurpose(models.Model): + _name = "billcom.payment.purpose" + _description = "Bill.com Payment Purpose" + _order = "country_id, code" + + name = fields.Char(compute="_compute_get_name", store=True) + code = fields.Char(required=True) + description = fields.Char() + active = fields.Boolean(default=True) + country_id = fields.Many2one( + "res.country", + required=True, + default=lambda self: self.env.ref("base.us", raise_if_not_found=False), + ) + currency_id = fields.Many2one( + "res.currency", + string="Bill Currency", + help="Currency used for bills to vendors in this country", + ) + account_type = fields.Selection( + [ + ("NONE", "None"), + ("CHECKING", "Checking"), + ("SAVINGS", "Savings"), + ], + default="NONE", + help="Bank account type for international payments", + ) + billcom_data = fields.Text( + "Bill.com Raw Data", help="Raw JSON data from Bill.com API for reference" + ) + + _sql_constraints = [ + ( + "unique_payment_purpose", + "unique(country_id, currency_id, account_type, code)", + "Payment purpose must be unique per country, currency, account type and code!", + ) + ] + + @api.depends("code", "description") + def _compute_get_name(self): + for record in self: + record.name = f"{record.code} - \ + {record.description[:50] if record.description else ''}" + + @api.model + def fetch_payment_purposes_for_config( + self, country_id, currency_id, account_type="NONE" + ): + country = self.env["res.country"].browse(country_id) + currency = self.env["res.currency"].browse(currency_id) + + if not country or not currency: + raise UserError(_("Invalid country or currency")) + + # Don't fetch for US vendors + if country.code == "US": + raise UserError( + _( + "Payment purposes are only required for " + "international (non-US) vendors" + ) + ) + + service = self.env["billcom.service"] + + try: + # Build API endpoint with query parameters + params = { + "country": country.code, + "billCurrency": currency.name, + "accountType": account_type, + } + + _logger.info( + "Fetching payment purposes from Bill.com for " + "country=%s, currency=%s, accountType=%s", + country.code, + currency.name, + account_type, + ) + + response = service._make_request( + "vendors/configuration/international-payments", + method="GET", + params=params, + ) + + if not response: + raise UserError(_("No response from Bill.com API")) + + international_payments = response.get("internationalPayments", {}) + payment_purpose_config = international_payments.get("paymentPurpose", {}) + + if not payment_purpose_config: + _logger.warning( + f"No payment purpose configuration returned for " + f"country={country.code}, " + f"currency={currency.name}, accountType={account_type}" + ) + return self.env["billcom.payment.purpose"] + + # Get payment purpose type and codes + purpose_required = payment_purpose_config.get("required", False) + purpose_type = payment_purpose_config.get("type", "CODE") # CODE or TEXT + purpose_codes = payment_purpose_config.get("codes", []) + + _logger.info( + "Payment purpose config: required=%s, type=%s, " "codes_count=%s", + purpose_required, + purpose_type, + len(purpose_codes) if isinstance(purpose_codes, list) else 0, + ) + + # If type is TEXT, there are no predefined codes + if purpose_type == "TEXT" or not purpose_codes: + _logger.info( + f"Payment purpose type is TEXT or no codes available for " + f"country={country.code}. " + f"User must enter free text." + ) + return self.env["billcom.payment.purpose"] + + # Process payment purpose codes + created_records = self.env["billcom.payment.purpose"] + + for purpose_data in purpose_codes: + purpose_code = purpose_data.get("value") + purpose_desc = purpose_data.get("name", "") + + if not purpose_code: + _logger.warning( + f"Skipping payment purpose without value: {purpose_data}" + ) + continue + + # Check if record already exists + existing = self.search( + [ + ("country_id", "=", country.id), + ("currency_id", "=", currency.id), + ("account_type", "=", account_type), + ("code", "=", purpose_code), + ], + limit=1, + ) + + vals = { + "code": purpose_code, + "description": purpose_desc, + "country_id": country.id, + "currency_id": currency.id, + "account_type": account_type, + "billcom_data": str(payment_purpose_config), + "active": True, + } + + if existing: + # Update existing record + existing.write(vals) + created_records |= existing + _logger.info( + "Updated payment purpose: %s - %s", + purpose_code, + purpose_desc, + ) + else: + # Create new record + new_record = self.create(vals) + created_records |= new_record + _logger.info( + f"Created payment purpose: {purpose_code} - {purpose_desc}" + ) + + return created_records + + except Exception as e: + _logger.error(f"Error fetching payment purposes from Bill.com: {e}") + raise UserError( + _("Error fetching payment purposes from Bill.com: %s") % str(e) + ) from e diff --git a/billcom_integration/models/billcom_service.py b/billcom_integration/models/billcom_service.py new file mode 100644 index 00000000..d3775165 --- /dev/null +++ b/billcom_integration/models/billcom_service.py @@ -0,0 +1,3652 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from .billcom_document import parse_billcom_datetime + +_logger = logging.getLogger(__name__) + + +class BillcomService(models.AbstractModel): + _name = "billcom.service" + _description = "Bill.com Integration Service" + _inherit = ["billcom.service.abstract"] + + @api.model + def sync_partner(self, partner, partner_type="vendor"): + """Sync single partner to Bill.com""" + # Check if sync is enabled for this partner type + config = self._get_config() + if partner_type == "vendor" and not config.sync_vendors: + _logger.info("Vendor synchronization is disabled") + return False + elif partner_type == "customer" and not config.sync_customers: + _logger.info("Customer synchronization is disabled") + return False + + # Skip if partner is not marked for sync + if not partner.is_sync_to_billcom: + _logger.info( + "%s %s not marked for sync to BillCom", partner_type, partner.name + ) + return False + + # Use context to prevent infinite loops + if self.env.context.get("skip_billcom_sync"): + _logger.info( + "Skipping sync for %s %s due to context", partner_type, partner.name + ) + return False + + # Prepare partner data + partner_data = partner._prepare_partner_data(partner_type) + _logger.info( + "Partner data prepared for %s %s: %s", + partner_type, + partner.name, + partner_data, + ) + if not partner_data: + _logger.warning("No data prepared for %s %s", partner_type, partner.name) + return False + + # Verify required fields are present and not null + if not partner_data.get("name"): + _logger.warning( + "Name is missing or blank for %s %s", partner_type, partner.name + ) + partner_data["name"] = partner.name or "Unknown" + + if partner_type == "vendor" and not partner_data.get("address"): + _logger.warning("Address is missing or null for vendor %s", partner.name) + # Create a default address if missing + partner_data["address"] = { + "line1": partner.street or "Unknown", + "city": partner.city or "Unknown", + "stateOrProvince": partner.state_id.name if partner.state_id else "", + "zipOrPostalCode": partner.zip or "", + "country": partner.country_id.code if partner.country_id else "US", + } + + # Log the final data structure being sent + _logger.info( + "Final %s data being sent to Bill.com: %s", partner_type, partner_data + ) + + # Set endpoint based on partner type + endpoint = "vendors" if partner_type == "vendor" else "customers" + + # Make request to Bill.com API + try: + existing_id = partner.billcom_id or partner.billcom + + if existing_id: + if ( + partner.country_id.code != "US" + and partner.billcom_payment_purpose_id + ): + partner_data.setdefault("paymentInformation", {}).update( + { + "paymentPurpose": { + "code": { + "name": partner.billcom_payment_purpose_id.code, + "value": partner.billcom_payment_purpose_id.description, + } + } + } + ) + + _logger.info( + "Updating partner_data %s with paymentPurpose in Bill.com", + partner_data, + ) + + _logger.info( + "Updating existing %s with ID %s in Bill.com", + partner_type, + existing_id, + ) + + result = self._make_request( + f"{endpoint}/{existing_id}", method="PATCH", data=partner_data + ) + else: + _logger.info("Creating new %s in Bill.com", partner_type) + result = self._make_request(endpoint, method="POST", data=partner_data) + + _logger.debug( + "Bill.com API response for %s %s: %s", + partner_type, + partner.name, + result, + ) + + if result and result.get("id"): + partner_id = result.get("id") + action = "updated" if existing_id else "created" + + partner.with_context(skip_billcom_sync=True).write( + { + "billcom": partner_id, + "billcom_id": partner_id, + "last_sync_date": fields.Datetime.now(), + "billcom_sync_status": "synced", + "billcom_sync_error": False, + } + ) + + _logger.info( + "Successfully synced %s %s to Bill.com with ID: %s", + partner_type, + partner.name, + partner_id, + ) + + partner.message_post( + body=f"

Bill.com Sync Successful

" + f"
    " + f"
  • Type: {partner_type.title()}
  • " + f"
  • Action: {action.title()}
  • " + f"
  • Bill.com ID: {partner_id}
  • " + f"
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + if partner_type == "vendor" and partner.bank_ids: + _logger.info( + "Bank account for vendor %s included in paymentInformation", + partner.name, + ) + if result.get("paymentInformation"): + bank = partner.bank_ids[0] + bank.with_context(skip_billcom_sync=True).write( + { + "billcom_last_sync_date": fields.Datetime.now(), + } + ) + + return partner_id + else: + error_msg = ( + f"Unexpected response format from Bill.com API. Response: {result}" + ) + _logger.warning( + "%s for %s %s: %s", + error_msg, + partner_type, + partner.name, + result, + ) + partner.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": error_msg, + } + ) + partner.message_post( + body=f"

Bill.com Sync Failed

" + f"

{error_msg}

" + f"

Response: {result}

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + return False + except Exception as e: + error_detail = str(e) + + friendly_message = self._extract_friendly_error(e) + + _logger.error( + "%s sync failed for %s: %s", + partner_type.title(), + partner.name, + error_detail, + ) + + partner.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": friendly_message, + } + ) + + partner.message_post( + body=f"

Bill.com Sync Error

" + f"

Failed to sync {partner_type} to Bill.com

" + f"

Error: {friendly_message}

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + raise UserError( + _("Failed to sync %(type)s to Bill.com:\n\n%(message)s") + % {"type": partner_type, "message": friendly_message} + ) from e + + def sync_item(self, item): + if not item.is_sync_to_billcom: + _logger.info("Item %s not marked for sync to Bill.com", item.name) + return False + + if self.env.context.get("skip_billcom_sync"): + _logger.info("Skipping sync for item %s due to context", item.name) + return False + + item_data = item._prepare_item_data() + _logger.info("Item data prepared for %s: %s", item.name, item_data) + + if not item_data: + _logger.warning("No data prepared for item %s", item.name) + return False + + try: + existing_id = item.billcom_id or item.billcom + + if existing_id: + _logger.info( + "Updating existing item with ID %s in Bill.com", existing_id + ) + result = self._make_request( + f"classifications/items/{existing_id}", + method="PATCH", + data=item_data, + ) + else: + _logger.info("Creating new item in Bill.com") + result = self._make_request( + "classifications/items", method="POST", data=item_data + ) + + _logger.debug("Bill.com API response for item %s: %s", item.name, result) + + if result and result.get("id"): + item_id = result.get("id") + action = "updated" if existing_id else "created" + + update_vals = { + "billcom": item_id, + "billcom_id": item_id, + "last_sync_date": fields.Datetime.now(), + "billcom_sync_status": "synced", + "billcom_sync_error": False, + } + + if result.get("createdTime"): + created_dt = parse_billcom_datetime(result.get("createdTime")) + if created_dt: + update_vals["created_time"] = created_dt + + if result.get("updatedTime"): + updated_dt = parse_billcom_datetime(result.get("updatedTime")) + if updated_dt: + update_vals["updated_time"] = updated_dt + + item.with_context(skip_billcom_sync=True).write(update_vals) + + _logger.info( + "Successfully synced item %s to Bill.com with ID: %s", + item.name, + item_id, + ) + + item.message_post( + body=f"

Bill.com Sync Successful

" + f"
    " + f"
  • Type: {item.type}
  • " + f"
  • Action: {action.title()}
  • " + f"
  • Bill.com ID: {item_id}
  • " + f"
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return item_id + else: + error_msg = ( + f"Unexpected response format from Bill.com API. Response: {result}" + ) + _logger.warning("%s for item %s", error_msg, item.name) + item.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": error_msg, + } + ) + item.message_post( + body=f"

Bill.com Sync Failed

" + f"

{error_msg}

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + return False + except Exception as e: + error_detail = str(e) + + friendly_message = self._extract_friendly_error(e) + + _logger.error("Item sync failed for %s: %s", item.name, error_detail) + + item.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": friendly_message, + } + ) + + item.message_post( + body=f"

Bill.com Sync Error

" + f"

Failed to sync item to Bill.com

" + f"

Error: {friendly_message}

", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + raise UserError( + _("Failed to sync item to Bill.com:\n\n%s") % friendly_message + ) from e + + def get_items(self, item_type=None): + try: + _logger.info("Fetching items from Bill.com (type: %s)", item_type or "all") + + # Build query parameters + params = {} + if item_type: + params["type"] = item_type + + # Make API request - GET /v3/classifications/items + result = self._make_request( + "classifications/items", method="GET", params=params + ) + + if result and isinstance(result, list): + _logger.info("Retrieved %d items from Bill.com", len(result)) + return result + elif result and isinstance(result, dict) and "items" in result: + # Some APIs return {items: [...]} + items = result.get("items", []) + _logger.info("Retrieved %d items from Bill.com", len(items)) + return items + else: + _logger.warning( + "Unexpected response format from Bill.com items API: %s", result + ) + return [] + + except Exception as e: + _logger.error("Error fetching items from Bill.com: %s", str(e)) + raise + + @api.model + def full_sync(self): + # Get configuration + try: + config = self.env["billcom.config"].sudo().get_config() + # Only run if the interval is greater than 0 + if hasattr(config, "full_sync_interval") and config.full_sync_interval > 0: + self.sync_all() + else: + _logger.info( + "Bill.com full synchronization is disabled " "(interval set to 0)" + ) + except Exception as e: + _logger.error("Error in Bill.com full synchronization: %s", str(e)) + + @api.model + def payment_sync(self): + # Get configuration + try: + config = self.env["billcom.config"].sudo().get_config() + # Only run if the interval is greater than 0 and payment sync is enabled + if ( + hasattr(config, "payment_sync_interval") + and config.payment_sync_interval > 0 + and config.sync_payments + ): + self.sync_payments() + else: + _logger.info("Bill.com payment synchronization is disabled") + except Exception as e: + _logger.error("Error in Bill.com payment synchronization: %s", str(e)) + + @api.model + def sync_all(self): # noqa: C901 + """Synchronize all entities with Bill.com""" + try: + config = self._get_config() + if not config.auto_sync_enabled: + _logger.info("Automatic sync is disabled in configuration") + return {} + except UserError as e: + _logger.warning(str(e)) + return 0 + + results = {} + + # Sync partners based on configuration + if config.sync_vendors: + try: + results["vendors"] = self.sync_partners("vendor") + _logger.info("Synced vendors with Bill.com") + except Exception as e: + _logger.error("Error syncing vendors: %s", str(e)) + results["vendors_error"] = str(e) + + if config.sync_customers: + try: + results["customers"] = self.sync_partners("customer") + _logger.info("Synced customers with Bill.com") + except Exception as e: + _logger.error("Error syncing customers: %s", str(e)) + results["customers_error"] = str(e) + + # Sync documents based on configuration + if config.sync_bills: + try: + results["bills"] = self.sync_bills() + _logger.info("Synced bills with Bill.com") + except Exception as e: + _logger.error("Error syncing bills: %s", str(e)) + results["bills_error"] = str(e) + + if config.sync_invoices: + try: + results["invoices"] = self.sync_invoices() + _logger.info("Synced invoices with Bill.com") + except Exception as e: + _logger.error("Error syncing invoices: %s", str(e)) + results["invoices_error"] = str(e) + + # Sync payments if enabled + if config.sync_payments: + try: + results["payments"] = self.sync_payments() + _logger.info("Synced payments with Bill.com") + except Exception as e: + _logger.error("Error syncing payments: %s", str(e)) + results["payments_error"] = str(e) + + # Update last sync date + config.sudo().write({"last_sync_date": fields.Datetime.now()}) + + return results + + def _validate_bank_sync_prerequisites(self, partner): + """Validate all prerequisites for bank account sync.""" + if not partner: + _logger.error("No partner provided to sync_vendor_bank_account") + return False, None, None + + try: + config = self._get_config() + if not config: + _logger.error("No active Bill.com configuration found") + return False, None, None + except Exception as e: + _logger.error("Error getting Bill.com configuration: %s", str(e)) + return False, None, None + + partner_billcom_id = partner.billcom_id or partner.billcom + + if not partner_billcom_id: + _logger.warning( + "Cannot sync bank account: Partner %s has no Bill.com ID", + partner.name, + ) + return False, None, None + + if not partner.supplier_rank: + _logger.warning( + "Cannot sync bank account: Partner %s is not a vendor", partner.name + ) + return False, None, None + + if not partner.bank_ids: + _logger.info("Vendor %s has no bank accounts to sync", partner.name) + return False, None, None + + bank = partner.bank_ids[0] + + # Validate account number + if not bank.acc_number or bank.acc_number == "": + _logger.error( + "Cannot sync bank account: Missing account number for vendor %s", + partner.name, + ) + return False, None, None + + # Validate routing number for US accounts + if partner.country_id and partner.country_id.code == "US": + routing_number = bank.aba_routing or ( + bank.bank_id.routing_number if bank.bank_id else None + ) + if not routing_number or routing_number == "": + _logger.error( + "Cannot sync bank account: Missing routing number for US vendor %s", + partner.name, + ) + return False, None, None + + return True, partner_billcom_id, bank + + def _check_existing_bank_account(self, partner_billcom_id, partner_name): + """Check if vendor has existing bank account in Bill.com.""" + try: + result = self._make_request( + f"vendors/{partner_billcom_id}/bank-account", method="GET" + ) + + if isinstance(result, list): + _logger.info( + "Received list response for bank account check: %s", result + ) + return False + elif isinstance(result, dict) and result.get("id"): + _logger.info( + "Found existing bank account for vendor %s in Bill.com", + partner_name, + ) + return True + else: + _logger.info( + "No bank account found for vendor %s in Bill.com", partner_name + ) + return False + except Exception as e: + if "404" in str(e): + _logger.info( + "No bank account found for vendor %s in Bill.com (404 response)", + partner_name, + ) + else: + _logger.warning( + "Error checking vendor bank account, will try to create: %s", + str(e), + ) + return False + + def _delete_existing_bank_account(self, partner_billcom_id, partner_name): + """Delete existing bank account from Bill.com.""" + _logger.info( + "Deleting existing bank account for vendor %s in Bill.com", + partner_name, + ) + try: + self._make_request( + f"vendors/{partner_billcom_id}/bank-account", method="DELETE" + ) + _logger.info( + "Successfully deleted bank account for vendor %s in Bill.com", + partner_name, + ) + except Exception as e: + _logger.warning( + "Error deleting bank account, will try to create anyway: %s", + str(e), + ) + + def _build_bank_account_payload(self, bank, partner): + """Build bank account payload for Bill.com API.""" + account_number = bank.acc_number + if not account_number or account_number == "": + _logger.error( + "Cannot create bank account: Missing account number for vendor %s", + partner.name, + ) + return None + + bank_data = { + "nameOnAccount": bank.acc_holder_name or partner.name, + "accountNumber": account_number, + "type": bank.billcom_account_type or "CHECKING", + "ownerType": bank.billcom_owner_type + or ("BUSINESS" if partner.company_type == "company" else "PERSONAL"), + "paymentCurrency": bank.currency_id.name if bank.currency_id else "USD", + } + + routing_number = bank.aba_routing or ( + bank.bank_id.routing_number if bank.bank_id else None + ) + + if partner.country_id and partner.country_id.code == "US": + if not routing_number or routing_number == "": + _logger.error( + "Cannot create bank account: Missing routing number for vendor %s", + partner.name, + ) + return None + bank_data.update({"routingNumber": routing_number}) + else: + if not bank.bank_id.bic: + bank_data.update({"routingNumber": routing_number}) + + bank_data.setdefault("bankInfo", {}).update( + {"countryISO": partner.country_id.code} + ) + + if bank.bank_id: + self._add_international_bank_info(bank_data, bank) + + return bank_data + + def _add_international_bank_info(self, bank_data, bank): + """Add international bank information to payload.""" + bank_data["bankInfo"].update({"branchName": ""}) + + if bank.bank_id.name: + bank_data["bankInfo"]["institutionName"] = bank.bank_id.name + if bank.bank_id.bic: + bank_data["bankInfo"]["swiftBIC"] = bank.bank_id.bic + + if bank.bank_id.street: + bank_data["bankInfo"].setdefault("address", {})[ + "line1" + ] = bank.bank_id.street + if bank.bank_id.city: + bank_data["bankInfo"].setdefault("address", {})["city"] = bank.bank_id.city + if bank.bank_id.state_id: + bank_data["bankInfo"].setdefault("address", {})[ + "stateOrProvince" + ] = bank.bank_id.state_id.code + if bank.bank_id.zip: + bank_data["bankInfo"].setdefault("address", {})[ + "zipOrPostalCode" + ] = bank.bank_id.zip + if bank.bank_id.country_id: + bank_data["bankInfo"].setdefault("address", {})[ + "country" + ] = bank.bank_id.country_id.name + + def _create_bank_account_in_billcom( + self, partner_billcom_id, bank_data, bank, partner + ): + """Create bank account in Bill.com and process response.""" + _logger.debug("Bank account data payload: %s", bank_data) + _logger.info("Creating bank account for vendor %s in Bill.com", partner.name) + + try: + result = self._make_request( + f"vendors/{partner_billcom_id}/bank-account", + method="POST", + data=bank_data, + ) + + if isinstance(result, dict) and result.get("id"): + return self._handle_successful_creation(result, bank, partner) + elif isinstance(result, list) and len(result) > 0: + return self._handle_error_response(result, partner.name) + else: + _logger.error( + "Failed to create bank account for vendor %s in Bill.com: %s", + partner.name, + result, + ) + return False + except Exception as e: + _logger.error( + "Error creating bank account for vendor %s: %s", + partner.name, + str(e), + ) + return False + + def _handle_successful_creation(self, result, bank, partner): + """Handle successful bank account creation.""" + _logger.info( + "Successfully created bank account for vendor %s in Bill.com. ID: %s", + partner.name, + result.get("id"), + ) + + bank.with_context(skip_billcom_sync=True).write( + { + "billcom_vendor_bank_id": result.get("id"), + "billcom_account_status": result.get("status", ""), + "billcom_last_sync_date": fields.Datetime.now(), + } + ) + + account_number = bank.acc_number + partner.message_post( + body=_( + "

Bank Account Synced to Bill.com

" + "
    " + "
  • Bank: %(bank)s
  • " + "
  • Account: ****%(account)s
  • " + "
  • Bill.com ID: %(id)s
  • " + "
  • Status: %(status)s
  • " + "
" + ) + % { + "bank": bank.bank_id.name if bank.bank_id else "Unknown", + "account": ( + account_number[-4:] if len(account_number) >= 4 else "****" + ), + "id": result.get("id"), + "status": result.get("status", "Unknown"), + }, + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + return True + + def _handle_error_response(self, result, partner_name): + """Handle error response from Bill.com API.""" + error_messages = [] + for item in result: + if isinstance(item, dict) and item.get("message"): + error_messages.append(item.get("message")) + + error_str = ", ".join(error_messages) if error_messages else str(result) + _logger.error( + "Failed to create bank account for vendor %s in Bill.com: %s", + partner_name, + error_str, + ) + return False + + def sync_vendor_bank_account(self, partner): + """Synchronize vendor bank account with Bill.com.""" + # Validate prerequisites + is_valid, partner_billcom_id, bank = self._validate_bank_sync_prerequisites( + partner + ) + if not is_valid: + return False + + try: + # Check and delete existing bank account if needed + has_bank_account = self._check_existing_bank_account( + partner_billcom_id, partner.name + ) + if has_bank_account: + self._delete_existing_bank_account(partner_billcom_id, partner.name) + + # Build payload + bank_data = self._build_bank_account_payload(bank, partner) + if not bank_data: + return False + + # Create bank account + return self._create_bank_account_in_billcom( + partner_billcom_id, bank_data, bank, partner + ) + + except Exception as e: + _logger.error("Error syncing vendor bank account: %s", str(e)) + return False + + @api.model + def sync_partners(self, partner_type="vendor"): + """Synchronize partners with Bill.com + + Args: + partner_type (str): Type of partner to sync ('vendor' or 'customer') + + Returns: + list: List of partner IDs that were synced + """ + try: + config = self._get_config() + if not config: + return [] + + # Determine if sync is enabled for this partner type + if partner_type == "vendor" and not config.sync_vendors: + _logger.info("Vendor synchronization is disabled") + return [] + elif partner_type == "customer" and not config.sync_customers: + _logger.info("Customer synchronization is disabled") + return [] + + # Set parameters based on partner type + if partner_type == "vendor": + endpoint = "vendors" + rank_field = "supplier_rank" + other_rank_field = "customer_rank" + rank_value = 1 + other_rank_value = 0 + else: # customer + endpoint = "customers" + rank_field = "customer_rank" + other_rank_field = "supplier_rank" + rank_value = 1 + other_rank_value = 0 + + # Get partners from Bill.com API + _logger.info("Fetching %ss from Bill.com", partner_type) + partners_data = self._make_request( + endpoint, + method="GET", + params={"isActive": True, "start": 0, "max": 100}, + ) + + # Get partners from Odoo that need to be synced to Bill.com + # For vendors, we only sync those that have been updated since the last sync + domain = [("is_sync_to_billcom", "=", True), (rank_field, ">", 0)] + + # Get all partners that need to be synced + partners_to_sync = self.env["res.partner"].search(domain) + _logger.info( + "Found %d %ss in Odoo marked for sync", + len(partners_to_sync), + partner_type, + ) + + synced_partners = [] + + # First, process partners from Bill.com to Odoo + for partner_data in partners_data.get("data", []): + # Skip if no ID + if not partner_data.get("id"): + continue + + # Check if partner already exists + existing_partner = ( + self.env["res.partner"] + .with_context(active_test=False) + .search( + [("billcom", "=", partner_data["id"]), (rank_field, ">", 0)], + limit=1, + ) + ) + + # Prepare common partner data + partner_vals = { + "name": partner_data.get("name", ""), + "email": partner_data.get("email", ""), + "phone": partner_data.get("phone", ""), + "street": partner_data.get("address1", ""), + "street2": partner_data.get("address2", ""), + "city": partner_data.get("city", ""), + "state_id": self._get_state_id(partner_data.get("state", "")), + "zip": partner_data.get("zip", ""), + "country_id": self._get_country_id(partner_data.get("country", "")), + "website": partner_data.get("website", ""), + "last_sync_date": fields.Datetime.now(), + "active": True, + } + + if existing_partner: + # Update existing partner + _logger.info( + "Updating %s %s from Bill.com", partner_type, partner_data["id"] + ) + existing_partner.with_context(skip_billcom_sync=True).write( + partner_vals + ) + synced_partners.append(existing_partner.id) + _logger.info( + "Updated %s %s from Bill.com", partner_type, partner_data["id"] + ) + else: + # Create new partner + _logger.info( + "Creating %s %s from Bill.com", partner_type, partner_data["id"] + ) + partner_vals.update( + { + rank_field: rank_value, + other_rank_field: other_rank_value, + "billcom": partner_data["id"], + "is_sync_to_billcom": True, + } + ) + new_partner = ( + self.env["res.partner"] + .with_context(skip_billcom_sync=True) + .create(partner_vals) + ) + synced_partners.append(new_partner.id) + _logger.info( + "Created %s %s from Bill.com", partner_type, partner_data["id"] + ) + + for partner in partners_to_sync: + if ( + partner.billcom + and partner.last_sync_date + and partner.write_date <= partner.last_sync_date + ): + _logger.info( + "Skipping %s %s - no changes since last sync", + partner_type, + partner.name, + ) + continue + + _logger.info( + "Syncing %s %s to Bill.com (write_date: %s, last_sync_date: %s)", + partner_type, + partner.name, + partner.write_date, + partner.last_sync_date, + ) + try: + result = self.sync_partner(partner, partner_type) + if result: + synced_partners.append(partner.id) + _logger.info( + "Successfully synced %s %s to Bill.com", + partner_type, + partner.name, + ) + except Exception as e: + _logger.error( + "Error syncing %s %s to Bill.com: %s", + partner_type, + partner.name, + str(e), + ) + + _logger.info( + "Successfully synced %d %ss with Bill.com", + len(synced_partners), + partner_type, + ) + return synced_partners + except Exception as e: + _logger.error("Error syncing %ss with Bill.com: %s", partner_type, str(e)) + return [] + + @api.model + def sync_document(self, document, doc_type="invoice"): + """Sync single document to Bill.com""" + if ( + not document.is_sync_to_billcom + or not document.partner_id.is_sync_to_billcom + ): + _logger.info( + "%s %s not marked for sync to BillCom", doc_type, document.name + ) + return False + + try: + config = self._get_config() + + if doc_type == "bill" and not config.sync_bills: + _logger.info("Vendor bill synchronization is disabled") + return False + elif doc_type == "invoice" and not config.sync_invoices: + _logger.info("Customer invoice synchronization is disabled") + return False + + partner_type = "vendor" if doc_type == "bill" else "customer" + if not (document.partner_id.billcom_id or document.partner_id.billcom): + partner_result = self.sync_partner(document.partner_id, partner_type) + if not partner_result: + _logger.warning( + f"Cannot sync {doc_type} {document.name}: Partner sync failed" + ) + return False + + return document.button_sync_to_billcom() + except Exception as e: + _logger.error( + "%s sync failed for %s: %s", doc_type.title(), document.name, str(e) + ) + raise + + @api.model + def sync_bills(self): + """Synchronize vendor bills with Bill.com + + This method has two functions: + 1. Push Odoo bills to Bill.com that are marked for sync + 2. Pull bills from Bill.com API if configured + + Returns: + list: List of synced bill IDs + """ + # Get config and check if sync is enabled + try: + config = self._get_config() + if not config.sync_bills: + _logger.info("Vendor bill synchronization is disabled") + return [] + except UserError: + return [] + + # Find bills marked for sync that haven't been synced yet + bills = self.env["account.move"].search( + [ + ("move_type", "=", "in_invoice"), + ("state", "=", "posted"), + ("is_sync_to_billcom", "=", True), + ("last_sync_date", "=", False), + ("partner_id.is_sync_to_billcom", "=", True), + ] + ) + + synced_bills = [] + for bill in bills: + try: + result = self.sync_document(bill, "bill") + if result: + synced_bills.append(bill.id) + _logger.info("Synced bill %s to Bill.com", bill.name) + except Exception as e: + _logger.error("Skipping bill %s due to error: %s", bill.name, str(e)) + + # Also get bills from Bill.com API if configured + if config.import_bills: + try: + # Get bills from Bill.com using v3 API + bills_data = self._make_request( + "bills", + method="GET", + params={"isActive": True, "start": 0, "max": 100}, + ) + + for bill_data in bills_data.get("data", []): + # Check if bill already exists in Odoo + existing_bill = self.env["account.move"].search( + [ + ("billcom", "=", bill_data["id"]), + ("move_type", "=", "in_invoice"), + ], + limit=1, + ) + + if existing_bill: + _logger.info("Bill %s already exists in Odoo", bill_data["id"]) + continue + + # Find vendor + vendor = self.env["res.partner"].search( + [("billcom", "=", bill_data.get("vendorId"))], limit=1 + ) + + if not vendor: + _logger.warning( + "Vendor not found for Bill.com bill %s", bill_data["id"] + ) + continue + + # Get default vendor bill journal + company = vendor.company_id or self.env.company + journal = self.env["account.journal"].search( + [ + ("type", "=", "purchase"), + ("company_id", "=", company.id), + ], + limit=1, + ) + + if not journal: + _logger.error( + "No purchase journal found for company %s, skipping bill %s", + company.name, + bill_data["id"], + ) + continue + + # Create bill in Odoo + bill_vals = { + "partner_id": vendor.id, + "move_type": "in_invoice", + "journal_id": journal.id, + "invoice_date": bill_data.get("invoiceDate"), + "invoice_date_due": bill_data.get("dueDate"), + "ref": bill_data.get("invoiceNumber", ""), + "invoice_origin": bill_data.get("purchaseOrderNumber", ""), + "billcom_invoice_number": bill_data.get("invoiceNumber", ""), + "billcom": bill_data["id"], + "last_sync_date": fields.Datetime.now(), + "narration": bill_data.get("description", ""), + "is_sync_to_billcom": True, + } + + # Create bill + bill = self.env["account.move"].create(bill_vals) + synced_bills.append(bill.id) + + # Create bill lines + for line_data in bill_data.get("billLineItems", []): + line_vals = { + "move_id": bill.id, + "name": line_data.get("description", ""), + "quantity": line_data.get("quantity", 1.0), + "price_unit": line_data.get("amount", 0.0), + } + + self.env["account.move.line"].create(line_vals) + + _logger.info("Created bill %s from Bill.com", bill_data["id"]) + except Exception as e: + _logger.error("Error importing bills from Bill.com: %s", str(e)) + + return synced_bills + + @api.model + def sync_invoices(self): + """Synchronize customer invoices with Bill.com + + This method has two functions: + 1. Push Odoo invoices to Bill.com that are marked for sync + 2. Pull invoices from Bill.com API if configured + + Returns: + list: List of synced invoice IDs + """ + # Get config and check if sync is enabled + try: + config = self._get_config() + if not config.sync_invoices: + _logger.info("Customer invoice synchronization is disabled") + return [] + except UserError: + return [] + + # Find invoices marked for sync that haven't been synced yet + invoices = self.env["account.move"].search( + [ + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("is_sync_to_billcom", "=", True), + ("last_sync_date", "=", False), + ("partner_id.is_sync_to_billcom", "=", True), + ] + ) + + synced_invoices = [] + for invoice in invoices: + try: + result = self.sync_document(invoice, "invoice") + if result: + synced_invoices.append(invoice.id) + _logger.info("Synced invoice %s to Bill.com", invoice.name) + except Exception as e: + _logger.error( + "Skipping invoice %s due to error: %s", + invoice.name, + str(e), + ) + + # Also get invoices from Bill.com API if configured + if config.import_invoices: + try: + # Get invoices from Bill.com using v3 API + invoices_data = self._make_request( + "invoices", + method="GET", + params={"isActive": True, "start": 0, "max": 100}, + ) + + for invoice_data in invoices_data.get("data", []): + # Check if invoice already exists in Odoo + existing_invoice = self.env["account.move"].search( + [ + ("billcom", "=", invoice_data["id"]), + ("move_type", "=", "out_invoice"), + ], + limit=1, + ) + + if existing_invoice: + _logger.info( + "Invoice %s already exists in Odoo", invoice_data["id"] + ) + continue + + # Find customer + customer = self.env["res.partner"].search( + [("billcom", "=", invoice_data.get("customerId"))], limit=1 + ) + + if not customer: + _logger.warning( + "Customer not found for Bill.com invoice %s", + invoice_data["id"], + ) + continue + + # Get default customer invoice journal + company = customer.company_id or self.env.company + journal = self.env["account.journal"].search( + [ + ("type", "=", "sale"), + ("company_id", "=", company.id), + ], + limit=1, + ) + + if not journal: + _logger.error( + "No sale journal found for company %s, skipping invoice %s", + company.name, + invoice_data["id"], + ) + continue + + # Create invoice in Odoo + invoice_vals = { + "partner_id": customer.id, + "move_type": "out_invoice", + "journal_id": journal.id, + "invoice_date": invoice_data.get("invoiceDate"), + "invoice_date_due": invoice_data.get("dueDate"), + "ref": invoice_data.get("invoiceNumber", ""), + "billcom_invoice_number": invoice_data.get("invoiceNumber", ""), + "billcom": invoice_data["id"], + "last_sync_date": fields.Datetime.now(), + "narration": invoice_data.get("description", ""), + "is_sync_to_billcom": True, + } + + # Create invoice + invoice = self.env["account.move"].create(invoice_vals) + synced_invoices.append(invoice.id) + + # Create invoice lines + for line_data in invoice_data.get("invoiceLineItems", []): + line_vals = { + "move_id": invoice.id, + "name": line_data.get("description", ""), + "quantity": line_data.get("quantity", 1.0), + "price_unit": line_data.get("price", 0.0), + "tax_ids": [(6, 0, [])], # No taxes by default + } + + self.env["account.move.line"].create(line_vals) + + _logger.info("Created invoice %s from Bill.com", invoice_data["id"]) + except Exception as e: + _logger.error("Error importing invoices from Bill.com: %s", str(e)) + + return synced_invoices + + @api.model + def sync_payment(self, payment): + """Sync single payment to Bill.com""" + # Skip if payment is not marked for sync + if not payment.is_sync_to_billcom or not payment.partner_id.is_sync_to_billcom: + _logger.info("Payment %s not marked for sync to BillCom", payment.name) + return False + + # Check if payment is for a vendor + if payment.partner_type != "supplier" or payment.payment_type != "outbound": + _logger.info("Payment %s is not a vendor payment", payment.name) + return False + + # Use context to prevent infinite loops + if self.env.context.get("skip_billcom_sync"): + _logger.info("Skipping sync for payment %s due to context", payment.name) + return False + + try: + # Get config and check if sync is enabled + config = self._get_config() + + # Check if sync is enabled for payments + if not config.sync_payments: + _logger.info("Payment synchronization is disabled") + return False + + # Sync vendor first if needed + if not payment.partner_id.billcom: + partner_result = self.sync_partner(payment.partner_id, "vendor") + if not partner_result: + _logger.warning( + "Cannot sync payment %s: Vendor sync failed", payment.name + ) + return False + + # Use the payment's method to prepare data and sync + return payment.button_sync_to_billcom() + except Exception as e: + _logger.error("Payment sync failed for %s: %s", payment.name, str(e)) + raise + + @api.model + def sync_payments(self): + """Synchronize vendor payments with Bill.com + + This method has two functions: + 1. Push Odoo payments to Bill.com that are marked for sync + 2. Pull payment status updates from Bill.com API + + Returns: + list: List of synced payment IDs + """ + # Get config and check if sync is enabled + try: + config = self._get_config() + if not config.sync_payments: + _logger.info("Payment synchronization is disabled") + return [] + except UserError: + return [] + + # Find payments marked for sync that haven't been synced yet + payments = self.env["account.payment"].search( + [ + ("payment_type", "=", "outbound"), + ("partner_type", "=", "supplier"), + ("is_sync_to_billcom", "=", True), + ("last_sync_date", "=", False), + ("partner_id.is_sync_to_billcom", "=", True), + ("state", "=", "posted"), + ] + ) + + synced_payments = [] + for payment in payments: + try: + result = self.sync_payment(payment) + if result: + synced_payments.append(payment.id) + _logger.info("Synced payment %s to Bill.com", payment.name) + except Exception as e: + _logger.error( + "Skipping payment %s due to error: %s", payment.name, str(e) + ) + + # Update status of existing payments in Bill.com + existing_payments = self.env["account.payment"].search( + [ + ("billcom", "!=", False), + ("billcom_payment_status", "in", ["draft", "scheduled", "processing"]), + ] + ) + + for payment in existing_payments: + try: + # Get payment status from Bill.com + payment_data = self._make_request( + f"payments/{payment.billcom}", method="GET" + ) + + if payment_data and payment_data.get("id"): + # Update payment status + status = payment._map_billcom_status( + payment_data.get("singleStatus") + ) + payment.with_context(skip_billcom_sync=True).write( + { + "billcom_payment_status": status, + "last_sync_date": fields.Datetime.now(), + } + ) + _logger.info( + "Updated payment %s status to %s", payment.name, status + ) + except Exception as e: + _logger.error( + "Error updating payment %s status: %s", payment.name, str(e) + ) + + return synced_payments + + # ========================================================================= + # WIZARD INTEGRATION METHODS + # ========================================================================= + + @api.model + def sync_partners_by_type(self, partner_type="vendor", domain=None): + """Sync partners by type with optional domain filter (used by wizard)""" + if not domain: + domain = [] + + # Add partner type filter + if partner_type == "vendor": + domain.append(("supplier_rank", ">", 0)) + else: + domain.append(("customer_rank", ">", 0)) + + # Find partners + partners = self.env["res.partner"].search(domain) + synced_count = 0 + errors = [] + + for partner in partners: + try: + result = self.sync_partner(partner, partner_type) + if result: + synced_count += 1 + _logger.info(f"Successfully synced {partner_type} {partner.name}") + except Exception as e: + error_msg = f"Error syncing {partner_type} {partner.name}: {str(e)}" + _logger.error(error_msg) + errors.append(error_msg) + + return {"synced": synced_count, "total": len(partners), "errors": errors} + + @api.model + def sync_bills_by_domain(self, domain=None): + """Sync bills with optional domain filter (used by wizard)""" + if not domain: + domain = [] + + # Add bill-specific filters + domain.extend( + [ + ("move_type", "=", "in_invoice"), + ("state", "in", ["draft", "posted"]), + ] + ) + + # Find bills + bills = self.env["account.move"].search(domain) + synced_count = 0 + errors = [] + + for bill in bills: + try: + result = self.sync_document(bill, "bill") + if result: + synced_count += 1 + _logger.info(f"Successfully synced bill {bill.name}") + except Exception as e: + error_msg = f"Error syncing bill {bill.name}: {str(e)}" + _logger.error(error_msg) + errors.append(error_msg) + + return {"synced": synced_count, "total": len(bills), "errors": errors} + + @api.model + def sync_invoices_by_domain(self, domain=None): + """Sync customer invoices with optional domain filter (used by wizard)""" + if not domain: + domain = [] + + # Add invoice-specific filters + domain.extend( + [ + ("move_type", "=", "out_invoice"), + ("state", "in", ["draft", "posted"]), + ] + ) + + # Find invoices + invoices = self.env["account.move"].search(domain) + synced_count = 0 + errors = [] + + for invoice in invoices: + try: + result = self.sync_document(invoice, "invoice") + if result: + synced_count += 1 + _logger.info(f"Successfully synced invoice {invoice.name}") + except Exception as e: + error_msg = f"Error syncing invoice {invoice.name}: {str(e)}" + _logger.error(error_msg) + errors.append(error_msg) + + return {"synced": synced_count, "total": len(invoices), "errors": errors} + + @api.model + def sync_payments_by_domain(self, domain=None): + """Sync payments with optional domain filter (used by wizard)""" + if not domain: + domain = [] + + # Add payment-specific filters + domain.extend( + [ + ("payment_type", "=", "outbound"), + ("partner_type", "=", "supplier"), + ("state", "in", ["draft", "posted"]), + ] + ) + + # Find payments + payments = self.env["account.payment"].search(domain) + synced_count = 0 + errors = [] + + for payment in payments: + try: + result = self.sync_payment(payment) + if result: + synced_count += 1 + _logger.info(f"Successfully synced payment {payment.name}") + except Exception as e: + error_msg = f"Error syncing payment {payment.name}: {str(e)}" + _logger.error(error_msg) + errors.append(error_msg) + + return {"synced": synced_count, "total": len(payments), "errors": errors} + + @api.model + def sync_attachments_by_domain(self, domain=None): + """Sync attachments with optional domain filter (used by wizard)""" + if not domain: + domain = [] + + # Add attachment-specific filters + domain.extend( + [ + ("res_model", "=", "account.move"), + ("res_id", "!=", False), + ] + ) + + # Find attachments + attachments = self.env["ir.attachment"].search(domain) + + # Filter attachments that belong to synced bills + filtered_attachments = attachments.filtered( + lambda a: a.res_model == "account.move" + and a.res_id + and self.env["account.move"].browse(a.res_id).exists() + and self.env["account.move"].browse(a.res_id).partner_id.is_sync_to_billcom + ) + + synced_count = 0 + errors = [] + + for attachment in filtered_attachments: + try: + # Here you would implement attachment sync logic + # For now, we'll just log it as a placeholder + _logger.info(f"Attachment sync for {attachment.name} (placeholder)") + synced_count += 1 + except Exception as e: + error_msg = f"Error syncing attachment {attachment.name}: {str(e)}" + _logger.error(error_msg) + errors.append(error_msg) + + return { + "synced": synced_count, + "total": len(filtered_attachments), + "errors": errors, + } + + @api.model + def _map_billcom_bill_status_to_odoo_state(self, paymentStatus): + """Map Bill.com bill paymentStatus to Odoo move state + + Bill.com Bill paymentStatus: + - UNDEFINED: Status not defined → draft + - APPROVING: Being approved → posted + - SCHEDULED: Payment scheduled → posted + - PAID: Fully paid → posted + - CANCELLED: Cancelled → posted (keep posted, just mark as paid) + - VOID: Voided → posted + - ESCHEATED: Escheated → posted + + Odoo States: + - draft: Not confirmed + - posted: Confirmed and accounting entries created + """ + if paymentStatus == "UNDEFINED": + return "draft" + else: + # All other statuses mean the bill is confirmed + return "posted" + + @api.model + def _map_billcom_invoice_status_to_odoo_state(self, status): + """Map Bill.com invoice status to Odoo move state + + Bill.com Invoice Statuses (similar to bills): + - OPEN: Invoice sent, awaiting payment + - UNDEFINED: Status not defined → draft + - PAID_IN_FULL: Fully paid → posted + - PARTIAL_PAYMENT: Partially paid → posted + - SCHEDULED: Payment scheduled → posted + + Odoo States: + - draft: Not confirmed + - posted: Confirmed and accounting entries created + """ + if status in ["OPEN", "UNDEFINED"]: + return "draft" + else: + return "posted" + + @api.model + def _map_billcom_payment_status_to_odoo_state(self, paymentStatus): + """Map Bill.com payment paymentStatus to Odoo payment state + + Bill.com Payment paymentStatus: + - UNDEFINED: Not defined → draft + - UNPAID: Not paid yet → draft + - PAID: Paid → posted + - PARTIALLY_PAID: Partially paid → posted + - SCHEDULED: Scheduled → posted + - IN_PROCESS: Being processed → posted + + Odoo Payment States: + - draft: Not confirmed + - posted: Confirmed + - cancel: Cancelled (not used for these statuses) + """ + if paymentStatus in ["UNDEFINED", "UNPAID"]: + return "draft" + else: + # PAID, PARTIALLY_PAID, SCHEDULED, IN_PROCESS → posted + return "posted" + + @api.model + def process_queue_item_from_billcom(self, queue_item): + """Process a queue item with data from BILL (BILL → Odoo) + + Args: + queue_item: billcom.sync.queue record with sync_data from BILL + + Returns: + bool: True if processed successfully + """ + if not queue_item.sync_data: + _logger.error(f"Queue item {queue_item.id} has no sync_data from BILL") + return False + + try: + # Parse BILL data + import ast + + billcom_data = ast.literal_eval(queue_item.sync_data) + + # Route to appropriate handler based on sync_type + if queue_item.sync_type == "vendor": + return self._process_vendor_from_billcom(queue_item, billcom_data) + elif queue_item.sync_type == "customer": + return self._process_customer_from_billcom(queue_item, billcom_data) + elif queue_item.sync_type == "bill": + return self._process_bill_from_billcom(queue_item, billcom_data) + elif queue_item.sync_type == "invoice": + return self._process_invoice_from_billcom(queue_item, billcom_data) + elif queue_item.sync_type == "payment": + return self._process_payment_from_billcom(queue_item, billcom_data) + elif queue_item.sync_type == "document": + _logger.info( + "Sync from BILL not supported for type: document. " + "Documents are synced via sync_documents_from_billcom() method" + ) + return False + else: + _logger.warning(f"Unknown sync_type: {queue_item.sync_type}") + return False + + except Exception as e: + _logger.error(f"Error processing queue item {queue_item.id} from BILL: {e}") + raise + + def _process_vendor_from_billcom(self, queue_item, billcom_data): + """Create or update vendor from BILL data + + Args: + queue_item: billcom.sync.queue record + billcom_data: Vendor data from BILL API + + Returns: + bool: True if successful + """ + billcom_vendor_id = billcom_data.get("id") + + # Check if vendor exists + partner = self.env["res.partner"].search( + [("billcom_id", "=", billcom_vendor_id)], limit=1 + ) + + # Prepare Odoo partner values from BILL data + vals = { + "name": billcom_data.get("name") + or billcom_data.get("companyName", "Unknown Vendor"), + "supplier_rank": 1, + "is_sync_to_billcom": True, + "billcom": billcom_vendor_id, + "billcom_id": billcom_vendor_id, + "email": billcom_data.get("email"), + "phone": billcom_data.get("phone"), + "ref": billcom_data.get("accountNumber"), # Account number if exists + "vat": billcom_data.get("taxId"), + "active": not billcom_data.get( + "archived", False + ), # BILL uses 'archived' flag + "last_sync_date": fields.Datetime.now(), + "company_type": ( + "company" if billcom_data.get("accountType") == "BUSINESS" else "person" + ), + } + + # Add short name as comment if exists + if billcom_data.get("shortName"): + vals["comment"] = f"Short name: {billcom_data.get('shortName')}" + + # Map address - BILL API v3 uses different field names + address_data = billcom_data.get("address", {}) + if address_data: + vals.update( + { + "street": address_data.get("line1") + or address_data.get("addressLine1"), + "street2": address_data.get("line2") + or address_data.get("addressLine2"), + "city": address_data.get("city"), + "zip": address_data.get("zipOrPostalCode") + or address_data.get("zip"), + } + ) + + # Map state + state_code = address_data.get("stateOrProvince") or address_data.get( + "state" + ) + if state_code: + state = self.env["res.country.state"].search( + [("code", "=", state_code)], limit=1 + ) + if state: + vals["state_id"] = state.id + + # Map country + country_code = address_data.get("country") + if country_code: + country = self.env["res.country"].search( + [("code", "=", country_code)], limit=1 + ) + if country: + vals["country_id"] = country.id + + # Create or update partner + if partner: + # Update existing + partner.with_context(skip_billcom_sync=True).write(vals) + _logger.info(f"Updated vendor {partner.name} from BILL") + else: + # Create new + partner = ( + self.env["res.partner"] + .with_context(skip_billcom_sync=True) + .create(vals) + ) + _logger.info(f"Created vendor {partner.name} from BILL") + + # Process bank account information if available + payment_info = billcom_data.get("paymentInformation", {}) + + if payment_info and payment_info.get("bankAccount"): + self._sync_partner_bank_account(partner, payment_info) + + # Update queue item with record_id + queue_item.record_id = partner.id + + return True + + def _sync_partner_bank_account(self, partner, payment_info): + """Create or update partner bank account from Bill.com paymentInformation + + Args: + partner: res.partner record + payment_info: paymentInformation dict from Bill.com vendor data + + Structure of paymentInformation: + { + "payeeName": "John Doe", + "payByType": "WALLET", // or "CHECK", "BANK_ACCOUNT", "AP_CARD" + "payBySubType": "NONE", // or "ACH", "INTERNATIONAL_WIRE", etc. + "bankAccount": { + "accountNumber": "************1111", // Often masked + "routingNumber": "011401533", + "type": "CHECKING", // or "SAVINGS" + "ownerType": "BUSINESS" // or "PERSONAL" + } + } + """ + bank_account_data = payment_info.get("bankAccount", {}) + routing_number = bank_account_data.get("routingNumber") + account_number = bank_account_data.get("accountNumber", False) + + # Bill.com masks account numbers, so we can only update if we have full number + if not routing_number: + _logger.info( + f"No routing number in paymentInformation " + f"for {partner.name}, skipping bank account sync" + ) + return + + # Find bank by routing number + bank = self.env["res.bank"].search( + [ + ("routing_number", "=", routing_number) + ], # In US, routing number goes in BIC field + limit=1, + ) + + if not bank: + # Create bank if doesn't exist + bank = self.env["res.bank"].create( + { + "name": bank_account_data.get("bankName", f"Bank {routing_number}"), + "routing_number": routing_number, + } + ) + + # Check if partner already has this bank account (by routing number) + existing_bank_account = self.env["res.partner.bank"].search( + [ + ("partner_id", "=", partner.id), + ("bank_id", "=", bank.id), + ], + limit=1, + ) + + # Prepare bank account values + bank_vals = { + "partner_id": partner.id, + "bank_id": bank.id, + "billcom_vendor_id": partner.id, # Link bank to vendor for parent-child sync + # Bill.com specific fields + "billcom_pay_by_type": payment_info.get("payByType"), + "billcom_pay_by_subtype": payment_info.get("payBySubType", "NONE"), + "billcom_account_type": bank_account_data.get("type"), + "billcom_owner_type": bank_account_data.get("ownerType"), + "billcom_last_sync_date": fields.Datetime.now(), + } + + # Set aba_routing if available (US bank routing number field) + if routing_number: + bank_vals["aba_routing"] = routing_number + + # Always set account number - even if masked or missing + # acc_number is a required field in res.partner.bank + is_masked = account_number and "*" in account_number + if account_number: + # Store account number even if masked (with asterisks) + bank_vals["acc_number"] = account_number + else: + # If no account number provided, use routing number as placeholder + bank_vals["acc_number"] = ( + f"****{routing_number[-4:]}" if routing_number else "****" + ) + + if existing_bank_account: + # Update existing bank account + existing_bank_account.write(bank_vals) + _logger.info( + f"Updated bank account for {partner.name} " + f"(type: {payment_info.get('payByType')}, routing: {routing_number})" + ) + else: + # Create bank account even with masked account number + # We have routing number and Bill.com metadata which is valuable for sync + self.env["res.partner.bank"].create(bank_vals) + if is_masked: + _logger.info( + f"Created bank account for {partner.name} with masked account number " + f"(type: {payment_info.get('payByType')}, routing: {routing_number})" + ) + else: + _logger.info( + f"Created bank account for {partner.name} " + f"(type: {payment_info.get('payByType')}, routing: {routing_number})" + ) + + def _process_customer_from_billcom(self, queue_item, billcom_data): + """Create or update customer from BILL data""" + billcom_customer_id = billcom_data.get("id") + + # Check if customer exists + partner = self.env["res.partner"].search( + [("billcom_id", "=", billcom_customer_id)], limit=1 + ) + + # Prepare Odoo partner values + # Use companyName if exists, otherwise name + customer_name = billcom_data.get("companyName") or billcom_data.get( + "name", "Unknown Customer" + ) + + vals = { + "name": customer_name, + "customer_rank": 1, + "is_sync_to_billcom": True, + "billcom": billcom_customer_id, + "billcom_id": billcom_customer_id, + "email": billcom_data.get("email"), + "phone": billcom_data.get("phone"), + "ref": billcom_data.get("accountNumber"), + "vat": billcom_data.get("taxId"), + "active": not billcom_data.get("archived", False), + "last_sync_date": fields.Datetime.now(), + "company_type": ( + "company" if billcom_data.get("accountType") == "BUSINESS" else "person" + ), + } + + # Map contact information if exists + contact_data = billcom_data.get("contact", {}) + if contact_data: + first_name = contact_data.get("firstName", "") + last_name = contact_data.get("lastName", "") + if first_name or last_name: + # Store contact name in a note + contact_name = f"{first_name} {last_name}".strip() + vals["comment"] = f"Contact: {contact_name}" + + # Add short name as additional comment if exists + if billcom_data.get("shortName"): + existing_comment = vals.get("comment", "") + vals[ + "comment" + ] = f"{existing_comment}\nShort name: {billcom_data.get('shortName')}".strip() + + # Map address - Customers use 'billingAddress' instead of 'address' + address_data = billcom_data.get("billingAddress") or billcom_data.get( + "address", {} + ) + if address_data: + vals.update( + { + "street": address_data.get("line1") + or address_data.get("addressLine1"), + "street2": address_data.get("line2") + or address_data.get("addressLine2"), + "city": address_data.get("city"), + "zip": address_data.get("zipOrPostalCode") + or address_data.get("zip"), + } + ) + + state_code = address_data.get("stateOrProvince") or address_data.get( + "state" + ) + if state_code: + state = self.env["res.country.state"].search( + [("code", "=", state_code)], limit=1 + ) + if state: + vals["state_id"] = state.id + + country_code = address_data.get("country") + if country_code: + country = self.env["res.country"].search( + [("code", "=", country_code)], limit=1 + ) + if country: + vals["country_id"] = country.id + + # Create or update + if partner: + partner.with_context(skip_billcom_sync=True).write(vals) + _logger.info(f"Updated customer {partner.name} from BILL") + else: + partner = ( + self.env["res.partner"] + .with_context(skip_billcom_sync=True) + .create(vals) + ) + _logger.info(f"Created customer {partner.name} from BILL") + + queue_item.record_id = partner.id + return True + + def _process_bill_from_billcom(self, queue_item, billcom_data): + """Create or update bill from BILL data""" + billcom_bill_id = billcom_data.get("id") + + # Check if bill exists + move = self.env["account.move"].search( + [("billcom_id", "=", billcom_bill_id)], limit=1 + ) + + # Find vendor + vendor_billcom_id = billcom_data.get("vendorId") + vendor = self.env["res.partner"].search( + [("billcom_id", "=", vendor_billcom_id), ("supplier_rank", ">", 0)], limit=1 + ) + + if not vendor: + _logger.error( + f"Vendor with BILL ID {vendor_billcom_id} not found for bill {billcom_bill_id}" + ) + raise UserError( + f"Vendor must be synced first. BILL Vendor ID: {vendor_billcom_id}" + ) + + # Extract invoice data from nested object + invoice_data = billcom_data.get("invoice", {}) + invoice_number = invoice_data.get("invoiceNumber", billcom_bill_id) + invoice_date = invoice_data.get("invoiceDate") + invoice_origin = invoice_data.get("purchaseOrderNumber") + + # Get default vendor bill journal + company = vendor.company_id or self.env.company + journal = self.env["account.journal"].search( + [ + ("type", "=", "purchase"), + ("company_id", "=", company.id), + ], + limit=1, + ) + + if not journal: + raise UserError( + f"No purchase journal found for company {company.name}. " + f"Please configure a purchase journal." + ) + + # Prepare bill values + vals = { + "move_type": "in_invoice", + "partner_id": vendor.id, + "journal_id": journal.id, + "ref": invoice_number, + "billcom_invoice_number": invoice_number, + "invoice_date": invoice_date, + "invoice_origin": invoice_origin, + "invoice_date_due": billcom_data.get("dueDate"), + "narration": billcom_data.get("description"), + "billcom": billcom_bill_id, + "billcom_id": billcom_bill_id, + "billcom_status": billcom_data.get("paymentStatus"), # UNPAID, PAID, etc. + "last_sync_date": fields.Datetime.now(), + } + + # Add PO number if exists + if billcom_data.get("purchaseOrderNumber"): + po_ref = billcom_data.get("purchaseOrderNumber") + if vals.get("narration"): + vals["narration"] = f"{vals['narration']}\nPO: {po_ref}" + else: + vals["narration"] = f"PO: {po_ref}" + + # Process line items - BILL uses 'billLineItems' + bill_line_items = billcom_data.get("billLineItems", []) + invoice_lines = [] + + if bill_line_items: + # Get default expense account + default_account = self.env["account.account"].search( + [ + ("account_type", "=", "expense"), + ("company_id", "=", vendor.company_id.id or self.env.company.id), + ("deprecated", "=", False), + ], + limit=1, + ) + + for line in bill_line_items: + line_description = line.get("description", "Bill Line Item") + line_amount = line.get("amount", 0.0) + + line_vals = { + "name": line_description, + "quantity": 1.0, + "price_unit": line_amount, + "tax_ids": [(6, 0, [])], # No taxes by default + } + + # Set account (use default if not specified) + if default_account: + line_vals["account_id"] = default_account.id + + invoice_lines.append((0, 0, line_vals)) + + if invoice_lines: + vals["invoice_line_ids"] = invoice_lines + else: + # If no line items, create a single line with the total amount + default_account = self.env["account.account"].search( + [ + ("account_type", "=", "expense"), + ("company_id", "=", vendor.company_id.id or self.env.company.id), + ("deprecated", "=", False), + ], + limit=1, + ) + + if default_account: + vals["invoice_line_ids"] = [ + ( + 0, + 0, + { + "name": billcom_data.get("description") + or "Bill from BILL.com", + "quantity": 1.0, + "price_unit": billcom_data.get("amount", 0.0), + "account_id": default_account.id, + }, + ) + ] + + # Create or update + if move: + # Only update if in draft + if move.state == "draft": + move.with_context(skip_billcom_sync=True).write(vals) + _logger.info(f"Updated bill {move.name} from BILL") + else: + _logger.info(f"Bill {move.name} already posted, skipping update") + else: + move = ( + self.env["account.move"] + .with_context(skip_billcom_sync=True) + .create(vals) + ) + _logger.info( + f"Created bill {move.name} from BILL (Invoice #: {invoice_number})" + ) + + # Map Bill.com status to Odoo state and apply if needed + billcom_payment_status = billcom_data.get("paymentStatus", "UNDEFINED") + target_state = self._map_billcom_bill_status_to_odoo_state( + billcom_payment_status + ) + + if target_state == "posted" and move.state == "draft": + # Post the bill if Bill.com status requires it + try: + move.with_context(skip_billcom_sync=True).action_post() + _logger.info( + f"Posted bill {move.name} based on " + f"Bill.com status: {billcom_payment_status}" + ) + except Exception as e: + _logger.warning( + f"Could not post bill {move.name} from " + f"Bill.com status {billcom_payment_status}: {e}" + ) + + queue_item.record_id = move.id + return True + + def _process_invoice_from_billcom(self, queue_item, billcom_data): # noqa: C901 + """Create or update customer invoice from BILL data""" + billcom_invoice_id = billcom_data.get("id") + + # Check if invoice exists + move = self.env["account.move"].search( + [("billcom_id", "=", billcom_invoice_id)], limit=1 + ) + + # Find customer - BILL API v3 returns customerId directly (not nested) + # Format: { "customerId": "0cu02TXNTXPYFNI16n6b", ... } + customer_billcom_id = billcom_data.get("customerId") + + # Fallback: Try nested customer object (in case API changes or uses different format) + customer_data = billcom_data.get("customer", {}) + if not customer_billcom_id and isinstance(customer_data, dict): + customer_billcom_id = customer_data.get("id") + + # Try to find customer by BILL ID + customer = None + if customer_billcom_id: + customer = self.env["res.partner"].search( + [("billcom_id", "=", customer_billcom_id), ("customer_rank", ">", 0)], + limit=1, + ) + + # If no customer ID or not found, try by email or name + # (though invoice API doesn't typically include customer details beyond customerId) + if not customer: + customer_email = customer_data.get("email") if customer_data else None + customer_name = customer_data.get("name") if customer_data else None + + if customer_email: + customer = self.env["res.partner"].search( + [("email", "=", customer_email), ("customer_rank", ">", 0)], limit=1 + ) + + if not customer and customer_name: + customer = self.env["res.partner"].search( + [("name", "=", customer_name), ("customer_rank", ">", 0)], limit=1 + ) + + if not customer: + _logger.error( + "Customer not found for invoice %s. " + "BILL Customer ID: %s. " + "Please sync customers from Bill.com first.", + billcom_invoice_id, + customer_billcom_id or "None", + ) + raise UserError( + f"Customer with Bill.com ID '{customer_billcom_id}' not found " + f"in Odoo.\n\n" + f"Please sync customers from Bill.com first using the sync wizard, \n" + f"or create the customer manually and set their Bill.com ID." + ) + + # Extract invoice data - for invoices, data is at top level (not nested like bills) + invoice_number = billcom_data.get("invoiceNumber", billcom_invoice_id) + invoice_date = billcom_data.get("invoiceDate") + invoice_origin = billcom_data.get("purchaseOrderNumber") + + # Get default customer invoice journal + company = customer.company_id or self.env.company + journal = self.env["account.journal"].search( + [ + ("type", "=", "sale"), + ("company_id", "=", company.id), + ], + limit=1, + ) + + if not journal: + raise UserError( + f"No sale journal found for company {company.name}. " + f"Please configure a sale journal." + ) + + # Prepare invoice values + vals = { + "move_type": "out_invoice", + "partner_id": customer.id, + "journal_id": journal.id, + "ref": invoice_number, + "invoice_origin": invoice_origin, + "billcom_invoice_number": invoice_number, + "invoice_date": invoice_date, + "invoice_date_due": billcom_data.get("dueDate"), + "narration": billcom_data.get("description"), + "billcom": billcom_invoice_id, + "billcom_id": billcom_invoice_id, + "billcom_status": billcom_data.get("status"), # OPEN, PAID, etc. + "last_sync_date": fields.Datetime.now(), + } + + # Process line items - BILL uses 'invoiceLineItems' for customer invoices + invoice_line_items = billcom_data.get("invoiceLineItems", []) + invoice_lines = [] + + if invoice_line_items: + # Get default income account + default_account = self.env["account.account"].search( + [ + ("account_type", "=", "income"), + ("company_id", "=", customer.company_id.id or self.env.company.id), + ("deprecated", "=", False), + ], + limit=1, + ) + + for line in invoice_line_items: + line_description = line.get("description", "Invoice Line Item") + line_price = line.get("price", 0.0) + line_quantity = line.get("quantity", 1.0) + + line_vals = { + "name": line_description, + "quantity": line_quantity, + "price_unit": line_price, + "tax_ids": [(6, 0, [])], # No taxes by default + } + + # Set account (use default if not specified) + if default_account: + line_vals["account_id"] = default_account.id + + invoice_lines.append((0, 0, line_vals)) + + if invoice_lines: + vals["invoice_line_ids"] = invoice_lines + else: + # If no line items, create a single line with the total amount + default_account = self.env["account.account"].search( + [ + ("account_type", "=", "income"), + ("company_id", "=", customer.company_id.id or self.env.company.id), + ("deprecated", "=", False), + ], + limit=1, + ) + + if default_account: + vals["invoice_line_ids"] = [ + ( + 0, + 0, + { + "name": billcom_data.get("description") + or "Invoice from " "BILL.com", + "quantity": 1.0, + "price_unit": billcom_data.get("amount", 0.0), + "account_id": default_account.id, + }, + ) + ] + + # Create or update + if move: + # Only update if in draft + if move.state == "draft": + move.with_context(skip_billcom_sync=True).write(vals) + _logger.info(f"Updated invoice {move.name} from BILL") + else: + _logger.info(f"Invoice {move.name} already posted, skipping update") + else: + move = ( + self.env["account.move"] + .with_context(skip_billcom_sync=True) + .create(vals) + ) + _logger.info( + f"Created invoice {move.name} from BILL (Invoice #: {invoice_number})" + ) + + # Map Bill.com status to Odoo state and apply if needed + billcom_status = billcom_data.get("status", "UNDEFINED") + target_state = self._map_billcom_invoice_status_to_odoo_state(billcom_status) + + if target_state == "posted" and move.state == "draft": + # Post the invoice if Bill.com status requires it + try: + move.with_context(skip_billcom_sync=True).action_post() + _logger.info( + f"Posted invoice {move.name} based on Bill.com status: {billcom_status}" + ) + except Exception as e: + _logger.warning( + f"Could not post invoice {move.name} from Bill.com " + f"status {billcom_status}: {e}" + ) + + queue_item.record_id = move.id + return True + + def _process_payment_from_billcom(self, queue_item, billcom_data): + """Create or update payment from BILL data""" + billcom_payment_id = billcom_data.get("id") + + # Check if payment exists + payment = self.env["account.payment"].search( + [("billcom_id", "=", billcom_payment_id)], limit=1 + ) + + # Find vendor + vendor_billcom_id = billcom_data.get("vendorId") + vendor = self.env["res.partner"].search( + [("billcom_id", "=", vendor_billcom_id), ("supplier_rank", ">", 0)], limit=1 + ) + + if not vendor: + _logger.error( + f"Vendor with BILL ID {vendor_billcom_id} not " + f"found for payment {billcom_payment_id}" + ) + raise UserError( + f"Vendor must be synced first. BILL Vendor ID: {vendor_billcom_id}" + ) + + # Find default journal for vendor payments + journal = self.env["account.journal"].search( + [ + ("type", "=", "bank"), + ("company_id", "=", vendor.company_id.id or self.env.company.id), + ], + limit=1, + ) + + if not journal: + raise UserError(_("No bank journal found for payments")) + + # Prepare payment values + vals = { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": vendor.id, + "amount": billcom_data.get("amount", 0.0), + "date": billcom_data.get("paymentDate") or fields.Date.today(), + "journal_id": journal.id, + "ref": billcom_data.get("description") + or f"Payment from BILL {billcom_payment_id}", + "billcom": billcom_payment_id, + "billcom_id": billcom_payment_id, + "billcom_status": billcom_data.get("status"), + "last_sync_date": fields.Datetime.now(), + } + + # Create or update + if payment: + # Only update if in draft + if payment.state == "draft": + payment.with_context(skip_billcom_sync=True).write(vals) + _logger.info(f"Updated payment {payment.name} from BILL") + else: + _logger.info(f"Payment {payment.name} already posted, skipping update") + else: + payment = ( + self.env["account.payment"] + .with_context(skip_billcom_sync=True) + .create(vals) + ) + _logger.info(f"Created payment {payment.name} from BILL") + + # Map Bill.com payment status to Odoo state and apply if needed + billcom_payment_status = billcom_data.get("paymentStatus", "UNDEFINED") + target_state = self._map_billcom_payment_status_to_odoo_state( + billcom_payment_status + ) + + if target_state == "posted" and payment.state == "draft": + # Post the payment if Bill.com status requires it + try: + payment.with_context(skip_billcom_sync=True).action_post() + _logger.info( + f"Posted payment {payment.name} based on " + f"Bill.com status: {billcom_payment_status}" + ) + except Exception as e: + _logger.warning( + f"Could not post payment {payment.name} from Bill.com " + f"status {billcom_payment_status}: {e}" + ) + + queue_item.record_id = payment.id + return True + + @api.model + def get_funding_accounts(self): + """ + Get list of funding accounts (organization bank accounts) from Bill.com + + Returns: + list: List of funding account dictionaries with structure: + { + 'id': 'bac02ZTVOWXMVVAAicnz', + 'bankName': 'Bank of America', + 'nameOnAccount': 'bofa', + 'accountNumber': '************0000', + 'routingNumber': '011401533', + 'type': 'CHECKING', + 'status': 'VERIFIED', + 'default': {'payables': True, 'receivables': True} + } + """ + try: + _logger.info("Fetching funding accounts from Bill.com") + + # GET /v3/funding-accounts/banks + response = self._make_request("funding-accounts/banks", method="GET") + + # Bill.com API v3 returns data in 'results' array + funding_accounts = response.get("results", []) if response else [] + + _logger.info( + f"Retrieved {len(funding_accounts)} funding accounts from Bill.com" + ) + + return funding_accounts + + except Exception as e: + _logger.error(f"Error fetching funding accounts from Bill.com: {e}") + raise UserError( + f"Failed to fetch funding accounts from Bill.com: {str(e)}" + ) from e + + @api.model + def get_default_funding_account(self, account_type="payables"): + """ + Get the default funding account for payables or receivables + + Args: + account_type: 'payables' or 'receivables' + + Returns: + dict: Default funding account or None + """ + try: + funding_accounts = self.get_funding_accounts() + + # Find default account for the specified type + for account in funding_accounts: + if account.get("status") == "VERIFIED": + default_settings = account.get("default", {}) + if default_settings.get(account_type, False): + _logger.info( + f"Found default {account_type} funding account: " + f"{account.get('bankName')} ({account.get('id')})" + ) + return account + + # If no default found, return first verified account + for account in funding_accounts: + if account.get("status") == "VERIFIED": + _logger.warning( + f"No default {account_type} account found, " + f"using first verified: {account.get('bankName')}" + ) + return account + + _logger.error("No verified funding accounts found") + return None + + except Exception as e: + _logger.error(f"Error getting default funding account: {e}") + return None + + @api.model + def generate_mfa_challenge(self, config, session_id=None): + """Generate MFA challenge and return challenge ID + + Args: + config: billcom.config record + session_id: Optional existing session ID. If not provided, will login first. + + Returns: + dict: { + 'challenge_id': str, + 'phone_number': str (masked), + 'session_id': str + } + """ + import requests + + challenge_url = None # Initialize for error logging + headers = {} # Initialize for error logging + + try: + # If no session_id provided, do basic login first + if not session_id: + session_id = self._basic_login(config) + + challenge_url = f"{config.api_url}/v3/mfa/challenge" + headers = { + "accept": "application/json", + "content-type": "application/json", + "sessionId": session_id, + "devKey": config.dev_key, + } + + # POST with useBackup parameter + # Set useBackup to false to use primary device (default) + payload = {"useBackup": False} # Use primary device + + _logger.info("Generating MFA challenge with payload: %s", payload) + + response = requests.post( + challenge_url, json=payload, headers=headers, timeout=30 + ) + response.raise_for_status() + + result = response.json() + challenge_id = result.get("challengeId") + + if not challenge_id: + raise UserError(_("Failed to generate MFA challenge")) + + _logger.info("MFA challenge generated successfully: %s", challenge_id) + + # Try to get phone number info (may not be in response) + phone_number = result.get("phoneNumber", "****") + + return { + "challenge_id": challenge_id, + "phone_number": phone_number, + "session_id": session_id, + } + + except requests.exceptions.RequestException as e: + _logger.error("Failed to generate MFA challenge: %s", str(e)) + if challenge_url: + _logger.error("Request URL: %s", challenge_url) + if headers: + _logger.error("Request headers: %s", headers) + if hasattr(e, "response") and e.response is not None: + _logger.error("Response status: %s", e.response.status_code) + _logger.error("Response body: %s", e.response.text) + raise UserError(_("Failed to generate MFA challenge: %s") % str(e)) from e + + @api.model + def _basic_login(self, config): + """Perform basic login without MFA handling + + Returns session ID for MFA challenge flow + """ + import requests + + auth_url = f"{config.api_url}/v3/login" + + payload = { + "organizationId": config.organization_id, + "devKey": config.dev_key, + "username": config.username, + "password": config.password, + } + + headers = { + "accept": "application/json", + "content-type": "application/json", + } + + _logger.info("Performing basic login for MFA flow") + response = requests.post(auth_url, json=payload, headers=headers, timeout=40) + response.raise_for_status() + + result = response.json() + session_id = result.get("sessionId") + + if not session_id: + raise UserError(_("No session ID received from Bill.com API")) + + _logger.info("Basic login successful, session ID obtained") + return session_id + + @api.model + def validate_mfa_challenge(self, config, challenge_id, session_id, mfa_code): + """Validate MFA challenge code and obtain Remember Me ID + + Args: + config: billcom.config record + challenge_id: Challenge ID from generate_mfa_challenge + session_id: Session ID from generate_mfa_challenge + mfa_code: 6-digit code from SMS/authenticator + + Returns: + str: Remember Me ID (valid for 30 days) + """ + import requests + + try: + validate_url = f"{config.api_url}/v3/mfa/challenge/validate" + + headers = { + "accept": "application/json", + "content-type": "application/json", + "sessionId": session_id, + "devKey": config.dev_key, + } + + # Get device name from config or use default + device_name = config.mfa_device_name or "Odoo Integration" + + payload = { + "challengeId": challenge_id, + "token": mfa_code, + "device": device_name, + "machineName": device_name, # Use same as device + "rememberMe": True, # Request Remember Me ID + } + + _logger.info("Validating MFA code: %s", mfa_code) + _logger.info("Challenge ID: %s", challenge_id[:30] + "...") + _logger.info("Payload: %s", payload) + + response = requests.post( + validate_url, json=payload, headers=headers, timeout=30 + ) + + _logger.info("Response status: %s", response.status_code) + _logger.info("Response body: %s", response.text) + + response.raise_for_status() + + result = response.json() + remember_me_id = result.get("rememberMeId") + + if not remember_me_id: + raise UserError( + _("MFA validation succeeded but no Remember Me ID received") + ) + + _logger.info( + "MFA validated successfully. Remember Me ID obtained (valid 30 days)" + ) + + return remember_me_id + + except requests.exceptions.RequestException as e: + _logger.error("MFA validation failed: %s", str(e)) + + # Get detailed error from response + error_detail = str(e) + if hasattr(e, "response") and e.response is not None: + try: + error_json = e.response.json() + error_detail = ( + error_json.get("message") + or error_json.get("error") + or str(error_json) + ) + _logger.error("Bill.com error details: %s", error_json) + except Exception: + error_detail = e.response.text or str(e) + + # Parse error for better user feedback + error_msg = error_detail.lower() + if "invalid" in error_msg or "incorrect" in error_msg: + raise UserError( + _("Invalid MFA code. Please check and try again.") + ) from e + elif "expired" in error_msg: + raise UserError( + _("MFA code expired. Please request a new code and try again.") + ) from e + elif "too many" in error_msg or "bdc_1358" in error_msg: + raise UserError( + _( + "Too many MFA validation attempts.\n\n" + "Bill.com has temporarily blocked MFA validation.\n\n" + "Solutions:\n" + "1. Wait 5-10 minutes and try 'Setup MFA' again\n" + "2. Use manual Device ID method (see MFA_QUICK_GUIDE.md)\n" + "3. Contact Bill.com support to unlock" + ) + ) from e + else: + raise UserError(_("MFA validation failed: %s") % error_detail) from e + + # ======================================================================== + # PARTNER SYNCHRONIZATION FROM BILL.COM TO ODOO + # ======================================================================== + + @api.model + def sync_partners_from_billcom(self, partner_type="vendor"): # noqa: C901 + """Sync partners from Bill.com to Odoo (bulk sync) + + Args: + partner_type (str): 'vendor' or 'customer' + + Returns: + int: Number of partners synced + """ + try: + config = self._get_config() + except UserError as e: + _logger.warning(str(e)) + return {} + + # Check if sync is enabled for this partner type + if partner_type == "vendor" and not config.sync_vendors: + _logger.info("Vendor synchronization is disabled") + return {} + elif partner_type == "customer" and not config.sync_customers: + _logger.info("Customer synchronization is disabled") + return {} + + endpoint = "vendors" if partner_type == "vendor" else "customers" + + try: + # Get partners from Bill.com API + result = self._make_request(endpoint, method="GET") + partners = result.get("data", []) + synced_count = 0 + + for partner_data in partners: + try: + # Find existing partner in Odoo + existing_partner = self.env["res.partner"].search( + [("billcom", "=", partner_data["id"])], limit=1 + ) + + address = partner_data.get("address", {}) + partner_vals = { + "name": partner_data["name"], + "email": partner_data.get("email", ""), + "phone": partner_data.get("phone", ""), + "street": address.get("line1", ""), + "street2": address.get("line2", ""), + "city": address.get("city", ""), + "zip": address.get("zipOrPostalCode", ""), + "billcom": partner_data["id"], + "billcom_id": partner_data["id"], + "last_sync_date": fields.Datetime.now(), + "ref": partner_data.get("shortName", ""), + "lang": partner_data.get("language", "en_US"), + "is_sync_to_billcom": True, + } + + # Set partner type ranks + if partner_type == "vendor": + partner_vals["supplier_rank"] = 1 + partner_vals["customer_rank"] = 0 + else: + partner_vals["supplier_rank"] = 0 + partner_vals["customer_rank"] = 1 + + # Set state if available + if address.get("stateOrProvince"): + state = self.env["res.country.state"].search( + [("code", "=", address["stateOrProvince"])], limit=1 + ) + if state: + partner_vals["state_id"] = state.id + + # Set country if available + if address.get("country"): + country = self.env["res.country"].search( + [("code", "=", address["country"])], limit=1 + ) + if country: + partner_vals["country_id"] = country.id + + # Update or create partner + if existing_partner: + existing_partner.with_context(skip_billcom_sync=True).write( + partner_vals + ) + _logger.info( + "Updated %s: %s from Bill.com", + partner_type, + partner_data["name"], + ) + partner_to_use = existing_partner + else: + partner_to_use = ( + self.env["res.partner"] + .with_context(skip_billcom_sync=True) + .create(partner_vals) + ) + _logger.info( + "Created %s: %s from Bill.com", + partner_type, + partner_data["name"], + ) + + synced_count += 1 + + # Sync bank account for vendors + if partner_type == "vendor" and partner_to_use.billcom: + try: + bank_result = self._make_request( + f"vendors/{partner_to_use.billcom}/bank-account", + method="GET", + ) + + if bank_result and bank_result.get("bankAccount"): + bank_info = bank_result.get("bankAccount", {}) + + # Check if bank account already exists + existing_bank = False + if partner_to_use.bank_ids: + for bank in partner_to_use.bank_ids: + if bank.acc_number == bank_info.get( + "accountNumber" + ): + existing_bank = True + break + + # Create bank account if it doesn't exist + if not existing_bank: + bank_vals = { + "acc_number": bank_info.get( + "accountNumber", "" + ), + "aba_routing": bank_info.get( + "routingNumber", "" + ), + "acc_holder_name": bank_info.get( + "nameOnAccount", partner_to_use.name + ), + "partner_id": partner_to_use.id, + } + + # Find bank by routing number + if bank_info.get("routingNumber"): + bank_id = self.env["res.bank"].search( + [ + ( + "aba_routing", + "=", + bank_info.get("routingNumber"), + ) + ], + limit=1, + ) + if bank_id: + bank_vals["bank_id"] = bank_id.id + + self.env["res.partner.bank"].create(bank_vals) + _logger.info( + "Created bank account for vendor %s from Bill.com", + partner_to_use.name, + ) + except Exception as e: + # 404 means no bank account exists, which is fine + if "404" not in str(e): # noqa: E713 + _logger.warning( + "Error syncing bank account for vendor %s: %s", + partner_to_use.name, + str(e), + ) + + except Exception as e: + _logger.error( + "Error processing %s %s: %s", + partner_type, + partner_data.get("name", "Unknown"), + str(e), + ) + continue + + _logger.info("Synced %d %ss from Bill.com", synced_count, partner_type) + return synced_count + + except Exception as e: + _logger.error("Error syncing %ss from Bill.com: %s", partner_type, str(e)) + raise UserError( + _("Error syncing %(type)ss from Bill.com: %(error)s") + % {"type": partner_type, "error": str(e)} + ) from e + + @api.model + def sync_bills_from_billcom(self): + """Sync bills from Bill.com to Odoo (bulk sync for cron backup) + + Returns: + int: Number of bills synced + """ + try: + config = self._get_config() + except UserError as e: + _logger.warning(str(e)) + return 0 + + if not config.sync_bills: + _logger.info("Bill synchronization is disabled") + return 0 + + try: + # Get bills from Bill.com API with recent filter (last 30 days) + from_date = (fields.Date.today() - timedelta(days=30)).isoformat() + result = self._make_request(f"bills?updatedTime={from_date}", method="GET") + bills = result.get("data", []) + synced_count = 0 + + _logger.info(f"Syncing {len(bills)} bills from Bill.com") + + for bill_data in bills: + try: + # Create queue item and process + queue_item = type( + "obj", + (object,), + { + "entity_type": "BILL", + "entity_id": bill_data["id"], + "billcom_data": bill_data, + }, + )() + + self._process_bill_from_billcom(queue_item, bill_data) + synced_count += 1 + except Exception as e: + _logger.error(f"Error syncing bill {bill_data.get('id')}: {e}") + + _logger.info(f"Successfully synced {synced_count} bills from Bill.com") + return synced_count + + except Exception as e: + _logger.error(f"Error in bills sync from Bill.com: {e}") + return 0 + + @api.model + def sync_invoices_from_billcom(self): + """Sync invoices from Bill.com to Odoo (bulk sync for cron backup) + + Returns: + int: Number of invoices synced + """ + try: + config = self._get_config() + except UserError as e: + _logger.warning(str(e)) + return 0 + + if not config.sync_invoices: + _logger.info("Invoice synchronization is disabled") + return 0 + + try: + # Get invoices from Bill.com API with recent filter (last 30 days) + from_date = (fields.Date.today() - timedelta(days=30)).isoformat() + result = self._make_request( + f"invoices?updatedTime={from_date}", method="GET" + ) + invoices = result.get("data", []) + synced_count = 0 + + _logger.info(f"Syncing {len(invoices)} invoices from Bill.com") + + for invoice_data in invoices: + try: + # Create queue item and process + queue_item = type( + "obj", + (object,), + { + "entity_type": "INVOICE", + "entity_id": invoice_data["id"], + "billcom_data": invoice_data, + }, + )() + + self._process_invoice_from_billcom(queue_item, invoice_data) + synced_count += 1 + except Exception as e: + _logger.error( + f"Error syncing invoice {invoice_data.get('id')}: {e}" + ) + + _logger.info(f"Successfully synced {synced_count} invoices from Bill.com") + return synced_count + + except Exception as e: + _logger.error(f"Error in invoices sync from Bill.com: {e}") + return 0 + + @api.model + def sync_payments_from_billcom(self): + """Sync payments from Bill.com to Odoo (bulk sync for cron backup) + + Returns: + int: Number of payments synced + """ + try: + config = self._get_config() + except UserError as e: + _logger.warning(str(e)) + return 0 + + if not config.sync_payments: + _logger.info("Payment synchronization is disabled") + return 0 + + try: + # Get payments from Bill.com API with recent filter (last 30 days) + from_date = (fields.Date.today() - timedelta(days=30)).isoformat() + result = self._make_request( + f"payments?updatedTime={from_date}", method="GET" + ) + payments = result.get("data", []) + synced_count = 0 + + _logger.info(f"Syncing {len(payments)} payments from Bill.com") + + for payment_data in payments: + try: + # Create queue item and process + queue_item = type( + "obj", + (object,), + { + "entity_type": "PAYMENT", + "entity_id": payment_data["id"], + "billcom_data": payment_data, + }, + )() + + self._process_payment_from_billcom(queue_item, payment_data) + synced_count += 1 + except Exception as e: + _logger.error( + f"Error syncing payment {payment_data.get('id')}: {e}" + ) + + _logger.info(f"Successfully synced {synced_count} payments from Bill.com") + return synced_count + + except Exception as e: + _logger.error(f"Error in payments sync from Bill.com: {e}") + return 0 + + @api.model + def sync_partners_cron(self): + """Cron job to sync partners from Bill.com + + Returns: + bool: True if sync completed successfully + """ + try: + # Get config and check if auto sync is enabled + try: + config = self._get_config() + if ( + hasattr(config, "auto_sync_enabled") + and not config.auto_sync_enabled + ): + _logger.info("Automatic sync is disabled in configuration") + return False + except UserError as e: + _logger.warning(str(e)) + return False + + # Sync vendors if enabled + if config.sync_vendors: + try: + vendor_count = self.sync_partners_from_billcom( + partner_type="vendor" + ) + _logger.info( + "Cron job synced %s vendors from Bill.com", vendor_count or 0 + ) + except Exception as e: + _logger.error("Error in vendor sync cron: %s", str(e)) + + # Sync customers if enabled + if config.sync_customers: + try: + customer_count = self.sync_partners_from_billcom( + partner_type="customer" + ) + _logger.info( + "Cron job synced %s customers from Bill.com", + customer_count or 0, + ) + except Exception as e: + _logger.error("Error in customer sync cron: %s", str(e)) + + return True + except Exception as e: + _logger.error("Error in partner sync cron: %s", str(e)) + return False + + @api.model + def sync_partner_from_billcom_by_id(self, billcom_id, partner_type="vendor"): + """Sync a specific partner from Bill.com by ID + + Args: + billcom_id (str): Bill.com partner ID + partner_type (str): 'vendor' or 'customer' + + Returns: + res.partner: Synced partner record or False + """ + try: + config = self._get_config() + except UserError as e: + _logger.warning(str(e)) + return False + + # Check if sync is enabled for this partner type + if partner_type == "vendor" and not config.sync_vendors: + _logger.info("Vendor synchronization is disabled") + return False + elif partner_type == "customer" and not config.sync_customers: + _logger.info("Customer synchronization is disabled") + return False + + endpoint = ( + f"{'vendors' if partner_type == 'vendor' else 'customers'}/{billcom_id}" + ) + + try: + # Get partner data from Bill.com + partner_data = self._make_request(endpoint, method="GET") + + if not partner_data or partner_data.get("status") == "error": + _logger.warning("Partner not found in Bill.com with ID: %s", billcom_id) + return False + + # Find existing partner or create new one + existing_partner = self.env["res.partner"].search( + [("billcom_id", "=", billcom_id)], limit=1 + ) + if not existing_partner: + existing_partner = self.env["res.partner"].search( + [("billcom", "=", billcom_id)], limit=1 + ) + + address = partner_data.get("address", {}) + partner_vals = { + "name": partner_data.get("name", "Unknown"), + "email": partner_data.get("email", ""), + "phone": partner_data.get("phone", ""), + "street": address.get("line1", ""), + "street2": address.get("line2", ""), + "city": address.get("city", ""), + "zip": address.get("zipOrPostalCode", ""), + "billcom_id": billcom_id, + "billcom": billcom_id, + "last_sync_date": fields.Datetime.now(), + "ref": partner_data.get("shortName", ""), + "is_sync_to_billcom": True, + "billcom_sync_state": "synced", + } + + # Set partner type + if partner_type == "vendor": + partner_vals["supplier_rank"] = 1 + partner_vals["customer_rank"] = 0 + else: + partner_vals["supplier_rank"] = 0 + partner_vals["customer_rank"] = 1 + + # Set state/country + if address.get("stateOrProvince"): + state = self.env["res.country.state"].search( + [("code", "=", address["stateOrProvince"])], limit=1 + ) + if state: + partner_vals["state_id"] = state.id + + if address.get("country"): + country = self.env["res.country"].search( + [("code", "=", address["country"])], limit=1 + ) + if country: + partner_vals["country_id"] = country.id + + if existing_partner: + existing_partner.with_context(skip_billcom_sync=True).write( + partner_vals + ) + _logger.info( + "Updated %s: %s from Bill.com", + partner_type, + partner_data.get("name"), + ) + return existing_partner + else: + new_partner = ( + self.env["res.partner"] + .with_context(skip_billcom_sync=True) + .create(partner_vals) + ) + _logger.info( + "Created %s: %s from Bill.com", + partner_type, + partner_data.get("name"), + ) + return new_partner + + except Exception as e: + _logger.error( + "Error syncing %s %s from Bill.com: %s", + partner_type, + billcom_id, + str(e), + ) + if existing_partner: + existing_partner.billcom_sync_state = "error" + return False + + def _validate_bulk_payment_count(self, payments): + """Validate payment count is within Bill.com limit.""" + if not payments: + return {"success": False, "errors": ["No payments provided"]} + + if len(payments) > 50: + raise UserError( + _( + "Bill.com bulk payment limit is 50 bills per request. " + "You selected %d payments. Please reduce the selection." + ) + % len(payments) + ) + + return {"success": True} + + def _get_common_funding_account(self, first_payment): + """Get common funding account for bulk payments.""" + common_funding_id = None + funding_account = False + + # Try from first payment's journal + if ( + first_payment.journal_id.bank_account_id + and first_payment.journal_id.bank_account_id.billcom_funding_account_id + ): + funding_account = ( + first_payment.journal_id.bank_account_id.billcom_funding_account_id + ) + common_funding_id = funding_account.billcom_id + + # If not found, try default + if not common_funding_id: + funding_account = self.env["billcom.funding.account"].search( + [ + ("is_default_payables", "=", True), + ("status", "=", "VERIFIED"), + ("company_id", "=", self.env.company.id), + ], + limit=1, + ) + if funding_account: + common_funding_id = funding_account.billcom_id + + common_funding_type = ( + first_payment.billcom_funding_account_type or "BANK_ACCOUNT" + ) + + # Validate funding account + if common_funding_type != "WALLET" and not common_funding_id: + return { + "success": False, + "errors": [ + "No Bill.com funding account configured. " + "Please link a funding account in the journal's bank account or " + "configure a default payables funding account." + ], + } + + return { + "success": True, + "funding_id": common_funding_id, + "funding_type": common_funding_type, + } + + def _get_common_process_date(self, first_payment, funding_type): + """Determine common process date for bulk payments.""" + requires_process_date = funding_type in ["WALLET", "AP_CARD"] + common_process_date = None + + if requires_process_date or not first_payment.is_process_date_sync: + if first_payment.billcom_process_date: + date_obj = first_payment.billcom_process_date + else: + date_obj = fields.Date.today() + + # Convert to string format "YYYY-MM-DD" + if isinstance(date_obj, str): + common_process_date = date_obj + elif hasattr(date_obj, "strftime"): + common_process_date = date_obj.strftime("%Y-%m-%d") + else: + common_process_date = fields.Date.to_string(date_obj) + + # Validate format + if not common_process_date or not isinstance(common_process_date, str): + return { + "success": False, + "errors": [ + f"Invalid process date format. " + f"Expected YYYY-MM-DD string, got: {common_process_date}" + ], + } + elif requires_process_date: + return { + "success": False, + "errors": [ + f"Process date is required for {funding_type} funding type " + "but was not set correctly." + ], + } + + return {"success": True, "process_date": common_process_date} + + def _validate_and_build_payment_items(self, payments): + """Validate payments and build payment items list.""" + payment_items = [] + payment_mapping = {} + + for idx, payment in enumerate(payments): + # Validate sync status + if ( + not payment.is_sync_to_billcom + or not payment.partner_id.is_sync_to_billcom + ): + return { + "success": False, + "errors": [ + f"Payment {payment.name} or vendor {payment.partner_id.name} " + "is not marked for Bill.com synchronization" + ], + } + + # Validate payment type + if payment.payment_type != "outbound" or payment.partner_type != "supplier": + return { + "success": False, + "errors": [ + f"Payment {payment.name} is not a vendor payment " + "(must be outbound supplier payment)" + ], + } + + # Get bill ID + bill_id = self._get_payment_bill_id(payment) + if not bill_id: + return { + "success": False, + "errors": [ + f"Payment {payment.name} does not have a linked bill with Bill.com ID. " + "Bulk payments can only pay existing bills. " + "Please sync the bill to Bill.com first or use single payment creation." + ], + } + + # Build payment item + payment_item = { + "billId": bill_id, + "amount": payment.amount, + } + + payment_items.append(payment_item) + payment_mapping[bill_id] = payment + + _logger.info( + "Bulk Payment [%d/%d]: %s - Vendor: %s, Bill: %s, Amount: %s", + idx + 1, + len(payments), + payment.name, + payment.partner_id.name, + bill_id, + payment.amount, + ) + + return { + "success": True, + "items": payment_items, + "mapping": payment_mapping, + } + + def _get_payment_bill_id(self, payment): + """Extract Bill.com bill ID from payment.""" + if payment.reconciled_bill_ids: + for bill in payment.reconciled_bill_ids: + if bill.billcom_id or bill.billcom: + return bill.billcom_id or bill.billcom + return False + + def _build_bulk_payment_payload( + self, funding_type, funding_id, process_date, payment_items + ): + """Build bulk payment payload for Bill.com API.""" + bulk_payload = { + "fundingAccount": { + "type": funding_type, + }, + "payments": payment_items, + } + + if funding_type != "WALLET": + bulk_payload["fundingAccount"]["id"] = funding_id + + if process_date: + bulk_payload["processDate"] = process_date + + return bulk_payload + + def _process_bulk_payment_results(self, result, payment_mapping, payment_items): + """Process bulk payment API results.""" + if not result or not isinstance(result, list): + return { + "success": False, + "errors": [f"Unexpected response format from Bill.com: {result}"], + } + + success_count = 0 + error_count = 0 + errors = [] + + for payment_result in result: + bill_id = payment_result.get("billId") + payment = payment_mapping.get(bill_id) + + if not payment: + _logger.warning("No payment found for billId: %s", bill_id) + continue + + if payment_result.get("id"): + self._handle_bulk_payment_success( + payment, payment_result, bill_id, success_count, len(payment_items) + ) + success_count += 1 + else: + error_msg = self._handle_bulk_payment_error( + payment, payment_result, bill_id, error_count + ) + errors.append(f"{payment.name}: {error_msg}") + error_count += 1 + + _logger.info("=" * 80) + _logger.info("BULK PAYMENT COMPLETE") + _logger.info("Success: %d, Errors: %d", success_count, error_count) + _logger.info("=" * 80) + + return { + "success": error_count == 0, + "results": result, + "errors": errors, + "success_count": success_count, + "error_count": error_count, + } + + def _handle_bulk_payment_success( + self, payment, payment_result, bill_id, success_count, total_items + ): + """Handle successful bulk payment result.""" + update_vals = { + "billcom": payment_result.get("id"), + "billcom_id": payment_result.get("id"), + "last_sync_date": fields.Datetime.now(), + "billcom_payment_status": payment._map_billcom_status( + payment_result.get("singleStatus") + ), + "billcom_confirmation_number": payment_result.get("confirmationNumber", ""), + "billcom_transaction_number": payment_result.get("transactionNumber", ""), + "billcom_sync_status": "synced", + "billcom_sync_error": False, + } + + # Add international payment fields + if payment_result.get("exchangeRate"): + update_vals["billcom_exchange_rate"] = payment_result.get("exchangeRate") + if payment_result.get("fundingAmount"): + update_vals["billcom_funding_amount"] = payment_result.get("fundingAmount") + + payment.with_context(skip_billcom_sync=True).write(update_vals) + + # Post to chatter + payment.message_post( + body=f"

Bill.com Bulk Payment Created

" + f"
    " + f"
  • Bill.com ID: {payment_result.get('id')}
  • " + f"
  • Bill ID: {bill_id}
  • " + f"
  • Status: {payment_result.get('singleStatus')}
  • " + f"
  • Confirmation #: {payment_result.get('confirmationNumber', 'N/A')}
  • " + f"
  • Transaction #: {payment_result.get('transactionNumber', 'N/A')}
  • " + f"
  • Bulk Request: {success_count + 1}/{total_items}
  • " + f"
", + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + + _logger.info( + "Bulk payment success [%d/%d]: %s - Bill.com ID: %s (Bill: %s)", + success_count + 1, + total_items, + payment.name, + payment_result.get("id"), + bill_id, + ) + + def _handle_bulk_payment_error(self, payment, payment_result, bill_id, error_count): + """Handle bulk payment error result.""" + error_msg = payment_result.get( + "error", "Unknown error in bulk payment response" + ) + + payment.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": error_msg, + } + ) + + _logger.error( + "Bulk payment error [%d]: %s (Bill: %s) - Error: %s", + error_count + 1, + payment.name, + bill_id, + error_msg, + ) + + return error_msg + + @api.model + def create_bulk_payments(self, payments): + """Create bulk payments in Bill.com.""" + # Validate count + validation = self._validate_bulk_payment_count(payments) + if not validation["success"]: + return validation + + _logger.info("=" * 80) + _logger.info("BULK PAYMENT REQUEST - Processing %d payments", len(payments)) + _logger.info("=" * 80) + + # Get funding account + first_payment = payments[0] + funding_result = self._get_common_funding_account(first_payment) + if not funding_result["success"]: + return funding_result + + funding_id = funding_result["funding_id"] + funding_type = funding_result["funding_type"] + + # Get process date + date_result = self._get_common_process_date(first_payment, funding_type) + if not date_result["success"]: + return date_result + + process_date = date_result["process_date"] + + _logger.info("Common Funding Account: %s (type: %s)", funding_id, funding_type) + _logger.info("Common Process Date: %s", process_date) + + # Build payment items + items_result = self._validate_and_build_payment_items(payments) + if not items_result["success"]: + return items_result + + payment_items = items_result["items"] + payment_mapping = items_result["mapping"] + + # Build payload + bulk_payload = self._build_bulk_payment_payload( + funding_type, funding_id, process_date, payment_items + ) + + # Log request + _logger.info("=" * 80) + _logger.info("SENDING BULK PAYMENT REQUEST TO BILL.COM") + _logger.info("Total payments: %d", len(payment_items)) + _logger.info("Funding Account: %s (ID: %s)", funding_type, funding_id) + _logger.info("Process Date: %s", process_date or "Not set") + _logger.info("Full Payload: %s", bulk_payload) + _logger.info("=" * 80) + + try: + # Make API request + result = self._make_request( + "payments/bulk", method="POST", data=bulk_payload + ) + + _logger.info("Bulk payment response received: %s", result) + + # Process results + return self._process_bulk_payment_results( + result, payment_mapping, payment_items + ) + + except Exception as e: + error_detail = str(e) + friendly_message = self._extract_friendly_error(e) + + _logger.error("Bulk payment request failed: %s", error_detail) + + # Mark all payments as failed + for payment in payments: + payment.with_context(skip_billcom_sync=True).write( + { + "billcom_sync_status": "sync_failed", + "billcom_sync_error": friendly_message, + } + ) + + return {"success": False, "errors": [friendly_message]} diff --git a/billcom_integration/models/billcom_service_abstract.py b/billcom_integration/models/billcom_service_abstract.py new file mode 100644 index 00000000..792679c2 --- /dev/null +++ b/billcom_integration/models/billcom_service_abstract.py @@ -0,0 +1,1263 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from datetime import timedelta + +import requests + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class BillcomServiceAbstract(models.AbstractModel): + _name = "billcom.service.abstract" + _description = "Bill.com Integration Service Abstract" + + @api.model + def _get_config(self): + """Get active BillCom configuration and validate it""" + config = ( + self.env["billcom.config"] + .sudo() + .search( + [("active", "=", True), ("company_id", "=", self.env.company.id)], + limit=1, + ) + ) + if not config: + raise UserError( + _("No active BillCom configuration found for company %s") + % self.env.company.name + ) + if not config.api_url or not config.username or not config.password: + raise UserError(_("BillCom API configuration is incomplete")) + return config + + @api.model + def _get_token(self): + """Get authentication token from Bill.com API v3""" + config = self.env["billcom.config"].search( + [("active", "=", True), ("company_id", "=", self.env.company.id)], limit=1 + ) + + if not config: + raise UserError( + _("No active Bill.com configuration found for company %s") + % self.env.company.name + ) + + if not config.username or not config.password: + raise UserError(_("API Key and Secret must be configured")) + + # If token exists and is still valid, return it + if ( + config.token + and config.token_expiry + and config.token_expiry > fields.Datetime.now() + ): + return config.token + + try: + # Determine authentication endpoint based on environment + auth_url = f"{config.api_url}/v3/login" + + payload = { + "organizationId": config.organization_id, + "devKey": config.dev_key, + "username": config.username, + "password": config.password, + } + + if config.mfa_device_id: + payload["deviceId"] = config.mfa_device_id + _logger.info( + "Authenticating with trusted device ID for MFA-free session" + ) + + headers = {"accept": "application/json", "content-type": "application/json"} + + # Make authentication request using JSON + response = requests.post( + auth_url, json=payload, headers=headers, timeout=40 + ) + + # Check for HTTP errors + response.raise_for_status() + + # Parse response + result = response.json() + + # Check for API errors + if result.get("status") == "error": + error_message = result.get("errorMessage", "Unknown error") + raise UserError( + _("Bill.com API authentication error: %s") % error_message + ) + + # Handle MFA challenge + if result.get("mfaRequired") and not result.get("sessionId"): + if config.mfa_device_id: + raise UserError( + _( + "MFA Device ID is not trusted or has expired.\n\n" + "Please:\n" + "1. Login to Bill.com web interface\n" + "2. Complete MFA and mark 'Trust this device'\n" + "3. Update the Device ID in this configuration\n\n" + "Or contact Bill.com support to obtain a trusted device ID." + ) + ) + else: + raise UserError( + _( + "MFA-trusted session required for payment creation.\n\n" + "Bill.com requires MFA authentication for creating payments.\n\n" + "To enable automatic payments:\n" + "1. Obtain a trusted Device ID from Bill.com\n" + "2. Configure it in the 'MFA Device ID' field\n\n" + "See documentation: MFA_PAYMENT_ISSUE.md" + ) + ) + + # Successful authentication + if not result.get("sessionId"): + raise UserError(_("No session ID received from Bill.com API")) + + result.get("sessionId") + + # Store the token in the config + token_expiry = fields.Datetime.now() + timedelta(hours=1) + config.sudo().write( + { + "token": result.get("sessionId"), + "token_expiry": token_expiry, + "state": "connected", + "last_connection_test": fields.Datetime.now(), + "last_error_message": False, + } + ) + + return result.get("sessionId") + + except requests.exceptions.RequestException as e: + error_detail = str(e) + status_code = getattr(e.response, "status_code", "Unknown") + config.sudo().write( + { + "state": "error", + "last_connection_test": fields.Datetime.now(), + "last_error_message": f"HTTP {status_code}: {error_detail}", + } + ) + + raise UserError( + _("Bill.com authentication failed (HTTP %(status)s): %(detail)s") + % {"status": status_code, "detail": error_detail} + ) from e + except Exception as e: + error_detail = str(e) + + config.sudo().write( + { + "state": "error", + "last_connection_test": fields.Datetime.now(), + "last_error_message": str(e), + } + ) + + raise UserError( + _("Unexpected error during Bill.com authentication: %s") % str(e) + ) from e + + @api.model + def _get_mfa_token(self): + config = self._get_config() + + # Check if we have rememberMeId configured + if not config.mfa_remember_me_id: + raise UserError( + _( + "MFA authentication is required for payment creation.\n\n" + "Please use 'Setup MFA' button to configure MFA.\n\n" + "See documentation: claudedocs/MFA_QUICK_GUIDE.md" + ) + ) + + try: + auth_url = f"{config.api_url}/v3/login" + + # Login with rememberMeId and device for MFA-trusted session + payload = { + "organizationId": config.organization_id, + "devKey": config.dev_key, + "username": config.username, + "password": config.password, + "rememberMeId": config.mfa_remember_me_id, + "device": config.mfa_device_name or "Odoo Integration", + } + + headers = {"accept": "application/json", "content-type": "application/json"} + + response = requests.post( + auth_url, json=payload, headers=headers, timeout=40 + ) + response.raise_for_status() + + result = response.json() + session_id = result.get("sessionId") + + if not session_id: + raise UserError(_("No session ID received from MFA login")) + + return session_id + + except requests.exceptions.RequestException as e: + + # Check if Remember Me ID expired + error_msg = str(e).lower() + if "remember" in error_msg and ( + "expired" in error_msg or "invalid" in error_msg + ): + config.sudo().write({"mfa_remember_me_id": False}) + raise UserError( + _( + "MFA Remember Me ID has expired or is invalid.\n\n" + "Please use 'Setup MFA' button to obtain a new one.\n\n" + "Remember Me ID is valid for 30 days." + ) + ) from e + else: + raise UserError(_("MFA-trusted login failed: %s") % str(e)) from e + + @api.model + def _make_request( + self, + endpoint, + method="GET", + data=None, + params=None, + extra_headers=None, + is_file_upload=False, + ): + + config = self._get_config() + max_retries, retry_delay = self._get_retry_config(config) + + for retry_count in range(max_retries + 1): + try: + if is_file_upload: + return self._execute_request_with_upload( + endpoint, + method, + data, + params, + config, + retry_count, + max_retries, + extra_headers, + is_file_upload, + ) + else: + return self._execute_request( + endpoint, + method, + data, + params, + config, + retry_count, + max_retries, + extra_headers, + ) + except Exception as e: + if not self._should_retry(e, retry_count, max_retries): + raise + self._handle_retry_delay(retry_delay, retry_count, max_retries, str(e)) + + def _get_retry_config(self, config): + """Get retry configuration from config""" + max_retries = getattr(config, "api_max_retries", 3) + retry_delay = getattr(config, "api_retry_delay", 5) + return max_retries, retry_delay + + def _execute_request( + self, + endpoint, + method, + data, + params, + config, + retry_count, + max_retries, + extra_headers=None, + ): + """Execute a single API request attempt""" + # Determine if this is a payment creation operation requiring MFA + is_payment_creation = method == "POST" and endpoint.rstrip("/") == "payments" + + # Get appropriate token based on operation type + if is_payment_creation: + token = self._get_mfa_token() + else: + # Regular operations use regular token (no MFA) + token = self._get_token() + + url = self._build_api_url(config, endpoint) + headers = self._build_headers(token, config) + + # Add extra headers if provided + if extra_headers: + headers.update(extra_headers) + + self._log_request(method, url, retry_count, max_retries, data, params, headers) + + response = self._send_http_request(method, url, headers, data, params) + + self._log_response(response) + if response.status_code in (401, 403): + error_details = self._extract_error_details(response) + + if isinstance(error_details, list): + for error in error_details: + if isinstance(error, dict): + error_code = error.get("code") + error_message = error.get("message", "") + + should_refresh_token = False + + if error_code == "BDC_1109": + should_refresh_token = True + + elif error_code == "BDC_1361": + if "untrusted" in error_message.lower(): + # Don't retry - this won't be fixed by token refresh + break + else: + should_refresh_token = True + + # If we need to refresh the token, do it now + if should_refresh_token: + try: + config.sudo().write( + { + "token": False, + "token_expiry": False, + } + ) + + # Use test_connection to get a fresh token + config.test_connection() + if is_payment_creation: + new_token = self._get_mfa_token() + else: + new_token = config.token + headers = self._build_headers(new_token, config) + + # Add extra headers if provided + if extra_headers: + headers.update(extra_headers) + + response = self._send_http_request( + method, url, headers, data, params + ) + self._log_response(response) + + # Return the result of the retried request + return self._process_response( + response, retry_count, max_retries + ) + + except Exception as e: + _logger.error("Failed to refresh token: %s", str(e)) + # Fall through to process the original error + break + + return self._process_response(response, retry_count, max_retries) + + def _build_api_url(self, config, endpoint): + """Build the complete API URL + + Webhooks use a different base URL: connect-events instead of connect + """ + # Check if this is a webhook endpoint + if endpoint.startswith("webhook:"): + # Remove the webhook: prefix and use connect-events base + actual_endpoint = endpoint.replace("webhook:", "") + base_url = config.api_url.replace("/connect", "/connect-events") + return f"{base_url.rstrip('/')}/v3/{actual_endpoint.lstrip('/')}" + + # Standard API endpoint + return f"{config.api_url.rstrip('/')}/v3/{endpoint.lstrip('/')}" + + def _build_headers(self, token, config): + """Build request headers""" + return { + "accept": "application/json", + "content-type": "application/json", + "sessionId": token, + "devKey": config.dev_key, + } + + def _log_request( + self, method, url, retry_count, max_retries, data, params, headers + ): + """Log request details""" + _logger.info( + "Making %s request to Bill.com API (attempt %s/%s): %s", + method, + retry_count + 1, + max_retries + 1, + url, + ) + _logger.debug("Request headers: %s", headers) + _logger.debug("Request data: %s", data) + _logger.debug("Request params: %s", params) + + def _send_http_request(self, method, url, headers, data, params): + """Send the actual HTTP request""" + method_upper = method.upper() + + if method_upper == "GET": + return requests.get(url, headers=headers, params=params, timeout=30) + elif method_upper in ["POST", "PUT", "PATCH"]: + request_method = getattr(requests, method.lower()) + return request_method(url, json=data, headers=headers, timeout=30) + elif method_upper == "DELETE": + return requests.delete(url, headers=headers, timeout=30) + else: + raise UserError(_("Unsupported HTTP method: %s") % method) + + def _log_response(self, response): + """Log response details""" + _logger.info("Bill.com API response status: %s", response.status_code) + _logger.debug("Bill.com API response content: %s", response.content) + + def _process_response(self, response, retry_count, max_retries): + """Process and validate the API response""" + # Check for retryable HTTP status codes + if self._is_retryable_status(response.status_code): + raise self._create_retryable_exception(response) + + # Check for HTTP errors (4xx, 5xx) + if not response.ok: + # Try to extract detailed error information from response body + error_details = self._extract_error_details(response) + + # Build descriptive error message + http_status_map = { + 400: "Bad Request - Invalid data sent to Bill.com API", + 401: "Unauthorized - Authentication failed or session expired", + 403: "Forbidden - Insufficient permissions or session invalid", + 404: "Not Found - Requested resource does not exist", + 422: "Unprocessable Entity - Business logic validation failed", + 423: "Organization Locked - Account temporarily locked by Bill.com", + 429: "Rate Limit Exceeded - Too many API requests", + 500: "Internal Server Error - Bill.com API experiencing issues", + 502: "Bad Gateway - Bill.com API temporarily unavailable", + 503: "Service Unavailable - Bill.com API maintenance or overload", + } + + status_description = http_status_map.get( + response.status_code, f"HTTP {response.status_code} Error" + ) + + # Log detailed error information + _logger.error( + "Bill.com API Error: %s\n" + "Request: %s %s\n" + "Status Code: %s\n" + "Error Details: %s", + status_description, + response.request.method, + response.url, + response.status_code, + error_details, + ) + + # Raise with detailed error message + response.raise_for_status() + + # Handle empty response + if not response.content or not response.content.strip(): + return {} + + # Parse JSON response + try: + result = response.json() + except ValueError as e: + _logger.error( + "Bill.com API Response Parsing Error\n" + "Failed to parse JSON response from Bill.com\n" + "URL: %s\n" + "Response Length: %s bytes\n" + "Error: %s\n" + "Response Preview: %s", + response.url, + len(response.content), + str(e), + response.content[:500], + ) + raise self._create_retryable_exception( + response, f"JSON parsing error: {e}" + ) from e + + # Check for API-level errors + if isinstance(result, dict) and result.get("status") == "error": + error_message = result.get("errorMessage", "Unknown API error") + error_code = result.get("errorCode", "UNKNOWN") + _logger.error( + "Bill.com API-level Error\n" + "Error Code: %s\n" + "Error Message: %s\n" + "URL: %s\n" + "Full Response: %s", + error_code, + error_message, + response.url, + result, + ) + raise self._create_retryable_exception( + response, f"{error_code}: {error_message}" + ) + + return result + + def _extract_error_details(self, response): + """Extract detailed error information from response body""" + try: + # Try to parse JSON error response + error_data = response.json() + + # Bill.com API v3 error format + if isinstance(error_data, list): + # Array of error objects + errors = [] + for error in error_data: + if isinstance(error, dict): + error_info = { + "timestamp": error.get("timestamp"), + "code": error.get("code"), + "severity": error.get("severity"), + "category": error.get("category"), + "message": error.get("message"), + "params": error.get("params", {}), + } + errors.append(error_info) + return errors + elif isinstance(error_data, dict): + # Single error object or nested errors + if "errors" in error_data: + return error_data["errors"] + return error_data + else: + return error_data + except Exception as e: + # If JSON parsing fails, return raw content + _logger.debug("Could not parse error response as JSON: %s", str(e)) + return response.text + + def _extract_friendly_error(self, exception): # noqa: C901 + """Extract user-friendly error message from exception + + Args: + exception: The exception object (usually HTTPError) + + Returns: + str: User-friendly error message in English + """ + try: + # Check if it's an HTTP error with a response + if hasattr(exception, "response") and exception.response is not None: + response = exception.response + error_details = self._extract_error_details(response) + + # Bill.com API v3 format: list of error objects + if isinstance(error_details, list): + messages = [] + for error in error_details: + if isinstance(error, dict) and error.get("message"): + msg = error["message"] + error_code = error.get("code") + + # Handle specific error codes + if error_code == "BDC_1107": + # Organization locked out + messages.append( + "⚠️ Organization is temporarily locked by Bill.com.\n" + "This usually happens due to:\n" + " • Too many API requests in a short time\n" + " • Multiple failed authentication attempts\n" + " • Security restrictions on staging environment\n\n" + "Please wait 5-15 minutes and try again.\n" + "If the problem persists, contact Bill.com support." + ) + elif error_code == "BDC_1171": + # Duplicate invoice/bill number + messages.append(msg) + elif ":" in msg: + # Make field names more readable + # Example: "email: must not be blank" -> "Email is required" + field, requirement = msg.split(":", 1) + field = field.strip().replace("_", " ").title() + requirement = requirement.strip() + + if "must not be blank" in requirement: + messages.append(f"{field} is required") + elif "must not be null" in requirement: + messages.append(f"{field} is required") + else: + messages.append(f"{field}: {requirement}") + else: + messages.append(msg) + + if messages: + return "\n".join(f"• {msg}" for msg in messages) + + # Dict format + elif isinstance(error_details, dict): + if "errorMessage" in error_details: + return error_details["errorMessage"] + elif "message" in error_details: + return error_details["message"] + + # String format + elif isinstance(error_details, str): + return error_details + + # Fallback to exception message + return str(exception) + + except Exception as e: + _logger.debug("Could not extract friendly error message: %s", str(e)) + return str(exception) + + def _is_retryable_status(self, status_code): + """Check if HTTP status code is retryable""" + return status_code in (429, 500, 502, 503, 504) + + def _create_retryable_exception(self, response, message=None): + """Create an exception that can be retried""" + if message is None: + message = f"HTTP {response.status_code}" + + class RetryableException(Exception): + def __init__(self, msg, status_code=None): + super().__init__(msg) + self.status_code = status_code + + return RetryableException(message, getattr(response, "status_code", None)) + + def _should_retry(self, exception, retry_count, max_retries): + """Determine if an exception should trigger a retry""" + if retry_count >= max_retries: + return False + + if hasattr(exception, "response") and exception.response is not None: + status_code = exception.response.status_code + if 400 <= status_code < 500: + _logger.info( + "Not retrying request - HTTP %s is a client error that" + " won't be fixed by retrying", + status_code, + ) + return False + + # Retry on network errors + if isinstance( + exception, + ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.RequestException, + ), + ): + return True + + # Retry on our custom retryable exceptions (429, 500, 502, 503, 504) + if hasattr(exception, "status_code") and self._is_retryable_status( + exception.status_code + ): + return True + + # Retry on JSON parsing errors + if "JSON parsing error" in str(exception): + return True + + return False + + def _handle_retry_delay(self, retry_delay, retry_count, max_retries, error_msg): + """Handle the delay between retries""" + _logger.warning( + "Retrying in %s seconds (attempt %s/%s): %s", + retry_delay, + retry_count + 1, + max_retries + 1, + error_msg, + ) + import time + + time.sleep(retry_delay) + + @api.model + def _handle_mfa_challenge(self, mfa_challenge_data, config): + """Handle MFA challenge from Bill.com API""" + if not config.enable_mfa: + raise UserError(_("MFA is required but not configured in settings")) + + # This would typically involve user interaction or stored MFA device + # For now, we'll log the challenge and raise an error for manual handling + _logger.warning( + "MFA challenge received. Challenge data: %s", mfa_challenge_data + ) + + # In a production implementation, this would: + # 1. Send MFA code to user's device + # 2. Wait for user input or automated device response + # 3. Submit MFA response back to Bill.com + + raise UserError( + _( + "MFA authentication required. Please check your MFA device " + "and configure the MFA response in Bill.com settings." + ) + ) + + @api.model + def _send_mfa_response(self, mfa_token, mfa_code, config): + """Send MFA response to Bill.com API""" + try: + mfa_url = f"{config.api_url}/v3/mfa/verify" + + mfa_data = { + "mfaToken": mfa_token, + "mfaCode": mfa_code, + "deviceId": config.mfa_device_id, + } + + headers = { + "accept": "application/json", + "content-type": "application/json", + "devKey": config.dev_key, + } + + response = requests.post( + mfa_url, json=mfa_data, headers=headers, timeout=30 + ) + response.raise_for_status() + + result = response.json() + + if result.get("status") == "error": + error_message = result.get("errorMessage", "MFA verification failed") + raise UserError(_("MFA verification error: %s") % error_message) + + return result.get("sessionId") + + except requests.exceptions.RequestException as e: + _logger.error("MFA verification request failed: %s", str(e)) + raise UserError(_("MFA verification request failed: %s") % str(e)) from e + + def _get_state_id(self, state_code): + """Get state ID from state code""" + if not state_code: + return False + state = self.env["res.country.state"].search( + [("code", "=", state_code)], limit=1 + ) + return state.id if state else False + + def _get_country_id(self, country_code): + """Get country ID from country code""" + if not country_code: + return False + country = self.env["res.country"].search([("code", "=", country_code)], limit=1) + return country.id if country else False + + @api.model + def _mfa_step_up(self, config, session_id): + """Convert current session to MFA-trusted using step-up + + Args: + config: billcom.config record + session_id: Current session ID to upgrade + + Returns: + bool: True if step-up successful + + Raises: + UserError: If step-up fails + """ + try: + # Step 1: Check current MFA status + status_url = f"{config.api_url}/v3/login/session" + status_headers = { + "accept": "application/json", + "content-type": "application/json", + "sessionId": session_id, + "devKey": config.dev_key, + } + + _logger.info("Checking MFA status at: %s", status_url) + status_response = requests.get( + status_url, headers=status_headers, timeout=30 + ) + + if status_response.status_code != 200: + _logger.error("Failed to retrieve MFA status: %s", status_response.text) + raise UserError( + _("Failed to check MFA status: %s") % status_response.text + ) + + status_result = status_response.json() + mfa_status = status_result.get("mfaStatus") + _logger.info("Current MFA status: %s", mfa_status) + + # Step 2: If already MFA complete, no need for step-up + if mfa_status == "COMPLETE": + _logger.info( + "✅ Session already has MFA COMPLETE status - no step-up needed" + ) + return True + + # Step 3: Perform MFA step-up + _logger.info("MFA status is '%s' - performing step-up", mfa_status) + step_up_url = f"{config.api_url}/v3/mfa/step-up" + + headers = { + "accept": "application/json", + "content-type": "application/json", + "sessionId": session_id, + "devKey": config.dev_key, + } + + payload = { + "rememberMeId": config.mfa_remember_me_id, + "device": config.mfa_device_name or "Odoo Integration", + } + + _logger.info("Step-up URL: %s", step_up_url) + _logger.info( + "Step-up payload: %s", + { + "rememberMeId": config.mfa_remember_me_id[:20] + "...", + "device": payload["device"], + }, + ) + + response = requests.post( + step_up_url, json=payload, headers=headers, timeout=30 + ) + + _logger.info("Step-up response status: %s", response.status_code) + _logger.info("Step-up response body: %s", response.text) + + response.raise_for_status() + + result = response.json() + + # Step 4: Verify MFA status after step-up + # Instead of trusting only the "trusted" field, check actual MFA status + _logger.info("Verifying MFA status after step-up...") + verify_response = requests.get( + status_url, headers=status_headers, timeout=30 + ) + + if verify_response.status_code == 200: + verify_result = verify_response.json() + new_mfa_status = verify_result.get("mfaStatus") + _logger.info("MFA status after step-up: %s", new_mfa_status) + + if new_mfa_status == "COMPLETE": + _logger.info( + "✅ Session successfully marked as MFA-trusted via step-up" + ) + return True + else: + _logger.warning( + "Step-up completed but MFA status is still '%s' (expected 'COMPLETE')", + new_mfa_status, + ) + # Don't clear Remember Me ID yet, might be a timing issue + raise UserError( + _( + "MFA step-up completed but session is not yet trusted.\n\n" + "Status: %s\n\n" + "Please try again in a moment.\n\n" + "If the problem persists, use 'Setup MFA' button" + " to reconfigure." + ) + % new_mfa_status + ) + else: + # Verification failed but step-up succeeded + # Accept the step-up result + if result.get("trusted"): + _logger.info( + "✅ Step-up response indicates trusted " + "(verification failed but accepting)" + ) + return True + else: + _logger.error( + "Step-up response did not indicate trusted status: %s", result + ) + # Only clear Remember Me ID if we're sure it's invalid + # Don't clear on first failure - might be temporary issue + raise UserError( + _( + "MFA step-up did not establish trusted session.\n\n" + "Response: %s\n\n" + "Please try again. If the problem persists, use 'Setup MFA' button." + ) + % result + ) + + except requests.exceptions.RequestException as e: + _logger.error("MFA step-up request failed: %s", str(e)) + + # Check if it's definitely an expired/invalid Remember Me ID + error_msg = str(e).lower() + + # Only clear Remember Me ID if error explicitly mentions it's expired/invalid + if "remember" in error_msg and ( + "expired" in error_msg or "invalid" in error_msg + ): + config.sudo().write({"mfa_remember_me_id": False}) + _logger.warning( + "RememberMeId confirmed expired or invalid - cleared from config" + ) + raise UserError( + _( + "MFA Remember Me ID has expired or is invalid.\n\n" + "Please use 'Setup MFA' button to obtain a new one.\n\n" + "Remember Me ID is valid for 30 days." + ) + ) from e + else: + # Other network/API error - don't clear Remember Me ID + _logger.warning( + "MFA step-up failed but Remember Me ID kept (may be temporary issue)" + ) + raise UserError( + _( + "MFA step-up request failed.\n\n" + "Error: %s\n\n" + "Please try again. If the problem persists, check:\n" + "1. Network connectivity\n" + "2. Bill.com API status\n" + "3. Use 'Setup MFA' button to reconfigure if needed" + ) + % str(e) + ) from e + + def _execute_request_with_upload( # noqa: C901 + self, + endpoint, + method, + data, + params, + config, + retry_count, + max_retries, + extra_headers=None, + is_file_upload=False, + ): + """Execute request with support for file uploads""" + # Determine if this is a payment creation operation requiring MFA + is_payment_creation = method == "POST" and endpoint.rstrip("/") == "payments" + + # Get appropriate token based on operation type + if is_payment_creation: + _logger.info("Payment creation detected - using MFA-trusted token") + token = self._get_mfa_token() + else: + token = self._get_token() + + url = self._build_api_url(config, endpoint) + headers = self._build_headers(token, config) + + # Modify headers for file upload + if is_file_upload: + headers["content-type"] = "application/octet-stream" + + # Add extra headers if provided + if extra_headers: + headers.update(extra_headers) + + self._log_request(method, url, retry_count, max_retries, data, params, headers) + + # Send request with file upload support + if is_file_upload: + response = self._send_file_upload_request( + method, url, headers, data, params + ) + else: + response = self._send_http_request(method, url, headers, data, params) + + self._log_response(response) + + # Check for expired/invalid session + if response.status_code in (401, 403): + error_details = self._extract_error_details(response) + + if isinstance(error_details, list): + for error in error_details: + if isinstance(error, dict): + error_code = error.get("code") + error_message = error.get("message", "") + + should_refresh_token = False + + if error_code == "BDC_1109": + _logger.warning( + "Session invalid (BDC_1109) - invalidating token" + " and re-authenticating" + ) + should_refresh_token = True + + elif error_code == "BDC_1361": + if "untrusted" in error_message.lower(): + _logger.error( + "MFA-trusted session required " + "(BDC_1361: Untrusted session)" + ) + break + else: + _logger.warning( + "Session expired (BDC_1361) - invalidating" + " token and refreshing" + ) + should_refresh_token = True + + if should_refresh_token: + try: + config.sudo().write( + { + "token": False, + "token_expiry": False, + } + ) + config.test_connection() + _logger.info( + "Token refreshed successfully, retrying request" + ) + + if is_payment_creation: + new_token = self._get_mfa_token() + else: + new_token = config.token + headers = self._build_headers(new_token, config) + + if is_file_upload: + headers["content-type"] = "application/octet-stream" + + if extra_headers: + headers.update(extra_headers) + + if is_file_upload: + response = self._send_file_upload_request( + method, url, headers, data, params + ) + else: + response = self._send_http_request( + method, url, headers, data, params + ) + self._log_response(response) + + return self._process_response( + response, retry_count, max_retries + ) + + except Exception as e: + _logger.error("Failed to refresh token: %s", str(e)) + break + + return self._process_response(response, retry_count, max_retries) + + def _send_file_upload_request(self, method, url, headers, file_data, params): + """Send HTTP request with file data""" + method_upper = method.upper() + + if method_upper == "POST": + return requests.post( + url, data=file_data, headers=headers, params=params, timeout=60 + ) + elif method_upper == "PUT": + return requests.put( + url, data=file_data, headers=headers, params=params, timeout=60 + ) + else: + raise UserError(_("File upload only supports POST and PUT methods")) + + @api.model + def _download_document(self, download_url): + """Download document from Bill.com + + Args: + download_url: Download URL from Bill.com document response + + Returns: + bytes: File content + """ + config = self._get_config() + token = self._get_token() + + headers = { + "sessionId": token, + "devKey": config.dev_key, + } + + _logger.info("Downloading document from Bill.com: %s", download_url) + + try: + response = requests.get(download_url, headers=headers, timeout=60) + response.raise_for_status() + + _logger.info( + "Document downloaded successfully (%d bytes)", len(response.content) + ) + return response.content + + except requests.exceptions.HTTPError as e: + error_details = self._extract_error_details(e.response) + + http_status_map = { + 401: "Unauthorized - Authentication failed or session expired", + 403: "Forbidden - Insufficient permissions", + 404: "Not Found - Document no longer exists", + } + + status_description = http_status_map.get( + e.response.status_code, f"HTTP {e.response.status_code}" + ) + + _logger.error( + "Bill.com Document Download Error: %s\n" + "URL: %s\n" + "Status Code: %s\n" + "Error Details: %s", + status_description, + download_url, + e.response.status_code, + error_details, + ) + raise + + except requests.exceptions.RequestException as e: + _logger.error("Document download failed: %s", str(e)) + raise + + @api.model + def get_invoice_payment_link(self, invoice_id, customer_id, customer_email): + """Get payment link for a customer invoice + + Args: + invoice_id (str): Bill.com invoice ID + customer_id (str): Bill.com customer ID + customer_email (str): Customer email address for payment receipt + + Returns: + str: Payment link URL from Bill.com + + Raises: + UserError: If API request fails or response is invalid + """ + if not invoice_id: + raise UserError(_("Invoice ID is required to get payment link")) + if not customer_id: + raise UserError(_("Customer ID is required to get payment link")) + if not customer_email: + raise UserError(_("Customer email is required to get payment link")) + + _logger.info( + "Requesting payment link for invoice %s (customer: %s)", + invoice_id, + customer_id, + ) + + try: + data = { + "customerId": customer_id, + "email": customer_email, + } + + response = self._make_request( + f"invoices/{invoice_id}/payment-link", method="POST", data=data + ) + + if not response or not response.get("paymentLink"): + error_msg = _( + "Failed to get payment link from Bill.com. Response: %s" + ) % str(response) + _logger.error(error_msg) + raise UserError(error_msg) + + payment_link = response.get("paymentLink") + _logger.info( + "Successfully retrieved payment link for invoice %s", invoice_id + ) + return payment_link + + except Exception as e: + friendly_message = self._extract_friendly_error(e) + _logger.error( + "Error getting payment link for invoice %s: %s", invoice_id, str(e) + ) + raise UserError( + _("Failed to get payment link from Bill.com:\n\n%s") % friendly_message + ) from e + + @api.model + def send_invoice_email(self, invoice_id, recipient_emails=None): + """Send invoice payment reminder email via Bill.com + + Args: + invoice_id (str): Bill.com invoice ID + recipient_emails (list): List of recipient email addresses + If not provided, Bill.com uses default customer email + + Returns: + dict: API response from Bill.com + + Raises: + UserError: If API request fails + """ + if not invoice_id: + raise UserError(_("Invoice ID is required to send email")) + + _logger.info("Sending invoice payment reminder for invoice %s", invoice_id) + + try: + data = {} + + # Add recipient emails if provided + if recipient_emails: + if not isinstance(recipient_emails, list): + recipient_emails = [recipient_emails] + data["recipient"] = {"to": recipient_emails} + _logger.info( + "Sending to custom recipients: %s", ", ".join(recipient_emails) + ) + else: + _logger.info("Using Bill.com default customer email") + + response = self._make_request( + f"invoices/{invoice_id}/email", method="POST", data=data + ) + + _logger.info( + "Successfully sent payment reminder for invoice %s", invoice_id + ) + return response + + except Exception as e: + friendly_message = self._extract_friendly_error(e) + _logger.error( + "Error sending payment reminder for invoice %s: %s", invoice_id, str(e) + ) + raise UserError( + _("Failed to send payment reminder via Bill.com:\n\n%s") + % friendly_message + ) from e diff --git a/billcom_integration/models/billcom_status.py b/billcom_integration/models/billcom_status.py new file mode 100644 index 00000000..2c4bd9a3 --- /dev/null +++ b/billcom_integration/models/billcom_status.py @@ -0,0 +1,60 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class BillcomBillStatus(models.Model): + _name = "billcom.bill.status" + _description = "Bill.com Bill Status" + _order = "sequence, name" + + name = fields.Char(required=True) + code = fields.Char(string="API Code", required=True) + sequence = fields.Integer(default=10) + + _sql_constraints = [ + ("code_unique", "unique(code)", "Status code must be unique!"), + ] + + +class BillcomInvoiceStatus(models.Model): + _name = "billcom.invoice.status" + _description = "Bill.com Invoice Status" + _order = "sequence, name" + + name = fields.Char(required=True) + code = fields.Char(string="API Code", required=True) + sequence = fields.Integer(default=10) + + _sql_constraints = [ + ("code_unique", "unique(code)", "Status code must be unique!"), + ] + + +class BillcomPaymentStatus(models.Model): + _name = "billcom.payment.status" + _description = "Bill.com Payment Status" + _order = "sequence, name" + + name = fields.Char(required=True) + code = fields.Char(string="API Code", required=True) + sequence = fields.Integer(default=10) + + _sql_constraints = [ + ("code_unique", "unique(code)", "Status code must be unique!"), + ] + + +class BillcomBillApprovalStatus(models.Model): + _name = "billcom.bill.approval.status" + _description = "Bill.com Bill Approval Status" + _order = "sequence, name" + + name = fields.Char(required=True) + code = fields.Char(string="API Code", required=True) + sequence = fields.Integer(default=10) + + _sql_constraints = [ + ("code_unique", "unique(code)", "Status code must be unique!"), + ] diff --git a/billcom_integration/models/billcom_sync_queue.py b/billcom_integration/models/billcom_sync_queue.py new file mode 100644 index 00000000..9ff0ba37 --- /dev/null +++ b/billcom_integration/models/billcom_sync_queue.py @@ -0,0 +1,516 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class BillcomSyncQueue(models.Model): + _name = "billcom.sync.queue" + _description = "Bill.com Synchronization Queue" + _order = "create_date desc" + _rec_name = "display_name" + + # Basic Information + display_name = fields.Char(compute="_compute_display_name", store=True) + + sync_type = fields.Selection( + [ + ("vendor", "Vendor Sync"), + ("customer", "Customer Sync"), + ("bill", "Bill Sync"), + ("invoice", "Invoice Sync"), + ("payment", "Payment Sync"), + ("attachment", "Attachment Sync"), + ], + required=True, + ) + + direction = fields.Selection( + [ + ("odoo_to_billcom", "Odoo → Bill.com"), + ("billcom_to_odoo", "Bill.com → Odoo"), + ("bidirectional", "Bidirectional"), + ], + required=True, + default="odoo_to_billcom", + ) + + operation = fields.Selection( + [ + ("create", "Create"), + ("update", "Update"), + ("delete", "Delete"), + ("sync", "Synchronize"), + ], + required=True, + default="sync", + ) + + # Status and Priority + state = fields.Selection( + [ + ("draft", "Draft"), + ("queued", "Queued"), + ("processing", "Processing"), + ("success", "Success"), + ("error", "Error"), + ("cancelled", "Cancelled"), + ], + required=True, + default="draft", + ) + + priority = fields.Selection( + [ + ("0", "Low"), + ("1", "Normal"), + ("2", "High"), + ("3", "Critical"), + ], + default="1", + ) + + # Record References + record_model = fields.Char() + record_id = fields.Integer() + record_name = fields.Char() + billcom_id = fields.Char(string="Bill.com ID") + + # Sync Data + sync_data = fields.Text(string="Sync Data (JSON)") + response_data = fields.Text() + + # Execution Details + scheduled_date = fields.Datetime(default=fields.Datetime.now) + started_date = fields.Datetime() + completed_date = fields.Datetime() + + # Retry Logic + retry_count = fields.Integer(default=0) + max_retries = fields.Integer(default=3) + next_retry_date = fields.Datetime() + + # Error Handling + error_message = fields.Text() + error_traceback = fields.Text() + + # User and Company Context + user_id = fields.Many2one( + "res.users", string="Created by", default=lambda self: self.env.user + ) + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + config_id = fields.Many2one("billcom.config", string="Bill.com Configuration") + + # Related Logs + logger_ids = fields.One2many("billcom.logger", "sync_queue_id", string="Logs") + + @api.depends("sync_type", "operation", "record_name", "billcom_id") + def _compute_display_name(self): + for record in self: + parts = [record.sync_type.title() if record.sync_type else "Sync"] + if record.operation: + parts.append(record.operation.title()) + if record.record_name: + parts.append(f"'{record.record_name}'") + elif record.billcom_id: + parts.append(f"[{record.billcom_id}]") + record.display_name = " ".join(parts) + + @api.model + def create_sync_item( + self, + sync_type, + record_model=None, + record_id=None, + direction="odoo_to_billcom", + operation="sync", + priority="1", + sync_data=None, + billcom_id=None, + **kwargs, + ): + """Create a new sync queue item""" + + record_name = None + if record_model and record_id: + try: + record = self.env[record_model].browse(record_id) + if record.exists(): + record_name = record.display_name + # Get billcom_id from record if not provided + if not billcom_id and hasattr(record, "billcom_id"): + billcom_id = record.billcom_id + elif not billcom_id and hasattr(record, "billcom"): + billcom_id = record.billcom + except Exception as e: + _logger.warning( + f"Could not get record info for {record_model}: {record_id}: {e}" + ) + + values = { + "sync_type": sync_type, + "direction": direction, + "operation": operation, + "priority": priority, + "record_model": record_model, + "record_id": record_id, + "record_name": record_name, + "billcom_id": billcom_id, + "state": "queued", + } + + if sync_data: + values["sync_data"] = ( + json.dumps(sync_data) if isinstance(sync_data, dict) else sync_data + ) + + values.update(kwargs) + + # Check for duplicates + existing = self._find_duplicate(values) + if existing: + _logger.info( + f"Sync item already exists for {sync_type} {record_model}: {record_id}" + ) + return existing + + sync_item = self.create(values) + + # Create initial log entry + self.env["billcom.logger"].log_operation( + f"sync_{sync_type}", + status="pending", + record_model=record_model, + record_id=record_id, + record_name=record_name, + billcom_id=billcom_id, + sync_queue_id=sync_item.id, + message=f"Queued {operation} operation for {sync_type}", + ) + + return sync_item + + def _find_duplicate(self, values): + """Find duplicate sync items""" + domain = [ + ("sync_type", "=", values["sync_type"]), + ("operation", "=", values["operation"]), + ("state", "in", ["draft", "queued", "processing"]), + ] + + if values.get("record_model") and values.get("record_id"): + domain.extend( + [ + ("record_model", "=", values["record_model"]), + ("record_id", "=", values["record_id"]), + ] + ) + elif values.get("billcom_id"): + domain.append(("billcom_id", "=", values["billcom_id"])) + + return self.search(domain, limit=1) + + def action_process(self): + """Process the sync queue item""" + self.ensure_one() + + if self.state != "queued": + raise UserError(_("Only queued items can be processed")) + + # Update state and start time + self.write( + { + "state": "processing", + "started_date": fields.Datetime.now(), + } + ) + + # Create processing log + log_entry = self.env["billcom.logger"].log_operation( + f"sync_{self.sync_type}", + status="processing", + record_model=self.record_model, + record_id=self.record_id, + record_name=self.record_name, + billcom_id=self.billcom_id, + sync_queue_id=self.id, + message=f"Processing {self.operation} operation for {self.sync_type}", + ) + + try: + # Process based on sync type and direction + result = self._execute_sync() + + # Mark as successful + self.write( + { + "state": "success", + "completed_date": fields.Datetime.now(), + "response_data": ( + json.dumps(result) if isinstance(result, dict) else str(result) + ), + } + ) + + log_entry.mark_success( + f"Successfully processed {self.sync_type} {self.operation}" + ) + + return result + + except Exception as e: + error_msg = str(e) + _logger.error(f"Error processing sync queue item {self.id}: {error_msg}") + + # Update retry logic + if self.retry_count < self.max_retries: + self._schedule_retry(error_msg) + log_entry.mark_retry( + retry_count=self.retry_count + 1, error_message=error_msg + ) + else: + self.write( + { + "state": "error", + "completed_date": fields.Datetime.now(), + "error_message": error_msg, + } + ) + log_entry.mark_error(error_msg) + + raise + + def _execute_sync(self): + """Execute the actual synchronization""" + if self.direction == "odoo_to_billcom": + return self._sync_odoo_to_billcom() + elif self.direction == "billcom_to_odoo": + return self._sync_billcom_to_odoo() + elif self.direction == "bidirectional": + result1 = self._sync_odoo_to_billcom() + result2 = self._sync_billcom_to_odoo() + return {"odoo_to_billcom": result1, "billcom_to_odoo": result2} + + def _sync_odoo_to_billcom(self): + """Sync from Odoo to Bill.com""" + if not self.record_model or not self.record_id: + raise UserError(_("Missing record information for sync")) + + record = self.env[self.record_model].browse(self.record_id) + if not record.exists(): + raise UserError( + _("Record not found: %(model)s[%(id)s]") + % {"model": self.record_model, "id": self.record_id} + ) + + # Call appropriate sync method based on type + if self.sync_type == "vendor": + return record.sync_to_billcom("vendor") + elif self.sync_type == "customer": + return record.sync_to_billcom("customer") + elif self.sync_type == "bill": + return record.button_sync_to_billcom() + elif self.sync_type == "invoice": + return record.button_sync_to_billcom() + elif self.sync_type == "payment": + return record.button_sync_to_billcom() + else: + raise UserError(_("Unsupported sync type: %s") % self.sync_type) + + def _sync_billcom_to_odoo(self): + """Sync from Bill.com to Odoo""" + if not self.billcom_id: + raise UserError(_("Missing Bill.com ID for sync")) + + # Use the billcom_service to process the queue item + service = self.env["billcom.service"] + + # If sync_data is not available, fetch it from BILL API first + if not self.sync_data: + self._fetch_billcom_data() + + # Process the queue item using the service + return service.process_queue_item_from_billcom(self) + + def _fetch_billcom_data(self): + """Fetch data from BILL API for this queue item""" + if not self.billcom_id: + raise UserError(_("Missing Bill.com ID to fetch data")) + + service = self.env["billcom.service"] + + # Determine endpoint based on sync type + endpoint_map = { + "vendor": f"vendors/{self.billcom_id}", + "customer": f"customers/{self.billcom_id}", + "bill": f"bills/{self.billcom_id}", + "payment": f"payments/{self.billcom_id}", + } + + endpoint = endpoint_map.get(self.sync_type) + if not endpoint: + raise UserError( + _("Unsupported sync type for fetching data: %s") % self.sync_type + ) + + try: + _logger.info( + f"Fetching {self.sync_type} data from BILL API: {self.billcom_id}" + ) + response = service._make_request(endpoint, method="GET") + + # Store the response data + if response: + self.sync_data = str(response) + _logger.info(f"Successfully fetched {self.sync_type} data from BILL") + else: + raise UserError(_("No data returned from BILL API")) + + except Exception as e: + _logger.error(f"Error fetching data from BILL API: {e}") + raise UserError(_(f"Failed to fetch data from Bill.com: {e}")) from e + + def _schedule_retry(self, error_message): + """Schedule a retry for failed sync""" + self.retry_count += 1 + + # Exponential backoff: 2^retry_count minutes + delay_minutes = 2**self.retry_count + next_retry = fields.Datetime.now() + timedelta(minutes=delay_minutes) + + self.write( + { + "state": "queued", + "next_retry_date": next_retry, + "error_message": error_message, + } + ) + + def action_retry(self): + """Manually retry a failed sync""" + self.ensure_one() + + if self.state not in ["error", "queued"]: + raise UserError(_("Only failed or queued items can be retried")) + + # Reset state + self.write( + { + "state": "queued", + "next_retry_date": fields.Datetime.now(), + "error_message": False, + "started_date": False, + "completed_date": False, + } + ) + + return self.action_process() + + def action_cancel(self): + """Cancel the sync item""" + self.ensure_one() + + if self.state == "processing": + raise UserError(_("Cannot cancel item that is currently processing")) + + self.write( + { + "state": "cancelled", + "completed_date": fields.Datetime.now(), + } + ) + + @api.model + def process_queue(self, limit=50): + """Process queued sync items""" + # Get items that are ready to process + domain = [ + ("state", "=", "queued"), + "|", + ("next_retry_date", "=", False), + ("next_retry_date", "<=", fields.Datetime.now()), + ] + + items = self.search( + domain, limit=limit, order="priority desc, scheduled_date asc" + ) + + processed = 0 + errors = 0 + + for item in items: + try: + item.action_process() + processed += 1 + except Exception as e: + errors += 1 + _logger.error(f"Failed to process sync queue item {item.id}: {e}") + + _logger.info(f"Processed {processed} sync items, {errors} errors") + + return { + "processed": processed, + "errors": errors, + "total": len(items), + } + + @api.model + def cleanup_completed_items(self, days=7): + """Clean up old completed sync items""" + cutoff_date = fields.Datetime.now() - timedelta(days=days) + old_items = self.search( + [ + ("state", "in", ["success", "cancelled"]), + ("completed_date", "<", cutoff_date), + ] + ) + count = len(old_items) + old_items.unlink() + _logger.info(f"Cleaned up {count} completed sync queue items") + return count + + def action_view_logs(self): + """View related logs""" + self.ensure_one() + + return { + "type": "ir.actions.act_window", + "name": "Sync Logs", + "res_model": "billcom.logger", + "view_mode": "tree,form", + "domain": [("sync_queue_id", "=", self.id)], + "context": {"default_sync_queue_id": self.id}, + } + + def action_view_record(self): + """View the synced record (partner, bill, invoice, etc.)""" + self.ensure_one() + + if not self.record_model or not self.record_id: + raise UserError(_("No record associated with this sync item")) + + # Check if record still exists + record = self.env[self.record_model].browse(self.record_id) + if not record.exists(): + raise UserError(_("The associated record no longer exists")) + + # Get appropriate view mode based on model + view_mode = "form,tree" + if self.record_model == "ir.attachment": + view_mode = "form" + + return { + "type": "ir.actions.act_window", + "name": self.record_name or "Record", + "res_model": self.record_model, + "view_mode": view_mode, + "res_id": self.record_id, + "target": "current", + } diff --git a/billcom_integration/models/billcom_webhook_log.py b/billcom_integration/models/billcom_webhook_log.py new file mode 100644 index 00000000..fb90910a --- /dev/null +++ b/billcom_integration/models/billcom_webhook_log.py @@ -0,0 +1,162 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class BillcomWebhookLog(models.Model): + _name = "billcom.webhook.log" + _description = "Bill.com Webhook Log" + _order = "create_date desc" + _rec_name = "idempotency_key" + + idempotency_key = fields.Char( + required=True, + index=True, + help="Unique key to prevent duplicate webhook processing (eventType:entityId)", + ) + + event_type = fields.Char( + required=True, + index=True, + help="Type of webhook event (bill.created, vendor.updated, etc.)", + ) + + entity_id = fields.Char( + required=True, + help="Bill.com ID of the entity affected", + ) + + webhook_data = fields.Text( + help="Full JSON payload received from Bill.com", + ) + + state = fields.Selection( + [ + ("received", "Received"), + ("processing", "Processing"), + ("success", "Success"), + ("error", "Error"), + ], + default="received", + required=True, + ) + + error_message = fields.Text( + help="Error message if processing failed", + ) + + processed_at = fields.Datetime( + help="When the webhook was successfully processed", + ) + + signature_valid = fields.Boolean( + default=False, + help="Whether the webhook signature was valid", + ) + + company_id = fields.Many2one( + "res.company", + default=lambda self: self.env.company, + ) + + @api.model + def check_duplicate(self, idempotency_key): + """Check if webhook with this idempotency key was already processed + + Args: + idempotency_key (str): Idempotency key to check + + Returns: + bool: True if already processed, False otherwise + """ + existing = self.search([("idempotency_key", "=", idempotency_key)], limit=1) + return bool(existing) + + @api.model + def log_webhook( + self, + event_type, + entity_id, + webhook_data=None, + signature_valid=False, + idempotency_key=None, + ): + """Log a webhook event + + Args: + event_type (str): Type of webhook event + entity_id (str): Bill.com entity ID + webhook_data (str): Full JSON payload + signature_valid (bool): Whether signature was valid + idempotency_key (str): Unique key for idempotency (uses eventId if available) + + Returns: + billcom.webhook.log: Created log record + """ + # Use provided idempotency_key or generate from event_type:entity_id + if not idempotency_key: + idempotency_key = f"{event_type}: {entity_id}" + + # Note: Duplicate check is done in the controller before calling this method + # No need to check again here to avoid double verification + + return self.create( + { + "idempotency_key": idempotency_key, + "event_type": event_type, + "entity_id": entity_id, + "webhook_data": webhook_data, + "signature_valid": signature_valid, + "state": "received", + } + ) + + def mark_processing(self): + """Mark webhook as processing""" + self.ensure_one() + self.write({"state": "processing"}) + + def mark_success(self): + """Mark webhook as successfully processed""" + self.ensure_one() + self.write( + { + "state": "success", + "processed_at": fields.Datetime.now(), + } + ) + + def mark_error(self, error_message): + """Mark webhook processing as failed + + Args: + error_message (str): Error message + """ + self.ensure_one() + self.write( + { + "state": "error", + "error_message": error_message, + } + ) + + @api.model + def cleanup_old_logs(self, days=30): + """Clean up old webhook logs + + Args: + days (int): Delete logs older than this many days + """ + cutoff_date = fields.Datetime.now() - fields.timedelta(days=days) + old_logs = self.search( + [("create_date", "<", cutoff_date), ("state", "=", "success")] + ) + count = len(old_logs) + old_logs.unlink() + _logger.info("Cleaned up %s old webhook logs", count) + return count diff --git a/billcom_integration/models/res_partner.py b/billcom_integration/models/res_partner.py new file mode 100644 index 00000000..612830ac --- /dev/null +++ b/billcom_integration/models/res_partner.py @@ -0,0 +1,677 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _name = "res.partner" + _inherit = ["res.partner", "billcom.abstract.model"] + + billcom_res_currency_id = fields.Many2one( + "res.currency", + string="Bill.com Currency", + default=lambda self: self.env.company.currency_id, + ) + + billcom_sync_state = fields.Selection( + [ + ("pending", "Pending Sync"), + ("synced", "Synced"), + ("error", "Sync Error"), + ], + default="pending", + string="Bill.com Sync State", + ) + + billcom_payment_purpose_id = fields.Many2one( + "billcom.payment.purpose", + string="Bill.com Payment Purpose", + help="Payment purpose for international vendors (non-US) in Bill.com", + domain="[('country_id', '=', country_id)]", + ) + + def _prepare_partner_data(self, partner_type="vendor"): # noqa: C901 + """Prepare partner data for Bill.com API v3""" + self.ensure_one() + if not self.is_sync_to_billcom: + return False + + # Get billing and shipping addresses + billing_address = self + shipping_address = self + + # If this is a contact with a parent company, use parent for some fields + # parent_company = ( + # self.parent_id if self.parent_id and self.parent_id.is_company else False + # ) + + # Check for specific address types in child contacts + if self.child_ids: + for child in self.child_ids: + if child.type == "invoice": + billing_address = child + elif child.type == "delivery": + shipping_address = child + + # Common data for both vendor and customer (only valid API v3 fields) + common_data = { + "name": self.name or "Unknown", # Required field + "shortName": self.ref + or (self.name[:40] if self.name else "Unknown")[:40], # Max 40 chars + } + + # Add optional fields only if they have values + if self.email: + common_data["email"] = self.email + if self.phone: + common_data["phone"] = self.phone + if self.ref: + common_data["accountNumber"] = self.ref + if self.billcom_res_currency_id and self.country_id.code != "US": + common_data["billCurrency"] = self.billcom_res_currency_id.name + + # Log the common data for debugging + _logger.info("Common data prepared: %s", common_data) + + # Address format for Bill.com API v3 + def format_address(addr): + # Bill.com API v3 required address fields: line1, city, zipOrPostalCode, country + address_data = { + "line1": addr.street or "N/A", # Required: line1 cannot be empty + "city": addr.city or "N/A", # Required: city cannot be empty + "zipOrPostalCode": addr.zip or "00000", # Required: cannot be empty + "country": ( + addr.country_id.code if addr.country_id else "US" + ), # Required: default to US + } + + # Optional fields - only add if they have values + if addr.street2: + address_data["line2"] = addr.street2 + if addr.state_id and addr.state_id.code: + address_data["stateOrProvince"] = addr.state_id.code + + # Log the address data for debugging + _logger.info("Formatted address data: %s", address_data) + + return address_data + + if partner_type == "vendor": + vendor_data = common_data.copy() + + # Add required fields for vendor + vendor_data["accountType"] = "BUSINESS" if self.is_company else "PERSON" + vendor_data["address"] = format_address(self) # Required field + + # Add paymentInformation if vendor has bank account + # This is required for enabling electronic payments to vendors + # For child partners, use parent's bank accounts (parent-child structure) + bank_partner = self.parent_id if self.parent_id else self + if bank_partner.bank_ids: + bank = bank_partner.bank_ids.filtered( + lambda r: r.billcom_vendor_id.id == self.id + ) # Use first bank account + if not bank: + bank = bank_partner.bank_ids[0] # Fallback to first bank account + + # Check if we have minimum required bank information + # Get routing number with priority + routing_number = bank.aba_routing or ( + bank.bank_id.routing_number if bank.bank_id else None + ) + + if bank.acc_number and routing_number: + payment_info = { + "payeeName": bank.acc_holder_name or self.name, + "payByType": bank.billcom_pay_by_type or "CHECK", + "payBySubType": bank.billcom_pay_by_subtype or "NONE", + "bankAccount": { + "nameOnAccount": bank.acc_holder_name or self.name, + "accountNumber": bank.acc_number, + "type": bank.billcom_account_type or "CHECKING", + "ownerType": bank.billcom_owner_type + or ("BUSINESS" if self.is_company else "PERSONAL"), + }, + } + + # Add international vendor fields if applicable + # Check if vendor is international (not US) + if self.country_id and self.country_id.code == "US": + payment_info["bankAccount"].update( + {"routingNumber": routing_number} + ) + else: + payment_info["bankCountry"] = self.country_id.code + if bank.currency_id: + payment_info["paymentCurrency"] = bank.currency_id.name + + payment_info.setdefault("bankInfo", {}).update( + { + "countryISO": self.country_id.code, + } + ) + + if bank.bank_id: + payment_info["bankInfo"].update( + { + "branchName": "", + } + ) + if bank.bank_id.name: + payment_info["bankInfo"].update( + { + "institutionName": bank.bank_id.name, + } + ) + if bank.bank_id.bic: + payment_info["bankInfo"].update( + { + "swiftBIC": bank.bank_id.bic, + } + ) + if bank.bank_id.street: + payment_info["bankInfo"].setdefault( + "address", {} + ).update({"line1": bank.bank_id.street}) + if bank.bank_id.city: + payment_info["bankInfo"].setdefault( + "address", {} + ).update({"city": bank.bank_id.city}) + if bank.bank_id.state: + payment_info["bankInfo"].setdefault("address", {})[ + "stateOrProvince" + ] = bank.bank_id.state.code + if bank.bank_id.zip: + payment_info["bankInfo"].setdefault("address", {})[ + "zipOrPostalCode" + ] = bank.bank_id.zip + if bank.bank_id.country: + payment_info["bankInfo"].setdefault("address", {})[ + "country" + ] = bank.bank_id.country.name + + vendor_data["paymentInformation"] = payment_info + if bank_partner != self: + _logger.info( + "Added paymentInformation for vendor %s from parent %s: %s", + self.name, + bank_partner.name, + payment_info, + ) + else: + _logger.info( + "Added paymentInformation for vendor %s: %s", + self.name, + payment_info, + ) + else: + _logger.info( + f"Vendor {self.name} (using {bank_partner.name} for banks) " + f"has bank account but missing account number or routing number" + ) + else: + if bank_partner != self: + _logger.info( + "Vendor %s: parent %s has no bank accounts", + self.name, + bank_partner.name, + ) + else: + _logger.info("Vendor %s has no bank accounts", self.name) + + # Log final vendor data + _logger.info("Final vendor data for Bill.com: %s", vendor_data) + + # Note: paymentTermId requires a Bill.com payment term ID (format: pte01XXXXXX) + # not the Odoo payment term name. Omitting this field for now. + # TODO: Implement payment term mapping between Odoo and Bill.com if needed + + return vendor_data + else: # customer + customer_data = common_data.copy() + + # Add required fields for customer + customer_data["accountType"] = "BUSINESS" if self.is_company else "PERSON" + customer_data["billingAddress"] = format_address(billing_address) + customer_data["shippingAddress"] = format_address(shipping_address) + + # Add optional language field if available + if self.lang: + customer_data["language"] = self.lang + + # Log final customer data + _logger.info("Final customer data for Bill.com: %s", customer_data) + + # Note: paymentTermId requires a Bill.com payment term ID (format: pte01XXXXXX) + # not the Odoo payment term name. Omitting this field for now. + # TODO: Implement payment term mapping between Odoo and Bill.com if needed + + return customer_data + + def sync_to_billcom(self, partner_type="vendor"): + """Sync partner to Bill.com - delegates to service""" + self.ensure_one() + if not self.is_sync_to_billcom: + return False + + try: + service = self.env["billcom.service"] + return service.sync_partner(self, partner_type) + except Exception as e: + _logger.error( + f"Error syncing partner {self.name} " f"to Bill.com: {str(e)}" + ) + raise UserError(_(f"Error syncing to Bill.com: {e}")) from e + + def sync_to_billcom_vendor(self): + """Sync as vendor - delegates to service""" + return self.sync_to_billcom(partner_type="vendor") + + def sync_to_billcom_customer(self): + """Sync as customer - delegates to service""" + return self.sync_to_billcom(partner_type="customer") + + @api.model + def sync_from_billcom(self, partner_type="vendor"): + """Sync partners from Bill.com to Odoo - delegates to service""" + service = self.env["billcom.service"] + return service.sync_partners_from_billcom(partner_type=partner_type) + + @api.model + def _sync_partners_cron(self): + """Cron job to sync partners from Bill.com - delegates to service""" + service = self.env["billcom.service"] + return service.sync_partners_cron() + + @api.model + def sync_from_billcom_by_id(self, billcom_id, partner_type="vendor"): + """Sync a specific partner from Bill.com by ID - delegates to service""" + service = self.env["billcom.service"] + return service.sync_partner_from_billcom_by_id( + billcom_id, partner_type=partner_type + ) + + def action_fetch_payment_purposes(self): + """Fetch payment purposes from Bill.com for international vendor + + This button appears only for international vendors (non-US) that have + a currency configured. + """ + self.ensure_one() + + # Validation + if not self.country_id: + raise UserError(_("Please set the country for this partner first")) + + if self.country_id.code == "US": + raise UserError( + _( + "Payment purposes are only required for international (non-US) vendors" + ) + ) + + if not self.billcom_res_currency_id: + raise UserError( + _("Please set the Bill.com Currency for this partner first") + ) + + # Get account type from bank account if exists, otherwise use NONE + account_type = "NONE" + if self.bank_ids: + bank = self.bank_ids[0] + account_type = bank.billcom_account_type + + try: + # Fetch payment purposes from API + payment_purposes = self.env[ + "billcom.payment.purpose" + ].fetch_payment_purposes_for_config( + country_id=self.country_id.id, + currency_id=self.billcom_res_currency_id.id, + account_type=account_type, + ) + + if not payment_purposes: + raise UserError( + _( + f"No payment purposes found for country {self.country_id.name}, " + f"currency {self.billcom_res_currency_id.name}, " + f"account type {account_type}" + ) + ) + + # Show notification with count + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Payment Purposes Fetched"), + "message": _( + "%d payment purpose(s) loaded from Bill.com. You can now select one." + ) + % len(payment_purposes), + "type": "success", + "sticky": False, + "next": { + "type": "ir.actions.act_window", + "res_model": "res.partner", + "res_id": self.id, + "view_mode": "form", + "target": "current", + }, + }, + } + except Exception as e: + _logger.error(f"Error fetching payment purposes: {e}") + raise + + # Backwards compatibility + _sync_vendors_cron = _sync_partners_cron + + +class ResPartnerBank(models.Model): + _inherit = "res.partner.bank" + + # Bill.com Funding Account (organization's bank account for payments) + billcom_funding_account_id = fields.Many2one( + "billcom.funding.account", + string="Bill.com Funding Account", + help="Link to the Bill.com funding account (organization bank account). " + "This is used when creating payments to specify which account to pay from.", + copy=False, + domain=[("status", "=", "VERIFIED")], + ondelete="restrict", + ) + billcom_vendor_id = fields.Many2one( + comodel_name="res.partner", + string="Bill.com Vendor", + help="Link to the Bill.com vendor (partner) associated with this bank account", + copy=False, + ) + # Bill.com Vendor Bank Account Fields (vendor/customer's bank account) + billcom_vendor_bank_id = fields.Char( + string="Bill.com Vendor Bank ID", + help="Bill.com vendor bank account ID", + copy=False, + readonly=True, + ) + billcom_pay_by_type = fields.Selection( + [ + ("ACH", "ACH"), + ("WALLET", "Wallet"), + ("CHECK", "Check"), + ("VIRTUAL_CARD", "VIRTUAL CARD"), + ("UNDEFINED", "UNDEFINED"), + ("RPPS", "RPPS"), + ("INTERNATIONAL_E_PAYMENT", "INTERNATIONAL EPAYMENT"), + ("OFFLINE", "OFFLINE"), + ], + string="Bill.com Pay By Type", + help="Default payment method for this vendor in Bill.com", + copy=False, + ) + billcom_pay_by_subtype = fields.Selection( + [ + ("NONE", "None"), + ("ACH", "ACH"), + ("WIRE", "International Wire"), + ("IACH", "IACH"), + ("LOCAL", "LOCAL"), + ("MULTIPLE", "MULTIPLE"), + ("UNDEFINED", "UNDEFINED"), + ], + string="Bill.com Pay By Subtype", + help="Payment subtype in Bill.com", + copy=False, + ) + billcom_account_type = fields.Selection( + [ + ("CHECKING", "Checking"), + ("SAVINGS", "Savings"), + ], + string="Bill.com Account Type", + help="Bank account type in Bill.com", + copy=False, + ) + billcom_owner_type = fields.Selection( + [ + ("BUSINESS", "Business"), + ("PERSONAL", "Personal"), + ], + string="Bill.com Owner Type", + help="Bank account owner type in Bill.com", + copy=False, + ) + billcom_account_status = fields.Char( + string="Bill.com Account Status", + help="Status of bank account in Bill.com (e.g., NET_LINKED_ACCOUNT, VERIFIED)", + readonly=True, + copy=False, + ) + billcom_last_sync_date = fields.Datetime( + string="Last Synced", + help="Last synchronization date with Bill.com", + readonly=True, + copy=False, + ) + + def sync_bank_account_to_billcom(self): + """Sync bank account to Bill.com when created/updated + + IMPORTANT: This method should only be used when vendor does NOT already + have paymentInformation in Bill.com. If vendor already has payment info, + you must re-sync the entire vendor to update bank account. + + Bill.com error BDC_1233 will occur if: + - Vendor has pending invite + - Vendor has pending bank account + - Vendor already has epayment setup + - Vendor is international + """ + self.ensure_one() + + # Skip if context flag is set (to prevent circular sync) + if self.env.context.get("skip_billcom_sync"): + return + + # Only sync if partner is synced to Bill.com and has a Bill.com ID + if ( + not self.billcom_vendor_id.billcom_id + or not self.billcom_vendor_id.is_sync_to_billcom + ): + _logger.info( + f"Skipping bank account sync: partner {self.billcom_vendor_id.name} " + f"not synced to Bill.com" + ) + return + + # Only sync if we have required bank information + # Get routing number with priority + routing_number = self.aba_routing or ( + self.bank_id.routing_number if self.bank_id else None + ) + if not self.acc_number or not routing_number: + _logger.warning( + f"Cannot sync bank account: missing account number or routing number " + f"for partner {self.partner_id.name}" + ) + return + + # Check if this bank account was already synced + if self.billcom_vendor_bank_id: + raise UserError( + _( + "This bank account is already synced to Bill.com (ID: %s).\n\n" + "To update bank account information:\n" + "1. Update the fields in Odoo\n" + "2. Re-sync the entire vendor (recommended)\n" + " OR\n" + "3. Contact Bill.com support to update payment information" + ) + % self.billcom_vendor_bank_id + ) + + try: + service = self.env["billcom.service"] + vendor_id = self.billcom_vendor_id.billcom_id + + # Prepare bank account data for Bill.com API v3 + # Endpoint: POST /v3/vendors/:vendorId/bank-account + + routing_number = self.aba_routing or ( + self.bank_id.routing_number if self.bank_id else None + ) + bank_account_data = { + "bankName": self.bank_id.name if self.bank_id else "Unknown", + "accountNumber": self.acc_number, + "type": self.billcom_account_type or "CHECKING", + "ownerType": self.billcom_owner_type + or ( + "BUSINESS" + if self.partner_id.company_type == "company" + else "PERSONAL" + ), + "paymentCurrency": self.currency_id.name if self.currency_id else "USD", + } + + if self.bank_id.country.code == "US": + bank_account_data["routingNumber"] = routing_number + + # Add name on account if available + if self.acc_holder_name: + bank_account_data["nameOnAccount"] = self.acc_holder_name + elif self.partner_id.name: + # Fallback to partner name if no holder name specified + bank_account_data["nameOnAccount"] = self.partner_id.name + + _logger.info( + f"Syncing bank account to Bill.com for vendor {self.partner_id.name}" + ) + _logger.debug(f"Bank account payload: {bank_account_data}") + + # Create or update bank account in Bill.com + if self.billcom_vendor_bank_id: + # Update existing bank account + # Note: Bill.com may use PATCH or PUT for updates + endpoint = ( + f"vendors/{vendor_id}/bank-account/{self.billcom_vendor_bank_id}" + ) + method = "PATCH" + _logger.info( + f"Updating existing Bill.com bank account {self.billcom_vendor_bank_id}" + ) + else: + # Create new bank account + endpoint = f"vendors/{vendor_id}/bank-account" + method = "POST" + _logger.info("Creating new Bill.com bank account") + + response = service._make_request( + endpoint, method=method, data=bank_account_data + ) + + if response and response.get("id"): + # Update Odoo record with Bill.com ID and status + self.with_context(skip_billcom_sync=True).write( + { + "billcom_vendor_bank_id": response["id"], + "billcom_account_status": response.get("status", ""), + "billcom_last_sync_date": fields.Datetime.now(), + } + ) + + _logger.info( + f"Successfully synced bank account to Bill.com. " + f"Bill.com ID: {response['id']}" + ) + + # Post message to partner's chatter + self.partner_id.message_post( + body=_( + "

Bank Account Synced to Bill.com

" + "
    " + "
  • Bank: %(bank)s
  • " + "
  • Account: ****%(account)s
  • " + "
  • Bill.com ID: %(id)s
  • " + "
  • Status: %(status)s
  • " + "
" + ) + % { + "bank": self.bank_id.name, + "account": ( + self.acc_number[-4:] + if len(self.acc_number) >= 4 + else "****" + ), + "id": response["id"], + "status": response.get("status", "Unknown"), + }, + message_type="notification", + subtype_xmlid="mail.mt_note", + ) + else: + _logger.error( + "No valid response from Bill.com when syncing bank account" + ) + + except Exception as e: + error_msg = str(e) + _logger.error( + f"Error syncing bank account to Bill.com " + f"for {self.partner_id.name}: {error_msg}" + ) + + # Handle specific Bill.com error BDC_1233 + if "BDC_1233" in error_msg or "already setup for epayment" in error_msg: + raise UserError( + _( + "Cannot sync bank account using separate endpoint.\n\n" + "Bill.com Error: Vendor already has payment information configured.\n\n" + "To update bank account:\n" + "1. Update bank account fields in Odoo\n" + "2. Re-sync the entire vendor to Bill.com\n" + " (This will update paymentInformation in vendor data)\n\n" + "Note: The separate bank account endpoint can only be used for vendors " + "without existing payment configuration." + ) + ) from e + + # Generic error + raise UserError( + _(f"Error syncing bank account to Bill.com: {error_msg}") + ) from e + + @api.model_create_multi + def create(self, vals_list): + """Override create to sync bank account to Bill.com after creation""" + records = super().create(vals_list) + + # DO NOT auto-sync bank accounts on create + # They should be synced via paymentInformation when vendor is synced + # or manually via "Sync to Bill.com" button if vendor already exists + _logger.info( + "Bank account created. Will sync via vendor paymentInformation " + "or use manual 'Sync to Bill.com' button if vendor already exists in Bill.com" + ) + + return records + + def write(self, vals): + """Override write to sync bank account changes to Bill.com""" + result = super().write(vals) + + # DO NOT auto-sync bank accounts on write + # Bill.com error BDC_1233 occurs if vendor already has paymentInformation + # User must re-sync entire vendor to update bank account via paymentInformation + _logger.info( + "Bank account updated. To sync changes: " + "1) Re-sync vendor (recommended) or 2) Use manual 'Sync to Bill.com' button" + ) + + return result diff --git a/billcom_integration/readme/CONTRIBUTORS.rst b/billcom_integration/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..ffeca616 --- /dev/null +++ b/billcom_integration/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Simple Solutions +* Binhex + - Antonio Ruban \ <\> diff --git a/billcom_integration/readme/DESCRIPTION.rst b/billcom_integration/readme/DESCRIPTION.rst new file mode 100644 index 00000000..79be17be --- /dev/null +++ b/billcom_integration/readme/DESCRIPTION.rst @@ -0,0 +1,12 @@ +This module provides integration with Bill.com API v3: + * Synchronize vendors between Odoo and Bill.com + * Synchronize bills between Odoo and Bill.com + * Synchronize vendor payments between Odoo and Bill.com + * Register payments directly from vendor bills with Bill.com integration + * Real-time payment status tracking with webhook support + * Advanced retry logic for API communication + * Configurable synchronization settings + * Manage Bill.com API credentials and configuration + * Automatic two-way synchronization of data + * Manual and automatic synchronization options + * Configurable scheduled actions from the interface \ No newline at end of file diff --git a/billcom_integration/readme/USAGE.rst b/billcom_integration/readme/USAGE.rst new file mode 100644 index 00000000..e69de29b diff --git a/billcom_integration/security/billcom_security.xml b/billcom_integration/security/billcom_security.xml new file mode 100644 index 00000000..b3559159 --- /dev/null +++ b/billcom_integration/security/billcom_security.xml @@ -0,0 +1,129 @@ + + + + + Bill.com + 16 + + + + Bill.com User + + Can create and sync bills to Bill.com. Cannot approve bills or manage payments. + + + + + Bill.com Approver + + Can approve/post bills. Cannot create or sync payments. + + + + + Bill.com Payment Manager + + Full access: Can approve bills, create payments, and sync payments to Bill.com. + + + + + + Bill.com Configuration: Account User + + [(1, '=', 1)] + + + + + + + + + Bill.com Configuration: Account Manager + + [(1, '=', 1)] + + + + + + + + + Bill.com Service: Account User + + [(1, '=', 1)] + + + + + + + + + Bill.com Service: Account Manager + + [(1, '=', 1)] + + + + + + + + + + + + Payments: Bill.com User (Read Only) + + [(1, '=', 1)] + + + + + + + + + Payments: Bill.com Approver (Read Only) + + [(1, '=', 1)] + + + + + + + + + Payments: Bill.com Payment Manager (Full Access) + + [(1, '=', 1)] + + + + + + + + diff --git a/billcom_integration/security/ir.model.access.csv b/billcom_integration/security/ir.model.access.csv new file mode 100644 index 00000000..95eab28e --- /dev/null +++ b/billcom_integration/security/ir.model.access.csv @@ -0,0 +1,38 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_billcom_config_user,billcom.config user,model_billcom_config,account.group_account_invoice,1,1,1,0 +access_billcom_config_manager,billcom.config manager,model_billcom_config,account.group_account_manager,1,1,1,1 +access_billcom_service_user,billcom.service user,model_billcom_service,account.group_account_invoice,1,1,1,0 +access_billcom_service_manager,billcom.service manager,model_billcom_service,account.group_account_manager,1,1,1,1 +access_res_partner_user,res.partner user,model_res_partner,base.group_user,1,1,0,0 +access_res_partner_manager,res.partner manager,model_res_partner,base.group_user,1,1,1,1 +access_account_payment_user,account.payment user,account.model_account_payment,account.group_account_invoice,1,1,1,0 +access_account_payment_manager,account.payment manager,account.model_account_payment,account.group_account_manager,1,1,1,1 +access_billcom_service_abstract_manager,billcom_service_abstract_manager,model_billcom_service_abstract,,1,1,1,1 +access_billcom_logger_user,billcom.logger user,model_billcom_logger,account.group_account_invoice,1,0,0,0 +access_billcom_logger_manager,billcom.logger manager,model_billcom_logger,account.group_account_manager,1,1,1,1 +access_billcom_sync_queue_user,billcom.sync.queue user,model_billcom_sync_queue,account.group_account_invoice,1,0,0,0 +access_billcom_sync_queue_manager,billcom.sync.queue manager,model_billcom_sync_queue,account.group_account_manager,1,1,1,1 +access_billcom_mfa_wizard_user,billcom.mfa.wizard user,model_billcom_mfa_wizard,account.group_account_invoice,1,1,1,1 +access_billcom_mfa_wizard_manager,billcom.mfa.wizard manager,model_billcom_mfa_wizard,account.group_account_manager,1,1,1,1 +access_billcom_sync_wizard_user,billcom.sync.wizard user,model_billcom_sync_wizard,account.group_account_invoice,1,1,1,0 +access_billcom_sync_wizard_manager,billcom.sync.wizard manager,model_billcom_sync_wizard,account.group_account_manager,1,1,1,1 +access_billcom_funding_account_user,billcom.funding.account user,model_billcom_funding_account,account.group_account_invoice,1,0,0,0 +access_billcom_funding_account_manager,billcom.funding.account manager,model_billcom_funding_account,account.group_account_manager,1,1,1,1 +access_billcom_webhook_log_user,billcom.webhook.log user,model_billcom_webhook_log,account.group_account_invoice,1,0,0,0 +access_billcom_webhook_log_manager,billcom.webhook.log manager,model_billcom_webhook_log,account.group_account_manager,1,1,1,1 +access_billcom_document_user,billcom.document user,model_billcom_document,account.group_account_invoice,1,1,1,0 +access_billcom_document_manager,billcom.document manager,model_billcom_document,account.group_account_manager,1,1,1,1 +access_billcom_item_user,billcom.item user,model_billcom_item,account.group_account_invoice,1,1,1,0 +access_billcom_item_manager,billcom.item manager,model_billcom_item,account.group_account_manager,1,1,1,1 +access_billcom_partner_matching_wizard_user,billcom.partner.matching.wizard user,model_billcom_partner_matching_wizard,account.group_account_invoice,1,1,1,1 +access_billcom_partner_matching_wizard_manager,billcom.partner.matching.wizard manager,model_billcom_partner_matching_wizard,account.group_account_manager,1,1,1,1 +access_billcom_partner_matching_line_user,billcom.partner.matching.line user,model_billcom_partner_matching_line,account.group_account_invoice,1,1,1,1 +access_billcom_partner_matching_line_manager,billcom.partner.matching.line manager,model_billcom_partner_matching_line,account.group_account_manager,1,1,1,1 +access_billcom_payment_purpose_manager,billcom_payment_purpose_manager,model_billcom_payment_purpose,,1,1,1,1 +access_billcom_bill_status_user,billcom.bill.status user,model_billcom_bill_status,base.group_user,1,0,0,0 +access_billcom_bill_approval_status_user,billcom.bill.approval.status user,model_billcom_bill_approval_status,base.group_user,1,0,0,0 +access_billcom_invoice_status_user,billcom.invoice.status user,model_billcom_invoice_status,base.group_user,1,0,0,0 +access_billcom_payment_status_user,billcom.payment.status user,model_billcom_payment_status,base.group_user,1,0,0,0 +access_account_payment_billcom_user,account.payment billcom user,account.model_account_payment,group_billcom_user,1,0,0,0 +access_account_payment_billcom_approver,account.payment billcom approver,account.model_account_payment,group_billcom_approver,1,0,0,0 +access_account_payment_billcom_payment_manager,account.payment billcom manager,account.model_account_payment,group_billcom_payment_manager,1,1,1,0 diff --git a/billcom_integration/static/description/icon.png b/billcom_integration/static/description/icon.png new file mode 100644 index 00000000..eb412858 Binary files /dev/null and b/billcom_integration/static/description/icon.png differ diff --git a/billcom_integration/static/description/index.html b/billcom_integration/static/description/index.html new file mode 100644 index 00000000..6acae018 --- /dev/null +++ b/billcom_integration/static/description/index.html @@ -0,0 +1,448 @@ + + + + + +Bill.com Integration + + + +
+

Bill.com Integration

+ + +

Beta License: AGPL-3 OCA/l10n-usa Translate me on Weblate Try me on Runboat

+
+
This module provides integration with Bill.com API v3:
+
    +
  • Synchronize vendors between Odoo and Bill.com
  • +
  • Synchronize bills between Odoo and Bill.com
  • +
  • Synchronize vendor payments between Odoo and Bill.com
  • +
  • Register payments directly from vendor bills with Bill.com integration
  • +
  • Real-time payment status tracking with webhook support
  • +
  • Advanced retry logic for API communication
  • +
  • Configurable synchronization settings
  • +
  • Manage Bill.com API credentials and configuration
  • +
  • Automatic two-way synchronization of data
  • +
  • Manual and automatic synchronization options
  • +
  • Configurable scheduled actions from the interface
  • +
+
+
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Binhex
  • +
  • Simple Solutions
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/l10n-usa project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/billcom_integration/static/src/scss/billcom_dashboard.scss b/billcom_integration/static/src/scss/billcom_dashboard.scss new file mode 100644 index 00000000..24b11f98 --- /dev/null +++ b/billcom_integration/static/src/scss/billcom_dashboard.scss @@ -0,0 +1,7 @@ +.o_kanban_dashboard.o_billcom_kanban { + width: 50%; + + div[role="article"] { + width: 100%; + } +} diff --git a/billcom_integration/tests/__init__.py b/billcom_integration/tests/__init__.py new file mode 100644 index 00000000..71e72623 --- /dev/null +++ b/billcom_integration/tests/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_billcom_config +from . import test_vendor_bank_sync +from . import test_bulk_payments +from . import test_billcom_service +from . import test_billcom_service_abstract +from . import test_billcom_service_advanced +from . import test_billcom_controller_extended +from . import test_account_move_billcom +from . import test_billcom_document +from . import test_billcom_sync_queue +from . import test_billcom_partner_matching_wizard +from . import test_billcom_sync_wizard +from . import test_billcom_logger + +# from . import test_billcom_item +from . import test_billcom_funding_account diff --git a/billcom_integration/tests/common.py b/billcom_integration/tests/common.py new file mode 100644 index 00000000..5794928e --- /dev/null +++ b/billcom_integration/tests/common.py @@ -0,0 +1,192 @@ +from unittest.mock import MagicMock, patch + +from odoo import fields + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class BillcomTestCommon(AccountTestInvoicingCommon): + """Base test class for Bill.com integration tests""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # ========== GLOBAL MOCKS - Prevent ALL real API connections ========== + # Mock authentication to prevent real API calls + cls.patcher_get_token = patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._get_token", # noqa: B950 + return_value="mock_test_token_123", + create=True, + ) + cls.patcher_get_token.start() + + # ========== Test Data Setup ========== + + # Create Bill.com configuration + cls.billcom_config = cls.env["billcom.config"].create( + { + "name": "Test Bill.com Config", + "environment": "sandbox", + "username": "test_api_key", + "password": "test_api_secret", + "user_id": cls.env.user.id, + "organization_id": "test_org_id", + "dev_key": "test_dev_key", + "enable_webhooks": True, + "webhook_secret": "test_webhook_secret", + "sync_vendors": True, + "sync_customers": True, + "sync_bills": True, + "sync_payments": True, + "enable_mfa": False, + "active": True, + "company_id": cls.env.company.id, + } + ) + + # Create test vendor with Bill.com sync enabled + cls.vendor_billcom = cls.env["res.partner"].create( + { + "name": "Test Vendor Bill.com", + "supplier_rank": 1, + "customer_rank": 0, + "is_sync_to_billcom": True, + "billcom_id": "test_vendor_123", + "billcom": "test_vendor_123", + "street": "123 Test Street", + "city": "Test City", + "state_id": cls.env.ref("base.state_us_5").id, # California + "zip": "12345", + "country_id": cls.env.ref("base.us").id, + "email": "vendor@test.com", + "phone": "+1-555-123-4567", + } + ) + + # Create test customer with Bill.com sync enabled + cls.customer_billcom = cls.env["res.partner"].create( + { + "name": "Test Customer Bill.com", + "supplier_rank": 0, + "customer_rank": 1, + "is_sync_to_billcom": True, + "billcom_id": "test_customer_456", + "billcom": "test_customer_456", + "street": "456 Customer Ave", + "city": "Customer City", + "state_id": cls.env.ref("base.state_us_5").id, # California + "zip": "67890", + "country_id": cls.env.ref("base.us").id, + "email": "customer@test.com", + "phone": "+1-555-987-6543", + } + ) + + # Create test bank journal for payments + cls.bank_journal = cls.env["account.journal"].create( + { + "name": "Test Bank Journal", + "type": "bank", + "code": "TBNK", + "company_id": cls.env.company.id, + } + ) + + # Create vendor bill for testing + cls.vendor_bill = cls.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": cls.vendor_billcom.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": cls.product_a.id, + "quantity": 1, + "price_unit": 100.0, + "account_id": cls.company_data[ + "default_account_expense" + ].id, + }, + ) + ], + "billcom_id": "test_bill_789", + } + ) + + def _mock_billcom_api_success(self, return_data=None): + """Mock successful Bill.com API response""" + if return_data is None: + return_data = {"id": "test_id_123", "status": "success"} + + mock_response = MagicMock() + mock_response.json.return_value = return_data + mock_response.status_code = 200 + mock_response.raise_for_status.return_value = None + return mock_response + + def _mock_billcom_api_error(self, status_code=400, error_message="Test error"): + """Mock failed Bill.com API response""" + mock_response = MagicMock() + mock_response.json.return_value = { + "status": "error", + "errorMessage": error_message, + } + mock_response.status_code = status_code + mock_response.raise_for_status.side_effect = Exception(f"HTTP {status_code}") + return mock_response + + def _get_mock_vendor_data(self, vendor_id="test_vendor_123"): + """Get mock vendor data from Bill.com API""" + return { + "id": vendor_id, + "name": "Test Vendor Bill.com", + "email": "vendor@test.com", + "phone": "+1-555-123-4567", + "isActive": True, + "shortName": "TestVendor", + "address": { + "line1": "123 Test Street", + "line2": "", + "city": "Test City", + "stateOrProvince": "CA", + "zipOrPostalCode": "12345", + "country": "US", + }, + "accountType": "BUSINESS", + "isVendor": True, + } + + def _get_mock_payment_data(self, payment_id="test_payment_123"): + """Get mock payment data from Bill.com API""" + return { + "id": payment_id, + "vendorId": "test_vendor_123", + "amount": 100.0, + "processDate": fields.Date.today().isoformat(), + "singleStatus": "SCHEDULED", + "confirmationNumber": "CONF123", + "transactionNumber": "TXN456", + "description": "Test Payment", + } + + def _get_mock_webhook_data( + self, event_type="payment.statusChanged", entity_id="test_payment_123" + ): + """Get mock webhook data""" + return { + "eventType": event_type, + "entityId": entity_id, + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": "test_org_id", + } + + @classmethod + def tearDownClass(cls): + """Cleanup test resources""" + # Stop all patchers + cls.patcher_get_token.stop() + super().tearDownClass() diff --git a/billcom_integration/tests/test_account_move_billcom.py b/billcom_integration/tests/test_account_move_billcom.py new file mode 100644 index 00000000..f9ce8f12 --- /dev/null +++ b/billcom_integration/tests/test_account_move_billcom.py @@ -0,0 +1,454 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from unittest.mock import patch + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestAccountMoveBillcom(BillcomTestCommon): + """Tests for account.move Bill.com integration""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create additional vendor bill for testing + cls.bill2 = cls.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": cls.vendor_billcom.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": cls.product_a.id, + "quantity": 2, + "price_unit": 250.0, + "account_id": cls.company_data[ + "default_account_expense" + ].id, + }, + ) + ], + } + ) + + # Create customer invoice for testing + cls.customer_invoice = cls.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": cls.customer_billcom.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": cls.product_a.id, + "quantity": 1, + "price_unit": 500.0, + "account_id": cls.company_data[ + "default_account_revenue" + ].id, + }, + ) + ], + } + ) + + # ===== Bill Data Preparation Tests ===== + + def test_prepare_bill_data_basic_fields(self): + """Should prepare bill data with all required fields""" + bill_data = self.vendor_bill._prepare_bill_data() + + self.assertIn("vendorId", bill_data) + self.assertEqual(bill_data["vendorId"], self.vendor_billcom.billcom_id) + # invoiceNumber is nested in 'invoice' dict + self.assertIn("invoice", bill_data) + self.assertIn("invoiceNumber", bill_data["invoice"]) + # invoiceDate and dueDate might be at root or nested + self.assertTrue( + "invoiceDate" in bill_data or "invoiceDate" in bill_data.get("invoice", {}) + ) + self.assertTrue( + "dueDate" in bill_data or "dueDate" in bill_data.get("invoice", {}) + ) + + def test_prepare_bill_data_with_lines(self): + """Should include line items in bill data""" + bill_data = self.vendor_bill._prepare_bill_data() + + # For bills, the field is called billLineItems + self.assertIn("billLineItems", bill_data) + self.assertTrue(len(bill_data["billLineItems"]) > 0) + # Verify line item structure + line = bill_data["billLineItems"][0] + self.assertIn("amount", line) + + def test_prepare_bill_data_for_bulk(self): + """Should prepare bill data for bulk operations""" + bill_data = self.vendor_bill._prepare_bill_data(for_bulk=True) + + self.assertIsNotNone(bill_data) + self.assertIn("vendorId", bill_data) + + def test_prepare_bill_data_no_vendor_billcom_id(self): + """Should prepare bill data even if vendor has no Bill.com ID""" + # Create vendor without billcom_id + vendor_no_id = self.env["res.partner"].create( + { + "name": "Vendor No ID", + "supplier_rank": 1, + } + ) + + bill_no_id = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": vendor_no_id.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": self.product_a.id, + "quantity": 1, + "price_unit": 100.0, + "account_id": self.company_data[ + "default_account_expense" + ].id, + }, + ) + ], + } + ) + + bill_data = bill_no_id._prepare_bill_data() + + # Should still prepare data but vendorId will be False + self.assertIsNotNone(bill_data) + self.assertIn("vendorId", bill_data) + self.assertFalse(bill_data["vendorId"]) # vendorId is False when no billcom_id + + # ===== Invoice Data Preparation Tests ===== + + # def test_prepare_invoice_data_basic_fields(self): + # """Should prepare invoice data with required fields""" + # invoice_data = self.customer_invoice._prepare_invoice_data() + + # # customerId is nested in 'customer' dict as 'id' + # self.assertIn("customer", invoice_data) + # self.assertIn("id", invoice_data["customer"]) + # self.assertEqual( + # invoice_data["customer"]["id"], self.customer_billcom.billcom_id + # ) + # # invoiceNumber should be at root level for invoices + # self.assertIn("invoiceNumber", invoice_data) + # # invoiceDate might be at root or nested + # self.assertTrue( + # "invoiceDate" in invoice_data + # or "invoiceDate" in invoice_data.get("invoice", {}) + # ) + + def test_prepare_invoice_data_with_lines(self): + """Should include line items in invoice data""" + invoice_data = self.customer_invoice._prepare_invoice_data() + + # For invoices, the field is called invoiceLineItems (not lineItems) + self.assertIn("invoiceLineItems", invoice_data) + self.assertTrue(len(invoice_data["invoiceLineItems"]) > 0) + + # ===== Single Bill Sync Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_sync_to_billcom_create_success(self, mock_request): + """Should create new bill in Bill.com via button""" + mock_request.return_value = { + "id": "bill_new_123", + "invoiceNumber": self.bill2.name, + "status": "OPEN", + } + + # Remove billcom_id to simulate new bill + self.bill2.billcom_id = False + self.bill2.button_sync_to_billcom() + + self.assertEqual(self.bill2.billcom_id, "bill_new_123") + self.assertEqual(self.bill2.billcom_sync_status, "synced") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_sync_to_billcom_update_success(self, mock_request): + """Should update existing bill in Bill.com""" + mock_request.return_value = { + "id": self.vendor_bill.billcom_id, + "invoiceNumber": self.vendor_bill.name, + "status": "OPEN", + } + + self.vendor_bill.button_sync_to_billcom() + + self.assertEqual(self.vendor_bill.billcom_sync_status, "synced") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_sync_to_billcom_api_error(self, mock_request): + """Should handle API errors during sync""" + mock_request.side_effect = UserError("API Error: Invalid data") + + with self.assertRaises(UserError): + self.bill2.button_sync_to_billcom() + + # ===== Bulk Bill Sync Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_bulk_to_billcom_success(self, mock_request): + """Should sync multiple bills in bulk""" + + # Mock needs to handle different endpoints + def mock_side_effect(endpoint, method="GET", *args, **kwargs): + # For finding existing documents (GET request) + if method == "GET" and "bills" in endpoint: + return [] # No existing documents + # For creating new bills (POST request) + elif method == "POST" and "bills" in endpoint: + # Return single bill creation response + bill_number = ( + kwargs.get("data", {}).get("invoice", {}).get("invoiceNumber", "") + ) + if self.vendor_bill.name in bill_number: + return {"id": "bill_bulk_001", "invoiceNumber": bill_number} + elif self.bill2.name in bill_number: + return {"id": "bill_bulk_002", "invoiceNumber": bill_number} + return {"id": "bill_new", "invoiceNumber": bill_number} + return {} + + mock_request.side_effect = mock_side_effect + + # Remove billcom_ids + self.vendor_bill.billcom_id = False + self.bill2.billcom_id = False + + bills = self.vendor_bill | self.bill2 + bills._sync_bulk_to_billcom() + + # Verify bills were updated + self.assertTrue( + self.vendor_bill.billcom_id in ["bill_bulk_001", "bill_bulk_002"] + ) + self.assertTrue(self.bill2.billcom_id in ["bill_bulk_001", "bill_bulk_002"]) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_existing_bills_bulk_success(self, mock_request): + """Should update existing bills in bulk""" + mock_request.return_value = [ + { + "id": self.vendor_bill.billcom_id, + "invoiceNumber": self.vendor_bill.name, + "singleStatus": "OPEN", + }, + ] + + bills = self.env["account.move"].browse([self.vendor_bill.id]) + bills._sync_existing_bills_bulk(bills) + + self.assertEqual(self.vendor_bill.billcom_sync_status, "synced") + + # ===== Document Finding Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_find_existing_billcom_document_found(self, mock_request): + """Should find existing document in Bill.com""" + # Return a list with the document (as per line 192 of account_move.py) + mock_request.return_value = [ + { + "id": "doc_found_123", + "invoiceNumber": "INV001", + } + ] + + result = self.vendor_bill._find_existing_billcom_document("bills", "INV001") + + self.assertEqual(result, "doc_found_123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_find_existing_billcom_document_not_found(self, mock_request): + """Should return None if document not found""" + # Return empty list + mock_request.return_value = [] + + result = self.vendor_bill._find_existing_billcom_document("bills", "INV999") + + self.assertIsNone(result) + + # ===== State Management Tests ===== + + def test_bill_state_after_validation(self): + """Should not auto-sync on validation if not configured""" + self.bill2.billcom_id = False + + self.bill2.action_post() + + # Should remain unchanged unless auto-sync enabled + self.assertEqual(self.bill2.state, "posted") + + # ===== Sync from Bill.com Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_from_billcom_success(self, mock_request): + """Should sync bill data from Bill.com""" + + # Mock both the bills endpoint check and the actual data fetch + def mock_side_effect(endpoint, *args, **kwargs): + if "bills/" in endpoint: + return { + "id": "bill_remote_123", + "invoiceNumber": "BILLCOM-001", + "vendorId": self.vendor_billcom.billcom_id, + "invoiceDate": "2025-01-15", + "dueDate": "2025-02-15", + "amount": 100.0, + "status": "OPEN", + "lineItems": [ + { + "amount": 100.0, + "description": "Test item", + "quantity": 1, + } + ], + } + return None + + mock_request.side_effect = mock_side_effect + + result = self.env["account.move"].sync_from_billcom("bill_remote_123") + + # Method returns True on success + self.assertTrue(result) + + # Verify bill was created or updated + synced_bill = self.env["account.move"].search( + [("billcom_id", "=", "bill_remote_123")], limit=1 + ) + if synced_bill: + self.assertEqual(synced_bill.move_type, "in_invoice") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_from_billcom_vendor_not_found(self, mock_request): + """Should handle vendor not found in Odoo""" + + def mock_side_effect(endpoint, *args, **kwargs): + if "bills/" in endpoint: + return { + "id": "bill_remote_456", + "vendorId": "nonexistent_vendor", + "invoiceNumber": "BILL-002", + "invoiceDate": "2025-01-15", + "dueDate": "2025-02-15", + "amount": 100.0, + } + return None + + mock_request.side_effect = mock_side_effect + + result = self.env["account.move"].sync_from_billcom("bill_remote_456") + + # Should return False when vendor not found + self.assertFalse(result) + + # ===== Attachment Sync Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_sync_attachments_to_billcom(self, mock_request): + """Should sync attachments to Bill.com""" + # Create test attachment + self.env["ir.attachment"].create( + { + "name": "test_invoice.pdf", + "datas": "dGVzdCBkYXRh", # base64 "test data" + "res_model": "account.move", + "res_id": self.vendor_bill.id, + } + ) + + mock_request.return_value = { + "id": "attachment_123", + "fileName": "test_invoice.pdf", + } + + self.vendor_bill.button_sync_attachments_to_billcom() + + # Should not raise errors + self.assertTrue(True) + + # ===== Cron Job Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.sync_bills" + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.sync_invoices" + ) + def test_sync_documents_cron(self, mock_sync_invoices, mock_sync_bills): + """Should sync not_synced documents via cron""" + # Enable auto sync in config + self.billcom_config.write({"auto_sync_enabled": True}) + + # Mock sync methods to prevent real API calls + mock_sync_invoices.return_value = None + mock_sync_bills.return_value = None + + # Call cron method + self.env["account.move"]._sync_documents_cron() + + # Verify sync methods were called + mock_sync_invoices.assert_called_once() + mock_sync_bills.assert_called_once() + + # ===== Validation Tests ===== + + def test_bill_without_vendor(self): + """Should handle bill without vendor gracefully""" + bill_data = self.vendor_bill._prepare_bill_data() + + # Should still prepare data + self.assertIsNotNone(bill_data) + + def test_bill_with_draft_state(self): + """Should allow sync of draft bills if configured""" + self.bill2.state = "draft" + + # Should not raise error + bill_data = self.bill2._prepare_bill_data() + self.assertIsNotNone(bill_data) diff --git a/billcom_integration/tests/test_account_payment.py b/billcom_integration/tests/test_account_payment.py new file mode 100644 index 00000000..cc2d98f2 --- /dev/null +++ b/billcom_integration/tests/test_account_payment.py @@ -0,0 +1,372 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from unittest.mock import patch + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestAccountPayment(BillcomTestCommon): + """Test account.payment Bill.com integration""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create a payment for testing + cls.payment = cls.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": cls.vendor_billcom.id, + "amount": 100.0, + "journal_id": cls.bank_journal.id, + "date": fields.Date.today(), + } + ) + + def test_payment_billcom_fields(self): + """Test that payments have all required Bill.com fields""" + required_fields = [ + "billcom", + "billcom_id", + "is_sync_to_billcom", + "billcom_sync_status", + ] + + for field in required_fields: + self.assertTrue( + hasattr(self.payment, field), f"Payment should have field: {field}" + ) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_payment_to_billcom(self, mock_request): + """Test syncing payment to Bill.com""" + mock_request.return_value = { + "id": "payment_abc123", + "amount": 100.0, + "processDate": fields.Date.today().isoformat(), + "singleStatus": "SCHEDULED", + } + + # Trigger sync + self.payment.sync_to_billcom() + + # Verify billcom_id was updated + self.assertEqual(self.payment.billcom_id, "payment_abc123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_payment_status_update_from_webhook(self, mock_request): + """Test payment status update from Bill.com webhook""" + self.payment.billcom_id = "payment_webhook_001" + + mock_request.return_value = { + "id": "payment_webhook_001", + "singleStatus": "PAID", + "paidDate": fields.Date.today().isoformat(), + } + + # Sync from Bill.com + self.payment.sync_from_billcom_by_id("payment_webhook_001") + + # Verify payment was synced + self.assertEqual(self.payment.billcom_id, "payment_webhook_001") + + def test_payment_for_vendor_bill(self): + """Test creating payment for vendor bill""" + # Post the vendor bill + self.vendor_bill.action_post() + + # Create payment from bill + payment = ( + self.env["account.payment"] + .with_context(active_ids=[self.vendor_bill.id], active_model="account.move") + .create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_billcom.id, + "amount": self.vendor_bill.amount_total, + "journal_id": self.bank_journal.id, + } + ) + ) + + self.assertEqual(payment.partner_id, self.vendor_billcom) + self.assertEqual(payment.amount, self.vendor_bill.amount_total) + + def test_payment_multi_currency(self): + """Test payment in different currency""" + eur = self.env["res.currency"].search([("name", "=", "EUR")], limit=1) + if not eur: + eur = self.env["res.currency"].create({"name": "EUR", "symbol": "€"}) + + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_billcom.id, + "amount": 100.0, + "currency_id": eur.id, + "journal_id": self.bank_journal.id, + } + ) + + self.assertEqual(payment.currency_id, eur) + + def test_payment_funding_account(self): + """Test payment with Bill.com funding account""" + # Create funding account + funding_account = self.env["billcom.funding.account"].create( + { + "name": "Test Bank Account", + "billcom_id": "funding_001", + "account_type": "Checking", + "last_four_digits": "1234", + } + ) + + # Payment with funding account (if field exists) + if hasattr(self.payment, "billcom_funding_account_id"): + self.payment.billcom_funding_account_id = funding_account + self.assertEqual(self.payment.billcom_funding_account_id, funding_account) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_payment_sync_error_handling(self, mock_request): + """Test error handling during payment sync""" + mock_request.side_effect = UserError("Payment sync failed") + + with self.assertRaises(UserError): + self.payment.sync_to_billcom() + + def test_payment_date_validation(self): + """Test payment date is required""" + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_billcom.id, + "amount": 100.0, + "journal_id": self.bank_journal.id, + "date": fields.Date.today(), + } + ) + + self.assertTrue(payment.date) + + def test_customer_payment_not_synced_to_billcom(self): + """Test customer payments (inbound) sync behavior""" + customer_payment = self.env["account.payment"].create( + { + "payment_type": "inbound", + "partner_type": "customer", + "partner_id": self.customer_billcom.id, + "amount": 100.0, + "journal_id": self.bank_journal.id, + } + ) + + self.assertEqual(customer_payment.payment_type, "inbound") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_update_billcom_payment_status(self, mock_request): + """Test updating payment status from Bill.com""" + # Setup a payment that needs update + self.payment.billcom_id = "payment_to_update" + self.payment.billcom_payment_status = "scheduled" + self.payment.last_sync_date = fields.Datetime.now() - fields.timedelta(hours=2) + + # Mock response + mock_request.return_value = { + "id": "payment_to_update", + "singleStatus": "PROCESSED", + "confirmationNumber": "CONF123", + } + + # Run update + self.env["account.payment"].update_billcom_payment_status() + + # Verify status updated + self.assertEqual(self.payment.billcom_payment_status, "processed") + self.assertEqual(self.payment.billcom_confirmation_number, "CONF123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_action_cancel_billcom_payment(self, mock_request): + """Test canceling payment in Bill.com""" + self.payment.billcom_id = "payment_to_cancel" + self.payment.billcom_payment_status = "scheduled" + + mock_request.return_value = {"id": "payment_to_cancel", "status": "CANCELED"} + + self.payment.action_cancel_billcom_payment() + + self.assertEqual(self.payment.billcom_payment_status, "canceled") + self.assertEqual(self.payment.state, "cancel") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_action_get_payment_status(self, mock_request): + """Test manual status fetch""" + self.payment.billcom_id = "payment_status_check" + + mock_request.return_value = { + "id": "payment_status_check", + "singleStatus": "SENT", + } + + self.payment.action_get_payment_status() + + self.assertEqual(self.payment.billcom_payment_status, "sent") + + def test_prepare_payment_data_wallet(self): + """Test payment data preparation for WALLET funding type""" + self.payment.billcom_funding_account_type = "WALLET" + # Wallet requires process date + self.payment.billcom_process_date = fields.Date.today() + fields.timedelta( + days=1 + ) + + data = self.payment._prepare_payment_data() + + self.assertEqual(data["fundingAccount"]["type"], "WALLET") + self.assertIn("processDate", data) + + def test_prepare_payment_data_default_funding(self): + """Test fallback to default funding account""" + # Ensure no funding account on journal + self.bank_journal.bank_account_id.billcom_funding_account_id = False + + # Create default funding account + self.env["billcom.funding.account"].create( + { + "name": "Default Funding", + "billcom_id": "funding_default_001", + "status": "VERIFIED", + "is_default_payables": True, + "account_type": "CHECKING", + } + ) + + data = self.payment._prepare_payment_data() + + self.assertEqual(data["fundingAccount"]["id"], "funding_default_001") + + def test_prepare_payment_data_international(self): + """Test international payment data preparation""" + # Setup international vendor + fr = self.env.ref("base.fr") + eur = self.env["res.currency"].search([("name", "=", "EUR")], limit=1) + if not eur: + eur = self.env["res.currency"].create({"name": "EUR", "symbol": "€"}) + + intl_vendor = self.env["res.partner"].create( + { + "name": "Intl Vendor", + "country_id": fr.id, + "is_sync_to_billcom": True, + } + ) + + # Add bank with BIC + self.env["res.partner.bank"].create( + { + "partner_id": intl_vendor.id, + "acc_number": "FR123", + "bank_id": self.env["res.bank"] + .create({"name": "Bank FR", "bic": "FRBIC123"}) + .id, + } + ) + + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": intl_vendor.id, + "amount": 100.0, + "currency_id": eur.id, + "journal_id": self.bank_journal.id, + "date": fields.Date.today(), + } + ) + + data = payment._prepare_payment_data() + + self.assertIn("internationalOptions", data) + self.assertEqual(data["internationalOptions"]["paymentCurrency"], "EUR") + self.assertEqual(data["internationalOptions"]["wireInstructions"], "FRBIC123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_existing_payment(self, mock_request): + """Test syncing an already existing payment (GET status)""" + self.payment.billcom_id = "payment_existing_123" + + mock_request.return_value = { + "id": "payment_existing_123", + "singleStatus": "PROCESSED", + } + + self.payment.button_sync_to_billcom() + + # Should call GET, not POST + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + self.assertEqual(kwargs["method"], "GET") + self.assertIn("payment_existing_123", args[0]) + + self.assertEqual(self.payment.billcom_payment_status, "processed") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_update_billcom_payment_status_retry(self, mock_request): + """Test retry logic in update_billcom_payment_status""" + self.payment.billcom_id = "payment_retry_test" + self.payment.billcom_payment_status = "scheduled" + # Ensure it's old enough to be picked up + self.payment.last_sync_date = fields.Datetime.now() - fields.timedelta(hours=2) + + # Fail twice, succeed on third try + mock_request.side_effect = [ + UserError("API Error 1"), + UserError("API Error 2"), + {"id": "payment_retry_test", "singleStatus": "PROCESSED"}, + ] + + # Configure retries + with patch("time.sleep"): # Skip sleep delay + self.env["account.payment"].update_billcom_payment_status() + + self.assertEqual(mock_request.call_count, 3) + self.assertEqual(self.payment.billcom_payment_status, "processed") + + def test_action_set_process_date(self): + """Test action_set_process_date_for_new_vendor""" + # Clear process date + self.payment.billcom_process_date = False + + self.payment.action_set_process_date_for_new_vendor() + + self.assertTrue(self.payment.billcom_process_date) + self.assertTrue(self.payment.billcom_process_date > fields.Date.today()) diff --git a/billcom_integration/tests/test_account_payment_register.py b/billcom_integration/tests/test_account_payment_register.py new file mode 100644 index 00000000..d6dbb603 --- /dev/null +++ b/billcom_integration/tests/test_account_payment_register.py @@ -0,0 +1,160 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo import fields +from odoo.tests import tagged + +from .common import BillcomTestCommon + + +@tagged("post_install", "-at_install", "billcom") +class TestAccountPaymentRegister(BillcomTestCommon): + """Tests for account.payment.register wizard""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create a vendor configured for Bill.com + cls.vendor_billcom = cls.env["res.partner"].create( + { + "name": "Bill.com Vendor", + "is_company": True, + "is_sync_to_billcom": True, + "billcom_id": "vendor_123", + "property_account_payable_id": cls.account_payable.id, + "property_account_receivable_id": cls.account_receivable.id, + } + ) + + # Create a vendor invoice + cls.invoice = cls.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": cls.vendor_billcom.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Test Service", + "quantity": 1, + "price_unit": 100.0, + "account_id": cls.account_expense.id, + }, + ) + ], + } + ) + cls.invoice.action_post() + + def test_default_get_sync_flag(self): + """Should default is_sync_to_billcom from vendor""" + ctx = { + "active_model": "account.move", + "active_ids": [self.invoice.id], + } + + wizard = self.env["account.payment.register"].with_context(**ctx).create({}) + wizard.default_get(["is_sync_to_billcom"]) + + # Note: default_get might not be called automatically by create, + # but we can call it directly to test logic + defaults = ( + self.env["account.payment.register"] + .with_context(**ctx) + .default_get(["is_sync_to_billcom"]) + ) + self.assertTrue(defaults.get("is_sync_to_billcom")) + + def test_compute_is_international_payment(self): + """Should detect international payments""" + # Set vendor to different country + fr = self.env.ref("base.fr") + self.vendor_billcom.country_id = fr.id + + ctx = { + "active_model": "account.move", + "active_ids": [self.invoice.id], + } + + wizard = ( + self.env["account.payment.register"] + .with_context(**ctx) + .create( + { + "partner_id": self.vendor_billcom.id, + } + ) + ) + + self.assertTrue(wizard.is_international_payment) + + def test_create_payments_triggers_sync(self): + """Should trigger sync when creating payments""" + ctx = { + "active_model": "account.move", + "active_ids": [self.invoice.id], + } + payment_method_line_ids = self.bank_journal.outbound_payment_method_line_ids + wizard = ( + self.env["account.payment.register"] + .with_context(**ctx) + .create( + { + "partner_id": self.vendor_billcom.id, + "amount": 100.0, + "payment_date": fields.Date.today(), + "journal_id": self.bank_journal.id, + "payment_method_line_id": payment_method_line_ids[0].id, + "is_sync_to_billcom": True, + "billcom_funding_account_type": "WALLET", + } + ) + ) + + with patch( + "odoo.addons.billcom_integration.models.account_payment.AccountPayment.button_sync_to_billcom" # noqa B950 + ) as mock_sync: + payments = wizard._create_payments() + + self.assertTrue(payments) + self.assertEqual(payments.billcom_funding_account_type, "WALLET") + mock_sync.assert_called_once() + + def test_create_payments_sync_error(self): + """Should handle sync errors gracefully""" + ctx = { + "active_model": "account.move", + "active_ids": [self.invoice.id], + } + + payment_method_line_ids = self.bank_journal.outbound_payment_method_line_ids + wizard = ( + self.env["account.payment.register"] + .with_context(**ctx) + .create( + { + "partner_id": self.vendor_billcom.id, + "amount": 100.0, + "payment_date": fields.Date.today(), + "journal_id": self.bank_journal.id, + "payment_method_line_id": payment_method_line_ids[0].id, + "is_sync_to_billcom": True, + } + ) + ) + + with patch( + "odoo.addons.billcom_integration.models.account_payment.AccountPayment.button_sync_to_billcom" # noqa B950 + ) as mock_sync: + mock_sync.side_effect = Exception("Sync Error") + + payments = wizard._create_payments() + + self.assertTrue(payments) + self.assertEqual(payments.billcom_sync_status, "sync_failed") + self.assertIn("Sync Error", payments.billcom_sync_error) diff --git a/billcom_integration/tests/test_api_connection.py b/billcom_integration/tests/test_api_connection.py new file mode 100644 index 00000000..b8e61405 --- /dev/null +++ b/billcom_integration/tests/test_api_connection.py @@ -0,0 +1,434 @@ +import logging +from unittest.mock import MagicMock, patch + +import requests + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + + +class TestBillcomAPIConnection(TransactionCase): + """Test suite for Bill.com API connection and endpoint validation""" + + def setUp(self): + super().setUp() + + # Create a test Bill.com configuration + self.config = self.env["billcom.config"].create( + { + "name": "Test Bill.com Config", + "environment": "sandbox", + "username": "test_user@example.com", + "password": "test_password", + "organization_id": "test_org_123", + "dev_key": "test_dev_key_456", + "user_id": self.env.user.id, + "company_id": self.env.company.id, + "active": True, + } + ) + + # Create service instance + self.service = self.env["billcom.service.abstract"] + + def test_api_url_configuration(self): + """Test that API URLs are correctly configured for different environments""" + + # Test sandbox URL + self.config.environment = "sandbox" + self.config._compute_api_url() + expected_sandbox = "https://gateway.stage.bill.com/connect" + self.assertEqual( + self.config.api_url, + expected_sandbox, + f"Sandbox URL should be {expected_sandbox}", + ) + + # Test production URL + self.config.environment = "production" + self.config._compute_api_url() + expected_production = "https://gateway.bill.com/connect" + self.assertEqual( + self.config.api_url, + expected_production, + f"Production URL should be {expected_production}", + ) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.requests.post" + ) + def test_authentication_request_format(self, mock_post): + """Test that authentication requests are formatted correctly""" + + # Mock successful response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "sessionId": "test_session_123", + "status": "success", + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + # Trigger authentication + token = self.service._get_token() + + # Verify the request was made correctly + mock_post.assert_called_once() + call_args = mock_post.call_args + + # Check URL + expected_url = f"{self.config.api_url}/v3/login" + self.assertEqual(call_args[0][0], expected_url, "Login URL should be correct") + + # Check headers + headers = call_args[1]["headers"] + self.assertEqual(headers["accept"], "application/json") + self.assertEqual(headers["content-type"], "application/json") + + # Check payload + payload = call_args[1]["json"] + expected_payload = { + "organizationId": self.config.organization_id, + "devKey": self.config.dev_key, + "username": self.config.username, + "password": self.config.password, + } + self.assertEqual( + payload, expected_payload, "Authentication payload should be correct" + ) + + # Verify token was returned + self.assertEqual(token, "test_session_123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.requests.post" + ) + def test_authentication_error_handling(self, mock_post): + """Test authentication error handling""" + + # Mock error response + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "status": "error", + "errorMessage": "Invalid credentials", + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + # Should raise UserError for authentication failure + with self.assertRaises(UserError) as cm: + self.service._get_token() + + self.assertIn("Invalid credentials", str(cm.exception)) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.requests.post" + ) + def test_mfa_challenge_handling(self, mock_post): + """Test MFA challenge detection and handling""" + + # Mock MFA challenge response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "mfaRequired": True, + "mfaToken": "test_mfa_token_123", + "status": "mfa_required", + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + # Enable MFA in config but don't configure it properly + self.config.enable_mfa = False + + # Should return None for MFA challenge when MFA not configured + result = self.service._get_token() + self.assertIsNone( + result, "Should return None for MFA challenge when not configured" + ) + + def test_connection_test_functionality(self): + """Test the connection test method""" + + with patch.object(self.service, "_get_token") as mock_get_token: + # Mock successful token retrieval + mock_get_token.return_value = "test_token_123" + + # Test successful connection + result = self.config.test_connection() + + # Verify config state was updated + self.assertEqual(self.config.state, "connected") + self.assertIsNotNone(self.config.last_connection_test) + self.assertFalse(self.config.last_error_message) + + # Verify return value is notification action + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + + def test_connection_test_failure(self): + """Test connection test failure handling""" + + with patch.object(self.service, "_get_token") as mock_get_token: + # Mock authentication failure + mock_get_token.side_effect = UserError("Authentication failed") + + # Test connection failure + with self.assertRaises(UserError): + self.config.test_connection() + + # Verify config state was updated + self.assertEqual(self.config.state, "error") + self.assertIsNotNone(self.config.last_connection_test) + self.assertIn("Authentication failed", self.config.last_error_message) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.requests.get" + ) + def test_api_request_headers(self, mock_get): + """Test that API requests include proper headers""" + + # Mock token + with patch.object(self.service, "_get_token", return_value="test_token_123"): + # Mock successful response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": []} + mock_response.content = b'{"data": []}' + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + # Make a test API request + self.service._make_request("vendors", method="GET") + + # Verify headers + mock_get.assert_called_once() + call_args = mock_get.call_args + headers = call_args[1]["headers"] + + expected_headers = { + "accept": "application/json", + "content-type": "application/json", + "sessionId": "test_token_123", + "devKey": self.config.dev_key, + } + + for key, value in expected_headers.items(): + self.assertEqual(headers[key], value, f"Header {key} should be {value}") + + def test_api_endpoint_url_construction(self): + """Test that API endpoint URLs are constructed correctly""" + + test_cases = [ + ("vendors", "https://gateway.stage.bill.com/connect/v3/vendors"), + ("customers", "https://gateway.stage.bill.com/connect/v3/customers"), + ("bills", "https://gateway.stage.bill.com/connect/v3/bills"), + ("payments", "https://gateway.stage.bill.com/connect/v3/payments"), + ("vendors/123", "https://gateway.stage.bill.com/connect/v3/vendors/123"), + ( + "vendors/123/bank-account", + "https://gateway.stage.bill.com/connect/v3/vendors/123/bank-account", + ), + ] + + for endpoint, expected_url in test_cases: + url = self.service._build_api_url(self.config, endpoint) + self.assertEqual( + url, + expected_url, + f"URL for endpoint '{endpoint}' should be '{expected_url}'", + ) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.requests.post" + ) + def test_network_error_handling(self, mock_post): + """Test handling of network errors""" + + # Mock connection error + mock_post.side_effect = requests.exceptions.ConnectionError("Connection failed") + + # Should raise UserError with proper message + with self.assertRaises(UserError) as cm: + self.service._get_token() + + self.assertIn("HTTP error during Bill.com authentication", str(cm.exception)) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.requests.post" + ) + def test_timeout_handling(self, mock_post): + """Test handling of request timeouts""" + + # Mock timeout error + mock_post.side_effect = requests.exceptions.Timeout("Request timed out") + + # Should raise UserError with proper message + with self.assertRaises(UserError) as cm: + self.service._get_token() + + self.assertIn("HTTP error during Bill.com authentication", str(cm.exception)) + + def test_retry_configuration(self): + """Test retry configuration from config""" + + # Set custom retry values + self.config.api_max_retries = 5 + self.config.api_retry_delay = 10 + + max_retries, retry_delay = self.service._get_retry_config(self.config) + + self.assertEqual(max_retries, 5, "Max retries should match config") + self.assertEqual(retry_delay, 10, "Retry delay should match config") + + def test_retryable_status_codes(self): + """Test identification of retryable HTTP status codes""" + + retryable_codes = [429, 500, 502, 503, 504] + non_retryable_codes = [200, 400, 401, 403, 404] + + for code in retryable_codes: + self.assertTrue( + self.service._is_retryable_status(code), + f"Status code {code} should be retryable", + ) + + for code in non_retryable_codes: + self.assertFalse( + self.service._is_retryable_status(code), + f"Status code {code} should not be retryable", + ) + + def test_empty_response_handling(self): + """Test handling of empty API responses""" + + # Mock empty response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b"" + mock_response.raise_for_status.return_value = None + + result = self.service._process_response(mock_response, 0, 3) + self.assertEqual(result, {}, "Empty response should return empty dict") + + def test_json_parsing_error_handling(self): + """Test handling of invalid JSON responses""" + + # Mock invalid JSON response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b"invalid json content" + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_response.raise_for_status.return_value = None + + # Should raise exception for JSON parsing error + with self.assertRaises(Exception) as cm: + self.service._process_response(mock_response, 0, 3) + + self.assertIn("JSON parsing error", str(cm.exception)) + + def test_api_error_response_handling(self): + """Test handling of API-level errors in responses""" + + # Mock API error response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "status": "error", + "errorMessage": "Invalid vendor data", + } + mock_response.raise_for_status.return_value = None + + # Should raise exception for API error + with self.assertRaises(Exception) as cm: + self.service._process_response(mock_response, 0, 3) + + self.assertIn("Invalid vendor data", str(cm.exception)) + + def test_config_validation(self): + """Test configuration validation""" + + # Test missing configuration + self.config.active = False + + with self.assertRaises(UserError) as cm: + self.service._get_config() + + self.assertIn("No active BillCom configuration found", str(cm.exception)) + + # Test incomplete configuration + self.config.active = True + self.config.username = False + + with self.assertRaises(UserError) as cm: + self.service._get_config() + + self.assertIn("BillCom API configuration is incomplete", str(cm.exception)) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.requests.post" + ) + def test_real_api_endpoint_connectivity(self, mock_post): + """Test basic connectivity to real Bill.com API endpoints (mocked)""" + + # This test verifies our request format would work with real API + # We mock the response but verify request structure + + mock_response = MagicMock() + mock_response.status_code = 400 # Expected for invalid test credentials + mock_response.json.return_value = { + "status": "error", + "errorMessage": "Invalid credentials", + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + # This should fail authentication but prove endpoint connectivity + with self.assertRaises(UserError): + self.service._get_token() + + # Verify we called the correct endpoint + mock_post.assert_called_once() + call_args = mock_post.call_args + + # Check we're hitting the right Bill.com endpoint + url = call_args[0][0] + self.assertTrue( + url.startswith("https://gateway.stage.bill.com/connect/v3/login"), + f"Should use correct Bill.com API endpoint, got: {url}", + ) + + _logger.info("✅ Bill.com API endpoint structure validation passed") + _logger.info(f"✅ Sandbox URL: {url}") + _logger.info("✅ Request format matches Bill.com API v3 specification") + + def test_integration_readiness(self): + """Test that the integration is ready for real API connection""" + + # Verify all required fields are present + required_fields = ["username", "password", "organization_id", "dev_key"] + + for field in required_fields: + self.assertTrue( + hasattr(self.config, field), f"Config should have field: {field}" + ) + self.assertTrue( + getattr(self.config, field), f"Config field {field} should have a value" + ) + + # Verify API URL is correctly computed + self.assertTrue( + self.config.api_url.startswith("https://"), "API URL should use HTTPS" + ) + self.assertIn( + "bill.com", self.config.api_url, "API URL should point to Bill.com" + ) + + _logger.info("✅ Integration configuration is ready for real API connection") + _logger.info(f"✅ Sandbox API URL: {self.config.api_url}") + _logger.info("✅ All required authentication fields are configured") + _logger.info("✅ Ready to test with real Bill.com sandbox credentials") diff --git a/billcom_integration/tests/test_billcom_config.py b/billcom_integration/tests/test_billcom_config.py new file mode 100644 index 00000000..d3c0f14e --- /dev/null +++ b/billcom_integration/tests/test_billcom_config.py @@ -0,0 +1,227 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from unittest.mock import patch + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomConfig(BillcomTestCommon): + """Test billcom.config model""" + + def test_config_required_fields(self): + """Test that all required fields are present""" + required_fields = [ + "name", + "environment", + "username", + "password", + "organization_id", + "dev_key", + ] + + for field in required_fields: + self.assertTrue( + hasattr(self.billcom_config, field), + f"Config should have field: {field}", + ) + + def test_config_environment_values(self): + """Test environment field values""" + # Test sandbox + self.billcom_config.environment = "sandbox" + self.assertEqual(self.billcom_config.environment, "sandbox") + + # Test production + self.billcom_config.environment = "production" + self.assertEqual(self.billcom_config.environment, "production") + + def test_config_api_url_computation(self): + """Test API URL is computed correctly based on environment""" + # Sandbox URL + self.billcom_config.environment = "sandbox" + self.billcom_config._compute_api_url() + self.assertIn("stage.bill.com", self.billcom_config.api_url) + + # Production URL + self.billcom_config.environment = "production" + self.billcom_config._compute_api_url() + self.assertEqual( + self.billcom_config.api_url, "https://gateway.prod.bill.com/connect" + ) + + def test_config_state_management(self): + """Test configuration state transitions""" + # Default state + self.assertEqual(self.billcom_config.state, "draft") + + # Test state changes + self.billcom_config.state = "connected" + self.assertEqual(self.billcom_config.state, "connected") + + self.billcom_config.state = "error" + self.assertEqual(self.billcom_config.state, "error") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._get_token" # noqa B950 + ) + def test_connection_test_success(self, mock_get_token): + """Test successful connection test""" + mock_get_token.return_value = "test_token_123" + + result = self.billcom_config.test_connection() + + # Verify state was updated + self.assertEqual(self.billcom_config.state, "connected") + self.assertIsNotNone(self.billcom_config.last_connection_test) + + # Verify notification action + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + + def test_connection_test_failure(self): + """Test connection test failure""" + # Stop the global patcher temporarily + self.patcher_get_token.stop() + + try: + with patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._get_token" # noqa B950 + ) as mock_get_token: + mock_get_token.side_effect = UserError("Authentication failed") + + # Call test_connection and expect UserError + try: + self.billcom_config.test_connection() + self.fail("Expected UserError to be raised") + except UserError as e: + # Verify error message + self.assertIn("Authentication failed", str(e)) + + # Verify error state was set (inside same transaction) + self.billcom_config.invalidate_recordset() + self.assertEqual(self.billcom_config.state, "error") + self.assertIn( + "Authentication failed", self.billcom_config.last_error_message + ) + finally: + # Restart the global patcher + self.patcher_get_token.start() + + def test_mfa_configuration(self): + """Test MFA-related fields""" + # MFA disabled by default + self.assertFalse(self.billcom_config.enable_mfa) + + # Enable MFA + self.billcom_config.enable_mfa = True + self.billcom_config.mfa_device_id = "device_123" + self.billcom_config.mfa_remember_me_id = "remember_456" + + self.assertTrue(self.billcom_config.enable_mfa) + self.assertEqual(self.billcom_config.mfa_device_id, "device_123") + + def test_sync_configuration_flags(self): + """Test sync configuration flags""" + # All sync flags enabled by default in test setup + self.assertTrue(self.billcom_config.sync_vendors) + self.assertTrue(self.billcom_config.sync_customers) + self.assertTrue(self.billcom_config.sync_bills) + self.assertTrue(self.billcom_config.sync_payments) + + def test_webhook_configuration(self): + """Test webhook configuration""" + self.assertTrue(self.billcom_config.enable_webhooks) + self.assertEqual(self.billcom_config.webhook_secret, "test_webhook_secret") + + # Disable webhooks + self.billcom_config.enable_webhooks = False + self.assertFalse(self.billcom_config.enable_webhooks) + + def test_company_isolation(self): + """Test that config is company-specific""" + self.assertEqual(self.billcom_config.company_id, self.env.company) + + # Create config for different company (if multicompany) + if len(self.env["res.company"].search([])) > 1: + other_company = self.env["res.company"].search( + [("id", "!=", self.env.company.id)], limit=1 + ) + + other_config = self.env["billcom.config"].create( + { + "name": "Other Company Config", + "environment": "sandbox", + "username": "other_user", + "password": "other_pass", + "organization_id": "other_org", + "dev_key": "other_dev_key", + "user_id": self.env.user.id, + "company_id": other_company.id, + } + ) + + self.assertNotEqual(other_config.company_id, self.billcom_config.company_id) + + def test_multiple_configs_one_active(self): + """Test only one config can be active per company""" + # Create second config for same company + self.env["billcom.config"].create( + { + "name": "Second Config", + "environment": "production", + "username": "second_user", + "password": "second_pass", + "organization_id": "second_org", + "dev_key": "second_dev_key", + "user_id": self.env.user.id, + "company_id": self.env.company.id, + "active": True, + } + ) + + # Both are active (implementation may enforce single active) + active_configs = self.env["billcom.config"].search( + [("active", "=", True), ("company_id", "=", self.env.company.id)] + ) + + # Should handle multiple active configs gracefully + self.assertGreaterEqual(len(active_configs), 1) + + def test_last_sync_date_tracking(self): + """Test last sync date is tracked""" + # Initially None + self.assertFalse(self.billcom_config.last_sync_date) + + # Set sync date + now = fields.Datetime.now() + self.billcom_config.last_sync_date = now + + self.assertEqual(self.billcom_config.last_sync_date, now) + + def test_config_tracking(self): + """Test that important fields are tracked""" + # billcom.config inherits mail.thread + self.assertIn("mail.thread", self.billcom_config._inherit) + + def test_config_archiving(self): + """Test config can be archived""" + self.assertTrue(self.billcom_config.active) + + # Archive config + self.billcom_config.active = False + self.assertFalse(self.billcom_config.active) + + def test_config_credentials_security(self): + """Test that password field is not exposed""" + # Password should not be in read results (if implemented correctly) + # This is a basic check + self.assertTrue(self.billcom_config.password) diff --git a/billcom_integration/tests/test_billcom_controller.py b/billcom_integration/tests/test_billcom_controller.py new file mode 100644 index 00000000..6ec00727 --- /dev/null +++ b/billcom_integration/tests/test_billcom_controller.py @@ -0,0 +1,541 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import hashlib +import hmac +import json +from unittest.mock import patch + +from odoo import fields +from odoo.tests import tagged + +from .common import BillcomTestCommon + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomWebhookController(BillcomTestCommon): + """Test Bill.com webhook controller""" + + def setUp(self): + super().setUp() + self.webhook_url = "/billcom/webhook" + + def _generate_signature(self, payload_data, secret=None): + """Generate HMAC-SHA256 signature for webhook (Base64 encoded) + + Bill.com uses HMAC-SHA256 with Base64 encoding, not hexadecimal. + This matches the actual Bill.com webhook signature format. + """ + import base64 + + if secret is None: + secret = self.billcom_config.webhook_secret + + payload_str = json.dumps(payload_data) + hash_digest = hmac.new( + secret.encode(), payload_str.encode(), hashlib.sha256 + ).digest() + + # Encode as Base64 (NOT hexdigest) + signature = base64.b64encode(hash_digest).decode("utf-8") + return signature + + def _get_bill_created_payload(self): + """Get Bill.com webhook payload for bill.created event""" + return { + "idempotencyKey": "bill-created-12345", + "eventType": "bill.created", + "entityId": "bill_abc123", + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": self.billcom_config.organization_id, + "data": { + "id": "bill_abc123", + "vendorId": self.vendor_billcom.billcom_id, + "amount": 250.50, + "invoiceNumber": "INV-001", + "invoiceDate": fields.Date.today().isoformat(), + "dueDate": ( + fields.Date.today() + fields.timedelta(days=30) + ).isoformat(), + }, + } + + def _get_bill_updated_payload(self): + """Get Bill.com webhook payload for bill.updated event""" + return { + "idempotencyKey": "bill-updated-67890", + "eventType": "bill.updated", + "entityId": self.vendor_bill.billcom_id, + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": self.billcom_config.organization_id, + "data": { + "id": self.vendor_bill.billcom_id, + "vendorId": self.vendor_billcom.billcom_id, + "amount": 150.75, + "status": "OPEN", + }, + } + + def _get_bill_archived_payload(self): + """Get Bill.com webhook payload for bill.archived event""" + return { + "idempotencyKey": "bill-archived-11111", + "eventType": "bill.archived", + "entityId": self.vendor_bill.billcom_id, + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": self.billcom_config.organization_id, + } + + def _get_vendor_created_payload(self): + """Get Bill.com webhook payload for vendor.created event""" + return { + "idempotencyKey": "vendor-created-22222", + "eventType": "vendor.created", + "entityId": "vendor_xyz789", + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": self.billcom_config.organization_id, + "data": { + "id": "vendor_xyz789", + "name": "New Vendor Inc", + "email": "newvendor@example.com", + "isActive": True, + }, + } + + def _get_vendor_updated_payload(self): + """Get Bill.com webhook payload for vendor.updated event""" + return { + "idempotencyKey": "vendor-updated-33333", + "eventType": "vendor.updated", + "entityId": self.vendor_billcom.billcom_id, + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": self.billcom_config.organization_id, + "data": { + "id": self.vendor_billcom.billcom_id, + "name": "Updated Vendor Name", + "email": "updated@example.com", + }, + } + + def _get_customer_created_payload(self): + """Get Bill.com webhook payload for customer.created event""" + return { + "idempotencyKey": "customer-created-44444", + "eventType": "customer.created", + "entityId": "customer_def456", + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": self.billcom_config.organization_id, + "data": { + "id": "customer_def456", + "name": "New Customer LLC", + "email": "newcustomer@example.com", + "isActive": True, + }, + } + + def _get_payment_updated_payload(self): + """Get Bill.com webhook payload for payment.updated event""" + return { + "idempotencyKey": "payment-updated-55555", + "eventType": "payment.updated", + "entityId": "payment_pqr321", + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": self.billcom_config.organization_id, + "data": { + "id": "payment_pqr321", + "status": "PAID", + "amount": 100.00, + }, + } + + def test_webhook_bill_created(self): + """Test webhook handling for bill.created event""" + payload = self._get_bill_created_payload() + signature = self._generate_signature(payload) + + # Mock the sync_from_billcom method + with patch.object( + type(self.env["account.move"]), "sync_from_billcom" + ) as mock_sync: + mock_sync.return_value = self.vendor_bill + + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + # Verify response + self.assertEqual(response.status_code, 200) + result = json.loads(response.content.decode()) + self.assertTrue(result.get("success")) + + # Verify sync method was called + mock_sync.assert_called_once_with("bill_abc123") + + # Verify webhook log created + webhook_log = self.env["billcom.webhook.log"].search( + [("idempotency_key", "=", payload["idempotencyKey"])] + ) + self.assertEqual(len(webhook_log), 1) + self.assertEqual(webhook_log.event_type, "bill.created") + self.assertEqual(webhook_log.state, "success") + self.assertTrue(webhook_log.signature_valid) + + def test_webhook_bill_updated(self): + """Test webhook handling for bill.updated event""" + payload = self._get_bill_updated_payload() + signature = self._generate_signature(payload) + + # Mock the sync_from_billcom method + with patch.object( + type(self.env["account.move"]), "sync_from_billcom" + ) as mock_sync: + mock_sync.return_value = self.vendor_bill + + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + self.assertEqual(response.status_code, 200) + mock_sync.assert_called_once() + + def test_webhook_bill_archived(self): + """Test webhook handling for bill.archived event""" + payload = self._get_bill_archived_payload() + signature = self._generate_signature(payload) + + # Ensure bill is active before test + self.vendor_bill.active = True + + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + self.assertEqual(response.status_code, 200) + + # Verify bill was archived + self.vendor_bill.invalidate_cache() + self.assertFalse(self.vendor_bill.active) + + def test_webhook_vendor_created(self): + """Test webhook handling for vendor.created event""" + payload = self._get_vendor_created_payload() + signature = self._generate_signature(payload) + + # Mock the sync method + with patch.object( + type(self.env["res.partner"]), "sync_from_billcom_by_id" + ) as mock_sync: + mock_partner = self.env["res.partner"].create( + { + "name": "New Vendor Inc", + "billcom_id": "vendor_xyz789", + "supplier_rank": 1, + } + ) + mock_sync.return_value = mock_partner + + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + self.assertEqual(response.status_code, 200) + mock_sync.assert_called_once_with( + billcom_id="vendor_xyz789", partner_type="vendor" + ) + + def test_webhook_customer_created(self): + """Test webhook handling for customer.created event""" + payload = self._get_customer_created_payload() + signature = self._generate_signature(payload) + + # Mock the sync method + with patch.object( + type(self.env["res.partner"]), "sync_from_billcom_by_id" + ) as mock_sync: + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + self.assertEqual(response.status_code, 200) + mock_sync.assert_called_once_with( + billcom_id="customer_def456", partner_type="customer" + ) + + def test_webhook_invalid_signature(self): + """Test webhook with invalid signature is rejected""" + payload = self._get_bill_created_payload() + invalid_signature = "invalid_signature_here" + + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": invalid_signature, + }, + ) + + # Verify rejection + self.assertEqual(response.status_code, 200) + result = json.loads(response.content.decode()) + self.assertFalse(result.get("success")) + + # Verify webhook log shows invalid signature + webhook_log = self.env["billcom.webhook.log"].search( + [("idempotency_key", "=", payload["idempotencyKey"])] + ) + self.assertEqual(len(webhook_log), 1) + self.assertFalse(webhook_log.signature_valid) + + def test_webhook_idempotency(self): + """Test webhook idempotency - duplicate events are ignored""" + payload = self._get_bill_created_payload() + signature = self._generate_signature(payload) + + # Send first webhook + with patch.object( + type(self.env["account.move"]), "sync_from_billcom" + ) as mock_sync: + mock_sync.return_value = self.vendor_bill + + response1 = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + self.assertEqual(response1.status_code, 200) + self.assertEqual(mock_sync.call_count, 1) + + # Send duplicate webhook + response2 = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + self.assertEqual(response2.status_code, 200) + result = json.loads(response2.content.decode()) + self.assertTrue(result.get("success")) + + # Verify sync was NOT called again (idempotency) + self.assertEqual(mock_sync.call_count, 1) + + # Verify only one webhook log exists + webhook_logs = self.env["billcom.webhook.log"].search( + [("idempotency_key", "=", payload["idempotencyKey"])] + ) + self.assertEqual(len(webhook_logs), 1) + + def test_webhook_missing_signature(self): + """Test webhook without signature header""" + payload = self._get_bill_created_payload() + + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + # No X-Bill-Signature header + }, + ) + + # Should still process but mark as invalid signature + self.assertEqual(response.status_code, 200) + + webhook_log = self.env["billcom.webhook.log"].search( + [("idempotency_key", "=", payload["idempotencyKey"])] + ) + self.assertFalse(webhook_log.signature_valid) + + def test_webhook_error_handling(self): + """Test webhook error handling when sync fails""" + payload = self._get_bill_created_payload() + signature = self._generate_signature(payload) + + # Mock sync to raise exception + with patch.object( + type(self.env["account.move"]), "sync_from_billcom" + ) as mock_sync: + mock_sync.side_effect = Exception("Sync failed!") + + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + # Should return success (webhook received) but log error + self.assertEqual(response.status_code, 200) + + # Verify webhook log shows error + webhook_log = self.env["billcom.webhook.log"].search( + [("idempotency_key", "=", payload["idempotencyKey"])] + ) + self.assertEqual(webhook_log.state, "error") + self.assertIn("Sync failed!", webhook_log.error_message) + + def test_webhook_payment_updated(self): + """Test webhook handling for payment.updated event""" + payload = self._get_payment_updated_payload() + signature = self._generate_signature(payload) + + # Mock the sync method + with patch.object( + type(self.env["account.payment"]), "sync_from_billcom_by_id" + ) as mock_sync: + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + self.assertEqual(response.status_code, 200) + mock_sync.assert_called_once_with("payment_pqr321") + + def test_webhook_all_event_types(self): + """Test that all supported event types are processed""" + event_types = [ + "bill.created", + "bill.updated", + "bill.archived", + "bill.restored", + "vendor.created", + "vendor.updated", + "vendor.archived", + "vendor.restored", + "customer.created", + "customer.updated", + "customer.archived", + "customer.restored", + "invoice.created", + "invoice.updated", + "payment.updated", + "payment.failed", + ] + + for event_type in event_types: + payload = { + "idempotencyKey": f"test-{event_type}-{fields.Datetime.now().timestamp()}", + "eventType": event_type, + "entityId": "test_entity_id", + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": self.billcom_config.organization_id, + } + signature = self._generate_signature(payload) + + with self.subTest(event_type=event_type): + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + + # All events should be accepted + self.assertEqual( + response.status_code, + 200, + f"Event {event_type} failed with status {response.status_code}", + ) + + # Verify webhook log created + webhook_log = self.env["billcom.webhook.log"].search( + [("idempotency_key", "=", payload["idempotencyKey"])] + ) + self.assertEqual(len(webhook_log), 1, f"No log for {event_type}") + self.assertEqual(webhook_log.event_type, event_type) + + def test_webhook_payment_failed(self): + """Test webhook handling for payment.failed event""" + # Create a payment and related bill + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_billcom.id, + "amount": 100.0, + "journal_id": self.bank_journal.id, + "billcom_id": "payment_failed_001", + "is_sync_to_billcom": True, + } + ) + + self.vendor_bill.billcom_id = "bill_failed_001" + if hasattr(self.vendor_bill, "payment_id"): + self.vendor_bill.payment_id = payment + + payload = { + "idempotencyKey": "payment-failed-test", + "eventType": "payment.failed", + "entityId": "payment_failed_001", + "timestamp": fields.Datetime.now().isoformat(), + "organizationId": self.billcom_config.organization_id, + "data": { + "transactionNumber": "TRANS_FAIL_1", + "vendor": {"name": "Test Vendor"}, + "bills": [{"billId": "bill_failed_001"}], + "errors": [{"message": "Insufficient funds", "code": "101"}], + }, + } + signature = self._generate_signature(payload) + + with patch.object(type(self.env["account.move"]), "search") as mock_search: + mock_search.return_value = self.vendor_bill + + self.vendor_bill.action_post() + payment.action_post() + + pass # Placeholder for logic above + + with patch.object( + type(self.env["account.move"]), "search", return_value=self.vendor_bill + ): + response = self.url_open( + self.webhook_url, + data=json.dumps(payload), + headers={ + "Content-Type": "application/json", + "X-Bill-Signature": signature, + }, + ) + self.assertEqual(response.status_code, 200) diff --git a/billcom_integration/tests/test_billcom_controller_extended.py b/billcom_integration/tests/test_billcom_controller_extended.py new file mode 100644 index 00000000..2338d9a4 --- /dev/null +++ b/billcom_integration/tests/test_billcom_controller_extended.py @@ -0,0 +1,327 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import hashlib +import hmac +import logging + +from odoo.tests import tagged + +from odoo.addons.billcom_integration.controllers.billcom_controller import ( + BillComController, +) + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomControllerExtended(BillcomTestCommon): + """Extended tests for Bill.com webhook controller""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.controller = BillComController() + + # ===== Webhook Signature Validation Tests ===== + + def test_validate_webhook_signature_valid(self): + """Should validate correct webhook signature""" + secret = "test_webhook_secret_123" + payload = b'{"test": "data"}' + + # Generate correct signature + hash_digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest() + signature = base64.b64encode(hash_digest).decode("utf-8") + + is_valid = self.controller._validate_webhook_signature( + payload, signature, secret + ) + + self.assertTrue(is_valid) + + def test_validate_webhook_signature_invalid(self): + """Should reject invalid webhook signature""" + secret = "test_webhook_secret_123" + payload = b'{"test": "data"}' + wrong_signature = "invalid_signature_here" + + is_valid = self.controller._validate_webhook_signature( + payload, wrong_signature, secret + ) + + self.assertFalse(is_valid) + + def test_validate_webhook_signature_no_secret(self): + """Should skip validation if no secret configured""" + payload = b'{"test": "data"}' + signature = "any_signature" + + is_valid = self.controller._validate_webhook_signature(payload, signature, None) + + self.assertTrue(is_valid) # Returns True when no secret + + def test_validate_webhook_signature_no_signature_header(self): + """Should return False if signature header missing""" + secret = "test_secret" + payload = b'{"test": "data"}' + + is_valid = self.controller._validate_webhook_signature(payload, None, secret) + + self.assertFalse(is_valid) + + def test_validate_webhook_signature_string_payload(self): + """Should handle string payload by converting to bytes""" + secret = "test_secret" + payload_str = '{"test": "data"}' + + # Generate signature for bytes version + hash_digest = hmac.new( + secret.encode("utf-8"), payload_str.encode("utf-8"), hashlib.sha256 + ).digest() + signature = base64.b64encode(hash_digest).decode("utf-8") + + is_valid = self.controller._validate_webhook_signature( + payload_str, signature, secret + ) + + self.assertTrue(is_valid) + + # ===== Webhook Data Extraction Tests ===== + + def test_extract_webhook_data_bill_event(self): + """Should correctly extract bill webhook data""" + data = { + "metadata": { + "eventId": "evt_123", + "eventType": "bill.created", + "organizationId": "org_456", + "subscriptionId": "sub_789", + }, + "bill": { + "id": "bill_001", + "invoiceNumber": "INV-001", + }, + } + + result = self.controller._extract_webhook_data(data) + + self.assertEqual(result["event_type"], "bill.created") + self.assertEqual(result["organization_id"], "org_456") + self.assertEqual(result["entity_id"], "bill_001") + self.assertEqual(result["entity_data"]["invoiceNumber"], "INV-001") + self.assertEqual(result["idempotency_key"], "evt_123") + + def test_extract_webhook_data_vendor_event(self): + """Should correctly extract vendor webhook data""" + data = { + "metadata": { + "eventId": "evt_456", + "eventType": "vendor.updated", + "organizationId": "org_456", + }, + "vendor": { + "id": "vendor_123", + "name": "Test Vendor", + }, + } + + result = self.controller._extract_webhook_data(data) + + self.assertEqual(result["event_type"], "vendor.updated") + self.assertEqual(result["entity_id"], "vendor_123") + self.assertEqual(result["entity_data"]["name"], "Test Vendor") + + def test_extract_webhook_data_payment_event(self): + """Should correctly extract payment webhook data""" + data = { + "metadata": { + "eventId": "evt_789", + "eventType": "payment.created", + "organizationId": "org_456", + }, + "payment": { + "id": "pay_001", + "amount": 500.00, + }, + } + + result = self.controller._extract_webhook_data(data) + + self.assertEqual(result["event_type"], "payment.created") + self.assertEqual(result["entity_id"], "pay_001") + self.assertEqual(result["entity_data"]["amount"], 500.00) + + def test_extract_webhook_data_autopay_event(self): + """Should handle autopay events""" + data = { + "metadata": { + "eventId": "evt_auto_123", + "eventType": "autopay.failed", + "organizationId": "org_456", + }, + "payment": { + "id": "pay_auto_001", + "status": "FAILED", + }, + } + + result = self.controller._extract_webhook_data(data) + + self.assertEqual(result["event_type"], "autopay.failed") + self.assertEqual(result["entity_id"], "pay_auto_001") + + def test_extract_webhook_data_bank_account_event(self): + """Should correctly extract bank account webhook data""" + data = { + "metadata": { + "eventId": "evt_bank_123", + "eventType": "bank-account.updated", + "organizationId": "org_456", + }, + "bankAccount": { + "id": "bank_001", + "accountNumber": "****1234", + }, + } + + result = self.controller._extract_webhook_data(data) + + self.assertEqual(result["event_type"], "bank-account.updated") + self.assertEqual(result["entity_id"], "bank_001") + + def test_extract_webhook_data_card_account_event(self): + """Should correctly extract card account webhook data""" + data = { + "metadata": { + "eventId": "evt_card_123", + "eventType": "card-account.updated", + "organizationId": "org_456", + }, + "cardAccount": { + "id": "card_001", + "lastFourDigits": "4242", + }, + } + + result = self.controller._extract_webhook_data(data) + + self.assertEqual(result["event_type"], "card-account.updated") + self.assertEqual(result["entity_id"], "card_001") + + def test_extract_webhook_data_no_entity_id(self): + """Should use event_id as entity_id when entity_id missing""" + data = { + "metadata": { + "eventId": "evt_no_entity_123", + "eventType": "payment.failed", + "organizationId": "org_456", + }, + "payment": { + # No 'id' field + "status": "FAILED", + "reason": "Insufficient funds", + }, + } + + result = self.controller._extract_webhook_data(data) + + self.assertEqual(result["entity_id"], "event-evt_no_entity_123") + + def test_extract_webhook_data_missing_event_type(self): + """Should raise ValueError if event type missing""" + data = { + "metadata": { + "eventId": "evt_123", + # No eventType + "organizationId": "org_456", + }, + } + + with self.assertRaises(ValueError) as context: + self.controller._extract_webhook_data(data) + + self.assertIn("No event type", str(context.exception)) + + def test_extract_webhook_data_missing_organization_id(self): + """Should raise ValueError if organization ID missing""" + data = { + "metadata": { + "eventId": "evt_123", + "eventType": "bill.created", + # No organizationId + }, + } + + with self.assertRaises(ValueError) as context: + self.controller._extract_webhook_data(data) + + self.assertIn("No organization ID", str(context.exception)) + + def test_extract_webhook_data_invalid_format(self): + """Should raise ValueError if data not a dict""" + data = "invalid_string_data" + + with self.assertRaises(ValueError) as context: + self.controller._extract_webhook_data(data) + + self.assertIn("Invalid webhook data format", str(context.exception)) + + # ===== Get Config Tests ===== + + def test_get_config_from_organization_id_no_org_id(self): + """Should return None if organization ID not provided""" + config = self.controller._get_config_from_organization_id(None) + + self.assertIsNone(config) + + # ===== Vendor Webhook Handler Tests ===== + + def test_format_vendor_comment(self): + """Should format vendor data as comment""" + vendor_data = { + "name": "Test Vendor Inc", + "networkStatus": "CONNECTED", + "paymentNetworkId": "pn_12345", + "rppsId": "rpps_67890", + "paymentInformation": { + "payByType": "ACH", + "lastPaymentDate": "2024-01-15T10:30:00Z", + }, + "balance": { + "amount": 1500.50, + "lastUpdatedDate": "2024-01-20T14:45:00Z", + }, + } + + comment = self.controller._format_vendor_comment(vendor_data) + + self.assertIn("Bill.com Vendor Info", comment) + self.assertIn("CONNECTED", comment) + self.assertIn("pn_12345", comment) + self.assertIn("ACH", comment) + + # ===== Error Handler Tests ===== + + def test_handle_error(self): + """Should format error as JSON response""" + error = Exception("Test error message") + + result = self.controller._handle_error(error) + + self.assertFalse(result["success"]) + self.assertIn("Test error message", result["error"]) + + def test_handle_error_user_error(self): + """Should handle UserError specifically""" + from odoo.exceptions import UserError + + error = UserError("User-facing error") + + result = self.controller._handle_error(error) + + self.assertFalse(result["success"]) + self.assertIn("User-facing error", result["error"]) diff --git a/billcom_integration/tests/test_billcom_document.py b/billcom_integration/tests/test_billcom_document.py new file mode 100644 index 00000000..85c1197e --- /dev/null +++ b/billcom_integration/tests/test_billcom_document.py @@ -0,0 +1,442 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import logging +from datetime import datetime +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.billcom_integration.models.billcom_document import ( + parse_billcom_datetime, +) + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomDocument(BillcomTestCommon): + """Tests for billcom.document model""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create test document + cls.test_file_data = base64.b64encode(b"Test file content") + cls.test_document = cls.env["billcom.document"].create( + { + "name": "test_document.pdf", + "bill_id": cls.vendor_bill.id, + "file_data": cls.test_file_data, + } + ) + + # ===== Helper Function Tests ===== + + def test_parse_billcom_datetime_with_milliseconds(self): + """Should parse datetime with milliseconds""" + + date_string = "2025-10-03T06:11:24.000+00:00" + result = parse_billcom_datetime(date_string) + + self.assertIsInstance(result, datetime) + self.assertEqual(result.year, 2025) + self.assertEqual(result.month, 10) + self.assertEqual(result.day, 3) + + def test_parse_billcom_datetime_with_timezone(self): + """Should parse datetime with timezone""" + date_string = "2025-10-03T06:11:24+00:00" + result = parse_billcom_datetime(date_string) + + self.assertIsInstance(result, datetime) + + def test_parse_billcom_datetime_with_z(self): + """Should parse datetime with Z timezone""" + date_string = "2025-10-03T06:11:24Z" + result = parse_billcom_datetime(date_string) + + self.assertIsInstance(result, datetime) + + def test_parse_billcom_datetime_invalid(self): + """Should return None for invalid datetime""" + result = parse_billcom_datetime("invalid_date") + self.assertIsNone(result) + + def test_parse_billcom_datetime_none(self): + """Should return None for None input""" + result = parse_billcom_datetime(None) + self.assertIsNone(result) + + # ===== File Size Validation Tests ===== + + def test_check_file_size_valid(self): + """Should accept files under 6 MB""" + # 1 MB file + small_file = base64.b64encode(b"x" * (1024 * 1024)) + doc = self.env["billcom.document"].create( + { + "name": "small.pdf", + "bill_id": self.vendor_bill.id, + "file_data": small_file, + } + ) + + # Should not raise error + self.assertTrue(doc) + + def test_check_file_size_too_large(self): + """Should reject files over 6 MB""" + # 7 MB file + large_file = base64.b64encode(b"x" * (7 * 1024 * 1024)) + + with self.assertRaises(UserError) as context: + self.env["billcom.document"].create( + { + "name": "large.pdf", + "bill_id": self.vendor_bill.id, + "file_data": large_file, + } + ) + + self.assertIn("exceeds Bill.com limit", str(context.exception)) + + # ===== Prepare Upload Data Tests ===== + + def test_prepare_upload_data_success(self): + """Should prepare file data for upload""" + self.vendor_bill.billcom_id = "bill_123" + result = self.test_document._prepare_upload_data() + + self.assertEqual(result, b"Test file content") + + def test_prepare_upload_data_no_file(self): + """Should raise error if no file data""" + doc = self.env["billcom.document"].create( + { + "name": "empty.pdf", + "bill_id": self.vendor_bill.id, + } + ) + + with self.assertRaises(UserError) as context: + doc._prepare_upload_data() + + self.assertIn("No file content", str(context.exception)) + + def test_prepare_upload_data_no_billcom_id(self): + """Should raise error if bill not synced""" + self.vendor_bill.billcom_id = False + + with self.assertRaises(UserError) as context: + self.test_document._prepare_upload_data() + + self.assertIn("must be synced", str(context.exception)) + + # ===== Upload to Bill.com Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_upload_to_billcom_success_complete(self, mock_request): + """Should upload document successfully (complete)""" + self.vendor_bill.billcom_id = "bill_123" + + mock_request.return_value = { + "id": "00hdocument123", + "uploadId": "upload_456", + "downloadLink": "https://bill.com/download/doc123", + "createdTime": "2025-10-03T06:11:24.000+00:00", + } + + result = self.test_document.button_upload_to_billcom() + + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(self.test_document.upload_status, "uploaded") + self.assertEqual(self.test_document.billcom_id, "00hdocument123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_upload_to_billcom_in_progress(self, mock_request): + """Should handle upload in progress""" + self.vendor_bill.billcom_id = "bill_123" + + mock_request.return_value = { + "uploadId": "0duupload789", # Upload ID, not document ID + } + + result = self.test_document.button_upload_to_billcom() + + self.assertEqual(self.test_document.upload_status, "in_progress") + self.assertEqual(self.test_document.billcom_upload_id, "0duupload789") + self.assertIn("Upload In Progress", result["params"]["title"]) + + def test_button_upload_to_billcom_no_billcom_id(self): + """Should raise error if bill not synced""" + self.vendor_bill.billcom_id = False + + with self.assertRaises(UserError) as context: + self.test_document.button_upload_to_billcom() + + self.assertIn("must be synced", str(context.exception)) + + # ===== Check Upload Status Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_check_upload_status_completed(self, mock_request): + """Should check upload status and update when complete""" + self.test_document.billcom_upload_id = "upload_123" + + # Mock status check response + def mock_request_side_effect(endpoint, method="GET", params=None): + if "upload-status" in endpoint: + return [ + { + "status": "UPLOADED", + "documentId": "00hdoc456", + } + ] + elif "documents/" in endpoint: + return { + "id": "00hdoc456", + "downloadLink": "https://bill.com/download/doc456", + "createdTime": "2025-10-03T06:11:24.000+00:00", + } + return {} + + mock_request.side_effect = mock_request_side_effect + + result = self.test_document.button_check_upload_status() + + self.assertEqual(self.test_document.upload_status, "uploaded") + self.assertEqual(self.test_document.billcom_id, "00hdoc456") + self.assertIn("Upload Complete", result["params"]["title"]) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_check_upload_status_in_progress(self, mock_request): + """Should show in progress status""" + self.test_document.billcom_upload_id = "upload_123" + + mock_request.return_value = [ + { + "status": "IN_PROGRESS", + } + ] + + result = self.test_document.button_check_upload_status() + + self.assertIn("Upload In Progress", result["params"]["title"]) + + def test_button_check_upload_status_no_upload_id(self): + """Should raise error if no upload ID""" + self.test_document.billcom_upload_id = False + + with self.assertRaises(UserError) as context: + self.test_document.button_check_upload_status() + + self.assertIn("No upload ID", str(context.exception)) + + # ===== Download from Bill.com Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._download_document" # noqa B950 + ) + def test_button_download_from_billcom_success(self, mock_download): + """Should download document successfully""" + self.test_document.download_link = "https://bill.com/download/doc123" + + mock_download.return_value = b"Downloaded content" + + result = self.test_document.button_download_from_billcom() + + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual( + base64.b64decode(self.test_document.file_data), b"Downloaded content" + ) + self.assertTrue(self.test_document.attachment_id) + + def test_button_download_from_billcom_no_link(self): + """Should raise error if no download link""" + self.test_document.download_link = False + + with self.assertRaises(UserError) as context: + self.test_document.button_download_from_billcom() + + self.assertIn("No download link", str(context.exception)) + + # ===== Create from Attachment Tests ===== + + def test_create_from_attachment_success(self): + """Should create document from attachment""" + attachment = self.env["ir.attachment"].create( + { + "name": "test_attach.pdf", + "datas": self.test_file_data, + "res_model": "account.move", + "res_id": self.vendor_bill.id, + } + ) + + doc = self.env["billcom.document"].create_from_attachment(attachment) + + self.assertEqual(doc.name, "test_attach.pdf") + self.assertEqual(doc.bill_id, self.vendor_bill) + self.assertEqual(doc.attachment_id, attachment) + + def test_create_from_attachment_duplicate(self): + """Should return existing document if already exists""" + attachment = self.env["ir.attachment"].create( + { + "name": "test_attach.pdf", + "datas": self.test_file_data, + "res_model": "account.move", + "res_id": self.vendor_bill.id, + } + ) + + # Create first document + doc1 = self.env["billcom.document"].create_from_attachment(attachment) + + # Try to create again + doc2 = self.env["billcom.document"].create_from_attachment(attachment) + + self.assertEqual(doc1, doc2) + + def test_create_from_attachment_wrong_model(self): + """Should raise error if attachment not for account.move""" + attachment = self.env["ir.attachment"].create( + { + "name": "test.pdf", + "datas": self.test_file_data, + "res_model": "res.partner", + "res_id": self.vendor_billcom.id, + } + ) + + with self.assertRaises(UserError) as context: + self.env["billcom.document"].create_from_attachment(attachment) + + self.assertIn("must be linked to a vendor bill", str(context.exception)) + + # ===== Sync Documents from Bill.com Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._download_document" # noqa B950 + ) + def test_sync_documents_from_billcom_success(self, mock_download, mock_request): + """Should sync documents from Bill.com""" + self.vendor_bill.billcom_id = "bill_123" + + mock_request.return_value = [ + { + "id": "00hdoc1", + "name": "invoice.pdf", + "downloadLink": "https://bill.com/download/doc1", + "createdTime": "2025-10-03T06:11:24.000+00:00", + }, + { + "id": "00hdoc2", + "name": "receipt.pdf", + "downloadLink": "https://bill.com/download/doc2", + "createdTime": "2025-10-03T07:11:24.000+00:00", + }, + ] + + mock_download.return_value = b"File content" + + count = self.env["billcom.document"].sync_documents_from_billcom( + self.vendor_bill + ) + + self.assertEqual(count, 2) + + # Verify documents were created + docs = self.env["billcom.document"].search( + [("bill_id", "=", self.vendor_bill.id)] + ) + self.assertGreaterEqual(len(docs), 2) + + def test_sync_documents_from_billcom_no_billcom_id(self): + """Should return 0 if bill not synced""" + self.vendor_bill.billcom_id = False + + count = self.env["billcom.document"].sync_documents_from_billcom( + self.vendor_bill + ) + + self.assertEqual(count, 0) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_documents_from_billcom_no_documents(self, mock_request): + """Should handle no documents returned""" + self.vendor_bill.billcom_id = "bill_123" + + mock_request.return_value = [] + + count = self.env["billcom.document"].sync_documents_from_billcom( + self.vendor_bill + ) + + self.assertEqual(count, 0) + + # ===== Create/Update Attachment Tests ===== + + def test_create_or_update_attachment_create_new(self): + """Should create new attachment""" + doc = self.env["billcom.document"].create( + { + "name": "new_doc.pdf", + "bill_id": self.vendor_bill.id, + "file_data": self.test_file_data, + } + ) + + doc._create_or_update_attachment(self.test_file_data) + + self.assertTrue(doc.attachment_id) + self.assertEqual(doc.attachment_id.name, "new_doc.pdf") + self.assertEqual(doc.attachment_id.res_model, "account.move") + self.assertEqual(doc.attachment_id.res_id, self.vendor_bill.id) + + def test_create_or_update_attachment_update_existing(self): + """Should update existing attachment""" + # Create attachment first + attachment = self.env["ir.attachment"].create( + { + "name": "old_name.pdf", + "datas": self.test_file_data, + "res_model": "account.move", + "res_id": self.vendor_bill.id, + } + ) + + doc = self.env["billcom.document"].create( + { + "name": "updated_doc.pdf", + "bill_id": self.vendor_bill.id, + "file_data": self.test_file_data, + "attachment_id": attachment.id, + } + ) + + new_data = base64.b64encode(b"Updated content") + doc._create_or_update_attachment(new_data) + + # Should update existing, not create new + self.assertEqual(doc.attachment_id, attachment) + self.assertEqual(doc.attachment_id.name, "updated_doc.pdf") diff --git a/billcom_integration/tests/test_billcom_funding_account.py b/billcom_integration/tests/test_billcom_funding_account.py new file mode 100644 index 00000000..8a79ded1 --- /dev/null +++ b/billcom_integration/tests/test_billcom_funding_account.py @@ -0,0 +1,472 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from psycopg2 import IntegrityError + +import odoo.tools +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomFundingAccount(BillcomTestCommon): + """Tests for billcom.funding.account model""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create test funding account + cls.test_account = cls.env["billcom.funding.account"].create( + { + "billcom_id": "00f123", + "bank_name": "Test Bank", + "name_on_account": "Test Company", + "account_number": "****1234", + "routing_number": "123456789", + "funding_type": "BANK_ACCOUNT", + "account_type": "CHECKING", + "owner_type": "BUSINESS", + "status": "VERIFIED", + "is_default_payables": True, + "is_default_receivables": False, + "company_id": cls.env.company.id, + } + ) + + # ===== Compute Name Tests ===== + + def test_compute_name_full_details(self): + """Should compute name from bank name, account holder, and number""" + self.test_account.invalidate_recordset() + + expected_name = "Test Bank (Test Company) [****1234]" + self.assertEqual(self.test_account.name, expected_name) + + def test_compute_name_bank_only(self): + """Should compute name from bank name only""" + account = self.env["billcom.funding.account"].create( + { + "billcom_id": "00f456", + "bank_name": "Another Bank", + "company_id": self.env.company.id, + } + ) + + self.assertEqual(account.name, "Another Bank") + + def test_compute_name_no_details(self): + """Should use default name when no details available""" + account = self.env["billcom.funding.account"].create( + { + "billcom_id": "00f789", + "company_id": self.env.company.id, + } + ) + + self.assertEqual(account.name, "Bill.com Funding Account") + + def test_compute_name_partial_details(self): + """Should compute name from partial details""" + account = self.env["billcom.funding.account"].create( + { + "billcom_id": "00f999", + "bank_name": "Partial Bank", + "account_number": "****5678", + "company_id": self.env.company.id, + } + ) + + self.assertEqual(account.name, "Partial Bank [****5678]") + + # ===== Sync Funding Accounts from Bill.com Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.get_funding_accounts" # noqa B950 + ) + def test_sync_funding_accounts_create_new(self, mock_get): + """Should create new funding accounts from Bill.com""" + mock_get.return_value = [ + { + "id": "00f888", + "bankName": "New Bank", + "nameOnAccount": "New Company", + "accountNumber": "****9999", + "routingNumber": "987654321", + "type": "SAVINGS", + "ownerType": "BUSINESS", + "status": "VERIFIED", + "archived": False, + "accessToAdmins": True, + "createdBy": "usr123", + "default": { + "payables": False, + "receivables": True, + }, + } + ] + + result = self.env[ + "billcom.funding.account" + ].sync_funding_accounts_from_billcom() + + self.assertEqual(result["created"], 1) + self.assertEqual(result["updated"], 0) + self.assertEqual(result["errors"], 0) + + new_account = self.env["billcom.funding.account"].search( + [("billcom_id", "=", "00f888")] + ) + self.assertTrue(new_account) + self.assertEqual(new_account.bank_name, "New Bank") + self.assertTrue(new_account.is_default_receivables) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.get_funding_accounts" # noqa B950 + ) + def test_sync_funding_accounts_update_existing(self, mock_get): + """Should update existing funding accounts from Bill.com""" + mock_get.return_value = [ + { + "id": "00f123", + "bankName": "Updated Bank", + "nameOnAccount": "Updated Company", + "accountNumber": "****1234", + "routingNumber": "123456789", + "type": "CHECKING", + "ownerType": "BUSINESS", + "status": "PENDING", + "archived": False, + "accessToAdmins": False, + "createdBy": "usr456", + "default": { + "payables": True, + "receivables": True, + }, + } + ] + + result = self.env[ + "billcom.funding.account" + ].sync_funding_accounts_from_billcom() + + self.assertEqual(result["created"], 0) + self.assertEqual(result["updated"], 1) + self.assertEqual(result["errors"], 0) + + self.test_account.invalidate_recordset() + self.assertEqual(self.test_account.bank_name, "Updated Bank") + self.assertEqual(self.test_account.status, "PENDING") + self.assertTrue(self.test_account.is_default_receivables) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.get_funding_accounts" # noqa B950 + ) + def test_sync_funding_accounts_skip_without_id(self, mock_get): + """Should skip accounts without Bill.com ID""" + mock_get.return_value = [ + { + "bankName": "No ID Bank", + "nameOnAccount": "Test", + } + ] + + result = self.env[ + "billcom.funding.account" + ].sync_funding_accounts_from_billcom() + + self.assertEqual(result["created"], 0) + self.assertEqual(result["errors"], 1) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.get_funding_accounts" # noqa B950 + ) + def test_sync_funding_accounts_handle_archived(self, mock_get): + """Should set active=False for archived accounts""" + mock_get.return_value = [ + { + "id": "00f777", + "bankName": "Archived Bank", + "nameOnAccount": "Test", + "archived": True, + "default": {}, + } + ] + + result = self.env[ + "billcom.funding.account" + ].sync_funding_accounts_from_billcom() + + self.assertEqual(result["created"], 1) + + archived_account = self.env["billcom.funding.account"].search( + [("billcom_id", "=", "00f777")] + ) + self.assertFalse(archived_account.active) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.get_funding_accounts" # noqa B950 + ) + def test_sync_funding_accounts_error_handling(self, mock_get): + """Should handle errors during sync""" + mock_get.side_effect = Exception("API Error") + + with self.assertRaises(UserError): + self.env["billcom.funding.account"].sync_funding_accounts_from_billcom() + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.get_funding_accounts" # noqa B950 + ) + def test_sync_funding_accounts_multiple(self, mock_get): + """Should sync multiple funding accounts""" + mock_get.return_value = [ + { + "id": "00f001", + "bankName": "Bank 1", + "default": {}, + }, + { + "id": "00f002", + "bankName": "Bank 2", + "default": {}, + }, + { + "id": "00f003", + "bankName": "Bank 3", + "default": {}, + }, + ] + + result = self.env[ + "billcom.funding.account" + ].sync_funding_accounts_from_billcom() + + self.assertEqual(result["created"], 3) + self.assertEqual(result["total"], 3) + + # ===== Prepare Funding Account Vals Tests ===== + + def test_prepare_funding_account_vals_complete(self): + """Should prepare complete values from Bill.com data""" + account_data = { + "id": "00f555", + "bankName": "Complete Bank", + "nameOnAccount": "Complete Account", + "accountNumber": "****5555", + "routingNumber": "555555555", + "type": "SAVINGS", + "ownerType": "PERSONAL", + "status": "PENDING", + "archived": False, + "accessToAdmins": True, + "createdBy": "usr999", + "default": { + "payables": True, + "receivables": False, + }, + } + + vals = self.env["billcom.funding.account"]._prepare_funding_account_vals( + account_data + ) + + self.assertEqual(vals["billcom_id"], "00f555") + self.assertEqual(vals["bank_name"], "Complete Bank") + self.assertEqual(vals["name_on_account"], "Complete Account") + self.assertEqual(vals["account_number"], "****5555") + self.assertEqual(vals["routing_number"], "555555555") + self.assertEqual(vals["account_type"], "SAVINGS") + self.assertEqual(vals["owner_type"], "PERSONAL") + self.assertEqual(vals["status"], "PENDING") + self.assertTrue(vals["active"]) + self.assertTrue(vals["access_to_admins"]) + self.assertTrue(vals["is_default_payables"]) + self.assertFalse(vals["is_default_receivables"]) + + def test_prepare_funding_account_vals_minimal(self): + """Should prepare minimal values from Bill.com data""" + account_data = { + "id": "00f666", + "bankName": "Minimal Bank", + } + + vals = self.env["billcom.funding.account"]._prepare_funding_account_vals( + account_data + ) + + self.assertEqual(vals["billcom_id"], "00f666") + self.assertEqual(vals["bank_name"], "Minimal Bank") + self.assertTrue(vals["active"]) # Default should be not archived + self.assertFalse(vals["is_default_payables"]) # Default from empty dict + self.assertFalse(vals["is_default_receivables"]) + + def test_prepare_funding_account_vals_archived(self): + """Should set active=False when archived""" + account_data = { + "id": "00f777", + "bankName": "Archived", + "archived": True, + } + + vals = self.env["billcom.funding.account"]._prepare_funding_account_vals( + account_data + ) + + self.assertFalse(vals["active"]) + + # ===== Action Sync from Bill.com Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.get_funding_accounts" # noqa B950 + ) + def test_action_sync_from_billcom_success(self, mock_get): + """Should return success notification""" + mock_get.return_value = [ + { + "id": "00f111", + "bankName": "Action Bank", + "default": {}, + } + ] + + result = self.env["billcom.funding.account"].action_sync_from_billcom() + + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + self.assertIn("Created: 1", result["params"]["message"]) + self.assertEqual(result["params"]["type"], "success") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.get_funding_accounts" # noqa B950 + ) + def test_action_sync_from_billcom_with_errors(self, mock_get): + """Should return warning notification when errors occur""" + mock_get.return_value = [ + { + "id": "00f222", + "bankName": "Good Bank", + "default": {}, + }, + { + # Missing ID will cause error + "bankName": "Bad Bank", + }, + ] + + result = self.env["billcom.funding.account"].action_sync_from_billcom() + + self.assertEqual(result["params"]["type"], "warning") + self.assertIn("Errors: 1", result["params"]["message"]) + + # ===== CRUD and Validation Tests ===== + + def test_create_funding_account_basic(self): + """Should create basic funding account""" + account = self.env["billcom.funding.account"].create( + { + "billcom_id": "00f333", + "bank_name": "Basic Bank", + "company_id": self.env.company.id, + } + ) + + self.assertEqual(account.bank_name, "Basic Bank") + self.assertTrue(account.active) + + @odoo.tools.mute_logger("odoo.sql_db") + def test_unique_constraint_billcom_id_company(self): + """Should enforce unique constraint on billcom_id + company_id""" + with self.assertRaises(IntegrityError): + self.env["billcom.funding.account"].create( + { + "billcom_id": "00f123", # Same as test_account + "bank_name": "Duplicate", + "company_id": self.env.company.id, + } + ) + + def test_search_default_payables(self): + """Should search for default payables account""" + accounts = self.env["billcom.funding.account"].search( + [("is_default_payables", "=", True)] + ) + + self.assertIn(self.test_account, accounts) + + def test_ordering(self): + """Should order by defaults first, then name""" + # Create accounts with different default settings + account1 = self.env["billcom.funding.account"].create( + { + "billcom_id": "00f_a", + "bank_name": "AAA Bank", + "is_default_payables": False, + "is_default_receivables": False, + "company_id": self.env.company.id, + } + ) + account2 = self.env["billcom.funding.account"].create( + { + "billcom_id": "00f_b", + "bank_name": "BBB Bank", + "is_default_payables": False, + "is_default_receivables": True, + "company_id": self.env.company.id, + } + ) + + accounts = self.env["billcom.funding.account"].search([]) + + # Payables default should be first (test_account) + # Then receivables default (account2) + # Then others + first_idx = accounts.ids.index(self.test_account.id) + second_idx = accounts.ids.index(account2.id) + third_idx = accounts.ids.index(account1.id) + + self.assertLess(first_idx, second_idx) + self.assertLess(second_idx, third_idx) + + def test_active_toggle(self): + """Should toggle active status""" + self.assertTrue(self.test_account.active) + + self.test_account.active = False + self.assertFalse(self.test_account.active) + + self.test_account.active = True + self.assertTrue(self.test_account.active) + + def test_funding_account_types(self): + """Should support different funding types""" + types = ["BANK_ACCOUNT", "CARD_ACCOUNT", "WALLET", "AP_CARD"] + + for idx, funding_type in enumerate(types): + account = self.env["billcom.funding.account"].create( + { + "billcom_id": f"00f_type_{idx}", + "bank_name": f"Type {funding_type}", + "funding_type": funding_type, + "company_id": self.env.company.id, + } + ) + self.assertEqual(account.funding_type, funding_type) + + def test_account_statuses(self): + """Should support different account statuses""" + statuses = ["VERIFIED", "PENDING", "UNVERIFIED", "FAILED"] + + for idx, status in enumerate(statuses): + account = self.env["billcom.funding.account"].create( + { + "billcom_id": f"00f_status_{idx}", + "bank_name": f"Status {status}", + "status": status, + "company_id": self.env.company.id, + } + ) + self.assertEqual(account.status, status) diff --git a/billcom_integration/tests/test_billcom_item.py b/billcom_integration/tests/test_billcom_item.py new file mode 100644 index 00000000..2f29298d --- /dev/null +++ b/billcom_integration/tests/test_billcom_item.py @@ -0,0 +1,336 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomItem(BillcomTestCommon): + """Tests for billcom.item model""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create test tax + cls.test_tax = cls.env["account.tax"].create( + { + "name": "Test Tax 10%", + "amount": 10.0, + "type_tax_use": "sale", + "company_id": cls.env.company.id, + } + ) + + # Create test Bill.com item + cls.test_item = cls.env["billcom.item"].create( + { + "billcom_id": "00i123", + "name": "Test Tax Item", + "type": "SALES_TAX", + "percentage": 10.0, + "tax_ids": [(6, 0, [cls.test_tax.id])], + } + ) + + # ===== Button Sync to Bill.com Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_sync_to_billcom_create(self, mock_request): + """Should create new item in Bill.com""" + mock_request.return_value = { + "id": "00i456", + "name": "New Tax Item", + "itemType": "SALES_TAX", + "percentage": 15.0, + } + + item = self.env["billcom.item"].create( + { + "name": "New Tax Item", + "type": "SALES_TAX", + "percentage": 15.0, + } + ) + + item.button_sync_to_billcom() + + self.assertEqual(item.billcom_id, "00i456") + mock_request.assert_called_once() + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_sync_to_billcom_update(self, mock_request): + """Should update existing item in Bill.com""" + mock_request.return_value = { + "id": "00i123", + "name": "Updated Tax Item", + "itemType": "SALES_TAX", + "percentage": 12.0, + } + + self.test_item.name = "Updated Tax Item" + self.test_item.percentage = 12.0 + self.test_item.button_sync_to_billcom() + + mock_request.assert_called_once() + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_button_sync_to_billcom_no_config(self, mock_request): + """Should raise error if no Bill.com config""" + # Deactivate config to trigger error + self.billcom_config.active = False + + item = self.env["billcom.item"].create( + { + "name": "No Config Item", + "type": "SALES_TAX", + "percentage": 10.0, + } + ) + + with self.assertRaises(UserError): + item.button_sync_to_billcom() + + # Should not make request if config is missing + mock_request.assert_not_called() + + # ===== Prepare Item Data Tests ===== + + def test_prepare_item_data_sales_tax(self): + """Should prepare data for SALES_TAX item""" + data = self.test_item._prepare_item_data() + + self.assertEqual(data["name"], "Test Tax Item") + self.assertEqual(data["itemType"], "SALES_TAX") + self.assertEqual(data["percentage"], 10.0) + self.assertIn("id", data) + + def test_prepare_item_data_new_item(self): + """Should prepare data for new item without billcom_id""" + item = self.env["billcom.item"].create( + { + "name": "New Item", + "type": "SALES_TAX", + "percentage": 8.0, + } + ) + + data = item._prepare_item_data() + + self.assertEqual(data["name"], "New Item") + self.assertNotIn("id", data) + + def test_prepare_item_data_archived(self): + """Should include archived status""" + self.test_item.active = False + data = self.test_item._prepare_item_data() + + self.assertTrue(data.get("archived")) + + # ===== Sync from Odoo Taxes Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_from_odoo_taxes_create_new(self, mock_request): + """Should create Bill.com items for Odoo taxes without items""" + mock_request.return_value = { + "id": "00i789", + "name": "Tax 15%", + "itemType": "SALES_TAX", + "percentage": 15.0, + } + + new_tax = self.env["account.tax"].create( + { + "name": "Tax 15%", + "amount": 15.0, + "type_tax_use": "sale", + "company_id": self.env.company.id, + } + ) + + self.env["billcom.item"].sync_from_odoo_taxes(self.billcom_config) + + item = self.env["billcom.item"].search( + [ + ("tax_ids", "in", new_tax.id), + ("billcom_config_id", "=", self.billcom_config.id), + ] + ) + self.assertTrue(item) + self.assertEqual(item.billcom_id, "00i789") + + # @patch( + # "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + # ) + # def test_sync_from_odoo_taxes_skip_existing(self, mock_request): + # """Should skip taxes that already have Bill.com items""" + # initial_count = self.env["billcom.item"].search_count( + # [("tax_ids", "in", self.test_tax.id)] + # ) + + # self.env["billcom.item"].sync_from_odoo_taxes() + + # final_count = self.env["billcom.item"].search_count( + # [("tax_ids", "in", self.test_tax.id)] + # ) + # self.assertEqual(initial_count, final_count) + + # @patch( + # "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + # ) + # def test_sync_from_odoo_taxes_only_sale_taxes(self, mock_request): + # """Should only sync sale taxes""" + # purchase_tax = self.env["account.tax"].create( + # { + # "name": "Purchase Tax", + # "amount": 5.0, + # "type_tax_use": "purchase", + # "company_id": self.env.company.id, + # } + # ) + + # self.env["billcom.item"].sync_from_odoo_taxes(self.billcom_config) + + # item = self.env["billcom.item"].search( + # [ + # ("tax_ids", "in", purchase_tax.id), + # ] + # ) + # self.assertFalse(item) + + # ===== Get Item for Tax Tests ===== + + def test_get_item_for_tax_existing(self): + """Should return existing Bill.com item for tax""" + item_id = self.env["billcom.item"].get_item_for_tax( + self.test_tax, self.billcom_config + ) + + self.assertEqual(item_id, "00i123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_get_item_for_tax_create_if_missing(self, mock_request): + """Should create item if none exists for tax""" + mock_request.return_value = { + "id": "00i999", + "name": "New Tax Item", + "itemType": "SALES_TAX", + "percentage": 20.0, + } + + new_tax = self.env["account.tax"].create( + { + "name": "New Tax 20%", + "amount": 20.0, + "type_tax_use": "sale", + "company_id": self.env.company.id, + } + ) + + item_id = self.env["billcom.item"].get_item_for_tax( + new_tax, self.billcom_config + ) + + self.assertEqual(item_id, "00i999") + item = self.env["billcom.item"].search( + [ + ("tax_ids", "in", new_tax.id), + ("billcom_config_id", "=", self.billcom_config.id), + ] + ) + self.assertTrue(item) + + def test_get_item_for_tax_no_billcom_id(self): + """Should return False if item exists but has no billcom_id""" + self.env["billcom.item"].create( + { + "name": "Item without ID", + "type": "SALES_TAX", + "percentage": 5.0, + "tax_ids": [(6, 0, [self.test_tax.id])], + } + ) + # Remove existing item with billcom_id + self.test_item.unlink() + + item_id = self.env["billcom.item"].get_item_for_tax( + self.test_tax, self.billcom_config + ) + + self.assertFalse(item_id) + + # ===== CRUD and Validation Tests ===== + + def test_create_item_basic(self): + """Should create basic item""" + item = self.env["billcom.item"].create( + { + "name": "Basic Item", + "type": "SALES_TAX", + "percentage": 5.0, + } + ) + + self.assertEqual(item.name, "Basic Item") + self.assertEqual(item.type, "SALES_TAX") + self.assertEqual(item.percentage, 5.0) + + def test_item_name_get(self): + """Should return proper display name""" + name = self.test_item.name_get()[0][1] + + self.assertIn("Test Tax Item", name) + self.assertIn("10.0%", name) + + def test_item_unlink(self): + """Should allow deletion of items""" + item = self.env["billcom.item"].create( + { + "name": "Delete Me", + "type": "SALES_TAX", + "percentage": 3.0, + } + ) + + item_id = item.id + item.unlink() + + self.assertFalse(self.env["billcom.item"].search([("id", "=", item_id)])) + + def test_item_active_toggle(self): + """Should toggle active status""" + self.assertTrue(self.test_item.active) + + self.test_item.active = False + self.assertFalse(self.test_item.active) + + self.test_item.active = True + self.assertTrue(self.test_item.active) + + def test_search_by_billcom_id(self): + """Should search items by Bill.com ID""" + items = self.env["billcom.item"].search([("billcom_id", "=", "00i123")]) + + self.assertEqual(len(items), 1) + self.assertEqual(items[0], self.test_item) + + def test_search_by_tax(self): + """Should search items by Odoo tax""" + items = self.env["billcom.item"].search([("tax_ids", "in", self.test_tax.id)]) + + self.assertIn(self.test_item, items) diff --git a/billcom_integration/tests/test_billcom_logger.py b/billcom_integration/tests/test_billcom_logger.py new file mode 100644 index 00000000..8bf06972 --- /dev/null +++ b/billcom_integration/tests/test_billcom_logger.py @@ -0,0 +1,424 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from datetime import timedelta + +from odoo import fields +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomLogger(BillcomTestCommon): + """Tests for billcom.logger model""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # ===== Compute Duration Tests ===== + + def test_compute_duration_with_times(self): + """Should compute duration from start and end times""" + start = fields.Datetime.now() + end = start + timedelta(seconds=10) + + log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "success", + "start_time": start, + "end_time": end, + } + ) + + self.assertEqual(log.duration, 10.0) + + def test_compute_duration_no_end_time(self): + """Should return 0 if no end time""" + log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "pending", + "start_time": fields.Datetime.now(), + } + ) + + self.assertEqual(log.duration, 0.0) + + # ===== Log Operation Tests ===== + + def test_log_operation_basic(self): + """Should create log entry with basic info""" + log = self.env["billcom.logger"].log_operation( + "sync_vendor", level="info", status="pending" + ) + + self.assertEqual(log.operation_type, "sync_vendor") + self.assertEqual(log.level, "info") + self.assertEqual(log.status, "pending") + self.assertTrue(log.start_time) + + def test_log_operation_with_context(self): + """Should create log with context information""" + log = self.env["billcom.logger"].log_operation( + "sync_vendor", + record_model="res.partner", + record_id=self.vendor_billcom.id, + billcom_id="00v123", + ) + + self.assertEqual(log.record_model, "res.partner") + self.assertEqual(log.record_id, self.vendor_billcom.id) + self.assertEqual(log.billcom_id, "00v123") + + # ===== Log API Request Tests ===== + + def test_log_api_request_get(self): + """Should log GET API request""" + log = self.env["billcom.logger"].log_api_request( + "/vendors/00v123", method="GET" + ) + + self.assertEqual(log.operation_type, "api_request") + self.assertEqual(log.endpoint, "/vendors/00v123") + self.assertEqual(log.http_method, "GET") + + def test_log_api_request_post(self): + """Should log POST API request with data""" + log = self.env["billcom.logger"].log_api_request( + "/vendors", + method="POST", + request_data='{"name": "Test Vendor"}', + status="processing", + ) + + self.assertEqual(log.http_method, "POST") + self.assertTrue(log.request_data) + self.assertEqual(log.status, "processing") + + # ===== Log Sync Operation Tests ===== + + def test_log_sync_operation_with_record(self): + """Should log sync operation with record name""" + log = self.env["billcom.logger"].log_sync_operation( + "sync_vendor", record_model="res.partner", record_id=self.vendor_billcom.id + ) + + self.assertEqual(log.operation_type, "sync_vendor") + self.assertEqual(log.record_model, "res.partner") + self.assertEqual(log.record_id, self.vendor_billcom.id) + self.assertTrue(log.record_name) + + def test_log_sync_operation_no_record(self): + """Should log sync without record""" + log = self.env["billcom.logger"].log_sync_operation("sync_vendor") + + self.assertEqual(log.operation_type, "sync_vendor") + self.assertFalse(log.record_model) + self.assertFalse(log.record_name) + + def test_log_sync_operation_invalid_record(self): + """Should handle invalid record ID""" + log = self.env["billcom.logger"].log_sync_operation( + "sync_vendor", record_model="res.partner", record_id=999999 + ) + + self.assertEqual(log.record_model, "res.partner") + self.assertTrue(log.record_name) # Should have fallback ID name + + # ===== Log Webhook Tests ===== + + def test_log_webhook_basic(self): + """Should log webhook event""" + log = self.env["billcom.logger"].log_webhook( + "vendor.updated", entity_id="00v123" + ) + + self.assertEqual(log.operation_type, "webhook") + self.assertIn("Webhook", log.message) + self.assertEqual(log.billcom_id, "00v123") + + def test_log_webhook_with_details(self): + """Should log webhook with details""" + log = self.env["billcom.logger"].log_webhook( + "payment.sent", + entity_id="00p456", + response_data='{"status": "sent"}', + ) + + self.assertEqual(log.billcom_id, "00p456") + self.assertTrue(log.response_data) + + # ===== Update Status Tests ===== + + def test_update_status_success(self): + """Should update status to success""" + log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "pending", + "start_time": fields.Datetime.now(), + } + ) + + log.update_status("success", message="Sync completed") + + self.assertEqual(log.status, "success") + self.assertEqual(log.message, "Sync completed") + self.assertTrue(log.end_time) + + def test_update_status_error(self): + """Should update status to error with traceback""" + log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "processing", + "start_time": fields.Datetime.now(), + } + ) + + log.update_status("error", error_message="API Error") + + self.assertEqual(log.status, "error") + self.assertEqual(log.error_message, "API Error") + self.assertTrue(log.end_time) + + # ===== Mark Success Tests ===== + + def test_mark_success_basic(self): + """Should mark log as successful""" + log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "processing", + "start_time": fields.Datetime.now(), + } + ) + + log.mark_success("Vendor synced successfully") + + self.assertEqual(log.status, "success") + self.assertEqual(log.message, "Vendor synced successfully") + + def test_mark_success_with_response(self): + """Should mark success with response data""" + log = self.env["billcom.logger"].create( + { + "operation_type": "api_request", + "level": "info", + "status": "processing", + "start_time": fields.Datetime.now(), + } + ) + + log.mark_success(response_data='{"id": "00v123"}', response_status_code=200) + + self.assertEqual(log.status, "success") + self.assertEqual(log.response_status_code, 200) + + # ===== Mark Error Tests ===== + + def test_mark_error_basic(self): + """Should mark log as error""" + log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "processing", + "start_time": fields.Datetime.now(), + } + ) + + log.mark_error("Connection timeout") + + self.assertEqual(log.status, "error") + self.assertEqual(log.error_message, "Connection timeout") + + def test_mark_error_with_code(self): + """Should mark error with response code""" + log = self.env["billcom.logger"].create( + { + "operation_type": "api_request", + "level": "info", + "status": "processing", + "start_time": fields.Datetime.now(), + } + ) + + log.mark_error("Unauthorized", response_status_code=401) + + self.assertEqual(log.status, "error") + self.assertEqual(log.response_status_code, 401) + + # ===== Mark Retry Tests ===== + + def test_mark_retry_basic(self): + """Should mark log for retry""" + log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "error", + "retry_count": 0, + } + ) + + log.mark_retry(retry_count=1) + + self.assertEqual(log.status, "retry") + self.assertEqual(log.retry_count, 1) + + # ===== Cleanup Old Logs Tests ===== + + def test_cleanup_old_logs_basic(self): + """Should delete old log entries""" + # Create old log + old_date = fields.Datetime.now() - timedelta(days=40) + old_log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "success", + "start_time": old_date, + } + ) + # Force old create_date + self.env.cr.execute( + "UPDATE billcom_logger SET create_date = %s WHERE id = %s", + (old_date, old_log.id), + ) + + # Create recent log + recent_log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "success", + } + ) + + count = self.env["billcom.logger"].cleanup_old_logs(days=30) + + self.assertGreater(count, 0) + self.assertFalse(old_log.exists()) + self.assertTrue(recent_log.exists()) + + # ===== Action View Related Record Tests ===== + + def test_action_view_related_record_success(self): + """Should return action to view related record""" + log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "record_model": "res.partner", + "record_id": self.vendor_billcom.id, + } + ) + + result = log.action_view_related_record() + + self.assertEqual(result["type"], "ir.actions.act_window") + self.assertEqual(result["res_model"], "res.partner") + self.assertEqual(result["res_id"], self.vendor_billcom.id) + + def test_action_view_related_record_no_record(self): + """Should return False if no related record""" + log = self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + } + ) + + result = log.action_view_related_record() + + self.assertFalse(result) + + # ===== Get Operation Stats Tests ===== + + def test_get_operation_stats_basic(self): + """Should calculate operation statistics""" + # Create test logs + for _ in range(5): + self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "info", + "status": "success", + "start_time": fields.Datetime.now(), + "end_time": fields.Datetime.now() + timedelta(seconds=5), + } + ) + + for _ in range(2): + self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "error", + "status": "error", + "start_time": fields.Datetime.now(), + "end_time": fields.Datetime.now() + timedelta(seconds=5), + } + ) + + stats = self.env["billcom.logger"].get_operation_stats("sync_vendor", days=7) + + self.assertGreaterEqual(stats["total"], 7) + self.assertGreaterEqual(stats["success"], 5) + self.assertGreaterEqual(stats["error"], 2) + self.assertGreater(stats["success_rate"], 0) + + def test_get_operation_stats_no_logs(self): + """Should return zeros for no logs""" + stats = self.env["billcom.logger"].get_operation_stats("sync_bill", days=1) + + self.assertEqual(stats["total"], 0) + self.assertEqual(stats["success_rate"], 0) + self.assertEqual(stats["avg_duration"], 0) + + # ===== Get Error Summary Tests ===== + + def test_get_error_summary_basic(self): + """Should return error summary""" + # Create error logs + for i in range(3): + self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "error", + "status": "error", + "record_name": f"Vendor {i}", + "error_message": f"Error {i}", + } + ) + + errors = self.env["billcom.logger"].get_error_summary(days=7, limit=10) + + self.assertGreaterEqual(len(errors), 3) + self.assertTrue(all("error_message" in err for err in errors)) + + def test_get_error_summary_with_limit(self): + """Should respect limit parameter""" + # Create many error logs + for i in range(15): + self.env["billcom.logger"].create( + { + "operation_type": "sync_vendor", + "level": "error", + "status": "error", + "error_message": f"Error {i}", + } + ) + + errors = self.env["billcom.logger"].get_error_summary(days=7, limit=5) + + self.assertLessEqual(len(errors), 5) diff --git a/billcom_integration/tests/test_billcom_partner_matching_wizard.py b/billcom_integration/tests/test_billcom_partner_matching_wizard.py new file mode 100644 index 00000000..2fe77bf7 --- /dev/null +++ b/billcom_integration/tests/test_billcom_partner_matching_wizard.py @@ -0,0 +1,879 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomPartnerMatchingWizard(BillcomTestCommon): + """Tests for billcom.partner.matching.wizard and line models""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create wizard + cls.wizard = cls.env["billcom.partner.matching.wizard"].create( + { + "partner_type": "vendor", + "state": "draft", + } + ) + + # Create test partners + cls.test_vendor = cls.env["res.partner"].create( + { + "name": "Test Vendor Inc", + "email": "test@vendor.com", + "phone": "+1-555-123-4567", + "supplier_rank": 1, + } + ) + + cls.test_vendor_linked = cls.env["res.partner"].create( + { + "name": "Linked Vendor", + "billcom_id": "00v123linked", + "email": "linked@vendor.com", + "supplier_rank": 1, + } + ) + + # ===== Wizard - Compute Stats Tests ===== + + def test_compute_stats_with_lines(self): + """Should compute stats from lines""" + # Create lines with different confidence levels + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v001", + "billcom_partner_name": "High Confidence", + "confidence_level": "high", + "selected": True, + } + ) + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v002", + "billcom_partner_name": "Medium Confidence", + "confidence_level": "medium", + } + ) + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v003", + "billcom_partner_name": "No Match", + "confidence_level": "none", + "is_duplicate_group": True, + } + ) + + self.assertEqual(self.wizard.total_partners, 3) + self.assertEqual(self.wizard.high_confidence_count, 1) + self.assertEqual(self.wizard.medium_confidence_count, 1) + self.assertEqual(self.wizard.no_match_count, 1) + self.assertEqual(self.wizard.selected_count, 1) + self.assertEqual(self.wizard.duplicate_group_count, 1) + + # ===== Wizard - Normalize Partner Name Tests ===== + + def test_normalize_partner_name_empty(self): + """Should return empty string for None""" + result = self.wizard._normalize_partner_name(None) + self.assertEqual(result, "") + + # ===== Wizard - Group Duplicates Tests ===== + + def test_group_billcom_duplicates_no_duplicates(self): + """Should return empty duplicates dict when no duplicates""" + billcom_partners = [ + {"id": "00v001", "name": "ABC Company"}, + {"id": "00v002", "name": "XYZ Corp"}, + ] + + _grouped, duplicates = self.wizard._group_billcom_duplicates(billcom_partners) + + self.assertEqual(len(duplicates), 0) + + # ===== Wizard - Normalize Text Tests ===== + + def test_normalize_text_basic(self): + """Should normalize text for comparison""" + result = self.wizard._normalize_text("Test Company!!!") + self.assertEqual(result, "test company") + + def test_normalize_text_remove_accents(self): + """Should remove accents from text""" + result = self.wizard._normalize_text("Société Générale") + self.assertEqual(result, "societe generale") + + def test_normalize_text_empty(self): + """Should return empty string for None""" + result = self.wizard._normalize_text(None) + self.assertEqual(result, "") + + # ===== Wizard - Normalize Phone Tests ===== + + def test_normalize_phone_digits_only(self): + """Should keep only digits""" + test_cases = [ + ("+1-555-123-4567", "15551234567"), + ("(555) 123-4567", "5551234567"), + ("555.123.4567", "5551234567"), + ] + + for input_phone, expected in test_cases: + result = self.wizard._normalize_phone(input_phone) + self.assertEqual(result, expected) + + def test_normalize_phone_empty(self): + """Should return empty string for None""" + result = self.wizard._normalize_phone(None) + self.assertEqual(result, "") + + # ===== Wizard - Find Odoo Matches Tests ===== + + def test_find_odoo_matches_high_confidence(self): + """Should find high confidence match (3/3 criteria)""" + billcom_partner = { + "id": "00v123", + "name": "Test Vendor Inc", + "email": "test@vendor.com", + "phone": "+1-555-123-4567", + } + + matches = self.wizard._find_odoo_matches( + billcom_partner, self.env["res.partner"].browse([self.test_vendor.id]) + ) + + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0]["confidence_level"], "high") + self.assertEqual(matches[0]["score"], 3) + + def test_find_odoo_matches_medium_confidence(self): + """Should find medium confidence match (2/3 criteria)""" + billcom_partner = { + "id": "00v123", + "name": "Different Name", + "email": "test@vendor.com", + "phone": "+1-555-123-4567", + } + + matches = self.wizard._find_odoo_matches( + billcom_partner, self.env["res.partner"].browse([self.test_vendor.id]) + ) + + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0]["confidence_level"], "medium") + self.assertEqual(matches[0]["score"], 2) + + def test_find_odoo_matches_low_confidence(self): + """Should find low confidence match (1/3 criteria)""" + billcom_partner = { + "id": "00v123", + "name": "Test Vendor Inc", + "email": "different@email.com", + "phone": "999-999-9999", + } + + matches = self.wizard._find_odoo_matches( + billcom_partner, self.env["res.partner"].browse([self.test_vendor.id]) + ) + + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0]["confidence_level"], "low") + self.assertEqual(matches[0]["score"], 1) + + def test_find_odoo_matches_no_match(self): + """Should return empty list when no criteria match""" + billcom_partner = { + "id": "00v123", + "name": "Completely Different", + "email": "different@email.com", + "phone": "999-999-9999", + } + + matches = self.wizard._find_odoo_matches( + billcom_partner, self.env["res.partner"].browse([self.test_vendor.id]) + ) + + self.assertEqual(len(matches), 0) + + # ===== Wizard - Action Find Matches Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_action_find_matches_success(self, mock_request): + """Should find matches between Odoo and Bill.com partners""" + # Mock Bill.com API response + mock_request.return_value = { + "results": [ + { + "id": "00v001", + "name": "Test Vendor Inc", + "email": "test@vendor.com", + "phone": "+1-555-123-4567", + "archived": False, + } + ], + "nextPage": None, + } + + # Enable vendor sync + self.billcom_config.write({"sync_vendors": True}) + + result = self.wizard.action_find_matches() + + self.assertEqual(result["type"], "ir.actions.act_window") + self.assertEqual(self.wizard.state, "review") + self.assertGreater(len(self.wizard.line_ids), 0) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_action_find_matches_sync_disabled(self, mock_request): + """Should raise error if sync is disabled""" + # Disable vendor sync + self.billcom_config.write({"sync_vendors": False}) + + with self.assertRaises(UserError) as context: + self.wizard.action_find_matches() + + self.assertIn("disabled in configuration", str(context.exception)) + + # ===== Wizard - Action Apply Selected Tests ===== + + def test_action_apply_selected_no_selection(self): + """Should raise error if no lines selected""" + with self.assertRaises(UserError) as context: + self.wizard.action_apply_selected() + + self.assertIn("No lines selected", str(context.exception)) + + @patch( + "odoo.addons.billcom_integration.wizards.billcom_partner_matching_line.BillcomPartnerMatchingLine.action_apply_link" # noqa B950 + ) + def test_action_apply_selected_link_success(self, mock_link): + """Should process selected link actions""" + mock_link.return_value = None + + # Create selected line with link action + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "odoo_partner_id": self.test_vendor.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test Vendor", + "action": "link", + "selected": True, + } + ) + + result = self.wizard.action_apply_selected() + + self.assertEqual(result["type"], "ir.actions.client") + mock_link.assert_called_once() + + # ===== Wizard - Selection Management Tests ===== + + def test_action_select_all(self): + """Should select all lines""" + # Create lines + for i in range(3): + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": f"00v{i}", + "billcom_partner_name": f"Partner {i}", + } + ) + + self.wizard.action_select_all() + + selected = self.wizard.line_ids.filtered(lambda l: l.selected) + self.assertEqual(len(selected), 3) + + def test_action_deselect_all(self): + """Should deselect all lines""" + # Create selected lines + for i in range(3): + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": f"00v{i}", + "billcom_partner_name": f"Partner {i}", + "selected": True, + } + ) + + self.wizard.action_deselect_all() + + selected = self.wizard.line_ids.filtered(lambda l: l.selected) + self.assertEqual(len(selected), 0) + + def test_action_select_no_match(self): + """Should select only no match lines""" + # Create lines with different confidence levels + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v001", + "billcom_partner_name": "High", + "confidence_level": "high", + } + ) + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v002", + "billcom_partner_name": "No Match", + "confidence_level": "none", + } + ) + + self.wizard.action_select_no_match() + + selected = self.wizard.line_ids.filtered(lambda l: l.selected) + self.assertEqual(len(selected), 1) + self.assertEqual(selected.confidence_level, "none") + + def test_action_select_high_confidence(self): + """Should select only high confidence lines""" + # Create lines + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v001", + "billcom_partner_name": "High", + "confidence_level": "high", + } + ) + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v002", + "billcom_partner_name": "Medium", + "confidence_level": "medium", + } + ) + + self.wizard.action_select_high_confidence() + + selected = self.wizard.line_ids.filtered(lambda l: l.selected) + self.assertEqual(len(selected), 1) + self.assertEqual(selected.confidence_level, "high") + + # ===== Wizard - Bulk Set Actions Tests ===== + + def test_action_bulk_set_link(self): + """Should set selected lines to link action""" + # Create selected line with odoo partner + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "odoo_partner_id": self.test_vendor.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + "selected": True, + } + ) + + self.wizard.action_bulk_set_link() + + self.assertEqual(line.action, "link") + + def test_action_bulk_set_create(self): + """Should set selected lines to create action""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + "selected": True, + } + ) + + self.wizard.action_bulk_set_create() + + self.assertEqual(line.action, "create") + + def test_action_bulk_set_ignore(self): + """Should set selected lines to ignore action""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + "selected": True, + } + ) + + self.wizard.action_bulk_set_ignore() + + self.assertEqual(line.action, "ignore") + + # ===== Wizard - Reset Tests ===== + + def test_action_reset(self): + """Should reset wizard to draft state""" + # Create lines + self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + } + ) + + self.wizard.write({"state": "review"}) + + self.wizard.action_reset() + + self.assertEqual(self.wizard.state, "draft") + self.assertEqual(len(self.wizard.line_ids), 0) + + def test_action_reset_all_billcom_ids(self): + """Should reset all Bill.com IDs""" + # Create partner with billcom_id + partner = self.env["res.partner"].create( + { + "name": "Test Reset", + "billcom_id": "00v999", + "billcom": "00v999", + "billcom_sync_status": "synced", + } + ) + + result = self.wizard.action_reset_all_billcom_ids() + + self.assertEqual(result["type"], "ir.actions.client") + self.assertFalse(partner.billcom_id) + self.assertFalse(partner.billcom) + self.assertEqual(partner.billcom_sync_status, "not_synced") + + # ===== Line - Compute Tests ===== + + def test_line_compute_confidence_color(self): + """Should compute correct color for confidence level""" + color_cases = [ + ("high", 10), + ("medium", 4), + ("low", 2), + ("none", 1), + ] + + for confidence, expected_color in color_cases: + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + "confidence_level": confidence, + } + ) + + self.assertEqual(line.confidence_color, expected_color) + + def test_line_compute_billcom_ids_count_single(self): + """Should return 1 for single Bill.com ID""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + } + ) + + self.assertEqual(line.billcom_ids_count, 1) + + def test_line_compute_billcom_ids_count_duplicate_group(self): + """Should count IDs in duplicate group""" + billcom_ids = [ + {"id": "00v001", "currency": "USD"}, + {"id": "00v002", "currency": "EUR"}, + ] + + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v001", + "billcom_partner_name": "Test Group", + "is_duplicate_group": True, + "billcom_ids_json": json.dumps(billcom_ids), + } + ) + + self.assertEqual(line.billcom_ids_count, 2) + + # ===== Line - Action Apply Link Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_line_action_apply_link_success(self, mock_request): + """Should link partner successfully""" + mock_request.return_value = { + "id": "00v123", + "name": "Test Vendor Inc", + "email": "test@vendor.com", + "phone": "+1-555-123-4567", + "archived": False, + "billCurrency": "USD", + } + + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "odoo_partner_id": self.test_vendor.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test Vendor", + "confidence_level": "high", + } + ) + + line.action_apply_link() + + self.assertEqual(line.state, "linked") + self.assertEqual(self.test_vendor.billcom_id, "00v123") + + def test_line_action_apply_link_no_billcom_id(self): + """Should set error if no Bill.com ID""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "odoo_partner_id": self.test_vendor.id, + "billcom_partner_name": "Test", + } + ) + + line.action_apply_link() + + self.assertEqual(line.state, "error") + self.assertIn("No Bill.com partner", line.error_message) + + def test_line_action_apply_link_no_odoo_partner(self): + """Should set error if no Odoo partner selected""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + } + ) + + line.action_apply_link() + + self.assertEqual(line.state, "error") + self.assertIn("No Odoo partner selected", line.error_message) + + def test_line_action_apply_link_already_linked(self): + """Should set error if Odoo partner already has Bill.com ID""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "odoo_partner_id": self.test_vendor_linked.id, + "billcom_partner_id": "00v999", + "billcom_partner_name": "Test", + } + ) + + line.action_apply_link() + + self.assertEqual(line.state, "error") + self.assertIn("already linked", line.error_message) + + # ===== Line - Action Apply Create Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_line_action_apply_create_success(self, mock_request): + """Should create new partner successfully""" + mock_request.return_value = { + "id": "00v123", + "name": "New Vendor Inc", + "email": "new@vendor.com", + "phone": "+1-555-999-8888", + "archived": False, + "billCurrency": "USD", + "accountType": "BUSINESS", + } + + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "New Vendor Inc", + "action": "create", + } + ) + + line.action_apply_create() + + self.assertEqual(line.state, "created") + self.assertTrue(line.odoo_partner_id) + self.assertEqual(line.odoo_partner_id.billcom_id, "00v123") + + def test_line_action_apply_create_no_billcom_id(self): + """Should set error if no Bill.com ID""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_name": "Test", + "action": "create", + } + ) + + line.action_apply_create() + + self.assertEqual(line.state, "error") + self.assertIn("No Bill.com partner", line.error_message) + + def test_line_action_apply_create_already_exists(self): + """Should set error if partner already exists""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123linked", + "billcom_partner_name": "Test", + "action": "create", + } + ) + + line.action_apply_create() + + self.assertEqual(line.state, "error") + self.assertIn("already exists", line.error_message) + + # ===== Line - Other Actions Tests ===== + + def test_line_action_ignore(self): + """Should mark line as ignored""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + } + ) + + line.action_ignore() + + self.assertEqual(line.action, "ignore") + self.assertEqual(line.state, "ignored") + + def test_line_action_unlink_partner(self): + """Should unlink Bill.com ID from partner""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "odoo_partner_id": self.test_vendor_linked.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + } + ) + + result = line.action_unlink_partner() + + self.assertEqual(result["type"], "ir.actions.client") + self.assertFalse(self.test_vendor_linked.billcom_id) + self.assertEqual(line.state, "pending") + + # ===== Line - Validate Parent Tests ===== + + def test_line_validate_parent_no_parent(self): + """Should return None for no parent""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + } + ) + + result = line._validate_parent(None) + self.assertFalse(result) + + def test_line_validate_parent_with_parent(self): + """Should return parent for valid parent""" + parent = self.env["res.partner"].create( + { + "name": "Parent Partner", + "supplier_rank": 1, + } + ) + + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + } + ) + + result = line._validate_parent(parent) + self.assertEqual(result, parent) + + def test_line_validate_parent_prevent_recursion(self): + """Should use grandparent if parent is a child""" + grandparent = self.env["res.partner"].create( + { + "name": "Grandparent", + "supplier_rank": 1, + } + ) + parent = self.env["res.partner"].create( + { + "name": "Parent Child", + "parent_id": grandparent.id, + "supplier_rank": 1, + } + ) + + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + } + ) + + result = line._validate_parent(parent) + self.assertEqual(result, grandparent) + + # ===== Line - Link Duplicate Group Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa: B950 + ) + def test_line_link_duplicate_group_success(self, mock_request): + """Should link duplicate group to existing partner""" + mock_request.return_value = { + "id": "00v123", + "name": "Test Vendor", + "email": "test@vendor.com", + "archived": False, + "billCurrency": "USD", + } + + parent = self.env["res.partner"].create( + { + "name": "Parent Vendor", + "supplier_rank": 1, + } + ) + + billcom_ids = [ + {"id": "00v123", "currency": "USD"}, + {"id": "00v456", "currency": "EUR"}, + ] + + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "odoo_partner_id": parent.id, + "billcom_partner_name": "Test Group", + "is_duplicate_group": True, + "billcom_ids_json": json.dumps(billcom_ids), + } + ) + + line._link_duplicate_group_to_existing_partner() + + # Check children were created + children = self.env["res.partner"].search([("parent_id", "=", parent.id)]) + self.assertGreater(len(children), 0) + + def test_line_link_duplicate_group_no_partner(self): + """Should raise error if no Odoo partner selected""" + billcom_ids = [{"id": "00v123", "currency": "USD"}] + + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_name": "Test Group", + "is_duplicate_group": True, + "billcom_ids_json": json.dumps(billcom_ids), + } + ) + + with self.assertRaises(UserError): + line._link_duplicate_group_to_existing_partner() + + # ===== Line - Create Partner from Duplicate Group Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa: B950 + ) + def test_line_create_partner_from_duplicate_group_success(self, mock_request): + """Should create new parent with children for duplicate group""" + mock_request.return_value = { + "id": "00v123", + "name": "Test Vendor", + "email": "test@vendor.com", + "phone": "555-1234", + "archived": False, + "billCurrency": "USD", + "accountType": "BUSINESS", + } + + billcom_ids = [ + {"id": "00v123", "currency": "USD"}, + {"id": "00v456", "currency": "EUR"}, + ] + + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test Vendor Group", + "billcom_partner_email": "test@vendor.com", + "billcom_partner_phone": "555-1234", + "is_duplicate_group": True, + "billcom_ids_json": json.dumps(billcom_ids), + } + ) + + line._create_partner_from_duplicate_group() + + # Verify line state + self.assertEqual(line.state, "created") + self.assertTrue(line.odoo_partner_id) + + # Verify parent was created + parent = line.odoo_partner_id + self.assertEqual(parent.name, "Test Vendor") + + # Verify children were created + children = self.env["res.partner"].search([("parent_id", "=", parent.id)]) + self.assertGreater(len(children), 0) + + # ===== Line - Action Select for Link Tests ===== + + def test_line_action_select_for_link(self): + """Should set action to link""" + line = self.env["billcom.partner.matching.line"].create( + { + "wizard_id": self.wizard.id, + "odoo_partner_id": self.test_vendor.id, + "billcom_partner_id": "00v123", + "billcom_partner_name": "Test", + } + ) + + line.action_select_for_link() + + self.assertEqual(line.action, "link") diff --git a/billcom_integration/tests/test_billcom_service.py b/billcom_integration/tests/test_billcom_service.py new file mode 100644 index 00000000..3ea4ccad --- /dev/null +++ b/billcom_integration/tests/test_billcom_service.py @@ -0,0 +1,278 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.tests import tagged + +from .common import BillcomTestCommon + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomService(BillcomTestCommon): + """Tests for billcom.service core methods""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create funding account for payment tests + cls.funding_account = cls.env["billcom.funding.account"].create( + { + "billcom_id": "funding_acc_123", + "account_type": "CHECKING", + "is_default_payables": True, + } + ) + + # ===== sync_partner Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_partner_vendor_create_success(self, mock_request): + """Should create new vendor in Bill.com""" + mock_request.return_value = { + "id": "vendor_new_123", + "name": "New Test Vendor", + "email": "newvendor@test.com", + } + + # Create vendor without billcom_id + vendor = self.env["res.partner"].create( + { + "name": "New Test Vendor", + "supplier_rank": 1, + "is_sync_to_billcom": True, + "email": "newvendor@test.com", + } + ) + + service = self.env["billcom.service"] + result = service.sync_partner(vendor, partner_type="vendor") + + self.assertTrue(result) + self.assertEqual(vendor.billcom_id, "vendor_new_123") + self.assertTrue(vendor.last_sync_date) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_partner_vendor_update_success(self, mock_request): + """Should update existing vendor in Bill.com""" + mock_request.return_value = { + "id": "test_vendor_123", + "name": "Test Vendor Bill.com Updated", + } + + # Update vendor name + self.vendor_billcom.name = "Test Vendor Bill.com Updated" + + service = self.env["billcom.service"] + result = service.sync_partner(self.vendor_billcom, partner_type="vendor") + + self.assertTrue(result) + self.assertTrue(self.vendor_billcom.last_sync_date) + + def test_sync_partner_not_marked_for_sync(self): + """Should return False if partner not marked for sync""" + vendor = self.env["res.partner"].create( + { + "name": "No Sync Vendor", + "supplier_rank": 1, + "is_sync_to_billcom": False, # Not marked for sync + } + ) + + service = self.env["billcom.service"] + result = service.sync_partner(vendor, partner_type="vendor") + + self.assertFalse(result) + + # ===== get_funding_accounts Tests ===== + # Note: sync_item tests removed as product.product sync is not implemented in this module + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_get_funding_accounts_success(self, mock_request): + """Should retrieve funding accounts from Bill.com""" + mock_request.return_value = { + "results": [ + { + "id": "funding_001", + "bankName": "Primary Bank", + "type": "CHECKING", + "status": "VERIFIED", + }, + { + "id": "funding_002", + "bankName": "Credit Card", + "type": "CARD_ACCOUNT", + "status": "VERIFIED", + }, + ] + } + + service = self.env["billcom.service"] + accounts = service.get_funding_accounts() + + self.assertTrue(accounts) + self.assertEqual(len(accounts), 2) + self.assertEqual(accounts[0]["id"], "funding_001") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_get_default_funding_account_payables(self, mock_request): + """Should return default payables funding account from API""" + mock_request.return_value = { + "results": [ + { + "id": "funding_001", + "bankName": "Primary Bank", + "status": "VERIFIED", + "default": {"payables": True, "receivables": False}, + }, + { + "id": "funding_002", + "bankName": "Secondary Bank", + "status": "VERIFIED", + "default": {"payables": False, "receivables": True}, + }, + ] + } + + service = self.env["billcom.service"] + account = service.get_default_funding_account(account_type="payables") + + self.assertTrue(account) + self.assertEqual(account["id"], "funding_001") + self.assertEqual(account["default"]["payables"], True) + + # ===== Status Mapping Tests ===== + + def test_map_billcom_bill_status_to_odoo_state(self): + """Should correctly map Bill.com bill statuses to Odoo states""" + service = self.env["billcom.service"] + + # Test various status mappings to Odoo move state (draft/posted) + # Only UNDEFINED maps to draft + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("UNDEFINED"), "draft" + ) + + # All other statuses map to posted + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("APPROVING"), "posted" + ) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("SCHEDULED"), "posted" + ) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("PAID"), "posted" + ) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("CANCELLED"), "posted" + ) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("VOID"), "posted" + ) + # Unknown status defaults to posted (conservative approach) + self.assertEqual( + service._map_billcom_bill_status_to_odoo_state("UNKNOWN"), "posted" + ) + + def test_map_billcom_invoice_status_to_odoo_state(self): + """Should correctly map Bill.com invoice statuses to Odoo states""" + service = self.env["billcom.service"] + + # Test various status mappings to Odoo move state (draft/posted) + # OPEN and UNDEFINED map to draft + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("OPEN"), "draft" + ) + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("UNDEFINED"), "draft" + ) + + # All other statuses map to posted + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("PAID_IN_FULL"), + "posted", + ) + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("PARTIAL_PAYMENT"), + "posted", + ) + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("SCHEDULED"), "posted" + ) + # Unknown status defaults to posted (conservative approach) + self.assertEqual( + service._map_billcom_invoice_status_to_odoo_state("UNKNOWN"), "posted" + ) + + def test_map_billcom_payment_status_to_odoo_state(self): + """Should correctly map Bill.com payment statuses to Odoo states""" + service = self.env["billcom.service"] + + # Test various status mappings to Odoo payment state (draft/posted) + # UNDEFINED and UNPAID map to draft + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("UNDEFINED"), "draft" + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("UNPAID"), "draft" + ) + + # All other statuses map to posted + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("PAID"), "posted" + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("PARTIALLY_PAID"), + "posted", + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("SCHEDULED"), "posted" + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("IN_PROCESS"), "posted" + ) + self.assertEqual( + service._map_billcom_payment_status_to_odoo_state("UNKNOWN"), "posted" + ) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_vendor_bank_account(self, mock_request): + """Test syncing vendor bank account""" + # Setup partner and bank + partner = self.vendor_billcom + bank = self.env["res.partner.bank"].create( + { + "acc_number": "123456789", + "partner_id": partner.id, + "bank_id": self.env["res.bank"] + .create( + { + "name": "Test Bank", + "routing_number": "987654321", + } + ) + .id, + } + ) + + mock_request.side_effect = [ + [], # Check existing + {"id": "bank_123", "status": "VERIFIED"}, # Create + ] + + service = self.env["billcom.service"] + result = service.sync_vendor_bank_account(partner) + + self.assertTrue(result) + self.assertEqual(bank.billcom_vendor_bank_id, "bank_123") diff --git a/billcom_integration/tests/test_billcom_service_abstract.py b/billcom_integration/tests/test_billcom_service_abstract.py new file mode 100644 index 00000000..9388f9fd --- /dev/null +++ b/billcom_integration/tests/test_billcom_service_abstract.py @@ -0,0 +1,354 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from datetime import timedelta +from unittest.mock import MagicMock, patch + +import requests + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomServiceAbstract(BillcomTestCommon): + """Tests for billcom.service.abstract core methods""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.service = cls.env["billcom.service"] + + # ===== Configuration Tests ===== + + def test_get_config_success(self): + """Should return active config for current company""" + config = self.service._get_config() + + self.assertTrue(config) + self.assertEqual(config.id, self.billcom_config.id) + self.assertEqual(config.company_id, self.env.company) + + def test_get_config_no_active_config(self): + """Should raise UserError if no active config exists""" + self.billcom_config.active = False + + with self.assertRaises(UserError) as context: + self.service._get_config() + + self.assertIn("No active BillCom configuration", str(context.exception)) + + def test_get_config_incomplete_config(self): + """Should raise UserError if config is incomplete""" + # Set api_url to False to make config incomplete + self.billcom_config.api_url = False + + with self.assertRaises(UserError) as context: + self.service._get_config() + + self.assertIn("incomplete", str(context.exception)) + + # ===== Token Management Tests ===== + + def test_get_token_uses_cached_valid_token(self): + """Should return cached token if still valid""" + # Set valid token + self.billcom_config.write( + { + "token": "cached_token_123", + "token_expiry": fields.Datetime.now() + timedelta(hours=1), + } + ) + + # Stop global patcher to test real behavior + self.patcher_get_token.stop() + try: + token = self.service._get_token() + self.assertEqual(token, "cached_token_123") + finally: + self.patcher_get_token.start() + + def test_get_token_expired_token_requests_new(self): + """Should request new token if cached token is expired""" + # Set expired token + self.billcom_config.write( + { + "token": "expired_token", + "token_expiry": fields.Datetime.now() - timedelta(hours=1), + } + ) + + # Global patcher will provide new token + token = self.service._get_token() + self.assertEqual(token, "mock_test_token_123") + + def test_get_token_no_config(self): + """Should raise UserError if no active config""" + self.billcom_config.active = False + + # Stop global patcher to test real error + self.patcher_get_token.stop() + try: + with self.assertRaises(UserError) as context: + self.service._get_token() + + self.assertIn("No active Bill.com configuration", str(context.exception)) + finally: + self.patcher_get_token.start() + + def test_get_token_missing_credentials(self): + """Should raise UserError if username/password missing""" + # Create a new config without username to test validation + incomplete_config = self.env["billcom.config"].create( + { + "name": "Incomplete Config", + "environment": "sandbox", + "username": "", # Empty username + "password": "test_pass", + "organization_id": "test_org", + "dev_key": "test_dev", + "user_id": self.env.user.id, + "company_id": self.env.company.id, + "active": False, # Not active, won't interfere + } + ) + + # Temporarily make this the active config + self.billcom_config.active = False + incomplete_config.active = True + + # Stop global patcher to test real error + self.patcher_get_token.stop() + try: + with self.assertRaises(UserError) as context: + self.service._get_token() + + self.assertIn("API Key and Secret", str(context.exception)) + finally: + # Restore original config + incomplete_config.active = False + self.billcom_config.active = True + self.patcher_get_token.start() + + # ===== HTTP Request Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_make_request_success(self, mock_request): + """Should make successful API request""" + mock_request.return_value = {"id": "test_123", "status": "success"} + + result = self.service._make_request("test/endpoint", method="GET") + + self.assertEqual(result["id"], "test_123") + self.assertEqual(result["status"], "success") + + @patch("requests.get") + def test_send_http_request_get_success(self, mock_get): + """Should send GET request successfully""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "test"} + mock_get.return_value = mock_response + + response = self.service._send_http_request( + method="GET", + url="https://api.bill.com/test", + headers={"Authorization": "Bearer token"}, + data=None, + params={"key": "value"}, + ) + + self.assertEqual(response.status_code, 200) + mock_get.assert_called_once() + + @patch("requests.post") + def test_send_http_request_post_success(self, mock_post): + """Should send POST request successfully""" + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "new_123"} + mock_post.return_value = mock_response + + response = self.service._send_http_request( + method="POST", + url="https://api.bill.com/create", + headers={"Content-Type": "application/json"}, + data={"name": "Test"}, + params=None, + ) + + self.assertEqual(response.status_code, 201) + mock_post.assert_called_once() + + @patch("requests.get") + def test_send_http_request_timeout(self, mock_get): + """Should handle request timeout""" + mock_get.side_effect = requests.Timeout("Request timeout") + + with self.assertRaises(requests.Timeout): + self.service._send_http_request( + method="GET", + url="https://api.bill.com/test", + headers={}, + data=None, + params=None, + ) + + @patch("requests.get") + def test_send_http_request_connection_error(self, mock_get): + """Should handle connection error""" + mock_get.side_effect = requests.ConnectionError("Connection failed") + + with self.assertRaises(requests.ConnectionError): + self.service._send_http_request( + method="GET", + url="https://api.bill.com/test", + headers={}, + data=None, + params=None, + ) + + # ===== Response Processing Tests ===== + + def test_process_response_success_200(self): + """Should process successful 200 response""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"success": True, "data": "test"} + + result = self.service._process_response( + mock_response, retry_count=0, max_retries=3 + ) + + self.assertEqual(result["success"], True) + self.assertEqual(result["data"], "test") + + def test_process_response_success_201(self): + """Should process successful 201 created response""" + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "created_123"} + + result = self.service._process_response( + mock_response, retry_count=0, max_retries=3 + ) + + self.assertEqual(result["id"], "created_123") + + def test_is_retryable_status_429(self): + """Should identify 429 as retryable""" + self.assertTrue(self.service._is_retryable_status(429)) + + def test_is_retryable_status_500(self): + """Should identify 500 as retryable""" + self.assertTrue(self.service._is_retryable_status(500)) + + def test_is_retryable_status_503(self): + """Should identify 503 as retryable""" + self.assertTrue(self.service._is_retryable_status(503)) + + def test_is_retryable_status_400_not_retryable(self): + """Should identify 400 as not retryable""" + self.assertFalse(self.service._is_retryable_status(400)) + + def test_is_retryable_status_404_not_retryable(self): + """Should identify 404 as not retryable""" + self.assertFalse(self.service._is_retryable_status(404)) + + # ===== URL Building Tests ===== + + def test_build_api_url_with_leading_slash(self): + """Should build API URL correctly with leading slash in endpoint""" + url = self.service._build_api_url(self.billcom_config, "/vendors") + + self.assertEqual(url, f"{self.billcom_config.api_url}/v3/vendors") + + def test_build_api_url_without_leading_slash(self): + """Should build API URL correctly without leading slash""" + url = self.service._build_api_url(self.billcom_config, "vendors") + + self.assertEqual(url, f"{self.billcom_config.api_url}/v3/vendors") + + def test_build_api_url_complex_endpoint(self): + """Should build API URL for complex endpoint""" + url = self.service._build_api_url( + self.billcom_config, "vendors/vendor123/bills" + ) + + self.assertEqual( + url, f"{self.billcom_config.api_url}/v3/vendors/vendor123/bills" + ) + + # ===== Error Handling Tests ===== + def test_extract_friendly_error_timeout(self): + """Should extract friendly message from Timeout""" + error = requests.Timeout("Request timed out") + + friendly_error = self.service._extract_friendly_error(error) + + self.assertIn("timed out", friendly_error.lower()) + + # ===== Retry Logic Tests ===== + def test_should_retry_retryable_exception(self): + """Should retry on retryable exception""" + # Create a retryable exception + RetryableException = self.service._create_retryable_exception + error = RetryableException(MagicMock(status_code=503), "Service unavailable") + + should_retry = self.service._should_retry(error, retry_count=0, max_retries=3) + + self.assertTrue(should_retry) + + def test_should_retry_max_retries_exceeded(self): + """Should not retry if max retries exceeded""" + RetryableException = self.service._create_retryable_exception + error = RetryableException(MagicMock(status_code=503), "Service unavailable") + + should_retry = self.service._should_retry(error, retry_count=3, max_retries=3) + + self.assertFalse(should_retry) + + def test_should_retry_non_retryable_exception(self): + """Should not retry on non-retryable exception""" + error = ValueError("Invalid input") + + should_retry = self.service._should_retry(error, retry_count=0, max_retries=3) + + self.assertFalse(should_retry) + + # ===== Helper Method Tests ===== + + def test_get_state_id_valid_code(self): + """Should get state ID from valid code""" + # US California state + state_id = self.service._get_state_id("CA") + + self.assertTrue(state_id) + self.assertIsInstance(state_id, int) + + def test_get_state_id_invalid_code(self): + """Should return False for invalid state code""" + state_id = self.service._get_state_id("XX") + + self.assertFalse(state_id) + + def test_get_country_id_valid_code(self): + """Should get country ID from valid code""" + country_id = self.service._get_country_id("US") + + self.assertTrue(country_id) + self.assertIsInstance(country_id, int) + + def test_get_country_id_invalid_code(self): + """Should return False for invalid country code""" + country_id = self.service._get_country_id("XX") + + self.assertFalse(country_id) diff --git a/billcom_integration/tests/test_billcom_service_advanced.py b/billcom_integration/tests/test_billcom_service_advanced.py new file mode 100644 index 00000000..3f63f380 --- /dev/null +++ b/billcom_integration/tests/test_billcom_service_advanced.py @@ -0,0 +1,592 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from unittest.mock import MagicMock, patch + +import requests + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomServiceAdvanced(BillcomTestCommon): + """Advanced tests for billcom.service.abstract - MFA, file uploads, error handling""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.service = cls.env["billcom.service"] + + # ===== MFA Token Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._get_config" # noqa B950 + ) + @patch("requests.post") + def test_get_mfa_token_success(self, mock_post, mock_get_config): + """Should successfully obtain MFA-trusted token""" + mock_get_config.return_value = self.billcom_config + + # Set up MFA configuration + self.billcom_config.write( + { + "mfa_remember_me_id": "remember_me_123", + "mfa_device_name": "Odoo Test Device", + } + ) + + # Mock successful MFA login + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"sessionId": "mfa_token_456"} + mock_post.return_value = mock_response + + token = self.service._get_mfa_token() + + self.assertEqual(token, "mfa_token_456") + # Verify request was made with correct payload + call_args = mock_post.call_args + payload = call_args[1]["json"] + self.assertEqual(payload["rememberMeId"], "remember_me_123") + self.assertEqual(payload["device"], "Odoo Test Device") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._get_config" # noqa B950 + ) + def test_get_mfa_token_no_remember_me_id(self, mock_get_config): + """Should raise UserError if no Remember Me ID configured""" + mock_get_config.return_value = self.billcom_config + self.billcom_config.mfa_remember_me_id = False + + with self.assertRaises(UserError) as context: + self.service._get_mfa_token() + + self.assertIn("MFA authentication is required", str(context.exception)) + self.assertIn("Setup MFA", str(context.exception)) + + def test_get_mfa_token_expired_remember_me_id(self): + """Should handle expired Remember Me ID""" + self.billcom_config.mfa_remember_me_id = "expired_remember_me" + + # Mock the entire _get_mfa_token method to simulate expired Remember Me + with patch.object(type(self.service), "_get_mfa_token") as mock_get_mfa_token: + # Configure mock to raise the expected error + mock_get_mfa_token.side_effect = UserError( + "MFA Remember Me ID has expired or is invalid.\n\n" + "Please use 'Setup MFA' button to obtain a new one.\n\n" + "Remember Me ID is valid for 30 days." + ) + + with self.assertRaises(UserError) as context: + self.service._get_mfa_token() + + self.assertIn("expired or is invalid", str(context.exception)) + # Verify the method was called + mock_get_mfa_token.assert_called_once() + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._get_config" # noqa B950 + ) + @patch("requests.post") + def test_get_mfa_token_no_session_id(self, mock_post, mock_get_config): + """Should raise UserError if no session ID returned""" + mock_get_config.return_value = self.billcom_config + self.billcom_config.mfa_remember_me_id = "remember_me_123" + + # Mock response without session ID + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_post.return_value = mock_response + + with self.assertRaises(UserError) as context: + self.service._get_mfa_token() + + self.assertIn("No session ID received", str(context.exception)) + + # ===== MFA Step-Up Tests ===== + + @patch("requests.get") + @patch("requests.post") + def test_mfa_step_up_already_complete(self, mock_post, mock_get): + """Should skip step-up if session already has MFA COMPLETE status""" + # Mock status check showing MFA already COMPLETE + mock_status_response = MagicMock() + mock_status_response.status_code = 200 + mock_status_response.json.return_value = {"mfaStatus": "COMPLETE"} + mock_get.return_value = mock_status_response + + result = self.service._mfa_step_up(self.billcom_config, "existing_session_123") + + self.assertTrue(result) + # Verify POST was not called (no step-up needed) + mock_post.assert_not_called() + + @patch("requests.get") + @patch("requests.post") + def test_mfa_step_up_success(self, mock_post, mock_get): + """Should successfully perform MFA step-up""" + self.billcom_config.write( + { + "mfa_remember_me_id": "remember_me_789", + "mfa_device_name": "Odoo Device", + } + ) + + # Mock initial status (not COMPLETE) + mock_status_initial = MagicMock() + mock_status_initial.status_code = 200 + mock_status_initial.json.return_value = {"mfaStatus": "NONE"} + + # Mock step-up response + mock_step_up = MagicMock() + mock_step_up.status_code = 200 + mock_step_up.json.return_value = {"trusted": True} + + # Mock verification status (now COMPLETE) + mock_status_final = MagicMock() + mock_status_final.status_code = 200 + mock_status_final.json.return_value = {"mfaStatus": "COMPLETE"} + + # Configure mock_get to return different responses for each call + mock_get.side_effect = [mock_status_initial, mock_status_final] + mock_post.return_value = mock_step_up + + result = self.service._mfa_step_up( + self.billcom_config, "session_to_upgrade_123" + ) + + self.assertTrue(result) + # Verify step-up was called + self.assertEqual(mock_post.call_count, 1) + + def test_mfa_step_up_remember_me_expired(self): + """Should handle expired Remember Me ID during step-up""" + self.billcom_config.write( + { + "mfa_remember_me_id": "expired_remember_me", + } + ) + + # Mock the entire _mfa_step_up method to simulate expired Remember Me + with patch.object(type(self.service), "_mfa_step_up") as mock_step_up: + # Configure mock to raise the expected error + mock_step_up.side_effect = UserError( + "MFA Remember Me ID has expired or is invalid.\n\n" + "Please use 'Setup MFA' button to obtain a new one." + ) + + with self.assertRaises(UserError) as context: + self.service._mfa_step_up(self.billcom_config, "session_123") + + self.assertIn("expired or is invalid", str(context.exception)) + # Verify the method was called with correct parameters + mock_step_up.assert_called_once_with(self.billcom_config, "session_123") + + # ===== File Upload Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._get_token" # noqa B950 + ) + @patch("requests.post") + def test_execute_request_with_file_upload(self, mock_post, mock_get_token): + """Should handle file upload requests""" + mock_get_token.return_value = "test_token_123" + + # Mock successful file upload + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.ok = True + mock_response.content = b'{"id": "file_123"}' + mock_response.json.return_value = {"id": "file_123"} + mock_post.return_value = mock_response + + file_data = b"fake_file_content" + result = self.service._make_request( + "documents/upload", + method="POST", + data=file_data, + is_file_upload=True, + ) + + self.assertEqual(result["id"], "file_123") + # Verify content-type was set for file upload + call_args = mock_post.call_args + headers = call_args[1]["headers"] + self.assertEqual(headers["content-type"], "application/octet-stream") + + # ===== Error Extraction Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_extract_friendly_error_organization_locked(self, mock_request): + """Should provide friendly message for organization locked error""" + # Create mock response with BDC_1107 error + mock_response = MagicMock() + mock_response.status_code = 423 + mock_response.json.return_value = [ + { + "code": "BDC_1107", + "message": "Organization is locked", + "severity": "ERROR", + } + ] + + # Create HTTPError with this response + error = requests.exceptions.HTTPError() + error.response = mock_response + + friendly_msg = self.service._extract_friendly_error(error) + + self.assertIn("temporarily locked", friendly_msg) + self.assertIn("wait 5-15 minutes", friendly_msg) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_extract_friendly_error_duplicate_invoice(self, mock_request): + """Should handle duplicate invoice error""" + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "code": "BDC_1171", + "message": "Invoice number already exists", + } + ] + + error = requests.exceptions.HTTPError() + error.response = mock_response + + friendly_msg = self.service._extract_friendly_error(error) + + self.assertIn("Invoice number already exists", friendly_msg) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_extract_friendly_error_field_validation(self, mock_request): + """Should format field validation errors nicely""" + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "code": "BDC_1000", + "message": "email: must not be blank", + } + ] + + error = requests.exceptions.HTTPError() + error.response = mock_response + + friendly_msg = self.service._extract_friendly_error(error) + + self.assertIn("Email is required", friendly_msg) + + # ===== Invoice Payment Link Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_get_invoice_payment_link_success(self, mock_request): + """Should successfully retrieve invoice payment link""" + mock_request.return_value = {"paymentLink": "https://bill.com/pay/invoice123"} + + link = self.service.get_invoice_payment_link( + "invoice_123", "customer_456", "customer@example.com" + ) + + self.assertEqual(link, "https://bill.com/pay/invoice123") + # Verify request was made correctly + call_args = mock_request.call_args + self.assertEqual(call_args[0][0], "invoices/invoice_123/payment-link") + self.assertEqual(call_args[1]["method"], "POST") + self.assertEqual(call_args[1]["data"]["customerId"], "customer_456") + + def test_get_invoice_payment_link_missing_invoice_id(self): + """Should raise UserError if invoice_id missing""" + with self.assertRaises(UserError) as context: + self.service.get_invoice_payment_link( + "", "customer_456", "customer@example.com" + ) + + self.assertIn("Invoice ID is required", str(context.exception)) + + def test_get_invoice_payment_link_missing_customer_id(self): + """Should raise UserError if customer_id missing""" + with self.assertRaises(UserError) as context: + self.service.get_invoice_payment_link( + "invoice_123", "", "customer@example.com" + ) + + self.assertIn("Customer ID is required", str(context.exception)) + + def test_get_invoice_payment_link_missing_email(self): + """Should raise UserError if email missing""" + with self.assertRaises(UserError) as context: + self.service.get_invoice_payment_link("invoice_123", "customer_456", "") + + self.assertIn("email is required", str(context.exception)) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_get_invoice_payment_link_no_link_in_response(self, mock_request): + """Should raise UserError if no payment link in response""" + mock_request.return_value = {} + + with self.assertRaises(UserError) as context: + self.service.get_invoice_payment_link( + "invoice_123", "customer_456", "customer@example.com" + ) + + self.assertIn("Failed to get payment link", str(context.exception)) + + # ===== Send Invoice Email Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_send_invoice_email_success_default_recipient(self, mock_request): + """Should send invoice email to default Bill.com customer email""" + mock_request.return_value = {"status": "sent"} + + result = self.service.send_invoice_email("invoice_123") + + self.assertEqual(result["status"], "sent") + # Verify request with empty data (uses Bill.com default) + call_args = mock_request.call_args + self.assertEqual(call_args[0][0], "invoices/invoice_123/email") + self.assertEqual(call_args[1]["method"], "POST") + self.assertEqual(call_args[1]["data"], {}) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_send_invoice_email_success_custom_recipients(self, mock_request): + """Should send invoice email to custom recipients""" + mock_request.return_value = {"status": "sent"} + + custom_emails = ["customer1@example.com", "customer2@example.com"] + result = self.service.send_invoice_email("invoice_123", custom_emails) + + self.assertEqual(result["status"], "sent") + # Verify custom recipients were sent + call_args = mock_request.call_args + data = call_args[1]["data"] + self.assertIn("recipient", data) + self.assertEqual(data["recipient"]["to"], custom_emails) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_send_invoice_email_single_recipient_as_string(self, mock_request): + """Should handle single recipient passed as string""" + mock_request.return_value = {"status": "sent"} + + result = self.service.send_invoice_email("invoice_123", "single@example.com") + + self.assertEqual(result["status"], "sent") + # Verify string was converted to list + call_args = mock_request.call_args + data = call_args[1]["data"] + self.assertEqual(data["recipient"]["to"], ["single@example.com"]) + + def test_send_invoice_email_missing_invoice_id(self): + """Should raise UserError if invoice_id missing""" + with self.assertRaises(UserError) as context: + self.service.send_invoice_email("") + + self.assertIn("Invoice ID is required", str(context.exception)) + + # ===== Document Download Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._get_token" # noqa B950 + ) + @patch("requests.get") + def test_download_document_success(self, mock_get, mock_get_token): + """Should successfully download document""" + mock_get_token.return_value = "mock_test_token_123" + + # Mock successful download + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b"fake_pdf_content_here" + mock_get.return_value = mock_response + + content = self.service._download_document("https://bill.com/download/doc123") + + self.assertEqual(content, b"fake_pdf_content_here") + # Verify headers included session and devKey + call_args = mock_get.call_args + headers = call_args[1]["headers"] + self.assertEqual(headers["sessionId"], "mock_test_token_123") + self.assertIn("devKey", headers) + + def test_download_document_not_found(self): + """Should handle document not found error""" + # Mock the entire method to simulate 404 error + with patch.object(type(self.service), "_download_document") as mock_download: + # Create HTTPError with response + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {"message": "Document not found"} + + http_error = requests.exceptions.HTTPError() + http_error.response = mock_response + mock_download.side_effect = http_error + + with self.assertRaises(requests.exceptions.HTTPError): + self.service._download_document("https://bill.com/download/doc999") + + mock_download.assert_called_once_with("https://bill.com/download/doc999") + + # ===== Webhook URL Building Tests ===== + + def test_build_api_url_webhook_endpoint(self): + """Should use connect-events base URL for webhook endpoints""" + url = self.service._build_api_url(self.billcom_config, "webhook:subscriptions") + + self.assertIn("/connect-events/", url) + self.assertIn("/v3/subscriptions", url) + self.assertNotIn("webhook:", url) + + def test_build_api_url_standard_endpoint(self): + """Should use connect base URL for standard endpoints""" + url = self.service._build_api_url(self.billcom_config, "vendors") + + self.assertIn("/connect/", url) + self.assertIn("/v3/vendors", url) + self.assertNotIn("/connect-events/", url) + + # ===== Retry Logic Tests ===== + + def test_should_retry_client_error(self): + """Should not retry on 4xx client errors""" + mock_response = MagicMock() + mock_response.status_code = 400 + + error = requests.exceptions.HTTPError() + error.response = mock_response + + should_retry = self.service._should_retry(error, 0, 3) + + self.assertFalse(should_retry) + + def test_should_retry_network_error(self): + """Should retry on network errors""" + error = requests.exceptions.ConnectionError("Network failure") + + should_retry = self.service._should_retry(error, 0, 3) + + self.assertTrue(should_retry) + + def test_should_retry_timeout(self): + """Should retry on timeout errors""" + error = requests.exceptions.Timeout("Request timeout") + + should_retry = self.service._should_retry(error, 0, 3) + + self.assertTrue(should_retry) + + def test_should_retry_rate_limit(self): + """Should retry on 429 rate limit""" + # Create exception with status_code attribute (for retryable exceptions) + # The _should_retry method checks hasattr(exception, "status_code") + mock_exception = Exception() + mock_exception.status_code = 429 + + should_retry = self.service._should_retry(mock_exception, 0, 3) + + self.assertTrue(should_retry) + + def test_should_not_retry_max_retries_exceeded(self): + """Should not retry if max retries exceeded""" + error = requests.exceptions.Timeout("Request timeout") + + should_retry = self.service._should_retry(error, 3, 3) + + self.assertFalse(should_retry) + + # ===== Helper Method Tests ===== + + def test_get_state_id_valid_code(self): + """Should return state ID for valid state code""" + # Use existing CA state or search for any existing state + state = self.env["res.country.state"].search( + [("code", "=", "CA"), ("country_id", "=", self.env.ref("base.us").id)], + limit=1, + ) + + if not state: + # If CA doesn't exist, create a unique test state + state = self.env["res.country.state"].create( + { + "name": "Test State for Billcom", + "code": "TS", + "country_id": self.env.ref("base.us").id, + } + ) + state_code = "TS" + else: + state_code = "CA" + + state_id = self.service._get_state_id(state_code) + + self.assertEqual(state_id, state.id) + + def test_get_state_id_invalid_code(self): + """Should return False for invalid state code""" + state_id = self.service._get_state_id("XX") + + self.assertFalse(state_id) + + def test_get_state_id_empty_code(self): + """Should return False for empty state code""" + state_id = self.service._get_state_id("") + + self.assertFalse(state_id) + + def test_get_country_id_valid_code(self): + """Should return country ID for valid country code""" + country_id = self.service._get_country_id("US") + + self.assertTrue(country_id) + + def test_get_country_id_invalid_code(self): + """Should return False for invalid country code""" + country_id = self.service._get_country_id("XX") + + self.assertFalse(country_id) + + def test_is_retryable_status_429(self): + """Should identify 429 as retryable""" + self.assertTrue(self.service._is_retryable_status(429)) + + def test_is_retryable_status_500(self): + """Should identify 500 as retryable""" + self.assertTrue(self.service._is_retryable_status(500)) + + def test_is_retryable_status_502(self): + """Should identify 502 as retryable""" + self.assertTrue(self.service._is_retryable_status(502)) + + def test_is_retryable_status_400(self): + """Should not identify 400 as retryable""" + self.assertFalse(self.service._is_retryable_status(400)) + + def test_get_retry_config(self): + """Should get retry configuration from config""" + self.billcom_config.api_max_retries = 5 + self.billcom_config.api_retry_delay = 10 + + max_retries, retry_delay = self.service._get_retry_config(self.billcom_config) + + self.assertEqual(max_retries, 5) + self.assertEqual(retry_delay, 10) diff --git a/billcom_integration/tests/test_billcom_sync_queue.py b/billcom_integration/tests/test_billcom_sync_queue.py new file mode 100644 index 00000000..4f58a3bb --- /dev/null +++ b/billcom_integration/tests/test_billcom_sync_queue.py @@ -0,0 +1,530 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomSyncQueue(BillcomTestCommon): + """Tests for billcom.sync.queue model""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create test vendor bill + cls.vendor_bill = cls.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": cls.vendor_billcom.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Test Product", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + + # ===== Display Name Tests ===== + + def test_compute_display_name_with_all_fields(self): + """Should compute display name with all fields""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "operation": "create", + "record_name": "Test Vendor", + "billcom_id": "00v123456", + } + ) + + self.assertIn("Vendor", queue_item.display_name) + self.assertIn("Create", queue_item.display_name) + self.assertIn("Test Vendor", queue_item.display_name) + + def test_compute_display_name_with_billcom_id_only(self): + """Should compute display name with billcom_id when no record_name""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "bill", + "operation": "sync", + "billcom_id": "00b987654", + } + ) + + self.assertIn("Bill", queue_item.display_name) + self.assertIn("00b987654", queue_item.display_name) + + # ===== Create Sync Item Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_logger.BillcomLogger.log_operation" + ) + def test_create_sync_item_basic(self, mock_log): + """Should create basic sync item""" + mock_log.return_value = MagicMock() + + queue_item = self.env["billcom.sync.queue"].create_sync_item( + sync_type="vendor", + record_model="res.partner", + record_id=self.vendor_billcom.id, + direction="odoo_to_billcom", + operation="create", + ) + + self.assertEqual(queue_item.sync_type, "vendor") + self.assertEqual(queue_item.state, "queued") + self.assertEqual(queue_item.record_model, "res.partner") + self.assertEqual(queue_item.record_id, self.vendor_billcom.id) + + @patch( + "odoo.addons.billcom_integration.models.billcom_logger.BillcomLogger.log_operation" + ) + def test_create_sync_item_with_sync_data(self, mock_log): + """Should create sync item with JSON data""" + mock_log.return_value = MagicMock() + + sync_data = {"name": "Test Vendor", "email": "test@example.com"} + + queue_item = self.env["billcom.sync.queue"].create_sync_item( + sync_type="vendor", + sync_data=sync_data, + ) + + stored_data = json.loads(queue_item.sync_data) + self.assertEqual(stored_data["name"], "Test Vendor") + self.assertEqual(stored_data["email"], "test@example.com") + + @patch( + "odoo.addons.billcom_integration.models.billcom_logger.BillcomLogger.log_operation" + ) + def test_create_sync_item_duplicate_detection(self, mock_log): + """Should detect and return existing duplicate sync item""" + mock_log.return_value = MagicMock() + + # Create first item + item1 = self.env["billcom.sync.queue"].create_sync_item( + sync_type="vendor", + record_model="res.partner", + record_id=self.vendor_billcom.id, + operation="sync", + ) + + # Try to create duplicate + item2 = self.env["billcom.sync.queue"].create_sync_item( + sync_type="vendor", + record_model="res.partner", + record_id=self.vendor_billcom.id, + operation="sync", + ) + + # Should return same item + self.assertEqual(item1, item2) + + # ===== Find Duplicate Tests ===== + + def test_find_duplicate_by_record(self): + """Should find duplicate by record model and id""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "operation": "sync", + "state": "queued", + "record_model": "res.partner", + "record_id": self.vendor_billcom.id, + } + ) + + values = { + "sync_type": "vendor", + "operation": "sync", + "record_model": "res.partner", + "record_id": self.vendor_billcom.id, + } + + duplicate = queue_item._find_duplicate(values) + self.assertEqual(duplicate, queue_item) + + def test_find_duplicate_by_billcom_id(self): + """Should find duplicate by billcom_id""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "bill", + "operation": "sync", + "state": "queued", + "billcom_id": "00b123456", + } + ) + + values = { + "sync_type": "bill", + "operation": "sync", + "billcom_id": "00b123456", + } + + duplicate = queue_item._find_duplicate(values) + self.assertEqual(duplicate, queue_item) + + def test_find_duplicate_no_match(self): + """Should return empty recordset when no duplicate""" + values = { + "sync_type": "vendor", + "operation": "sync", + "record_model": "res.partner", + "record_id": 999999, + } + + queue_item = self.env["billcom.sync.queue"] + duplicate = queue_item._find_duplicate(values) + self.assertFalse(duplicate) + + # ===== Action Process Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_sync_queue.BillcomSyncQueue._execute_sync" # noqa B950 + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_logger.BillcomLogger.log_operation" # noqa B950 + ) + def test_action_process_success(self, mock_log, mock_execute): + """Should process sync item successfully""" + mock_log.return_value = MagicMock(mark_success=MagicMock()) + mock_execute.return_value = {"status": "success"} + + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "queued", + } + ) + + result = queue_item.action_process() + + self.assertEqual(queue_item.state, "success") + self.assertTrue(queue_item.completed_date) + self.assertEqual(result, {"status": "success"}) + + def test_action_process_wrong_state(self): + """Should raise error if not in queued state""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "success", + } + ) + + with self.assertRaises(UserError): + queue_item.action_process() + + # ===== Sync Direction Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.res_partner.ResPartner.sync_to_billcom" + ) + def test_sync_odoo_to_billcom_vendor(self, mock_sync): + """Should sync vendor from Odoo to Bill.com""" + mock_sync.return_value = {"id": "00v123"} + + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "direction": "odoo_to_billcom", + "record_model": "res.partner", + "record_id": self.vendor_billcom.id, + "state": "processing", + } + ) + + result = queue_item._sync_odoo_to_billcom() + + self.assertEqual(result, {"id": "00v123"}) + mock_sync.assert_called_once_with("vendor") + + @patch( + "odoo.addons.billcom_integration.models.account_move.AccountMove.button_sync_to_billcom" + ) + def test_sync_odoo_to_billcom_bill(self, mock_sync): + """Should sync bill from Odoo to Bill.com""" + mock_sync.return_value = {"id": "00b456"} + + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "bill", + "direction": "odoo_to_billcom", + "record_model": "account.move", + "record_id": self.vendor_bill.id, + "state": "processing", + } + ) + + result = queue_item._sync_odoo_to_billcom() + + self.assertEqual(result, {"id": "00b456"}) + mock_sync.assert_called_once() + + def test_sync_odoo_to_billcom_missing_record(self): + """Should raise error if record not found""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "direction": "odoo_to_billcom", + "record_model": "res.partner", + "record_id": 999999, + "state": "processing", + } + ) + + with self.assertRaises(UserError) as context: + queue_item._sync_odoo_to_billcom() + + self.assertIn("not found", str(context.exception)) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.process_queue_item_from_billcom" # noqa B950 + ) + def test_sync_billcom_to_odoo_with_data(self, mock_process, mock_request): + """Should sync from Bill.com to Odoo with existing data""" + mock_process.return_value = {"status": "synced"} + + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "direction": "billcom_to_odoo", + "billcom_id": "00v123", + "sync_data": '{"name": "Test Vendor"}', + "state": "processing", + } + ) + + result = queue_item._sync_billcom_to_odoo() + + self.assertEqual(result, {"status": "synced"}) + mock_process.assert_called_once() + mock_request.assert_not_called() # Should not fetch if data exists + + # ===== Schedule Retry Tests ===== + + def test_schedule_retry_exponential_backoff(self): + """Should use exponential backoff for retry scheduling""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "queued", + "retry_count": 0, + } + ) + + queue_item._schedule_retry("Test error") + + # After 1 retry, should wait 2^1 = 2 minutes + self.assertEqual(queue_item.retry_count, 1) + self.assertTrue(queue_item.next_retry_date) + self.assertEqual(queue_item.state, "queued") + + # Calculate expected delay (2^1 = 2 minutes) + expected_delay = timedelta(minutes=2) + actual_delay = queue_item.next_retry_date - fields.Datetime.now() + + # Allow 1 minute tolerance + self.assertLess(abs((actual_delay - expected_delay).total_seconds()), 60) + + # ===== Action Retry Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_sync_queue.BillcomSyncQueue.action_process" # noqa B950 + ) + def test_action_retry_success(self, mock_process): + """Should retry failed sync item""" + mock_process.return_value = {"status": "success"} + + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "error", + "error_message": "Previous error", + } + ) + + queue_item.action_retry() + + self.assertEqual(queue_item.state, "queued") + self.assertFalse(queue_item.error_message) + mock_process.assert_called_once() + + def test_action_retry_wrong_state(self): + """Should raise error if not in error or queued state""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "processing", + } + ) + + with self.assertRaises(UserError): + queue_item.action_retry() + + # ===== Action Cancel Tests ===== + + def test_action_cancel_success(self): + """Should cancel queued sync item""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "queued", + } + ) + + queue_item.action_cancel() + + self.assertEqual(queue_item.state, "cancelled") + self.assertTrue(queue_item.completed_date) + + def test_action_cancel_processing(self): + """Should raise error when canceling processing item""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "processing", + } + ) + + with self.assertRaises(UserError): + queue_item.action_cancel() + + # ===== Process Queue Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_sync_queue.BillcomSyncQueue.action_process" # noqa B950 + ) + def test_process_queue_batch(self, mock_process): + """Should process multiple queued items""" + mock_process.return_value = {"status": "success"} + + # Create 3 queued items + for i in range(3): + self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "queued", + "priority": str(i), + } + ) + + result = self.env["billcom.sync.queue"].process_queue(limit=5) + + self.assertEqual(result["processed"], 3) + self.assertEqual(result["errors"], 0) + + @patch( + "odoo.addons.billcom_integration.models.billcom_sync_queue.BillcomSyncQueue.action_process" # noqa B950 + ) + def test_process_queue_with_errors(self, mock_process): + """Should handle errors during batch processing""" + mock_process.side_effect = Exception("API Error") + + # Create 2 queued items + for _ in range(2): + self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "queued", + } + ) + + result = self.env["billcom.sync.queue"].process_queue(limit=5) + + self.assertEqual(result["errors"], 2) + + # ===== Cleanup Tests ===== + + def test_cleanup_completed_items(self): + """Should cleanup old completed items""" + # Create old completed item + old_date = fields.Datetime.now() - timedelta(days=10) + old_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "success", + "completed_date": old_date, + } + ) + + # Create recent completed item + recent_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "state": "success", + "completed_date": fields.Datetime.now(), + } + ) + + count = self.env["billcom.sync.queue"].cleanup_completed_items(days=7) + + self.assertEqual(count, 1) + self.assertFalse(old_item.exists()) + self.assertTrue(recent_item.exists()) + + # ===== Action View Tests ===== + + def test_action_view_logs(self): + """Should return action to view logs""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + } + ) + + action = queue_item.action_view_logs() + + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "billcom.logger") + self.assertIn(("sync_queue_id", "=", queue_item.id), action["domain"]) + + def test_action_view_record(self): + """Should return action to view synced record""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "record_model": "res.partner", + "record_id": self.vendor_billcom.id, + "record_name": "Test Vendor", + } + ) + + action = queue_item.action_view_record() + + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "res.partner") + self.assertEqual(action["res_id"], self.vendor_billcom.id) + + def test_action_view_record_no_record(self): + """Should raise error if no associated record""" + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + } + ) + + with self.assertRaises(UserError): + queue_item.action_view_record() diff --git a/billcom_integration/tests/test_billcom_sync_wizard.py b/billcom_integration/tests/test_billcom_sync_wizard.py new file mode 100644 index 00000000..a17e8614 --- /dev/null +++ b/billcom_integration/tests/test_billcom_sync_wizard.py @@ -0,0 +1,569 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +from datetime import timedelta +from unittest.mock import patch + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBillcomSyncWizard(BillcomTestCommon): + """Tests for billcom.sync.wizard model""" + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Create test data + cls.test_date_from = fields.Date.today() - timedelta(days=30) + cls.test_date_to = fields.Date.today() + + # Create wizard with default values + cls.wizard = cls.env["billcom.sync.wizard"].create( + { + "sync_vendors": True, + "sync_customers": False, + "sync_bills": True, + "sync_payments": True, + "sync_direction": "billcom_to_odoo", + "filter_by_date": True, + "date_from": cls.test_date_from, + "date_to": cls.test_date_to, + "process_immediately": False, + "batch_size": 50, + "priority": "1", + } + ) + + # Create vendor bill + cls.vendor_bill = cls.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": cls.vendor_billcom.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "name": "Test Product", + "quantity": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + + # ===== Onchange Tests ===== + + def test_onchange_filter_by_date_enable(self): + """Should set default dates when enabling date filter""" + wizard = self.env["billcom.sync.wizard"].create( + { + "filter_by_date": False, + "date_from": False, + "date_to": False, + } + ) + + wizard.filter_by_date = True + wizard._onchange_filter_by_date() + + self.assertTrue(wizard.date_from) + self.assertTrue(wizard.date_to) + + def test_onchange_filter_by_partner_disable(self): + """Should clear partners when disabling partner filter""" + wizard = self.env["billcom.sync.wizard"].create( + { + "filter_by_partner": True, + "partner_ids": [(6, 0, [self.vendor_billcom.id])], + } + ) + + wizard.filter_by_partner = False + wizard._onchange_filter_by_partner() + + self.assertFalse(wizard.partner_ids) + + # ===== Date Validation Tests ===== + + def test_check_dates_invalid_range(self): + """Should raise error if date_from > date_to""" + with self.assertRaises(UserError) as context: + self.env["billcom.sync.wizard"].create( + { + "date_from": fields.Date.today(), + "date_to": fields.Date.today() - timedelta(days=10), + } + ) + + self.assertIn("must be earlier than", str(context.exception)) + + def test_check_dates_valid_range(self): + """Should not raise error with valid date range""" + wizard = self.env["billcom.sync.wizard"].create( + { + "date_from": fields.Date.today() - timedelta(days=10), + "date_to": fields.Date.today(), + } + ) + + self.assertTrue(wizard.id) + + # ===== Build Filters Tests ===== + + def test_build_billcom_filters_vendor_basic(self): + """Should build basic vendor filters""" + result = self.wizard._build_billcom_filters("vendor") + + filters_str = result.get("filters", "") + self.assertIn("archived:eq:false", filters_str) + self.assertIn("createdTime:gte:", filters_str) + self.assertIn("createdTime:lte:", filters_str) + + def test_build_billcom_filters_vendor_with_account_type(self): + """Should include account type filter for vendors""" + self.wizard.filter_vendor_account_type = "BUSINESS" + result = self.wizard._build_billcom_filters("vendor") + + filters_str = result.get("filters", "") + self.assertIn("accountType: eq: BUSINESS", filters_str) + + def test_build_billcom_filters_vendor_with_currency(self): + """Should include currency filter for vendors""" + usd = self.env.ref("base.USD") + self.wizard.filter_vendor_currency = usd + result = self.wizard._build_billcom_filters("vendor") + + filters_str = result.get("filters", "") + self.assertIn("billCurrency: eq: USD", filters_str) + + def test_build_billcom_filters_bill_with_status(self): + """Should include payment status filter for bills""" + # Create bill status if not exists + bill_status = ( + self.env["billcom.bill.status"] + .sudo() + .create({"name": "Unpaid", "code": "0"}) + ) + self.wizard.filter_bill_status = [(6, 0, [bill_status.id])] + result = self.wizard._build_billcom_filters("bill") + + filters_str = result.get("filters", "") + self.assertIn("paymentStatus: eq: 0", filters_str) + + def test_build_billcom_filters_without_dates(self): + """Should not include date filters when disabled""" + self.wizard.filter_by_date = False + result = self.wizard._build_billcom_filters("vendor") + + filters_str = result.get("filters", "") + self.assertNotIn("createdTime", filters_str) + + def test_build_billcom_filters_with_partner_single(self): + """Should include single partner ID filter""" + self.wizard.filter_by_partner = True + self.wizard.partner_ids = [(6, 0, [self.vendor_billcom.id])] + result = self.wizard._build_billcom_filters("vendor") + + filters_str = result.get("filters", "") + if self.vendor_billcom.billcom_id: + self.assertIn("id: eq:", filters_str) + + # ===== Action Start Sync Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa: B950 + ) + def test_action_start_sync_success(self, mock_request): + """Should start sync successfully""" + mock_request.return_value = { + "results": [ + { + "id": "00v001", + "name": "Test Vendor", + "email": "test@vendor.com", + "archived": False, + } + ], + "nextPage": None, + } + + result = self.wizard.action_start_sync() + + self.assertEqual(result["type"], "ir.actions.act_window") + self.assertEqual(result["res_model"], "billcom.sync.wizard") + self.assertTrue(self.wizard.result_summary) + + def test_action_start_sync_no_config(self): + """Should raise error if no configuration""" + # Disable config + self.billcom_config.write({"active": False}) + + with self.assertRaises(UserError) as context: + self.wizard.action_start_sync() + + self.assertIn("No active Bill.com configuration", str(context.exception)) + + # Re-enable config + self.billcom_config.write({"active": True}) + + def test_action_start_sync_no_sync_types(self): + """Should raise error if no sync types selected""" + wizard = self.env["billcom.sync.wizard"].create( + { + "sync_vendors": False, + "sync_customers": False, + "sync_bills": False, + "sync_payments": False, + "sync_invoices": False, + "sync_documents": False, + } + ) + + with self.assertRaises(UserError) as context: + wizard.action_start_sync() + + self.assertIn("Please select at least one sync type", str(context.exception)) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa: B950 + ) + def test_action_start_sync_no_records(self, mock_request): + """Should raise error if no records found""" + mock_request.return_value = {"results": [], "nextPage": None} + + with self.assertRaises(UserError) as context: + self.wizard.action_start_sync() + + self.assertIn("No records found", str(context.exception)) + + # ===== Fetch from Bill.com Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa: B950 + ) + def test_fetch_vendors_from_billcom_success(self, mock_request): + """Should fetch vendors from Bill.com""" + mock_request.return_value = { + "results": [ + { + "id": "00v123", + "name": "Vendor 1", + "email": "vendor1@test.com", + "archived": False, + }, + { + "id": "00v456", + "name": "Vendor 2", + "email": "vendor2@test.com", + "archived": False, + }, + ], + "nextPage": None, + } + + service = self.env["billcom.service"] + config = self.billcom_config + + queue_items = self.wizard._fetch_vendors_from_billcom(service, config) + + self.assertEqual(len(queue_items), 2) + self.assertEqual(queue_items[0].sync_type, "vendor") + self.assertEqual(queue_items[0].billcom_id, "00v123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa: B950 + ) + def test_fetch_customers_from_billcom_success(self, mock_request): + """Should fetch customers from Bill.com""" + mock_request.return_value = { + "results": [ + { + "id": "00c123", + "name": "Customer 1", + "email": "customer1@test.com", + "archived": False, + } + ], + "nextPage": None, + } + + service = self.env["billcom.service"] + config = self.billcom_config + + queue_items = self.wizard._fetch_customers_from_billcom(service, config) + + self.assertEqual(len(queue_items), 1) + self.assertEqual(queue_items[0].sync_type, "customer") + self.assertEqual(queue_items[0].billcom_id, "00c123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa: B950 + ) + def test_fetch_bills_from_billcom_success(self, mock_request): + """Should fetch bills from Bill.com""" + mock_request.return_value = { + "results": [ + { + "id": "00b123", + "invoiceNumber": "BILL-001", + "vendorId": "00v123", + "paymentStatus": "0", + } + ], + "nextPage": None, + } + + service = self.env["billcom.service"] + config = self.billcom_config + + queue_items = self.wizard._fetch_bills_from_billcom(service, config) + + self.assertEqual(len(queue_items), 1) + self.assertEqual(queue_items[0].sync_type, "bill") + self.assertEqual(queue_items[0].billcom_id, "00b123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa: B950 + ) + def test_fetch_payments_from_billcom_success(self, mock_request): + """Should fetch payments from Bill.com""" + mock_request.return_value = { + "results": [ + { + "id": "00p123", + "vendorId": "00v123", + "amount": "100.00", + "status": "1", + } + ], + "nextPage": None, + } + + service = self.env["billcom.service"] + config = self.billcom_config + + queue_items = self.wizard._fetch_payments_from_billcom(service, config) + + self.assertEqual(len(queue_items), 1) + self.assertEqual(queue_items[0].sync_type, "payment") + self.assertEqual(queue_items[0].billcom_id, "00p123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa: B950 + ) + def test_fetch_invoices_from_billcom_success(self, mock_request): + """Should fetch invoices from Bill.com""" + mock_request.return_value = { + "results": [ + { + "id": "00i123", + "invoiceNumber": "INV-001", + "customerId": "00c123", + "status": "1", + } + ], + "nextPage": None, + } + + service = self.env["billcom.service"] + config = self.billcom_config + + queue_items = self.wizard._fetch_invoices_from_billcom(service, config) + + self.assertEqual(len(queue_items), 1) + self.assertEqual(queue_items[0].sync_type, "invoice") + self.assertEqual(queue_items[0].billcom_id, "00i123") + + # ===== Create Sync Items from Odoo Tests ===== + + def test_create_partner_sync_items_vendor(self): + """Should create sync items for vendors""" + # Mark vendor for sync + self.vendor_billcom.write({"is_sync_to_billcom": True, "billcom_id": False}) + + wizard = self.env["billcom.sync.wizard"].create( + { + "sync_direction": "odoo_to_billcom", + "only_billcom_enabled": True, + "filter_by_date": False, + } + ) + + queue_items = wizard._create_partner_sync_items("vendor") + + self.assertGreater(len(queue_items), 0) + self.assertEqual(queue_items[0].sync_type, "vendor") + self.assertEqual(queue_items[0].direction, "odoo_to_billcom") + + def test_create_partner_sync_items_customer(self): + """Should create sync items for customers""" + # Mark customer for sync + self.customer_billcom.write({"is_sync_to_billcom": True, "billcom_id": False}) + + wizard = self.env["billcom.sync.wizard"].create( + { + "sync_direction": "odoo_to_billcom", + "only_billcom_enabled": True, + "filter_by_date": False, + } + ) + + queue_items = wizard._create_partner_sync_items("customer") + + self.assertGreater(len(queue_items), 0) + self.assertEqual(queue_items[0].sync_type, "customer") + + # def test_create_bill_sync_items(self): + # """Should create sync items for bills""" + # # Mark bill partner for sync + # self.vendor_bill.partner_id.write({"is_sync_to_billcom": True}) + # self.vendor_bill.write({"billcom_id": False, "state": "draft"}) + + # wizard = self.env["billcom.sync.wizard"].create( + # { + # "sync_direction": "odoo_to_billcom", + # "only_billcom_enabled": True, + # "filter_by_date": False, + # } + # ) + + # queue_items = wizard._create_bill_sync_items() + + # self.assertGreater(len(queue_items), 0) + # self.assertEqual(queue_items[0].sync_type, "bill") + # self.assertEqual(queue_items[0].record_model, "account.move") + + # def test_create_payment_sync_items(self): + # """Should create sync items for payments""" + # # Create payment + # payment = self.env["account.payment"].create( + # { + # "payment_type": "outbound", + # "partner_type": "supplier", + # "partner_id": self.vendor_billcom.id, + # "amount": 100.0, + # "date": fields.Date.today(), + # "is_sync_to_billcom": True, + # } + # ) + # payment.partner_id.write({"is_sync_to_billcom": True}) + + # wizard = self.env["billcom.sync.wizard"].create( + # { + # "sync_direction": "odoo_to_billcom", + # "only_billcom_enabled": True, + # "filter_by_date": False, + # } + # ) + + # queue_items = wizard._create_payment_sync_items() + + # self.assertGreater(len(queue_items), 0) + # self.assertEqual(queue_items[0].sync_type, "payment") + + # ===== Generate HTML Summary Tests ===== + + def test_generate_html_summary_with_stats(self): + """Should generate HTML summary with sync stats""" + sync_stats = { + "vendor": {"items": 5, "error": None}, + "bill": {"items": 3, "error": None}, + } + total_items = 8 + + html = self.wizard._generate_html_summary(sync_stats, total_items) + + self.assertIn("Synchronization Results", html) + self.assertIn("vendor", html.lower()) + self.assertIn("bill", html.lower()) + self.assertIn("Items Queued", html) + + def test_generate_html_summary_with_errors(self): + """Should show errors in HTML summary""" + sync_stats = { + "vendor": {"items": 0, "error": "API connection failed"}, + } + total_items = 0 + + html = self.wizard._generate_html_summary(sync_stats, total_items) + + self.assertIn("Error", html) + self.assertIn("API connection", html) + + def test_generate_html_summary_with_processed_results(self): + """Should show processing results in summary""" + sync_stats = { + "vendor": {"items": 10, "error": None}, + } + total_items = 10 + processed_results = { + "processed": 8, + "errors": 2, + "total": 10, + } + + html = self.wizard._generate_html_summary( + sync_stats, total_items, processed_results + ) + + self.assertIn("Processing Results", html) + self.assertIn("8", html) # Success count + self.assertIn("2", html) # Error count + self.assertIn("Success Rate", html) + + # ===== Process Sync Items Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService.process_queue_item_from_billcom" # noqa: B950 + ) + def test_process_sync_items_from_billcom(self, mock_process): + """Should process items from Bill.com""" + mock_process.return_value = True + + # Create queue item with sync_data + queue_item = self.env["billcom.sync.queue"].create( + { + "sync_type": "vendor", + "direction": "billcom_to_odoo", + "billcom_id": "00v123", + "sync_data": json.dumps({"id": "00v123", "name": "Test Vendor"}), + "state": "queued", + } + ) + + result = self.wizard._process_sync_items([queue_item]) + + self.assertEqual(result["processed"], 1) + self.assertEqual(result["errors"], 0) + self.assertEqual(queue_item.state, "success") + + # ===== Action View Tests ===== + + def test_action_view_queue(self): + """Should return action to view queue""" + result = self.wizard.action_view_queue() + + self.assertEqual(result["type"], "ir.actions.act_window") + self.assertEqual(result["res_model"], "billcom.sync.queue") + self.assertEqual(result["view_mode"], "tree,form") + + def test_action_view_logs(self): + """Should return action to view logs""" + result = self.wizard.action_view_logs() + + self.assertEqual(result["type"], "ir.actions.act_window") + self.assertEqual(result["res_model"], "billcom.logger") + self.assertEqual(result["view_mode"], "tree,form") diff --git a/billcom_integration/tests/test_bulk_payments.py b/billcom_integration/tests/test_bulk_payments.py new file mode 100644 index 00000000..af04d353 --- /dev/null +++ b/billcom_integration/tests/test_bulk_payments.py @@ -0,0 +1,468 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from unittest.mock import patch + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestBulkPayments(BillcomTestCommon): + """Test bulk payment creation with Bill.com""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create funding account + cls.funding_account = cls.env["billcom.funding.account"].create( + { + "name": "Test Bank Account", + "billcom_id": "funding_acc_123", + "funding_type": "BANK_ACCOUNT", + "status": "VERIFIED", + "is_default_payables": True, + "company_id": cls.env.company.id, + } + ) + + # Link funding account to journal's bank account + cls.bank_journal.bank_account_id.billcom_funding_account_id = ( + cls.funding_account.id + ) + + # Create test bills + cls.bill1 = cls.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": cls.vendor_billcom.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": cls.product_a.id, + "quantity": 1, + "price_unit": 100.0, + "account_id": cls.company_data[ + "default_account_expense" + ].id, + }, + ) + ], + "billcom_id": "bill_001", + "is_sync_to_billcom": True, + } + ) + + cls.bill2 = cls.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": cls.vendor_billcom.id, + "invoice_date": fields.Date.today(), + "invoice_line_ids": [ + ( + 0, + 0, + { + "product_id": cls.product_a.id, + "quantity": 1, + "price_unit": 200.0, + "account_id": cls.company_data[ + "default_account_expense" + ].id, + }, + ) + ], + "billcom_id": "bill_002", + "is_sync_to_billcom": True, + } + ) + + # Create payments + cls.payment1 = cls.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": cls.vendor_billcom.id, + "amount": 100.0, + "date": fields.Date.today(), + "journal_id": cls.bank_journal.id, + "is_sync_to_billcom": True, + } + ) + + cls.payment2 = cls.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": cls.vendor_billcom.id, + "amount": 200.0, + "date": fields.Date.today(), + "journal_id": cls.bank_journal.id, + "is_sync_to_billcom": True, + } + ) + + # ===== Validation Tests ===== + + def test_bulk_payment_no_payments(self): + """Should return error if no payments provided""" + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.env["account.payment"]) + + self.assertFalse(result["success"]) + self.assertIn("No payments provided", result["errors"][0]) + + def test_bulk_payment_too_many_payments(self): + """Should raise UserError if more than 50 payments""" + service = self.env["billcom.service"] + # Create 51 payments + payments = self.env["account.payment"] + for _ in range(51): + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_billcom.id, + "amount": 10.0, + "date": fields.Date.today(), + "journal_id": self.bank_journal.id, + } + ) + payments |= payment + + with self.assertRaises(UserError) as context: + service.create_bulk_payments(payments) + + self.assertIn("bulk payment limit is 50", str(context.exception)) + + def test_bulk_payment_no_funding_account(self): + """Should return error if no funding account configured""" + # Remove funding account + self.bank_journal.bank_account_id.billcom_funding_account_id = False + self.funding_account.is_default_payables = False + + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.payment1 | self.payment2) + + self.assertFalse(result.get("success")) + self.assertTrue(len(result.get("errors", [])) > 0) + self.assertIn("No Bill.com funding account", result["errors"][0]) + + def test_bulk_payment_not_marked_for_sync(self): + """Should return error if payment not marked for sync""" + self.payment1.is_sync_to_billcom = False + + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.payment1 | self.payment2) + + self.assertFalse(result.get("success")) + self.assertTrue(len(result.get("errors", [])) > 0) + + def test_bulk_payment_wrong_payment_type(self): + """Should return error if payment is not outbound supplier""" + # Change to inbound + self.payment1.payment_type = "inbound" + + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.payment1 | self.payment2) + + self.assertFalse(result.get("success")) + self.assertTrue(len(result.get("errors", [])) > 0) + + def test_bulk_payment_no_bill_id(self): + """Should return error if payment has no linked bill with Bill.com ID""" + # Remove bill.com ID + self.bill1.billcom_id = False + + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.payment1 | self.payment2) + + self.assertFalse(result.get("success")) + self.assertTrue(len(result.get("errors", [])) > 0) + + # ===== Success Flow Tests ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._get_payment_bill_id" # noqa B950 + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_bulk_payment_success(self, mock_request, mock_get_bill_id): + """Should create bulk payments successfully""" + mock_request.return_value = [ + { + "id": "payment_001", + "billId": "bill_001", + "singleStatus": "SCHEDULED", + "confirmationNumber": "CONF001", + "transactionNumber": "TXN001", + }, + { + "id": "payment_002", + "billId": "bill_002", + "singleStatus": "SCHEDULED", + "confirmationNumber": "CONF002", + "transactionNumber": "TXN002", + }, + ] + + # Mock _get_payment_bill_id to return correct bill IDs + def get_bill_id_side_effect(payment): + if payment == self.payment1: + return "bill_001" + elif payment == self.payment2: + return "bill_002" + return False + + mock_get_bill_id.side_effect = get_bill_id_side_effect + + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.payment1 | self.payment2) + + self.assertTrue(result.get("success"), f"Expected success but got: {result}") + self.assertEqual(result.get("success_count", 0), 2) + self.assertEqual(result.get("error_count", 0), 0) + + # Verify payment1 updated + self.assertEqual(self.payment1.billcom_id, "payment_001") + self.assertEqual(self.payment1.billcom_confirmation_number, "CONF001") + + # Verify payment2 updated + self.assertEqual(self.payment2.billcom_id, "payment_002") + self.assertEqual(self.payment2.billcom_confirmation_number, "CONF002") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._get_payment_bill_id" # noqa B950 + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_bulk_payment_partial_success(self, mock_request, mock_get_bill_id): + """Should handle partial success (some payments fail)""" + mock_request.return_value = [ + { + "id": "payment_001", + "billId": "bill_001", + "singleStatus": "SCHEDULED", + "confirmationNumber": "CONF001", + }, + { + "billId": "bill_002", + "error": "Insufficient funds", + }, + ] + + # Mock _get_payment_bill_id to return correct bill IDs + def get_bill_id_side_effect(payment): + if payment == self.payment1: + return "bill_001" + elif payment == self.payment2: + return "bill_002" + return False + + mock_get_bill_id.side_effect = get_bill_id_side_effect + + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.payment1 | self.payment2) + + self.assertFalse(result.get("success")) # Not full success + self.assertEqual(result.get("success_count", 0), 1) + self.assertEqual(result.get("error_count", 0), 1) + + # Verify payment1 synced + self.assertEqual(self.payment1.billcom_sync_status, "synced") + + # Verify payment2 failed + self.assertEqual(self.payment2.billcom_sync_status, "sync_failed") + self.assertIn("Insufficient funds", self.payment2.billcom_sync_error) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._get_payment_bill_id" # noqa B950 + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_bulk_payment_with_wallet(self, mock_request, mock_get_bill_id): + """Should handle WALLET funding type with process date""" + mock_request.return_value = [ + { + "id": "payment_001", + "billId": "bill_001", + "singleStatus": "SCHEDULED", + }, + { + "id": "payment_002", + "billId": "bill_002", + "singleStatus": "SCHEDULED", + }, + ] + + # Mock _get_payment_bill_id to return correct bill IDs + def get_bill_id_side_effect(payment): + if payment == self.payment1: + return "bill_001" + elif payment == self.payment2: + return "bill_002" + return False + + mock_get_bill_id.side_effect = get_bill_id_side_effect + + # Set funding type to WALLET + self.payment1.write( + { + "billcom_funding_account_type": "WALLET", + "billcom_process_date": fields.Date.today(), + } + ) + self.payment2.write( + { + "billcom_funding_account_type": "WALLET", + "billcom_process_date": fields.Date.today(), + } + ) + + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.payment1 | self.payment2) + + self.assertTrue(result.get("success"), f"Expected success but got: {result}") + + # Verify API was called with processDate + call_args = mock_request.call_args + if call_args: + payload = call_args.kwargs.get("data", {}) + self.assertIn("processDate", payload) + self.assertEqual(payload["fundingAccount"]["type"], "WALLET") + + # ===== Error Handling Tests ==== + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._get_payment_bill_id" # noqa B950 + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_bulk_payment_posts_to_chatter(self, mock_request, mock_get_bill_id): + """Should post success messages to payment chatter""" + mock_request.return_value = [ + { + "id": "payment_001", + "billId": "bill_001", + "singleStatus": "SCHEDULED", + "confirmationNumber": "CONF001", + }, + ] + + # Mock _get_payment_bill_id to return correct bill ID + mock_get_bill_id.return_value = "bill_001" + + # Count messages before + messages_before = len(self.payment1.message_ids) + + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.payment1) + + self.assertTrue(result.get("success")) + + # Should have new message in chatter + messages_after = len(self.payment1.message_ids) + self.assertGreater(messages_after, messages_before) + + # Verify message content + last_message = self.payment1.message_ids[0] + self.assertIn("Bill.com Bulk Payment Created", last_message.body) + self.assertIn("payment_001", last_message.body) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._get_payment_bill_id" # noqa B950 + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_payment_status_update(self, mock_request, mock_get_bill_id): + """Should update payment status from Bill.com""" + # Payment already synced + self.payment1.billcom_id = "payment_existing_001" + + mock_request.return_value = { + "id": "payment_existing_001", + "status": "PAID", + "paidDate": fields.Date.today().isoformat(), + } + self.assertEqual(self.payment1.billcom_id, "payment_existing_001") + + # ===== Payment Validation Tests ===== + + def test_payment_requires_partner(self): + """Should reject payment without partner""" + payment_no_partner = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "amount": 100.0, + "date": fields.Date.today(), + "journal_id": self.bank_journal.id, + } + ) + + service = self.env["billcom.service"] + result = service.create_bulk_payments(payment_no_partner) + + self.assertFalse(result.get("success")) + + def test_payment_amount_validation(self): + """Should validate payment amount is positive""" + payment = self.env["account.payment"].create( + { + "payment_type": "outbound", + "partner_type": "supplier", + "partner_id": self.vendor_billcom.id, + "amount": 0.0, + "date": fields.Date.today(), + "journal_id": self.bank_journal.id, + "is_sync_to_billcom": True, + } + ) + + service = self.env["billcom.service"] + result = service.create_bulk_payments(payment) + + # Should fail validation + self.assertFalse(result.get("success")) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._get_payment_bill_id" # noqa B950 + ) + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_payment_duplicate_prevention(self, mock_request, mock_get_bill_id): + """Should prevent duplicate payment creation""" + # Payment already has billcom_id + self.payment1.billcom_id = "payment_existing_123" + + mock_get_bill_id.return_value = "bill_001" + + service = self.env["billcom.service"] + result = service.create_bulk_payments(self.payment1) + + # Should skip or update, not create new + self.assertIsNotNone(result) + + # ===== Mixed Payment Types Tests ===== + + def test_bulk_payments_mixed_currencies(self): + """Should handle payments in different currencies""" + # This would require currency setup + # For now, ensure all payments use company currency + self.assertEqual(self.payment1.currency_id, self.env.company.currency_id) + self.assertEqual(self.payment2.currency_id, self.env.company.currency_id) diff --git a/billcom_integration/tests/test_res_partner.py b/billcom_integration/tests/test_res_partner.py new file mode 100644 index 00000000..555c56fd --- /dev/null +++ b/billcom_integration/tests/test_res_partner.py @@ -0,0 +1,557 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestResPartner(BillcomTestCommon): + """Test res.partner Bill.com integration""" + + def test_partner_billcom_fields(self): + """Test that partner has all required Bill.com fields""" + required_fields = [ + "billcom", + "billcom_id", + "is_sync_to_billcom", + "billcom_sync_status", + "billcom_sync_error", + "last_sync_date", + "billcom_res_currency_id", + "billcom_sync_state", + "billcom_payment_purpose_id", + ] + + for field in required_fields: + self.assertTrue( + hasattr(self.vendor_billcom, field), + f"Partner should have field: {field}", + ) + + def test_prepare_partner_data_vendor(self): + """Test _prepare_partner_data for vendor""" + partner_data = self.vendor_billcom._prepare_partner_data(partner_type="vendor") + + self.assertIsNotNone(partner_data) + self.assertEqual(partner_data["name"], "Test Vendor Bill.com") + self.assertEqual(partner_data["email"], "vendor@test.com") + self.assertEqual(partner_data["phone"], "+1-555-123-4567") + + # Check address structure + self.assertIn("mailingAddress", partner_data) + address = partner_data["mailingAddress"] + self.assertEqual(address["line1"], "123 Test Street") + self.assertEqual(address["city"], "Test City") + self.assertEqual(address["zipOrPostalCode"], "12345") + + def test_prepare_partner_data_customer(self): + """Test _prepare_partner_data for customer""" + partner_data = self.customer_billcom._prepare_partner_data( + partner_type="customer" + ) + + self.assertIsNotNone(partner_data) + self.assertEqual(partner_data["name"], "Test Customer Bill.com") + self.assertIn("mailingAddress", partner_data) + + def test_prepare_partner_data_not_synced(self): + """Test _prepare_partner_data returns False for non-sync partners""" + partner = self.env["res.partner"].create( + { + "name": "Non-Sync Partner", + "is_sync_to_billcom": False, + } + ) + + partner_data = partner._prepare_partner_data() + self.assertFalse(partner_data) + + def test_currency_field_in_partner_data(self): + """Test that billcom_res_currency_id is included in API data""" + # Set EUR currency for non-US vendor + eur = self.env["res.currency"].search([("name", "=", "EUR")], limit=1) + if not eur: + eur = self.env["res.currency"].create({"name": "EUR", "symbol": "€"}) + + intl_vendor = self.env["res.partner"].create( + { + "name": "International Vendor", + "supplier_rank": 1, + "is_sync_to_billcom": True, + "street": "456 Euro St", + "city": "Paris", + "zip": "75001", + "country_id": self.env.ref("base.fr").id, # France + "billcom_res_currency_id": eur.id, + } + ) + + partner_data = intl_vendor._prepare_partner_data(partner_type="vendor") + self.assertEqual(partner_data.get("billCurrency"), "EUR") + + def test_currency_not_included_for_us_vendors(self): + """Test that billCurrency is not sent for US vendors""" + partner_data = self.vendor_billcom._prepare_partner_data(partner_type="vendor") + + # US vendors should not have billCurrency field + self.assertNotIn("billCurrency", partner_data) + + def test_parent_child_partner_structure(self): + """Test parent-child partner relationships for multi-currency""" + # Create parent partner + parent = self.env["res.partner"].create( + { + "name": "Parent Vendor Corp", + "supplier_rank": 1, + "is_company": True, + "is_sync_to_billcom": True, + "billcom_id": "parent_vendor_001", + } + ) + + # Create children with different currencies + usd = self.env["res.currency"].search([("name", "=", "USD")], limit=1) + eur = self.env["res.currency"].search([("name", "=", "EUR")], limit=1) + if not eur: + eur = self.env["res.currency"].create({"name": "EUR", "symbol": "€"}) + + child_usd = self.env["res.partner"].create( + { + "name": "Parent Vendor Corp", + "parent_id": parent.id, + "type": "contact", + "billcom_res_currency_id": usd.id, + "billcom_id": "child_vendor_usd", + } + ) + + child_eur = self.env["res.partner"].create( + { + "name": "Parent Vendor Corp", + "parent_id": parent.id, + "type": "contact", + "billcom_res_currency_id": eur.id, + "billcom_id": "child_vendor_eur", + } + ) + + # Verify parent-child relationships + self.assertEqual(child_usd.parent_id, parent) + self.assertEqual(child_eur.parent_id, parent) + self.assertIn(child_usd, parent.child_ids) + self.assertIn(child_eur, parent.child_ids) + + # Verify different currencies + self.assertEqual(child_usd.billcom_res_currency_id.name, "USD") + self.assertEqual(child_eur.billcom_res_currency_id.name, "EUR") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._make_request" # noqa B950 + ) + def test_sync_to_billcom_vendor(self, mock_request): + """Test syncing vendor to Bill.com""" + # Mock API response + mock_request.return_value = { + "id": "vendor_abc123", + "name": "Test Vendor Bill.com", + "isActive": True, + } + + # Trigger sync + self.vendor_billcom.sync_to_billcom(partner_type="vendor") + + # Verify API was called + mock_request.assert_called_once() + + # Verify billcom_id was updated + self.assertEqual(self.vendor_billcom.billcom_id, "vendor_abc123") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._make_request" # noqa B950 + ) + def test_sync_from_billcom_by_id(self, mock_request): + """Test syncing partner from Bill.com by ID""" + # Mock API response + mock_request.return_value = { + "id": "vendor_xyz789", + "name": "New Vendor from Bill.com", + "email": "newvendor@example.com", + "phone": "+1-555-999-8888", + "isActive": True, + "address": { + "line1": "789 New St", + "city": "New City", + "stateOrProvince": "CA", + "zipOrPostalCode": "99999", + "country": "US", + }, + "billCurrency": "USD", + } + + # Call sync_from_billcom_by_id + partner = self.env["res.partner"].sync_from_billcom_by_id( + billcom_id="vendor_xyz789", partner_type="vendor" + ) + + # Verify partner was created/updated + self.assertIsNotNone(partner) + self.assertEqual(partner.billcom_id, "vendor_xyz789") + self.assertEqual(partner.name, "New Vendor from Bill.com") + self.assertEqual(partner.email, "newvendor@example.com") + + def test_billcom_sync_status_field(self): + """Test billcom_sync_status field values""" + # Test default value + new_partner = self.env["res.partner"].create( + { + "name": "Test Partner Status", + "is_sync_to_billcom": True, + } + ) + + self.assertEqual(new_partner.billcom_sync_status, "not_synced") + + # Test status changes + new_partner.billcom_sync_status = "synced" + self.assertEqual(new_partner.billcom_sync_status, "synced") + + new_partner.billcom_sync_status = "sync_failed" + self.assertEqual(new_partner.billcom_sync_status, "sync_failed") + + def test_partner_address_formatting(self): + """Test that partner addresses are formatted correctly for Bill.com""" + # Create partner with complete address + partner = self.env["res.partner"].create( + { + "name": "Address Test Partner", + "supplier_rank": 1, + "is_sync_to_billcom": True, + "street": "123 Main St", + "street2": "Suite 100", + "city": "San Francisco", + "state_id": self.env.ref("base.state_us_5").id, # California + "zip": "94105", + "country_id": self.env.ref("base.us").id, + } + ) + + partner_data = partner._prepare_partner_data(partner_type="vendor") + address = partner_data["mailingAddress"] + + self.assertEqual(address["line1"], "123 Main St") + self.assertEqual(address["line2"], "Suite 100") + self.assertEqual(address["city"], "San Francisco") + self.assertEqual(address["stateOrProvince"], "CA") + self.assertEqual(address["zipOrPostalCode"], "94105") + self.assertEqual(address["country"], "US") + + def test_partner_minimal_address(self): + """Test partner with minimal required address fields""" + partner = self.env["res.partner"].create( + { + "name": "Minimal Address Partner", + "is_sync_to_billcom": True, + # No street, city, or zip - should use defaults + } + ) + + partner_data = partner._prepare_partner_data(partner_type="vendor") + address = partner_data["mailingAddress"] + + # Should have default values + self.assertEqual(address["line1"], "N/A") + self.assertEqual(address["city"], "N/A") + self.assertEqual(address["zipOrPostalCode"], "00000") + self.assertEqual(address["country"], "US") + + def test_payment_purpose_field(self): + """Test billcom_payment_purpose_id field""" + # Create payment purpose + purpose = self.env["billcom.payment.purpose"].create( + { + "name": "BUSINESS", + "description": "Business Payment", + "country_id": self.env.ref("base.us").id, + } + ) + + partner = self.env["res.partner"].create( + { + "name": "Partner with Purpose", + "supplier_rank": 1, + "billcom_payment_purpose_id": purpose.id, + "country_id": self.env.ref("base.us").id, + } + ) + + self.assertEqual(partner.billcom_payment_purpose_id, purpose) + + def test_partner_bank_account_sync(self): + """Test that partner bank accounts have Bill.com fields""" + bank = self.env["res.partner.bank"].create( + { + "partner_id": self.vendor_billcom.id, + "acc_number": "123456789", + "aba_routing": "021000021", + } + ) + + # Check if bank has billcom fields (from billcom.abstract.model) + self.assertTrue(hasattr(bank, "billcom_id")) + self.assertTrue(hasattr(bank, "is_sync_to_billcom")) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service.BillcomService._make_request" # noqa B950 + ) + def test_sync_error_handling(self, mock_request): + """Test that sync errors are properly captured""" + # Mock API error + mock_request.side_effect = UserError("API Connection Failed") + + # Attempt sync + with self.assertRaises(UserError): + self.vendor_billcom.sync_to_billcom(partner_type="vendor") + + def test_archived_partner_sync(self): + """Test syncing archived/inactive partners""" + # Archive partner + self.vendor_billcom.active = False + + partner_data = self.vendor_billcom._prepare_partner_data(partner_type="vendor") + + # Should still prepare data for archived partners + self.assertIsNotNone(partner_data) + + def test_multiple_addresses_priority(self): + """Test that invoice and delivery addresses are used when available""" + # Create parent with child addresses + parent = self.env["res.partner"].create( + { + "name": "Multi-Address Company", + "is_company": True, + "is_sync_to_billcom": True, + "street": "100 Main Office", + "city": "Office City", + "zip": "10000", + "country_id": self.env.ref("base.us").id, + } + ) + + # Create invoice address + self.env["res.partner"].create( + { + "name": "Billing Department", + "parent_id": parent.id, + "type": "invoice", + "street": "200 Billing St", + "city": "Billing City", + "zip": "20000", + "country_id": self.env.ref("base.us").id, + } + ) + + # Create delivery address + self.env["res.partner"].create( + { + "name": "Warehouse", + "parent_id": parent.id, + "type": "delivery", + "street": "300 Warehouse Ave", + "city": "Warehouse City", + "zip": "30000", + "country_id": self.env.ref("base.us").id, + } + ) + + # Prepare data - should use child addresses + partner_data = parent._prepare_partner_data(partner_type="vendor") + + # Billing address should be from invoice contact + billing = partner_data.get("mailingAddress") + self.assertEqual(billing["line1"], "200 Billing St") + self.assertEqual(billing["city"], "Billing City") + + # Shipping address should be from delivery contact + # Note: Bill.com API v3 uses 'shippingAddress' for customers, but for vendors + # it typically only uses 'address' (mapped to mailingAddress in our logic). + # However, if we were testing a customer, it would have shippingAddress. + # Let's test as customer to verify shipping address logic + customer_data = parent._prepare_partner_data(partner_type="customer") + shipping = customer_data.get("shippingAddress") + self.assertEqual(shipping["line1"], "300 Warehouse Ave") + self.assertEqual(shipping["city"], "Warehouse City") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_bank_account_to_billcom_create(self, mock_request): + """Test creating a new bank account in Bill.com""" + # Create a bank account for the vendor + bank = self.env["res.partner.bank"].create( + { + "partner_id": self.vendor_billcom.id, + "acc_number": "987654321", + "aba_routing": "123456789", + "billcom_account_type": "CHECKING", + "billcom_owner_type": "BUSINESS", + } + ) + + # Mock API response for creation + mock_request.return_value = {"id": "bank_new_001", "status": "VERIFIED"} + + # Trigger sync + bank.sync_bank_account_to_billcom() + + # Verify API call + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + self.assertEqual(kwargs["method"], "POST") + self.assertIn("bank-account", args[0]) + self.assertEqual(kwargs["data"]["accountNumber"], "987654321") + + # Verify Odoo record updated + self.assertEqual(bank.billcom_vendor_bank_id, "bank_new_001") + self.assertEqual(bank.billcom_account_status, "VERIFIED") + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_bank_account_to_billcom_update(self, mock_request): + """Test updating an existing bank account in Bill.com""" + # Create a bank account that is already synced + bank = self.env["res.partner.bank"].create( + { + "partner_id": self.vendor_billcom.id, + "acc_number": "987654321", + "aba_routing": "123456789", + "billcom_vendor_bank_id": "bank_existing_001", + } + ) + + # Mock API response for update + mock_request.return_value = {"id": "bank_existing_001", "status": "VERIFIED"} + + # Trigger sync + bank.sync_bank_account_to_billcom() + + # Verify API call uses PATCH and includes ID in URL + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + self.assertEqual(kwargs["method"], "PATCH") + self.assertIn("bank_existing_001", args[0]) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_bank_account_error_bdc_1233(self, mock_request): + """Test handling of BDC_1233 error (already setup for epayment)""" + bank = self.env["res.partner.bank"].create( + { + "partner_id": self.vendor_billcom.id, + "acc_number": "987654321", + "aba_routing": "123456789", + } + ) + + # Mock API error + mock_request.side_effect = UserError( + "BDC_1233: Vendor already setup for epayment" + ) + + # Verify specific user error is raised + with self.assertRaises(UserError) as cm: + bank.sync_bank_account_to_billcom() + + self.assertIn( + "Vendor already has payment information configured", str(cm.exception) + ) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_action_fetch_payment_purposes(self, mock_request): + """Test fetching payment purposes for international vendor""" + # Setup international vendor + eur = self.env["res.currency"].search([("name", "=", "EUR")], limit=1) + if not eur: + eur = self.env["res.currency"].create({"name": "EUR", "symbol": "€"}) + + intl_vendor = self.env["res.partner"].create( + { + "name": "Intl Vendor", + "is_sync_to_billcom": True, + "country_id": self.env.ref("base.fr").id, + "billcom_res_currency_id": eur.id, + } + ) + + # Mock API response + mock_request.return_value = { + "results": [ + {"id": "purpose_1", "name": "SERVICES", "description": "Services"}, + {"id": "purpose_2", "name": "GOODS", "description": "Goods"}, + ] + } + + # Trigger action + action = intl_vendor.action_fetch_payment_purposes() + + # Verify notification returned + self.assertEqual(action["tag"], "display_notification") + self.assertIn("2 payment purpose(s) loaded", action["params"]["message"]) + + # Verify purposes created + purposes = self.env["billcom.payment.purpose"].search([]) + self.assertTrue(len(purposes) >= 2) + + def test_prepare_partner_data_international_bank(self): + """Test preparing partner data with international bank info""" + # Setup international vendor with bank + eur = self.env["res.currency"].search([("name", "=", "EUR")], limit=1) + if not eur: + eur = self.env["res.currency"].create({"name": "EUR", "symbol": "€"}) + + intl_vendor = self.env["res.partner"].create( + { + "name": "Intl Vendor Bank Test", + "is_sync_to_billcom": True, + "country_id": self.env.ref("base.fr").id, + "billcom_res_currency_id": eur.id, + } + ) + + self.env["res.partner.bank"].create( + { + "partner_id": intl_vendor.id, + "acc_number": "FR76123456789", + "currency_id": eur.id, + "bank_id": self.env["res.bank"] + .create( + { + "name": "Bank of France", + "bic": "BOFFRPP", + "country": self.env.ref("base.fr").id, + } + ) + .id, + } + ) + + # Prepare data + data = intl_vendor._prepare_partner_data(partner_type="vendor") + + # Verify international bank fields + payment_info = data["paymentInformation"] + self.assertEqual(payment_info["bankCountry"], "FR") + self.assertEqual(payment_info["paymentCurrency"], "EUR") + self.assertEqual(payment_info["bankInfo"]["swiftBIC"], "BOFFRPP") + self.assertEqual(payment_info["bankInfo"]["countryISO"], "FR") diff --git a/billcom_integration/tests/test_vendor_bank_sync.py b/billcom_integration/tests/test_vendor_bank_sync.py new file mode 100644 index 00000000..8637a3ff --- /dev/null +++ b/billcom_integration/tests/test_vendor_bank_sync.py @@ -0,0 +1,333 @@ +# Copyright 2025 Binhex - Simple Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from unittest.mock import patch + +from odoo.tests import tagged + +from .common import BillcomTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "billcom") +class TestVendorBankSync(BillcomTestCommon): + """Test vendor bank account synchronization with Bill.com""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create US bank for vendor + cls.us_bank = cls.env["res.partner.bank"].create( + { + "partner_id": cls.vendor_billcom.id, + "acc_number": "123456789", + "aba_routing": "021000021", + "acc_holder_name": "Test Vendor LLC", + } + ) + + # Create international vendor with bank + cls.intl_vendor = cls.env["res.partner"].create( + { + "name": "International Vendor", + "supplier_rank": 1, + "billcom_id": "intl_vendor_001", + "country_id": cls.env.ref("base.uk").id, + } + ) + + # International bank info with SWIFT + cls.intl_bank_info = cls.env["res.bank"].create( + { + "name": "International Bank", + "bic": "DEUTDEFF", + "street": "Bank Street 1", + "city": "London", + } + ) + + # International bank account + cls.intl_bank_account = cls.env["res.partner.bank"].create( + { + "partner_id": cls.intl_vendor.id, + "acc_number": "GB29NWBK60161331926819", + "bank_id": cls.intl_bank_info.id, + } + ) + + # ===== Tests de Validación ===== + + # def test_sync_vendor_bank_no_partner(self): + # """Should return False if no partner provided""" + # service = self.env["billcom.service"] + # result = service.sync_vendor_bank_account(None) + # self.assertFalse(result) + + def test_sync_vendor_bank_no_billcom_id(self): + """Should return False if partner has no billcom_id""" + vendor = ( + self.env["res.partner"] + .with_context(skip_billcom_sync=True) + .create( + { + "name": "No ID Vendor", + "supplier_rank": 1, + } + ) + ) + # Add bank account + self.env["res.partner.bank"].with_context(skip_billcom_sync=True).create( + { + "partner_id": vendor.id, + "acc_number": "987654321", + "aba_routing": "021000021", + } + ) + service = self.env["billcom.service"] + result = service.sync_vendor_bank_account(vendor) + self.assertFalse(result) + + def test_sync_vendor_bank_not_vendor(self): + """Should return False if partner is not a vendor""" + customer = self.customer_billcom + customer.supplier_rank = 0 # Ensure NOT a vendor + # Add bank account + self.env["res.partner.bank"].with_context(skip_billcom_sync=True).create( + { + "partner_id": customer.id, + "acc_number": "555666777", + "aba_routing": "021000021", + } + ) + service = self.env["billcom.service"] + result = service.sync_vendor_bank_account(customer) + self.assertFalse(result) + + def test_sync_vendor_bank_no_bank_accounts(self): + """Should return False if vendor has no bank accounts""" + vendor = ( + self.env["res.partner"] + .with_context(skip_billcom_sync=True) + .create( + { + "name": "No Bank Vendor", + "supplier_rank": 1, + "billcom_id": "vendor_no_bank", + } + ) + ) + service = self.env["billcom.service"] + result = service.sync_vendor_bank_account(vendor) + self.assertFalse(result) + + def test_sync_vendor_bank_no_account_number(self): + """Should return False if bank has no account number""" + vendor = self.vendor_billcom + bank = ( + self.env["res.partner.bank"] + .with_context(skip_billcom_sync=True) + .create( + { + "partner_id": vendor.id, + "acc_number": "", # Empty + } + ) + ) + # Replace vendor banks with this one + vendor.with_context(skip_billcom_sync=True).write( + {"bank_ids": [(6, 0, [bank.id])]} + ) + service = self.env["billcom.service"] + result = service.sync_vendor_bank_account(vendor) + self.assertFalse(result) + + # def test_sync_vendor_bank_us_no_routing(self): + # """Should return False if US bank has no routing number""" + # vendor = self.vendor_billcom + # bank = ( + # self.env["res.partner.bank"] + # .with_context(skip_billcom_sync=True) + # .create( + # { + # "partner_id": vendor.id, + # "acc_number": "999888777", + # "aba_routing": "", # No routing + # } + # ) + # ) + # # Replace vendor banks with this one + # vendor.with_context(skip_billcom_sync=True).write( + # {"bank_ids": [(6, 0, [bank.id])]} + # ) + # service = self.env["billcom.service"] + # result = service.sync_vendor_bank_account(vendor) + # self.assertFalse(result) + + # ===== Tests de Flujo Principal US ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_vendor_bank_us_checking_success(self, mock_request): + """Should create US CHECKING bank account successfully""" + + # Mock: GET returns 404 (not exists), POST returns success + def side_effect(endpoint, method="GET", **kwargs): + if method == "GET": + raise Exception("404 Not Found") + elif method == "POST": + return { + "id": "bank_account_123", + "status": "ACTIVE", + "accountNumber": "****6789", + } + + mock_request.side_effect = side_effect + + service = self.env["billcom.service"] + result = service.sync_vendor_bank_account(self.vendor_billcom) + + self.assertTrue(result) + # Verify bank record was updated + self.assertEqual(self.us_bank.billcom_vendor_bank_id, "bank_account_123") + self.assertEqual(self.us_bank.billcom_account_status, "ACTIVE") + self.assertIsNotNone(self.us_bank.billcom_last_sync_date) + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_vendor_bank_us_with_existing_delete_first(self, mock_request): + """Should delete existing account before creating new one""" + + # Mock: GET returns existing, DELETE success, POST success + call_count = {"get": 0, "delete": 0, "post": 0} + + def side_effect(endpoint, method="GET", **kwargs): + if method == "GET": + call_count["get"] += 1 + return {"id": "old_bank_123", "status": "ACTIVE"} + elif method == "DELETE": + call_count["delete"] += 1 + return True + elif method == "POST": + call_count["post"] += 1 + return {"id": "new_bank_456", "status": "ACTIVE"} + + mock_request.side_effect = side_effect + + service = self.env["billcom.service"] + result = service.sync_vendor_bank_account(self.vendor_billcom) + + self.assertTrue(result) + self.assertEqual(call_count["get"], 1) + self.assertEqual(call_count["delete"], 1) + self.assertEqual(call_count["post"], 1) + self.assertEqual(self.us_bank.billcom_vendor_bank_id, "new_bank_456") + + # ===== Tests de Flujo Internacional ===== + + # @patch( + # "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + # ) + # def test_sync_vendor_bank_international_with_swift(self, mock_request): + # """Should include SWIFT/BIC for international banks""" + + # def side_effect(endpoint, method="GET", data=None, **kwargs): + # if method == "GET": + # raise Exception("404 Not Found") + # elif method == "POST": + # # Verify payload includes bankInfo with SWIFT + # self.assertIn("bankInfo", data) + # self.assertEqual(data["bankInfo"]["swiftBIC"], "DEUTDEFF") + # self.assertEqual(data["bankInfo"]["countryISO"], "GB") + # return {"id": "intl_bank_789", "status": "ACTIVE"} + + # mock_request.side_effect = side_effect + + # service = self.env["billcom.service"] + # result = service.sync_vendor_bank_account(self.intl_vendor) + + # self.assertTrue(result) + # self.assertEqual(self.intl_bank_account.billcom_vendor_bank_id, "intl_bank_789") + + # ===== Tests de Manejo de Errores ===== + + # @patch( + # "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + # ) + # def test_sync_vendor_bank_api_error_on_create(self, mock_request): + # """Should handle API errors when creating account""" + + # def side_effect(endpoint, method="GET", **kwargs): + # if method == "GET": + # raise Exception("404 Not Found") + # elif method == "POST": + # raise Exception("API Error: Invalid account number") + + # mock_request.side_effect = side_effect + + # service = self.env["billcom.service"] + # result = service.sync_vendor_bank_account(self.vendor_billcom) + + # self.assertFalse(result) + + # @patch( + # "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + # ) + # def test_sync_vendor_bank_delete_fails_continue_anyway(self, mock_request): + # """Should continue creation even if DELETE fails""" + + # def side_effect(endpoint, method="GET", **kwargs): + # if method == "GET": + # return {"id": "old_bank", "status": "ACTIVE"} + # elif method == "DELETE": + # raise Exception("Delete failed") + # elif method == "POST": + # return {"id": "new_bank_999", "status": "ACTIVE"} + + # mock_request.side_effect = side_effect + + # service = self.env["billcom.service"] + # result = service.sync_vendor_bank_account(self.vendor_billcom) + + # # Should succeed despite DELETE error + # self.assertTrue(result) + # self.assertEqual(self.us_bank.billcom_vendor_bank_id, "new_bank_999") + + # ===== Tests de Actualización de Datos ===== + + @patch( + "odoo.addons.billcom_integration.models.billcom_service_abstract.BillcomServiceAbstract._make_request" # noqa B950 + ) + def test_sync_vendor_bank_posts_to_chatter(self, mock_request): + """Should post message to partner chatter""" + + def side_effect(*args, **kwargs): + # Get method from args or kwargs + method = kwargs.get("method", args[1] if len(args) > 1 else "GET") + if method == "GET": + raise Exception("404 Not Found") + elif method == "POST": + return {"id": "bank_chatter_123", "status": "ACTIVE"} + return {} + + mock_request.side_effect = side_effect + + # Count messages before + messages_before = len(self.vendor_billcom.message_ids) + + service = self.env["billcom.service"] + result = service.sync_vendor_bank_account(self.vendor_billcom) + + self.assertTrue(result) + # Should have new message in chatter + messages_after = len(self.vendor_billcom.message_ids) + self.assertGreater(messages_after, messages_before) + + # Verify message content + last_message = self.vendor_billcom.message_ids[0] + self.assertIn("Bank Account Synced", last_message.body) + self.assertIn("bank_chatter_123", last_message.body) diff --git a/billcom_integration/views/account_move_views.xml b/billcom_integration/views/account_move_views.xml new file mode 100644 index 00000000..5e7b1050 --- /dev/null +++ b/billcom_integration/views/account_move_views.xml @@ -0,0 +1,244 @@ + + + + Sync to Bill.com + + + code + records.button_sync_to_billcom() + + + + + Send Invoice Payment + + + code + records.button_send_invoice_payment_reminder() + + + + + + account.move.tree.billcom + account.move + + + + + + + + + + + + account.move.supplier.form.billcom + account.move + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Bill.com Dashboard + billcom.config + kanban,tree,form + + +

+ Create your first Bill.com configuration! +

+

+ Configure your connection to Bill.com API to start synchronizing + vendors, bills, and payments between Odoo and Bill.com. +

+
+
+ +
diff --git a/billcom_integration/views/billcom_config_views.xml b/billcom_integration/views/billcom_config_views.xml new file mode 100644 index 00000000..560283dd --- /dev/null +++ b/billcom_integration/views/billcom_config_views.xml @@ -0,0 +1,560 @@ + + + + billcom.config.form + billcom.config + +
+
+ + +
+ + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ +
+
+ + + + billcom.document.search + billcom.document + + + + + + + + + + + + + + + + + + + + + + + + Bill.com Documents + billcom.document + tree,form + {'search_default_uploaded': 1} + +

+ Upload documents to Bill.com bills +

+

+ Manage document uploads for your Bill.com bills. Documents can be PDFs, images, or other supported file types. +

+
+
+
diff --git a/billcom_integration/views/billcom_from_billcom_actions.xml b/billcom_integration/views/billcom_from_billcom_actions.xml new file mode 100644 index 00000000..992dbb53 --- /dev/null +++ b/billcom_integration/views/billcom_from_billcom_actions.xml @@ -0,0 +1,148 @@ + + + + + + + Vendors from Bill.com + + + code + +action = model.browse(context.get('active_id')).action_open_vendors_from_billcom() + + + + + + Customers from Bill.com + + + code + +action = model.browse(context.get('active_id')).action_open_customers_from_billcom() + + + + + + Bills from Bill.com + + + code + +action = model.browse(context.get('active_id')).action_open_bills_from_billcom() + + + + + + Invoices from Bill.com + + + code + +action = model.browse(context.get('active_id')).action_open_invoices_from_billcom() + + + + + + Payments from Bill.com + + + code + +action = model.browse(context.get('active_id')).action_open_payments_from_billcom() + + + + + + Vendors from Bill.com + res.partner + tree,form + [('supplier_rank', '>', 0), ('billcom_id', '!=', False)] + {'default_supplier_rank': 1} + +

+ No vendors synced from Bill.com yet +

+

+ Use the Sync Wizard to import vendors from Bill.com. +

+
+
+ + + Customers from Bill.com + res.partner + tree,form + [('customer_rank', '>', 0), ('billcom_id', '!=', False)] + {'default_customer_rank': 1} + +

+ No customers synced from Bill.com yet +

+

+ Use the Sync Wizard to import customers from Bill.com. +

+
+
+ + + Bills from Bill.com + account.move + tree,form + [('move_type', '=', 'in_invoice'), ('billcom_id', '!=', False)] + +

+ No bills synced from Bill.com yet +

+

+ Use the Sync Wizard to import bills from Bill.com. +

+
+
+ + + Invoices from Bill.com + account.move + tree,form + [('move_type', '=', 'out_invoice'), ('billcom_id', '!=', False)] + +

+ No invoices synced from Bill.com yet +

+

+ Use the Sync Wizard to import invoices from Bill.com. +

+
+
+ + + Payments from Bill.com + account.payment + tree,form + [('payment_type', '=', 'outbound'), ('billcom_id', '!=', False)] + +

+ No payments synced from Bill.com yet +

+

+ Payments synced from Bill.com will appear here. +

+
+
+ +
diff --git a/billcom_integration/views/billcom_funding_account_views.xml b/billcom_integration/views/billcom_funding_account_views.xml new file mode 100644 index 00000000..8abbd461 --- /dev/null +++ b/billcom_integration/views/billcom_funding_account_views.xml @@ -0,0 +1,187 @@ + + + + + billcom.funding.account.tree + billcom.funding.account + + + + + + + + + + + + + + + + + billcom.funding.account.form + billcom.funding.account + +
+
+
+ +
+ +
+ + + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + billcom.funding.account.search + billcom.funding.account + + + + + + + + + + + + + + + + + + + + + + + + Funding Accounts + billcom.funding.account + tree,form + {'search_default_verified': 1} + +

+ No funding accounts found +

+

+ Funding accounts are your organization's bank accounts in Bill.com.
+ Click the "Sync from Bill.com" button to import them. +

+
+
+ + + +
diff --git a/billcom_integration/views/billcom_item_views.xml b/billcom_integration/views/billcom_item_views.xml new file mode 100644 index 00000000..5b724554 --- /dev/null +++ b/billcom_integration/views/billcom_item_views.xml @@ -0,0 +1,216 @@ + + + + + billcom.item.tree + billcom.item + + + + + + + + + + + + + + + + + billcom.item.form + billcom.item + +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + + + billcom.item.search + billcom.item + + + + + + + + + + + + + + + + + + + + + + + + + Bill.com Items + billcom.item + tree,form + {'search_default_sales_tax': 1, 'search_default_has_tax': 1} + +

+ Create a Bill.com Item +

+

+ Bill.com items are used to classify transactions.
+ Items of type "Sales Tax" are automatically mapped to Odoo taxes. +

+
+
+ + + +
diff --git a/billcom_integration/views/billcom_logger_views.xml b/billcom_integration/views/billcom_logger_views.xml new file mode 100644 index 00000000..083d38b9 --- /dev/null +++ b/billcom_integration/views/billcom_logger_views.xml @@ -0,0 +1,221 @@ + + + + + billcom.logger.tree + billcom.logger + + + + + + + + + + + + + + + + + + + billcom.logger.form + billcom.logger + +
+
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + billcom.logger.search + billcom.logger + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Bill.com Logs + billcom.logger + tree,form + {'search_default_filter_today': 1} + +

+ No Bill.com logs found +

+

+ Bill.com integration logs will appear here when operations are performed. +

+
+
+
diff --git a/billcom_integration/views/billcom_payment_purpose_views.xml b/billcom_integration/views/billcom_payment_purpose_views.xml new file mode 100644 index 00000000..e66db222 --- /dev/null +++ b/billcom_integration/views/billcom_payment_purpose_views.xml @@ -0,0 +1,127 @@ + + + + + + view.billcom.payment.purpose.tree + billcom.payment.purpose + + + + + + + + + + + + + + + view.billcom.payment.purpose.form + billcom.payment.purpose + +
+ +
+ +
+
+
+ + + + + + + + + + + +
+
+
+
+ + + + view.billcom.payment.purpose.search + billcom.payment.purpose + + + + + + + + + + + + + + + + + + + + + + Billcom Payment Purpose + ir.actions.act_window + billcom.payment.purpose + tree,form + [] + {} + +

+ There is no examples click here to add new Billcom Payment Purpose. +

+
+
+ +
diff --git a/billcom_integration/views/billcom_sync_queue_views.xml b/billcom_integration/views/billcom_sync_queue_views.xml new file mode 100644 index 00000000..46eda041 --- /dev/null +++ b/billcom_integration/views/billcom_sync_queue_views.xml @@ -0,0 +1,332 @@ + + + + + billcom.sync.queue.tree + billcom.sync.queue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + billcom.sync.queue.search + billcom.sync.queue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Bill.com Sync Queue + billcom.sync.queue + tree,form + {'search_default_filter_queued': 1, 'search_default_filter_processing': 1} + +

+ No sync queue items found +

+

+ Synchronization items will appear here when operations are queued for processing. +

+
+
+ + + + Bill.com: Process Sync Queue + + code + model.process_queue() + 5 + minutes + -1 + + + + +
diff --git a/billcom_integration/views/billcom_webhook_log_views.xml b/billcom_integration/views/billcom_webhook_log_views.xml new file mode 100644 index 00000000..17e1f595 --- /dev/null +++ b/billcom_integration/views/billcom_webhook_log_views.xml @@ -0,0 +1,183 @@ + + + + + billcom.webhook.log.tree + billcom.webhook.log + + + + + + + + + + + + + + + + billcom.webhook.log.form + billcom.webhook.log + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + billcom.webhook.log.search + billcom.webhook.log + + + + + + + + + + + + + + + + + + + + + + + + + + + + Webhook Logs + billcom.webhook.log + tree,form + {'search_default_last_7_days': 1} + +

+ No webhook logs yet +

+

+ Webhook logs will appear here when Bill.com sends webhook notifications to Odoo. +

+
+
+ + +
diff --git a/billcom_integration/views/menus.xml b/billcom_integration/views/menus.xml new file mode 100644 index 00000000..d904135b --- /dev/null +++ b/billcom_integration/views/menus.xml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/billcom_integration/views/res_partner_bank_views.xml b/billcom_integration/views/res_partner_bank_views.xml new file mode 100644 index 00000000..42fdfd7a --- /dev/null +++ b/billcom_integration/views/res_partner_bank_views.xml @@ -0,0 +1,82 @@ + + + + + + Sync to Bill.com + + + code + records.sync_bank_account_to_billcom() + + + + + + view.res.partner.bank.form + res.partner.bank + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+ +
diff --git a/billcom_integration/views/res_partner_views.xml b/billcom_integration/views/res_partner_views.xml new file mode 100644 index 00000000..bee44f44 --- /dev/null +++ b/billcom_integration/views/res_partner_views.xml @@ -0,0 +1,202 @@ + + + + + res.partner.form.inherit.billcom + res.partner + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +