diff --git a/Marco.Pms.Services/Controllers/CollectionController.cs b/Marco.Pms.Services/Controllers/CollectionController.cs index 56a78e7..0b13983 100644 --- a/Marco.Pms.Services/Controllers/CollectionController.cs +++ b/Marco.Pms.Services/Controllers/CollectionController.cs @@ -5,6 +5,9 @@ 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.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; @@ -15,7 +18,6 @@ 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; @@ -55,7 +57,8 @@ namespace Marco.Pms.Services.Controllers /// /// Fetches a paginated and filtered list of invoices after validating permissions. /// - [HttpGet] + + [HttpGet("invoice/list")] 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) { @@ -213,748 +216,616 @@ namespace Marco.Pms.Services.Controllers } - /// - /// Retrieves complete details of a specific invoice including associated comments, attachments, and payments. + /// 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. - /// Returns invoice details with associated data or a NotFound/BadRequest response. + /// 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) { - _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 () => + try { - using var scope = _serviceScopeFactory.CreateScope(); - var _permission = scope.ServiceProvider.GetRequiredService(); - return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); - }); + _logger.LogInfo("GetInvoiceDetailsAsync called for InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId); - var viewPermissionTask = Task.Run(async () => - { - using var scope = _serviceScopeFactory.CreateScope(); - var _permission = scope.ServiceProvider.GetRequiredService(); - return await _permission.HasPermission(PermissionsMaster.ViewCollection, loggedInEmployee.Id); - }); + // Retrieve currently logged-in employee for permissions validation + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + _logger.LogInfo("Permission checks initiated for EmployeeId: {EmployeeId}", 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); - }); + // 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); - var editPermissionTask = Task.Run(async () => - { - using var scope = _serviceScopeFactory.CreateScope(); - var _permission = scope.ServiceProvider.GetRequiredService(); - return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id); - }); + // 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)); + } - var addPaymentPermissionTask = Task.Run(async () => - { - using var scope = _serviceScopeFactory.CreateScope(); - var _permission = scope.ServiceProvider.GetRequiredService(); - return await _permission.HasPermission(PermissionsMaster.AddPayment, loggedInEmployee.Id); - }); + _logger.LogInfo("Permission granted for EmployeeId {EmployeeId}, proceeding with invoice retrieval.", 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(); - }); + // 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); - await Task.WhenAll(infraProjectTask, serviceProjectTask); + 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)); + } - var infraProject = infraProjectTask.Result; - var serviceProject = serviceProjectTask.Result; + _logger.LogInfo("Invoice found: InvoiceId {InvoiceId}. Initiating related data load.", id); - if (serviceProject == null) - { - if (infraProject == null) + // 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); } - } - else - { - response.Project = _mapper.Map(serviceProject); - } - // Populate related data - if (comments.Any()) - response.Comments = _mapper.Map>(comments); + // Map comments if present + if (comments.Any()) + response.Comments = _mapper.Map>(comments); - if (attachments.Any()) - { - response.Attachments = attachments.Select(a => + // Map attachments with pre-signed URLs and metadata + if (attachments.Any()) { - 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(); + 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); - 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)); + // 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) { - // 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; + // 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); - _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) + // Concurrent permission checks scoped per call for thread safety + var permissionTasks = new[] { - var batchId = Guid.NewGuid(); + HasPermissionAsync(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id), + HasPermissionAsync(PermissionsMaster.CreateCollection, loggedInEmployee.Id) + }; + await Task.WhenAll(permissionTasks); - 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 + 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)); } - // Commit transaction - await transaction.CommitAsync(); - _logger.LogInfo("Invoice {InvoiceId} created successfully with {AttachmentCount} attachments.", - invoice.Id, documents.Count); + _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 ex) + catch (Exception e) { - await transaction.RollbackAsync(); - _logger.LogError(ex, "Transaction rolled back during invoice creation for ProjectId {ProjectId}", model.ProjectId); + _logger.LogError(e, "Unhandled exception in CreateInvoiceAsync: {Message}", e.Message); return StatusCode(500, ApiResponse.ErrorResponse( - "Transaction failed: " + ex.Message, - "An error occurred while creating the invoice", 500)); + "Internal Server Error", + "An unexpected 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. + /// Creates a received payment entry for a given invoice after validating permissions and business rules. /// - /// The received invoice payment data transfer object containing payment details. - /// An action result containing the created payment view model or error response. + /// 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) { - // 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; + // Retrieve the logged-in employee for auditing and permission validation + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + _logger.LogInfo("CreateReceivedInvoicePaymentAsync initiated - EmployeeId: {EmployeeId}", loggedInEmployee.Id); - // Add new payment record and save changes - _context.ReceivedInvoicePayments.Add(receivedInvoicePayment); - await _context.SaveChangesAsync(); + // 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); - // Map entity to view model for response - var response = _mapper.Map(receivedInvoicePayment); + 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)); + } - _logger.LogInfo("Successfully created received payment {PaymentId} for invoice {InvoiceId}", - receivedInvoicePayment.Id, model.InvoiceId); + // 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)); + } - return StatusCode(201, ApiResponse.SuccessResponse(response, "Payment invoice received successfully", 201)); + 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, "Error occurred while creating received payment for invoice {InvoiceId}", model.InvoiceId); + _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 while processing the request", 500)); + "Internal Server Error", + "An unexpected error occurred during payment creation. Please try again or contact support.", + 500)); } } /// - /// Adds a comment to the specified invoice, validating model and invoice existence. + /// Adds a comment to a specific invoice after validating user permissions and input. /// - /// DTO containing InvoiceId and Comment text. - /// 201 Created with comment details, or error codes for validation/invoice not found. + /// 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) { - // 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 () => + try { - using var scope = _serviceScopeFactory.CreateScope(); - var _permission = scope.ServiceProvider.GetRequiredService(); - return await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); - }); + // Retrieve current employee context for auditing and permissions + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + _logger.LogInfo("AddCommentToInvoiceAsync started by EmployeeId: {EmployeeId}", 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); - }); + // 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); - var createPermissionTask = Task.Run(async () => - { - using var scope = _serviceScopeFactory.CreateScope(); - var _permission = scope.ServiceProvider.GetRequiredService(); - return await _permission.HasPermission(PermissionsMaster.CreateCollection, loggedInEmployee.Id); - }); + // 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)); + } - var editPermissionTask = Task.Run(async () => - { - using var scope = _serviceScopeFactory.CreateScope(); - var _permission = scope.ServiceProvider.GetRequiredService(); - return await _permission.HasPermission(PermissionsMaster.EditCollection, loggedInEmployee.Id); - }); + _logger.LogInfo("Permission granted for EmployeeId {EmployeeId}. Validating input...", 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); - }); + // 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 all permission checks to complete concurrently - await Task.WhenAll(adminPermissionTask, viewPermissionTask, createPermissionTask, editPermissionTask, addPaymentPermissionTask); + await using var context = await _dbContextFactory.CreateDbContextAsync(); - // Capture permission results - var hasAdminPermission = adminPermissionTask.Result; - var hasViewPermission = viewPermissionTask.Result; - var hasCreatePermission = createPermissionTask.Result; - var hasEditPermission = editPermissionTask.Result; - var hasAddPaymentPermission = addPaymentPermissionTask.Result; + // 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); - // 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); + 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)); + } - // 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)); + // 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)); } - - // 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)) + catch (Exception ex) { - _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)); + _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)); } - - 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 @@ -962,187 +833,142 @@ namespace Marco.Pms.Services.Controllers #region =================================================================== Put Functions =================================================================== /// - /// Updates an existing invoice if it exists, has no payments, and the model is valid. + /// Updates an existing invoice including attachment management, after performing permission and business validations. /// - /// The unique identifier of the invoice to update. - /// The updated invoice data transfer object. - /// Success response on update, or appropriate error if validation fails. + /// 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) { - // 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); + // 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); - // Map updated data to existing invoice entity + // 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 attachment updates if provided - if (model.Attachments?.Any() ?? false) + // Handle attachments if provided + if (model.Attachments?.Any() == true) { - var inactiveDocumentIds = model.Attachments + // 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 (inactiveDocumentIds.Any()) + // Remove inactive attachments if any + if (inactiveDocIds.Any()) { - var existingInvoiceAttachments = await _context.InvoiceAttachments - .AsNoTracking() - .Where(ia => inactiveDocumentIds.Contains(ia.DocumentId) && ia.TenantId == tenantId) + var attachmentsToRemove = await context.InvoiceAttachments + .Where(ia => inactiveDocIds.Contains(ia.DocumentId) && ia.TenantId == tenantId) .ToListAsync(); - _context.InvoiceAttachments.RemoveRange(existingInvoiceAttachments); - _logger.LogInfo("Removed {Count} inactive attachments for InvoiceId {InvoiceId}", existingInvoiceAttachments.Count, id); + context.InvoiceAttachments.RemoveRange(attachmentsToRemove); + _logger.LogInfo("Removed {Count} inactive attachments from InvoiceId {InvoiceId}.", attachmentsToRemove.Count, id); } - // Process and upload new attachments + // Process new attachments by uploading and persisting if (newAttachments.Any()) { var batchId = Guid.NewGuid(); @@ -1151,25 +977,24 @@ namespace Marco.Pms.Services.Controllers foreach (var attachment in newAttachments) { - string base64Data = attachment.Base64Data?.Split(',').LastOrDefault() ?? string.Empty; + var base64Data = attachment.Base64Data?.Split(',').LastOrDefault() ?? string.Empty; if (string.IsNullOrWhiteSpace(base64Data)) { - _logger.LogWarning("Base64 data missing for attachment: {FileName}", attachment.FileName ?? "Unknown"); + _logger.LogWarning("Empty Base64 data for new attachment on InvoiceId {InvoiceId}.", id); return BadRequest(ApiResponse.ErrorResponse( - "Invalid attachment data", - "Base64 data is missing or malformed for one or more attachments.", + "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 = $"{objectKeyPerfix}/Invoice/{fileName}"; + var objectKey = $"{objectKeyPrefix}/Invoice/{fileName}"; - // Upload file to S3 + // Upload file to S3 asynchronously await _s3Service.UploadFileAsync(base64Data, contentType, objectKey); - // Create document record var document = new Document { Id = Guid.NewGuid(), @@ -1184,37 +1009,36 @@ namespace Marco.Pms.Services.Controllers }; documents.Add(document); - // Link document to invoice - var invoiceAttachment = new InvoiceAttachment + invoiceAttachments.Add(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); + // 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); } } - // Save all changes in a single transaction - await _context.SaveChangesAsync(); + // Persist all updates (invoice + attachments) in one SaveChanges call for consistency + await context.SaveChangesAsync(); - await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + // Push audit log entry with old invoice entity snapshot + await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = invoice.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), - OldObject = invoiceStateBeforeChange, + OldObject = invoiceStateBefore, UpdatedAt = DateTime.UtcNow }, "InvoiceModificationLog"); - _logger.LogInfo("Invoice {InvoiceId} updated successfully by EmployeeId {EmployeeId}, TenantId {TenantId}", - invoice.Id, loggedInEmployee.Id, tenantId); + _logger.LogInfo("Invoice {InvoiceId} updated successfully by EmployeeId {EmployeeId}, TenantId {TenantId}.", invoice.Id, loggedInEmployee.Id, tenantId); - // Build response + // Prepare response with updated invoice data and related mappings var response = _mapper.Map(invoice); response.Project = projectVM; response.BilledTo = _mapper.Map(billedTo); @@ -1225,111 +1049,105 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - _logger.LogError(ex, "Error occurred while updating InvoiceId {InvoiceId}, TenantId {TenantId}", id, tenantId); + _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.", + "Internal Server Error", + "An unexpected error occurred while updating the invoice. Please contact support.", 500)); } } /// - /// Marks the specified invoice as completed if it exists and is not already completed. + /// Marks a specified invoice as completed after validating permissions and business rules. /// - /// The unique identifier of the invoice to mark as completed. - /// An action result indicating success or the nature of the error. + /// 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 scope for permission service resolution + // Create a DI scope for resolving services using var scope = _serviceScopeFactory.CreateScope(); - var _permission = scope.ServiceProvider.GetRequiredService(); - var _updateLogHelper = scope.ServiceProvider.GetRequiredService(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + var updateLogHelper = scope.ServiceProvider.GetRequiredService(); - // Get the currently logged-in employee + // Get the currently logged-in employee for audit and permission checks var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - // Log starting permission checks - _logger.LogInfo("Starting permission checks for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + _logger.LogInfo("MarkAsCompletedAsync initiated by EmployeeId {EmployeeId} for InvoiceId {InvoiceId}", loggedInEmployee.Id, invoiceId); - // Capture permission results - var hasAdminPermission = await _permission.HasPermission(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id); + // 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); - // 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); + _logger.LogWarning("Access denied: EmployeeId {EmployeeId} lacks CollectionAdmin permission.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse( "Access Denied", - "User does not have permission to access collection data.", + "User does not have permission to mark invoices as completed.", 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(); - // Create a new async database context for the current request's scope. - 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); - // 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)); + _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 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); + _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)); + "Invoice Already Completed", + "Invoice is already marked as completed.", + 400)); } try { - var invoiceStateBeforeChange = _updateLogHelper.EntityToBsonDocument(invoice); + // Capture state before modification for audit logs + var previousState = 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(); + await context.SaveChangesAsync(); - _logger.LogInfo("Invoice {InvoiceId} marked as completed by user {UserId}", invoiceId, loggedInEmployee.Id); + _logger.LogInfo("Invoice {InvoiceId} marked as completed successfully by EmployeeId {EmployeeId}.", invoice.Id, loggedInEmployee.Id); - await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + // Log the update event with previous state snapshot + await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject { EntityId = invoice.Id.ToString(), UpdatedById = loggedInEmployee.Id.ToString(), - OldObject = invoiceStateBeforeChange, + 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 is marked as completed successfully", 200)); + return Ok(ApiResponse.SuccessResponse(response, "Invoice 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); + _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 processing the request", 500)); + "Internal Server Error", + "An unexpected error occurred while marking the invoice as completed.", + 500)); } } @@ -1347,6 +1165,33 @@ namespace Marco.Pms.Services.Controllers 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. /// @@ -1357,6 +1202,7 @@ namespace Marco.Pms.Services.Controllers .Include(ic => ic.CreatedBy).ThenInclude(e => e!.JobRole) .AsNoTracking() .Where(ic => ic.InvoiceId == invoiceId && ic.TenantId == tenantId) + .OrderByDescending(ic => ic.CreatedAt) .ToListAsync(); }