diff --git a/apps/fyle/exceptions.py b/apps/fyle/exceptions.py index f9ae7de..4eac11a 100644 --- a/apps/fyle/exceptions.py +++ b/apps/fyle/exceptions.py @@ -5,6 +5,7 @@ from fyle.platform.exceptions import NoPrivilegeError, RetryException, InvalidTokenError as FyleInvalidTokenError from rest_framework.response import Response from rest_framework.views import status +from rest_framework.exceptions import ValidationError from sage_desktop_sdk.exceptions.hh2_exceptions import WrongParamsError from sage_desktop_api.exceptions import BulkError @@ -90,6 +91,10 @@ def new_fn(*args, **kwargs): logger.info('Bulk Error %s', exception.response) return Response(data={'message': 'Bulk Error'}, status=status.HTTP_400_BAD_REQUEST) + except ValidationError as e: + logger.exception(e) + return Response({"message": e.detail}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exception: logger.exception(exception) return Response(data={'message': 'An unhandled error has occurred, please re-try later'}, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/fyle/helpers.py b/apps/fyle/helpers.py index 4f5277f..bb21775 100644 --- a/apps/fyle/helpers.py +++ b/apps/fyle/helpers.py @@ -5,8 +5,9 @@ from django.db.models import Q from fyle_integrations_platform_connector import PlatformConnector +from rest_framework.exceptions import ValidationError -from apps.workspaces.models import FyleCredential, ExportSetting +from apps.workspaces.models import Workspace, FyleCredential, ExportSetting from apps.accounting_exports.models import AccountingExport from apps.fyle.models import ExpenseFilter from apps.fyle.constants import DEFAULT_FYLE_CONDITIONS @@ -255,3 +256,13 @@ def get_exportable_accounting_exports_ids(workspace_id: int): ).values_list('id', flat=True) return accounting_export_ids + + +def assert_valid_request(workspace_id:int, org_id:str): + """ + Assert if the request is valid by checking + the url_workspace_id and fyle_org_id workspace + """ + workspace = Workspace.objects.get(org_id=org_id) + if workspace.id != workspace_id: + raise ValidationError('Workspace mismatch') diff --git a/apps/fyle/queue.py b/apps/fyle/queue.py index e4b488d..8d2ba91 100644 --- a/apps/fyle/queue.py +++ b/apps/fyle/queue.py @@ -3,6 +3,7 @@ * User Triggered Async Tasks * Schedule Triggered Async Tasks """ +import logging from django_q.tasks import async_task from apps.fyle.tasks import ( @@ -11,6 +12,10 @@ ) from apps.accounting_exports.models import AccountingExport from apps.workspaces.models import Workspace +from apps.fyle.helpers import assert_valid_request + +logger = logging.getLogger(__name__) +logger.level = logging.INFO def queue_import_reimbursable_expenses(workspace_id: int, synchronous: bool = False): @@ -61,7 +66,7 @@ def queue_import_credit_card_expenses(workspace_id: int, synchronous: bool = Fal import_credit_card_expenses(workspace_id, accounting_export) -def async_handle_webhook_callback(body: dict) -> None: +def async_handle_webhook_callback(body: dict, workspace_id: int) -> None: """ Async'ly import and export expenses :param body: body @@ -70,7 +75,14 @@ def async_handle_webhook_callback(body: dict) -> None: if body.get('action') == 'ACCOUNTING_EXPORT_INITIATED' and body.get('data'): org_id = body['data']['org_id'] + assert_valid_request(workspace_id=workspace_id, org_id=org_id) workspace = Workspace.objects.get(org_id=org_id) queue_import_reimbursable_expenses(workspace_id=workspace.id) queue_import_credit_card_expenses(workspace_id=workspace.id) async_task('apps.workspaces.tasks.export_to_sage300', workspace.id) + + elif body.get('action') == 'UPDATED_AFTER_APPROVAL' and body.get('data') and body.get('resource') == 'EXPENSE': + org_id = body['data']['org_id'] + logger.info("| Updating non-exported expenses through webhook | Content: {{WORKSPACE_ID: {} Payload: {}}}".format(workspace_id, body.get('data'))) + assert_valid_request(workspace_id=workspace_id, org_id=org_id) + async_task('apps.fyle.tasks.update_non_exported_expenses', body['data']) diff --git a/apps/fyle/tasks.py b/apps/fyle/tasks.py index 29fbf59..67f3152 100644 --- a/apps/fyle/tasks.py +++ b/apps/fyle/tasks.py @@ -6,9 +6,11 @@ """ import logging from datetime import datetime +from typing import Dict from django.db import transaction from fyle_integrations_platform_connector import PlatformConnector +from fyle_integrations_platform_connector.apis.expenses import Expenses as FyleExpenses from apps.accounting_exports.models import AccountingExport from apps.workspaces.models import ExportSetting, Workspace, FyleCredential @@ -120,3 +122,28 @@ def import_credit_card_expenses(workspace_id, accounting_export: AccountingExpor :param workspace_id: workspace id """ import_expenses(workspace_id, accounting_export, 'PERSONAL_CORPORATE_CREDIT_CARD_ACCOUNT', 'CCC') + + +def update_non_exported_expenses(data: Dict) -> None: + """ + To update expenses not in COMPLETE, IN_PROGRESS state + """ + expense_state = None + org_id = data['org_id'] + expense_id = data['id'] + workspace = Workspace.objects.get(org_id=org_id) + expense = Expense.objects.filter(workspace_id=workspace.id, expense_id=expense_id).first() + + if expense: + if 'state' in expense.accounting_export_summary: + expense_state = expense.accounting_export_summary['state'] + else: + expense_state = 'NOT_EXPORTED' + + if expense_state and expense_state not in ['COMPLETE', 'IN_PROGRESS']: + expense_obj = [] + expense_obj.append(data) + expense_objects = FyleExpenses().construct_expense_object(expense_obj, expense.workspace_id) + Expense.create_expense_objects( + expense_objects, expense.workspace_id + ) diff --git a/apps/fyle/views.py b/apps/fyle/views.py index 679ad50..cd8dc36 100644 --- a/apps/fyle/views.py +++ b/apps/fyle/views.py @@ -140,6 +140,6 @@ class WebhookCallbackView(generics.CreateAPIView): @handle_view_exceptions() def post(self, request, *args, **kwargs): - async_handle_webhook_callback(request.data) + async_handle_webhook_callback(request.data, int(kwargs['workspace_id'])) return Response(data={}, status=status.HTTP_200_OK) diff --git a/requirements.txt b/requirements.txt index 7a28c12..f201e87 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,12 +24,12 @@ gevent==23.9.1 gunicorn==20.1.0 # Platform SDK -fyle==0.36.1 +fyle==0.37.0 # Reusable Fyle Packages fyle-rest-auth==1.7.2 fyle-accounting-mappings==1.33.1 -fyle-integrations-platform-connector==1.37.4 +fyle-integrations-platform-connector==1.38.1 # Postgres Dependincies diff --git a/tests/test_fyle/fixtures.py b/tests/test_fyle/fixtures.py index 4cb6410..04db471 100644 --- a/tests/test_fyle/fixtures.py +++ b/tests/test_fyle/fixtures.py @@ -1,4 +1,270 @@ fixtures = { + "raw_expense": { + 'accounting_export_summary': { + 'error_type': 'ACCOUNTING_INTEGRATION_ERROR', + 'state': 'ERROR', + 'tpa_id': 'tpayfjPPHTDgv', + 'url': 'https://staging1.fyle.tech/app/settings/#/integrations/native_apps?integrationIframeTarget=integrations/intacct/main/dashboard' + }, + 'activity_details': None, + 'added_to_report_at': None, + 'admin_amount': None, + 'advance_wallet_id': None, + 'amount': 12, + 'approvals': [ + { + 'approver_user': { + 'email': 'admin1@fyleforimporrttest.in', + 'full_name': 'Theresa Brown', + 'id': 'usVN2WTtPqE7' + }, + 'approver_user_id': 'usVN2WTtPqE7', + 'state': 'APPROVAL_DONE' + } + ], + 'approver_comments': [], + 'category': { + 'code': '223', + 'display_name': 'ABN Withholding', + 'id': 317995, + 'name': 'ABN Withholding', + 'sub_category': None, + 'system_category': None + }, + 'category_id': 317995, + 'claim_amount': 12, + 'code': None, + 'commute_deduction': None, + 'commute_details': None, + 'commute_details_id': None, + 'cost_center': { + 'code': '96441', + 'id': 23166, + 'name': 'Administration' + }, + 'cost_center_id': 23166, + 'created_at': '2024-05-10T07:52:10.551260+00:00', + 'creator_user_id': 'usVN2WTtPqE7', + 'currency': 'USD', + 'custom_fields': [ + { + 'is_enabled': True, + 'name': 'Custom Expense Field', + 'type': 'TEXT', + 'value': None + }, + { + 'is_enabled': True, + 'name': 'Locationcustom', + 'type': 'SELECT', + 'value': None + }, + { + 'is_enabled': True, + 'name': 'Deptcustom', + 'type': 'SELECT', + 'value': None + } + ], + 'custom_fields_flattened': { + 'custom_expense_field': None, + 'deptcustom': None, + 'locationcustom': None + }, + 'distance': None, + 'distance_unit': None, + 'employee': { + 'business_unit': None, + 'code': None, + 'custom_fields': [], + 'department': None, + 'department_id': None, + 'flattened_custom_field': {}, + 'has_accepted_invite': True, + 'id': 'ouhC0BNdc33I', + 'is_enabled': True, + 'joined_at': None, + 'level': None, + 'location': None, + 'mobile': None, + 'org_id': 'orAW3T2QmroT', + 'org_name': 'Fyle For import_test', + 'title': None, + 'user': { + 'email': 'admin1@fyleforimporrttest.in', + 'full_name': 'Theresa Brown', + 'id': 'usVN2WTtPqE7' + }, + 'user_id': 'usVN2WTtPqE7' + }, + 'employee_id': 'ouhC0BNdc33I', + 'ended_at': None, + 'expense_rule_data': None, + 'expense_rule_id': None, + 'extracted_data': None, + 'file_ids': [], + 'files': [], + 'foreign_amount': None, + 'foreign_currency': None, + 'hotel_is_breakfast_provided': False, + 'id': 'txhJLOSKs1iN', + 'invoice_number': None, + 'is_billable': None, + 'is_corporate_card_transaction_auto_matched': False, + 'is_exported': None, + 'is_manually_flagged': None, + 'is_physical_bill_submitted': None, + 'is_policy_flagged': None, + 'is_receipt_mandatory': None, + 'is_reimbursable': True, + 'is_split': False, + 'is_verified': True, + 'is_weekend_spend': False, + 'last_exported_at': None, + 'last_settled_at': '2024-05-10T07:55:07.373278+00:00', + 'last_verified_at': '2024-05-10T07:55:02.329280+00:00', + 'locations': [], + 'matched_corporate_card_transaction_ids': [], + 'matched_corporate_card_transactions': [], + 'merchant': None, + 'mileage_calculated_amount': None, + 'mileage_calculated_distance': None, + 'mileage_is_round_trip': None, + 'mileage_rate': None, + 'mileage_rate_id': None, + 'missing_mandatory_fields': { + 'amount': False, + 'currency': False, + 'expense_field_ids': [], + 'receipt': False + }, + 'org_id': 'orAW3T2QmroT', + 'per_diem_num_days': None, + 'per_diem_rate': None, + 'per_diem_rate_id': None, + 'physical_bill_submitted_at': None, + 'policy_amount': None, + 'policy_checks': { + 'are_approvers_added': False, + 'is_amount_limit_applied': False, + 'is_flagged_ever': False, + 'violations': None + }, + 'project': { + 'code': 'B3DNLG7TVM', + 'display_name': 'Project 6', + 'id': 330241, + 'name': 'Project 6', + 'sub_project': None + }, + 'project_id': 330241, + 'purpose': None, + 'report': { + 'amount': 12, + 'approvals': [ + { + 'approver_user': { + 'email': 'admin1@fyleforimporrttest.in', + 'full_name': 'Theresa Brown', + 'id': 'usVN2WTtPqE7' + }, + 'approver_user_id': 'usVN2WTtPqE7', + 'state': 'APPROVAL_DONE' + } + ], + 'id': 'rpN41rGGnxNI', + 'last_approved_at': '2024-05-10T07:53:25.774+00:00', + 'last_paid_at': None, + 'last_submitted_at': '2024-05-10T07:53:09.457+00:00', + 'last_verified_at': '2024-05-10T07:55:02.32928+00:00', + 'reimbursement_id': 'reimYNNUkKQiWp', + 'reimbursement_seq_num': 'P/2024/05/T/P/2024/05/R/30', + 'seq_num': 'C/2024/05/R/45', + 'settlement_id': 'setUkp31alIp7', + 'state': 'PAYMENT_PROCESSING', + 'title': '#5: May 2024' + }, + 'report_id': 'rpN41rGGnxNI', + 'report_last_approved_at': '2024-05-10T07:53:25.774000+00:00', + 'report_last_paid_at': None, + 'report_settlement_id': 'setUkp31alIp7', + 'seq_num': 'E/2024/05/T/442', + 'source': 'WEBAPP', + 'source_account': { + 'id': 'accUMhoA4foa5', + 'type': 'PERSONAL_CASH_ACCOUNT' + }, + 'source_account_id': 'accUMhoA4foa5', + 'spent_at': '2024-05-10T00:00:00+00:00', + 'split_group_amount': None, + 'split_group_id': 'txhJLOSKs1iN', + 'started_at': None, + 'state': 'PAYMENT_PROCESSING', + 'state_display_name': 'Processing', + 'tax_amount': None, + 'tax_group': None, + 'tax_group_id': None, + 'travel_classes': [], + 'updated_at': '2024-06-10T11:41:40.779611+00:00', + 'user': { + 'email': 'admin1@fyleforimporrttest.in', + 'full_name': 'Theresa Brown', + 'id': 'usVN2WTtPqE7' + }, + 'user_id': 'usVN2WTtPqE7', + 'verifications': [ + { + 'verifier_user': { + 'email': 'owner@fyleforimporrttest.in', + 'full_name': 'Fyle For import_test', + 'id': 'usbzW0rVpuWC' + }, + 'verifier_user_id': 'usbzW0rVpuWC' + } + ], + 'verifier_comments': [] + }, + "default_raw_expense": { + 'employee_email': 'admin1@fyleforimporrttest.in', + 'employee_name': 'Theresa Brown', + 'category': 'Old Category', + 'sub_category': None, + 'project': 'Project 6', + 'org_id': 'orAW3T2QmroT', + 'expense_number': 'E/2024/05/T/442', + 'claim_number': 'C/2024/05/R/45', + 'amount': 12.0, + 'currency': 'USD', + 'foreign_amount': None, + 'foreign_currency': None, + 'reimbursable': True, + 'state': 'PAYMENT_PROCESSING', + 'vendor': None, + 'cost_center': 'Administration', + 'corporate_card_id': None, + 'purpose': None, + 'report_id': 'rpN41rGGnxNI', + 'billable': False, + 'file_ids': [], + 'spent_at': '2024-05-10 17:00:00', + 'approved_at': '2024-05-10 07:53:25', + 'posted_at': None, + 'is_skipped': False, + 'expense_created_at': '2024-05-10 07:52:10', + 'expense_updated_at': '2024-05-13 05:53:25', + 'fund_source': 'PERSONAL', + 'verified_at': '2024-05-10 07:53:25', + 'custom_properties': { + 'Deptcustom': None, + 'Locationcustom': None, + 'Custom Expense Field': None + }, + 'report_title': '#5: May 2024', + 'tax_amount': None, + 'tax_group_id': None, + 'previous_export_state': None, + 'accounting_export_summary': [] + }, "get_my_profile": { "data": { "org": { diff --git a/tests/test_fyle/test_tasks.py b/tests/test_fyle/test_tasks.py new file mode 100644 index 0000000..e2ba493 --- /dev/null +++ b/tests/test_fyle/test_tasks.py @@ -0,0 +1,62 @@ +from .fixtures import fixtures as data +from django.urls import reverse +from rest_framework.exceptions import ValidationError +from rest_framework import status +from apps.fyle.tasks import ( + update_non_exported_expenses +) +from apps.fyle.models import Expense +from apps.workspaces.models import Workspace + + +def test_update_non_exported_expenses(db, create_temp_workspace, mocker, api_client): + expense = data['raw_expense'] + default_raw_expense = data['default_raw_expense'] + org_id = expense['org_id'] + payload = { + "resource": "EXPENSE", + "action": 'UPDATED_AFTER_APPROVAL', + "data": expense, + "reason": 'expense update testing', + } + + expense_created, _ = Expense.objects.update_or_create( + org_id=org_id, + expense_id='txhJLOSKs1iN', + workspace_id=1, + defaults=default_raw_expense + ) + expense_created.accounting_export_summary = {} + expense_created.save() + + workspace = Workspace.objects.filter(id=1).first() + workspace.org_id = org_id + workspace.save() + + assert expense_created.category == 'Old Category' + + update_non_exported_expenses(payload['data']) + + expense = Expense.objects.get(expense_id='txhJLOSKs1iN', org_id=org_id) + assert expense.category == 'ABN Withholding' + + expense.accounting_export_summary = {"synced": True, "state": "COMPLETE"} + expense.category = 'Old Category' + expense.save() + + update_non_exported_expenses(payload['data']) + expense = Expense.objects.get(expense_id='txhJLOSKs1iN', org_id=org_id) + assert expense.category == 'Old Category' + + try: + update_non_exported_expenses(payload['data']) + except ValidationError as e: + assert e.detail[0] == 'Workspace mismatch' + + url = reverse('webhook-callback', kwargs={'workspace_id': 1}) + response = api_client.post(url, data=payload, format='json') + assert response.status_code == status.HTTP_200_OK + + url = reverse('webhook-callback', kwargs={'workspace_id': 2}) + response = api_client.post(url, data=payload, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST