638 lines
27 KiB
Markdown
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.
|