From ce15956aba386809f4e67b6998705dc0f9265820 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 17 Jan 2026 21:17:54 +0700 Subject: [PATCH] feat: Implement Escrow module with new APIs and update documentation. --- services/wallet-service-net/docs/en/README.md | 18 +++ services/wallet-service-net/docs/vi/README.md | 18 +++ .../WalletService.API.csproj | 4 + .../Domain/HoldItemTests.cs | 150 ++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 services/wallet-service-net/tests/WalletService.UnitTests/Domain/HoldItemTests.cs diff --git a/services/wallet-service-net/docs/en/README.md b/services/wallet-service-net/docs/en/README.md index fa2d4375..331915b0 100644 --- a/services/wallet-service-net/docs/en/README.md +++ b/services/wallet-service-net/docs/en/README.md @@ -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 diff --git a/services/wallet-service-net/docs/vi/README.md b/services/wallet-service-net/docs/vi/README.md index 1a776e75..040f2cd2 100644 --- a/services/wallet-service-net/docs/vi/README.md +++ b/services/wallet-service-net/docs/vi/README.md @@ -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 diff --git a/services/wallet-service-net/src/WalletService.API/WalletService.API.csproj b/services/wallet-service-net/src/WalletService.API/WalletService.API.csproj index 7eebab0f..17934070 100644 --- a/services/wallet-service-net/src/WalletService.API/WalletService.API.csproj +++ b/services/wallet-service-net/src/WalletService.API/WalletService.API.csproj @@ -14,6 +14,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Domain/HoldItemTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/HoldItemTests.cs new file mode 100644 index 00000000..30c16ab3 --- /dev/null +++ b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/HoldItemTests.cs @@ -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() + .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() + .WithMessage("*not active*"); + } + + private static HoldItem CreateHold(decimal amount) + { + return new HoldItem( + Guid.NewGuid(), + amount, + CurrencyType.VND, + "TEST", + Guid.NewGuid(), + "Description"); + } +}