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.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; // For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 namespace Marco.Pms.Services.Controllers { [Route("api/[controller]")] [ApiController] [Authorize] public class ExpenseController : ControllerBase { private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly PermissionServices _permission; private readonly ILoggingService _logger; private readonly S3UploadService _s3Service; private readonly IMapper _mapper; private readonly Guid tenantId; public ExpenseController( ApplicationDbContext context, UserHelper userHelper, PermissionServices permission, ILoggingService logger, S3UploadService s3Service, IMapper mapper) { _context = context; _userHelper = userHelper; _permission = permission; _logger = logger; _s3Service = s3Service; _mapper = mapper; tenantId = userHelper.GetTenantId(); } [HttpGet("list")] public async Task GetExpensesList1(string? filter, int pageSize = 20, int pageNumber = 1) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var loggedInEmployeeId = loggedInEmployee.Id; List? expensesList = null; var expensesListQuery = _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) .OrderByDescending(e => e.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize); var HasViewAllPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId); var HasViewSelfPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId); if (HasViewSelfPermission) { expensesListQuery = expensesListQuery.Where(e => e.CreatedById == loggedInEmployeeId); } else if (!HasViewAllPermission) { _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expanses list.", loggedInEmployeeId); return Ok(ApiResponse.SuccessResponse(new List(), "No Expense found for current user", 200)); } ExpensesFilter? expenesFilter = null; if (!string.IsNullOrWhiteSpace(filter)) { try { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; expenesFilter = JsonSerializer.Deserialize(filter, options); } catch (Exception ex) { _logger.LogError(ex, "[GetExpensesList] Failed to parse filter came from website or mobile"); try { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; expenesFilter = JsonSerializer.Deserialize(unescapedJsonString, options); } catch (Exception ex1) { _logger.LogError(ex1, "[GetExpensesList] Failed to parse filter came from postman"); } } } var projectIds = expenesFilter?.ProjectIds; var filterStatusIds = expenesFilter?.StatusIds; var createdByIds = expenesFilter?.CreatedByIds; var paidById = expenesFilter?.PaidById; var startDate = expenesFilter?.StartDate; var endDate = expenesFilter?.EndDate; if (startDate != null && endDate != null) { expensesListQuery = expensesListQuery.Where(e => e.CreatedAt.Date >= startDate && e.CreatedAt.Date <= endDate); } else if (projectIds != null && projectIds.Any()) { expensesListQuery = expensesListQuery.Where(e => projectIds.Contains(e.ProjectId)); } else if (filterStatusIds != null && filterStatusIds.Any()) { expensesListQuery = expensesListQuery.Where(e => filterStatusIds.Contains(e.StatusId)); } else if (createdByIds != null && createdByIds.Any() && HasViewAllPermission) { expensesListQuery = expensesListQuery.Where(e => createdByIds.Contains(e.CreatedById)); } else if (paidById != null && paidById.Any()) { expensesListQuery = expensesListQuery.Where(e => paidById.Contains(e.PaidById)); } expensesList = await expensesListQuery.ToListAsync(); var response = _mapper.Map>(expensesList); var statusIds = expensesList.Select(e => e.StatusId).ToList(); var statusMappings = await _context.ExpensesStatusMapping .Include(sm => sm.NextStatus) .Where(sm => statusIds.Contains(sm.StatusId)) .ToListAsync(); foreach (var expense in response) { var statusMapping = statusMappings.Where(sm => sm.StatusId == expense.Status?.Id).Select(sm => _mapper.Map(sm.NextStatus)).ToList(); expense.NextStatus = statusMapping; } return StatusCode(200, ApiResponse.SuccessResponse(response, $"{response.Count} records of expenses for you fetched successfully", 200)); } /// /// 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] // 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 hasViewAllPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId); var hasViewSelfPermission = await _permission.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId); // 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 (hasViewAllPermission) { // User has 'View All' permission, no initial restriction on who created the expense. _logger.LogInfo("User {EmployeeId} has 'View All' permission.", loggedInEmployeeId); } else if (hasViewSelfPermission) { // 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 && hasViewAllPermission) { 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)); } } /// /// 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; } [HttpGet("details/{id}")] public string Get(int id) { return "value"; } [HttpPost("create")] public async Task Post([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 == dto.StatusId); 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 = new Expenses { ProjectId = dto.ProjectId, ExpensesTypeId = dto.ExpensesTypeId, PaymentModeId = dto.PaymentModeId, PaidById = dto.PaidById, CreatedById = loggedInEmployee.Id, TransactionDate = dto.TransactionDate, CreatedAt = DateTime.UtcNow, TransactionId = dto.TransactionId, Description = dto.Description, Location = dto.Location, GSTNumber = dto.GSTNumber, SupplerName = dto.SupplerName, Amount = dto.Amount, NoOfPersons = dto.NoOfPersons, StatusId = dto.StatusId, PreApproved = dto.PreApproved, IsActive = true, TenantId = tenantId }; _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, //Base64Data = attachment.Base64Data, 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)); } [HttpPut("edit/{id}")] public void Put(int id, [FromBody] string value) { } [HttpDelete("delete/{id}")] public void Delete(int id) { } } }