# 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 1. **Wallet Management** - Create, deposit, withdraw, transfer, freeze/unfreeze/close wallets with multi-currency support 2. **Currency Exchange** - Atomic in-wallet exchange between VND, USD, and PPoint with configurable rates 3. **Loyalty Points** - Separate point account system with earn, spend, expire, bonus, and admin adjustment 4. **Escrow/Holds** - Lock funds for campaigns/orders with partial execute/release/cancel 5. **Payment Gateway** - VNPay integration for external payments with IPN callback validation 6. **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\ (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\ (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\ | Multi-currency balances (one per currency) | | Transactions | IReadOnlyCollection\ | Transaction history | | Holds | IReadOnlyCollection\ | 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\ | 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 1. `20260117141548_AddWalletHolds` - Initial schema with holds 2. `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 ```json { "ConnectionStrings": { "DefaultConnection": "Host=..neon.tech;Database=wallet_service;..." }, "Redis": { "ConnectionString": "localhost:6379" }, "Jwt": { "Secret": "...", "Issuer": "goodgo-platform", "Audience": "goodgo-services", "AccessTokenExpiryMinutes": 15, "RefreshTokenExpiryDays": 7 } } ``` ### appsettings.Development.json ```json { "VnPay": { "TmnCode": "GOODGO01", "HashSecret": "GOODGOSECRETKEYFORSANDBOX2026", "PaymentUrl": "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html", "ReturnUrl": "http://localhost:3001/payment/return", "ApiUrl": "https://sandbox.vnpayment.vn/merchant_webapi/api/transaction" } } ``` ### Middleware Pipeline Order 1. Serilog request logging 2. ProblemDetails (RFC 7807) 3. Swagger (Development only) 4. CORS (AllowAny) 5. Routing 6. Authentication (JWT Bearer) 7. Authorization 8. TenantMiddleware (RLS - sets PostgreSQL session variables) 9. Health checks + Controllers ### MediatR Pipeline Behaviors 1. `LoggingBehavior<,>` - Request/response logging 2. `ValidatorBehavior<,>` - FluentValidation in pipeline 3. `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`