Added an APIs get payment history list and add payment to purchase invoices

This commit is contained in:
ashutosh.nehete 2025-12-01 11:01:20 +05:30
parent 28deae6416
commit e92976049e
3 changed files with 247 additions and 1 deletions

View File

@ -1,4 +1,5 @@
using AutoMapper; using AutoMapper;
using Marco.Pms.Model.Dtos.Collection;
using Marco.Pms.Model.Dtos.PurchaseInvoice; using Marco.Pms.Model.Dtos.PurchaseInvoice;
using Marco.Pms.Model.Utilities; using Marco.Pms.Model.Utilities;
using Marco.Pms.Services.Service.ServiceInterfaces; using Marco.Pms.Services.Service.ServiceInterfaces;
@ -167,6 +168,33 @@ namespace Marco.Pms.Services.Controllers
#endregion #endregion
#region =================================================================== Purchase Invoice History Functions =================================================================== #region =================================================================== Purchase Invoice History Functions ===================================================================
[HttpGet("payment-history/list/{purchaseInvoiceId}")]
public async Task<IActionResult> 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<IActionResult> 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 #endregion
} }
} }

View File

@ -1,6 +1,7 @@
using AutoMapper; using AutoMapper;
using Marco.Pms.DataAccess.Data; using Marco.Pms.DataAccess.Data;
using Marco.Pms.Helpers.Utility; using Marco.Pms.Helpers.Utility;
using Marco.Pms.Model.Dtos.Collection;
using Marco.Pms.Model.Dtos.PurchaseInvoice; using Marco.Pms.Model.Dtos.PurchaseInvoice;
using Marco.Pms.Model.Employees; using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Filters; using Marco.Pms.Model.Filters;
@ -11,6 +12,7 @@ using Marco.Pms.Model.PurchaseInvoice;
using Marco.Pms.Model.ServiceProject; using Marco.Pms.Model.ServiceProject;
using Marco.Pms.Model.Utilities; using Marco.Pms.Model.Utilities;
using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.Collection;
using Marco.Pms.Model.ViewModels.Organization; using Marco.Pms.Model.ViewModels.Organization;
using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Model.ViewModels.Projects;
using Marco.Pms.Model.ViewModels.PurchaseInvoice; using Marco.Pms.Model.ViewModels.PurchaseInvoice;
@ -1135,6 +1137,219 @@ namespace Marco.Pms.Services.Service
#region =================================================================== Purchase Invoice History Functions =================================================================== #region =================================================================== Purchase Invoice History Functions ===================================================================
/// <summary>
/// Retrieves the full payment history for a given Purchase Invoice,
/// including related adjustment heads and creator information,
/// with multi-tenant safety and structured logging.
/// </summary>
/// <param name="purchaseInvoiceId">Identifier of the purchase invoice.</param>
/// <param name="loggedInEmployee">The employee requesting the history.</param>
/// <param name="tenantId">Current tenant identifier for multi-tenancy boundary.</param>
/// <param name="ct">Cancellation token for cooperative cancellation.</param>
/// <returns>
/// Standardized <see cref="ApiResponse{T}"/> containing the list of payment history
/// view models or a detailed error response.
/// </returns>
public async Task<ApiResponse<object>> 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<object>.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<object>.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<List<ReceivedInvoicePaymentVM>>(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<object>.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<object>.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<object>.ErrorResponse("An unexpected error occurred while retrieving purchase invoice history.", "Unhandled exception in GetPurchaseInvoiceHistoryListAsync.", 500);
}
}
/// <summary>
/// Adds a payment entry against an existing Purchase Invoice with full validation
/// and structured logging suitable for enterprise scenarios.
/// </summary>
/// <param name="model">Payment details to be recorded against the invoice.</param>
/// <param name="loggedInEmployee">The currently logged-in employee performing this action.</param>
/// <param name="tenantId">Current tenant identifier to enforce multi-tenancy boundaries.</param>
/// <param name="ct">Cancellation token for cooperative cancellation.</param>
/// <returns>Standardized ApiResponse with the created payment view model or error details.</returns>
public async Task<ApiResponse<object>> 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<object>.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<object>.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<object>.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<object>.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<object>.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<object>.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<PurchaseInvoicePayment>(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<object>.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<ReceivedInvoicePaymentVM>(receivedInvoicePayment);
responseVm.PaymentAdjustmentHead = _mapper.Map<PaymentAdjustmentHeadVM>(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<object>.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<object>.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<object>.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<object>.ErrorResponse("An unexpected error occurred while processing the payment.", "Unhandled exception in AddPurchaseInvoicePaymentAsync.", 500);
}
}
#endregion #endregion
#region =================================================================== Helper Functions =================================================================== #region =================================================================== Helper Functions ===================================================================

View File

@ -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.Employees;
using Marco.Pms.Model.PurchaseInvoice; using Marco.Pms.Model.PurchaseInvoice;
using Marco.Pms.Model.Utilities; using Marco.Pms.Model.Utilities;
@ -23,6 +24,8 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces
#endregion #endregion
#region =================================================================== Purchase Invoice History Functions =================================================================== #region =================================================================== Purchase Invoice History Functions ===================================================================
Task<ApiResponse<object>> GetPurchaseInvoiceHistoryListAsync(Guid purchaseInvoiceId, Employee loggedInEmployee, Guid tenantId, CancellationToken ct);
Task<ApiResponse<object>> AddPurchaseInvoicePaymentAsync(ReceivedInvoicePaymentDto model, Employee loggedInEmployee, Guid tenantId, CancellationToken ct);
#endregion #endregion
#region =================================================================== Helper Functions =================================================================== #region =================================================================== Helper Functions ===================================================================