using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Helpers.Utility; using Marco.Pms.Model.Collection; using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Filters; using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.Projects; using Marco.Pms.Model.ServiceProject; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Collection; using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Extensions; using Marco.Pms.Services.Service; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; using System.Text.Json; using Document = Marco.Pms.Model.DocumentManager.Document; namespace Marco.Pms.Services.Controllers { [Route("api/[controller]")] [ApiController] [Authorize] public class CollectionController : ControllerBase { private readonly IDbContextFactory _dbContextFactory; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly UserHelper _userHelper; private readonly S3UploadService _s3Service; private readonly IMapper _mapper; private readonly ILoggingService _logger; private readonly Guid tenantId; public CollectionController(IDbContextFactory dbContextFactory, IServiceScopeFactory serviceScopeFactory, S3UploadService s3Service, UserHelper userhelper, ILoggingService logger, IMapper mapper) { _dbContextFactory = dbContextFactory; _serviceScopeFactory = serviceScopeFactory; _userHelper = userhelper; _s3Service = s3Service; _mapper = mapper; _logger = logger; tenantId = userhelper.GetTenantId(); } #region =================================================================== Get Functions =================================================================== /// /// Fetches a paginated and filtered list of invoices after validating permissions. /// [HttpGet("invoice/list")] public async Task GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] string? filter, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false) { try { _logger.LogInfo( "GetInvoiceListAsync called. Page: {PageNumber}, Size: {PageSize}, Active: {IsActive}, Pending: {IsPending}, Search: '{Search}', From: {From}, To: {To}", pageNumber, pageSize, isActive, isPending, searchString ?? string.Empty, fromDate ?? DateTime.UtcNow, toDate ?? DateTime.UtcNow); // Validate user identity and permissions in parallel for best performance var employee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("Performing permission checks for EmployeeId: {EmployeeId}", employee.Id); var permissionTasks = new[] { HasPermissionAsync(PermissionsMaster.CollectionAdmin, employee.Id), HasPermissionAsync(PermissionsMaster.ViewCollection, employee.Id), HasPermissionAsync(PermissionsMaster.CreateCollection, employee.Id), HasPermissionAsync(PermissionsMaster.EditCollection, employee.Id), HasPermissionAsync(PermissionsMaster.AddPayment, employee.Id) }; await Task.WhenAll(permissionTasks); if (permissionTasks.All(t => !t.Result)) { _logger.LogWarning("Access denied. EmployeeId {EmployeeId} lacks relevant collection permissions.", employee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have necessary permissions.", 403)); } _logger.LogInfo("Permissions validated for EmployeeId {EmployeeId}.", employee.Id); var advanceFilter = TryDeserializeFilter(filter); await using var _context = await _dbContextFactory.CreateDbContextAsync(); await using var context = await _dbContextFactory.CreateDbContextAsync(); // Fetch related project data asynchronously and in parallel var infraProjectsQuery = _context.Projects .Where(p => p.TenantId == tenantId); var serviceProjectsQuery = context.ServiceProjects .Where(sp => sp.TenantId == tenantId); if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any()) { var projectSearchFilter = advanceFilter.SearchFilters .Where(f => f.Column == "ProjectName") .Select(f => new SearchItem { Column = "Name", Value = f.Value }) .ToList(); if (projectSearchFilter.Any()) { infraProjectsQuery = infraProjectsQuery.ApplySearchFilters(projectSearchFilter); serviceProjectsQuery = serviceProjectsQuery.ApplySearchFilters(projectSearchFilter); } } var infraProjectsTask = infraProjectsQuery .Select(p => _mapper.Map(p)) .ToListAsync(); var serviceProjectsTask = serviceProjectsQuery .Select(sp => _mapper.Map(sp)) .ToListAsync(); await Task.WhenAll(infraProjectsTask, serviceProjectsTask); var projects = infraProjectsTask.Result; projects.AddRange(serviceProjectsTask.Result); var projIds = projects.Select(p => p.Id).Distinct().ToList(); // Build invoice query efficiently - always use AsNoTracking for reads var query = _context.Invoices .AsNoTracking() .Include(i => i.BilledTo) .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) .Where(i => projIds.Contains(i.ProjectId) && i.IsActive == isActive && i.TenantId == tenantId); // Filter by date, ensuring date boundaries are correct if (fromDate.HasValue && toDate.HasValue) { var fromUtc = fromDate.Value.Date; var toUtc = toDate.Value.Date.AddDays(1).AddTicks(-1); query = query.Where(i => i.InvoiceDate >= fromUtc && i.InvoiceDate <= toUtc); _logger.LogDebug("Date filter applied: {From} to {To}", fromUtc, toUtc); } if (!string.IsNullOrEmpty(searchString)) { query = query.Where(i => i.Title.Contains(searchString) || i.InvoiceNumber.Contains(searchString)); _logger.LogDebug("Search filter applied: '{Search}'", searchString); } if (projectId.HasValue) { query = query.Where(i => i.ProjectId == projectId.Value); _logger.LogDebug("Project filter applied: {ProjectId}", projectId.Value); } if (advanceFilter != null) { query = query.ApplyCustomFilters(advanceFilter); if (advanceFilter.SearchFilters != null) { var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList(); if (invoiceSearchFilter.Any()) { query = query.ApplySearchFilters(invoiceSearchFilter); } } } var hasSortFilters = advanceFilter?.SortFilters?.Any() ?? false; if (!hasSortFilters) { query = query.OrderByDescending(i => i.InvoiceDate); } var totalItems = await query.CountAsync(); _logger.LogInfo("Total invoices found: {TotalItems}", totalItems); string groupByColumn = "ProjectId"; if (!string.IsNullOrWhiteSpace(advanceFilter?.GroupByColumn)) { groupByColumn = advanceFilter.GroupByColumn; } query = query.ApplyGroupByFilters(groupByColumn); var pagedInvoices = await query .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); if (!pagedInvoices.Any()) { _logger.LogInfo("No invoices match criteria."); return Ok(ApiResponse.SuccessResponse( new { CurrentPage = pageNumber, TotalPages = 0, TotalEntities = 0, Data = Array.Empty() }, "No invoices found." )); } // Fetch related payments in a single query to minimize DB calls var invoiceIds = pagedInvoices.Select(i => i.Id).ToList(); var paymentGroups = await _context.ReceivedInvoicePayments .AsNoTracking() .Where(p => invoiceIds.Contains(p.InvoiceId) && p.TenantId == tenantId) .GroupBy(p => p.InvoiceId) .Select(g => new { InvoiceId = g.Key, PaidAmount = g.Sum(p => p.Amount) }) .ToDictionaryAsync(g => g.InvoiceId, g => g.PaidAmount); _logger.LogDebug("Received payment data for {Count} invoices.", paymentGroups.Count); // Build results and compute balances in memory for tight control var results = new List(); foreach (var invoice in pagedInvoices) { var total = invoice.BasicAmount + invoice.TaxAmount; var paid = paymentGroups.GetValueOrDefault(invoice.Id, 0); var balance = total - paid; // Filter pending if (isPending && (balance <= 0 || invoice.MarkAsCompleted)) continue; var vm = _mapper.Map(invoice); // Project mapping logic - minimize nested object allocations vm.Project = projects.Where(sp => sp.Id == invoice.ProjectId).FirstOrDefault(); vm.BalanceAmount = balance; results.Add(vm); } var totalPages = (int)Math.Ceiling((double)totalItems / pageSize); //string groupByColumn = "Project"; //if (!string.IsNullOrWhiteSpace(advanceFilter?.GroupByColumn)) //{ // groupByColumn = advanceFilter.GroupByColumn; //} //var resultQuery = results.AsQueryable(); //object finalResponseData = resultQuery.ApplyGroupByFilters(groupByColumn); _logger.LogInfo("Returning {Count} invoices (page {PageNumber} of {TotalPages}).", results.Count, pageNumber, totalPages); return Ok(ApiResponse.SuccessResponse( new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalItems, Data = results }, $"{results.Count} invoices fetched successfully." )); } catch (Exception ex) { // Centralized and structured error logging _logger.LogError(ex, "Error in GetInvoiceListAsync: {Message}", ex.Message); // Use standardized error response structure return StatusCode(500, ApiResponse.ErrorResponse( "Internal Server Error", "An unexpected error occurred while fetching invoices.", 500)); } } /// /// Retrieves detailed information about a specific invoice including related data such as /// comments, attachments, payments, and associated project information after validating permissions. /// /// The unique identifier of the invoice to fetch. /// Returns invoice details with related entities or appropriate error responses. [HttpGet("invoice/details/{id}")] public async Task GetInvoiceDetailsAsync(Guid id) { try { _logger.LogInfo("GetInvoiceDetailsAsync called for InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId); // Retrieve currently logged-in employee for permissions validation var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("Permission checks initiated for EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Run all necessary permission checks in parallel using scoped DI contexts var permissionTasks = new[] { HasPermissionAsync(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.ViewCollection, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.CreateCollection, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.EditCollection, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.AddPayment, loggedInEmployee.Id) }; await Task.WhenAll(permissionTasks); // Aggregate permission results if (permissionTasks.All(t => !t.Result)) { _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} without required collection permissions.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to access this invoice data.", 403)); } _logger.LogInfo("Permission granted for EmployeeId {EmployeeId}, proceeding with invoice retrieval.", loggedInEmployee.Id); await using var context = await _dbContextFactory.CreateDbContextAsync(); // Fetch invoice with related user data and billing entity var invoice = await context.Invoices .AsNoTracking() .Include(i => i.BilledTo) .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) .FirstOrDefaultAsync(i => i.Id == id && i.TenantId == tenantId); if (invoice == null) { _logger.LogWarning("Invoice not found: InvoiceId {InvoiceId}, TenantId {TenantId}", id, tenantId); return NotFound(ApiResponse.ErrorResponse( "Invoice Not Found", "The specified invoice does not exist.", 404)); } _logger.LogInfo("Invoice found: InvoiceId {InvoiceId}. Initiating related data load.", id); // Load related collections in parallel using separate DbContexts for thread safety var commentsTask = LoadInvoiceCommentsAsync(id, tenantId); var attachmentsTask = LoadInvoiceAttachmentsAsync(id, tenantId); var paymentsTask = LoadReceivedInvoicePaymentsAsync(id, tenantId); await Task.WhenAll(commentsTask, attachmentsTask, paymentsTask); var comments = commentsTask.Result; var attachments = attachmentsTask.Result; var receivedInvoicePayments = paymentsTask.Result; // Initialize the response view model mapping from invoice entity var response = _mapper.Map(invoice); // Load project info concurrently from infrastructure and service projects var infraProjectTask = LoadInfraProjectAsync(invoice.ProjectId); var serviceProjectTask = LoadServiceProjectAsync(invoice.ProjectId); await Task.WhenAll(infraProjectTask, serviceProjectTask); var infraProject = infraProjectTask.Result; var serviceProject = serviceProjectTask.Result; // Map project based on availability if (serviceProject != null) { response.Project = _mapper.Map(serviceProject); } else if (infraProject != null) { response.Project = _mapper.Map(infraProject); } // Map comments if present if (comments.Any()) response.Comments = _mapper.Map>(comments); // Map attachments with pre-signed URLs and metadata if (attachments.Any()) { response.Attachments = attachments.Select(a => { var vm = _mapper.Map(a); vm.PreSignedUrl = _s3Service.GeneratePreSignedUrl(a.Document!.S3Key); vm.UploadedBy = _mapper.Map(a.Document.UploadedBy); vm.FileName = a.Document.FileName; vm.ContentType = a.Document.ContentType; return vm; }).ToList(); } // Map received payments if any if (receivedInvoicePayments.Any()) response.ReceivedInvoicePayments = _mapper.Map>(receivedInvoicePayments); // Calculate payment summary amounts double totalPaidAmount = receivedInvoicePayments.Sum(rip => rip.Amount); double invoiceTotalAmount = invoice.BasicAmount + invoice.TaxAmount; response.BalanceAmount = invoiceTotalAmount - totalPaidAmount; _logger.LogInfo("Invoice details assembly complete: InvoiceId {InvoiceId}, Total={Total}, Paid={Paid}, Balance={Balance}", id, invoiceTotalAmount, totalPaidAmount, response.BalanceAmount); return Ok(ApiResponse.SuccessResponse(response, "Invoice details fetched successfully", 200)); } catch (Exception ex) { // Log the detailed error for diagnostics with stack trace _logger.LogError(ex, "Unhandled exception in GetInvoiceDetailsAsync for InvoiceId {InvoiceId}: {Message}", id, ex.Message); // Return standardized error response for unexpected exceptions return StatusCode(500, ApiResponse.ErrorResponse( "Internal Server Error", "An unexpected error occurred while fetching invoice details.", 500)); } } #endregion #region =================================================================== Post Functions =================================================================== /// /// Creates a new invoice along with optional attachments after validating permissions and input validations. /// /// Invoice data transfer object containing invoice details and attachments. /// Returns the created invoice details or validation/error responses. [HttpPost("invoice/create")] public async Task CreateInvoiceAsync([FromBody] InvoiceDto model) { try { // Retrieve current employee context for permission validation and auditing var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("CreateInvoiceAsync started - EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, model.ProjectId); // Concurrent permission checks scoped per call for thread safety var permissionTasks = new[] { HasPermissionAsync(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.CreateCollection, loggedInEmployee.Id) }; await Task.WhenAll(permissionTasks); if (permissionTasks.All(t => !t.Result)) { _logger.LogWarning("Access denied - EmployeeId {EmployeeId} lacks create/admin collection permissions.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to create invoices.", 403)); } _logger.LogInfo("Permissions validated - EmployeeId: {EmployeeId}. Validating input...", loggedInEmployee.Id); // Input validation with comprehensive, clear validation error logs and messages if (string.IsNullOrWhiteSpace(model.InvoiceNumber) || model.InvoiceNumber.Length > 17) { _logger.LogWarning("Validation failed: Invalid InvoiceNumber length {Length} for InvoiceNumber: {InvoiceNumber}", model.InvoiceNumber.Length, model.InvoiceNumber); return BadRequest(ApiResponse.ErrorResponse( "Invalid Invoice Number", "Invoice Number is required and must be 17 characters or fewer.", 400)); } if (model.InvoiceDate.Date > DateTime.UtcNow.Date) { _logger.LogWarning("Validation failed: InvoiceDate {InvoiceDate} cannot be in the future.", model.InvoiceDate); return BadRequest(ApiResponse.ErrorResponse( "Invalid Invoice Date", "Invoice date cannot be in the future.", 400)); } if (model.InvoiceDate.Date > model.ClientSubmitedDate.Date) { _logger.LogWarning("Validation failed: InvoiceDate {InvoiceDate} is later than ClientSubmitedDate {ClientSubmitedDate}.", model.InvoiceDate, model.ClientSubmitedDate); return BadRequest(ApiResponse.ErrorResponse( "Invalid Date Sequence", "Invoice date cannot be later than client submitted date.", 400)); } if (model.ClientSubmitedDate.Date > DateTime.UtcNow.Date) { _logger.LogWarning("Validation failed: ClientSubmitedDate {ClientSubmitedDate} cannot be in the future.", model.ClientSubmitedDate); return BadRequest(ApiResponse.ErrorResponse( "Invalid Client Submitted Date", "Client submitted date cannot be in the future.", 400)); } if (model.ClientSubmitedDate.Date > model.ExceptedPaymentDate.Date) { _logger.LogWarning("Validation failed: ClientSubmitedDate {ClientSubmitedDate} is later than ExceptedPaymentDate {ExceptedPaymentDate}.", model.ClientSubmitedDate, model.ExceptedPaymentDate); return BadRequest(ApiResponse.ErrorResponse( "Invalid Date Sequence", "Client submission date cannot be later than the expected payment date.", 400)); } await using var _context = await _dbContextFactory.CreateDbContextAsync(); // Ensure unique InvoiceNumber within tenant scope if (await _context.Invoices.AnyAsync(i => i.InvoiceNumber == model.InvoiceNumber && i.TenantId == tenantId)) { _logger.LogWarning("Invoice number conflict: InvoiceNumber '{InvoiceNumber}' already exists for TenantId {TenantId}.", model.InvoiceNumber, tenantId); return Conflict(ApiResponse.ErrorResponse( "Invoice Number Conflict", $"Invoice number '{model.InvoiceNumber}' is already in use. Please use a unique invoice number.", 409)); } // Optional e-invoice uniqueness validation if (!string.IsNullOrWhiteSpace(model.EInvoiceNumber)) { if (await _context.Invoices.AnyAsync(i => i.EInvoiceNumber == model.EInvoiceNumber && i.TenantId == tenantId)) { _logger.LogWarning("E-Invoice number conflict: EInvoiceNumber '{EInvoiceNumber}' already exists for TenantId {TenantId}.", model.EInvoiceNumber, tenantId); return Conflict(ApiResponse.ErrorResponse( "E-Invoice Number Conflict", $"E-Invoice number '{model.EInvoiceNumber}' is already assigned to another invoice. Please provide a unique E-Invoice number.", 409)); } } // Concurrently fetch project and billed-to organization data with scoped DbContexts var infraProjectTask = LoadInfraProjectAsync(model.ProjectId); var serviceProjectTask = LoadServiceProjectAsync(model.ProjectId); var billedToTask = LoadOrganizationAsync(model.BilledToId); await Task.WhenAll(infraProjectTask, serviceProjectTask, billedToTask); var infraProject = infraProjectTask.Result; var serviceProject = serviceProjectTask.Result; var billedTo = billedToTask.Result; if (infraProject == null && serviceProject == null) { _logger.LogWarning("Project not found: ProjectId {ProjectId}, TenantId {TenantId}", model.ProjectId, tenantId); return NotFound(ApiResponse.ErrorResponse("Project Not Found", "Specified project does not exist.", 404)); } if (billedTo == null) { _logger.LogWarning("BilledTo organization not found: OrganizationId {BilledToId}", model.BilledToId); return NotFound(ApiResponse.ErrorResponse("Organization Not Found", "Specified billing organization does not exist.", 404)); } string objectKeyPrefix = serviceProject != null ? $"tenant-{tenantId}/ServiceProject/{model.ProjectId}" : $"tenant-{tenantId}/Project/{model.ProjectId}"; await using var transaction = await _context.Database.BeginTransactionAsync(); Invoice invoice; try { invoice = _mapper.Map(model); invoice.IsActive = true; invoice.MarkAsCompleted = false; invoice.CreatedAt = DateTime.UtcNow; invoice.CreatedById = loggedInEmployee.Id; invoice.TenantId = tenantId; _context.Invoices.Add(invoice); await _context.SaveChangesAsync(); if (model.Attachments?.Any() == true) { var batchId = Guid.NewGuid(); var documents = new List(); var invoiceAttachments = new List(); foreach (var attachment in model.Attachments) { var base64Data = attachment.Base64Data?.Split(',').LastOrDefault() ?? string.Empty; if (string.IsNullOrWhiteSpace(base64Data)) { _logger.LogWarning("Attachment missing base64 data: FileName {FileName}", attachment.FileName ?? "unknown"); return BadRequest(ApiResponse.ErrorResponse("Missing Attachment Data", "Attachment base64 data is required.", 400)); } var fileType = _s3Service.GetContentTypeFromBase64(base64Data); var fileName = _s3Service.GenerateFileName(fileType, tenantId, "invoice"); var objectKey = $"{objectKeyPrefix}/Invoice/{fileName}"; await _s3Service.UploadFileAsync(base64Data, fileType, objectKey); var document = new Document { Id = Guid.NewGuid(), BatchId = batchId, UploadedById = loggedInEmployee.Id, FileName = attachment.FileName ?? fileName, ContentType = attachment.ContentType, S3Key = objectKey, FileSize = attachment.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId }; documents.Add(document); invoiceAttachments.Add(new InvoiceAttachment { InvoiceId = invoice.Id, DocumentId = document.Id, TenantId = tenantId }); } _context.Documents.AddRange(documents); _context.InvoiceAttachments.AddRange(invoiceAttachments); await _context.SaveChangesAsync(); } await transaction.CommitAsync(); _logger.LogInfo("Invoice created successfully: InvoiceId {InvoiceId}, with {AttachmentCount} attachments.", invoice.Id, model.Attachments?.Count ?? 0); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Transaction rolled back during invoice creation for ProjectId {ProjectId}, EmployeeId {EmployeeId}", model.ProjectId, loggedInEmployee.Id); return StatusCode(500, ApiResponse.ErrorResponse( "Invoice Creation Failed", "An error occurred while creating the invoice. Please try again or contact support.", 500)); } // Prepare and return response with related data mapped var response = _mapper.Map(invoice); response.Project = serviceProject != null ? _mapper.Map(serviceProject) : _mapper.Map(infraProject); response.BilledTo = _mapper.Map(billedTo); response.CreatedBy = _mapper.Map(loggedInEmployee); response.BalanceAmount = response.BasicAmount + response.TaxAmount; return StatusCode(201, ApiResponse.SuccessResponse(response, "Invoice created successfully.", 201)); } catch (Exception e) { _logger.LogError(e, "Unhandled exception in CreateInvoiceAsync: {Message}", e.Message); return StatusCode(500, ApiResponse.ErrorResponse( "Internal Server Error", "An unexpected error occurred while creating the invoice.", 500)); } } /// /// Creates a received payment entry for a given invoice after validating permissions and business rules. /// /// Payment details including invoice ID, amount, and payment date. /// Returns the created payment details or error responses in case of validation failures. [HttpPost("invoice/payment/received")] public async Task CreateReceivedInvoicePaymentAsync([FromBody] ReceivedInvoicePaymentDto model) { try { // Retrieve the logged-in employee for auditing and permission validation var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("CreateReceivedInvoicePaymentAsync initiated - EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Perform asynchronous permission checks concurrently using scoped DI for thread safety var permissionTasks = new[] { HasPermissionAsync(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.AddPayment, loggedInEmployee.Id) }; await Task.WhenAll(permissionTasks); if (permissionTasks.All(t => !t.Result)) { _logger.LogWarning("Access denied for EmployeeId {EmployeeId} - lacks required collection permissions.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to add payments.", 403)); } // Model null check if (model == null) { _logger.LogWarning("Received null model for payment creation request."); return BadRequest(ApiResponse.ErrorResponse( "Invalid Request", "Request payload cannot be null.", 400)); } await using var context = await _dbContextFactory.CreateDbContextAsync(); // Retrieve invoice with tenant isolation and no-tracking for read-only var invoice = await context.Invoices .AsNoTracking() .FirstOrDefaultAsync(i => i.Id == model.InvoiceId && i.TenantId == tenantId); if (invoice == null) { _logger.LogWarning("Invoice not found: InvoiceId {InvoiceId}, TenantId {TenantId}", model.InvoiceId, tenantId); return NotFound(ApiResponse.ErrorResponse( "Invoice Not Found", "The specified invoice does not exist.", 404)); } // Prevent adding payment to completed invoice if (invoice.MarkAsCompleted) { _logger.LogWarning("Attempted to add payment to completed InvoiceId {InvoiceId}", model.InvoiceId); return BadRequest(ApiResponse.ErrorResponse( "Invalid Operation", "Cannot add payments to an invoice marked as completed.", 400)); } // Validate payment received date is not in the future if (model.PaymentReceivedDate.Date > DateTime.UtcNow.Date) { _logger.LogWarning("Invalid payment date: PaymentReceivedDate {PaymentReceivedDate} is in the future for InvoiceId {InvoiceId}", model.PaymentReceivedDate, model.InvoiceId); return BadRequest(ApiResponse.ErrorResponse( "Invalid Payment Date", "Payment received date cannot be in the future.", 400)); } // Validate client submission date vs payment received date if (invoice.ClientSubmitedDate.Date > model.PaymentReceivedDate.Date) { _logger.LogWarning("Client submission date {ClientSubmitedDate} is later than PaymentReceivedDate {PaymentReceivedDate} for InvoiceId {InvoiceId}", invoice.ClientSubmitedDate, model.PaymentReceivedDate, model.InvoiceId); return BadRequest(ApiResponse.ErrorResponse( "Invalid Dates", "Client submission date cannot be later than payment received date.", 400)); } // Efficiently sum existing payments for the invoice var previousPaymentsSum = await context.ReceivedInvoicePayments .Where(rip => rip.InvoiceId == invoice.Id && rip.TenantId == tenantId) .SumAsync(rip => (double?)rip.Amount) ?? 0d; var newTotalPaid = previousPaymentsSum + model.Amount; var invoiceTotal = invoice.BasicAmount + invoice.TaxAmount; // Business rule: prevent overpayment if (newTotalPaid > invoiceTotal) { _logger.LogWarning( "Overpayment detected: InvoiceId {InvoiceId}, TenantId {TenantId}, InvoiceTotal {InvoiceTotal}, PreviousPaid {PreviousPaid}, NewPayment {NewPayment}, NewTotalPaid {NewTotalPaid}", invoice.Id, tenantId, invoiceTotal, previousPaymentsSum, model.Amount, newTotalPaid); return BadRequest(ApiResponse.ErrorResponse( "Overpayment Error", $"Total payments ({previousPaymentsSum}) plus this payment ({model.Amount}) exceed invoice total ({invoiceTotal}).", 400)); } // Map DTO to entity and set audit fields var paymentEntity = _mapper.Map(model); paymentEntity.CreatedAt = DateTime.UtcNow; paymentEntity.CreatedById = loggedInEmployee.Id; paymentEntity.TenantId = tenantId; // Add payment entity and persist changes context.ReceivedInvoicePayments.Add(paymentEntity); await context.SaveChangesAsync(); var responseVm = _mapper.Map(paymentEntity); _logger.LogInfo("Received payment created successfully: PaymentId {PaymentId}, InvoiceId {InvoiceId}", paymentEntity.Id, model.InvoiceId); return StatusCode(201, ApiResponse.SuccessResponse(responseVm, "Received payment created successfully", 201)); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error while creating received payment for InvoiceId {InvoiceId}", model.InvoiceId); return StatusCode(500, ApiResponse.ErrorResponse( "Internal Server Error", "An unexpected error occurred during payment creation. Please try again or contact support.", 500)); } } /// /// Adds a comment to a specific invoice after validating user permissions and input. /// /// DTO containing InvoiceId and comment text. /// Returns the created comment details or error response. [HttpPost("invoice/add/comment")] public async Task AddCommentToInvoiceAsync([FromBody] InvoiceCommentDto model) { try { // Retrieve current employee context for auditing and permissions var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("AddCommentToInvoiceAsync started by EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Concurrently check all relevant collection-related permissions var permissionTasks = new[] { HasPermissionAsync(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.ViewCollection, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.CreateCollection, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.EditCollection, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.AddPayment, loggedInEmployee.Id) }; await Task.WhenAll(permissionTasks); // Aggregate permission results if (permissionTasks.All(t => !t.Result)) { _logger.LogWarning("Access denied: EmployeeId {EmployeeId} lacks collection permissions.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to add comments to collections.", 403)); } _logger.LogInfo("Permission granted for EmployeeId {EmployeeId}. Validating input...", loggedInEmployee.Id); // Validate input model early to avoid unnecessary DB calls if (model == null || string.IsNullOrWhiteSpace(model.Comment)) { _logger.LogWarning("Invalid comment payload received from EmployeeId {EmployeeId}.", loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse( "Invalid Input", "Comment text must not be null or empty.", 400)); } await using var context = await _dbContextFactory.CreateDbContextAsync(); // Find the invoice with tenant isolation and no tracking for read-only purposes var invoice = await context.Invoices .AsNoTracking() .FirstOrDefaultAsync(i => i.Id == model.InvoiceId && i.TenantId == tenantId); if (invoice == null) { _logger.LogWarning("Invoice not found: InvoiceId {InvoiceId}, TenantId {TenantId}.", model.InvoiceId, tenantId); return NotFound(ApiResponse.ErrorResponse( "Invoice Not Found", $"Invoice with ID '{model.InvoiceId}' does not exist for this tenant.", 404)); } // Create a new comment entity with audit properties var comment = new InvoiceComment { Id = Guid.NewGuid(), Comment = model.Comment.Trim(), InvoiceId = model.InvoiceId, CreatedAt = DateTime.UtcNow, CreatedById = loggedInEmployee.Id, TenantId = tenantId }; context.InvoiceComments.Add(comment); await context.SaveChangesAsync(); _logger.LogInfo("Comment added successfully: CommentId {CommentId}, InvoiceId {InvoiceId}, EmployeeId {EmployeeId}.", comment.Id, comment.InvoiceId, loggedInEmployee.Id); var responseVm = _mapper.Map(comment); // Return 201 Created with the details of the new comment return StatusCode(201, ApiResponse.SuccessResponse( responseVm, "Comment added to invoice successfully.", 201)); } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception in AddCommentToInvoiceAsync by EmployeeId {EmployeeId}.", (await _userHelper.GetCurrentEmployeeAsync()).Id); return StatusCode(500, ApiResponse.ErrorResponse( "Internal Server Error", "An unexpected error occurred while adding the comment. Please try again or contact support.", 500)); } } #endregion #region =================================================================== Put Functions =================================================================== /// /// Updates an existing invoice including attachment management, after performing permission and business validations. /// /// Invoice ID from route parameter. /// Invoice DTO with updated details. /// Returns updated invoice details on success or relevant error responses. [HttpPut("invoice/edit/{id}")] public async Task UpdateInvoiceAsync(Guid id, [FromBody] InvoiceDto model) { try { // Retrieve current logged-in employee context for audit and permission purposes var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("UpdateInvoiceAsync started - EmployeeId: {EmployeeId}, InvoiceId: {InvoiceId}", loggedInEmployee.Id, id); // Concurrently check required permissions with scoped DI for thread safety var permissionTasks = new[] { HasPermissionAsync(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id), HasPermissionAsync(PermissionsMaster.EditCollection, loggedInEmployee.Id) }; await Task.WhenAll(permissionTasks); if (permissionTasks.All(t => !t.Result)) { _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - lacks admin and edit permissions.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to update invoices.", 403)); } _logger.LogInfo("Permissions validated for EmployeeId {EmployeeId}. Validating IDs...", loggedInEmployee.Id); // Validate that route ID matches model ID to prevent mismatches if (!model.Id.HasValue || id != model.Id) { _logger.LogWarning("Invoice ID mismatch between route ({RouteId}) and model ({ModelId}).", id, model.Id ?? Guid.Empty); return BadRequest(ApiResponse.ErrorResponse( "Invalid Invoice ID", "Invoice ID in URL does not match ID in request body.", 400)); } await using var context = await _dbContextFactory.CreateDbContextAsync(); using var scope = _serviceScopeFactory.CreateScope(); var updateLogHelper = scope.ServiceProvider.GetRequiredService(); // Fetch invoice with tenant isolation var invoice = await context.Invoices.FirstOrDefaultAsync(i => i.Id == id && i.TenantId == tenantId); if (invoice == null) { _logger.LogWarning("Invoice not found - InvoiceId: {InvoiceId}, TenantId: {TenantId}.", id, tenantId); return NotFound(ApiResponse.ErrorResponse( "Invoice Not Found", "Specified invoice does not exist for this tenant.", 404)); } // Concurrently fetch project and billedTo organization entities var infraProjectTask = LoadInfraProjectAsync(model.ProjectId); var serviceProjectTask = LoadServiceProjectAsync(model.ProjectId); var billedToTask = LoadOrganizationAsync(model.BilledToId); await Task.WhenAll(infraProjectTask, serviceProjectTask, billedToTask); var infraProject = infraProjectTask.Result; var serviceProject = serviceProjectTask.Result; var billedTo = billedToTask.Result; if (serviceProject == null && infraProject == null) { _logger.LogWarning("Project not found - ProjectId: {ProjectId}, TenantId: {TenantId}.", model.ProjectId, tenantId); return NotFound(ApiResponse.ErrorResponse("Project Not Found", "Specified project does not exist.", 404)); } if (billedTo == null) { _logger.LogWarning("Organization not found - OrganizationId: {BilledToId}.", model.BilledToId); return NotFound(ApiResponse.ErrorResponse("Organization Not Found", "Specified billing organization does not exist.", 404)); } // Compose project view model and prefix for attachment S3 keys BasicProjectVM? projectVM = serviceProject != null ? _mapper.Map(serviceProject) : _mapper.Map(infraProject); string objectKeyPrefix = serviceProject != null ? $"tenant-{tenantId}/ServiceProject/{model.ProjectId}" : $"tenant-{tenantId}/Project/{model.ProjectId}"; // Restrict updates if payments have been received to enforce business rules bool hasReceivedPayments = await context.ReceivedInvoicePayments .AnyAsync(rip => rip.InvoiceId == id && rip.TenantId == tenantId); if (hasReceivedPayments) { _logger.LogWarning("Invoice update blocked: payments received for InvoiceId {InvoiceId}, TenantId {TenantId}", id, tenantId); return BadRequest(ApiResponse.ErrorResponse( "Invoice Update Not Allowed", "Invoice cannot be updated because payments have already been received.", 400)); } // Capture existing invoice state for audit logging var invoiceStateBefore = updateLogHelper.EntityToBsonDocument(invoice); // Map updated fields onto existing entity _mapper.Map(model, invoice); invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedById = loggedInEmployee.Id; // Handle attachments if provided if (model.Attachments?.Any() == true) { // Identify attachments to remove (inactive with DocumentId) var inactiveDocIds = model.Attachments .Where(a => !a.IsActive && a.DocumentId.HasValue) .Select(a => a.DocumentId!.Value) .ToList(); // Identify new attachments (active with base64 data) var newAttachments = model.Attachments .Where(a => a.IsActive && !string.IsNullOrWhiteSpace(a.Base64Data)) .ToList(); // Remove inactive attachments if any if (inactiveDocIds.Any()) { var attachmentsToRemove = await context.InvoiceAttachments .Where(ia => inactiveDocIds.Contains(ia.DocumentId) && ia.TenantId == tenantId) .ToListAsync(); context.InvoiceAttachments.RemoveRange(attachmentsToRemove); _logger.LogInfo("Removed {Count} inactive attachments from InvoiceId {InvoiceId}.", attachmentsToRemove.Count, id); } // Process new attachments by uploading and persisting if (newAttachments.Any()) { var batchId = Guid.NewGuid(); var documents = new List(); var invoiceAttachments = new List(); foreach (var attachment in newAttachments) { var base64Data = attachment.Base64Data?.Split(',').LastOrDefault() ?? string.Empty; if (string.IsNullOrWhiteSpace(base64Data)) { _logger.LogWarning("Empty Base64 data for new attachment on InvoiceId {InvoiceId}.", id); return BadRequest(ApiResponse.ErrorResponse( "Invalid Attachment Data", "Attachment base64 data is missing or empty.", 400)); } var contentType = _s3Service.GetContentTypeFromBase64(base64Data); var fileName = _s3Service.GenerateFileName(contentType, tenantId, "invoice"); var objectKey = $"{objectKeyPrefix}/Invoice/{fileName}"; // Upload file to S3 asynchronously await _s3Service.UploadFileAsync(base64Data, contentType, objectKey); var document = new Document { Id = Guid.NewGuid(), BatchId = batchId, UploadedById = loggedInEmployee.Id, FileName = attachment.FileName ?? fileName, ContentType = contentType, S3Key = objectKey, FileSize = attachment.FileSize, UploadedAt = DateTime.UtcNow, TenantId = tenantId }; documents.Add(document); invoiceAttachments.Add(new InvoiceAttachment { InvoiceId = invoice.Id, DocumentId = document.Id, TenantId = tenantId }); } // Add and persist new documents and invoice attachments context.Documents.AddRange(documents); context.InvoiceAttachments.AddRange(invoiceAttachments); _logger.LogInfo("Added {Count} new attachments to InvoiceId {InvoiceId}.", invoiceAttachments.Count, id); } } // Persist all updates (invoice + attachments) in one SaveChanges call for consistency await context.SaveChangesAsync(); // Push audit log entry with old invoice entity snapshot await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = invoice.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = invoiceStateBefore, UpdatedAt = DateTime.UtcNow }, "InvoiceModificationLog"); _logger.LogInfo("Invoice {InvoiceId} updated successfully by EmployeeId {EmployeeId}, TenantId {TenantId}.", invoice.Id, loggedInEmployee.Id, tenantId); // Prepare response with updated invoice data and related mappings var response = _mapper.Map(invoice); response.Project = projectVM; response.BilledTo = _mapper.Map(billedTo); response.UpdatedBy = _mapper.Map(loggedInEmployee); response.BalanceAmount = response.BasicAmount + response.TaxAmount; return Ok(ApiResponse.SuccessResponse(response, "Invoice updated successfully", 200)); } catch (Exception ex) { _logger.LogError(ex, "Exception occurred during update of InvoiceId {InvoiceId}, TenantId {TenantId}.", id, tenantId); return StatusCode(500, ApiResponse.ErrorResponse( "Internal Server Error", "An unexpected error occurred while updating the invoice. Please contact support.", 500)); } } /// /// Marks a specified invoice as completed after validating permissions and business rules. /// /// The unique identifier of the invoice to mark completed. /// Returns updated invoice details or relevant error responses. [HttpPut("invoice/marked/completed/{invoiceId}")] public async Task MarkAsCompletedAsync(Guid invoiceId) { // Create a DI scope for resolving services using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); var updateLogHelper = scope.ServiceProvider.GetRequiredService(); // Get the currently logged-in employee for audit and permission checks var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("MarkAsCompletedAsync initiated by EmployeeId {EmployeeId} for InvoiceId {InvoiceId}", loggedInEmployee.Id, invoiceId); // Check if the employee has CollectionAdmin permission var hasAdminPermission = await permissionService.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); _logger.LogInfo("Permission check result: EmployeeId {EmployeeId} Admin={Admin}", loggedInEmployee.Id, hasAdminPermission); if (!hasAdminPermission) { _logger.LogWarning("Access denied: EmployeeId {EmployeeId} lacks CollectionAdmin permission.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to mark invoices as completed.", 403)); } await using var context = await _dbContextFactory.CreateDbContextAsync(); // Retrieve the invoice with tenant isolation, tracked for updates var invoice = await context.Invoices.FirstOrDefaultAsync(i => i.Id == invoiceId && i.TenantId == tenantId); if (invoice == null) { _logger.LogWarning("Invoice not found: InvoiceId {InvoiceId}, TenantId {TenantId}", invoiceId, tenantId); return NotFound(ApiResponse.ErrorResponse( "Invoice Not Found", "The specified invoice does not exist.", 404)); } if (invoice.MarkAsCompleted) { _logger.LogWarning("Invoice {InvoiceId} is already marked as completed by EmployeeId {EmployeeId}.", invoice.Id, loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse( "Invoice Already Completed", "Invoice is already marked as completed.", 400)); } try { // Capture state before modification for audit logs var previousState = updateLogHelper.EntityToBsonDocument(invoice); invoice.MarkAsCompleted = true; invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedById = loggedInEmployee.Id; await context.SaveChangesAsync(); _logger.LogInfo("Invoice {InvoiceId} marked as completed successfully by EmployeeId {EmployeeId}.", invoice.Id, loggedInEmployee.Id); // Log the update event with previous state snapshot await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = invoice.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = previousState, UpdatedAt = DateTime.UtcNow }, "InvoiceModificationLog"); // Prepare response view model var response = _mapper.Map(invoice); response.UpdatedBy = _mapper.Map(loggedInEmployee); response.BalanceAmount = response.BasicAmount + response.TaxAmount; return Ok(ApiResponse.SuccessResponse(response, "Invoice marked as completed successfully.", 200)); } catch (Exception ex) { _logger.LogError(ex, "Exception while marking invoice {InvoiceId} as completed by EmployeeId {EmployeeId}.", invoiceId, loggedInEmployee.Id); return StatusCode(500, ApiResponse.ErrorResponse( "Internal Server Error", "An unexpected error occurred while marking the invoice as completed.", 500)); } } #endregion #region =================================================================== Helper Functions =================================================================== private AdvanceFilter? TryDeserializeFilter(string? filter) { if (string.IsNullOrWhiteSpace(filter)) { return null; } var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; AdvanceFilter? advanceFilter = null; try { // First, try to deserialize directly. This is the expected case (e.g., from a web client). advanceFilter = JsonSerializer.Deserialize(filter, options); } catch (JsonException ex) { _logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter); // If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients). try { // Unescape the string first, then deserialize the result. string unescapedJsonString = JsonSerializer.Deserialize(filter, options) ?? ""; if (!string.IsNullOrWhiteSpace(unescapedJsonString)) { advanceFilter = JsonSerializer.Deserialize(unescapedJsonString, options); } } catch (JsonException ex1) { // If both attempts fail, log the final error and return null. _logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter); return null; } } return advanceFilter; } /// /// Async permission check helper with scoped DI lifetime /// private async Task HasPermissionAsync(Guid permission, Guid employeeId) { using var scope = _serviceScopeFactory.CreateScope(); var permissionService = scope.ServiceProvider.GetRequiredService(); return await permissionService.HasPermission(permission, employeeId); } /// /// Helper method to load infrastructure project by id. /// private async Task LoadInfraProjectAsync(Guid projectId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Projects.Where(p => p.Id == projectId && p.TenantId == tenantId).FirstOrDefaultAsync(); } /// /// Helper method to load service project by id. /// private async Task LoadServiceProjectAsync(Guid projectId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjects.Where(sp => sp.Id == projectId && sp.TenantId == tenantId).FirstOrDefaultAsync(); } /// /// Helper method to load organization by Id. /// private async Task LoadOrganizationAsync(Guid organizationId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Organizations.FirstOrDefaultAsync(o => o.Id == organizationId && o.IsActive); } /// /// Loads invoice comments asynchronously with related metadata. /// private async Task> LoadInvoiceCommentsAsync(Guid invoiceId, Guid tenantId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.InvoiceComments .Include(ic => ic.CreatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(ic => ic.InvoiceId == invoiceId && ic.TenantId == tenantId) .OrderByDescending(ic => ic.CreatedAt) .ToListAsync(); } /// /// Loads invoice attachments and their upload metadata asynchronously. /// private async Task> LoadInvoiceAttachmentsAsync(Guid invoiceId, Guid tenantId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.InvoiceAttachments .Include(ia => ia.Document) .ThenInclude(d => d!.UploadedBy) .ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(ia => ia.InvoiceId == invoiceId && ia.TenantId == tenantId && ia.Document != null && ia.Document.UploadedBy != null) .ToListAsync(); } /// /// Loads received invoice payment records asynchronously with creator metadata. /// private async Task> LoadReceivedInvoicePaymentsAsync(Guid invoiceId, Guid tenantId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ReceivedInvoicePayments .Include(rip => rip.PaymentAdjustmentHead) .Include(rip => rip.CreatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(rip => rip.InvoiceId == invoiceId && rip.TenantId == tenantId) .ToListAsync(); } #endregion } }