using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Helpers.Utility; using Marco.Pms.Model.Dtos.Expenses; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Expenses; using Marco.Pms.Model.Filters; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.MongoDBModels.Employees; using Marco.Pms.Model.MongoDBModels.Expenses; using Marco.Pms.Model.MongoDBModels.Masters; using Marco.Pms.Model.MongoDBModels.Project; using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Expanses; using Marco.Pms.Model.ViewModels.Expenses; using Marco.Pms.Model.ViewModels.Master; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Service; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using System.Text.Json; using System.Text.RegularExpressions; using Document = Marco.Pms.Model.DocumentManager.Document; namespace Marco.Pms.Services.Service { public class ExpensesService : IExpensesService { private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly ILoggingService _logger; private readonly S3UploadService _s3Service; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly UtilityMongoDBHelper _updateLogHelper; private readonly CacheUpdateHelper _cache; private readonly IMapper _mapper; private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8"); private static readonly Guid Review = Guid.Parse("6537018f-f4e9-4cb3-a210-6c3b2da999d7"); private static readonly Guid RejectedByReviewer = Guid.Parse("965eda62-7907-4963-b4a1-657fb0b2724b"); private static readonly Guid Approve = Guid.Parse("4068007f-c92f-4f37-a907-bc15fe57d4d8"); private static readonly Guid RejectedByApprover = Guid.Parse("d1ee5eec-24b6-4364-8673-a8f859c60729"); private static readonly Guid ProcessPending = Guid.Parse("f18c5cfd-7815-4341-8da2-2c2d65778e27"); private static readonly Guid Processed = Guid.Parse("61578360-3a49-4c34-8604-7b35a3787b95"); private static readonly string Collection = "ExpensesModificationLog"; public ExpensesService( IDbContextFactory dbContextFactory, ApplicationDbContext context, IServiceScopeFactory serviceScopeFactory, UtilityMongoDBHelper updateLogHelper, CacheUpdateHelper cache, ILoggingService logger, S3UploadService s3Service, IMapper mapper) { _dbContextFactory = dbContextFactory; _context = context; _logger = logger; _cache = cache; _serviceScopeFactory = serviceScopeFactory; _updateLogHelper = updateLogHelper; _s3Service = s3Service; _mapper = mapper; } #region =================================================================== Expense Functions =================================================================== #region =================================================================== Get Functions =================================================================== /// /// 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. public async Task> GetExpensesListAsync(Employee loggedInEmployee, Guid tenantId, string? searchString, string? filter, int pageSize, int pageNumber) { try { _logger.LogInfo( "Attempting to fetch expenses list for PageNumber: {PageNumber}, PageSize: {PageSize} with Filter: {Filter}", pageNumber, pageSize, filter ?? ""); // 1. --- Get User Permissions --- 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 ApiResponse.ErrorResponse("User not found or not authenticated.", 403); } Guid loggedInEmployeeId = loggedInEmployee.Id; List expenseVM = new List(); var totalEntites = 0; 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); if (!hasViewAllPermissionTask.Result && !hasViewSelfPermissionTask.Result) { // User has neither required permission. Deny access. _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expenses list.", loggedInEmployeeId); return ApiResponse.SuccessResponse(new List(), "You do not have permission to view any expenses.", 200); } // 2. --- Deserialize Filter and Apply --- ExpensesFilter? expenseFilter = TryDeserializeFilter(filter); //var (totalPages, totalCount, cacheList) = await _cache.GetExpenseListAsync(tenantId, loggedInEmployeeId, hasViewAllPermissionTask.Result, hasViewSelfPermissionTask.Result, // pageNumber, pageSize, expenseFilter, searchString); List? cacheList = null; var totalPages = 0; var totalCount = 0; // 3. --- Build Base Query and Apply Permissions --- // Start with a base IQueryable. Filters will be chained onto this. var expensesQuery = _context.Expenses .Include(e => e.PaidBy) .Include(e => e.CreatedBy) .Include(e => e.ProcessedBy) .Include(e => e.ApprovedBy) .Include(e => e.ReviewedBy) .Include(e => e.PaymentMode) .Include(e => e.Project) .Include(e => e.PaymentMode) .Include(e => e.ExpenseCategory) .Include(e => e.Status) .Where(e => e.TenantId == tenantId); // Always filter by TenantId first. if (cacheList == null) { await _cache.AddExpensesListToCache(expenses: await expensesQuery.ToListAsync(), tenantId); // Apply permission-based filtering BEFORE any other filters or pagination. if (hasViewAllPermissionTask.Result) { expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployeeId || e.StatusId != Draft); } 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); } if (expenseFilter != null) { if (expenseFilter.StartDate.HasValue && expenseFilter.EndDate.HasValue) { if (expenseFilter.IsTransactionDate) { expensesQuery = expensesQuery.Where(e => e.TransactionDate.Date >= expenseFilter.StartDate.Value.Date && e.TransactionDate.Date <= expenseFilter.EndDate.Value.Date); } else { 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)); } if (expenseFilter.ExpenseCategoryIds?.Any() == true) { expensesQuery = expensesQuery.Where(e => expenseFilter.ExpenseCategoryIds.Contains(e.ExpenseCategoryId)); } // 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)); } } if (!string.IsNullOrWhiteSpace(searchString)) { var searchStringLower = searchString.ToLower(); expensesQuery = expensesQuery.Include(e => e.PaidBy).Include(e => e.CreatedBy) .Where(e => e.Description.ToLower().Contains(searchStringLower) || (e.TransactionId != null && e.TransactionId.ToLower().Contains(searchStringLower)) || (e.PaidBy != null && (e.PaidBy.FirstName + " " + e.PaidBy.LastName).ToLower().Contains(searchStringLower)) || (e.CreatedBy != null && (e.CreatedBy.FirstName + " " + e.CreatedBy.LastName).ToLower().Contains(searchStringLower))); } // 4. --- Apply Ordering and Pagination --- // This should be the last step before executing the query. totalEntites = await expensesQuery.CountAsync(); 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 ApiResponse.SuccessResponse(new List(), "No expenses found for the given criteria.", 200); } //expenseVM = await GetAllExpnesRelatedTables(expensesList, tenantId); expenseVM = _mapper.Map>(expensesList); totalPages = (int)Math.Ceiling((double)totalEntites / pageSize); } else { var permissionStatusMapping = await _context.StatusPermissionMapping .GroupBy(ps => ps.StatusId) .Select(g => new { StatusId = g.Key, PermissionIds = g.Select(ps => ps.PermissionId).ToList() }).ToListAsync(); expenseVM = cacheList.Select(m => { var response = _mapper.Map(m); if (response.Status != null && (response.NextStatus?.Any() ?? false)) { response.Status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == Guid.Parse(m.Status.Id)).Select(ps => ps.PermissionIds).FirstOrDefault(); foreach (var status in response.NextStatus) { status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault(); } } return response; }).ToList(); totalEntites = (int)totalCount; } // 7. --- Return Final Success Response --- var message = $"{expenseVM.Count} expense records fetched successfully."; _logger.LogInfo(message); var response = new { CurrentFilter = expenseFilter, CurrentPage = pageNumber, TotalPages = totalPages, TotalEntites = totalEntites, Data = expenseVM, }; return ApiResponse.SuccessResponse(response, message, 200); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Databsae Exception occured while fetching list expenses"); return ApiResponse.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500); } catch (Exception ex) { _logger.LogError(ex, "Error occured while fetching list expenses"); return ApiResponse.ErrorResponse("Error Occured", ExceptionMapper(ex), 500); } } public async Task> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId) { try { if (!id.HasValue && string.IsNullOrWhiteSpace(expenseUId)) { _logger.LogWarning("Invalid parameters: Both Id and PaymentRequestUID are null or empty."); return ApiResponse.ErrorResponse("At least one parameter (Id or expenseUId) must be provided.", "Invalid argument.", 400); } ExpenseDetailsMongoDB? expenseDetails = null; if (expenseDetails == null) { var expense = await _context.Expenses .Include(e => e.PaidBy) .Include(e => e.CreatedBy) .Include(e => e.ProcessedBy) .Include(e => e.ApprovedBy) .Include(e => e.ReviewedBy) .Include(e => e.PaymentMode) .Include(e => e.Project) .Include(e => e.PaymentMode) .Include(e => e.ExpenseCategory) .Include(e => e.Status) .AsNoTracking().FirstOrDefaultAsync(e => (e.Id == id || e.ExpenseUId == expenseUId) && e.TenantId == tenantId); if (expense == null) { if (id.HasValue) { _logger.LogWarning("User attempted to fetch expense details with ID {ExpenseId}, but not found in both database and cache", id); } else if (!string.IsNullOrWhiteSpace(expenseUId)) { _logger.LogWarning("User attempted to fetch expense details with expenseUId {ExpenseUId}, but not found in both database and cache", expenseUId); } return ApiResponse.ErrorResponse("Expense Not Found", "Expense Not Found", 404); } expenseDetails = await GetAllExpnesRelatedTablesForSingle(expense, expense.TenantId); } var vm = _mapper.Map(expenseDetails); var permissionStatusMappingTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.StatusPermissionMapping .GroupBy(ps => ps.StatusId) .Select(g => new { StatusId = g.Key, PermissionIds = g.Select(ps => ps.PermissionId).ToList() }).ToListAsync(); }); var expenseReimburseTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesReimburseMapping .Include(er => er.ExpensesReimburse) .ThenInclude(er => er!.ReimburseBy) .ThenInclude(e => e!.JobRole) .Where(er => er.TenantId == tenantId && er.ExpensesId == vm.Id) .Select(er => er.ExpensesReimburse).FirstOrDefaultAsync(); }); var permissionStatusMappings = permissionStatusMappingTask.Result; var expensesReimburse = expenseReimburseTask.Result; if (vm.Status != null && (vm.NextStatus?.Any() ?? false)) { vm.Status.PermissionIds = permissionStatusMappings.Where(ps => ps.StatusId == vm.Status.Id).Select(ps => ps.PermissionIds).FirstOrDefault(); foreach (var status in vm.NextStatus) { status.PermissionIds = permissionStatusMappings.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault(); } int index = vm.NextStatus.FindIndex(ns => ns.DisplayName == "Reject"); if (index > -1) { var item = vm.NextStatus[index]; vm.NextStatus.RemoveAt(index); vm.NextStatus.Insert(0, item); } } vm.ExpensesReimburse = _mapper.Map(expensesReimburse); foreach (var document in expenseDetails.Documents) { var response = vm.Documents.FirstOrDefault(d => d.DocumentId == Guid.Parse(document.DocumentId)); response!.PreSignedUrl = _s3Service.GeneratePreSignedUrl(document.S3Key); response!.ThumbPreSignedUrl = _s3Service.GeneratePreSignedUrl(document.ThumbS3Key); } var expenselogs = await _context.ExpenseLogs.Include(el => el.UpdatedBy).Where(el => el.ExpenseId == vm.Id).Select(el => _mapper.Map(el)).ToListAsync(); vm.ExpenseLogs = expenselogs; _logger.LogInfo("Employee {EmployeeId} successfully fetched expense details with ID {ExpenseId}", loggedInEmployee.Id, vm.Id); return ApiResponse.SuccessResponse(vm, "Successfully fetched the details of expense", 200); } catch (Exception ex) { _logger.LogError(ex, "An unhandled exception occurred while fetching an expense details {ExpenseId} or {ExpenseUId}.", id ?? Guid.Empty, expenseUId ?? "EX_00000"); return ApiResponse.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500); } } public async Task> GetSupplerNameListAsync(Employee loggedInEmployee, Guid tenantId) { try { var supplerNameList = await _context.Expenses.Where(e => e.TenantId == tenantId).Select(e => e.SupplerName).Distinct().ToListAsync(); _logger.LogInfo("Employee {EmployeeId} fetched list of suppler names from expenses in a tenant {TenantId}", loggedInEmployee.Id, tenantId); return ApiResponse.SuccessResponse(supplerNameList, $"{supplerNameList.Count} records of suppler names fetched from expense", 200); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Databsae Exception occured while fetching suppler name list from expense"); return ApiResponse.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500); } } public async Task> GetFilterObjectAsync(Employee loggedInEmployee, Guid tenantId) { try { var expenses = await _context.Expenses .Include(e => e.PaidBy) .Include(e => e.Project) .Include(e => e.CreatedBy) .Include(e => e.Status) .Include(e => e.ExpenseCategory) .Where(e => e.TenantId == tenantId) .ToListAsync(); // Construct the final object from the results of the completed tasks. var response = new { Projects = expenses.Where(e => e.Project != null).Select(e => new { Id = e.Project!.Id, Name = e.Project.Name }).Distinct().ToList(), PaidBy = expenses.Where(e => e.PaidBy != null).Select(e => new { Id = e.PaidBy!.Id, Name = $"{e.PaidBy.FirstName} {e.PaidBy.LastName}" }).Distinct().ToList(), CreatedBy = expenses.Where(e => e.CreatedBy != null).Select(e => new { Id = e.CreatedBy!.Id, Name = $"{e.CreatedBy.FirstName} {e.CreatedBy.LastName}" }).Distinct().ToList(), Status = expenses.Where(e => e.Status != null).Select(e => new { Id = e.Status!.Id, Name = e.Status.Name }).Distinct().ToList(), ExpensesCategory = expenses.Where(e => e.ExpenseCategory != null).Select(e => new { Id = e.ExpenseCategory!.Id, Name = e.ExpenseCategory.Name }).Distinct().ToList() }; return ApiResponse.SuccessResponse(response, "Successfully fetched the filter list", 200); } catch (Exception ex) { _logger.LogError(ex, "Exception occured while fetching the list filters for expenses"); return ApiResponse.ErrorResponse("Internal Exception Occured", ExceptionMapper(ex), 500); } } #endregion #region =================================================================== Post Functions =================================================================== /// /// Creates a new expense entry along with its bill attachments. /// This operation is transactional and performs validations and file uploads concurrently for optimal performance /// by leveraging async/await without unnecessary thread-pool switching via Task.Run. /// /// The data transfer object containing expense details and attachments. /// An IActionResult indicating the result of the creation operation. public async Task> CreateExpenseAsync(CreateExpensesDto dto, Employee loggedInEmployee, Guid tenantId) { _logger.LogDebug("Starting 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 { // 1. Authorization & Validation: Run all I/O-bound checks concurrently using factories for safety. // PERMISSION CHECKS: Use IServiceScopeFactory for thread-safe access to scoped services. var hasUploadPermissionTask = Task.Run(async () => // Task.Run is acceptable here to create a new scope, but let's do it cleaner. { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id); }); // VALIDATION CHECKS: Use IDbContextFactory for thread-safe, parallel database queries. // Each task gets its own DbContext instance. var projectTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == dto.ProjectId); }); var paidByTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Employees.AsNoTracking().FirstOrDefaultAsync(e => e.Id == dto.PaidById); }); var expenseCategoriesTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpenseCategoryMasters.AsNoTracking().FirstOrDefaultAsync(et => et.Id == dto.ExpenseCategoryId); }); var paymentModeTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.PaymentModeMatser.AsNoTracking().FirstOrDefaultAsync(pm => pm.Id == dto.PaymentModeId); }); var expenseUIdTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var result = await dbContext.Expenses .Where(e => !string.IsNullOrWhiteSpace(e.ExpenseUId)).ToListAsync(); return result .Select(e => ExtractNumber(e.ExpenseUId)) .OrderByDescending(id => id).FirstOrDefault(); }); var statusMappingTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMapping .Include(s => s.Status) .Include(s => s.NextStatus) .AsNoTracking() .Where(es => es.StatusId == Draft && es.Status != null) .GroupBy(s => s.StatusId) .Select(g => new { Status = g.Select(s => s.Status).FirstOrDefault(), NextStatus = g.Select(s => s.NextStatus).ToList() }) .FirstOrDefaultAsync(); }); // Await all prerequisite checks at once. await Task.WhenAll(hasUploadPermissionTask, projectTask, expenseCategoriesTask, paymentModeTask, statusMappingTask, paidByTask, expenseUIdTask); // 2. Aggregate and Check Results if (!await hasUploadPermissionTask) { _logger.LogWarning("Access DENIED for employee {EmployeeId} on project {ProjectId}.", loggedInEmployee.Id, dto.ProjectId); return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to upload expenses for this project.", 403); } var validationErrors = new List(); var project = await projectTask; var expenseCategory = await expenseCategoriesTask; var paymentMode = await paymentModeTask; var statusMapping = await statusMappingTask; var paidBy = await paidByTask; var lastExpenseUId = expenseUIdTask.Result; if (project == null) validationErrors.Add("Project not found."); if (paidBy == null) validationErrors.Add("Paid by employee not found"); if (expenseCategory == null) validationErrors.Add("Expense Category not found."); if (paymentMode == null) validationErrors.Add("Payment Mode not found."); if (statusMapping == null) validationErrors.Add("Default status 'Draft' not found."); if ((expenseCategory?.IsAttachmentRequried ?? true) && !(dto.BillAttachments?.Any() ?? false)) validationErrors.Add("Bill Attachment is requried, but not found"); if (validationErrors.Any()) { await transaction.RollbackAsync(); var errorMessage = string.Join(" ", validationErrors); _logger.LogWarning("Expense creation failed due to validation errors: {ValidationErrors}", errorMessage); return ApiResponse.ErrorResponse("Invalid input data.", errorMessage, 400); } var currentexpenseUId = (lastExpenseUId + 1).ToString("D5"); // 3. Entity Creation var expense = _mapper.Map(dto); expense.ExpenseUId = $"EX-{currentexpenseUId}"; 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(); await _cache.AddExpenseByObjectAsync(expense); var response = _mapper.Map(expense); response.PaidBy = _mapper.Map(paidBy); response.Project = _mapper.Map(project); response.Status = _mapper.Map(statusMapping!.Status); response.NextStatus = _mapper.Map>(statusMapping.NextStatus); response.PaymentMode = _mapper.Map(paymentMode); response.ExpensesCategory = _mapper.Map(expenseCategory); _logger.LogInfo("Successfully created Expense {ExpenseId} for Project {ProjectId}.", expense.Id, expense.ProjectId); return ApiResponse.SuccessResponse(response, "Expense created successfully.", 201); } catch (DbUpdateException dbEx) { await transaction.RollbackAsync(); _logger.LogError(dbEx, "Databsae Exception occured while adding expense"); return ApiResponse.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500); } catch (ArgumentException ex) // Catches bad Base64 from attachment pre-validation { _logger.LogError(ex, "Invalid argument during expense creation for project {ProjectId}.", dto.ProjectId); return ApiResponse.ErrorResponse("Invalid Request Data.", ExceptionMapper(ex), 400); } catch (Exception ex) // General-purpose catch for unexpected errors (e.g., S3 or DB connection failure) { _logger.LogError(ex, "An unhandled exception occurred while creating an expense for project {ProjectId}.", dto.ProjectId); return ApiResponse.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500); } } /// /// Changes the status of an expense record, performing validation, permission checks, and logging. /// /// The DTO containing the expense ID and the target status ID. /// The employee performing the action. /// The ID of the tenant owning the expense. /// An ApiResponse containing the updated expense details or an error. public async Task> ChangeStatusAsync(ExpenseRecordDto model, Employee loggedInEmployee, Guid tenantId) { using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); // 1. Fetch Existing Expense with Related Entities (Single Query) var expense = await _context.Expenses .Include(e => e.ExpenseCategory) .Include(e => e.Project) .Include(e => e.PaidBy).ThenInclude(e => e!.JobRole) .Include(e => e.PaymentMode) .Include(e => e.Status) .Include(e => e.CreatedBy) .Include(e => e.ReviewedBy) .Include(e => e.ApprovedBy) .Include(e => e.ProcessedBy) .FirstOrDefaultAsync(e => e.Id == model.ExpenseId && e.StatusId != model.StatusId && e.TenantId == tenantId ); if (expense == null) { _logger.LogWarning("ChangeStatus: Expense not found or already at target status. ExpenseId={ExpenseId}, TenantId={TenantId}", model.ExpenseId, tenantId); return ApiResponse.ErrorResponse("Expense not found or status is already set.", "Expense not found", 404); } _logger.LogInfo("ChangeStatus: Requested status change. ExpenseId={ExpenseId} FromStatus={FromStatusId} ToStatus={ToStatusId}", expense.Id, expense.StatusId, model.StatusId); // 2. Run Prerequisite Checks in Parallel (Status transition + Permissions) var statusTransitionTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMapping .Include(m => m.NextStatus) .FirstOrDefaultAsync(m => m.StatusId == expense.StatusId && m.NextStatusId == model.StatusId); }); var targetStatusPermissionsTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.StatusPermissionMapping .Where(spm => spm.StatusId == model.StatusId) .ToListAsync(); }); await Task.WhenAll(statusTransitionTask, targetStatusPermissionsTask); var statusTransition = await statusTransitionTask; var requiredPermissions = await targetStatusPermissionsTask; // 3. Validate Transition and Required Fields if (statusTransition == null) { _logger.LogWarning("ChangeStatus: Invalid status transition. ExpenseId={ExpenseId}, FromStatus={FromStatus}, ToStatus={ToStatus}", expense.Id, expense.StatusId, model.StatusId); return ApiResponse.ErrorResponse("Status change is not permitted.", "Invalid Transition", 400); } // Validate special logic for "Processed" if (statusTransition.NextStatusId == Processed && (string.IsNullOrWhiteSpace(model.ReimburseTransactionId) || !model.ReimburseDate.HasValue || model.ReimburseById == null || model.ReimburseById == Guid.Empty)) { _logger.LogWarning("ChangeStatus: Missing reimbursement fields for 'Processed'. ExpenseId={ExpenseId}", expense.Id); return ApiResponse.ErrorResponse("Reimbursement details are missing or invalid.", "Invalid Reimbursement", 400); } // 4. Permission Check (CreatedBy -> Reviewer bypass, else required permissions) bool hasPermission = false; if (model.StatusId == Review && expense.CreatedById == loggedInEmployee.Id) { hasPermission = true; } else if (requiredPermissions.Any()) { var permissionService = scope.ServiceProvider.GetRequiredService(); foreach (var permission in requiredPermissions) { if (await permissionService.HasPermission(permission.PermissionId, loggedInEmployee.Id) && model.StatusId != Review) { hasPermission = true; break; } } } if (!hasPermission) { _logger.LogWarning("ChangeStatus: Permission denied. EmployeeId={EmployeeId} ExpenseId={ExpenseId} ToStatus={ToStatusId}", loggedInEmployee.Id, expense.Id, model.StatusId); return ApiResponse.ErrorResponse("You do not have permission for this action.", "Access Denied", 403); } // 5. Prepare for update (Audit snapshot) var expenseStateBeforeChange = _updateLogHelper.EntityToBsonDocument(expense); // 6. Apply Status Transition expense.StatusId = statusTransition.NextStatusId; expense.Status = statusTransition.NextStatus; // Handle reviewer/approver/processor fields based on target StatusId (Guid) if (model.StatusId == Approve || model.StatusId == RejectedByReviewer) { expense.ReviewedById = loggedInEmployee.Id; } else if (model.StatusId == ProcessPending || model.StatusId == RejectedByApprover) { expense.ApprovedById = loggedInEmployee.Id; } else if (model.StatusId == Processed) { expense.ProcessedById = loggedInEmployee.Id; } // 7. Add Reimbursement if applicable if (model.StatusId == Processed) { var reimbursement = new ExpensesReimburse { ReimburseTransactionId = model.ReimburseTransactionId!, ReimburseDate = model.ReimburseDate!.Value, ReimburseById = model.ReimburseById!.Value, ReimburseNote = model.Comment ?? string.Empty, TenantId = tenantId }; _context.ExpensesReimburse.Add(reimbursement); _context.ExpensesReimburseMapping.Add(new ExpensesReimburseMapping { ExpensesId = expense.Id, ExpensesReimburseId = reimbursement.Id, TenantId = tenantId }); } // 8. Add Expense Log Entry _context.ExpenseLogs.Add(new ExpenseLog { ExpenseId = expense.Id, Action = $"Status changed to '{statusTransition.NextStatus?.Name}'", UpdatedById = loggedInEmployee.Id, UpdateAt = DateTime.UtcNow, Comment = model.Comment, TenantId = tenantId }); // 9. Commit database transaction try { await _context.SaveChangesAsync(); _logger.LogInfo("ChangeStatus: Status updated successfully. ExpenseId={ExpenseId} NewStatus={NewStatusId}", expense.Id, expense.StatusId); } catch (DbUpdateConcurrencyException ex) { _logger.LogError(ex, "ChangeStatus: Concurrency error. ExpenseId={ExpenseId}", expense.Id); return ApiResponse.ErrorResponse("Expense was modified by another user. Please refresh and try again.", "Concurrency Error", 409); } _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; await _firebase.SendExpenseMessageAsync(expense, name, tenantId); }); // 10. Post-processing (audit log, cache, fetch next states) try { var auditLogTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = expense.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = expenseStateBeforeChange, UpdatedAt = DateTime.UtcNow }, Collection); var cacheUpdateTask = _cache.ReplaceExpenseAsync(expense); var getNextStatusesTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(async t => { var dbContext = t.Result; var nextStatuses = await dbContext.ExpensesStatusMapping .Include(m => m.NextStatus) .Where(m => m.StatusId == expense.StatusId && m.NextStatus != null) .Select(m => m.NextStatus) .ToListAsync(); await dbContext.DisposeAsync(); return nextStatuses; }).Unwrap(); await Task.WhenAll(auditLogTask, getNextStatusesTask, cacheUpdateTask); // Prepare response with possible next states var nextPossibleStatuses = await getNextStatusesTask; var responseDto = _mapper.Map(expense); if (nextPossibleStatuses is { Count: > 0 }) responseDto.NextStatus = _mapper.Map>(nextPossibleStatuses); return ApiResponse.SuccessResponse(responseDto); } catch (Exception ex) { _logger.LogError(ex, "ChangeStatus: Post-operation error (e.g. audit logging). ExpenseId={ExpenseId}", expense.Id); var responseDto = _mapper.Map(expense); return ApiResponse.SuccessResponse(responseDto, "Status updated, but audit logging or cache update failed."); } } #endregion #region =================================================================== Put Functions =================================================================== public async Task> UpdateExpanseAsync(Guid id, UpdateExpensesDto model, Employee loggedInEmployee, Guid tenantId) { if (id != model.Id) { _logger.LogWarning("Id provided by path parameter and Id from body not matches for employee {EmployeeId}", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Invalid Parameters", "Invalid Parameters", 400); } var existingExpense = await _context.Expenses .Include(e => e.ExpenseCategory) .Include(e => e.Project) .Include(e => e.PaidBy) .ThenInclude(e => e!.JobRole) .Include(e => e.PaymentMode) .Include(e => e.Status) .Include(e => e.CreatedBy) .Include(e => e.ReviewedBy) .Include(e => e.ApprovedBy) .Include(e => e.ProcessedBy) .FirstOrDefaultAsync(e => e.Id == model.Id && e.TenantId == tenantId); if (existingExpense == null) { _logger.LogWarning("User attempted to update expense with ID {ExpenseId}, but not found in database", id); return ApiResponse.ErrorResponse("Expense not found", "Expense not found", 404); } if (existingExpense.StatusId != Draft && existingExpense.StatusId != RejectedByReviewer && existingExpense.StatusId != RejectedByApprover) { _logger.LogWarning("User attempted to update expense with ID {ExpenseId}, but donot have status of DRAFT or REJECTED, but is {StatusId}", existingExpense.Id, existingExpense.StatusId); return ApiResponse.ErrorResponse("Expense connot be updated", "Expense connot be updated", 400); } if (existingExpense.CreatedById != loggedInEmployee.Id) { _logger.LogWarning("User attempted to update expense with ID {ExpenseId} which not created by them", existingExpense.Id); return ApiResponse.ErrorResponse("You donot have access to update this expense", "You donot have access to update this expense", 400); } var existingEntityBson = _updateLogHelper.EntityToBsonDocument(existingExpense); // Capture state for audit log BEFORE changes _mapper.Map(model, existingExpense); _context.Entry(existingExpense).State = EntityState.Modified; try { await _context.SaveChangesAsync(); _logger.LogInfo("Successfully updated Expense {ExpenseId} 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 ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409); } if (model.BillAttachments?.Any() ?? false) { var newBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId == null && ba.IsActive).ToList(); if (newBillAttachments.Any()) { await ProcessAndUploadAttachmentsAsync(newBillAttachments, existingExpense, loggedInEmployee.Id, tenantId); try { await _context.SaveChangesAsync(); _logger.LogInfo("{Count} New attachments added while updating expense {ExpenseId} by employee {EmployeeId}", newBillAttachments.Count, existingExpense.Id, loggedInEmployee.Id); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Databsae Exception occured while adding new attachments during updating expense"); return ApiResponse.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500); } } var deleteBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId != null && !ba.IsActive).ToList(); if (deleteBillAttachments.Any()) { var documentIds = deleteBillAttachments.Select(d => d.DocumentId!.Value).ToList(); try { await DeleteAttachemnts(documentIds); _logger.LogInfo("{Count} Attachments deleted while updating expense {ExpenseId} by employee {EmployeeId}", deleteBillAttachments.Count, existingExpense.Id, loggedInEmployee.Id); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Databsae Exception occured while deleting attachments during updating expense"); return ApiResponse.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500); } catch (Exception ex) { _logger.LogError(ex, "Exception occured while deleting attachments during updating expense"); return ApiResponse.ErrorResponse("Exception occured while deleting attachments during updating expense ", ExceptionMapper(ex), 500); } } } try { // Task to save the detailed audit log to a separate system (e.g., MongoDB). var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = existingExpense.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntityBson, UpdatedAt = DateTime.UtcNow }, Collection); // Task to get all possible next statuses from the *new* current state to help the UI. // NOTE: This now fetches a list of all possible next states, which is more useful for a UI. var getNextStatusesTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(t => { var dbContext = t.Result; return dbContext.ExpensesStatusMapping .Include(s => s.NextStatus) .Where(s => s.StatusId == existingExpense.StatusId && s.NextStatus != null) .Select(s => s.NextStatus) // Select only the status object .ToListAsync() .ContinueWith(res => { dbContext.Dispose(); // Ensure the context is disposed return res.Result; }); }).Unwrap(); var cacheUpdateTask = _cache.ReplaceExpenseAsync(existingExpense); await Task.WhenAll(mongoDBTask, getNextStatusesTask, cacheUpdateTask); var nextPossibleStatuses = getNextStatusesTask.Result; var response = _mapper.Map(existingExpense); if (nextPossibleStatuses != null) { // The response DTO should have a property like: public List NextAvailableStatuses { get; set; } response.NextStatus = _mapper.Map>(nextPossibleStatuses); } return ApiResponse.SuccessResponse(response, "Expense Updated Successfully", 200); } catch (Exception ex) { // This catch block handles errors from post-save operations like MongoDB logging. // The primary update was successful, but we must log this failure. _logger.LogError(ex, "Error occurred during post-save operations for ExpenseId: {ExpenseId} (e.g., audit logging). The primary status change was successful.", existingExpense.Id); // We can still return a success response because the main operation succeeded, // but we should not block the user for a failed audit log. // Alternatively, if audit logging is critical, you could return an error. // Here, we choose to return success but log the ancillary failure. var response = _mapper.Map(existingExpense); return ApiResponse.SuccessResponse(response, "Status updated, but a post-processing error occurred."); } } #endregion #region =================================================================== Delete Functions =================================================================== public async Task> DeleteExpanseAsync(Guid id, Employee loggedInEmployee, Guid tenantId) { var expenseTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Expenses.Where(e => e.Id == id && e.StatusId == Draft && e.TenantId == tenantId).FirstOrDefaultAsync(); }); var hasAprrovePermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseApprove, loggedInEmployee.Id); }); await Task.WhenAll(expenseTask, hasAprrovePermissionTask); var hasAprrovePermission = hasAprrovePermissionTask.Result; var existingExpense = expenseTask.Result; if (existingExpense == null) { var message = hasAprrovePermission ? "Expenses not found" : "Expense cannot be deleted"; if (hasAprrovePermission) { _logger.LogWarning("Employee {EmployeeId} attempted to delete expense {ExpenseId}, but not found in database", loggedInEmployee.Id, id); } else { _logger.LogWarning("Employee {EmployeeId} attempted to delete expense {ExpenseId}, Which is created by another employee", loggedInEmployee.Id, id); } return ApiResponse.ErrorResponse(message, message, 400); } if (existingExpense.StatusId != Draft) { _logger.LogWarning("User attempted to delete expense with ID {ExpenseId}, but donot have status of DRAFT or REJECTED", loggedInEmployee.Id); return ApiResponse.ErrorResponse("Expense connot be deleted", "Expense connot be deleted", 400); } if (!hasAprrovePermission && existingExpense.CreatedById != loggedInEmployee.Id) { _logger.LogWarning("User attempted to delete expense with ID {ExpenseId} which not created by them", loggedInEmployee.Id); return ApiResponse.ErrorResponse("You donot have access to delete this expense", "You donot have access to delete this expense", 400); } var documentIds = await _context.BillAttachments .Where(ba => ba.ExpensesId == existingExpense.Id) .Select(ba => ba.DocumentId) .ToListAsync(); var existingEntityBson = _updateLogHelper.EntityToBsonDocument(existingExpense); _context.Expenses.Remove(existingExpense); try { await _context.SaveChangesAsync(); _logger.LogInfo("Employeee {EmployeeId} successfully deleted the expense {EmpenseId}", loggedInEmployee.Id, id); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Databsae Exception occured while deleting expense"); return ApiResponse.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500); } try { var attachmentDeletionTask = Task.Run(async () => { await DeleteAttachemnts(documentIds); }); var cacheTask = Task.Run(async () => { await _cache.DeleteExpenseAsync(id, tenantId); }); var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = existingExpense.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntityBson, UpdatedAt = DateTime.UtcNow }, Collection); await Task.WhenAll(attachmentDeletionTask, cacheTask, mongoDBTask); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Databsae Exception occured while deleting attachments during updating expense"); return ApiResponse.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500); } catch (Exception ex) { _logger.LogError(ex, "Exception occured while deleting attachments during updating expense"); return ApiResponse.ErrorResponse("Exception occured while deleting attachments during updating expense ", ExceptionMapper(ex), 500); } return ApiResponse.SuccessResponse("Success", "Expense Deleted Successfully", 200); } #endregion #endregion #region =================================================================== Payment Request Functions =================================================================== public async Task> GetPaymentRequestListAsync(string? searchString, string? filter, bool isActive, int pageSize, int pageNumber, Employee loggedInEmployee, Guid tenantId) { _logger.LogInfo("Start GetPaymentRequestListAsync: TenantId={TenantId}, PageNumber={PageNumber}, PageSize={PageSize}, EmployeeId={EmployeeId}", tenantId, pageNumber, pageSize, loggedInEmployee.Id); try { var hasViewSelfPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployee.Id); }); var hasViewAllPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployee.Id); }); await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask); if (!hasViewAllPermissionTask.Result && !hasViewSelfPermissionTask.Result) { // User has neither required permission. Deny access. _logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get payment request list.", loggedInEmployee.Id); return ApiResponse.SuccessResponse(new List(), "You do not have permission to view any payment request.", 200); } // Initial query including the necessary navigation properties and basic multi-tenant/security constraints var paymentRequestQuery = _context.PaymentRequests .Include(pr => pr.Currency) .Include(pr => pr.Project) .Include(pr => pr.RecurringPayment) .Include(pr => pr.ExpenseCategory) .Include(pr => pr.ExpenseStatus) .Include(pr => pr.CreatedBy) .ThenInclude(e => e!.JobRole) .Where(pr => pr.TenantId == tenantId && pr.IsActive == isActive && pr.Currency != null && pr.ExpenseCategory != null && pr.ExpenseStatus != null && pr.CreatedBy != null && pr.CreatedBy.JobRole != null); if (hasViewSelfPermissionTask.Result) { paymentRequestQuery = paymentRequestQuery.Where(pr => pr.CreatedById == loggedInEmployee.Id); } // Deserialize and apply advanced filter if provided PaymentRequestFilter? paymentRequestFilter = TryDeserializePaymentRequestFilter(filter); if (paymentRequestFilter != null) { if (paymentRequestFilter.ProjectIds?.Any() ?? false) { paymentRequestQuery = paymentRequestQuery .Where(pr => pr.ProjectId.HasValue && paymentRequestFilter.ProjectIds.Contains(pr.ProjectId.Value)); } if (paymentRequestFilter.StatusIds?.Any() ?? false) { paymentRequestQuery = paymentRequestQuery .Where(pr => paymentRequestFilter.StatusIds.Contains(pr.ExpenseStatusId)); } if (paymentRequestFilter.CreatedByIds?.Any() ?? false) { paymentRequestQuery = paymentRequestQuery .Where(pr => paymentRequestFilter.CreatedByIds.Contains(pr.CreatedById)); } if (paymentRequestFilter.CurrencyIds?.Any() ?? false) { paymentRequestQuery = paymentRequestQuery .Where(pr => paymentRequestFilter.CurrencyIds.Contains(pr.CurrencyId)); } if (paymentRequestFilter.ExpenseCategoryIds?.Any() ?? false) { paymentRequestQuery = paymentRequestQuery .Where(pr => pr.ExpenseCategoryId.HasValue && paymentRequestFilter.ExpenseCategoryIds.Contains(pr.ExpenseCategoryId.Value)); } if (paymentRequestFilter.Payees?.Any() ?? false) { paymentRequestQuery = paymentRequestQuery .Where(pr => paymentRequestFilter.Payees.Contains(pr.Payee)); } if (paymentRequestFilter.StartDate.HasValue && paymentRequestFilter.EndDate.HasValue) { DateTime startDate = paymentRequestFilter.StartDate.Value.Date; DateTime endDate = paymentRequestFilter.EndDate.Value.Date; paymentRequestQuery = paymentRequestQuery .Where(pr => pr.CreatedAt.Date >= startDate && pr.CreatedAt.Date <= endDate); } } // Full-text search by payee, title, or UID if (!string.IsNullOrWhiteSpace(searchString)) { paymentRequestQuery = paymentRequestQuery .Where(pr => pr.Payee.Contains(searchString) || pr.Title.Contains(searchString) || (pr.UIDPrefix + "/" + pr.UIDPostfix.ToString().PadLeft(5, '0')).Contains(searchString) ); } // Get total count for pagination var totalEntities = await paymentRequestQuery.CountAsync(); // Fetch paginated result set var paymentRequests = await paymentRequestQuery .OrderByDescending(e => e.CreatedAt) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize); var results = paymentRequests.Select(pr => { var result = _mapper.Map(pr); result.PaymentRequestUID = $"{pr.UIDPrefix}/{pr.UIDPostfix:D5}"; return result; }).ToList(); var response = new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalEntities, Data = results, }; _logger.LogInfo("GetPaymentRequestListAsync: {ResultCount} payment requests fetched successfully for TenantId={TenantId} Page={PageNumber}/{TotalPages}", results.Count, tenantId, pageNumber, totalPages); return ApiResponse.SuccessResponse(response, $"{results.Count} payment requests fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Error occurred in GetPaymentRequestListAsync for TenantId={TenantId}, EmployeeId={EmployeeId}: {Message}", tenantId, loggedInEmployee.Id, ex.Message); return ApiResponse.ErrorResponse("An error occurred while fetching payment requests.", ex.Message, 500); } finally { _logger.LogInfo("End GetPaymentRequestListAsync for TenantId={TenantId}, EmployeeId={EmployeeId}", tenantId, loggedInEmployee.Id); } } public async Task> GetPaymentRequestDetailsAsync(Guid? id, string? paymentRequestUId, Employee loggedInEmployee, Guid tenantId) { _logger.LogInfo("Start GetPaymentRequestDetailsAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId} with Id: {Id}, UID: {UID}", loggedInEmployee.Id, tenantId, id ?? Guid.Empty, paymentRequestUId ?? "PY/1125/00000"); try { // Validate input: at least one identifier must be provided if (!id.HasValue && string.IsNullOrWhiteSpace(paymentRequestUId)) { _logger.LogWarning("Invalid parameters: Both Id and PaymentRequestUID are null or empty."); return ApiResponse.ErrorResponse("At least one parameter (Id or PaymentRequestUID) must be provided.", "Invalid argument.", 400); } // Check user permissions concurrently var hasViewSelfPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployee.Id); }); var hasViewAllPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployee.Id); }); var hasReviewPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseReview, loggedInEmployee.Id); }); var hasApprovePermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseApprove, loggedInEmployee.Id); }); var hasProcessPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(PermissionsMaster.ExpenseProcess, loggedInEmployee.Id); }); await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask, hasReviewPermissionTask, hasApprovePermissionTask, hasProcessPermissionTask); bool hasViewSelfPermission = hasViewSelfPermissionTask.Result; bool hasViewAllPermission = hasViewAllPermissionTask.Result; bool hasReviewPermission = hasReviewPermissionTask.Result; bool hasApprovePermission = hasApprovePermissionTask.Result; bool hasProcessPermission = hasProcessPermissionTask.Result; // Deny access if user has no relevant permissions if (!hasViewSelfPermission && !hasViewAllPermission && !hasReviewPermission && !hasApprovePermission && !hasProcessPermission) { _logger.LogWarning("Access DENIED: Employee {EmployeeId} has no permission to view payment requests.", loggedInEmployee.Id); return ApiResponse.SuccessResponse(new List(), "You do not have permission to view any payment request.", 200); } // Query payment request with all necessary navigation properties and validation constraints var paymentRequest = await _context.PaymentRequests .Include(pr => pr.Currency) .Include(pr => pr.Project) .Include(pr => pr.RecurringPayment) .Include(pr => pr.ExpenseCategory) .Include(pr => pr.ExpenseStatus) .Include(pr => pr.CreatedBy).ThenInclude(e => e!.JobRole) .Include(pr => pr.UpdatedBy).ThenInclude(e => e!.JobRole) .Where(pr => (pr.Id == id || (pr.UIDPrefix + "/" + pr.UIDPostfix.ToString().PadLeft(5, '0')) == paymentRequestUId) && pr.TenantId == tenantId && pr.Currency != null && pr.ExpenseCategory != null && pr.ExpenseStatus != null && pr.CreatedBy != null && pr.CreatedBy.JobRole != null) .FirstOrDefaultAsync(); if (paymentRequest == null) { _logger.LogWarning("Payment Request not found: Id={Id}, UID={UID}, TenantId={TenantId}", id ?? Guid.Empty, paymentRequestUId ?? "PY/1125/00000", tenantId); return ApiResponse.ErrorResponse("Payment Request not found.", "Payment Request not found.", 404); } // Check if employee has only "view self" permission but the payment request is created by another employee => deny bool selfCheck = hasViewSelfPermission && !hasViewAllPermission && !hasReviewPermission && !hasApprovePermission && !hasProcessPermission && paymentRequest.CreatedById != loggedInEmployee.Id; if (selfCheck) { _logger.LogWarning("Access DENIED: Employee {EmployeeId} lacks permission to view PaymentRequest {PaymentRequestId} created by another employee.", loggedInEmployee.Id, paymentRequest.Id); return ApiResponse.SuccessResponse(new List(), "You do not have permission to view this payment request.", 200); } // Concurrently fetch next possible statuses and related permissions var nextStatusTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); var nextStatuses = await context.ExpensesStatusMapping .Include(esm => esm.NextStatus) .Where(esm => esm.StatusId == paymentRequest.ExpenseStatusId && esm.NextStatus != null) .Select(esm => esm.NextStatus!) .ToListAsync(); var nextStatusIds = nextStatuses.Select(ns => ns.Id).ToList(); var permissionMappings = await context.StatusPermissionMapping.Where(spm => nextStatusIds.Contains(spm.StatusId)).ToListAsync(); var results = new List(); foreach (var status in nextStatuses) { var permissionIds = permissionMappings.Where(spm => spm.StatusId == status.Id).Select(spm => spm.PermissionId).ToList(); bool hasPermission = await permissionService.HasPermissionAny(permissionIds, loggedInEmployee.Id); // Special case: allow review status if creator is the logged-in user bool hasStatusPermission = Review == status.Id && loggedInEmployee.Id == paymentRequest.CreatedById; if (!hasPermission && !hasStatusPermission) { continue; } var mappedStatus = _mapper.Map(status); mappedStatus.PermissionIds = permissionIds; results.Add(mappedStatus); } return results; }); // Concurrently fetch attachments with pre-signed URLs var documentTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); var documents = await context.PaymentRequestAttachments .Include(pra => pra.Document) .Where(pra => pra.PaymentRequestId == paymentRequest.Id && pra.Document != null) .Select(pra => pra.Document!) .ToListAsync(); return documents.Select(d => { var attachmentVM = _mapper.Map(d); attachmentVM.Url = _s3Service.GeneratePreSignedUrl(d.S3Key); return attachmentVM; }).ToList(); }); await Task.WhenAll(nextStatusTask, documentTask); var nextStatuses = nextStatusTask.Result; var attachmentVMs = documentTask.Result; // Map main response model and populate additional fields var response = _mapper.Map(paymentRequest); response.PaymentRequestUID = $"{paymentRequest.UIDPrefix}/{paymentRequest.UIDPostfix:D5}"; response.Attachments = attachmentVMs; response.NextStatus = nextStatuses; _logger.LogInfo("Payment request details fetched successfully for PaymentRequestId: {PaymentRequestId}, EmployeeId: {EmployeeId}", paymentRequest.Id, loggedInEmployee.Id); return ApiResponse.SuccessResponse(response, "Payment request fetched successfully.", 200); } catch (Exception ex) { _logger.LogError(ex, "Error in GetPaymentRequestDetailsAsync for TenantId={TenantId}, EmployeeId={EmployeeId}: {Message}", tenantId, loggedInEmployee.Id, ex.Message); return ApiResponse.ErrorResponse("An error occurred while fetching the payment request details.", ex.Message, 500); } finally { _logger.LogInfo("End GetPaymentRequestDetailsAsync called by EmployeeId: {EmployeeId}", loggedInEmployee.Id); } } public async Task> GetPayeeNameListAsync(Employee loggedInEmployee, Guid tenantId) { try { var payeeList = await _context.PaymentRequests.Where(e => e.TenantId == tenantId).Select(e => e.Payee).Distinct().ToListAsync(); _logger.LogInfo("Employee {EmployeeId} fetched list of payees from payment request in a tenant {TenantId}", loggedInEmployee.Id, tenantId); return ApiResponse.SuccessResponse(payeeList, $"{payeeList.Count} records of payees fetched from payment request", 200); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Databsae Exception occured while fetching payee list from payment request"); return ApiResponse.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500); } } public async Task> GetPaymentRequestFilterObjectAsync(Employee loggedInEmployee, Guid tenantId) { try { var paymentRequests = await _context.PaymentRequests .Include(pr => pr.Currency) .Include(pr => pr.Project) .Include(pr => pr.ExpenseCategory) .Include(pr => pr.ExpenseStatus) .Include(pr => pr.CreatedBy) .Where(e => e.TenantId == tenantId) .ToListAsync(); // Construct the final object from the results of the completed tasks. var response = new { Projects = paymentRequests.Where(pr => pr.Project != null).Select(pr => new { Id = pr.Project!.Id, Name = pr.Project.Name }).Distinct().ToList(), Currency = paymentRequests.Where(pr => pr.Currency != null).Select(pr => new { Id = pr.Currency!.Id, Name = pr.Currency.CurrencyName }).Distinct().ToList(), CreatedBy = paymentRequests.Where(pr => pr.CreatedBy != null).Select(pr => new { Id = pr.CreatedBy!.Id, Name = $"{pr.CreatedBy.FirstName} {pr.CreatedBy.LastName}" }).Distinct().ToList(), Status = paymentRequests.Where(pr => pr.ExpenseStatus != null).Select(pr => new { Id = pr.ExpenseStatus!.Id, Name = pr.ExpenseStatus.Name }).Distinct().ToList(), ExpensesCategory = paymentRequests.Where(pr => pr.ExpenseCategory != null).Select(pr => new { Id = pr.ExpenseCategory!.Id, Name = pr.ExpenseCategory.Name }).Distinct().ToList(), Payees = paymentRequests.Where(pr => !string.IsNullOrWhiteSpace(pr.Payee)).Select(pr => new { Id = pr.Payee, Name = pr.Payee }).Distinct().ToList() }; return ApiResponse.SuccessResponse(response, "Successfully fetched the filter list", 200); } catch (Exception ex) { _logger.LogError(ex, "Exception occured while fetching the list filters for payment requests"); return ApiResponse.ErrorResponse("Internal Exception Occured", ExceptionMapper(ex), 500); } } public async Task> CreatePaymentRequestAsync(PaymentRequestDto model, Employee loggedInEmployee, Guid tenantId) { _logger.LogInfo("Start CreatePaymentRequestAsync for EmployeeId: {EmployeeId} TenantId: {TenantId}", loggedInEmployee.Id, tenantId); string uIDPrefix = $"PY/{DateTime.Now:MMyy}"; try { // Execute database lookups concurrently var expenseCategoryTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ExpenseCategoryMasters.FirstOrDefaultAsync(et => et.Id == model.ExpenseCategoryId && et.IsActive); }); var currencyTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.CurrencyMaster.FirstOrDefaultAsync(c => c.Id == model.CurrencyId); }); var expenseStatusTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ExpensesStatusMaster.FirstOrDefaultAsync(es => es.Id == Draft && es.IsActive); }); var projectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Projects.FirstOrDefaultAsync(P => model.ProjectId.HasValue && P.Id == model.ProjectId.Value); }); await Task.WhenAll(expenseCategoryTask, currencyTask, expenseStatusTask, projectTask); var expenseCategory = await expenseCategoryTask; if (expenseCategory == null) { _logger.LogWarning("Expense Category not found with Id: {ExpenseCategoryId}", model.ExpenseCategoryId); return ApiResponse.ErrorResponse("Expense Category not found.", "Expense Category not found.", 404); } var currency = await currencyTask; if (currency == null) { _logger.LogWarning("Currency not found with Id: {CurrencyId}", model.CurrencyId); return ApiResponse.ErrorResponse("Currency not found.", "Currency not found.", 404); } var expenseStatus = await expenseStatusTask; if (expenseStatus == null) { _logger.LogWarning("Expense Status not found for Draft."); return ApiResponse.ErrorResponse("Expense Status (Draft) not found.", "Expense Status not found.", 404); } var project = await projectTask; // Project is optional so no error if not found // Generate unique UID postfix based on existing requests for the current prefix var lastPR = await _context.PaymentRequests.Where(pr => pr.UIDPrefix == uIDPrefix) .OrderByDescending(pr => pr.UIDPostfix) .FirstOrDefaultAsync(); int uIDPostfix = lastPR == null ? 1 : (lastPR.UIDPostfix + 1); // Map DTO to PaymentRequest entity and set additional mandatory fields var paymentRequest = _mapper.Map(model); paymentRequest.ExpenseStatusId = Draft; paymentRequest.UIDPrefix = uIDPrefix; paymentRequest.UIDPostfix = uIDPostfix; paymentRequest.IsActive = true; paymentRequest.CreatedAt = DateTime.UtcNow; paymentRequest.CreatedById = loggedInEmployee.Id; paymentRequest.TenantId = tenantId; await _context.PaymentRequests.AddAsync(paymentRequest); await _context.SaveChangesAsync(); // Process bill attachments if any if (model.BillAttachments?.Any() == true) { _logger.LogInfo("Processing {AttachmentCount} attachments for PaymentRequest Id: {PaymentRequestId}", model.BillAttachments.Count, paymentRequest.Id); // Validate base64 attachments data before processing foreach (var attachment in model.BillAttachments) { if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data)) { _logger.LogWarning("Invalid or missing Base64 data for attachment: {FileName}", attachment.FileName ?? "N/A"); throw new ArgumentException($"Invalid or missing Base64 data for attachment: {attachment.FileName ?? "N/A"}"); } } var batchId = Guid.NewGuid(); // Process all attachments concurrently var processingTasks = model.BillAttachments.Select(attachment => ProcessSinglePaymentRequestAttachmentAsync(attachment, paymentRequest, loggedInEmployee.Id, tenantId, batchId) ).ToList(); var results = await Task.WhenAll(processingTasks); // Add documents and payment request attachments after concurrent processing foreach (var (document, paymentRequestAttachment) in results) { _context.Documents.Add(document); _context.PaymentRequestAttachments.Add(paymentRequestAttachment); } await _context.SaveChangesAsync(); _logger.LogInfo("{AttachmentCount} attachments processed and saved for PaymentRequest Id: {PaymentRequestId}", results.Length, paymentRequest.Id); } // Prepare response VM var response = _mapper.Map(paymentRequest); response.PaymentRequestUID = $"{paymentRequest.UIDPrefix}/{paymentRequest.UIDPostfix:D5}"; response.Currency = currency; response.ExpenseCategory = _mapper.Map(expenseCategory); response.ExpenseStatus = _mapper.Map(expenseStatus); response.Project = _mapper.Map(project); response.CreatedBy = _mapper.Map(loggedInEmployee); _logger.LogInfo("Payment request created successfully with UID: {PaymentRequestUID}", response.PaymentRequestUID); return ApiResponse.SuccessResponse(response, "Created the Payment Request Successfully.", 201); } catch (ArgumentException ex) { _logger.LogError(ex, "Argument error in CreatePaymentRequestAsync: {Message}", ex.Message); return ApiResponse.ErrorResponse(ex.Message, "Invalid data.", 400); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error in CreatePaymentRequestAsync: {Message}", ex.Message); return ApiResponse.ErrorResponse("An error occurred while creating the payment request.", ex.Message, 500); } finally { _logger.LogInfo("End CreatePaymentRequestAsync for EmployeeId: {EmployeeId}", loggedInEmployee.Id); } } public async Task> EditPaymentRequestAsync(Guid id, PaymentRequestDto model, Employee loggedInEmployee, Guid tenantId) { _logger.LogInfo("Start EditPaymentRequestAsync for PaymentRequestId: {PaymentRequestId}, EmployeeId: {EmployeeId}", id, loggedInEmployee.Id); if (model.Id == null || id != model.Id) { _logger.LogWarning("Mismatch between URL id and payload id: {Id} vs {ModelId}", id, model?.Id ?? Guid.Empty); return ApiResponse.ErrorResponse("Invalid argument: ID mismatch.", "Invalid argument: ID mismatch.", 400); } try { // Concurrently fetch related entities to validate input references var expenseCategoryTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ExpenseCategoryMasters.FirstOrDefaultAsync(et => et.Id == model.ExpenseCategoryId && et.IsActive); }); var currencyTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.CurrencyMaster.FirstOrDefaultAsync(c => c.Id == model.CurrencyId); }); var projectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return model.ProjectId.HasValue ? await context.Projects.FirstOrDefaultAsync(p => p.Id == model.ProjectId.Value) : null; }); await Task.WhenAll(expenseCategoryTask, currencyTask, projectTask); var expenseCategory = await expenseCategoryTask; if (expenseCategory == null) { _logger.LogWarning("Expense Category not found with Id: {ExpenseCategoryId}", model.ExpenseCategoryId); return ApiResponse.ErrorResponse("Expense Category not found.", "Expense Category not found.", 404); } var currency = await currencyTask; if (currency == null) { _logger.LogWarning("Currency not found with Id: {CurrencyId}", model.CurrencyId); return ApiResponse.ErrorResponse("Currency not found.", "Currency not found.", 404); } var project = await projectTask; // Project can be null (optional) // Retrieve the existing payment request with relevant navigation properties for validation and mapping var paymentRequest = await _context.PaymentRequests .Include(pr => pr.ExpenseCategory) .Include(pr => pr.Project) .Include(pr => pr.ExpenseStatus) .Include(pr => pr.RecurringPayment) .Include(pr => pr.Currency) .Include(pr => pr.CreatedBy).ThenInclude(e => e!.JobRole) .Include(pr => pr.UpdatedBy).ThenInclude(e => e!.JobRole) .FirstOrDefaultAsync(pr => pr.Id == id); if (paymentRequest == null) { _logger.LogWarning("Payment Request not found with Id: {PaymentRequestId}", id); return ApiResponse.ErrorResponse("Payment Request not found.", "Payment Request not found.", 404); } // Check if status prevents editing (only allow edit if status Draft, RejectedByReviewer, or RejectedByApprover) bool statusCheck = paymentRequest.ExpenseStatusId != Draft && paymentRequest.ExpenseStatusId != RejectedByReviewer && paymentRequest.ExpenseStatusId != RejectedByApprover; bool isVariableRecurring = paymentRequest.RecurringPayment?.IsVariable ?? false; // Capture existing state for auditing var existingEntityBson = _updateLogHelper.EntityToBsonDocument(paymentRequest); // Only map updates if allowed by status if (!statusCheck && paymentRequest.CreatedById == loggedInEmployee.Id) { _mapper.Map(model, paymentRequest); paymentRequest.UpdatedAt = DateTime.UtcNow; paymentRequest.UpdatedById = loggedInEmployee.Id; } if (isVariableRecurring) { paymentRequest.Amount = model.Amount; } paymentRequest.IsAdvancePayment = model.IsAdvancePayment; // Handle bill attachment updates: add new attachments and delete deactivated ones if (model.BillAttachments?.Any() == true) { var newBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId == null && ba.IsActive).ToList(); if (newBillAttachments.Any()) { _logger.LogInfo("Processing {AttachmentCount} new attachments for PaymentRequest Id: {PaymentRequestId}", newBillAttachments.Count, paymentRequest.Id); // Pre-validate base64 data before upload foreach (var attachment in newBillAttachments) { if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data)) { _logger.LogWarning("Invalid or missing Base64 data for attachment: {FileName}", attachment.FileName ?? "N/A"); throw new ArgumentException($"Invalid or missing Base64 data for attachment: {attachment.FileName ?? "N/A"}"); } } var batchId = Guid.NewGuid(); var processingTasks = newBillAttachments.Select(attachment => ProcessSinglePaymentRequestAttachmentAsync(attachment, paymentRequest, loggedInEmployee.Id, tenantId, batchId)).ToList(); var results = await Task.WhenAll(processingTasks); foreach (var (document, paymentRequestAttachment) in results) { _context.Documents.Add(document); _context.PaymentRequestAttachments.Add(paymentRequestAttachment); } try { await _context.SaveChangesAsync(); _logger.LogInfo("Added {Count} new attachments for PaymentRequest {PaymentRequestId} by Employee {EmployeeId}", newBillAttachments.Count, paymentRequest.Id, loggedInEmployee.Id); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Database Exception while adding attachments during PaymentRequest update"); return ApiResponse.ErrorResponse("Database exception during attachment addition.", ExceptionMapper(dbEx), 500); } } var deleteBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId != null && !ba.IsActive).ToList(); if (deleteBillAttachments.Any()) { var documentIds = deleteBillAttachments.Select(d => d.DocumentId!.Value).ToList(); try { await DeletePaymentRequestAttachemnts(documentIds); _logger.LogInfo("Deleted {Count} attachments for PaymentRequest {PaymentRequestId} by Employee {EmployeeId}", deleteBillAttachments.Count, paymentRequest.Id, loggedInEmployee.Id); } catch (DbUpdateException dbEx) { _logger.LogError(dbEx, "Database Exception while deleting attachments during PaymentRequest update"); return ApiResponse.ErrorResponse("Database exception during attachment deletion.", ExceptionMapper(dbEx), 500); } catch (Exception ex) { _logger.LogError(ex, "Unexpected exception while deleting attachments during PaymentRequest update"); return ApiResponse.ErrorResponse("Error occurred while deleting attachments.", ExceptionMapper(ex), 500); } } } // Log the update audit trail await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = paymentRequest.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = existingEntityBson, UpdatedAt = DateTime.UtcNow }, "PaymentRequestModificationLog"); // Prepare response view model with updated details var response = _mapper.Map(paymentRequest); response.PaymentRequestUID = $"{paymentRequest.UIDPrefix}/{paymentRequest.UIDPostfix:D5}"; response.Currency = currency; response.ExpenseCategory = _mapper.Map(expenseCategory); response.Project = _mapper.Map(project); _logger.LogInfo("PaymentRequest updated successfully with UID: {PaymentRequestUID}", response.PaymentRequestUID); return ApiResponse.SuccessResponse(response, "Payment Request updated successfully.", 200); } catch (ArgumentException ex) { _logger.LogError(ex, "Argument error in EditPaymentRequestAsync: {Message}", ex.Message); return ApiResponse.ErrorResponse(ex.Message, "Invalid data.", 400); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error in EditPaymentRequestAsync: {Message}", ex.Message); return ApiResponse.ErrorResponse("An error occurred while updating the payment request.", ex.Message, 500); } finally { _logger.LogInfo("End EditPaymentRequestAsync for PaymentRequestId: {PaymentRequestId}", id); } } #endregion #region =================================================================== Advance Payment Functions =================================================================== public async Task> GetAdvancePaymentTransactionAsync(Guid employeeId) { try { var transactions = await _context.AdvancePaymentTransactions .Include(t => t.Employee) .Include(t => t.CreatedBy) .Where(t => t.EmployeeId == employeeId && t.IsActive) .OrderByDescending(t => t.CreatedAt) .ToListAsync(); if (transactions == null || !transactions.Any()) return ApiResponse.ErrorResponse("No advance payment transactions found.", null, 404); var response = transactions.Select(transaction => { var result = _mapper.Map(transaction); result.FinanceUId = $"{transaction.FinanceUIdPrefix}/{transaction.FinanceUIdPostfix:D5}"; return result; }).ToList(); return ApiResponse.SuccessResponse(response, "Advance payment transaction fetched successfully", 200); } catch (Exception ex) { _logger.LogError(ex, "Exception occurred while fetching the list of Advance Payment Transactions."); return ApiResponse.ErrorResponse("Internal exception occurred.", ExceptionMapper(ex), 500); } } #endregion #region =================================================================== Recurring Payment Functions =================================================================== #endregion #region =================================================================== Helper Functions =================================================================== private int ExtractNumber(string id) { // Extract trailing number; handles EX_0001, EX-0001, EX0001 var m = Regex.Match(id ?? string.Empty, @"(\d+)$"); return m.Success ? int.Parse(m.Value) : int.MinValue; // put invalid IDs at the bottom } private static object ExceptionMapper(Exception ex) { return new { Message = ex.Message, StackTrace = ex.StackTrace, Source = ex.Source, InnerException = new { Message = ex.InnerException?.Message, StackTrace = ex.InnerException?.StackTrace, Source = ex.InnerException?.Source, } }; } private async Task> GetAllExpnesRelatedTables(List model, Guid tenantId) { List expenseList = new List(); var projectIds = model.Select(m => m.ProjectId).ToList(); var statusIds = model.Select(m => m.StatusId).ToList(); var expenseCategoryIds = model.Select(m => m.ExpenseCategoryId).ToList(); var paymentModeIds = model.Select(m => m.PaymentModeId).ToList(); var createdByIds = model.Select(m => m.CreatedById).ToList(); var paidByIds = model.Select(m => m.PaidById).ToList(); var projectTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Projects.AsNoTracking().Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).ToListAsync(); }); var paidByTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Employees.AsNoTracking().Where(e => paidByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync(); }); var createdByTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.Employees.AsNoTracking().Where(e => createdByIds.Contains(e.Id) && e.TenantId == tenantId).ToListAsync(); }); var expenseCategoriesTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpenseCategoryMasters.AsNoTracking().Where(ec => expenseCategoryIds.Contains(ec.Id) && ec.TenantId == tenantId).ToListAsync(); }); var paymentModeTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.PaymentModeMatser.AsNoTracking().Where(pm => paymentModeIds.Contains(pm.Id)).ToListAsync(); }); var statusMappingTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMapping .Include(s => s.Status) .Include(s => s.NextStatus) .AsNoTracking() .Where(es => statusIds.Contains(es.StatusId) && es.Status != null) .GroupBy(s => s.StatusId) .Select(g => new { StatusId = g.Key, Status = g.Select(s => s.Status).FirstOrDefault(), NextStatus = g.Select(s => s.NextStatus).OrderBy(s => s!.Name).ToList() }).ToListAsync(); }); var statusTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMaster .AsNoTracking() .Where(es => statusIds.Contains(es.Id)) .ToListAsync(); }); var permissionStatusMappingTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.StatusPermissionMapping .GroupBy(ps => ps.StatusId) .Select(g => new { StatusId = g.Key, PermissionIds = g.Select(ps => ps.PermissionId).ToList() }).ToListAsync(); }); // Await all prerequisite checks at once. await Task.WhenAll(projectTask, expenseCategoriesTask, paymentModeTask, statusMappingTask, paidByTask, createdByTask, statusTask, permissionStatusMappingTask); var projects = projectTask.Result; var expenseCategories = expenseCategoriesTask.Result; var paymentModes = paymentModeTask.Result; var statusMappings = statusMappingTask.Result; var permissionStatusMappings = permissionStatusMappingTask.Result; var paidBys = paidByTask.Result; var createdBys = createdByTask.Result; expenseList = model.Select(m => { var response = _mapper.Map(m); response.Project = projects.Where(p => p.Id == m.ProjectId).Select(p => _mapper.Map(p)).FirstOrDefault(); response.PaidBy = paidBys.Where(p => p.Id == m.PaidById).Select(p => _mapper.Map(p)).FirstOrDefault(); response.CreatedBy = createdBys.Where(e => e.Id == m.CreatedById).Select(e => _mapper.Map(e)).FirstOrDefault(); response.Status = statusMappings.Where(s => s.StatusId == m.StatusId).Select(s => _mapper.Map(s.Status)).FirstOrDefault(); if (response.Status == null) { var status = statusTask.Result; response.Status = status.Where(s => s.Id == m.StatusId).Select(s => _mapper.Map(s)).FirstOrDefault(); } if (response.Status != null) { response.Status.PermissionIds = permissionStatusMappings.Where(ps => ps.StatusId == m.StatusId).Select(ps => ps.PermissionIds).FirstOrDefault(); } response.NextStatus = statusMappings.Where(s => s.StatusId == m.StatusId).Select(s => _mapper.Map>(s.NextStatus)).FirstOrDefault(); if (response.NextStatus != null) { foreach (var status in response.NextStatus) { status.PermissionIds = permissionStatusMappings.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault(); } } response.PaymentMode = paymentModes.Where(pm => pm.Id == m.PaymentModeId).Select(pm => _mapper.Map(pm)).FirstOrDefault(); response.ExpensesCategory = expenseCategories.Where(ec => ec.Id == m.ExpenseCategoryId).Select(ec => _mapper.Map(ec)).FirstOrDefault(); return response; }).ToList(); return expenseList; } private async Task GetAllExpnesRelatedTablesForSingle(Expenses model, Guid tenantId) { var statusMappingTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMapping .Include(s => s.Status) .Include(s => s.NextStatus) .AsNoTracking() .Where(es => es.StatusId == model.StatusId && es.Status != null) .GroupBy(s => s.StatusId) .Select(g => new { StatusId = g.Key, Status = g.Select(s => s.Status).FirstOrDefault(), NextStatus = g.Select(s => s.NextStatus).OrderBy(s => s!.Name).ToList() }).FirstOrDefaultAsync(); }); var statusTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.ExpensesStatusMaster .AsNoTracking() .FirstOrDefaultAsync(es => es.Id == model.StatusId); }); var billAttachmentsTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); return await dbContext.BillAttachments .Include(ba => ba.Document) .AsNoTracking() .Where(ba => ba.ExpensesId == model.Id && ba.Document != null) .GroupBy(ba => ba.ExpensesId) .Select(g => new { ExpensesId = g.Key, Documents = g.Select(ba => new DocumentMongoDB { DocumentId = ba.Document!.Id.ToString(), FileName = ba.Document.FileName, ContentType = ba.Document.ContentType, S3Key = ba.Document.S3Key, ThumbS3Key = ba.Document.ThumbS3Key ?? ba.Document.S3Key }).ToList() }) .FirstOrDefaultAsync(); }); // Await all prerequisite checks at once. await Task.WhenAll(statusTask, billAttachmentsTask); var statusMapping = statusMappingTask.Result; var billAttachment = billAttachmentsTask.Result; var response = _mapper.Map(model); response.Project = _mapper.Map(model.Project); response.PaidBy = _mapper.Map(model.PaidBy); response.CreatedBy = _mapper.Map(model.CreatedBy); response.ReviewedBy = _mapper.Map(model.ReviewedBy); response.ApprovedBy = _mapper.Map(model.ApprovedBy); response.ProcessedBy = _mapper.Map(model.ProcessedBy); if (statusMapping != null) { response.Status = _mapper.Map(statusMapping.Status); response.NextStatus = _mapper.Map>(statusMapping.NextStatus); } if (response.Status == null) { var status = statusTask.Result; response.Status = _mapper.Map(status); } response.PaymentMode = _mapper.Map(model.PaymentMode); response.ExpenseCategory = _mapper.Map(model.ExpenseCategory); if (billAttachment != null) response.Documents = billAttachment.Documents; return response; } 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; } private PaymentRequestFilter? TryDeserializePaymentRequestFilter(string? filter) { if (string.IsNullOrWhiteSpace(filter)) { return null; } var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; PaymentRequestFilter? 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; } /// /// Processes and uploads attachments concurrently, then adds the resulting entities to the main DbContext. /// private async Task ProcessAndUploadAttachmentsAsync(IEnumerable attachments, Expenses expense, Guid employeeId, Guid tenantId) { // Pre-validate all attachments to fail fast before any uploads. foreach (var attachment in attachments) { if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data)) { throw new ArgumentException($"Invalid or missing Base64 data for attachment: {attachment.FileName ?? "N/A"}"); } } var batchId = Guid.NewGuid(); // Create a list of tasks to be executed concurrently. var processingTasks = attachments.Select(attachment => ProcessSingleExpenseAttachmentAsync(attachment, expense, employeeId, tenantId, batchId) ).ToList(); var results = await Task.WhenAll(processingTasks); // This part is thread-safe as it runs after all concurrent 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); } /// /// Handles the logic for a single attachment: upload to S3 and create corresponding entities. /// private async Task<(Document document, BillAttachments billAttachment)> ProcessSingleExpenseAttachmentAsync( FileUploadModel attachment, Expenses expense, Guid employeeId, Guid tenantId, Guid batchId) { 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}"; // Await the I/O-bound upload operation directly. await _s3Service.UploadFileAsync(base64Data, fileType, objectKey); _logger.LogInfo("Uploaded file to S3 with key: {ObjectKey}", objectKey); return CreateExpenseAttachmentEntities(batchId, expense.Id, employeeId, tenantId, objectKey, attachment); } /// /// A private static helper method to create Document and BillAttachment entities. /// This remains unchanged as it's a pure data-shaping method. /// private static (Document document, BillAttachments billAttachment) CreateExpenseAttachmentEntities( 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); } /// /// Handles the logic for a single attachment: upload to S3 and create corresponding entities. /// private async Task<(Document document, PaymentRequestAttachment billAttachment)> ProcessSinglePaymentRequestAttachmentAsync( FileUploadModel attachment, PaymentRequest paymentRequest, Guid employeeId, Guid tenantId, Guid batchId) { var base64Data = attachment.Base64Data!.Contains(',') ? attachment.Base64Data[(attachment.Base64Data.IndexOf(",") + 1)..] : attachment.Base64Data; var fileType = _s3Service.GetContentTypeFromBase64(base64Data); var fileName = _s3Service.GenerateFileName(fileType, paymentRequest.Id, "PaymentRequest"); string objectKey; if (paymentRequest.ProjectId.HasValue) { objectKey = $"tenant-{tenantId}/project-{paymentRequest.ProjectId}/PaymentRequest/{fileName}"; } else { objectKey = $"tenant-{tenantId}/PaymentRequest/{fileName}"; } // Await the I/O-bound upload operation directly. await _s3Service.UploadFileAsync(base64Data, fileType, objectKey); _logger.LogInfo("Uploaded file to S3 with key: {ObjectKey}", objectKey); return CreatePaymentRequestAttachmentEntities(batchId, paymentRequest.Id, employeeId, tenantId, objectKey, attachment); } /// /// A private static helper method to create Document and BillAttachment entities. /// This remains unchanged as it's a pure data-shaping method. /// private static (Document document, PaymentRequestAttachment paymentRequestAttachment) CreatePaymentRequestAttachmentEntities( Guid batchId, Guid paymentRequestId, 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 paymentRequestAttachment = new PaymentRequestAttachment { Document = document, PaymentRequestId = paymentRequestId, TenantId = tenantId }; return (document, paymentRequestAttachment); } private async Task DeleteAttachemnts(List documentIds) { var attachmentTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var attachments = await dbContext.BillAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync(); dbContext.BillAttachments.RemoveRange(attachments); await dbContext.SaveChangesAsync(); }); var documentsTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var documents = await dbContext.Documents.AsNoTracking().Where(ba => documentIds.Contains(ba.Id)).ToListAsync(); if (documents.Any()) { dbContext.Documents.RemoveRange(documents); await dbContext.SaveChangesAsync(); List deletionObject = new List(); foreach (var document in documents) { deletionObject.Add(new S3DeletionObject { Key = document.S3Key }); if (!string.IsNullOrWhiteSpace(document.ThumbS3Key) && document.ThumbS3Key != document.S3Key) { deletionObject.Add(new S3DeletionObject { Key = document.ThumbS3Key }); } } await _updateLogHelper.PushToS3DeletionAsync(deletionObject); } }); await Task.WhenAll(attachmentTask, documentsTask); } private async Task DeletePaymentRequestAttachemnts(List documentIds) { var attachmentTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var attachments = await dbContext.PaymentRequestAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync(); dbContext.PaymentRequestAttachments.RemoveRange(attachments); await dbContext.SaveChangesAsync(); }); var documentsTask = Task.Run(async () => { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); var documents = await dbContext.Documents.AsNoTracking().Where(ba => documentIds.Contains(ba.Id)).ToListAsync(); if (documents.Any()) { dbContext.Documents.RemoveRange(documents); await dbContext.SaveChangesAsync(); List deletionObject = new List(); foreach (var document in documents) { deletionObject.Add(new S3DeletionObject { Key = document.S3Key }); if (!string.IsNullOrWhiteSpace(document.ThumbS3Key) && document.ThumbS3Key != document.S3Key) { deletionObject.Add(new S3DeletionObject { Key = document.ThumbS3Key }); } } await _updateLogHelper.PushToS3DeletionAsync(deletionObject); } }); await Task.WhenAll(attachmentTask, documentsTask); } #endregion } }