Added the payment Request status Change API

This commit is contained in:
ashutosh.nehete 2025-11-03 19:28:41 +05:30
parent a204efb133
commit 18480b94cd
16 changed files with 15278 additions and 24 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Added_Payment_Infromation_In_Payment_request_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "NextStatusId",
table: "StatusUpdateLogs",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<DateTime>(
name: "PaidAt",
table: "PaymentRequests",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "PaidById",
table: "PaymentRequests",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<string>(
name: "PaidTransactionId",
table: "PaymentRequests",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PaymentRequests_PaidById",
table: "PaymentRequests",
column: "PaidById");
migrationBuilder.AddForeignKey(
name: "FK_PaymentRequests_Employees_PaidById",
table: "PaymentRequests",
column: "PaidById",
principalTable: "Employees",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PaymentRequests_Employees_PaidById",
table: "PaymentRequests");
migrationBuilder.DropIndex(
name: "IX_PaymentRequests_PaidById",
table: "PaymentRequests");
migrationBuilder.DropColumn(
name: "NextStatusId",
table: "StatusUpdateLogs");
migrationBuilder.DropColumn(
name: "PaidAt",
table: "PaymentRequests");
migrationBuilder.DropColumn(
name: "PaidById",
table: "PaymentRequests");
migrationBuilder.DropColumn(
name: "PaidTransactionId",
table: "PaymentRequests");
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,86 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Added_UIDPerfix_In_Expense_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AdvancePaymentTransactions_Projects_ProjectId",
table: "AdvancePaymentTransactions");
migrationBuilder.AddColumn<int>(
name: "UIDPostfix",
table: "Expenses",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "UIDPrefix",
table: "Expenses",
type: "longtext",
nullable: false)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AlterColumn<Guid>(
name: "ProjectId",
table: "AdvancePaymentTransactions",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)")
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.AddForeignKey(
name: "FK_AdvancePaymentTransactions_Projects_ProjectId",
table: "AdvancePaymentTransactions",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AdvancePaymentTransactions_Projects_ProjectId",
table: "AdvancePaymentTransactions");
migrationBuilder.DropColumn(
name: "UIDPostfix",
table: "Expenses");
migrationBuilder.DropColumn(
name: "UIDPrefix",
table: "Expenses");
migrationBuilder.AlterColumn<Guid>(
name: "ProjectId",
table: "AdvancePaymentTransactions",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true)
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.AddForeignKey(
name: "FK_AdvancePaymentTransactions_Projects_ProjectId",
table: "AdvancePaymentTransactions",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@ -2097,7 +2097,7 @@ namespace Marco.Pms.DataAccess.Migrations
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<Guid>("ProjectId")
b.Property<Guid?>("ProjectId")
.HasColumnType("char(36)");
b.Property<Guid>("TenantId")
@ -2372,6 +2372,13 @@ namespace Marco.Pms.DataAccess.Migrations
b.Property<string>("TransactionId")
.HasColumnType("longtext");
b.Property<int>("UIDPostfix")
.HasColumnType("int");
b.Property<string>("UIDPrefix")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("ApprovedById");
@ -2563,6 +2570,15 @@ namespace Marco.Pms.DataAccess.Migrations
b.Property<bool>("IsAdvancePayment")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("PaidAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("PaidById")
.HasColumnType("char(36)");
b.Property<string>("PaidTransactionId")
.HasColumnType("longtext");
b.Property<string>("Payee")
.IsRequired()
.HasColumnType("longtext");
@ -2603,6 +2619,8 @@ namespace Marco.Pms.DataAccess.Migrations
b.HasIndex("ExpenseStatusId");
b.HasIndex("PaidById");
b.HasIndex("ProjectId");
b.HasIndex("RecurringPaymentId");
@ -3853,6 +3871,9 @@ namespace Marco.Pms.DataAccess.Migrations
b.Property<Guid>("EntityId")
.HasColumnType("char(36)");
b.Property<Guid>("NextStatusId")
.HasColumnType("char(36)");
b.Property<Guid>("StatusId")
.HasColumnType("char(36)");
@ -6219,9 +6240,7 @@ namespace Marco.Pms.DataAccess.Migrations
b.HasOne("Marco.Pms.Model.Projects.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.HasForeignKey("ProjectId");
b.HasOne("Marco.Pms.Model.TenantModels.Tenant", "Tenant")
.WithMany()
@ -6469,6 +6488,10 @@ namespace Marco.Pms.DataAccess.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Marco.Pms.Model.Employees.Employee", "PaidBy")
.WithMany()
.HasForeignKey("PaidById");
b.HasOne("Marco.Pms.Model.Projects.Project", "Project")
.WithMany()
.HasForeignKey("ProjectId");
@ -6495,6 +6518,8 @@ namespace Marco.Pms.DataAccess.Migrations
b.Navigation("ExpenseStatus");
b.Navigation("PaidBy");
b.Navigation("Project");
b.Navigation("RecurringPayment");

View File

@ -0,0 +1,12 @@
namespace Marco.Pms.Model.Dtos.Expenses
{
public class PaymentRequestRecordDto
{
public Guid PaymentRequestId { get; set; }
public Guid StatusId { get; set; }
public string? Comment { get; set; }
public string? PaidTransactionId { get; set; }
public DateTime? PaidAt { get; set; }
public Guid? PaidById { get; set; }
}
}

View File

@ -12,7 +12,7 @@ namespace Marco.Pms.Model.Expenses
public string FinanceUIdPrefix { get; set; } = default!;
public int FinanceUIdPostfix { get; set; }
public string Title { get; set; } = default!;
public Guid ProjectId { get; set; }
public Guid? ProjectId { get; set; }
[ValidateNever]
[ForeignKey("ProjectId")]

View File

@ -10,6 +10,8 @@ namespace Marco.Pms.Model.Expenses
public class Expenses : TenantRelation
{
public Guid Id { get; set; }
public string UIDPrefix { get; set; } = default!;
public int UIDPostfix { get; set; }
public Guid ProjectId { get; set; }
[ValidateNever]

View File

@ -43,6 +43,13 @@ namespace Marco.Pms.Model.Expenses
[ValidateNever]
[ForeignKey("ExpenseStatusId")]
public ExpensesStatusMaster? ExpenseStatus { get; set; }
public string? PaidTransactionId { get; set; }
public DateTime? PaidAt { get; set; }
public Guid? PaidById { get; set; }
[ValidateNever]
[ForeignKey("PaidById")]
public Employee? PaidBy { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public Guid CreatedById { get; set; }

View File

@ -9,6 +9,7 @@ namespace Marco.Pms.Model.Master
{
public Guid Id { get; set; }
public Guid StatusId { get; set; }
public Guid NextStatusId { get; set; }
public Guid EntityId { get; set; }
public string? Comment { get; set; }
public DateTime UpdatedAt { get; set; }

View File

@ -7,5 +7,6 @@
public string ContentType { get; set; } = string.Empty;
public string S3Key { get; set; } = string.Empty;
public string ThumbS3Key { get; set; } = string.Empty;
public long FileSize { get; set; }
}
}

View File

@ -20,12 +20,16 @@ namespace Marco.Pms.Model.ViewModels.Expenses
public RecurringPayment? RecurringPayment { get; set; }
public ExpensesCategoryMasterVM? ExpenseCategory { get; set; }
public ExpensesStatusMasterVM? ExpenseStatus { get; set; }
public string? PaidTransactionId { get; set; }
public DateTime? PaidAt { get; set; }
public BasicEmployeeVM? PaidBy { get; set; }
public bool IsAdvancePayment { get; set; }
public DateTime CreatedAt { get; set; }
public BasicEmployeeVM? CreatedBy { get; set; }
public DateTime UpdatedAt { get; set; }
public BasicEmployeeVM? UpdatedBy { get; set; }
public List<ExpensesStatusMasterVM>? NextStatus { get; set; }
public List<PaymentRequestUpdateLog>? UpdateLogs { get; set; }
public List<PaymentRequestAttachmentVM>? Attachments { get; set; }
public bool IsActive { get; set; }
}

View File

@ -0,0 +1,15 @@
using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.Master;
namespace Marco.Pms.Model.ViewModels.Expenses
{
public class PaymentRequestUpdateLog
{
public Guid Id { get; set; }
public ExpensesStatusMasterVM? Status { get; set; }
public ExpensesStatusMasterVM? NextStatus { get; set; }
public string? Comment { get; set; }
public DateTime UpdatedAt { get; set; }
public BasicEmployeeVM? UpdatedBy { get; set; }
}
}

View File

@ -172,6 +172,19 @@ namespace Marco.Pms.Services.Controllers
return StatusCode(response.StatusCode, response);
}
[HttpPost("payment-request/action")]
public async Task<IActionResult> 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);
}
[HttpPut("payment-request/edit/{id}")]
public async Task<IActionResult> EditPaymentRequest(Guid id, [FromBody] PaymentRequestDto model)
{

View File

@ -6,6 +6,7 @@ using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Entitlements;
using Marco.Pms.Model.Expenses;
using Marco.Pms.Model.Filters;
using Marco.Pms.Model.Master;
using Marco.Pms.Model.MongoDBModels;
using Marco.Pms.Model.MongoDBModels.Employees;
using Marco.Pms.Model.MongoDBModels.Expenses;
@ -46,6 +47,7 @@ namespace Marco.Pms.Services.Service
private static readonly Guid RejectedByApprover = Guid.Parse("d1ee5eec-24b6-4364-8673-a8f859c60729");
private static readonly Guid ProcessPending = Guid.Parse("f18c5cfd-7815-4341-8da2-2c2d65778e27");
private static readonly Guid Processed = Guid.Parse("61578360-3a49-4c34-8604-7b35a3787b95");
private static readonly Guid AdvancePayment = Guid.Parse("f67beee6-6763-4108-922c-03bd86b9178d");
private static readonly string Collection = "ExpensesModificationLog";
public ExpensesService(
IDbContextFactory<ApplicationDbContext> dbContextFactory,
@ -549,9 +551,19 @@ namespace Marco.Pms.Services.Service
var currentexpenseUId = (lastExpenseUId + 1).ToString("D5");
string uIDPrefix = $"EX/{DateTime.Now:MMyy}";
// Generate unique UID postfix based on existing requests for the current prefix
var lastPR = await _context.Expenses.Where(pr => pr.UIDPrefix == uIDPrefix)
.OrderByDescending(pr => pr.UIDPostfix)
.FirstOrDefaultAsync();
int uIDPostfix = lastPR == null ? 1 : (lastPR.UIDPostfix + 1);
// 3. Entity Creation
var expense = _mapper.Map<Expenses>(dto);
expense.ExpenseUId = $"EX-{currentexpenseUId}";
expense.UIDPostfix = uIDPostfix;
expense.UIDPrefix = uIDPrefix;
expense.CreatedById = loggedInEmployee.Id;
expense.CreatedAt = DateTime.UtcNow;
expense.TenantId = tenantId;
@ -642,6 +654,12 @@ namespace Marco.Pms.Services.Service
expense.Id, expense.StatusId, model.StatusId);
// 2. Run Prerequisite Checks in Parallel (Status transition + Permissions)
var processedStatusTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ExpensesStatusMaster
.FirstOrDefaultAsync(es => es.Id == Processed);
});
var statusTransitionTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -658,9 +676,10 @@ namespace Marco.Pms.Services.Service
.ToListAsync();
});
await Task.WhenAll(statusTransitionTask, targetStatusPermissionsTask);
var statusTransition = await statusTransitionTask;
var requiredPermissions = await targetStatusPermissionsTask;
await Task.WhenAll(statusTransitionTask, targetStatusPermissionsTask, processedStatusTask);
var statusTransition = statusTransitionTask.Result;
var requiredPermissions = targetStatusPermissionsTask.Result;
var processedStatus = processedStatusTask.Result;
// 3. Validate Transition and Required Fields
if (statusTransition == null)
@ -710,10 +729,65 @@ namespace Marco.Pms.Services.Service
// 5. Prepare for update (Audit snapshot)
var expenseStateBeforeChange = _updateLogHelper.EntityToBsonDocument(expense);
// 6. Apply Status Transition
expense.StatusId = statusTransition.NextStatusId;
expense.Status = statusTransition.NextStatus;
var expenseLogs = new List<ExpenseLog>
{
new ExpenseLog
{
ExpenseId = expense.Id,
Action = $"Status changed to '{statusTransition.NextStatus?.Name}'",
UpdatedById = loggedInEmployee.Id,
UpdateAt = DateTime.UtcNow,
Comment = model.Comment,
TenantId = tenantId
},
};
// 6. Apply Status Transition
if (model.StatusId == ProcessPending && expense.PaymentModeId == AdvancePayment)
{
expense.StatusId = Processed;
expense.Status = processedStatus;
expense.ProcessedById = loggedInEmployee.Id;
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 = expense.UIDPostfix,
FinanceUIdPrefix = expense.UIDPrefix,
Title = expense.Description,
ProjectId = expense.ProjectId,
EmployeeId = expense.PaidById,
Amount = 0 - expense.Amount,
CurrentBalance = lastBalance - expense.Amount,
CreatedAt = expense.TransactionDate,
CreatedById = loggedInEmployee.Id,
IsActive = true,
TenantId = tenantId
});
var expenseLog = new ExpenseLog
{
ExpenseId = expense.Id,
Action = $"Status changed to '{processedStatus?.Name}'",
UpdatedById = loggedInEmployee.Id,
UpdateAt = DateTime.UtcNow,
Comment = model.Comment,
TenantId = tenantId
};
expenseLogs.Add(expenseLog);
}
else
{
expense.StatusId = statusTransition.NextStatusId;
expense.Status = statusTransition.NextStatus;
}
// Handle reviewer/approver/processor fields based on target StatusId (Guid)
if (model.StatusId == Approve || model.StatusId == RejectedByReviewer)
{
@ -749,15 +823,7 @@ namespace Marco.Pms.Services.Service
}
// 8. Add Expense Log Entry
_context.ExpenseLogs.Add(new ExpenseLog
{
ExpenseId = expense.Id,
Action = $"Status changed to '{statusTransition.NextStatus?.Name}'",
UpdatedById = loggedInEmployee.Id,
UpdateAt = DateTime.UtcNow,
Comment = model.Comment,
TenantId = tenantId
});
_context.ExpenseLogs.AddRange(expenseLogs);
// 9. Commit database transaction
try
@ -1383,6 +1449,14 @@ namespace Marco.Pms.Services.Service
results.Add(mappedStatus);
}
int index = results.FindIndex(ns => ns.DisplayName == "Reject");
if (index > -1)
{
var item = results[index];
results.RemoveAt(index);
results.Insert(0, item);
}
return results;
});
@ -1404,16 +1478,45 @@ namespace Marco.Pms.Services.Service
}).ToList();
});
await Task.WhenAll(nextStatusTask, documentTask);
var updateLogsTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.StatusUpdateLogs.Include(sul => sul.UpdatedBy).Where(sul => sul.EntityId == paymentRequest.Id && sul.TenantId == tenantId).ToListAsync();
});
await Task.WhenAll(nextStatusTask, documentTask, updateLogsTask);
var nextStatuses = nextStatusTask.Result;
var attachmentVMs = documentTask.Result;
var updateLogs = updateLogsTask.Result;
var statusIds = updateLogs.Select(sul => sul.StatusId).ToList();
statusIds.AddRange(updateLogs.Select(sul => sul.NextStatusId).ToList());
statusIds = statusIds.Distinct().ToList();
var status = await _context.ExpensesStatusMaster.Where(es => statusIds.Contains(es.Id)).ToListAsync();
// Map main response model and populate additional fields
var response = _mapper.Map<PaymentRequestDetailsVM>(paymentRequest);
response.PaymentRequestUID = $"{paymentRequest.UIDPrefix}/{paymentRequest.UIDPostfix:D5}";
response.Attachments = attachmentVMs;
response.NextStatus = nextStatuses;
response.UpdateLogs = updateLogs.Select(ul =>
{
var statusVm = status.FirstOrDefault(es => es.Id == ul.StatusId);
var nextStatusVm = status.FirstOrDefault(es => es.Id == ul.NextStatusId);
return new PaymentRequestUpdateLog
{
Id = ul.Id,
Comment = ul.Comment,
Status = _mapper.Map<ExpensesStatusMasterVM>(statusVm),
NextStatus = _mapper.Map<ExpensesStatusMasterVM>(nextStatusVm),
UpdatedAt = ul.UpdatedAt,
UpdatedBy = _mapper.Map<BasicEmployeeVM>(ul.UpdatedBy)
};
}).ToList();
_logger.LogInfo("Payment request details fetched successfully for PaymentRequestId: {PaymentRequestId}, EmployeeId: {EmployeeId}", paymentRequest.Id, loggedInEmployee.Id);
@ -1474,12 +1577,11 @@ namespace Marco.Pms.Services.Service
return ApiResponse<object>.ErrorResponse("Internal Exception Occured", ExceptionMapper(ex), 500);
}
}
public async Task<ApiResponse<object>> CreatePaymentRequestAsync(PaymentRequestDto model, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start CreatePaymentRequestAsync for EmployeeId: {EmployeeId} TenantId: {TenantId}", loggedInEmployee.Id, tenantId);
string uIDPrefix = $"PY/{DateTime.Now:MMyy}";
string uIDPrefix = $"PR/{DateTime.Now:MMyy}";
try
{
@ -1615,6 +1717,194 @@ namespace Marco.Pms.Services.Service
_logger.LogInfo("End CreatePaymentRequestAsync for EmployeeId: {EmployeeId}", loggedInEmployee.Id);
}
}
public async Task<ApiResponse<object>> ChangePaymentRequestStatusAsync(PaymentRequestRecordDto model, Employee loggedInEmployee, Guid tenantId)
{
using var scope = _serviceScopeFactory.CreateScope();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
// 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<object>.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 == paymentRequest.ExpenseStatusId)
.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<object>.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<object>.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<PermissionServices>();
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<object>.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)
{
paymentRequest.PaidAt = model.PaidAt;
paymentRequest.PaidById = model.PaidById;
paymentRequest.PaidTransactionId = model.PaidTransactionId;
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,
CreatedAt = model.PaidAt!.Value,
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<object>.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<PaymentRequestVM>(paymentRequest);
return ApiResponse<object>.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<PaymentRequestVM>(paymentRequest);
return ApiResponse<object>.SuccessResponse(responseDto, "Status updated, but audit logging or cache update failed.");
}
}
public async Task<ApiResponse<object>> EditPaymentRequestAsync(Guid id, PaymentRequestDto model, Employee loggedInEmployee, Guid tenantId)
{
_logger.LogInfo("Start EditPaymentRequestAsync for PaymentRequestId: {PaymentRequestId}, EmployeeId: {EmployeeId}", id, loggedInEmployee.Id);
@ -2023,7 +2313,8 @@ namespace Marco.Pms.Services.Service
FileName = ba.Document.FileName,
ContentType = ba.Document.ContentType,
S3Key = ba.Document.S3Key,
ThumbS3Key = ba.Document.ThumbS3Key ?? ba.Document.S3Key
ThumbS3Key = ba.Document.ThumbS3Key ?? ba.Document.S3Key,
FileSize = ba.Document.FileSize,
}).ToList()
})
.FirstOrDefaultAsync();

View File

@ -23,6 +23,7 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces
Task<ApiResponse<object>> GetPayeeNameListAsync(Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetPaymentRequestFilterObjectAsync(Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> CreatePaymentRequestAsync(PaymentRequestDto model, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> ChangePaymentRequestStatusAsync(PaymentRequestRecordDto model, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> EditPaymentRequestAsync(Guid id, PaymentRequestDto model, Employee loggedInEmployee, Guid tenantId);
#endregion