marco.pms.api/Marco.Pms.Services/Controllers/CollectionController.cs

690 lines
34 KiB
C#

using AutoMapper;
using Marco.Pms.DataAccess.Data;
using Marco.Pms.Model.Collection;
using Marco.Pms.Model.DocumentManager;
using Marco.Pms.Model.Dtos.Collection;
using Marco.Pms.Model.Utilities;
using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.Collection;
using Marco.Pms.Model.ViewModels.Projects;
using Marco.Pms.Services.Service;
using MarcoBMS.Services.Helpers;
using MarcoBMS.Services.Service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
namespace Marco.Pms.Services.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class CollectionController : ControllerBase
{
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly UserHelper _userHelper;
private readonly S3UploadService _s3Service;
private readonly IMapper _mapper;
private readonly ILoggingService _logger;
private readonly Guid tenantId;
public CollectionController(IDbContextFactory<ApplicationDbContext> dbContextFactory,
IServiceScopeFactory serviceScopeFactory,
S3UploadService s3Service,
UserHelper userhelper,
ILoggingService logger,
IMapper mapper)
{
_dbContextFactory = dbContextFactory;
_serviceScopeFactory = serviceScopeFactory;
_userHelper = userhelper;
_s3Service = s3Service;
_mapper = mapper;
_logger = logger;
tenantId = userhelper.GetTenantId();
}
[HttpGet("invoice/list")]
public async Task<IActionResult> GetInvoiceListAsync([FromQuery] string? searchString, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1
, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false)
{
_logger.LogInfo(
"Fetching invoice list: Page {PageNumber}, Size {PageSize}, Active={IsActive}, PendingOnly={IsPending}, Search='{SearchString}', From={From}, To={To}",
pageNumber, pageSize, isActive, isPending, searchString ?? "", fromDate?.Date ?? DateTime.MinValue, toDate?.Date ?? DateTime.MaxValue);
await using var _context = await _dbContextFactory.CreateDbContextAsync();
// Build base query with required includes and no tracking
var invoicesQuery = _context.Invoices
.Include(i => i.Project)
.Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole)
.Where(i => i.IsActive == isActive && i.TenantId == tenantId)
.AsNoTracking(); // Disable change tracking for read-only query
// Apply date filter
if (fromDate.HasValue && toDate.HasValue)
{
var fromDateUtc = fromDate.Value.Date;
var toDateUtc = toDate.Value.Date.AddDays(1).AddTicks(-1); // End of day
invoicesQuery = invoicesQuery.Where(i => i.InvoiceDate >= fromDateUtc && i.InvoiceDate <= toDateUtc);
_logger.LogDebug("Applied date filter: {From} to {To}", fromDateUtc, toDateUtc);
}
// Apply search filter
if (!string.IsNullOrWhiteSpace(searchString))
{
invoicesQuery = invoicesQuery.Where(i => i.Title.Contains(searchString) || i.InvoiceNumber.Contains(searchString));
_logger.LogDebug("Applied search filter with term: {SearchString}", searchString);
}
// Get total count before pagination
var totalEntites = await invoicesQuery.CountAsync();
_logger.LogDebug("Total matching invoices: {TotalCount}", totalEntites);
// Apply sorting and pagination
var invoices = await invoicesQuery
.OrderByDescending(i => i.InvoiceDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
if (!invoices.Any())
{
_logger.LogInfo("No invoices found for the given criteria.");
var emptyResponse = new
{
CurrentPage = pageNumber,
TotalPages = 0,
TotalEntites = 0,
Data = new List<InvoiceListVM>()
};
return Ok(ApiResponse<object>.SuccessResponse(emptyResponse, "No invoices found"));
}
// Fetch all related payment data in a single query
var invoiceIds = invoices.Select(i => i.Id).ToList();
var paymentGroups = await _context.ReceivedInvoicePayments
.AsNoTracking()
.Where(rip => invoiceIds.Contains(rip.InvoiceId) && rip.TenantId == tenantId)
.GroupBy(rip => rip.InvoiceId)
.Select(g => new
{
InvoiceId = g.Key,
PaidAmount = g.Sum(rip => rip.Amount)
})
.ToDictionaryAsync(x => x.InvoiceId, x => x.PaidAmount);
_logger.LogDebug("Fetched payment data for {Count} invoices", paymentGroups.Count);
// Map and calculate balance in memory
var results = new List<InvoiceListVM>();
foreach (var invoice in invoices)
{
var totalAmount = invoice.BasicAmount + invoice.TaxAmount;
var paidAmount = paymentGroups.GetValueOrDefault(invoice.Id, 0);
var balanceAmount = totalAmount - paidAmount;
// Skip if filtering for pending invoices and balance is zero
if (isPending && balanceAmount <= 0)
continue;
var result = _mapper.Map<InvoiceListVM>(invoice);
result.BalanceAmount = balanceAmount;
results.Add(result);
}
var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize);
var response = new
{
CurrentPage = pageNumber,
TotalPages = totalPages,
TotalEntites = totalEntites,
Data = results
};
_logger.LogInfo("Successfully returned {ResultCount} invoices out of {TotalCount} total", results.Count, totalEntites);
return Ok(ApiResponse<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();
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
_logger.LogInfo("Starting invoice creation for ProjectId: {ProjectId} by EmployeeId: {EmployeeId}",
model.ProjectId, loggedInEmployee.Id);
if (model.InvoiceNumber.Length > 17)
{
_logger.LogWarning("Invoice Number {InvoiceNumber} is greater than 17 charater",
model.InvoiceNumber);
return BadRequest(ApiResponse<object>.ErrorResponse(
"Invoice Number {InvoiceNumber} is greater than 17 charater",
"Invoice Number {InvoiceNumber} is greater than 17 charater", 400));
}
// Validate date sequence
if (model.InvoiceDate.Date > DateTime.UtcNow.Date)
{
_logger.LogWarning("Invoice date {InvoiceDate} cannot be in the future.",
model.InvoiceDate);
return BadRequest(ApiResponse<object>.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<object>.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<object>.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<object>.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<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);
if (project == null)
{
_logger.LogWarning("Project not found: ProjectId {ProjectId}, TenantId {TenantId}",
model.ProjectId, tenantId);
return NotFound(ApiResponse<object>.ErrorResponse("Project not found", "Project not found", 404));
}
// Begin transaction scope with async flow support
await using var transaction = await _context.Database.BeginTransactionAsync();
var invoice = new Invoice();
try
{
// Map and create invoice
invoice = _mapper.Map<Invoice>(model);
invoice.IsActive = true;
invoice.MarkAsCompleted = false;
invoice.CreatedAt = DateTime.UtcNow;
invoice.CreatedById = loggedInEmployee.Id;
invoice.TenantId = tenantId;
_context.Invoices.Add(invoice);
await _context.SaveChangesAsync(); // Save to generate invoice.Id
// Handle attachments
var documents = new List<Document>();
var invoiceAttachments = new List<InvoiceAttachment>();
if (model.Attachments?.Any() == true)
{
var batchId = Guid.NewGuid();
foreach (var attachment in model.Attachments)
{
string base64 = attachment.Base64Data?.Split(',').LastOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(base64))
{
_logger.LogWarning("Base64 data is missing for attachment {FileName}", attachment.FileName ?? "");
return BadRequest(ApiResponse<object>.ErrorResponse("Base64 data is missing", "Image data missing", 400));
}
var fileType = _s3Service.GetContentTypeFromBase64(base64);
var fileName = _s3Service.GenerateFileName(fileType, tenantId, "invoice");
var objectKey = $"tenant-{tenantId}/Project/{model.ProjectId}/Invoice/{fileName}";
await _s3Service.UploadFileAsync(base64, fileType, objectKey);
var document = new Document
{
Id = Guid.NewGuid(),
BatchId = batchId,
UploadedById = loggedInEmployee.Id,
FileName = attachment.FileName ?? fileName,
ContentType = attachment.ContentType,
S3Key = objectKey,
FileSize = attachment.FileSize,
UploadedAt = DateTime.UtcNow,
TenantId = tenantId
};
documents.Add(document);
var invoiceAttachment = new InvoiceAttachment
{
InvoiceId = invoice.Id,
DocumentId = document.Id,
TenantId = tenantId
};
invoiceAttachments.Add(invoiceAttachment);
}
_context.Documents.AddRange(documents);
_context.InvoiceAttachments.AddRange(invoiceAttachments);
await _context.SaveChangesAsync(); // Save attachments and mappings
}
// Commit transaction
await transaction.CommitAsync();
_logger.LogInfo("Invoice {InvoiceId} created successfully with {AttachmentCount} attachments.",
invoice.Id, documents.Count);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "Transaction rolled back during invoice creation for ProjectId {ProjectId}", model.ProjectId);
return StatusCode(500, ApiResponse<object>.ErrorResponse(
"Transaction failed: " + ex.Message,
"An error occurred while creating the invoice", 500));
}
// Build response
var response = _mapper.Map<InvoiceListVM>(invoice);
response.Project = _mapper.Map<BasicProjectVM>(project);
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
response.BalanceAmount = response.BasicAmount + response.TaxAmount;
return StatusCode(201, ApiResponse<object>.SuccessResponse(response, "Invoice Created Successfully", 201));
}
/// <summary>
/// Creates a new received invoice payment record after validating business rules.
/// </summary>
/// <param name="model">The received invoice payment data transfer object containing payment details.</param>
/// <returns>An action result containing the created payment view model or error response.</returns>
[HttpPost("invoice/payment/received")]
public async Task<IActionResult> CreateReceivedInvoicePaymentAsync([FromBody] ReceivedInvoicePaymentDto model)
{
// Validate input model
if (model == null)
{
_logger.LogWarning("Received invoice payment creation request with null model");
return BadRequest(ApiResponse<object>.ErrorResponse("Invalid model", "Request payload cannot be null", 400));
}
await using var _context = await _dbContextFactory.CreateDbContextAsync();
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
// Retrieve invoice with tenant isolation and no tracking for read-only access
var invoice = await _context.Invoices
.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == model.InvoiceId && i.TenantId == tenantId);
if (invoice == null)
{
_logger.LogWarning("Invoice not found for ID {InvoiceId} and TenantId {TenantId}", model.InvoiceId, tenantId);
return NotFound(ApiResponse<object>.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<object>.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<object>.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<object>.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<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
var receivedInvoicePayment = _mapper.Map<ReceivedInvoicePayment>(model);
receivedInvoicePayment.CreatedAt = DateTime.UtcNow;
receivedInvoicePayment.CreatedById = loggedInEmployee.Id;
receivedInvoicePayment.TenantId = tenantId;
// Add new payment record and save changes
_context.ReceivedInvoicePayments.Add(receivedInvoicePayment);
await _context.SaveChangesAsync();
// Map entity to view model for response
var response = _mapper.Map<ReceivedInvoicePaymentVM>(receivedInvoicePayment);
_logger.LogInfo("Successfully created received payment {PaymentId} for invoice {InvoiceId}",
receivedInvoicePayment.Id, model.InvoiceId);
return StatusCode(201, ApiResponse<object>.SuccessResponse(response, "Payment invoice received successfully", 201));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while creating received payment for invoice {InvoiceId}", model.InvoiceId);
return StatusCode(500, ApiResponse<object>.ErrorResponse(
"Internal server error",
"An unexpected error occurred while processing the request", 500));
}
}
/// <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>
/// <param name="invoiceId">The unique identifier of the invoice to mark as completed.</param>
/// <returns>An action result indicating success or the nature of the error.</returns>
[HttpPut("invoice/marked/completed/{invoiceId}")]
public async Task<IActionResult> MarkAsCompletedAsync(Guid invoiceId)
{
// Create a new async database context for the current request's scope.
await using var _context = await _dbContextFactory.CreateDbContextAsync();
// Retrieve the current logged in employee for audit/logging (optional use).
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
// Attempt to find the invoice with tenant isolation; use AsNoTracking if no updates needed (but here we update so tracking is okay).
var invoice = await _context.Invoices
.FirstOrDefaultAsync(i => i.Id == invoiceId && i.TenantId == tenantId);
// Log and return 404 if the invoice does not exist.
if (invoice == null)
{
_logger.LogWarning("Invoice not found for ID {InvoiceId} and TenantId {TenantId}", invoiceId, tenantId);
return NotFound(ApiResponse<object>.ErrorResponse("Invoice not found", "The specified invoice does not exist", 404));
}
// If the invoice is already marked as completed, log and return meaningful error.
if (invoice.MarkAsCompleted)
{
_logger.LogWarning("Attempt to mark already completed invoice {InvoiceId} as completed by user {UserId}", invoiceId, loggedInEmployee.Id);
return BadRequest(ApiResponse<object>.ErrorResponse(
"Invoice already completed",
"Invoice is already marked as completed", 400));
}
try
{
// Mark invoice as completed.
invoice.MarkAsCompleted = true;
// Persist the change to the database.
await _context.SaveChangesAsync();
_logger.LogInfo("Invoice {InvoiceId} marked as completed by user {UserId}", invoiceId, loggedInEmployee.Id);
return Ok(ApiResponse<object>.SuccessResponse(new { }, "Invoice is marked as completed successfully", 200));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while marking invoice {InvoiceId} as completed by user {UserId}", invoiceId, loggedInEmployee.Id);
return StatusCode(500, ApiResponse<object>.ErrorResponse(
"Internal server error",
"An unexpected error occurred while processing the request", 500));
}
}
/// <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();
}
}
}