using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Expenses; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Expenses; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Expanses; using Marco.Pms.Model.ViewModels.Master; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Service; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Text.Json; using Document = Marco.Pms.Model.DocumentManager.Document; namespace Marco.Pms.Services.Controllers { [Route("api/[controller]")] [ApiController] [Authorize] public class ExpenseController : ControllerBase { private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly PermissionServices _permission; private readonly ILoggingService _logger; private readonly S3UploadService _s3Service; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IMapper _mapper; private readonly Guid tenantId; private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8"); public ExpenseController( IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, PermissionServices permission, IServiceScopeFactory serviceScopeFactory, ILoggingService logger, S3UploadService s3Service, IMapper mapper) { _dbContextFactory = dbContextFactory; _context = context; _userHelper = userHelper; _permission = permission; _logger = logger; _serviceScopeFactory = serviceScopeFactory; _s3Service = s3Service; _mapper = mapper; tenantId = userHelper.GetTenantId(); } /// /// Retrieves a paginated list of expenses based on user permissions and optional filters. /// /// A URL-encoded JSON string containing filter criteria. See . /// The number of records to return per page. /// The page number to retrieve. /// A paginated list of expenses. [HttpGet("list")] // Assuming this is a GET endpoint public async Task GetExpensesList(string? filter, int pageSize = 20, int pageNumber = 1) { try { _logger.LogInfo( "Attempting to fetch expenses list for PageNumber: {PageNumber}, PageSize: {PageSize} with Filter: {Filter}", pageNumber, pageSize, filter ?? ""); // 1. --- Get User and Permissions --- var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); if (loggedInEmployee == null) { // This is an authentication/authorization issue. The user should be logged in. _logger.LogWarning("Could not find an employee for the current logged-in user."); return Unauthorized(ApiResponse.ErrorResponse("User not found or not authenticated.", 401)); } Guid loggedInEmployeeId = loggedInEmployee.Id; var hasViewSelfPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId); }); var hasViewAllPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId); }); await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask); // 2. --- Build Base Query and Apply Permissions --- // Start with a base IQueryable. Filters will be chained onto this. var expensesQuery = _context.Expenses .Include(e => e.ExpensesType) .Include(e => e.Project) .Include(e => e.PaidBy) .ThenInclude(e => e!.JobRole) .Include(e => e.PaymentMode) .Include(e => e.Status) .Include(e => e.CreatedBy) .Where(e => e.TenantId == tenantId); // Always filter by TenantId first. // Apply permission-based filtering BEFORE any other filters or pagination. if (hasViewAllPermissionTask.Result) { // User has 'View All' permission, no initial restriction on who created the expense. _logger.LogInfo("User {EmployeeId} has 'View All' permission.", loggedInEmployeeId); } else if (hasViewSelfPermissionTask.Result) { // User only has 'View Self' permission, so restrict the query to their own expenses. _logger.LogInfo("User {EmployeeId} has 'View Self' permission. Restricting query to their expenses.", loggedInEmployeeId); expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployeeId); } else { // User has neither required permission. Deny access. _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expenses list.", loggedInEmployeeId); return Ok(ApiResponse.SuccessResponse(new List(), "You do not have permission to view any expenses.", 200)); } // 3. --- Deserialize Filter and Apply --- ExpensesFilter? expenseFilter = TryDeserializeFilter(filter); if (expenseFilter != null) { // CRITICAL FIX: Apply filters cumulatively using multiple `if` statements, not `if-else if`. if (expenseFilter.StartDate.HasValue && expenseFilter.EndDate.HasValue) { expensesQuery = expensesQuery.Where(e => e.CreatedAt.Date >= expenseFilter.StartDate.Value.Date && e.CreatedAt.Date <= expenseFilter.EndDate.Value.Date); } if (expenseFilter.ProjectIds?.Any() == true) { expensesQuery = expensesQuery.Where(e => expenseFilter.ProjectIds.Contains(e.ProjectId)); } if (expenseFilter.StatusIds?.Any() == true) { expensesQuery = expensesQuery.Where(e => expenseFilter.StatusIds.Contains(e.StatusId)); } if (expenseFilter.PaidById?.Any() == true) { expensesQuery = expensesQuery.Where(e => expenseFilter.PaidById.Contains(e.PaidById)); } // Only allow filtering by 'CreatedBy' if the user has 'View All' permission. if (expenseFilter.CreatedByIds?.Any() == true && hasViewAllPermissionTask.Result) { expensesQuery = expensesQuery.Where(e => expenseFilter.CreatedByIds.Contains(e.CreatedById)); } } // 4. --- Apply Ordering and Pagination --- // This should be the last step before executing the query. var paginatedQuery = expensesQuery .OrderByDescending(e => e.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize); // 5. --- Execute Query and Map Results --- var expensesList = await paginatedQuery.ToListAsync(); if (!expensesList.Any()) { _logger.LogInfo("No expenses found matching the criteria for employee {EmployeeId}.", loggedInEmployeeId); return Ok(ApiResponse.SuccessResponse(new List(), "No expenses found for the given criteria.", 200)); } var response = _mapper.Map>(expensesList); // 6. --- Efficiently Fetch and Append 'Next Status' Information --- var statusIds = expensesList.Select(e => e.StatusId).Distinct().ToList(); var statusMappings = await _context.ExpensesStatusMapping .Include(sm => sm.NextStatus) .Where(sm => statusIds.Contains(sm.StatusId)) .ToListAsync(); // Use a Lookup for efficient O(1) mapping. This is much better than repeated `.Where()` in a loop. var statusMapLookup = statusMappings.ToLookup(sm => sm.StatusId); foreach (var expense in response) { if (expense.Status?.Id != null && statusMapLookup.Contains(expense.Status.Id)) { expense.NextStatus = statusMapLookup[expense.Status.Id] .Select(sm => _mapper.Map(sm.NextStatus)) .ToList(); } else { expense.NextStatus = new List(); // Ensure it's never null } } // 7. --- Return Final Success Response --- var message = $"{response.Count} expense records fetched successfully."; _logger.LogInfo(message); return StatusCode(200, ApiResponse.SuccessResponse(response, message, 200)); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Databsae Exception occured while fetching list expenses"); return BadRequest(ApiResponse.ErrorResponse("Databsae Exception", new { Message = dbEx.Message, StackTrace = dbEx.StackTrace, Source = dbEx.Source, innerexcption = new { Message = dbEx.InnerException?.Message, StackTrace = dbEx.InnerException?.StackTrace, Source = dbEx.InnerException?.Source, } }, 400)); } catch (Exception ex) { _logger.LogError(ex, "Error occured while fetching list expenses"); return BadRequest(ApiResponse.ErrorResponse("Error Occured", new { Message = ex.Message, StackTrace = ex.StackTrace, Source = ex.Source, innerexcption = new { Message = ex.InnerException?.Message, StackTrace = ex.InnerException?.StackTrace, Source = ex.InnerException?.Source, } }, 400)); } } [HttpGet("details/{id}")] public string Get(int id) { return "value"; } [HttpPost("create")] public async Task CreateExpense1([FromBody] CreateExpensesDto dto) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var hasUploadPermission = await _permission.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id); var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, dto.ProjectId); if (!hasUploadPermission || !hasProjectPermission) { _logger.LogWarning("Access DENIED for employee {EmployeeId} for uploading expense on project {ProjectId}.", loggedInEmployee.Id, dto.ProjectId); return StatusCode(403, ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to Upload expenses for this project", 403)); } var isExpensesTypeExist = await _context.ExpensesTypeMaster.AnyAsync(et => et.Id == dto.ExpensesTypeId); if (!isExpensesTypeExist) { _logger.LogWarning("Expenses type not for ID: {ExpensesTypeId} when creating new expense", dto.ExpensesTypeId); return NotFound(ApiResponse.ErrorResponse("Expanses Type not found", "Expanses Type not found", 404)); } var isPaymentModeExist = await _context.PaymentModeMatser.AnyAsync(et => et.Id == dto.PaymentModeId); if (!isPaymentModeExist) { _logger.LogWarning("Payment Mode not for ID: {PaymentModeId} when creating new expense", dto.PaymentModeId); return NotFound(ApiResponse.ErrorResponse("Payment Mode not found", "Payment Mode not found", 404)); } var isStatusExist = await _context.ExpensesStatusMaster.AnyAsync(et => et.Id == Draft); if (!isStatusExist) { _logger.LogWarning("Status not for ID: {PaymentModeId} when creating new expense", dto.PaymentModeId); return NotFound(ApiResponse.ErrorResponse("Status not found", "Status not found", 404)); } var expense = _mapper.Map(dto); expense.CreatedById = loggedInEmployee.Id; expense.CreatedAt = DateTime.UtcNow; expense.TenantId = tenantId; expense.IsActive = true; expense.StatusId = Draft; _context.Expenses.Add(expense); Guid batchId = Guid.NewGuid(); foreach (var attachment in dto.BillAttachments) { if (!_s3Service.IsBase64String(attachment.Base64Data)) { _logger.LogWarning("Image upload failed: Base64 data is missing While creating new expense entity for project {ProjectId} by employee {EmployeeId}", expense.ProjectId, expense.PaidById); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } var base64 = attachment.Base64Data!.Contains(',') ? attachment.Base64Data[(attachment.Base64Data.IndexOf(",") + 1)..] : attachment.Base64Data; var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileName = _s3Service.GenerateFileName(fileType, expense.Id, "Expense"); var objectKey = $"tenant-{tenantId}/project-{expense.ProjectId}/Expenses/{fileName}"; try { await _s3Service.UploadFileAsync(base64, fileType, objectKey); _logger.LogInfo("Image uploaded to S3 with key: {ObjectKey}", objectKey); } catch (Exception ex) { _logger.LogError(ex, "Error occured while saving image to S3"); return BadRequest(ApiResponse.ErrorResponse("Cannot upload attachment to S3", new { message = ex.Message, innerexcption = ex.InnerException?.Message, stackTrace = ex.StackTrace, source = ex.Source }, 400)); } var document = new Document { BatchId = batchId, UploadedById = loggedInEmployee.Id, FileName = attachment.FileName ?? "", ContentType = attachment.ContentType ?? "", S3Key = objectKey, FileSize = attachment.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId }; _context.Documents.Add(document); var billAttachement = new BillAttachments { DocumentId = document.Id, ExpensesId = expense.Id, TenantId = tenantId }; _context.BillAttachments.Add(billAttachement); } try { await _context.SaveChangesAsync(); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Error occured while saving Expense, Document and bill attachment entity"); return BadRequest(ApiResponse.ErrorResponse("Databsae Exception", new { Message = dbEx.Message, StackTrace = dbEx.StackTrace, Source = dbEx.Source, innerexcption = new { Message = dbEx.InnerException?.Message, StackTrace = dbEx.InnerException?.StackTrace, Source = dbEx.InnerException?.Source, } }, 400)); } _logger.LogInfo("Documents and attachments saved for Expense: {ExpenseId}", expense.Id); return StatusCode(201, ApiResponse.SuccessResponse(expense, "Expense created Successfully", 201)); } /// /// Creates a new expense entry along with its bill attachments. /// This operation is transactional and performs validations and file uploads in parallel for optimal performance. /// Permission checks are also run in parallel using IServiceScopeFactory to ensure thread safety. /// /// The data transfer object containing expense details and attachments. /// An IActionResult indicating the result of the creation operation. [HttpPost] public async Task CreateExpense([FromBody] CreateExpensesDto dto) { _logger.LogDebug("CreateExpense for Project {ProjectId}", dto.ProjectId); // The entire operation is wrapped in a transaction to ensure data consistency. await using var transaction = await _context.Database.BeginTransactionAsync(); try { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // 1. Authorization: Run permission checks in parallel using a service scope for each task. // This is crucial for thread-safety as IPermissionService is a scoped service. var hasUploadPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id); }); var hasProjectPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasProjectPermission(loggedInEmployee, dto.ProjectId); }); await Task.WhenAll(hasUploadPermissionTask, hasProjectPermissionTask); if (!hasUploadPermissionTask.Result || !hasProjectPermissionTask.Result) { _logger.LogWarning("Access DENIED for employee {EmployeeId} on project {ProjectId}.", loggedInEmployee.Id, dto.ProjectId); return StatusCode(403, ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to upload expenses for this project.", 403)); } // 2. Validation: Check if prerequisite entities exist. // The method now returns a tuple indicating success or failure. // Each task creates its own DbContext instance from the factory, making the parallel calls thread-safe. var projectGetTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Projects.FirstOrDefaultAsync(p => p.Id == dto.ProjectId); }); var expenseTypeGetTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesTypeMaster.FirstOrDefaultAsync(et => et.Id == dto.ExpensesTypeId); }); var paymentModeGetTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.PaymentModeMatser.FirstOrDefaultAsync(pm => pm.Id == dto.PaymentModeId); }); var statusGetTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMaster.FirstOrDefaultAsync(es => es.Id == Draft); }); await Task.WhenAll(projectGetTask, expenseTypeGetTask, paymentModeGetTask, statusGetTask); var project = await projectGetTask; var expenseType = await expenseTypeGetTask; var paymentMode = await paymentModeGetTask; var status = await statusGetTask; if (project == null) { await transaction.RollbackAsync(); // Ensure transaction is terminated before returning. _logger.LogWarning("Expense creation failed due to validation: Project with ID {ProjectId} not found.", dto.ProjectId); return NotFound(ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404)); } else if (expenseType == null) { await transaction.RollbackAsync(); // Ensure transaction is terminated before returning. _logger.LogWarning("Expense creation failed due to validation: Expense Type with ID {ExpensesTypeId} not found.", dto.ExpensesTypeId); return NotFound(ApiResponse.ErrorResponse("Expense Type not found.", "Expense Type not found.", 404)); } else if (paymentMode == null) { await transaction.RollbackAsync(); // Ensure transaction is terminated before returning. _logger.LogWarning("Expense creation failed due to validation: Payment Mode with ID {PaymentModeId} not found.", dto.PaymentModeId); return NotFound(ApiResponse.ErrorResponse("Payment Mode not found.", "Payment Mode not found.", 404)); } else if (status == null) { await transaction.RollbackAsync(); // Ensure transaction is terminated before returning. _logger.LogWarning("Expense creation failed due to validation: Status with ID {StatusId} not found.", Draft); return NotFound(ApiResponse.ErrorResponse("Status not found.", "Status not found.", 404)); } // 3. Entity Creation var expense = _mapper.Map(dto); expense.CreatedById = loggedInEmployee.Id; expense.CreatedAt = DateTime.UtcNow; expense.TenantId = tenantId; expense.IsActive = true; expense.StatusId = Draft; _context.Expenses.Add(expense); // 4. Process Attachments if (dto.BillAttachments?.Any() ?? false) { await ProcessAndUploadAttachmentsAsync(dto.BillAttachments, expense, loggedInEmployee.Id, tenantId); } // 5. Database Commit await _context.SaveChangesAsync(); // 6. Transaction Commit await transaction.CommitAsync(); var response = _mapper.Map(expense); response.Project = _mapper.Map(project); response.Status = _mapper.Map(status); response.PaymentMode = _mapper.Map(paymentMode); response.ExpensesType = _mapper.Map(expenseType); _logger.LogInfo("Successfully created Expense {ExpenseId} for Project {ProjectId}.", expense.Id, expense.ProjectId); return StatusCode(201, ApiResponse.SuccessResponse(response, "Expense created successfully.", 201)); } catch (ArgumentException ex) // For invalid Base64 or other bad arguments. { await transaction.RollbackAsync(); _logger.LogError(ex, "Invalid argument provided during expense creation for project {ProjectId}.", dto.ProjectId); return BadRequest(ApiResponse.ErrorResponse("Invalid Request Data", new { Message = ex.Message, StackTrace = ex.StackTrace, Source = ex.Source, innerexcption = new { Message = ex.InnerException?.Message, StackTrace = ex.InnerException?.StackTrace, Source = ex.InnerException?.Source, } }, 400)); } catch (Exception ex) // General-purpose catch for unexpected errors (e.g., S3 failure). { await transaction.RollbackAsync(); _logger.LogError(ex, "An unhandled exception occurred while creating an expense for project {ProjectId}.", dto.ProjectId); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", new { Message = ex.Message, StackTrace = ex.StackTrace, Source = ex.Source, innerexcption = new { Message = ex.InnerException?.Message, StackTrace = ex.InnerException?.StackTrace, Source = ex.InnerException?.Source, } }, 500)); } } /// /// Processes and uploads attachments in parallel, then adds the resulting entities to the main DbContext. /// private async Task ProcessAndUploadAttachmentsAsync(IEnumerable attachments, Expenses expense, Guid employeeId, Guid tenantId) { var batchId = Guid.NewGuid(); var processingTasks = attachments.Select(attachment => Task.Run(async () => { if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data)) throw new ArgumentException("Invalid or missing Base64 data for an attachment."); var base64Data = attachment.Base64Data!.Contains(',') ? attachment.Base64Data[(attachment.Base64Data.IndexOf(",") + 1)..] : attachment.Base64Data; var fileType = _s3Service.GetContentTypeFromBase64(base64Data); var fileName = _s3Service.GenerateFileName(fileType, expense.Id, "Expense"); var objectKey = $"tenant-{tenantId}/project-{expense.ProjectId}/Expenses/{fileName}"; // Upload and create entities await _s3Service.UploadFileAsync(base64Data, fileType, objectKey); _logger.LogInfo("Uploaded file to S3 with key: {ObjectKey}", objectKey); return CreateAttachmentEntities(batchId, expense.Id, employeeId, tenantId, objectKey, attachment); })).ToList(); var results = await Task.WhenAll(processingTasks); // This part is thread-safe as it runs after all parallel tasks are complete. foreach (var (document, billAttachment) in results) { _context.Documents.Add(document); _context.BillAttachments.Add(billAttachment); } _logger.LogInfo("{AttachmentCount} attachments processed and staged for saving.", results.Length); } /// /// A private static helper method to create Document and BillAttachment entities. /// private static (Document document, BillAttachments billAttachment) CreateAttachmentEntities( Guid batchId, Guid expenseId, Guid uploadedById, Guid tenantId, string s3Key, FileUploadModel attachmentDto) { var document = new Document { BatchId = batchId, UploadedById = uploadedById, FileName = attachmentDto.FileName ?? "", ContentType = attachmentDto.ContentType ?? "", S3Key = s3Key, FileSize = attachmentDto.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId }; var billAttachment = new BillAttachments { Document = document, ExpensesId = expenseId, TenantId = tenantId }; return (document, billAttachment); } [HttpPost("action")] public async Task ChangeStatus([FromBody] ExpenseRecordDto model) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var exsitingExpenses = await _context.Expenses .FirstOrDefaultAsync(e => e.Id == model.ExpenseId && e.TenantId == tenantId); if (exsitingExpenses == null) { return NotFound(ApiResponse.ErrorResponse("Expense not found", "Expense not found", 404)); } exsitingExpenses.StatusId = model.StatusId; try { await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException dbEx) { // --- Step 3: Handle Concurrency Conflicts --- // This happens if another user modified the project after we fetched it. _logger.LogError(dbEx, "Error occured while update status of expanse."); return StatusCode(500, ApiResponse.ErrorResponse("Error occured while update status of expanse.", new { Message = dbEx.Message, StackTrace = dbEx.StackTrace, Source = dbEx.Source, innerexcption = new { Message = dbEx.InnerException?.Message, StackTrace = dbEx.InnerException?.StackTrace, Source = dbEx.InnerException?.Source, } }, 500)); } var response = _mapper.Map(exsitingExpenses); return Ok(ApiResponse.SuccessResponse(response)); } [HttpPut("edit/{id}")] public async Task UpdateExpanse(Guid id, [FromBody] UpdateExpensesDto model) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var exsitingExpense = await _context.Expenses.FirstOrDefaultAsync(e => e.Id == model.Id && e.TenantId == tenantId); if (exsitingExpense == null) { return NotFound(ApiResponse.ErrorResponse("Expense not found", "Expense not found", 404)); } _mapper.Map(model, exsitingExpense); _context.Entry(exsitingExpense).State = EntityState.Modified; try { await _context.SaveChangesAsync(); _logger.LogInfo("Successfully updated project {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); } catch (DbUpdateConcurrencyException ex) { // --- Step 3: Handle Concurrency Conflicts --- // This happens if another user modified the project after we fetched it. _logger.LogError(ex, "Concurrency conflict while updating project {ProjectId} ", id); return StatusCode(409, ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409)); } var response = _mapper.Map(exsitingExpense); return Ok(ApiResponse.SuccessResponse(response)); } [HttpDelete("delete/{id}")] public void Delete(int id) { } #region =================================================================== Helper Functions =================================================================== /// /// Deserializes the filter string, handling multiple potential formats (e.g., direct JSON vs. escaped JSON string). /// /// The JSON filter string from the request. /// An object or null if deserialization fails. private ExpensesFilter? TryDeserializeFilter(string? filter) { if (string.IsNullOrWhiteSpace(filter)) { return null; } var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; ExpensesFilter? expenseFilter = null; try { // First, try to deserialize directly. This is the expected case (e.g., from a web client). expenseFilter = JsonSerializer.Deserialize(filter, options); } catch (JsonException ex) { _logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter); // If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients). try { // Unescape the string first, then deserialize the result. string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; if (!string.IsNullOrWhiteSpace(unescapedJsonString)) { expenseFilter = JsonSerializer.Deserialize(unescapedJsonString, options); } } catch (JsonException ex1) { // If both attempts fail, log the final error and return null. _logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter); return null; } } return expenseFilter; } #endregion } }