feat: Implement Escrow module with new APIs and update documentation.

This commit is contained in:
Ho Ngoc Hai
2026-01-17 21:17:54 +07:00
parent cb08cee1d4
commit ce15956aba
4 changed files with 190 additions and 0 deletions

View File

@@ -8,6 +8,7 @@
The Wallet Service provides comprehensive wallet and loyalty points management with:
- **Wallet Management** - Create, deposit, withdraw, transfer funds
- **Escrow Module** - Hold, commit, and release funds (for Promotion Service)
- **Point Account** - Earn, spend, and track loyalty points
- **Transaction History** - Full audit trail of all transactions
- **Multi-Currency Support** - Default VND with currency support
@@ -72,6 +73,16 @@ dotnet run --project src/WalletService.API
| `POST` | `/api/v1/wallets/{id}/unfreeze` | Unfreeze wallet |
| `GET` | `/api/v1/wallets/transactions` | Get transaction history |
### Escrow APIs (New)
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/wallets/{walletId}/holds` | Create a hold |
| `POST` | `/api/v1/wallets/{walletId}/holds/{holdId}/execute` | Execute/Commit hold (deduct funds) |
| `POST` | `/api/v1/wallets/{walletId}/holds/{holdId}/release` | Release hold (return funds) |
| `POST` | `/api/v1/wallets/{walletId}/holds/{holdId}/cancel` | Cancel hold |
| `GET` | `/api/v1/wallets/{walletId}/holds/{holdId}` | Get hold details |
### Points APIs
| Method | Endpoint | Description |
@@ -140,6 +151,13 @@ wallet.Withdraw(new Money(500000m, "VND"), "Shopping", "REF002");
// Freeze/Unfreeze
wallet.Freeze();
wallet.Unfreeze();
// Escrow
var hold = wallet.Hold(100000m, CurrencyType.VND, "CAMPAIGN", campaignId, "Hold for campaign");
wallet.ExecuteHold(hold.Id, 50000m, "ORDER123"); // Commit 50k
wallet.ReleaseHold(hold.Id, 50000m); // Return 50k
wallet.CancelHold(hold.Id); // Cancel remaining
```
### Point Account Aggregate

View File

@@ -8,6 +8,7 @@
Wallet Service cung cấp quản lý ví và điểm thưởng toàn diện:
- **Quản Lý Ví** - Tạo, nạp tiền, rút tiền, chuyển khoản
- **Escrow Module** - Ký quỹ, cam kết và giải phóng tiền (cho Promotion Service)
- **Tài Khoản Điểm** - Tích, tiêu và theo dõi điểm thưởng
- **Lịch Sử Giao Dịch** - Audit trail đầy đủ các giao dịch
- **Hỗ Trợ Đa Tiền Tệ** - Mặc định VND với hỗ trợ đa tiền tệ
@@ -72,6 +73,16 @@ dotnet run --project src/WalletService.API
| `POST` | `/api/v1/wallets/{id}/unfreeze` | Mở đóng băng ví |
| `GET` | `/api/v1/wallets/transactions` | Lấy lịch sử giao dịch |
### Escrow APIs (New)
| Method | Endpoint | Mô Tả |
|--------|----------|-------|
| `POST` | `/api/v1/wallets/{walletId}/holds` | Tạo lệnh giữ tiền (Hold) |
| `POST` | `/api/v1/wallets/{walletId}/holds/{holdId}/execute` | Thực thi lệnh giữ tiền (trừ tiền thực) |
| `POST` | `/api/v1/wallets/{walletId}/holds/{holdId}/release` | Giải phóng tiền giữ (trả lại ví) |
| `POST` | `/api/v1/wallets/{walletId}/holds/{holdId}/cancel` | Hủy lệnh giữ tiền |
| `GET` | `/api/v1/wallets/{walletId}/holds/{holdId}` | Lấy thông tin lệnh giữ tiền |
### Points APIs
| Method | Endpoint | Mô Tả |
@@ -140,6 +151,13 @@ wallet.Withdraw(new Money(500000m, "VND"), "Mua sắm", "REF002");
// Đóng băng/Mở đóng băng
wallet.Freeze();
wallet.Unfreeze();
// Escrow / Ký Quỹ
var hold = wallet.Hold(100000m, CurrencyType.VND, "CAMPAIGN", campaignId, "Hold for campaign");
wallet.ExecuteHold(hold.Id, 50000m, "ORDER123"); // Thực thi 50k
wallet.ReleaseHold(hold.Id, 50000m); // Trả lại 50k
wallet.CancelHold(hold.Id); // Hủy phần còn lại
```
### Point Account Aggregate

View File

@@ -14,6 +14,10 @@
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->

View File

@@ -0,0 +1,150 @@
namespace WalletService.UnitTests.Domain;
using FluentAssertions;
using WalletService.Domain.AggregatesModel.WalletAggregate;
using WalletService.Domain.Exceptions;
using Xunit;
public class HoldItemTests
{
[Fact]
public void Create_ValidArguments_ShouldCreateHoldItem()
{
// Arrange
var walletId = Guid.NewGuid();
var amount = 100m;
var currency = CurrencyType.VND;
var refType = "CAMPAIGN";
var refId = Guid.NewGuid();
var desc = "Test Hold";
// Act
var hold = new HoldItem(walletId, amount, currency, refType, refId, desc);
// Assert
hold.WalletId.Should().Be(walletId);
hold.OriginalAmount.Should().Be(amount);
hold.RemainingAmount.Should().Be(amount);
hold.ExecutedAmount.Should().Be(0);
hold.ReleasedAmount.Should().Be(0);
hold.Status.Should().Be(HoldStatus.Active);
hold.ReferenceType.Should().Be(refType);
hold.ReferenceId.Should().Be(refId);
hold.Description.Should().Be(desc);
}
[Fact]
public void Execute_ValidAmount_ShouldReduceRemainingAndIncreaseExecuted()
{
// Arrange
var hold = CreateHold(100m);
var execAmount = 40m;
// Act
hold.Execute(execAmount, "EXEC01");
// Assert
hold.RemainingAmount.Should().Be(60m);
hold.ExecutedAmount.Should().Be(40m);
hold.Status.Should().Be(HoldStatus.Active); // Still active as remaining > 0
}
[Fact]
public void Execute_FullAmount_ShouldMarkAsExecuted()
{
// Arrange
var hold = CreateHold(100m);
// Act
hold.Execute(100m, "EXEC_FULL");
// Assert
hold.RemainingAmount.Should().Be(0);
hold.ExecutedAmount.Should().Be(100m);
hold.Status.Should().Be(HoldStatus.Executed);
}
[Fact]
public void Execute_ExcessAmount_ShouldThrowException()
{
// Arrange
var hold = CreateHold(100m);
// Act & Assert
var act = () => hold.Execute(101m);
act.Should().Throw<WalletDomainException>()
.WithMessage("*Cannot execute*");
}
[Fact]
public void Release_ValidAmount_ShouldReduceRemainingAndIncreaseReleased()
{
// Arrange
var hold = CreateHold(100m);
var releaseAmount = 30m;
// Act
hold.Release(releaseAmount);
// Assert
hold.RemainingAmount.Should().Be(70m);
hold.ReleasedAmount.Should().Be(30m);
hold.Status.Should().Be(HoldStatus.PartiallyReleased);
}
[Fact]
public void Release_RestAmount_ShouldMarkAsReleased()
{
// Arrange
var hold = CreateHold(100m);
// Act
hold.Release(100m);
// Assert
hold.RemainingAmount.Should().Be(0);
hold.ReleasedAmount.Should().Be(100m);
hold.Status.Should().Be(HoldStatus.Released);
}
[Fact]
public void Cancel_ShouldReleaseAllRemainingAndMarkCancelled()
{
// Arrange
var hold = CreateHold(100m);
hold.Execute(20m); // Remaining 80
// Act
hold.Cancel();
// Assert
hold.RemainingAmount.Should().Be(0);
hold.ExecutedAmount.Should().Be(20m);
hold.ReleasedAmount.Should().Be(80m); // The rest is released
hold.Status.Should().Be(HoldStatus.Cancelled);
}
[Fact]
public void Execute_OnCancelledHold_ShouldThrowException()
{
// Arrange
var hold = CreateHold(100m);
hold.Cancel();
// Act & Assert
var act = () => hold.Execute(10m);
act.Should().Throw<WalletDomainException>()
.WithMessage("*not active*");
}
private static HoldItem CreateHold(decimal amount)
{
return new HoldItem(
Guid.NewGuid(),
amount,
CurrencyType.VND,
"TEST",
Guid.NewGuid(),
"Description");
}
}