feat: implement escrow functionality for holding, executing, and releasing funds.
This commit is contained in:
45
.agent/workflows/docker-local-ops.md
Normal file
45
.agent/workflows/docker-local-ops.md
Normal file
@@ -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ế `<service_name>` 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 <service_name>
|
||||
```
|
||||
|
||||
**Xem log 1 service**
|
||||
```sh
|
||||
cd deployments/local
|
||||
docker compose logs -f <service_name>
|
||||
```
|
||||
|
||||
**Restart 1 service**
|
||||
```sh
|
||||
cd deployments/local
|
||||
docker compose restart <service_name>
|
||||
```
|
||||
@@ -0,0 +1,237 @@
|
||||
namespace WalletService.API.Application.Commands;
|
||||
|
||||
using MediatR;
|
||||
using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
using WalletService.Domain.Exceptions;
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateHoldCommand - creates an escrow hold on a wallet.
|
||||
/// VI: Handler cho CreateHoldCommand - tạo escrow hold trên ví.
|
||||
/// </summary>
|
||||
public class CreateHoldCommandHandler : IRequestHandler<CreateHoldCommand, HoldResult>
|
||||
{
|
||||
private readonly IWalletRepository _walletRepository;
|
||||
private readonly ILogger<CreateHoldCommandHandler> _logger;
|
||||
|
||||
public CreateHoldCommandHandler(
|
||||
IWalletRepository walletRepository,
|
||||
ILogger<CreateHoldCommandHandler> logger)
|
||||
{
|
||||
_walletRepository = walletRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HoldResult> 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<CurrencyType>(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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ExecuteHoldCommandHandler : IRequestHandler<ExecuteHoldCommand, HoldResult>
|
||||
{
|
||||
private readonly IWalletRepository _walletRepository;
|
||||
private readonly ILogger<ExecuteHoldCommandHandler> _logger;
|
||||
|
||||
public ExecuteHoldCommandHandler(
|
||||
IWalletRepository walletRepository,
|
||||
ILogger<ExecuteHoldCommandHandler> logger)
|
||||
{
|
||||
_walletRepository = walletRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HoldResult> 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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ReleaseHoldCommandHandler : IRequestHandler<ReleaseHoldCommand, HoldResult>
|
||||
{
|
||||
private readonly IWalletRepository _walletRepository;
|
||||
private readonly ILogger<ReleaseHoldCommandHandler> _logger;
|
||||
|
||||
public ReleaseHoldCommandHandler(
|
||||
IWalletRepository walletRepository,
|
||||
ILogger<ReleaseHoldCommandHandler> logger)
|
||||
{
|
||||
_walletRepository = walletRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HoldResult> 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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class CancelHoldCommandHandler : IRequestHandler<CancelHoldCommand, HoldResult>
|
||||
{
|
||||
private readonly IWalletRepository _walletRepository;
|
||||
private readonly ILogger<CancelHoldCommandHandler> _logger;
|
||||
|
||||
public CancelHoldCommandHandler(
|
||||
IWalletRepository walletRepository,
|
||||
ILogger<CancelHoldCommandHandler> logger)
|
||||
{
|
||||
_walletRepository = walletRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HoldResult> 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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace WalletService.API.Application.Commands;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create an escrow hold on a wallet.
|
||||
/// VI: Command để tạo escrow hold trên ví.
|
||||
/// </summary>
|
||||
public record CreateHoldCommand(
|
||||
Guid UserId,
|
||||
decimal Amount,
|
||||
string CurrencyCode,
|
||||
string ReferenceType,
|
||||
Guid ReferenceId,
|
||||
string Description,
|
||||
DateTime? ExpiresAt = null
|
||||
) : IRequest<HoldResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to execute (commit) an escrow hold.
|
||||
/// VI: Command để thực thi (cam kết) escrow hold.
|
||||
/// </summary>
|
||||
public record ExecuteHoldCommand(
|
||||
Guid WalletId,
|
||||
Guid HoldId,
|
||||
decimal Amount,
|
||||
string? ExecutionRef = null
|
||||
) : IRequest<HoldResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to release (return) an escrow hold.
|
||||
/// VI: Command để giải phóng (trả lại) escrow hold.
|
||||
/// </summary>
|
||||
public record ReleaseHoldCommand(
|
||||
Guid WalletId,
|
||||
Guid HoldId,
|
||||
decimal? Amount = null // null = release all remaining
|
||||
) : IRequest<HoldResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to cancel an escrow hold.
|
||||
/// VI: Command để hủy escrow hold.
|
||||
/// </summary>
|
||||
public record CancelHoldCommand(
|
||||
Guid WalletId,
|
||||
Guid HoldId
|
||||
) : IRequest<HoldResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of hold operations.
|
||||
/// VI: Kết quả của các thao tác hold.
|
||||
/// </summary>
|
||||
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
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for escrow/hold management operations.
|
||||
/// VI: Controller cho các thao tác quản lý escrow/hold.
|
||||
/// </summary>
|
||||
[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<HoldsController> _logger;
|
||||
|
||||
public HoldsController(IMediator mediator, ILogger<HoldsController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create an escrow hold on a wallet.
|
||||
/// VI: Tạo escrow hold trên ví.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[SwaggerOperation(Summary = "Create hold", Description = "Create an escrow hold - lock funds for a campaign or order")]
|
||||
[SwaggerResponse(201, "Hold created", typeof(ApiResponse<HoldResult>))]
|
||||
[SwaggerResponse(400, "Invalid request or insufficient balance")]
|
||||
[SwaggerResponse(404, "Wallet not found")]
|
||||
public async Task<IActionResult> 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<HoldResult>(true, "Hold created", result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a specific hold by ID.
|
||||
/// VI: Lấy hold theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("{holdId:guid}")]
|
||||
[SwaggerOperation(Summary = "Get hold", Description = "Get escrow hold details by ID")]
|
||||
[SwaggerResponse(200, "Success", typeof(ApiResponse<HoldResult>))]
|
||||
[SwaggerResponse(404, "Hold not found")]
|
||||
public async Task<IActionResult> GetHold(Guid walletId, Guid holdId)
|
||||
{
|
||||
// For now, return 501 - implement query later
|
||||
return StatusCode(501, new ApiResponse<object>(false, "Not implemented - use Query service"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Execute (commit) a portion of an escrow hold.
|
||||
/// VI: Thực thi (cam kết) một phần escrow hold.
|
||||
/// </summary>
|
||||
[HttpPost("{holdId:guid}/execute")]
|
||||
[SwaggerOperation(Summary = "Execute hold", Description = "Execute/commit a portion of the escrowed funds")]
|
||||
[SwaggerResponse(200, "Execution successful", typeof(ApiResponse<HoldResult>))]
|
||||
[SwaggerResponse(400, "Invalid request or insufficient held amount")]
|
||||
[SwaggerResponse(404, "Wallet or hold not found")]
|
||||
public async Task<IActionResult> 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<HoldResult>(true, "Hold executed", result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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í.
|
||||
/// </summary>
|
||||
[HttpPost("{holdId:guid}/release")]
|
||||
[SwaggerOperation(Summary = "Release hold", Description = "Release/return escrowed funds back to wallet")]
|
||||
[SwaggerResponse(200, "Release successful", typeof(ApiResponse<HoldResult>))]
|
||||
[SwaggerResponse(400, "Invalid request")]
|
||||
[SwaggerResponse(404, "Wallet or hold not found")]
|
||||
public async Task<IActionResult> 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<HoldResult>(true, "Hold released", result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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í.
|
||||
/// </summary>
|
||||
[HttpPost("{holdId:guid}/cancel")]
|
||||
[SwaggerOperation(Summary = "Cancel hold", Description = "Cancel hold and release all remaining funds")]
|
||||
[SwaggerResponse(200, "Cancel successful", typeof(ApiResponse<HoldResult>))]
|
||||
[SwaggerResponse(404, "Wallet or hold not found")]
|
||||
public async Task<IActionResult> CancelHold(Guid walletId, Guid holdId)
|
||||
{
|
||||
var command = new CancelHoldCommand(walletId, holdId);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
return Ok(new ApiResponse<HoldResult>(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
|
||||
@@ -0,0 +1,249 @@
|
||||
namespace WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
|
||||
using WalletService.Domain.Exceptions;
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// 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í.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public class HoldItem : Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Wallet ID that owns this hold.
|
||||
/// VI: ID ví sở hữu hold này.
|
||||
/// </summary>
|
||||
public Guid WalletId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Original amount that was held.
|
||||
/// VI: Số tiền ban đầu bị giữ.
|
||||
/// </summary>
|
||||
public decimal OriginalAmount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public decimal RemainingAmount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Total amount that has been executed (paid out).
|
||||
/// VI: Tổng số tiền đã được thực thi (đã thanh toán).
|
||||
/// </summary>
|
||||
public decimal ExecutedAmount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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í.
|
||||
/// </summary>
|
||||
public decimal ReleasedAmount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Currency type ID.
|
||||
/// VI: ID loại tiền tệ.
|
||||
/// </summary>
|
||||
public int CurrencyTypeId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Currency type.
|
||||
/// VI: Loại tiền tệ.
|
||||
/// </summary>
|
||||
public CurrencyType CurrencyType { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reference type (e.g., "CAMPAIGN", "ORDER").
|
||||
/// VI: Loại tham chiếu (ví dụ: "CAMPAIGN", "ORDER").
|
||||
/// </summary>
|
||||
public string ReferenceType { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Reference ID (e.g., CampaignId, OrderId).
|
||||
/// VI: ID tham chiếu (ví dụ: CampaignId, OrderId).
|
||||
/// </summary>
|
||||
public Guid ReferenceId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Description of the hold.
|
||||
/// VI: Mô tả của hold.
|
||||
/// </summary>
|
||||
public string Description { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hold status.
|
||||
/// VI: Trạng thái hold.
|
||||
/// </summary>
|
||||
public HoldStatus Status { get; private set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core.
|
||||
/// VI: ID trạng thái cho EF Core.
|
||||
/// </summary>
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hold creation timestamp.
|
||||
/// VI: Thời điểm tạo hold.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời điểm cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hold expiration date (optional).
|
||||
/// VI: Ngày hết hạn hold (tùy chọn).
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt { get; private set; }
|
||||
|
||||
// EF Core constructor
|
||||
protected HoldItem() { }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new hold item.
|
||||
/// VI: Tạo mục giữ mới.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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ữ.
|
||||
/// </summary>
|
||||
/// <param name="amount">Amount to execute / Số tiền cần thực thi</param>
|
||||
/// <param name="executionRef">Optional reference for this execution / Tham chiếu tùy chọn cho thực thi này</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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í.
|
||||
/// </summary>
|
||||
/// <param name="amount">Amount to release / Số tiền cần giải phóng</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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í.
|
||||
/// </summary>
|
||||
public void ReleaseAll()
|
||||
{
|
||||
if (RemainingAmount > 0)
|
||||
{
|
||||
Release(RemainingAmount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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í).
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
ValidateCanModify();
|
||||
|
||||
ReleaseAll();
|
||||
Status = HoldStatus.Cancelled;
|
||||
StatusId = Status.Id;
|
||||
UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if hold has expired.
|
||||
/// VI: Kiểm tra xem hold đã hết hạn chưa.
|
||||
/// </summary>
|
||||
public bool IsExpired => ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
|
||||
using WalletService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hold status enumeration for escrow operations.
|
||||
/// VI: Enumeration trạng thái giữ cho các thao tác ký quỹ.
|
||||
/// </summary>
|
||||
public class HoldStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Hold is active - amount is locked.
|
||||
/// VI: Hold đang hoạt động - số tiền bị khóa.
|
||||
/// </summary>
|
||||
public static HoldStatus Active = new(1, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static HoldStatus PartiallyReleased = new(2, nameof(PartiallyReleased));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static HoldStatus Released = new(3, nameof(Released));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hold is executed - amount transferred to recipient.
|
||||
/// VI: Hold đã được thực thi - số tiền đã chuyển cho người nhận.
|
||||
/// </summary>
|
||||
public static HoldStatus Executed = new(4, nameof(Executed));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hold is cancelled - operation aborted.
|
||||
/// VI: Hold đã bị hủy - thao tác đã bị hủy bỏ.
|
||||
/// </summary>
|
||||
public static HoldStatus Cancelled = new(5, nameof(Cancelled));
|
||||
|
||||
protected HoldStatus() : base(0, "Unknown") { }
|
||||
|
||||
public HoldStatus(int id, string name) : base(id, name) { }
|
||||
}
|
||||
@@ -38,5 +38,25 @@ public class TransactionType : Enumeration
|
||||
/// </summary>
|
||||
public static TransactionType Refund = new(5, nameof(Refund));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hold created - funds locked for escrow
|
||||
/// VI: Tạo hold - tiền bị khóa cho ký quỹ
|
||||
/// </summary>
|
||||
public static TransactionType HoldCreated = new(6, nameof(HoldCreated));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hold executed - escrowed funds committed/paid out
|
||||
/// VI: Thực thi hold - tiền ký quỹ được cam kết/thanh toán
|
||||
/// </summary>
|
||||
public static TransactionType HoldExecuted = new(7, nameof(HoldExecuted));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Hold released - escrowed funds returned to wallet
|
||||
/// VI: Giải phóng hold - tiền ký quỹ được trả lại ví
|
||||
/// </summary>
|
||||
public static TransactionType HoldReleased = new(8, nameof(HoldReleased));
|
||||
|
||||
protected TransactionType() : base(0, "Unknown") { }
|
||||
|
||||
public TransactionType(int id, string name) : base(id, name) { }
|
||||
}
|
||||
|
||||
@@ -79,6 +79,14 @@ public class Wallet : Entity, IAggregateRoot
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<WalletTransaction> Transactions => _transactions.AsReadOnly();
|
||||
|
||||
private readonly List<HoldItem> _holds = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Active escrow holds.
|
||||
/// VI: Các khoản ký quỹ đang hoạt động.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<HoldItem> Holds => _holds.AsReadOnly();
|
||||
|
||||
protected Wallet() { }
|
||||
|
||||
/// <summary>
|
||||
@@ -410,4 +418,205 @@ public class Wallet : Entity, IAggregateRoot
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escrow Methods
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="amount">Amount to hold / Số tiền cần giữ</param>
|
||||
/// <param name="currencyType">Currency type / Loại tiền tệ</param>
|
||||
/// <param name="referenceType">Reference type (e.g., "CAMPAIGN") / Loại tham chiếu</param>
|
||||
/// <param name="referenceId">Reference ID (e.g., CampaignId) / ID tham chiếu</param>
|
||||
/// <param name="description">Description / Mô tả</param>
|
||||
/// <param name="expiresAt">Optional expiration / Hết hạn tùy chọn</param>
|
||||
/// <returns>The created HoldItem / Mục hold đã tạo</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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ữ.
|
||||
/// </summary>
|
||||
/// <param name="holdId">Hold ID / ID hold</param>
|
||||
/// <param name="amount">Amount to execute / Số tiền cần thực thi</param>
|
||||
/// <param name="executionRef">Execution reference / Tham chiếu thực thi</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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í.
|
||||
/// </summary>
|
||||
/// <param name="holdId">Hold ID / ID hold</param>
|
||||
/// <param name="amount">Amount to release (null = all remaining) / Số tiền giải phóng (null = tất cả còn lại)</param>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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í.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a hold by ID.
|
||||
/// VI: Lấy hold theo ID.
|
||||
/// </summary>
|
||||
public HoldItem? GetHold(Guid holdId)
|
||||
{
|
||||
return _holds.FirstOrDefault(h => h.Id == holdId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get holds by reference.
|
||||
/// VI: Lấy holds theo tham chiếu.
|
||||
/// </summary>
|
||||
public IEnumerable<HoldItem> GetHoldsByReference(string referenceType, Guid referenceId)
|
||||
{
|
||||
return _holds.Where(h => h.ReferenceType == referenceType && h.ReferenceId == referenceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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ệ.
|
||||
/// </summary>
|
||||
public decimal GetTotalHeldAmount(CurrencyType currencyType)
|
||||
{
|
||||
return _holds
|
||||
.Where(h => h.CurrencyTypeId == currencyType.Id && h.IsActive)
|
||||
.Sum(h => h.RemainingAmount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace WalletService.Domain.Events;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace WalletService.Domain.Events;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when an escrow hold is created.
|
||||
/// VI: Domain event được phát ra khi tạo escrow hold.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace WalletService.Domain.Events;
|
||||
|
||||
using MediatR;
|
||||
|
||||
/// <summary>
|
||||
/// 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í).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace WalletService.Infrastructure.EntityConfigurations;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using WalletService.Domain.AggregatesModel.WalletAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for HoldItem entity.
|
||||
/// VI: Cấu hình EF Core cho entity HoldItem.
|
||||
/// </summary>
|
||||
public class HoldItemEntityTypeConfiguration : IEntityTypeConfiguration<HoldItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<HoldItem> 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);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,16 @@ public class WalletEntityTypeConfiguration : IEntityTypeConfiguration<Wallet>
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public class WalletServiceContext : DbContext, IUnitOfWork
|
||||
public DbSet<Wallet> Wallets { get; set; } = null!;
|
||||
public DbSet<WalletItem> WalletItems { get; set; } = null!;
|
||||
public DbSet<WalletTransaction> WalletTransactions { get; set; } = null!;
|
||||
public DbSet<HoldItem> WalletHolds { get; set; } = null!;
|
||||
public DbSet<PointAccount> PointAccounts { get; set; } = null!;
|
||||
public DbSet<PointTransaction> PointTransactions { get; set; } = null!;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user