3971 lines
210 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.Master;
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;
//Expense Status
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 Done = Guid.Parse("b8586f67-dc19-49c3-b4af-224149efe1d3");
// Payment mode
private static readonly Guid AdvancePayment = Guid.Parse("f67beee6-6763-4108-922c-03bd86b9178d");
// Recurring payment status
private static readonly Guid ActiveTemplateStatus = Guid.Parse("da462422-13b2-45cc-a175-910a225f6fc8");
private static readonly Guid CompletedTemplateStatus = Guid.Parse("306856fb-5655-42eb-bf8b-808bb5e84725");
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)) ||
(e.UIDPrefix + "/" + e.UIDPostfix.ToString().PadLeft(5, '0')).Contains(searchString));
}
// 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(),
ExpenseCategory = 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.EmployeeId == expense.PaidById && 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);
}
var result = ValidateTdsPercentage(model.TDSPercentage);
if (result != null)
{
return result;
}
expense.ProcessedById = loggedInEmployee.Id;
expense.BaseAmount = model.BaseAmount;
expense.TaxAmount = model.TaxAmount;
expense.TDSPercentage = model.TDSPercentage;
}
// 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.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 =================================================================== Payment Request Functions ===================================================================
public async Task<ApiResponse<object>> GetPaymentRequestListAsync(string? searchString, string? filter, bool isActive, int pageSize, int pageNumber, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start GetPaymentRequestListAsync: TenantId={TenantId}, PageNumber={PageNumber}, PageSize={PageSize}, EmployeeId={EmployeeId}",
tenantId, pageNumber, pageSize, loggedInEmployee.Id);
try
{
var hasViewSelfPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployee.Id);
});
var hasViewAllPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployee.Id);
});
await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask);
if (!hasViewAllPermissionTask.Result && !hasViewSelfPermissionTask.Result)
{
// User has neither required permission. Deny access.
_logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get payment request list.", loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(new List<ExpenseList>(), "You do not have permission to view any payment request.", 200);
}
// Initial query including the necessary navigation properties and basic multi-tenant/security constraints
var paymentRequestQuery = _context.PaymentRequests
.Include(pr => pr.Currency)
.Include(pr => pr.Project)
.Include(pr => pr.RecurringPayment)
.Include(pr => pr.ExpenseCategory)
.Include(pr => pr.ExpenseStatus)
.Include(pr => pr.CreatedBy)
.ThenInclude(e => e!.JobRole)
.Where(pr => pr.TenantId == tenantId &&
pr.IsActive == isActive &&
pr.Currency != null &&
pr.ExpenseCategory != null &&
pr.ExpenseStatus != null &&
pr.CreatedBy != null &&
pr.CreatedBy.JobRole != null);
if (hasViewSelfPermissionTask.Result && !hasViewAllPermissionTask.Result)
{
paymentRequestQuery = paymentRequestQuery.Where(pr => pr.CreatedById == loggedInEmployee.Id);
}
paymentRequestQuery = paymentRequestQuery.Where(pr => pr.ExpenseStatusId != Draft || pr.CreatedById == loggedInEmployee.Id);
// Deserialize and apply advanced filter if provided
PaymentRequestFilter? paymentRequestFilter = TryDeserializePaymentRequestFilter(filter);
if (paymentRequestFilter != null)
{
if (paymentRequestFilter.ProjectIds?.Any() ?? false)
{
paymentRequestQuery = paymentRequestQuery
.Where(pr => pr.ProjectId.HasValue && paymentRequestFilter.ProjectIds.Contains(pr.ProjectId.Value));
}
if (paymentRequestFilter.StatusIds?.Any() ?? false)
{
paymentRequestQuery = paymentRequestQuery
.Where(pr => paymentRequestFilter.StatusIds.Contains(pr.ExpenseStatusId));
}
if (paymentRequestFilter.CreatedByIds?.Any() ?? false)
{
paymentRequestQuery = paymentRequestQuery
.Where(pr => paymentRequestFilter.CreatedByIds.Contains(pr.CreatedById));
}
if (paymentRequestFilter.CurrencyIds?.Any() ?? false)
{
paymentRequestQuery = paymentRequestQuery
.Where(pr => paymentRequestFilter.CurrencyIds.Contains(pr.CurrencyId));
}
if (paymentRequestFilter.ExpenseCategoryIds?.Any() ?? false)
{
paymentRequestQuery = paymentRequestQuery
.Where(pr => pr.ExpenseCategoryId.HasValue && paymentRequestFilter.ExpenseCategoryIds.Contains(pr.ExpenseCategoryId.Value));
}
if (paymentRequestFilter.Payees?.Any() ?? false)
{
paymentRequestQuery = paymentRequestQuery
.Where(pr => paymentRequestFilter.Payees.Contains(pr.Payee));
}
if (paymentRequestFilter.StartDate.HasValue && paymentRequestFilter.EndDate.HasValue)
{
DateTime startDate = paymentRequestFilter.StartDate.Value.Date;
DateTime endDate = paymentRequestFilter.EndDate.Value.Date;
paymentRequestQuery = paymentRequestQuery
.Where(pr => pr.CreatedAt.Date >= startDate && pr.CreatedAt.Date <= endDate);
}
}
// Full-text search by payee, title, or UID
if (!string.IsNullOrWhiteSpace(searchString))
{
paymentRequestQuery = paymentRequestQuery
.Where(pr =>
pr.Payee.Contains(searchString) ||
pr.Title.Contains(searchString) ||
(pr.UIDPrefix + "/" + pr.UIDPostfix.ToString().PadLeft(5, '0')).Contains(searchString)
);
}
// Get total count for pagination
var totalEntities = await paymentRequestQuery.CountAsync();
// Fetch paginated result set
var paymentRequests = await paymentRequestQuery
.OrderByDescending(e => e.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
var results = paymentRequests.Select(pr =>
{
var result = _mapper.Map<PaymentRequestVM>(pr);
result.PaymentRequestUID = $"{pr.UIDPrefix}/{pr.UIDPostfix:D5}";
return result;
}).ToList();
var response = new
{
CurrentPage = pageNumber,
TotalPages = totalPages,
TotalEntities = totalEntities,
Data = results,
};
_logger.LogInfo("GetPaymentRequestListAsync: {ResultCount} payment requests fetched successfully for TenantId={TenantId} Page={PageNumber}/{TotalPages}",
results.Count, tenantId, pageNumber, totalPages);
return ApiResponse<object>.SuccessResponse(response, $"{results.Count} payment requests fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred in GetPaymentRequestListAsync for TenantId={TenantId}, EmployeeId={EmployeeId}: {Message}", tenantId, loggedInEmployee.Id, ex.Message);
return ApiResponse<object>.ErrorResponse("An error occurred while fetching payment requests.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End GetPaymentRequestListAsync for TenantId={TenantId}, EmployeeId={EmployeeId}", tenantId, loggedInEmployee.Id);
}
}
public async Task<ApiResponse<object>> GetPaymentRequestDetailsAsync(Guid? id, string? paymentRequestUId, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start GetPaymentRequestDetailsAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId} with Id: {Id}, UID: {UID}",
loggedInEmployee.Id, tenantId, id ?? Guid.Empty, paymentRequestUId ?? "PY/1125/00000");
try
{
// Validate input: at least one identifier must be provided
if (!id.HasValue && string.IsNullOrWhiteSpace(paymentRequestUId))
{
_logger.LogWarning("Invalid parameters: Both Id and PaymentRequestUID are null or empty.");
return ApiResponse<object>.ErrorResponse("At least one parameter (Id or PaymentRequestUID) must be provided.", "Invalid argument.", 400);
}
// Check user permissions concurrently
var hasViewSelfPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployee.Id);
});
var hasViewAllPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployee.Id);
});
var hasReviewPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseReview, loggedInEmployee.Id);
});
var hasApprovePermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseApprove, loggedInEmployee.Id);
});
var hasProcessPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseProcess, loggedInEmployee.Id);
});
var hasManagePermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseManage, loggedInEmployee.Id);
});
await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask, hasReviewPermissionTask, hasApprovePermissionTask, hasProcessPermissionTask, hasManagePermissionTask);
bool hasViewSelfPermission = hasViewSelfPermissionTask.Result;
bool hasViewAllPermission = hasViewAllPermissionTask.Result;
bool hasReviewPermission = hasReviewPermissionTask.Result;
bool hasApprovePermission = hasApprovePermissionTask.Result;
bool hasProcessPermission = hasProcessPermissionTask.Result;
bool hasManagePermission = hasProcessPermissionTask.Result;
// Deny access if user has no relevant permissions
if (!hasViewSelfPermission && !hasViewAllPermission && !hasReviewPermission && !hasApprovePermission && !hasProcessPermission)
{
_logger.LogWarning("Access DENIED: Employee {EmployeeId} has no permission to view payment requests.", loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(new { }, "You do not have permission to view any payment request.", 200);
}
// Query payment request with all necessary navigation properties and validation constraints
var paymentRequest = await _context.PaymentRequests
.Include(pr => pr.Currency)
.Include(pr => pr.Project)
.Include(pr => pr.RecurringPayment)
.Include(pr => pr.ExpenseCategory)
.Include(pr => pr.ExpenseStatus)
.Include(pr => pr.PaidBy).ThenInclude(e => e!.JobRole)
.Include(pr => pr.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(pr => pr.UpdatedBy).ThenInclude(e => e!.JobRole)
.Where(pr =>
(pr.Id == id || (pr.UIDPrefix + "/" + pr.UIDPostfix.ToString().PadLeft(5, '0')) == paymentRequestUId) &&
pr.TenantId == tenantId &&
pr.Currency != null &&
pr.ExpenseCategory != null &&
pr.ExpenseStatus != null &&
pr.CreatedBy != null &&
pr.CreatedBy.JobRole != null)
.FirstOrDefaultAsync();
if (paymentRequest == null)
{
_logger.LogWarning("Payment Request not found: Id={Id}, UID={UID}, TenantId={TenantId}", id ?? Guid.Empty, paymentRequestUId ?? "PY/1125/00000", tenantId);
return ApiResponse<object>.ErrorResponse("Payment Request not found.", "Payment Request not found.", 404);
}
// Check if employee has only "view self" permission but the payment request is created by another employee => deny
bool selfCheck = hasViewSelfPermission && !hasViewAllPermission && !hasReviewPermission && !hasApprovePermission && !hasProcessPermission
&& paymentRequest.CreatedById != loggedInEmployee.Id;
if (selfCheck)
{
_logger.LogWarning("Access DENIED: Employee {EmployeeId} lacks permission to view PaymentRequest {PaymentRequestId} created by another employee.",
loggedInEmployee.Id, paymentRequest.Id);
return ApiResponse<object>.SuccessResponse(new { }, "You do not have permission to view this payment request.", 200);
}
// Concurrently fetch next possible statuses and related permissions
var nextStatusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
var nextStatuses = await context.ExpensesStatusMapping
.Include(esm => esm.NextStatus)
.Where(esm => esm.StatusId == paymentRequest.ExpenseStatusId && esm.NextStatus != null && !(esm.NextStatusId == Done && paymentRequest.IsAdvancePayment))
.Select(esm => esm.NextStatus!)
.ToListAsync();
var nextStatusIds = nextStatuses.Select(ns => ns.Id).ToList();
var permissionMappings = await context.StatusPermissionMapping.Where(spm => nextStatusIds.Contains(spm.StatusId)).ToListAsync();
var results = new List<ExpensesStatusMasterVM>();
foreach (var status in nextStatuses)
{
var permissionIds = permissionMappings.Where(spm => spm.StatusId == status.Id).Select(spm => spm.PermissionId).ToList();
bool hasPermission = await permissionService.HasPermissionAny(permissionIds, loggedInEmployee.Id);
// Special case: allow review status if creator is the logged-in user
bool hasStatusPermission = Review == status.Id && loggedInEmployee.Id == paymentRequest.CreatedById;
if (!hasPermission || !hasStatusPermission)
{
continue;
}
var mappedStatus = _mapper.Map<ExpensesStatusMasterVM>(status);
mappedStatus.PermissionIds = permissionIds;
results.Add(mappedStatus);
}
int index = results.FindIndex(ns => ns.DisplayName == "Reject");
if (index > -1)
{
var item = results[index];
results.RemoveAt(index);
results.Insert(0, item);
}
return results;
});
// Concurrently fetch attachments with pre-signed URLs
var documentTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
var documents = await context.PaymentRequestAttachments
.Include(pra => pra.Document)
.Where(pra => pra.PaymentRequestId == paymentRequest.Id && pra.Document != null)
.Select(pra => pra.Document!)
.ToListAsync();
return documents.Select(d =>
{
var attachmentVM = _mapper.Map<PaymentRequestAttachmentVM>(d);
attachmentVM.Url = _s3Service.GeneratePreSignedUrl(d.S3Key);
return attachmentVM;
}).ToList();
});
var updateLogsTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.StatusUpdateLogs
.Include(sul => sul.UpdatedBy)
.Where(sul => sul.EntityId == paymentRequest.Id && sul.TenantId == tenantId)
.OrderByDescending(sul => sul.UpdatedAt)
.ToListAsync();
});
await Task.WhenAll(nextStatusTask, documentTask, updateLogsTask);
var nextStatuses = nextStatusTask.Result;
var attachmentVMs = documentTask.Result;
var updateLogs = updateLogsTask.Result;
var statusIds = updateLogs.Where(sul => sul.StatusId.HasValue).Select(sul => sul.StatusId!.Value).ToList();
statusIds.AddRange(updateLogs.Select(sul => sul.NextStatusId).ToList());
statusIds = statusIds.Distinct().ToList();
var status = await _context.ExpensesStatusMaster.Where(es => statusIds.Contains(es.Id)).ToListAsync();
// Map main response model and populate additional fields
var response = _mapper.Map<PaymentRequestDetailsVM>(paymentRequest);
response.PaymentRequestUID = $"{paymentRequest.UIDPrefix}/{paymentRequest.UIDPostfix:D5}";
//if (paymentRequest.RecurringPayment != null)
// response.RecurringPaymentUID = $"{paymentRequest.RecurringPayment.UIDPrefix}/{paymentRequest.RecurringPayment.UIDPostfix:D5}";
response.Attachments = attachmentVMs;
// Assign nextStatuses only if:
// 1. The payment request was rejected by approver/reviewer AND the current user is the creator, OR
// 2. The payment request is in any other status (not rejected)
var isRejected = paymentRequest.ExpenseStatusId == RejectedByApprover
|| paymentRequest.ExpenseStatusId == RejectedByReviewer;
if ((!isRejected) || (isRejected && (loggedInEmployee.Id == paymentRequest.CreatedById || hasManagePermission)))
{
response.NextStatus = nextStatuses;
}
response.UpdateLogs = updateLogs.Select(ul =>
{
var statusVm = status.FirstOrDefault(es => es.Id == ul.StatusId);
var nextStatusVm = status.FirstOrDefault(es => es.Id == ul.NextStatusId);
return new PaymentRequestUpdateLog
{
Id = ul.Id,
Comment = ul.Comment,
Status = _mapper.Map<ExpensesStatusMasterVM>(statusVm),
NextStatus = _mapper.Map<ExpensesStatusMasterVM>(nextStatusVm),
UpdatedAt = ul.UpdatedAt,
UpdatedBy = _mapper.Map<BasicEmployeeVM>(ul.UpdatedBy)
};
}).ToList();
_logger.LogInfo("Payment request details fetched successfully for PaymentRequestId: {PaymentRequestId}, EmployeeId: {EmployeeId}", paymentRequest.Id, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, "Payment request fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in GetPaymentRequestDetailsAsync for TenantId={TenantId}, EmployeeId={EmployeeId}: {Message}", tenantId, loggedInEmployee.Id, ex.Message);
return ApiResponse<object>.ErrorResponse("An error occurred while fetching the payment request details.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End GetPaymentRequestDetailsAsync called by EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
public async Task<ApiResponse<object>> GetPayeeNameListAsync(Employee loggedInEmployee, Guid tenantId)
{
try
{
var payeeList = await _context.PaymentRequests.Where(e => e.TenantId == tenantId).Select(e => e.Payee).Distinct().ToListAsync();
_logger.LogInfo("Employee {EmployeeId} fetched list of payees from payment request in a tenant {TenantId}", loggedInEmployee.Id, tenantId);
return ApiResponse<object>.SuccessResponse(payeeList, $"{payeeList.Count} records of payees fetched from payment request", 200);
}
catch (DbUpdateException dbEx)
{
_logger.LogError(dbEx, "Databsae Exception occured while fetching payee list from payment request");
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
}
}
public async Task<ApiResponse<object>> GetPaymentRequestFilterObjectAsync(Employee loggedInEmployee, Guid tenantId)
{
try
{
var paymentRequests = await _context.PaymentRequests
.Include(pr => pr.Currency)
.Include(pr => pr.Project)
.Include(pr => pr.ExpenseCategory)
.Include(pr => pr.ExpenseStatus)
.Include(pr => pr.CreatedBy)
.Where(e => e.TenantId == tenantId)
.ToListAsync();
// Construct the final object from the results of the completed tasks.
var response = new
{
Projects = paymentRequests.Where(pr => pr.Project != null).Select(pr => new { Id = pr.Project!.Id, Name = pr.Project.Name }).Distinct().ToList(),
Currency = paymentRequests.Where(pr => pr.Currency != null).Select(pr => new { Id = pr.Currency!.Id, Name = pr.Currency.CurrencyName }).Distinct().ToList(),
CreatedBy = paymentRequests.Where(pr => pr.CreatedBy != null).Select(pr => new { Id = pr.CreatedBy!.Id, Name = $"{pr.CreatedBy.FirstName} {pr.CreatedBy.LastName}" }).Distinct().ToList(),
Status = paymentRequests.Where(pr => pr.ExpenseStatus != null).Select(pr => new { Id = pr.ExpenseStatus!.Id, Name = pr.ExpenseStatus.Name }).Distinct().ToList(),
ExpenseCategory = paymentRequests.Where(pr => pr.ExpenseCategory != null).Select(pr => new { Id = pr.ExpenseCategory!.Id, Name = pr.ExpenseCategory.Name }).Distinct().ToList(),
Payees = paymentRequests.Where(pr => !string.IsNullOrWhiteSpace(pr.Payee)).Select(pr => new { Id = pr.Payee, Name = pr.Payee }).Distinct().ToList()
};
return ApiResponse<object>.SuccessResponse(response, "Successfully fetched the filter list", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occured while fetching the list filters for payment requests");
return ApiResponse<object>.ErrorResponse("Internal Exception Occured", ExceptionMapper(ex), 500);
}
}
public async Task<ApiResponse<object>> CreatePaymentRequestAsync(PaymentRequestDto model, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start CreatePaymentRequestAsync for EmployeeId: {EmployeeId} TenantId: {TenantId}", loggedInEmployee.Id, tenantId);
string uIDPrefix = $"PR/{DateTime.Now:MMyy}";
try
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
var hasUploadPermission = await permissionService.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id);
if (!hasUploadPermission)
{
_logger.LogWarning("Access DENIED: Employee {EmployeeId} has no permission to create payment requests.", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "You do not have permission to create any payment request.", 409);
}
// Execute database lookups concurrently
var expenseCategoryTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ExpenseCategoryMasters.FirstOrDefaultAsync(et => et.Id == model.ExpenseCategoryId && et.IsActive);
});
var currencyTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.CurrencyMaster.FirstOrDefaultAsync(c => c.Id == model.CurrencyId);
});
var expenseStatusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ExpensesStatusMaster.FirstOrDefaultAsync(es => es.Id == Draft && es.IsActive);
});
var projectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Projects.FirstOrDefaultAsync(P => model.ProjectId.HasValue && P.Id == model.ProjectId.Value);
});
await Task.WhenAll(expenseCategoryTask, currencyTask, expenseStatusTask, projectTask);
var expenseCategory = await expenseCategoryTask;
if (expenseCategory == null)
{
_logger.LogWarning("Expense Category not found with Id: {ExpenseCategoryId}", model.ExpenseCategoryId);
return ApiResponse<object>.ErrorResponse("Expense Category not found.", "Expense Category not found.", 404);
}
var currency = await currencyTask;
if (currency == null)
{
_logger.LogWarning("Currency not found with Id: {CurrencyId}", model.CurrencyId);
return ApiResponse<object>.ErrorResponse("Currency not found.", "Currency not found.", 404);
}
var expenseStatus = await expenseStatusTask;
if (expenseStatus == null)
{
_logger.LogWarning("Expense Status not found for Draft.");
return ApiResponse<object>.ErrorResponse("Expense Status (Draft) not found.", "Expense Status not found.", 404);
}
var project = await projectTask;
// Project is optional so no error if not found
// Generate unique UID postfix based on existing requests for the current prefix
var lastPR = await _context.PaymentRequests.Where(pr => pr.UIDPrefix == uIDPrefix)
.OrderByDescending(pr => pr.UIDPostfix)
.FirstOrDefaultAsync();
int uIDPostfix = lastPR == null ? 1 : (lastPR.UIDPostfix + 1);
// Map DTO to PaymentRequest entity and set additional mandatory fields
var paymentRequest = _mapper.Map<PaymentRequest>(model);
paymentRequest.ExpenseStatusId = Draft;
paymentRequest.UIDPrefix = uIDPrefix;
paymentRequest.UIDPostfix = uIDPostfix;
paymentRequest.IsActive = true;
paymentRequest.CreatedAt = DateTime.UtcNow;
paymentRequest.CreatedById = loggedInEmployee.Id;
paymentRequest.TenantId = tenantId;
await _context.PaymentRequests.AddAsync(paymentRequest);
// 8. Add paymentRequest Log Entry
_context.StatusUpdateLogs.Add(new StatusUpdateLog
{
Id = Guid.NewGuid(),
EntityId = paymentRequest.Id,
//StatusId = Draft,
NextStatusId = Draft,
UpdatedById = loggedInEmployee.Id,
UpdatedAt = DateTime.UtcNow,
Comment = "Payment request is saved as draft",
TenantId = tenantId
});
await _context.SaveChangesAsync();
// Process bill attachments if any
if (model.BillAttachments?.Any() == true)
{
_logger.LogInfo("Processing {AttachmentCount} attachments for PaymentRequest Id: {PaymentRequestId}",
model.BillAttachments.Count, paymentRequest.Id);
// Validate base64 attachments data before processing
foreach (var attachment in model.BillAttachments)
{
if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data))
{
_logger.LogWarning("Invalid or missing Base64 data for attachment: {FileName}", attachment.FileName ?? "N/A");
throw new ArgumentException($"Invalid or missing Base64 data for attachment: {attachment.FileName ?? "N/A"}");
}
}
var batchId = Guid.NewGuid();
// Process all attachments concurrently
var processingTasks = model.BillAttachments.Select(attachment =>
ProcessSinglePaymentRequestAttachmentAsync(attachment, paymentRequest, loggedInEmployee.Id, tenantId, batchId)
).ToList();
var results = await Task.WhenAll(processingTasks);
// Add documents and payment request attachments after concurrent processing
foreach (var (document, paymentRequestAttachment) in results)
{
_context.Documents.Add(document);
_context.PaymentRequestAttachments.Add(paymentRequestAttachment);
}
await _context.SaveChangesAsync();
_logger.LogInfo("{AttachmentCount} attachments processed and saved for PaymentRequest Id: {PaymentRequestId}",
results.Length, paymentRequest.Id);
}
// Prepare response VM
var response = _mapper.Map<PaymentRequestVM>(paymentRequest);
response.PaymentRequestUID = $"{paymentRequest.UIDPrefix}/{paymentRequest.UIDPostfix:D5}";
response.Currency = currency;
response.ExpenseCategory = _mapper.Map<ExpenseCategoryMasterVM>(expenseCategory);
response.ExpenseStatus = _mapper.Map<ExpensesStatusMasterVM>(expenseStatus);
response.Project = _mapper.Map<BasicProjectVM>(project);
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
_logger.LogInfo("Payment request created successfully with UID: {PaymentRequestUID}", response.PaymentRequestUID);
return ApiResponse<object>.SuccessResponse(response, "Created the Payment Request Successfully.", 201);
}
catch (ArgumentException ex)
{
_logger.LogError(ex, "Argument error in CreatePaymentRequestAsync: {Message}", ex.Message);
return ApiResponse<object>.ErrorResponse(ex.Message, "Invalid data.", 400);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error in CreatePaymentRequestAsync: {Message}", ex.Message);
return ApiResponse<object>.ErrorResponse("An error occurred while creating the payment request.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End CreatePaymentRequestAsync for EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
public async Task<ApiResponse<object>> ChangePaymentRequestStatusAsync(PaymentRequestRecordDto model, Employee loggedInEmployee, Guid tenantId)
{
using var scope = _serviceScopeFactory.CreateScope();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
// 1. Fetch Existing Payment Request with Related Entities (Single Query)
var paymentRequest = await _context.PaymentRequests
.Include(pr => pr.Currency)
.Include(pr => pr.Project)
.Include(pr => pr.RecurringPayment)
.Include(pr => pr.ExpenseCategory)
.Include(pr => pr.ExpenseStatus)
.Include(pr => pr.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(pr => pr.UpdatedBy).ThenInclude(e => e!.JobRole)
.FirstOrDefaultAsync(pr =>
pr.Id == model.PaymentRequestId &&
pr.ExpenseStatusId != model.StatusId &&
pr.TenantId == tenantId
);
if (paymentRequest == null)
{
_logger.LogWarning("ChangeStatus: Payment Request not found or already at target status. payment RequestId={PaymentRequestId}, TenantId={TenantId}", model.PaymentRequestId, tenantId);
return ApiResponse<object>.ErrorResponse("payment Request not found or status is already set.", "payment Request not found", 404);
}
_logger.LogInfo("ChangeStatus: Requested status change. PaymentRequestId={PaymentRequestId} FromStatus={FromStatusId} ToStatus={ToStatusId}",
paymentRequest.Id, paymentRequest.ExpenseStatusId, 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 == paymentRequest.ExpenseStatusId && 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. PaymentRequestId={PaymentRequestId}, FromStatus={FromStatus}, ToStatus={ToStatus}",
paymentRequest.Id, paymentRequest.ExpenseStatusId, 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.PaidTransactionId) ||
!model.PaidAt.HasValue ||
model.PaidById == null ||
model.PaidById == Guid.Empty))
{
_logger.LogWarning("ChangeStatus: Missing payment fields for 'Processed'. PaymentRequestId={PaymentRequestId}", paymentRequest.Id);
return ApiResponse<object>.ErrorResponse("payment details are missing or invalid.", "Invalid Payment", 400);
}
// 4. Permission Check (CreatedBy -> Reviewer bypass, else required permissions)
bool hasPermission = false;
if (model.StatusId == Review && paymentRequest.CreatedById == loggedInEmployee.Id)
{
hasPermission = true;
}
else if (requiredPermissions.Any())
{
var permissionIds = requiredPermissions.Select(p => p.PermissionId).ToList();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
hasPermission = await permissionService.HasPermissionAny(permissionIds, loggedInEmployee.Id) && model.StatusId != Review;
}
if (!hasPermission)
{
_logger.LogWarning("ChangeStatus: Permission denied. EmployeeId={EmployeeId} PaymentRequestId={PaymentRequestId} ToStatus={ToStatusId}",
loggedInEmployee.Id, paymentRequest.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 paymentRequestStateBeforeChange = _updateLogHelper.EntityToBsonDocument(paymentRequest);
// 6. Apply Status Transition
paymentRequest.ExpenseStatusId = statusTransition.NextStatusId;
paymentRequest.ExpenseStatus = statusTransition.NextStatus;
var updatedAt = DateTime.UtcNow;
// 7. Add Payment details if applicable
if (model.StatusId == Processed)
{
var totalAmount = model.BaseAmount + model.TaxAmount;
if (!totalAmount.HasValue || totalAmount != paymentRequest.Amount)
{
// Log the mismatch error with relevant details
_logger.LogWarning("Payment amount mismatch: calculated totalAmount = {TotalAmount}, expected Amount = {ExpectedAmount}", totalAmount ?? 0, paymentRequest.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 payment request amount ({paymentRequest.Amount}).",
400);
}
var result = ValidateTdsPercentage(model.TDSPercentage);
if (result != null)
{
return result;
}
paymentRequest.PaidAt = model.PaidAt;
paymentRequest.PaidById = model.PaidById;
paymentRequest.PaidTransactionId = model.PaidTransactionId;
paymentRequest.TDSPercentage = model.TDSPercentage;
paymentRequest.BaseAmount = model.BaseAmount;
paymentRequest.TaxAmount = model.TaxAmount;
if (paymentRequest.IsAdvancePayment)
{
var lastTransactionTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.AdvancePaymentTransactions
.OrderByDescending(apt => apt.CreatedAt)
.FirstOrDefaultAsync(apt => apt.EmployeeId == paymentRequest.CreatedById && apt.TenantId == tenantId);
});
var doneStatusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ExpensesStatusMaster.FirstOrDefaultAsync(es => es.Id == Done && es.IsActive);
});
await Task.WhenAll(lastTransactionTask, doneStatusTask);
var lastTransaction = lastTransactionTask.Result;
var doneStatus = doneStatusTask.Result;
double lastBalance = 0;
if (lastTransaction != null)
{
lastBalance = lastTransaction.CurrentBalance;
}
_context.AdvancePaymentTransactions.Add(new AdvancePaymentTransaction
{
Id = Guid.NewGuid(),
FinanceUIdPostfix = paymentRequest.UIDPostfix,
FinanceUIdPrefix = paymentRequest.UIDPrefix,
Title = paymentRequest.Title,
ProjectId = paymentRequest.ProjectId,
EmployeeId = paymentRequest.CreatedById,
Amount = paymentRequest.Amount,
CurrentBalance = lastBalance + paymentRequest.Amount,
PaidAt = model.PaidAt!.Value,
CreatedAt = DateTime.UtcNow,
CreatedById = loggedInEmployee.Id,
IsActive = true,
TenantId = tenantId
});
if (doneStatus != null)
{
paymentRequest.ExpenseStatusId = doneStatus.Id;
paymentRequest.ExpenseStatus = doneStatus;
_context.StatusUpdateLogs.Add(new StatusUpdateLog
{
Id = Guid.NewGuid(),
EntityId = paymentRequest.Id,
StatusId = statusTransition.NextStatusId,
NextStatusId = doneStatus.Id,
UpdatedById = loggedInEmployee.Id,
UpdatedAt = DateTime.UtcNow.AddMilliseconds(10),
Comment = model.Comment,
TenantId = tenantId
});
}
}
}
try
{
await _context.SaveChangesAsync();
_logger.LogInfo("ChangeStatus: Status updated successfully. PaymentRequestId={PaymentRequestId} NewStatus={NewStatusId}", paymentRequest.Id, paymentRequest.ExpenseStatusId);
}
catch (DbUpdateConcurrencyException ex)
{
_logger.LogError(ex, "ChangeStatus: Concurrency error. PaymentRequestId={PaymentRequestId}", paymentRequest.Id);
return ApiResponse<object>.ErrorResponse("Payment Request was modified by another user. Please refresh and try again.", "Concurrency Error", 409);
}
if (model.StatusId == Done)
{
if (!model.PaymentModeId.HasValue)
{
return ApiResponse<object>.ErrorResponse("Payment mode Id is missing from payload", "Payment mode Id is missing from payload", 400);
}
var expenseConversion = new ExpenseConversionDto
{
PaymentModeId = model.PaymentModeId.Value,
PaymentRequestId = model.PaymentRequestId,
Location = model.Location,
GSTNumber = model.GSTNumber,
BillAttachments = model.BillAttachments
};
var response = await ChangeToExpanseFromPaymentRequestAsync(expenseConversion, paymentRequest, loggedInEmployee, tenantId);
if (!response.Success)
{
return response;
}
}
// 8. Add paymentRequest Log Entry
_context.StatusUpdateLogs.Add(new StatusUpdateLog
{
Id = Guid.NewGuid(),
EntityId = paymentRequest.Id,
StatusId = statusTransition.StatusId,
NextStatusId = statusTransition.NextStatusId,
UpdatedById = loggedInEmployee.Id,
UpdatedAt = updatedAt,
Comment = model.Comment,
TenantId = tenantId
});
// 9. Commit database transaction
try
{
await _context.SaveChangesAsync();
_logger.LogInfo("ChangeStatus: Status updated successfully. PaymentRequestId={PaymentRequestId} NewStatus={NewStatusId}", paymentRequest.Id, paymentRequest.ExpenseStatusId);
}
catch (DbUpdateConcurrencyException ex)
{
_logger.LogError(ex, "ChangeStatus: Concurrency error. PaymentRequestId={PaymentRequestId}", paymentRequest.Id);
return ApiResponse<object>.ErrorResponse("Payment Request 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(paymentRequest, name, tenantId);
//});
// 10. Post-processing (audit log, cache, fetch next states)
try
{
await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = paymentRequest.Id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = paymentRequestStateBeforeChange,
UpdatedAt = DateTime.UtcNow
}, "PaymentRequestModificationLog");
// Prepare response
var responseDto = _mapper.Map<PaymentRequestVM>(paymentRequest);
return ApiResponse<object>.SuccessResponse(responseDto, "Payment Request status chnaged successfully", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "ChangeStatus: Post-operation error (e.g. audit logging). PaymentRequestId={PaymentRequestId}", paymentRequest.Id);
var responseDto = _mapper.Map<PaymentRequestVM>(paymentRequest);
return ApiResponse<object>.SuccessResponse(responseDto, "Status updated, but audit logging or cache update failed.");
}
}
public async Task<ApiResponse<object>> ChangeToExpanseFromPaymentRequestAsync(ExpenseConversionDto model, PaymentRequest paymentRequest, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start ChangeToExpanseFromPaymentRequestAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId} PaymentRequestId: {PaymentRequestId}",
loggedInEmployee.Id, tenantId, model.PaymentRequestId);
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var ispaymentRequestApplicable = (paymentRequest.Id == model.PaymentRequestId &&
!paymentRequest.IsAdvancePayment &&
paymentRequest.ProjectId.HasValue &&
paymentRequest.ExpenseCategoryId.HasValue &&
paymentRequest.PaidById.HasValue &&
paymentRequest.PaidAt.HasValue &&
paymentRequest.ExpenseCategory != null &&
paymentRequest.TenantId == tenantId &&
paymentRequest.IsActive);
if (!ispaymentRequestApplicable)
{
_logger.LogWarning("Payment request not found for Id: {PaymentRequestId}, TenantId: {TenantId}", model.PaymentRequestId, tenantId);
return ApiResponse<object>.ErrorResponse("Payment request not found.", "Payment request not found.", 404);
}
// Check payment request status for eligibility to convert
if (paymentRequest.ExpenseStatusId != Done)
{
_logger.LogWarning("Payment request {PaymentRequestId} status is not processed. Current status: {StatusId}", paymentRequest.Id, paymentRequest.ExpenseStatusId);
return ApiResponse<object>.ErrorResponse("Payment is not processed.", "Payment is not processed.", 400);
}
// Verify attachment requirements
var hasAttachments = model.BillAttachments?.Any() ?? false;
if (!hasAttachments && paymentRequest.ExpenseCategory!.IsAttachmentRequried)
{
_logger.LogWarning("Attachment is required for ExpenseCategory {ExpenseCategoryId} but no attachments provided.", paymentRequest.ExpenseCategoryId!);
return ApiResponse<object>.ErrorResponse("Attachment is required.", "Attachment is required.", 400);
}
// Concurrently fetch status update logs and expense statuses required for logging and state transition
var statusUpdateLogTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.StatusUpdateLogs
.Where(sul => sul.EntityId == paymentRequest.Id && sul.TenantId == tenantId)
.OrderByDescending(sul => sul.UpdatedAt)
.ToListAsync();
});
var expenseStatusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ExpensesStatusMaster.ToListAsync();
});
await Task.WhenAll(statusUpdateLogTask, expenseStatusTask);
var statusUpdateLogs = statusUpdateLogTask.Result;
var expenseStatuses = expenseStatusTask.Result;
// Generate Expense UID with prefix for current period
string uIDPrefix = $"EX/{DateTime.Now:MMyy}";
var lastExpense = await _context.Expenses
.Where(e => e.UIDPrefix == uIDPrefix)
.OrderByDescending(e => e.UIDPostfix)
.FirstOrDefaultAsync();
int uIDPostfix = lastExpense == null ? 1 : (lastExpense.UIDPostfix + 1);
// Get user IDs involved in review, approval, and processing from logs for audit trail linking
var reviewedLog = statusUpdateLogs.FirstOrDefault(sul => sul.NextStatusId == Approve || sul.NextStatusId == RejectedByReviewer);
var approvedLog = statusUpdateLogs.FirstOrDefault(sul => sul.NextStatusId == ProcessPending || sul.NextStatusId == RejectedByApprover);
var processedLog = statusUpdateLogs.FirstOrDefault(sul => sul.NextStatusId == Processed);
// Create new Expense record replicating required data from PaymentRequest and input model
var expense = new Expenses
{
Id = Guid.NewGuid(),
UIDPrefix = uIDPrefix,
UIDPostfix = uIDPostfix,
ProjectId = paymentRequest.ProjectId!.Value,
ExpenseCategoryId = paymentRequest.ExpenseCategoryId!.Value,
PaymentModeId = model.PaymentModeId,
PaidById = paymentRequest.PaidById!.Value,
CreatedById = loggedInEmployee.Id,
ReviewedById = reviewedLog?.UpdatedById,
ApprovedById = approvedLog?.UpdatedById,
ProcessedById = processedLog?.UpdatedById,
TransactionDate = paymentRequest.PaidAt!.Value,
Description = paymentRequest.Description,
CreatedAt = DateTime.UtcNow,
TransactionId = paymentRequest.PaidTransactionId,
Location = model.Location,
GSTNumber = model.GSTNumber,
SupplerName = paymentRequest.Payee,
CurrencyId = paymentRequest.CurrencyId,
Amount = paymentRequest.Amount,
BaseAmount = paymentRequest.BaseAmount,
TaxAmount = paymentRequest.TaxAmount,
TDSPercentage = paymentRequest.TDSPercentage,
PaymentRequestId = paymentRequest.Id,
StatusId = Processed,
PreApproved = false,
IsActive = true,
TenantId = tenantId
};
_context.Expenses.Add(expense);
// Prepare ExpenseLog entries for each relevant previous status update log with reduced timestamp to preserve order
var millisecondsOffset = 60;
var expenseLogs = statusUpdateLogs
.Where(sul => expenseStatuses.Any(es => es.Id == sul.NextStatusId))
.Select(sul =>
{
var nextStatus = expenseStatuses.FirstOrDefault(es => es.Id == sul.NextStatusId);
var log = new ExpenseLog
{
ExpenseId = expense.Id,
Action = $"Status changed to '{nextStatus?.Name}'",
UpdatedById = loggedInEmployee.Id,
UpdateAt = DateTime.UtcNow.AddMilliseconds(millisecondsOffset),
Comment = $"Status changed to '{nextStatus?.Name}'",
TenantId = tenantId
};
millisecondsOffset -= 1;
return log;
}).ToList();
_context.ExpenseLogs.AddRange(expenseLogs);
// Process and upload bill attachments if present
if (hasAttachments)
{
await ProcessAndUploadAttachmentsAsync(model.BillAttachments!, expense, loggedInEmployee.Id, tenantId);
}
// Mark the payment request as converted to expense to prevent duplicates
paymentRequest.IsExpenseCreated = true;
// Persist all changes within transaction to ensure atomicity
await _context.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogInfo("Expense converted successfully from PaymentRequestId: {PaymentRequestId} by EmployeeId: {EmployeeId}", paymentRequest.Id, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(model, "Expense created successfully.", 201);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ChangeToExpanseFromPaymentRequestAsync for PaymentRequestId: {PaymentRequestId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}: {Message}",
model.PaymentRequestId, tenantId, loggedInEmployee.Id, ex.Message);
await transaction.RollbackAsync();
return ApiResponse<object>.ErrorResponse("An error occurred while converting payment request to expense.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End ChangeToExpanseFromPaymentRequestAsync called by EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
public async Task<ApiResponse<object>> EditPaymentRequestAsync(Guid id, PaymentRequestDto model, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start EditPaymentRequestAsync for PaymentRequestId: {PaymentRequestId}, EmployeeId: {EmployeeId}", id, loggedInEmployee.Id);
if (model.Id == null || id != model.Id)
{
_logger.LogWarning("Mismatch between URL id and payload id: {Id} vs {ModelId}", id, model?.Id ?? Guid.Empty);
return ApiResponse<object>.ErrorResponse("Invalid argument: ID mismatch.", "Invalid argument: ID mismatch.", 400);
}
try
{
// Concurrently fetch related entities to validate input references
var expenseCategoryTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ExpenseCategoryMasters.FirstOrDefaultAsync(et => et.Id == model.ExpenseCategoryId && et.IsActive);
});
var currencyTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.CurrencyMaster.FirstOrDefaultAsync(c => c.Id == model.CurrencyId);
});
var projectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return model.ProjectId.HasValue ? await context.Projects.FirstOrDefaultAsync(p => p.Id == model.ProjectId.Value) : null;
});
var hasManagePermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ExpenseManage, loggedInEmployee.Id);
});
await Task.WhenAll(expenseCategoryTask, currencyTask, projectTask, hasManagePermissionTask);
var expenseCategory = await expenseCategoryTask;
if (expenseCategory == null)
{
_logger.LogWarning("Expense Category not found with Id: {ExpenseCategoryId}", model.ExpenseCategoryId);
return ApiResponse<object>.ErrorResponse("Expense Category not found.", "Expense Category not found.", 404);
}
var currency = await currencyTask;
if (currency == null)
{
_logger.LogWarning("Currency not found with Id: {CurrencyId}", model.CurrencyId);
return ApiResponse<object>.ErrorResponse("Currency not found.", "Currency not found.", 404);
}
var project = await projectTask; // Project can be null (optional)
// Retrieve the existing payment request with relevant navigation properties for validation and mapping
var paymentRequest = await _context.PaymentRequests
.Include(pr => pr.ExpenseCategory)
.Include(pr => pr.Project)
.Include(pr => pr.ExpenseStatus)
.Include(pr => pr.RecurringPayment)
.Include(pr => pr.Currency)
.Include(pr => pr.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(pr => pr.UpdatedBy).ThenInclude(e => e!.JobRole)
.FirstOrDefaultAsync(pr => pr.Id == id);
if (paymentRequest == null)
{
_logger.LogWarning("Payment Request not found with Id: {PaymentRequestId}", id);
return ApiResponse<object>.ErrorResponse("Payment Request not found.", "Payment Request not found.", 404);
}
var hasManagePermission = hasManagePermissionTask.Result;
if (!hasManagePermission && paymentRequest.CreatedById != loggedInEmployee.Id)
{
_logger.LogWarning("Access DENIED: Employee {EmployeeId} has no permission to edit payment requests.", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "You do not have permission to edit any payment request.", 409);
}
// Check if status prevents editing (only allow edit if status Draft, RejectedByReviewer, or RejectedByApprover)
bool statusCheck = paymentRequest.ExpenseStatusId != Draft &&
paymentRequest.ExpenseStatusId != RejectedByReviewer &&
paymentRequest.ExpenseStatusId != RejectedByApprover;
bool isVariableRecurring = paymentRequest.RecurringPayment?.IsVariable ?? false;
// Capture existing state for auditing
var existingEntityBson = _updateLogHelper.EntityToBsonDocument(paymentRequest);
// Only map updates if allowed by status
if (!statusCheck && paymentRequest.CreatedById == loggedInEmployee.Id)
{
_mapper.Map(model, paymentRequest);
paymentRequest.UpdatedAt = DateTime.UtcNow;
paymentRequest.UpdatedById = loggedInEmployee.Id;
}
if (isVariableRecurring)
{
paymentRequest.Amount = model.Amount;
}
paymentRequest.IsAdvancePayment = model.IsAdvancePayment;
var paymentRequestUID = $"{paymentRequest.UIDPrefix}/{paymentRequest.UIDPostfix:D5}";
try
{
await _context.SaveChangesAsync();
_logger.LogInfo("PaymentRequest updated successfully with UID: {PaymentRequestUID}", paymentRequestUID);
}
catch (DbUpdateException dbEx)
{
_logger.LogError(dbEx, "Database Exception during Payment Request update");
return ApiResponse<object>.ErrorResponse("Database exception during payment request updation", ExceptionMapper(dbEx), 500);
}
// Handle bill attachment updates: add new attachments and delete deactivated ones
if (model.BillAttachments?.Any() == true && !statusCheck)
{
var newBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId == null && ba.IsActive).ToList();
if (newBillAttachments.Any())
{
_logger.LogInfo("Processing {AttachmentCount} new attachments for PaymentRequest Id: {PaymentRequestId}", newBillAttachments.Count, paymentRequest.Id);
// Pre-validate base64 data before upload
foreach (var attachment in newBillAttachments)
{
if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data))
{
_logger.LogWarning("Invalid or missing Base64 data for attachment: {FileName}", attachment.FileName ?? "N/A");
throw new ArgumentException($"Invalid or missing Base64 data for attachment: {attachment.FileName ?? "N/A"}");
}
}
var batchId = Guid.NewGuid();
var processingTasks = newBillAttachments.Select(attachment =>
ProcessSinglePaymentRequestAttachmentAsync(attachment, paymentRequest, loggedInEmployee.Id, tenantId, batchId)).ToList();
var results = await Task.WhenAll(processingTasks);
foreach (var (document, paymentRequestAttachment) in results)
{
_context.Documents.Add(document);
_context.PaymentRequestAttachments.Add(paymentRequestAttachment);
}
try
{
await _context.SaveChangesAsync();
_logger.LogInfo("Added {Count} new attachments for PaymentRequest {PaymentRequestId} by Employee {EmployeeId}", newBillAttachments.Count, paymentRequest.Id, loggedInEmployee.Id);
}
catch (DbUpdateException dbEx)
{
_logger.LogError(dbEx, "Database Exception while adding attachments during PaymentRequest update");
return ApiResponse<object>.ErrorResponse("Database exception during attachment addition.", ExceptionMapper(dbEx), 500);
}
}
var deleteBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId != null && !ba.IsActive).ToList();
if (deleteBillAttachments.Any())
{
var documentIds = deleteBillAttachments.Select(d => d.DocumentId!.Value).ToList();
try
{
await DeletePaymentRequestAttachemnts(documentIds);
_logger.LogInfo("Deleted {Count} attachments for PaymentRequest {PaymentRequestId} by Employee {EmployeeId}", deleteBillAttachments.Count, paymentRequest.Id, loggedInEmployee.Id);
}
catch (DbUpdateException dbEx)
{
_logger.LogError(dbEx, "Database Exception while deleting attachments during PaymentRequest update");
return ApiResponse<object>.ErrorResponse("Database exception during attachment deletion.", ExceptionMapper(dbEx), 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected exception while deleting attachments during PaymentRequest update");
return ApiResponse<object>.ErrorResponse("Error occurred while deleting attachments.", ExceptionMapper(ex), 500);
}
}
}
// Log the update audit trail
await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = paymentRequest.Id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = existingEntityBson,
UpdatedAt = DateTime.UtcNow
}, "PaymentRequestModificationLog");
// Prepare response view model with updated details
var response = _mapper.Map<PaymentRequestVM>(paymentRequest);
response.PaymentRequestUID = paymentRequestUID;
response.Currency = currency;
response.ExpenseCategory = _mapper.Map<ExpenseCategoryMasterVM>(expenseCategory);
response.Project = _mapper.Map<BasicProjectVM>(project);
return ApiResponse<object>.SuccessResponse(response, "Payment Request updated successfully.", 200);
}
catch (ArgumentException ex)
{
_logger.LogError(ex, "Argument error in EditPaymentRequestAsync: {Message}", ex.Message);
return ApiResponse<object>.ErrorResponse(ex.Message, "Invalid data.", 400);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error in EditPaymentRequestAsync: {Message}", ex.Message);
return ApiResponse<object>.ErrorResponse("An error occurred while updating the payment request.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End EditPaymentRequestAsync for PaymentRequestId: {PaymentRequestId}", id);
}
}
public async Task<ApiResponse<object>> DeletePaymentRequestAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
{
var paymentRequestTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.PaymentRequests.AsNoTracking().Where(e => e.Id == id && e.ExpenseStatusId == 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(paymentRequestTask, hasAprrovePermissionTask);
var hasAprrovePermission = hasAprrovePermissionTask.Result;
var paymentRequest = paymentRequestTask.Result;
if (paymentRequest == null)
{
var message = hasAprrovePermission ? "Payment Request not found" : "Payment Request cannot be deleted";
if (hasAprrovePermission)
{
_logger.LogWarning("Employee {EmployeeId} attempted to delete payment request {PaymentRequestId}, but not found in database", loggedInEmployee.Id, id);
}
else
{
_logger.LogWarning("Employee {EmployeeId} attempted to delete payment request {PaymentRequestId}, Which is created by another employee", loggedInEmployee.Id, id);
}
return ApiResponse<object>.ErrorResponse(message, message, 400);
}
if (paymentRequest.ExpenseStatusId != Draft)
{
_logger.LogWarning("User attempted to delete payment request with ID {PaymentRequestId}, but donot have status of DRAFT or REJECTED", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Payment Request connot be deleted", "Payment Request connot be deleted", 400);
}
if (!hasAprrovePermission && paymentRequest.CreatedById != loggedInEmployee.Id)
{
_logger.LogWarning("User attempted to delete payment request with ID {PaymentRequestId} which not created by them", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("You donot have access to delete this payment request", "You donot have access to delete this payment request", 400);
}
var documentIds = await _context.PaymentRequestAttachments
.Where(ba => ba.PaymentRequestId == paymentRequest.Id)
.Select(ba => ba.DocumentId)
.ToListAsync();
var existingEntityBson = _updateLogHelper.EntityToBsonDocument(paymentRequest);
paymentRequest.IsActive = false;
_context.PaymentRequests.Update(paymentRequest);
try
{
await _context.SaveChangesAsync();
_logger.LogInfo("Employeee {EmployeeId} successfully deleted the payment request {EmpenseId}", loggedInEmployee.Id, id);
}
catch (DbUpdateException dbEx)
{
_logger.LogError(dbEx, "Databsae Exception occured while deleting payment request");
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
}
try
{
var attachmentDeletionTask = Task.Run(async () =>
{
await DeletePaymentRequestAttachemnts(documentIds);
});
var cacheTask = Task.Run(async () =>
{
await _cache.DeleteExpenseAsync(id, tenantId);
});
var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = paymentRequest.Id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = existingEntityBson,
UpdatedAt = DateTime.UtcNow
}, "PaymentRequestModificationLog");
await Task.WhenAll(attachmentDeletionTask, cacheTask, mongoDBTask);
}
catch (DbUpdateException dbEx)
{
_logger.LogError(dbEx, "Databsae Exception occured while deleting attachments during updating payment request");
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occured while deleting attachments during updating payment request");
return ApiResponse<object>.ErrorResponse("Exception occured while deleting attachments during updating payment request ", ExceptionMapper(ex), 500);
}
return ApiResponse<object>.SuccessResponse("Success", "Payment Request Deleted Successfully", 200);
}
#endregion
#region =================================================================== Recurring Payment Functions ===================================================================
public async Task<ApiResponse<object>> GetRecurringPaymentListAsync(string? searchString, string? filter, bool isActive, int pageSize, int pageNumber, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start GetRecurringPaymentListAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId}, PageNumber: {PageNumber}, PageSize: {PageSize}",
loggedInEmployee.Id, tenantId, pageNumber, pageSize);
try
{
// Check permissions concurrently: view self and view all recurring payments
var hasViewSelfPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ViewSelfRecurring, loggedInEmployee.Id);
});
var hasViewAllPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ViewAllRecurring, loggedInEmployee.Id);
});
await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask);
bool hasViewSelfPermission = hasViewSelfPermissionTask.Result;
bool hasViewAllPermission = hasViewAllPermissionTask.Result;
// Deny access if user has no relevant permissions
if (!hasViewAllPermission && !hasViewSelfPermission)
{
_logger.LogWarning("Access DENIED: Employee {EmployeeId} has no permission to view recurring payments.", loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(new { }, "You do not have permission to view any recurring payment.", 200);
}
// Base query with required navigation properties and tenant + active status filters
var recurringPaymentQuery = _context.RecurringPayments
.Include(rp => rp.Currency)
.Include(rp => rp.ExpenseCategory)
.Include(rp => rp.Status)
.Include(rp => rp.Project)
.Include(rp => rp.CreatedBy).ThenInclude(e => e!.JobRole)
.Where(rp => rp.TenantId == tenantId && rp.IsActive == isActive);
// If user has only view-self permission, restrict to their own payments
if (hasViewSelfPermission && !hasViewAllPermission)
{
recurringPaymentQuery = recurringPaymentQuery.Where(rp => rp.CreatedById == loggedInEmployee.Id);
}
// Deserialize and apply advanced filters
RecurringPaymentFilter? recurringPaymentFilter = TryDeserializeRecurringPaymentFilter(filter);
if (recurringPaymentFilter != null)
{
if (recurringPaymentFilter.ProjectIds?.Any() ?? false)
{
recurringPaymentQuery = recurringPaymentQuery
.Where(rp => rp.ProjectId.HasValue && recurringPaymentFilter.ProjectIds.Contains(rp.ProjectId.Value));
}
if (recurringPaymentFilter.StatusIds?.Any() ?? false)
{
recurringPaymentQuery = recurringPaymentQuery
.Where(rp => recurringPaymentFilter.StatusIds.Contains(rp.StatusId));
}
if (recurringPaymentFilter.CreatedByIds?.Any() ?? false)
{
recurringPaymentQuery = recurringPaymentQuery
.Where(rp => recurringPaymentFilter.CreatedByIds.Contains(rp.CreatedById));
}
if (recurringPaymentFilter.CurrencyIds?.Any() ?? false)
{
recurringPaymentQuery = recurringPaymentQuery
.Where(rp => recurringPaymentFilter.CurrencyIds.Contains(rp.CurrencyId));
}
if (recurringPaymentFilter.ExpenseCategoryIds?.Any() ?? false)
{
recurringPaymentQuery = recurringPaymentQuery
.Where(rp => rp.ExpenseCategoryId.HasValue && recurringPaymentFilter.ExpenseCategoryIds.Contains(rp.ExpenseCategoryId.Value));
}
if (recurringPaymentFilter.Payees?.Any() ?? false)
{
recurringPaymentQuery = recurringPaymentQuery
.Where(rp => recurringPaymentFilter.Payees.Contains(rp.Payee));
}
if (recurringPaymentFilter.StartDate.HasValue && recurringPaymentFilter.EndDate.HasValue)
{
DateTime startDate = recurringPaymentFilter.StartDate.Value.Date;
DateTime endDate = recurringPaymentFilter.EndDate.Value.Date;
recurringPaymentQuery = recurringPaymentQuery
.Where(rp => rp.CreatedAt.Date >= startDate && rp.CreatedAt.Date <= endDate);
}
}
// Apply search string filter if provided
if (!string.IsNullOrWhiteSpace(searchString))
{
recurringPaymentQuery = recurringPaymentQuery
.Where(rp =>
rp.Payee.Contains(searchString) ||
rp.Title.Contains(searchString) ||
(rp.UIDPrefix + "/" + rp.UIDPostfix.ToString().PadLeft(5, '0')).Contains(searchString)
);
}
// Get total count of matching records for pagination
var totalEntities = await recurringPaymentQuery.CountAsync();
// Calculate total pages (ceil)
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
// Fetch paginated data ordered by creation date desc
var recurringPayments = await recurringPaymentQuery
.OrderByDescending(rp => rp.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// Map entities to view models and set recurring payment UID
var results = recurringPayments.Select(rp =>
{
var vm = _mapper.Map<RecurringPaymentVM>(rp);
vm.RecurringPaymentUId = $"{rp.UIDPrefix}/{rp.UIDPostfix:D5}";
return vm;
}).ToList();
var response = new
{
CurrentPage = pageNumber,
TotalPages = totalPages,
TotalEntities = totalEntities,
Data = results,
};
_logger.LogInfo("Recurring payments fetched successfully: {Count} records for TenantId: {TenantId}, Page: {PageNumber}/{TotalPages}",
results.Count, tenantId, pageNumber, totalPages);
return ApiResponse<object>.SuccessResponse(response, $"{results.Count} recurring payments fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in GetRecurringPaymentListAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId}: {Message}", loggedInEmployee.Id, tenantId, ex.Message);
return ApiResponse<object>.ErrorResponse("An error occurred while fetching recurring payments.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End GetRecurringPaymentListAsync called by EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
public async Task<ApiResponse<object>> GetRecurringPaymentDetailsAsync(Guid? id, string? recurringPaymentUId, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start GetRecurringPaymentDetailsAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId} with Id: {Id}, UID: {UID}",
loggedInEmployee.Id, tenantId, id ?? Guid.Empty, recurringPaymentUId ?? "");
try
{
// Validate input: require at least one identifier
if (!id.HasValue && string.IsNullOrWhiteSpace(recurringPaymentUId))
{
_logger.LogWarning("Invalid parameters: Both Id and RecurringPaymentUID are null or empty.");
return ApiResponse<object>.ErrorResponse("At least one parameter (Id or RecurringPaymentUID) must be provided.", "Invalid argument.", 400);
}
// Concurrent permission checks for view-self, view-all, and manage recurring payments
var hasViewSelfPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ViewSelfRecurring, loggedInEmployee.Id);
});
var hasViewAllPermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ViewAllRecurring, loggedInEmployee.Id);
});
var hasManagePermissionTask = Task.Run(async () =>
{
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
return await permissionService.HasPermission(PermissionsMaster.ManageRecurring, loggedInEmployee.Id);
});
await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask, hasManagePermissionTask);
bool hasViewSelfPermission = hasViewSelfPermissionTask.Result;
bool hasViewAllPermission = hasViewAllPermissionTask.Result;
bool hasManagePermission = hasManagePermissionTask.Result;
// Deny access if user lacks all relevant permissions
if (!hasViewSelfPermission && !hasViewAllPermission && !hasManagePermission)
{
_logger.LogWarning("Access DENIED: Employee {EmployeeId} has no permission to view recurring payments.", loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(new { }, "You do not have permission to view any recurring payment.", 200);
}
// Query recurring payment by Id or UID with navigation and tenant checks
var recurringPayment = await _context.RecurringPayments
.Include(rp => rp.Currency)
.Include(rp => rp.Project)
.Include(rp => rp.ExpenseCategory)
.Include(rp => rp.Status)
.Include(rp => rp.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(rp => rp.UpdatedBy).ThenInclude(e => e!.JobRole)
.Where(rp =>
(rp.Id == id || (rp.UIDPrefix + "/" + rp.UIDPostfix.ToString().PadLeft(5, '0')) == recurringPaymentUId) &&
rp.TenantId == tenantId &&
rp.Currency != null &&
rp.ExpenseCategory != null &&
rp.Status != null &&
rp.CreatedBy != null &&
rp.CreatedBy.JobRole != null)
.FirstOrDefaultAsync();
if (recurringPayment == null)
{
_logger.LogWarning("Recurring Payment not found: Id={Id}, UID={UID}, TenantId={TenantId}", id ?? Guid.Empty, recurringPaymentUId ?? "N/A", tenantId);
return ApiResponse<object>.ErrorResponse("Recurring Payment not found.", "Recurring payment not found.", 404);
}
// If user has only view-self permission and the recurring payment belongs to another employee, deny access
bool selfCheck = hasViewSelfPermission && !hasViewAllPermission && !hasManagePermission &&
recurringPayment.CreatedById != loggedInEmployee.Id;
if (selfCheck)
{
_logger.LogWarning("Access DENIED: Employee {EmployeeId} lacks permission to view RecurringPayment {RecurringPaymentId} created by another employee.",
loggedInEmployee.Id, recurringPayment.Id);
return ApiResponse<object>.SuccessResponse(new { }, "You do not have permission to view this recurring payment.", 200);
}
// Concurrently fetch employees notified on this recurring payment and relevant active payment requests
var employeeTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
var emails = recurringPayment.NotifyTo.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return await context.Employees
.Include(e => e.JobRole)
.Where(e => emails.Contains(e.Email) && e.TenantId == tenantId && e.IsActive)
.ToListAsync();
});
var paymentRequestTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.PaymentRequests
.Where(pr => pr.RecurringPaymentId == recurringPayment.Id && pr.TenantId == tenantId && pr.IsActive)
.Select(pr => _mapper.Map<BasicPaymentRequestVM>(pr))
.ToListAsync();
});
await Task.WhenAll(employeeTask, paymentRequestTask);
var employees = employeeTask.Result;
var paymentRequests = paymentRequestTask.Result;
// Map main response DTO and enrich with notification employees and payment requests
var response = _mapper.Map<RecurringPaymentDetailsVM>(recurringPayment);
response.NotifyTo = _mapper.Map<List<BasicEmployeeVM>>(employees);
response.PaymentRequests = paymentRequests;
_logger.LogInfo("Recurring payment details fetched successfully for RecurringPaymentId: {RecurringPaymentId} by EmployeeId: {EmployeeId}",
recurringPayment.Id, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, "Recurring payment details fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in GetRecurringPaymentDetailsAsync for TenantId={TenantId}, EmployeeId={EmployeeId}: {Message}", tenantId, loggedInEmployee.Id, ex.Message);
return ApiResponse<object>.ErrorResponse("An error occurred while fetching the recurring payment details.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End GetRecurringPaymentDetailsAsync called by EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
public async Task<ApiResponse<object>> CreateRecurringPaymentAsync(CreateRecurringTemplateDto model, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start CreateRecurringPaymentAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId}", loggedInEmployee.Id, tenantId);
try
{
// Validate that EndDate is not earlier than today's UTC date
if (DateTime.UtcNow.Date > model.EndDate)
{
// Log a warning for an invalid EndDate value
_logger.LogWarning("Date validation error: EndDate ({EndDate}) cannot be earlier than today's date ({Today}).",
model.EndDate, DateTime.UtcNow.Date);
return ApiResponse<object>.ErrorResponse("End date cannot be earlier than today.", "End date cannot be earlier than today.", 400);
}
// Check if user has permission to create recurring payment templates
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
var hasPermission = await permissionService.HasPermission(PermissionsMaster.ManageRecurring, loggedInEmployee.Id);
if (!hasPermission)
{
_logger.LogWarning("Access denied: Employee {EmployeeId} attempted to create recurring payment template without required permission.", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("You do not have access to create recurring template.", "Permission denied.", 403);
}
// Concurrently fetch related entities required for validation and response mapping
var expenseCategoryTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ExpenseCategoryMasters.FirstOrDefaultAsync(et => et.Id == model.ExpenseCategoryId && et.IsActive);
});
var recurringStatusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.RecurringPaymentStatus.FirstOrDefaultAsync(rs => rs.Id == model.StatusId);
});
var currencyTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.CurrencyMaster.FirstOrDefaultAsync(c => c.Id == model.CurrencyId);
});
var projectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return model.ProjectId.HasValue ? await context.Projects.FirstOrDefaultAsync(p => p.Id == model.ProjectId.Value) : null;
});
await Task.WhenAll(expenseCategoryTask, recurringStatusTask, currencyTask, projectTask);
var expenseCategory = await expenseCategoryTask;
if (expenseCategory == null)
{
_logger.LogWarning("Expense Category not found with Id: {ExpenseCategoryId}", model.ExpenseCategoryId);
return ApiResponse<object>.ErrorResponse("Expense Category not found.", "Expense Category not found.", 404);
}
var recurringStatus = await recurringStatusTask;
if (recurringStatus == null)
{
_logger.LogWarning("Recurring Payment Status not found with Id: {StatusId}", model.StatusId);
return ApiResponse<object>.ErrorResponse("Recurring Payment Status not found.", "Recurring Payment Status not found.", 404);
}
var currency = await currencyTask;
if (currency == null)
{
_logger.LogWarning("Currency not found with Id: {CurrencyId}", model.CurrencyId);
return ApiResponse<object>.ErrorResponse("Currency not found.", "Currency not found.", 404);
}
var project = await projectTask; // Optional, can be null
// Generate unique UID prefix and postfix per current month/year
string uIDPrefix = $"RP/{DateTime.Now:MMyy}";
var lastRecurringPayment = await _context.RecurringPayments
.Where(e => e.UIDPrefix == uIDPrefix)
.OrderByDescending(e => e.UIDPostfix)
.FirstOrDefaultAsync();
int uIDPostfix = lastRecurringPayment == null ? 1 : lastRecurringPayment.UIDPostfix + 1;
// Map input DTO to entity and set additional fields
var recurringPayment = _mapper.Map<RecurringPayment>(model);
recurringPayment.UIDPrefix = uIDPrefix;
recurringPayment.UIDPostfix = uIDPostfix;
recurringPayment.IsActive = true;
recurringPayment.CreatedAt = DateTime.UtcNow;
recurringPayment.CreatedById = loggedInEmployee.Id;
recurringPayment.TenantId = tenantId;
_context.RecurringPayments.Add(recurringPayment);
await _context.SaveChangesAsync();
// Prepare response view model with enriched data
var response = _mapper.Map<RecurringPaymentVM>(recurringPayment);
response.RecurringPaymentUId = $"{recurringPayment.UIDPrefix}/{recurringPayment.UIDPostfix:D5}";
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
response.ExpenseCategory = _mapper.Map<ExpenseCategoryMasterVM>(expenseCategory);
response.Status = recurringStatus;
response.Currency = currency;
response.Project = _mapper.Map<BasicProjectVM>(project);
_logger.LogInfo("Recurring Payment Template created successfully with UID: {RecurringPaymentUId} by EmployeeId: {EmployeeId}", response.RecurringPaymentUId, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, "Recurring Payment Template created successfully.", 201);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in CreateRecurringPaymentAsync called by EmployeeId: {EmployeeId}: {Message}", loggedInEmployee.Id, ex.Message);
return ApiResponse<object>.ErrorResponse("An error occurred while creating the recurring payment template.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End CreateRecurringPaymentAsync called by EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
public async Task<ApiResponse<object>> PaymentRequestConversionAsync(List<Guid> recurringTemplateIds, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start PaymentRequestConversionAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId} with RecurringTemplateIds: {RecurringTemplateIds}",
loggedInEmployee.Id, tenantId, recurringTemplateIds);
// SuperAdmin check - restrict access only to specific user
var superAdminId = Guid.Parse("08dd8b35-d98b-44f1-896d-12aec3f035aa");
if (loggedInEmployee.Id != superAdminId)
{
_logger.LogWarning("Access denied for EmployeeId: {EmployeeId}. Only super admin can perform this operation.", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "User does not have permission to access this function", 403);
}
// Get active recurring payments matching the provided IDs and tenant
var recurringPayments = await _context.RecurringPayments
.AsNoTracking()
.Where(rp => recurringTemplateIds.Contains(rp.Id)
&& rp.NextStrikeDate.HasValue
&& rp.NextStrikeDate.Value.Date == DateTime.UtcNow.Date
&& rp.EndDate.Date >= DateTime.UtcNow.Date
&& rp.IsActive
&& rp.StatusId == ActiveTemplateStatus
&& rp.TenantId == tenantId)
.ToListAsync();
if (!recurringPayments.Any())
{
_logger.LogWarning("No active recurring payments found for TenantId: {TenantId} and given IDs.", tenantId);
return ApiResponse<object>.ErrorResponse("Recurring template not found", "Recurring template not found", 404);
}
var updatedRecurringPayments = new List<RecurringPayment>();
var paymentRequests = new List<PaymentRequest>();
var statusUpdateLogs = new List<StatusUpdateLog>();
// Generate UID prefix for payment requests for this period (month/year)
string uIDPrefix = $"PR/{DateTime.Now:MMyy}";
// Get last generated payment request for UID postfixing (to maintain unique numbering)
var lastPR = await _context.PaymentRequests
.Where(pr => pr.UIDPrefix == uIDPrefix)
.OrderByDescending(pr => pr.UIDPostfix)
.FirstOrDefaultAsync();
int uIDPostfix = lastPR == null ? 1 : lastPR.UIDPostfix + 1;
foreach (var recurringPayment in recurringPayments)
{
if (recurringPayment.NextStrikeDate.HasValue)
{
var nextStrikeDate = GetNextStrikeDate(recurringPayment.Frequency, recurringPayment.NextStrikeDate.Value);
// Update latest generated date to today (UTC)
recurringPayment.LatestPRGeneratedAt = DateTime.UtcNow.Date;
if (nextStrikeDate.Date > recurringPayment.EndDate.Date)
{
recurringPayment.NextStrikeDate = null;
recurringPayment.StatusId = CompletedTemplateStatus;
}
else
{
recurringPayment.NextStrikeDate = nextStrikeDate.Date;
}
updatedRecurringPayments.Add(recurringPayment);
// Create new payment request mapped from recurring payment data
var newPaymentRequest = new PaymentRequest
{
Id = Guid.NewGuid(),
Title = recurringPayment.Title,
Description = recurringPayment.Description,
UIDPrefix = uIDPrefix,
UIDPostfix = uIDPostfix,
Payee = recurringPayment.Payee,
IsAdvancePayment = false,
CurrencyId = recurringPayment.CurrencyId,
Amount = recurringPayment.Amount,
DueDate = DateTime.UtcNow.AddDays(recurringPayment.PaymentBufferDays),
ProjectId = recurringPayment.ProjectId,
RecurringPaymentId = recurringPayment.Id,
ExpenseCategoryId = recurringPayment.ExpenseCategoryId,
ExpenseStatusId = Review,
IsExpenseCreated = false,
CreatedAt = DateTime.UtcNow,
CreatedById = loggedInEmployee.Id,
IsActive = true,
TenantId = recurringPayment.TenantId
};
statusUpdateLogs.Add(new StatusUpdateLog
{
Id = Guid.NewGuid(),
EntityId = newPaymentRequest.Id,
//StatusId = Draft,
NextStatusId = Draft,
UpdatedById = loggedInEmployee.Id,
UpdatedAt = DateTime.UtcNow,
Comment = "Payment request is saved as draft",
TenantId = tenantId
});
statusUpdateLogs.Add(new StatusUpdateLog
{
Id = Guid.NewGuid(),
EntityId = newPaymentRequest.Id,
StatusId = Draft,
NextStatusId = Review,
UpdatedById = loggedInEmployee.Id,
UpdatedAt = DateTime.UtcNow.AddMilliseconds(1),
Comment = "Payment request submited for Review",
TenantId = tenantId
});
paymentRequests.Add(newPaymentRequest);
uIDPostfix++;
}
}
if (!updatedRecurringPayments.Any())
{
_logger.LogWarning("No applicable recurring payments for conversion found for TenantId: {TenantId}.", tenantId);
return ApiResponse<object>.ErrorResponse("No applicable recurring templates found to convert", "No applicable recurring templates found", 404);
}
try
{
// Update recurring payments with latest generated dates
_context.RecurringPayments.UpdateRange(updatedRecurringPayments);
// Add newly created payment requests
if (paymentRequests.Any())
{
_context.PaymentRequests.AddRange(paymentRequests);
}
// Add update logs from new payment requests
if (statusUpdateLogs.Any())
{
_context.StatusUpdateLogs.AddRange(statusUpdateLogs);
}
await _context.SaveChangesAsync();
var newRecurringTemplateIds = updatedRecurringPayments.Select(rp => rp.Id).ToList();
var newRecurringPayments = await _context.RecurringPayments
.Include(rp => rp.Currency)
.Include(rp => rp.ExpenseCategory)
.Include(rp => rp.Status)
.Include(rp => rp.Project)
.AsNoTracking()
.Where(rp => newRecurringTemplateIds.Contains(rp.Id))
.ToListAsync();
_logger.LogInfo("{Count} payment requests created successfully from recurring payments by EmployeeId: {EmployeeId} for TenantId: {TenantId}",
paymentRequests.Count, loggedInEmployee.Id, tenantId);
var response = newRecurringPayments.Select(rp => new
{
RecurringPayment = _mapper.Map<RecurringPaymentVM>(rp),
DueDate = DateTime.UtcNow.AddDays(rp.PaymentBufferDays),
Emails = rp.NotifyTo.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
}).ToList();
return ApiResponse<object>.SuccessResponse(response, $"{paymentRequests.Count} conversion(s) to payment request completed successfully.", 201);
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Database error during PaymentRequestConversionAsync for TenantId: {TenantId}, EmployeeId: {EmployeeId}: {Message}. Inner exception: {InnerException}",
tenantId, loggedInEmployee.Id, ex.Message, ex.InnerException?.Message ?? "");
return ApiResponse<object>.ErrorResponse("Database error occurred", ExceptionMapper(ex), 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during PaymentRequestConversionAsync for TenantId: {TenantId}, EmployeeId: {EmployeeId}: {Message}",
tenantId, loggedInEmployee.Id, ex.Message);
return ApiResponse<object>.ErrorResponse("An unexpected error occurred", ex.Message, 500);
}
finally
{
_logger.LogInfo("End PaymentRequestConversionAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId}", loggedInEmployee.Id, tenantId);
}
}
public async Task<ApiResponse<object>> EditRecurringPaymentAsync(Guid id, UpdateRecurringTemplateDto model, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start EditRecurringPaymentAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId}, RecurringPaymentId: {RecurringPaymentId}",
loggedInEmployee.Id, tenantId, id);
try
{
// 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);
}
// Validate that EndDate is not earlier than today's UTC date
if (DateTime.UtcNow.Date > model.EndDate)
{
// Log a warning for an invalid EndDate value
_logger.LogWarning("Date validation error: EndDate ({EndDate}) cannot be earlier than today's date ({Today}).",
model.EndDate, DateTime.UtcNow.Date);
return ApiResponse<object>.ErrorResponse("End date cannot be earlier than today.", "End date cannot be earlier than today.", 400);
}
// Permission check for managing recurring payments
using var scope = _serviceScopeFactory.CreateScope();
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
var hasPermission = await permissionService.HasPermission(PermissionsMaster.ManageRecurring, loggedInEmployee.Id);
if (!hasPermission)
{
_logger.LogWarning("Access denied: Employee {EmployeeId} attempted to update recurring template without permission.", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("You do not have access to update recurring template.", "Permission denied.", 403);
}
// Concurrently fetch required related entities for validation
var expenseCategoryTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ExpenseCategoryMasters.FirstOrDefaultAsync(et => et.Id == model.ExpenseCategoryId && et.IsActive);
});
var recurringStatusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.RecurringPaymentStatus.FirstOrDefaultAsync(rs => rs.Id == model.StatusId);
});
var currencyTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.CurrencyMaster.FirstOrDefaultAsync(c => c.Id == model.CurrencyId);
});
var projectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return model.ProjectId.HasValue ? await context.Projects.FirstOrDefaultAsync(p => p.Id == model.ProjectId.Value) : null;
});
await Task.WhenAll(expenseCategoryTask, recurringStatusTask, currencyTask, projectTask);
var expenseCategory = await expenseCategoryTask;
if (expenseCategory == null)
{
_logger.LogWarning("Expense Category not found with Id: {ExpenseCategoryId}", model.ExpenseCategoryId);
return ApiResponse<object>.ErrorResponse("Expense Category not found.", "Expense Category not found.", 404);
}
var recurringStatus = await recurringStatusTask;
if (recurringStatus == null)
{
_logger.LogWarning("Recurring Payment Status not found with Id: {StatusId}", model.StatusId);
return ApiResponse<object>.ErrorResponse("Recurring Payment Status not found.", "Recurring Payment Status not found.", 404);
}
var currency = await currencyTask;
if (currency == null)
{
_logger.LogWarning("Currency not found with Id: {CurrencyId}", model.CurrencyId);
return ApiResponse<object>.ErrorResponse("Currency not found.", "Currency not found.", 404);
}
var project = await projectTask; // Optional
// Retrieve the existing recurring payment record for update
var recurringPayment = await _context.RecurringPayments
.FirstOrDefaultAsync(rp => rp.Id == id && rp.IsActive && rp.TenantId == tenantId);
if (recurringPayment == null)
{
_logger.LogWarning("Recurring Payment Template not found with Id: {RecurringPaymentId}", id);
return ApiResponse<object>.ErrorResponse("Recurring Payment Template not found.", "Recurring Payment Template not found.", 404);
}
// Map updated values from DTO to entity
_mapper.Map(model, recurringPayment);
recurringPayment.UpdatedAt = DateTime.UtcNow;
recurringPayment.UpdatedById = loggedInEmployee.Id;
// Save changes to database
await _context.SaveChangesAsync();
// Map entity to view model for response
var response = _mapper.Map<RecurringPaymentVM>(recurringPayment);
response.RecurringPaymentUId = $"{recurringPayment.UIDPrefix}/{recurringPayment.UIDPostfix:D5}";
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
response.ExpenseCategory = _mapper.Map<ExpenseCategoryMasterVM>(expenseCategory);
response.Status = recurringStatus;
response.Currency = currency;
response.Project = _mapper.Map<BasicProjectVM>(project);
_logger.LogInfo("Recurring Payment Template updated successfully with UID: {RecurringPaymentUId} by EmployeeId: {EmployeeId}", response.RecurringPaymentUId, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, "Recurring Payment Template updated successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in EditRecurringPaymentAsync called by EmployeeId: {EmployeeId}: {Message}", loggedInEmployee.Id, ex.Message);
return ApiResponse<object>.ErrorResponse("An error occurred while updating the recurring payment template.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End EditRecurringPaymentAsync called by EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
#endregion
#region =================================================================== Advance Payment Functions ===================================================================
public async Task<ApiResponse<object>> GetAdvancePaymentTransactionAsync(Guid employeeId, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start GetAdvancePaymentTransactionAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId} and EmployeeId param: {ParamEmployeeId}",
loggedInEmployee.Id, tenantId, employeeId);
try
{
// Fetch advance payment transactions for specified employee, including related navigation properties
var transactions = await _context.AdvancePaymentTransactions
.Include(apt => apt.Project)
.Include(apt => apt.Employee).ThenInclude(e => e!.JobRole)
.Include(apt => apt.CreatedBy).ThenInclude(e => e!.JobRole)
.Where(apt => apt.EmployeeId == employeeId && apt.TenantId == tenantId && apt.IsActive)
.OrderByDescending(apt => apt.CreatedAt)
.ToListAsync();
// Check if any transactions found
if (transactions == null || !transactions.Any())
{
_logger.LogWarning("No advance payment transactions found for EmployeeId: {EmployeeId} in TenantId: {TenantId}.", employeeId, tenantId);
return ApiResponse<object>.ErrorResponse("No advance payment transactions found.", null, 404);
}
// Map transactions to view model with formatted FinanceUId
var response = transactions.Select(transaction =>
{
var result = _mapper.Map<AdvancePaymentTransactionVM>(transaction);
result.FinanceUId = $"{transaction.FinanceUIdPrefix}/{transaction.FinanceUIdPostfix:D5}";
return result;
}).ToList();
_logger.LogInfo("Fetched {Count} advance payment transactions for EmployeeId: {EmployeeId} in TenantId: {TenantId}.", response.Count, employeeId, tenantId);
return ApiResponse<object>.SuccessResponse(response, "Advance payment transactions fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception occurred in GetAdvancePaymentTransactionAsync for EmployeeId: {EmployeeId} in TenantId: {TenantId}: {Message}",
employeeId, tenantId, ex.Message);
return ApiResponse<object>.ErrorResponse("Internal exception occurred.", ExceptionMapper(ex), 500);
}
finally
{
_logger.LogInfo("End GetAdvancePaymentTransactionAsync called by EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
public async Task<ApiResponse<object>> GetAdvancePaymentEmployeeListAsync(string? searchString, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start GetAdvancePaymentEmployeeListAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId} with SearchString: {SearchString}",
loggedInEmployee.Id, tenantId, string.IsNullOrWhiteSpace(searchString) ? "NULL or Empty" : searchString);
try
{
// Base query: select employees who have advance payment transactions for the tenant
var employeeQuery = _context.AdvancePaymentTransactions
.Include(apt => apt.Employee)
.ThenInclude(e => e!.JobRole)
.Where(apt => apt.TenantId == tenantId && apt.Employee != null)
.Select(apt => apt.Employee!);
// Apply search filter if provided (concatenate first and last name for full name search)
if (!string.IsNullOrWhiteSpace(searchString))
{
employeeQuery = employeeQuery.Where(e =>
(e.FirstName + " " + e.LastName).Contains(searchString));
}
// Fetch distinct employees matching criteria
var employees = await employeeQuery
.Distinct()
.ToListAsync();
// Map to response view model
var response = _mapper.Map<List<BasicEmployeeVM>>(employees);
_logger.LogInfo("Fetched {Count} employees with advance payments for TenantId: {TenantId}", employees.Count, tenantId);
return ApiResponse<object>.SuccessResponse(response, "List of employees having advance payments fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in GetAdvancePaymentEmployeeListAsync called by EmployeeId: {EmployeeId} for TenantId: {TenantId}: {Message}",
loggedInEmployee.Id, tenantId, ex.Message);
return ApiResponse<object>.ErrorResponse("An error occurred while fetching employees with advance payments.", ex.Message, 500);
}
finally
{
_logger.LogInfo("End GetAdvancePaymentEmployeeListAsync called by EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
#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))
{
response.NextStatus = statusMapping.NextStatus.Where(ns => ns != null && ns.Id != Done).Select(ns => _mapper.Map<ExpensesStatusMasterMongoDB>(ns)).ToList();
}
}
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);
}
/// <summary>
/// Determines if recurring payments are applicable based on frequency, iteration count, and date logic.
/// </summary>
/// <param name="numberOfIteration">Maximum allowed payment iterations.</param>
/// <param name="frequency">Frequency of recurring payments (e.g., Monthly, Quarterly).</param>
/// <param name="strikeDate">The starting date for recurring payments.</param>
/// <param name="latestPRGeneratedAt">The date of the latest payment receipt, if any.</param>
/// <returns>True if recurring payments are applicable; otherwise, false.</returns>
private static bool IsRecurringApplicable(int numberOfIteration, PLAN_FREQUENCY frequency, DateTime strikeDate, DateTime? latestPRGeneratedAt)
{
// Validate input parameters
if (numberOfIteration <= 0)
{
return false;
}
// Ensure strikeDate is in a consistent timezone (UTC or local as per your business logic)
var currentDate = strikeDate.Date;
var endDate = DateTime.UtcNow.Date;
// List to store generated dates for validation
var dates = new List<DateTime>();
// Define increment logic for each frequency
Func<DateTime, DateTime> incrementFunc = frequency switch
{
PLAN_FREQUENCY.MONTHLY => d => d.AddMonths(1),
PLAN_FREQUENCY.QUARTERLY => d => d.AddMonths(3),
PLAN_FREQUENCY.HALF_YEARLY => d => d.AddMonths(6),
PLAN_FREQUENCY.YEARLY => d => d.AddYears(1),
PLAN_FREQUENCY.DAILY => d => d.AddDays(1),
PLAN_FREQUENCY.WEEKLY => d => d.AddDays(7),
_ => d => d.AddDays(1)
};
// Return false if frequency is not supported
if (incrementFunc == null)
{
return false;
}
// Generate dates based on frequency until endDate
while (currentDate <= endDate)
{
dates.Add(currentDate);
currentDate = incrementFunc(currentDate);
}
// Validation: Must have at least one date and not exceed iteration count
if (!dates.Any() || dates.Count > numberOfIteration)
{
return false;
}
// Validation: Last generated date must match endDate
if (dates.Last() != endDate)
{
return false;
}
// Validation: Latest payment receipt should not be on endDate
if (latestPRGeneratedAt.HasValue && latestPRGeneratedAt.Value.Date == endDate)
{
return false;
}
return true;
}
/// <summary>
/// Validates the TDS Percentage in the provided model.
/// </summary>
/// <param name="model">The input model containing TDS Percentage.</param>
/// <returns>Returns an error response if validation fails; otherwise, null.</returns>
private ApiResponse<object>? ValidateTdsPercentage(double? TDSPercentage)
{
// Check if TDSPercentage is present in the model
if (!TDSPercentage.HasValue)
{
return null; // No validation needed if TDSPercentage is not provided
}
var tdsValue = TDSPercentage.Value;
// Validate TDS Percentage range: must be between 0 and 100 inclusive
if (tdsValue < 0 || tdsValue > 100)
{
// Log a warning with structured logging for traceability
_logger.LogWarning("TDS Percentage validation failed. Provided value: {TdsValue} is outside the valid range (0 - 100).", tdsValue);
// Return a consistent and clear error response with HTTP status 400
return ApiResponse<object>.ErrorResponse(
"Invalid TDS Percentage value. Allowed range is 0 to 100 inclusive.",
"TDS Percentage value out of range.",
400);
}
return null; // Validation successful, no error
}
private DateTime GetNextStrikeDate(PLAN_FREQUENCY frequency, DateTime currentStrikeDate)
{
var nextStrikeDate = currentStrikeDate.AddDays(1);
switch (frequency)
{
case PLAN_FREQUENCY.MONTHLY:
nextStrikeDate = currentStrikeDate.AddMonths(1);
break;
case PLAN_FREQUENCY.QUARTERLY:
nextStrikeDate = currentStrikeDate.AddMonths(3);
break;
case PLAN_FREQUENCY.HALF_YEARLY:
nextStrikeDate = currentStrikeDate.AddMonths(6);
break;
case PLAN_FREQUENCY.YEARLY:
nextStrikeDate = currentStrikeDate.AddYears(1);
break;
case PLAN_FREQUENCY.DAILY:
nextStrikeDate = currentStrikeDate.AddDays(1);
break;
case PLAN_FREQUENCY.WEEKLY:
nextStrikeDate = currentStrikeDate.AddDays(7);
break;
}
return nextStrikeDate;
}
#endregion
}
}