diff --git a/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs b/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs index 398c646..87fd5bd 100644 --- a/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs +++ b/Marco.Pms.Services/Controllers/PurchaseInvoiceController.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Dtos.PurchaseInvoice; using Marco.Pms.Model.Utilities; using Marco.Pms.Services.Service.ServiceInterfaces; @@ -167,6 +168,33 @@ namespace Marco.Pms.Services.Controllers #endregion #region =================================================================== Purchase Invoice History Functions =================================================================== + [HttpGet("payment-history/list/{purchaseInvoiceId}")] + public async Task GetPurchaseInvoiceHistoryList(Guid purchaseInvoiceId, CancellationToken cancellationToken) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _purchaseInvoiceService.GetPurchaseInvoiceHistoryListAsync(purchaseInvoiceId, loggedInEmployee, tenantId, cancellationToken); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("add/payment")] + public async Task AddPurchaseInvoicePayment([FromBody] ReceivedInvoicePaymentDto model, CancellationToken ct) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _purchaseInvoiceService.AddPurchaseInvoicePaymentAsync(model, loggedInEmployee, tenantId, ct); + if (response.Success) + { + var notification = new + { + LoggedInUserId = loggedInEmployee.Id, + Keyword = "Delivery_Challan", + Response = response.Data + }; + await _signalR.SendNotificationAsync(notification); + } + + // Return the HTTP response + return StatusCode(response.StatusCode, response); + } #endregion } } diff --git a/Marco.Pms.Services/Service/PurchaseInvoiceService.cs b/Marco.Pms.Services/Service/PurchaseInvoiceService.cs index 1a50d0b..6f0648a 100644 --- a/Marco.Pms.Services/Service/PurchaseInvoiceService.cs +++ b/Marco.Pms.Services/Service/PurchaseInvoiceService.cs @@ -1,6 +1,7 @@ using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Helpers.Utility; +using Marco.Pms.Model.Dtos.Collection; using Marco.Pms.Model.Dtos.PurchaseInvoice; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Filters; @@ -11,6 +12,7 @@ using Marco.Pms.Model.PurchaseInvoice; 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.Model.ViewModels.PurchaseInvoice; @@ -1135,6 +1137,219 @@ namespace Marco.Pms.Services.Service #region =================================================================== Purchase Invoice History Functions =================================================================== + /// + /// Retrieves the full payment history for a given Purchase Invoice, + /// including related adjustment heads and creator information, + /// with multi-tenant safety and structured logging. + /// + /// Identifier of the purchase invoice. + /// The employee requesting the history. + /// Current tenant identifier for multi-tenancy boundary. + /// Cancellation token for cooperative cancellation. + /// + /// Standardized containing the list of payment history + /// view models or a detailed error response. + /// + public async Task> GetPurchaseInvoiceHistoryListAsync(Guid purchaseInvoiceId, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) + { + // Guard clauses to fail fast on obviously invalid parameters. + if (purchaseInvoiceId == Guid.Empty) + { + _logger.LogWarning("GetPurchaseInvoiceHistoryListAsync called with empty PurchaseInvoiceId. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); + + return ApiResponse.ErrorResponse("Purchase invoice reference is required.", "PurchaseInvoiceId is empty.", 400); + } + + try + { + // Create a short-lived DbContext instance via factory for this operation. + await using var context = await _dbContextFactory.CreateDbContextAsync(ct); + + // Step 1: Ensure the invoice exists for this tenant. + var purchaseInvoice = await context.PurchaseInvoiceDetails + .AsNoTracking() + .FirstOrDefaultAsync( + pi => pi.Id == purchaseInvoiceId && pi.TenantId == tenantId, + ct); + + if (purchaseInvoice == null) + { + _logger.LogWarning("Purchase Invoice not found. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", purchaseInvoiceId, tenantId, loggedInEmployee.Id); + + return ApiResponse.ErrorResponse("Purchase invoice not found.", $"Purchase invoice not found for InvoiceId: {purchaseInvoiceId}, TenantId: {tenantId}.", 404); + } + + // Step 2: Query payment history with necessary related data eagerly loaded. + var paymentHistoryQuery = context.PurchaseInvoicePayments + .Include(pip => pip.PaymentAdjustmentHead) + .Include(pip => pip.CreatedBy) // Include creator + .ThenInclude(e => e!.JobRole) // Include creator's job role + .Where(pip => pip.InvoiceId == purchaseInvoiceId && pip.TenantId == tenantId); + + var paymentHistory = await paymentHistoryQuery.ToListAsync(ct); + + // Step 3: Map to view models for safe response shaping. + var responseVm = _mapper.Map>(paymentHistory); + + _logger.LogInfo("Purchase Invoice payment history retrieved successfully. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}, PaymentsCount: {PaymentsCount}", + purchaseInvoiceId, tenantId, loggedInEmployee.Id, responseVm.Count); + + // Even if there is no payment history, return 200 with empty collection. + return ApiResponse.SuccessResponse(responseVm, "Purchase invoice payment history retrieved successfully.", 200); + } + catch (OperationCanceledException) + { + // Explicitly handle cancellation to avoid logging it as an error. + _logger.LogWarning("GetPurchaseInvoiceHistoryListAsync operation was canceled. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", + purchaseInvoiceId, tenantId, loggedInEmployee.Id); + + return ApiResponse.ErrorResponse("The operation was canceled.", "GetPurchaseInvoiceHistoryListAsync was canceled by the caller.", 499); + } + catch (Exception ex) + { + // Catch-all to ensure no unhandled exception reaches the client. + _logger.LogError(ex, "Unexpected error while retrieving Purchase Invoice payment history. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", + purchaseInvoiceId, tenantId, loggedInEmployee.Id); + + return ApiResponse.ErrorResponse("An unexpected error occurred while retrieving purchase invoice history.", "Unhandled exception in GetPurchaseInvoiceHistoryListAsync.", 500); + } + } + + /// + /// Adds a payment entry against an existing Purchase Invoice with full validation + /// and structured logging suitable for enterprise scenarios. + /// + /// Payment details to be recorded against the invoice. + /// The currently logged-in employee performing this action. + /// Current tenant identifier to enforce multi-tenancy boundaries. + /// Cancellation token for cooperative cancellation. + /// Standardized ApiResponse with the created payment view model or error details. + public async Task> AddPurchaseInvoicePaymentAsync(ReceivedInvoicePaymentDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct) + { + // Guard clauses to fail fast on invalid input and avoid null reference issues. + if (model == null) + { + _logger.LogWarning("AddPurchaseInvoicePaymentAsync called with null model. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invalid payment data.", "Received null payment model.", 400); + } + + if (model.InvoiceId == Guid.Empty) + { + _logger.LogWarning("AddPurchaseInvoicePaymentAsync called with empty InvoiceId. TenantId: {TenantId}, EmployeeId: {EmployeeId}", tenantId, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Invoice reference is required.", "InvoiceId is empty in payment model.", 200); + } + + if (model.Amount <= 0) + { + _logger.LogWarning("AddPurchaseInvoicePaymentAsync called with non-positive Amount. TenantId: {TenantId}, EmployeeId: {EmployeeId}, Amount: {Amount}", tenantId, loggedInEmployee.Id, model.Amount); + return ApiResponse.ErrorResponse("Payment amount must be greater than zero.", $"Invalid payment amount: {model.Amount}.", 400); + } + + try + { + // Create a short-lived DbContext instance using the factory to ensure proper scope per operation. + await using var context = await _dbContextFactory.CreateDbContextAsync(ct); + + // Step 1: Validate that the invoice exists for the current tenant. + var purchaseInvoice = await context.PurchaseInvoiceDetails + .AsNoTracking() + .FirstOrDefaultAsync( + pi => pi.Id == model.InvoiceId && pi.TenantId == tenantId, + ct); + + if (purchaseInvoice == null) + { + _logger.LogWarning("Purchase Invoice not found. InvoiceId: {InvoiceId}, TenantId: {TenantId}", model.InvoiceId, tenantId); + + return ApiResponse.ErrorResponse("Purchase invoice not found.", $"Purchase invoice not found for InvoiceId: {model.InvoiceId}, TenantId: {tenantId}.", 404); + } + + // Step 2: Validate Payment Adjustment Head. + var paymentAdjustmentHead = await context.PaymentAdjustmentHeads + .FirstOrDefaultAsync(pah => pah.Id == model.PaymentAdjustmentHeadId && pah.TenantId == tenantId, ct); + + if (paymentAdjustmentHead == null) + { + _logger.LogWarning("Payment Adjustment Head not found. PaymentAdjustmentHeadId: {PaymentAdjustmentHeadId}, TenantId: {TenantId}", model.PaymentAdjustmentHeadId, tenantId); + + return ApiResponse.ErrorResponse("Payment adjustment head not found.", $"Payment adjustment head not found for Id: {model.PaymentAdjustmentHeadId}, TenantId: {tenantId}.", + 404); + } + + // Step 3: Get existing payments and ensure the new payment does not exceed the invoice total. + var existingPayments = await context.PurchaseInvoicePayments + .Where(pi => pi.InvoiceId == model.InvoiceId && pi.TenantId == tenantId) + .ToListAsync(ct); + + var alreadyPaidAmount = existingPayments.Sum(pi => pi.Amount); + var proposedTotalPaidAmount = alreadyPaidAmount + model.Amount; + + if (proposedTotalPaidAmount > purchaseInvoice.TotalAmount) + { + _logger.LogWarning("Attempt to add payment exceeding invoice total. InvoiceId: {InvoiceId}, TenantId: {TenantId}, InvoiceTotal: {InvoiceTotal}, AlreadyPaid: {AlreadyPaid}, NewAmount: {NewAmount}, ProposedTotal: {ProposedTotal}", + model.InvoiceId, tenantId, purchaseInvoice.TotalAmount, alreadyPaidAmount, model.Amount, proposedTotalPaidAmount); + + return ApiResponse.ErrorResponse("Total payment amount cannot exceed the invoice amount.", "Payment addition rejected due to exceeding invoice total amount.", + 400); + } + + // Step 4: Map DTO to entity and initialize metadata. + var receivedInvoicePayment = _mapper.Map(model); + receivedInvoicePayment.Id = Guid.NewGuid(); + receivedInvoicePayment.CreatedAt = DateTime.UtcNow; + receivedInvoicePayment.CreatedById = loggedInEmployee.Id; + receivedInvoicePayment.TenantId = tenantId; + + // Step 5: Persist the new payment record. + context.PurchaseInvoicePayments.Add(receivedInvoicePayment); + + // For enterprise robustness, pass the cancellation token to SaveChangesAsync. + var saveResult = await context.SaveChangesAsync(ct); + + if (saveResult <= 0) + { + _logger.LogError(null, "SaveChangesAsync returned 0 while adding Purchase Invoice payment. InvoiceId: {InvoiceId}, TenantId: {TenantId}, PaymentId: {PaymentId}", + model.InvoiceId, tenantId, receivedInvoicePayment.Id); + + return ApiResponse.ErrorResponse("Failed to add payment due to a persistence issue.", "Database SaveChangesAsync returned 0 rows affected while adding PurchaseInvoicePayment.", + statusCode: StatusCodes.Status500InternalServerError); + } + + // Step 6: Map entity back to a response view model. + var responseVm = _mapper.Map(receivedInvoicePayment); + responseVm.PaymentAdjustmentHead = _mapper.Map(paymentAdjustmentHead); + + _logger.LogInfo("Purchase Invoice payment added successfully. InvoiceId: {InvoiceId}, TenantId: {TenantId}, PaymentId: {PaymentId}, Amount: {Amount}, EmployeeId: {EmployeeId}", + model.InvoiceId, tenantId, receivedInvoicePayment.Id, receivedInvoicePayment.Amount, loggedInEmployee.Id); + + return ApiResponse.SuccessResponse(responseVm, "Payment has been recorded successfully.", 201); // 201 Created is more appropriate for new resource. + } + catch (OperationCanceledException) + { + // Explicitly handle cancellation to avoid logging it as an error. + _logger.LogError(null, "AddPurchaseInvoicePaymentAsync operation was canceled. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", + model.InvoiceId, tenantId, loggedInEmployee.Id); + + return ApiResponse.ErrorResponse("The operation was canceled.", "AddPurchaseInvoicePaymentAsync was canceled by the caller.", 499); // 499 used by some systems for client cancellation. + } + catch (DbUpdateException dbEx) + { + // Database-related exceptions with structured logging for observability. + _logger.LogError(dbEx, "Database update error while adding Purchase Invoice payment. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", + model.InvoiceId, tenantId, loggedInEmployee.Id); + + return ApiResponse.ErrorResponse("An error occurred while saving the payment.", "Database update exception occurred during payment creation.", 500); + } + catch (Exception ex) + { + // Catch-all to avoid leaking unhandled exceptions to the client. + _logger.LogError(ex, "Unexpected error while adding Purchase Invoice payment. InvoiceId: {InvoiceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", + model.InvoiceId, tenantId, loggedInEmployee.Id); + + return ApiResponse.ErrorResponse("An unexpected error occurred while processing the payment.", "Unhandled exception in AddPurchaseInvoicePaymentAsync.", 500); + } + } + #endregion #region =================================================================== Helper Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs index 583d40e..a1e2e9c 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IPurchaseInvoiceService.cs @@ -1,4 +1,5 @@ -using Marco.Pms.Model.Dtos.PurchaseInvoice; +using Marco.Pms.Model.Dtos.Collection; +using Marco.Pms.Model.Dtos.PurchaseInvoice; using Marco.Pms.Model.Employees; using Marco.Pms.Model.PurchaseInvoice; using Marco.Pms.Model.Utilities; @@ -23,6 +24,8 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces #endregion #region =================================================================== Purchase Invoice History Functions =================================================================== + Task> GetPurchaseInvoiceHistoryListAsync(Guid purchaseInvoiceId, Employee loggedInEmployee, Guid tenantId, CancellationToken ct); + Task> AddPurchaseInvoicePaymentAsync(ReceivedInvoicePaymentDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct); #endregion #region =================================================================== Helper Functions ===================================================================