Each SERVICE_DOCS.md documents: Overview, API Endpoints, Commands, Queries, Domain Model, Database Schema, Integration Events, Dependencies, Configuration. Generated by 23 parallel audit agents reading actual source code. Key corrections from audit: - inventory-service: 12 commands/6 queries (was listed as scaffold) - promotion-service: 12 commands/10 queries (was listed as 0) - mission-service: 4 commands/7 queries (was listed as 0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
601 lines
27 KiB
Markdown
601 lines
27 KiB
Markdown
# 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\<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
|
|
|
|
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`
|