# 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`
