Added the add comment API and get Details API

This commit is contained in:
ashutosh.nehete 2025-10-14 11:24:35 +05:30
parent c92e71b292
commit 62688d508f
6 changed files with 313 additions and 11 deletions

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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<InvoiceAttachmentVM>? Attachments { get; set; }
public List<ReceivedInvoicePaymentVM>? ReceivedInvoicePayments { get; set; }
public List<InvoiceCommentVM>? Comments { get; set; }
}
}

View File

@ -148,13 +148,82 @@ namespace Marco.Pms.Services.Controllers
return Ok(ApiResponse<object>.SuccessResponse(response, $"{results.Count} invoices fetched successfully"));
}
/// <summary>
/// Retrieves complete details of a specific invoice including associated comments, attachments, and payments.
/// </summary>
/// <param name="id">The unique identifier of the invoice.</param>
/// <returns>Returns invoice details with associated data or a NotFound/BadRequest response.</returns>
[HttpGet("invoice/details/{id}")]
public async Task<IActionResult> 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<object>.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<InvoiceDetailsVM>(invoice);
// Populate related data
if (comments.Any())
response.Comments = _mapper.Map<List<InvoiceCommentVM>>(comments);
if (attachments.Any())
{
response.Attachments = attachments.Select(a =>
{
var result = _mapper.Map<InvoiceAttachmentVM>(a);
result.PreSignedUrl = _s3Service.GeneratePreSignedUrl(a.Document!.S3Key);
result.UploadedBy = _mapper.Map<BasicEmployeeVM>(a.Document.UploadedBy);
return result;
}).ToList();
}
if (receivedInvoicePayments.Any())
response.ReceivedInvoicePayments = _mapper.Map<List<ReceivedInvoicePaymentVM>>(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<object>.SuccessResponse(response, "Invoice details fetched successfully", 200));
}
[HttpPost("invoice/create")]
public async Task<IActionResult> CreateInvoiceAsync([FromBody] InvoiceDto model)
{
await using var _context = await _dbContextFactory.CreateDbContextAsync();
//using var scope = _serviceScopeFactory.CreateScope();
//var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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
}
}
/// <summary>
/// Adds a comment to the specified invoice, validating model and invoice existence.
/// </summary>
/// <param name="model">DTO containing InvoiceId and Comment text.</param>
/// <returns>201 Created with comment details, or error codes for validation/invoice not found.</returns>
[HttpPost("invoice/add/comment")]
public async Task<IActionResult> 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<object>.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<object>.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<InvoiceCommentVM>(comment);
// Return successful creation with comment details.
return StatusCode(201, ApiResponse<object>.SuccessResponse(
response,
"Comment added to invoice successfully.",
201));
}
/// <summary>
/// Marks the specified invoice as completed if it exists and is not already completed.
/// </summary>
@ -454,5 +644,46 @@ namespace Marco.Pms.Services.Controllers
}
}
/// <summary>
/// Loads invoice comments asynchronously with related metadata.
/// </summary>
private async Task<List<InvoiceComment>> 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();
}
/// <summary>
/// Loads invoice attachments and their upload metadata asynchronously.
/// </summary>
private async Task<List<InvoiceAttachment>> 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();
}
/// <summary>
/// Loads received invoice payment records asynchronously with creator metadata.
/// </summary>
private async Task<List<ReceivedInvoicePayment>> 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();
}
}
}

View File

@ -261,9 +261,14 @@ namespace Marco.Pms.Services.MappingProfiles
#region ======================================================= Collection =======================================================
CreateMap<InvoiceDto, Invoice>();
CreateMap<Invoice, InvoiceListVM>();
CreateMap<Invoice, InvoiceDetailsVM>();
CreateMap<ReceivedInvoicePaymentDto, ReceivedInvoicePayment>();
CreateMap<ReceivedInvoicePayment, ReceivedInvoicePaymentVM>();
CreateMap<InvoiceComment, InvoiceCommentVM>();
CreateMap<InvoiceAttachment, InvoiceAttachmentVM>();
#endregion
#region ======================================================= Master =======================================================