diff --git a/apps/fyle/exceptions.py b/apps/fyle/exceptions.py index a24b527..c7fd279 100644 --- a/apps/fyle/exceptions.py +++ b/apps/fyle/exceptions.py @@ -3,6 +3,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 apps.workspaces.models import FyleCredential, Workspace, ExportSettings, AdvancedSetting from apps.tasks.models import AccountingExport @@ -43,6 +44,10 @@ def new_fn(*args, **kwargs): except ExportSettings.DoesNotExist: return Response({'message': 'Export Settings does not exist in workspace'}, 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 9117e41..7ce49be 100644 --- a/apps/fyle/helpers.py +++ b/apps/fyle/helpers.py @@ -5,8 +5,9 @@ from django.conf import settings from fyle.platform import Platform +from rest_framework.exceptions import ValidationError -from apps.workspaces.models import FyleCredential +from apps.workspaces.models import Workspace, FyleCredential def post_request(url, body, refresh_token=None): @@ -142,3 +143,13 @@ def download_iif_file(file_id: str, workspace_id: int): )['data'][0]['download_url'] return download_url + + +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 3b66ca3..189e741 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 ( import_credit_card_expenses, @@ -10,6 +11,10 @@ ) from apps.workspaces.models import Workspace from apps.tasks.models import AccountingExport +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): @@ -60,14 +65,20 @@ 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: bodys + :param body: body :return: 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) async_task('apps.workspaces.tasks.run_import_export', 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 d32cd41..824d632 100644 --- a/apps/fyle/tasks.py +++ b/apps/fyle/tasks.py @@ -5,6 +5,7 @@ 2. Import Credit Card Expenses from Fyle """ import logging +from typing import Dict from datetime import datetime import traceback @@ -12,6 +13,7 @@ from fyle_integrations_platform_connector import PlatformConnector from fyle.platform.exceptions import RetryException, NoPrivilegeError +from fyle_integrations_platform_connector.apis.expenses import Expenses as FyleExpenses from apps.tasks.models import AccountingExport from apps.workspaces.models import Workspace, ExportSettings, FyleCredential @@ -151,3 +153,21 @@ def import_credit_card_expenses(workspace_id, accounting_export: AccountingExpor accounting_export.status = 'FATAL' accounting_export.save() logger.exception('Something unexpected happened workspace_id: %s %s', accounting_export.workspace_id, accounting_export.errors) + + +def update_non_exported_expenses(data: Dict) -> None: + """ + To update expenses not in COMPLETE, IN_PROGRESS state + """ + 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 and not expense.exported: + 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 d80c44d..1e57d4c 100644 --- a/apps/fyle/views.py +++ b/apps/fyle/views.py @@ -31,6 +31,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 b7c12e0..b0fdd84 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.32.3 -fyle-integrations-platform-connector==1.37.4 +fyle-accounting-mappings==1.33.1 +fyle-integrations-platform-connector==1.38.1 # Postgres Dependincies psycopg2-binary==2.8.4 diff --git a/tests/test_fyle/fixtures.py b/tests/test_fyle/fixtures.py index d70c1b4..f56be07 100644 --- a/tests/test_fyle/fixtures.py +++ b/tests/test_fyle/fixtures.py @@ -1,4 +1,266 @@ 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, + '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 + }, + 'tax_amount': None, + 'tax_group_id': None + }, 'get_my_profile': { 'data': { 'org': { diff --git a/tests/test_fyle/test_tasks.py b/tests/test_fyle/test_tasks.py index 0275977..15c7cc5 100644 --- a/tests/test_fyle/test_tasks.py +++ b/tests/test_fyle/test_tasks.py @@ -1,9 +1,15 @@ import pytest - -from apps.fyle.tasks import import_reimbursable_expenses, import_credit_card_expenses +from django.urls import reverse +from rest_framework.exceptions import ValidationError +from rest_framework import status + +from apps.fyle.tasks import ( + import_reimbursable_expenses, + import_credit_card_expenses, + update_non_exported_expenses +) from apps.tasks.models import AccountingExport from apps.fyle.models import Expense -from apps.workspaces.tasks import run_import_export from apps.workspaces.models import Workspace, ExportSettings, AdvancedSetting, FieldMapping from apps.qbd.models import Journal from .fixtures import fixtures @@ -247,3 +253,56 @@ def test_support_post_date_integration( journals = Journal.create_journal([expense_objects], 'CCC', export_settings, accounting_export, workspace_id) assert journals[0].date.strftime("%m/%d/%Y") == '05/06/2022' + + +def test_update_non_exported_expenses(db, create_temp_workspace, mocker, api_client): + expense = fixtures['raw_expense'] + default_raw_expense = fixtures['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.exported = False + 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.exported = True + 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