1336 lines
67 KiB
C#
1336 lines
67 KiB
C#
using AutoMapper;
|
|
using Marco.Pms.DataAccess.Data;
|
|
using Marco.Pms.Helpers.Utility;
|
|
using Marco.Pms.Model.Collection;
|
|
using Marco.Pms.Model.Dtos.Collection;
|
|
using Marco.Pms.Model.Entitlements;
|
|
using Marco.Pms.Model.Filters;
|
|
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;
|
|
using Marco.Pms.Model.ViewModels.Organization;
|
|
using Marco.Pms.Model.ViewModels.Projects;
|
|
using Marco.Pms.Services.Extensions;
|
|
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;
|
|
using System.Text.Json;
|
|
using Document = Marco.Pms.Model.DocumentManager.Document;
|
|
|
|
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();
|
|
}
|
|
|
|
#region =================================================================== Get Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Fetches a paginated and filtered list of invoices after validating permissions.
|
|
/// </summary>
|
|
|
|
[HttpGet("invoice/list")]
|
|
public async Task<IActionResult> GetInvoiceListAsync([FromQuery] Guid? projectId, [FromQuery] string? searchString, [FromQuery] string? filter, [FromQuery] DateTime? fromDate,
|
|
[FromQuery] DateTime? toDate, [FromQuery] int pageSize = 20, [FromQuery] int pageNumber = 1, [FromQuery] bool isActive = true, [FromQuery] bool isPending = false)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInfo(
|
|
"GetInvoiceListAsync called. Page: {PageNumber}, Size: {PageSize}, Active: {IsActive}, Pending: {IsPending}, Search: '{Search}', From: {From}, To: {To}",
|
|
pageNumber, pageSize, isActive, isPending, searchString ?? string.Empty, fromDate ?? DateTime.UtcNow, toDate ?? DateTime.UtcNow);
|
|
|
|
// Validate user identity and permissions in parallel for best performance
|
|
var employee = await _userHelper.GetCurrentEmployeeAsync();
|
|
_logger.LogInfo("Performing permission checks for EmployeeId: {EmployeeId}", employee.Id);
|
|
|
|
var permissionTasks = new[]
|
|
{
|
|
HasPermissionAsync(PermissionsMaster.CollectionAdmin, employee.Id),
|
|
HasPermissionAsync(PermissionsMaster.ViewCollection, employee.Id),
|
|
HasPermissionAsync(PermissionsMaster.CreateCollection, employee.Id),
|
|
HasPermissionAsync(PermissionsMaster.EditCollection, employee.Id),
|
|
HasPermissionAsync(PermissionsMaster.AddPayment, employee.Id)
|
|
};
|
|
|
|
await Task.WhenAll(permissionTasks);
|
|
|
|
if (permissionTasks.All(t => !t.Result))
|
|
{
|
|
_logger.LogWarning("Access denied. EmployeeId {EmployeeId} lacks relevant collection permissions.", employee.Id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse(
|
|
"Access Denied",
|
|
"User does not have necessary permissions.",
|
|
403));
|
|
}
|
|
|
|
_logger.LogInfo("Permissions validated for EmployeeId {EmployeeId}.", employee.Id);
|
|
|
|
var advanceFilter = TryDeserializeFilter(filter);
|
|
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
// Fetch related project data asynchronously and in parallel
|
|
var infraProjectsQuery = _context.Projects
|
|
.Where(p => p.TenantId == tenantId);
|
|
|
|
var serviceProjectsQuery = context.ServiceProjects
|
|
.Where(sp => sp.TenantId == tenantId);
|
|
|
|
if (advanceFilter?.SearchFilters != null && advanceFilter.SearchFilters.Any())
|
|
{
|
|
var projectSearchFilter = advanceFilter.SearchFilters
|
|
.Where(f => f.Column == "ProjectName")
|
|
.Select(f => new SearchItem { Column = "Name", Value = f.Value })
|
|
.ToList();
|
|
if (projectSearchFilter.Any())
|
|
{
|
|
infraProjectsQuery = infraProjectsQuery.ApplySearchFilters(projectSearchFilter);
|
|
serviceProjectsQuery = serviceProjectsQuery.ApplySearchFilters(projectSearchFilter);
|
|
}
|
|
}
|
|
|
|
var infraProjectsTask = infraProjectsQuery
|
|
.Select(p => _mapper.Map<BasicProjectVM>(p))
|
|
.ToListAsync();
|
|
var serviceProjectsTask = serviceProjectsQuery
|
|
.Select(sp => _mapper.Map<BasicProjectVM>(sp))
|
|
.ToListAsync();
|
|
|
|
await Task.WhenAll(infraProjectsTask, serviceProjectsTask);
|
|
|
|
var projects = infraProjectsTask.Result;
|
|
projects.AddRange(serviceProjectsTask.Result);
|
|
|
|
var projIds = projects.Select(p => p.Id).Distinct().ToList();
|
|
|
|
// Build invoice query efficiently - always use AsNoTracking for reads
|
|
var query = _context.Invoices
|
|
.AsNoTracking()
|
|
.Include(i => i.BilledTo)
|
|
.Include(i => i.CreatedBy).ThenInclude(e => e!.JobRole)
|
|
.Include(i => i.UpdatedBy).ThenInclude(e => e!.JobRole)
|
|
.Where(i => projIds.Contains(i.ProjectId) && i.IsActive == isActive && i.TenantId == tenantId);
|
|
|
|
// Filter by date, ensuring date boundaries are correct
|
|
if (fromDate.HasValue && toDate.HasValue)
|
|
{
|
|
var fromUtc = fromDate.Value.Date;
|
|
var toUtc = toDate.Value.Date.AddDays(1).AddTicks(-1);
|
|
query = query.Where(i => i.InvoiceDate >= fromUtc && i.InvoiceDate <= toUtc);
|
|
_logger.LogDebug("Date filter applied: {From} to {To}", fromUtc, toUtc);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(searchString))
|
|
{
|
|
query = query.Where(i => i.Title.Contains(searchString) || i.InvoiceNumber.Contains(searchString));
|
|
_logger.LogDebug("Search filter applied: '{Search}'", searchString);
|
|
}
|
|
|
|
if (projectId.HasValue)
|
|
{
|
|
query = query.Where(i => i.ProjectId == projectId.Value);
|
|
_logger.LogDebug("Project filter applied: {ProjectId}", projectId.Value);
|
|
}
|
|
|
|
if (advanceFilter != null)
|
|
{
|
|
query = query.ApplyCustomFilters(advanceFilter);
|
|
if (advanceFilter.SearchFilters != null)
|
|
{
|
|
var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList();
|
|
if (invoiceSearchFilter.Any())
|
|
{
|
|
query = query.ApplySearchFilters(invoiceSearchFilter);
|
|
}
|
|
}
|
|
}
|
|
var hasSortFilters = advanceFilter?.SortFilters?.Any() ?? false;
|
|
if (!hasSortFilters)
|
|
{
|
|
query = query.OrderByDescending(i => i.InvoiceDate);
|
|
}
|
|
|
|
var totalItems = await query.CountAsync();
|
|
_logger.LogInfo("Total invoices found: {TotalItems}", totalItems);
|
|
|
|
string groupByColumn = "ProjectId";
|
|
if (!string.IsNullOrWhiteSpace(advanceFilter?.GroupByColumn))
|
|
{
|
|
groupByColumn = advanceFilter.GroupByColumn;
|
|
}
|
|
query = query.ApplyGroupByFilters(groupByColumn);
|
|
|
|
var pagedInvoices = await query
|
|
.Skip((pageNumber - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
if (!pagedInvoices.Any())
|
|
{
|
|
_logger.LogInfo("No invoices match criteria.");
|
|
return Ok(ApiResponse<object>.SuccessResponse(
|
|
new { CurrentPage = pageNumber, TotalPages = 0, TotalEntities = 0, Data = Array.Empty<InvoiceListVM>() },
|
|
"No invoices found."
|
|
));
|
|
}
|
|
|
|
// Fetch related payments in a single query to minimize DB calls
|
|
var invoiceIds = pagedInvoices.Select(i => i.Id).ToList();
|
|
var paymentGroups = await _context.ReceivedInvoicePayments
|
|
.AsNoTracking()
|
|
.Where(p => invoiceIds.Contains(p.InvoiceId) && p.TenantId == tenantId)
|
|
.GroupBy(p => p.InvoiceId)
|
|
.Select(g => new { InvoiceId = g.Key, PaidAmount = g.Sum(p => p.Amount) })
|
|
.ToDictionaryAsync(g => g.InvoiceId, g => g.PaidAmount);
|
|
|
|
_logger.LogDebug("Received payment data for {Count} invoices.", paymentGroups.Count);
|
|
|
|
// Build results and compute balances in memory for tight control
|
|
var results = new List<InvoiceListVM>();
|
|
|
|
foreach (var invoice in pagedInvoices)
|
|
{
|
|
var total = invoice.BasicAmount + invoice.TaxAmount;
|
|
var paid = paymentGroups.GetValueOrDefault(invoice.Id, 0);
|
|
var balance = total - paid;
|
|
|
|
// Filter pending
|
|
if (isPending && (balance <= 0 || invoice.MarkAsCompleted))
|
|
continue;
|
|
|
|
var vm = _mapper.Map<InvoiceListVM>(invoice);
|
|
|
|
// Project mapping logic - minimize nested object allocations
|
|
vm.Project = projects.Where(sp => sp.Id == invoice.ProjectId).FirstOrDefault();
|
|
|
|
|
|
vm.BalanceAmount = balance;
|
|
results.Add(vm);
|
|
}
|
|
|
|
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
|
|
//string groupByColumn = "Project";
|
|
//if (!string.IsNullOrWhiteSpace(advanceFilter?.GroupByColumn))
|
|
//{
|
|
// groupByColumn = advanceFilter.GroupByColumn;
|
|
//}
|
|
//var resultQuery = results.AsQueryable();
|
|
//object finalResponseData = resultQuery.ApplyGroupByFilters(groupByColumn);
|
|
|
|
_logger.LogInfo("Returning {Count} invoices (page {PageNumber} of {TotalPages}).", results.Count, pageNumber, totalPages);
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(
|
|
new { CurrentPage = pageNumber, TotalPages = totalPages, TotalEntities = totalItems, Data = results },
|
|
$"{results.Count} invoices fetched successfully."
|
|
));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Centralized and structured error logging
|
|
_logger.LogError(ex, "Error in GetInvoiceListAsync: {Message}", ex.Message);
|
|
|
|
// Use standardized error response structure
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse(
|
|
"Internal Server Error",
|
|
"An unexpected error occurred while fetching invoices.",
|
|
500));
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Retrieves detailed information about a specific invoice including related data such as
|
|
/// comments, attachments, payments, and associated project information after validating permissions.
|
|
/// </summary>
|
|
/// <param name="id">The unique identifier of the invoice to fetch.</param>
|
|
/// <returns>Returns invoice details with related entities or appropriate error responses.</returns>
|
|
|
|
[HttpGet("invoice/details/{id}")]
|
|
public async Task<IActionResult> GetInvoiceDetailsAsync(Guid id)
|
|
{
|
|
try
|
|
{
|
|
_logger.LogInfo("GetInvoiceDetailsAsync called for InvoiceId: {InvoiceId}, TenantId: {TenantId}", id, tenantId);
|
|
|
|
// Retrieve currently logged-in employee for permissions validation
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
_logger.LogInfo("Permission checks initiated for EmployeeId: {EmployeeId}", 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);
|
|
|
|
// 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<object>.ErrorResponse(
|
|
"Access Denied",
|
|
"User does not have permission to access this invoice data.",
|
|
403));
|
|
}
|
|
|
|
_logger.LogInfo("Permission granted for EmployeeId {EmployeeId}, proceeding with invoice retrieval.", loggedInEmployee.Id);
|
|
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
// 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);
|
|
|
|
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 found: InvoiceId {InvoiceId}. Initiating related data load.", id);
|
|
|
|
// 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<InvoiceDetailsVM>(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<BasicProjectVM>(serviceProject);
|
|
}
|
|
else if (infraProject != null)
|
|
{
|
|
response.Project = _mapper.Map<BasicProjectVM>(infraProject);
|
|
}
|
|
|
|
// Map comments if present
|
|
if (comments.Any())
|
|
response.Comments = _mapper.Map<List<InvoiceCommentVM>>(comments);
|
|
|
|
// Map attachments with pre-signed URLs and metadata
|
|
if (attachments.Any())
|
|
{
|
|
response.Attachments = attachments.Select(a =>
|
|
{
|
|
var vm = _mapper.Map<InvoiceAttachmentVM>(a);
|
|
vm.PreSignedUrl = _s3Service.GeneratePreSignedUrl(a.Document!.S3Key);
|
|
vm.UploadedBy = _mapper.Map<BasicEmployeeVM>(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<List<ReceivedInvoicePaymentVM>>(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<object>.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);
|
|
|
|
// Return standardized error response for unexpected exceptions
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse(
|
|
"Internal Server Error",
|
|
"An unexpected error occurred while fetching invoice details.",
|
|
500));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Post Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Creates a new invoice along with optional attachments after validating permissions and input validations.
|
|
/// </summary>
|
|
/// <param name="model">Invoice data transfer object containing invoice details and attachments.</param>
|
|
/// <returns>Returns the created invoice details or validation/error responses.</returns>
|
|
|
|
[HttpPost("invoice/create")]
|
|
public async Task<IActionResult> CreateInvoiceAsync([FromBody] InvoiceDto model)
|
|
{
|
|
try
|
|
{
|
|
// 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);
|
|
|
|
// Concurrent permission checks scoped per call for thread safety
|
|
var permissionTasks = new[]
|
|
{
|
|
HasPermissionAsync(PermissionsMaster.CollectionAdmin, loggedInEmployee.Id),
|
|
HasPermissionAsync(PermissionsMaster.CreateCollection, loggedInEmployee.Id)
|
|
};
|
|
await Task.WhenAll(permissionTasks);
|
|
|
|
if (permissionTasks.All(t => !t.Result))
|
|
{
|
|
_logger.LogWarning("Access denied - EmployeeId {EmployeeId} lacks create/admin collection permissions.", loggedInEmployee.Id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse(
|
|
"Access Denied",
|
|
"User does not have permission to create invoices.",
|
|
403));
|
|
}
|
|
|
|
_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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<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();
|
|
|
|
if (model.Attachments?.Any() == true)
|
|
{
|
|
var batchId = Guid.NewGuid();
|
|
var documents = new List<Document>();
|
|
var invoiceAttachments = new List<InvoiceAttachment>();
|
|
|
|
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<object>.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<object>.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<InvoiceListVM>(invoice);
|
|
response.Project = serviceProject != null
|
|
? _mapper.Map<BasicProjectVM>(serviceProject)
|
|
: _mapper.Map<BasicProjectVM>(infraProject);
|
|
response.BilledTo = _mapper.Map<BasicOrganizationVm>(billedTo);
|
|
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
|
response.BalanceAmount = response.BasicAmount + response.TaxAmount;
|
|
|
|
return StatusCode(201, ApiResponse<object>.SuccessResponse(response, "Invoice created successfully.", 201));
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogError(e, "Unhandled exception in CreateInvoiceAsync: {Message}", e.Message);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse(
|
|
"Internal Server Error",
|
|
"An unexpected error occurred while creating the invoice.",
|
|
500));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a received payment entry for a given invoice after validating permissions and business rules.
|
|
/// </summary>
|
|
/// <param name="model">Payment details including invoice ID, amount, and payment date.</param>
|
|
/// <returns>Returns the created payment details or error responses in case of validation failures.</returns>
|
|
|
|
[HttpPost("invoice/payment/received")]
|
|
public async Task<IActionResult> CreateReceivedInvoicePaymentAsync([FromBody] ReceivedInvoicePaymentDto model)
|
|
{
|
|
try
|
|
{
|
|
// Retrieve the logged-in employee for auditing and permission validation
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
_logger.LogInfo("CreateReceivedInvoicePaymentAsync initiated - EmployeeId: {EmployeeId}", loggedInEmployee.Id);
|
|
|
|
// 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);
|
|
|
|
if (permissionTasks.All(t => !t.Result))
|
|
{
|
|
_logger.LogWarning("Access denied for EmployeeId {EmployeeId} - lacks required collection permissions.", loggedInEmployee.Id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse(
|
|
"Access Denied",
|
|
"User does not have permission to add payments.",
|
|
403));
|
|
}
|
|
|
|
// Model null check
|
|
if (model == null)
|
|
{
|
|
_logger.LogWarning("Received null model for payment creation request.");
|
|
return BadRequest(ApiResponse<object>.ErrorResponse(
|
|
"Invalid Request",
|
|
"Request payload cannot be null.",
|
|
400));
|
|
}
|
|
|
|
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<object>.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<object>.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<object>.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<object>.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<object>.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<ReceivedInvoicePayment>(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<ReceivedInvoicePaymentVM>(paymentEntity);
|
|
|
|
_logger.LogInfo("Received payment created successfully: PaymentId {PaymentId}, InvoiceId {InvoiceId}", paymentEntity.Id, model.InvoiceId);
|
|
|
|
return StatusCode(201, ApiResponse<object>.SuccessResponse(responseVm, "Received payment created successfully", 201));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Unexpected error while creating received payment for InvoiceId {InvoiceId}", model.InvoiceId);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse(
|
|
"Internal Server Error",
|
|
"An unexpected error occurred during payment creation. Please try again or contact support.",
|
|
500));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a comment to a specific invoice after validating user permissions and input.
|
|
/// </summary>
|
|
/// <param name="model">DTO containing InvoiceId and comment text.</param>
|
|
/// <returns>Returns the created comment details or error response.</returns>
|
|
|
|
[HttpPost("invoice/add/comment")]
|
|
public async Task<IActionResult> AddCommentToInvoiceAsync([FromBody] InvoiceCommentDto model)
|
|
{
|
|
try
|
|
{
|
|
// Retrieve current employee context for auditing and permissions
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
_logger.LogInfo("AddCommentToInvoiceAsync started by EmployeeId: {EmployeeId}", 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);
|
|
|
|
// Aggregate permission results
|
|
if (permissionTasks.All(t => !t.Result))
|
|
{
|
|
_logger.LogWarning("Access denied: EmployeeId {EmployeeId} lacks collection permissions.", loggedInEmployee.Id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse(
|
|
"Access Denied",
|
|
"User does not have permission to add comments to collections.",
|
|
403));
|
|
}
|
|
|
|
_logger.LogInfo("Permission granted for EmployeeId {EmployeeId}. Validating input...", 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<object>.ErrorResponse(
|
|
"Invalid Input",
|
|
"Comment text must not be null or empty.",
|
|
400));
|
|
}
|
|
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
// 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);
|
|
|
|
if (invoice == null)
|
|
{
|
|
_logger.LogWarning("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 this tenant.",
|
|
404));
|
|
}
|
|
|
|
// 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<InvoiceCommentVM>(comment);
|
|
|
|
// Return 201 Created with the details of the new comment
|
|
return StatusCode(201, ApiResponse<object>.SuccessResponse(
|
|
responseVm,
|
|
"Comment added to invoice successfully.",
|
|
201));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Unhandled exception in AddCommentToInvoiceAsync by EmployeeId {EmployeeId}.",
|
|
(await _userHelper.GetCurrentEmployeeAsync()).Id);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse(
|
|
"Internal Server Error",
|
|
"An unexpected error occurred while adding the comment. Please try again or contact support.",
|
|
500));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Put Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Updates an existing invoice including attachment management, after performing permission and business validations.
|
|
/// </summary>
|
|
/// <param name="id">Invoice ID from route parameter.</param>
|
|
/// <param name="model">Invoice DTO with updated details.</param>
|
|
/// <returns>Returns updated invoice details on success or relevant error responses.</returns>
|
|
|
|
[HttpPut("invoice/edit/{id}")]
|
|
public async Task<IActionResult> UpdateInvoiceAsync(Guid id, [FromBody] InvoiceDto model)
|
|
{
|
|
try
|
|
{
|
|
// 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);
|
|
|
|
// 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<object>.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<object>.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<UtilityMongoDBHelper>();
|
|
|
|
// 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<object>.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<object>.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<object>.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<BasicProjectVM>(serviceProject)
|
|
: _mapper.Map<BasicProjectVM>(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<object>.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 attachments if provided
|
|
if (model.Attachments?.Any() == true)
|
|
{
|
|
// 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 any
|
|
if (inactiveDocIds.Any())
|
|
{
|
|
var attachmentsToRemove = await context.InvoiceAttachments
|
|
.Where(ia => inactiveDocIds.Contains(ia.DocumentId) && ia.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
context.InvoiceAttachments.RemoveRange(attachmentsToRemove);
|
|
_logger.LogInfo("Removed {Count} inactive attachments from InvoiceId {InvoiceId}.", attachmentsToRemove.Count, id);
|
|
}
|
|
|
|
// Process new attachments by uploading and persisting
|
|
if (newAttachments.Any())
|
|
{
|
|
var batchId = Guid.NewGuid();
|
|
var documents = new List<Document>();
|
|
var invoiceAttachments = new List<InvoiceAttachment>();
|
|
|
|
foreach (var attachment in newAttachments)
|
|
{
|
|
var base64Data = attachment.Base64Data?.Split(',').LastOrDefault() ?? string.Empty;
|
|
|
|
if (string.IsNullOrWhiteSpace(base64Data))
|
|
{
|
|
_logger.LogWarning("Empty Base64 data for new attachment on InvoiceId {InvoiceId}.", id);
|
|
return BadRequest(ApiResponse<object>.ErrorResponse(
|
|
"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 = $"{objectKeyPrefix}/Invoice/{fileName}";
|
|
|
|
// Upload file to S3 asynchronously
|
|
await _s3Service.UploadFileAsync(base64Data, contentType, objectKey);
|
|
|
|
var document = new Document
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
UploadedById = loggedInEmployee.Id,
|
|
FileName = attachment.FileName ?? fileName,
|
|
ContentType = contentType,
|
|
S3Key = objectKey,
|
|
FileSize = attachment.FileSize,
|
|
UploadedAt = DateTime.UtcNow,
|
|
TenantId = tenantId
|
|
};
|
|
documents.Add(document);
|
|
|
|
invoiceAttachments.Add(new InvoiceAttachment
|
|
{
|
|
InvoiceId = invoice.Id,
|
|
DocumentId = document.Id,
|
|
TenantId = tenantId
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Persist all updates (invoice + attachments) in one SaveChanges call for consistency
|
|
await context.SaveChangesAsync();
|
|
|
|
// Push audit log entry with old invoice entity snapshot
|
|
await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = invoice.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = invoiceStateBefore,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, "InvoiceModificationLog");
|
|
|
|
_logger.LogInfo("Invoice {InvoiceId} updated successfully by EmployeeId {EmployeeId}, TenantId {TenantId}.", invoice.Id, loggedInEmployee.Id, tenantId);
|
|
|
|
// Prepare response with updated invoice data and related mappings
|
|
var response = _mapper.Map<InvoiceListVM>(invoice);
|
|
response.Project = projectVM;
|
|
response.BilledTo = _mapper.Map<BasicOrganizationVm>(billedTo);
|
|
response.UpdatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
|
response.BalanceAmount = response.BasicAmount + response.TaxAmount;
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(response, "Invoice updated successfully", 200));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception occurred during update of InvoiceId {InvoiceId}, TenantId {TenantId}.", id, tenantId);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse(
|
|
"Internal Server Error",
|
|
"An unexpected error occurred while updating the invoice. Please contact support.",
|
|
500));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks a specified invoice as completed after validating permissions and business rules.
|
|
/// </summary>
|
|
/// <param name="invoiceId">The unique identifier of the invoice to mark completed.</param>
|
|
/// <returns>Returns updated invoice details or relevant error responses.</returns>
|
|
|
|
[HttpPut("invoice/marked/completed/{invoiceId}")]
|
|
public async Task<IActionResult> MarkAsCompletedAsync(Guid invoiceId)
|
|
{
|
|
// Create a DI scope for resolving services
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
|
|
|
|
// Get the currently logged-in employee for audit and permission checks
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
|
|
_logger.LogInfo("MarkAsCompletedAsync initiated by EmployeeId {EmployeeId} for InvoiceId {InvoiceId}", loggedInEmployee.Id, invoiceId);
|
|
|
|
// 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);
|
|
|
|
if (!hasAdminPermission)
|
|
{
|
|
_logger.LogWarning("Access denied: EmployeeId {EmployeeId} lacks CollectionAdmin permission.", loggedInEmployee.Id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse(
|
|
"Access Denied",
|
|
"User does not have permission to mark invoices as completed.",
|
|
403));
|
|
}
|
|
|
|
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);
|
|
|
|
if (invoice == null)
|
|
{
|
|
_logger.LogWarning("Invoice not found: InvoiceId {InvoiceId}, TenantId {TenantId}", invoiceId, tenantId);
|
|
return NotFound(ApiResponse<object>.ErrorResponse(
|
|
"Invoice Not Found",
|
|
"The specified invoice does not exist.",
|
|
404));
|
|
}
|
|
|
|
if (invoice.MarkAsCompleted)
|
|
{
|
|
_logger.LogWarning("Invoice {InvoiceId} is already marked as completed by EmployeeId {EmployeeId}.", invoice.Id, loggedInEmployee.Id);
|
|
return BadRequest(ApiResponse<object>.ErrorResponse(
|
|
"Invoice Already Completed",
|
|
"Invoice is already marked as completed.",
|
|
400));
|
|
}
|
|
|
|
try
|
|
{
|
|
// Capture state before modification for audit logs
|
|
var previousState = updateLogHelper.EntityToBsonDocument(invoice);
|
|
|
|
invoice.MarkAsCompleted = true;
|
|
invoice.UpdatedAt = DateTime.UtcNow;
|
|
invoice.UpdatedById = loggedInEmployee.Id;
|
|
|
|
await context.SaveChangesAsync();
|
|
|
|
_logger.LogInfo("Invoice {InvoiceId} marked as completed successfully by EmployeeId {EmployeeId}.", invoice.Id, loggedInEmployee.Id);
|
|
|
|
// Log the update event with previous state snapshot
|
|
await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = invoice.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = previousState,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, "InvoiceModificationLog");
|
|
|
|
// Prepare response view model
|
|
var response = _mapper.Map<InvoiceListVM>(invoice);
|
|
response.UpdatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
|
response.BalanceAmount = response.BasicAmount + response.TaxAmount;
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(response, "Invoice marked as completed successfully.", 200));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception while marking invoice {InvoiceId} as completed by EmployeeId {EmployeeId}.", invoiceId, loggedInEmployee.Id);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse(
|
|
"Internal Server Error",
|
|
"An unexpected error occurred while marking the invoice as completed.",
|
|
500));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Helper Functions ===================================================================
|
|
|
|
private AdvanceFilter? TryDeserializeFilter(string? filter)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
AdvanceFilter? advanceFilter = null;
|
|
|
|
try
|
|
{
|
|
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
|
|
advanceFilter = JsonSerializer.Deserialize<AdvanceFilter>(filter, options);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
|
|
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
|
|
try
|
|
{
|
|
// Unescape the string first, then deserialize the result.
|
|
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
|
|
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
|
|
{
|
|
advanceFilter = JsonSerializer.Deserialize<AdvanceFilter>(unescapedJsonString, options);
|
|
}
|
|
}
|
|
catch (JsonException ex1)
|
|
{
|
|
// If both attempts fail, log the final error and return null.
|
|
_logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
return null;
|
|
}
|
|
}
|
|
return advanceFilter;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Async permission check helper with scoped DI lifetime
|
|
/// </summary>
|
|
private async Task<bool> HasPermissionAsync(Guid permission, Guid employeeId)
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(permission, employeeId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper method to load infrastructure project by id.
|
|
/// </summary>
|
|
private async Task<Project?> LoadInfraProjectAsync(Guid projectId)
|
|
{
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await context.Projects.Where(p => p.Id == projectId && p.TenantId == tenantId).FirstOrDefaultAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper method to load service project by id.
|
|
/// </summary>
|
|
private async Task<ServiceProject?> LoadServiceProjectAsync(Guid projectId)
|
|
{
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await context.ServiceProjects.Where(sp => sp.Id == projectId && sp.TenantId == tenantId).FirstOrDefaultAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper method to load organization by Id.
|
|
/// </summary>
|
|
private async Task<Organization?> LoadOrganizationAsync(Guid organizationId)
|
|
{
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await context.Organizations.FirstOrDefaultAsync(o => o.Id == organizationId && o.IsActive);
|
|
}
|
|
|
|
/// <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)
|
|
.OrderByDescending(ic => ic.CreatedAt)
|
|
.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.PaymentAdjustmentHead)
|
|
.Include(rip => rip.CreatedBy).ThenInclude(e => e!.JobRole)
|
|
.AsNoTracking()
|
|
.Where(rip => rip.InvoiceId == invoiceId && rip.TenantId == tenantId)
|
|
.ToListAsync();
|
|
}
|
|
|
|
#endregion
|
|
|
|
}
|
|
}
|