Files
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

638 lines
27 KiB
Markdown

# PromotionService - Service Documentation
Generated from actual source code audit on 2026-03-13.
---
## 1. Overview
**Purpose**: Manages marketing campaigns with vouchers for merchants. Supports campaign lifecycle (draft, active, paused, completed, cancelled), voucher generation/claiming/redemption, and escrow integration with the Wallet Service.
**Port**: 5008 (local development via launchSettings.json), 8080 (Docker container)
**Database**: PostgreSQL - `promotion_service` on Neon (cloud)
- Connection string configured via `ConnectionStrings:DefaultConnection` or `DATABASE_URL` env var
- Cloud host: `ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech`
**Framework**: .NET 10.0 (C# 14), Clean Architecture + CQRS (MediatR)
**Solution file**: `PromotionService.slnx`
**Migration**: Single migration `20260117144846_InitialCreate` - auto-applied on startup via `dbContext.Database.MigrateAsync()`
---
## 2. API Endpoints
### 2.1 CampaignsController (`api/v1/campaigns`)
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| POST | `/api/v1/campaigns` | Yes | Create a new campaign |
| GET | `/api/v1/campaigns/{id}` | No | Get campaign by ID |
| GET | `/api/v1/campaigns?merchantId=&activeOnly=` | No | Get campaigns list (optional filters) |
| POST | `/api/v1/campaigns/{id}/activate` | Yes | Activate a campaign |
| POST | `/api/v1/campaigns/{id}/pause` | Yes | Pause a campaign |
| POST | `/api/v1/campaigns/{id}/cancel` | Yes | Cancel a campaign (releases escrow) |
### 2.2 VouchersController (`api/v1/vouchers`)
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| POST | `/api/v1/vouchers/claim` | Yes | Claim a free voucher |
| GET | `/api/v1/vouchers/validate/{code}?userId=` | Yes | Validate a voucher code for a user |
| POST | `/api/v1/vouchers/redeem` | Yes | Redeem a voucher against an order |
| GET | `/api/v1/vouchers/user/{userId}` | Yes | Get all vouchers owned by a user |
### 2.3 AdminCampaignsController (`api/v1/admin/campaigns`)
All endpoints require `[Authorize]`.
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/v1/admin/campaigns?pageNumber=&pageSize=&merchantId=&status=&searchTerm=` | Yes | Paginated campaign list with filters |
| GET | `/api/v1/admin/campaigns/{id}/statistics` | Yes | Campaign statistics (voucher counts, redemption totals, utilization rate) |
| GET | `/api/v1/admin/campaigns/{id}/vouchers?pageNumber=&pageSize=&status=` | Yes | Paginated vouchers for a campaign |
| PUT | `/api/v1/admin/campaigns/{id}` | Yes | Update campaign details (name, description, dates, maxPerUser) |
| POST | `/api/v1/admin/campaigns/{id}/complete` | Yes | Force complete a campaign |
| DELETE | `/api/v1/admin/campaigns/{id}` | Yes | Soft delete (cancels) a campaign |
### 2.4 AdminVouchersController (`api/v1/admin/vouchers`)
All endpoints require `[Authorize]`.
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/v1/admin/vouchers?pageNumber=&pageSize=&campaignId=&userId=&status=&codeSearch=` | Yes | Paginated voucher list with filters |
| GET | `/api/v1/admin/vouchers/search?q=` | Yes | Search vouchers by code (NOTE: handler not implemented) |
| GET | `/api/v1/admin/vouchers/by-user/{userId}?pageNumber=&pageSize=` | Yes | Vouchers by user |
| POST | `/api/v1/admin/vouchers/{id}/revoke` | Yes | Revoke a voucher (marks as expired) |
| POST | `/api/v1/admin/vouchers/{id}/extend` | Yes | Extend voucher expiry by N days |
### 2.5 AdminRedemptionsController (`api/v1/admin/redemptions`)
All endpoints require `[Authorize]`.
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/v1/admin/redemptions?pageNumber=&pageSize=&campaignId=&voucherId=&userId=&dateFrom=&dateTo=` | Yes | Paginated redemption list with filters |
| GET | `/api/v1/admin/redemptions/by-campaign/{campaignId}?pageNumber=&pageSize=` | Yes | Redemptions by campaign |
| GET | `/api/v1/admin/redemptions/by-voucher/{voucherId}?pageNumber=&pageSize=` | Yes | Redemptions by voucher |
| GET | `/api/v1/admin/redemptions/statistics?campaignId=` | Yes | Redemption statistics (totals, averages, today/week/month counts) |
### 2.6 Health Check Endpoints
| Route | Description |
|-------|-------------|
| `/health` | Full health check (PostgreSQL + Wallet Service) |
| `/health/live` | Liveness probe (always passes if app is running) |
| `/health/ready` | Readiness probe (checks all dependencies) |
---
## 3. Commands
### 3.1 Campaign Commands
#### CreateCampaignCommand
- **Parameters**: MerchantId (Guid), MerchantWalletId (Guid), Name (string), Description (string?), BackingAssetType (string: "Point"/"Currency"), BackingAssetCode (string), FaceValue (decimal), AcquisitionType (string: "Free"/"ExchangePoints"/"Purchase"), AcquisitionPrice (decimal), TotalVouchers (int), StartDate (DateTime), EndDate (DateTime), VoucherValidityDays (int, default 30), MaxPerUser (int, default 1)
- **Returns**: CampaignDto
- **Handler**: CreateCampaignCommandHandler
- **Logic**: Parses enums, creates Campaign aggregate, optionally creates escrow hold via Wallet Service, generates voucher codes, persists. Raises `CampaignCreatedDomainEvent`.
#### ActivateCampaignCommand
- **Parameters**: CampaignId (Guid)
- **Returns**: bool
- **Handler**: ActivateCampaignCommandHandler
- **Logic**: Finds campaign, calls `campaign.Activate()`. Requires escrow to be set. Raises `CampaignActivatedDomainEvent`.
#### PauseCampaignCommand
- **Parameters**: CampaignId (Guid)
- **Returns**: bool
- **Handler**: PauseCampaignCommandHandler
- **Logic**: Finds campaign, calls `campaign.Pause()`. Only works on Active campaigns.
#### CancelCampaignCommand
- **Parameters**: CampaignId (Guid)
- **Returns**: bool
- **Handler**: CancelCampaignCommandHandler
- **Logic**: Releases escrow hold via Wallet Service if exists, then calls `campaign.Cancel()`. Raises `CampaignCancelledDomainEvent`.
#### UpdateCampaignCommand (Admin)
- **Parameters**: CampaignId (Guid), Name (string?), Description (string?), StartDate (DateTime?), EndDate (DateTime?), MaxPerUser (int?)
- **Returns**: bool
- **Handler**: UpdateCampaignCommandHandler
#### CompleteCampaignCommand (Admin)
- **Parameters**: CampaignId (Guid)
- **Returns**: bool
- **Handler**: CompleteCampaignCommandHandler
- **Logic**: Force completes campaign. Only from Active or Paused state.
#### DeleteCampaignCommand (Admin)
- **Parameters**: CampaignId (Guid)
- **Returns**: bool
- **Handler**: DeleteCampaignCommandHandler
- **Logic**: Soft delete implemented as Cancel().
### 3.2 Voucher Commands
#### ClaimVoucherCommand
- **Parameters**: CampaignId (Guid), UserId (Guid)
- **Returns**: VoucherDto
- **Handler**: ClaimVoucherCommandHandler
- **Logic**: Verifies campaign is Free acquisition type, calls `campaign.IssueVoucher()`, persists. Raises `VoucherClaimedDomainEvent`.
#### ExchangeVoucherCommand
- **Parameters**: CampaignId (Guid), UserId (Guid), UserWalletId (Guid)
- **Returns**: VoucherDto
- **Handler**: NOT IMPLEMENTED (command defined, no handler)
#### PurchaseVoucherCommand
- **Parameters**: CampaignId (Guid), UserId (Guid), UserWalletId (Guid)
- **Returns**: VoucherDto
- **Handler**: NOT IMPLEMENTED (command defined, no handler)
#### RedeemVoucherCommand
- **Parameters**: VoucherCode (string), UserId (Guid), OrderId (Guid?), OrderAmount (decimal)
- **Returns**: RedemptionDto
- **Handler**: RedeemVoucherCommandHandler
- **Logic**: Finds voucher by code, verifies ownership and validity, calculates amounts (min of order amount and voucher value), executes escrow via Wallet Service, creates Redemption record. Surplus refunded to merchant.
#### RevokeVoucherCommand (Admin)
- **Parameters**: VoucherId (Guid), Reason (string)
- **Returns**: bool
- **Handler**: RevokeVoucherCommandHandler
- **Logic**: Marks voucher as Expired (revoke = expire).
#### ExtendVoucherExpiryCommand (Admin)
- **Parameters**: VoucherId (Guid), AdditionalDays (int)
- **Returns**: bool
- **Handler**: ExtendVoucherExpiryCommandHandler
---
## 4. Queries
### 4.1 Public Queries
#### GetCampaignQuery
- **Parameters**: CampaignId (Guid)
- **Returns**: CampaignDto? (null if not found)
- **Handler**: GetCampaignQueryHandler
#### GetCampaignsQuery
- **Parameters**: MerchantId (Guid?), ActiveOnly (bool)
- **Returns**: IEnumerable\<CampaignSummaryDto\>
- **Handler**: GetCampaignsQueryHandler
- **Logic**: If ActiveOnly=true, returns active campaigns within date range. If MerchantId provided, returns by merchant. Otherwise defaults to active campaigns.
#### GetCampaignStatisticsQuery
- **Parameters**: CampaignId (Guid)
- **Returns**: CampaignStatisticsDto?
- **Handler**: NOT IMPLEMENTED (query defined, no handler)
#### ValidateVoucherQuery
- **Parameters**: VoucherCode (string), UserId (Guid)
- **Returns**: VoucherValidationDto
- **Handler**: ValidateVoucherQueryHandler
- **Logic**: Checks voucher exists, ownership matches, and is valid for redemption. Returns validation result with remaining value and campaign name.
#### GetUserVouchersQuery
- **Parameters**: UserId (Guid)
- **Returns**: IEnumerable\<VoucherSummaryDto\>
- **Handler**: GetUserVouchersQueryHandler
### 4.2 Admin Queries
#### GetAllCampaignsQuery
- **Parameters**: PageNumber (int), PageSize (int), MerchantId (Guid?), Status (string?), SearchTerm (string?)
- **Returns**: PaginatedResponse\<AdminCampaignListDto\>
- **Handler**: GetAllCampaignsQueryHandler (uses PromotionServiceContext directly)
#### GetAdminCampaignStatisticsQuery
- **Parameters**: CampaignId (Guid)
- **Returns**: AdminCampaignStatisticsDto?
- **Handler**: GetAdminCampaignStatisticsQueryHandler
- **Logic**: Loads campaign with vouchers, loads redemptions, calculates claimed/redeemed/expired counts, total redeemed/refunded values, utilization rate.
#### GetCampaignVouchersQuery
- **Parameters**: CampaignId (Guid), PageNumber (int), PageSize (int), Status (string?)
- **Returns**: PaginatedResponse\<AdminVoucherListDto\>
- **Handler**: NOT IMPLEMENTED (query defined, no handler -- uses GetAllVouchersQueryHandler with CampaignId filter via controller)
#### GetAllVouchersQuery
- **Parameters**: PageNumber (int), PageSize (int), CampaignId (Guid?), UserId (Guid?), Status (string?), CodeSearch (string?)
- **Returns**: PaginatedResponse\<AdminVoucherListDto\>
- **Handler**: GetAllVouchersQueryHandler (uses join with Campaigns for CampaignName)
#### SearchVouchersQuery
- **Parameters**: SearchTerm (string)
- **Returns**: IEnumerable\<AdminVoucherListDto\>
- **Handler**: NOT IMPLEMENTED (query defined, no handler)
#### GetAllRedemptionsQuery
- **Parameters**: PageNumber (int), PageSize (int), CampaignId (Guid?), VoucherId (Guid?), UserId (Guid?), DateFrom (DateTime?), DateTo (DateTime?)
- **Returns**: PaginatedResponse\<AdminRedemptionListDto\>
- **Handler**: GetAllRedemptionsQueryHandler (uses double join with Vouchers and Campaigns)
#### GetRedemptionStatisticsQuery
- **Parameters**: CampaignId (Guid?)
- **Returns**: AdminRedemptionStatisticsDto
- **Handler**: GetRedemptionStatisticsQueryHandler
- **Logic**: Calculates total count, total amount used/refunded, average amount, and counts for today/this week/this month.
---
## 5. Domain Model
### 5.1 Campaign Aggregate
#### Campaign (Aggregate Root)
- **File**: `src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Campaign.cs`
- **Extends**: Entity, IAggregateRoot
- **Properties**:
- MerchantId (Guid) - owning merchant
- Name (string, max 255) - campaign name
- Description (string?, max 1000)
- BackingAssetTypeId (int) / BackingAssetType (AssetType) - Currency(1) or Point(2)
- BackingAssetCode (string, max 10) - e.g., "VND", "USD", "PPoint"
- FaceValue (decimal, 18,2) - value of each voucher
- AcquisitionTypeId (int) / AcquisitionType (AcquisitionType) - Free(1), ExchangePoints(2), Purchase(3)
- AcquisitionPrice (decimal, 18,2) - cost to acquire (0 for free)
- EscrowHoldId (Guid?) - Wallet Service hold reference
- EscrowWalletId (Guid?) - Wallet providing escrow
- EscrowAmount (decimal, 18,2) - total held (FaceValue * TotalVouchers)
- TotalVouchers (int) - total planned vouchers
- IssuedVouchers (int) - count of claimed vouchers
- MaxPerUser (int) - max vouchers per user (0 = unlimited)
- StartDate (DateTime) - campaign start
- EndDate (DateTime) - campaign end
- VoucherValidityDays (int) - days voucher is valid after claiming
- StatusId (int) / Status (CampaignStatus)
- CreatedAt (DateTime)
- UpdatedAt (DateTime)
- Vouchers (IReadOnlyCollection\<Voucher\>) - child vouchers
- **Behavior Methods**:
- `SetEscrowHold(walletId, holdId)` - set escrow references (Draft only)
- `Activate()` - Draft/Paused -> Active (requires escrow)
- `Pause()` - Active -> Paused
- `Cancel()` - any except Completed/Cancelled -> Cancelled
- `Complete()` - Active/Paused -> Completed
- `Update(name?, description?, startDate?, endDate?, maxPerUser?)` - update fields
- `GenerateVouchers(count)` - create voucher codes (Draft only, format: "V" + 6 alphanumeric)
- `IssueVoucher(userId)` - claim an available voucher for a user (Active only, enforces MaxPerUser)
- `GetVoucherByCode(code)` - find voucher by code
- `GetUserVouchers(userId)` - get user's vouchers
- Computed: `AvailableVoucherCount`, `ClaimedVoucherCount`, `RedeemedVoucherCount`, `TotalRedeemedValue`
- **Validation (constructor)**:
- Name cannot be empty
- FaceValue must be > 0
- TotalVouchers must be > 0
- EndDate must be after StartDate
- Non-free campaigns require AcquisitionPrice > 0
#### Voucher (Entity, child of Campaign)
- **File**: `src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Voucher.cs`
- **Extends**: Entity
- **Properties**:
- CampaignId (Guid) - parent campaign FK
- Code (string, max 20, unique) - voucher code
- OwnerId (Guid?) - user who claimed (null if unclaimed)
- FaceValue (decimal, 18,2) - original value
- RemainingValue (decimal, 18,2) - value left after partial redemptions
- StatusId (int) / Status (VoucherStatus)
- ClaimedAt (DateTime?) - when claimed
- ExpiresAt (DateTime?) - expiry date (set on claim)
- RedeemedAt (DateTime?) - last redemption time
- CreatedAt (DateTime)
- UpdatedAt (DateTime)
- **Behavior Methods**:
- `Claim(userId, validityDays)` - Available -> Claimed, sets owner and expiry
- `Redeem(amount)` - deducts from RemainingValue, returns actual amount deducted, status becomes PartiallyRedeemed or FullyRedeemed
- `Expire()` - marks as Expired
- `ExtendExpiry(additionalDays)` - extends ExpiresAt
- `IsValidForRedemption()` - checks status is Claimed/PartiallyRedeemed, has remaining value, not expired
- `IsExpired` (computed) - checks ExpiresAt vs now
### 5.2 Redemption Aggregate
#### Redemption (Aggregate Root)
- **File**: `src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/Redemption.cs`
- **Extends**: Entity, IAggregateRoot
- **Properties**:
- VoucherId (Guid) - redeemed voucher
- CampaignId (Guid) - campaign reference
- UserId (Guid) - user who redeemed
- OrderId (Guid?) - linked order (optional)
- AmountUsed (decimal, 18,2) - amount applied to order
- AmountRefunded (decimal, 18,2) - surplus returned to merchant
- ExecutionReference (string?, max 100) - Wallet Service execution ref
- RedeemedAt (DateTime) - redemption timestamp
- CreatedAt (DateTime)
### 5.3 Enumerations (Type-Safe Enum Pattern)
#### CampaignStatus
| Id | Name | Description |
|----|------|-------------|
| 1 | Draft | Campaign being prepared |
| 2 | Active | Campaign running, vouchers can be claimed |
| 3 | Paused | Campaign temporarily stopped |
| 4 | Completed | Campaign ended normally |
| 5 | Cancelled | Campaign cancelled, escrow released |
#### VoucherStatus
| Id | Name | Description |
|----|------|-------------|
| 1 | Available | Not claimed yet |
| 2 | Claimed | Owned by a user |
| 3 | PartiallyRedeemed | Some value used |
| 4 | FullyRedeemed | All value used |
| 5 | Expired | Expired without full use |
#### AssetType
| Id | Name | Description |
|----|------|-------------|
| 1 | Currency | VND, USD, etc. |
| 2 | Point | Loyalty points (PPoint) |
#### AcquisitionType
| Id | Name | Description |
|----|------|-------------|
| 1 | Free | Free giveaway |
| 2 | ExchangePoints | Exchange with loyalty points |
| 3 | Purchase | Purchase with currency |
### 5.4 Domain Exceptions
- `PromotionDomainException` - base for business rule violations
- `CampaignNotActiveException` - operating on non-active campaign
- `VoucherAlreadyClaimedException` - claiming an already-claimed voucher
- `VoucherExpiredException` - using an expired voucher
- `InsufficientVoucherValueException` - voucher has insufficient remaining value
- `DomainException` - generic base (from template)
- `SampleDomainException` - leftover from template (unused)
---
## 6. Database Schema
### 6.1 Table: `campaigns`
| Column | Type | Nullable | Constraints |
|--------|------|----------|-------------|
| id | uuid | NO | PK, ValueGeneratedNever |
| merchant_id | uuid | NO | |
| name | varchar(255) | NO | |
| description | varchar(1000) | YES | |
| backing_asset_type_id | integer | NO | |
| backing_asset_code | varchar(10) | NO | |
| face_value | numeric(18,2) | NO | |
| acquisition_type_id | integer | NO | |
| acquisition_price | numeric(18,2) | NO | |
| escrow_hold_id | uuid | YES | |
| escrow_wallet_id | uuid | YES | |
| escrow_amount | numeric(18,2) | NO | |
| total_vouchers | integer | NO | |
| issued_vouchers | integer | NO | |
| max_per_user | integer | NO | |
| start_date | timestamp with time zone | NO | |
| end_date | timestamp with time zone | NO | |
| voucher_validity_days | integer | NO | |
| status_id | integer | NO | |
| created_at | timestamp with time zone | NO | |
| updated_at | timestamp with time zone | NO | |
**Indexes**:
- `ix_campaigns_merchant_id` on (merchant_id)
- `ix_campaigns_status_id` on (status_id)
- `ix_campaigns_date_range` on (start_date, end_date)
### 6.2 Table: `vouchers`
| Column | Type | Nullable | Constraints |
|--------|------|----------|-------------|
| id | uuid | NO | PK, ValueGeneratedNever |
| campaign_id | uuid | NO | FK -> campaigns.id (CASCADE) |
| code | varchar(20) | NO | |
| owner_id | uuid | YES | |
| face_value | numeric(18,2) | NO | |
| remaining_value | numeric(18,2) | NO | |
| status_id | integer | NO | |
| claimed_at | timestamp with time zone | YES | |
| expires_at | timestamp with time zone | YES | |
| redeemed_at | timestamp with time zone | YES | |
| created_at | timestamp with time zone | NO | |
| updated_at | timestamp with time zone | NO | |
**Indexes**:
- `ix_vouchers_code` on (code) UNIQUE
- `ix_vouchers_owner_id` on (owner_id)
- `ix_vouchers_campaign_id` on (campaign_id)
- `ix_vouchers_status_id` on (status_id)
### 6.3 Table: `redemptions`
| Column | Type | Nullable | Constraints |
|--------|------|----------|-------------|
| id | uuid | NO | PK, ValueGeneratedNever |
| voucher_id | uuid | NO | |
| campaign_id | uuid | NO | |
| user_id | uuid | NO | |
| order_id | uuid | YES | |
| amount_used | numeric(18,2) | NO | |
| amount_refunded | numeric(18,2) | NO | |
| execution_reference | varchar(100) | YES | |
| redeemed_at | timestamp with time zone | NO | |
| created_at | timestamp with time zone | NO | |
**Indexes**:
- `ix_redemptions_voucher_id` on (voucher_id)
- `ix_redemptions_user_id` on (user_id)
- `ix_redemptions_campaign_id` on (campaign_id)
- `ix_redemptions_order_id` on (order_id)
**Note**: The `redemptions` table does NOT have a foreign key to `vouchers` or `campaigns` at the database level (no FK constraint defined in EF configuration).
---
## 7. Domain Events
All defined in `src/PromotionService.Domain/Events/PromotionDomainEvents.cs`. These are `INotification` records dispatched via MediatR before SaveChanges.
| Event | Parameters | Raised By |
|-------|-----------|-----------|
| CampaignCreatedDomainEvent | CampaignId, MerchantId, CampaignName | Campaign constructor |
| CampaignActivatedDomainEvent | CampaignId, MerchantId | Campaign.Activate() |
| CampaignCancelledDomainEvent | CampaignId, MerchantId, EscrowHoldId? | Campaign.Cancel() |
| VoucherClaimedDomainEvent | VoucherId, CampaignId, UserId, VoucherCode | Campaign.IssueVoucher() |
| VoucherRedeemedDomainEvent | VoucherId, CampaignId, UserId, AmountUsed, AmountRefunded | Defined but NOT raised by any code |
**Note**: No domain event handlers are implemented in the service. Events are dispatched via `PromotionServiceContext.DispatchDomainEventsAsync()` but no `INotificationHandler<T>` implementations exist. These events are available for future cross-service integration (e.g., via RabbitMQ integration events).
---
## 8. Dependencies
### 8.1 NuGet Packages
**API Layer** (`PromotionService.API`):
- MediatR 12.4.1
- FluentValidation 11.11.0
- FluentValidation.DependencyInjectionExtensions 11.11.0
- Microsoft.AspNetCore.Authentication.JwtBearer 10.0.0
- Microsoft.EntityFrameworkCore.Design 10.0.0
- Swashbuckle.AspNetCore 7.2.0
- Asp.Versioning.Mvc 8.1.0
- Asp.Versioning.Mvc.ApiExplorer 8.1.0
- AspNetCore.HealthChecks.NpgSql 8.0.2
- AspNetCore.HealthChecks.Redis 8.0.1
- Hellang.Middleware.ProblemDetails 6.5.1
- Serilog.AspNetCore 8.0.3
- Serilog.Sinks.Console 6.0.0
- Serilog.Sinks.Seq 8.0.0
**Domain Layer** (`PromotionService.Domain`):
- MediatR.Contracts 2.0.1 (for INotification only)
**Infrastructure Layer** (`PromotionService.Infrastructure`):
- Microsoft.EntityFrameworkCore 10.0.0
- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0
- Microsoft.EntityFrameworkCore.Tools 10.0.0
- MediatR 12.4.1
- Dapper 2.1.35
- Microsoft.Extensions.Http.Polly 9.0.0
- Polly 8.5.0
- StackExchange.Redis 2.8.16
**Build** (Directory.Build.props):
- Microsoft.SourceLink.GitHub 8.0.0
### 8.2 External Service Dependencies
| Service | Purpose | Base URL (Docker) | Base URL (Dev) |
|---------|---------|-------------------|----------------|
| Wallet Service | Escrow holds (create, execute, release, cancel) | http://wallet-service-net:8080 | http://localhost:5003 |
| IAM Service | JWT authentication (Authority for token validation) | http://iam-service-net:8080 | http://localhost:5001 |
### 8.3 Wallet Service Client (IWalletServiceClient)
HTTP client with Polly retry policy (3 retries, exponential backoff). Methods:
- `CreateHoldAsync` - POST `/api/v1/wallets/{walletId}/holds`
- `ExecuteHoldAsync` - POST `/api/v1/wallets/{walletId}/holds/{holdId}/execute`
- `ReleaseHoldAsync` - POST `/api/v1/wallets/{walletId}/holds/{holdId}/release`
- `CancelHoldAsync` - POST `/api/v1/wallets/{walletId}/holds/{holdId}/cancel`
- `GetWalletByUserIdAsync` - GET `/api/v1/wallets/user/{userId}`
---
## 9. Configuration
### 9.1 appsettings.json
```
ConnectionStrings:DefaultConnection - PostgreSQL connection string (or DATABASE_URL env var)
WalletService:BaseUrl - Wallet service URL (default: http://wallet-service-net:8080)
WalletService:TimeoutSeconds - HTTP timeout (30s)
IamService:BaseUrl - IAM service URL
IamService:ServiceName - "promotion-service"
Jwt:Authority - JWT issuer authority URL
Jwt:Audience - "goodgo-api"
Jwt:RequireHttpsMetadata - false
Jwt:Secret - JWT signing key
Jwt:Issuer - "goodgo-platform"
Jwt:AccessTokenExpiryMinutes - 15
Jwt:RefreshTokenExpiryDays - 7
RabbitMQ:Host - RabbitMQ host
RabbitMQ:Port - 5672
RabbitMQ:Username/Password - guest/guest
Redis:ConnectionString - Redis connection string
Serilog - Structured logging config (Console sink)
```
### 9.2 Environment Variables
| Variable | Description |
|----------|-------------|
| ASPNETCORE_ENVIRONMENT | Development / Production |
| ASPNETCORE_URLS | http://+:8080 (Docker) |
| DATABASE_URL | Fallback connection string |
### 9.3 Build Configuration
- Target Framework: net10.0
- Language Version: C# 14
- Nullable: enabled
- ImplicitUsings: enabled
- TreatWarningsAsErrors: true
- GenerateDocumentationFile: true
- Suppressed warnings: 1591 (missing XML doc), CA2017
---
## 10. MediatR Pipeline Behaviors
Registered in order:
1. **LoggingBehavior** - logs request name, elapsed time (Stopwatch), and errors
2. **ValidatorBehavior** - runs all FluentValidation validators for the request type, throws ValidationException on failure
3. **TransactionBehavior** - wraps Commands in a database transaction (skips Queries based on name ending with "Query"), uses EF Core ExecutionStrategy for retry
---
## 11. Infrastructure Patterns
### DbContext (PromotionServiceContext)
- Implements `IUnitOfWork`
- DbSets: Campaigns, Vouchers, Redemptions
- `SaveEntitiesAsync()` dispatches domain events before saving
- Transaction management: `BeginTransactionAsync()`, `CommitTransactionAsync()`, `RollbackTransaction()`
### Repositories
- `CampaignRepository` - ICampaignRepository implementation, uses Include(Vouchers) for GetByIdAsync
- `RedemptionRepository` - IRedemptionRepository implementation
### Idempotency
- `IRequestManager` / `RequestManager` - tracks client request IDs to prevent duplicate processing
- `ClientRequest` entity (Id, Name, Time) - registered but table not included in migration
### DI Registration (DependencyInjection.cs)
- PromotionServiceContext with Npgsql (retry on failure: 5 attempts, 30s max delay)
- ICampaignRepository -> CampaignRepository (Scoped)
- IRedemptionRepository -> RedemptionRepository (Scoped)
- IRequestManager -> RequestManager (Scoped)
---
## 12. Docker
- **Dockerfile**: Multi-stage build (sdk:10.0 -> aspnet:10.0)
- **Runtime user**: dotnetuser (UID 1001, GID 1001)
- **Port**: 8080
- **Healthcheck**: curl http://localhost:8080/health/live (30s interval, 3 retries)
- **docker-compose.yml**: Template-level (uses "myservice" naming, not production-ready), includes PostgreSQL 16 and Redis 7
---
## 13. Known Gaps and Issues
1. **Missing Handlers**: `ExchangeVoucherCommand`, `PurchaseVoucherCommand`, `SearchVouchersQuery`, `GetCampaignStatisticsQuery`, and `GetCampaignVouchersQuery` are defined but have no handler implementations.
2. **VoucherRedeemedDomainEvent**: Defined but never raised by any code.
3. **No FluentValidation Validators**: No validator classes exist in the codebase despite FluentValidation being registered in the pipeline. Commands have no request validation beyond domain-level checks.
4. **No Integration Event Handlers**: Domain events are dispatched but no handlers exist. No RabbitMQ integration despite RabbitMQ config being present in appsettings.
5. **ClientRequest Table Missing from Migration**: The `ClientRequest` entity for idempotency is registered in DI but its table is not created in the migration.
6. **docker-compose.yml uses template naming**: Service is named "myservice-api" instead of "promotion-service".
7. **SampleDomainException**: Leftover from template, unused.
8. **Redemption table has no FK constraints**: No foreign keys to vouchers or campaigns tables at the database level.
9. **Redis health check package included** but Redis is not used in the service code.