diff --git a/apps/internal/migrations/0029_auto_generated_sql.py b/apps/internal/migrations/0029_auto_generated_sql.py new file mode 100644 index 00000000..ae0ef7d6 --- /dev/null +++ b/apps/internal/migrations/0029_auto_generated_sql.py @@ -0,0 +1,9 @@ +# Generated by Django +from django.db import migrations +from apps.internal.helpers import safe_run_sql +sql_files = [ + 'fyle-integrations-db-migrations/intacct/functions/delete_workspace.sql' +] +class Migration(migrations.Migration): + dependencies = [('internal', '0028_auto_generated_sql')] + operations = safe_run_sql(sql_files) diff --git a/apps/internal/tasks.py b/apps/internal/tasks.py index df85677a..d349522b 100644 --- a/apps/internal/tasks.py +++ b/apps/internal/tasks.py @@ -4,14 +4,18 @@ from django.db.models import Q from django_q.models import OrmQ, Schedule + +from fyle_accounting_library.system_comments.models import SystemComment from fyle_accounting_library.fyle_platform.enums import ExpenseImportSourceEnum from apps.tasks.models import TaskLog from apps.fyle.models import ExpenseGroup from apps.workspaces.actions import export_to_intacct from apps.workspaces.models import LastExportDetail, Workspace +from apps.workspaces.system_comments import add_system_comment from workers.helpers import RoutingKeyEnum, WorkerActionEnum, publish_to_rabbitmq from apps.fyle.actions import post_accounting_export_summary, update_failed_expenses +from apps.workspaces.enums import SystemCommentEntityTypeEnum, SystemCommentIntentEnum, SystemCommentReasonEnum, SystemCommentSourceEnum logger = logging.getLogger(__name__) logger.level = logging.INFO @@ -36,6 +40,7 @@ def retrigger_stuck_exports() -> None: Re-triggers export stuck exports by identifying failed export attempts and retrying them. """ + system_comments = [] prod_workspace_ids = Workspace.objects.filter( ~Q(name__icontains='fyle for') & ~Q(name__iendswith='test') ).values_list('id', flat=True) @@ -66,6 +71,7 @@ def retrigger_stuck_exports() -> None: for expense_group in expense_groups: expenses.extend(expense_group.expenses.all()) workspace_ids_list = list(workspace_ids) + task_logs_dict = {tl.expense_group_id: tl for tl in task_logs} task_logs.update(status='FAILED', updated_at=datetime.now(), re_attempt_export=True) for workspace_id in workspace_ids_list: errored_expenses = [expense for expense in expenses if expense.workspace_id == workspace_id] @@ -83,10 +89,30 @@ def retrigger_stuck_exports() -> None: export_expense_group_ids = list(expense_groups.filter(workspace_id=workspace_id).values_list('id', flat=True)) if export_expense_group_ids and len(export_expense_group_ids) < 200: logger.info('Re-triggering export for expense group %s since no 1 hour schedule for workspace %s', export_expense_group_ids, workspace_id) + + for expense_group_id in export_expense_group_ids: + expense_group = expense_groups.filter(id=expense_group_id, workspace_id=workspace_id).first() + task_log = task_logs_dict.get(expense_group_id) + if expense_group and task_log: + stuck_duration_seconds = (datetime.now(timezone.utc) - task_log.updated_at.replace(tzinfo=timezone.utc)).total_seconds() + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.RETRIGGER_STUCK_EXPORTS, + intent=SystemCommentIntentEnum.EXPORT_RETRIGGERED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE_GROUP, + workspace_id=workspace_id, + entity_id=expense_group_id, + reason=SystemCommentReasonEnum.EXPORT_RETRIGGERED_STUCK, + info={'stuck_duration_seconds': stuck_duration_seconds} + ) + export_to_intacct(workspace_id=workspace_id, expense_group_ids=export_expense_group_ids, triggered_by=ExpenseImportSourceEnum.INTERNAL) else: logger.info('Skipping export for workspace %s since it has more than 200 expense groups', workspace_id) + if system_comments: + SystemComment.bulk_create_comments(system_comments) + def pause_and_resume_export_schedules() -> None: """ diff --git a/apps/sage_intacct/models.py b/apps/sage_intacct/models.py index 66bdcde1..7325f1b7 100644 --- a/apps/sage_intacct/models.py +++ b/apps/sage_intacct/models.py @@ -147,6 +147,7 @@ def get_project_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, gener entity_type=SystemCommentEntityTypeEnum.EXPENSE, workspace_id=expense_group.workspace_id, entity_id=lineitem.id, + persist_without_export=False, reason=SystemCommentReasonEnum.DEFAULT_PROJECT_APPLIED, info={'default_project_id': general_mappings.default_project_id, 'default_project_name': general_mappings.default_project_name} ) @@ -200,7 +201,8 @@ def get_department_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, ge workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.EMPLOYEE_DEPARTMENT_APPLIED, - info={'employee_department_id': employee_department, 'employee_department_name': employee_department} + info={'employee_department_id': employee_department, 'employee_department_name': employee_department}, + persist_without_export=False ) return employee_department @@ -214,7 +216,8 @@ def get_department_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, ge workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.DEFAULT_DEPARTMENT_APPLIED, - info={'default_department_id': general_mappings.default_department_id, 'default_department_name': general_mappings.default_department_name} + info={'default_department_id': general_mappings.default_department_id, 'default_department_name': general_mappings.default_department_name}, + persist_without_export=False ) return general_mappings.default_department_id @@ -266,7 +269,8 @@ def get_location_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, gene workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.EMPLOYEE_LOCATION_APPLIED, - info={'employee_location_id': employee_location, 'employee_location_name': employee_location} + info={'employee_location_id': employee_location, 'employee_location_name': employee_location}, + persist_without_export=False ) return employee_location @@ -280,7 +284,8 @@ def get_location_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, gene workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.DEFAULT_LOCATION_APPLIED, - info={'default_location_id': general_mappings.default_location_id, 'default_location_name': general_mappings.default_location_name} + info={'default_location_id': general_mappings.default_location_id, 'default_location_name': general_mappings.default_location_name}, + persist_without_export=False ) return general_mappings.default_location_id @@ -377,6 +382,7 @@ def get_item_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, general_ workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.DEFAULT_ITEM_APPLIED, + persist_without_export=False, info={'default_item_id': general_mappings.default_item_id, 'default_item_name': general_mappings.default_item_name} ) return general_mappings.default_item_id @@ -533,7 +539,8 @@ def get_class_id_or_none(expense_group: ExpenseGroup, lineitem: Expense, general workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.DEFAULT_CLASS_APPLIED, - info={'default_class_id': general_mappings.default_class_id, 'default_class_name': general_mappings.default_class_name} + info={'default_class_id': general_mappings.default_class_id, 'default_class_name': general_mappings.default_class_name}, + persist_without_export=False ) return general_mappings.default_class_id @@ -909,7 +916,8 @@ def get_ccc_account_id(general_mappings: GeneralMapping, expense: Expense, descr workspace_id=general_mappings.workspace_id, entity_id=expense.id, reason=SystemCommentReasonEnum.EMPLOYEE_CCC_ACCOUNT_APPLIED, - info={'employee_ccc_account_id': employee_mapping.destination_card_account.destination_id, 'employee_ccc_account_name': employee_mapping.destination_card_account.display_name} + info={'employee_ccc_account_id': employee_mapping.destination_card_account.destination_id, 'employee_ccc_account_name': employee_mapping.destination_card_account.display_name}, + persist_without_export=False ) return employee_mapping.destination_card_account.destination_id @@ -922,7 +930,8 @@ def get_ccc_account_id(general_mappings: GeneralMapping, expense: Expense, descr workspace_id=general_mappings.workspace_id, entity_id=expense.id, reason=SystemCommentReasonEnum.DEFAULT_CREDIT_CARD_APPLIED, - info={'default_charge_card_id': general_mappings.default_charge_card_id, 'default_charge_card_name': general_mappings.default_charge_card_name} + info={'default_charge_card_id': general_mappings.default_charge_card_id, 'default_charge_card_name': general_mappings.default_charge_card_name}, + persist_without_export=False ) return general_mappings.default_charge_card_id @@ -1009,7 +1018,8 @@ def create_bill(expense_group: ExpenseGroup, supdoc_id: str = None, system_comme workspace_id=expense_group.workspace_id, entity_id=expense_group.id, reason=SystemCommentReasonEnum.DEFAULT_CCC_VENDOR_APPLIED, - info={'default_ccc_vendor_id': general_mappings.default_ccc_vendor_id, 'default_ccc_vendor_name': general_mappings.default_ccc_vendor_name} + info={'default_ccc_vendor_id': general_mappings.default_ccc_vendor_id, 'default_ccc_vendor_name': general_mappings.default_ccc_vendor_name}, + persist_without_export=False ) bill_object, _ = Bill.objects.update_or_create( @@ -1126,6 +1136,23 @@ def create_bill_lineitems(expense_group: ExpenseGroup, configuration: Configurat allocation_dimensions = set(allocation_detail.keys()) user_defined_dimensions = [user_defined_dimension for user_defined_dimension in user_defined_dimensions if list(user_defined_dimension.keys())[0] not in allocation_dimensions] + if lineitem.billable and not (dimensions_values['customer_id'] and dimensions_values['item_id']): + missing_fields = [] + if not dimensions_values['customer_id']: + missing_fields.append('customer_id') + if not dimensions_values['item_id']: + missing_fields.append('item_id') + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.CREATE_BILL_LINEITEMS, + intent=SystemCommentIntentEnum.BILLABLE_DISABLED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE, + workspace_id=expense_group.workspace_id, + entity_id=lineitem.id, + reason=SystemCommentReasonEnum.BILLABLE_SET_TO_FALSE_MISSING_DIMENSIONS, + info={'missing_fields': missing_fields, 'original_billable': lineitem.billable} + ) + bill_lineitem_object, _ = BillLineitem.objects.update_or_create( bill=bill, expense_id=lineitem.id, @@ -1341,9 +1368,27 @@ def create_expense_report_lineitems(expense_group: ExpenseGroup, configuration: workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.CREDIT_CARD_MISC_VENDOR_APPLIED, - info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'} + info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'}, + persist_without_export=False ) + if lineitem.billable and not (customer_id and item_id): + missing_fields = [] + if not customer_id: + missing_fields.append('customer_id') + if not item_id: + missing_fields.append('item_id') + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.CREATE_EXPENSE_REPORT_LINEITEMS, + intent=SystemCommentIntentEnum.BILLABLE_DISABLED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE, + workspace_id=expense_group.workspace_id, + entity_id=lineitem.id, + reason=SystemCommentReasonEnum.BILLABLE_SET_TO_FALSE_MISSING_DIMENSIONS, + info={'missing_fields': missing_fields, 'original_billable': lineitem.billable} + ) + expense_report_lineitem_object, _ = ExpenseReportLineitem.objects.update_or_create( expense_report=expense_report, expense_id=lineitem.id, @@ -1523,7 +1568,8 @@ def create_journal_entry_lineitems(expense_group: ExpenseGroup, configuration: C workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.CREDIT_CARD_MISC_VENDOR_APPLIED, - info={'original_merchant': lineitem.vendor, 'vendor_used': 'Credit Card Misc'} + info={'original_merchant': lineitem.vendor, 'vendor_used': 'Credit Card Misc'}, + persist_without_export=False ) else: @@ -1547,7 +1593,8 @@ def create_journal_entry_lineitems(expense_group: ExpenseGroup, configuration: C workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.CREDIT_CARD_MISC_VENDOR_APPLIED, - info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'} + info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'}, + persist_without_export=False ) else: raise ValueErrorWithResponse(message='Something Went Wrong', response='Credit Card Misc vendor not found') @@ -1568,6 +1615,23 @@ def create_journal_entry_lineitems(expense_group: ExpenseGroup, configuration: C allocation_id, _ = get_allocation_id_or_none(expense_group=expense_group, lineitem=lineitem) + if lineitem.billable and not (customer_id and item_id): + missing_fields = [] + if not customer_id: + missing_fields.append('customer_id') + if not item_id: + missing_fields.append('item_id') + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.CREATE_JOURNAL_ENTRY_LINEITEMS, + intent=SystemCommentIntentEnum.BILLABLE_DISABLED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE, + workspace_id=expense_group.workspace_id, + entity_id=lineitem.id, + reason=SystemCommentReasonEnum.BILLABLE_SET_TO_FALSE_MISSING_DIMENSIONS, + info={'missing_fields': missing_fields, 'original_billable': lineitem.billable} + ) + journal_entry_lineitem_object, _ = JournalEntryLineitem.objects.update_or_create( journal_entry=journal_entry, expense_id=lineitem.id, @@ -1656,7 +1720,8 @@ def create_charge_card_transaction(expense_group: ExpenseGroup, vendor_id: str = workspace_id=expense_group.workspace_id, entity_id=expense_group.id, reason=SystemCommentReasonEnum.CREDIT_CARD_MISC_VENDOR_APPLIED, - info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'} + info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'}, + persist_without_export=False ) else: raise ValueErrorWithResponse(message='Something Went Wrong', response='Credit Card Misc vendor not found') @@ -1792,7 +1857,25 @@ def create_charge_card_transaction_lineitems(expense_group: ExpenseGroup, config workspace_id=expense_group.workspace_id, entity_id=lineitem.id, reason=SystemCommentReasonEnum.CREDIT_CARD_MISC_VENDOR_APPLIED, - info={'original_merchant': lineitem.vendor, 'vendor_used': 'Credit Card Misc'} + info={'original_merchant': lineitem.vendor, 'vendor_used': 'Credit Card Misc'}, + persist_without_export=False + ) + + if lineitem.billable and not (customer_id and item_id): + missing_fields = [] + if not customer_id: + missing_fields.append('customer_id') + if not item_id: + missing_fields.append('item_id') + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS, + intent=SystemCommentIntentEnum.BILLABLE_DISABLED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE, + workspace_id=expense_group.workspace_id, + entity_id=lineitem.id, + reason=SystemCommentReasonEnum.BILLABLE_SET_TO_FALSE_MISSING_DIMENSIONS, + info={'missing_fields': missing_fields, 'original_billable': lineitem.billable} ) charge_card_transaction_lineitem_object, _ = ChargeCardTransactionLineitem.objects.update_or_create( diff --git a/apps/sage_intacct/tasks.py b/apps/sage_intacct/tasks.py index de963680..f64476f6 100644 --- a/apps/sage_intacct/tasks.py +++ b/apps/sage_intacct/tasks.py @@ -31,7 +31,7 @@ from apps.fyle.models import Expense, ExpenseGroup from apps.sage_intacct.utils import SageIntacctConnector from apps.workspaces.enums import ExportTypeEnum, SystemCommentEntityTypeEnum, SystemCommentIntentEnum, SystemCommentReasonEnum, SystemCommentSourceEnum -from apps.workspaces.system_comments import add_system_comment +from apps.workspaces.system_comments import add_system_comment, create_filtered_system_comments from apps.sage_intacct.actions import update_last_export_details from apps.sage_intacct.connector import SageIntacctRestConnector from apps.sage_intacct.helpers import get_sage_intacct_connection @@ -744,8 +744,21 @@ def create_journal_entry(expense_group_id: int, task_log_id: int, is_auto_export return in_progress_expenses = [] + system_comments = [] # Don't include expenses with previous export state as ERROR and it's an auto import/export run - if not (is_auto_export and expense_group.expenses.first().previous_export_state == 'ERROR'): + if is_auto_export and expense_group.expenses.first().previous_export_state == 'ERROR': + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.CREATE_JOURNAL_ENTRY, + intent=SystemCommentIntentEnum.EXPORT_SUMMARY_NOT_UPDATED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE_GROUP, + workspace_id=expense_group.workspace_id, + entity_id=expense_group.id, + export_type=ExportTypeEnum.JOURNAL_ENTRY, + reason=SystemCommentReasonEnum.EXPORT_SUMMARY_NOT_UPDATED_ERROR_STATE, + info={'previous_export_state': 'ERROR', 'is_auto_export': True} + ) + else: try: in_progress_expenses.extend(expense_group.expenses.all()) update_expense_and_post_summary(in_progress_expenses, expense_group.workspace_id, expense_group.fund_source) @@ -762,8 +775,6 @@ def create_journal_entry(expense_group_id: int, task_log_id: int, is_auto_export sage_intacct_credentials = SageIntacctCredential.get_active_sage_intacct_credentials(expense_group.workspace_id) sage_intacct_connection = get_sage_intacct_connection(workspace_id=expense_group.workspace_id, connection_type=SageIntacctRestConnectionTypeEnum.UPSERT.value) - system_comments = [] - if settings.BRAND_ID == 'fyle': if configuration.auto_map_employees and configuration.auto_create_destination_entity \ and configuration.auto_map_employees != 'EMPLOYEE_CODE': @@ -783,7 +794,8 @@ def create_journal_entry(expense_group_id: int, task_log_id: int, is_auto_export workspace_id=expense_group.workspace_id, entity_id=expense_group.id, reason=SystemCommentReasonEnum.CREDIT_CARD_MISC_VENDOR_APPLIED, - info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'} + info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'}, + persist_without_export=False ) __validate_employee_mapping(expense_group, configuration) @@ -843,14 +855,6 @@ def create_journal_entry(expense_group_id: int, task_log_id: int, is_auto_export resolve_errors_for_exported_expense_group(expense_group) worker_logger.info('Resolved errors for exported expense group %s for workspace id %s', expense_group.id, expense_group.workspace_id) - if system_comments: - batch_id = SystemComment.generate_batch_id() - for comment in system_comments: - comment['workspace_id'] = expense_group.workspace_id - comment['export_type'] = ExportTypeEnum.JOURNAL_ENTRY - comment['batch_id'] = batch_id - SystemComment.bulk_create_comments(system_comments, batch_id=batch_id) - try: generate_export_url_and_update_expense(expense_group) except Exception as e: @@ -950,6 +954,14 @@ def create_journal_entry(expense_group_id: int, task_log_id: int, is_auto_export update_failed_expenses(expense_group.expenses.all(), True) post_accounting_export_summary(workspace_id=expense_group.workspace_id, expense_ids=[expense.id for expense in expense_group.expenses.all()], fund_source=expense_group.fund_source, is_failed=True) + finally: + create_filtered_system_comments( + system_comments=system_comments, + workspace_id=expense_group.workspace_id, + export_type=ExportTypeEnum.JOURNAL_ENTRY, + is_exported_to_intacct=is_exported_to_intacct + ) + if last_export and last_export_failed: update_last_export_details(expense_group.workspace_id) @@ -986,8 +998,21 @@ def create_expense_report(expense_group_id: int, task_log_id: int, is_auto_expor return in_progress_expenses = [] + system_comments = [] # Don't include expenses with previous export state as ERROR and it's an auto import/export run - if not (is_auto_export and expense_group.expenses.first().previous_export_state == 'ERROR'): + if is_auto_export and expense_group.expenses.first().previous_export_state == 'ERROR': + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.CREATE_EXPENSE_REPORT, + intent=SystemCommentIntentEnum.EXPORT_SUMMARY_NOT_UPDATED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE_GROUP, + workspace_id=expense_group.workspace_id, + entity_id=expense_group.id, + export_type=ExportTypeEnum.EXPENSE_REPORT, + reason=SystemCommentReasonEnum.EXPORT_SUMMARY_NOT_UPDATED_ERROR_STATE, + info={'previous_export_state': 'ERROR', 'is_auto_export': True} + ) + else: try: in_progress_expenses.extend(expense_group.expenses.all()) update_expense_and_post_summary(in_progress_expenses, expense_group.workspace_id, expense_group.fund_source) @@ -1024,8 +1049,6 @@ def create_expense_report(expense_group_id: int, task_log_id: int, is_auto_expor task_log.save() with transaction.atomic(): - system_comments = [] - expense_report_object = ExpenseReport.create_expense_report(expense_group, task_log.supdoc_id) expense_report_lineitems_objects = ExpenseReportLineitem.create_expense_report_lineitems( @@ -1072,14 +1095,6 @@ def create_expense_report(expense_group_id: int, task_log_id: int, is_auto_expor resolve_errors_for_exported_expense_group(expense_group) worker_logger.info('Resolved errors for exported expense group %s for workspace id %s', expense_group.id, expense_group.workspace_id) - if system_comments: - batch_id = SystemComment.generate_batch_id() - for comment in system_comments: - comment['workspace_id'] = expense_group.workspace_id - comment['export_type'] = ExportTypeEnum.EXPENSE_REPORT - comment['batch_id'] = batch_id - SystemComment.bulk_create_comments(system_comments, batch_id=batch_id) - try: generate_export_url_and_update_expense(expense_group) except Exception as e: @@ -1176,6 +1191,14 @@ def create_expense_report(expense_group_id: int, task_log_id: int, is_auto_expor update_failed_expenses(expense_group.expenses.all(), True) post_accounting_export_summary(workspace_id=expense_group.workspace_id, expense_ids=[expense.id for expense in expense_group.expenses.all()], fund_source=expense_group.fund_source, is_failed=True) + finally: + create_filtered_system_comments( + system_comments=system_comments, + workspace_id=expense_group.workspace_id, + export_type=ExportTypeEnum.EXPENSE_REPORT, + is_exported_to_intacct=is_exported_to_intacct + ) + if last_export: if last_export_failed: update_last_export_details(expense_group.workspace_id) @@ -1216,8 +1239,21 @@ def create_bill(expense_group_id: int, task_log_id: int, is_auto_export: bool, l return in_progress_expenses = [] + system_comments = [] # Don't include expenses with previous export state as ERROR and it's an auto import/export run - if not (is_auto_export and expense_group.expenses.first().previous_export_state == 'ERROR'): + if is_auto_export and expense_group.expenses.first().previous_export_state == 'ERROR': + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.CREATE_BILL, + intent=SystemCommentIntentEnum.EXPORT_SUMMARY_NOT_UPDATED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE_GROUP, + workspace_id=expense_group.workspace_id, + entity_id=expense_group.id, + export_type=ExportTypeEnum.BILL, + reason=SystemCommentReasonEnum.EXPORT_SUMMARY_NOT_UPDATED_ERROR_STATE, + info={'previous_export_state': 'ERROR', 'is_auto_export': True} + ) + else: try: in_progress_expenses.extend(expense_group.expenses.all()) update_expense_and_post_summary(in_progress_expenses, expense_group.workspace_id, expense_group.fund_source) @@ -1254,8 +1290,6 @@ def create_bill(expense_group_id: int, task_log_id: int, is_auto_export: bool, l task_log.save() with transaction.atomic(): - system_comments = [] - bill_object = Bill.create_bill(expense_group, task_log.supdoc_id, system_comments) bill_lineitems_objects = BillLineitem.create_bill_lineitems(expense_group, configuration, system_comments) @@ -1297,14 +1331,6 @@ def create_bill(expense_group_id: int, task_log_id: int, is_auto_export: bool, l resolve_errors_for_exported_expense_group(expense_group) worker_logger.info('Resolved errors for exported expense group %s for workspace id %s', expense_group.id, expense_group.workspace_id) - if system_comments: - batch_id = SystemComment.generate_batch_id() - for comment in system_comments: - comment['workspace_id'] = expense_group.workspace_id - comment['export_type'] = ExportTypeEnum.BILL - comment['batch_id'] = batch_id - SystemComment.bulk_create_comments(system_comments, batch_id=batch_id) - try: generate_export_url_and_update_expense(expense_group) except Exception as e: @@ -1400,6 +1426,14 @@ def create_bill(expense_group_id: int, task_log_id: int, is_auto_export: bool, l update_failed_expenses(expense_group.expenses.all(), True) post_accounting_export_summary(workspace_id=expense_group.workspace_id, expense_ids=[expense.id for expense in expense_group.expenses.all()], fund_source=expense_group.fund_source, is_failed=True) + finally: + create_filtered_system_comments( + system_comments=system_comments, + workspace_id=expense_group.workspace_id, + export_type=ExportTypeEnum.BILL, + is_exported_to_intacct=is_exported_to_intacct + ) + if last_export: if last_export_failed: update_last_export_details(expense_group.workspace_id) @@ -1439,8 +1473,21 @@ def create_charge_card_transaction(expense_group_id: int, task_log_id: int, is_a return in_progress_expenses = [] + system_comments = [] # Don't include expenses with previous export state as ERROR and it's an auto import/export run - if not (is_auto_export and expense_group.expenses.first().previous_export_state == 'ERROR'): + if is_auto_export and expense_group.expenses.first().previous_export_state == 'ERROR': + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS, + intent=SystemCommentIntentEnum.EXPORT_SUMMARY_NOT_UPDATED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE_GROUP, + workspace_id=expense_group.workspace_id, + entity_id=expense_group.id, + export_type=ExportTypeEnum.CHARGE_CARD_TRANSACTION, + reason=SystemCommentReasonEnum.EXPORT_SUMMARY_NOT_UPDATED_ERROR_STATE, + info={'previous_export_state': 'ERROR', 'is_auto_export': True} + ) + else: try: in_progress_expenses.extend(expense_group.expenses.all()) update_expense_and_post_summary(in_progress_expenses, expense_group.workspace_id, expense_group.fund_source) @@ -1457,8 +1504,6 @@ def create_charge_card_transaction(expense_group_id: int, task_log_id: int, is_a sage_intacct_credentials = SageIntacctCredential.get_active_sage_intacct_credentials(expense_group.workspace_id) sage_intacct_connection = get_sage_intacct_connection(workspace_id=expense_group.workspace_id, connection_type=SageIntacctRestConnectionTypeEnum.UPSERT.value) - system_comments = [] - merchant = expense_group.expenses.first().vendor vendor, is_fallback = get_or_create_credit_card_vendor(expense_group.workspace_id, configuration, merchant, sage_intacct_connection) @@ -1471,7 +1516,8 @@ def create_charge_card_transaction(expense_group_id: int, task_log_id: int, is_a workspace_id=expense_group.workspace_id, entity_id=expense_group.id, reason=SystemCommentReasonEnum.CREDIT_CARD_MISC_VENDOR_APPLIED, - info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'} + info={'original_merchant': merchant, 'vendor_used': 'Credit Card Misc'}, + persist_without_export=False ) vendor_id = vendor.destination_id if vendor else None @@ -1532,14 +1578,6 @@ def create_charge_card_transaction(expense_group_id: int, task_log_id: int, is_a resolve_errors_for_exported_expense_group(expense_group) worker_logger.info('Resolved errors for exported expense group %s for workspace id %s', expense_group.id, expense_group.workspace_id) - if system_comments: - batch_id = SystemComment.generate_batch_id() - for comment in system_comments: - comment['workspace_id'] = expense_group.workspace_id - comment['export_type'] = ExportTypeEnum.CHARGE_CARD_TRANSACTION - comment['batch_id'] = batch_id - SystemComment.bulk_create_comments(system_comments, batch_id=batch_id) - try: generate_export_url_and_update_expense(expense_group) except Exception as e: @@ -1641,6 +1679,14 @@ def create_charge_card_transaction(expense_group_id: int, task_log_id: int, is_a update_failed_expenses(expense_group.expenses.all(), True) post_accounting_export_summary(workspace_id=expense_group.workspace_id, expense_ids=[expense.id for expense in expense_group.expenses.all()], fund_source=expense_group.fund_source, is_failed=True) + finally: + create_filtered_system_comments( + system_comments=system_comments, + workspace_id=expense_group.workspace_id, + export_type=ExportTypeEnum.CHARGE_CARD_TRANSACTION, + is_exported_to_intacct=is_exported_to_intacct + ) + if last_export and last_export_failed: update_last_export_details(expense_group.workspace_id) @@ -1675,34 +1721,64 @@ def check_expenses_reimbursement_status(expenses: list[Expense], workspace_id: i return is_paid -def validate_for_skipping_payment(export_module: Bill | ExpenseReport, workspace_id: int, type: str) -> bool: +def validate_for_skipping_payment(export_module: Bill | ExpenseReport, workspace_id: int, type: str, system_comments: list = None) -> bool: """ Validate for skipping payment :param export_module: Export Module :param workspace_id: Workspace Id :param type: Type + :param system_comments: optional list to collect system comment data :return: True if payment is to be skipped, False otherwise """ + should_skip = False task_log = TaskLog.objects.filter(task_id='PAYMENT_{}'.format(export_module.expense_group.id), workspace_id=workspace_id, type=type).first() + if task_log: now = timezone.now() + reason = None + info = {'task_log_created_at': str(task_log.created_at), 'task_log_updated_at': str(task_log.updated_at)} if now - relativedelta(months=2) > task_log.created_at: export_module.is_retired = True export_module.save() - return True + should_skip = True + reason = SystemCommentReasonEnum.PAYMENT_SKIPPED_TASK_LOG_RETIRED + info = {'task_log_created_at': str(task_log.created_at)} elif now - relativedelta(months=1) > task_log.created_at and now - relativedelta(months=2) < task_log.created_at: # if updated_at is within 1 months will be skipped if task_log.updated_at > now - relativedelta(months=1): - return True + should_skip = True + reason = SystemCommentReasonEnum.PAYMENT_SKIPPED_TASK_LOG_RECENT_UPDATE + info = {'task_log_created_at': str(task_log.created_at), 'task_log_updated_at': str(task_log.updated_at), 'age_months': '1-2', 'updated_within': '1 month'} + # If created is within 1 month elif now - relativedelta(months=1) < task_log.created_at: # Skip if updated within the last week if task_log.updated_at > now - relativedelta(weeks=1): - return True + should_skip = True + reason = SystemCommentReasonEnum.PAYMENT_SKIPPED_TASK_LOG_RECENT_UPDATE + info = {'task_log_created_at': str(task_log.created_at), 'task_log_updated_at': str(task_log.updated_at), 'age_months': '<1', 'updated_within': '1 week'} + + if should_skip: + if system_comments is not None: + export_type = ExportTypeEnum.BILL if isinstance(export_module, Bill) else ExportTypeEnum.EXPENSE_REPORT + source = SystemCommentSourceEnum.CREATE_EXPENSE_REPORT if isinstance(export_module, ExpenseReport) else SystemCommentSourceEnum.CREATE_BILL + info['export_type'] = export_type + add_system_comment( + system_comments=system_comments, + source=source, + intent=SystemCommentIntentEnum.EXPORT_MODULE_RETIRED, + entity_type=SystemCommentEntityTypeEnum.EXPENSE_GROUP, + workspace_id=workspace_id, + entity_id=export_module.expense_group.id, + export_type=export_type, + reason=reason, + info=info + ) + return should_skip - return False + return should_skip def create_ap_payment(workspace_id: int) -> None: @@ -1714,6 +1790,7 @@ def create_ap_payment(workspace_id: int) -> None: fyle_credentials = FyleCredential.objects.get(workspace_id=workspace_id) platform = PlatformConnector(fyle_credentials) filter_credit_expenses = False + system_comments = [] bills = Bill.objects.filter( payment_synced=False, expense_group__workspace_id=workspace_id, @@ -1730,7 +1807,7 @@ def create_ap_payment(workspace_id: int) -> None: ) if expense_group_reimbursement_status: - skip_payment = validate_for_skipping_payment(export_module=bill, workspace_id=workspace_id, type='CREATING_AP_PAYMENT') + skip_payment = validate_for_skipping_payment(export_module=bill, workspace_id=workspace_id, type='CREATING_AP_PAYMENT', system_comments=system_comments) if skip_payment: continue @@ -1878,6 +1955,9 @@ def create_ap_payment(workspace_id: int) -> None: logger.exception('Something unexpected happened workspace_id: %s %s', task_log.workspace_id, task_log.detail) + if system_comments: + SystemComment.bulk_create_comments(system_comments) + def create_sage_intacct_reimbursement(workspace_id: int) -> None: """ @@ -1893,6 +1973,7 @@ def create_sage_intacct_reimbursement(workspace_id: int) -> None: return filter_credit_expenses = False + system_comments = [] expense_reports: list[ExpenseReport] = ExpenseReport.objects.filter( payment_synced=False, expense_group__workspace_id=workspace_id, @@ -1904,7 +1985,7 @@ def create_sage_intacct_reimbursement(workspace_id: int) -> None: expense_report.expense_group.expenses.all(), workspace_id=workspace_id, platform=platform, filter_credit_expenses=filter_credit_expenses) if expense_group_reimbursement_status: - skip_reimbursement = validate_for_skipping_payment(export_module=expense_report, workspace_id=workspace_id, type='CREATING_REIMBURSEMENT') + skip_reimbursement = validate_for_skipping_payment(export_module=expense_report, workspace_id=workspace_id, type='CREATING_REIMBURSEMENT', system_comments=system_comments) if skip_reimbursement: continue @@ -2051,6 +2132,9 @@ def create_sage_intacct_reimbursement(workspace_id: int) -> None: task_log.detail ) + if system_comments: + SystemComment.bulk_create_comments(system_comments) + def get_all_sage_intacct_bill_ids(sage_objects: Bill) -> dict: """ diff --git a/apps/workspaces/enums.py b/apps/workspaces/enums.py index cf8d2634..acdff653 100644 --- a/apps/workspaces/enums.py +++ b/apps/workspaces/enums.py @@ -28,6 +28,7 @@ class SystemCommentSourceEnum(str, Enum): CREATE_BILL = 'CREATE_BILL' # Lineitem creation functions + CREATE_BILL_LINEITEMS = 'CREATE_BILL_LINEITEMS' CREATE_EXPENSE_REPORT_LINEITEMS = 'CREATE_EXPENSE_REPORT_LINEITEMS' CREATE_JOURNAL_ENTRY_LINEITEMS = 'CREATE_JOURNAL_ENTRY_LINEITEMS' CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS = 'CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS' @@ -44,6 +45,14 @@ class SystemCommentSourceEnum(str, Enum): FILTER_EXPENSE_GROUPS = 'FILTER_EXPENSE_GROUPS' GROUP_EXPENSES_AND_SAVE = 'GROUP_EXPENSES_AND_SAVE' + # Export state & retry handling + CREATE_JOURNAL_ENTRY = 'CREATE_JOURNAL_ENTRY' + CREATE_EXPENSE_REPORT = 'CREATE_EXPENSE_REPORT' + RETRIGGER_STUCK_EXPORTS = 'RETRIGGER_STUCK_EXPORTS' + + # Connection handling + HANDLE_SAGE_INTACCT_REST_API_CONNECTION = 'HANDLE_SAGE_INTACCT_REST_API_CONNECTION' + class SystemCommentIntentEnum(str, Enum): """ @@ -59,6 +68,11 @@ class SystemCommentIntentEnum(str, Enum): DELETE_EXPENSES = 'DELETE_EXPENSES' VENDOR_NOT_FOUND = 'VENDOR_NOT_FOUND' SKIP_EXPENSE = 'SKIP_EXPENSE' + BILLABLE_DISABLED = 'BILLABLE_DISABLED' + EXPORT_SUMMARY_NOT_UPDATED = 'EXPORT_SUMMARY_NOT_UPDATED' + EXPORT_RETRIGGERED = 'EXPORT_RETRIGGERED' + EXPORT_MODULE_RETIRED = 'EXPORT_MODULE_RETIRED' + CONNECTION_FAILED = 'CONNECTION_FAILED' class SystemCommentReasonEnum(str, Enum): @@ -100,6 +114,15 @@ class SystemCommentReasonEnum(str, Enum): REIMBURSABLE_EXPENSE_NOT_CONFIGURED = 'Reimbursable expense skipped because reimbursable expense export is not configured.' CCC_EXPENSE_NOT_CONFIGURED = 'Corporate card expense skipped because corporate card expense export is not configured.' + # Billable handling - explaining WHY + BILLABLE_SET_TO_FALSE_MISSING_DIMENSIONS = 'Billable was set to false because customer_id or item_id is missing. Both customer and item are required for billable expenses.' + + # Export state & retry handling - explaining WHY + EXPORT_SUMMARY_NOT_UPDATED_ERROR_STATE = 'Export summary not updated because expense group had a previous export state of ERROR during auto-export.' + EXPORT_RETRIGGERED_STUCK = 'Export was re-triggered because it was stuck in ENQUEUED or IN_PROGRESS state for more than 60 minutes.' + PAYMENT_SKIPPED_TASK_LOG_RETIRED = 'Payment was skipped because task log is older than 2 months.' + PAYMENT_SKIPPED_TASK_LOG_RECENT_UPDATE = 'Payment was skipped because task log was recently updated (within 1 month for logs 1-2 months old, or within 1 week for logs less than 1 month old).' + class SystemCommentEntityTypeEnum(str, Enum): """ diff --git a/apps/workspaces/system_comments.py b/apps/workspaces/system_comments.py index dc3adf13..5c73733f 100644 --- a/apps/workspaces/system_comments.py +++ b/apps/workspaces/system_comments.py @@ -1,6 +1,8 @@ from enum import Enum from typing import Union +from fyle_accounting_library.system_comments.models import SystemComment + def add_system_comment( system_comments: list | None, @@ -12,7 +14,8 @@ def add_system_comment( export_type: Union[Enum, str] = None, is_user_visible: bool = False, reason: Union[Enum, str] = None, - info: dict = None + info: dict = None, + persist_without_export: bool = True ) -> None: """ Add a system comment to the list @@ -27,6 +30,7 @@ def add_system_comment( :param is_user_visible: whether the system comment is user visible :param reason: reason of the system comment :param info: info of the system comment + :param persist_without_export: whether to persist this comment even if export fails (default True) :return: None """ if system_comments is None: @@ -40,8 +44,40 @@ def add_system_comment( 'entity_id': entity_id, 'export_type': export_type.value if isinstance(export_type, Enum) else export_type, 'is_user_visible': is_user_visible, + 'persist_without_export': persist_without_export, 'detail': { 'reason': reason.value if isinstance(reason, Enum) else reason, 'info': info or {} } }) + + +def create_filtered_system_comments( + system_comments: list | None, + workspace_id: int, + export_type: Union[Enum, str], + is_exported_to_intacct: bool +) -> None: + """ + Filter and create system comments based on export status and persist_without_export flag + + :param system_comments: list of system comments to create + :param workspace_id: workspace id + :param export_type: export type enum or string + :param is_exported_to_intacct: whether the export was successful + :return: None + """ + if not system_comments: + return + + comments_to_flush = [] + export_type_value = export_type.value if isinstance(export_type, Enum) else export_type + + for comment in system_comments: + comment['workspace_id'] = workspace_id + comment['export_type'] = export_type_value + if is_exported_to_intacct or comment.get('persist_without_export', True): + comments_to_flush.append(comment) + + if comments_to_flush: + SystemComment.bulk_create_comments(comments_to_flush) diff --git a/apps/workspaces/views.py b/apps/workspaces/views.py index ca54675f..bf9b6e61 100644 --- a/apps/workspaces/views.py +++ b/apps/workspaces/views.py @@ -27,6 +27,7 @@ from fyle_rest_auth.utils import AuthUtils from fyle.platform import exceptions as fyle_exc from fyle_rest_auth.helpers import get_fyle_admin +from fyle_accounting_library.system_comments.models import SystemComment from fyle_accounting_library.fyle_platform.enums import ExpenseImportSourceEnum from fyle_accounting_mappings.models import ExpenseAttribute, FyleSyncTimestamp @@ -57,6 +58,8 @@ LastExportDetailSerializer, SageIntacctCredentialSerializer, ) +from apps.workspaces.system_comments import add_system_comment +from apps.workspaces.enums import SystemCommentSourceEnum, SystemCommentIntentEnum User = get_user_model() @@ -415,6 +418,7 @@ def handle_sage_intacct_rest_api_connection( :param si_user_password: Sage Intacct user password :return: None """ + system_comments = [] try: sage_intacct_credentials = SageIntacctCredential.objects.filter(workspace=workspace).first() @@ -472,6 +476,16 @@ def handle_sage_intacct_rest_api_connection( except SageIntacctRestInvalidTokenError as e: logger.info('Something went wrong while connecting to Sage Intacct - %s', e.response) + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.HANDLE_SAGE_INTACCT_REST_API_CONNECTION, + intent=SystemCommentIntentEnum.CONNECTION_FAILED, + entity_type=None, + workspace_id=workspace.id, + entity_id=None, + reason='Sage Intacct REST API connection failed: Invalid token error', + info={'error': str(e.response), 'error_type': 'InvalidTokenError'} + ) return Response( { 'message': e.response @@ -481,6 +495,16 @@ def handle_sage_intacct_rest_api_connection( except SageIntacctRESTBadRequestError as e: logger.info('Something went wrong while connecting to Sage Intacct - %s', e.response) + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.HANDLE_SAGE_INTACCT_REST_API_CONNECTION, + intent=SystemCommentIntentEnum.CONNECTION_FAILED, + entity_type=None, + workspace_id=workspace.id, + entity_id=None, + reason='Sage Intacct REST API connection failed: Bad request error', + info={'error': str(e.response), 'error_type': 'BadRequestError'} + ) return Response( { 'message': e.response @@ -489,12 +513,25 @@ def handle_sage_intacct_rest_api_connection( ) except SageIntacctRESTInternalServerError as e: logger.info('Something went wrong while connecting to Sage Intacct - %s', e.response) + add_system_comment( + system_comments=system_comments, + source=SystemCommentSourceEnum.HANDLE_SAGE_INTACCT_REST_API_CONNECTION, + intent=SystemCommentIntentEnum.CONNECTION_FAILED, + entity_type=None, + workspace_id=workspace.id, + entity_id=None, + reason='Sage Intacct REST API connection failed: Internal server error', + info={'error': str(e.response), 'error_type': 'InternalServerError'} + ) return Response( { 'message': 'Something went wrong while connecting to Sage Intacct' }, status=status.HTTP_401_UNAUTHORIZED ) + finally: + if system_comments: + SystemComment.bulk_create_comments(system_comments) def handle_sage_intacct_soap_api_connection( self, diff --git a/fyle-integrations-db-migrations b/fyle-integrations-db-migrations index d862bc50..7081a475 160000 --- a/fyle-integrations-db-migrations +++ b/fyle-integrations-db-migrations @@ -1 +1 @@ -Subproject commit d862bc504e55c1494fe4125755ba9c6703a6aac2 +Subproject commit 7081a4755cc29c9ef025554d06c4ca2a2de7d27a diff --git a/tests/conftest.py b/tests/conftest.py index ea9acb0f..49bffc86 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +from typing import Callable, Optional from unittest import mock from datetime import datetime, timezone @@ -12,6 +13,7 @@ from apps.workspaces.models import FeatureConfig, Workspace from apps.fyle.models import Expense, ExpenseGroup from apps.mappings.models import GeneralMapping +from apps.tasks.models import TaskLog @pytest.fixture @@ -343,3 +345,28 @@ def add_category_test_expense_group(db, add_category_test_expense): ) expense_group.expenses.add(expense) return expense_group + + +@pytest.fixture +def get_or_create_task_log(db) -> Callable: + """ + Fixture to get or create a TaskLog for an expense group + Returns a function that can be called with expense_group and optional parameters + """ + def _get_or_create_task_log( + expense_group: ExpenseGroup, + task_type: str = 'FETCHING_EXPENSES', + status: str = 'COMPLETE', + updated_at: Optional[datetime] = None + ) -> TaskLog: + task_log = TaskLog.objects.filter(expense_group_id=expense_group.id).first() + if not task_log: + task_log = TaskLog.objects.create( + expense_group_id=expense_group.id, + workspace_id=expense_group.workspace_id, + type=task_type, + status=status, + updated_at=updated_at + ) + return task_log + return _get_or_create_task_log diff --git a/tests/test_system_comments/test_system_comments.py b/tests/test_system_comments/test_system_comments.py index 2f0c41f7..76718d50 100644 --- a/tests/test_system_comments/test_system_comments.py +++ b/tests/test_system_comments/test_system_comments.py @@ -1,13 +1,25 @@ -import pytest +from datetime import datetime, timedelta, timezone +from dateutil.relativedelta import relativedelta +from django.utils import timezone as django_timezone from apps.fyle.models import ExpenseGroup +from apps.tasks.models import TaskLog from apps.fyle.tasks import ( delete_expense_group_and_related_data, handle_category_changes_for_expense, ) +from apps.internal.tasks import retrigger_stuck_exports from apps.mappings.models import GeneralMapping -from fyle_accounting_mappings.models import Mapping, EmployeeMapping, DestinationAttribute, ExpenseAttribute from apps.sage_intacct.models import ( + Bill, + BillLineitem, + ChargeCardTransaction, + ChargeCardTransactionLineitem, + Configuration, + ExpenseReport, + ExpenseReportLineitem, + JournalEntry, + JournalEntryLineitem, get_class_id_or_none, get_ccc_account_id, get_department_id_or_none, @@ -15,6 +27,23 @@ get_location_id_or_none, get_project_id_or_none, ) +from apps.sage_intacct.tasks import ( + create_bill, + create_charge_card_transaction, + create_expense_report, + create_journal_entry, + validate_for_skipping_payment, +) +from sageintacctsdk.exceptions import InvalidTokenError, NoPrivilegeError, WrongParamsError +from intacctsdk.exceptions import ( + BadRequestError as IntacctRESTBadRequestError, + InvalidTokenError as IntacctRESTInvalidTokenError, + InternalServerError as IntacctRESTInternalServerError +) +from fyle_intacct_api.exceptions import BulkError +from apps.workspaces.models import SageIntacctCredential +from fyle_accounting_library.system_comments.models import SystemComment +from fyle_accounting_mappings.models import Mapping, EmployeeMapping, DestinationAttribute, ExpenseAttribute from apps.workspaces.enums import ( ExportTypeEnum, SystemCommentEntityTypeEnum, @@ -334,9 +363,7 @@ def test_delete_expense_group_generates_comment(db, mocker): Test delete_expense_group_and_related_data generates system comment """ expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() - - if not expense_group: - pytest.skip("No expense group available for test") + assert expense_group is not None, "No expense group available for test" group_id = expense_group.id @@ -620,3 +647,1186 @@ def test_get_ccc_account_id_generates_comment(db, create_expense_group_expense, assert comment['detail']['reason'] == SystemCommentReasonEnum.EMPLOYEE_CCC_ACCOUNT_APPLIED.value assert comment['detail']['info']['employee_ccc_account_id'] == 'ccc_account_123' assert comment['detail']['info']['employee_ccc_account_name'] == 'Employee CCC Account' + + +def test_billable_disabled_expense_report_lineitem(db, create_expense_group_expense, mocker): + """ + Test create_expense_report_lineitems generates system comment when billable is set to False due to missing customer_id or item_id + """ + expense_group, expense = create_expense_group_expense + + expense.billable = True + expense.save() + + employee_email = 'test@fyle.in' + expense_group.description['employee_email'] = employee_email + expense_group.save() + + expense_attribute = ExpenseAttribute.objects.first() + expense_attribute.value = employee_email + expense_attribute.save() + + destination_attr = DestinationAttribute.objects.first() + destination_attr.destination_id = 'emp_123' + destination_attr.attribute_type = 'EMPLOYEE' + destination_attr.save() + + employee_mapping = EmployeeMapping.objects.first() + employee_mapping.source_employee = expense_attribute + employee_mapping.destination_employee = destination_attr + employee_mapping.workspace_id = expense_group.workspace_id + employee_mapping.save() + + configuration = Configuration.objects.get(workspace_id=1) + mocker.patch('apps.sage_intacct.models.import_string', return_value=lambda *args, **kwargs: (None, False)) + mocker.patch('apps.sage_intacct.models.get_customer_id_or_none', return_value=None) + mocker.patch('apps.sage_intacct.models.get_item_id_or_none', return_value=None) + + ExpenseReport.create_expense_report(expense_group) + + system_comments = [] + + ExpenseReportLineitem.create_expense_report_lineitems( + expense_group=expense_group, + configuration=configuration, + system_comments=system_comments + ) + + billable_comments = [c for c in system_comments if c.get('intent') == 'BILLABLE_DISABLED'] + assert len(billable_comments) >= 1 + comment = billable_comments[0] + assert comment['source'] == 'CREATE_EXPENSE_REPORT_LINEITEMS' + assert comment['intent'] == 'BILLABLE_DISABLED' + assert comment['entity_type'] == 'EXPENSE' + assert comment['entity_id'] == expense.id + assert comment['detail']['reason'] == SystemCommentReasonEnum.BILLABLE_SET_TO_FALSE_MISSING_DIMENSIONS.value + assert 'missing_fields' in comment['detail']['info'] + assert comment['detail']['info']['original_billable'] is True + + +def test_billable_disabled_bill_lineitem(db, create_expense_group_expense, mocker): + """ + Test create_bill_lineitems generates system comment when billable is set to False due to missing customer_id or item_id + """ + expense_group, expense = create_expense_group_expense + + expense.billable = True + expense.save() + + employee_email = 'test@fyle.in' + expense_group.description['employee_email'] = employee_email + expense_group.save() + + expense_attribute = ExpenseAttribute.objects.first() + expense_attribute.value = employee_email + expense_attribute.save() + + destination_attr = DestinationAttribute.objects.first() + destination_attr.destination_id = 'vendor_123' + destination_attr.attribute_type = 'VENDOR' + destination_attr.save() + + employee_mapping = EmployeeMapping.objects.first() + employee_mapping.source_employee = expense_attribute + employee_mapping.destination_vendor = destination_attr + employee_mapping.workspace_id = expense_group.workspace_id + employee_mapping.save() + + configuration = Configuration.objects.get(workspace_id=1) + mocker.patch('apps.sage_intacct.models.get_customer_id_or_none', return_value=None) + mocker.patch('apps.sage_intacct.models.get_item_id_or_none', return_value=None) + + Bill.create_bill(expense_group) + + system_comments = [] + + BillLineitem.create_bill_lineitems( + expense_group=expense_group, + configuration=configuration, + system_comments=system_comments + ) + + billable_comments = [c for c in system_comments if c.get('intent') == 'BILLABLE_DISABLED'] + assert len(billable_comments) >= 1 + comment = billable_comments[0] + assert comment['source'] == 'CREATE_BILL_LINEITEMS' + assert comment['intent'] == 'BILLABLE_DISABLED' + assert comment['entity_type'] == 'EXPENSE' + assert comment['entity_id'] == expense.id + assert comment['detail']['reason'] == SystemCommentReasonEnum.BILLABLE_SET_TO_FALSE_MISSING_DIMENSIONS.value + assert 'missing_fields' in comment['detail']['info'] + assert comment['detail']['info']['original_billable'] is True + + +def test_billable_disabled_journal_entry_lineitem(db, create_expense_group_expense, mocker): + """ + Test create_journal_entry_lineitems generates system comment when billable is set to False due to missing customer_id or item_id + """ + expense_group, expense = create_expense_group_expense + + expense.billable = True + expense.save() + + employee_email = 'test@fyle.in' + expense_group.description['employee_email'] = employee_email + expense_group.save() + + expense_attribute = ExpenseAttribute.objects.first() + expense_attribute.value = employee_email + expense_attribute.save() + + destination_attr = DestinationAttribute.objects.first() + destination_attr.destination_id = 'emp_123' + destination_attr.attribute_type = 'EMPLOYEE' + destination_attr.save() + + employee_mapping = EmployeeMapping.objects.first() + employee_mapping.source_employee = expense_attribute + employee_mapping.destination_employee = destination_attr + employee_mapping.workspace_id = expense_group.workspace_id + employee_mapping.save() + + configuration = Configuration.objects.get(workspace_id=1) + mocker.patch('apps.sage_intacct.models.import_string', return_value=lambda *args, **kwargs: (None, False)) + mocker.patch('apps.sage_intacct.models.get_customer_id_or_none', return_value=None) + mocker.patch('apps.sage_intacct.models.get_item_id_or_none', return_value=None) + + JournalEntry.create_journal_entry(expense_group) + sage_intacct_connection = mocker.MagicMock() + + system_comments = [] + + JournalEntryLineitem.create_journal_entry_lineitems( + expense_group=expense_group, + configuration=configuration, + sage_intacct_connection=sage_intacct_connection, + system_comments=system_comments + ) + + billable_comments = [c for c in system_comments if c.get('intent') == 'BILLABLE_DISABLED'] + assert len(billable_comments) >= 1 + comment = billable_comments[0] + assert comment['source'] == 'CREATE_JOURNAL_ENTRY_LINEITEMS' + assert comment['intent'] == 'BILLABLE_DISABLED' + assert comment['entity_type'] == 'EXPENSE' + assert comment['entity_id'] == expense.id + assert comment['detail']['reason'] == SystemCommentReasonEnum.BILLABLE_SET_TO_FALSE_MISSING_DIMENSIONS.value + assert 'missing_fields' in comment['detail']['info'] + assert comment['detail']['info']['original_billable'] is True + + +def test_billable_disabled_charge_card_transaction_lineitem(db, create_expense_group_expense, mocker): + """ + Test create_charge_card_transaction_lineitems generates system comment when billable is set to False due to missing customer_id or item_id + """ + expense_group, expense = create_expense_group_expense + + expense.billable = True + expense.save() + + expense_group.description['employee_email'] = 'test@fyle.in' + expense_group.save() + + configuration = Configuration.objects.get(workspace_id=1) + mocker.patch('apps.sage_intacct.models.import_string', return_value=lambda *args, **kwargs: (None, False)) + mocker.patch('apps.sage_intacct.models.get_customer_id_or_none', return_value=None) + mocker.patch('apps.sage_intacct.models.get_item_id_or_none', return_value=None) + + ChargeCardTransaction.create_charge_card_transaction(expense_group, 'Test Vendor') + sage_intacct_connection = mocker.MagicMock() + + system_comments = [] + + ChargeCardTransactionLineitem.create_charge_card_transaction_lineitems( + expense_group=expense_group, + configuration=configuration, + sage_intacct_connection=sage_intacct_connection, + system_comments=system_comments + ) + + billable_comments = [c for c in system_comments if c.get('intent') == 'BILLABLE_DISABLED'] + assert len(billable_comments) >= 1 + comment = billable_comments[0] + assert comment['source'] == 'CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS' + assert comment['intent'] == 'BILLABLE_DISABLED' + assert comment['entity_type'] == 'EXPENSE' + assert comment['entity_id'] == expense.id + assert comment['detail']['reason'] == SystemCommentReasonEnum.BILLABLE_SET_TO_FALSE_MISSING_DIMENSIONS.value + assert 'missing_fields' in comment['detail']['info'] + assert comment['detail']['info']['original_billable'] is True + + +def test_export_summary_not_updated_expense_report(db, mocker, get_or_create_task_log): + """ + Test create_expense_report generates single system comment per expense group when previous export state is ERROR + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ExpenseReport.create_expense_report') + mocker.patch('apps.sage_intacct.models.ExpenseReportLineitem.create_expense_report_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_expense_report') + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_expense_report( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_EXPENSE_REPORT' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['entity_type'] == 'EXPENSE_GROUP' + assert comment['entity_id'] == expense_group.id + assert comment['export_type'] == 'EXPENSE_REPORT' + assert comment['detail']['reason'] == SystemCommentReasonEnum.EXPORT_SUMMARY_NOT_UPDATED_ERROR_STATE.value + assert comment['detail']['info']['previous_export_state'] == 'ERROR' + assert comment['detail']['info']['is_auto_export'] is True + + +def test_export_summary_not_updated_bill(db, mocker, get_or_create_task_log): + """ + Test create_bill generates single system comment per expense group when previous export state is ERROR + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.Bill.create_bill') + mocker.patch('apps.sage_intacct.models.BillLineitem.create_bill_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_bill') + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_bill( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_BILL' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['entity_type'] == 'EXPENSE_GROUP' + assert comment['entity_id'] == expense_group.id + assert comment['export_type'] == 'BILL' + assert comment['detail']['reason'] == SystemCommentReasonEnum.EXPORT_SUMMARY_NOT_UPDATED_ERROR_STATE.value + assert comment['detail']['info']['previous_export_state'] == 'ERROR' + assert comment['detail']['info']['is_auto_export'] is True + + +def test_export_summary_not_updated_charge_card_transaction(db, mocker, get_or_create_task_log): + """ + Test create_charge_card_transaction generates single system comment per expense group when previous export state is ERROR + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ChargeCardTransaction.create_charge_card_transaction') + mocker.patch('apps.sage_intacct.models.ChargeCardTransactionLineitem.create_charge_card_transaction_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_charge_card_transaction') + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_charge_card_transaction( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['entity_type'] == 'EXPENSE_GROUP' + assert comment['entity_id'] == expense_group.id + assert comment['export_type'] == 'CHARGE_CARD_TRANSACTION' + assert comment['detail']['reason'] == SystemCommentReasonEnum.EXPORT_SUMMARY_NOT_UPDATED_ERROR_STATE.value + assert comment['detail']['info']['previous_export_state'] == 'ERROR' + assert comment['detail']['info']['is_auto_export'] is True + + +def test_retrigger_stuck_exports_system_comment(db, mocker, get_or_create_task_log): + """ + Test retrigger_stuck_exports generates system comment with stuck_duration_seconds + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense_group.updated_at = datetime.now(timezone.utc) - timedelta(hours=2) + expense_group.save() + + get_or_create_task_log( + expense_group, + task_type='FETCHING_EXPENSES', + status='ENQUEUED', + updated_at=datetime.now() - timedelta(minutes=90) + ) + + captured_comments = [] + mocker.patch('apps.internal.tasks.export_to_intacct') + mocker.patch('apps.internal.tasks.update_failed_expenses') + mocker.patch('apps.internal.tasks.post_accounting_export_summary') + mocker.patch('apps.internal.tasks.Schedule.objects.filter', return_value=mocker.MagicMock(filter=mocker.MagicMock(return_value=None))) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + retrigger_stuck_exports() + + retrigger_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_RETRIGGERED'] + if retrigger_comments: + comment = retrigger_comments[0] + assert comment['source'] == 'RETRIGGER_STUCK_EXPORTS' + assert comment['intent'] == 'EXPORT_RETRIGGERED' + assert comment['entity_type'] == 'EXPENSE_GROUP' + assert comment['detail']['reason'] == SystemCommentReasonEnum.EXPORT_RETRIGGERED_STUCK.value + assert 'stuck_duration_seconds' in comment['detail']['info'] + assert isinstance(comment['detail']['info']['stuck_duration_seconds'], (int, float)) + assert comment['detail']['info']['stuck_duration_seconds'] > 0 + + +def test_export_summary_not_updated_expense_report_wrong_params_error(db, mocker, get_or_create_task_log): + """ + Test create_expense_report flushes system comments when WrongParamsError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ExpenseReport.create_expense_report') + mocker.patch('apps.sage_intacct.models.ExpenseReportLineitem.create_expense_report_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_expense_report', return_value={'key': '123'}) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_expense_report', side_effect=WrongParamsError('Some of the parameters are wrong', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_expense_report( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_EXPENSE_REPORT' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'EXPENSE_REPORT' + + +def test_export_summary_not_updated_expense_report_bulk_error(db, mocker, get_or_create_task_log): + """ + Test create_expense_report flushes system comments when BulkError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ExpenseReport.create_expense_report') + mocker.patch('apps.sage_intacct.models.ExpenseReportLineitem.create_expense_report_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_expense_report', side_effect=BulkError({'error': ['Bulk error']})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_expense_report( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_EXPENSE_REPORT' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'EXPENSE_REPORT' + + +def test_export_summary_not_updated_bill_wrong_params_error(db, mocker, get_or_create_task_log): + """ + Test create_bill flushes system comments when WrongParamsError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.Bill.create_bill') + mocker.patch('apps.sage_intacct.models.BillLineitem.create_bill_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_bill', return_value={'data': {'apbill': {'key': '123'}}}) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_bill', side_effect=WrongParamsError('Some of the parameters are wrong', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_bill( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_BILL' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'BILL' + + +def test_export_summary_not_updated_bill_bulk_error(db, mocker, get_or_create_task_log): + """ + Test create_bill flushes system comments when BulkError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.Bill.create_bill') + mocker.patch('apps.sage_intacct.models.BillLineitem.create_bill_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_bill', side_effect=BulkError({'error': ['Bulk error']})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_bill( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_BILL' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'BILL' + + +def test_export_summary_not_updated_charge_card_transaction_wrong_params_error(db, mocker, get_or_create_task_log): + """ + Test create_charge_card_transaction flushes system comments when WrongParamsError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ChargeCardTransaction.create_charge_card_transaction') + mocker.patch('apps.sage_intacct.models.ChargeCardTransactionLineitem.create_charge_card_transaction_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_charge_card_transaction', return_value={'data': {'cctransaction': {'key': '123'}}}) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_charge_card_transaction', side_effect=WrongParamsError('Some of the parameters are wrong', {})) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_or_create_vendor', return_value=(mocker.MagicMock(destination_id='vendor123'), False)) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_charge_card_transaction( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'CHARGE_CARD_TRANSACTION' + + +def test_export_summary_not_updated_charge_card_transaction_bulk_error(db, mocker, get_or_create_task_log): + """ + Test create_charge_card_transaction flushes system comments when BulkError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ChargeCardTransaction.create_charge_card_transaction') + mocker.patch('apps.sage_intacct.models.ChargeCardTransactionLineitem.create_charge_card_transaction_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_charge_card_transaction', side_effect=BulkError({'error': ['Bulk error']})) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_or_create_vendor', return_value=(mocker.MagicMock(destination_id='vendor123'), False)) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_charge_card_transaction( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'CHARGE_CARD_TRANSACTION' + + +def test_export_summary_not_updated_expense_report_no_privilege_error(db, mocker, get_or_create_task_log): + """ + Test create_expense_report flushes system comments when NoPrivilegeError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ExpenseReport.create_expense_report') + mocker.patch('apps.sage_intacct.models.ExpenseReportLineitem.create_expense_report_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_expense_report', side_effect=NoPrivilegeError('No privilege', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_expense_report( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_EXPENSE_REPORT' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'EXPENSE_REPORT' + + +def test_export_summary_not_updated_bill_no_privilege_error(db, mocker, get_or_create_task_log): + """ + Test create_bill flushes system comments when NoPrivilegeError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.Bill.create_bill') + mocker.patch('apps.sage_intacct.models.BillLineitem.create_bill_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_bill', side_effect=NoPrivilegeError('No privilege', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_bill( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_BILL' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'BILL' + + +def test_export_summary_not_updated_charge_card_transaction_no_privilege_error(db, mocker, get_or_create_task_log): + """ + Test create_charge_card_transaction flushes system comments when NoPrivilegeError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ChargeCardTransaction.create_charge_card_transaction') + mocker.patch('apps.sage_intacct.models.ChargeCardTransactionLineitem.create_charge_card_transaction_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_charge_card_transaction', side_effect=NoPrivilegeError('No privilege', {})) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_or_create_vendor', return_value=(mocker.MagicMock(destination_id='vendor123'), False)) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_charge_card_transaction( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'CHARGE_CARD_TRANSACTION' + + +def test_export_summary_not_updated_expense_report_rest_bad_request_error(db, mocker, get_or_create_task_log): + """ + Test create_expense_report flushes system comments when IntacctRESTBadRequestError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ExpenseReport.create_expense_report') + mocker.patch('apps.sage_intacct.models.ExpenseReportLineitem.create_expense_report_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_expense_report', side_effect=IntacctRESTBadRequestError('Bad request', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_expense_report( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_EXPENSE_REPORT' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'EXPENSE_REPORT' + + +def test_export_summary_not_updated_bill_rest_bad_request_error(db, mocker, get_or_create_task_log): + """ + Test create_bill flushes system comments when IntacctRESTBadRequestError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.Bill.create_bill') + mocker.patch('apps.sage_intacct.models.BillLineitem.create_bill_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_bill', side_effect=IntacctRESTBadRequestError('Bad request', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_bill( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_BILL' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'BILL' + + +def test_export_summary_not_updated_charge_card_transaction_rest_bad_request_error(db, mocker, get_or_create_task_log): + """ + Test create_charge_card_transaction flushes system comments when IntacctRESTBadRequestError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ChargeCardTransaction.create_charge_card_transaction') + mocker.patch('apps.sage_intacct.models.ChargeCardTransactionLineitem.create_charge_card_transaction_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_charge_card_transaction', side_effect=IntacctRESTBadRequestError('Bad request', {})) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_or_create_vendor', return_value=(mocker.MagicMock(destination_id='vendor123'), False)) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_charge_card_transaction( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'CHARGE_CARD_TRANSACTION' + + +def test_export_summary_not_updated_expense_report_rest_internal_server_error(db, mocker, get_or_create_task_log): + """ + Test create_expense_report flushes system comments when IntacctRESTInternalServerError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ExpenseReport.create_expense_report') + mocker.patch('apps.sage_intacct.models.ExpenseReportLineitem.create_expense_report_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_expense_report', side_effect=IntacctRESTInternalServerError('Internal server error', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_expense_report( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_EXPENSE_REPORT' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'EXPENSE_REPORT' + + +def test_export_summary_not_updated_bill_rest_internal_server_error(db, mocker, get_or_create_task_log): + """ + Test create_bill flushes system comments when IntacctRESTInternalServerError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.Bill.create_bill') + mocker.patch('apps.sage_intacct.models.BillLineitem.create_bill_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_bill', side_effect=IntacctRESTInternalServerError('Internal server error', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_bill( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_BILL' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'BILL' + + +def test_export_summary_not_updated_charge_card_transaction_rest_internal_server_error(db, mocker, get_or_create_task_log): + """ + Test create_charge_card_transaction flushes system comments when IntacctRESTInternalServerError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ChargeCardTransaction.create_charge_card_transaction') + mocker.patch('apps.sage_intacct.models.ChargeCardTransactionLineitem.create_charge_card_transaction_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_charge_card_transaction', side_effect=IntacctRESTInternalServerError('Internal server error', {})) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_or_create_vendor', return_value=(mocker.MagicMock(destination_id='vendor123'), False)) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_charge_card_transaction( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'CHARGE_CARD_TRANSACTION' + + +def test_export_summary_not_updated_journal_entry(db, mocker, get_or_create_task_log): + """ + Test create_journal_entry generates single system comment per expense group when previous export state is ERROR + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + # Create a real JournalEntry instance instead of mocking + journal_entry = JournalEntry.create_journal_entry(expense_group) + mocker.patch('apps.sage_intacct.models.JournalEntry.create_journal_entry', return_value=journal_entry) + mocker.patch('apps.sage_intacct.models.JournalEntryLineitem.create_journal_entry_lineitems', return_value=[]) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_journal_entry', return_value={'key': '123'}) + mocker.patch('apps.sage_intacct.tasks.get_journal_entry_record_number', return_value='123') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_journal_entry', return_value={'glbatch': {'RECORD_URL': 'https://test.com?.r=123'}}) + mocker.patch('apps.sage_intacct.tasks.load_attachments', return_value=(None, False)) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_journal_entry( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_JOURNAL_ENTRY' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'JOURNAL_ENTRY' + + +def test_export_summary_not_updated_expense_report_rest_invalid_token_error(db, mocker, get_or_create_task_log): + """ + Test create_expense_report flushes system comments when IntacctRESTInvalidTokenError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ExpenseReport.create_expense_report') + mocker.patch('apps.sage_intacct.models.ExpenseReportLineitem.create_expense_report_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_expense_report', side_effect=IntacctRESTInvalidTokenError('Invalid token', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_expense_report( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_EXPENSE_REPORT' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'EXPENSE_REPORT' + + +def test_export_summary_not_updated_bill_credential_does_not_exist(db, mocker, get_or_create_task_log): + """ + Test create_bill flushes system comments when SageIntacctCredential.DoesNotExist occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.Bill.create_bill') + mocker.patch('apps.sage_intacct.models.BillLineitem.create_bill_lineitems') + mocker.patch('apps.workspaces.models.SageIntacctCredential.get_active_sage_intacct_credentials', side_effect=SageIntacctCredential.DoesNotExist()) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_bill( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_BILL' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'BILL' + + +def test_export_summary_not_updated_bill_invalid_token_error(db, mocker, get_or_create_task_log): + """ + Test create_bill flushes system comments when InvalidTokenError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.Bill.create_bill') + mocker.patch('apps.sage_intacct.models.BillLineitem.create_bill_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_bill', side_effect=InvalidTokenError('Invalid token', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_bill( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_BILL' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'BILL' + + +def test_export_summary_not_updated_bill_rest_invalid_token_error(db, mocker, get_or_create_task_log): + """ + Test create_bill flushes system comments when IntacctRESTInvalidTokenError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.Bill.create_bill') + mocker.patch('apps.sage_intacct.models.BillLineitem.create_bill_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_bill', side_effect=IntacctRESTInvalidTokenError('Invalid token', {})) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_bill( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_BILL' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'BILL' + + +def test_export_summary_not_updated_charge_card_transaction_rest_invalid_token_error(db, mocker, get_or_create_task_log): + """ + Test create_charge_card_transaction flushes system comments when IntacctRESTInvalidTokenError occurs + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense = expense_group.expenses.first() + expense.previous_export_state = 'ERROR' + expense.save() + + task_log = get_or_create_task_log(expense_group) + + captured_comments = [] + mocker.patch('apps.sage_intacct.models.ChargeCardTransaction.create_charge_card_transaction') + mocker.patch('apps.sage_intacct.models.ChargeCardTransactionLineitem.create_charge_card_transaction_lineitems') + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.post_charge_card_transaction', side_effect=IntacctRESTInvalidTokenError('Invalid token', {})) + mocker.patch('apps.sage_intacct.utils.SageIntacctConnector.get_or_create_vendor', return_value=(mocker.MagicMock(destination_id='vendor123'), False)) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + create_charge_card_transaction( + expense_group_id=expense_group.id, + task_log_id=task_log.id, + last_export=True, + is_auto_export=True + ) + + export_summary_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_SUMMARY_NOT_UPDATED'] + assert len(export_summary_comments) == 1 + comment = export_summary_comments[0] + assert comment['source'] == 'CREATE_CHARGE_CARD_TRANSACTION_LINEITEMS' + assert comment['intent'] == 'EXPORT_SUMMARY_NOT_UPDATED' + assert comment['export_type'] == 'CHARGE_CARD_TRANSACTION' + + +def test_retrigger_stuck_exports_with_expense_groups_found(db, mocker, get_or_create_task_log): + """ + Test retrigger_stuck_exports creates system comments when expense groups are found (covers lines 92-96, 111-112) + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + expense_group.workspace.name = 'Workspace 1' + expense_group.workspace.save(update_fields=['name']) + expense_group.save() + + task_log = get_or_create_task_log( + expense_group, + task_type='CREATING_BILLS', + status='ENQUEUED' + ) + TaskLog.objects.filter(id=task_log.id).update( + status='ENQUEUED', + updated_at=django_timezone.now() - timedelta(hours=2) + ) + task_log.refresh_from_db() + + captured_comments = [] + + mocker.patch('apps.internal.tasks.export_to_intacct') + mocker.patch('apps.internal.tasks.update_failed_expenses') + mocker.patch('apps.internal.tasks.post_accounting_export_summary') + + mock_schedule_qs = mocker.MagicMock() + mock_schedule_filter = mocker.MagicMock() + mock_schedule_filter.first.return_value = None + mock_schedule_qs.filter.return_value = mock_schedule_filter + mocker.patch('apps.internal.tasks.Schedule.objects.filter', return_value=mock_schedule_qs) + mocker.patch('apps.internal.tasks.OrmQ.objects.all', return_value=[]) + mocker.patch.object(SystemComment, 'bulk_create_comments', side_effect=lambda comments: captured_comments.extend(comments)) + + retrigger_stuck_exports() + + retrigger_comments = [c for c in captured_comments if c.get('intent') == 'EXPORT_RETRIGGERED'] + assert len(retrigger_comments) >= 1, "System comments should be created and flushed" + comment = retrigger_comments[0] + assert comment['source'] == 'RETRIGGER_STUCK_EXPORTS' + assert comment['intent'] == 'EXPORT_RETRIGGERED' + assert comment['entity_type'] == 'EXPENSE_GROUP' + assert comment['entity_id'] == expense_group.id + assert 'stuck_duration_seconds' in comment['detail']['info'] + + +def test_validate_for_skipping_payment_task_log_1_to_2_months_old(db, get_or_create_task_log): + """ + Test validate_for_skipping_payment skips payment when task log is 1-2 months old and updated within 1 month + """ + expense_group = ExpenseGroup.objects.filter(workspace_id=1).first() + assert expense_group is not None, "No expense group available for test" + + bill = Bill.objects.filter(expense_group=expense_group).first() + if not bill: + bill = Bill.objects.create( + expense_group=expense_group, + vendor_id='vendor123', + description={}, + memo='Test memo', + currency='USD', + transaction_date=datetime.now() + ) + + now = django_timezone.now() + task_log = get_or_create_task_log( + expense_group, + task_type='CREATING_BILLS', + status='COMPLETE' + ) + TaskLog.objects.filter(id=task_log.id).update( + task_id='PAYMENT_{}'.format(expense_group.id), + type='CREATING_BILLS', + status='COMPLETE', + created_at=now - relativedelta(months=1, days=15), + updated_at=now - relativedelta(days=20) + ) + task_log.refresh_from_db() + + system_comments = [] + should_skip = validate_for_skipping_payment(bill, expense_group.workspace_id, 'CREATING_BILLS', system_comments) + + assert should_skip is True + assert len(system_comments) == 1 + comment = system_comments[0] + assert comment['intent'] == 'EXPORT_MODULE_RETIRED' + assert comment['detail']['reason'] == SystemCommentReasonEnum.PAYMENT_SKIPPED_TASK_LOG_RECENT_UPDATE.value + assert comment['detail']['info']['age_months'] == '1-2' + assert comment['detail']['info']['updated_within'] == '1 month' diff --git a/tests/test_workspaces/test_views.py b/tests/test_workspaces/test_views.py index 32891b5d..b4f72232 100644 --- a/tests/test_workspaces/test_views.py +++ b/tests/test_workspaces/test_views.py @@ -18,6 +18,8 @@ from apps.sage_intacct.models import SageIntacctAttributesCount from apps.tasks.models import TaskLog from apps.workspaces.models import Configuration, FeatureConfig, LastExportDetail, SageIntacctCredential, Workspace +from apps.workspaces.enums import SystemCommentIntentEnum, SystemCommentSourceEnum +from fyle_accounting_library.system_comments.models import SystemComment from fyle_integrations_imports.models import ImportLog from tests.helper import dict_compare_keys from tests.test_fyle.fixtures import data as fyle_data @@ -871,7 +873,7 @@ def test_handle_sage_intacct_rest_api_connection_existing_credentials(mocker, ap 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 + Test handle_sage_intacct_rest_api_connection handles invalid token error and creates system comment """ workspace_id = 1 @@ -883,6 +885,8 @@ def test_handle_sage_intacct_rest_api_connection_invalid_token_error(mocker, api # Delete existing credentials to trigger new connection path SageIntacctCredential.objects.filter(workspace_id=workspace_id).delete() + SystemComment.objects.filter(workspace_id=workspace_id, intent=SystemCommentIntentEnum.CONNECTION_FAILED).delete() + # Mock IntacctRESTSDK to raise InvalidTokenError mocker.patch( 'apps.workspaces.views.IntacctRESTSDK', @@ -903,6 +907,20 @@ def test_handle_sage_intacct_rest_api_connection_invalid_token_error(mocker, api assert response.status_code == 401 + system_comments = SystemComment.objects.filter( + workspace_id=workspace_id, + source=SystemCommentSourceEnum.HANDLE_SAGE_INTACCT_REST_API_CONNECTION, + intent=SystemCommentIntentEnum.CONNECTION_FAILED + ) + assert system_comments.count() == 1 + + comment = system_comments.first() + assert comment.workspace_id == workspace_id + assert comment.entity_type is None + assert comment.entity_id is None + assert 'Invalid token error' in comment.detail['reason'] + assert comment.detail['info']['error_type'] == 'InvalidTokenError' + # Reset feature config feature_config.migrated_to_rest_api = False feature_config.save() @@ -910,7 +928,7 @@ def test_handle_sage_intacct_rest_api_connection_invalid_token_error(mocker, api 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 + Test handle_sage_intacct_rest_api_connection handles bad request error and creates system comment """ workspace_id = 1 @@ -922,6 +940,8 @@ def test_handle_sage_intacct_rest_api_connection_bad_request_error(mocker, api_c # Delete existing credentials to trigger new connection path SageIntacctCredential.objects.filter(workspace_id=workspace_id).delete() + SystemComment.objects.filter(workspace_id=workspace_id, intent=SystemCommentIntentEnum.CONNECTION_FAILED).delete() + # Mock IntacctRESTSDK to raise BadRequestError mocker.patch( 'apps.workspaces.views.IntacctRESTSDK', @@ -942,6 +962,20 @@ def test_handle_sage_intacct_rest_api_connection_bad_request_error(mocker, api_c assert response.status_code == 400 + system_comments = SystemComment.objects.filter( + workspace_id=workspace_id, + source=SystemCommentSourceEnum.HANDLE_SAGE_INTACCT_REST_API_CONNECTION, + intent=SystemCommentIntentEnum.CONNECTION_FAILED + ) + assert system_comments.count() == 1 + + comment = system_comments.first() + assert comment.workspace_id == workspace_id + assert comment.entity_type is None + assert comment.entity_id is None + assert 'Bad request error' in comment.detail['reason'] + assert comment.detail['info']['error_type'] == 'BadRequestError' + # Reset feature config feature_config.migrated_to_rest_api = False feature_config.save() @@ -949,7 +983,7 @@ def test_handle_sage_intacct_rest_api_connection_bad_request_error(mocker, api_c 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 + Test handle_sage_intacct_rest_api_connection handles internal server error and creates system comment """ workspace_id = 1 @@ -961,6 +995,8 @@ def test_handle_sage_intacct_rest_api_connection_internal_server_error(mocker, a # Delete existing credentials to trigger new connection path SageIntacctCredential.objects.filter(workspace_id=workspace_id).delete() + SystemComment.objects.filter(workspace_id=workspace_id, intent=SystemCommentIntentEnum.CONNECTION_FAILED).delete() + # Mock IntacctRESTSDK to raise InternalServerError mocker.patch( 'apps.workspaces.views.IntacctRESTSDK', @@ -982,6 +1018,20 @@ def test_handle_sage_intacct_rest_api_connection_internal_server_error(mocker, a assert response.status_code == 401 assert response.data['message'] == 'Something went wrong while connecting to Sage Intacct' + system_comments = SystemComment.objects.filter( + workspace_id=workspace_id, + source=SystemCommentSourceEnum.HANDLE_SAGE_INTACCT_REST_API_CONNECTION, + intent=SystemCommentIntentEnum.CONNECTION_FAILED + ) + assert system_comments.count() == 1 + + comment = system_comments.first() + assert comment.workspace_id == workspace_id + assert comment.entity_type is None + assert comment.entity_id is None + assert 'Internal server error' in comment.detail['reason'] + assert comment.detail['info']['error_type'] == 'InternalServerError' + # Reset feature config feature_config.migrated_to_rest_api = False feature_config.save()