1718 lines
87 KiB
C#

using AutoMapper;
using Marco.Pms.DataAccess.Data;
using Marco.Pms.Helpers.Utility;
using Marco.Pms.Model.DocumentManager;
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.TenantModels;
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.Expenses.Masters;
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.EntityFrameworkCore;
using System.Text.Json;
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 Guid AdvancePayment = Guid.Parse("f67beee6-6763-4108-922c-03bd86b9178d");
private static readonly Guid ActiveTemplateStatus = Guid.Parse("da462422-13b2-45cc-a175-910a225f6fc8");
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 =================================================================== Expense Functions ===================================================================
#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.ExpenseCategory)
.Include(e => e.PaymentRequest)
.Include(e => e.Status)
.Include(e => e.Currency)
.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();
// 5. --- Execute Query and Map Results ---
var expensesList = await expensesQuery
.OrderByDescending(e => e.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize).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 = expensesList.Select(e =>
{
var result = _mapper.Map<ExpenseList>(e);
result.ExpenseUId = $"{e.UIDPrefix}/{e.UIDPostfix:D5}";
if (e.PaymentRequest != null)
result.PaymentRequestUID = $"{e.PaymentRequest.UIDPrefix}/{e.PaymentRequest.UIDPostfix:D5}";
return result;
}).ToList();
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, 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<object>.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)
.Include(e => e.Currency)
.Include(e => e.PaymentRequest)
.AsNoTracking().FirstOrDefaultAsync(e => (e.Id == id || (e.UIDPrefix + "/" + e.UIDPostfix.ToString().PadLeft(5, '0')) == 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<object>.ErrorResponse("Expense Not Found", "Expense Not Found", 404);
}
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
var hasManagePermission = await permissionService.HasPermission(PermissionsMaster.ExpenseManage, loggedInEmployee.Id);
expenseDetails = await GetAllExpnesRelatedTablesForSingle(expense, hasManagePermission, loggedInEmployee.Id, 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} or {ExpenseUId}.", id ?? Guid.Empty, expenseUId ?? "EX_00000");
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.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<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 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 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);
// 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 expenseCategory = await expenseCategoriesTask;
var paymentMode = await paymentModeTask;
var statusMapping = await statusMappingTask;
var paidBy = await paidByTask;
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<object>.ErrorResponse("Invalid input data.", errorMessage, 400);
}
string uIDPrefix = $"EX/{DateTime.Now:MMyy}";
// Generate unique UID postfix based on existing requests for the current prefix
var lastPR = await _context.Expenses.Where(pr => pr.UIDPrefix == uIDPrefix)
.OrderByDescending(pr => pr.UIDPostfix)
.FirstOrDefaultAsync();
int uIDPostfix = lastPR == null ? 1 : (lastPR.UIDPostfix + 1);
// 3. Entity Creation
var expense = _mapper.Map<Expenses>(dto);
expense.CurrencyId = dto.CurrencyId ?? Guid.Parse("78e96e4a-7ce0-4164-ae3a-c833ad45ec2c");
expense.UIDPostfix = uIDPostfix;
expense.UIDPrefix = uIDPrefix;
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);
}
var expenseLog = new ExpenseLog
{
ExpenseId = expense.Id,
Action = $"Status changed to '{statusMapping!.Status?.Name}'",
UpdatedById = loggedInEmployee.Id,
UpdateAt = DateTime.UtcNow,
Comment = $"Status changed to '{statusMapping!.Status?.Name}'",
TenantId = tenantId
};
_context.ExpenseLogs.Add(expenseLog);
// 5. Database Commit
await _context.SaveChangesAsync();
// 6. Transaction Commit
await transaction.CommitAsync();
await _cache.AddExpenseByObjectAsync(expense);
var response = _mapper.Map<ExpenseList>(expense);
response.ExpenseUId = $"{expense.UIDPrefix}/{expense.UIDPostfix:D5}";
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.ExpenseCategory = _mapper.Map<ExpenseCategoryMasterVM>(expenseCategory);
_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.ExpenseCategory)
.Include(e => e.Project)
.Include(e => e.PaidBy).ThenInclude(e => e!.JobRole)
.Include(e => e.PaymentMode)
.Include(e => e.Status)
.Include(e => e.Currency)
.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 processedStatusTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ExpensesStatusMaster
.FirstOrDefaultAsync(es => es.Id == Processed);
});
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, processedStatusTask);
var statusTransition = statusTransitionTask.Result;
var requiredPermissions = targetStatusPermissionsTask.Result;
var processedStatus = processedStatusTask.Result;
// 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);
var expenseLogs = new List<ExpenseLog>
{
new ExpenseLog
{
ExpenseId = expense.Id,
Action = $"Status changed to '{statusTransition.NextStatus?.Name}'",
UpdatedById = loggedInEmployee.Id,
UpdateAt = DateTime.UtcNow,
Comment = model.Comment,
TenantId = tenantId
},
};
// 6. Apply Status Transition
if (model.StatusId == ProcessPending && expense.PaymentModeId == AdvancePayment)
{
expense.StatusId = Processed;
expense.Status = processedStatus;
expense.ProcessedById = loggedInEmployee.Id;
var lastTransaction = await _context.AdvancePaymentTransactions.OrderByDescending(apt => apt.CreatedAt).FirstOrDefaultAsync(apt => apt.TenantId == tenantId);
double lastBalance = 0;
if (lastTransaction != null)
{
lastBalance = lastTransaction.CurrentBalance;
}
_context.AdvancePaymentTransactions.Add(new AdvancePaymentTransaction
{
Id = Guid.NewGuid(),
FinanceUIdPostfix = expense.UIDPostfix,
FinanceUIdPrefix = expense.UIDPrefix,
Title = expense.Description,
ProjectId = expense.ProjectId,
EmployeeId = expense.PaidById,
Amount = 0 - expense.Amount,
CurrentBalance = lastBalance - expense.Amount,
PaidAt = expense.TransactionDate,
CreatedAt = DateTime.UtcNow,
CreatedById = loggedInEmployee.Id,
IsActive = true,
TenantId = tenantId
});
var expenseLog = new ExpenseLog
{
ExpenseId = expense.Id,
Action = $"Status changed to '{processedStatus?.Name}'",
UpdatedById = loggedInEmployee.Id,
UpdateAt = DateTime.UtcNow,
Comment = model.Comment,
TenantId = tenantId
};
expenseLogs.Add(expenseLog);
}
else
{
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)
{
var totalAmount = model.BaseAmount + model.TaxAmount;
if (!totalAmount.HasValue || totalAmount != expense.Amount)
{
// Log the mismatch error with relevant details
_logger.LogWarning("Payment amount mismatch: calculated totalAmount = {TotalAmount}, expected Amount = {ExpectedAmount}", totalAmount ?? 0, expense.Amount);
// Return a structured error response indicating the amount discrepancy
return ApiResponse<object>.ErrorResponse(
"Payment amount validation failed.",
$"The sum of the base amount and tax amount ({totalAmount}) does not match the expected expense amount ({expense.Amount}).",
400);
}
expense.ProcessedById = loggedInEmployee.Id;
expense.BaseAmount = model.BaseAmount;
expense.TaxAmount = model.TaxAmount;
}
// 7. Add Reimbursement if applicable
if (model.StatusId == Processed)
{
expense.TDSPercentage = model.TDSPercentage;
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.AddRange(expenseLogs);
// 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)
{
// Validate if the employee Id from the URL path matches the Id in the request body (model)
if (id != model.Id)
{
// Log a warning with details for traceability when Ids do not match
_logger.LogWarning("Mismatch detected: Path parameter Id ({PathId}) does not match body Id ({BodyId}) for employee {EmployeeId}",
id, model.Id, loggedInEmployee.Id);
// Return standardized error response with HTTP 400 Bad Request status and clear message
return ApiResponse<object>.ErrorResponse("The employee Id in the path does not match the Id in the request body.",
"The employee Id in the path does not match the Id in the request body.", 400);
}
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
var hasManagePermission = await permissionService.HasPermission(PermissionsMaster.ExpenseManage, loggedInEmployee.Id);
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<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 (!hasManagePermission && 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);
existingExpense.StatusId = Draft;
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
#endregion
#region =================================================================== Helper Functions ===================================================================
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<ExpenseDetailsMongoDB> GetAllExpnesRelatedTablesForSingle(Expenses model, bool hasManagePermission, Guid loggedInEmployeeId, 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,
FileSize = ba.Document.FileSize,
}).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.ExpenseUId = $"{model.UIDPrefix}/{model.UIDPostfix:D5}";
if (model.PaymentRequest != null)
response.PaymentRequestUID = $"{model.PaymentRequest.UIDPrefix}/{model.PaymentRequest.UIDPostfix:D5}";
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);
// Assign nextStatuses only if:
// 1. The expense was rejected by approver/reviewer AND the current user is the creator, OR
// 2. The expense is in any other status (not rejected)
var isRejected = model.StatusId == RejectedByApprover
|| model.StatusId == RejectedByReviewer;
if ((!isRejected) || (isRejected && (loggedInEmployeeId == model.CreatedById || hasManagePermission)))
{
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.ExpenseCategory = _mapper.Map<ExpenseCategoryMasterMongoDB>(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<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;
}
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<PaymentRequestFilter>(filter, options);
}
catch (JsonException ex)
{
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializePaymentRequestFilter), 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<PaymentRequestFilter>(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(TryDeserializePaymentRequestFilter), filter);
return null;
}
}
return expenseFilter;
}
private RecurringPaymentFilter? TryDeserializeRecurringPaymentFilter(string? filter)
{
if (string.IsNullOrWhiteSpace(filter))
{
return null;
}
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
RecurringPaymentFilter? expenseFilter = null;
try
{
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
expenseFilter = JsonSerializer.Deserialize<RecurringPaymentFilter>(filter, options);
}
catch (JsonException ex)
{
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeRecurringPaymentFilter), 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<RecurringPaymentFilter>(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(TryDeserializeRecurringPaymentFilter), 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 =>
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);
}
/// <summary>
/// Handles the logic for a single attachment: upload to S3 and create corresponding entities.
/// </summary>
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);
}
/// <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) 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);
}
/// <summary>
/// Handles the logic for a single attachment: upload to S3 and create corresponding entities.
/// </summary>
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);
}
/// <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, 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<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);
}
private async Task DeletePaymentRequestAttachemnts(List<Guid> 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<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);
}
private static bool IsRecurringApplicable(int numberOfIteration, PLAN_FREQUENCY frequency, DateTime strikeDate, DateTime? latestPRGeneratedAt)
{
List<DateTime> dates = new List<DateTime>();
DateTime currentDate = strikeDate;
DateTime endDate = DateTime.UtcNow.Date;
switch (frequency)
{
case PLAN_FREQUENCY.MONTHLY:
while (currentDate <= endDate)
{
dates.Add(currentDate);
currentDate = currentDate.AddMonths(1);
}
break;
case PLAN_FREQUENCY.QUARTERLY:
while (currentDate <= endDate)
{
dates.Add(currentDate);
currentDate = currentDate.AddMonths(3);
}
break;
case PLAN_FREQUENCY.HALF_YEARLY:
while (currentDate <= endDate)
{
dates.Add(currentDate);
currentDate = currentDate.AddMonths(6);
}
break;
case PLAN_FREQUENCY.YEARLY:
while (currentDate <= endDate)
{
dates.Add(currentDate);
currentDate = currentDate.AddYears(1);
}
break;
case PLAN_FREQUENCY.DAILY:
while (currentDate <= endDate)
{
dates.Add(currentDate);
currentDate = currentDate.AddDays(1);
}
break;
case PLAN_FREQUENCY.WEEKLY:
while (currentDate <= endDate)
{
dates.Add(currentDate);
currentDate = currentDate.AddDays(7);
}
break;
}
if (!dates.Any() || dates.Count > numberOfIteration)
{
return false;
}
if (dates.Last() != endDate)
{
return false;
}
if (latestPRGeneratedAt.HasValue && latestPRGeneratedAt.Value == endDate)
{
return false;
}
return true;
}
#endregion
}
}