diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml
index 938fbb8a..b76e755d 100644
--- a/deployments/local/docker-compose.yml
+++ b/deployments/local/docker-compose.yml
@@ -529,6 +529,57 @@ services:
- "traefik.http.services.mission-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.mission-service.loadbalancer.healthcheck.interval=10s"
+ # Promotion Service .NET - Voucher, Gift Card, Campaign Management
+ promotion-service-net:
+ build:
+ context: ../../services/promotion-service-net
+ dockerfile: Dockerfile
+ image: goodgo/promotion-service-net:latest
+ container_name: promotion-service-net-local
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ - ASPNETCORE_URLS=http://+:8080
+ # EN: Database - Neon PostgreSQL
+ # VI: Cơ sở dữ liệu - Neon PostgreSQL
+ - ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=promotion_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require
+ # EN: IAM Service Communication
+ # VI: Giao tiếp IAM Service
+ - IamService__BaseUrl=http://iam-service-net:8080
+ - IamService__ServiceName=promotion-service
+ # EN: Wallet Service Communication
+ # VI: Giao tiếp Wallet Service
+ - WalletService__BaseUrl=http://wallet-service-net:8080
+ # EN: JWT Configuration
+ # VI: Cấu hình JWT
+ - Jwt__Authority=http://iam-service-net:8080
+ - Jwt__Audience=goodgo-api
+ - Jwt__RequireHttpsMetadata=false
+ ports:
+ - "5008:8080"
+ depends_on:
+ iam-service-net:
+ condition: service_healthy
+ wallet-service-net:
+ condition: service_healthy
+ traefik:
+ condition: service_started
+ networks:
+ - microservices-network
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.promotion-service.rule=PathPrefix(`/api/v1/campaigns`) || PathPrefix(`/api/v1/vouchers`) || PathPrefix(`/api/v1/admin/campaigns`) || PathPrefix(`/api/v1/admin/vouchers`)"
+ - "traefik.http.routers.promotion-service.entrypoints=web"
+ - "traefik.http.services.promotion-service.loadbalancer.server.port=8080"
+ - "traefik.http.services.promotion-service.loadbalancer.healthcheck.path=/health/live"
+ - "traefik.http.services.promotion-service.loadbalancer.healthcheck.interval=10s"
+
# Jaeger - Distributed Tracing
# jaeger:
diff --git a/services/promotion-service-net/Directory.Build.props b/services/promotion-service-net/Directory.Build.props
new file mode 100644
index 00000000..c3b74373
--- /dev/null
+++ b/services/promotion-service-net/Directory.Build.props
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ 14.0
+ enable
+ enable
+ true
+ true
+ $(NoWarn);1591;CA2017
+
+
+
+ GoodGo Team
+ GoodGo
+ © 2026 GoodGo. All rights reserved.
+ git
+
+
+
+
+
+
diff --git a/services/promotion-service-net/Dockerfile b/services/promotion-service-net/Dockerfile
new file mode 100644
index 00000000..0bf55314
--- /dev/null
+++ b/services/promotion-service-net/Dockerfile
@@ -0,0 +1,66 @@
+# Build stage / Giai đoạn build
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+
+# EN: Copy project files for layer caching
+# VI: Sao chép các file project để tận dụng layer caching
+COPY ["src/PromotionService.API/PromotionService.API.csproj", "src/PromotionService.API/"]
+COPY ["src/PromotionService.Domain/PromotionService.Domain.csproj", "src/PromotionService.Domain/"]
+COPY ["src/PromotionService.Infrastructure/PromotionService.Infrastructure.csproj", "src/PromotionService.Infrastructure/"]
+COPY ["Directory.Build.props", "./"]
+
+# EN: Restore dependencies
+# VI: Khôi phục dependencies
+RUN dotnet restore "src/PromotionService.API/PromotionService.API.csproj"
+
+# EN: Copy all source code
+# VI: Sao chép toàn bộ source code
+COPY src/ ./src/
+
+# EN: Build the application
+# VI: Build ứng dụng
+WORKDIR "/src/src/PromotionService.API"
+RUN dotnet build "PromotionService.API.csproj" -c Release -o /app/build --no-restore
+
+# Publish stage / Giai đoạn publish
+FROM build AS publish
+RUN dotnet publish "PromotionService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
+
+# Runtime stage / Giai đoạn runtime
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
+WORKDIR /app
+
+# EN: Create non-root user for security
+# VI: Tạo user non-root cho bảo mật
+RUN groupadd -g 1001 dotnetuser && \
+ useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser
+
+# EN: Copy published application
+# VI: Sao chép ứng dụng đã publish
+COPY --from=publish /app/publish .
+
+# EN: Change ownership to non-root user
+# VI: Thay đổi quyền sở hữu sang user non-root
+RUN chown -R dotnetuser:dotnetuser /app
+
+# EN: Switch to non-root user
+# VI: Chuyển sang user non-root
+USER dotnetuser
+
+# EN: Expose port
+# VI: Mở cổng
+EXPOSE 8080
+
+# EN: Set environment variables
+# VI: Thiết lập biến môi trường
+ENV ASPNETCORE_URLS=http://+:8080
+ENV ASPNETCORE_ENVIRONMENT=Production
+
+# EN: Health check
+# VI: Kiểm tra health
+HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+ CMD curl -f http://localhost:8080/health/live || exit 1
+
+# EN: Start the application
+# VI: Khởi động ứng dụng
+ENTRYPOINT ["dotnet", "PromotionService.API.dll"]
diff --git a/services/promotion-service-net/PromotionService.slnx b/services/promotion-service-net/PromotionService.slnx
new file mode 100644
index 00000000..caedc0bf
--- /dev/null
+++ b/services/promotion-service-net/PromotionService.slnx
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services/promotion-service-net/docker-compose.yml b/services/promotion-service-net/docker-compose.yml
new file mode 100644
index 00000000..254ceb12
--- /dev/null
+++ b/services/promotion-service-net/docker-compose.yml
@@ -0,0 +1,72 @@
+version: '3.8'
+
+# EN: Docker Compose for local development
+# VI: Docker Compose cho phát triển local
+
+services:
+ myservice-api:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: myservice-api
+ ports:
+ - "5000:8080"
+ environment:
+ - ASPNETCORE_ENVIRONMENT=Development
+ - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
+ - REDIS_URL=redis:6379
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ networks:
+ - myservice-network
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
+
+ postgres:
+ image: postgres:16-alpine
+ container_name: myservice-postgres
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: myservice_db
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ networks:
+ - myservice-network
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ redis:
+ image: redis:7-alpine
+ container_name: myservice-redis
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ networks:
+ - myservice-network
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ postgres_data:
+ redis_data:
+
+networks:
+ myservice-network:
+ driver: bridge
diff --git a/services/promotion-service-net/global.json b/services/promotion-service-net/global.json
new file mode 100644
index 00000000..f78eeaf4
--- /dev/null
+++ b/services/promotion-service-net/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.101",
+ "rollForward": "latestMinor",
+ "allowPrerelease": false
+ }
+}
\ No newline at end of file
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/LoggingBehavior.cs b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/LoggingBehavior.cs
new file mode 100644
index 00000000..c3798178
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/LoggingBehavior.cs
@@ -0,0 +1,58 @@
+using System.Diagnostics;
+using MediatR;
+
+namespace PromotionService.API.Application.Behaviors;
+
+///
+/// EN: MediatR behavior for logging request handling.
+/// VI: MediatR behavior để logging việc xử lý request.
+///
+/// EN: Request type / VI: Loại request
+/// EN: Response type / VI: Loại response
+public class LoggingBehavior : IPipelineBehavior
+ where TRequest : IRequest
+{
+ private readonly ILogger> _logger;
+
+ public LoggingBehavior(ILogger> logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ var requestName = typeof(TRequest).Name;
+
+ _logger.LogInformation(
+ "Handling {RequestName} / Đang xử lý {RequestName}",
+ requestName);
+
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ var response = await next();
+
+ stopwatch.Stop();
+
+ _logger.LogInformation(
+ "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms",
+ requestName, stopwatch.ElapsedMilliseconds);
+
+ return response;
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+
+ _logger.LogError(ex,
+ "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms",
+ requestName, stopwatch.ElapsedMilliseconds);
+
+ throw;
+ }
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/TransactionBehavior.cs b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/TransactionBehavior.cs
new file mode 100644
index 00000000..51720ee7
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/TransactionBehavior.cs
@@ -0,0 +1,84 @@
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using PromotionService.Infrastructure;
+
+namespace PromotionService.API.Application.Behaviors;
+
+///
+/// EN: MediatR behavior for handling database transactions.
+/// VI: MediatR behavior để xử lý database transactions.
+///
+/// EN: Request type / VI: Loại request
+/// EN: Response type / VI: Loại response
+public class TransactionBehavior : IPipelineBehavior
+ where TRequest : IRequest
+{
+ private readonly PromotionServiceContext _dbContext;
+ private readonly ILogger> _logger;
+
+ public TransactionBehavior(
+ PromotionServiceContext dbContext,
+ ILogger> logger)
+ {
+ _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ var requestName = typeof(TRequest).Name;
+
+ // EN: Skip transaction for queries (read operations)
+ // VI: Bỏ qua transaction cho queries (các thao tác đọc)
+ if (requestName.EndsWith("Query"))
+ {
+ return await next();
+ }
+
+ // EN: Skip if already in a transaction
+ // VI: Bỏ qua nếu đã trong transaction
+ if (_dbContext.HasActiveTransaction)
+ {
+ return await next();
+ }
+
+ var strategy = _dbContext.Database.CreateExecutionStrategy();
+
+ return await strategy.ExecuteAsync(async () =>
+ {
+ await using var transaction = await _dbContext.BeginTransactionAsync();
+
+ _logger.LogInformation(
+ "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}",
+ transaction?.TransactionId, requestName);
+
+ try
+ {
+ var response = await next();
+
+ if (transaction != null)
+ {
+ await _dbContext.CommitTransactionAsync(transaction);
+
+ _logger.LogInformation(
+ "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}",
+ transaction.TransactionId, requestName);
+ }
+
+ return response;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}",
+ transaction?.TransactionId, requestName);
+
+ _dbContext.RollbackTransaction();
+ throw;
+ }
+ });
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/ValidatorBehavior.cs b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/ValidatorBehavior.cs
new file mode 100644
index 00000000..e31a4554
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/ValidatorBehavior.cs
@@ -0,0 +1,63 @@
+using FluentValidation;
+using MediatR;
+
+namespace PromotionService.API.Application.Behaviors;
+
+///
+/// EN: MediatR behavior for FluentValidation integration.
+/// VI: MediatR behavior để tích hợp FluentValidation.
+///
+/// EN: Request type / VI: Loại request
+/// EN: Response type / VI: Loại response
+public class ValidatorBehavior : IPipelineBehavior
+ where TRequest : IRequest
+{
+ private readonly IEnumerable> _validators;
+ private readonly ILogger> _logger;
+
+ public ValidatorBehavior(
+ IEnumerable> validators,
+ ILogger> logger)
+ {
+ _validators = validators ?? throw new ArgumentNullException(nameof(validators));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ var requestName = typeof(TRequest).Name;
+
+ if (!_validators.Any())
+ {
+ return await next();
+ }
+
+ _logger.LogDebug(
+ "Validating {RequestName} / Đang validate {RequestName}",
+ requestName);
+
+ var context = new ValidationContext(request);
+
+ var validationResults = await Task.WhenAll(
+ _validators.Select(v => v.ValidateAsync(context, cancellationToken)));
+
+ var failures = validationResults
+ .SelectMany(r => r.Errors)
+ .Where(f => f != null)
+ .ToList();
+
+ if (failures.Count != 0)
+ {
+ _logger.LogWarning(
+ "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi",
+ requestName, failures.Count);
+
+ throw new ValidationException(failures);
+ }
+
+ return await next();
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommand.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommand.cs
new file mode 100644
index 00000000..3058d610
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommand.cs
@@ -0,0 +1,14 @@
+using MediatR;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Command to change status of a Sample.
+/// VI: Command để thay đổi trạng thái của Sample.
+///
+/// EN: Sample ID / VI: ID sample
+/// EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)
+public record ChangeSampleStatusCommand(
+ Guid SampleId,
+ string NewStatus
+) : IRequest;
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs
new file mode 100644
index 00000000..a3b62f3c
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs
@@ -0,0 +1,70 @@
+using MediatR;
+using PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Handler for ChangeSampleStatusCommand.
+/// VI: Handler cho ChangeSampleStatusCommand.
+///
+public class ChangeSampleStatusCommandHandler : IRequestHandler
+{
+ private readonly ISampleRepository _sampleRepository;
+ private readonly ILogger _logger;
+
+ public ChangeSampleStatusCommandHandler(
+ ISampleRepository sampleRepository,
+ ILogger logger)
+ {
+ _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task Handle(
+ ChangeSampleStatusCommand request,
+ CancellationToken cancellationToken)
+ {
+ _logger.LogInformation(
+ "Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}",
+ request.SampleId, request.NewStatus);
+
+ // EN: Get existing sample / VI: Lấy sample đã tồn tại
+ var sample = await _sampleRepository.GetAsync(request.SampleId);
+
+ if (sample is null)
+ {
+ _logger.LogWarning(
+ "Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
+ request.SampleId);
+ return false;
+ }
+
+ // EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action
+ switch (request.NewStatus.ToLowerInvariant())
+ {
+ case "activate":
+ sample.Activate();
+ break;
+ case "complete":
+ sample.Complete();
+ break;
+ case "cancel":
+ sample.Cancel();
+ break;
+ default:
+ _logger.LogWarning(
+ "Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}",
+ request.NewStatus);
+ return false;
+ }
+
+ // EN: Save changes / VI: Lưu thay đổi
+ await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation(
+ "Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}",
+ request.SampleId, request.NewStatus);
+
+ return true;
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommand.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommand.cs
new file mode 100644
index 00000000..eb5f9405
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommand.cs
@@ -0,0 +1,21 @@
+using MediatR;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Command to create a new Sample.
+/// VI: Command để tạo một Sample mới.
+///
+/// EN: Sample name / VI: Tên sample
+/// EN: Optional description / VI: Mô tả tùy chọn
+public record CreateSampleCommand(
+ string Name,
+ string? Description
+) : IRequest;
+
+///
+/// EN: Result of CreateSampleCommand.
+/// VI: Kết quả của CreateSampleCommand.
+///
+/// EN: Created sample ID / VI: ID sample đã tạo
+public record CreateSampleCommandResult(Guid Id);
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommandHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommandHandler.cs
new file mode 100644
index 00000000..32877a24
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommandHandler.cs
@@ -0,0 +1,46 @@
+using MediatR;
+using PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Handler for CreateSampleCommand.
+/// VI: Handler cho CreateSampleCommand.
+///
+public class CreateSampleCommandHandler : IRequestHandler
+{
+ private readonly ISampleRepository _sampleRepository;
+ private readonly ILogger _logger;
+
+ public CreateSampleCommandHandler(
+ ISampleRepository sampleRepository,
+ ILogger logger)
+ {
+ _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task Handle(
+ CreateSampleCommand request,
+ CancellationToken cancellationToken)
+ {
+ _logger.LogInformation(
+ "Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}",
+ request.Name);
+
+ // EN: Create domain entity / VI: Tạo domain entity
+ var sample = new Sample(request.Name, request.Description);
+
+ // EN: Add to repository / VI: Thêm vào repository
+ _sampleRepository.Add(sample);
+
+ // EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events)
+ await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation(
+ "Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}",
+ sample.Id);
+
+ return new CreateSampleCommandResult(sample.Id);
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommand.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommand.cs
new file mode 100644
index 00000000..687b6323
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommand.cs
@@ -0,0 +1,10 @@
+using MediatR;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Command to delete a Sample.
+/// VI: Command để xóa một Sample.
+///
+/// EN: Sample ID to delete / VI: ID sample cần xóa
+public record DeleteSampleCommand(Guid SampleId) : IRequest;
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommandHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommandHandler.cs
new file mode 100644
index 00000000..8688808e
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommandHandler.cs
@@ -0,0 +1,54 @@
+using MediatR;
+using PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Handler for DeleteSampleCommand.
+/// VI: Handler cho DeleteSampleCommand.
+///
+public class DeleteSampleCommandHandler : IRequestHandler
+{
+ private readonly ISampleRepository _sampleRepository;
+ private readonly ILogger _logger;
+
+ public DeleteSampleCommandHandler(
+ ISampleRepository sampleRepository,
+ ILogger logger)
+ {
+ _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task Handle(
+ DeleteSampleCommand request,
+ CancellationToken cancellationToken)
+ {
+ _logger.LogInformation(
+ "Deleting sample {SampleId} / Xóa sample {SampleId}",
+ request.SampleId);
+
+ // EN: Get existing sample / VI: Lấy sample đã tồn tại
+ var sample = await _sampleRepository.GetAsync(request.SampleId);
+
+ if (sample is null)
+ {
+ _logger.LogWarning(
+ "Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
+ request.SampleId);
+ return false;
+ }
+
+ // EN: Delete sample / VI: Xóa sample
+ _sampleRepository.Delete(sample);
+
+ // EN: Save changes / VI: Lưu thay đổi
+ await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation(
+ "Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công",
+ request.SampleId);
+
+ return true;
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommand.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommand.cs
new file mode 100644
index 00000000..c9125e91
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommand.cs
@@ -0,0 +1,16 @@
+using MediatR;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Command to update an existing Sample.
+/// VI: Command để cập nhật một Sample đã tồn tại.
+///
+/// EN: Sample ID to update / VI: ID sample cần cập nhật
+/// EN: New name / VI: Tên mới
+/// EN: New description / VI: Mô tả mới
+public record UpdateSampleCommand(
+ Guid SampleId,
+ string Name,
+ string? Description
+) : IRequest;
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommandHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommandHandler.cs
new file mode 100644
index 00000000..5dd0e84b
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommandHandler.cs
@@ -0,0 +1,54 @@
+using MediatR;
+using PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Handler for UpdateSampleCommand.
+/// VI: Handler cho UpdateSampleCommand.
+///
+public class UpdateSampleCommandHandler : IRequestHandler
+{
+ private readonly ISampleRepository _sampleRepository;
+ private readonly ILogger _logger;
+
+ public UpdateSampleCommandHandler(
+ ISampleRepository sampleRepository,
+ ILogger logger)
+ {
+ _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task Handle(
+ UpdateSampleCommand request,
+ CancellationToken cancellationToken)
+ {
+ _logger.LogInformation(
+ "Updating sample {SampleId} / Cập nhật sample {SampleId}",
+ request.SampleId);
+
+ // EN: Get existing sample / VI: Lấy sample đã tồn tại
+ var sample = await _sampleRepository.GetAsync(request.SampleId);
+
+ if (sample is null)
+ {
+ _logger.LogWarning(
+ "Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
+ request.SampleId);
+ return false;
+ }
+
+ // EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method
+ sample.Update(request.Name, request.Description);
+
+ // EN: Save changes / VI: Lưu thay đổi
+ await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation(
+ "Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công",
+ request.SampleId);
+
+ return true;
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQuery.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQuery.cs
new file mode 100644
index 00000000..fcf57b5d
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQuery.cs
@@ -0,0 +1,23 @@
+using MediatR;
+
+namespace PromotionService.API.Application.Queries;
+
+///
+/// EN: Query to get a Sample by ID.
+/// VI: Query để lấy một Sample theo ID.
+///
+/// EN: Sample ID / VI: ID sample
+public record GetSampleQuery(Guid SampleId) : IRequest;
+
+///
+/// EN: Sample view model for API responses.
+/// VI: Sample view model cho API responses.
+///
+public record SampleViewModel(
+ Guid Id,
+ string Name,
+ string? Description,
+ string Status,
+ DateTime CreatedAt,
+ DateTime? UpdatedAt
+);
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQueryHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQueryHandler.cs
new file mode 100644
index 00000000..a5d87a7f
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQueryHandler.cs
@@ -0,0 +1,39 @@
+using MediatR;
+using PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+namespace PromotionService.API.Application.Queries;
+
+///
+/// EN: Handler for GetSampleQuery.
+/// VI: Handler cho GetSampleQuery.
+///
+public class GetSampleQueryHandler : IRequestHandler
+{
+ private readonly ISampleRepository _sampleRepository;
+
+ public GetSampleQueryHandler(ISampleRepository sampleRepository)
+ {
+ _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
+ }
+
+ public async Task Handle(
+ GetSampleQuery request,
+ CancellationToken cancellationToken)
+ {
+ var sample = await _sampleRepository.GetAsync(request.SampleId);
+
+ if (sample is null)
+ {
+ return null;
+ }
+
+ return new SampleViewModel(
+ sample.Id,
+ sample.Name,
+ sample.Description,
+ sample.Status.Name,
+ sample.CreatedAt,
+ sample.UpdatedAt
+ );
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQuery.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQuery.cs
new file mode 100644
index 00000000..63988838
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQuery.cs
@@ -0,0 +1,9 @@
+using MediatR;
+
+namespace PromotionService.API.Application.Queries;
+
+///
+/// EN: Query to get all Samples.
+/// VI: Query để lấy tất cả Samples.
+///
+public record GetSamplesQuery : IRequest>;
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQueryHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQueryHandler.cs
new file mode 100644
index 00000000..059f9176
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQueryHandler.cs
@@ -0,0 +1,34 @@
+using MediatR;
+using PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+namespace PromotionService.API.Application.Queries;
+
+///
+/// EN: Handler for GetSamplesQuery.
+/// VI: Handler cho GetSamplesQuery.
+///
+public class GetSamplesQueryHandler : IRequestHandler>
+{
+ private readonly ISampleRepository _sampleRepository;
+
+ public GetSamplesQueryHandler(ISampleRepository sampleRepository)
+ {
+ _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
+ }
+
+ public async Task> Handle(
+ GetSamplesQuery request,
+ CancellationToken cancellationToken)
+ {
+ var samples = await _sampleRepository.GetAllAsync();
+
+ return samples.Select(sample => new SampleViewModel(
+ sample.Id,
+ sample.Name,
+ sample.Description,
+ sample.Status.Name,
+ sample.CreatedAt,
+ sample.UpdatedAt
+ ));
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Validations/CreateSampleCommandValidator.cs b/services/promotion-service-net/src/PromotionService.API/Application/Validations/CreateSampleCommandValidator.cs
new file mode 100644
index 00000000..43259527
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Validations/CreateSampleCommandValidator.cs
@@ -0,0 +1,25 @@
+using FluentValidation;
+using PromotionService.API.Application.Commands;
+
+namespace PromotionService.API.Application.Validations;
+
+///
+/// EN: Validator for CreateSampleCommand.
+/// VI: Validator cho CreateSampleCommand.
+///
+public class CreateSampleCommandValidator : AbstractValidator
+{
+ public CreateSampleCommandValidator()
+ {
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Name is required / Tên là bắt buộc")
+ .MaximumLength(200)
+ .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
+
+ RuleFor(x => x.Description)
+ .MaximumLength(1000)
+ .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
+ .When(x => x.Description != null);
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Validations/UpdateSampleCommandValidator.cs b/services/promotion-service-net/src/PromotionService.API/Application/Validations/UpdateSampleCommandValidator.cs
new file mode 100644
index 00000000..ed428869
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Validations/UpdateSampleCommandValidator.cs
@@ -0,0 +1,29 @@
+using FluentValidation;
+using PromotionService.API.Application.Commands;
+
+namespace PromotionService.API.Application.Validations;
+
+///
+/// EN: Validator for UpdateSampleCommand.
+/// VI: Validator cho UpdateSampleCommand.
+///
+public class UpdateSampleCommandValidator : AbstractValidator
+{
+ public UpdateSampleCommandValidator()
+ {
+ RuleFor(x => x.SampleId)
+ .NotEmpty()
+ .WithMessage("Sample ID is required / ID sample là bắt buộc");
+
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Name is required / Tên là bắt buộc")
+ .MaximumLength(200)
+ .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
+
+ RuleFor(x => x.Description)
+ .MaximumLength(1000)
+ .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
+ .When(x => x.Description != null);
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs b/services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs
new file mode 100644
index 00000000..8a7150c7
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs
@@ -0,0 +1,200 @@
+using Asp.Versioning;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using PromotionService.API.Application.Commands;
+using PromotionService.API.Application.Queries;
+
+namespace PromotionService.API.Controllers;
+
+///
+/// EN: Controller for Sample CRUD operations using CQRS pattern.
+/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
+///
+[ApiController]
+[ApiVersion("1.0")]
+[Route("api/v{version:apiVersion}/[controller]")]
+[Produces("application/json")]
+public class SamplesController : ControllerBase
+{
+ private readonly IMediator _mediator;
+ private readonly ILogger _logger;
+
+ public SamplesController(IMediator mediator, ILogger logger)
+ {
+ _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// EN: Get all samples.
+ /// VI: Lấy tất cả samples.
+ ///
+ /// EN: List of samples / VI: Danh sách samples
+ [HttpGet]
+ [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
+ public async Task GetSamples()
+ {
+ var samples = await _mediator.Send(new GetSamplesQuery());
+ return Ok(new { success = true, data = samples });
+ }
+
+ ///
+ /// EN: Get a sample by ID.
+ /// VI: Lấy một sample theo ID.
+ ///
+ /// EN: Sample ID / VI: ID sample
+ /// EN: Sample details / VI: Chi tiết sample
+ [HttpGet("{id:guid}")]
+ [ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GetSample(Guid id)
+ {
+ var sample = await _mediator.Send(new GetSampleQuery(id));
+
+ if (sample is null)
+ {
+ return NotFound(new
+ {
+ success = false,
+ error = new
+ {
+ code = "SAMPLE_NOT_FOUND",
+ message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
+ }
+ });
+ }
+
+ return Ok(new { success = true, data = sample });
+ }
+
+ ///
+ /// EN: Create a new sample.
+ /// VI: Tạo một sample mới.
+ ///
+ /// EN: Create request / VI: Request tạo
+ /// EN: Created sample ID / VI: ID sample đã tạo
+ [HttpPost]
+ [ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task CreateSample([FromBody] CreateSampleRequest request)
+ {
+ var command = new CreateSampleCommand(request.Name, request.Description);
+ var result = await _mediator.Send(command);
+
+ return CreatedAtAction(
+ nameof(GetSample),
+ new { id = result.Id },
+ new { success = true, data = result });
+ }
+
+ ///
+ /// EN: Update an existing sample.
+ /// VI: Cập nhật một sample đã tồn tại.
+ ///
+ /// EN: Sample ID / VI: ID sample
+ /// EN: Update request / VI: Request cập nhật
+ /// EN: Success status / VI: Trạng thái thành công
+ [HttpPut("{id:guid}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task UpdateSample(Guid id, [FromBody] UpdateSampleRequest request)
+ {
+ var command = new UpdateSampleCommand(id, request.Name, request.Description);
+ var result = await _mediator.Send(command);
+
+ if (!result)
+ {
+ return NotFound(new
+ {
+ success = false,
+ error = new
+ {
+ code = "SAMPLE_NOT_FOUND",
+ message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
+ }
+ });
+ }
+
+ return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" });
+ }
+
+ ///
+ /// EN: Delete a sample.
+ /// VI: Xóa một sample.
+ ///
+ /// EN: Sample ID / VI: ID sample
+ /// EN: Success status / VI: Trạng thái thành công
+ [HttpDelete("{id:guid}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task DeleteSample(Guid id)
+ {
+ var command = new DeleteSampleCommand(id);
+ var result = await _mediator.Send(command);
+
+ if (!result)
+ {
+ return NotFound(new
+ {
+ success = false,
+ error = new
+ {
+ code = "SAMPLE_NOT_FOUND",
+ message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
+ }
+ });
+ }
+
+ return NoContent();
+ }
+
+ ///
+ /// EN: Change sample status.
+ /// VI: Thay đổi trạng thái sample.
+ ///
+ /// EN: Sample ID / VI: ID sample
+ /// EN: Status change request / VI: Request thay đổi trạng thái
+ /// EN: Success status / VI: Trạng thái thành công
+ [HttpPatch("{id:guid}/status")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request)
+ {
+ var command = new ChangeSampleStatusCommand(id, request.Status);
+ var result = await _mediator.Send(command);
+
+ if (!result)
+ {
+ return BadRequest(new
+ {
+ success = false,
+ error = new
+ {
+ code = "STATUS_CHANGE_FAILED",
+ message = "Failed to change sample status / Thay đổi trạng thái sample thất bại"
+ }
+ });
+ }
+
+ return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" });
+ }
+}
+
+///
+/// EN: Request model for creating a sample.
+/// VI: Model request để tạo sample.
+///
+public record CreateSampleRequest(string Name, string? Description);
+
+///
+/// EN: Request model for updating a sample.
+/// VI: Model request để cập nhật sample.
+///
+public record UpdateSampleRequest(string Name, string? Description);
+
+///
+/// EN: Request model for changing sample status.
+/// VI: Model request để thay đổi trạng thái sample.
+///
+public record ChangeStatusRequest(string Status);
diff --git a/services/promotion-service-net/src/PromotionService.API/Program.cs b/services/promotion-service-net/src/PromotionService.API/Program.cs
new file mode 100644
index 00000000..a9424716
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Program.cs
@@ -0,0 +1,144 @@
+using Asp.Versioning;
+using FluentValidation;
+using Hellang.Middleware.ProblemDetails;
+using PromotionService.API.Application.Behaviors;
+using PromotionService.Infrastructure;
+using Serilog;
+
+// EN: Configure Serilog early / VI: Cấu hình Serilog sớm
+Log.Logger = new LoggerConfiguration()
+ .WriteTo.Console()
+ .CreateBootstrapLogger();
+
+try
+{
+ Log.Information("Starting PromotionService API / Khởi động PromotionService API");
+
+ var builder = WebApplication.CreateBuilder(args);
+
+ // EN: Configure Serilog / VI: Cấu hình Serilog
+ builder.Host.UseSerilog((context, services, configuration) => configuration
+ .ReadFrom.Configuration(context.Configuration)
+ .ReadFrom.Services(services)
+ .Enrich.FromLogContext()
+ .WriteTo.Console());
+
+ // EN: Add Infrastructure services / VI: Thêm Infrastructure services
+ builder.Services.AddInfrastructure(builder.Configuration);
+
+ // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
+ builder.Services.AddMediatR(cfg =>
+ {
+ cfg.RegisterServicesFromAssemblyContaining();
+ cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
+ cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>));
+ cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
+ });
+
+ // EN: Add FluentValidation / VI: Thêm FluentValidation
+ builder.Services.AddValidatorsFromAssemblyContaining();
+
+ // EN: Add API versioning / VI: Thêm API versioning
+ builder.Services.AddApiVersioning(options =>
+ {
+ options.DefaultApiVersion = new ApiVersion(1, 0);
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ReportApiVersions = true;
+ options.ApiVersionReader = ApiVersionReader.Combine(
+ new UrlSegmentApiVersionReader(),
+ new HeaderApiVersionReader("X-Api-Version"));
+ })
+ .AddApiExplorer(options =>
+ {
+ options.GroupNameFormat = "'v'VVV";
+ options.SubstituteApiVersionInUrl = true;
+ });
+
+ // EN: Add controllers / VI: Thêm controllers
+ builder.Services.AddControllers();
+
+ // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware
+ builder.Services.AddProblemDetails(options =>
+ {
+ options.IncludeExceptionDetails = (ctx, ex) =>
+ builder.Environment.IsDevelopment();
+ });
+
+ // EN: Add Swagger / VI: Thêm Swagger
+ builder.Services.AddEndpointsApiExplorer();
+ builder.Services.AddSwaggerGen(options =>
+ {
+ options.SwaggerDoc("v1", new()
+ {
+ Title = "PromotionService API",
+ Version = "v1",
+ Description = "PromotionService microservice API / API microservice PromotionService"
+ });
+ });
+
+ // EN: Add health checks / VI: Thêm health checks
+ builder.Services.AddHealthChecks()
+ .AddNpgSql(
+ builder.Configuration.GetConnectionString("DefaultConnection")
+ ?? builder.Configuration["DATABASE_URL"]
+ ?? "",
+ name: "postgresql",
+ tags: ["db", "postgresql"]);
+
+ // EN: Add CORS / VI: Thêm CORS
+ builder.Services.AddCors(options =>
+ {
+ options.AddDefaultPolicy(policy =>
+ {
+ policy.AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+ });
+ });
+
+ var app = builder.Build();
+
+ // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline
+ app.UseSerilogRequestLogging();
+ app.UseProblemDetails();
+
+ if (app.Environment.IsDevelopment())
+ {
+ app.UseSwagger();
+ app.UseSwaggerUI(c =>
+ {
+ c.SwaggerEndpoint("/swagger/v1/swagger.json", "PromotionService API v1");
+ c.RoutePrefix = "swagger";
+ });
+ }
+
+ app.UseCors();
+ app.UseRouting();
+
+ // EN: Map health check endpoints / VI: Map health check endpoints
+ app.MapHealthChecks("/health");
+ app.MapHealthChecks("/health/live", new()
+ {
+ Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy
+ });
+ app.MapHealthChecks("/health/ready");
+
+ // EN: Map controllers / VI: Map controllers
+ app.MapControllers();
+
+ // EN: Run the application / VI: Chạy ứng dụng
+ app.Run();
+}
+catch (Exception ex)
+{
+ Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ");
+ throw;
+}
+finally
+{
+ Log.CloseAndFlush();
+}
+
+// EN: Make Program class accessible for integration tests
+// VI: Làm cho class Program có thể truy cập cho integration tests
+public partial class Program { }
diff --git a/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj b/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj
new file mode 100644
index 00000000..85409bb8
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj
@@ -0,0 +1,43 @@
+
+
+
+ PromotionService.API
+ PromotionService.API
+ Web API layer with CQRS pattern
+ myservice-api
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services/promotion-service-net/src/PromotionService.API/Properties/launchSettings.json b/services/promotion-service-net/src/PromotionService.API/Properties/launchSettings.json
new file mode 100644
index 00000000..6355d40b
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Properties/launchSettings.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json b/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json
new file mode 100644
index 00000000..e407ac85
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json
@@ -0,0 +1,19 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "Microsoft.AspNetCore": "Information",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Information"
+ }
+ },
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Debug",
+ "Override": {
+ "Microsoft": "Information",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Information",
+ "System": "Information"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/services/promotion-service-net/src/PromotionService.API/appsettings.json b/services/promotion-service-net/src/PromotionService.API/appsettings.json
new file mode 100644
index 00000000..523dc0fc
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/appsettings.json
@@ -0,0 +1,46 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning",
+ "System": "Warning"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
+ }
+ }
+ ],
+ "Enrich": [
+ "FromLogContext",
+ "WithMachineName",
+ "WithThreadId"
+ ]
+ },
+ "ConnectionStrings": {
+ "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres"
+ },
+ "Redis": {
+ "ConnectionString": "localhost:6379"
+ },
+ "Jwt": {
+ "Secret": "your-super-secret-key-min-32-characters",
+ "Issuer": "goodgo-platform",
+ "Audience": "goodgo-services",
+ "AccessTokenExpiryMinutes": 15,
+ "RefreshTokenExpiryDays": 7
+ },
+ "AllowedHosts": "*"
+}
\ No newline at end of file
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AcquisitionType.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AcquisitionType.cs
new file mode 100644
index 00000000..d551bd50
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AcquisitionType.cs
@@ -0,0 +1,31 @@
+namespace PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+using PromotionService.Domain.SeedWork;
+
+///
+/// EN: How the user acquires the voucher.
+/// VI: Cách người dùng nhận voucher.
+///
+public class AcquisitionType : Enumeration
+{
+ ///
+ /// EN: Free voucher (giveaway).
+ /// VI: Voucher miễn phí (tặng).
+ ///
+ public static readonly AcquisitionType Free = new(1, nameof(Free));
+
+ ///
+ /// EN: Exchange with loyalty points.
+ /// VI: Đổi bằng điểm thưởng.
+ ///
+ public static readonly AcquisitionType ExchangePoints = new(2, nameof(ExchangePoints));
+
+ ///
+ /// EN: Purchase with currency.
+ /// VI: Mua bằng tiền tệ.
+ ///
+ public static readonly AcquisitionType Purchase = new(3, nameof(Purchase));
+
+ protected AcquisitionType() : base(0, "Unknown") { }
+ public AcquisitionType(int id, string name) : base(id, name) { }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AssetType.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AssetType.cs
new file mode 100644
index 00000000..5237a6ea
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AssetType.cs
@@ -0,0 +1,25 @@
+namespace PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+using PromotionService.Domain.SeedWork;
+
+///
+/// EN: Type of backing asset for the voucher.
+/// VI: Loại tài sản đảm bảo cho voucher.
+///
+public class AssetType : Enumeration
+{
+ ///
+ /// EN: Currency (VND, USD, etc.)
+ /// VI: Tiền tệ (VND, USD, v.v.)
+ ///
+ public static readonly AssetType Currency = new(1, nameof(Currency));
+
+ ///
+ /// EN: Points (PPoint).
+ /// VI: Điểm (PPoint).
+ ///
+ public static readonly AssetType Point = new(2, nameof(Point));
+
+ protected AssetType() : base(0, "Unknown") { }
+ public AssetType(int id, string name) : base(id, name) { }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Campaign.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Campaign.cs
new file mode 100644
index 00000000..f71538fe
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Campaign.cs
@@ -0,0 +1,434 @@
+namespace PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+using PromotionService.Domain.Events;
+using PromotionService.Domain.Exceptions;
+using PromotionService.Domain.SeedWork;
+
+///
+/// EN: Campaign Aggregate Root - represents a marketing campaign with vouchers.
+/// VI: Campaign Aggregate Root - đại diện cho một chiến dịch marketing với voucher.
+///
+public class Campaign : Entity, IAggregateRoot
+{
+ private readonly List _vouchers = new();
+
+ ///
+ /// EN: ID of the merchant who owns the campaign.
+ /// VI: ID của merchant sở hữu chiến dịch.
+ ///
+ public Guid MerchantId { get; private set; }
+
+ ///
+ /// EN: Campaign name.
+ /// VI: Tên chiến dịch.
+ ///
+ public string Name { get; private set; } = null!;
+
+ ///
+ /// EN: Campaign description.
+ /// VI: Mô tả chiến dịch.
+ ///
+ public string? Description { get; private set; }
+
+ #region Backing Asset (What the voucher contains)
+
+ ///
+ /// EN: Type of backing asset (Currency or Point).
+ /// VI: Loại tài sản đảm bảo (Tiền tệ hoặc Điểm).
+ ///
+ public AssetType BackingAssetType { get; private set; } = null!;
+ public int BackingAssetTypeId { get; private set; }
+
+ ///
+ /// EN: Currency/Point code (e.g., VND, USD, PPoint).
+ /// VI: Mã tiền tệ/điểm (ví dụ: VND, USD, PPoint).
+ ///
+ public string BackingAssetCode { get; private set; } = null!;
+
+ ///
+ /// EN: Face value of each voucher.
+ /// VI: Mệnh giá của mỗi voucher.
+ ///
+ public decimal FaceValue { get; private set; }
+
+ #endregion
+
+ #region Acquisition (How user gets the voucher)
+
+ ///
+ /// EN: How user acquires the voucher (Free, ExchangePoints, Purchase).
+ /// VI: Cách người dùng nhận voucher (Miễn phí, Đổi điểm, Mua).
+ ///
+ public AcquisitionType AcquisitionType { get; private set; } = null!;
+ public int AcquisitionTypeId { get; private set; }
+
+ ///
+ /// EN: Price to acquire (points or currency amount).
+ /// VI: Giá để nhận (số điểm hoặc số tiền).
+ ///
+ public decimal AcquisitionPrice { get; private set; }
+
+ #endregion
+
+ #region Escrow (Wallet Service Hold)
+
+ ///
+ /// EN: Hold ID from Wallet Service for escrow.
+ /// VI: ID giữ từ Wallet Service cho ký quỹ.
+ ///
+ public Guid? EscrowHoldId { get; private set; }
+
+ ///
+ /// EN: Wallet ID that provides the escrow.
+ /// VI: ID ví cung cấp ký quỹ.
+ ///
+ public Guid? EscrowWalletId { get; private set; }
+
+ ///
+ /// EN: Total amount held in escrow.
+ /// VI: Tổng số tiền ký quỹ.
+ ///
+ public decimal EscrowAmount { get; private set; }
+
+ #endregion
+
+ #region Campaign Limits
+
+ ///
+ /// EN: Total number of vouchers to issue.
+ /// VI: Tổng số voucher phát hành.
+ ///
+ public int TotalVouchers { get; private set; }
+
+ ///
+ /// EN: Number of vouchers already issued (claimed).
+ /// VI: Số voucher đã phát hành (đã nhận).
+ ///
+ public int IssuedVouchers { get; private set; }
+
+ ///
+ /// EN: Maximum vouchers per user (0 = unlimited).
+ /// VI: Số voucher tối đa mỗi người dùng (0 = không giới hạn).
+ ///
+ public int MaxPerUser { get; private set; }
+
+ ///
+ /// EN: Campaign start date.
+ /// VI: Ngày bắt đầu chiến dịch.
+ ///
+ public DateTime StartDate { get; private set; }
+
+ ///
+ /// EN: Campaign end date.
+ /// VI: Ngày kết thúc chiến dịch.
+ ///
+ public DateTime EndDate { get; private set; }
+
+ ///
+ /// EN: Voucher validity days after claiming.
+ /// VI: Số ngày voucher có hiệu lực sau khi nhận.
+ ///
+ public int VoucherValidityDays { get; private set; }
+
+ #endregion
+
+ #region Status
+
+ ///
+ /// EN: Current campaign status.
+ /// VI: Trạng thái chiến dịch hiện tại.
+ ///
+ public CampaignStatus Status { get; private set; } = null!;
+ public int StatusId { get; private set; }
+
+ ///
+ /// EN: Timestamp of creation.
+ /// VI: Thời điểm tạo.
+ ///
+ public DateTime CreatedAt { get; private set; }
+
+ ///
+ /// EN: Timestamp of last update.
+ /// VI: Thời điểm cập nhật cuối.
+ ///
+ public DateTime UpdatedAt { get; private set; }
+
+ #endregion
+
+ ///
+ /// EN: Collection of vouchers in this campaign.
+ /// VI: Danh sách voucher trong chiến dịch này.
+ ///
+ public IReadOnlyCollection Vouchers => _vouchers.AsReadOnly();
+
+ // EF Core constructor
+ protected Campaign() { }
+
+ ///
+ /// EN: Create a new campaign.
+ /// VI: Tạo chiến dịch mới.
+ ///
+ public Campaign(
+ Guid merchantId,
+ string name,
+ string? description,
+ AssetType backingAssetType,
+ string backingAssetCode,
+ decimal faceValue,
+ AcquisitionType acquisitionType,
+ decimal acquisitionPrice,
+ int totalVouchers,
+ DateTime startDate,
+ DateTime endDate,
+ int voucherValidityDays = 30,
+ int maxPerUser = 1)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new PromotionDomainException("Campaign name is required");
+ if (faceValue <= 0)
+ throw new PromotionDomainException("Face value must be greater than zero");
+ if (totalVouchers <= 0)
+ throw new PromotionDomainException("Total vouchers must be greater than zero");
+ if (endDate <= startDate)
+ throw new PromotionDomainException("End date must be after start date");
+ if (acquisitionType != AcquisitionType.Free && acquisitionPrice <= 0)
+ throw new PromotionDomainException("Acquisition price is required for non-free campaigns");
+
+ Id = Guid.NewGuid();
+ MerchantId = merchantId;
+ Name = name;
+ Description = description;
+ BackingAssetType = backingAssetType;
+ BackingAssetTypeId = backingAssetType.Id;
+ BackingAssetCode = backingAssetCode;
+ FaceValue = faceValue;
+ AcquisitionType = acquisitionType;
+ AcquisitionTypeId = acquisitionType.Id;
+ AcquisitionPrice = acquisitionPrice;
+ TotalVouchers = totalVouchers;
+ IssuedVouchers = 0;
+ MaxPerUser = maxPerUser;
+ StartDate = startDate;
+ EndDate = endDate;
+ VoucherValidityDays = voucherValidityDays;
+ Status = CampaignStatus.Draft;
+ StatusId = Status.Id;
+ CreatedAt = DateTime.UtcNow;
+ UpdatedAt = DateTime.UtcNow;
+
+ // Calculate escrow amount
+ EscrowAmount = faceValue * totalVouchers;
+
+ AddDomainEvent(new CampaignCreatedDomainEvent(Id, merchantId, name));
+ }
+
+ #region Escrow Management
+
+ ///
+ /// EN: Set the escrow hold ID after Wallet Service confirms the hold.
+ /// VI: Đặt ID giữ ký quỹ sau khi Wallet Service xác nhận giữ.
+ ///
+ public void SetEscrowHold(Guid walletId, Guid holdId)
+ {
+ if (Status != CampaignStatus.Draft)
+ throw new PromotionDomainException("Can only set escrow on draft campaigns");
+
+ EscrowWalletId = walletId;
+ EscrowHoldId = holdId;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ #endregion
+
+ #region Lifecycle Methods
+
+ ///
+ /// EN: Activate the campaign (make it live).
+ /// VI: Kích hoạt chiến dịch (đưa vào hoạt động).
+ ///
+ public void Activate()
+ {
+ if (Status != CampaignStatus.Draft && Status != CampaignStatus.Paused)
+ throw new PromotionDomainException($"Cannot activate campaign in {Status.Name} status");
+
+ if (!EscrowHoldId.HasValue)
+ throw new PromotionDomainException("Escrow must be set before activating");
+
+ Status = CampaignStatus.Active;
+ StatusId = Status.Id;
+ UpdatedAt = DateTime.UtcNow;
+
+ AddDomainEvent(new CampaignActivatedDomainEvent(Id, MerchantId));
+ }
+
+ ///
+ /// EN: Pause the campaign (temporarily stop).
+ /// VI: Tạm dừng chiến dịch (dừng tạm thời).
+ ///
+ public void Pause()
+ {
+ if (Status != CampaignStatus.Active)
+ throw new PromotionDomainException("Can only pause active campaigns");
+
+ Status = CampaignStatus.Paused;
+ StatusId = Status.Id;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// EN: Cancel the campaign (release escrow).
+ /// VI: Hủy chiến dịch (giải phóng ký quỹ).
+ ///
+ public void Cancel()
+ {
+ if (Status == CampaignStatus.Completed || Status == CampaignStatus.Cancelled)
+ throw new PromotionDomainException($"Cannot cancel campaign in {Status.Name} status");
+
+ Status = CampaignStatus.Cancelled;
+ StatusId = Status.Id;
+ UpdatedAt = DateTime.UtcNow;
+
+ AddDomainEvent(new CampaignCancelledDomainEvent(Id, MerchantId, EscrowHoldId));
+ }
+
+ ///
+ /// EN: Complete the campaign (normal end).
+ /// VI: Hoàn thành chiến dịch (kết thúc bình thường).
+ ///
+ public void Complete()
+ {
+ if (Status != CampaignStatus.Active && Status != CampaignStatus.Paused)
+ throw new PromotionDomainException($"Cannot complete campaign in {Status.Name} status");
+
+ Status = CampaignStatus.Completed;
+ StatusId = Status.Id;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ #endregion
+
+ #region Voucher Management
+
+ ///
+ /// EN: Generate voucher codes for this campaign.
+ /// VI: Tạo mã voucher cho chiến dịch này.
+ ///
+ public void GenerateVouchers(int count)
+ {
+ if (Status != CampaignStatus.Draft)
+ throw new PromotionDomainException("Can only generate vouchers for draft campaigns");
+
+ if (_vouchers.Count + count > TotalVouchers)
+ throw new PromotionDomainException($"Cannot generate {count} vouchers. Maximum is {TotalVouchers}");
+
+ for (int i = 0; i < count; i++)
+ {
+ var code = GenerateVoucherCode();
+ var voucher = new Voucher(Id, code, FaceValue, VoucherValidityDays);
+ _vouchers.Add(voucher);
+ }
+
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// EN: Issue a voucher to a user (claim).
+ /// VI: Phát voucher cho người dùng (nhận).
+ ///
+ public Voucher IssueVoucher(Guid userId)
+ {
+ if (Status != CampaignStatus.Active)
+ throw new CampaignNotActiveException(Id);
+
+ if (DateTime.UtcNow < StartDate || DateTime.UtcNow > EndDate)
+ throw new PromotionDomainException("Campaign is not within active date range");
+
+ // Check max per user
+ if (MaxPerUser > 0)
+ {
+ var userVoucherCount = _vouchers.Count(v => v.OwnerId == userId);
+ if (userVoucherCount >= MaxPerUser)
+ throw new PromotionDomainException($"User already has maximum {MaxPerUser} vouchers from this campaign");
+ }
+
+ // Find available voucher
+ var voucher = _vouchers.FirstOrDefault(v => v.Status == VoucherStatus.Available);
+ if (voucher == null)
+ throw new PromotionDomainException("No available vouchers in this campaign");
+
+ voucher.Claim(userId, VoucherValidityDays);
+ IssuedVouchers++;
+ UpdatedAt = DateTime.UtcNow;
+
+ AddDomainEvent(new VoucherClaimedDomainEvent(voucher.Id, Id, userId, voucher.Code));
+
+ return voucher;
+ }
+
+ ///
+ /// EN: Get a specific voucher by code.
+ /// VI: Lấy voucher theo mã.
+ ///
+ public Voucher? GetVoucherByCode(string code)
+ {
+ return _vouchers.FirstOrDefault(v => v.Code.Equals(code, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// EN: Get vouchers for a specific user.
+ /// VI: Lấy voucher của người dùng.
+ ///
+ public IEnumerable GetUserVouchers(Guid userId)
+ {
+ return _vouchers.Where(v => v.OwnerId == userId);
+ }
+
+ private string GenerateVoucherCode()
+ {
+ // Format: PREFIX-RANDOM (e.g., CAMP-A1B2C3)
+ const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ var random = new Random();
+ var code = new string(Enumerable.Repeat(chars, 6)
+ .Select(s => s[random.Next(s.Length)]).ToArray());
+
+ // Ensure uniqueness
+ while (_vouchers.Any(v => v.Code == $"V{code}"))
+ {
+ code = new string(Enumerable.Repeat(chars, 6)
+ .Select(s => s[random.Next(s.Length)]).ToArray());
+ }
+
+ return $"V{code}";
+ }
+
+ #endregion
+
+ #region Statistics
+
+ ///
+ /// EN: Get available voucher count.
+ /// VI: Lấy số voucher còn lại.
+ ///
+ public int AvailableVoucherCount => _vouchers.Count(v => v.Status == VoucherStatus.Available);
+
+ ///
+ /// EN: Get claimed voucher count.
+ /// VI: Lấy số voucher đã nhận.
+ ///
+ public int ClaimedVoucherCount => _vouchers.Count(v => v.Status != VoucherStatus.Available);
+
+ ///
+ /// EN: Get redeemed voucher count.
+ /// VI: Lấy số voucher đã sử dụng.
+ ///
+ public int RedeemedVoucherCount => _vouchers.Count(v =>
+ v.Status == VoucherStatus.PartiallyRedeemed || v.Status == VoucherStatus.FullyRedeemed);
+
+ ///
+ /// EN: Get total redeemed value.
+ /// VI: Lấy tổng giá trị đã sử dụng.
+ ///
+ public decimal TotalRedeemedValue => _vouchers.Sum(v => v.FaceValue - v.RemainingValue);
+
+ #endregion
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/CampaignStatus.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/CampaignStatus.cs
new file mode 100644
index 00000000..c8d676be
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/CampaignStatus.cs
@@ -0,0 +1,43 @@
+namespace PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+using PromotionService.Domain.SeedWork;
+
+///
+/// EN: Status of the marketing campaign.
+/// VI: Trạng thái của chiến dịch marketing.
+///
+public class CampaignStatus : Enumeration
+{
+ ///
+ /// EN: Draft - campaign is being prepared.
+ /// VI: Nháp - chiến dịch đang được chuẩn bị.
+ ///
+ public static readonly CampaignStatus Draft = new(1, nameof(Draft));
+
+ ///
+ /// EN: Active - campaign is running, vouchers can be claimed.
+ /// VI: Hoạt động - chiến dịch đang chạy, voucher có thể được nhận.
+ ///
+ public static readonly CampaignStatus Active = new(2, nameof(Active));
+
+ ///
+ /// EN: Paused - campaign is temporarily stopped.
+ /// VI: Tạm dừng - chiến dịch tạm thời dừng.
+ ///
+ public static readonly CampaignStatus Paused = new(3, nameof(Paused));
+
+ ///
+ /// EN: Completed - campaign has ended normally.
+ /// VI: Hoàn thành - chiến dịch đã kết thúc bình thường.
+ ///
+ public static readonly CampaignStatus Completed = new(4, nameof(Completed));
+
+ ///
+ /// EN: Cancelled - campaign was cancelled, escrow released.
+ /// VI: Hủy bỏ - chiến dịch bị hủy, escrow được giải phóng.
+ ///
+ public static readonly CampaignStatus Cancelled = new(5, nameof(Cancelled));
+
+ protected CampaignStatus() : base(0, "Unknown") { }
+ public CampaignStatus(int id, string name) : base(id, name) { }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/ICampaignRepository.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/ICampaignRepository.cs
new file mode 100644
index 00000000..6d17bae6
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/ICampaignRepository.cs
@@ -0,0 +1,52 @@
+namespace PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+using PromotionService.Domain.SeedWork;
+
+///
+/// EN: Repository interface for Campaign aggregate.
+/// VI: Interface repository cho Campaign aggregate.
+///
+public interface ICampaignRepository : IRepository
+{
+ ///
+ /// EN: Get campaign by ID with vouchers.
+ /// VI: Lấy chiến dịch theo ID với voucher.
+ ///
+ Task GetByIdAsync(Guid id);
+
+ ///
+ /// EN: Get campaigns by merchant ID.
+ /// VI: Lấy chiến dịch theo ID merchant.
+ ///
+ Task> GetByMerchantIdAsync(Guid merchantId);
+
+ ///
+ /// EN: Get active campaigns.
+ /// VI: Lấy các chiến dịch đang hoạt động.
+ ///
+ Task> GetActiveAsync();
+
+ ///
+ /// EN: Get voucher by code across all campaigns.
+ /// VI: Lấy voucher theo mã trên tất cả chiến dịch.
+ ///
+ Task GetVoucherByCodeAsync(string code);
+
+ ///
+ /// EN: Get vouchers owned by a user.
+ /// VI: Lấy voucher thuộc về người dùng.
+ ///
+ Task> GetUserVouchersAsync(Guid userId);
+
+ ///
+ /// EN: Add a new campaign.
+ /// VI: Thêm chiến dịch mới.
+ ///
+ Campaign Add(Campaign campaign);
+
+ ///
+ /// EN: Update an existing campaign.
+ /// VI: Cập nhật chiến dịch hiện có.
+ ///
+ void Update(Campaign campaign);
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Voucher.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Voucher.cs
new file mode 100644
index 00000000..e3dc5846
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Voucher.cs
@@ -0,0 +1,199 @@
+namespace PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+using PromotionService.Domain.Exceptions;
+using PromotionService.Domain.SeedWork;
+
+///
+/// EN: Voucher entity representing a single voucher in a campaign.
+/// VI: Entity voucher đại diện cho một voucher trong chiến dịch.
+///
+public class Voucher : Entity
+{
+ ///
+ /// EN: Campaign ID this voucher belongs to.
+ /// VI: ID chiến dịch voucher này thuộc về.
+ ///
+ public Guid CampaignId { get; private set; }
+
+ ///
+ /// EN: Unique voucher code.
+ /// VI: Mã voucher duy nhất.
+ ///
+ public string Code { get; private set; } = null!;
+
+ ///
+ /// EN: User ID who owns this voucher (null if unclaimed).
+ /// VI: ID người dùng sở hữu voucher này (null nếu chưa nhận).
+ ///
+ public Guid? OwnerId { get; private set; }
+
+ ///
+ /// EN: Original face value of voucher.
+ /// VI: Mệnh giá gốc của voucher.
+ ///
+ public decimal FaceValue { get; private set; }
+
+ ///
+ /// EN: Remaining value (after partial redemptions).
+ /// VI: Giá trị còn lại (sau khi sử dụng một phần).
+ ///
+ public decimal RemainingValue { get; private set; }
+
+ ///
+ /// EN: Voucher status.
+ /// VI: Trạng thái voucher.
+ ///
+ public VoucherStatus Status { get; private set; } = null!;
+ public int StatusId { get; private set; }
+
+ ///
+ /// EN: When the voucher was claimed by a user.
+ /// VI: Thời điểm voucher được nhận bởi người dùng.
+ ///
+ public DateTime? ClaimedAt { get; private set; }
+
+ ///
+ /// EN: When the voucher expires.
+ /// VI: Thời điểm voucher hết hạn.
+ ///
+ public DateTime? ExpiresAt { get; private set; }
+
+ ///
+ /// EN: When the voucher was last redeemed.
+ /// VI: Thời điểm voucher được sử dụng lần cuối.
+ ///
+ public DateTime? RedeemedAt { get; private set; }
+
+ ///
+ /// EN: Creation timestamp.
+ /// VI: Thời điểm tạo.
+ ///
+ public DateTime CreatedAt { get; private set; }
+
+ ///
+ /// EN: Last update timestamp.
+ /// VI: Thời điểm cập nhật cuối.
+ ///
+ public DateTime UpdatedAt { get; private set; }
+
+ // EF Core constructor
+ protected Voucher() { }
+
+ ///
+ /// EN: Create a new voucher.
+ /// VI: Tạo voucher mới.
+ ///
+ public Voucher(Guid campaignId, string code, decimal faceValue, int validityDays)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ throw new PromotionDomainException("Voucher code is required");
+ if (faceValue <= 0)
+ throw new PromotionDomainException("Face value must be greater than zero");
+
+ Id = Guid.NewGuid();
+ CampaignId = campaignId;
+ Code = code;
+ FaceValue = faceValue;
+ RemainingValue = faceValue;
+ Status = VoucherStatus.Available;
+ StatusId = Status.Id;
+ CreatedAt = DateTime.UtcNow;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// EN: Claim the voucher for a user.
+ /// VI: Nhận voucher cho người dùng.
+ ///
+ public void Claim(Guid userId, int validityDays)
+ {
+ if (Status != VoucherStatus.Available)
+ throw new VoucherAlreadyClaimedException(Id, Code);
+
+ OwnerId = userId;
+ Status = VoucherStatus.Claimed;
+ StatusId = Status.Id;
+ ClaimedAt = DateTime.UtcNow;
+ ExpiresAt = DateTime.UtcNow.AddDays(validityDays);
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// EN: Redeem a portion of the voucher value.
+ /// VI: Sử dụng một phần giá trị voucher.
+ ///
+ /// Amount to redeem / Số tiền sử dụng
+ /// Actual amount deducted / Số tiền thực tế bị trừ
+ public decimal Redeem(decimal amount)
+ {
+ ValidateCanRedeem();
+
+ if (amount <= 0)
+ throw new PromotionDomainException("Redeem amount must be greater than zero");
+
+ // Calculate actual amount (cannot exceed remaining)
+ var actualAmount = Math.Min(amount, RemainingValue);
+
+ RemainingValue -= actualAmount;
+ RedeemedAt = DateTime.UtcNow;
+ UpdatedAt = DateTime.UtcNow;
+
+ // Update status
+ Status = RemainingValue == 0 ? VoucherStatus.FullyRedeemed : VoucherStatus.PartiallyRedeemed;
+ StatusId = Status.Id;
+
+ return actualAmount;
+ }
+
+ ///
+ /// EN: Mark voucher as expired.
+ /// VI: Đánh dấu voucher hết hạn.
+ ///
+ public void Expire()
+ {
+ if (Status == VoucherStatus.FullyRedeemed)
+ return; // Already fully used
+
+ Status = VoucherStatus.Expired;
+ StatusId = Status.Id;
+ UpdatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// EN: Check if voucher is valid for redemption.
+ /// VI: Kiểm tra voucher có hợp lệ để sử dụng.
+ ///
+ public bool IsValidForRedemption()
+ {
+ return (Status == VoucherStatus.Claimed || Status == VoucherStatus.PartiallyRedeemed)
+ && RemainingValue > 0
+ && (!ExpiresAt.HasValue || ExpiresAt.Value > DateTime.UtcNow);
+ }
+
+ ///
+ /// EN: Check if voucher has expired.
+ /// VI: Kiểm tra voucher đã hết hạn chưa.
+ ///
+ public bool IsExpired => ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value;
+
+ private void ValidateCanRedeem()
+ {
+ if (Status == VoucherStatus.Available)
+ throw new PromotionDomainException("Voucher has not been claimed yet");
+
+ if (Status == VoucherStatus.FullyRedeemed)
+ throw new PromotionDomainException("Voucher has already been fully redeemed");
+
+ if (Status == VoucherStatus.Expired)
+ throw new VoucherExpiredException(Id, Code);
+
+ if (IsExpired)
+ {
+ Expire();
+ throw new VoucherExpiredException(Id, Code);
+ }
+
+ if (RemainingValue <= 0)
+ throw new InsufficientVoucherValueException(Id, 0, 0);
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/VoucherStatus.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/VoucherStatus.cs
new file mode 100644
index 00000000..178b8843
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/VoucherStatus.cs
@@ -0,0 +1,43 @@
+namespace PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+using PromotionService.Domain.SeedWork;
+
+///
+/// EN: Status of a voucher in its lifecycle.
+/// VI: Trạng thái của voucher trong vòng đời.
+///
+public class VoucherStatus : Enumeration
+{
+ ///
+ /// EN: Available - voucher is not claimed yet.
+ /// VI: Sẵn sàng - voucher chưa được nhận.
+ ///
+ public static readonly VoucherStatus Available = new(1, nameof(Available));
+
+ ///
+ /// EN: Claimed - voucher is owned by a user.
+ /// VI: Đã nhận - voucher thuộc về người dùng.
+ ///
+ public static readonly VoucherStatus Claimed = new(2, nameof(Claimed));
+
+ ///
+ /// EN: Partially redeemed - some value has been used.
+ /// VI: Đã dùng một phần - một phần giá trị đã được sử dụng.
+ ///
+ public static readonly VoucherStatus PartiallyRedeemed = new(3, nameof(PartiallyRedeemed));
+
+ ///
+ /// EN: Fully redeemed - all value has been used.
+ /// VI: Đã dùng hết - toàn bộ giá trị đã được sử dụng.
+ ///
+ public static readonly VoucherStatus FullyRedeemed = new(4, nameof(FullyRedeemed));
+
+ ///
+ /// EN: Expired - voucher has expired without full use.
+ /// VI: Hết hạn - voucher đã hết hạn mà chưa dùng hết.
+ ///
+ public static readonly VoucherStatus Expired = new(5, nameof(Expired));
+
+ protected VoucherStatus() : base(0, "Unknown") { }
+ public VoucherStatus(int id, string name) : base(id, name) { }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/IRedemptionRepository.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/IRedemptionRepository.cs
new file mode 100644
index 00000000..e77fe6d5
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/IRedemptionRepository.cs
@@ -0,0 +1,34 @@
+namespace PromotionService.Domain.AggregatesModel.RedemptionAggregate;
+
+using PromotionService.Domain.SeedWork;
+
+///
+/// EN: Repository interface for Redemption aggregate.
+/// VI: Interface repository cho Redemption aggregate.
+///
+public interface IRedemptionRepository : IRepository
+{
+ ///
+ /// EN: Get redemptions by voucher ID.
+ /// VI: Lấy các lần sử dụng theo ID voucher.
+ ///
+ Task> GetByVoucherIdAsync(Guid voucherId);
+
+ ///
+ /// EN: Get redemptions by user ID.
+ /// VI: Lấy các lần sử dụng theo ID người dùng.
+ ///
+ Task> GetByUserIdAsync(Guid userId);
+
+ ///
+ /// EN: Get redemptions by campaign ID.
+ /// VI: Lấy các lần sử dụng theo ID chiến dịch.
+ ///
+ Task> GetByCampaignIdAsync(Guid campaignId);
+
+ ///
+ /// EN: Add a new redemption.
+ /// VI: Thêm lần sử dụng mới.
+ ///
+ Redemption Add(Redemption redemption);
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/Redemption.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/Redemption.cs
new file mode 100644
index 00000000..4fccdbfa
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/Redemption.cs
@@ -0,0 +1,92 @@
+namespace PromotionService.Domain.AggregatesModel.RedemptionAggregate;
+
+using PromotionService.Domain.SeedWork;
+
+///
+/// EN: Redemption Aggregate Root - records voucher redemption history.
+/// VI: Redemption Aggregate Root - ghi lại lịch sử sử dụng voucher.
+///
+public class Redemption : Entity, IAggregateRoot
+{
+ ///
+ /// EN: ID of the voucher that was redeemed.
+ /// VI: ID của voucher được sử dụng.
+ ///
+ public Guid VoucherId { get; private set; }
+
+ ///
+ /// EN: ID of the campaign.
+ /// VI: ID của chiến dịch.
+ ///
+ public Guid CampaignId { get; private set; }
+
+ ///
+ /// EN: ID of the user who redeemed.
+ /// VI: ID của người dùng sử dụng.
+ ///
+ public Guid UserId { get; private set; }
+
+ ///
+ /// EN: Order ID if linked to an order.
+ /// VI: ID đơn hàng nếu liên kết với đơn hàng.
+ ///
+ public Guid? OrderId { get; private set; }
+
+ ///
+ /// EN: Amount used from the voucher.
+ /// VI: Số tiền sử dụng từ voucher.
+ ///
+ public decimal AmountUsed { get; private set; }
+
+ ///
+ /// EN: Amount refunded to merchant (surplus).
+ /// VI: Số tiền hoàn lại cho merchant (dư).
+ ///
+ public decimal AmountRefunded { get; private set; }
+
+ ///
+ /// EN: Execution reference from Wallet Service.
+ /// VI: Tham chiếu thực thi từ Wallet Service.
+ ///
+ public string? ExecutionReference { get; private set; }
+
+ ///
+ /// EN: When the redemption occurred.
+ /// VI: Thời điểm sử dụng.
+ ///
+ public DateTime RedeemedAt { get; private set; }
+
+ ///
+ /// EN: Creation timestamp.
+ /// VI: Thời điểm tạo.
+ ///
+ public DateTime CreatedAt { get; private set; }
+
+ // EF Core constructor
+ protected Redemption() { }
+
+ ///
+ /// EN: Create a redemption record.
+ /// VI: Tạo bản ghi sử dụng.
+ ///
+ public Redemption(
+ Guid voucherId,
+ Guid campaignId,
+ Guid userId,
+ Guid? orderId,
+ decimal amountUsed,
+ decimal amountRefunded,
+ string? executionReference = null)
+ {
+ Id = Guid.NewGuid();
+ VoucherId = voucherId;
+ CampaignId = campaignId;
+ UserId = userId;
+ OrderId = orderId;
+ AmountUsed = amountUsed;
+ AmountRefunded = amountRefunded;
+ ExecutionReference = executionReference;
+ RedeemedAt = DateTime.UtcNow;
+ CreatedAt = DateTime.UtcNow;
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs
new file mode 100644
index 00000000..b8cf88d1
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs
@@ -0,0 +1,61 @@
+using PromotionService.Domain.SeedWork;
+
+namespace PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+///
+/// EN: Repository interface for Sample aggregate.
+/// VI: Interface repository cho Sample aggregate.
+///
+///
+/// EN: Following repository pattern, this interface defines the contract
+/// for data access operations on Sample aggregate.
+/// VI: Theo pattern repository, interface này định nghĩa contract
+/// cho các thao tác truy cập dữ liệu trên Sample aggregate.
+///
+public interface ISampleRepository : IRepository
+{
+ ///
+ /// EN: Get a sample by its ID.
+ /// VI: Lấy một sample theo ID.
+ ///
+ /// EN: The sample ID / VI: ID của sample
+ /// EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy
+ Task GetAsync(Guid sampleId);
+
+ ///
+ /// EN: Get all samples.
+ /// VI: Lấy tất cả samples.
+ ///
+ /// EN: List of samples / VI: Danh sách samples
+ Task> GetAllAsync();
+
+ ///
+ /// EN: Add a new sample.
+ /// VI: Thêm một sample mới.
+ ///
+ /// EN: The sample to add / VI: Sample cần thêm
+ /// EN: The added sample / VI: Sample đã thêm
+ Sample Add(Sample sample);
+
+ ///
+ /// EN: Update an existing sample.
+ /// VI: Cập nhật một sample đã tồn tại.
+ ///
+ /// EN: The sample to update / VI: Sample cần cập nhật
+ void Update(Sample sample);
+
+ ///
+ /// EN: Delete a sample.
+ /// VI: Xóa một sample.
+ ///
+ /// EN: The sample to delete / VI: Sample cần xóa
+ void Delete(Sample sample);
+
+ ///
+ /// EN: Get samples by status.
+ /// VI: Lấy samples theo trạng thái.
+ ///
+ /// EN: The status ID / VI: ID trạng thái
+ /// EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước
+ Task> GetByStatusAsync(int statusId);
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/Sample.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/Sample.cs
new file mode 100644
index 00000000..e9522849
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/Sample.cs
@@ -0,0 +1,158 @@
+using PromotionService.Domain.Events;
+using PromotionService.Domain.Exceptions;
+using PromotionService.Domain.SeedWork;
+
+namespace PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+///
+/// EN: Sample aggregate root demonstrating DDD patterns.
+/// VI: Sample aggregate root minh họa các pattern DDD.
+///
+public class Sample : Entity, IAggregateRoot
+{
+ // EN: Private fields for encapsulation
+ // VI: Fields private để đóng gói
+ private string _name = null!;
+ private string? _description;
+ private SampleStatus _status = null!;
+ private DateTime _createdAt;
+ private DateTime? _updatedAt;
+
+ ///
+ /// EN: Sample name (required).
+ /// VI: Tên sample (bắt buộc).
+ ///
+ public string Name => _name;
+
+ ///
+ /// EN: Optional description.
+ /// VI: Mô tả tùy chọn.
+ ///
+ public string? Description => _description;
+
+ ///
+ /// EN: Current status.
+ /// VI: Trạng thái hiện tại.
+ ///
+ public SampleStatus Status => _status;
+
+ ///
+ /// EN: Status ID for EF Core mapping.
+ /// VI: ID trạng thái cho EF Core mapping.
+ ///
+ public int StatusId { get; private set; }
+
+ ///
+ /// EN: Creation timestamp.
+ /// VI: Thời gian tạo.
+ ///
+ public DateTime CreatedAt => _createdAt;
+
+ ///
+ /// EN: Last update timestamp.
+ /// VI: Thời gian cập nhật cuối.
+ ///
+ public DateTime? UpdatedAt => _updatedAt;
+
+ ///
+ /// EN: Private constructor for EF Core.
+ /// VI: Constructor private cho EF Core.
+ ///
+ protected Sample()
+ {
+ }
+
+ ///
+ /// EN: Create a new Sample with required information.
+ /// VI: Tạo một Sample mới với thông tin bắt buộc.
+ ///
+ /// EN: Sample name / VI: Tên sample
+ /// EN: Optional description / VI: Mô tả tùy chọn
+ public Sample(string name, string? description = null) : this()
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new SampleDomainException("Sample name cannot be empty");
+
+ Id = Guid.NewGuid();
+ _name = name;
+ _description = description;
+ _status = SampleStatus.Draft;
+ StatusId = SampleStatus.Draft.Id;
+ _createdAt = DateTime.UtcNow;
+
+ // EN: Add domain event for creation
+ // VI: Thêm domain event cho việc tạo
+ AddDomainEvent(new SampleCreatedDomainEvent(this));
+ }
+
+ ///
+ /// EN: Update sample information.
+ /// VI: Cập nhật thông tin sample.
+ ///
+ public void Update(string name, string? description)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ throw new SampleDomainException("Sample name cannot be empty");
+
+ if (_status == SampleStatus.Cancelled)
+ throw new SampleDomainException("Cannot update a cancelled sample");
+
+ _name = name;
+ _description = description;
+ _updatedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// EN: Activate the sample.
+ /// VI: Kích hoạt sample.
+ ///
+ public void Activate()
+ {
+ if (_status != SampleStatus.Draft)
+ throw new SampleDomainException("Only draft samples can be activated");
+
+ var previousStatus = _status;
+ _status = SampleStatus.Active;
+ StatusId = SampleStatus.Active.Id;
+ _updatedAt = DateTime.UtcNow;
+
+ AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
+ }
+
+ ///
+ /// EN: Complete the sample.
+ /// VI: Hoàn thành sample.
+ ///
+ public void Complete()
+ {
+ if (_status != SampleStatus.Active)
+ throw new SampleDomainException("Only active samples can be completed");
+
+ var previousStatus = _status;
+ _status = SampleStatus.Completed;
+ StatusId = SampleStatus.Completed.Id;
+ _updatedAt = DateTime.UtcNow;
+
+ AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
+ }
+
+ ///
+ /// EN: Cancel the sample.
+ /// VI: Hủy sample.
+ ///
+ public void Cancel()
+ {
+ if (_status == SampleStatus.Completed)
+ throw new SampleDomainException("Cannot cancel a completed sample");
+
+ if (_status == SampleStatus.Cancelled)
+ throw new SampleDomainException("Sample is already cancelled");
+
+ var previousStatus = _status;
+ _status = SampleStatus.Cancelled;
+ StatusId = SampleStatus.Cancelled.Id;
+ _updatedAt = DateTime.UtcNow;
+
+ AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs
new file mode 100644
index 00000000..4f370b3c
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs
@@ -0,0 +1,77 @@
+using PromotionService.Domain.SeedWork;
+
+namespace PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+///
+/// EN: Sample status enumeration following type-safe enum pattern.
+/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
+///
+public class SampleStatus : Enumeration
+{
+ ///
+ /// EN: Draft status - initial state
+ /// VI: Trạng thái nháp - trạng thái ban đầu
+ ///
+ public static SampleStatus Draft = new(1, nameof(Draft));
+
+ ///
+ /// EN: Active status - ready for use
+ /// VI: Trạng thái hoạt động - sẵn sàng sử dụng
+ ///
+ public static SampleStatus Active = new(2, nameof(Active));
+
+ ///
+ /// EN: Completed status - finished processing
+ /// VI: Trạng thái hoàn thành - đã xử lý xong
+ ///
+ public static SampleStatus Completed = new(3, nameof(Completed));
+
+ ///
+ /// EN: Cancelled status - cancelled by user
+ /// VI: Trạng thái đã hủy - bị hủy bởi người dùng
+ ///
+ public static SampleStatus Cancelled = new(4, nameof(Cancelled));
+
+ public SampleStatus(int id, string name) : base(id, name)
+ {
+ }
+
+ ///
+ /// EN: Get all available statuses.
+ /// VI: Lấy tất cả các trạng thái có sẵn.
+ ///
+ public static IEnumerable List() => GetAll();
+
+ ///
+ /// EN: Parse status from name.
+ /// VI: Parse trạng thái từ tên.
+ ///
+ public static SampleStatus FromName(string name)
+ {
+ var status = List().SingleOrDefault(s =>
+ string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
+
+ if (status is null)
+ {
+ throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
+ }
+
+ return status;
+ }
+
+ ///
+ /// EN: Parse status from ID.
+ /// VI: Parse trạng thái từ ID.
+ ///
+ public static SampleStatus From(int id)
+ {
+ var status = List().SingleOrDefault(s => s.Id == id);
+
+ if (status is null)
+ {
+ throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
+ }
+
+ return status;
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/Events/PromotionDomainEvents.cs b/services/promotion-service-net/src/PromotionService.Domain/Events/PromotionDomainEvents.cs
new file mode 100644
index 00000000..d92c35f3
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/Events/PromotionDomainEvents.cs
@@ -0,0 +1,50 @@
+namespace PromotionService.Domain.Events;
+
+using MediatR;
+
+///
+/// EN: Event raised when a new campaign is created.
+/// VI: Sự kiện được phát ra khi chiến dịch mới được tạo.
+///
+public record CampaignCreatedDomainEvent(
+ Guid CampaignId,
+ Guid MerchantId,
+ string CampaignName) : INotification;
+
+///
+/// EN: Event raised when a campaign is activated.
+/// VI: Sự kiện được phát ra khi chiến dịch được kích hoạt.
+///
+public record CampaignActivatedDomainEvent(
+ Guid CampaignId,
+ Guid MerchantId) : INotification;
+
+///
+/// EN: Event raised when a campaign is cancelled.
+/// VI: Sự kiện được phát ra khi chiến dịch bị hủy.
+///
+public record CampaignCancelledDomainEvent(
+ Guid CampaignId,
+ Guid MerchantId,
+ Guid? EscrowHoldId) : INotification;
+
+///
+/// EN: Event raised when a voucher is claimed by a user.
+/// VI: Sự kiện được phát ra khi voucher được nhận bởi người dùng.
+///
+public record VoucherClaimedDomainEvent(
+ Guid VoucherId,
+ Guid CampaignId,
+ Guid UserId,
+ string VoucherCode) : INotification;
+
+///
+/// EN: Event raised when a voucher is redeemed.
+/// VI: Sự kiện được phát ra khi voucher được sử dụng.
+///
+public record VoucherRedeemedDomainEvent(
+ Guid VoucherId,
+ Guid CampaignId,
+ Guid UserId,
+ decimal AmountUsed,
+ decimal AmountRefunded) : INotification;
diff --git a/services/promotion-service-net/src/PromotionService.Domain/Events/SampleCreatedDomainEvent.cs b/services/promotion-service-net/src/PromotionService.Domain/Events/SampleCreatedDomainEvent.cs
new file mode 100644
index 00000000..06a99e02
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/Events/SampleCreatedDomainEvent.cs
@@ -0,0 +1,22 @@
+using MediatR;
+using PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+namespace PromotionService.Domain.Events;
+
+///
+/// EN: Domain event raised when a new Sample is created.
+/// VI: Domain event được phát ra khi một Sample mới được tạo.
+///
+public class SampleCreatedDomainEvent : INotification
+{
+ ///
+ /// EN: The newly created sample.
+ /// VI: Sample mới được tạo.
+ ///
+ public Sample Sample { get; }
+
+ public SampleCreatedDomainEvent(Sample sample)
+ {
+ Sample = sample;
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/Events/SampleStatusChangedDomainEvent.cs b/services/promotion-service-net/src/PromotionService.Domain/Events/SampleStatusChangedDomainEvent.cs
new file mode 100644
index 00000000..a6129c3b
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/Events/SampleStatusChangedDomainEvent.cs
@@ -0,0 +1,39 @@
+using MediatR;
+using PromotionService.Domain.AggregatesModel.SampleAggregate;
+
+namespace PromotionService.Domain.Events;
+
+///
+/// EN: Domain event raised when Sample status changes.
+/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
+///
+public class SampleStatusChangedDomainEvent : INotification
+{
+ ///
+ /// EN: The sample ID.
+ /// VI: ID của sample.
+ ///
+ public Guid SampleId { get; }
+
+ ///
+ /// EN: Previous status before the change.
+ /// VI: Trạng thái trước khi thay đổi.
+ ///
+ public SampleStatus PreviousStatus { get; }
+
+ ///
+ /// EN: New status after the change.
+ /// VI: Trạng thái mới sau khi thay đổi.
+ ///
+ public SampleStatus NewStatus { get; }
+
+ public SampleStatusChangedDomainEvent(
+ Guid sampleId,
+ SampleStatus previousStatus,
+ SampleStatus newStatus)
+ {
+ SampleId = sampleId;
+ PreviousStatus = previousStatus;
+ NewStatus = newStatus;
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/Exceptions/DomainException.cs b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/DomainException.cs
new file mode 100644
index 00000000..64c4a352
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/DomainException.cs
@@ -0,0 +1,21 @@
+namespace PromotionService.Domain.Exceptions;
+
+///
+/// EN: Base exception for domain errors.
+/// VI: Exception cơ sở cho các lỗi domain.
+///
+public class DomainException : Exception
+{
+ public DomainException()
+ {
+ }
+
+ public DomainException(string message) : base(message)
+ {
+ }
+
+ public DomainException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/Exceptions/PromotionExceptions.cs b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/PromotionExceptions.cs
new file mode 100644
index 00000000..8b79c47b
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/PromotionExceptions.cs
@@ -0,0 +1,80 @@
+namespace PromotionService.Domain.Exceptions;
+
+///
+/// EN: Base exception for Promotion domain errors.
+/// VI: Exception cơ sở cho lỗi domain Promotion.
+///
+public class PromotionDomainException : Exception
+{
+ public PromotionDomainException() { }
+ public PromotionDomainException(string message) : base(message) { }
+ public PromotionDomainException(string message, Exception innerException) : base(message, innerException) { }
+}
+
+///
+/// EN: Exception when trying to operate on a non-active campaign.
+/// VI: Exception khi cố thao tác trên chiến dịch không hoạt động.
+///
+public class CampaignNotActiveException : PromotionDomainException
+{
+ public Guid CampaignId { get; }
+
+ public CampaignNotActiveException(Guid campaignId)
+ : base($"Campaign {campaignId} is not active")
+ {
+ CampaignId = campaignId;
+ }
+}
+
+///
+/// EN: Exception when a voucher has already been claimed.
+/// VI: Exception khi voucher đã được nhận.
+///
+public class VoucherAlreadyClaimedException : PromotionDomainException
+{
+ public Guid VoucherId { get; }
+ public string VoucherCode { get; }
+
+ public VoucherAlreadyClaimedException(Guid voucherId, string code)
+ : base($"Voucher {code} has already been claimed")
+ {
+ VoucherId = voucherId;
+ VoucherCode = code;
+ }
+}
+
+///
+/// EN: Exception when a voucher has expired.
+/// VI: Exception khi voucher đã hết hạn.
+///
+public class VoucherExpiredException : PromotionDomainException
+{
+ public Guid VoucherId { get; }
+ public string VoucherCode { get; }
+
+ public VoucherExpiredException(Guid voucherId, string code)
+ : base($"Voucher {code} has expired")
+ {
+ VoucherId = voucherId;
+ VoucherCode = code;
+ }
+}
+
+///
+/// EN: Exception when voucher has insufficient value for redemption.
+/// VI: Exception khi voucher không đủ giá trị để sử dụng.
+///
+public class InsufficientVoucherValueException : PromotionDomainException
+{
+ public Guid VoucherId { get; }
+ public decimal RemainingValue { get; }
+ public decimal RequestedAmount { get; }
+
+ public InsufficientVoucherValueException(Guid voucherId, decimal remaining, decimal requested)
+ : base($"Voucher has insufficient value. Remaining: {remaining}, Requested: {requested}")
+ {
+ VoucherId = voucherId;
+ RemainingValue = remaining;
+ RequestedAmount = requested;
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/Exceptions/SampleDomainException.cs b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/SampleDomainException.cs
new file mode 100644
index 00000000..7009e045
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/SampleDomainException.cs
@@ -0,0 +1,21 @@
+namespace PromotionService.Domain.Exceptions;
+
+///
+/// EN: Exception for Sample aggregate domain errors.
+/// VI: Exception cho các lỗi domain của Sample aggregate.
+///
+public class SampleDomainException : DomainException
+{
+ public SampleDomainException()
+ {
+ }
+
+ public SampleDomainException(string message) : base(message)
+ {
+ }
+
+ public SampleDomainException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/PromotionService.Domain.csproj b/services/promotion-service-net/src/PromotionService.Domain/PromotionService.Domain.csproj
new file mode 100644
index 00000000..f3ebeca4
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/PromotionService.Domain.csproj
@@ -0,0 +1,14 @@
+
+
+
+ PromotionService.Domain
+ PromotionService.Domain
+ Domain layer containing core business logic and entities
+
+
+
+
+
+
+
+
diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Entity.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Entity.cs
new file mode 100644
index 00000000..c36c2679
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Entity.cs
@@ -0,0 +1,102 @@
+using MediatR;
+
+namespace PromotionService.Domain.SeedWork;
+
+///
+/// EN: Base class for all domain entities.
+/// VI: Lớp cơ sở cho tất cả các entity trong domain.
+///
+public abstract class Entity
+{
+ private int? _requestedHashCode;
+ private Guid _id;
+ private List _domainEvents = new();
+
+ ///
+ /// EN: Unique identifier for the entity.
+ /// VI: Định danh duy nhất cho entity.
+ ///
+ public virtual Guid Id
+ {
+ get => _id;
+ protected set => _id = value;
+ }
+
+ ///
+ /// EN: Domain events raised by this entity.
+ /// VI: Các domain event được phát ra bởi entity này.
+ ///
+ public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly();
+
+ ///
+ /// EN: Add a domain event to be dispatched.
+ /// VI: Thêm một domain event để dispatch.
+ ///
+ public void AddDomainEvent(INotification eventItem)
+ {
+ _domainEvents.Add(eventItem);
+ }
+
+ ///
+ /// EN: Remove a domain event.
+ /// VI: Xóa một domain event.
+ ///
+ public void RemoveDomainEvent(INotification eventItem)
+ {
+ _domainEvents.Remove(eventItem);
+ }
+
+ ///
+ /// EN: Clear all domain events.
+ /// VI: Xóa tất cả domain events.
+ ///
+ public void ClearDomainEvents()
+ {
+ _domainEvents.Clear();
+ }
+
+ ///
+ /// EN: Check if entity is transient (not persisted yet).
+ /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không.
+ ///
+ public bool IsTransient()
+ {
+ return Id == default;
+ }
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not Entity item)
+ return false;
+
+ if (ReferenceEquals(this, item))
+ return true;
+
+ if (GetType() != item.GetType())
+ return false;
+
+ if (item.IsTransient() || IsTransient())
+ return false;
+
+ return item.Id == Id;
+ }
+
+ public override int GetHashCode()
+ {
+ if (IsTransient())
+ return base.GetHashCode();
+
+ _requestedHashCode ??= Id.GetHashCode() ^ 31;
+ return _requestedHashCode.Value;
+ }
+
+ public static bool operator ==(Entity? left, Entity? right)
+ {
+ return left?.Equals(right) ?? right is null;
+ }
+
+ public static bool operator !=(Entity? left, Entity? right)
+ {
+ return !(left == right);
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Enumeration.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Enumeration.cs
new file mode 100644
index 00000000..610905f8
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Enumeration.cs
@@ -0,0 +1,95 @@
+using System.Reflection;
+
+namespace PromotionService.Domain.SeedWork;
+
+///
+/// EN: Base class for enumeration classes (type-safe enum pattern).
+/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu).
+///
+///
+/// EN: This provides a type-safe alternative to enums with additional functionality
+/// like validation, parsing, and rich behavior.
+/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung
+/// như validation, parsing, và hành vi phong phú.
+///
+public abstract class Enumeration : IComparable
+{
+ ///
+ /// EN: The name of the enumeration value.
+ /// VI: Tên của giá trị enumeration.
+ ///
+ public string Name { get; private set; }
+
+ ///
+ /// EN: The unique identifier of the enumeration value.
+ /// VI: Định danh duy nhất của giá trị enumeration.
+ ///
+ public int Id { get; private set; }
+
+ protected Enumeration(int id, string name) => (Id, Name) = (id, name);
+
+ public override string ToString() => Name;
+
+ ///
+ /// EN: Get all enumeration values of a given type.
+ /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước.
+ ///
+ public static IEnumerable GetAll() where T : Enumeration =>
+ typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
+ .Select(f => f.GetValue(null))
+ .Cast();
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not Enumeration otherValue)
+ return false;
+
+ var typeMatches = GetType() == obj.GetType();
+ var valueMatches = Id.Equals(otherValue.Id);
+
+ return typeMatches && valueMatches;
+ }
+
+ public override int GetHashCode() => Id.GetHashCode();
+
+ ///
+ /// EN: Get absolute difference between two enumeration values.
+ /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration.
+ ///
+ public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue)
+ {
+ return Math.Abs(firstValue.Id - secondValue.Id);
+ }
+
+ ///
+ /// EN: Parse an integer ID to the corresponding enumeration value.
+ /// VI: Parse một ID integer thành giá trị enumeration tương ứng.
+ ///
+ public static T FromValue(int value) where T : Enumeration
+ {
+ var matchingItem = Parse(value, "value", item => item.Id == value);
+ return matchingItem;
+ }
+
+ ///
+ /// EN: Parse a display name to the corresponding enumeration value.
+ /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng.
+ ///
+ public static T FromDisplayName(string displayName) where T : Enumeration
+ {
+ var matchingItem = Parse(displayName, "display name", item => item.Name == displayName);
+ return matchingItem;
+ }
+
+ private static T Parse(TValue value, string description, Func predicate) where T : Enumeration
+ {
+ var matchingItem = GetAll().FirstOrDefault(predicate);
+
+ if (matchingItem is null)
+ throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}");
+
+ return matchingItem;
+ }
+
+ public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id);
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IAggregateRoot.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IAggregateRoot.cs
new file mode 100644
index 00000000..c7844a53
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IAggregateRoot.cs
@@ -0,0 +1,15 @@
+namespace PromotionService.Domain.SeedWork;
+
+///
+/// EN: Marker interface for aggregate roots.
+/// VI: Interface đánh dấu cho aggregate roots.
+///
+///
+/// EN: Aggregate roots are the entry points to aggregates and are the only objects
+/// that outside code should hold references to.
+/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất
+/// mà code bên ngoài nên giữ tham chiếu đến.
+///
+public interface IAggregateRoot
+{
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IRepository.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IRepository.cs
new file mode 100644
index 00000000..635b2e53
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IRepository.cs
@@ -0,0 +1,15 @@
+namespace PromotionService.Domain.SeedWork;
+
+///
+/// EN: Generic repository interface for aggregate roots.
+/// VI: Interface repository generic cho aggregate roots.
+///
+/// EN: The aggregate root type / VI: Kiểu aggregate root
+public interface IRepository where T : IAggregateRoot
+{
+ ///
+ /// EN: The unit of work for this repository.
+ /// VI: Unit of work cho repository này.
+ ///
+ IUnitOfWork UnitOfWork { get; }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IUnitOfWork.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IUnitOfWork.cs
new file mode 100644
index 00000000..6a2217da
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IUnitOfWork.cs
@@ -0,0 +1,30 @@
+namespace PromotionService.Domain.SeedWork;
+
+///
+/// EN: Unit of Work pattern interface.
+/// VI: Interface cho Unit of Work pattern.
+///
+///
+/// EN: Maintains a list of objects affected by a business transaction
+/// and coordinates the writing out of changes.
+/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ
+/// và điều phối việc ghi các thay đổi.
+///
+public interface IUnitOfWork : IDisposable
+{
+ ///
+ /// EN: Save all changes made in this unit of work.
+ /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này.
+ ///
+ /// EN: Cancellation token / VI: Token hủy
+ /// EN: Number of entities written / VI: Số entity đã ghi
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// EN: Save all changes and dispatch domain events.
+ /// VI: Lưu tất cả thay đổi và dispatch domain events.
+ ///
+ /// EN: Cancellation token / VI: Token hủy
+ /// EN: True if successful / VI: True nếu thành công
+ Task SaveEntitiesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/ValueObject.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/ValueObject.cs
new file mode 100644
index 00000000..fd714e0c
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/ValueObject.cs
@@ -0,0 +1,53 @@
+namespace PromotionService.Domain.SeedWork;
+
+///
+/// EN: Base class for Value Objects following DDD patterns.
+/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD.
+///
+///
+/// EN: Value objects are immutable and compared by their values, not identity.
+/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh.
+///
+public abstract class ValueObject
+{
+ ///
+ /// EN: Get the atomic values that make up this value object.
+ /// VI: Lấy các giá trị nguyên tử tạo nên value object này.
+ ///
+ protected abstract IEnumerable