From 62688d508f098955d868e366c68650afcdb7166b Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Tue, 14 Oct 2025 11:24:35 +0530 Subject: [PATCH] Added the add comment API and get Details API --- .../Dtos/Collection/InvoiceCommentDto.cs | 8 + .../Collection/InvoiceAttachmentVM.cs | 14 + .../ViewModels/Collection/InvoiceCommentVM.cs | 13 + .../ViewModels/Collection/InvoiceDetailsVM.cs | 31 +++ .../Controllers/CollectionController.cs | 253 +++++++++++++++++- .../MappingProfiles/MappingProfile.cs | 5 + 6 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 Marco.Pms.Model/Dtos/Collection/InvoiceCommentDto.cs create mode 100644 Marco.Pms.Model/ViewModels/Collection/InvoiceAttachmentVM.cs create mode 100644 Marco.Pms.Model/ViewModels/Collection/InvoiceCommentVM.cs create mode 100644 Marco.Pms.Model/ViewModels/Collection/InvoiceDetailsVM.cs diff --git a/Marco.Pms.Model/Dtos/Collection/InvoiceCommentDto.cs b/Marco.Pms.Model/Dtos/Collection/InvoiceCommentDto.cs new file mode 100644 index 0000000..5a1c4f5 --- /dev/null +++ b/Marco.Pms.Model/Dtos/Collection/InvoiceCommentDto.cs @@ -0,0 +1,8 @@ +namespace Marco.Pms.Model.Dtos.Collection +{ + public class InvoiceCommentDto + { + public required string Comment { get; set; } + public required Guid InvoiceId { get; set; } + } +} diff --git a/Marco.Pms.Model/ViewModels/Collection/InvoiceAttachmentVM.cs b/Marco.Pms.Model/ViewModels/Collection/InvoiceAttachmentVM.cs new file mode 100644 index 0000000..488fb25 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Collection/InvoiceAttachmentVM.cs @@ -0,0 +1,14 @@ +using Marco.Pms.Model.ViewModels.Activities; + +namespace Marco.Pms.Model.ViewModels.Collection +{ + public class InvoiceAttachmentVM + { + public Guid Id { get; set; } + public Guid InvoiceId { get; set; } + public Guid DocumentId { get; set; } + public string? PreSignedUrl { get; set; } + public BasicEmployeeVM? UploadedBy { get; set; } + + } +} diff --git a/Marco.Pms.Model/ViewModels/Collection/InvoiceCommentVM.cs b/Marco.Pms.Model/ViewModels/Collection/InvoiceCommentVM.cs new file mode 100644 index 0000000..978efbf --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Collection/InvoiceCommentVM.cs @@ -0,0 +1,13 @@ +using Marco.Pms.Model.ViewModels.Activities; + +namespace Marco.Pms.Model.ViewModels.Collection +{ + public class InvoiceCommentVM + { + public Guid Id { get; set; } + public string Comment { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public BasicEmployeeVM? CreatedBy { get; set; } + public Guid InvoiceId { get; set; } + } +} diff --git a/Marco.Pms.Model/ViewModels/Collection/InvoiceDetailsVM.cs b/Marco.Pms.Model/ViewModels/Collection/InvoiceDetailsVM.cs new file mode 100644 index 0000000..b63ebe5 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Collection/InvoiceDetailsVM.cs @@ -0,0 +1,31 @@ +using Marco.Pms.Model.ViewModels.Activities; +using Marco.Pms.Model.ViewModels.Projects; + +namespace Marco.Pms.Model.ViewModels.Collection +{ + public class InvoiceDetailsVM + { + public Guid Id { get; set; } + public string Title { get; set; } = default!; + public string Description { get; set; } = default!; + public string InvoiceNumber { get; set; } = default!; + public string? EInvoiceNumber { get; set; } + public BasicProjectVM? Project { get; set; } + public DateTime InvoiceDate { get; set; } + public DateTime ClientSubmitedDate { get; set; } + public DateTime ExceptedPaymentDate { get; set; } + public double BasicAmount { get; set; } + public double TaxAmount { get; set; } + public double BalanceAmount { get; set; } + public bool IsActive { get; set; } = true; + public bool MarkAsCompleted { get; set; } = true; + public DateTime CreatedAt { get; set; } + public BasicEmployeeVM? CreatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public BasicEmployeeVM? UpdatedBy { get; set; } + public List? Attachments { get; set; } + public List? ReceivedInvoicePayments { get; set; } + public List? Comments { get; set; } + + } +} diff --git a/Marco.Pms.Services/Controllers/CollectionController.cs b/Marco.Pms.Services/Controllers/CollectionController.cs index 4664700..57a9389 100644 --- a/Marco.Pms.Services/Controllers/CollectionController.cs +++ b/Marco.Pms.Services/Controllers/CollectionController.cs @@ -148,13 +148,82 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(response, $"{results.Count} invoices fetched successfully")); } + /// + /// Retrieves complete details of a specific invoice including associated comments, attachments, and payments. + /// + /// The unique identifier of the invoice. + /// Returns invoice details with associated data or a NotFound/BadRequest response. + [HttpGet("invoice/details/{id}")] + public async Task GetInvoiceDetailsAsync(Guid id) + { + _logger.LogInfo("Fetching details for InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId); + + await using var context = await _dbContextFactory.CreateDbContextAsync(); + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Retrieve primary invoice details with related entities (project, created/updated by + roles) + var invoice = await context.Invoices + .Include(i => i.Project) + .Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole) + .Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole) + .AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == id && i.TenantId == tenantId); + + if (invoice == null) + { + _logger.LogWarning("Invoice not found. InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId); + return NotFound(ApiResponse.ErrorResponse("Invoice not found", "The specified invoice does not exist.", 404)); + } + + _logger.LogInfo("Invoice {InvoiceId} found. Fetching related data...", id); + + // Parallelize loading of child collections using independent DbContext instances — efficient and thread-safe + var commentsTask = LoadInvoiceCommentsAsync(id, tenantId); + var attachmentsTask = LoadInvoiceAttachmentsAsync(id, tenantId); + var paymentsTask = LoadReceivedInvoicePaymentsAsync(id, tenantId); + + await Task.WhenAll(commentsTask, attachmentsTask, paymentsTask); + + var comments = commentsTask.Result; + var attachments = attachmentsTask.Result; + var receivedInvoicePayments = paymentsTask.Result; + + // Map invoice to response view model + var response = _mapper.Map(invoice); + + // Populate related data + if (comments.Any()) + response.Comments = _mapper.Map>(comments); + + if (attachments.Any()) + { + response.Attachments = attachments.Select(a => + { + var result = _mapper.Map(a); + result.PreSignedUrl = _s3Service.GeneratePreSignedUrl(a.Document!.S3Key); + result.UploadedBy = _mapper.Map(a.Document.UploadedBy); + return result; + }).ToList(); + } + + if (receivedInvoicePayments.Any()) + response.ReceivedInvoicePayments = _mapper.Map>(receivedInvoicePayments); + + // Compute total paid and balance amounts + double totalPaidAmount = receivedInvoicePayments.Sum(rip => rip.Amount); + double totalAmount = invoice.BasicAmount + invoice.TaxAmount; + response.BalanceAmount = totalAmount - totalPaidAmount; + + _logger.LogInfo("Invoice {InvoiceId} details fetched successfully: Total = {TotalAmount}, Paid = {PaidAmount}, Balance = {BalanceAmount}", + id, totalAmount, totalPaidAmount, response.BalanceAmount); + + return Ok(ApiResponse.SuccessResponse(response, "Invoice details fetched successfully", 200)); + } [HttpPost("invoice/create")] public async Task CreateInvoiceAsync([FromBody] InvoiceDto model) { await using var _context = await _dbContextFactory.CreateDbContextAsync(); - //using var scope = _serviceScopeFactory.CreateScope(); - //var permissionService = scope.ServiceProvider.GetRequiredService(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); _logger.LogInfo("Starting invoice creation for ProjectId: {ProjectId} by EmployeeId: {EmployeeId}", @@ -183,7 +252,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogWarning("Invoice date {InvoiceDate} is later than client submitted date {ClientSubmitedDate}", model.InvoiceDate, model.ClientSubmitedDate); return BadRequest(ApiResponse.ErrorResponse( - "Invoice date is later than client submitted date", + "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) @@ -199,18 +268,49 @@ namespace Marco.Pms.Services.Controllers _logger.LogWarning("Client submitted date {ClientSubmitedDate} is later than expected payment date {ExpectedPaymentDate}", model.ClientSubmitedDate, model.ExceptedPaymentDate); return BadRequest(ApiResponse.ErrorResponse( - "Client submitted date is later than expected payment date", + "Client submission date cannot be later than the expected payment date", "Client submitted date is later than expected payment date", 400)); } - if (model.ExceptedPaymentDate.Date < DateTime.UtcNow.Date) + + // 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) { - _logger.LogWarning("Excepted Payment Date {ExceptedPaymentDate} cannot be in the future.", - model.ExceptedPaymentDate); - return BadRequest(ApiResponse.ErrorResponse( - "Excepted Payment Date cannot be in the future", - "Excepted Payment Date cannot be in the future", 400)); + // Log the conflict event with full context for audit/review. + _logger.LogWarning( + "Invoice number conflict detected for InvoiceNumber: {InvoiceNumber} and TenantId: {TenantId}", + model.InvoiceNumber, tenantId); + + // Return HTTP 409 (Conflict) with a descriptive, actionable message. + return StatusCode(409, ApiResponse.ErrorResponse( + "Invoice number already exists", + $"The invoice number '{model.InvoiceNumber}' is already in use for this tenant. Please choose a unique invoice number.", + 409)); } + // If E-InvoiceNumber is provided (optional), validate its uniqueness for this tenant. + if (!string.IsNullOrWhiteSpace(model.EInvoiceNumber)) + { + var eInvoiceNumberExists = await _context.Invoices + .AnyAsync(i => i.EInvoiceNumber == model.EInvoiceNumber && i.TenantId == tenantId); + + if (eInvoiceNumberExists) + { + _logger.LogWarning( + "E-Invoice number conflict detected for EInvoiceNumber: {EInvoiceNumber} and TenantId: {TenantId}", + model.EInvoiceNumber, tenantId); + + // Return HTTP 409 (Conflict) with a tailored message for E-Invoice. + return StatusCode(409, ApiResponse.ErrorResponse( + "E-Invoice number already exists", + $"The E-Invoice number '{model.EInvoiceNumber}' is already assigned to another invoice for this tenant. Please provide a unique E-Invoice number.", + 409)); + } + } + + // Fetch project var project = await _context.Projects .FirstOrDefaultAsync(p => p.Id == model.ProjectId && p.TenantId == tenantId); @@ -365,10 +465,39 @@ namespace Marco.Pms.Services.Controllers _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 submitted date is later than payment received date", + "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 @@ -398,6 +527,67 @@ namespace Marco.Pms.Services.Controllers } } + /// + /// Adds a comment to the specified invoice, validating model and invoice existence. + /// + /// DTO containing InvoiceId and Comment text. + /// 201 Created with comment details, or error codes for validation/invoice not found. + [HttpPost("invoice/add/comment")] + public async Task AddCommentToInvoiceAsync([FromBody] InvoiceCommentDto model) + { + // Validate incoming data early to avoid unnecessary database calls. + if (string.IsNullOrWhiteSpace(model.Comment)) + { + _logger.LogWarning("Invalid or missing comment data for InvoiceId {InvoiceId}, TenantId {TenantId}", model.InvoiceId, tenantId); + return BadRequest(ApiResponse.ErrorResponse( + "Invalid comment data", + "The comment text and model must not be null or empty.", + 400)); + } + + await using var _context = await _dbContextFactory.CreateDbContextAsync(); + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Find the target invoice for the specified tenant. + var invoice = await _context.Invoices + .FirstOrDefaultAsync(i => i.Id == model.InvoiceId && i.TenantId == tenantId); + + if (invoice == null) + { + _logger.LogWarning("Cannot add comment—invoice not found. InvoiceId {InvoiceId}, TenantId {TenantId}", model.InvoiceId, tenantId); + return NotFound(ApiResponse.ErrorResponse( + "Invoice not found", + $"Invoice with ID '{model.InvoiceId}' does not exist for the specified tenant.", + 404)); + } + + // Construct the new comment entity with required audit metadata. + var comment = new InvoiceComment + { + Id = Guid.NewGuid(), + Comment = model.Comment.Trim(), + InvoiceId = model.InvoiceId, + CreatedAt = DateTime.UtcNow, + CreatedById = loggedInEmployee.Id, + TenantId = tenantId + }; + + _context.InvoiceComments.Add(comment); + await _context.SaveChangesAsync(); + + _logger.LogInfo("Added new comment to invoice {InvoiceId} by employee {EmployeeId}, TenantId {TenantId}", + comment.InvoiceId, loggedInEmployee.Id, tenantId); + + var response = _mapper.Map(comment); + + // Return successful creation with comment details. + return StatusCode(201, ApiResponse.SuccessResponse( + response, + "Comment added to invoice successfully.", + 201)); + } + + /// /// Marks the specified invoice as completed if it exists and is not already completed. /// @@ -454,5 +644,46 @@ namespace Marco.Pms.Services.Controllers } } + /// + /// Loads invoice comments asynchronously with related metadata. + /// + private async Task> LoadInvoiceCommentsAsync(Guid invoiceId, Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.InvoiceComments + .Include(ic => ic.CreatedBy).ThenInclude(e => e!.JobRole) + .AsNoTracking() + .Where(ic => ic.InvoiceId == invoiceId && ic.TenantId == tenantId) + .ToListAsync(); + } + + /// + /// Loads invoice attachments and their upload metadata asynchronously. + /// + private async Task> LoadInvoiceAttachmentsAsync(Guid invoiceId, Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.InvoiceAttachments + .Include(ia => ia.Document) + .ThenInclude(d => d!.UploadedBy) + .ThenInclude(e => e!.JobRole) + .AsNoTracking() + .Where(ia => ia.InvoiceId == invoiceId && ia.TenantId == tenantId && ia.Document != null && ia.Document.UploadedBy != null) + .ToListAsync(); + } + + /// + /// Loads received invoice payment records asynchronously with creator metadata. + /// + private async Task> LoadReceivedInvoicePaymentsAsync(Guid invoiceId, Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.ReceivedInvoicePayments + .Include(rip => rip.CreatedBy).ThenInclude(e => e!.JobRole) + .AsNoTracking() + .Where(rip => rip.InvoiceId == invoiceId && rip.TenantId == tenantId) + .ToListAsync(); + } + } } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index fecece5..3b376d8 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -261,9 +261,14 @@ namespace Marco.Pms.Services.MappingProfiles #region ======================================================= Collection ======================================================= CreateMap(); CreateMap(); + CreateMap(); CreateMap(); CreateMap(); + + CreateMap(); + + CreateMap(); #endregion #region ======================================================= Master =======================================================