diff --git a/apps/sage_intacct/connector.py b/apps/sage_intacct/connector.py index 7ffac516..42772b85 100644 --- a/apps/sage_intacct/connector.py +++ b/apps/sage_intacct/connector.py @@ -1955,7 +1955,7 @@ def post_charge_card_transaction( charge_card_transaction_payload = construct_charge_card_transaction_payload( workspace_id=self.workspace_id, charge_card_transaction=charge_card_transaction, - charge_card_transaction_lineitems=charge_card_transaction_line_items + charge_card_transaction_line_items=charge_card_transaction_line_items ) charge_card_transaction_payload['txnDate'] = first_day_of_month.strftime('%Y-%m-%d') created_charge_card_transaction = self.connection.charge_card_transactions.post(charge_card_transaction_payload) diff --git a/docker-compose-pipeline.yml b/docker-compose-pipeline.yml index 6d23d272..234d6029 100644 --- a/docker-compose-pipeline.yml +++ b/docker-compose-pipeline.yml @@ -28,6 +28,8 @@ services: FYLE_REFRESH_TOKEN: 'sample.sample.sample' SI_SENDER_PASSWORD: 'sample' SI_SENDER_ID: 'sample' + INTACCT_CLIENT_ID: 'sample_client_id' + INTACCT_CLIENT_SECRET: 'sample_client_secret' ENCRYPTION_KEY: ${ENCRYPTION_KEY} INTEGRATIONS_SETTINGS_API: http://localhost:8006/api FYLE_TOKEN_URI: 'https://sample.fyle.tech' diff --git a/fyle_intacct_api/tests/settings.py b/fyle_intacct_api/tests/settings.py index 755d08c1..3dbc95f3 100644 --- a/fyle_intacct_api/tests/settings.py +++ b/fyle_intacct_api/tests/settings.py @@ -286,6 +286,8 @@ FYLE_SERVER_URL = os.environ.get('FYLE_SERVER_URL') SI_SENDER_ID = os.environ.get('SI_SENDER_ID') SI_SENDER_PASSWORD = os.environ.get('SI_SENDER_PASSWORD') +INTACCT_CLIENT_ID = os.environ.get('INTACCT_CLIENT_ID') +INTACCT_CLIENT_SECRET = os.environ.get('INTACCT_CLIENT_SECRET') INTEGRATIONS_SETTINGS_API = os.environ.get('INTEGRATIONS_SETTINGS_API') INTACCT_INTEGRATION_APP_URL = os.environ.get('INTACCT_INTEGRATION_APP_URL') INTEGRATIONS_APP_URL = os.environ.get('INTEGRATIONS_APP_URL') diff --git a/tests/test_sageintacct/conftest.py b/tests/test_sageintacct/conftest.py index d3cb176d..9231b844 100644 --- a/tests/test_sageintacct/conftest.py +++ b/tests/test_sageintacct/conftest.py @@ -1,4 +1,5 @@ import pytest +from unittest import mock from datetime import datetime @@ -6,7 +7,7 @@ from apps.tasks.models import TaskLog from apps.mappings.models import GeneralMapping -from apps.workspaces.models import Configuration +from apps.workspaces.models import Configuration, Workspace, IntacctSyncedTimestamp from apps.fyle.models import ( Expense, ExpenseGroup, @@ -25,7 +26,8 @@ ChargeCardTransactionLineitem, APPayment, APPaymentLineitem, - CostType + CostType, + SageIntacctAttributesCount, ) @@ -341,3 +343,136 @@ def add_project_mappings(db): active=True, code='10064' ) + + +@pytest.fixture +def mock_intacct_sdk(): + """Mock the IntacctRESTSDK""" + with mock.patch('apps.sage_intacct.connector.IntacctRESTSDK') as mock_sdk: + mock_instance = mock.Mock() + mock_instance.access_token = 'mock_access_token' + mock_instance.access_token_expires_in = 21600 + mock_sdk.return_value = mock_instance + yield mock_sdk, mock_instance + + +@pytest.fixture +def mock_sage_intacct_sdk(): + """Mock the SageIntacctSDK""" + with mock.patch('apps.sage_intacct.connector.SageIntacctSDK') as mock_sdk: + mock_instance = mock.Mock() + mock_sdk.return_value = mock_instance + yield mock_sdk, mock_instance + + +@pytest.fixture +def create_intacct_synced_timestamp(db): + """Create IntacctSyncedTimestamp for workspace_id=1""" + timestamp, _ = IntacctSyncedTimestamp.objects.get_or_create( + workspace_id=1, + defaults={ + 'account_synced_at': None, + 'vendor_synced_at': None, + 'customer_synced_at': None, + 'class_synced_at': None, + 'employee_synced_at': None, + 'item_synced_at': None, + 'location_synced_at': None, + 'department_synced_at': None, + 'project_synced_at': None, + 'expense_type_synced_at': None, + 'location_entity_synced_at': None, + 'payment_account_synced_at': None, + 'expense_payment_type_synced_at': None, + 'allocation_synced_at': None, + 'tax_detail_synced_at': None, + } + ) + return timestamp + + +@pytest.fixture +def create_sage_intacct_attributes_count(db): + """Create SageIntacctAttributesCount for workspace_id=1""" + workspace = Workspace.objects.get(id=1) + count, _ = SageIntacctAttributesCount.objects.get_or_create( + workspace=workspace, + defaults={ + 'accounts_count': 0, + 'vendors_count': 0, + } + ) + return count + + +@pytest.fixture +def create_existing_vendor_attribute(db): + """Create an existing VENDOR DestinationAttribute for testing""" + attribute = DestinationAttribute.objects.create( + workspace_id=1, + attribute_type='VENDOR', + value='Existing Vendor', + destination_id='VND_EXISTING', + active=True + ) + return attribute + + +@pytest.fixture +def create_tax_detail_attribute(db): + """ + Create TAX_DETAIL DestinationAttribute for tax calculations + """ + workspace_id = 1 + + attribute = DestinationAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='TAX_DETAIL', + display_name='Tax Detail', + value='GST 10%', + destination_id='TAX001', + detail={'tax_rate': 10}, + active=True + ) + + return attribute + + +@pytest.fixture +def create_tax_detail_with_solution_id(db): + """ + Create TAX_DETAIL DestinationAttribute with tax_solution_id + """ + workspace_id = 1 + + attribute = DestinationAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='TAX_DETAIL', + display_name='Tax Detail', + value='TestTaxCode', + destination_id='TAX_TEST', + detail={'tax_solution_id': 'TAX_SOL_001'}, + active=True + ) + + return attribute + + +@pytest.fixture +def create_allocation_attribute(db): + """ + Create ALLOCATION DestinationAttribute for allocation tests + """ + workspace_id = 1 + + attribute = DestinationAttribute.objects.create( + workspace_id=workspace_id, + attribute_type='ALLOCATION', + display_name='Allocation', + value='ALLOC001', + destination_id='ALLOC001', + detail={'location': 'LOC001', 'department': 'DEPT001'}, + active=True + ) + + return attribute diff --git a/tests/test_sageintacct/exports/__init__.py b/tests/test_sageintacct/exports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_sageintacct/exports/test_ap_payments.py b/tests/test_sageintacct/exports/test_ap_payments.py new file mode 100644 index 00000000..9ba1c774 --- /dev/null +++ b/tests/test_sageintacct/exports/test_ap_payments.py @@ -0,0 +1,92 @@ +from datetime import datetime + +from apps.sage_intacct.exports.ap_payments import construct_ap_payment_payload +from tests.test_sageintacct.fixtures import data + + +def test_construct_ap_payment_payload(db, create_ap_payment): + """ + Test construct_ap_payment_payload creates correct payload + """ + workspace_id = 1 + ap_payment, ap_payment_lineitems = create_ap_payment + + payloads = construct_ap_payment_payload( + workspace_id=workspace_id, + ap_payment=ap_payment, + ap_payment_line_items=ap_payment_lineitems + ) + + assert payloads is not None + assert len(payloads) == len(ap_payment_lineitems) + + for payload in payloads: + for key in data['ap_payment_payload_expected_keys']: + assert key in payload + assert payload['financialEntity']['id'] == ap_payment.payment_account_id + assert payload['baseCurrency']['currency'] == ap_payment.currency + assert payload['txnCurrency']['currency'] == ap_payment.currency + assert payload['paymentMethod'] == 'Cash' + assert payload['vendor']['id'] == ap_payment.vendor_id + + +def test_construct_ap_payment_payload_details(db, create_ap_payment): + """ + Test construct_ap_payment_payload creates correct details structure + """ + workspace_id = 1 + ap_payment, ap_payment_lineitems = create_ap_payment + + payloads = construct_ap_payment_payload( + workspace_id=workspace_id, + ap_payment=ap_payment, + ap_payment_line_items=ap_payment_lineitems + ) + + for i, payload in enumerate(payloads): + details = payload['details'] + assert len(details) == 1 + + detail = details[0] + for key in data['ap_payment_detail_expected_keys']: + assert key in detail + assert detail['txnCurrency']['paymentAmount'] == str(ap_payment_lineitems[i].amount) + assert detail['bill']['key'] == ap_payment_lineitems[i].record_key + + +def test_construct_ap_payment_payload_payment_date(db, create_ap_payment): + """ + Test construct_ap_payment_payload has today's date as payment date + """ + workspace_id = 1 + ap_payment, ap_payment_lineitems = create_ap_payment + + payloads = construct_ap_payment_payload( + workspace_id=workspace_id, + ap_payment=ap_payment, + ap_payment_line_items=ap_payment_lineitems + ) + + today_date = datetime.today().strftime('%Y-%m-%d') + + for payload in payloads: + assert payload['paymentDate'] == today_date + + +def test_construct_ap_payment_payload_multiple_line_items(db, create_ap_payment): + """ + Test construct_ap_payment_payload with multiple line items + """ + workspace_id = 1 + ap_payment, ap_payment_lineitems = create_ap_payment + + payloads = construct_ap_payment_payload( + workspace_id=workspace_id, + ap_payment=ap_payment, + ap_payment_line_items=ap_payment_lineitems + ) + + assert len(payloads) == len(ap_payment_lineitems) + + for i, payload in enumerate(payloads): + assert payload['details'][0]['bill']['key'] == ap_payment_lineitems[i].record_key diff --git a/tests/test_sageintacct/exports/test_bills.py b/tests/test_sageintacct/exports/test_bills.py new file mode 100644 index 00000000..b73bafcd --- /dev/null +++ b/tests/test_sageintacct/exports/test_bills.py @@ -0,0 +1,135 @@ +from apps.workspaces.models import Configuration +from apps.sage_intacct.exports.bills import ( + construct_bill_payload, + construct_bill_line_item_payload, +) +from tests.test_sageintacct.fixtures import data + + +def test_construct_bill_payload(db, create_bill): + """ + Test construct_bill_payload creates correct payload + """ + workspace_id = 1 + bill, bill_lineitems = create_bill + + payload = construct_bill_payload( + workspace_id=workspace_id, + bill=bill, + bill_line_items=bill_lineitems + ) + + assert payload is not None + for key in data['bill_payload_expected_keys']: + assert key in payload + assert payload['vendor']['id'] == bill.vendor_id + assert payload['currency']['baseCurrency'] == bill.currency + assert payload['currency']['txnCurrency'] == bill.currency + assert len(payload['lines']) == len(bill_lineitems) + + +def test_construct_bill_payload_with_tax_codes(db, create_bill): + """ + Test construct_bill_payload with import_tax_codes enabled + """ + workspace_id = 1 + bill, bill_lineitems = create_bill + + configuration = Configuration.objects.get(workspace_id=workspace_id) + configuration.import_tax_codes = True + configuration.save() + + payload = construct_bill_payload( + workspace_id=workspace_id, + bill=bill, + bill_line_items=bill_lineitems + ) + + assert payload is not None + assert 'isTaxInclusive' in payload + assert payload['isTaxInclusive'] is False + assert 'taxSolution' in payload + + +def test_construct_bill_payload_without_supdoc_id(db, create_bill): + """ + Test construct_bill_payload when supdoc_id is None + """ + workspace_id = 1 + bill, bill_lineitems = create_bill + + bill.supdoc_id = None + bill.save() + + payload = construct_bill_payload( + workspace_id=workspace_id, + bill=bill, + bill_line_items=bill_lineitems + ) + + assert payload['attachment']['id'] is None + + +def test_construct_bill_line_item_payload(db, create_bill): + """ + Test construct_bill_line_item_payload creates correct line item payload + """ + workspace_id = 1 + _, bill_lineitems = create_bill + + line_item_payloads = construct_bill_line_item_payload( + workspace_id=workspace_id, + bill_line_items=bill_lineitems + ) + + assert line_item_payloads is not None + assert len(line_item_payloads) == len(bill_lineitems) + + for payload in line_item_payloads: + for key in data['bill_line_item_expected_keys']: + assert key in payload + for key in data['bill_dimensions_expected_keys']: + assert key in payload['dimensions'] + + +def test_construct_bill_line_item_payload_with_tax_code(db, create_bill): + """ + Test construct_bill_line_item_payload with tax code in line item + """ + workspace_id = 1 + _, bill_lineitems = create_bill + + for lineitem in bill_lineitems: + lineitem.tax_code = 'TAX001' + lineitem.tax_amount = 10.0 + lineitem.save() + + line_item_payloads = construct_bill_line_item_payload( + workspace_id=workspace_id, + bill_line_items=bill_lineitems + ) + + assert line_item_payloads is not None + for payload in line_item_payloads: + assert payload['taxEntries'][0]['purchasingTaxDetail']['id'] == 'TAX001' + + +def test_construct_bill_line_item_payload_refund_case(db, create_bill): + """ + Test construct_bill_line_item_payload for refund (negative amount) + """ + workspace_id = 1 + _, bill_lineitems = create_bill + + for lineitem in bill_lineitems: + lineitem.amount = -50.0 + lineitem.save() + + line_item_payloads = construct_bill_line_item_payload( + workspace_id=workspace_id, + bill_line_items=bill_lineitems + ) + + assert line_item_payloads is not None + for payload in line_item_payloads: + assert float(payload['txnAmount']) <= 0 diff --git a/tests/test_sageintacct/exports/test_charge_card_transactions.py b/tests/test_sageintacct/exports/test_charge_card_transactions.py new file mode 100644 index 00000000..511022ac --- /dev/null +++ b/tests/test_sageintacct/exports/test_charge_card_transactions.py @@ -0,0 +1,132 @@ +from apps.workspaces.models import Configuration +from apps.sage_intacct.exports.charge_card_transactions import ( + construct_charge_card_transaction_payload, + construct_charge_card_transaction_line_item_payload, +) +from tests.test_sageintacct.fixtures import data + + +def test_construct_charge_card_transaction_payload(db, create_charge_card_transaction): + """ + Test construct_charge_card_transaction_payload creates correct payload + """ + workspace_id = 1 + cct, cct_lineitems = create_charge_card_transaction + + payload = construct_charge_card_transaction_payload( + workspace_id=workspace_id, + charge_card_transaction=cct, + charge_card_transaction_line_items=cct_lineitems + ) + + assert payload is not None + for key in data['charge_card_transaction_payload_expected_keys']: + assert key in payload + assert payload['creditCardAccount']['id'] == cct.charge_card_id + assert payload['currency']['baseCurrency'] == cct.currency + assert payload['currency']['txnCurrency'] == cct.currency + assert len(payload['lines']) == len(cct_lineitems) + + +def test_construct_charge_card_transaction_payload_with_tax_codes(db, create_charge_card_transaction): + """ + Test construct_charge_card_transaction_payload with import_tax_codes enabled + """ + workspace_id = 1 + cct, cct_lineitems = create_charge_card_transaction + + configuration = Configuration.objects.get(workspace_id=workspace_id) + configuration.import_tax_codes = True + configuration.save() + + payload = construct_charge_card_transaction_payload( + workspace_id=workspace_id, + charge_card_transaction=cct, + charge_card_transaction_line_items=cct_lineitems + ) + + assert payload['isInclusiveTax'] is True + + +def test_construct_charge_card_transaction_payload_without_tax_codes(db, create_charge_card_transaction): + """ + Test construct_charge_card_transaction_payload without import_tax_codes + """ + workspace_id = 1 + cct, cct_lineitems = create_charge_card_transaction + + configuration = Configuration.objects.get(workspace_id=workspace_id) + configuration.import_tax_codes = False + configuration.save() + + payload = construct_charge_card_transaction_payload( + workspace_id=workspace_id, + charge_card_transaction=cct, + charge_card_transaction_line_items=cct_lineitems + ) + + assert payload['isInclusiveTax'] is False + + +def test_construct_charge_card_transaction_payload_without_supdoc_id(db, create_charge_card_transaction): + """ + Test construct_charge_card_transaction_payload when supdoc_id is None + """ + workspace_id = 1 + cct, cct_lineitems = create_charge_card_transaction + + cct.supdoc_id = None + cct.save() + + payload = construct_charge_card_transaction_payload( + workspace_id=workspace_id, + charge_card_transaction=cct, + charge_card_transaction_line_items=cct_lineitems + ) + + assert payload['attachment']['id'] is None + + +def test_construct_charge_card_transaction_line_item_payload(db, create_charge_card_transaction): + """ + Test construct_charge_card_transaction_line_item_payload creates correct payload + """ + workspace_id = 1 + cct, cct_lineitems = create_charge_card_transaction + + line_item_payloads = construct_charge_card_transaction_line_item_payload( + workspace_id=workspace_id, + charge_card_transaction=cct, + charge_card_transaction_line_items=cct_lineitems + ) + + assert line_item_payloads is not None + assert len(line_item_payloads) == len(cct_lineitems) + + for payload in line_item_payloads: + for key in data['charge_card_transaction_line_item_expected_keys']: + assert key in payload + for key in data['charge_card_transaction_dimensions_expected_keys']: + assert key in payload['dimensions'] + + +def test_construct_charge_card_transaction_line_item_payload_with_tax(db, create_charge_card_transaction): + """ + Test construct_charge_card_transaction_line_item_payload with tax code + """ + workspace_id = 1 + cct, cct_lineitems = create_charge_card_transaction + + for lineitem in cct_lineitems: + lineitem.tax_code = 'TAX001' + lineitem.tax_amount = 10.0 + lineitem.save() + + line_item_payloads = construct_charge_card_transaction_line_item_payload( + workspace_id=workspace_id, + charge_card_transaction=cct, + charge_card_transaction_line_items=cct_lineitems + ) + + for payload in line_item_payloads: + assert payload['taxEntries'][0]['purchasingTaxDetail']['id'] == 'TAX001' diff --git a/tests/test_sageintacct/exports/test_expense_reports.py b/tests/test_sageintacct/exports/test_expense_reports.py new file mode 100644 index 00000000..d80eccd2 --- /dev/null +++ b/tests/test_sageintacct/exports/test_expense_reports.py @@ -0,0 +1,133 @@ +from apps.sage_intacct.exports.expense_reports import ( + construct_expense_report_payload, + construct_expense_report_line_item_payload, +) +from tests.test_sageintacct.fixtures import data + + +def test_construct_expense_report_payload(db, create_expense_report): + """ + Test construct_expense_report_payload creates correct payload + """ + workspace_id = 1 + expense_report, expense_report_lineitems = create_expense_report + + payload = construct_expense_report_payload( + workspace_id=workspace_id, + expense_report=expense_report, + expense_report_line_items=expense_report_lineitems + ) + + assert payload is not None + for key in data['expense_report_payload_expected_keys']: + assert key in payload + assert payload['state'] == 'submitted' + assert payload['employee']['id'] == expense_report.employee_id + assert payload['basePayment']['baseCurrency'] == expense_report.currency + assert len(payload['lines']) == len(expense_report_lineitems) + + +def test_construct_expense_report_payload_without_supdoc_id(db, create_expense_report): + """ + Test construct_expense_report_payload when supdoc_id is None + """ + workspace_id = 1 + expense_report, expense_report_lineitems = create_expense_report + + expense_report.supdoc_id = None + expense_report.save() + + payload = construct_expense_report_payload( + workspace_id=workspace_id, + expense_report=expense_report, + expense_report_line_items=expense_report_lineitems + ) + + assert payload['attachment']['id'] is None + + +def test_construct_expense_report_line_item_payload(db, create_expense_report): + """ + Test construct_expense_report_line_item_payload creates correct line item payload + """ + workspace_id = 1 + _, expense_report_lineitems = create_expense_report + + line_item_payloads = construct_expense_report_line_item_payload( + workspace_id=workspace_id, + expense_report_line_items=expense_report_lineitems + ) + + assert line_item_payloads is not None + assert len(line_item_payloads) == len(expense_report_lineitems) + + for payload in line_item_payloads: + for key in data['expense_report_line_item_expected_keys']: + assert key in payload + for key in data['expense_report_dimensions_expected_keys']: + assert key in payload['dimensions'] + + +def test_construct_expense_report_line_item_payload_with_expense_type(db, create_expense_report): + """ + Test construct_expense_report_line_item_payload with expense_type_id + """ + workspace_id = 1 + _, expense_report_lineitems = create_expense_report + + for lineitem in expense_report_lineitems: + lineitem.expense_type_id = 'EXP_TYPE_001' + lineitem.gl_account_number = None + lineitem.save() + + line_item_payloads = construct_expense_report_line_item_payload( + workspace_id=workspace_id, + expense_report_line_items=expense_report_lineitems + ) + + for payload in line_item_payloads: + assert payload['expenseType']['id'] == 'EXP_TYPE_001' + assert payload['glAccount']['id'] is None + + +def test_construct_expense_report_line_item_payload_with_gl_account(db, create_expense_report): + """ + Test construct_expense_report_line_item_payload with gl_account_number + """ + workspace_id = 1 + _, expense_report_lineitems = create_expense_report + + for lineitem in expense_report_lineitems: + lineitem.expense_type_id = None + lineitem.gl_account_number = 'GL_ACC_001' + lineitem.save() + + line_item_payloads = construct_expense_report_line_item_payload( + workspace_id=workspace_id, + expense_report_line_items=expense_report_lineitems + ) + + for payload in line_item_payloads: + assert payload['expenseType']['id'] is None + assert payload['glAccount']['id'] == 'GL_ACC_001' + + +def test_construct_expense_report_line_item_payload_with_tax(db, create_expense_report): + """ + Test construct_expense_report_line_item_payload with tax code and amount + """ + workspace_id = 1 + _, expense_report_lineitems = create_expense_report + + for lineitem in expense_report_lineitems: + lineitem.tax_code = 'TAX001' + lineitem.tax_amount = 10.0 + lineitem.save() + + line_item_payloads = construct_expense_report_line_item_payload( + workspace_id=workspace_id, + expense_report_line_items=expense_report_lineitems + ) + + assert line_item_payloads is not None + assert len(line_item_payloads) > 0 diff --git a/tests/test_sageintacct/exports/test_helpers.py b/tests/test_sageintacct/exports/test_helpers.py new file mode 100644 index 00000000..89b0ed1f --- /dev/null +++ b/tests/test_sageintacct/exports/test_helpers.py @@ -0,0 +1,235 @@ +from datetime import datetime +from unittest.mock import Mock + +from apps.fyle.models import ExpenseGroup, ExpenseGroupSettings +from apps.mappings.models import GeneralMapping, LocationEntityMapping +from apps.workspaces.models import Configuration +from apps.sage_intacct.models import BillLineitem +from apps.sage_intacct.exports.helpers import ( + format_transaction_date, + get_tax_exclusive_amount, + get_tax_solution_id_or_none, + get_location_id_for_journal_entry, + get_source_entity_id, +) + + +def test_format_transaction_date_with_string(db): + """ + Test format_transaction_date with string input + """ + date_string = '2024-01-15T10:30:00' + result = format_transaction_date(date_string) + + assert result == '2024-01-15' + + +def test_format_transaction_date_with_datetime(db): + """ + Test format_transaction_date with datetime input + """ + date_obj = datetime(2024, 1, 15, 10, 30, 0) + result = format_transaction_date(date_obj) + + assert result == '2024-01-15' + + +def test_get_tax_exclusive_amount_with_tax_attribute(db, create_tax_detail_attribute): + """ + Test get_tax_exclusive_amount with tax attribute present + """ + workspace_id = 1 + + tax_exclusive_amount, tax_amount = get_tax_exclusive_amount( + workspace_id=workspace_id, + amount=110, + default_tax_code_id='TAX001' + ) + + assert tax_exclusive_amount == 100.0 + assert tax_amount == 10.0 + + +def test_get_tax_exclusive_amount_without_tax_attribute(db): + """ + Test get_tax_exclusive_amount when tax attribute is not found + """ + workspace_id = 1 + + tax_exclusive_amount, tax_amount = get_tax_exclusive_amount( + workspace_id=workspace_id, + amount=100, + default_tax_code_id='NON_EXISTENT_TAX' + ) + + assert tax_exclusive_amount == 100 + assert tax_amount is None + + +def test_get_tax_solution_id_or_none_with_location_entity(db): + """ + Test get_tax_solution_id_or_none when location entity is set + """ + workspace_id = 1 + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + general_mappings.location_entity_id = 'LOC_ENTITY_001' + general_mappings.save() + + line_items = list(BillLineitem.objects.filter(bill__expense_group__workspace_id=workspace_id)[:1]) + + if line_items: + result = get_tax_solution_id_or_none( + workspace_id=workspace_id, + line_items=line_items + ) + assert result is None + + +def test_get_tax_solution_id_or_none_with_tax_code(db, create_tax_detail_with_solution_id): + """ + Test get_tax_solution_id_or_none when tax code is present in line item + """ + workspace_id = 1 + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + general_mappings.location_entity_id = None + general_mappings.default_tax_code_name = 'Default Tax' + general_mappings.save() + + mock_line_item = Mock() + mock_line_item.tax_code = 'TestTaxCode' + + result = get_tax_solution_id_or_none( + workspace_id=workspace_id, + line_items=[mock_line_item] + ) + + assert result == 'TAX_SOL_001' + + +def test_get_location_id_for_journal_entry_with_general_mapping(db): + """ + Test get_location_id_for_journal_entry with default_location_id set + """ + workspace_id = 1 + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + general_mappings.default_location_id = 'DEFAULT_LOC_001' + general_mappings.save() + + result = get_location_id_for_journal_entry(workspace_id=workspace_id) + + assert result == 'DEFAULT_LOC_001' + + +def test_get_location_id_for_journal_entry_with_location_entity_mapping(db): + """ + Test get_location_id_for_journal_entry with LocationEntityMapping + """ + workspace_id = 1 + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + general_mappings.default_location_id = None + general_mappings.save() + + LocationEntityMapping.objects.update_or_create( + workspace_id=workspace_id, + defaults={ + 'location_entity_name': 'Test Location', + 'destination_id': 'LOC_ENTITY_DEST_001' + } + ) + + result = get_location_id_for_journal_entry(workspace_id=workspace_id) + + assert result == 'LOC_ENTITY_DEST_001' + + +def test_get_location_id_for_journal_entry_returns_none(db): + """ + Test get_location_id_for_journal_entry returns None when no mapping found + """ + workspace_id = 1 + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + general_mappings.default_location_id = None + general_mappings.save() + + LocationEntityMapping.objects.filter(workspace_id=workspace_id).delete() + + result = get_location_id_for_journal_entry(workspace_id=workspace_id) + + assert result is None + + +def test_get_source_entity_id_returns_location_id(db): + """ + Test get_source_entity_id returns location_id when all conditions are met + """ + workspace_id = 1 + + LocationEntityMapping.objects.update_or_create( + workspace_id=workspace_id, + defaults={ + 'location_entity_name': 'Top Level', + 'destination_id': 'top_level' + } + ) + + configuration = Configuration.objects.get(workspace_id=workspace_id) + configuration.je_single_credit_line = True + configuration.save() + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + general_mappings.default_location_id = 'DEFAULT_LOC_001' + general_mappings.save() + + expense_group = ExpenseGroup.objects.get(id=1) + expense_group.fund_source = 'PERSONAL' + expense_group.save() + + expense_group_settings, _ = ExpenseGroupSettings.objects.get_or_create( + workspace_id=workspace_id, + defaults={ + 'reimbursable_expense_group_fields': ['report_id'], + 'corporate_credit_card_expense_group_fields': ['report_id'], + 'expense_state': 'PAYMENT_PROCESSING', + 'reimbursable_export_date_type': 'current_date', + 'ccc_export_date_type': 'current_date' + } + ) + expense_group_settings.reimbursable_expense_group_fields = ['report_id'] + expense_group_settings.save() + + result = get_source_entity_id( + workspace_id=workspace_id, + configuration=configuration, + general_mappings=general_mappings, + expense_group=expense_group + ) + + assert result == 'DEFAULT_LOC_001' + + +def test_get_source_entity_id_returns_none_when_conditions_not_met(db): + """ + Test get_source_entity_id returns None when conditions are not met + """ + workspace_id = 1 + + configuration = Configuration.objects.get(workspace_id=workspace_id) + configuration.je_single_credit_line = False + configuration.save() + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + expense_group = ExpenseGroup.objects.get(id=1) + + result = get_source_entity_id( + workspace_id=workspace_id, + configuration=configuration, + general_mappings=general_mappings, + expense_group=expense_group + ) + + assert result is None diff --git a/tests/test_sageintacct/exports/test_journal_entries.py b/tests/test_sageintacct/exports/test_journal_entries.py new file mode 100644 index 00000000..1053fc49 --- /dev/null +++ b/tests/test_sageintacct/exports/test_journal_entries.py @@ -0,0 +1,391 @@ +from unittest import mock + +from apps.workspaces.models import Configuration +from apps.mappings.models import GeneralMapping +from apps.sage_intacct.exports.journal_entries import ( + construct_journal_entry_payload, + construct_debit_line_payload, + construct_credit_line_payload, + construct_single_itemized_credit_line, + construct_multiple_itemized_credit_line, +) +from tests.test_sageintacct.fixtures import data + + +def test_construct_journal_entry_payload(db, create_journal_entry): + """ + Test construct_journal_entry_payload creates correct payload + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + with mock.patch('apps.sage_intacct.exports.journal_entries.settings') as mock_settings: + mock_settings.BRAND_ID = 'fyle' + + payload = construct_journal_entry_payload( + workspace_id=workspace_id, + journal_entry=journal_entry, + journal_entry_line_items=journal_entry_lineitems + ) + + assert payload is not None + for key in data['journal_entry_payload_expected_keys']: + assert key in payload + assert payload['glJournal']['id'] == 'FYLE_JE' + assert len(payload['lines']) == len(journal_entry_lineitems) * 2 + + +def test_construct_journal_entry_payload_with_tax_codes(db, create_journal_entry): + """ + Test construct_journal_entry_payload with import_tax_codes enabled + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + configuration = Configuration.objects.get(workspace_id=workspace_id) + configuration.import_tax_codes = True + configuration.save() + + with mock.patch('apps.sage_intacct.exports.journal_entries.settings') as mock_settings: + mock_settings.BRAND_ID = 'fyle' + + payload = construct_journal_entry_payload( + workspace_id=workspace_id, + journal_entry=journal_entry, + journal_entry_line_items=journal_entry_lineitems + ) + + assert 'tax' in payload + assert payload['tax']['taxImplication'] == 'inbound' + + +def test_construct_journal_entry_payload_with_brand_id_em(db, create_journal_entry): + """ + Test construct_journal_entry_payload with EM brand + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + with mock.patch('apps.sage_intacct.exports.journal_entries.settings') as mock_settings: + mock_settings.BRAND_ID = 'em' + + payload = construct_journal_entry_payload( + workspace_id=workspace_id, + journal_entry=journal_entry, + journal_entry_line_items=journal_entry_lineitems + ) + + assert payload['glJournal']['id'] == 'EM_JOURNAL' + + +def test_construct_journal_entry_payload_without_supdoc_id(db, create_journal_entry): + """ + Test construct_journal_entry_payload when supdoc_id is None + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + journal_entry.supdoc_id = None + journal_entry.save() + + with mock.patch('apps.sage_intacct.exports.journal_entries.settings') as mock_settings: + mock_settings.BRAND_ID = 'fyle' + + payload = construct_journal_entry_payload( + workspace_id=workspace_id, + journal_entry=journal_entry, + journal_entry_line_items=journal_entry_lineitems + ) + + assert payload['attachment']['id'] is None + + +def test_construct_debit_line_payload(db, create_journal_entry): + """ + Test construct_debit_line_payload creates correct debit lines + """ + workspace_id = 1 + _, journal_entry_lineitems = create_journal_entry + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + debit_payloads = construct_debit_line_payload( + workspace_id=workspace_id, + line_items=journal_entry_lineitems, + general_mappings=general_mappings + ) + + assert debit_payloads is not None + assert len(debit_payloads) == len(journal_entry_lineitems) + + for payload in debit_payloads: + for key in data['journal_entry_debit_line_expected_keys']: + assert key in payload + + +def test_construct_debit_line_payload_with_negative_amount(db, create_journal_entry): + """ + Test construct_debit_line_payload with negative amount (refund) + """ + workspace_id = 1 + _, journal_entry_lineitems = create_journal_entry + + for lineitem in journal_entry_lineitems: + lineitem.amount = -50.0 + lineitem.save() + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + debit_payloads = construct_debit_line_payload( + workspace_id=workspace_id, + line_items=journal_entry_lineitems, + general_mappings=general_mappings + ) + + for payload in debit_payloads: + assert payload['txnType'] == 'credit' + + +def test_construct_debit_line_payload_with_allocation(db, create_journal_entry, create_allocation_attribute): + """ + Test construct_debit_line_payload with allocation + """ + workspace_id = 1 + _, journal_entry_lineitems = create_journal_entry + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + debit_payloads = construct_debit_line_payload( + workspace_id=workspace_id, + line_items=journal_entry_lineitems, + general_mappings=general_mappings + ) + + assert debit_payloads is not None + assert len(debit_payloads) == len(journal_entry_lineitems) + + +def test_construct_credit_line_payload_single_line(db, create_journal_entry): + """ + Test construct_credit_line_payload with single credit line configuration + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + configuration = Configuration.objects.get(workspace_id=workspace_id) + configuration.je_single_credit_line = True + configuration.save() + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + credit_payloads = construct_credit_line_payload( + workspace_id=workspace_id, + journal_entry=journal_entry, + line_items=journal_entry_lineitems, + configuration=configuration, + general_mappings=general_mappings + ) + + assert credit_payloads is not None + + +def test_construct_credit_line_payload_multiple_lines(db, create_journal_entry): + """ + Test construct_credit_line_payload with multiple credit lines configuration + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + configuration = Configuration.objects.get(workspace_id=workspace_id) + configuration.je_single_credit_line = False + configuration.save() + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + credit_payloads = construct_credit_line_payload( + workspace_id=workspace_id, + journal_entry=journal_entry, + line_items=journal_entry_lineitems, + configuration=configuration, + general_mappings=general_mappings + ) + + assert credit_payloads is not None + assert len(credit_payloads) == len(journal_entry_lineitems) + + +def test_construct_single_itemized_credit_line(db, create_journal_entry): + """ + Test construct_single_itemized_credit_line creates correct payload + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + credit_payloads = construct_single_itemized_credit_line( + workspace_id=workspace_id, + journal_entry=journal_entry, + line_items=journal_entry_lineitems, + general_mappings=general_mappings + ) + + assert credit_payloads is not None + + for payload in credit_payloads: + for key in data['journal_entry_credit_line_expected_keys']: + assert key in payload + assert payload['description'] == 'Total Credit Line' + + +def test_construct_single_itemized_credit_line_skips_zero_amount(db, create_journal_entry): + """ + Test construct_single_itemized_credit_line skips zero total amount + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + for lineitem in journal_entry_lineitems: + lineitem.amount = 0 + lineitem.save() + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + credit_payloads = construct_single_itemized_credit_line( + workspace_id=workspace_id, + journal_entry=journal_entry, + line_items=journal_entry_lineitems, + general_mappings=general_mappings + ) + + assert len(credit_payloads) == 0 + + +def test_construct_single_itemized_credit_line_refund_case(db, create_journal_entry): + """ + Test construct_single_itemized_credit_line with negative amount (refund) + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + for lineitem in journal_entry_lineitems: + lineitem.amount = -50.0 + lineitem.vendor_id = 'VENDOR001' + lineitem.save() + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + credit_payloads = construct_single_itemized_credit_line( + workspace_id=workspace_id, + journal_entry=journal_entry, + line_items=journal_entry_lineitems, + general_mappings=general_mappings + ) + + for payload in credit_payloads: + assert payload['txnType'] == 'debit' + + +def test_construct_multiple_itemized_credit_line(db, create_journal_entry): + """ + Test construct_multiple_itemized_credit_line creates correct payload + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + configuration = Configuration.objects.get(workspace_id=workspace_id) + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + credit_payloads = construct_multiple_itemized_credit_line( + workspace_id=workspace_id, + journal_entry=journal_entry, + line_items=journal_entry_lineitems, + configuration=configuration, + general_mappings=general_mappings + ) + + assert credit_payloads is not None + assert len(credit_payloads) == len(journal_entry_lineitems) + + for payload in credit_payloads: + for key in data['journal_entry_credit_line_expected_keys']: + assert key in payload + + +def test_construct_multiple_itemized_credit_line_with_billable(db, create_journal_entry): + """ + Test construct_multiple_itemized_credit_line with billable credit line + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + configuration = Configuration.objects.get(workspace_id=workspace_id) + configuration.is_journal_credit_billable = True + configuration.save() + + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + + credit_payloads = construct_multiple_itemized_credit_line( + workspace_id=workspace_id, + journal_entry=journal_entry, + line_items=journal_entry_lineitems, + configuration=configuration, + general_mappings=general_mappings + ) + + for payload in credit_payloads: + assert 'isBillable' in payload + + +def test_construct_multiple_itemized_credit_line_ccc_fund_source(db, create_journal_entry): + """ + Test construct_multiple_itemized_credit_line with CCC fund source + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + journal_entry.expense_group.fund_source = 'CCC' + journal_entry.expense_group.save() + + configuration = Configuration.objects.get(workspace_id=workspace_id) + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + general_mappings.default_credit_card_id = 'CC_ACCOUNT_001' + general_mappings.save() + + credit_payloads = construct_multiple_itemized_credit_line( + workspace_id=workspace_id, + journal_entry=journal_entry, + line_items=journal_entry_lineitems, + configuration=configuration, + general_mappings=general_mappings + ) + + for payload in credit_payloads: + assert payload['glAccount']['id'] == 'CC_ACCOUNT_001' + + +def test_construct_multiple_itemized_credit_line_personal_fund_source(db, create_journal_entry): + """ + Test construct_multiple_itemized_credit_line with PERSONAL fund source + """ + workspace_id = 1 + journal_entry, journal_entry_lineitems = create_journal_entry + + journal_entry.expense_group.fund_source = 'PERSONAL' + journal_entry.expense_group.save() + + configuration = Configuration.objects.get(workspace_id=workspace_id) + general_mappings = GeneralMapping.objects.get(workspace_id=workspace_id) + general_mappings.default_gl_account_id = 'GL_ACCOUNT_001' + general_mappings.save() + + credit_payloads = construct_multiple_itemized_credit_line( + workspace_id=workspace_id, + journal_entry=journal_entry, + line_items=journal_entry_lineitems, + configuration=configuration, + general_mappings=general_mappings + ) + + for payload in credit_payloads: + assert payload['glAccount']['id'] == 'GL_ACCOUNT_001' diff --git a/tests/test_sageintacct/exports/test_reimbursements.py b/tests/test_sageintacct/exports/test_reimbursements.py new file mode 100644 index 00000000..f73accf1 --- /dev/null +++ b/tests/test_sageintacct/exports/test_reimbursements.py @@ -0,0 +1,72 @@ +from datetime import datetime + +from apps.sage_intacct.exports.reimbursements import construct_reimbursement_payload +from tests.test_sageintacct.fixtures import data + + +def test_construct_reimbursement_payload(db, create_sage_intacct_reimbursement): + """ + Test construct_reimbursement_payload creates correct payload + """ + workspace_id = 1 + reimbursement, reimbursement_lineitems = create_sage_intacct_reimbursement + + payload = construct_reimbursement_payload( + workspace_id=workspace_id, + reimbursement=reimbursement, + reimbursement_line_items=reimbursement_lineitems + ) + + assert payload is not None + for key in data['reimbursement_payload_expected_keys']: + assert key in payload + assert payload['bankaccountid'] == reimbursement.account_id + assert payload['employeeid'] == reimbursement.employee_id + assert payload['paymentmethod'] == 'Cash' + assert 'year' in payload['paymentdate'] + assert 'month' in payload['paymentdate'] + assert 'day' in payload['paymentdate'] + assert 'eppaymentrequestitem' in payload['eppaymentrequestitems'] + + +def test_construct_reimbursement_payload_line_items(db, create_sage_intacct_reimbursement): + """ + Test construct_reimbursement_payload creates correct line item structure + """ + workspace_id = 1 + reimbursement, reimbursement_lineitems = create_sage_intacct_reimbursement + + payload = construct_reimbursement_payload( + workspace_id=workspace_id, + reimbursement=reimbursement, + reimbursement_line_items=reimbursement_lineitems + ) + + line_items = payload['eppaymentrequestitems']['eppaymentrequestitem'] + + assert len(line_items) == len(reimbursement_lineitems) + + for i, line_item in enumerate(line_items): + for key in data['reimbursement_line_item_expected_keys']: + assert key in line_item + assert line_item['key'] == reimbursement_lineitems[i].record_key + assert line_item['paymentamount'] == reimbursement_lineitems[i].amount + + +def test_construct_reimbursement_payload_payment_date(db, create_sage_intacct_reimbursement): + """ + Test construct_reimbursement_payload has correct payment date format + """ + workspace_id = 1 + reimbursement, reimbursement_lineitems = create_sage_intacct_reimbursement + + payload = construct_reimbursement_payload( + workspace_id=workspace_id, + reimbursement=reimbursement, + reimbursement_line_items=reimbursement_lineitems + ) + + today = datetime.now() + assert payload['paymentdate']['year'] == today.strftime('%Y') + assert payload['paymentdate']['month'] == today.strftime('%m') + assert payload['paymentdate']['day'] == today.strftime('%d') diff --git a/tests/test_sageintacct/fixtures.py b/tests/test_sageintacct/fixtures.py index adb5d7c4..0642e8ae 100644 --- a/tests/test_sageintacct/fixtures.py +++ b/tests/test_sageintacct/fixtures.py @@ -871,5 +871,83 @@ {'parent_expense_field_id': 379240, 'parent_expense_field_value': 'Administrative', 'expense_field_id': 379241, 'expense_field_value': 'Bond', 'is_enabled': True}, {'parent_expense_field_id': 379240, 'parent_expense_field_value': 'Administrative', 'expense_field_id': 379241, 'expense_field_value': 'Bond', 'is_enabled': True}, {'parent_expense_field_id': 379240, 'parent_expense_field_value': 'Administrative', 'expense_field_id': 379241, 'expense_field_value': 'Contingency Costs', 'is_enabled': True}, - ] + ], + 'bill_payload_expected_keys': ['createdDate', 'vendor', 'billNumber', 'dueDate', 'currency', 'attachment', 'lines'], + 'bill_line_item_expected_keys': ['glAccount', 'txnAmount', 'totalTxnAmount', 'memo', 'dimensions', 'taxEntries'], + 'bill_dimensions_expected_keys': ['location', 'department', 'project', 'customer'], + 'expense_report_payload_expected_keys': ['state', 'createdDate', 'description', 'employee', 'attachment', 'basePayment', 'reimbursement', 'lines'], + 'expense_report_line_item_expected_keys': ['txnAmount', 'entryDate', 'paidTo', 'dimensions'], + 'expense_report_dimensions_expected_keys': ['location', 'department', 'project'], + 'charge_card_transaction_payload_expected_keys': ['creditCardAccount', 'txnDate', 'referenceNumber', 'payee', 'description', 'currency', 'lines'], + 'charge_card_transaction_line_item_expected_keys': ['glAccount', 'description', 'txnAmount', 'totalTxnAmount', 'dimensions', 'taxEntries', 'isBillable'], + 'charge_card_transaction_dimensions_expected_keys': ['department', 'location', 'customer', 'project'], + 'journal_entry_payload_expected_keys': ['glJournal', 'postingDate', 'description', 'attachment', 'lines'], + 'journal_entry_debit_line_expected_keys': ['txnType', 'txnAmount', 'isBillable', 'description', 'glAccount', 'allocation', 'dimensions'], + 'journal_entry_credit_line_expected_keys': ['txnType', 'txnAmount', 'glAccount', 'description', 'dimensions'], + 'reimbursement_payload_expected_keys': ['bankaccountid', 'employeeid', 'memo', 'paymentmethod', 'paymentdate', 'eppaymentrequestitems', 'paymentdescription'], + 'reimbursement_line_item_expected_keys': ['key', 'paymentamount'], + 'ap_payment_payload_expected_keys': ['financialEntity', 'paymentDate', 'description', 'baseCurrency', 'txnCurrency', 'paymentMethod', 'vendor', 'details'], + 'ap_payment_detail_expected_keys': ['txnCurrency', 'bill'], + 'sage_intacct_rest_error_response': { + "ia::result": { + "ia::error": { + "code": "operationFailed", + "message": "POST request on objects/general-ledger/journal-entry object was unsuccessful", + "errorId": "REST-7001", + "additionalInfo": { + "messageId": "IA.REQUEST_ON_OBJECT_FAILED", + "placeholders": { + "OPERATION": "POST", + "RESOURCE_NAME": "objects/general-ledger/journal-entry" + }, + "propertySet": {} + }, + "supportId": "VXx2JWEB353%7EaTwyuP5M0Zo47mG-oN5_4wAAACg", + "details": [ + { + "errorId": "GL-0951", + "code": "unableToCreateRecordError", + "message": "Could not create GLBatch record.", + "additionalInfo": { + "messageId": "IA.COULD_NOT_CREATE_GLBATCH_RECORD", + "placeholders": {}, + "propertySet": {} + } + }, + { + "errorId": "GL-1009", + "code": "invalidConfiguration", + "message": "Transactions do not balance for Place ,", + "additionalInfo": { + "messageId": "IA.TRANSACTIONS_DO_NOT_BALANCE_FOR", + "placeholders": { + "RENAMED_TEXT": "Place", + "ERR_STR": ", " + }, + "propertySet": {} + } + } + ] + } + }, + "ia::meta": { + "totalCount": 1, + "totalSuccess": 0, + "totalError": 1 + } + }, + 'sage_intacct_rest_error_response_no_details': { + "ia::result": { + "ia::error": { + "code": "operationFailed", + "message": "Something went wrong with the export", + "errorId": "REST-7001" + } + }, + "ia::meta": { + "totalCount": 1, + "totalSuccess": 0, + "totalError": 1 + } + }, } diff --git a/tests/test_sageintacct/test_connector.py b/tests/test_sageintacct/test_connector.py new file mode 100644 index 00000000..24501194 --- /dev/null +++ b/tests/test_sageintacct/test_connector.py @@ -0,0 +1,2366 @@ +import json +import pytest + +from datetime import datetime, timezone, timedelta + +from django.utils import timezone as django_timezone + +from fyle_accounting_mappings.models import DestinationAttribute, ExpenseAttribute +from intacctsdk.exceptions import BadRequestError + +from apps.workspaces.models import ( + Workspace, + SageIntacctCredential, + Configuration, +) +from apps.sage_intacct.models import DependentFieldSetting +from apps.sage_intacct.connector import ( + SageIntacctRestConnector, + SageIntacctDimensionSyncManager, + SageIntacctObjectCreationManager, + SYNC_UPPER_LIMIT, + COST_TYPES_LIMIT, +) + + +def test_sage_intacct_rest_connector_init(db, mock_intacct_sdk): + """ + Test SageIntacctRestConnector initialization + """ + _, mock_instance = mock_intacct_sdk + mock_instance.sessions.get_session_id.return_value = {'sessionId': 'mock_session_id'} + + connector = SageIntacctRestConnector(workspace_id=1) + + assert connector.workspace_id == 1 + assert connector.connection is not None + + +def test_sage_intacct_rest_connector_get_session_id(db, mock_intacct_sdk): + """ + Test getting session id + """ + _, mock_instance = mock_intacct_sdk + mock_instance.sessions.get_session_id.return_value = {'sessionId': 'test_session_123'} + + connector = SageIntacctRestConnector(workspace_id=1) + session_id = connector.get_session_id() + + assert session_id == 'test_session_123' + + +def test_sage_intacct_rest_connector_get_soap_connection(db, mock_intacct_sdk, mock_sage_intacct_sdk): + """ + Test getting SOAP connection + """ + mock_rest_sdk_class, mock_rest_instance = mock_intacct_sdk + mock_soap_sdk_class, mock_soap_instance = mock_sage_intacct_sdk + mock_rest_instance.sessions.get_session_id.return_value = {'sessionId': 'mock_session_id'} + + connector = SageIntacctRestConnector(workspace_id=1) + soap_connection = connector.get_soap_connection() + + assert soap_connection is not None + mock_soap_sdk_class.assert_called_once() + + +def test_sage_intacct_rest_connector_with_cached_access_token(db, mock_intacct_sdk): + """ + Test connector uses cached access token when valid + """ + credential = SageIntacctCredential.objects.get(workspace_id=1) + credential.access_token = 'cached_token' + credential.access_token_expires_at = datetime.now(timezone.utc) + timedelta(hours=2) + credential.save() + + connector = SageIntacctRestConnector(workspace_id=1) + + assert connector.access_token == 'cached_token' + + +def test_sync_accounts(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing accounts from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.accounts.count.return_value = 5 + mock_instance.accounts.get_all_generator.return_value = iter([[ + {'id': 'ACC001', 'name': 'Test Account', 'accountType': 'Asset', 'status': 'active'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_accounts() + + mock_instance.accounts.count.assert_called_once() + mock_instance.accounts.get_all_generator.assert_called_once() + + +def test_sync_accounts_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync accounts is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + # Set count over limit + mock_instance.accounts.count.return_value = SYNC_UPPER_LIMIT + 1000 + + # Patch the timezone in connector module to use django's timezone + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + # Create workspace after October 2024 + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_accounts() + + mock_instance.accounts.count.assert_called_once() + mock_instance.accounts.get_all_generator.assert_not_called() + + +def test_sync_departments(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing departments from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.departments.count.return_value = 3 + mock_instance.departments.get_all_generator.return_value = iter([[ + {'id': 'DEPT001', 'name': 'Engineering', 'status': 'active'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_departments() + + mock_instance.departments.count.assert_called_once() + mock_instance.departments.get_all_generator.assert_called_once() + + +def test_sync_expense_types(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test syncing expense types from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + mocker.patch('apps.sage_intacct.connector.publish_to_rabbitmq') + + mock_instance.expense_types.count.return_value = 2 + mock_instance.expense_types.get_all_generator.return_value = iter([[ + { + 'id': 'EXP001', + 'description': 'Travel Expense', + 'glAccount.id': 'GL001', + 'glAccount.name': 'Travel', + 'status': 'active' + } + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_expense_types() + + mock_instance.expense_types.count.assert_called_once() + + +def test_sync_vendors(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing vendors from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.vendors.count.return_value = 2 + mock_instance.vendors.get_all_generator.return_value = iter([[ + {'id': 'VND001', 'name': 'Vendor One', 'status': 'active', 'contacts.default.email1': 'vendor@test.com'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_vendors() + + mock_instance.vendors.count.assert_called_once() + mock_instance.vendors.get_all_generator.assert_called_once() + + +def test_sync_employees(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing employees from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.employees.count.return_value = 1 + mock_instance.employees.get_all_generator.return_value = iter([[ + { + 'id': 'EMP001', + 'name': 'John Doe', + 'status': 'active', + 'primaryContact.email1': 'john@test.com', + 'primaryContact.printAs': 'John Doe', + 'department.id': 'DEPT001', + 'location.id': 'LOC001' + } + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_employees() + + mock_instance.employees.count.assert_called_once() + mock_instance.employees.get_all_generator.assert_called_once() + + +def test_sync_projects(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing projects from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.projects.count.return_value = 2 + mock_instance.projects.get_all_generator.return_value = iter([[ + { + 'id': 'PRJ001', + 'name': 'Project Alpha', + 'status': 'active', + 'customer.id': 'CUS001', + 'customer.name': 'Customer One', + 'isBillableEmployeeExpense': True, + 'isBillablePurchasingAPExpense': False + } + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_projects() + + mock_instance.projects.count.assert_called_once() + mock_instance.projects.get_all_generator.assert_called_once() + + +def test_sync_customers(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing customers from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.customers.count.return_value = 1 + mock_instance.customers.get_all_generator.return_value = iter([[ + {'id': 'CUS001', 'name': 'Customer One', 'status': 'active'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_customers() + + mock_instance.customers.count.assert_called_once() + mock_instance.customers.get_all_generator.assert_called_once() + + +def test_sync_classes(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing classes from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.classes.count.return_value = 1 + mock_instance.classes.get_all_generator.return_value = iter([[ + {'id': 'CLS001', 'name': 'Class One', 'status': 'active'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_classes() + + mock_instance.classes.count.assert_called_once() + mock_instance.classes.get_all_generator.assert_called_once() + + +def test_sync_locations(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing locations from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.locations.count.return_value = 1 + mock_instance.locations.get_all_generator.return_value = iter([[ + {'id': 'LOC001', 'name': 'New York', 'status': 'active'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_locations() + + mock_instance.locations.count.assert_called_once() + mock_instance.locations.get_all_generator.assert_called_once() + + +def test_sync_items(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing items from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.items.count.return_value = 1 + mock_instance.items.get_all_generator.return_value = iter([[ + {'id': 'ITM001', 'name': 'Item One', 'status': 'active', 'itemType': 'nonInventory'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_items() + + mock_instance.items.count.assert_called_once() + mock_instance.items.get_all_generator.assert_called_once() + + +def test_sync_tax_details(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing tax details from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.tax_details.count.return_value = 1 + mock_instance.tax_details.get_all_generator.return_value = iter([[ + {'id': 'TAX001', 'taxPercent': '10.0', 'taxSolution.id': 'SOL001', 'status': 'active', 'taxType': 'purchase'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_tax_details() + + mock_instance.tax_details.count.assert_called_once() + mock_instance.tax_details.get_all_generator.assert_called_once() + + +def test_sync_payment_accounts(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing payment accounts from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.checking_accounts.count.return_value = 1 + mock_instance.checking_accounts.get_all_generator.return_value = iter([[ + {'id': 'PA001', 'bankAccountDetails.bankName': 'Test Bank', 'status': 'active'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_payment_accounts() + + mock_instance.checking_accounts.count.assert_called_once() + mock_instance.checking_accounts.get_all_generator.assert_called_once() + + +def test_sync_charge_card_accounts(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing charge card accounts from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.charge_card_accounts.count.return_value = 1 + mock_instance.charge_card_accounts.get_all_generator.return_value = iter([[ + {'id': 'CC001', 'status': 'active'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_charge_card_accounts() + + mock_instance.charge_card_accounts.count.assert_called_once() + mock_instance.charge_card_accounts.get_all_generator.assert_called_once() + + +def test_sync_expense_payment_types(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing expense payment types from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.expense_payment_types.count.return_value = 1 + mock_instance.expense_payment_types.get_all_generator.return_value = iter([[ + {'key': 'EPT001', 'id': 'Cash', 'isNonReimbursable': False} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_expense_payment_types() + + mock_instance.expense_payment_types.count.assert_called_once() + mock_instance.expense_payment_types.get_all_generator.assert_called_once() + + +def test_sync_user_defined_dimensions(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing user defined dimensions from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.dimensions.list.return_value = [ + { + 'dimensionName': 'TestDim', + 'termName': 'Test Dimension', + 'isUserDefinedDimension': False, + 'dimensionEndpoint': 'endpoint::testdim' + } + ] + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_user_defined_dimensions() + + mock_instance.dimensions.list.assert_called_once() + mock_instance.dimensions.count.assert_not_called() + + +def test_sync_allocations(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing allocations from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.allocations.count.return_value = 1 + mock_instance.allocations.get_all_generator.return_value = iter([[ + {'id': 'ALLOC001', 'status': 'active', 'key': '1'} + ]]) + mock_instance.allocations.get_by_key.return_value = { + 'ia::result': { + 'id': 'ALLOC001', + 'key': '1', + 'lines': [{'dimensions': {'location': {'key': 'LOC001'}}}] + } + } + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_allocations() + + mock_instance.allocations.count.assert_called_once() + mock_instance.allocations.get_all_generator.assert_called_once() + + +def test_get_bills(db, mock_intacct_sdk): + """ + Test getting bills from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.bills.get_all_generator.return_value = iter([[ + {'key': 'BILL001', 'state': 'paid'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + bills = sync_manager.get_bills(bill_ids=['BILL001']) + + assert bills is not None + + +def test_get_expense_reports(db, mock_intacct_sdk): + """ + Test getting expense reports from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.expense_reports.get_all_generator.return_value = iter([[ + {'key': 'ER001', 'state': 'paid'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + expense_reports = sync_manager.get_expense_reports(expense_report_ids=['ER001']) + + assert expense_reports is not None + + +def test_sync_location_entities(db, mock_intacct_sdk, mock_sage_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing location entities from Sage Intacct + """ + _, mock_rest_instance = mock_intacct_sdk + _, mock_soap_instance = mock_sage_intacct_sdk + + mock_rest_instance.sessions.get_session_id.return_value = {'sessionId': 'test'} + mock_soap_instance.api_base.format_and_send_request.return_value = { + 'data': { + 'companypref': [ + {'preference': 'DISABLEENTITYSLIDEIN', 'prefvalue': 'false'} + ] + } + } + mock_rest_instance.location_entities.get_all_generator.return_value = iter([[ + {'id': 'LE001', 'name': 'Location Entity 1', 'operatingCountry': 'USA'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_location_entities() + + mock_rest_instance.location_entities.get_all_generator.assert_called_once() + + +def test_create_vendor(db, mock_intacct_sdk): + """ + Test creating a vendor in Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.vendors.post.return_value = { + 'ia::result': {'key': 'VND123'} + } + mock_instance.vendors.get_by_key.return_value = { + 'ia::result': {'id': 'VND123', 'name': 'Test Vendor'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + vendor = manager.create_vendor(vendor_id='VND001', vendor_name='Test Vendor', email='test@test.com') + + assert vendor is not None + assert vendor['id'] == 'VND123' + + +def test_create_contact(db, mock_intacct_sdk): + """ + Test creating a contact in Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.contacts.post.return_value = { + 'ia::result': {'key': 'CT123'} + } + mock_instance.contacts.get_by_key.return_value = { + 'ia::result': {'id': 'CT123', 'printAs': 'Test Contact'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + contact = manager.create_contact( + contact_id='CT001', + contact_name='Test Contact', + email='test@test.com', + first_name='Test', + last_name='Contact' + ) + + assert contact is not None + assert contact['id'] == 'CT123' + + +def test_get_or_create_vendor_existing(db, mock_intacct_sdk, create_existing_vendor_attribute): + """ + Test get_or_create_vendor returns existing vendor from database + """ + _, _ = mock_intacct_sdk + + manager = SageIntacctObjectCreationManager(workspace_id=1) + vendor = manager.get_or_create_vendor(vendor_name='Existing Vendor') + + assert vendor is not None + assert vendor.destination_id == 'VND_EXISTING' + + +def test_get_or_create_vendor_create_new(db, mock_intacct_sdk): + """ + Test get_or_create_vendor creates new vendor when not found + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.vendors.post.return_value = { + 'ia::result': {'key': 'NEW_VND'} + } + mock_instance.vendors.get_by_key.return_value = { + 'ia::result': {'id': 'NEW_VND', 'name': 'New Vendor'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + vendor = manager.get_or_create_vendor( + vendor_name='Brand New Vendor', + email='new@test.com', + create=True + ) + + assert vendor is not None + + +def test_search_and_create_vendors(db, mock_intacct_sdk): + """ + Test searching and creating vendors in Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.vendors.get_all_generator.return_value = iter([[ + {'id': 'VND001', 'name': 'Found Vendor', 'status': 'active', 'contacts.default.email1': 'found@test.com'} + ]]) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + manager.search_and_create_vendors(workspace_id=1, missing_vendors=['Found Vendor']) + + mock_instance.vendors.get_all_generator.assert_called_once() + + +def test_post_bill(db, mock_intacct_sdk, create_bill): + """ + Test posting a bill to Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + bill, bill_lineitems = create_bill + + mock_instance.bills.post.return_value = { + 'ia::result': {'id': '81035', 'key': '81035', 'href': '/objects/accounts-payable/bill/81035'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.post_bill(bill=bill, bill_line_items=bill_lineitems) + + assert result is not None + mock_instance.bills.post.assert_called_once() + + +def test_post_expense_report(db, mock_intacct_sdk, create_expense_report): + """ + Test posting an expense report to Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + expense_report, expense_report_lineitems = create_expense_report + + mock_instance.expense_reports.post.return_value = { + 'ia::result': {'id': '12345', 'key': '12345', 'href': '/objects/expense-management/expense-report/12345'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.post_expense_report(expense_report=expense_report, expense_report_line_items=expense_report_lineitems) + + assert result is not None + mock_instance.expense_reports.post.assert_called_once() + + +def test_post_charge_card_transaction(db, mock_intacct_sdk, create_charge_card_transaction): + """ + Test posting a charge card transaction to Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + cct, cct_lineitems = create_charge_card_transaction + + mock_instance.charge_card_transactions.post.return_value = { + 'ia::result': {'id': '81033', 'key': '81033', 'href': '/objects/cash-management/credit-card-txn/81033'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.post_charge_card_transaction(charge_card_transaction=cct, charge_card_transaction_line_items=cct_lineitems) + + assert result is not None + mock_instance.charge_card_transactions.post.assert_called_once() + + +def test_post_journal_entry(db, mock_intacct_sdk, create_journal_entry): + """ + Test posting a journal entry to Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + journal_entry, journal_entry_lineitems = create_journal_entry + + mock_instance.journal_entries.post.return_value = { + 'ia::result': {'id': '120680', 'key': '120680', 'href': '/objects/general-ledger/journal-entry/120680'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.post_journal_entry(journal_entry=journal_entry, journal_entry_line_items=journal_entry_lineitems) + + assert result is not None + mock_instance.journal_entries.post.assert_called_once() + + +def test_post_ap_payment(db, mock_intacct_sdk, create_ap_payment): + """ + Test posting an AP payment to Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + ap_payment, ap_payment_lineitems = create_ap_payment + + mock_instance.ap_payments.post.return_value = { + 'ia::result': {'id': '54321', 'key': '54321', 'href': '/objects/accounts-payable/ap-payment/54321'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.post_ap_payment(ap_payment=ap_payment, ap_payment_line_items=ap_payment_lineitems) + + assert result is not None + mock_instance.ap_payments.post.assert_called_once() + + +def test_post_sage_intacct_reimbursement(db, mock_intacct_sdk, mock_sage_intacct_sdk, create_sage_intacct_reimbursement): + """ + Test posting a reimbursement to Sage Intacct + """ + mock_rest_sdk_class, mock_rest_instance = mock_intacct_sdk + mock_soap_sdk_class, mock_soap_instance = mock_sage_intacct_sdk + reimbursement, reimbursement_lineitems = create_sage_intacct_reimbursement + + mock_rest_instance.sessions.get_session_id.return_value = {'sessionId': 'test'} + mock_soap_instance.reimbursements.post.return_value = { + 'key': 'REIMB123' + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.post_sage_intacct_reimbursement(reimbursement=reimbursement, reimbursement_line_items=reimbursement_lineitems) + + assert result is not None + + +def test_get_or_create_attachments_folder(db, mock_intacct_sdk): + """ + Test getting or creating attachments folder + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.attachment_folders.get_all_generator.return_value = iter([[]]) + mock_instance.attachment_folders.post.return_value = {'ia::result': {'key': 'FOLDER1'}} + + manager = SageIntacctObjectCreationManager(workspace_id=1) + manager.get_or_create_attachments_folder() + + mock_instance.attachment_folders.get_all_generator.assert_called_once() + mock_instance.attachment_folders.post.assert_called_once() + + +def test_post_attachments(db, mock_intacct_sdk): + """ + Test posting attachments to Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.attachments.post.return_value = { + 'ia::result': {'key': 'ATT123'} + } + + attachments = [{'id': 'att1', 'download_url': 'https://example.com/file.pdf'}] + manager = SageIntacctObjectCreationManager(workspace_id=1) + result, key = manager.post_attachments( + attachments=attachments, + attachment_id='SUPDOC001', + attachment_number=1 + ) + + assert result == 'SUPDOC001' + assert key == 'ATT123' + + +def test_post_attachments_empty(db, mock_intacct_sdk): + """ + Test posting empty attachments returns False + """ + _, _ = mock_intacct_sdk + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result, key = manager.post_attachments( + attachments=[], + attachment_id='SUPDOC001', + attachment_number=1 + ) + + assert result is False + assert key is None + + +def test_update_expense_report_attachments(db, mock_intacct_sdk): + """ + Test updating expense report attachments + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.expense_reports.update_attachment.return_value = {'success': True} + + manager = SageIntacctObjectCreationManager(workspace_id=1) + manager.update_expense_report_attachments(object_key='ER123', attachment_id='ATT001') + + mock_instance.expense_reports.update_attachment.assert_called_once_with( + object_id='ER123', + attachment_id='ATT001' + ) + + +def test_update_bill_attachments(db, mock_intacct_sdk): + """ + Test updating bill attachments + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.bills.update_attachment.return_value = {'success': True} + + manager = SageIntacctObjectCreationManager(workspace_id=1) + manager.update_bill_attachments(object_key='BILL123', attachment_id='ATT001') + + mock_instance.bills.update_attachment.assert_called_once_with( + object_id='BILL123', + attachment_id='ATT001' + ) + + +def test_update_charge_card_transaction_attachments(db, mock_intacct_sdk): + """ + Test updating charge card transaction attachments + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.charge_card_transactions.update_attachment.return_value = {'success': True} + + manager = SageIntacctObjectCreationManager(workspace_id=1) + manager.update_charge_card_transaction_attachments(object_key='CCT123', attachment_id='ATT001') + + mock_instance.charge_card_transactions.update_attachment.assert_called_once_with( + object_id='CCT123', + attachment_id='ATT001' + ) + + +def test_update_journal_entry_attachments(db, mock_intacct_sdk): + """ + Test updating journal entry attachments + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.journal_entries.update_attachment.return_value = {'success': True} + + manager = SageIntacctObjectCreationManager(workspace_id=1) + manager.update_journal_entry_attachments(object_key='JE123', attachment_id='ATT001') + + mock_instance.journal_entries.update_attachment.assert_called_once_with( + object_id='JE123', + attachment_id='ATT001' + ) + + +def test_get_journal_entry(db, mock_intacct_sdk, mock_sage_intacct_sdk): + """ + Test getting journal entry from Sage Intacct + """ + _, mock_rest_instance = mock_intacct_sdk + _, mock_soap_instance = mock_sage_intacct_sdk + + mock_rest_instance.sessions.get_session_id.return_value = {'sessionId': 'test'} + mock_soap_instance.journal_entries.get.return_value = {'recordno': '123'} + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.get_journal_entry(journal_entry_id='123') + + assert result is not None + + +def test_get_charge_card_transaction(db, mock_intacct_sdk, mock_sage_intacct_sdk): + """ + Test getting charge card transaction from Sage Intacct + """ + _, mock_rest_instance = mock_intacct_sdk + _, mock_soap_instance = mock_sage_intacct_sdk + + mock_rest_instance.sessions.get_session_id.return_value = {'sessionId': 'test'} + mock_soap_instance.charge_card_transactions.get.return_value = {'recordno': '123'} + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.get_charge_card_transaction(charge_card_transaction_id='123') + + assert result is not None + + +def test_get_bill(db, mock_intacct_sdk, mock_sage_intacct_sdk): + """ + Test getting bill from Sage Intacct + """ + _, mock_rest_instance = mock_intacct_sdk + _, mock_soap_instance = mock_sage_intacct_sdk + + mock_rest_instance.sessions.get_session_id.return_value = {'sessionId': 'test'} + mock_soap_instance.bills.get.return_value = {'recordno': '123'} + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.get_bill(bill_id='123') + + assert result is not None + + +def test_get_expense_report(db, mock_intacct_sdk, mock_sage_intacct_sdk): + """ + Test getting expense report from Sage Intacct + """ + _, mock_rest_instance = mock_intacct_sdk + _, mock_soap_instance = mock_sage_intacct_sdk + + mock_rest_instance.sessions.get_session_id.return_value = {'sessionId': 'test'} + mock_soap_instance.expense_reports.get.return_value = {'recordno': '123'} + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.get_expense_report(expense_report_id='123') + + assert result is not None + + +def test_sync_accounts_old_workspace_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync accounts proceeds for old workspaces even when over limit + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.accounts.count.return_value = SYNC_UPPER_LIMIT + 1000 + mock_instance.accounts.get_all_generator.return_value = iter([[ + {'id': 'ACC001', 'name': 'Test Account', 'accountType': 'Asset', 'status': 'active'} + ]]) + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 1, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_accounts() + + mock_instance.accounts.count.assert_called_once() + mock_instance.accounts.get_all_generator.assert_called_once() + + +def test_sync_departments_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync departments is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.departments.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_departments() + + mock_instance.departments.count.assert_called_once() + mock_instance.departments.get_all_generator.assert_not_called() + + +def test_sync_expense_types_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync expense types is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + mocker.patch('apps.sage_intacct.connector.publish_to_rabbitmq') + + mock_instance.expense_types.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_expense_types() + + mock_instance.expense_types.count.assert_called_once() + mock_instance.expense_types.get_all_generator.assert_not_called() + + +def test_sync_charge_card_accounts_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync charge card accounts is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.charge_card_accounts.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_charge_card_accounts() + + mock_instance.charge_card_accounts.count.assert_called_once() + mock_instance.charge_card_accounts.get_all_generator.assert_not_called() + + +def test_sync_payment_accounts_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync payment accounts is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.checking_accounts.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_payment_accounts() + + mock_instance.checking_accounts.count.assert_called_once() + mock_instance.checking_accounts.get_all_generator.assert_not_called() + + +def test_sync_projects_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync projects is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.projects.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_projects() + + mock_instance.projects.count.assert_called_once() + mock_instance.projects.get_all_generator.assert_not_called() + + +def test_sync_items_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync items is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.items.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_items() + + mock_instance.items.count.assert_called_once() + mock_instance.items.get_all_generator.assert_not_called() + + +def test_sync_vendors_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync vendors is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.vendors.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_vendors() + + mock_instance.vendors.count.assert_called_once() + mock_instance.vendors.get_all_generator.assert_not_called() + + +def test_sync_employees_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync employees is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.employees.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_employees() + + mock_instance.employees.count.assert_called_once() + mock_instance.employees.get_all_generator.assert_not_called() + + +def test_sync_customers_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync customers is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.customers.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_customers() + + mock_instance.customers.count.assert_called_once() + mock_instance.customers.get_all_generator.assert_not_called() + + +def test_sync_classes_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync classes is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.classes.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_classes() + + mock_instance.classes.count.assert_called_once() + mock_instance.classes.get_all_generator.assert_not_called() + + +def test_sync_locations_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync locations is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.locations.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_locations() + + mock_instance.locations.count.assert_called_once() + mock_instance.locations.get_all_generator.assert_not_called() + + +def test_sync_allocations_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync allocations is skipped when count exceeds limit for new workspaces + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.allocations.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_allocations() + + mock_instance.allocations.count.assert_called_once() + mock_instance.allocations.get_all_generator.assert_not_called() + + +def test_sync_cost_types(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing cost types from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + DependentFieldSetting.objects.create( + workspace_id=1, + is_import_enabled=True, + project_field_id=1, + cost_code_field_id=2, + cost_type_field_id=3 + ) + + mock_instance.cost_types.count.return_value = 2 + mock_instance.cost_types.get_all_generator.return_value = iter([[ + { + 'id': 'CT001', + 'key': '1', + 'name': 'Cost Type 1', + 'status': 'active', + 'project.id': 'PRJ001', + 'project.name': 'Project 1', + 'project.key': '1', + 'task.id': 'TASK001', + 'task.name': 'Task 1', + 'task.key': '1', + 'audit.createdDateTime': '2024-01-01T00:00:00Z', + 'audit.modifiedDateTime': '2024-01-01T00:00:00Z' + } + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_cost_types() + + mock_instance.cost_types.count.assert_called_once() + mock_instance.cost_types.get_all_generator.assert_called_once() + + +def test_sync_cost_codes(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing cost codes from Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + DependentFieldSetting.objects.create( + workspace_id=1, + is_import_enabled=True, + project_field_id=1, + cost_code_field_id=2, + cost_type_field_id=3 + ) + + mock_instance.tasks.count.return_value = 2 + mock_instance.tasks.get_all_generator.return_value = iter([[ + { + 'key': '1', + 'name': 'Task 1', + 'project.key': '1', + 'project.name': 'Project 1' + } + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_cost_codes() + + mock_instance.tasks.count.assert_called_once() + mock_instance.tasks.get_all_generator.assert_called_once() + + +def test_sync_user_defined_dimensions_with_udd(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing user defined dimensions with actual UDD + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.dimensions.list.return_value = [ + { + 'dimensionName': 'Custom Field', + 'termName': 'Custom Field', + 'isUserDefinedDimension': True, + 'dimensionEndpoint': 'endpoint::custom_field' + } + ] + mock_instance.dimensions.count.return_value = 2 + mock_instance.dimensions.get_all_generator.return_value = iter([[ + {'id': 'CF001', 'name': 'Custom Value 1'}, + {'id': 'CF002', 'name': 'Custom Value 2'} + ]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_user_defined_dimensions() + + mock_instance.dimensions.list.assert_called_once() + mock_instance.dimensions.count.assert_called() + + +def test_sync_user_defined_dimensions_with_exception(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing user defined dimensions handles exception + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.dimensions.list.return_value = [ + { + 'dimensionName': 'Custom Field', + 'termName': 'Custom Field', + 'isUserDefinedDimension': True, + 'dimensionEndpoint': 'endpoint::custom_field' + } + ] + mock_instance.dimensions.count.side_effect = Exception('API Error') + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_user_defined_dimensions() + + mock_instance.dimensions.list.assert_called_once() + + +def test_sync_allocations_skips_empty_entry(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test sync allocations skips when allocation entry is empty + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.allocations.count.return_value = 1 + mock_instance.allocations.get_all_generator.return_value = iter([[ + {'id': 'ALLOC001', 'status': 'active', 'key': '1'} + ]]) + mock_instance.allocations.get_by_key.return_value = {'ia::result': None} + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_allocations() + + mock_instance.allocations.count.assert_called_once() + mock_instance.allocations.get_by_key.assert_called_once() + + +def test_sync_location_entities_skipped_when_entity_slide_disabled(db, mock_intacct_sdk, mock_sage_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test syncing location entities is skipped when entity slide is disabled + """ + _, mock_rest_instance = mock_intacct_sdk + _, mock_soap_instance = mock_sage_intacct_sdk + + mock_rest_instance.sessions.get_session_id.return_value = {'sessionId': 'test'} + mock_soap_instance.api_base.format_and_send_request.return_value = { + 'data': { + 'companypref': [ + {'preference': 'DISABLEENTITYSLIDEIN', 'prefvalue': 'true'} + ] + } + } + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_location_entities() + + mock_rest_instance.location_entities.get_all_generator.assert_not_called() + + +def test_sync_location_entities_handles_exception(db, mock_intacct_sdk, mock_sage_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test syncing location entities handles exception in get_entity_slide_preference + """ + _, mock_rest_instance = mock_intacct_sdk + _, mock_soap_instance = mock_sage_intacct_sdk + + mock_rest_instance.sessions.get_session_id.return_value = {'sessionId': 'test'} + mock_soap_instance.api_base.format_and_send_request.side_effect = Exception('API Error') + mock_rest_instance.location_entities.get_all_generator.return_value = iter([[]]) + + mock_logger = mocker.patch('apps.sage_intacct.connector.logger') + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_location_entities() + + # Exception is caught and logged + assert mock_logger.exception.called or mock_logger.error.called or mock_logger.info.called + + +def test_get_or_create_vendor_long_name(db, mock_intacct_sdk): + """ + Test get_or_create_vendor truncates long vendor id + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.vendors.post.return_value = { + 'ia::result': {'key': 'NEW_VND'} + } + mock_instance.vendors.get_by_key.return_value = { + 'ia::result': {'id': 'NEW_VND', 'name': 'Very Long Vendor Name That Exceeds Limit'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + vendor = manager.get_or_create_vendor( + vendor_name='Very Long Vendor Name That Exceeds The Twenty Character Limit', + email='long@test.com', + create=True + ) + + assert vendor is not None + + +def test_get_or_create_vendor_with_bad_request_error(db, mock_intacct_sdk): + """ + Test get_or_create_vendor handles BadRequestError for duplicate vendor + """ + _, mock_instance = mock_intacct_sdk + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'Another record with the value already exists' + } + } + } + + mock_instance.vendors.post.side_effect = BadRequestError( + msg='Duplicate', + response=str(error_response) + ) + mock_instance.vendors.get_all_generator.return_value = iter([[ + {'id': 'VND001', 'name': 'Duplicate Vendor', 'status': 'active', 'contacts.default.email1': 'dup@test.com'} + ]]) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + vendor = manager.get_or_create_vendor( + vendor_name='Duplicate Vendor', + email='dup@test.com', + create=True + ) + + assert vendor is None or vendor is not None + + +def test_post_bill_with_closed_period_error(db, mock_intacct_sdk, create_bill): + """ + Test posting a bill handles closed period error + """ + _, mock_instance = mock_intacct_sdk + bill, bill_lineitems = create_bill + + configuration = Configuration.objects.get(workspace_id=1) + configuration.change_accounting_period = True + configuration.save() + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'period is closed' + } + } + } + + mock_instance.bills.post.side_effect = [ + BadRequestError(msg='Closed period', response=json.dumps(error_response)), + {'ia::result': {'id': '81035', 'key': '81035', 'href': '/objects/accounts-payable/bill/81035'}} + ] + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.post_bill(bill=bill, bill_line_items=bill_lineitems) + + assert result is not None + + +def test_post_expense_report_with_closed_period_error(db, mock_intacct_sdk, create_expense_report): + """ + Test posting an expense report handles closed period error + """ + _, mock_instance = mock_intacct_sdk + expense_report, expense_report_lineitems = create_expense_report + + configuration = Configuration.objects.get(workspace_id=1) + configuration.change_accounting_period = True + configuration.save() + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'period is closed' + } + } + } + + mock_instance.expense_reports.post.side_effect = [ + BadRequestError(msg='Closed period', response=json.dumps(error_response)), + {'ia::result': {'id': '12345', 'key': '12345', 'href': '/objects/expense-management/expense-report/12345'}} + ] + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.post_expense_report(expense_report=expense_report, expense_report_line_items=expense_report_lineitems) + + assert result is not None + + +def test_post_charge_card_transaction_with_closed_period_error(db, mock_intacct_sdk, create_charge_card_transaction): + """ + Test posting a charge card transaction with closed period error re-raises if not handled + """ + _, mock_instance = mock_intacct_sdk + cct, cct_lineitems = create_charge_card_transaction + + configuration = Configuration.objects.get(workspace_id=1) + configuration.change_accounting_period = False + configuration.save() + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'period is closed' + } + } + } + + mock_instance.charge_card_transactions.post.side_effect = BadRequestError( + msg='Closed period', + response=json.dumps(error_response) + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + + with pytest.raises(BadRequestError): + manager.post_charge_card_transaction(charge_card_transaction=cct, charge_card_transaction_line_items=cct_lineitems) + + +def test_post_journal_entry_with_closed_period_error(db, mock_intacct_sdk, create_journal_entry): + """ + Test posting a journal entry handles closed period error + """ + _, mock_instance = mock_intacct_sdk + journal_entry, journal_entry_lineitems = create_journal_entry + + configuration = Configuration.objects.get(workspace_id=1) + configuration.change_accounting_period = True + configuration.save() + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'period is closed' + } + } + } + + mock_instance.journal_entries.post.side_effect = [ + BadRequestError(msg='Closed period', response=json.dumps(error_response)), + {'ia::result': {'id': '120680', 'key': '120680', 'href': '/objects/general-ledger/journal-entry/120680'}} + ] + + manager = SageIntacctObjectCreationManager(workspace_id=1) + result = manager.post_journal_entry(journal_entry=journal_entry, journal_entry_line_items=journal_entry_lineitems) + + assert result is not None + + +def test_post_attachments_update_existing(db, mock_intacct_sdk): + """ + Test posting attachments updates existing attachment + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.attachments.update.return_value = {'success': True} + + attachments = [{'id': 'att1', 'download_url': 'https://example.com/file.pdf'}] + manager = SageIntacctObjectCreationManager(workspace_id=1) + result, key = manager.post_attachments( + attachments=attachments, + attachment_id='SUPDOC001', + attachment_number=2, + attachment_key='ATT_KEY_123' + ) + + assert result is False + assert key is None + mock_instance.attachments.update.assert_called_once() + + +def test_post_attachments_update_with_error(db, mock_intacct_sdk): + """ + Test posting attachments handles update error + """ + _, mock_instance = mock_intacct_sdk + + class MockException(Exception): + response = 'Update failed' + + mock_instance.attachments.update.side_effect = MockException('Update failed') + + attachments = [{'id': 'att1', 'download_url': 'https://example.com/file.pdf'}] + manager = SageIntacctObjectCreationManager(workspace_id=1) + result, key = manager.post_attachments( + attachments=attachments, + attachment_id='SUPDOC001', + attachment_number=2, + attachment_key='ATT_KEY_123' + ) + + assert result is False + assert key is None + + +def test_create_employee(db, mock_intacct_sdk): + """ + Test creating an employee in Sage Intacct + """ + _, mock_instance = mock_intacct_sdk + + DestinationAttribute.objects.create( + workspace_id=1, + attribute_type='DEPARTMENT', + value='Engineering', + destination_id='DEPT001', + active=True + ) + DestinationAttribute.objects.create( + workspace_id=1, + attribute_type='LOCATION', + value='New York', + destination_id='LOC001', + active=True + ) + + mock_instance.contacts.post.return_value = { + 'ia::result': {'key': 'CT123'} + } + mock_instance.contacts.get_by_key.return_value = { + 'ia::result': {'id': 'CT123', 'printAs': 'John Doe'} + } + mock_instance.employees.post.return_value = { + 'ia::result': {'key': 'EMP123'} + } + mock_instance.employees.get_by_key.return_value = { + 'ia::result': {'id': 'EMP123', 'name': 'John Doe'} + } + + employee_attr = ExpenseAttribute.objects.create( + workspace_id=1, + attribute_type='EMPLOYEE', + value='john@test.com', + source_id='src123', + detail={ + 'full_name': 'John Doe', + 'department': 'Engineering', + 'location': 'New York' + } + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + employee = manager.create_employee(employee=employee_attr) + + assert employee is not None + assert employee['id'] == 'EMP123' + + +def test_create_employee_without_location(db, mock_intacct_sdk): + """ + Test creating an employee returns None when location is not found + """ + _, _ = mock_intacct_sdk + + # Delete all LOCATION attributes to ensure no match + DestinationAttribute.objects.filter( + workspace_id=1, + attribute_type='LOCATION' + ).delete() + + # Clear general mappings default location + from apps.mappings.models import GeneralMapping + general_mappings = GeneralMapping.objects.filter(workspace_id=1).first() + if general_mappings: + general_mappings.default_location_id = None + general_mappings.save() + + employee_attr = ExpenseAttribute.objects.create( + workspace_id=1, + attribute_type='EMPLOYEE', + value='john@test.com', + source_id='src124', + detail={ + 'full_name': 'John Doe', + 'department': 'Unknown Dept', + 'location': 'Unknown Location' + } + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + employee = manager.create_employee(employee=employee_attr) + + assert employee is None + + +def test_get_or_create_employee(db, mock_intacct_sdk): + """ + Test get_or_create_employee returns existing employee + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.employees.get_all_generator.return_value = iter([[ + { + 'id': 'EMP001', + 'name': 'John Doe', + 'status': 'active', + 'primaryContact.email1': 'john@test.com', + 'primaryContact.printAs': 'John Doe', + 'department.id': 'DEPT001', + 'location.id': 'LOC001' + } + ]]) + + employee_attr = ExpenseAttribute.objects.create( + workspace_id=1, + attribute_type='EMPLOYEE', + value='john@test.com', + source_id='src125', + detail={ + 'full_name': 'John Doe', + 'department': 'Engineering', + 'location': 'New York' + } + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + employee = manager.get_or_create_employee(source_employee=employee_attr) + + assert employee is not None + assert employee.destination_id == 'EMP001' + + +def test_create_contact_without_email(db, mock_intacct_sdk): + """ + Test creating a contact without email + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.contacts.post.return_value = { + 'ia::result': {'key': 'CT123'} + } + mock_instance.contacts.get_by_key.return_value = { + 'ia::result': {'id': 'CT123', 'printAs': 'Test Contact'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + contact = manager.create_contact( + contact_id='CT001', + contact_name='Test Contact', + email=None, + first_name='Test', + last_name='Contact' + ) + + assert contact is not None + assert contact['id'] == 'CT123' + + +def test_sync_cost_types_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync cost types is skipped when count exceeds limit + """ + _, mock_instance = mock_intacct_sdk + + DependentFieldSetting.objects.create( + workspace_id=1, + is_import_enabled=True, + project_field_id=1, + cost_code_field_id=2, + cost_type_field_id=3 + ) + + mock_instance.cost_types.count.return_value = COST_TYPES_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_cost_types() + + mock_instance.cost_types.count.assert_called_once() + mock_instance.cost_types.get_all_generator.assert_not_called() + + +def test_sync_cost_types_with_last_synced_at(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test sync cost types with last_synced_at filters data + """ + _, mock_instance = mock_intacct_sdk + + DependentFieldSetting.objects.create( + workspace_id=1, + is_import_enabled=True, + project_field_id=1, + cost_code_field_id=2, + cost_type_field_id=3, + last_synced_at=datetime.now() + ) + + mock_instance.cost_types.count.return_value = 2 + mock_instance.cost_types.get_all_generator.return_value = iter([[]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_cost_types() + + mock_instance.cost_types.count.assert_called_once() + mock_instance.cost_types.get_all_generator.assert_called_once() + + +def test_sync_cost_codes_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync cost codes is skipped when count exceeds limit + """ + _, mock_instance = mock_intacct_sdk + + DependentFieldSetting.objects.create( + workspace_id=1, + is_import_enabled=True, + project_field_id=1, + cost_code_field_id=2, + cost_type_field_id=3 + ) + + mock_instance.tasks.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_cost_codes() + + mock_instance.tasks.count.assert_called_once() + mock_instance.tasks.get_all_generator.assert_not_called() + + +def test_sync_cost_codes_with_last_synced_at(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count): + """ + Test sync cost codes with last_synced_at filters data + """ + _, mock_instance = mock_intacct_sdk + + DependentFieldSetting.objects.create( + workspace_id=1, + is_import_enabled=True, + project_field_id=1, + cost_code_field_id=2, + cost_type_field_id=3, + last_synced_at=datetime.now() + ) + + mock_instance.tasks.count.return_value = 2 + mock_instance.tasks.get_all_generator.return_value = iter([[]]) + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_cost_codes() + + mock_instance.tasks.count.assert_called_once() + mock_instance.tasks.get_all_generator.assert_called_once() + + +def test_sync_expense_payment_types_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync expense payment types is skipped when count exceeds limit + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.expense_payment_types.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_expense_payment_types() + + mock_instance.expense_payment_types.count.assert_called_once() + mock_instance.expense_payment_types.get_all_generator.assert_not_called() + + +def test_sync_tax_details_skips_when_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test sync tax details is skipped when count exceeds limit + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.tax_details.count.return_value = SYNC_UPPER_LIMIT + 1000 + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_tax_details() + + mock_instance.tax_details.count.assert_called_once() + mock_instance.tax_details.get_all_generator.assert_not_called() + + +def test_sync_user_defined_dimensions_skips_udd_over_limit(db, mock_intacct_sdk, create_intacct_synced_timestamp, create_sage_intacct_attributes_count, mocker): + """ + Test syncing user defined dimensions skips UDD when count exceeds limit + """ + _, mock_instance = mock_intacct_sdk + + mocker.patch( + 'apps.sage_intacct.connector.timezone', + django_timezone + ) + + workspace = Workspace.objects.get(id=1) + workspace.created_at = datetime(2024, 11, 1, tzinfo=timezone.utc) + workspace.save() + + mock_instance.dimensions.list.return_value = [ + { + 'dimensionName': 'Custom Field', + 'termName': 'Custom Field', + 'isUserDefinedDimension': True, + 'dimensionEndpoint': 'endpoint::custom_field' + } + ] + mock_instance.dimensions.count.return_value = SYNC_UPPER_LIMIT + 1000 + + sync_manager = SageIntacctDimensionSyncManager(workspace_id=1) + sync_manager.sync_user_defined_dimensions() + + mock_instance.dimensions.list.assert_called_once() + mock_instance.dimensions.count.assert_called_once() + mock_instance.dimensions.get_all_generator.assert_not_called() + + +def test_create_contact_with_exception(db, mock_intacct_sdk): + """ + Test creating a contact returns None on exception + """ + _, mock_instance = mock_intacct_sdk + + class MockException(Exception): + response = 'Contact creation failed' + + mock_instance.contacts.post.side_effect = MockException('Failed') + + manager = SageIntacctObjectCreationManager(workspace_id=1) + contact = manager.create_contact( + contact_id='CT001', + contact_name='Test Contact', + email='test@test.com', + first_name='Test', + last_name='Contact' + ) + + assert contact is None + + +def test_create_contact_no_object_key(db, mock_intacct_sdk): + """ + Test creating a contact returns None when no object key in response + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.contacts.post.return_value = { + 'ia::result': {} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + contact = manager.create_contact( + contact_id='CT001', + contact_name='Test Contact', + email='test@test.com', + first_name='Test', + last_name='Contact' + ) + + assert contact is None + + +def test_create_vendor_no_object_key(db, mock_intacct_sdk): + """ + Test creating a vendor returns None when no object key in response + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.vendors.post.return_value = { + 'ia::result': {} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + vendor = manager.create_vendor( + vendor_id='VND001', + vendor_name='Test Vendor', + email='test@test.com' + ) + + assert vendor is None + + +def test_create_employee_contact_fails(db, mock_intacct_sdk): + """ + Test creating an employee returns None when contact creation fails + """ + _, mock_instance = mock_intacct_sdk + + DestinationAttribute.objects.create( + workspace_id=1, + attribute_type='LOCATION', + value='Test Location', + destination_id='LOC001', + active=True + ) + + class MockException(Exception): + response = 'Contact creation failed' + + mock_instance.contacts.post.side_effect = MockException('Failed') + + employee_attr = ExpenseAttribute.objects.create( + workspace_id=1, + attribute_type='EMPLOYEE', + value='john@test.com', + source_id='src126', + detail={ + 'full_name': 'John Doe', + 'department': 'Unknown', + 'location': 'Test Location' + } + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + employee = manager.create_employee(employee=employee_attr) + + assert employee is None + + +def test_create_employee_with_exception(db, mock_intacct_sdk): + """ + Test creating an employee returns None on exception during employee post + """ + _, mock_instance = mock_intacct_sdk + + DestinationAttribute.objects.create( + workspace_id=1, + attribute_type='LOCATION', + value='Test Location 2', + destination_id='LOC002', + active=True + ) + + mock_instance.contacts.post.return_value = { + 'ia::result': {'key': 'CT123'} + } + mock_instance.contacts.get_by_key.return_value = { + 'ia::result': {'id': 'CT123', 'printAs': 'John Doe'} + } + + class MockException(Exception): + response = 'Employee creation failed' + + mock_instance.employees.post.side_effect = MockException('Failed') + + employee_attr = ExpenseAttribute.objects.create( + workspace_id=1, + attribute_type='EMPLOYEE', + value='john2@test.com', + source_id='src127', + detail={ + 'full_name': 'John Doe 2', + 'department': 'Unknown', + 'location': 'Test Location 2' + } + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + employee = manager.create_employee(employee=employee_attr) + + assert employee is None + + +def test_create_employee_no_object_key(db, mock_intacct_sdk): + """ + Test creating an employee returns None when no object key in response + """ + _, mock_instance = mock_intacct_sdk + + DestinationAttribute.objects.create( + workspace_id=1, + attribute_type='LOCATION', + value='Test Location 3', + destination_id='LOC003', + active=True + ) + + mock_instance.contacts.post.return_value = { + 'ia::result': {'key': 'CT123'} + } + mock_instance.contacts.get_by_key.return_value = { + 'ia::result': {'id': 'CT123', 'printAs': 'John Doe'} + } + mock_instance.employees.post.return_value = { + 'ia::result': {} + } + + employee_attr = ExpenseAttribute.objects.create( + workspace_id=1, + attribute_type='EMPLOYEE', + value='john3@test.com', + source_id='src128', + detail={ + 'full_name': 'John Doe 3', + 'department': 'Unknown', + 'location': 'Test Location 3' + } + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + employee = manager.create_employee(employee=employee_attr) + + assert employee is None + + +def test_get_or_create_employee_creates_new(db, mock_intacct_sdk): + """ + Test get_or_create_employee creates a new employee when not found + """ + _, mock_instance = mock_intacct_sdk + + mock_instance.employees.get_all_generator.return_value = iter([[]]) + + DestinationAttribute.objects.create( + workspace_id=1, + attribute_type='LOCATION', + value='Test Location 4', + destination_id='LOC004', + active=True + ) + + mock_instance.contacts.post.return_value = { + 'ia::result': {'key': 'CT124'} + } + mock_instance.contacts.get_by_key.return_value = { + 'ia::result': {'id': 'CT124', 'printAs': 'New Employee'} + } + mock_instance.employees.post.return_value = { + 'ia::result': {'key': 'EMP124'} + } + mock_instance.employees.get_by_key.return_value = { + 'ia::result': {'id': 'EMP124', 'name': 'New Employee'} + } + + employee_attr = ExpenseAttribute.objects.create( + workspace_id=1, + attribute_type='EMPLOYEE', + value='new@test.com', + source_id='src129', + detail={ + 'full_name': 'New Employee', + 'department': 'Unknown', + 'location': 'Test Location 4' + } + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + employee = manager.get_or_create_employee(source_employee=employee_attr) + + assert employee is not None + + +def test_get_or_create_vendor_with_duplicate_error_finds_vendor(db, mock_intacct_sdk): + """ + Test get_or_create_vendor handles duplicate error by searching for vendor + """ + _, mock_instance = mock_intacct_sdk + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'Another record with the value already exists' + } + } + } + + mock_instance.vendors.post.side_effect = BadRequestError( + msg='Duplicate', + response=json.dumps(error_response) + ) + mock_instance.vendors.get_all_generator.return_value = iter([[ + {'id': 'VND_FOUND', 'name': 'Duplicate Vendor Found', 'status': 'active', 'contacts.default.email1': 'dup@test.com'} + ]]) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + vendor = manager.get_or_create_vendor( + vendor_name='Duplicate Vendor Found', + email='dup@test.com', + create=True + ) + + assert vendor is not None + assert vendor.destination_id == 'VND_FOUND' + + +def test_get_or_create_vendor_with_duplicate_error_creates_new(db, mock_intacct_sdk): + """ + Test get_or_create_vendor handles duplicate error and creates new vendor when not found + """ + _, mock_instance = mock_intacct_sdk + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'Another record with the value already exists' + } + } + } + + mock_instance.vendors.post.side_effect = [ + BadRequestError(msg='Duplicate', response=json.dumps(error_response)), + {'ia::result': {'key': 'VND_NEW'}} + ] + mock_instance.vendors.get_all_generator.return_value = iter([[]]) + mock_instance.vendors.get_by_key.return_value = { + 'ia::result': {'id': 'VND_NEW', 'name': 'New Vendor Created'} + } + + manager = SageIntacctObjectCreationManager(workspace_id=1) + vendor = manager.get_or_create_vendor( + vendor_name='New Vendor To Create', + email='new_dup@test.com', + create=True + ) + + assert vendor is not None + + +def test_get_or_create_vendor_duplicate_create_fails(db, mock_intacct_sdk): + """ + Test get_or_create_vendor returns None when duplicate handling create fails + """ + _, mock_instance = mock_intacct_sdk + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'Another record with the value already exists' + } + } + } + + class MockException(Exception): + response = 'Create failed again' + + mock_instance.vendors.post.side_effect = [ + BadRequestError(msg='Duplicate', response=json.dumps(error_response)), + MockException('Failed') + ] + mock_instance.vendors.get_all_generator.return_value = iter([[]]) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + vendor = manager.get_or_create_vendor( + vendor_name='Failing Vendor', + email='fail@test.com', + create=True + ) + + assert vendor is None + + +def test_post_expense_report_non_closed_period_error_re_raises(db, mock_intacct_sdk, create_expense_report): + """ + Test posting an expense report re-raises for non-closed period errors + """ + _, mock_instance = mock_intacct_sdk + expense_report, expense_report_lineitems = create_expense_report + + configuration = Configuration.objects.get(workspace_id=1) + configuration.change_accounting_period = True + configuration.save() + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'Some other error' + } + } + } + + mock_instance.expense_reports.post.side_effect = BadRequestError( + msg='Error', + response=json.dumps(error_response) + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + + with pytest.raises(BadRequestError): + manager.post_expense_report(expense_report=expense_report, expense_report_line_items=expense_report_lineitems) + + +def test_post_bill_non_closed_period_error_re_raises(db, mock_intacct_sdk, create_bill): + """ + Test posting a bill re-raises for non-closed period errors + """ + _, mock_instance = mock_intacct_sdk + bill, bill_lineitems = create_bill + + configuration = Configuration.objects.get(workspace_id=1) + configuration.change_accounting_period = True + configuration.save() + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'Some other error not about period' + } + } + } + + mock_instance.bills.post.side_effect = BadRequestError( + msg='Error', + response=json.dumps(error_response) + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + + with pytest.raises(BadRequestError): + manager.post_bill(bill=bill, bill_line_items=bill_lineitems) + + +def test_post_journal_entry_non_closed_period_error_re_raises(db, mock_intacct_sdk, create_journal_entry): + """ + Test posting a journal entry re-raises for non-closed period errors + """ + _, mock_instance = mock_intacct_sdk + journal_entry, journal_entry_lineitems = create_journal_entry + + configuration = Configuration.objects.get(workspace_id=1) + configuration.change_accounting_period = True + configuration.save() + + error_response = { + 'ia::result': { + 'ia::error': { + 'details': 'Some other error not about period' + } + } + } + + mock_instance.journal_entries.post.side_effect = BadRequestError( + msg='Error', + response=json.dumps(error_response) + ) + + manager = SageIntacctObjectCreationManager(workspace_id=1) + + with pytest.raises(BadRequestError): + manager.post_journal_entry(journal_entry=journal_entry, journal_entry_line_items=journal_entry_lineitems) diff --git a/tests/test_sageintacct/test_tasks.py b/tests/test_sageintacct/test_tasks.py index cd7557f4..da2cabcf 100644 --- a/tests/test_sageintacct/test_tasks.py +++ b/tests/test_sageintacct/test_tasks.py @@ -1,3 +1,4 @@ +import json import logging import random from datetime import datetime, timedelta, timezone @@ -12,6 +13,11 @@ from fyle_accounting_library.fyle_platform.enums import ExpenseImportSourceEnum from fyle_accounting_mappings.models import DestinationAttribute, EmployeeMapping, ExpenseAttribute from sageintacctsdk.exceptions import InvalidTokenError, NoPrivilegeError, WrongParamsError +from intacctsdk.exceptions import ( + BadRequestError as IntacctRESTBadRequestError, + InvalidTokenError as IntacctRESTInvalidTokenError, + InternalServerError as IntacctRESTInternalServerError +) from apps.exceptions import ValueErrorWithResponse from apps.fyle.models import Expense, ExpenseGroup, Reimbursement @@ -39,6 +45,9 @@ __validate_expense_group, check_cache_and_search_vendors, check_sage_intacct_object_status, + check_sage_intacct_object_status_rest, + check_sage_intacct_bill_status_rest, + check_sage_intacct_expense_report_status_rest, create_ap_payment, create_bill, create_charge_card_transaction, @@ -49,6 +58,7 @@ get_employee_as_vendors_name, get_or_create_credit_card_vendor, handle_sage_intacct_errors, + handle_sage_intacct_rest_errors, load_attachments, mark_paid_on_fyle, process_fyle_reimbursements, @@ -2283,6 +2293,913 @@ def test_create_charge_card_transaction_task_log_does_not_exist(mocker, db): ) +def test_handle_sage_intacct_rest_errors_with_details(db, mocker): + """ + Test handle_sage_intacct_rest_errors with details in error response + """ + task_log = TaskLog.objects.filter(workspace_id=1).first() + expense_group = ExpenseGroup.objects.get(id=1) + + mocker.patch('apps.sage_intacct.tasks.post_accounting_export_summary') + mocker.patch('apps.sage_intacct.tasks.update_failed_expenses') + + error_response = data['sage_intacct_rest_error_response'] + + exception = IntacctRESTBadRequestError( + msg='POST request failed', + response=json.dumps(error_response) + ) + + handle_sage_intacct_rest_errors( + exception=exception, + expense_group=expense_group, + task_log=task_log, + export_type='Journal Entry' + ) + + assert task_log.status == 'FAILED' + assert len(task_log.sage_intacct_errors) == 2 + assert task_log.sage_intacct_errors[0]['short_description'] == 'Journal Entry error' + assert task_log.sage_intacct_errors[0]['long_description'] == 'Could not create GLBatch record.' + assert task_log.sage_intacct_errors[1]['long_description'] == 'Transactions do not balance for Place ,' + + error = Error.objects.filter(workspace_id=1, expense_group=expense_group).first() + assert error is not None + assert error.type == 'INTACCT_ERROR' + assert error.is_resolved is False + + +def test_handle_sage_intacct_rest_errors_without_details(db, mocker): + """ + Test handle_sage_intacct_rest_errors when no details in error response + """ + task_log = TaskLog.objects.filter(workspace_id=1).first() + expense_group = ExpenseGroup.objects.get(id=1) + + mocker.patch('apps.sage_intacct.tasks.post_accounting_export_summary') + mocker.patch('apps.sage_intacct.tasks.update_failed_expenses') + + error_response = data['sage_intacct_rest_error_response_no_details'] + + exception = IntacctRESTBadRequestError( + msg='POST request failed', + response=json.dumps(error_response) + ) + + handle_sage_intacct_rest_errors( + exception=exception, + expense_group=expense_group, + task_log=task_log, + export_type='Bill' + ) + + assert task_log.status == 'FAILED' + assert len(task_log.sage_intacct_errors) == 1 + assert task_log.sage_intacct_errors[0]['short_description'] == 'Bill error' + assert task_log.sage_intacct_errors[0]['long_description'] == 'Something went wrong with the export' + + +def test_handle_sage_intacct_rest_errors_with_string_response(db, mocker): + """ + Test handle_sage_intacct_rest_errors when response is already a dict + """ + task_log = TaskLog.objects.filter(workspace_id=1).first() + expense_group = ExpenseGroup.objects.get(id=1) + + mocker.patch('apps.sage_intacct.tasks.post_accounting_export_summary') + mocker.patch('apps.sage_intacct.tasks.update_failed_expenses') + + error_response = data['sage_intacct_rest_error_response'] + + exception = IntacctRESTBadRequestError( + msg='POST request failed', + response=error_response + ) + + handle_sage_intacct_rest_errors( + exception=exception, + expense_group=expense_group, + task_log=task_log, + export_type='Expense Report' + ) + + assert task_log.status == 'FAILED' + assert len(task_log.sage_intacct_errors) == 2 + + +def test_handle_sage_intacct_rest_errors_invalid_json(db, mocker): + """ + Test handle_sage_intacct_rest_errors when response is invalid JSON + """ + task_log = TaskLog.objects.filter(workspace_id=1).first() + expense_group = ExpenseGroup.objects.get(id=1) + + mocker.patch('apps.sage_intacct.tasks.post_accounting_export_summary') + mocker.patch('apps.sage_intacct.tasks.update_failed_expenses') + + exception = IntacctRESTBadRequestError( + msg='POST request failed', + response='Invalid JSON response' + ) + + handle_sage_intacct_rest_errors( + exception=exception, + expense_group=expense_group, + task_log=task_log, + export_type='Charge Card Transaction' + ) + + assert task_log.status == 'FAILED' + assert len(task_log.sage_intacct_errors) == 1 + assert task_log.sage_intacct_errors[0]['long_description'] == 'Invalid JSON response' + + +def test_handle_sage_intacct_rest_errors_no_ia_result(db, mocker): + """ + Test handle_sage_intacct_rest_errors when ia::result key is missing + """ + task_log = TaskLog.objects.filter(workspace_id=1).first() + expense_group = ExpenseGroup.objects.get(id=1) + + mocker.patch('apps.sage_intacct.tasks.post_accounting_export_summary') + mocker.patch('apps.sage_intacct.tasks.update_failed_expenses') + + error_response = {'error': 'Some error occurred'} + + exception = IntacctRESTBadRequestError( + msg='POST request failed', + response=json.dumps(error_response) + ) + + handle_sage_intacct_rest_errors( + exception=exception, + expense_group=expense_group, + task_log=task_log, + export_type='Journal Entry' + ) + + assert task_log.status == 'FAILED' + assert len(task_log.sage_intacct_errors) == 1 + assert 'error' in task_log.sage_intacct_errors[0]['long_description'] + + +def test_handle_sage_intacct_rest_errors_primary_error_selection(db, mocker): + """ + Test handle_sage_intacct_rest_errors selects correct primary error from details + """ + task_log = TaskLog.objects.filter(workspace_id=1).first() + expense_group = ExpenseGroup.objects.get(id=1) + + mocker.patch('apps.sage_intacct.tasks.post_accounting_export_summary') + mocker.patch('apps.sage_intacct.tasks.update_failed_expenses') + + error_response = { + "ia::result": { + "ia::error": { + "code": "operationFailed", + "message": "POST request failed", + "details": [ + { + "message": "Could not create record.", + "correction": None + }, + { + "message": "Transactions do not balance", + "correction": "Please check your journal entry lines" + } + ] + } + } + } + + exception = IntacctRESTBadRequestError( + msg='POST request failed', + response=json.dumps(error_response) + ) + + handle_sage_intacct_rest_errors( + exception=exception, + expense_group=expense_group, + task_log=task_log, + export_type='Journal Entry' + ) + + assert task_log.status == 'FAILED' + + error = Error.objects.filter(workspace_id=1, expense_group=expense_group).first() + assert error is not None + assert 'Transactions do not balance' in error.error_detail + + +def test_create_ap_payment_rest_bad_request_error_skips_payment(mocker, db): + """ + Test create_ap_payment with IntacctRESTBadRequestError that triggers payment skip + """ + mocker.patch('sageintacctsdk.apis.Bills.post', return_value=data['bill_response']) + mocker.patch('sageintacctsdk.apis.Bills.get', return_value=data['bill_response']['data']) + mocker.patch('apps.sage_intacct.tasks.load_attachments', return_value=('sdfgh', False)) + mocker.patch('sageintacctsdk.apis.Bills.update_attachment', return_value=data['bill_response']) + mocker.patch('fyle_integrations_platform_connector.apis.Expenses.get', return_value=[]) + + workspace_id = 1 + task_log = TaskLog.objects.filter(workspace_id=workspace_id).first() + task_log.status = 'READY' + task_log.save() + + expense_group = ExpenseGroup.objects.get(id=1) + create_bill(expense_group.id, task_log.id, True, False) + + bill = Bill.objects.get(expense_group=expense_group) + bill.payment_synced = False + bill.paid_on_sage_intacct = False + bill.save() + + for expense in bill.expense_group.expenses.all(): + expense.paid_on_fyle = True + expense.save() + + mocker.patch( + 'apps.workspaces.models.FeatureConfig.get_feature_config', + return_value=True + ) + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection' + ) + + error_response = json.dumps({ + "ia::result": { + "ia::error": { + "message": "Oops, we can't find this transaction; enter a valid transaction" + } + } + }) + + mocker.patch( + 'apps.sage_intacct.models.APPayment.create_ap_payment', + side_effect=IntacctRESTBadRequestError('Bad request', error_response) + ) + + create_ap_payment(workspace_id) + + bill.refresh_from_db() + assert bill.payment_synced is True + assert bill.paid_on_sage_intacct is True + + +def test_create_ap_payment_rest_bad_request_error_saves_task_log(mocker, db): + """ + Test create_ap_payment with IntacctRESTBadRequestError that saves task_log + """ + mocker.patch('sageintacctsdk.apis.Bills.post', return_value=data['bill_response']) + mocker.patch('sageintacctsdk.apis.Bills.get', return_value=data['bill_response']['data']) + mocker.patch('apps.sage_intacct.tasks.load_attachments', return_value=('sdfgh', False)) + mocker.patch('sageintacctsdk.apis.Bills.update_attachment', return_value=data['bill_response']) + mocker.patch('fyle_integrations_platform_connector.apis.Expenses.get', return_value=[]) + + workspace_id = 1 + task_log = TaskLog.objects.filter(workspace_id=workspace_id).first() + task_log.status = 'READY' + task_log.save() + + expense_group = ExpenseGroup.objects.get(id=1) + create_bill(expense_group.id, task_log.id, True, False) + + bill = Bill.objects.get(expense_group=expense_group) + bill.payment_synced = False + bill.paid_on_sage_intacct = False + bill.save() + + for expense in bill.expense_group.expenses.all(): + expense.paid_on_fyle = True + expense.save() + + mocker.patch( + 'apps.workspaces.models.FeatureConfig.get_feature_config', + return_value=True + ) + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection' + ) + + error_response = json.dumps({ + "ia::result": { + "ia::error": { + "message": "Some other error occurred" + } + } + }) + + mocker.patch( + 'apps.sage_intacct.models.APPayment.create_ap_payment', + side_effect=IntacctRESTBadRequestError('Bad request', error_response) + ) + + create_ap_payment(workspace_id) + + payment_task_log = TaskLog.objects.filter( + workspace_id=workspace_id, + task_id='PAYMENT_{}'.format(bill.expense_group.id) + ).first() + assert payment_task_log.status == 'FAILED' + + bill.refresh_from_db() + assert bill.payment_synced is False + + +def test_create_ap_payment_rest_invalid_token_error(mocker, db): + """ + Test create_ap_payment with IntacctRESTInvalidTokenError + """ + mocker.patch('sageintacctsdk.apis.Bills.post', return_value=data['bill_response']) + mocker.patch('sageintacctsdk.apis.Bills.get', return_value=data['bill_response']['data']) + mocker.patch('apps.sage_intacct.tasks.load_attachments', return_value=('sdfgh', False)) + mocker.patch('sageintacctsdk.apis.Bills.update_attachment', return_value=data['bill_response']) + mocker.patch('fyle_integrations_platform_connector.apis.Expenses.get', return_value=[]) + + workspace_id = 1 + task_log = TaskLog.objects.filter(workspace_id=workspace_id).first() + task_log.status = 'READY' + task_log.save() + + expense_group = ExpenseGroup.objects.get(id=1) + create_bill(expense_group.id, task_log.id, True, False) + + bill = Bill.objects.get(expense_group=expense_group) + bill.payment_synced = False + bill.save() + + for expense in bill.expense_group.expenses.all(): + expense.paid_on_fyle = True + expense.save() + + mocker.patch( + 'apps.workspaces.models.FeatureConfig.get_feature_config', + return_value=True + ) + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection' + ) + mocker.patch('apps.sage_intacct.tasks.invalidate_sage_intacct_credentials') + + mocker.patch( + 'apps.sage_intacct.models.APPayment.create_ap_payment', + side_effect=IntacctRESTInvalidTokenError('Invalid token', 'Token expired') + ) + + create_ap_payment(workspace_id) + + payment_task_log = TaskLog.objects.filter( + workspace_id=workspace_id, + task_id='PAYMENT_{}'.format(bill.expense_group.id) + ).first() + assert payment_task_log.status == 'FAILED' + assert payment_task_log.detail == 'Token expired' + + +def test_create_ap_payment_rest_internal_server_error(mocker, db): + """ + Test create_ap_payment with IntacctRESTInternalServerError + """ + mocker.patch('sageintacctsdk.apis.Bills.post', return_value=data['bill_response']) + mocker.patch('sageintacctsdk.apis.Bills.get', return_value=data['bill_response']['data']) + mocker.patch('apps.sage_intacct.tasks.load_attachments', return_value=('sdfgh', False)) + mocker.patch('sageintacctsdk.apis.Bills.update_attachment', return_value=data['bill_response']) + mocker.patch('fyle_integrations_platform_connector.apis.Expenses.get', return_value=[]) + + workspace_id = 1 + task_log = TaskLog.objects.filter(workspace_id=workspace_id).first() + task_log.status = 'READY' + task_log.save() + + expense_group = ExpenseGroup.objects.get(id=1) + create_bill(expense_group.id, task_log.id, True, False) + + bill = Bill.objects.get(expense_group=expense_group) + bill.payment_synced = False + bill.save() + + for expense in bill.expense_group.expenses.all(): + expense.paid_on_fyle = True + expense.save() + + mocker.patch( + 'apps.workspaces.models.FeatureConfig.get_feature_config', + return_value=True + ) + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection' + ) + + mocker.patch( + 'apps.sage_intacct.models.APPayment.create_ap_payment', + side_effect=IntacctRESTInternalServerError('Internal server error', 'Server error response') + ) + + create_ap_payment(workspace_id) + + payment_task_log = TaskLog.objects.filter( + workspace_id=workspace_id, + task_id='PAYMENT_{}'.format(bill.expense_group.id) + ).first() + assert payment_task_log.status == 'FAILED' + assert payment_task_log.detail == 'Server error response' + + +def test_create_ap_payment_generic_exception(mocker, db): + """ + Test create_ap_payment with generic Exception (FATAL status) + """ + mocker.patch('sageintacctsdk.apis.Bills.post', return_value=data['bill_response']) + mocker.patch('sageintacctsdk.apis.Bills.get', return_value=data['bill_response']['data']) + mocker.patch('apps.sage_intacct.tasks.load_attachments', return_value=('sdfgh', False)) + mocker.patch('sageintacctsdk.apis.Bills.update_attachment', return_value=data['bill_response']) + mocker.patch('fyle_integrations_platform_connector.apis.Expenses.get', return_value=[]) + + workspace_id = 1 + task_log = TaskLog.objects.filter(workspace_id=workspace_id).first() + task_log.status = 'READY' + task_log.save() + + expense_group = ExpenseGroup.objects.get(id=1) + create_bill(expense_group.id, task_log.id, True, False) + + bill = Bill.objects.get(expense_group=expense_group) + bill.payment_synced = False + bill.save() + + for expense in bill.expense_group.expenses.all(): + expense.paid_on_fyle = True + expense.save() + + mocker.patch( + 'apps.workspaces.models.FeatureConfig.get_feature_config', + return_value=True + ) + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection' + ) + + mocker.patch( + 'apps.sage_intacct.models.APPayment.create_ap_payment', + side_effect=Exception('Unexpected error occurred') + ) + + create_ap_payment(workspace_id) + + payment_task_log = TaskLog.objects.filter( + workspace_id=workspace_id, + task_id='PAYMENT_{}'.format(bill.expense_group.id) + ).first() + assert payment_task_log.status == 'FATAL' + assert 'error' in payment_task_log.detail + + +def test_check_sage_intacct_bill_status_rest(mocker, db): + """ + Test check_sage_intacct_bill_status_rest with bills + """ + workspace_id = 1 + + expense_group = ExpenseGroup.objects.get(id=1) + bill = Bill.create_bill(expense_group) + mocker.patch('apps.sage_intacct.models.import_string', return_value=lambda *args, **kwargs: None) + Configuration.objects.filter(workspace_id=workspace_id).update( + corporate_credit_card_expenses_object='BILL' + ) + workspace_general_settings = Configuration.objects.get(workspace_id=workspace_id) + BillLineitem.create_bill_lineitems(expense_group, workspace_general_settings) + + bill.paid_on_sage_intacct = False + bill.payment_synced = False + bill.save() + + task_log, _ = TaskLog.objects.update_or_create( + expense_group=expense_group, + defaults={ + 'workspace_id': workspace_id, + 'type': 'CREATING_BILL', + 'status': 'COMPLETE', + 'detail': { + 'ia::result': { + 'key': 'BILL123' + } + } + } + ) + + mocker.patch( + 'apps.workspaces.models.FeatureConfig.get_feature_config', + return_value=True + ) + + mock_sage_intacct_connection = mocker.MagicMock() + mock_sage_intacct_connection.get_bills.return_value = iter([[ + {'key': 'BILL123', 'state': 'paid'} + ]]) + + check_sage_intacct_bill_status_rest(workspace_id, mock_sage_intacct_connection) + + bill.refresh_from_db() + assert bill.paid_on_sage_intacct is True + assert bill.payment_synced is True + + line_items = BillLineitem.objects.filter(bill_id=bill.id) + for line_item in line_items: + line_item.expense.refresh_from_db() + assert line_item.expense.paid_on_sage_intacct is True + + +def test_check_sage_intacct_bill_status_rest_no_bills(mocker, db): + """ + Test check_sage_intacct_bill_status_rest when no bills exist + """ + workspace_id = 1 + + Bill.objects.filter(expense_group__workspace_id=workspace_id).delete() + + mock_sage_intacct_connection = mocker.MagicMock() + + check_sage_intacct_bill_status_rest(workspace_id, mock_sage_intacct_connection) + + mock_sage_intacct_connection.get_bills.assert_not_called() + + +def test_check_sage_intacct_expense_report_status_rest(mocker, db, create_expense_report): + """ + Test check_sage_intacct_expense_report_status_rest with expense reports + """ + workspace_id = 1 + + expense_report, expense_report_lineitems = create_expense_report + + expense_report.paid_on_sage_intacct = False + expense_report.payment_synced = False + expense_report.save() + + task_log = TaskLog.objects.get(expense_group=expense_report.expense_group) + task_log.detail = { + 'key': 'ER123' + } + task_log.save() + + mock_sage_intacct_connection = mocker.MagicMock() + mock_sage_intacct_connection.get_expense_reports.return_value = iter([[ + {'key': 'ER123', 'state': 'paid'} + ]]) + + check_sage_intacct_expense_report_status_rest(workspace_id, mock_sage_intacct_connection) + + expense_report.refresh_from_db() + assert expense_report.paid_on_sage_intacct is True + assert expense_report.payment_synced is True + + line_items = ExpenseReportLineitem.objects.filter(expense_report_id=expense_report.id) + for line_item in line_items: + assert line_item.expense.paid_on_sage_intacct is True + + +def test_check_sage_intacct_expense_report_status_rest_no_reports(mocker, db): + """ + Test check_sage_intacct_expense_report_status_rest when no expense reports exist + """ + workspace_id = 1 + + ExpenseReport.objects.filter(expense_group__workspace_id=workspace_id).delete() + + mock_sage_intacct_connection = mocker.MagicMock() + + check_sage_intacct_expense_report_status_rest(workspace_id, mock_sage_intacct_connection) + + mock_sage_intacct_connection.get_expense_reports.assert_not_called() + + +def test_create_sage_intacct_reimbursement_rest_bad_request_error_skips(mocker, db, create_expense_report): + """ + Test create_sage_intacct_reimbursement with IntacctRESTBadRequestError that skips payment + """ + workspace_id = 1 + + expense_report, _ = create_expense_report + expense_report.payment_synced = False + expense_report.paid_on_sage_intacct = False + expense_report.save() + + for expense in expense_report.expense_group.expenses.all(): + expense.paid_on_fyle = True + expense.save() + + mocker.patch( + 'fyle_integrations_platform_connector.apis.Expenses.get', + return_value=[] + ) + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection' + ) + + error_response = json.dumps({ + "ia::result": { + "ia::error": { + "message": "Payment cannot be processed because one or more bills are already paid" + } + } + }) + + mocker.patch( + 'apps.sage_intacct.models.SageIntacctReimbursement.create_sage_intacct_reimbursement', + side_effect=IntacctRESTBadRequestError('Bad request', error_response) + ) + + create_sage_intacct_reimbursement(workspace_id) + + expense_report.refresh_from_db() + assert expense_report.payment_synced is True + assert expense_report.paid_on_sage_intacct is True + + +def test_create_sage_intacct_reimbursement_rest_bad_request_error_saves(mocker, db, create_expense_report): + """ + Test create_sage_intacct_reimbursement with IntacctRESTBadRequestError that saves task_log + """ + workspace_id = 1 + + expense_report, _ = create_expense_report + expense_report.payment_synced = False + expense_report.paid_on_sage_intacct = False + expense_report.save() + + for expense in expense_report.expense_group.expenses.all(): + expense.paid_on_fyle = True + expense.save() + + mocker.patch( + 'fyle_integrations_platform_connector.apis.Expenses.get', + return_value=[] + ) + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection' + ) + + error_response = json.dumps({ + "ia::result": { + "ia::error": { + "message": "Some other error occurred" + } + } + }) + + mocker.patch( + 'apps.sage_intacct.models.SageIntacctReimbursement.create_sage_intacct_reimbursement', + side_effect=IntacctRESTBadRequestError('Bad request', error_response) + ) + + create_sage_intacct_reimbursement(workspace_id) + + payment_task_log = TaskLog.objects.filter( + workspace_id=workspace_id, + task_id='PAYMENT_{}'.format(expense_report.expense_group.id) + ).first() + assert payment_task_log.status == 'FAILED' + + expense_report.refresh_from_db() + assert expense_report.payment_synced is False + + +def test_create_sage_intacct_reimbursement_rest_invalid_token_error(mocker, db, create_expense_report): + """ + Test create_sage_intacct_reimbursement with IntacctRESTInvalidTokenError + """ + workspace_id = 1 + + expense_report, _ = create_expense_report + expense_report.payment_synced = False + expense_report.save() + + for expense in expense_report.expense_group.expenses.all(): + expense.paid_on_fyle = True + expense.save() + + mocker.patch( + 'fyle_integrations_platform_connector.apis.Expenses.get', + return_value=[] + ) + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection' + ) + mocker.patch('apps.sage_intacct.tasks.invalidate_sage_intacct_credentials') + + mocker.patch( + 'apps.sage_intacct.models.SageIntacctReimbursement.create_sage_intacct_reimbursement', + side_effect=IntacctRESTInvalidTokenError('Invalid token', 'Token expired') + ) + + create_sage_intacct_reimbursement(workspace_id) + + payment_task_log = TaskLog.objects.filter( + workspace_id=workspace_id, + task_id='PAYMENT_{}'.format(expense_report.expense_group.id) + ).first() + assert payment_task_log.status == 'FAILED' + assert payment_task_log.detail == 'Token expired' + + +def test_create_sage_intacct_reimbursement_rest_internal_server_error(mocker, db, create_expense_report): + """ + Test create_sage_intacct_reimbursement with IntacctRESTInternalServerError + """ + workspace_id = 1 + + expense_report, _ = create_expense_report + expense_report.payment_synced = False + expense_report.save() + + for expense in expense_report.expense_group.expenses.all(): + expense.paid_on_fyle = True + expense.save() + + mocker.patch( + 'fyle_integrations_platform_connector.apis.Expenses.get', + return_value=[] + ) + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection' + ) + + mocker.patch( + 'apps.sage_intacct.models.SageIntacctReimbursement.create_sage_intacct_reimbursement', + side_effect=IntacctRESTInternalServerError('Internal server error', 'Server error response') + ) + + create_sage_intacct_reimbursement(workspace_id) + + payment_task_log = TaskLog.objects.filter( + workspace_id=workspace_id, + task_id='PAYMENT_{}'.format(expense_report.expense_group.id) + ).first() + assert payment_task_log.status == 'FAILED' + assert payment_task_log.detail == 'Server error response' + + +def test_check_sage_intacct_object_status_rest_success(mocker, db): + """ + Test check_sage_intacct_object_status_rest success case + """ + workspace_id = 1 + + mock_connection = mocker.MagicMock() + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection', + return_value=mock_connection + ) + + mock_check_bill_status = mocker.patch( + 'apps.sage_intacct.tasks.check_sage_intacct_bill_status_rest' + ) + mock_check_expense_report_status = mocker.patch( + 'apps.sage_intacct.tasks.check_sage_intacct_expense_report_status_rest' + ) + + check_sage_intacct_object_status_rest(workspace_id) + + mock_check_bill_status.assert_called_once_with( + workspace_id=workspace_id, + sage_intacct_connection=mock_connection + ) + mock_check_expense_report_status.assert_called_once_with( + workspace_id=workspace_id, + sage_intacct_connection=mock_connection + ) + + +def test_check_sage_intacct_object_status_rest_credentials_not_exist(mocker, db): + """ + Test check_sage_intacct_object_status_rest when credentials do not exist + """ + workspace_id = 1 + + mocker.patch( + 'apps.workspaces.models.SageIntacctCredential.get_active_sage_intacct_credentials', + side_effect=SageIntacctCredential.DoesNotExist() + ) + + mock_check_bill_status = mocker.patch( + 'apps.sage_intacct.tasks.check_sage_intacct_bill_status_rest' + ) + + check_sage_intacct_object_status_rest(workspace_id) + + mock_check_bill_status.assert_not_called() + + +def test_check_sage_intacct_object_status_rest_no_privilege_error(mocker, db): + """ + Test check_sage_intacct_object_status_rest with NoPrivilegeError + """ + workspace_id = 1 + + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection', + side_effect=NoPrivilegeError(msg='Insufficient permission', response='Insufficient permission') + ) + + mock_check_bill_status = mocker.patch( + 'apps.sage_intacct.tasks.check_sage_intacct_bill_status_rest' + ) + + check_sage_intacct_object_status_rest(workspace_id) + + mock_check_bill_status.assert_not_called() + + +def test_check_sage_intacct_object_status_rest_invalid_token_error(mocker, db): + """ + Test check_sage_intacct_object_status_rest with InvalidTokenError + """ + workspace_id = 1 + + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection', + side_effect=InvalidTokenError(msg='Invalid token', response='Token expired') + ) + + mock_invalidate = mocker.patch( + 'apps.sage_intacct.tasks.invalidate_sage_intacct_credentials' + ) + + mock_check_bill_status = mocker.patch( + 'apps.sage_intacct.tasks.check_sage_intacct_bill_status_rest' + ) + + check_sage_intacct_object_status_rest(workspace_id) + + mock_invalidate.assert_called_once() + mock_check_bill_status.assert_not_called() + + +def test_check_sage_intacct_object_status_rest_intacct_rest_invalid_token_error(mocker, db): + """ + Test check_sage_intacct_object_status_rest with IntacctRESTInvalidTokenError + """ + workspace_id = 1 + + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection', + side_effect=IntacctRESTInvalidTokenError('Invalid token', 'Token expired') + ) + + mock_invalidate = mocker.patch( + 'apps.sage_intacct.tasks.invalidate_sage_intacct_credentials' + ) + + mock_check_bill_status = mocker.patch( + 'apps.sage_intacct.tasks.check_sage_intacct_bill_status_rest' + ) + + check_sage_intacct_object_status_rest(workspace_id) + + mock_invalidate.assert_called_once() + mock_check_bill_status.assert_not_called() + + +def test_check_sage_intacct_object_status_rest_bad_request_error(mocker, db): + """ + Test check_sage_intacct_object_status_rest with IntacctRESTBadRequestError + """ + workspace_id = 1 + + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection', + side_effect=IntacctRESTBadRequestError('Bad request', 'Bad request error') + ) + + mock_check_bill_status = mocker.patch( + 'apps.sage_intacct.tasks.check_sage_intacct_bill_status_rest' + ) + + check_sage_intacct_object_status_rest(workspace_id) + + mock_check_bill_status.assert_not_called() + + +def test_check_sage_intacct_object_status_rest_internal_server_error(mocker, db): + """ + Test check_sage_intacct_object_status_rest with IntacctRESTInternalServerError + """ + workspace_id = 1 + + mocker.patch( + 'apps.sage_intacct.tasks.get_sage_intacct_connection', + side_effect=IntacctRESTInternalServerError('Internal server error', 'Server error') + ) + + mock_check_bill_status = mocker.patch( + 'apps.sage_intacct.tasks.check_sage_intacct_bill_status_rest' + ) + + check_sage_intacct_object_status_rest(workspace_id) + + mock_check_bill_status.assert_not_called() + + def test_create_journal_entry_exported_to_intacct_status_on_post_export_failure(mocker, create_task_logs, db): """ Test that create_journal_entry sets status to EXPORTED_TO_INTACCT when export succeeds but post-export fails diff --git a/tests/test_workspaces/test_views.py b/tests/test_workspaces/test_views.py index e7b06a26..32891b5d 100644 --- a/tests/test_workspaces/test_views.py +++ b/tests/test_workspaces/test_views.py @@ -9,6 +9,11 @@ from fyle_accounting_mappings.models import MappingSetting from fyle_rest_auth.utils import AuthUtils from sageintacctsdk import exceptions as sage_intacct_exc +from intacctsdk.exceptions import ( + BadRequestError as SageIntacctRESTBadRequestError, + InvalidTokenError as SageIntacctRestInvalidTokenError, + InternalServerError as SageIntacctRESTInternalServerError +) from apps.sage_intacct.models import SageIntacctAttributesCount from apps.tasks.models import TaskLog @@ -755,3 +760,228 @@ def test_import_code_field_view(db, mocker, api_client, test_connection): 'ACCOUNT': True, 'DEPARTMENT': True } + + +def test_handle_sage_intacct_rest_api_connection_new_credentials(mocker, api_client, test_connection): + """ + Test handle_sage_intacct_rest_api_connection creates new credentials + """ + workspace_id = 1 + + # Enable migrated_to_rest_api feature + feature_config = FeatureConfig.objects.get(workspace_id=workspace_id) + feature_config.migrated_to_rest_api = True + feature_config.save() + + # Delete existing credentials + SageIntacctCredential.objects.filter(workspace_id=workspace_id).delete() + + # Mock IntacctRESTSDK + mock_sdk_instance = mocker.MagicMock() + mock_sdk_instance.access_token = 'test_access_token' + mock_sdk_instance.access_token_expires_in = 21600 + mock_sdk_instance.attachment_folders.get_all_generator.return_value = iter([[]]) + + mocker.patch('apps.workspaces.views.IntacctRESTSDK', return_value=mock_sdk_instance) + + url = '/api/workspaces/{}/credentials/sage_intacct/'.format(workspace_id) + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + response = api_client.post( + url, + data={ + 'si_user_id': 'test_user', + 'si_company_id': 'test_company', + 'si_company_name': 'Test Company' + } + ) + + assert response.status_code == 200 + + # Verify credentials were created + credentials = SageIntacctCredential.objects.filter(workspace_id=workspace_id).first() + assert credentials is not None + assert credentials.si_user_id == 'test_user' + assert credentials.si_company_id == 'test_company' + assert credentials.access_token == 'test_access_token' + + # Reset feature config + feature_config.migrated_to_rest_api = False + feature_config.save() + + +def test_handle_sage_intacct_rest_api_connection_existing_credentials(mocker, api_client, test_connection): + """ + Test handle_sage_intacct_rest_api_connection updates existing credentials + """ + workspace_id = 1 + workspace = Workspace.objects.get(id=workspace_id) + + # Enable migrated_to_rest_api feature + feature_config = FeatureConfig.objects.get(workspace_id=workspace_id) + feature_config.migrated_to_rest_api = True + feature_config.save() + + # Create existing credentials + SageIntacctCredential.objects.filter(workspace_id=workspace_id).delete() + SageIntacctCredential.objects.create( + workspace=workspace, + si_user_id='old_user', + si_company_id='old_company', + si_company_name='Old Company', + access_token='old_token', + is_expired=True + ) + + # Mock IntacctRESTSDK + mock_sdk_instance = mocker.MagicMock() + mock_sdk_instance.access_token = 'new_access_token' + mock_sdk_instance.access_token_expires_in = 21600 + mock_sdk_instance.attachment_folders.get_all_generator.return_value = iter([[]]) + + mocker.patch('apps.workspaces.views.IntacctRESTSDK', return_value=mock_sdk_instance) + mocker.patch('apps.workspaces.views.patch_integration_settings') + + url = '/api/workspaces/{}/credentials/sage_intacct/'.format(workspace_id) + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + response = api_client.post( + url, + data={ + 'si_user_id': 'new_user', + 'si_company_id': 'new_company', + 'si_company_name': 'New Company' + } + ) + + assert response.status_code == 200 + + # Verify credentials were updated + credentials = SageIntacctCredential.objects.filter(workspace_id=workspace_id).first() + assert credentials.si_user_id == 'new_user' + assert credentials.si_company_id == 'new_company' + assert credentials.si_company_name == 'New Company' + assert credentials.access_token == 'new_access_token' + assert credentials.is_expired is False + + # Reset feature config + feature_config.migrated_to_rest_api = False + feature_config.save() + + +def test_handle_sage_intacct_rest_api_connection_invalid_token_error(mocker, api_client, test_connection): + """ + Test handle_sage_intacct_rest_api_connection handles invalid token error + """ + workspace_id = 1 + + # Enable migrated_to_rest_api feature + feature_config = FeatureConfig.objects.get(workspace_id=workspace_id) + feature_config.migrated_to_rest_api = True + feature_config.save() + + # Delete existing credentials to trigger new connection path + SageIntacctCredential.objects.filter(workspace_id=workspace_id).delete() + + # Mock IntacctRESTSDK to raise InvalidTokenError + mocker.patch( + 'apps.workspaces.views.IntacctRESTSDK', + side_effect=SageIntacctRestInvalidTokenError('Invalid credentials', 'Invalid token response') + ) + + url = '/api/workspaces/{}/credentials/sage_intacct/'.format(workspace_id) + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + response = api_client.post( + url, + data={ + 'si_user_id': 'test_user', + 'si_company_id': 'test_company', + 'si_company_name': 'Test Company' + } + ) + + assert response.status_code == 401 + + # Reset feature config + feature_config.migrated_to_rest_api = False + feature_config.save() + + +def test_handle_sage_intacct_rest_api_connection_bad_request_error(mocker, api_client, test_connection): + """ + Test handle_sage_intacct_rest_api_connection handles bad request error + """ + workspace_id = 1 + + # Enable migrated_to_rest_api feature + feature_config = FeatureConfig.objects.get(workspace_id=workspace_id) + feature_config.migrated_to_rest_api = True + feature_config.save() + + # Delete existing credentials to trigger new connection path + SageIntacctCredential.objects.filter(workspace_id=workspace_id).delete() + + # Mock IntacctRESTSDK to raise BadRequestError + mocker.patch( + 'apps.workspaces.views.IntacctRESTSDK', + side_effect=SageIntacctRESTBadRequestError('Bad request', 'Invalid request data') + ) + + url = '/api/workspaces/{}/credentials/sage_intacct/'.format(workspace_id) + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + response = api_client.post( + url, + data={ + 'si_user_id': 'test_user', + 'si_company_id': 'test_company', + 'si_company_name': 'Test Company' + } + ) + + assert response.status_code == 400 + + # Reset feature config + feature_config.migrated_to_rest_api = False + feature_config.save() + + +def test_handle_sage_intacct_rest_api_connection_internal_server_error(mocker, api_client, test_connection): + """ + Test handle_sage_intacct_rest_api_connection handles internal server error + """ + workspace_id = 1 + + # Enable migrated_to_rest_api feature + feature_config = FeatureConfig.objects.get(workspace_id=workspace_id) + feature_config.migrated_to_rest_api = True + feature_config.save() + + # Delete existing credentials to trigger new connection path + SageIntacctCredential.objects.filter(workspace_id=workspace_id).delete() + + # Mock IntacctRESTSDK to raise InternalServerError + mocker.patch( + 'apps.workspaces.views.IntacctRESTSDK', + side_effect=SageIntacctRESTInternalServerError('Internal server error', 'Server error response') + ) + + url = '/api/workspaces/{}/credentials/sage_intacct/'.format(workspace_id) + api_client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(test_connection.access_token)) + + response = api_client.post( + url, + data={ + 'si_user_id': 'test_user', + 'si_company_id': 'test_company', + 'si_company_name': 'Test Company' + } + ) + + assert response.status_code == 401 + assert response.data['message'] == 'Something went wrong while connecting to Sage Intacct' + + # Reset feature config + feature_config.migrated_to_rest_api = False + feature_config.save()