diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/CampaignCommandHandlers.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CampaignCommandHandlers.cs
new file mode 100644
index 00000000..bd43d61b
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CampaignCommandHandlers.cs
@@ -0,0 +1,227 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using PromotionService.API.Application.DTOs;
+using PromotionService.API.Application.Services;
+using PromotionService.Domain.AggregatesModel.CampaignAggregate;
+using PromotionService.Domain.Exceptions;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Handler for CreateCampaignCommand.
+/// VI: Handler cho CreateCampaignCommand.
+///
+public class CreateCampaignCommandHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+ private readonly IWalletServiceClient _walletService;
+ private readonly ILogger _logger;
+
+ public CreateCampaignCommandHandler(
+ ICampaignRepository campaignRepository,
+ IWalletServiceClient walletService,
+ ILogger logger)
+ {
+ _campaignRepository = campaignRepository;
+ _walletService = walletService;
+ _logger = logger;
+ }
+
+ public async Task Handle(CreateCampaignCommand request, CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Creating campaign {Name} for merchant {MerchantId}",
+ request.Name, request.MerchantId);
+
+ // Parse enums
+ var backingAssetType = request.BackingAssetType.ToLower() == "point"
+ ? AssetType.Point
+ : AssetType.Currency;
+
+ var acquisitionType = request.AcquisitionType.ToLower() switch
+ {
+ "free" => AcquisitionType.Free,
+ "exchangepoints" => AcquisitionType.ExchangePoints,
+ "purchase" => AcquisitionType.Purchase,
+ _ => throw new PromotionDomainException($"Invalid acquisition type: {request.AcquisitionType}")
+ };
+
+ // Create campaign aggregate
+ var campaign = new Campaign(
+ request.MerchantId,
+ request.Name,
+ request.Description,
+ backingAssetType,
+ request.BackingAssetCode,
+ request.FaceValue,
+ acquisitionType,
+ request.AcquisitionPrice,
+ request.TotalVouchers,
+ request.StartDate,
+ request.EndDate,
+ request.VoucherValidityDays,
+ request.MaxPerUser);
+
+ // Create escrow hold in Wallet Service
+ var escrowAmount = request.FaceValue * request.TotalVouchers;
+ _logger.LogInformation("Creating escrow hold for {Amount} {Currency}",
+ escrowAmount, request.BackingAssetCode);
+
+ var holdResult = await _walletService.CreateHoldAsync(
+ request.MerchantWalletId,
+ escrowAmount,
+ request.BackingAssetCode,
+ "CAMPAIGN",
+ campaign.Id,
+ $"Campaign escrow: {request.Name}",
+ cancellationToken);
+
+ // Set escrow reference on campaign
+ campaign.SetEscrowHold(holdResult.WalletId, holdResult.HoldId);
+
+ // Generate voucher codes
+ campaign.GenerateVouchers(request.TotalVouchers);
+
+ // Persist
+ _campaignRepository.Add(campaign);
+ await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation("Campaign {CampaignId} created with {VoucherCount} vouchers",
+ campaign.Id, request.TotalVouchers);
+
+ return MapToDto(campaign);
+ }
+
+ private static CampaignDto MapToDto(Campaign campaign) => new(
+ campaign.Id,
+ campaign.MerchantId,
+ campaign.Name,
+ campaign.Description,
+ campaign.BackingAssetTypeId == AssetType.Point.Id ? "Point" : "Currency",
+ campaign.BackingAssetCode,
+ campaign.FaceValue,
+ campaign.AcquisitionTypeId == AcquisitionType.Free.Id ? "Free"
+ : campaign.AcquisitionTypeId == AcquisitionType.ExchangePoints.Id ? "ExchangePoints"
+ : "Purchase",
+ campaign.AcquisitionPrice,
+ campaign.TotalVouchers,
+ campaign.IssuedVouchers,
+ campaign.AvailableVoucherCount,
+ campaign.StartDate,
+ campaign.EndDate,
+ campaign.VoucherValidityDays,
+ campaign.StatusId == CampaignStatus.Draft.Id ? "Draft"
+ : campaign.StatusId == CampaignStatus.Active.Id ? "Active"
+ : campaign.StatusId == CampaignStatus.Paused.Id ? "Paused"
+ : campaign.StatusId == CampaignStatus.Completed.Id ? "Completed"
+ : "Cancelled",
+ campaign.CreatedAt,
+ campaign.UpdatedAt);
+}
+
+///
+/// EN: Handler for ActivateCampaignCommand.
+/// VI: Handler cho ActivateCampaignCommand.
+///
+public class ActivateCampaignCommandHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+ private readonly ILogger _logger;
+
+ public ActivateCampaignCommandHandler(
+ ICampaignRepository campaignRepository,
+ ILogger logger)
+ {
+ _campaignRepository = campaignRepository;
+ _logger = logger;
+ }
+
+ public async Task Handle(ActivateCampaignCommand request, CancellationToken cancellationToken)
+ {
+ var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
+ ?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
+
+ campaign.Activate();
+ _campaignRepository.Update(campaign);
+ await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation("Campaign {CampaignId} activated", request.CampaignId);
+ return true;
+ }
+}
+
+///
+/// EN: Handler for PauseCampaignCommand.
+/// VI: Handler cho PauseCampaignCommand.
+///
+public class PauseCampaignCommandHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+ private readonly ILogger _logger;
+
+ public PauseCampaignCommandHandler(
+ ICampaignRepository campaignRepository,
+ ILogger logger)
+ {
+ _campaignRepository = campaignRepository;
+ _logger = logger;
+ }
+
+ public async Task Handle(PauseCampaignCommand request, CancellationToken cancellationToken)
+ {
+ var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
+ ?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
+
+ campaign.Pause();
+ _campaignRepository.Update(campaign);
+ await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation("Campaign {CampaignId} paused", request.CampaignId);
+ return true;
+ }
+}
+
+///
+/// EN: Handler for CancelCampaignCommand.
+/// VI: Handler cho CancelCampaignCommand.
+///
+public class CancelCampaignCommandHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+ private readonly IWalletServiceClient _walletService;
+ private readonly ILogger _logger;
+
+ public CancelCampaignCommandHandler(
+ ICampaignRepository campaignRepository,
+ IWalletServiceClient walletService,
+ ILogger logger)
+ {
+ _campaignRepository = campaignRepository;
+ _walletService = walletService;
+ _logger = logger;
+ }
+
+ public async Task Handle(CancelCampaignCommand request, CancellationToken cancellationToken)
+ {
+ var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
+ ?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
+
+ // Release escrow if exists
+ if (campaign.EscrowHoldId.HasValue && campaign.EscrowWalletId.HasValue)
+ {
+ _logger.LogInformation("Releasing escrow hold {HoldId} for campaign {CampaignId}",
+ campaign.EscrowHoldId, request.CampaignId);
+
+ await _walletService.CancelHoldAsync(
+ campaign.EscrowWalletId.Value,
+ campaign.EscrowHoldId.Value,
+ cancellationToken);
+ }
+
+ campaign.Cancel();
+ _campaignRepository.Update(campaign);
+ await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation("Campaign {CampaignId} cancelled", request.CampaignId);
+ return true;
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/CampaignCommands.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CampaignCommands.cs
new file mode 100644
index 00000000..60f90153
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CampaignCommands.cs
@@ -0,0 +1,42 @@
+using MediatR;
+using PromotionService.API.Application.DTOs;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Command to create a new campaign.
+/// VI: Command để tạo chiến dịch mới.
+///
+public record CreateCampaignCommand(
+ Guid MerchantId,
+ Guid MerchantWalletId,
+ string Name,
+ string? Description,
+ string BackingAssetType,
+ string BackingAssetCode,
+ decimal FaceValue,
+ string AcquisitionType,
+ decimal AcquisitionPrice,
+ int TotalVouchers,
+ DateTime StartDate,
+ DateTime EndDate,
+ int VoucherValidityDays = 30,
+ int MaxPerUser = 1) : IRequest;
+
+///
+/// EN: Command to activate a campaign.
+/// VI: Command để kích hoạt chiến dịch.
+///
+public record ActivateCampaignCommand(Guid CampaignId) : IRequest;
+
+///
+/// EN: Command to pause a campaign.
+/// VI: Command để tạm dừng chiến dịch.
+///
+public record PauseCampaignCommand(Guid CampaignId) : IRequest;
+
+///
+/// EN: Command to cancel a campaign.
+/// VI: Command để hủy chiến dịch.
+///
+public record CancelCampaignCommand(Guid CampaignId) : IRequest;
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
deleted file mode 100644
index 3058d610..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommand.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-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
deleted file mode 100644
index a3b62f3c..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-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
deleted file mode 100644
index eb5f9405..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommand.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 32877a24..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommandHandler.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-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
deleted file mode 100644
index 687b6323..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommand.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index 8688808e..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommandHandler.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-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
deleted file mode 100644
index c9125e91..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommand.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index 5dd0e84b..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommandHandler.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-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/Commands/VoucherCommandHandlers.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/VoucherCommandHandlers.cs
new file mode 100644
index 00000000..3c0a976c
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/VoucherCommandHandlers.cs
@@ -0,0 +1,171 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using PromotionService.API.Application.DTOs;
+using PromotionService.API.Application.Services;
+using PromotionService.Domain.AggregatesModel.CampaignAggregate;
+using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
+using PromotionService.Domain.Events;
+using PromotionService.Domain.Exceptions;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Handler for ClaimVoucherCommand (free vouchers).
+/// VI: Handler cho ClaimVoucherCommand (voucher miễn phí).
+///
+public class ClaimVoucherCommandHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+ private readonly ILogger _logger;
+
+ public ClaimVoucherCommandHandler(
+ ICampaignRepository campaignRepository,
+ ILogger logger)
+ {
+ _campaignRepository = campaignRepository;
+ _logger = logger;
+ }
+
+ public async Task Handle(ClaimVoucherCommand request, CancellationToken cancellationToken)
+ {
+ var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
+ ?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
+
+ // Verify it's a free campaign
+ if (campaign.AcquisitionTypeId != AcquisitionType.Free.Id)
+ throw new PromotionDomainException("This campaign requires payment or point exchange");
+
+ var voucher = campaign.IssueVoucher(request.UserId);
+ _campaignRepository.Update(campaign);
+ await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation("Voucher {VoucherCode} claimed by user {UserId}", voucher.Code, request.UserId);
+
+ return MapToDto(voucher);
+ }
+
+ private static VoucherDto MapToDto(Voucher voucher) => new(
+ voucher.Id,
+ voucher.CampaignId,
+ voucher.Code,
+ voucher.OwnerId,
+ voucher.FaceValue,
+ voucher.RemainingValue,
+ voucher.StatusId == VoucherStatus.Available.Id ? "Available"
+ : voucher.StatusId == VoucherStatus.Claimed.Id ? "Claimed"
+ : voucher.StatusId == VoucherStatus.PartiallyRedeemed.Id ? "PartiallyRedeemed"
+ : voucher.StatusId == VoucherStatus.FullyRedeemed.Id ? "FullyRedeemed"
+ : "Expired",
+ voucher.ClaimedAt,
+ voucher.ExpiresAt,
+ voucher.RedeemedAt);
+}
+
+///
+/// EN: Handler for RedeemVoucherCommand.
+/// VI: Handler cho RedeemVoucherCommand.
+///
+public class RedeemVoucherCommandHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+ private readonly IRedemptionRepository _redemptionRepository;
+ private readonly IWalletServiceClient _walletService;
+ private readonly ILogger _logger;
+
+ public RedeemVoucherCommandHandler(
+ ICampaignRepository campaignRepository,
+ IRedemptionRepository redemptionRepository,
+ IWalletServiceClient walletService,
+ ILogger logger)
+ {
+ _campaignRepository = campaignRepository;
+ _redemptionRepository = redemptionRepository;
+ _walletService = walletService;
+ _logger = logger;
+ }
+
+ public async Task Handle(RedeemVoucherCommand request, CancellationToken cancellationToken)
+ {
+ // Find voucher by code
+ var voucher = await _campaignRepository.GetVoucherByCodeAsync(request.VoucherCode)
+ ?? throw new PromotionDomainException($"Voucher {request.VoucherCode} not found");
+
+ // Verify ownership
+ if (voucher.OwnerId != request.UserId)
+ throw new PromotionDomainException("You do not own this voucher");
+
+ // Validate voucher can be redeemed
+ if (!voucher.IsValidForRedemption())
+ {
+ if (voucher.IsExpired)
+ throw new VoucherExpiredException(voucher.Id, voucher.Code);
+ throw new PromotionDomainException("Voucher cannot be redeemed");
+ }
+
+ // Get campaign for escrow info
+ var campaign = await _campaignRepository.GetByIdAsync(voucher.CampaignId)
+ ?? throw new PromotionDomainException("Campaign not found");
+
+ // Calculate amounts
+ var orderAmount = request.OrderAmount;
+ var voucherValue = voucher.RemainingValue;
+ var amountUsed = Math.Min(orderAmount, voucherValue);
+ var amountRefunded = voucherValue - amountUsed; // Surplus goes back to merchant
+
+ _logger.LogInformation("Redeeming voucher {VoucherCode}: OrderAmount={OrderAmount}, VoucherValue={VoucherValue}, AmountUsed={AmountUsed}, Refund={Refund}",
+ request.VoucherCode, orderAmount, voucherValue, amountUsed, amountRefunded);
+
+ // Execute escrow for amount used
+ if (campaign.EscrowHoldId.HasValue && campaign.EscrowWalletId.HasValue)
+ {
+ var executionRef = $"REDEEM:{voucher.Code}:{request.OrderId}";
+
+ await _walletService.ExecuteHoldAsync(
+ campaign.EscrowWalletId.Value,
+ campaign.EscrowHoldId.Value,
+ amountUsed,
+ executionRef,
+ cancellationToken);
+
+ // Release surplus back to merchant if any
+ if (amountRefunded > 0)
+ {
+ await _walletService.ReleaseHoldAsync(
+ campaign.EscrowWalletId.Value,
+ campaign.EscrowHoldId.Value,
+ amountRefunded,
+ cancellationToken);
+ }
+ }
+
+ // Update voucher
+ voucher.Redeem(voucherValue); // Redeem full value
+
+ // Create redemption record
+ var redemption = new Redemption(
+ voucher.Id,
+ voucher.CampaignId,
+ request.UserId,
+ request.OrderId,
+ amountUsed,
+ amountRefunded,
+ $"REDEEM:{voucher.Code}");
+
+ _redemptionRepository.Add(redemption);
+ _campaignRepository.Update(campaign);
+ await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
+
+ _logger.LogInformation("Voucher {VoucherCode} redeemed, AmountUsed={AmountUsed}",
+ voucher.Code, amountUsed);
+
+ return new RedemptionDto(
+ redemption.Id,
+ redemption.VoucherId,
+ redemption.CampaignId,
+ redemption.UserId,
+ redemption.OrderId,
+ redemption.AmountUsed,
+ redemption.AmountRefunded,
+ redemption.RedeemedAt);
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/VoucherCommands.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/VoucherCommands.cs
new file mode 100644
index 00000000..3391163c
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/VoucherCommands.cs
@@ -0,0 +1,40 @@
+using MediatR;
+using PromotionService.API.Application.DTOs;
+
+namespace PromotionService.API.Application.Commands;
+
+///
+/// EN: Command to claim a free voucher.
+/// VI: Command để nhận voucher miễn phí.
+///
+public record ClaimVoucherCommand(
+ Guid CampaignId,
+ Guid UserId) : IRequest;
+
+///
+/// EN: Command to exchange points for a voucher.
+/// VI: Command để đổi điểm lấy voucher.
+///
+public record ExchangeVoucherCommand(
+ Guid CampaignId,
+ Guid UserId,
+ Guid UserWalletId) : IRequest;
+
+///
+/// EN: Command to purchase a voucher.
+/// VI: Command để mua voucher.
+///
+public record PurchaseVoucherCommand(
+ Guid CampaignId,
+ Guid UserId,
+ Guid UserWalletId) : IRequest;
+
+///
+/// EN: Command to redeem a voucher for an order.
+/// VI: Command để sử dụng voucher cho đơn hàng.
+///
+public record RedeemVoucherCommand(
+ string VoucherCode,
+ Guid UserId,
+ Guid? OrderId,
+ decimal OrderAmount) : IRequest;
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/DTOs/CampaignDtos.cs b/services/promotion-service-net/src/PromotionService.API/Application/DTOs/CampaignDtos.cs
new file mode 100644
index 00000000..e10e0129
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/DTOs/CampaignDtos.cs
@@ -0,0 +1,53 @@
+namespace PromotionService.API.Application.DTOs;
+
+///
+/// EN: Campaign DTO for API responses.
+/// VI: DTO Campaign cho các phản hồi API.
+///
+public record CampaignDto(
+ Guid Id,
+ Guid MerchantId,
+ string Name,
+ string? Description,
+ string BackingAssetType,
+ string BackingAssetCode,
+ decimal FaceValue,
+ string AcquisitionType,
+ decimal AcquisitionPrice,
+ int TotalVouchers,
+ int IssuedVouchers,
+ int AvailableVouchers,
+ DateTime StartDate,
+ DateTime EndDate,
+ int VoucherValidityDays,
+ string Status,
+ DateTime CreatedAt,
+ DateTime UpdatedAt);
+
+///
+/// EN: Campaign summary DTO for list responses.
+/// VI: DTO tóm tắt Campaign cho các phản hồi danh sách.
+///
+public record CampaignSummaryDto(
+ Guid Id,
+ string Name,
+ string Status,
+ int TotalVouchers,
+ int IssuedVouchers,
+ DateTime StartDate,
+ DateTime EndDate);
+
+///
+/// EN: Campaign statistics DTO.
+/// VI: DTO thống kê Campaign.
+///
+public record CampaignStatisticsDto(
+ Guid CampaignId,
+ string CampaignName,
+ int TotalVouchers,
+ int AvailableVouchers,
+ int ClaimedVouchers,
+ int RedeemedVouchers,
+ decimal TotalFaceValue,
+ decimal TotalRedeemedValue,
+ decimal UtilizationRate);
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/DTOs/VoucherDtos.cs b/services/promotion-service-net/src/PromotionService.API/Application/DTOs/VoucherDtos.cs
new file mode 100644
index 00000000..ac492dc6
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/DTOs/VoucherDtos.cs
@@ -0,0 +1,55 @@
+namespace PromotionService.API.Application.DTOs;
+
+///
+/// EN: Voucher DTO for API responses.
+/// VI: DTO Voucher cho các phản hồi API.
+///
+public record VoucherDto(
+ Guid Id,
+ Guid CampaignId,
+ string Code,
+ Guid? OwnerId,
+ decimal FaceValue,
+ decimal RemainingValue,
+ string Status,
+ DateTime? ClaimedAt,
+ DateTime? ExpiresAt,
+ DateTime? RedeemedAt);
+
+///
+/// EN: Voucher summary DTO for list responses.
+/// VI: DTO tóm tắt Voucher cho các phản hồi danh sách.
+///
+public record VoucherSummaryDto(
+ Guid Id,
+ string Code,
+ decimal RemainingValue,
+ string Status,
+ DateTime? ExpiresAt);
+
+///
+/// EN: Voucher validation result.
+/// VI: Kết quả xác thực voucher.
+///
+public record VoucherValidationDto(
+ bool IsValid,
+ string? ErrorMessage,
+ Guid? VoucherId,
+ string? VoucherCode,
+ decimal? RemainingValue,
+ DateTime? ExpiresAt,
+ string? CampaignName);
+
+///
+/// EN: Redemption DTO.
+/// VI: DTO Redemption.
+///
+public record RedemptionDto(
+ Guid Id,
+ Guid VoucherId,
+ Guid CampaignId,
+ Guid UserId,
+ Guid? OrderId,
+ decimal AmountUsed,
+ decimal AmountRefunded,
+ DateTime RedeemedAt);
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
deleted file mode 100644
index fcf57b5d..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQuery.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-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
deleted file mode 100644
index a5d87a7f..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQueryHandler.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-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
deleted file mode 100644
index 63988838..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQuery.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index 059f9176..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQueryHandler.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-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/Queries/PromotionQueries.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/PromotionQueries.cs
new file mode 100644
index 00000000..28935e22
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/PromotionQueries.cs
@@ -0,0 +1,34 @@
+using MediatR;
+using PromotionService.API.Application.DTOs;
+
+namespace PromotionService.API.Application.Queries;
+
+///
+/// EN: Query to get a campaign by ID.
+/// VI: Query để lấy chiến dịch theo ID.
+///
+public record GetCampaignQuery(Guid CampaignId) : IRequest;
+
+///
+/// EN: Query to get campaigns by merchant.
+/// VI: Query để lấy chiến dịch theo merchant.
+///
+public record GetCampaignsQuery(Guid? MerchantId = null, bool ActiveOnly = false) : IRequest>;
+
+///
+/// EN: Query to get campaign statistics.
+/// VI: Query để lấy thống kê chiến dịch.
+///
+public record GetCampaignStatisticsQuery(Guid CampaignId) : IRequest;
+
+///
+/// EN: Query to validate a voucher code.
+/// VI: Query để xác thực mã voucher.
+///
+public record ValidateVoucherQuery(string VoucherCode, Guid UserId) : IRequest;
+
+///
+/// EN: Query to get user's vouchers.
+/// VI: Query để lấy voucher của người dùng.
+///
+public record GetUserVouchersQuery(Guid UserId) : IRequest>;
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Queries/PromotionQueryHandlers.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/PromotionQueryHandlers.cs
new file mode 100644
index 00000000..88fdfa19
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/PromotionQueryHandlers.cs
@@ -0,0 +1,138 @@
+using MediatR;
+using PromotionService.API.Application.DTOs;
+using PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+namespace PromotionService.API.Application.Queries;
+
+///
+/// EN: Handler for GetCampaignQuery.
+/// VI: Handler cho GetCampaignQuery.
+///
+public class GetCampaignQueryHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+
+ public GetCampaignQueryHandler(ICampaignRepository campaignRepository)
+ {
+ _campaignRepository = campaignRepository;
+ }
+
+ public async Task Handle(GetCampaignQuery request, CancellationToken cancellationToken)
+ {
+ var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId);
+ return campaign == null ? null : MapToDto(campaign);
+ }
+
+ private static CampaignDto MapToDto(Campaign c) => new(
+ c.Id, c.MerchantId, c.Name, c.Description,
+ c.BackingAssetTypeId == AssetType.Point.Id ? "Point" : "Currency",
+ c.BackingAssetCode, c.FaceValue,
+ c.AcquisitionTypeId == AcquisitionType.Free.Id ? "Free"
+ : c.AcquisitionTypeId == AcquisitionType.ExchangePoints.Id ? "ExchangePoints" : "Purchase",
+ c.AcquisitionPrice, c.TotalVouchers, c.IssuedVouchers, c.AvailableVoucherCount,
+ c.StartDate, c.EndDate, c.VoucherValidityDays,
+ MapStatus(c.StatusId), c.CreatedAt, c.UpdatedAt);
+
+ private static string MapStatus(int statusId) => statusId switch
+ {
+ 1 => "Draft", 2 => "Active", 3 => "Paused", 4 => "Completed", _ => "Cancelled"
+ };
+}
+
+///
+/// EN: Handler for GetCampaignsQuery.
+/// VI: Handler cho GetCampaignsQuery.
+///
+public class GetCampaignsQueryHandler : IRequestHandler>
+{
+ private readonly ICampaignRepository _campaignRepository;
+
+ public GetCampaignsQueryHandler(ICampaignRepository campaignRepository)
+ {
+ _campaignRepository = campaignRepository;
+ }
+
+ public async Task> Handle(GetCampaignsQuery request, CancellationToken cancellationToken)
+ {
+ IEnumerable campaigns;
+
+ if (request.ActiveOnly)
+ {
+ campaigns = await _campaignRepository.GetActiveAsync();
+ }
+ else if (request.MerchantId.HasValue)
+ {
+ campaigns = await _campaignRepository.GetByMerchantIdAsync(request.MerchantId.Value);
+ }
+ else
+ {
+ campaigns = await _campaignRepository.GetActiveAsync();
+ }
+
+ return campaigns.Select(c => new CampaignSummaryDto(
+ c.Id, c.Name,
+ c.StatusId == 1 ? "Draft" : c.StatusId == 2 ? "Active" : c.StatusId == 3 ? "Paused" : c.StatusId == 4 ? "Completed" : "Cancelled",
+ c.TotalVouchers, c.IssuedVouchers, c.StartDate, c.EndDate));
+ }
+}
+
+///
+/// EN: Handler for ValidateVoucherQuery.
+/// VI: Handler cho ValidateVoucherQuery.
+///
+public class ValidateVoucherQueryHandler : IRequestHandler
+{
+ private readonly ICampaignRepository _campaignRepository;
+
+ public ValidateVoucherQueryHandler(ICampaignRepository campaignRepository)
+ {
+ _campaignRepository = campaignRepository;
+ }
+
+ public async Task Handle(ValidateVoucherQuery request, CancellationToken cancellationToken)
+ {
+ var voucher = await _campaignRepository.GetVoucherByCodeAsync(request.VoucherCode);
+
+ if (voucher == null)
+ return new VoucherValidationDto(false, "Voucher not found", null, null, null, null, null);
+
+ if (voucher.OwnerId != request.UserId)
+ return new VoucherValidationDto(false, "You do not own this voucher", voucher.Id, voucher.Code, null, null, null);
+
+ if (!voucher.IsValidForRedemption())
+ {
+ var errorMsg = voucher.IsExpired ? "Voucher has expired" : "Voucher cannot be redeemed";
+ return new VoucherValidationDto(false, errorMsg, voucher.Id, voucher.Code, voucher.RemainingValue, voucher.ExpiresAt, null);
+ }
+
+ var campaign = await _campaignRepository.GetByIdAsync(voucher.CampaignId);
+
+ return new VoucherValidationDto(
+ true, null, voucher.Id, voucher.Code,
+ voucher.RemainingValue, voucher.ExpiresAt, campaign?.Name);
+ }
+}
+
+///
+/// EN: Handler for GetUserVouchersQuery.
+/// VI: Handler cho GetUserVouchersQuery.
+///
+public class GetUserVouchersQueryHandler : IRequestHandler>
+{
+ private readonly ICampaignRepository _campaignRepository;
+
+ public GetUserVouchersQueryHandler(ICampaignRepository campaignRepository)
+ {
+ _campaignRepository = campaignRepository;
+ }
+
+ public async Task> Handle(GetUserVouchersQuery request, CancellationToken cancellationToken)
+ {
+ var vouchers = await _campaignRepository.GetUserVouchersAsync(request.UserId);
+
+ return vouchers.Select(v => new VoucherSummaryDto(
+ v.Id, v.Code, v.RemainingValue,
+ v.StatusId == 1 ? "Available" : v.StatusId == 2 ? "Claimed" : v.StatusId == 3 ? "PartiallyRedeemed" : v.StatusId == 4 ? "FullyRedeemed" : "Expired",
+ v.ExpiresAt));
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Services/IWalletServiceClient.cs b/services/promotion-service-net/src/PromotionService.API/Application/Services/IWalletServiceClient.cs
new file mode 100644
index 00000000..08b40931
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Services/IWalletServiceClient.cs
@@ -0,0 +1,81 @@
+namespace PromotionService.API.Application.Services;
+
+///
+/// EN: Interface for Wallet Service client (escrow operations).
+/// VI: Interface cho Wallet Service client (thao tác ký quỹ).
+///
+public interface IWalletServiceClient
+{
+ ///
+ /// EN: Create an escrow hold in the wallet.
+ /// VI: Tạo một lệnh giữ ký quỹ trong ví.
+ ///
+ Task CreateHoldAsync(
+ Guid walletId,
+ decimal amount,
+ string currencyType,
+ string referenceType,
+ Guid referenceId,
+ string description,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// EN: Execute (commit) a portion of the held amount.
+ /// VI: Thực thi (cam kết) một phần số tiền bị giữ.
+ ///
+ Task ExecuteHoldAsync(
+ Guid walletId,
+ Guid holdId,
+ decimal amount,
+ string? executionRef = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// EN: Release (return) a portion of the held amount.
+ /// VI: Giải phóng (trả lại) một phần số tiền bị giữ.
+ ///
+ Task ReleaseHoldAsync(
+ Guid walletId,
+ Guid holdId,
+ decimal? amount = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// EN: Cancel the entire hold.
+ /// VI: Hủy toàn bộ lệnh giữ.
+ ///
+ Task CancelHoldAsync(
+ Guid walletId,
+ Guid holdId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// EN: Get wallet by user ID.
+ /// VI: Lấy ví theo ID người dùng.
+ ///
+ Task GetWalletByUserIdAsync(
+ Guid userId,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// EN: Result of escrow hold creation.
+/// VI: Kết quả tạo lệnh giữ ký quỹ.
+///
+public record HoldResult(
+ Guid HoldId,
+ Guid WalletId,
+ decimal Amount,
+ string CurrencyType,
+ string ReferenceType,
+ Guid ReferenceId,
+ string Status);
+
+///
+/// EN: Wallet information.
+/// VI: Thông tin ví.
+///
+public record WalletInfo(
+ Guid Id,
+ Guid UserId,
+ string Status);
diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Services/WalletServiceClient.cs b/services/promotion-service-net/src/PromotionService.API/Application/Services/WalletServiceClient.cs
new file mode 100644
index 00000000..ae4523bf
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Application/Services/WalletServiceClient.cs
@@ -0,0 +1,148 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Polly;
+using Polly.Retry;
+
+namespace PromotionService.API.Application.Services;
+
+///
+/// EN: HTTP client implementation for Wallet Service.
+/// VI: Triển khai HTTP client cho Wallet Service.
+///
+public class WalletServiceClient : IWalletServiceClient
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly AsyncRetryPolicy _retryPolicy;
+
+ public WalletServiceClient(HttpClient httpClient, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+
+ // Retry policy with exponential backoff
+ _retryPolicy = Policy
+ .Handle()
+ .OrResult(r => !r.IsSuccessStatusCode && r.StatusCode != System.Net.HttpStatusCode.BadRequest)
+ .WaitAndRetryAsync(3, retryAttempt =>
+ TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
+ (result, timeSpan, retryCount, context) =>
+ {
+ _logger.LogWarning("Retry {RetryCount} for Wallet Service call after {Delay}s",
+ retryCount, timeSpan.TotalSeconds);
+ });
+ }
+
+ public async Task CreateHoldAsync(
+ Guid walletId,
+ decimal amount,
+ string currencyType,
+ string referenceType,
+ Guid referenceId,
+ string description,
+ CancellationToken cancellationToken = default)
+ {
+ var request = new
+ {
+ Amount = amount,
+ CurrencyType = currencyType,
+ ReferenceType = referenceType,
+ ReferenceId = referenceId,
+ Description = description
+ };
+
+ var response = await _retryPolicy.ExecuteAsync(async () =>
+ await _httpClient.PostAsJsonAsync($"/api/v1/wallets/{walletId}/holds", request, cancellationToken));
+
+ response.EnsureSuccessStatusCode();
+
+ var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken)
+ ?? throw new InvalidOperationException("Failed to deserialize hold result");
+
+ _logger.LogInformation("Created hold {HoldId} for wallet {WalletId}", result.HoldId, walletId);
+ return result;
+ }
+
+ public async Task ExecuteHoldAsync(
+ Guid walletId,
+ Guid holdId,
+ decimal amount,
+ string? executionRef = null,
+ CancellationToken cancellationToken = default)
+ {
+ var request = new { Amount = amount, ExecutionReference = executionRef };
+
+ var response = await _retryPolicy.ExecuteAsync(async () =>
+ await _httpClient.PostAsJsonAsync($"/api/v1/wallets/{walletId}/holds/{holdId}/execute", request, cancellationToken));
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogError("Failed to execute hold {HoldId}: {StatusCode}", holdId, response.StatusCode);
+ return false;
+ }
+
+ _logger.LogInformation("Executed hold {HoldId} for {Amount}", holdId, amount);
+ return true;
+ }
+
+ public async Task ReleaseHoldAsync(
+ Guid walletId,
+ Guid holdId,
+ decimal? amount = null,
+ CancellationToken cancellationToken = default)
+ {
+ var request = new { Amount = amount };
+
+ var response = await _retryPolicy.ExecuteAsync(async () =>
+ await _httpClient.PostAsJsonAsync($"/api/v1/wallets/{walletId}/holds/{holdId}/release", request, cancellationToken));
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogError("Failed to release hold {HoldId}: {StatusCode}", holdId, response.StatusCode);
+ return false;
+ }
+
+ _logger.LogInformation("Released hold {HoldId}", holdId);
+ return true;
+ }
+
+ public async Task CancelHoldAsync(
+ Guid walletId,
+ Guid holdId,
+ CancellationToken cancellationToken = default)
+ {
+ var response = await _retryPolicy.ExecuteAsync(async () =>
+ await _httpClient.PostAsync($"/api/v1/wallets/{walletId}/holds/{holdId}/cancel", null, cancellationToken));
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogError("Failed to cancel hold {HoldId}: {StatusCode}", holdId, response.StatusCode);
+ return false;
+ }
+
+ _logger.LogInformation("Cancelled hold {HoldId}", holdId);
+ return true;
+ }
+
+ public async Task GetWalletByUserIdAsync(
+ Guid userId,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var response = await _httpClient.GetAsync($"/api/v1/wallets/user/{userId}", cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ return null;
+
+ return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get wallet for user {UserId}", userId);
+ return null;
+ }
+ }
+}
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
deleted file mode 100644
index 43259527..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Validations/CreateSampleCommandValidator.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-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
deleted file mode 100644
index ed428869..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Application/Validations/UpdateSampleCommandValidator.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-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/CampaignsController.cs b/services/promotion-service-net/src/PromotionService.API/Controllers/CampaignsController.cs
new file mode 100644
index 00000000..705e3317
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Controllers/CampaignsController.cs
@@ -0,0 +1,111 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using PromotionService.API.Application.Commands;
+using PromotionService.API.Application.DTOs;
+using PromotionService.API.Application.Queries;
+
+namespace PromotionService.API.Controllers;
+
+///
+/// EN: Controller for Campaign management.
+/// VI: Controller để quản lý Campaign.
+///
+[ApiController]
+[Route("api/v1/[controller]")]
+[Produces("application/json")]
+public class CampaignsController : ControllerBase
+{
+ private readonly IMediator _mediator;
+ private readonly ILogger _logger;
+
+ public CampaignsController(IMediator mediator, ILogger logger)
+ {
+ _mediator = mediator;
+ _logger = logger;
+ }
+
+ ///
+ /// EN: Create a new campaign.
+ /// VI: Tạo chiến dịch mới.
+ ///
+ [HttpPost]
+ [Authorize(Roles = "Merchant,Admin")]
+ [ProducesResponseType(typeof(CampaignDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> CreateCampaign([FromBody] CreateCampaignCommand command)
+ {
+ _logger.LogInformation("Creating campaign {Name}", command.Name);
+ var result = await _mediator.Send(command);
+ return CreatedAtAction(nameof(GetCampaign), new { id = result.Id }, result);
+ }
+
+ ///
+ /// EN: Get campaign by ID.
+ /// VI: Lấy chiến dịch theo ID.
+ ///
+ [HttpGet("{id:guid}")]
+ [ProducesResponseType(typeof(CampaignDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetCampaign(Guid id)
+ {
+ var result = await _mediator.Send(new GetCampaignQuery(id));
+ return result == null ? NotFound() : Ok(result);
+ }
+
+ ///
+ /// EN: Get campaigns list.
+ /// VI: Lấy danh sách chiến dịch.
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
+ public async Task>> GetCampaigns(
+ [FromQuery] Guid? merchantId = null,
+ [FromQuery] bool activeOnly = false)
+ {
+ var result = await _mediator.Send(new GetCampaignsQuery(merchantId, activeOnly));
+ return Ok(result);
+ }
+
+ ///
+ /// EN: Activate a campaign.
+ /// VI: Kích hoạt chiến dịch.
+ ///
+ [HttpPost("{id:guid}/activate")]
+ [Authorize(Roles = "Merchant,Admin")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task ActivateCampaign(Guid id)
+ {
+ var result = await _mediator.Send(new ActivateCampaignCommand(id));
+ return result ? Ok() : NotFound();
+ }
+
+ ///
+ /// EN: Pause a campaign.
+ /// VI: Tạm dừng chiến dịch.
+ ///
+ [HttpPost("{id:guid}/pause")]
+ [Authorize(Roles = "Merchant,Admin")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task PauseCampaign(Guid id)
+ {
+ var result = await _mediator.Send(new PauseCampaignCommand(id));
+ return result ? Ok() : NotFound();
+ }
+
+ ///
+ /// EN: Cancel a campaign.
+ /// VI: Hủy chiến dịch.
+ ///
+ [HttpPost("{id:guid}/cancel")]
+ [Authorize(Roles = "Merchant,Admin")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task CancelCampaign(Guid id)
+ {
+ var result = await _mediator.Send(new CancelCampaignCommand(id));
+ return result ? Ok() : NotFound();
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs b/services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs
deleted file mode 100644
index 8a7150c7..00000000
--- a/services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs
+++ /dev/null
@@ -1,200 +0,0 @@
-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/Controllers/VouchersController.cs b/services/promotion-service-net/src/PromotionService.API/Controllers/VouchersController.cs
new file mode 100644
index 00000000..86886baf
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/Controllers/VouchersController.cs
@@ -0,0 +1,81 @@
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using PromotionService.API.Application.Commands;
+using PromotionService.API.Application.DTOs;
+using PromotionService.API.Application.Queries;
+
+namespace PromotionService.API.Controllers;
+
+///
+/// EN: Controller for Voucher operations.
+/// VI: Controller cho các thao tác Voucher.
+///
+[ApiController]
+[Route("api/v1/[controller]")]
+[Produces("application/json")]
+public class VouchersController : ControllerBase
+{
+ private readonly IMediator _mediator;
+ private readonly ILogger _logger;
+
+ public VouchersController(IMediator mediator, ILogger logger)
+ {
+ _mediator = mediator;
+ _logger = logger;
+ }
+
+ ///
+ /// EN: Claim a free voucher.
+ /// VI: Nhận voucher miễn phí.
+ ///
+ [HttpPost("claim")]
+ [Authorize]
+ [ProducesResponseType(typeof(VoucherDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> ClaimVoucher([FromBody] ClaimVoucherCommand command)
+ {
+ var result = await _mediator.Send(command);
+ return Ok(result);
+ }
+
+ ///
+ /// EN: Validate a voucher code.
+ /// VI: Xác thực mã voucher.
+ ///
+ [HttpGet("validate/{code}")]
+ [Authorize]
+ [ProducesResponseType(typeof(VoucherValidationDto), StatusCodes.Status200OK)]
+ public async Task> ValidateVoucher(string code, [FromQuery] Guid userId)
+ {
+ var result = await _mediator.Send(new ValidateVoucherQuery(code, userId));
+ return Ok(result);
+ }
+
+ ///
+ /// EN: Redeem a voucher.
+ /// VI: Sử dụng voucher.
+ ///
+ [HttpPost("redeem")]
+ [Authorize]
+ [ProducesResponseType(typeof(RedemptionDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> RedeemVoucher([FromBody] RedeemVoucherCommand command)
+ {
+ var result = await _mediator.Send(command);
+ return Ok(result);
+ }
+
+ ///
+ /// EN: Get user's vouchers.
+ /// VI: Lấy voucher của người dùng.
+ ///
+ [HttpGet("user/{userId:guid}")]
+ [Authorize]
+ [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
+ public async Task>> GetUserVouchers(Guid userId)
+ {
+ var result = await _mediator.Send(new GetUserVouchersQuery(userId));
+ return Ok(result);
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/HealthChecks/WalletServiceHealthCheck.cs b/services/promotion-service-net/src/PromotionService.API/HealthChecks/WalletServiceHealthCheck.cs
new file mode 100644
index 00000000..9759417b
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.API/HealthChecks/WalletServiceHealthCheck.cs
@@ -0,0 +1,42 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace PromotionService.API.HealthChecks;
+
+///
+/// EN: Health check for Wallet Service connectivity.
+/// VI: Kiểm tra sức khỏe kết nối Wallet Service.
+///
+public class WalletServiceHealthCheck : IHealthCheck
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ public WalletServiceHealthCheck(IHttpClientFactory httpClientFactory, ILogger logger)
+ {
+ _httpClient = httpClientFactory.CreateClient("WalletService");
+ _logger = logger;
+ }
+
+ public async Task CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var response = await _httpClient.GetAsync("/health/live", cancellationToken);
+
+ if (response.IsSuccessStatusCode)
+ {
+ return HealthCheckResult.Healthy("Wallet Service is reachable");
+ }
+
+ _logger.LogWarning("Wallet Service health check failed with status {StatusCode}", response.StatusCode);
+ return HealthCheckResult.Degraded($"Wallet Service returned {response.StatusCode}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Wallet Service health check failed");
+ return HealthCheckResult.Unhealthy("Wallet Service is not reachable", ex);
+ }
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.API/Program.cs b/services/promotion-service-net/src/PromotionService.API/Program.cs
index a9424716..38ac804c 100644
--- a/services/promotion-service-net/src/PromotionService.API/Program.cs
+++ b/services/promotion-service-net/src/PromotionService.API/Program.cs
@@ -1,9 +1,14 @@
using Asp.Versioning;
using FluentValidation;
using Hellang.Middleware.ProblemDetails;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
using PromotionService.API.Application.Behaviors;
+using PromotionService.API.Application.Services;
+using PromotionService.API.HealthChecks;
using PromotionService.Infrastructure;
using Serilog;
+using System.Text;
// EN: Configure Serilog early / VI: Cấu hình Serilog sớm
Log.Logger = new LoggerConfiguration()
@@ -26,6 +31,14 @@ try
// EN: Add Infrastructure services / VI: Thêm Infrastructure services
builder.Services.AddInfrastructure(builder.Configuration);
+ // EN: Add Wallet Service HTTP client / VI: Thêm HTTP client cho Wallet Service
+ builder.Services.AddHttpClient(client =>
+ {
+ var walletServiceUrl = builder.Configuration["WalletService:BaseUrl"] ?? "http://wallet-service-net:8080";
+ client.BaseAddress = new Uri(walletServiceUrl);
+ client.DefaultRequestHeaders.Add("Accept", "application/json");
+ });
+
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
builder.Services.AddMediatR(cfg =>
{
@@ -77,13 +90,20 @@ try
});
// EN: Add health checks / VI: Thêm health checks
+ builder.Services.AddHttpClient("WalletService", client =>
+ {
+ var walletServiceUrl = builder.Configuration["WalletService:BaseUrl"] ?? "http://wallet-service-net:8080";
+ client.BaseAddress = new Uri(walletServiceUrl);
+ });
+
builder.Services.AddHealthChecks()
.AddNpgSql(
builder.Configuration.GetConnectionString("DefaultConnection")
?? builder.Configuration["DATABASE_URL"]
?? "",
name: "postgresql",
- tags: ["db", "postgresql"]);
+ tags: ["db", "postgresql"])
+ .AddCheck("wallet-service", tags: ["service", "wallet"]);
// EN: Add CORS / VI: Thêm CORS
builder.Services.AddCors(options =>
@@ -96,6 +116,23 @@ try
});
});
+ // EN: Add JWT Authentication / VI: Thêm JWT Authentication
+ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.Authority = builder.Configuration["Jwt:Authority"];
+ options.Audience = builder.Configuration["Jwt:Audience"];
+ options.RequireHttpsMetadata = builder.Configuration.GetValue("Jwt:RequireHttpsMetadata", false);
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateLifetime = true
+ };
+ });
+ builder.Services.AddAuthorization();
+
var app = builder.Build();
// EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline
@@ -114,6 +151,8 @@ try
app.UseCors();
app.UseRouting();
+ app.UseAuthentication();
+ app.UseAuthorization();
// EN: Map health check endpoints / VI: Map health check endpoints
app.MapHealthChecks("/health");
diff --git a/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj b/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj
index 85409bb8..438ea642 100644
--- a/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj
+++ b/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj
@@ -14,6 +14,11 @@
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json b/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json
index e407ac85..9b26c0f8 100644
--- a/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json
+++ b/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json
@@ -3,7 +3,7 @@
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
- "Microsoft.EntityFrameworkCore.Database.Command": "Information"
+ "Microsoft.EntityFrameworkCore": "Information"
}
},
"Serilog": {
@@ -11,9 +11,32 @@
"Default": "Debug",
"Override": {
"Microsoft": "Information",
- "Microsoft.EntityFrameworkCore.Database.Command": "Information",
- "System": "Information"
+ "Microsoft.EntityFrameworkCore": "Information",
+ "System": "Warning"
}
}
+ },
+ "ConnectionStrings": {
+ "DefaultConnection": "Host=localhost;Port=5432;Database=promotion_service_dev;Username=postgres;Password=postgres"
+ },
+ "WalletService": {
+ "BaseUrl": "http://localhost:5003",
+ "TimeoutSeconds": 30
+ },
+ "IamService": {
+ "BaseUrl": "http://localhost:5001",
+ "ServiceName": "promotion-service"
+ },
+ "RabbitMQ": {
+ "Host": "localhost",
+ "Port": 5672,
+ "Username": "guest",
+ "Password": "guest",
+ "VirtualHost": "/"
+ },
+ "Jwt": {
+ "Authority": "http://localhost:5001",
+ "Audience": "goodgo-api",
+ "RequireHttpsMetadata": false
}
}
\ 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
index 523dc0fc..a25d8369 100644
--- a/services/promotion-service-net/src/PromotionService.API/appsettings.json
+++ b/services/promotion-service-net/src/PromotionService.API/appsettings.json
@@ -30,17 +30,34 @@
]
},
"ConnectionStrings": {
- "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres"
+ "DefaultConnection": "Host=localhost;Port=5432;Database=promotion_service;Username=postgres;Password=postgres"
+ },
+ "WalletService": {
+ "BaseUrl": "http://localhost:5003",
+ "TimeoutSeconds": 30
+ },
+ "IamService": {
+ "BaseUrl": "http://localhost:5001",
+ "ServiceName": "promotion-service"
+ },
+ "RabbitMQ": {
+ "Host": "localhost",
+ "Port": 5672,
+ "Username": "guest",
+ "Password": "guest",
+ "VirtualHost": "/"
+ },
+ "Jwt": {
+ "Authority": "http://localhost:5001",
+ "Audience": "goodgo-api",
+ "RequireHttpsMetadata": false,
+ "Secret": "your-super-secret-key-min-32-characters",
+ "Issuer": "goodgo-platform",
+ "AccessTokenExpiryMinutes": 15,
+ "RefreshTokenExpiryDays": 7
},
"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/SampleAggregate/ISampleRepository.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs
deleted file mode 100644
index b8cf88d1..00000000
--- a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-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
deleted file mode 100644
index e9522849..00000000
--- a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/Sample.cs
+++ /dev/null
@@ -1,158 +0,0 @@
-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
deleted file mode 100644
index 4f370b3c..00000000
--- a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-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/SampleCreatedDomainEvent.cs b/services/promotion-service-net/src/PromotionService.Domain/Events/SampleCreatedDomainEvent.cs
deleted file mode 100644
index 06a99e02..00000000
--- a/services/promotion-service-net/src/PromotionService.Domain/Events/SampleCreatedDomainEvent.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-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
deleted file mode 100644
index a6129c3b..00000000
--- a/services/promotion-service-net/src/PromotionService.Domain/Events/SampleStatusChangedDomainEvent.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-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.Infrastructure/DependencyInjection.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/DependencyInjection.cs
index d1c42a77..a984575d 100644
--- a/services/promotion-service-net/src/PromotionService.Infrastructure/DependencyInjection.cs
+++ b/services/promotion-service-net/src/PromotionService.Infrastructure/DependencyInjection.cs
@@ -1,7 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
-using PromotionService.Domain.AggregatesModel.SampleAggregate;
+using PromotionService.Domain.AggregatesModel.CampaignAggregate;
+using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
using PromotionService.Infrastructure.Idempotency;
using PromotionService.Infrastructure.Repositories;
@@ -47,7 +48,8 @@ public static class DependencyInjection
});
// EN: Register repositories / VI: Đăng ký repositories
- services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped();
diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/CampaignEntityTypeConfiguration.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/CampaignEntityTypeConfiguration.cs
new file mode 100644
index 00000000..d717c298
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/CampaignEntityTypeConfiguration.cs
@@ -0,0 +1,129 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+namespace PromotionService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: EF Core configuration for Campaign entity.
+/// VI: Cấu hình EF Core cho entity Campaign.
+///
+public class CampaignEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("campaigns");
+
+ builder.HasKey(c => c.Id);
+
+ builder.Property(c => c.Id)
+ .HasColumnName("id")
+ .ValueGeneratedNever();
+
+ builder.Property(c => c.MerchantId)
+ .HasColumnName("merchant_id")
+ .IsRequired();
+
+ builder.Property(c => c.Name)
+ .HasColumnName("name")
+ .HasMaxLength(255)
+ .IsRequired();
+
+ builder.Property(c => c.Description)
+ .HasColumnName("description")
+ .HasMaxLength(1000);
+
+ // Backing Asset
+ builder.Property(c => c.BackingAssetTypeId)
+ .HasColumnName("backing_asset_type_id")
+ .IsRequired();
+
+ builder.Ignore(c => c.BackingAssetType);
+
+ builder.Property(c => c.BackingAssetCode)
+ .HasColumnName("backing_asset_code")
+ .HasMaxLength(10)
+ .IsRequired();
+
+ builder.Property(c => c.FaceValue)
+ .HasColumnName("face_value")
+ .HasPrecision(18, 2)
+ .IsRequired();
+
+ // Acquisition
+ builder.Property(c => c.AcquisitionTypeId)
+ .HasColumnName("acquisition_type_id")
+ .IsRequired();
+
+ builder.Ignore(c => c.AcquisitionType);
+
+ builder.Property(c => c.AcquisitionPrice)
+ .HasColumnName("acquisition_price")
+ .HasPrecision(18, 2);
+
+ // Escrow
+ builder.Property(c => c.EscrowHoldId)
+ .HasColumnName("escrow_hold_id");
+
+ builder.Property(c => c.EscrowWalletId)
+ .HasColumnName("escrow_wallet_id");
+
+ builder.Property(c => c.EscrowAmount)
+ .HasColumnName("escrow_amount")
+ .HasPrecision(18, 2);
+
+ // Limits
+ builder.Property(c => c.TotalVouchers)
+ .HasColumnName("total_vouchers")
+ .IsRequired();
+
+ builder.Property(c => c.IssuedVouchers)
+ .HasColumnName("issued_vouchers")
+ .IsRequired();
+
+ builder.Property(c => c.MaxPerUser)
+ .HasColumnName("max_per_user");
+
+ builder.Property(c => c.StartDate)
+ .HasColumnName("start_date")
+ .IsRequired();
+
+ builder.Property(c => c.EndDate)
+ .HasColumnName("end_date")
+ .IsRequired();
+
+ builder.Property(c => c.VoucherValidityDays)
+ .HasColumnName("voucher_validity_days");
+
+ // Status
+ builder.Property(c => c.StatusId)
+ .HasColumnName("status_id")
+ .IsRequired();
+
+ builder.Ignore(c => c.Status);
+
+ builder.Property(c => c.CreatedAt)
+ .HasColumnName("created_at")
+ .IsRequired();
+
+ builder.Property(c => c.UpdatedAt)
+ .HasColumnName("updated_at")
+ .IsRequired();
+
+ // Navigation to Vouchers
+ builder.HasMany(c => c.Vouchers)
+ .WithOne()
+ .HasForeignKey(v => v.CampaignId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // Indexes
+ builder.HasIndex(c => c.MerchantId)
+ .HasDatabaseName("ix_campaigns_merchant_id");
+
+ builder.HasIndex(c => c.StatusId)
+ .HasDatabaseName("ix_campaigns_status_id");
+
+ builder.HasIndex(c => new { c.StartDate, c.EndDate })
+ .HasDatabaseName("ix_campaigns_date_range");
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/RedemptionEntityTypeConfiguration.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/RedemptionEntityTypeConfiguration.cs
new file mode 100644
index 00000000..9a579a1a
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/RedemptionEntityTypeConfiguration.cs
@@ -0,0 +1,72 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
+
+namespace PromotionService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: EF Core configuration for Redemption entity.
+/// VI: Cấu hình EF Core cho entity Redemption.
+///
+public class RedemptionEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("redemptions");
+
+ builder.HasKey(r => r.Id);
+
+ builder.Property(r => r.Id)
+ .HasColumnName("id")
+ .ValueGeneratedNever();
+
+ builder.Property(r => r.VoucherId)
+ .HasColumnName("voucher_id")
+ .IsRequired();
+
+ builder.Property(r => r.CampaignId)
+ .HasColumnName("campaign_id")
+ .IsRequired();
+
+ builder.Property(r => r.UserId)
+ .HasColumnName("user_id")
+ .IsRequired();
+
+ builder.Property(r => r.OrderId)
+ .HasColumnName("order_id");
+
+ builder.Property(r => r.AmountUsed)
+ .HasColumnName("amount_used")
+ .HasPrecision(18, 2)
+ .IsRequired();
+
+ builder.Property(r => r.AmountRefunded)
+ .HasColumnName("amount_refunded")
+ .HasPrecision(18, 2);
+
+ builder.Property(r => r.ExecutionReference)
+ .HasColumnName("execution_reference")
+ .HasMaxLength(100);
+
+ builder.Property(r => r.RedeemedAt)
+ .HasColumnName("redeemed_at")
+ .IsRequired();
+
+ builder.Property(r => r.CreatedAt)
+ .HasColumnName("created_at")
+ .IsRequired();
+
+ // Indexes
+ builder.HasIndex(r => r.VoucherId)
+ .HasDatabaseName("ix_redemptions_voucher_id");
+
+ builder.HasIndex(r => r.UserId)
+ .HasDatabaseName("ix_redemptions_user_id");
+
+ builder.HasIndex(r => r.CampaignId)
+ .HasDatabaseName("ix_redemptions_campaign_id");
+
+ builder.HasIndex(r => r.OrderId)
+ .HasDatabaseName("ix_redemptions_order_id");
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs
deleted file mode 100644
index 44de9559..00000000
--- a/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Metadata.Builders;
-using PromotionService.Domain.AggregatesModel.SampleAggregate;
-
-namespace PromotionService.Infrastructure.EntityConfigurations;
-
-///
-/// EN: EF Core configuration for Sample entity.
-/// VI: Cấu hình EF Core cho entity Sample.
-///
-public class SampleEntityTypeConfiguration : IEntityTypeConfiguration
-{
- public void Configure(EntityTypeBuilder builder)
- {
- // EN: Table name / VI: Tên bảng
- builder.ToTable("samples");
-
- // EN: Primary key / VI: Khóa chính
- builder.HasKey(s => s.Id);
-
- // EN: Ignore domain events (not persisted)
- // VI: Bỏ qua domain events (không lưu)
- builder.Ignore(s => s.DomainEvents);
-
- // EN: Properties / VI: Các thuộc tính
- builder.Property(s => s.Id)
- .HasColumnName("id")
- .IsRequired();
-
- builder.Property("_name")
- .HasColumnName("name")
- .HasMaxLength(200)
- .IsRequired();
-
- builder.Property("_description")
- .HasColumnName("description")
- .HasMaxLength(1000);
-
- builder.Property("_createdAt")
- .HasColumnName("created_at")
- .IsRequired();
-
- builder.Property("_updatedAt")
- .HasColumnName("updated_at");
-
- // EN: Status relationship / VI: Quan hệ với Status
- builder.Property(s => s.StatusId)
- .HasColumnName("status_id")
- .IsRequired();
-
- builder.HasOne(s => s.Status)
- .WithMany()
- .HasForeignKey(s => s.StatusId)
- .OnDelete(DeleteBehavior.Restrict);
-
- // EN: Indexes / VI: Các index
- builder.HasIndex("_name");
- builder.HasIndex(s => s.StatusId);
- builder.HasIndex("_createdAt");
- }
-}
diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs
deleted file mode 100644
index 0c446fb6..00000000
--- a/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Metadata.Builders;
-using PromotionService.Domain.AggregatesModel.SampleAggregate;
-
-namespace PromotionService.Infrastructure.EntityConfigurations;
-
-///
-/// EN: EF Core configuration for SampleStatus enumeration.
-/// VI: Cấu hình EF Core cho enumeration SampleStatus.
-///
-public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration
-{
- public void Configure(EntityTypeBuilder builder)
- {
- // EN: Table name / VI: Tên bảng
- builder.ToTable("sample_statuses");
-
- // EN: Primary key / VI: Khóa chính
- builder.HasKey(s => s.Id);
-
- builder.Property(s => s.Id)
- .HasColumnName("id")
- .ValueGeneratedNever()
- .IsRequired();
-
- builder.Property(s => s.Name)
- .HasColumnName("name")
- .HasMaxLength(50)
- .IsRequired();
-
- // EN: Seed initial data / VI: Seed dữ liệu ban đầu
- builder.HasData(
- SampleStatus.Draft,
- SampleStatus.Active,
- SampleStatus.Completed,
- SampleStatus.Cancelled
- );
- }
-}
diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/VoucherEntityTypeConfiguration.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/VoucherEntityTypeConfiguration.cs
new file mode 100644
index 00000000..9264dbe7
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/VoucherEntityTypeConfiguration.cs
@@ -0,0 +1,82 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PromotionService.Domain.AggregatesModel.CampaignAggregate;
+
+namespace PromotionService.Infrastructure.EntityConfigurations;
+
+///
+/// EN: EF Core configuration for Voucher entity.
+/// VI: Cấu hình EF Core cho entity Voucher.
+///
+public class VoucherEntityTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("vouchers");
+
+ builder.HasKey(v => v.Id);
+
+ builder.Property(v => v.Id)
+ .HasColumnName("id")
+ .ValueGeneratedNever();
+
+ builder.Property(v => v.CampaignId)
+ .HasColumnName("campaign_id")
+ .IsRequired();
+
+ builder.Property(v => v.Code)
+ .HasColumnName("code")
+ .HasMaxLength(20)
+ .IsRequired();
+
+ builder.Property(v => v.OwnerId)
+ .HasColumnName("owner_id");
+
+ builder.Property(v => v.FaceValue)
+ .HasColumnName("face_value")
+ .HasPrecision(18, 2)
+ .IsRequired();
+
+ builder.Property(v => v.RemainingValue)
+ .HasColumnName("remaining_value")
+ .HasPrecision(18, 2)
+ .IsRequired();
+
+ builder.Property(v => v.StatusId)
+ .HasColumnName("status_id")
+ .IsRequired();
+
+ builder.Ignore(v => v.Status);
+
+ builder.Property(v => v.ClaimedAt)
+ .HasColumnName("claimed_at");
+
+ builder.Property(v => v.ExpiresAt)
+ .HasColumnName("expires_at");
+
+ builder.Property(v => v.RedeemedAt)
+ .HasColumnName("redeemed_at");
+
+ builder.Property(v => v.CreatedAt)
+ .HasColumnName("created_at")
+ .IsRequired();
+
+ builder.Property(v => v.UpdatedAt)
+ .HasColumnName("updated_at")
+ .IsRequired();
+
+ // Indexes
+ builder.HasIndex(v => v.Code)
+ .IsUnique()
+ .HasDatabaseName("ix_vouchers_code");
+
+ builder.HasIndex(v => v.OwnerId)
+ .HasDatabaseName("ix_vouchers_owner_id");
+
+ builder.HasIndex(v => v.CampaignId)
+ .HasDatabaseName("ix_vouchers_campaign_id");
+
+ builder.HasIndex(v => v.StatusId)
+ .HasDatabaseName("ix_vouchers_status_id");
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/Migrations/20260117144846_InitialCreate.Designer.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/Migrations/20260117144846_InitialCreate.Designer.cs
new file mode 100644
index 00000000..ca903700
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Infrastructure/Migrations/20260117144846_InitialCreate.Designer.cs
@@ -0,0 +1,286 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PromotionService.Infrastructure;
+
+#nullable disable
+
+namespace PromotionService.Infrastructure.Migrations
+{
+ [DbContext(typeof(PromotionServiceContext))]
+ [Migration("20260117144846_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AcquisitionPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("acquisition_price");
+
+ b.Property("AcquisitionTypeId")
+ .HasColumnType("integer")
+ .HasColumnName("acquisition_type_id");
+
+ b.Property("BackingAssetCode")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("backing_asset_code");
+
+ b.Property("BackingAssetTypeId")
+ .HasColumnType("integer")
+ .HasColumnName("backing_asset_type_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("description");
+
+ b.Property("EndDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("end_date");
+
+ b.Property("EscrowAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("escrow_amount");
+
+ b.Property("EscrowHoldId")
+ .HasColumnType("uuid")
+ .HasColumnName("escrow_hold_id");
+
+ b.Property("EscrowWalletId")
+ .HasColumnType("uuid")
+ .HasColumnName("escrow_wallet_id");
+
+ b.Property("FaceValue")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("face_value");
+
+ b.Property("IssuedVouchers")
+ .HasColumnType("integer")
+ .HasColumnName("issued_vouchers");
+
+ b.Property("MaxPerUser")
+ .HasColumnType("integer")
+ .HasColumnName("max_per_user");
+
+ b.Property("MerchantId")
+ .HasColumnType("uuid")
+ .HasColumnName("merchant_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("name");
+
+ b.Property("StartDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_date");
+
+ b.Property("StatusId")
+ .HasColumnType("integer")
+ .HasColumnName("status_id");
+
+ b.Property("TotalVouchers")
+ .HasColumnType("integer")
+ .HasColumnName("total_vouchers");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("VoucherValidityDays")
+ .HasColumnType("integer")
+ .HasColumnName("voucher_validity_days");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MerchantId")
+ .HasDatabaseName("ix_campaigns_merchant_id");
+
+ b.HasIndex("StatusId")
+ .HasDatabaseName("ix_campaigns_status_id");
+
+ b.HasIndex("StartDate", "EndDate")
+ .HasDatabaseName("ix_campaigns_date_range");
+
+ b.ToTable("campaigns", (string)null);
+ });
+
+ modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Voucher", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CampaignId")
+ .HasColumnType("uuid")
+ .HasColumnName("campaign_id");
+
+ b.Property("ClaimedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("claimed_at");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("FaceValue")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("face_value");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.Property("RedeemedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("redeemed_at");
+
+ b.Property("RemainingValue")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("remaining_value");
+
+ b.Property("StatusId")
+ .HasColumnType("integer")
+ .HasColumnName("status_id");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CampaignId")
+ .HasDatabaseName("ix_vouchers_campaign_id");
+
+ b.HasIndex("Code")
+ .IsUnique()
+ .HasDatabaseName("ix_vouchers_code");
+
+ b.HasIndex("OwnerId")
+ .HasDatabaseName("ix_vouchers_owner_id");
+
+ b.HasIndex("StatusId")
+ .HasDatabaseName("ix_vouchers_status_id");
+
+ b.ToTable("vouchers", (string)null);
+ });
+
+ modelBuilder.Entity("PromotionService.Domain.AggregatesModel.RedemptionAggregate.Redemption", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AmountRefunded")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("amount_refunded");
+
+ b.Property("AmountUsed")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("amount_used");
+
+ b.Property("CampaignId")
+ .HasColumnType("uuid")
+ .HasColumnName("campaign_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExecutionReference")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("execution_reference");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid")
+ .HasColumnName("order_id");
+
+ b.Property("RedeemedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("redeemed_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("VoucherId")
+ .HasColumnType("uuid")
+ .HasColumnName("voucher_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CampaignId")
+ .HasDatabaseName("ix_redemptions_campaign_id");
+
+ b.HasIndex("OrderId")
+ .HasDatabaseName("ix_redemptions_order_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_redemptions_user_id");
+
+ b.HasIndex("VoucherId")
+ .HasDatabaseName("ix_redemptions_voucher_id");
+
+ b.ToTable("redemptions", (string)null);
+ });
+
+ modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Voucher", b =>
+ {
+ b.HasOne("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", null)
+ .WithMany("Vouchers")
+ .HasForeignKey("CampaignId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", b =>
+ {
+ b.Navigation("Vouchers");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/Migrations/20260117144846_InitialCreate.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/Migrations/20260117144846_InitialCreate.cs
new file mode 100644
index 00000000..08a6ad40
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Infrastructure/Migrations/20260117144846_InitialCreate.cs
@@ -0,0 +1,163 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PromotionService.Infrastructure.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "campaigns",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ merchant_id = table.Column(type: "uuid", nullable: false),
+ name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true),
+ backing_asset_type_id = table.Column(type: "integer", nullable: false),
+ backing_asset_code = table.Column(type: "character varying(10)", maxLength: 10, nullable: false),
+ face_value = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ acquisition_type_id = table.Column(type: "integer", nullable: false),
+ acquisition_price = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ escrow_hold_id = table.Column(type: "uuid", nullable: true),
+ escrow_wallet_id = table.Column(type: "uuid", nullable: true),
+ escrow_amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ total_vouchers = table.Column(type: "integer", nullable: false),
+ issued_vouchers = table.Column(type: "integer", nullable: false),
+ max_per_user = table.Column(type: "integer", nullable: false),
+ start_date = table.Column(type: "timestamp with time zone", nullable: false),
+ end_date = table.Column(type: "timestamp with time zone", nullable: false),
+ voucher_validity_days = table.Column(type: "integer", nullable: false),
+ status_id = table.Column(type: "integer", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_campaigns", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "redemptions",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ voucher_id = table.Column(type: "uuid", nullable: false),
+ campaign_id = table.Column(type: "uuid", nullable: false),
+ user_id = table.Column(type: "uuid", nullable: false),
+ order_id = table.Column(type: "uuid", nullable: true),
+ amount_used = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ amount_refunded = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ execution_reference = table.Column(type: "character varying(100)", maxLength: 100, nullable: true),
+ redeemed_at = table.Column(type: "timestamp with time zone", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_redemptions", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "vouchers",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ campaign_id = table.Column(type: "uuid", nullable: false),
+ code = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ owner_id = table.Column(type: "uuid", nullable: true),
+ face_value = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ remaining_value = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ status_id = table.Column(type: "integer", nullable: false),
+ claimed_at = table.Column(type: "timestamp with time zone", nullable: true),
+ expires_at = table.Column(type: "timestamp with time zone", nullable: true),
+ redeemed_at = table.Column(type: "timestamp with time zone", nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_vouchers", x => x.id);
+ table.ForeignKey(
+ name: "FK_vouchers_campaigns_campaign_id",
+ column: x => x.campaign_id,
+ principalTable: "campaigns",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_campaigns_date_range",
+ table: "campaigns",
+ columns: new[] { "start_date", "end_date" });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_campaigns_merchant_id",
+ table: "campaigns",
+ column: "merchant_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_campaigns_status_id",
+ table: "campaigns",
+ column: "status_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_redemptions_campaign_id",
+ table: "redemptions",
+ column: "campaign_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_redemptions_order_id",
+ table: "redemptions",
+ column: "order_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_redemptions_user_id",
+ table: "redemptions",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_redemptions_voucher_id",
+ table: "redemptions",
+ column: "voucher_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_vouchers_campaign_id",
+ table: "vouchers",
+ column: "campaign_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_vouchers_code",
+ table: "vouchers",
+ column: "code",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_vouchers_owner_id",
+ table: "vouchers",
+ column: "owner_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_vouchers_status_id",
+ table: "vouchers",
+ column: "status_id");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "redemptions");
+
+ migrationBuilder.DropTable(
+ name: "vouchers");
+
+ migrationBuilder.DropTable(
+ name: "campaigns");
+ }
+ }
+}
diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/Migrations/PromotionServiceContextModelSnapshot.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/Migrations/PromotionServiceContextModelSnapshot.cs
new file mode 100644
index 00000000..57f3c334
--- /dev/null
+++ b/services/promotion-service-net/src/PromotionService.Infrastructure/Migrations/PromotionServiceContextModelSnapshot.cs
@@ -0,0 +1,283 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PromotionService.Infrastructure;
+
+#nullable disable
+
+namespace PromotionService.Infrastructure.Migrations
+{
+ [DbContext(typeof(PromotionServiceContext))]
+ partial class PromotionServiceContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AcquisitionPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("acquisition_price");
+
+ b.Property("AcquisitionTypeId")
+ .HasColumnType("integer")
+ .HasColumnName("acquisition_type_id");
+
+ b.Property("BackingAssetCode")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("backing_asset_code");
+
+ b.Property("BackingAssetTypeId")
+ .HasColumnType("integer")
+ .HasColumnName("backing_asset_type_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("description");
+
+ b.Property("EndDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("end_date");
+
+ b.Property("EscrowAmount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("escrow_amount");
+
+ b.Property("EscrowHoldId")
+ .HasColumnType("uuid")
+ .HasColumnName("escrow_hold_id");
+
+ b.Property("EscrowWalletId")
+ .HasColumnType("uuid")
+ .HasColumnName("escrow_wallet_id");
+
+ b.Property("FaceValue")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("face_value");
+
+ b.Property("IssuedVouchers")
+ .HasColumnType("integer")
+ .HasColumnName("issued_vouchers");
+
+ b.Property("MaxPerUser")
+ .HasColumnType("integer")
+ .HasColumnName("max_per_user");
+
+ b.Property("MerchantId")
+ .HasColumnType("uuid")
+ .HasColumnName("merchant_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("name");
+
+ b.Property("StartDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_date");
+
+ b.Property("StatusId")
+ .HasColumnType("integer")
+ .HasColumnName("status_id");
+
+ b.Property("TotalVouchers")
+ .HasColumnType("integer")
+ .HasColumnName("total_vouchers");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("VoucherValidityDays")
+ .HasColumnType("integer")
+ .HasColumnName("voucher_validity_days");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MerchantId")
+ .HasDatabaseName("ix_campaigns_merchant_id");
+
+ b.HasIndex("StatusId")
+ .HasDatabaseName("ix_campaigns_status_id");
+
+ b.HasIndex("StartDate", "EndDate")
+ .HasDatabaseName("ix_campaigns_date_range");
+
+ b.ToTable("campaigns", (string)null);
+ });
+
+ modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Voucher", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property