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