From cb08cee1d42f5d30ac3a6444d157594c24a4ebf6 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 17 Jan 2026 21:15:05 +0700 Subject: [PATCH] feat: implement escrow functionality for holding, executing, and releasing funds. --- .agent/workflows/docker-local-ops.md | 45 ++++ .../Commands/EscrowCommandHandlers.cs | 237 +++++++++++++++++ .../Application/Commands/EscrowCommands.cs | 66 +++++ .../Controllers/HoldsController.cs | 142 ++++++++++ .../WalletAggregate/HoldItem.cs | 249 ++++++++++++++++++ .../WalletAggregate/HoldStatus.cs | 44 ++++ .../WalletAggregate/TransactionType.cs | 20 ++ .../AggregatesModel/WalletAggregate/Wallet.cs | 209 +++++++++++++++ .../Events/EscrowExecutedDomainEvent.cs | 35 +++ .../Events/EscrowHeldDomainEvent.cs | 38 +++ .../Events/EscrowReleasedDomainEvent.cs | 32 +++ .../HoldItemEntityTypeConfiguration.cs | 103 ++++++++ .../WalletEntityTypeConfiguration.cs | 10 + .../WalletServiceContext.cs | 1 + 14 files changed, 1231 insertions(+) create mode 100644 .agent/workflows/docker-local-ops.md create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/EscrowCommandHandlers.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Application/Commands/EscrowCommands.cs create mode 100644 services/wallet-service-net/src/WalletService.API/Controllers/HoldsController.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldItem.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldStatus.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/EscrowExecutedDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/EscrowHeldDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Domain/Events/EscrowReleasedDomainEvent.cs create mode 100644 services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/HoldItemEntityTypeConfiguration.cs diff --git a/.agent/workflows/docker-local-ops.md b/.agent/workflows/docker-local-ops.md new file mode 100644 index 00000000..88710ad2 --- /dev/null +++ b/.agent/workflows/docker-local-ops.md @@ -0,0 +1,45 @@ +--- +description: Kiểm tra, rebuild và chạy Docker tại deployments/local (Toàn bộ hoặc từng Service) +--- + +### 1. Cho toàn bộ hệ thống + +**Kiểm tra trạng thái** +```sh +cd deployments/local +docker compose ps +``` + +**Rebuild toàn bộ** +```sh +cd deployments/local +docker compose down +docker compose build +``` + +**Khởi chạy toàn bộ** +```sh +cd deployments/local +docker compose up -d +``` + +### 2. Cho 1 Service cụ thể +*Thay thế `` bằng tên service (ví dụ: `wallet-service-net`, `redis`, ...)* + +**Rebuild và chạy lại 1 service (Nhanh nhất)** +```sh +cd deployments/local +docker compose up -d --build +``` + +**Xem log 1 service** +```sh +cd deployments/local +docker compose logs -f +``` + +**Restart 1 service** +```sh +cd deployments/local +docker compose restart +``` diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/EscrowCommandHandlers.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/EscrowCommandHandlers.cs new file mode 100644 index 00000000..402f8b43 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/EscrowCommandHandlers.cs @@ -0,0 +1,237 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; + +/// +/// EN: Handler for CreateHoldCommand - creates an escrow hold on a wallet. +/// VI: Handler cho CreateHoldCommand - tạo escrow hold trên ví. +/// +public class CreateHoldCommandHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + private readonly ILogger _logger; + + public CreateHoldCommandHandler( + IWalletRepository walletRepository, + ILogger logger) + { + _walletRepository = walletRepository; + _logger = logger; + } + + public async Task Handle(CreateHoldCommand request, CancellationToken cancellationToken) + { + // EN: Get wallet by user ID + // VI: Lấy ví theo user ID + var wallet = await _walletRepository.GetByUserIdAsync(request.UserId) + ?? throw new WalletDomainException($"Wallet not found for user {request.UserId}"); + + // EN: Get currency type + // VI: Lấy loại tiền tệ + var currencyType = Enumeration.FromDisplayName(request.CurrencyCode) + ?? throw new WalletDomainException($"Unknown currency: {request.CurrencyCode}"); + + // EN: Create hold + // VI: Tạo hold + var hold = wallet.Hold( + request.Amount, + currencyType, + request.ReferenceType, + request.ReferenceId, + request.Description, + request.ExpiresAt); + + _walletRepository.Update(wallet); + await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Created hold {HoldId} for {Amount} {Currency} on wallet {WalletId}, Reference: {ReferenceType}:{ReferenceId}", + hold.Id, request.Amount, currencyType.Name, wallet.Id, request.ReferenceType, request.ReferenceId); + + return MapToResult(hold, wallet.Id); + } + + private static HoldResult MapToResult(HoldItem hold, Guid walletId) => new( + hold.Id, + walletId, + hold.OriginalAmount, + hold.RemainingAmount, + hold.ExecutedAmount, + hold.ReleasedAmount, + hold.Status.Name, + hold.CurrencyType.Name, + hold.ReferenceType, + hold.ReferenceId, + hold.CreatedAt, + hold.UpdatedAt + ); +} + +/// +/// EN: Handler for ExecuteHoldCommand - executes (commits) a portion of an escrow hold. +/// VI: Handler cho ExecuteHoldCommand - thực thi (cam kết) một phần escrow hold. +/// +public class ExecuteHoldCommandHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + private readonly ILogger _logger; + + public ExecuteHoldCommandHandler( + IWalletRepository walletRepository, + ILogger logger) + { + _walletRepository = walletRepository; + _logger = logger; + } + + public async Task Handle(ExecuteHoldCommand request, CancellationToken cancellationToken) + { + // EN: Get wallet + // VI: Lấy ví + var wallet = await _walletRepository.GetByIdAsync(request.WalletId) + ?? throw new WalletDomainException($"Wallet {request.WalletId} not found"); + + // EN: Execute hold + // VI: Thực thi hold + wallet.ExecuteHold(request.HoldId, request.Amount, request.ExecutionRef); + + _walletRepository.Update(wallet); + await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + var hold = wallet.GetHold(request.HoldId)!; + + _logger.LogInformation( + "Executed {Amount} from hold {HoldId} on wallet {WalletId}, Remaining: {Remaining}", + request.Amount, request.HoldId, wallet.Id, hold.RemainingAmount); + + return MapToResult(hold, wallet.Id); + } + + private static HoldResult MapToResult(HoldItem hold, Guid walletId) => new( + hold.Id, + walletId, + hold.OriginalAmount, + hold.RemainingAmount, + hold.ExecutedAmount, + hold.ReleasedAmount, + hold.Status.Name, + hold.CurrencyType.Name, + hold.ReferenceType, + hold.ReferenceId, + hold.CreatedAt, + hold.UpdatedAt + ); +} + +/// +/// EN: Handler for ReleaseHoldCommand - releases (returns) a portion of an escrow hold. +/// VI: Handler cho ReleaseHoldCommand - giải phóng (trả lại) một phần escrow hold. +/// +public class ReleaseHoldCommandHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + private readonly ILogger _logger; + + public ReleaseHoldCommandHandler( + IWalletRepository walletRepository, + ILogger logger) + { + _walletRepository = walletRepository; + _logger = logger; + } + + public async Task Handle(ReleaseHoldCommand request, CancellationToken cancellationToken) + { + // EN: Get wallet + // VI: Lấy ví + var wallet = await _walletRepository.GetByIdAsync(request.WalletId) + ?? throw new WalletDomainException($"Wallet {request.WalletId} not found"); + + // EN: Release hold + // VI: Giải phóng hold + wallet.ReleaseHold(request.HoldId, request.Amount); + + _walletRepository.Update(wallet); + await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + var hold = wallet.GetHold(request.HoldId)!; + + _logger.LogInformation( + "Released {Amount} from hold {HoldId} on wallet {WalletId}, Remaining: {Remaining}", + request.Amount ?? hold.OriginalAmount, request.HoldId, wallet.Id, hold.RemainingAmount); + + return MapToResult(hold, wallet.Id); + } + + private static HoldResult MapToResult(HoldItem hold, Guid walletId) => new( + hold.Id, + walletId, + hold.OriginalAmount, + hold.RemainingAmount, + hold.ExecutedAmount, + hold.ReleasedAmount, + hold.Status.Name, + hold.CurrencyType.Name, + hold.ReferenceType, + hold.ReferenceId, + hold.CreatedAt, + hold.UpdatedAt + ); +} + +/// +/// EN: Handler for CancelHoldCommand - cancels an escrow hold and releases all remaining. +/// VI: Handler cho CancelHoldCommand - hủy escrow hold và giải phóng tất cả còn lại. +/// +public class CancelHoldCommandHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + private readonly ILogger _logger; + + public CancelHoldCommandHandler( + IWalletRepository walletRepository, + ILogger logger) + { + _walletRepository = walletRepository; + _logger = logger; + } + + public async Task Handle(CancelHoldCommand request, CancellationToken cancellationToken) + { + // EN: Get wallet + // VI: Lấy ví + var wallet = await _walletRepository.GetByIdAsync(request.WalletId) + ?? throw new WalletDomainException($"Wallet {request.WalletId} not found"); + + // EN: Cancel hold + // VI: Hủy hold + wallet.CancelHold(request.HoldId); + + _walletRepository.Update(wallet); + await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + var hold = wallet.GetHold(request.HoldId)!; + + _logger.LogInformation("Cancelled hold {HoldId} on wallet {WalletId}", request.HoldId, wallet.Id); + + return MapToResult(hold, wallet.Id); + } + + private static HoldResult MapToResult(HoldItem hold, Guid walletId) => new( + hold.Id, + walletId, + hold.OriginalAmount, + hold.RemainingAmount, + hold.ExecutedAmount, + hold.ReleasedAmount, + hold.Status.Name, + hold.CurrencyType.Name, + hold.ReferenceType, + hold.ReferenceId, + hold.CreatedAt, + hold.UpdatedAt + ); +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/EscrowCommands.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/EscrowCommands.cs new file mode 100644 index 00000000..8f1a83ad --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/EscrowCommands.cs @@ -0,0 +1,66 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; + +/// +/// EN: Command to create an escrow hold on a wallet. +/// VI: Command để tạo escrow hold trên ví. +/// +public record CreateHoldCommand( + Guid UserId, + decimal Amount, + string CurrencyCode, + string ReferenceType, + Guid ReferenceId, + string Description, + DateTime? ExpiresAt = null +) : IRequest; + +/// +/// EN: Command to execute (commit) an escrow hold. +/// VI: Command để thực thi (cam kết) escrow hold. +/// +public record ExecuteHoldCommand( + Guid WalletId, + Guid HoldId, + decimal Amount, + string? ExecutionRef = null +) : IRequest; + +/// +/// EN: Command to release (return) an escrow hold. +/// VI: Command để giải phóng (trả lại) escrow hold. +/// +public record ReleaseHoldCommand( + Guid WalletId, + Guid HoldId, + decimal? Amount = null // null = release all remaining +) : IRequest; + +/// +/// EN: Command to cancel an escrow hold. +/// VI: Command để hủy escrow hold. +/// +public record CancelHoldCommand( + Guid WalletId, + Guid HoldId +) : IRequest; + +/// +/// EN: Result of hold operations. +/// VI: Kết quả của các thao tác hold. +/// +public record HoldResult( + Guid HoldId, + Guid WalletId, + decimal OriginalAmount, + decimal RemainingAmount, + decimal ExecutedAmount, + decimal ReleasedAmount, + string Status, + string CurrencyCode, + string ReferenceType, + Guid ReferenceId, + DateTime CreatedAt, + DateTime UpdatedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Controllers/HoldsController.cs b/services/wallet-service-net/src/WalletService.API/Controllers/HoldsController.cs new file mode 100644 index 00000000..63aa1148 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Controllers/HoldsController.cs @@ -0,0 +1,142 @@ +namespace WalletService.API.Controllers; + +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using WalletService.API.Application.Commands; + +/// +/// EN: Controller for escrow/hold management operations. +/// VI: Controller cho các thao tác quản lý escrow/hold. +/// +[ApiController] +[Route("api/v1/wallets/{walletId:guid}/holds")] +[Authorize] +[SwaggerTag("Escrow/Hold management endpoints / Các endpoint quản lý ký quỹ")] +public class HoldsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public HoldsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Create an escrow hold on a wallet. + /// VI: Tạo escrow hold trên ví. + /// + [HttpPost] + [SwaggerOperation(Summary = "Create hold", Description = "Create an escrow hold - lock funds for a campaign or order")] + [SwaggerResponse(201, "Hold created", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid request or insufficient balance")] + [SwaggerResponse(404, "Wallet not found")] + public async Task CreateHold(Guid walletId, [FromBody] CreateHoldRequest request) + { + var command = new CreateHoldCommand( + request.UserId, + request.Amount, + request.CurrencyCode, + request.ReferenceType, + request.ReferenceId, + request.Description, + request.ExpiresAt); + + var result = await _mediator.Send(command); + + return CreatedAtAction( + nameof(GetHold), + new { walletId = result.WalletId, holdId = result.HoldId }, + new ApiResponse(true, "Hold created", result)); + } + + /// + /// EN: Get a specific hold by ID. + /// VI: Lấy hold theo ID. + /// + [HttpGet("{holdId:guid}")] + [SwaggerOperation(Summary = "Get hold", Description = "Get escrow hold details by ID")] + [SwaggerResponse(200, "Success", typeof(ApiResponse))] + [SwaggerResponse(404, "Hold not found")] + public async Task GetHold(Guid walletId, Guid holdId) + { + // For now, return 501 - implement query later + return StatusCode(501, new ApiResponse(false, "Not implemented - use Query service")); + } + + /// + /// EN: Execute (commit) a portion of an escrow hold. + /// VI: Thực thi (cam kết) một phần escrow hold. + /// + [HttpPost("{holdId:guid}/execute")] + [SwaggerOperation(Summary = "Execute hold", Description = "Execute/commit a portion of the escrowed funds")] + [SwaggerResponse(200, "Execution successful", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid request or insufficient held amount")] + [SwaggerResponse(404, "Wallet or hold not found")] + public async Task ExecuteHold(Guid walletId, Guid holdId, [FromBody] ExecuteHoldRequest request) + { + var command = new ExecuteHoldCommand(walletId, holdId, request.Amount, request.ExecutionRef); + var result = await _mediator.Send(command); + + return Ok(new ApiResponse(true, "Hold executed", result)); + } + + /// + /// EN: Release (return) a portion of an escrow hold back to the wallet. + /// VI: Giải phóng (trả lại) một phần escrow hold về ví. + /// + [HttpPost("{holdId:guid}/release")] + [SwaggerOperation(Summary = "Release hold", Description = "Release/return escrowed funds back to wallet")] + [SwaggerResponse(200, "Release successful", typeof(ApiResponse))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(404, "Wallet or hold not found")] + public async Task ReleaseHold(Guid walletId, Guid holdId, [FromBody] ReleaseHoldRequest request) + { + var command = new ReleaseHoldCommand(walletId, holdId, request.Amount); + var result = await _mediator.Send(command); + + return Ok(new ApiResponse(true, "Hold released", result)); + } + + /// + /// EN: Cancel an escrow hold and release all remaining back to wallet. + /// VI: Hủy escrow hold và giải phóng tất cả còn lại về ví. + /// + [HttpPost("{holdId:guid}/cancel")] + [SwaggerOperation(Summary = "Cancel hold", Description = "Cancel hold and release all remaining funds")] + [SwaggerResponse(200, "Cancel successful", typeof(ApiResponse))] + [SwaggerResponse(404, "Wallet or hold not found")] + public async Task CancelHold(Guid walletId, Guid holdId) + { + var command = new CancelHoldCommand(walletId, holdId); + var result = await _mediator.Send(command); + + return Ok(new ApiResponse(true, "Hold cancelled", result)); + } +} + +#region Request DTOs + +public record CreateHoldRequest( + Guid UserId, + decimal Amount, + string CurrencyCode, + string ReferenceType, + Guid ReferenceId, + string Description, + DateTime? ExpiresAt = null +); + +public record ExecuteHoldRequest( + decimal Amount, + string? ExecutionRef = null +); + +public record ReleaseHoldRequest( + decimal? Amount = null // null = release all remaining +); + +#endregion diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldItem.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldItem.cs new file mode 100644 index 00000000..6b55ce1f --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldItem.cs @@ -0,0 +1,249 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; + +/// +/// EN: Hold item entity representing a locked/escrowed amount in the wallet. +/// VI: Entity mục giữ đại diện cho số tiền bị khóa/ký quỹ trong ví. +/// +/// +/// EN: Used for escrow operations where funds are locked until a condition is met. +/// VI: Được sử dụng cho các thao tác ký quỹ khi tiền bị khóa cho đến khi điều kiện được đáp ứng. +/// +public class HoldItem : Entity +{ + /// + /// EN: Wallet ID that owns this hold. + /// VI: ID ví sở hữu hold này. + /// + public Guid WalletId { get; private set; } + + /// + /// EN: Original amount that was held. + /// VI: Số tiền ban đầu bị giữ. + /// + public decimal OriginalAmount { get; private set; } + + /// + /// EN: Remaining amount still held (after partial releases/executions). + /// VI: Số tiền còn lại vẫn bị giữ (sau các giải phóng/thực thi một phần). + /// + public decimal RemainingAmount { get; private set; } + + /// + /// EN: Total amount that has been executed (paid out). + /// VI: Tổng số tiền đã được thực thi (đã thanh toán). + /// + public decimal ExecutedAmount { get; private set; } + + /// + /// EN: Total amount that has been released back to wallet. + /// VI: Tổng số tiền đã được giải phóng trở lại ví. + /// + public decimal ReleasedAmount { get; private set; } + + /// + /// EN: Currency type ID. + /// VI: ID loại tiền tệ. + /// + public int CurrencyTypeId { get; private set; } + + /// + /// EN: Currency type. + /// VI: Loại tiền tệ. + /// + public CurrencyType CurrencyType { get; private set; } = null!; + + /// + /// EN: Reference type (e.g., "CAMPAIGN", "ORDER"). + /// VI: Loại tham chiếu (ví dụ: "CAMPAIGN", "ORDER"). + /// + public string ReferenceType { get; private set; } = null!; + + /// + /// EN: Reference ID (e.g., CampaignId, OrderId). + /// VI: ID tham chiếu (ví dụ: CampaignId, OrderId). + /// + public Guid ReferenceId { get; private set; } + + /// + /// EN: Description of the hold. + /// VI: Mô tả của hold. + /// + public string Description { get; private set; } = null!; + + /// + /// EN: Hold status. + /// VI: Trạng thái hold. + /// + public HoldStatus Status { get; private set; } = null!; + + /// + /// EN: Status ID for EF Core. + /// VI: ID trạng thái cho EF Core. + /// + public int StatusId { get; private set; } + + /// + /// EN: Hold creation timestamp. + /// VI: Thời điểm tạo hold. + /// + public DateTime CreatedAt { get; private set; } + + /// + /// EN: Last update timestamp. + /// VI: Thời điểm cập nhật cuối. + /// + public DateTime UpdatedAt { get; private set; } + + /// + /// EN: Hold expiration date (optional). + /// VI: Ngày hết hạn hold (tùy chọn). + /// + public DateTime? ExpiresAt { get; private set; } + + // EF Core constructor + protected HoldItem() { } + + /// + /// EN: Create a new hold item. + /// VI: Tạo mục giữ mới. + /// + public HoldItem( + Guid walletId, + decimal amount, + CurrencyType currencyType, + string referenceType, + Guid referenceId, + string description, + DateTime? expiresAt = null) + { + if (amount <= 0) + throw new WalletDomainException("Hold amount must be greater than zero"); + + Id = Guid.NewGuid(); + WalletId = walletId; + OriginalAmount = amount; + RemainingAmount = amount; + ExecutedAmount = 0; + ReleasedAmount = 0; + CurrencyType = currencyType; + CurrencyTypeId = currencyType.Id; + ReferenceType = referenceType; + ReferenceId = referenceId; + Description = description; + Status = HoldStatus.Active; + StatusId = Status.Id; + CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + ExpiresAt = expiresAt; + } + + /// + /// EN: Execute (commit) a portion of the held amount. + /// VI: Thực thi (cam kết) một phần số tiền bị giữ. + /// + /// Amount to execute / Số tiền cần thực thi + /// Optional reference for this execution / Tham chiếu tùy chọn cho thực thi này + public void Execute(decimal amount, string? executionRef = null) + { + ValidateCanModify(); + + if (amount <= 0) + throw new WalletDomainException("Execute amount must be greater than zero"); + + if (amount > RemainingAmount) + throw new WalletDomainException($"Cannot execute {amount}. Only {RemainingAmount} remaining"); + + RemainingAmount -= amount; + ExecutedAmount += amount; + UpdatedAt = DateTime.UtcNow; + UpdateStatus(); + } + + /// + /// EN: Release (return) a portion of the held amount back to the wallet. + /// VI: Giải phóng (trả lại) một phần số tiền bị giữ về ví. + /// + /// Amount to release / Số tiền cần giải phóng + public void Release(decimal amount) + { + ValidateCanModify(); + + if (amount <= 0) + throw new WalletDomainException("Release amount must be greater than zero"); + + if (amount > RemainingAmount) + throw new WalletDomainException($"Cannot release {amount}. Only {RemainingAmount} remaining"); + + RemainingAmount -= amount; + ReleasedAmount += amount; + UpdatedAt = DateTime.UtcNow; + UpdateStatus(); + } + + /// + /// EN: Release all remaining amount back to the wallet. + /// VI: Giải phóng tất cả số tiền còn lại về ví. + /// + public void ReleaseAll() + { + if (RemainingAmount > 0) + { + Release(RemainingAmount); + } + } + + /// + /// EN: Cancel the hold (release all remaining back to wallet). + /// VI: Hủy hold (giải phóng tất cả số còn lại về ví). + /// + public void Cancel() + { + ValidateCanModify(); + + ReleaseAll(); + Status = HoldStatus.Cancelled; + StatusId = Status.Id; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Check if hold has expired. + /// VI: Kiểm tra xem hold đã hết hạn chưa. + /// + public bool IsExpired => ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value; + + /// + /// EN: Check if hold is still active (can be modified). + /// VI: Kiểm tra xem hold còn hoạt động không (có thể sửa đổi). + /// + public bool IsActive => Status == HoldStatus.Active || Status == HoldStatus.PartiallyReleased; + + private void ValidateCanModify() + { + if (!IsActive) + throw new WalletDomainException($"Cannot modify hold in {Status.Name} status"); + + if (IsExpired) + throw new WalletDomainException("Hold has expired"); + } + + private void UpdateStatus() + { + if (RemainingAmount == 0) + { + // All amount has been used (executed or released) + Status = ExecutedAmount > 0 ? HoldStatus.Executed : HoldStatus.Released; + } + else if (ReleasedAmount > 0 || ExecutedAmount > 0) + { + // Some amount was released or executed but not all + Status = HoldStatus.PartiallyReleased; + } + // else status remains Active + + StatusId = Status.Id; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldStatus.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldStatus.cs new file mode 100644 index 00000000..b3692a22 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/HoldStatus.cs @@ -0,0 +1,44 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Hold status enumeration for escrow operations. +/// VI: Enumeration trạng thái giữ cho các thao tác ký quỹ. +/// +public class HoldStatus : Enumeration +{ + /// + /// EN: Hold is active - amount is locked. + /// VI: Hold đang hoạt động - số tiền bị khóa. + /// + public static HoldStatus Active = new(1, nameof(Active)); + + /// + /// EN: Hold is partially released - some amount returned. + /// VI: Hold đã được giải phóng một phần - một số tiền đã được trả lại. + /// + public static HoldStatus PartiallyReleased = new(2, nameof(PartiallyReleased)); + + /// + /// EN: Hold is fully released - all amount returned. + /// VI: Hold đã được giải phóng hoàn toàn - tất cả số tiền đã được trả lại. + /// + public static HoldStatus Released = new(3, nameof(Released)); + + /// + /// EN: Hold is executed - amount transferred to recipient. + /// VI: Hold đã được thực thi - số tiền đã chuyển cho người nhận. + /// + public static HoldStatus Executed = new(4, nameof(Executed)); + + /// + /// EN: Hold is cancelled - operation aborted. + /// VI: Hold đã bị hủy - thao tác đã bị hủy bỏ. + /// + public static HoldStatus Cancelled = new(5, nameof(Cancelled)); + + protected HoldStatus() : base(0, "Unknown") { } + + public HoldStatus(int id, string name) : base(id, name) { } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/TransactionType.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/TransactionType.cs index 1f2ab5db..56ebc21a 100644 --- a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/TransactionType.cs +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/TransactionType.cs @@ -38,5 +38,25 @@ public class TransactionType : Enumeration /// public static TransactionType Refund = new(5, nameof(Refund)); + /// + /// EN: Hold created - funds locked for escrow + /// VI: Tạo hold - tiền bị khóa cho ký quỹ + /// + public static TransactionType HoldCreated = new(6, nameof(HoldCreated)); + + /// + /// EN: Hold executed - escrowed funds committed/paid out + /// VI: Thực thi hold - tiền ký quỹ được cam kết/thanh toán + /// + public static TransactionType HoldExecuted = new(7, nameof(HoldExecuted)); + + /// + /// EN: Hold released - escrowed funds returned to wallet + /// VI: Giải phóng hold - tiền ký quỹ được trả lại ví + /// + public static TransactionType HoldReleased = new(8, nameof(HoldReleased)); + + protected TransactionType() : base(0, "Unknown") { } + public TransactionType(int id, string name) : base(id, name) { } } diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs index b60c4384..9dcad7ea 100644 --- a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs @@ -79,6 +79,14 @@ public class Wallet : Entity, IAggregateRoot /// public IReadOnlyCollection Transactions => _transactions.AsReadOnly(); + private readonly List _holds = new(); + + /// + /// EN: Active escrow holds. + /// VI: Các khoản ký quỹ đang hoạt động. + /// + public IReadOnlyCollection Holds => _holds.AsReadOnly(); + protected Wallet() { } /// @@ -410,4 +418,205 @@ public class Wallet : Entity, IAggregateRoot } #endregion + + #region Escrow Methods + + /// + /// EN: Create an escrow hold - lock funds for a campaign or order. + /// VI: Tạo escrow hold - khóa tiền cho chiến dịch hoặc đơn hàng. + /// + /// Amount to hold / Số tiền cần giữ + /// Currency type / Loại tiền tệ + /// Reference type (e.g., "CAMPAIGN") / Loại tham chiếu + /// Reference ID (e.g., CampaignId) / ID tham chiếu + /// Description / Mô tả + /// Optional expiration / Hết hạn tùy chọn + /// The created HoldItem / Mục hold đã tạo + public HoldItem Hold( + decimal amount, + CurrencyType currencyType, + string referenceType, + Guid referenceId, + string description, + DateTime? expiresAt = null) + { + EnsureWalletIsActive(); + + if (amount <= 0) + throw new WalletDomainException("Hold amount must be greater than zero"); + + // Check sufficient balance + var balance = _balances.FirstOrDefault(b => b.CurrencyTypeId == currencyType.Id); + if (balance == null || !balance.HasSufficientBalance(amount)) + throw new InsufficientBalanceException(balance?.Balance ?? 0, amount); + + // Subtract from available balance + balance.Subtract(amount); + UpdatedAt = DateTime.UtcNow; + + // Create hold + var hold = new HoldItem( + Id, + amount, + currencyType, + referenceType, + referenceId, + description, + expiresAt); + + _holds.Add(hold); + + // Record transaction + var transaction = new WalletTransaction( + Id, + new Money(amount, currencyType.Name), + TransactionType.HoldCreated, + balance.Balance, + $"Hold created: {description}", + hold.Id.ToString()); + + _transactions.Add(transaction); + + AddDomainEvent(new EscrowHeldDomainEvent( + Id, + hold.Id, + UserId, + referenceType, + referenceId, + amount, + currencyType.Id)); + + return hold; + } + + /// + /// EN: Execute (commit) a portion of the held amount. + /// VI: Thực thi (cam kết) một phần số tiền bị giữ. + /// + /// Hold ID / ID hold + /// Amount to execute / Số tiền cần thực thi + /// Execution reference / Tham chiếu thực thi + public void ExecuteHold(Guid holdId, decimal amount, string? executionRef = null) + { + EnsureWalletIsActive(); + + var hold = _holds.FirstOrDefault(h => h.Id == holdId); + if (hold == null) + throw new WalletDomainException($"Hold {holdId} not found"); + + var remainingBefore = hold.RemainingAmount; + hold.Execute(amount, executionRef); + UpdatedAt = DateTime.UtcNow; + + // Record transaction + var transaction = new WalletTransaction( + Id, + new Money(amount, hold.CurrencyType.Name), + TransactionType.HoldExecuted, + GetBalance(hold.CurrencyType), + $"Hold executed: {executionRef ?? hold.Description}", + holdId.ToString()); + + _transactions.Add(transaction); + + AddDomainEvent(new EscrowExecutedDomainEvent( + Id, + holdId, + UserId, + amount, + hold.RemainingAmount, + executionRef)); + } + + /// + /// EN: Release (return) a portion of the held amount back to the wallet. + /// VI: Giải phóng (trả lại) một phần số tiền bị giữ về ví. + /// + /// Hold ID / ID hold + /// Amount to release (null = all remaining) / Số tiền giải phóng (null = tất cả còn lại) + public void ReleaseHold(Guid holdId, decimal? amount = null) + { + EnsureWalletIsActive(); + + var hold = _holds.FirstOrDefault(h => h.Id == holdId); + if (hold == null) + throw new WalletDomainException($"Hold {holdId} not found"); + + var releaseAmount = amount ?? hold.RemainingAmount; + if (releaseAmount <= 0) + return; // Nothing to release + + // Release from hold + hold.Release(releaseAmount); + + // Return to available balance + var balance = GetOrCreateBalance(hold.CurrencyType); + balance.Add(releaseAmount); + UpdatedAt = DateTime.UtcNow; + + // Record transaction + var transaction = new WalletTransaction( + Id, + new Money(releaseAmount, hold.CurrencyType.Name), + TransactionType.HoldReleased, + balance.Balance, + $"Hold released: {hold.Description}", + holdId.ToString()); + + _transactions.Add(transaction); + + AddDomainEvent(new EscrowReleasedDomainEvent( + Id, + holdId, + UserId, + releaseAmount, + hold.RemainingAmount)); + } + + /// + /// EN: Cancel a hold and release all remaining back to wallet. + /// VI: Hủy hold và giải phóng tất cả số còn lại về ví. + /// + public void CancelHold(Guid holdId) + { + var hold = _holds.FirstOrDefault(h => h.Id == holdId); + if (hold == null) + throw new WalletDomainException($"Hold {holdId} not found"); + + if (hold.RemainingAmount > 0) + { + ReleaseHold(holdId, hold.RemainingAmount); + } + } + + /// + /// EN: Get a hold by ID. + /// VI: Lấy hold theo ID. + /// + public HoldItem? GetHold(Guid holdId) + { + return _holds.FirstOrDefault(h => h.Id == holdId); + } + + /// + /// EN: Get holds by reference. + /// VI: Lấy holds theo tham chiếu. + /// + public IEnumerable GetHoldsByReference(string referenceType, Guid referenceId) + { + return _holds.Where(h => h.ReferenceType == referenceType && h.ReferenceId == referenceId); + } + + /// + /// EN: Get total held amount for a currency type. + /// VI: Lấy tổng số tiền bị giữ cho một loại tiền tệ. + /// + public decimal GetTotalHeldAmount(CurrencyType currencyType) + { + return _holds + .Where(h => h.CurrencyTypeId == currencyType.Id && h.IsActive) + .Sum(h => h.RemainingAmount); + } + + #endregion } diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/EscrowExecutedDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/EscrowExecutedDomainEvent.cs new file mode 100644 index 00000000..8096daae --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/EscrowExecutedDomainEvent.cs @@ -0,0 +1,35 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when escrow hold is executed (funds committed). +/// VI: Domain event được phát ra khi escrow hold được thực thi (tiền đã cam kết). +/// +public class EscrowExecutedDomainEvent : INotification +{ + public Guid WalletId { get; } + public Guid HoldId { get; } + public Guid UserId { get; } + public decimal ExecutedAmount { get; } + public decimal RemainingAmount { get; } + public string? ExecutionRef { get; } + public DateTime OccurredOn { get; } + + public EscrowExecutedDomainEvent( + Guid walletId, + Guid holdId, + Guid userId, + decimal executedAmount, + decimal remainingAmount, + string? executionRef = null) + { + WalletId = walletId; + HoldId = holdId; + UserId = userId; + ExecutedAmount = executedAmount; + RemainingAmount = remainingAmount; + ExecutionRef = executionRef; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/EscrowHeldDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/EscrowHeldDomainEvent.cs new file mode 100644 index 00000000..54f2edce --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/EscrowHeldDomainEvent.cs @@ -0,0 +1,38 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when an escrow hold is created. +/// VI: Domain event được phát ra khi tạo escrow hold. +/// +public class EscrowHeldDomainEvent : INotification +{ + public Guid WalletId { get; } + public Guid HoldId { get; } + public Guid UserId { get; } + public string ReferenceType { get; } + public Guid ReferenceId { get; } + public decimal Amount { get; } + public int CurrencyTypeId { get; } + public DateTime OccurredOn { get; } + + public EscrowHeldDomainEvent( + Guid walletId, + Guid holdId, + Guid userId, + string referenceType, + Guid referenceId, + decimal amount, + int currencyTypeId) + { + WalletId = walletId; + HoldId = holdId; + UserId = userId; + ReferenceType = referenceType; + ReferenceId = referenceId; + Amount = amount; + CurrencyTypeId = currencyTypeId; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/EscrowReleasedDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/EscrowReleasedDomainEvent.cs new file mode 100644 index 00000000..b3fa39fa --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/EscrowReleasedDomainEvent.cs @@ -0,0 +1,32 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when escrow hold is released (funds returned to wallet). +/// VI: Domain event được phát ra khi escrow hold được giải phóng (tiền trả về ví). +/// +public class EscrowReleasedDomainEvent : INotification +{ + public Guid WalletId { get; } + public Guid HoldId { get; } + public Guid UserId { get; } + public decimal ReleasedAmount { get; } + public decimal RemainingAmount { get; } + public DateTime OccurredOn { get; } + + public EscrowReleasedDomainEvent( + Guid walletId, + Guid holdId, + Guid userId, + decimal releasedAmount, + decimal remainingAmount) + { + WalletId = walletId; + HoldId = holdId; + UserId = userId; + ReleasedAmount = releasedAmount; + RemainingAmount = remainingAmount; + OccurredOn = DateTime.UtcNow; + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/HoldItemEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/HoldItemEntityTypeConfiguration.cs new file mode 100644 index 00000000..dac7dd57 --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/HoldItemEntityTypeConfiguration.cs @@ -0,0 +1,103 @@ +namespace WalletService.Infrastructure.EntityConfigurations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WalletService.Domain.AggregatesModel.WalletAggregate; + +/// +/// EN: EF Core configuration for HoldItem entity. +/// VI: Cấu hình EF Core cho entity HoldItem. +/// +public class HoldItemEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wallet_holds"); + + builder.HasKey(h => h.Id); + + builder.Property(h => h.Id) + .HasColumnName("id"); + + builder.Property(h => h.WalletId) + .HasColumnName("wallet_id") + .IsRequired(); + + builder.Property(h => h.OriginalAmount) + .HasColumnName("original_amount") + .HasPrecision(18, 2) + .IsRequired(); + + builder.Property(h => h.RemainingAmount) + .HasColumnName("remaining_amount") + .HasPrecision(18, 2) + .IsRequired(); + + builder.Property(h => h.ExecutedAmount) + .HasColumnName("executed_amount") + .HasPrecision(18, 2) + .IsRequired(); + + builder.Property(h => h.ReleasedAmount) + .HasColumnName("released_amount") + .HasPrecision(18, 2) + .IsRequired(); + + builder.Property(h => h.CurrencyTypeId) + .HasColumnName("currency_type_id") + .IsRequired(); + + builder.HasOne(h => h.CurrencyType) + .WithMany() + .HasForeignKey(h => h.CurrencyTypeId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(h => h.ReferenceType) + .HasColumnName("reference_type") + .HasMaxLength(50) + .IsRequired(); + + builder.Property(h => h.ReferenceId) + .HasColumnName("reference_id") + .IsRequired(); + + builder.Property(h => h.Description) + .HasColumnName("description") + .HasMaxLength(500) + .IsRequired(); + + builder.Property(h => h.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.HasOne(h => h.Status) + .WithMany() + .HasForeignKey(h => h.StatusId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Property(h => h.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(h => h.UpdatedAt) + .HasColumnName("updated_at") + .IsRequired(); + + builder.Property(h => h.ExpiresAt) + .HasColumnName("expires_at"); + + // Indexes + builder.HasIndex(h => h.WalletId) + .HasDatabaseName("ix_wallet_holds_wallet_id"); + + builder.HasIndex(h => new { h.ReferenceType, h.ReferenceId }) + .HasDatabaseName("ix_wallet_holds_reference"); + + builder.HasIndex(h => h.StatusId) + .HasDatabaseName("ix_wallet_holds_status_id"); + + // Ignore computed properties + builder.Ignore(h => h.IsExpired); + builder.Ignore(h => h.IsActive); + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs index fa7704ce..cf4c8610 100644 --- a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs @@ -79,6 +79,16 @@ public class WalletEntityTypeConfiguration : IEntityTypeConfiguration .HasForeignKey(t => t.WalletId) .OnDelete(DeleteBehavior.Cascade); + // EN: Configure navigation to holds (escrow) + // VI: Cấu hình navigation đến holds (ký quỹ) + var holdsNavigation = builder.Metadata.FindNavigation(nameof(Wallet.Holds)); + holdsNavigation?.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(w => w.Holds) + .WithOne() + .HasForeignKey(h => h.WalletId) + .OnDelete(DeleteBehavior.Cascade); + builder.Ignore(w => w.DomainEvents); } } diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs b/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs index efd4f1c2..9611c55a 100644 --- a/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs +++ b/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs @@ -19,6 +19,7 @@ public class WalletServiceContext : DbContext, IUnitOfWork public DbSet Wallets { get; set; } = null!; public DbSet WalletItems { get; set; } = null!; public DbSet WalletTransactions { get; set; } = null!; + public DbSet WalletHolds { get; set; } = null!; public DbSet PointAccounts { get; set; } = null!; public DbSet PointTransactions { get; set; } = null!;