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.MongoDBModels.Utility; 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.Service; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; 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] public async Task GetInvoiceListAsync([FromQuery] Guid? projectId, [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) { 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); await using var _context = await _dbContextFactory.CreateDbContextAsync(); await using var context = await _dbContextFactory.CreateDbContextAsync(); // 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 => 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); } var totalItems = await query.CountAsync(); _logger.LogInfo("Total invoices found: {TotalItems}", totalItems); var pagedInvoices = await query .OrderByDescending(i => i.InvoiceDate) .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); // Fetch related project data asynchronously and in parallel var projIds = pagedInvoices.Select(i => i.ProjectId).Distinct().ToList(); var infraProjectsTask = _context.Projects .Where(p => projIds.Contains(p.Id) && p.TenantId == tenantId) .ToListAsync(); var serviceProjectsTask = context.ServiceProjects .Where(sp => projIds.Contains(sp.Id) && sp.TenantId == tenantId) .ToListAsync(); await Task.WhenAll(infraProjectsTask, serviceProjectsTask); var infraProjects = infraProjectsTask.Result; var serviceProjects = serviceProjectsTask.Result; // 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 = serviceProjects.Where(sp => sp.Id == invoice.ProjectId).Select(p => _mapper.Map(p)).FirstOrDefault() ?? infraProjects.Where(ip => ip.Id == invoice.ProjectId).Select(sp => _mapper.Map(sp)).FirstOrDefault(); vm.BalanceAmount = balance; results.Add(vm); } var totalPages = (int)Math.Ceiling((double)totalItems / pageSize); _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 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); // Get the currently logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Log starting permission checks _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Initiate permission check tasks asynchronously var adminPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); }); var viewPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.ViewCollection, loggedInEmployee.Id); }); var createPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.CreateCollection, loggedInEmployee.Id); }); var editPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id); }); var addPaymentPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.AddPayment, loggedInEmployee.Id); }); // Await all permission checks to complete concurrently await Task.WhenAll(adminPermissionTask, viewPermissionTask, createPermissionTask, editPermissionTask, addPaymentPermissionTask); // Capture permission results var hasAdminPermission = adminPermissionTask.Result; var hasViewPermission = viewPermissionTask.Result; var hasCreatePermission = createPermissionTask.Result; var hasEditPermission = editPermissionTask.Result; var hasAddPaymentPermission = addPaymentPermissionTask.Result; // Log permission results for audit _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, View={View}, Create={Create}, Edit={Edit}, Add Payment={AddPayment}", loggedInEmployee.Id, hasAdminPermission, hasViewPermission, hasCreatePermission, hasEditPermission, hasAddPaymentPermission); // Check if user has any relevant permission; if none, deny access if (!hasAdminPermission && !hasViewPermission && !hasCreatePermission && !hasEditPermission && !hasAddPaymentPermission) { _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to access collection data.", 403)); } // Optionally log success or continue with further processing here _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); await using var context = await _dbContextFactory.CreateDbContextAsync(); // Retrieve primary invoice details with related entities (project, created/updated by + roles) var invoice = await context.Invoices .Include(i => i.BilledTo) .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); var infraProjectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Projects.Where(p => p.Id == invoice.ProjectId && p.TenantId == tenantId).FirstOrDefaultAsync(); }); var serviceProjectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjects.Where(sp => sp.Id == invoice.ProjectId && sp.TenantId == tenantId).FirstOrDefaultAsync(); }); await Task.WhenAll(infraProjectTask, serviceProjectTask); var infraProject = infraProjectTask.Result; var serviceProject = serviceProjectTask.Result; if (serviceProject == null) { if (infraProject == null) { response.Project = _mapper.Map(infraProject); } } else { response.Project = _mapper.Map(serviceProject); } // 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); result.FileName = a.Document.FileName; result.ContentType = a.Document.ContentType; 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)); } #endregion #region =================================================================== Post Functions =================================================================== [HttpPost("invoice/create")] public async Task CreateInvoiceAsync([FromBody] InvoiceDto model) { // Get the currently logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Log starting permission checks _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Initiate permission check tasks asynchronously var adminPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); }); var createPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.CreateCollection, loggedInEmployee.Id); }); // Await all permission checks to complete concurrently await Task.WhenAll(adminPermissionTask, createPermissionTask); // Capture permission results var hasAdminPermission = adminPermissionTask.Result; var hasCreatePermission = createPermissionTask.Result; // Log permission results for audit _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, Create={Create}", loggedInEmployee.Id, hasAdminPermission, hasCreatePermission); // Check if user has any relevant permission; if none, deny access if (!hasAdminPermission && !hasCreatePermission) { _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to access collection data.", 403)); } // Optionally log success or continue with further processing here _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); await using var _context = await _dbContextFactory.CreateDbContextAsync(); _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 cannot be greater than 17 charater", "Invoice Number 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 infraProjectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Projects.Where(p => p.Id == model.ProjectId && p.TenantId == tenantId).FirstOrDefaultAsync(); }); var serviceProjectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjects.Where(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive).FirstOrDefaultAsync(); }); var billedToTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Organizations.Where(o => o.Id == model.BilledToId && o.IsActive).FirstOrDefaultAsync(); }); 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", "Project not found", 404)); } if (billedTo == null) { return NotFound(ApiResponse.ErrorResponse("Organization not found", "Organization not found", 404)); } BasicProjectVM? projectVM = null; string objectKeyPerfix = ""; if (serviceProject == null) { if (infraProject != null) { projectVM = _mapper.Map(infraProject); objectKeyPerfix = $"tenant-{tenantId}/Project/{model.ProjectId}"; } } else { projectVM = _mapper.Map(serviceProject); objectKeyPerfix = $"tenant-{tenantId}/ServiceProject/{model.ProjectId}"; } // 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 = $"{objectKeyPerfix}/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 = projectVM; 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)); } /// /// 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) { // Get the currently logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Log starting permission checks _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Initiate permission check tasks asynchronously var adminPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); }); var addPaymentPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.AddPayment, loggedInEmployee.Id); }); // Await all permission checks to complete concurrently await Task.WhenAll(adminPermissionTask, addPaymentPermissionTask); // Capture permission results var hasAdminPermission = adminPermissionTask.Result; var hasAddPaymentPermission = addPaymentPermissionTask.Result; // Log permission results for audit _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, Add Payment={AddPayment}", loggedInEmployee.Id, hasAdminPermission, hasAddPaymentPermission); // Check if user has any relevant permission; if none, deny access if (!hasAdminPermission && !hasAddPaymentPermission) { _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to access collection data.", 403)); } // Optionally log success or continue with further processing here _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); // 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(); // 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) { // Get the currently logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Log starting permission checks _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Initiate permission check tasks asynchronously var adminPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); }); var viewPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.ViewCollection, loggedInEmployee.Id); }); var createPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.CreateCollection, loggedInEmployee.Id); }); var editPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id); }); var addPaymentPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.AddPayment, loggedInEmployee.Id); }); // Await all permission checks to complete concurrently await Task.WhenAll(adminPermissionTask, viewPermissionTask, createPermissionTask, editPermissionTask, addPaymentPermissionTask); // Capture permission results var hasAdminPermission = adminPermissionTask.Result; var hasViewPermission = viewPermissionTask.Result; var hasCreatePermission = createPermissionTask.Result; var hasEditPermission = editPermissionTask.Result; var hasAddPaymentPermission = addPaymentPermissionTask.Result; // Log permission results for audit _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, View={View}, Create={Create}, Edit={Edit}, Add Payment={AddPayment}", loggedInEmployee.Id, hasAdminPermission, hasViewPermission, hasCreatePermission, hasEditPermission, hasAddPaymentPermission); // Check if user has any relevant permission; if none, deny access if (!hasAdminPermission && !hasViewPermission && !hasCreatePermission && !hasEditPermission && !hasAddPaymentPermission) { _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to access collection data.", 403)); } // Optionally log success or continue with further processing here _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); // 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(); // 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)); } #endregion #region =================================================================== Put Functions =================================================================== /// /// Updates an existing invoice if it exists, has no payments, and the model is valid. /// /// The unique identifier of the invoice to update. /// The updated invoice data transfer object. /// Success response on update, or appropriate error if validation fails. [HttpPut("invoice/edit/{id}")] public async Task UpdateInvoiceAsync(Guid id, [FromBody] InvoiceDto model) { // Get the currently logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Log starting permission checks _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Initiate permission check tasks asynchronously var adminPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); }); var editPermissionTask = Task.Run(async () => { using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id); }); // Await all permission checks to complete concurrently await Task.WhenAll(adminPermissionTask, editPermissionTask); // Capture permission results var hasAdminPermission = adminPermissionTask.Result; var hasEditPermission = editPermissionTask.Result; // Log permission results for audit _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}, Process={Process}", loggedInEmployee.Id, hasAdminPermission, hasEditPermission); // Check if user has any relevant permission; if none, deny access if (!hasAdminPermission && !hasEditPermission) { _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to access collection data.", 403)); } // Optionally log success or continue with further processing here _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); // Validate route and model ID consistency if (!model.Id.HasValue || id != model.Id) { _logger.LogWarning("Invoice ID mismatch: route ID {RouteId} does not match model ID {ModelId}", id, model.Id ?? Guid.Empty); return BadRequest(ApiResponse.ErrorResponse( "Invalid invoice ID", "The invoice ID in the URL does not match the ID in the request body.", 400)); } await using var _context = await _dbContextFactory.CreateDbContextAsync(); using var scope = _serviceScopeFactory.CreateScope(); var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); // Retrieve the 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 for ID {InvoiceId} and TenantId {TenantId}", id, tenantId); return NotFound(ApiResponse.ErrorResponse( "Invoice not found", "The specified invoice does not exist for this tenant.", 404)); } // Fetch project var infraProjectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Projects.Where(p => p.Id == model.ProjectId && p.TenantId == tenantId).FirstOrDefaultAsync(); }); var serviceProjectTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.ServiceProjects.Where(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive).FirstOrDefaultAsync(); }); var billedToTask = Task.Run(async () => { await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.Organizations.Where(o => o.Id == model.BilledToId && o.IsActive).FirstOrDefaultAsync(); }); 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", "Project not found", 404)); } if (billedTo == null) { return NotFound(ApiResponse.ErrorResponse("Organization not found", "Organization not found", 404)); } BasicProjectVM? projectVM = null; string objectKeyPerfix = ""; if (serviceProject == null) { if (infraProject != null) { projectVM = _mapper.Map(infraProject); objectKeyPerfix = $"tenant-{tenantId}/Project/{model.ProjectId}"; } } else { projectVM = _mapper.Map(serviceProject); objectKeyPerfix = $"tenant-{tenantId}/ServiceProject/{model.ProjectId}"; } // Prevent modification if any payment has already been received var receivedPaymentExists = await _context.ReceivedInvoicePayments .AnyAsync(rip => rip.InvoiceId == id && rip.TenantId == tenantId); if (receivedPaymentExists) { _logger.LogWarning("Update blocked: Payment already received for InvoiceId {InvoiceId}, TenantId {TenantId}", id, tenantId); return BadRequest(ApiResponse.ErrorResponse( "Invoice cannot be updated", "This invoice has received payments and cannot be modified.", 400)); } try { var invoiceStateBeforeChange = _updateLogHelper.EntityToBsonDocument(invoice); // Map updated data to existing invoice entity _mapper.Map(model, invoice); invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedById = loggedInEmployee.Id; // Handle attachment updates if provided if (model.Attachments?.Any() ?? false) { var inactiveDocumentIds = model.Attachments .Where(a => !a.IsActive && a.DocumentId.HasValue) .Select(a => a.DocumentId!.Value) .ToList(); var newAttachments = model.Attachments .Where(a => a.IsActive && !string.IsNullOrWhiteSpace(a.Base64Data)) .ToList(); // Remove inactive attachments if (inactiveDocumentIds.Any()) { var existingInvoiceAttachments = await _context.InvoiceAttachments .AsNoTracking() .Where(ia => inactiveDocumentIds.Contains(ia.DocumentId) && ia.TenantId == tenantId) .ToListAsync(); _context.InvoiceAttachments.RemoveRange(existingInvoiceAttachments); _logger.LogInfo("Removed {Count} inactive attachments for InvoiceId {InvoiceId}", existingInvoiceAttachments.Count, id); } // Process and upload new attachments if (newAttachments.Any()) { var batchId = Guid.NewGuid(); var documents = new List(); var invoiceAttachments = new List(); foreach (var attachment in newAttachments) { string base64Data = attachment.Base64Data?.Split(',').LastOrDefault() ?? string.Empty; if (string.IsNullOrWhiteSpace(base64Data)) { _logger.LogWarning("Base64 data missing for attachment: {FileName}", attachment.FileName ?? "Unknown"); return BadRequest(ApiResponse.ErrorResponse( "Invalid attachment data", "Base64 data is missing or malformed for one or more attachments.", 400)); } var contentType = _s3Service.GetContentTypeFromBase64(base64Data); var fileName = _s3Service.GenerateFileName(contentType, tenantId, "invoice"); var objectKey = $"{objectKeyPerfix}/Invoice/{fileName}"; // Upload file to S3 await _s3Service.UploadFileAsync(base64Data, contentType, objectKey); // Create document record 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); // Link document to invoice var invoiceAttachment = new InvoiceAttachment { InvoiceId = invoice.Id, DocumentId = document.Id, TenantId = tenantId }; invoiceAttachments.Add(invoiceAttachment); } _context.Documents.AddRange(documents); _context.InvoiceAttachments.AddRange(invoiceAttachments); _logger.LogInfo("Added {Count} new attachments to InvoiceId {InvoiceId}", invoiceAttachments.Count, id); } } // Save all changes in a single transaction await _context.SaveChangesAsync(); await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = invoice.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = invoiceStateBeforeChange, UpdatedAt = DateTime.UtcNow }, "InvoiceModificationLog"); _logger.LogInfo("Invoice {InvoiceId} updated successfully by EmployeeId {EmployeeId}, TenantId {TenantId}", invoice.Id, loggedInEmployee.Id, tenantId); // Build response 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, "Error occurred while updating InvoiceId {InvoiceId}, TenantId {TenantId}", id, tenantId); return StatusCode(500, ApiResponse.ErrorResponse( "Internal server error", "An unexpected error occurred while updating the invoice.", 500)); } } /// /// 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 scope for permission service resolution using var scope = _serviceScopeFactory.CreateScope(); var _permission = scope.ServiceProvider.GetRequiredService(); var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); // Get the currently logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // Log starting permission checks _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); // Capture permission results var hasAdminPermission = await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); // Log permission results for audit _logger.LogInfo("Permission results for EmployeeId {EmployeeId}: Admin={Admin}", loggedInEmployee.Id, hasAdminPermission); // Check if user has any relevant permission; if none, deny access if (!hasAdminPermission) { _logger.LogWarning("Permission denied for EmployeeId {EmployeeId} - No collection-related permissions found.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", "User does not have permission to access collection data.", 403)); } // Optionally log success or continue with further processing here _logger.LogInfo("Permission granted for EmployeeId {EmployeeId} - Proceeding with collection access.", loggedInEmployee.Id); // Create a new async database context for the current request's scope. await using var _context = await _dbContextFactory.CreateDbContextAsync(); // 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 { var invoiceStateBeforeChange = _updateLogHelper.EntityToBsonDocument(invoice); // Mark invoice as completed. invoice.MarkAsCompleted = true; invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedById = loggedInEmployee.Id; // Persist the change to the database. await _context.SaveChangesAsync(); _logger.LogInfo("Invoice {InvoiceId} marked as completed by user {UserId}", invoiceId, loggedInEmployee.Id); await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = invoice.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), OldObject = invoiceStateBeforeChange, UpdatedAt = DateTime.UtcNow }, "InvoiceModificationLog"); var response = _mapper.Map(invoice); response.UpdatedBy = _mapper.Map(loggedInEmployee); response.BalanceAmount = response.BasicAmount + response.TaxAmount; return Ok(ApiResponse.SuccessResponse(response, "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)); } } #endregion #region =================================================================== Helper Functions =================================================================== /// /// 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); } /// /// 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.PaymentAdjustmentHead) .Include(rip => rip.CreatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(rip => rip.InvoiceId == invoiceId && rip.TenantId == tenantId) .ToListAsync(); } #endregion } }