1466 lines
76 KiB
C#

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.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<ApplicationDbContext> _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<ApplicationDbContext> 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 =================================================================== Get Functions ===================================================================
/// <summary>
/// Retrieves a paginated list of expenses based on user permissions and optional filters.
/// </summary>
/// <param name="filter">A URL-encoded JSON string containing filter criteria. See <see cref="ExpensesFilter"/>.</param>
/// <param name="pageSize">The number of records to return per page.</param>
/// <param name="pageNumber">The page number to retrieve.</param>
/// <returns>A paginated list of expenses.</returns>
public async Task<ApiResponse<object>> 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<object>.ErrorResponse("User not found or not authenticated.", 403);
}
Guid loggedInEmployeeId = loggedInEmployee.Id;
List<ExpenseList> expenseVM = new List<ExpenseList>();
var totalEntites = 0;
var hasViewSelfPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId);
});
var hasViewAllPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
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<object>.SuccessResponse(new List<ExpenseList>(), "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<ExpenseDetailsMongoDB>? 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.ExpensesType)
.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.ExpenseTypeIds?.Any() == true)
{
expensesQuery = expensesQuery.Where(e => expenseFilter.ExpenseTypeIds.Contains(e.ExpensesTypeId));
}
// 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<object>.SuccessResponse(new List<ExpenseList>(), "No expenses found for the given criteria.", 200);
}
//expenseVM = await GetAllExpnesRelatedTables(expensesList, tenantId);
expenseVM = _mapper.Map<List<ExpenseList>>(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<ExpenseList>(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<object>.SuccessResponse(response, message, 200);
}
catch (DbUpdateException dbEx)
{
_logger.LogError(dbEx, "Databsae Exception occured while fetching list expenses");
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occured while fetching list expenses");
return ApiResponse<object>.ErrorResponse("Error Occured", ExceptionMapper(ex), 500);
}
}
public async Task<ApiResponse<object>> GetExpenseDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
{
try
{
//ExpenseDetailsMongoDB? expenseDetails = await _cache.GetExpenseDetailsById(id, tenantId);
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.ExpensesType)
.Include(e => e.Status)
.AsNoTracking().FirstOrDefaultAsync(e => e.Id == id && e.TenantId == tenantId);
if (expense == null)
{
_logger.LogWarning("User attempted to fetch expense details with ID {ExpenseId}, but not found in both database and cache", id);
return ApiResponse<object>.ErrorResponse("Expense Not Found", "Expense Not Found", 404);
}
expenseDetails = await GetAllExpnesRelatedTablesForSingle(expense, expense.TenantId);
}
var vm = _mapper.Map<ExpenseDetailsVM>(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<ExpensesReimburseVM>(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<ExpenseLogVM>(el)).ToListAsync();
vm.ExpenseLogs = expenselogs;
_logger.LogInfo("Employee {EmployeeId} successfully fetched expense details with ID {ExpenseId}", loggedInEmployee.Id, vm.Id);
return ApiResponse<object>.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}.", id);
return ApiResponse<object>.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500);
}
}
public async Task<ApiResponse<object>> 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<object>.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<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
}
}
public async Task<ApiResponse<object>> 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.ExpensesType)
.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 }).ToList(),
PaidBy = expenses.Where(e => e.PaidBy != null).Select(e => new { Id = e.PaidBy!.Id, Name = $"{e.PaidBy.FirstName} {e.PaidBy.LastName}" }).ToList(),
CreatedBy = expenses.Where(e => e.CreatedBy != null).Select(e => new { Id = e.CreatedBy!.Id, Name = $"{e.CreatedBy.FirstName} {e.CreatedBy.LastName}" }).ToList(),
Status = expenses.Where(e => e.Status != null).Select(e => new { Id = e.Status!.Id, Name = e.Status.Name }).ToList(),
ExpensesType = expenses.Where(e => e.ExpensesType != null).Select(e => new { Id = e.ExpensesType!.Id, Name = e.ExpensesType.Name }).ToList()
};
return ApiResponse<object>.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<object>.ErrorResponse("Internal Exception Occured", ExceptionMapper(ex), 500);
}
}
#endregion
#region =================================================================== Post Functions ===================================================================
/// <summary>
/// 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.
/// </summary>
/// <param name="dto">The data transfer object containing expense details and attachments.</param>
/// <returns>An IActionResult indicating the result of the creation operation.</returns>
public async Task<ApiResponse<object>> 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<PermissionServices>();
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 expenseTypeTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ExpensesTypeMaster.AsNoTracking().FirstOrDefaultAsync(et => et.Id == dto.ExpensesTypeId);
});
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, expenseTypeTask, 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<object>.ErrorResponse("Access Denied.", "You do not have permission to upload expenses for this project.", 403);
}
var validationErrors = new List<string>();
var project = await projectTask;
var expenseType = await expenseTypeTask;
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 (expenseType == null) validationErrors.Add("Expense Type not found.");
if (paymentMode == null) validationErrors.Add("Payment Mode not found.");
if (statusMapping == null) validationErrors.Add("Default status 'Draft' not found.");
if ((expenseType?.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<object>.ErrorResponse("Invalid input data.", errorMessage, 400);
}
var currentexpenseUId = (lastExpenseUId + 1).ToString("D5");
// 3. Entity Creation
var expense = _mapper.Map<Expenses>(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<ExpenseList>(expense);
response.PaidBy = _mapper.Map<BasicEmployeeVM>(paidBy);
response.Project = _mapper.Map<ProjectInfoVM>(project);
response.Status = _mapper.Map<ExpensesStatusMasterVM>(statusMapping!.Status);
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(statusMapping.NextStatus);
response.PaymentMode = _mapper.Map<PaymentModeMatserVM>(paymentMode);
response.ExpensesType = _mapper.Map<ExpensesTypeMasterVM>(expenseType);
_logger.LogInfo("Successfully created Expense {ExpenseId} for Project {ProjectId}.", expense.Id, expense.ProjectId);
return ApiResponse<object>.SuccessResponse(response, "Expense created successfully.", 201);
}
catch (DbUpdateException dbEx)
{
await transaction.RollbackAsync();
_logger.LogError(dbEx, "Databsae Exception occured while adding expense");
return ApiResponse<object>.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<object>.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<object>.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500);
}
}
/// <summary>
/// Changes the status of an expense record, performing validation, permission checks, and logging.
/// </summary>
/// <param name="model">The DTO containing the expense ID and the target status ID.</param>
/// <param name="loggedInEmployee">The employee performing the action.</param>
/// <param name="tenantId">The ID of the tenant owning the expense.</param>
/// <returns>An ApiResponse containing the updated expense details or an error.</returns>
public async Task<ApiResponse<object>> ChangeStatusAsync(ExpenseRecordDto model, Employee loggedInEmployee, Guid tenantId)
{
using var scope = _serviceScopeFactory.CreateScope();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
// 1. Fetch Existing Expense with Related Entities (Single Query)
var expense = await _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)
.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<object>.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<object>.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<object>.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<PermissionServices>();
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<object>.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<object>.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<ExpenseList>(expense);
if (nextPossibleStatuses is { Count: > 0 })
responseDto.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(nextPossibleStatuses);
return ApiResponse<object>.SuccessResponse(responseDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "ChangeStatus: Post-operation error (e.g. audit logging). ExpenseId={ExpenseId}", expense.Id);
var responseDto = _mapper.Map<ExpenseList>(expense);
return ApiResponse<object>.SuccessResponse(responseDto, "Status updated, but audit logging or cache update failed.");
}
}
#endregion
#region =================================================================== Put Functions ===================================================================
public async Task<ApiResponse<object>> 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<object>.ErrorResponse("Invalid Parameters", "Invalid Parameters", 400);
}
var existingExpense = await _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)
.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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occured while deleting attachments during updating expense");
return ApiResponse<object>.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<ExpenseList>(existingExpense);
if (nextPossibleStatuses != null)
{
// The response DTO should have a property like: public List<ExpensesStatusMasterVM> NextAvailableStatuses { get; set; }
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(nextPossibleStatuses);
}
return ApiResponse<object>.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<ExpenseList>(existingExpense);
return ApiResponse<object>.SuccessResponse(response, "Status updated, but a post-processing error occurred.");
}
}
#endregion
#region =================================================================== Delete Functions ===================================================================
public async Task<ApiResponse<object>> 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<PermissionServices>();
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<object>.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<object>.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<object>.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<object>.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<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occured while deleting attachments during updating expense");
return ApiResponse<object>.ErrorResponse("Exception occured while deleting attachments during updating expense ", ExceptionMapper(ex), 500);
}
return ApiResponse<object>.SuccessResponse("Success", "Expense Deleted Successfully", 200);
}
#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<List<ExpenseList>> GetAllExpnesRelatedTables(List<Expenses> model, Guid tenantId)
{
List<ExpenseList> expenseList = new List<ExpenseList>();
var projectIds = model.Select(m => m.ProjectId).ToList();
var statusIds = model.Select(m => m.StatusId).ToList();
var expensesTypeIds = model.Select(m => m.ExpensesTypeId).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 expenseTypeTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ExpensesTypeMaster.AsNoTracking().Where(et => expensesTypeIds.Contains(et.Id) && et.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) && pm.TenantId == tenantId).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, expenseTypeTask, paymentModeTask, statusMappingTask, paidByTask, createdByTask, statusTask, permissionStatusMappingTask);
var projects = projectTask.Result;
var expenseTypes = expenseTypeTask.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<ExpenseList>(m);
response.Project = projects.Where(p => p.Id == m.ProjectId).Select(p => _mapper.Map<ProjectInfoVM>(p)).FirstOrDefault();
response.PaidBy = paidBys.Where(p => p.Id == m.PaidById).Select(p => _mapper.Map<BasicEmployeeVM>(p)).FirstOrDefault();
response.CreatedBy = createdBys.Where(e => e.Id == m.CreatedById).Select(e => _mapper.Map<BasicEmployeeVM>(e)).FirstOrDefault();
response.Status = statusMappings.Where(s => s.StatusId == m.StatusId).Select(s => _mapper.Map<ExpensesStatusMasterVM>(s.Status)).FirstOrDefault();
if (response.Status == null)
{
var status = statusTask.Result;
response.Status = status.Where(s => s.Id == m.StatusId).Select(s => _mapper.Map<ExpensesStatusMasterVM>(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<List<ExpensesStatusMasterVM>>(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<PaymentModeMatserVM>(pm)).FirstOrDefault();
response.ExpensesType = expenseTypes.Where(et => et.Id == m.ExpensesTypeId).Select(et => _mapper.Map<ExpensesTypeMasterVM>(et)).FirstOrDefault();
return response;
}).ToList();
return expenseList;
}
private async Task<ExpenseDetailsMongoDB> 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<ExpenseDetailsMongoDB>(model);
response.Project = _mapper.Map<ProjectBasicMongoDB>(model.Project);
response.PaidBy = _mapper.Map<BasicEmployeeMongoDB>(model.PaidBy);
response.CreatedBy = _mapper.Map<BasicEmployeeMongoDB>(model.CreatedBy);
response.ReviewedBy = _mapper.Map<BasicEmployeeMongoDB>(model.ReviewedBy);
response.ApprovedBy = _mapper.Map<BasicEmployeeMongoDB>(model.ApprovedBy);
response.ProcessedBy = _mapper.Map<BasicEmployeeMongoDB>(model.ProcessedBy);
if (statusMapping != null)
{
response.Status = _mapper.Map<ExpensesStatusMasterMongoDB>(statusMapping.Status);
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterMongoDB>>(statusMapping.NextStatus);
}
if (response.Status == null)
{
var status = statusTask.Result;
response.Status = _mapper.Map<ExpensesStatusMasterMongoDB>(status);
}
response.PaymentMode = _mapper.Map<PaymentModeMatserMongoDB>(model.PaymentMode);
response.ExpensesType = _mapper.Map<ExpensesTypeMasterMongoDB>(model.ExpensesType);
if (billAttachment != null) response.Documents = billAttachment.Documents;
return response;
}
/// <summary>
/// Deserializes the filter string, handling multiple potential formats (e.g., direct JSON vs. escaped JSON string).
/// </summary>
/// <param name="filter">The JSON filter string from the request.</param>
/// <returns>An <see cref="ExpensesFilter"/> object or null if deserialization fails.</returns>
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<ExpensesFilter>(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<string>(filter, options) ?? "";
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
{
expenseFilter = JsonSerializer.Deserialize<ExpensesFilter>(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;
}
/// <summary>
/// Processes and uploads attachments concurrently, then adds the resulting entities to the main DbContext.
/// </summary>
private async Task ProcessAndUploadAttachmentsAsync(IEnumerable<FileUploadModel> 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 =>
ProcessSingleAttachmentAsync(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);
}
/// <summary>
/// Handles the logic for a single attachment: upload to S3 and create corresponding entities.
/// </summary>
private async Task<(Document document, BillAttachments billAttachment)> ProcessSingleAttachmentAsync(
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 CreateAttachmentEntities(batchId, expense.Id, employeeId, tenantId, objectKey, attachment);
}
/// <summary>
/// A private static helper method to create Document and BillAttachment entities.
/// This remains unchanged as it's a pure data-shaping method.
/// </summary>
private static (Document document, BillAttachments billAttachment) CreateAttachmentEntities(
Guid batchId, Guid expenseId, Guid uploadedById, Guid tenantId, string s3Key, FileUploadModel attachmentDto)
{
var document = new Document
{
BatchId = batchId,
UploadedById = uploadedById,
FileName = attachmentDto.FileName ?? "",
ContentType = attachmentDto.ContentType ?? "",
S3Key = s3Key,
FileSize = attachmentDto.FileSize,
UploadedAt = DateTime.UtcNow,
TenantId = tenantId
};
var billAttachment = new BillAttachments { Document = document, ExpensesId = expenseId, TenantId = tenantId };
return (document, billAttachment);
}
private async Task DeleteAttachemnts(List<Guid> 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<S3DeletionObject> deletionObject = new List<S3DeletionObject>();
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
}
}