WalletService - Service Documentation
Auto-generated from source code audit. Last updated: 2026-03-13.
Overview
WalletService is a microservice responsible for digital wallet management, loyalty points, escrow/hold operations, and payment gateway integration. It follows Clean Architecture + CQRS patterns with MediatR.
- Port: 5004 (Development)
- Database:
wallet_service (Neon PostgreSQL)
- Framework: .NET 10.0, ASP.NET Core
- Auth: JWT Bearer (Duende IdentityServer)
- Payment Gateway: VNPay (sandbox, API v2.1.0)
- Multi-tenancy: User-level tenant isolation via EF Core global query filters + PostgreSQL RLS session variables
- Multi-currency: VND (id=1), USD (id=2), PPoint/Loyalty Points (id=3)
Key Capabilities
- Wallet Management - Create, deposit, withdraw, transfer, freeze/unfreeze/close wallets with multi-currency support
- Currency Exchange - Atomic in-wallet exchange between VND, USD, and PPoint with configurable rates
- Loyalty Points - Separate point account system with earn, spend, expire, bonus, and admin adjustment
- Escrow/Holds - Lock funds for campaigns/orders with partial execute/release/cancel
- Payment Gateway - VNPay integration for external payments with IPN callback validation
- Admin Backoffice - Full admin CRUD, statistics, search, freeze/unfreeze, balance/points adjustment
API Endpoints
Wallets (/api/v1/wallets) - [Authorize]
| Method |
Path |
Description |
Auth |
| GET |
/{userId:guid} |
Get wallet by user ID |
Bearer |
| POST |
/ |
Create new wallet |
Bearer |
| POST |
/{userId:guid}/deposit |
Deposit money into wallet |
Bearer |
| POST |
/{userId:guid}/withdraw |
Withdraw money from wallet |
Bearer |
| GET |
/{userId:guid}/transactions |
Get wallet transactions (paginated) |
Bearer |
Points (/api/v1/points) - [Authorize]
| Method |
Path |
Description |
Auth |
| GET |
/{userId:guid} |
Get point account by user ID |
Bearer |
| POST |
/ |
Create new point account |
Bearer |
| POST |
/{userId:guid}/earn |
Earn points |
Bearer |
| POST |
/{userId:guid}/spend |
Spend points |
Bearer |
| GET |
/{userId:guid}/transactions |
Get point transactions (paginated) |
Bearer |
Payments (/api/v1/payments)
| Method |
Path |
Description |
Auth |
| POST |
/create |
Create payment via gateway (VNPay) |
Bearer |
| GET |
/{orderId:guid} |
Get payment by order ID |
Bearer |
| GET |
/vnpay/callback |
VNPay IPN callback |
Anonymous |
| GET |
/vnpay/return |
VNPay customer return URL |
Anonymous |
Escrow/Holds (/api/v1/wallets/{walletId:guid}/holds) - [Authorize]
| Method |
Path |
Description |
Auth |
| POST |
/ |
Create escrow hold |
Bearer |
| GET |
/{holdId:guid} |
Get hold by ID (501 Not Implemented) |
Bearer |
| POST |
/{holdId:guid}/execute |
Execute (commit) hold portion |
Bearer |
| POST |
/{holdId:guid}/release |
Release hold portion back to wallet |
Bearer |
| POST |
/{holdId:guid}/cancel |
Cancel hold, release all remaining |
Bearer |
Admin Wallets (/api/v1/admin/wallets) - [Authorize(Roles = "Admin,SuperAdmin")]
| Method |
Path |
Description |
Auth |
| GET |
/ |
Get all wallets (paginated, filterable) |
Admin |
| GET |
/{walletId:guid} |
Get wallet by ID with details |
Admin |
| POST |
/{walletId:guid}/freeze |
Freeze wallet |
Admin |
| POST |
/{walletId:guid}/unfreeze |
Unfreeze wallet |
Admin |
| POST |
/{walletId:guid}/adjust |
Adjust wallet balance (credit/debit) |
Admin |
| GET |
/statistics |
Get wallet statistics |
Admin |
| GET |
/search |
Search wallets by userId/walletId/status |
Admin |
Admin Points (/api/v1/admin/points) - [Authorize(Roles = "Admin,SuperAdmin")]
| Method |
Path |
Description |
Auth |
| GET |
/ |
Get all point accounts (paginated, filterable) |
Admin |
| GET |
/{accountId:guid} |
Get point account by ID |
Admin |
| POST |
/{accountId:guid}/adjust |
Adjust points (add/subtract) |
Admin |
| POST |
/{accountId:guid}/bonus |
Grant bonus points |
Admin |
| GET |
/statistics |
Get points statistics |
Admin |
| GET |
/search |
Search point accounts by userId |
Admin |
Health (/api/v1/health) - [AllowAnonymous]
| Method |
Path |
Description |
| GET |
/live |
Liveness probe |
| GET |
/ready |
Readiness probe |
Also mapped via MapHealthChecks: /health, /health/live, /health/ready
Commands
Wallet Commands
| Command |
Input |
Logic |
Validator |
CreateWalletCommand |
UserId, Currency="VND" |
Check duplicate by userId, parse CurrencyType, create Wallet + initial WalletItem(balance=0), raise WalletCreatedDomainEvent |
UserId NotEmpty; Currency NotEmpty, MaxLength(10) |
DepositCommand |
UserId, Amount, Description, ReferenceId? |
Get wallet by userId, deposit with default CurrencyType, add WalletTransaction(Credit), raise WalletBalanceChangedDomainEvent |
UserId NotEmpty; Amount > 0, <= 1B; Description NotEmpty, MaxLength(500); ReferenceId MaxLength(100) |
WithdrawCommand |
UserId, Amount, Description, ReferenceId? |
Get wallet by userId, validate sufficient balance, withdraw with default CurrencyType, add WalletTransaction(Debit), raise WalletBalanceChangedDomainEvent |
Same as Deposit |
ExchangeCommand |
UserId, FromAmount, FromCurrencyTypeId, ToCurrencyTypeId, CustomRate? |
Get wallet, parse CurrencyTypes from IDs, calculate rate (custom or from BaseExchangeRate), atomic Withdraw + Deposit within aggregate, raise WalletExchangedDomainEvent |
UserId NotEmpty; FromAmount > 0; From/ToCurrencyTypeId > 0 and not equal; CustomRate > 0 (if provided) |
Escrow Commands
| Command |
Input |
Logic |
Validator |
CreateHoldCommand |
UserId, Amount, CurrencyCode, ReferenceType, ReferenceId, Description, ExpiresAt? |
Get wallet by userId, parse CurrencyType, check balance, subtract from available, create HoldItem, add WalletTransaction(HoldCreated), raise EscrowHeldDomainEvent |
None |
ExecuteHoldCommand |
WalletId, HoldId, Amount, ExecutionRef? |
Get wallet by ID, find hold, validate active+not expired, execute portion, add WalletTransaction(HoldExecuted), raise EscrowExecutedDomainEvent |
None |
ReleaseHoldCommand |
WalletId, HoldId, Amount? (null=all) |
Get wallet by ID, find hold, release portion back to available balance, add WalletTransaction(HoldReleased), raise EscrowReleasedDomainEvent |
None |
CancelHoldCommand |
WalletId, HoldId |
Get wallet by ID, release all remaining hold amount back to wallet |
None |
Points Commands
| Command |
Input |
Logic |
Validator |
CreatePointAccountCommand |
UserId |
Check duplicate, create PointAccount with 0 points |
UserId NotEmpty |
EarnPointsCommand |
UserId, Points, Source, Description, ExpiryMonths?=12 |
Get account by userId, earn points, add PointTransaction(Earn), raise PointsEarnedDomainEvent |
UserId NotEmpty; Points > 0; Source NotEmpty, MaxLength(100); Description NotEmpty, MaxLength(500); ExpiryMonths > 0, <= 120 |
SpendPointsCommand |
UserId, Points, Source, Description |
Get account by userId, validate sufficient points, spend, add PointTransaction(Spend), raise PointsSpentDomainEvent |
UserId NotEmpty; Points > 0; Source NotEmpty, MaxLength(100); Description NotEmpty, MaxLength(500) |
Payment Commands
| Command |
Input |
Logic |
Validator |
CreatePaymentCommand |
OrderId, Amount, Currency, GatewayName, ReturnUrl, IpAddress |
Find gateway by name, create Payment entity, call gateway.CreatePaymentAsync, mark Processing or Failed, raise PaymentCreatedDomainEvent |
OrderId NotEmpty; Amount > 0; Currency NotEmpty, MaxLength(10); GatewayName NotEmpty, MaxLength(50); ReturnUrl valid URL; IpAddress NotEmpty |
ProcessPaymentCallbackCommand |
GatewayName, Parameters (dict) |
Find gateway, validate HMAC-SHA512 hash, find payment by orderId (vnp_TxnRef), complete or fail based on vnp_ResponseCode, raise PaymentCompletedDomainEvent or PaymentFailedDomainEvent |
GatewayName NotEmpty; Parameters NotNull, Count > 0 |
Admin Commands
| Command |
Input |
Logic |
Validator |
AdminFreezeWalletCommand |
WalletId, Reason, AdminId |
Get wallet by ID, call Freeze() (status Active->Frozen) |
None |
AdminUnfreezeWalletCommand |
WalletId, Reason, AdminId |
Get wallet by ID, call Unfreeze() (status Frozen->Active) |
None |
AdminAdjustBalanceCommand |
WalletId, Amount, CurrencyTypeId, Reason, AdminId |
Get wallet, positive amount=Deposit, negative=Withdraw, with admin reason |
None |
AdminAdjustPointsCommand |
AccountId, Points, Reason, AdminId |
Get point account by ID, call AdjustPoints (positive=add, negative=subtract) |
None |
AdminGrantBonusCommand |
AccountId, Points, Reason, ExpiryMonths?, AdminId |
Get point account, call AddBonusPoints with expiry date |
None |
Queries
| Query |
Input |
Output |
Logic |
GetWalletQuery |
UserId |
WalletDto? (Id, UserId, Balance, Currency, Status, CreatedAt, UpdatedAt) |
Get wallet by userId, return default currency balance |
GetWalletTransactionsQuery |
UserId, Page=1, PageSize=20 |
WalletTransactionsDto (paginated list) |
Get wallet, then paginated transactions |
GetPointAccountQuery |
UserId |
PointAccountDto? (Id, UserId, TotalPoints, AvailablePoints, dates) |
Get point account by userId |
GetPointTransactionsQuery |
UserId, Page=1, PageSize=20 |
PointTransactionsDto (paginated list) |
Get account, then paginated transactions |
GetPaymentByOrderIdQuery |
OrderId |
PaymentDto? |
Find payment by orderId |
GetPaymentByTransactionIdQuery |
TransactionId (string) |
PaymentDto? |
Find payment by gateway transaction ID |
GetAllWalletsQuery |
Page, PageSize, Status?, Currency? |
AdminWalletsListDto (paginated) |
Admin: filter by status, include balances |
GetWalletByIdQuery |
WalletId |
AdminWalletDetailDto? |
Admin: include balances + transactions count |
SearchWalletsQuery |
UserId?, WalletId?, Status? |
List<AdminWalletDetailDto> (max 50) |
Admin: flexible search |
GetWalletStatisticsQuery |
(none) |
WalletStatisticsDto |
Admin: total/active/frozen/closed counts, balance by currency, today's transactions |
GetAllPointAccountsQuery |
Page, PageSize, MinPoints?, MaxPoints? |
AdminPointAccountsListDto (paginated) |
Admin: filter by point range |
GetPointAccountByIdQuery |
AccountId |
AdminPointAccountDetailDto? |
Admin: include transaction count |
SearchPointAccountsQuery |
UserId? |
List<AdminPointAccountDetailDto> (max 50) |
Admin: search by userId |
GetPointsStatisticsQuery |
(none) |
PointsStatisticsDto |
Admin: totals for issued/available/spent/expired points, today's activity |
Domain Model
Aggregate: Wallet (Root)
Entity: Wallet (extends Entity, IAggregateRoot)
| Property |
Type |
Description |
| Id |
Guid |
Wallet ID (generated) |
| UserId |
Guid |
User ID from IAM Service |
| DefaultCurrencyTypeId |
int |
Default currency (1=VND, 2=USD, 3=PPoint) |
| StatusId |
int |
Wallet status (1=Active, 2=Frozen, 3=Closed) |
| CreatedAt |
DateTime |
Creation timestamp |
| UpdatedAt |
DateTime |
Last update timestamp |
| Balances |
IReadOnlyCollection<WalletItem> |
Multi-currency balances (one per currency) |
| Transactions |
IReadOnlyCollection<WalletTransaction> |
Transaction history |
| Holds |
IReadOnlyCollection<HoldItem> |
Active escrow holds |
Behavior Methods:
Deposit(amount, currencyType, description, referenceId?) - Add funds
Withdraw(amount, currencyType, description, referenceId?) - Remove funds (validates balance)
Exchange(fromAmount, fromCurrency, toCurrency, customRate?) - Atomic currency exchange
TransferOut(amount, toWalletId, description) - Send to another wallet
TransferIn(amount, fromWalletId, description) - Receive from another wallet
Hold(amount, currencyType, referenceType, referenceId, description, expiresAt?) - Create escrow hold
ExecuteHold(holdId, amount, executionRef?) - Commit hold portion
ReleaseHold(holdId, amount?) - Return hold back to wallet
CancelHold(holdId) - Release all remaining
Freeze() - Status -> Frozen (blocks transactions)
Unfreeze() - Status -> Active
Close() - Status -> Closed (requires zero balances)
Entity: WalletItem (child of Wallet)
| Property |
Type |
Description |
| Id |
Guid |
Item ID |
| WalletId |
Guid |
Parent wallet FK |
| CurrencyTypeId |
int |
Currency type |
| Balance |
decimal |
Current balance |
| CreatedAt / UpdatedAt |
DateTime |
Timestamps |
Entity: WalletTransaction
| Property |
Type |
Description |
| Id |
Guid |
Transaction ID |
| WalletId |
Guid |
Parent wallet FK |
| Amount |
Money (value object) |
Amount + Currency |
| TypeId |
int |
TransactionType enum ID |
| ReferenceId |
string? |
External reference |
| Description |
string |
Transaction description |
| BalanceAfter |
decimal |
Balance after this transaction |
| CreatedAt |
DateTime |
Timestamp |
Entity: HoldItem (child of Wallet)
| Property |
Type |
Description |
| Id |
Guid |
Hold ID |
| WalletId |
Guid |
Parent wallet FK |
| OriginalAmount |
decimal |
Initial held amount |
| RemainingAmount |
decimal |
Currently held |
| ExecutedAmount |
decimal |
Total committed |
| ReleasedAmount |
decimal |
Total returned |
| CurrencyTypeId |
int |
Currency type |
| ReferenceType |
string |
e.g., "CAMPAIGN", "ORDER" |
| ReferenceId |
Guid |
External reference ID |
| Description |
string |
Hold description |
| StatusId |
int |
HoldStatus enum ID |
| ExpiresAt |
DateTime? |
Optional expiration |
| CreatedAt / UpdatedAt |
DateTime |
Timestamps |
Value Object: Money
Amount (decimal) + Currency (string, uppercase)
- Methods: Add, Subtract, IsGreaterThanOrEqual
Enumerations:
| Enumeration |
Values |
| CurrencyType |
VND(1, rate=1), USD(2, rate=25000), PPoint(3, rate=1000) |
| TransactionType |
Credit(1), Debit(2), TransferOut(3), TransferIn(4), Refund(5), HoldCreated(6), HoldExecuted(7), HoldReleased(8) |
| WalletStatus |
Active(1), Frozen(2), Closed(3) |
| HoldStatus |
Active(1), PartiallyReleased(2), Released(3), Executed(4), Cancelled(5) |
Aggregate: PointAccount (Root)
Entity: PointAccount (extends Entity, IAggregateRoot)
| Property |
Type |
Description |
| Id |
Guid |
Account ID |
| UserId |
Guid |
User ID from IAM Service |
| TotalPoints |
long |
Lifetime earned points |
| AvailablePoints |
long |
Currently usable points |
| CreatedAt / UpdatedAt |
DateTime |
Timestamps |
| Transactions |
IReadOnlyCollection<PointTransaction> |
Transaction history |
Behavior Methods:
EarnPoints(points, source, description, expiresAt?) - Add points
SpendPoints(points, source, description) - Deduct points (validates balance)
ExpirePoints(points, description) - System expiration
AddBonusPoints(points, source, description, expiresAt?) - Special promotion bonus
AdjustPoints(adjustment, reason) - Admin manual adjustment
Entity: PointTransaction
| Property |
Type |
Description |
| Id |
Guid |
Transaction ID |
| AccountId |
Guid |
Parent account FK |
| Points |
long |
Point amount |
| TypeId |
int |
PointTransactionType enum ID |
| Source |
string |
e.g., order ID, "ADMIN" |
| Description |
string |
Transaction description |
| ExpiresAt |
DateTime? |
Point expiration date |
| BalanceAfter |
long |
Balance after transaction |
| CreatedAt |
DateTime |
Timestamp |
Enumeration: PointTransactionType
- Earn(1), Spend(2), Expire(3), Adjust(4), Bonus(5)
Aggregate: Payment (Root)
Entity: Payment (extends Entity, IAggregateRoot) - uses private fields
| Property |
Type |
Description |
| Id |
Guid |
Payment ID |
| OrderId |
Guid |
Associated order |
| Amount |
decimal |
Payment amount |
| Currency |
string |
Currency code |
| GatewayName |
string |
e.g., "VNPAY" |
| TransactionId |
string? |
Gateway transaction ID |
| PaymentUrl |
string? |
Customer redirect URL |
| StatusId |
int |
PaymentStatus enum ID |
| ErrorCode / ErrorMessage |
string? |
Failure details |
| CreatedAt |
DateTime |
Creation timestamp |
| CompletedAt |
DateTime? |
Completion timestamp |
Behavior Methods:
MarkAsProcessing(paymentUrl, transactionId?) - Pending -> Processing
Complete(transactionId) - Pending/Processing -> Completed
Fail(errorCode, errorMessage) - Pending/Processing -> Failed
Refund() - Completed -> Refunded
Enumeration: PaymentStatus
- Pending(1), Processing(2), Completed(3), Failed(4), Refunded(5)
Domain Events
| Event |
Properties |
Raised When |
WalletCreatedDomainEvent |
WalletId, UserId, OccurredAt |
Wallet constructor |
WalletBalanceChangedDomainEvent |
WalletId, UserId, TransactionType, Amount, NewBalance |
Deposit, Withdraw, Transfer |
WalletExchangedDomainEvent |
WalletId, UserId, FromCurrencyId, ToCurrencyId, FromAmount, ToAmount, Rate |
Currency exchange |
EscrowHeldDomainEvent |
WalletId, HoldId, UserId, ReferenceType, ReferenceId, Amount, CurrencyTypeId |
Hold created |
EscrowExecutedDomainEvent |
WalletId, HoldId, UserId, Amount, RemainingAmount, ExecutionRef |
Hold portion executed |
EscrowReleasedDomainEvent |
WalletId, HoldId, UserId, Amount, RemainingAmount |
Hold portion released |
PointsEarnedDomainEvent |
AccountId, UserId, Points, NewBalance, Source |
Points earned/bonus added |
PointsSpentDomainEvent |
AccountId, UserId, Points, NewBalance, Source |
Points spent |
PaymentCreatedDomainEvent |
PaymentId, OrderId, Amount, Currency, GatewayName |
Payment created |
PaymentCompletedDomainEvent |
PaymentId, OrderId, TransactionId, Amount, Currency |
Payment completed |
PaymentFailedDomainEvent |
PaymentId, OrderId, ErrorCode, ErrorMessage |
Payment failed |
Domain Exceptions
| Exception |
Base |
Description |
WalletDomainException |
Exception |
Base wallet domain error |
InsufficientBalanceException |
WalletDomainException |
Balance < requested amount |
PointsDomainException |
Exception |
Base points domain error |
InsufficientPointsException |
PointsDomainException |
Points < requested amount |
Database Schema
Table: wallets
| Column |
Type |
Nullable |
Description |
| id |
uuid |
NO |
PK, ValueGeneratedNever |
| user_id |
uuid |
NO |
User ID from IAM |
| default_currency_type_id |
int |
NO |
Default currency (default=1/VND) |
| status_id |
int |
NO |
WalletStatus enum ID |
| created_at |
timestamp |
NO |
Creation time |
| updated_at |
timestamp |
NO |
Last update |
Indexes: ix_wallets_user_id (unique)
Query Filter: Tenant isolation by user_id (bypassed for admin/service calls)
Table: wallet_items
| Column |
Type |
Nullable |
Description |
| id |
uuid |
NO |
PK |
| wallet_id |
uuid |
NO |
FK -> wallets |
| currency_type_id |
int |
NO |
CurrencyType enum ID |
| balance |
decimal(18,2) |
NO |
Current balance |
| created_at |
timestamp |
NO |
|
| updated_at |
timestamp |
NO |
|
Indexes: ix_wallet_items_wallet_currency (unique: wallet_id + currency_type_id), ix_wallet_items_wallet_id
Cascade: Delete with parent wallet
Table: wallet_transactions
| Column |
Type |
Nullable |
Description |
| id |
uuid |
NO |
PK |
| wallet_id |
uuid |
NO |
FK -> wallets |
| amount |
decimal(18,2) |
NO |
Transaction amount (owned: Money.Amount) |
| currency |
varchar(3) |
NO |
Currency code (owned: Money.Currency) |
| type_id |
int |
NO |
TransactionType enum ID |
| reference_id |
varchar(100) |
YES |
External reference |
| description |
varchar(500) |
NO |
Description |
| balance_after |
decimal(18,2) |
NO |
Balance after transaction |
| created_at |
timestamp |
NO |
|
Indexes: ix_wallet_transactions_wallet_id, ix_wallet_transactions_created_at
Cascade: Delete with parent wallet
Table: wallet_holds
| Column |
Type |
Nullable |
Description |
| id |
uuid |
NO |
PK |
| wallet_id |
uuid |
NO |
FK -> wallets |
| original_amount |
decimal(18,2) |
NO |
Initially held |
| remaining_amount |
decimal(18,2) |
NO |
Currently held |
| executed_amount |
decimal(18,2) |
NO |
Total committed |
| released_amount |
decimal(18,2) |
NO |
Total returned |
| currency_type_id |
int |
NO |
FK (restrict delete) |
| reference_type |
varchar(50) |
NO |
e.g., "CAMPAIGN" |
| reference_id |
uuid |
NO |
External ref ID |
| description |
varchar(500) |
NO |
|
| status_id |
int |
NO |
FK (restrict delete) |
| created_at |
timestamp |
NO |
|
| updated_at |
timestamp |
NO |
|
| expires_at |
timestamp |
YES |
Optional expiry |
Indexes: ix_wallet_holds_wallet_id, ix_wallet_holds_reference (reference_type + reference_id), ix_wallet_holds_status_id
Cascade: Delete with parent wallet
Table: point_accounts
| Column |
Type |
Nullable |
Description |
| id |
uuid |
NO |
PK |
| user_id |
uuid |
NO |
User ID from IAM |
| total_points |
bigint |
NO |
Lifetime earned |
| available_points |
bigint |
NO |
Currently usable |
| created_at |
timestamp |
NO |
|
| updated_at |
timestamp |
NO |
|
Indexes: ix_point_accounts_user_id (unique)
Table: point_transactions
| Column |
Type |
Nullable |
Description |
| id |
uuid |
NO |
PK |
| account_id |
uuid |
NO |
FK -> point_accounts |
| points |
bigint |
NO |
Point amount |
| type_id |
int |
NO |
PointTransactionType enum ID |
| source |
varchar(100) |
NO |
Source identifier |
| description |
varchar(500) |
NO |
|
| expires_at |
timestamp |
YES |
Point expiration |
| balance_after |
bigint |
NO |
Balance after transaction |
| created_at |
timestamp |
NO |
|
Indexes: ix_point_transactions_account_id, ix_point_transactions_created_at, ix_point_transactions_expires_at
Cascade: Delete with parent account
Table: payments
| Column |
Type |
Nullable |
Description |
| id |
uuid |
NO |
PK |
| order_id |
uuid |
NO |
Associated order |
| amount |
decimal(18,2) |
NO |
Payment amount |
| currency |
varchar(10) |
NO |
Currency code |
| gateway_name |
varchar(50) |
NO |
e.g., "VNPAY" |
| transaction_id |
varchar(255) |
YES |
Gateway transaction ID |
| payment_url |
varchar(2048) |
YES |
Customer redirect URL |
| status_id |
int |
NO |
PaymentStatus enum ID |
| error_code |
varchar(50) |
YES |
|
| error_message |
varchar(500) |
YES |
|
| created_at |
timestamp |
NO |
|
| completed_at |
timestamp |
YES |
|
Indexes: ix_payments_order_id, ix_payments_transaction_id, ix_payments_status_id, ix_payments_gateway_name
Migrations
20260117141548_AddWalletHolds - Initial schema with holds
20260306175521_PhaseTwo - Phase 2 additions (payments, multi-currency, etc.)
Dependencies
NuGet Packages
API Layer:
- MediatR 12.4.1 (CQRS)
- FluentValidation 11.11.0 + DI Extensions
- Microsoft.AspNetCore.Authentication.JwtBearer 10.0.1
- Asp.Versioning.Mvc 8.1.0
- AspNetCore.HealthChecks.NpgSql 8.0.2, Redis 8.0.1
- Hellang.Middleware.ProblemDetails 6.5.1
- Serilog.AspNetCore 8.0.3, Console 6.0.0, Seq 8.0.0
- Swashbuckle.AspNetCore 7.2.0 + Annotations
- EF Core Design 10.0.0
Domain Layer:
- MediatR.Contracts 2.0.1 (domain events only)
Infrastructure Layer:
- Microsoft.EntityFrameworkCore 10.0.0
- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0
- MediatR 12.4.1 (domain event dispatch)
- Dapper 2.1.35 (read-optimized queries)
- Polly 8.5.0, Microsoft.Extensions.Http.Polly 9.0.0
- StackExchange.Redis 2.8.16
Service Dependencies
- IAM Service: JWT token validation, user ID claims
- Order Service: OrderId referenced in Payments
Configuration
appsettings.json
appsettings.Development.json
Middleware Pipeline Order
- Serilog request logging
- ProblemDetails (RFC 7807)
- Swagger (Development only)
- CORS (AllowAny)
- Routing
- Authentication (JWT Bearer)
- Authorization
- TenantMiddleware (RLS - sets PostgreSQL session variables)
- Health checks + Controllers
MediatR Pipeline Behaviors
LoggingBehavior<,> - Request/response logging
ValidatorBehavior<,> - FluentValidation in pipeline
TransactionBehavior<,> - Auto transaction wrapping for commands
Multi-Tenancy
- Wallet isolation: User-level via EF Core global query filter on
Wallet.UserId
- Bypass: Admin users and service-to-service calls (via
X-Service-Call: internal header)
- PostgreSQL RLS: TenantMiddleware sets
SET LOCAL app.current_shop_id and app.current_merchant_id session variables
- Adapter pattern:
ITenantProvider (API layer) -> WalletTenantProviderAdapter -> IWalletTenantProvider (Infrastructure layer)
Tests
Unit Tests (tests/WalletService.UnitTests/)
Domain/WalletTests.cs - Wallet entity behavior tests
Domain/PointAccountTests.cs - PointAccount entity behavior tests
Domain/HoldItemTests.cs - HoldItem entity behavior tests
Application/Commands/CreatePaymentCommandHandlerTests.cs - Payment creation handler tests
Application/Commands/ProcessPaymentCallbackCommandHandlerTests.cs - Callback processing tests
Application/EscrowCommandHandlersTests.cs - Escrow command handler tests
Functional Tests (tests/WalletService.FunctionalTests/)
CustomWebApplicationFactory.cs - Test server setup
Controllers/HealthControllerTests.cs - Health endpoint tests
Exchange Rates (Hardcoded in CurrencyType)
| From |
To |
Rate |
Example |
| VND |
USD |
0.00004 |
25,000 VND = 1 USD |
| VND |
PPoint |
0.001 |
1,000 VND = 1 PPoint |
| USD |
VND |
25,000 |
1 USD = 25,000 VND |
| USD |
PPoint |
25 |
1 USD = 25 PPoint |
| PPoint |
VND |
1,000 |
1 PPoint = 1,000 VND |
| PPoint |
USD |
0.04 |
25 PPoint = 1 USD |
Formula: rate = fromCurrency.BaseExchangeRate / toCurrency.BaseExchangeRate