3752 lines
199 KiB
C#
3752 lines
199 KiB
C#
using AutoMapper;
|
|
using Marco.Pms.DataAccess.Data;
|
|
using Marco.Pms.Helpers.Utility;
|
|
using Marco.Pms.Model.Dtos.Expenses;
|
|
using Marco.Pms.Model.Employees;
|
|
using Marco.Pms.Model.Entitlements;
|
|
using Marco.Pms.Model.Expenses;
|
|
using Marco.Pms.Model.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.Master;
|
|
using Marco.Pms.Model.ViewModels.Projects;
|
|
using Marco.Pms.Services.Helpers;
|
|
using Marco.Pms.Services.Service.ServiceInterfaces;
|
|
using MarcoBMS.Services.Service;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Text.Json;
|
|
using Document = Marco.Pms.Model.DocumentManager.Document;
|
|
|
|
namespace Marco.Pms.Services.Service
|
|
{
|
|
public class ExpensesService : IExpensesService
|
|
{
|
|
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly ILoggingService _logger;
|
|
private readonly S3UploadService _s3Service;
|
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
|
private readonly UtilityMongoDBHelper _updateLogHelper;
|
|
private readonly CacheUpdateHelper _cache;
|
|
private readonly IMapper _mapper;
|
|
|
|
private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8");
|
|
private static readonly Guid Review = Guid.Parse("6537018f-f4e9-4cb3-a210-6c3b2da999d7");
|
|
private static readonly Guid RejectedByReviewer = Guid.Parse("965eda62-7907-4963-b4a1-657fb0b2724b");
|
|
private static readonly Guid Approve = Guid.Parse("4068007f-c92f-4f37-a907-bc15fe57d4d8");
|
|
private static readonly Guid RejectedByApprover = Guid.Parse("d1ee5eec-24b6-4364-8673-a8f859c60729");
|
|
private static readonly Guid ProcessPending = Guid.Parse("f18c5cfd-7815-4341-8da2-2c2d65778e27");
|
|
private static readonly Guid Processed = Guid.Parse("61578360-3a49-4c34-8604-7b35a3787b95");
|
|
|
|
private static readonly Guid AdvancePayment = Guid.Parse("f67beee6-6763-4108-922c-03bd86b9178d");
|
|
|
|
private static readonly Guid ActiveTemplateStatus = Guid.Parse("da462422-13b2-45cc-a175-910a225f6fc8");
|
|
|
|
|
|
private static readonly string Collection = "ExpensesModificationLog";
|
|
public ExpensesService(
|
|
IDbContextFactory<ApplicationDbContext> dbContextFactory,
|
|
ApplicationDbContext context,
|
|
IServiceScopeFactory serviceScopeFactory,
|
|
UtilityMongoDBHelper updateLogHelper,
|
|
CacheUpdateHelper cache,
|
|
ILoggingService logger,
|
|
S3UploadService s3Service,
|
|
IMapper mapper)
|
|
{
|
|
_dbContextFactory = dbContextFactory;
|
|
_context = context;
|
|
_logger = logger;
|
|
_cache = cache;
|
|
_serviceScopeFactory = serviceScopeFactory;
|
|
_updateLogHelper = updateLogHelper;
|
|
_s3Service = s3Service;
|
|
_mapper = mapper;
|
|
}
|
|
|
|
#region =================================================================== Expense Functions ===================================================================
|
|
|
|
#region =================================================================== Get Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Retrieves a paginated list of expenses based on user permissions and optional filters.
|
|
/// </summary>
|
|
/// <param name="filter">A URL-encoded JSON string containing filter criteria. See <see cref="ExpensesFilter"/>.</param>
|
|
/// <param name="pageSize">The number of records to return per page.</param>
|
|
/// <param name="pageNumber">The page number to retrieve.</param>
|
|
/// <returns>A paginated list of expenses.</returns>
|
|
public async Task<ApiResponse<object>> GetExpensesListAsync(Employee loggedInEmployee, Guid tenantId, string? searchString, string? filter, int pageSize, int pageNumber)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInfo(
|
|
"Attempting to fetch expenses list for PageNumber: {PageNumber}, PageSize: {PageSize} with Filter: {Filter}",
|
|
pageNumber, pageSize, filter ?? "");
|
|
|
|
// 1. --- Get User Permissions ---
|
|
if (loggedInEmployee == null)
|
|
{
|
|
// This is an authentication/authorization issue. The user should be logged in.
|
|
_logger.LogWarning("Could not find an employee for the current logged-in user.");
|
|
return ApiResponse<object>.ErrorResponse("User not found or not authenticated.", 403);
|
|
}
|
|
Guid loggedInEmployeeId = loggedInEmployee.Id;
|
|
List<ExpenseList> expenseVM = new List<ExpenseList>();
|
|
var totalEntites = 0;
|
|
var hasViewSelfPermissionTask = Task.Run(async () =>
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ExpenseViewSelf, loggedInEmployeeId);
|
|
});
|
|
|
|
var hasViewAllPermissionTask = Task.Run(async () =>
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ExpenseViewAll, loggedInEmployeeId);
|
|
});
|
|
|
|
await Task.WhenAll(hasViewSelfPermissionTask, hasViewAllPermissionTask);
|
|
|
|
if (!hasViewAllPermissionTask.Result && !hasViewSelfPermissionTask.Result)
|
|
{
|
|
// User has neither required permission. Deny access.
|
|
_logger.LogWarning("Access DENIED for employee {EmployeeId} attempting to get expenses list.", loggedInEmployeeId);
|
|
return ApiResponse<object>.SuccessResponse(new List<ExpenseList>(), "You do not have permission to view any expenses.", 200);
|
|
}
|
|
|
|
|
|
// 2. --- Deserialize Filter and Apply ---
|
|
ExpensesFilter? expenseFilter = TryDeserializeFilter(filter);
|
|
|
|
//var (totalPages, totalCount, cacheList) = await _cache.GetExpenseListAsync(tenantId, loggedInEmployeeId, hasViewAllPermissionTask.Result, hasViewSelfPermissionTask.Result,
|
|
// pageNumber, pageSize, expenseFilter, searchString);
|
|
|
|
List<ExpenseDetailsMongoDB>? cacheList = null;
|
|
var totalPages = 0;
|
|
var totalCount = 0;
|
|
|
|
// 3. --- Build Base Query and Apply Permissions ---
|
|
// Start with a base IQueryable. Filters will be chained onto this.
|
|
var expensesQuery = _context.Expenses
|
|
.Include(e => e.PaidBy)
|
|
.Include(e => e.CreatedBy)
|
|
.Include(e => e.ProcessedBy)
|
|
.Include(e => e.ApprovedBy)
|
|
.Include(e => e.ReviewedBy)
|
|
.Include(e => e.PaymentMode)
|
|
.Include(e => e.Project)
|
|
.Include(e => e.PaymentMode)
|
|
.Include(e => e.ExpenseCategory)
|
|
.Include(e => e.PaymentRequest)
|
|
.Include(e => e.Status)
|
|
.Include(e => e.Currency)
|
|
.Where(e => e.TenantId == tenantId); // Always filter by TenantId first.
|
|
|
|
if (cacheList == null)
|
|
{
|
|
//await _cache.AddExpensesListToCache(expenses: await expensesQuery.ToListAsync(), tenantId);
|
|
|
|
// Apply permission-based filtering BEFORE any other filters or pagination.
|
|
if (hasViewAllPermissionTask.Result)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployeeId || e.StatusId != Draft);
|
|
}
|
|
else if (hasViewSelfPermissionTask.Result)
|
|
{
|
|
// User only has 'View Self' permission, so restrict the query to their own expenses.
|
|
_logger.LogInfo("User {EmployeeId} has 'View Self' permission. Restricting query to their expenses.", loggedInEmployeeId);
|
|
expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployeeId);
|
|
}
|
|
|
|
if (expenseFilter != null)
|
|
{
|
|
if (expenseFilter.StartDate.HasValue && expenseFilter.EndDate.HasValue)
|
|
{
|
|
if (expenseFilter.IsTransactionDate)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => e.TransactionDate.Date >= expenseFilter.StartDate.Value.Date && e.TransactionDate.Date <= expenseFilter.EndDate.Value.Date);
|
|
}
|
|
else
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => e.CreatedAt.Date >= expenseFilter.StartDate.Value.Date && e.CreatedAt.Date <= expenseFilter.EndDate.Value.Date);
|
|
}
|
|
|
|
}
|
|
|
|
if (expenseFilter.ProjectIds?.Any() == true)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => expenseFilter.ProjectIds.Contains(e.ProjectId));
|
|
}
|
|
|
|
if (expenseFilter.StatusIds?.Any() == true)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => expenseFilter.StatusIds.Contains(e.StatusId));
|
|
}
|
|
|
|
if (expenseFilter.PaidById?.Any() == true)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => expenseFilter.PaidById.Contains(e.PaidById));
|
|
}
|
|
if (expenseFilter.ExpenseCategoryIds?.Any() == true)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => expenseFilter.ExpenseCategoryIds.Contains(e.ExpenseCategoryId));
|
|
}
|
|
|
|
// Only allow filtering by 'CreatedBy' if the user has 'View All' permission.
|
|
if (expenseFilter.CreatedByIds?.Any() == true && hasViewAllPermissionTask.Result)
|
|
{
|
|
expensesQuery = expensesQuery.Where(e => expenseFilter.CreatedByIds.Contains(e.CreatedById));
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(searchString))
|
|
{
|
|
var searchStringLower = searchString.ToLower();
|
|
expensesQuery = expensesQuery.Include(e => e.PaidBy).Include(e => e.CreatedBy)
|
|
.Where(e => e.Description.ToLower().Contains(searchStringLower) ||
|
|
(e.TransactionId != null && e.TransactionId.ToLower().Contains(searchStringLower)) ||
|
|
(e.PaidBy != null && (e.PaidBy.FirstName + " " + e.PaidBy.LastName).ToLower().Contains(searchStringLower)) ||
|
|
(e.CreatedBy != null && (e.CreatedBy.FirstName + " " + e.CreatedBy.LastName).ToLower().Contains(searchStringLower)));
|
|
}
|
|
|
|
// 4. --- Apply Ordering and Pagination ---
|
|
// This should be the last step before executing the query.
|
|
|
|
totalEntites = await expensesQuery.CountAsync();
|
|
|
|
// 5. --- Execute Query and Map Results ---
|
|
var expensesList = await expensesQuery
|
|
.OrderByDescending(e => e.CreatedAt)
|
|
.Skip((pageNumber - 1) * pageSize)
|
|
.Take(pageSize).ToListAsync();
|
|
|
|
if (!expensesList.Any())
|
|
{
|
|
_logger.LogInfo("No expenses found matching the criteria for employee {EmployeeId}.", loggedInEmployeeId);
|
|
return ApiResponse<object>.SuccessResponse(new List<ExpenseList>(), "No expenses found for the given criteria.", 200);
|
|
}
|
|
|
|
//expenseVM = await GetAllExpnesRelatedTables(expensesList, tenantId);
|
|
expenseVM = expensesList.Select(e =>
|
|
{
|
|
var result = _mapper.Map<ExpenseList>(e);
|
|
result.ExpenseUId = $"{e.UIDPrefix}/{e.UIDPostfix:D5}";
|
|
if (e.PaymentRequest != null)
|
|
result.PaymentRequestUID = $"{e.PaymentRequest.UIDPrefix}/{e.PaymentRequest.UIDPostfix:D5}";
|
|
return result;
|
|
}).ToList();
|
|
totalPages = (int)Math.Ceiling((double)totalEntites / pageSize);
|
|
|
|
}
|
|
else
|
|
{
|
|
var permissionStatusMapping = await _context.StatusPermissionMapping
|
|
.GroupBy(ps => ps.StatusId)
|
|
.Select(g => new
|
|
{
|
|
StatusId = g.Key,
|
|
PermissionIds = g.Select(ps => ps.PermissionId).ToList()
|
|
}).ToListAsync();
|
|
|
|
expenseVM = cacheList.Select(m =>
|
|
{
|
|
var response = _mapper.Map<ExpenseList>(m);
|
|
if (response.Status != null && (response.NextStatus?.Any() ?? false))
|
|
{
|
|
response.Status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == Guid.Parse(m.Status.Id)).Select(ps => ps.PermissionIds).FirstOrDefault();
|
|
foreach (var status in response.NextStatus)
|
|
{
|
|
status.PermissionIds = permissionStatusMapping.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault();
|
|
}
|
|
}
|
|
return response;
|
|
}).ToList();
|
|
totalEntites = (int)totalCount;
|
|
}
|
|
// 7. --- Return Final Success Response ---
|
|
var message = $"{expenseVM.Count} expense records fetched successfully.";
|
|
_logger.LogInfo(message);
|
|
|
|
|
|
var response = new
|
|
{
|
|
CurrentFilter = expenseFilter,
|
|
CurrentPage = pageNumber,
|
|
TotalPages = totalPages,
|
|
TotalEntites = totalEntites,
|
|
Data = expenseVM,
|
|
};
|
|
return ApiResponse<object>.SuccessResponse(response, message, 200);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while fetching list expenses");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error occured while fetching list expenses");
|
|
return ApiResponse<object>.ErrorResponse("Error Occured", ExceptionMapper(ex), 500);
|
|
}
|
|
}
|
|
public async Task<ApiResponse<object>> GetExpenseDetailsAsync(Guid? id, string? expenseUId, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
try
|
|
{
|
|
if (!id.HasValue && string.IsNullOrWhiteSpace(expenseUId))
|
|
{
|
|
_logger.LogWarning("Invalid parameters: Both Id and PaymentRequestUID are null or empty.");
|
|
return ApiResponse<object>.ErrorResponse("At least one parameter (Id or expenseUId) must be provided.", "Invalid argument.", 400);
|
|
}
|
|
ExpenseDetailsMongoDB? expenseDetails = null;
|
|
if (expenseDetails == null)
|
|
{
|
|
var expense = await _context.Expenses
|
|
.Include(e => e.PaidBy)
|
|
.Include(e => e.CreatedBy)
|
|
.Include(e => e.ProcessedBy)
|
|
.Include(e => e.ApprovedBy)
|
|
.Include(e => e.ReviewedBy)
|
|
.Include(e => e.PaymentMode)
|
|
.Include(e => e.Project)
|
|
.Include(e => e.PaymentMode)
|
|
.Include(e => e.ExpenseCategory)
|
|
.Include(e => e.Status)
|
|
.Include(e => e.Currency)
|
|
.Include(e => e.PaymentRequest)
|
|
.AsNoTracking().FirstOrDefaultAsync(e => (e.Id == id || (e.UIDPrefix + "/" + e.UIDPostfix.ToString().PadLeft(5, '0')) == expenseUId) && e.TenantId == tenantId);
|
|
|
|
if (expense == null)
|
|
{
|
|
if (id.HasValue)
|
|
{
|
|
_logger.LogWarning("User attempted to fetch expense details with ID {ExpenseId}, but not found in both database and cache", id);
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(expenseUId))
|
|
{
|
|
_logger.LogWarning("User attempted to fetch expense details with expenseUId {ExpenseUId}, but not found in both database and cache", expenseUId);
|
|
}
|
|
return ApiResponse<object>.ErrorResponse("Expense Not Found", "Expense Not Found", 404);
|
|
}
|
|
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
var hasManagePermission = await permissionService.HasPermission(PermissionsMaster.ExpenseManage, loggedInEmployee.Id);
|
|
|
|
expenseDetails = await GetAllExpnesRelatedTablesForSingle(expense, hasManagePermission, loggedInEmployee.Id, expense.TenantId);
|
|
}
|
|
var vm = _mapper.Map<ExpenseDetailsVM>(expenseDetails);
|
|
|
|
var permissionStatusMappingTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.StatusPermissionMapping
|
|
.GroupBy(ps => ps.StatusId)
|
|
.Select(g => new
|
|
{
|
|
StatusId = g.Key,
|
|
PermissionIds = g.Select(ps => ps.PermissionId).ToList()
|
|
}).ToListAsync();
|
|
});
|
|
var expenseReimburseTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesReimburseMapping
|
|
.Include(er => er.ExpensesReimburse)
|
|
.ThenInclude(er => er!.ReimburseBy)
|
|
.ThenInclude(e => e!.JobRole)
|
|
.Where(er => er.TenantId == tenantId && er.ExpensesId == vm.Id)
|
|
.Select(er => er.ExpensesReimburse).FirstOrDefaultAsync();
|
|
});
|
|
var permissionStatusMappings = permissionStatusMappingTask.Result;
|
|
var expensesReimburse = expenseReimburseTask.Result;
|
|
|
|
if (vm.Status != null && (vm.NextStatus?.Any() ?? false))
|
|
{
|
|
vm.Status.PermissionIds = permissionStatusMappings.Where(ps => ps.StatusId == vm.Status.Id).Select(ps => ps.PermissionIds).FirstOrDefault();
|
|
foreach (var status in vm.NextStatus)
|
|
{
|
|
status.PermissionIds = permissionStatusMappings.Where(ps => ps.StatusId == status.Id).Select(ps => ps.PermissionIds).FirstOrDefault();
|
|
}
|
|
int index = vm.NextStatus.FindIndex(ns => ns.DisplayName == "Reject");
|
|
if (index > -1)
|
|
{
|
|
var item = vm.NextStatus[index];
|
|
vm.NextStatus.RemoveAt(index);
|
|
vm.NextStatus.Insert(0, item);
|
|
}
|
|
}
|
|
vm.ExpensesReimburse = _mapper.Map<ExpensesReimburseVM>(expensesReimburse);
|
|
|
|
foreach (var document in expenseDetails.Documents)
|
|
{
|
|
var response = vm.Documents.FirstOrDefault(d => d.DocumentId == Guid.Parse(document.DocumentId));
|
|
|
|
response!.PreSignedUrl = _s3Service.GeneratePreSignedUrl(document.S3Key);
|
|
response!.ThumbPreSignedUrl = _s3Service.GeneratePreSignedUrl(document.ThumbS3Key);
|
|
}
|
|
|
|
var expenselogs = await _context.ExpenseLogs.Include(el => el.UpdatedBy).Where(el => el.ExpenseId == vm.Id).Select(el => _mapper.Map<ExpenseLogVM>(el)).ToListAsync();
|
|
|
|
vm.ExpenseLogs = expenselogs;
|
|
|
|
_logger.LogInfo("Employee {EmployeeId} successfully fetched expense details with ID {ExpenseId}", loggedInEmployee.Id, vm.Id);
|
|
return ApiResponse<object>.SuccessResponse(vm, "Successfully fetched the details of expense", 200);
|
|
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "An unhandled exception occurred while fetching an expense details {ExpenseId} or {ExpenseUId}.", id ?? Guid.Empty, expenseUId ?? "EX_00000");
|
|
return ApiResponse<object>.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500);
|
|
}
|
|
}
|
|
public async Task<ApiResponse<object>> GetSupplerNameListAsync(Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
try
|
|
{
|
|
var supplerNameList = await _context.Expenses.Where(e => e.TenantId == tenantId).Select(e => e.SupplerName).Distinct().ToListAsync();
|
|
_logger.LogInfo("Employee {EmployeeId} fetched list of suppler names from expenses in a tenant {TenantId}", loggedInEmployee.Id, tenantId);
|
|
return ApiResponse<object>.SuccessResponse(supplerNameList, $"{supplerNameList.Count} records of suppler names fetched from expense", 200);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while fetching suppler name list from expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
|
|
}
|
|
}
|
|
public async Task<ApiResponse<object>> GetFilterObjectAsync(Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
try
|
|
{
|
|
var expenses = await _context.Expenses
|
|
.Include(e => e.PaidBy)
|
|
.Include(e => e.Project)
|
|
.Include(e => e.CreatedBy)
|
|
.Include(e => e.Status)
|
|
.Include(e => e.ExpenseCategory)
|
|
.Where(e => e.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
// Construct the final object from the results of the completed tasks.
|
|
var response = new
|
|
{
|
|
Projects = expenses.Where(e => e.Project != null).Select(e => new { Id = e.Project!.Id, Name = e.Project.Name }).Distinct().ToList(),
|
|
PaidBy = expenses.Where(e => e.PaidBy != null).Select(e => new { Id = e.PaidBy!.Id, Name = $"{e.PaidBy.FirstName} {e.PaidBy.LastName}" }).Distinct().ToList(),
|
|
CreatedBy = expenses.Where(e => e.CreatedBy != null).Select(e => new { Id = e.CreatedBy!.Id, Name = $"{e.CreatedBy.FirstName} {e.CreatedBy.LastName}" }).Distinct().ToList(),
|
|
Status = expenses.Where(e => e.Status != null).Select(e => new { Id = e.Status!.Id, Name = e.Status.Name }).Distinct().ToList(),
|
|
ExpensesCategory = expenses.Where(e => e.ExpenseCategory != null).Select(e => new { Id = e.ExpenseCategory!.Id, Name = e.ExpenseCategory.Name }).Distinct().ToList()
|
|
};
|
|
return ApiResponse<object>.SuccessResponse(response, "Successfully fetched the filter list", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception occured while fetching the list filters for expenses");
|
|
return ApiResponse<object>.ErrorResponse("Internal Exception Occured", ExceptionMapper(ex), 500);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Post Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Creates a new expense entry along with its bill attachments.
|
|
/// This operation is transactional and performs validations and file uploads concurrently for optimal performance
|
|
/// by leveraging async/await without unnecessary thread-pool switching via Task.Run.
|
|
/// </summary>
|
|
/// <param name="dto">The data transfer object containing expense details and attachments.</param>
|
|
/// <returns>An IActionResult indicating the result of the creation operation.</returns>
|
|
public async Task<ApiResponse<object>> CreateExpenseAsync(CreateExpensesDto dto, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
_logger.LogDebug("Starting CreateExpense for Project {ProjectId}", dto.ProjectId);
|
|
// The entire operation is wrapped in a transaction to ensure data consistency.
|
|
await using var transaction = await _context.Database.BeginTransactionAsync();
|
|
|
|
try
|
|
{
|
|
// 1. Authorization & Validation: Run all I/O-bound checks concurrently using factories for safety.
|
|
|
|
// PERMISSION CHECKS: Use IServiceScopeFactory for thread-safe access to scoped services.
|
|
var hasUploadPermissionTask = Task.Run(async () => // Task.Run is acceptable here to create a new scope, but let's do it cleaner.
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ExpenseUpload, loggedInEmployee.Id);
|
|
});
|
|
|
|
// VALIDATION CHECKS: Use IDbContextFactory for thread-safe, parallel database queries.
|
|
// Each task gets its own DbContext instance.
|
|
var projectTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == dto.ProjectId);
|
|
});
|
|
var paidByTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Employees.AsNoTracking().FirstOrDefaultAsync(e => e.Id == dto.PaidById);
|
|
});
|
|
var expenseCategoriesTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpenseCategoryMasters.AsNoTracking().FirstOrDefaultAsync(et => et.Id == dto.ExpenseCategoryId);
|
|
});
|
|
var paymentModeTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.PaymentModeMatser.AsNoTracking().FirstOrDefaultAsync(pm => pm.Id == dto.PaymentModeId);
|
|
});
|
|
var statusMappingTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesStatusMapping
|
|
.Include(s => s.Status)
|
|
.Include(s => s.NextStatus)
|
|
.AsNoTracking()
|
|
.Where(es => es.StatusId == Draft && es.Status != null)
|
|
.GroupBy(s => s.StatusId)
|
|
.Select(g => new
|
|
{
|
|
Status = g.Select(s => s.Status).FirstOrDefault(),
|
|
NextStatus = g.Select(s => s.NextStatus).ToList()
|
|
})
|
|
.FirstOrDefaultAsync();
|
|
});
|
|
|
|
|
|
// Await all prerequisite checks at once.
|
|
await Task.WhenAll(hasUploadPermissionTask, projectTask, expenseCategoriesTask, paymentModeTask, statusMappingTask, paidByTask);
|
|
|
|
// 2. Aggregate and Check Results
|
|
if (!await hasUploadPermissionTask)
|
|
{
|
|
_logger.LogWarning("Access DENIED for employee {EmployeeId} on project {ProjectId}.", loggedInEmployee.Id, dto.ProjectId);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied.", "You do not have permission to upload expenses for this project.", 403);
|
|
}
|
|
|
|
var validationErrors = new List<string>();
|
|
var project = await projectTask;
|
|
var expenseCategory = await expenseCategoriesTask;
|
|
var paymentMode = await paymentModeTask;
|
|
var statusMapping = await statusMappingTask;
|
|
var paidBy = await paidByTask;
|
|
|
|
if (project == null) validationErrors.Add("Project not found.");
|
|
if (paidBy == null) validationErrors.Add("Paid by employee not found");
|
|
if (expenseCategory == null) validationErrors.Add("Expense Category not found.");
|
|
if (paymentMode == null) validationErrors.Add("Payment Mode not found.");
|
|
if (statusMapping == null) validationErrors.Add("Default status 'Draft' not found.");
|
|
if ((expenseCategory?.IsAttachmentRequried ?? true) && !(dto.BillAttachments?.Any() ?? false)) validationErrors.Add("Bill Attachment is requried, but not found");
|
|
|
|
if (validationErrors.Any())
|
|
{
|
|
await transaction.RollbackAsync();
|
|
var errorMessage = string.Join(" ", validationErrors);
|
|
_logger.LogWarning("Expense creation failed due to validation errors: {ValidationErrors}", errorMessage);
|
|
return ApiResponse<object>.ErrorResponse("Invalid input data.", errorMessage, 400);
|
|
}
|
|
|
|
|
|
string uIDPrefix = $"EX/{DateTime.Now:MMyy}";
|
|
|
|
// Generate unique UID postfix based on existing requests for the current prefix
|
|
var lastPR = await _context.Expenses.Where(pr => pr.UIDPrefix == uIDPrefix)
|
|
.OrderByDescending(pr => pr.UIDPostfix)
|
|
.FirstOrDefaultAsync();
|
|
int uIDPostfix = lastPR == null ? 1 : (lastPR.UIDPostfix + 1);
|
|
|
|
// 3. Entity Creation
|
|
var expense = _mapper.Map<Expenses>(dto);
|
|
expense.CurrencyId = dto.CurrencyId ?? Guid.Parse("78e96e4a-7ce0-4164-ae3a-c833ad45ec2c");
|
|
expense.UIDPostfix = uIDPostfix;
|
|
expense.UIDPrefix = uIDPrefix;
|
|
expense.CreatedById = loggedInEmployee.Id;
|
|
expense.CreatedAt = DateTime.UtcNow;
|
|
expense.TenantId = tenantId;
|
|
expense.IsActive = true;
|
|
expense.StatusId = Draft;
|
|
|
|
_context.Expenses.Add(expense);
|
|
|
|
// 4. Process Attachments
|
|
if (dto.BillAttachments?.Any() ?? false)
|
|
{
|
|
await ProcessAndUploadAttachmentsAsync(dto.BillAttachments, expense, loggedInEmployee.Id, tenantId);
|
|
}
|
|
|
|
var expenseLog = new ExpenseLog
|
|
{
|
|
ExpenseId = expense.Id,
|
|
Action = $"Status changed to '{statusMapping!.Status?.Name}'",
|
|
UpdatedById = loggedInEmployee.Id,
|
|
UpdateAt = DateTime.UtcNow,
|
|
Comment = $"Status changed to '{statusMapping!.Status?.Name}'",
|
|
TenantId = tenantId
|
|
};
|
|
|
|
_context.ExpenseLogs.Add(expenseLog);
|
|
|
|
// 5. Database Commit
|
|
await _context.SaveChangesAsync();
|
|
|
|
// 6. Transaction Commit
|
|
await transaction.CommitAsync();
|
|
|
|
await _cache.AddExpenseByObjectAsync(expense);
|
|
|
|
var response = _mapper.Map<ExpenseList>(expense);
|
|
response.ExpenseUId = $"{expense.UIDPrefix}/{expense.UIDPostfix:D5}";
|
|
response.PaidBy = _mapper.Map<BasicEmployeeVM>(paidBy);
|
|
response.Project = _mapper.Map<ProjectInfoVM>(project);
|
|
response.Status = _mapper.Map<ExpensesStatusMasterVM>(statusMapping!.Status);
|
|
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(statusMapping.NextStatus);
|
|
response.PaymentMode = _mapper.Map<PaymentModeMatserVM>(paymentMode);
|
|
response.ExpenseCategory = _mapper.Map<ExpensesCategoryMasterVM>(expenseCategory);
|
|
|
|
_logger.LogInfo("Successfully created Expense {ExpenseId} for Project {ProjectId}.", expense.Id, expense.ProjectId);
|
|
return ApiResponse<object>.SuccessResponse(response, "Expense created successfully.", 201);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
await transaction.RollbackAsync();
|
|
_logger.LogError(dbEx, "Databsae Exception occured while adding expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
|
|
}
|
|
catch (ArgumentException ex) // Catches bad Base64 from attachment pre-validation
|
|
{
|
|
_logger.LogError(ex, "Invalid argument during expense creation for project {ProjectId}.", dto.ProjectId);
|
|
return ApiResponse<object>.ErrorResponse("Invalid Request Data.", ExceptionMapper(ex), 400);
|
|
}
|
|
catch (Exception ex) // General-purpose catch for unexpected errors (e.g., S3 or DB connection failure)
|
|
{
|
|
_logger.LogError(ex, "An unhandled exception occurred while creating an expense for project {ProjectId}.", dto.ProjectId);
|
|
return ApiResponse<object>.ErrorResponse("An internal server error occurred.", ExceptionMapper(ex), 500);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Changes the status of an expense record, performing validation, permission checks, and logging.
|
|
/// </summary>
|
|
/// <param name="model">The DTO containing the expense ID and the target status ID.</param>
|
|
/// <param name="loggedInEmployee">The employee performing the action.</param>
|
|
/// <param name="tenantId">The ID of the tenant owning the expense.</param>
|
|
/// <returns>An ApiResponse containing the updated expense details or an error.</returns>
|
|
public async Task<ApiResponse<object>> ChangeStatusAsync(ExpenseRecordDto model, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
|
|
|
|
// 1. Fetch Existing Expense with Related Entities (Single Query)
|
|
var expense = await _context.Expenses
|
|
.Include(e => e.ExpenseCategory)
|
|
.Include(e => e.Project)
|
|
.Include(e => e.PaidBy).ThenInclude(e => e!.JobRole)
|
|
.Include(e => e.PaymentMode)
|
|
.Include(e => e.Status)
|
|
.Include(e => e.Currency)
|
|
.Include(e => e.CreatedBy)
|
|
.Include(e => e.ReviewedBy)
|
|
.Include(e => e.ApprovedBy)
|
|
.Include(e => e.ProcessedBy)
|
|
.FirstOrDefaultAsync(e =>
|
|
e.Id == model.ExpenseId &&
|
|
e.StatusId != model.StatusId &&
|
|
e.TenantId == tenantId
|
|
);
|
|
|
|
if (expense == null)
|
|
{
|
|
_logger.LogWarning("ChangeStatus: Expense not found or already at target status. ExpenseId={ExpenseId}, TenantId={TenantId}", model.ExpenseId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Expense not found or status is already set.", "Expense not found", 404);
|
|
}
|
|
|
|
_logger.LogInfo("ChangeStatus: Requested status change. ExpenseId={ExpenseId} FromStatus={FromStatusId} ToStatus={ToStatusId}",
|
|
expense.Id, expense.StatusId, model.StatusId);
|
|
|
|
// 2. Run Prerequisite Checks in Parallel (Status transition + Permissions)
|
|
var processedStatusTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesStatusMaster
|
|
.FirstOrDefaultAsync(es => es.Id == Processed);
|
|
});
|
|
var statusTransitionTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.ExpensesStatusMapping
|
|
.Include(m => m.NextStatus)
|
|
.FirstOrDefaultAsync(m => m.StatusId == expense.StatusId && m.NextStatusId == model.StatusId);
|
|
});
|
|
|
|
var targetStatusPermissionsTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.StatusPermissionMapping
|
|
.Where(spm => spm.StatusId == model.StatusId)
|
|
.ToListAsync();
|
|
});
|
|
|
|
await Task.WhenAll(statusTransitionTask, targetStatusPermissionsTask, processedStatusTask);
|
|
var statusTransition = statusTransitionTask.Result;
|
|
var requiredPermissions = targetStatusPermissionsTask.Result;
|
|
var processedStatus = processedStatusTask.Result;
|
|
|
|
// 3. Validate Transition and Required Fields
|
|
if (statusTransition == null)
|
|
{
|
|
_logger.LogWarning("ChangeStatus: Invalid status transition. ExpenseId={ExpenseId}, FromStatus={FromStatus}, ToStatus={ToStatus}",
|
|
expense.Id, expense.StatusId, model.StatusId);
|
|
return ApiResponse<object>.ErrorResponse("Status change is not permitted.", "Invalid Transition", 400);
|
|
}
|
|
|
|
// Validate special logic for "Processed"
|
|
if (statusTransition.NextStatusId == Processed &&
|
|
(string.IsNullOrWhiteSpace(model.ReimburseTransactionId) ||
|
|
!model.ReimburseDate.HasValue ||
|
|
model.ReimburseById == null ||
|
|
model.ReimburseById == Guid.Empty))
|
|
{
|
|
_logger.LogWarning("ChangeStatus: Missing reimbursement fields for 'Processed'. ExpenseId={ExpenseId}", expense.Id);
|
|
return ApiResponse<object>.ErrorResponse("Reimbursement details are missing or invalid.", "Invalid Reimbursement", 400);
|
|
}
|
|
|
|
// 4. Permission Check (CreatedBy -> Reviewer bypass, else required permissions)
|
|
bool hasPermission = false;
|
|
if (model.StatusId == Review && expense.CreatedById == loggedInEmployee.Id)
|
|
{
|
|
hasPermission = true;
|
|
}
|
|
else if (requiredPermissions.Any())
|
|
{
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
foreach (var permission in requiredPermissions)
|
|
{
|
|
if (await permissionService.HasPermission(permission.PermissionId, loggedInEmployee.Id) && model.StatusId != Review)
|
|
{
|
|
hasPermission = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasPermission)
|
|
{
|
|
_logger.LogWarning("ChangeStatus: Permission denied. EmployeeId={EmployeeId} ExpenseId={ExpenseId} ToStatus={ToStatusId}",
|
|
loggedInEmployee.Id, expense.Id, model.StatusId);
|
|
return ApiResponse<object>.ErrorResponse("You do not have permission for this action.", "Access Denied", 403);
|
|
}
|
|
|
|
// 5. Prepare for update (Audit snapshot)
|
|
var expenseStateBeforeChange = _updateLogHelper.EntityToBsonDocument(expense);
|
|
|
|
var expenseLogs = new List<ExpenseLog>
|
|
{
|
|
new ExpenseLog
|
|
{
|
|
ExpenseId = expense.Id,
|
|
Action = $"Status changed to '{statusTransition.NextStatus?.Name}'",
|
|
UpdatedById = loggedInEmployee.Id,
|
|
UpdateAt = DateTime.UtcNow,
|
|
Comment = model.Comment,
|
|
TenantId = tenantId
|
|
},
|
|
};
|
|
|
|
// 6. Apply Status Transition
|
|
if (model.StatusId == ProcessPending && expense.PaymentModeId == AdvancePayment)
|
|
{
|
|
expense.StatusId = Processed;
|
|
expense.Status = processedStatus;
|
|
expense.ProcessedById = loggedInEmployee.Id;
|
|
|
|
var lastTransaction = await _context.AdvancePaymentTransactions.OrderByDescending(apt => apt.CreatedAt).FirstOrDefaultAsync(apt => apt.TenantId == tenantId);
|
|
double lastBalance = 0;
|
|
if (lastTransaction != null)
|
|
{
|
|
lastBalance = lastTransaction.CurrentBalance;
|
|
}
|
|
|
|
_context.AdvancePaymentTransactions.Add(new AdvancePaymentTransaction
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
FinanceUIdPostfix = expense.UIDPostfix,
|
|
FinanceUIdPrefix = expense.UIDPrefix,
|
|
Title = expense.Description,
|
|
ProjectId = expense.ProjectId,
|
|
EmployeeId = expense.PaidById,
|
|
Amount = 0 - expense.Amount,
|
|
CurrentBalance = lastBalance - expense.Amount,
|
|
PaidAt = expense.TransactionDate,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CreatedById = loggedInEmployee.Id,
|
|
IsActive = true,
|
|
TenantId = tenantId
|
|
});
|
|
|
|
var expenseLog = new ExpenseLog
|
|
{
|
|
ExpenseId = expense.Id,
|
|
Action = $"Status changed to '{processedStatus?.Name}'",
|
|
UpdatedById = loggedInEmployee.Id,
|
|
UpdateAt = DateTime.UtcNow,
|
|
Comment = model.Comment,
|
|
TenantId = tenantId
|
|
};
|
|
expenseLogs.Add(expenseLog);
|
|
}
|
|
else
|
|
{
|
|
expense.StatusId = statusTransition.NextStatusId;
|
|
expense.Status = statusTransition.NextStatus;
|
|
}
|
|
// Handle reviewer/approver/processor fields based on target StatusId (Guid)
|
|
if (model.StatusId == Approve || model.StatusId == RejectedByReviewer)
|
|
{
|
|
expense.ReviewedById = loggedInEmployee.Id;
|
|
}
|
|
else if (model.StatusId == ProcessPending || model.StatusId == RejectedByApprover)
|
|
{
|
|
expense.ApprovedById = loggedInEmployee.Id;
|
|
}
|
|
else if (model.StatusId == Processed)
|
|
{
|
|
var totalAmount = model.BaseAmount + model.TaxAmount;
|
|
if (!totalAmount.HasValue || totalAmount != expense.Amount)
|
|
{
|
|
// Log the mismatch error with relevant details
|
|
_logger.LogWarning("Payment amount mismatch: calculated totalAmount = {TotalAmount}, expected Amount = {ExpectedAmount}", totalAmount ?? 0, expense.Amount);
|
|
|
|
// Return a structured error response indicating the amount discrepancy
|
|
return ApiResponse<object>.ErrorResponse(
|
|
"Payment amount validation failed.",
|
|
$"The sum of the base amount and tax amount ({totalAmount}) does not match the expected expense amount ({expense.Amount}).",
|
|
400);
|
|
}
|
|
expense.ProcessedById = loggedInEmployee.Id;
|
|
expense.BaseAmount = model.BaseAmount;
|
|
expense.TaxAmount = model.TaxAmount;
|
|
}
|
|
|
|
// 7. Add Reimbursement if applicable
|
|
if (model.StatusId == Processed)
|
|
{
|
|
expense.TDSPercentage = model.TDSPercentage;
|
|
var reimbursement = new ExpensesReimburse
|
|
{
|
|
ReimburseTransactionId = model.ReimburseTransactionId!,
|
|
ReimburseDate = model.ReimburseDate!.Value,
|
|
ReimburseById = model.ReimburseById!.Value,
|
|
ReimburseNote = model.Comment ?? string.Empty,
|
|
TenantId = tenantId
|
|
};
|
|
_context.ExpensesReimburse.Add(reimbursement);
|
|
_context.ExpensesReimburseMapping.Add(new ExpensesReimburseMapping
|
|
{
|
|
ExpensesId = expense.Id,
|
|
ExpensesReimburseId = reimbursement.Id,
|
|
TenantId = tenantId
|
|
});
|
|
}
|
|
|
|
// 8. Add Expense Log Entry
|
|
_context.ExpenseLogs.AddRange(expenseLogs);
|
|
|
|
// 9. Commit database transaction
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInfo("ChangeStatus: Status updated successfully. ExpenseId={ExpenseId} NewStatus={NewStatusId}", expense.Id, expense.StatusId);
|
|
}
|
|
catch (DbUpdateConcurrencyException ex)
|
|
{
|
|
_logger.LogError(ex, "ChangeStatus: Concurrency error. ExpenseId={ExpenseId}", expense.Id);
|
|
return ApiResponse<object>.ErrorResponse("Expense was modified by another user. Please refresh and try again.", "Concurrency Error", 409);
|
|
}
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
// --- Push Notification Section ---
|
|
// This section attempts to send a test push notification to the user's device.
|
|
// It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens.
|
|
|
|
var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}";
|
|
|
|
await _firebase.SendExpenseMessageAsync(expense, name, tenantId);
|
|
|
|
});
|
|
|
|
// 10. Post-processing (audit log, cache, fetch next states)
|
|
try
|
|
{
|
|
var auditLogTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = expense.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = expenseStateBeforeChange,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, Collection);
|
|
|
|
var cacheUpdateTask = _cache.ReplaceExpenseAsync(expense);
|
|
|
|
var getNextStatusesTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(async t =>
|
|
{
|
|
var dbContext = t.Result;
|
|
var nextStatuses = await dbContext.ExpensesStatusMapping
|
|
.Include(m => m.NextStatus)
|
|
.Where(m => m.StatusId == expense.StatusId && m.NextStatus != null)
|
|
.Select(m => m.NextStatus)
|
|
.ToListAsync();
|
|
await dbContext.DisposeAsync();
|
|
return nextStatuses;
|
|
}).Unwrap();
|
|
|
|
await Task.WhenAll(auditLogTask, getNextStatusesTask, cacheUpdateTask);
|
|
|
|
// Prepare response with possible next states
|
|
var nextPossibleStatuses = await getNextStatusesTask;
|
|
var responseDto = _mapper.Map<ExpenseList>(expense);
|
|
if (nextPossibleStatuses is { Count: > 0 })
|
|
responseDto.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(nextPossibleStatuses);
|
|
|
|
return ApiResponse<object>.SuccessResponse(responseDto);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "ChangeStatus: Post-operation error (e.g. audit logging). ExpenseId={ExpenseId}", expense.Id);
|
|
var responseDto = _mapper.Map<ExpenseList>(expense);
|
|
return ApiResponse<object>.SuccessResponse(responseDto, "Status updated, but audit logging or cache update failed.");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Put Functions ===================================================================
|
|
|
|
public async Task<ApiResponse<object>> UpdateExpanseAsync(Guid id, UpdateExpensesDto model, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
// Validate if the employee Id from the URL path matches the Id in the request body (model)
|
|
if (id != model.Id)
|
|
{
|
|
// Log a warning with details for traceability when Ids do not match
|
|
_logger.LogWarning("Mismatch detected: Path parameter Id ({PathId}) does not match body Id ({BodyId}) for employee {EmployeeId}",
|
|
id, model.Id, loggedInEmployee.Id);
|
|
|
|
// Return standardized error response with HTTP 400 Bad Request status and clear message
|
|
return ApiResponse<object>.ErrorResponse("The employee Id in the path does not match the Id in the request body.",
|
|
"The employee Id in the path does not match the Id in the request body.", 400);
|
|
}
|
|
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
|
|
var hasManagePermission = await permissionService.HasPermission(PermissionsMaster.ExpenseManage, loggedInEmployee.Id);
|
|
|
|
var existingExpense = await _context.Expenses
|
|
.Include(e => e.ExpenseCategory)
|
|
.Include(e => e.Project)
|
|
.Include(e => e.PaidBy)
|
|
.ThenInclude(e => e!.JobRole)
|
|
.Include(e => e.PaymentMode)
|
|
.Include(e => e.Status)
|
|
.Include(e => e.CreatedBy)
|
|
.Include(e => e.ReviewedBy)
|
|
.Include(e => e.ApprovedBy)
|
|
.Include(e => e.ProcessedBy)
|
|
.FirstOrDefaultAsync(e =>
|
|
e.Id == model.Id &&
|
|
e.TenantId == tenantId);
|
|
|
|
|
|
if (existingExpense == null)
|
|
{
|
|
_logger.LogWarning("User attempted to update expense with ID {ExpenseId}, but not found in database", id);
|
|
return ApiResponse<object>.ErrorResponse("Expense not found", "Expense not found", 404);
|
|
}
|
|
|
|
if (existingExpense.StatusId != Draft && existingExpense.StatusId != RejectedByReviewer && existingExpense.StatusId != RejectedByApprover)
|
|
{
|
|
_logger.LogWarning("User attempted to update expense with ID {ExpenseId}, but donot have status of DRAFT or REJECTED, but is {StatusId}", existingExpense.Id, existingExpense.StatusId);
|
|
return ApiResponse<object>.ErrorResponse("Expense connot be updated", "Expense connot be updated", 400);
|
|
}
|
|
if (!hasManagePermission && existingExpense.CreatedById != loggedInEmployee.Id)
|
|
{
|
|
_logger.LogWarning("User attempted to update expense with ID {ExpenseId} which not created by them", existingExpense.Id);
|
|
return ApiResponse<object>.ErrorResponse("You donot have access to update this expense", "You donot have access to update this expense", 400);
|
|
}
|
|
|
|
var existingEntityBson = _updateLogHelper.EntityToBsonDocument(existingExpense); // Capture state for audit log BEFORE changes
|
|
_mapper.Map(model, existingExpense);
|
|
existingExpense.StatusId = Draft;
|
|
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInfo("Successfully updated Expense {ExpenseId} by user {UserId}.", id, loggedInEmployee.Id);
|
|
}
|
|
catch (DbUpdateConcurrencyException ex)
|
|
{
|
|
// --- Step 3: Handle Concurrency Conflicts ---
|
|
// This happens if another user modified the project after we fetched it.
|
|
_logger.LogError(ex, "Concurrency conflict while updating project {ProjectId} ", id);
|
|
return ApiResponse<object>.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409);
|
|
}
|
|
|
|
if (model.BillAttachments?.Any() ?? false)
|
|
{
|
|
var newBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId == null && ba.IsActive).ToList();
|
|
if (newBillAttachments.Any())
|
|
{
|
|
await ProcessAndUploadAttachmentsAsync(newBillAttachments, existingExpense, loggedInEmployee.Id, tenantId);
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInfo("{Count} New attachments added while updating expense {ExpenseId} by employee {EmployeeId}",
|
|
newBillAttachments.Count, existingExpense.Id, loggedInEmployee.Id);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while adding new attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
|
|
|
|
}
|
|
}
|
|
|
|
var deleteBillAttachments = model.BillAttachments.Where(ba => ba.DocumentId != null && !ba.IsActive).ToList();
|
|
if (deleteBillAttachments.Any())
|
|
{
|
|
var documentIds = deleteBillAttachments.Select(d => d.DocumentId!.Value).ToList();
|
|
try
|
|
{
|
|
await DeleteAttachemnts(documentIds);
|
|
_logger.LogInfo("{Count} Attachments deleted while updating expense {ExpenseId} by employee {EmployeeId}",
|
|
deleteBillAttachments.Count, existingExpense.Id, loggedInEmployee.Id);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while deleting attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception occured while deleting attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Exception occured while deleting attachments during updating expense ", ExceptionMapper(ex), 500);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
try
|
|
{
|
|
// Task to save the detailed audit log to a separate system (e.g., MongoDB).
|
|
var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = existingExpense.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = existingEntityBson,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, Collection);
|
|
|
|
// Task to get all possible next statuses from the *new* current state to help the UI.
|
|
// NOTE: This now fetches a list of all possible next states, which is more useful for a UI.
|
|
var getNextStatusesTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(t =>
|
|
{
|
|
var dbContext = t.Result;
|
|
return dbContext.ExpensesStatusMapping
|
|
.Include(s => s.NextStatus)
|
|
.Where(s => s.StatusId == existingExpense.StatusId && s.NextStatus != null)
|
|
.Select(s => s.NextStatus) // Select only the status object
|
|
.ToListAsync()
|
|
.ContinueWith(res =>
|
|
{
|
|
dbContext.Dispose(); // Ensure the context is disposed
|
|
return res.Result;
|
|
});
|
|
}).Unwrap();
|
|
|
|
var cacheUpdateTask = _cache.ReplaceExpenseAsync(existingExpense);
|
|
|
|
await Task.WhenAll(mongoDBTask, getNextStatusesTask, cacheUpdateTask);
|
|
|
|
var nextPossibleStatuses = getNextStatusesTask.Result;
|
|
|
|
var response = _mapper.Map<ExpenseList>(existingExpense);
|
|
if (nextPossibleStatuses != null)
|
|
{
|
|
// The response DTO should have a property like: public List<ExpensesStatusMasterVM> NextAvailableStatuses { get; set; }
|
|
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterVM>>(nextPossibleStatuses);
|
|
}
|
|
|
|
return ApiResponse<object>.SuccessResponse(response, "Expense Updated Successfully", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// This catch block handles errors from post-save operations like MongoDB logging.
|
|
// The primary update was successful, but we must log this failure.
|
|
_logger.LogError(ex, "Error occurred during post-save operations for ExpenseId: {ExpenseId} (e.g., audit logging). The primary status change was successful.", existingExpense.Id);
|
|
|
|
// We can still return a success response because the main operation succeeded,
|
|
// but we should not block the user for a failed audit log.
|
|
// Alternatively, if audit logging is critical, you could return an error.
|
|
// Here, we choose to return success but log the ancillary failure.
|
|
var response = _mapper.Map<ExpenseList>(existingExpense);
|
|
return ApiResponse<object>.SuccessResponse(response, "Status updated, but a post-processing error occurred.");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Delete Functions ===================================================================
|
|
|
|
public async Task<ApiResponse<object>> DeleteExpanseAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
var expenseTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Expenses.Where(e => e.Id == id && e.StatusId == Draft && e.TenantId == tenantId).FirstOrDefaultAsync();
|
|
});
|
|
|
|
var hasAprrovePermissionTask = Task.Run(async () =>
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ExpenseApprove, loggedInEmployee.Id);
|
|
});
|
|
|
|
await Task.WhenAll(expenseTask, hasAprrovePermissionTask);
|
|
|
|
var hasAprrovePermission = hasAprrovePermissionTask.Result;
|
|
var existingExpense = expenseTask.Result;
|
|
|
|
if (existingExpense == null)
|
|
{
|
|
var message = hasAprrovePermission ? "Expenses not found" : "Expense cannot be deleted";
|
|
if (hasAprrovePermission)
|
|
{
|
|
_logger.LogWarning("Employee {EmployeeId} attempted to delete expense {ExpenseId}, but not found in database", loggedInEmployee.Id, id);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Employee {EmployeeId} attempted to delete expense {ExpenseId}, Which is created by another employee", loggedInEmployee.Id, id);
|
|
}
|
|
return ApiResponse<object>.ErrorResponse(message, message, 400);
|
|
}
|
|
if (existingExpense.StatusId != Draft)
|
|
{
|
|
_logger.LogWarning("User attempted to delete expense with ID {ExpenseId}, but donot have status of DRAFT or REJECTED", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Expense connot be deleted", "Expense connot be deleted", 400);
|
|
}
|
|
|
|
if (!hasAprrovePermission && existingExpense.CreatedById != loggedInEmployee.Id)
|
|
{
|
|
_logger.LogWarning("User attempted to delete expense with ID {ExpenseId} which not created by them", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("You donot have access to delete this expense", "You donot have access to delete this expense", 400);
|
|
|
|
}
|
|
|
|
var documentIds = await _context.BillAttachments
|
|
.Where(ba => ba.ExpensesId == existingExpense.Id)
|
|
.Select(ba => ba.DocumentId)
|
|
.ToListAsync();
|
|
|
|
var existingEntityBson = _updateLogHelper.EntityToBsonDocument(existingExpense);
|
|
|
|
_context.Expenses.Remove(existingExpense);
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInfo("Employeee {EmployeeId} successfully deleted the expense {EmpenseId}", loggedInEmployee.Id, id);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while deleting expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
|
|
}
|
|
try
|
|
{
|
|
var attachmentDeletionTask = Task.Run(async () =>
|
|
{
|
|
await DeleteAttachemnts(documentIds);
|
|
});
|
|
|
|
var cacheTask = Task.Run(async () =>
|
|
{
|
|
await _cache.DeleteExpenseAsync(id, tenantId);
|
|
});
|
|
var mongoDBTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = existingExpense.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = existingEntityBson,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, Collection);
|
|
|
|
await Task.WhenAll(attachmentDeletionTask, cacheTask, mongoDBTask);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Databsae Exception occured while deleting attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Databsae Exception", ExceptionMapper(dbEx), 500);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception occured while deleting attachments during updating expense");
|
|
return ApiResponse<object>.ErrorResponse("Exception occured while deleting attachments during updating expense ", ExceptionMapper(ex), 500);
|
|
}
|
|
return ApiResponse<object>.SuccessResponse("Success", "Expense Deleted Successfully", 200);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== 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}";
|
|
//if (pr.RecurringPayment != null)
|
|
// result.RecurringPaymentUID = $"{pr.RecurringPayment.UIDPrefix}/{pr.RecurringPayment.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.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)
|
|
.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.Select(sul => sul.StatusId).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 submited 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<ExpensesCategoryMasterVM>(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)
|
|
.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 == paymentRequest.ExpenseStatusId)
|
|
.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;
|
|
|
|
|
|
// 7. Add Reimbursement 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);
|
|
}
|
|
paymentRequest.PaidAt = model.PaidAt;
|
|
paymentRequest.PaidById = model.PaidById;
|
|
paymentRequest.PaidTransactionId = model.PaidTransactionId;
|
|
paymentRequest.TDSPercentage = model.TDSPercentage;
|
|
paymentRequest.BaseAmount = model.BaseAmount;
|
|
paymentRequest.TaxAmount = model.TaxAmount;
|
|
|
|
var lastTransaction = await _context.AdvancePaymentTransactions.OrderByDescending(apt => apt.CreatedAt).FirstOrDefaultAsync(apt => apt.TenantId == tenantId);
|
|
double lastBalance = 0;
|
|
if (lastTransaction != null)
|
|
{
|
|
lastBalance = lastTransaction.CurrentBalance;
|
|
}
|
|
|
|
_context.AdvancePaymentTransactions.Add(new AdvancePaymentTransaction
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
FinanceUIdPostfix = 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
|
|
});
|
|
}
|
|
|
|
// 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 = DateTime.UtcNow,
|
|
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, 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
|
|
{
|
|
// Retrieve payment request with required navigation property and validation
|
|
var paymentRequest = await _context.PaymentRequests
|
|
.Include(pr => pr.ExpenseCategory)
|
|
.FirstOrDefaultAsync(pr =>
|
|
pr.Id == model.PaymentRequestId &&
|
|
!pr.IsAdvancePayment &&
|
|
pr.ProjectId.HasValue &&
|
|
pr.ExpenseCategoryId.HasValue &&
|
|
pr.PaidById.HasValue &&
|
|
pr.PaidAt.HasValue &&
|
|
pr.ExpenseCategory != null &&
|
|
pr.TenantId == tenantId &&
|
|
pr.IsActive);
|
|
|
|
if (paymentRequest == null)
|
|
{
|
|
_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 != Processed)
|
|
{
|
|
_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<ExpensesCategoryMasterVM>(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
|
|
{
|
|
// 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<ExpensesCategoryMasterVM>(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.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>();
|
|
|
|
// Generate UID prefix for payment requests for this period (month/year)
|
|
string uIDPrefix = $"PR/{DateTime.Now:MM.yy}";
|
|
|
|
// 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)
|
|
{
|
|
// Check if recurring payment is applicable for generating a new payment request
|
|
var isApplicable = IsRecurringApplicable(
|
|
recurringPayment.NumberOfIteration,
|
|
recurringPayment.Frequency,
|
|
recurringPayment.StrikeDate.Date,
|
|
recurringPayment.LatestPRGeneratedAt);
|
|
|
|
if (isApplicable)
|
|
{
|
|
// Update latest generated date to today (UTC)
|
|
recurringPayment.LatestPRGeneratedAt = DateTime.UtcNow.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
|
|
};
|
|
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);
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInfo("{Count} payment requests created successfully from recurring payments by EmployeeId: {EmployeeId} for TenantId: {TenantId}",
|
|
paymentRequests.Count, loggedInEmployee.Id, tenantId);
|
|
|
|
return ApiResponse<object>.SuccessResponse(recurringTemplateIds, $"{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);
|
|
}
|
|
|
|
// 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<ExpensesCategoryMasterVM>(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 || hasManagePermission)))
|
|
{
|
|
response.NextStatus = _mapper.Map<List<ExpensesStatusMasterMongoDB>>(statusMapping.NextStatus);
|
|
}
|
|
}
|
|
if (response.Status == null)
|
|
{
|
|
var status = statusTask.Result;
|
|
response.Status = _mapper.Map<ExpensesStatusMasterMongoDB>(status);
|
|
}
|
|
response.PaymentMode = _mapper.Map<PaymentModeMatserMongoDB>(model.PaymentMode);
|
|
response.ExpenseCategory = _mapper.Map<ExpenseCategoryMasterMongoDB>(model.ExpenseCategory);
|
|
if (billAttachment != null) response.Documents = billAttachment.Documents;
|
|
|
|
return response;
|
|
|
|
}
|
|
private ExpensesFilter? TryDeserializeFilter(string? filter)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
ExpensesFilter? expenseFilter = null;
|
|
|
|
try
|
|
{
|
|
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
|
|
expenseFilter = JsonSerializer.Deserialize<ExpensesFilter>(filter, options);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
|
|
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
|
|
try
|
|
{
|
|
// Unescape the string first, then deserialize the result.
|
|
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
|
|
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
|
|
{
|
|
expenseFilter = JsonSerializer.Deserialize<ExpensesFilter>(unescapedJsonString, options);
|
|
}
|
|
}
|
|
catch (JsonException ex1)
|
|
{
|
|
// If both attempts fail, log the final error and return null.
|
|
_logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
return null;
|
|
}
|
|
}
|
|
return expenseFilter;
|
|
}
|
|
private PaymentRequestFilter? TryDeserializePaymentRequestFilter(string? filter)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
PaymentRequestFilter? expenseFilter = null;
|
|
|
|
try
|
|
{
|
|
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
|
|
expenseFilter = JsonSerializer.Deserialize<PaymentRequestFilter>(filter, options);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializePaymentRequestFilter), filter);
|
|
|
|
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
|
|
try
|
|
{
|
|
// Unescape the string first, then deserialize the result.
|
|
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
|
|
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
|
|
{
|
|
expenseFilter = JsonSerializer.Deserialize<PaymentRequestFilter>(unescapedJsonString, options);
|
|
}
|
|
}
|
|
catch (JsonException ex1)
|
|
{
|
|
// If both attempts fail, log the final error and return null.
|
|
_logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializePaymentRequestFilter), filter);
|
|
return null;
|
|
}
|
|
}
|
|
return expenseFilter;
|
|
}
|
|
private RecurringPaymentFilter? TryDeserializeRecurringPaymentFilter(string? filter)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
RecurringPaymentFilter? expenseFilter = null;
|
|
|
|
try
|
|
{
|
|
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
|
|
expenseFilter = JsonSerializer.Deserialize<RecurringPaymentFilter>(filter, options);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeRecurringPaymentFilter), filter);
|
|
|
|
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
|
|
try
|
|
{
|
|
// Unescape the string first, then deserialize the result.
|
|
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
|
|
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
|
|
{
|
|
expenseFilter = JsonSerializer.Deserialize<RecurringPaymentFilter>(unescapedJsonString, options);
|
|
}
|
|
}
|
|
catch (JsonException ex1)
|
|
{
|
|
// If both attempts fail, log the final error and return null.
|
|
_logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeRecurringPaymentFilter), filter);
|
|
return null;
|
|
}
|
|
}
|
|
return expenseFilter;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes and uploads attachments concurrently, then adds the resulting entities to the main DbContext.
|
|
/// </summary>
|
|
private async Task ProcessAndUploadAttachmentsAsync(IEnumerable<FileUploadModel> attachments, Expenses expense, Guid employeeId, Guid tenantId)
|
|
{
|
|
// Pre-validate all attachments to fail fast before any uploads.
|
|
foreach (var attachment in attachments)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attachment.Base64Data) || !_s3Service.IsBase64String(attachment.Base64Data))
|
|
{
|
|
throw new ArgumentException($"Invalid or missing Base64 data for attachment: {attachment.FileName ?? "N/A"}");
|
|
}
|
|
}
|
|
|
|
var batchId = Guid.NewGuid();
|
|
|
|
// Create a list of tasks to be executed concurrently.
|
|
var processingTasks = attachments.Select(attachment =>
|
|
ProcessSingleExpenseAttachmentAsync(attachment, expense, employeeId, tenantId, batchId)
|
|
).ToList();
|
|
|
|
var results = await Task.WhenAll(processingTasks);
|
|
|
|
// This part is thread-safe as it runs after all concurrent tasks are complete.
|
|
foreach (var (document, billAttachment) in results)
|
|
{
|
|
_context.Documents.Add(document);
|
|
_context.BillAttachments.Add(billAttachment);
|
|
}
|
|
_logger.LogInfo("{AttachmentCount} attachments processed and staged for saving.", results.Length);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles the logic for a single attachment: upload to S3 and create corresponding entities.
|
|
/// </summary>
|
|
private async Task<(Document document, BillAttachments billAttachment)> ProcessSingleExpenseAttachmentAsync(
|
|
FileUploadModel attachment, Expenses expense, Guid employeeId, Guid tenantId, Guid batchId)
|
|
{
|
|
var base64Data = attachment.Base64Data!.Contains(',') ? attachment.Base64Data[(attachment.Base64Data.IndexOf(",") + 1)..] : attachment.Base64Data;
|
|
var fileType = _s3Service.GetContentTypeFromBase64(base64Data);
|
|
var fileName = _s3Service.GenerateFileName(fileType, expense.Id, "Expense");
|
|
var objectKey = $"tenant-{tenantId}/project-{expense.ProjectId}/Expenses/{fileName}";
|
|
|
|
// Await the I/O-bound upload operation directly.
|
|
await _s3Service.UploadFileAsync(base64Data, fileType, objectKey);
|
|
_logger.LogInfo("Uploaded file to S3 with key: {ObjectKey}", objectKey);
|
|
|
|
return CreateExpenseAttachmentEntities(batchId, expense.Id, employeeId, tenantId, objectKey, attachment);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// A private static helper method to create Document and BillAttachment entities.
|
|
/// This remains unchanged as it's a pure data-shaping method.
|
|
/// </summary>
|
|
private static (Document document, BillAttachments billAttachment) CreateExpenseAttachmentEntities(
|
|
Guid batchId, Guid expenseId, Guid uploadedById, Guid tenantId, string s3Key, FileUploadModel attachmentDto)
|
|
{
|
|
var document = new Document
|
|
{
|
|
BatchId = batchId,
|
|
UploadedById = uploadedById,
|
|
FileName = attachmentDto.FileName ?? "",
|
|
ContentType = attachmentDto.ContentType ?? "",
|
|
S3Key = s3Key,
|
|
FileSize = attachmentDto.FileSize,
|
|
UploadedAt = DateTime.UtcNow,
|
|
TenantId = tenantId
|
|
};
|
|
var billAttachment = new BillAttachments { Document = document, ExpensesId = expenseId, TenantId = tenantId };
|
|
return (document, billAttachment);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles the logic for a single attachment: upload to S3 and create corresponding entities.
|
|
/// </summary>
|
|
private async Task<(Document document, PaymentRequestAttachment billAttachment)> ProcessSinglePaymentRequestAttachmentAsync(
|
|
FileUploadModel attachment, PaymentRequest paymentRequest, Guid employeeId, Guid tenantId, Guid batchId)
|
|
{
|
|
var base64Data = attachment.Base64Data!.Contains(',') ? attachment.Base64Data[(attachment.Base64Data.IndexOf(",") + 1)..] : attachment.Base64Data;
|
|
var fileType = _s3Service.GetContentTypeFromBase64(base64Data);
|
|
var fileName = _s3Service.GenerateFileName(fileType, paymentRequest.Id, "PaymentRequest");
|
|
string objectKey;
|
|
if (paymentRequest.ProjectId.HasValue)
|
|
{
|
|
objectKey = $"tenant-{tenantId}/project-{paymentRequest.ProjectId}/PaymentRequest/{fileName}";
|
|
}
|
|
else
|
|
{
|
|
objectKey = $"tenant-{tenantId}/PaymentRequest/{fileName}";
|
|
}
|
|
|
|
// Await the I/O-bound upload operation directly.
|
|
await _s3Service.UploadFileAsync(base64Data, fileType, objectKey);
|
|
_logger.LogInfo("Uploaded file to S3 with key: {ObjectKey}", objectKey);
|
|
|
|
return CreatePaymentRequestAttachmentEntities(batchId, paymentRequest.Id, employeeId, tenantId, objectKey, attachment);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A private static helper method to create Document and BillAttachment entities.
|
|
/// This remains unchanged as it's a pure data-shaping method.
|
|
/// </summary>
|
|
private static (Document document, PaymentRequestAttachment paymentRequestAttachment) CreatePaymentRequestAttachmentEntities(
|
|
Guid batchId, Guid paymentRequestId, Guid uploadedById, Guid tenantId, string s3Key, FileUploadModel attachmentDto)
|
|
{
|
|
var document = new Document
|
|
{
|
|
BatchId = batchId,
|
|
UploadedById = uploadedById,
|
|
FileName = attachmentDto.FileName ?? "",
|
|
ContentType = attachmentDto.ContentType ?? "",
|
|
S3Key = s3Key,
|
|
FileSize = attachmentDto.FileSize,
|
|
UploadedAt = DateTime.UtcNow,
|
|
TenantId = tenantId
|
|
};
|
|
var paymentRequestAttachment = new PaymentRequestAttachment { Document = document, PaymentRequestId = paymentRequestId, TenantId = tenantId };
|
|
return (document, paymentRequestAttachment);
|
|
}
|
|
|
|
private async Task DeleteAttachemnts(List<Guid> documentIds)
|
|
{
|
|
var attachmentTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var attachments = await dbContext.BillAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync();
|
|
|
|
dbContext.BillAttachments.RemoveRange(attachments);
|
|
await dbContext.SaveChangesAsync();
|
|
});
|
|
var documentsTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var documents = await dbContext.Documents.AsNoTracking().Where(ba => documentIds.Contains(ba.Id)).ToListAsync();
|
|
|
|
if (documents.Any())
|
|
{
|
|
dbContext.Documents.RemoveRange(documents);
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
List<S3DeletionObject> deletionObject = new List<S3DeletionObject>();
|
|
foreach (var document in documents)
|
|
{
|
|
deletionObject.Add(new S3DeletionObject
|
|
{
|
|
Key = document.S3Key
|
|
});
|
|
if (!string.IsNullOrWhiteSpace(document.ThumbS3Key) && document.ThumbS3Key != document.S3Key)
|
|
{
|
|
deletionObject.Add(new S3DeletionObject
|
|
{
|
|
Key = document.ThumbS3Key
|
|
});
|
|
}
|
|
}
|
|
await _updateLogHelper.PushToS3DeletionAsync(deletionObject);
|
|
}
|
|
});
|
|
|
|
await Task.WhenAll(attachmentTask, documentsTask);
|
|
}
|
|
private async Task DeletePaymentRequestAttachemnts(List<Guid> documentIds)
|
|
{
|
|
var attachmentTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var attachments = await dbContext.PaymentRequestAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync();
|
|
|
|
dbContext.PaymentRequestAttachments.RemoveRange(attachments);
|
|
await dbContext.SaveChangesAsync();
|
|
});
|
|
var documentsTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
var documents = await dbContext.Documents.AsNoTracking().Where(ba => documentIds.Contains(ba.Id)).ToListAsync();
|
|
|
|
if (documents.Any())
|
|
{
|
|
dbContext.Documents.RemoveRange(documents);
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
List<S3DeletionObject> deletionObject = new List<S3DeletionObject>();
|
|
foreach (var document in documents)
|
|
{
|
|
deletionObject.Add(new S3DeletionObject
|
|
{
|
|
Key = document.S3Key
|
|
});
|
|
if (!string.IsNullOrWhiteSpace(document.ThumbS3Key) && document.ThumbS3Key != document.S3Key)
|
|
{
|
|
deletionObject.Add(new S3DeletionObject
|
|
{
|
|
Key = document.ThumbS3Key
|
|
});
|
|
}
|
|
}
|
|
await _updateLogHelper.PushToS3DeletionAsync(deletionObject);
|
|
}
|
|
});
|
|
|
|
await Task.WhenAll(attachmentTask, documentsTask);
|
|
}
|
|
|
|
private static bool IsRecurringApplicable(int numberOfIteration, PLAN_FREQUENCY frequency, DateTime strikeDate, DateTime? latestPRGeneratedAt)
|
|
{
|
|
List<DateTime> dates = new List<DateTime>();
|
|
DateTime currentDate = strikeDate;
|
|
DateTime endDate = DateTime.UtcNow.Date;
|
|
|
|
switch (frequency)
|
|
{
|
|
case PLAN_FREQUENCY.MONTHLY:
|
|
while (currentDate <= endDate)
|
|
{
|
|
dates.Add(currentDate);
|
|
currentDate = currentDate.AddMonths(1);
|
|
}
|
|
break;
|
|
case PLAN_FREQUENCY.QUARTERLY:
|
|
while (currentDate <= endDate)
|
|
{
|
|
dates.Add(currentDate);
|
|
currentDate = currentDate.AddMonths(3);
|
|
}
|
|
break;
|
|
case PLAN_FREQUENCY.HALF_YEARLY:
|
|
while (currentDate <= endDate)
|
|
{
|
|
dates.Add(currentDate);
|
|
currentDate = currentDate.AddMonths(6);
|
|
}
|
|
break;
|
|
case PLAN_FREQUENCY.YEARLY:
|
|
while (currentDate <= endDate)
|
|
{
|
|
dates.Add(currentDate);
|
|
currentDate = currentDate.AddYears(1);
|
|
}
|
|
break;
|
|
case PLAN_FREQUENCY.DAILY:
|
|
while (currentDate <= endDate)
|
|
{
|
|
dates.Add(currentDate);
|
|
currentDate = currentDate.AddDays(1);
|
|
}
|
|
break;
|
|
case PLAN_FREQUENCY.WEEKLY:
|
|
while (currentDate <= endDate)
|
|
{
|
|
dates.Add(currentDate);
|
|
currentDate = currentDate.AddDays(7);
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (!dates.Any() || dates.Count > numberOfIteration)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (dates.Last() != endDate)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (latestPRGeneratedAt.HasValue && latestPRGeneratedAt.Value == endDate)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|