using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Collection; using Marco.Pms.Model.DocumentManager; using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Collection; using Marco.Pms.Model.ViewModels.Projects; 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; 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(); } [HttpGet("invoice/list")] public async Task GetInvoiceListAsync([FromQuery] string? searchString, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1 , [FromQuery] bool isActive = true, [FromQuery] bool isPending = false) { _logger.LogInfo( "Fetching invoice list: Page {PageNumber}, Size {PageSize}, Active={IsActive}, PendingOnly={IsPending}, Search='{SearchString}', From={From}, To={To}", pageNumber, pageSize, isActive, isPending, searchString ?? "", fromDate?.Date ?? DateTime.MinValue, toDate?.Date ?? DateTime.MaxValue); await using var _context = await _dbContextFactory.CreateDbContextAsync(); // Build base query with required includes and no tracking var invoicesQuery = _context.Invoices .Include(i => i.Project) .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) .Where(i => i.IsActive == isActive && i.TenantId == tenantId) .AsNoTracking(); // Disable change tracking for read-only query // Apply date filter if (fromDate.HasValue && toDate.HasValue) { var fromDateUtc = fromDate.Value.Date; var toDateUtc = toDate.Value.Date.AddDays(1).AddTicks(-1); // End of day invoicesQuery = invoicesQuery.Where(i => i.InvoiceDate >= fromDateUtc && i.InvoiceDate <= toDateUtc); _logger.LogDebug("Applied date filter: {From} to {To}", fromDateUtc, toDateUtc); } // Apply search filter if (!string.IsNullOrWhiteSpace(searchString)) { invoicesQuery = invoicesQuery.Where(i => i.Title.Contains(searchString) || i.InvoiceNumber.Contains(searchString)); _logger.LogDebug("Applied search filter with term: {SearchString}", searchString); } // Get total count before pagination var totalEntites = await invoicesQuery.CountAsync(); _logger.LogDebug("Total matching invoices: {TotalCount}", totalEntites); // Apply sorting and pagination var invoices = await invoicesQuery .OrderByDescending(i => i.InvoiceDate) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); if (!invoices.Any()) { _logger.LogInfo("No invoices found for the given criteria."); var emptyResponse = new { CurrentPage = pageNumber, TotalPages = 0, TotalEntites = 0, Data = new List() }; return Ok(ApiResponse.SuccessResponse(emptyResponse, "No invoices found")); } // Fetch all related payment data in a single query var invoiceIds = invoices.Select(i => i.Id).ToList(); var paymentGroups = await _context.ReceivedInvoicePayments .AsNoTracking() .Where(rip => invoiceIds.Contains(rip.InvoiceId) && rip.TenantId == tenantId) .GroupBy(rip => rip.InvoiceId) .Select(g => new { InvoiceId = g.Key, PaidAmount = g.Sum(rip => rip.Amount) }) .ToDictionaryAsync(x => x.InvoiceId, x => x.PaidAmount); _logger.LogDebug("Fetched payment data for {Count} invoices", paymentGroups.Count); // Map and calculate balance in memory var results = new List(); foreach (var invoice in invoices) { var totalAmount = invoice.BasicAmount + invoice.TaxAmount; var paidAmount = paymentGroups.GetValueOrDefault(invoice.Id, 0); var balanceAmount = totalAmount - paidAmount; // Skip if filtering for pending invoices and balance is zero if (isPending && balanceAmount <= 0) continue; var result = _mapper.Map(invoice); result.BalanceAmount = balanceAmount; results.Add(result); } var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize); var response = new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntites = totalEntites, Data = results }; _logger.LogInfo("Successfully returned {ResultCount} invoices out of {TotalCount} total", results.Count, totalEntites); return Ok(ApiResponse.SuccessResponse(response, $"{results.Count} invoices fetched successfully")); } /// /// Retrieves complete details of a specific invoice including associated comments, attachments, and payments. /// /// The unique identifier of the invoice. /// Returns invoice details with associated data or a NotFound/BadRequest response. [HttpGet("invoice/details/{id}")] public async Task GetInvoiceDetailsAsync(Guid id) { _logger.LogInfo("Fetching details for InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId); await using var context = await _dbContextFactory.CreateDbContextAsync(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Retrieve primary invoice details with related entities (project, created/updated by + roles) var invoice = await context.Invoices .Include(i => i.Project) .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .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 {InvoiceId} found. Fetching related data...", id); // Parallelize loading of child collections using independent DbContext instances — efficient and thread-safe 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; // Map invoice to response view model var response = _mapper.Map(invoice); // Populate related data if (comments.Any()) response.Comments = _mapper.Map>(comments); if (attachments.Any()) { response.Attachments = attachments.Select(a => { var result = _mapper.Map(a); result.PreSignedUrl = _s3Service.GeneratePreSignedUrl(a.Document!.S3Key); result.UploadedBy = _mapper.Map(a.Document.UploadedBy); return result; }).ToList(); } if (receivedInvoicePayments.Any()) response.ReceivedInvoicePayments = _mapper.Map>(receivedInvoicePayments); // Compute total paid and balance amounts double totalPaidAmount = receivedInvoicePayments.Sum(rip => rip.Amount); double totalAmount = invoice.BasicAmount + invoice.TaxAmount; response.BalanceAmount = totalAmount - totalPaidAmount; _logger.LogInfo("Invoice {InvoiceId} details fetched successfully: Total = {TotalAmount}, Paid = {PaidAmount}, Balance = {BalanceAmount}", id, totalAmount, totalPaidAmount, response.BalanceAmount); return Ok(ApiResponse.SuccessResponse(response, "Invoice details fetched successfully", 200)); } [HttpPost("invoice/create")] public async Task CreateInvoiceAsync([FromBody] InvoiceDto model) { await using var _context = await _dbContextFactory.CreateDbContextAsync(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("Starting invoice creation for ProjectId: {ProjectId} by EmployeeId: {EmployeeId}", model.ProjectId, loggedInEmployee.Id); if (model.InvoiceNumber.Length > 17) { _logger.LogWarning("Invoice Number {InvoiceNumber} is greater than 17 charater", model.InvoiceNumber); return BadRequest(ApiResponse.ErrorResponse( "Invoice Number {InvoiceNumber} is greater than 17 charater", "Invoice Number {InvoiceNumber} is greater than 17 charater", 400)); } // Validate date sequence if (model.InvoiceDate.Date > DateTime.UtcNow.Date) { _logger.LogWarning("Invoice date {InvoiceDate} cannot be in the future.", model.InvoiceDate); return BadRequest(ApiResponse.ErrorResponse( "Invoice date cannot be in the future", "Invoice date cannot be in the future", 400)); } if (model.InvoiceDate.Date > model.ClientSubmitedDate.Date) { _logger.LogWarning("Invoice date {InvoiceDate} is later than client submitted date {ClientSubmitedDate}", model.InvoiceDate, model.ClientSubmitedDate); return BadRequest(ApiResponse.ErrorResponse( "Invoice date cannot be later than the client submitted date", "Invoice date is later than client submitted date", 400)); } if (model.ClientSubmitedDate.Date > DateTime.UtcNow.Date) { _logger.LogWarning("Client submited date {ClientSubmitedDate} cannot be in the future.", model.InvoiceDate); return BadRequest(ApiResponse.ErrorResponse( "Client submited date cannot be in the future", "Client submited date cannot be in the future", 400)); } if (model.ClientSubmitedDate.Date > model.ExceptedPaymentDate.Date) { _logger.LogWarning("Client submitted date {ClientSubmitedDate} is later than expected payment date {ExpectedPaymentDate}", model.ClientSubmitedDate, model.ExceptedPaymentDate); return BadRequest(ApiResponse.ErrorResponse( "Client submission date cannot be later than the expected payment date", "Client submitted date is later than expected payment date", 400)); } // Check for existing InvoiceNumber for this tenant before creating/updating to maintain uniqueness. var invoiceNumberExists = await _context.Invoices .AnyAsync(i => i.InvoiceNumber == model.InvoiceNumber && i.TenantId == tenantId); if (invoiceNumberExists) { // Log the conflict event with full context for audit/review. _logger.LogWarning( "Invoice number conflict detected for InvoiceNumber: {InvoiceNumber} and TenantId: {TenantId}", model.InvoiceNumber, tenantId); // Return HTTP 409 (Conflict) with a descriptive, actionable message. return StatusCode(409, ApiResponse.ErrorResponse( "Invoice number already exists", $"The invoice number '{model.InvoiceNumber}' is already in use for this tenant. Please choose a unique invoice number.", 409)); } // If E-InvoiceNumber is provided (optional), validate its uniqueness for this tenant. if (!string.IsNullOrWhiteSpace(model.EInvoiceNumber)) { var eInvoiceNumberExists = await _context.Invoices .AnyAsync(i => i.EInvoiceNumber == model.EInvoiceNumber && i.TenantId == tenantId); if (eInvoiceNumberExists) { _logger.LogWarning( "E-Invoice number conflict detected for EInvoiceNumber: {EInvoiceNumber} and TenantId: {TenantId}", model.EInvoiceNumber, tenantId); // Return HTTP 409 (Conflict) with a tailored message for E-Invoice. return StatusCode(409, ApiResponse.ErrorResponse( "E-Invoice number already exists", $"The E-Invoice number '{model.EInvoiceNumber}' is already assigned to another invoice for this tenant. Please provide a unique E-Invoice number.", 409)); } } // Fetch project var project = await _context.Projects .FirstOrDefaultAsync(p => p.Id == model.ProjectId && p.TenantId == tenantId); if (project == null) { _logger.LogWarning("Project not found: ProjectId {ProjectId}, TenantId {TenantId}", model.ProjectId, tenantId); return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); } // Begin transaction scope with async flow support await using var transaction = await _context.Database.BeginTransactionAsync(); var invoice = new Invoice(); try { // Map and create invoice 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(); // Save to generate invoice.Id // Handle attachments var documents = new List(); var invoiceAttachments = new List(); if (model.Attachments?.Any() == true) { var batchId = Guid.NewGuid(); foreach (var attachment in model.Attachments) { string base64 = attachment.Base64Data?.Split(',').LastOrDefault() ?? ""; if (string.IsNullOrWhiteSpace(base64)) { _logger.LogWarning("Base64 data is missing for attachment {FileName}", attachment.FileName ?? ""); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Image data missing", 400)); } var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileName = _s3Service.GenerateFileName(fileType, tenantId, "invoice"); var objectKey = $"tenant-{tenantId}/Project/{model.ProjectId}/Invoice/{fileName}"; await _s3Service.UploadFileAsync(base64, 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); var invoiceAttachment = new InvoiceAttachment { InvoiceId = invoice.Id, DocumentId = document.Id, TenantId = tenantId }; invoiceAttachments.Add(invoiceAttachment); } _context.Documents.AddRange(documents); _context.InvoiceAttachments.AddRange(invoiceAttachments); await _context.SaveChangesAsync(); // Save attachments and mappings } // Commit transaction await transaction.CommitAsync(); _logger.LogInfo("Invoice {InvoiceId} created successfully with {AttachmentCount} attachments.", invoice.Id, documents.Count); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Transaction rolled back during invoice creation for ProjectId {ProjectId}", model.ProjectId); return StatusCode(500, ApiResponse.ErrorResponse( "Transaction failed: " + ex.Message, "An error occurred while creating the invoice", 500)); } // Build response var response = _mapper.Map(invoice); response.Project = _mapper.Map(project); response.CreatedBy = _mapper.Map(loggedInEmployee); response.BalanceAmount = response.BasicAmount + response.TaxAmount; return StatusCode(201, ApiResponse.SuccessResponse(response, "Invoice Created Successfully", 201)); } /// /// Creates a new received invoice payment record after validating business rules. /// /// The received invoice payment data transfer object containing payment details. /// An action result containing the created payment view model or error response. [HttpPost("invoice/payment/received")] public async Task CreateReceivedInvoicePaymentAsync([FromBody] ReceivedInvoicePaymentDto model) { // Validate input model if (model == null) { _logger.LogWarning("Received invoice payment creation request with null model"); return BadRequest(ApiResponse.ErrorResponse("Invalid model", "Request payload cannot be null", 400)); } await using var _context = await _dbContextFactory.CreateDbContextAsync(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Retrieve invoice with tenant isolation and no tracking for read-only access var invoice = await _context.Invoices .AsNoTracking() .FirstOrDefaultAsync(i => i.Id == model.InvoiceId && i.TenantId == tenantId); if (invoice == null) { _logger.LogWarning("Invoice not found for ID {InvoiceId} and TenantId {TenantId}", model.InvoiceId, tenantId); return NotFound(ApiResponse.ErrorResponse("Invoice not found", "The specified invoice does not exist", 404)); } // Check if invoice is already marked as completed if (invoice.MarkAsCompleted) { _logger.LogWarning("Attempt to add payment to completed invoice {InvoiceId}", model.InvoiceId); return BadRequest(ApiResponse.ErrorResponse( "Cannot add received payment to completed invoice", "Payments cannot be added to invoices that are already marked as completed", 400)); } // Validate payment received date is not in the future if (model.PaymentReceivedDate.Date > DateTime.UtcNow.Date) { _logger.LogWarning("Future payment date {PaymentReceivedDate} provided for invoice {InvoiceId}", model.PaymentReceivedDate, model.InvoiceId); return BadRequest(ApiResponse.ErrorResponse( "Payment received date cannot be in the future", "The payment received date must not be later than the current date", 400)); } // Validate client submitted date is not later than payment received date if (invoice.ClientSubmitedDate.Date > model.PaymentReceivedDate.Date) { _logger.LogWarning("Client submitted date {ClientSubmitedDate} is later than payment received date {PaymentReceivedDate} for invoice {InvoiceId}", invoice.ClientSubmitedDate, model.PaymentReceivedDate, model.InvoiceId); return BadRequest(ApiResponse.ErrorResponse( "Client submission date cannot be later than the payment received date", "The client submission date cannot be later than the payment received date", 400)); } // Retrieve all previous payments for the given invoice and tenant in a single, efficient query. var receivedInvoicePayments = await _context.ReceivedInvoicePayments .Where(rip => rip.InvoiceId == invoice.Id && rip.TenantId == tenantId) .Select(rip => rip.Amount) // Only select required field for better performance. .ToListAsync(); // Calculate the sum of all previous payments to determine the total paid so far. var previousPaidAmount = receivedInvoicePayments.Sum(); var totalPaidAmount = previousPaidAmount + model.Amount; // Compute the invoice's total amount payable including taxes. var totalAmount = invoice.BasicAmount + invoice.TaxAmount; // Business rule validation: Prevent the overpayment scenario. if (totalPaidAmount > totalAmount) { // Log the details for easier debugging and audit trails. _logger.LogWarning( "Overpayment attempt detected. InvoiceId: {InvoiceId}, TenantId: {TenantId}, TotalInvoiceAmount: {TotalInvoiceAmount}, PreviousPaidAmount: {PreviousPaidAmount}, AttemptedPayment: {AttemptedPayment}, CalculatedTotalPaid: {TotalPaidAmount}.", invoice.Id, tenantId, totalAmount, previousPaidAmount, model.Amount, totalPaidAmount); // Return a bad request response with a clear, actionable error message. return BadRequest(ApiResponse.ErrorResponse( "Invalid payment: total paid amount exceeds invoice total.", $"The total of existing payments ({previousPaidAmount}) plus the new payment ({model.Amount}) would exceed the invoice total ({totalAmount}). Please verify payment details.", 400)); } try { // Map DTO to entity and set creation metadata var receivedInvoicePayment = _mapper.Map(model); receivedInvoicePayment.CreatedAt = DateTime.UtcNow; receivedInvoicePayment.CreatedById = loggedInEmployee.Id; receivedInvoicePayment.TenantId = tenantId; // Add new payment record and save changes _context.ReceivedInvoicePayments.Add(receivedInvoicePayment); await _context.SaveChangesAsync(); // Map entity to view model for response var response = _mapper.Map(receivedInvoicePayment); _logger.LogInfo("Successfully created received payment {PaymentId} for invoice {InvoiceId}", receivedInvoicePayment.Id, model.InvoiceId); return StatusCode(201, ApiResponse.SuccessResponse(response, "Payment invoice received successfully", 201)); } catch (Exception ex) { _logger.LogError(ex, "Error occurred while creating received payment for invoice {InvoiceId}", model.InvoiceId); return StatusCode(500, ApiResponse.ErrorResponse( "Internal server error", "An unexpected error occurred while processing the request", 500)); } } /// /// Adds a comment to the specified invoice, validating model and invoice existence. /// /// DTO containing InvoiceId and Comment text. /// 201 Created with comment details, or error codes for validation/invoice not found. [HttpPost("invoice/add/comment")] public async Task AddCommentToInvoiceAsync([FromBody] InvoiceCommentDto model) { // Validate incoming data early to avoid unnecessary database calls. if (string.IsNullOrWhiteSpace(model.Comment)) { _logger.LogWarning("Invalid or missing comment data for InvoiceId {InvoiceId}, TenantId {TenantId}", model.InvoiceId, tenantId); return BadRequest(ApiResponse.ErrorResponse( "Invalid comment data", "The comment text and model must not be null or empty.", 400)); } await using var _context = await _dbContextFactory.CreateDbContextAsync(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Find the target invoice for the specified tenant. var invoice = await _context.Invoices .FirstOrDefaultAsync(i => i.Id == model.InvoiceId && i.TenantId == tenantId); if (invoice == null) { _logger.LogWarning("Cannot add comment—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 the specified tenant.", 404)); } // Construct the new comment entity with required audit metadata. 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("Added new comment to invoice {InvoiceId} by employee {EmployeeId}, TenantId {TenantId}", comment.InvoiceId, loggedInEmployee.Id, tenantId); var response = _mapper.Map(comment); // Return successful creation with comment details. return StatusCode(201, ApiResponse.SuccessResponse( response, "Comment added to invoice successfully.", 201)); } /// /// Marks the specified invoice as completed if it exists and is not already completed. /// /// The unique identifier of the invoice to mark as completed. /// An action result indicating success or the nature of the error. [HttpPut("invoice/marked/completed/{invoiceId}")] public async Task MarkAsCompletedAsync(Guid invoiceId) { // Create a new async database context for the current request's scope. await using var _context = await _dbContextFactory.CreateDbContextAsync(); // Retrieve the current logged in employee for audit/logging (optional use). var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Attempt to find the invoice with tenant isolation; use AsNoTracking if no updates needed (but here we update so tracking is okay). var invoice = await _context.Invoices .FirstOrDefaultAsync(i => i.Id == invoiceId && i.TenantId == tenantId); // Log and return 404 if the invoice does not exist. if (invoice == null) { _logger.LogWarning("Invoice not found for ID {InvoiceId} and TenantId {TenantId}", invoiceId, tenantId); return NotFound(ApiResponse.ErrorResponse("Invoice not found", "The specified invoice does not exist", 404)); } // If the invoice is already marked as completed, log and return meaningful error. if (invoice.MarkAsCompleted) { _logger.LogWarning("Attempt to mark already completed invoice {InvoiceId} as completed by user {UserId}", invoiceId, loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse( "Invoice already completed", "Invoice is already marked as completed", 400)); } try { // Mark invoice as completed. invoice.MarkAsCompleted = true; // Persist the change to the database. await _context.SaveChangesAsync(); _logger.LogInfo("Invoice {InvoiceId} marked as completed by user {UserId}", invoiceId, loggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(new { }, "Invoice is marked as completed successfully", 200)); } catch (Exception ex) { _logger.LogError(ex, "Error occurred while marking invoice {InvoiceId} as completed by user {UserId}", invoiceId, loggedInEmployee.Id); return StatusCode(500, ApiResponse.ErrorResponse( "Internal server error", "An unexpected error occurred while processing the request", 500)); } } /// /// 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) .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.CreatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(rip => rip.InvoiceId == invoiceId && rip.TenantId == tenantId) .ToListAsync(); } } }