diff --git a/Marco.Pms.Services/Controllers/ExpenseController.cs b/Marco.Pms.Services/Controllers/ExpenseController.cs index 0e86ea5..888ed97 100644 --- a/Marco.Pms.Services/Controllers/ExpenseController.cs +++ b/Marco.Pms.Services/Controllers/ExpenseController.cs @@ -157,6 +157,7 @@ namespace Marco.Pms.Services.Controllers var response = await _expensesService.GetPaymentRequestFilterObjectAsync(loggedInEmployee, tenantId); return StatusCode(response.StatusCode, response); } + [HttpPost("payment-request/create")] public async Task CreatePaymentRequest([FromBody] PaymentRequestDto model) { @@ -170,6 +171,19 @@ namespace Marco.Pms.Services.Controllers return StatusCode(response.StatusCode, response); } + [HttpPost("payment-request/action")] + public async Task ChangePaymentRequestStatus([FromBody] PaymentRequestRecordDto model) + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _expensesService.ChangePaymentRequestStatusAsync(model, loggedInEmployee, tenantId); + if (response.Success) + { + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Payment_Request", Response = response.Data }; + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + } + #endregion #region =================================================================== Payment Request Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ExpensesService.cs b/Marco.Pms.Services/Service/ExpensesService.cs index b0ef9e7..d767d5c 100644 --- a/Marco.Pms.Services/Service/ExpensesService.cs +++ b/Marco.Pms.Services/Service/ExpensesService.cs @@ -1819,6 +1819,210 @@ namespace Marco.Pms.Services.Service _logger.LogInfo("End CreatePaymentRequestAsync for EmployeeId: {EmployeeId}", loggedInEmployee.Id); } } + public async Task> ChangePaymentRequestStatusAsync(PaymentRequestRecordDto model, Employee loggedInEmployee, Guid tenantId) + { + using var scope = _serviceScopeFactory.CreateScope(); + var _firebase = scope.ServiceProvider.GetRequiredService(); + + // 1. Fetch Existing Payment Request with Related Entities (Single Query) + var paymentRequest = await _context.PaymentRequests + .Include(pr => pr.Currency) + .Include(pr => pr.Project) + .Include(pr => pr.RecurringPayment) + .Include(pr => pr.ExpenseCategory) + .Include(pr => pr.ExpenseStatus) + .Include(pr => pr.CreatedBy).ThenInclude(e => e!.JobRole) + .FirstOrDefaultAsync(pr => + pr.Id == model.PaymentRequestId && + pr.ExpenseStatusId != model.StatusId && + pr.TenantId == tenantId + ); + + if (paymentRequest == null) + { + _logger.LogWarning("ChangeStatus: Payment Request not found or already at target status. payment RequestId={PaymentRequestId}, TenantId={TenantId}", model.PaymentRequestId, tenantId); + return ApiResponse.ErrorResponse("payment Request not found or status is already set.", "payment Request not found", 404); + } + + _logger.LogInfo("ChangeStatus: Requested status change. PaymentRequestId={PaymentRequestId} FromStatus={FromStatusId} ToStatus={ToStatusId}", + paymentRequest.Id, paymentRequest.ExpenseStatusId, model.StatusId); + + // 2. Run Prerequisite Checks in Parallel (Status transition + Permissions) + var statusTransitionTask = Task.Run(async () => + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.ExpensesStatusMapping + .Include(m => m.NextStatus) + .FirstOrDefaultAsync(m => m.StatusId == paymentRequest.ExpenseStatusId && m.NextStatusId == model.StatusId); + }); + + var targetStatusPermissionsTask = Task.Run(async () => + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + return await dbContext.StatusPermissionMapping + .Where(spm => spm.StatusId == model.StatusId) + .ToListAsync(); + }); + + await Task.WhenAll(statusTransitionTask, targetStatusPermissionsTask); + var statusTransition = await statusTransitionTask; + var requiredPermissions = await targetStatusPermissionsTask; + + // 3. Validate Transition and Required Fields + if (statusTransition == null) + { + _logger.LogWarning("ChangeStatus: Invalid status transition. PaymentRequestId={PaymentRequestId}, FromStatus={FromStatus}, ToStatus={ToStatus}", + paymentRequest.Id, paymentRequest.ExpenseStatusId, model.StatusId); + return ApiResponse.ErrorResponse("Status change is not permitted.", "Invalid Transition", 400); + } + + // Validate special logic for "Processed" + if (statusTransition.NextStatusId == Processed && + (string.IsNullOrWhiteSpace(model.PaidTransactionId) || + !model.PaidAt.HasValue || + model.PaidById == null || + model.PaidById == Guid.Empty)) + { + _logger.LogWarning("ChangeStatus: Missing payment fields for 'Processed'. PaymentRequestId={PaymentRequestId}", paymentRequest.Id); + return ApiResponse.ErrorResponse("payment details are missing or invalid.", "Invalid Payment", 400); + } + + // 4. Permission Check (CreatedBy -> Reviewer bypass, else required permissions) + bool hasPermission = false; + if (model.StatusId == Review && paymentRequest.CreatedById == loggedInEmployee.Id) + { + hasPermission = true; + } + else if (requiredPermissions.Any()) + { + var permissionIds = requiredPermissions.Select(p => p.PermissionId).ToList(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + hasPermission = await permissionService.HasPermissionAny(permissionIds, loggedInEmployee.Id) && model.StatusId != Review; + + } + + if (!hasPermission) + { + _logger.LogWarning("ChangeStatus: Permission denied. EmployeeId={EmployeeId} PaymentRequestId={PaymentRequestId} ToStatus={ToStatusId}", + loggedInEmployee.Id, paymentRequest.Id, model.StatusId); + return ApiResponse.ErrorResponse("You do not have permission for this action.", "Access Denied", 403); + } + + // 5. Prepare for update (Audit snapshot) + var paymentRequestStateBeforeChange = _updateLogHelper.EntityToBsonDocument(paymentRequest); + + // 6. Apply Status Transition + paymentRequest.ExpenseStatusId = statusTransition.NextStatusId; + paymentRequest.ExpenseStatus = statusTransition.NextStatus; + + + // 7. Add Reimbursement if applicable + if (model.StatusId == Processed) + { + var totalAmount = model.BaseAmount + model.TaxAmount; + if (!totalAmount.HasValue || totalAmount != paymentRequest.Amount) + { + // Log the mismatch error with relevant details + _logger.LogWarning("Payment amount mismatch: calculated totalAmount = {TotalAmount}, expected Amount = {ExpectedAmount}", totalAmount ?? 0, paymentRequest.Amount); + + // Return a structured error response indicating the amount discrepancy + return ApiResponse.ErrorResponse( + "Payment amount validation failed.", + $"The sum of the base amount and tax amount ({totalAmount}) does not match the expected payment request amount ({paymentRequest.Amount}).", + 400); + } + paymentRequest.PaidAt = model.PaidAt; + paymentRequest.PaidById = model.PaidById; + paymentRequest.PaidTransactionId = model.PaidTransactionId; + paymentRequest.TDSPercentage = model.TDSPercentage; + paymentRequest.BaseAmount = model.BaseAmount; + paymentRequest.TaxAmount = model.TaxAmount; + + var lastTransaction = await _context.AdvancePaymentTransactions.OrderByDescending(apt => apt.CreatedAt).FirstOrDefaultAsync(apt => apt.TenantId == tenantId); + double lastBalance = 0; + if (lastTransaction != null) + { + lastBalance = lastTransaction.CurrentBalance; + } + + _context.AdvancePaymentTransactions.Add(new AdvancePaymentTransaction + { + Id = Guid.NewGuid(), + FinanceUIdPostfix = paymentRequest.UIDPostfix, + FinanceUIdPrefix = paymentRequest.UIDPrefix, + Title = paymentRequest.Title, + ProjectId = paymentRequest.ProjectId, + EmployeeId = paymentRequest.CreatedById, + Amount = paymentRequest.Amount, + CurrentBalance = lastBalance + paymentRequest.Amount, + PaidAt = model.PaidAt!.Value, + CreatedAt = DateTime.UtcNow, + CreatedById = loggedInEmployee.Id, + IsActive = true, + TenantId = tenantId + }); + } + + // 8. Add paymentRequest Log Entry + _context.StatusUpdateLogs.Add(new StatusUpdateLog + { + Id = Guid.NewGuid(), + EntityId = paymentRequest.Id, + StatusId = statusTransition.StatusId, + NextStatusId = statusTransition.NextStatusId, + UpdatedById = loggedInEmployee.Id, + UpdatedAt = DateTime.UtcNow, + Comment = model.Comment, + TenantId = tenantId + }); + + // 9. Commit database transaction + try + { + await _context.SaveChangesAsync(); + _logger.LogInfo("ChangeStatus: Status updated successfully. PaymentRequestId={PaymentRequestId} NewStatus={NewStatusId}", paymentRequest.Id, paymentRequest.ExpenseStatusId); + } + catch (DbUpdateConcurrencyException ex) + { + _logger.LogError(ex, "ChangeStatus: Concurrency error. PaymentRequestId={PaymentRequestId}", paymentRequest.Id); + return ApiResponse.ErrorResponse("Payment Request was modified by another user. Please refresh and try again.", "Concurrency Error", 409); + } + + //_ = Task.Run(async () => + //{ + // // --- Push Notification Section --- + // // This section attempts to send a test push notification to the user's device. + // // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. + + // var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + + // await _firebase.SendExpenseMessageAsync(paymentRequest, name, tenantId); + + //}); + + // 10. Post-processing (audit log, cache, fetch next states) + try + { + await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject + { + EntityId = paymentRequest.Id.ToString(), + UpdatedById = loggedInEmployee.Id.ToString(), + OldObject = paymentRequestStateBeforeChange, + UpdatedAt = DateTime.UtcNow + }, "PaymentRequestModificationLog"); + + // Prepare response + var responseDto = _mapper.Map(paymentRequest); + + return ApiResponse.SuccessResponse(responseDto, "Payment Request status chnaged successfully", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, "ChangeStatus: Post-operation error (e.g. audit logging). PaymentRequestId={PaymentRequestId}", paymentRequest.Id); + var responseDto = _mapper.Map(paymentRequest); + return ApiResponse.SuccessResponse(responseDto, "Status updated, but audit logging or cache update failed."); + } + } #endregion #region =================================================================== Payment Request Functions =================================================================== diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs index 730c635..2045e30 100644 --- a/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IExpensesService.cs @@ -23,6 +23,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces Task> GetPayeeNameListAsync(Employee loggedInEmployee, Guid tenantId); Task> GetPaymentRequestFilterObjectAsync(Employee loggedInEmployee, Guid tenantId); Task> CreatePaymentRequestAsync(PaymentRequestDto model, Employee loggedInEmployee, Guid tenantId); + Task> ChangePaymentRequestStatusAsync(PaymentRequestRecordDto model, Employee loggedInEmployee, Guid tenantId); #endregion #region =================================================================== Payment Request Functions ===================================================================