feat: Implement promotion, campaign, voucher, and redemption domain features, replacing sample entities and related infrastructure.

This commit is contained in:
Ho Ngoc Hai
2026-01-17 21:57:26 +07:00
parent c9ec3194ba
commit a50d7139ed
52 changed files with 2479 additions and 1409 deletions

View File

@@ -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;
/// <summary>
/// EN: Handler for CreateCampaignCommand.
/// VI: Handler cho CreateCampaignCommand.
/// </summary>
public class CreateCampaignCommandHandler : IRequestHandler<CreateCampaignCommand, CampaignDto>
{
private readonly ICampaignRepository _campaignRepository;
private readonly IWalletServiceClient _walletService;
private readonly ILogger<CreateCampaignCommandHandler> _logger;
public CreateCampaignCommandHandler(
ICampaignRepository campaignRepository,
IWalletServiceClient walletService,
ILogger<CreateCampaignCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_walletService = walletService;
_logger = logger;
}
public async Task<CampaignDto> 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);
}
/// <summary>
/// EN: Handler for ActivateCampaignCommand.
/// VI: Handler cho ActivateCampaignCommand.
/// </summary>
public class ActivateCampaignCommandHandler : IRequestHandler<ActivateCampaignCommand, bool>
{
private readonly ICampaignRepository _campaignRepository;
private readonly ILogger<ActivateCampaignCommandHandler> _logger;
public ActivateCampaignCommandHandler(
ICampaignRepository campaignRepository,
ILogger<ActivateCampaignCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_logger = logger;
}
public async Task<bool> 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;
}
}
/// <summary>
/// EN: Handler for PauseCampaignCommand.
/// VI: Handler cho PauseCampaignCommand.
/// </summary>
public class PauseCampaignCommandHandler : IRequestHandler<PauseCampaignCommand, bool>
{
private readonly ICampaignRepository _campaignRepository;
private readonly ILogger<PauseCampaignCommandHandler> _logger;
public PauseCampaignCommandHandler(
ICampaignRepository campaignRepository,
ILogger<PauseCampaignCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_logger = logger;
}
public async Task<bool> 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;
}
}
/// <summary>
/// EN: Handler for CancelCampaignCommand.
/// VI: Handler cho CancelCampaignCommand.
/// </summary>
public class CancelCampaignCommandHandler : IRequestHandler<CancelCampaignCommand, bool>
{
private readonly ICampaignRepository _campaignRepository;
private readonly IWalletServiceClient _walletService;
private readonly ILogger<CancelCampaignCommandHandler> _logger;
public CancelCampaignCommandHandler(
ICampaignRepository campaignRepository,
IWalletServiceClient walletService,
ILogger<CancelCampaignCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_walletService = walletService;
_logger = logger;
}
public async Task<bool> 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;
}
}

View File

@@ -0,0 +1,42 @@
using MediatR;
using PromotionService.API.Application.DTOs;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new campaign.
/// VI: Command để tạo chiến dịch mới.
/// </summary>
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<CampaignDto>;
/// <summary>
/// EN: Command to activate a campaign.
/// VI: Command để kích hoạt chiến dịch.
/// </summary>
public record ActivateCampaignCommand(Guid CampaignId) : IRequest<bool>;
/// <summary>
/// EN: Command to pause a campaign.
/// VI: Command để tạm dừng chiến dịch.
/// </summary>
public record PauseCampaignCommand(Guid CampaignId) : IRequest<bool>;
/// <summary>
/// EN: Command to cancel a campaign.
/// VI: Command để hủy chiến dịch.
/// </summary>
public record CancelCampaignCommand(Guid CampaignId) : IRequest<bool>;

View File

@@ -1,14 +0,0 @@
using MediatR;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Command to change status of a Sample.
/// VI: Command để thay đổi trạng thái của Sample.
/// </summary>
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
/// <param name="NewStatus">EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)</param>
public record ChangeSampleStatusCommand(
Guid SampleId,
string NewStatus
) : IRequest<bool>;

View File

@@ -1,70 +0,0 @@
using MediatR;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Handler for ChangeSampleStatusCommand.
/// VI: Handler cho ChangeSampleStatusCommand.
/// </summary>
public class ChangeSampleStatusCommandHandler : IRequestHandler<ChangeSampleStatusCommand, bool>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<ChangeSampleStatusCommandHandler> _logger;
public ChangeSampleStatusCommandHandler(
ISampleRepository sampleRepository,
ILogger<ChangeSampleStatusCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> 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;
}
}

View File

@@ -1,21 +0,0 @@
using MediatR;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Command to create a new Sample.
/// VI: Command để tạo một Sample mới.
/// </summary>
/// <param name="Name">EN: Sample name / VI: Tên sample</param>
/// <param name="Description">EN: Optional description / VI: Mô tả tùy chọn</param>
public record CreateSampleCommand(
string Name,
string? Description
) : IRequest<CreateSampleCommandResult>;
/// <summary>
/// EN: Result of CreateSampleCommand.
/// VI: Kết quả của CreateSampleCommand.
/// </summary>
/// <param name="Id">EN: Created sample ID / VI: ID sample đã tạo</param>
public record CreateSampleCommandResult(Guid Id);

View File

@@ -1,46 +0,0 @@
using MediatR;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Handler for CreateSampleCommand.
/// VI: Handler cho CreateSampleCommand.
/// </summary>
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<CreateSampleCommandHandler> _logger;
public CreateSampleCommandHandler(
ISampleRepository sampleRepository,
ILogger<CreateSampleCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CreateSampleCommandResult> 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);
}
}

View File

@@ -1,10 +0,0 @@
using MediatR;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Command to delete a Sample.
/// VI: Command để xóa một Sample.
/// </summary>
/// <param name="SampleId">EN: Sample ID to delete / VI: ID sample cần xóa</param>
public record DeleteSampleCommand(Guid SampleId) : IRequest<bool>;

View File

@@ -1,54 +0,0 @@
using MediatR;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Handler for DeleteSampleCommand.
/// VI: Handler cho DeleteSampleCommand.
/// </summary>
public class DeleteSampleCommandHandler : IRequestHandler<DeleteSampleCommand, bool>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<DeleteSampleCommandHandler> _logger;
public DeleteSampleCommandHandler(
ISampleRepository sampleRepository,
ILogger<DeleteSampleCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> 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;
}
}

View File

@@ -1,16 +0,0 @@
using MediatR;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Command to update an existing Sample.
/// VI: Command để cập nhật một Sample đã tồn tại.
/// </summary>
/// <param name="SampleId">EN: Sample ID to update / VI: ID sample cần cập nhật</param>
/// <param name="Name">EN: New name / VI: Tên mới</param>
/// <param name="Description">EN: New description / VI: Mô tả mới</param>
public record UpdateSampleCommand(
Guid SampleId,
string Name,
string? Description
) : IRequest<bool>;

View File

@@ -1,54 +0,0 @@
using MediatR;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Handler for UpdateSampleCommand.
/// VI: Handler cho UpdateSampleCommand.
/// </summary>
public class UpdateSampleCommandHandler : IRequestHandler<UpdateSampleCommand, bool>
{
private readonly ISampleRepository _sampleRepository;
private readonly ILogger<UpdateSampleCommandHandler> _logger;
public UpdateSampleCommandHandler(
ISampleRepository sampleRepository,
ILogger<UpdateSampleCommandHandler> logger)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<bool> 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;
}
}

View File

@@ -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;
/// <summary>
/// EN: Handler for ClaimVoucherCommand (free vouchers).
/// VI: Handler cho ClaimVoucherCommand (voucher miễn phí).
/// </summary>
public class ClaimVoucherCommandHandler : IRequestHandler<ClaimVoucherCommand, VoucherDto>
{
private readonly ICampaignRepository _campaignRepository;
private readonly ILogger<ClaimVoucherCommandHandler> _logger;
public ClaimVoucherCommandHandler(
ICampaignRepository campaignRepository,
ILogger<ClaimVoucherCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_logger = logger;
}
public async Task<VoucherDto> 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);
}
/// <summary>
/// EN: Handler for RedeemVoucherCommand.
/// VI: Handler cho RedeemVoucherCommand.
/// </summary>
public class RedeemVoucherCommandHandler : IRequestHandler<RedeemVoucherCommand, RedemptionDto>
{
private readonly ICampaignRepository _campaignRepository;
private readonly IRedemptionRepository _redemptionRepository;
private readonly IWalletServiceClient _walletService;
private readonly ILogger<RedeemVoucherCommandHandler> _logger;
public RedeemVoucherCommandHandler(
ICampaignRepository campaignRepository,
IRedemptionRepository redemptionRepository,
IWalletServiceClient walletService,
ILogger<RedeemVoucherCommandHandler> logger)
{
_campaignRepository = campaignRepository;
_redemptionRepository = redemptionRepository;
_walletService = walletService;
_logger = logger;
}
public async Task<RedemptionDto> 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);
}
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using PromotionService.API.Application.DTOs;
namespace PromotionService.API.Application.Commands;
/// <summary>
/// EN: Command to claim a free voucher.
/// VI: Command để nhận voucher miễn phí.
/// </summary>
public record ClaimVoucherCommand(
Guid CampaignId,
Guid UserId) : IRequest<VoucherDto>;
/// <summary>
/// EN: Command to exchange points for a voucher.
/// VI: Command để đổi điểm lấy voucher.
/// </summary>
public record ExchangeVoucherCommand(
Guid CampaignId,
Guid UserId,
Guid UserWalletId) : IRequest<VoucherDto>;
/// <summary>
/// EN: Command to purchase a voucher.
/// VI: Command để mua voucher.
/// </summary>
public record PurchaseVoucherCommand(
Guid CampaignId,
Guid UserId,
Guid UserWalletId) : IRequest<VoucherDto>;
/// <summary>
/// EN: Command to redeem a voucher for an order.
/// VI: Command để sử dụng voucher cho đơn hàng.
/// </summary>
public record RedeemVoucherCommand(
string VoucherCode,
Guid UserId,
Guid? OrderId,
decimal OrderAmount) : IRequest<RedemptionDto>;

View File

@@ -0,0 +1,53 @@
namespace PromotionService.API.Application.DTOs;
/// <summary>
/// EN: Campaign DTO for API responses.
/// VI: DTO Campaign cho các phản hồi API.
/// </summary>
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);
/// <summary>
/// EN: Campaign summary DTO for list responses.
/// VI: DTO tóm tắt Campaign cho các phản hồi danh sách.
/// </summary>
public record CampaignSummaryDto(
Guid Id,
string Name,
string Status,
int TotalVouchers,
int IssuedVouchers,
DateTime StartDate,
DateTime EndDate);
/// <summary>
/// EN: Campaign statistics DTO.
/// VI: DTO thống kê Campaign.
/// </summary>
public record CampaignStatisticsDto(
Guid CampaignId,
string CampaignName,
int TotalVouchers,
int AvailableVouchers,
int ClaimedVouchers,
int RedeemedVouchers,
decimal TotalFaceValue,
decimal TotalRedeemedValue,
decimal UtilizationRate);

View File

@@ -0,0 +1,55 @@
namespace PromotionService.API.Application.DTOs;
/// <summary>
/// EN: Voucher DTO for API responses.
/// VI: DTO Voucher cho các phản hồi API.
/// </summary>
public record VoucherDto(
Guid Id,
Guid CampaignId,
string Code,
Guid? OwnerId,
decimal FaceValue,
decimal RemainingValue,
string Status,
DateTime? ClaimedAt,
DateTime? ExpiresAt,
DateTime? RedeemedAt);
/// <summary>
/// EN: Voucher summary DTO for list responses.
/// VI: DTO tóm tắt Voucher cho các phản hồi danh sách.
/// </summary>
public record VoucherSummaryDto(
Guid Id,
string Code,
decimal RemainingValue,
string Status,
DateTime? ExpiresAt);
/// <summary>
/// EN: Voucher validation result.
/// VI: Kết quả xác thực voucher.
/// </summary>
public record VoucherValidationDto(
bool IsValid,
string? ErrorMessage,
Guid? VoucherId,
string? VoucherCode,
decimal? RemainingValue,
DateTime? ExpiresAt,
string? CampaignName);
/// <summary>
/// EN: Redemption DTO.
/// VI: DTO Redemption.
/// </summary>
public record RedemptionDto(
Guid Id,
Guid VoucherId,
Guid CampaignId,
Guid UserId,
Guid? OrderId,
decimal AmountUsed,
decimal AmountRefunded,
DateTime RedeemedAt);

View File

@@ -1,23 +0,0 @@
using MediatR;
namespace PromotionService.API.Application.Queries;
/// <summary>
/// EN: Query to get a Sample by ID.
/// VI: Query để lấy một Sample theo ID.
/// </summary>
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
/// <summary>
/// EN: Sample view model for API responses.
/// VI: Sample view model cho API responses.
/// </summary>
public record SampleViewModel(
Guid Id,
string Name,
string? Description,
string Status,
DateTime CreatedAt,
DateTime? UpdatedAt
);

View File

@@ -1,39 +0,0 @@
using MediatR;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetSampleQuery.
/// VI: Handler cho GetSampleQuery.
/// </summary>
public class GetSampleQueryHandler : IRequestHandler<GetSampleQuery, SampleViewModel?>
{
private readonly ISampleRepository _sampleRepository;
public GetSampleQueryHandler(ISampleRepository sampleRepository)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
}
public async Task<SampleViewModel?> 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
);
}
}

View File

@@ -1,9 +0,0 @@
using MediatR;
namespace PromotionService.API.Application.Queries;
/// <summary>
/// EN: Query to get all Samples.
/// VI: Query để lấy tất cả Samples.
/// </summary>
public record GetSamplesQuery : IRequest<IEnumerable<SampleViewModel>>;

View File

@@ -1,34 +0,0 @@
using MediatR;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetSamplesQuery.
/// VI: Handler cho GetSamplesQuery.
/// </summary>
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, IEnumerable<SampleViewModel>>
{
private readonly ISampleRepository _sampleRepository;
public GetSamplesQueryHandler(ISampleRepository sampleRepository)
{
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
}
public async Task<IEnumerable<SampleViewModel>> 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
));
}
}

View File

@@ -0,0 +1,34 @@
using MediatR;
using PromotionService.API.Application.DTOs;
namespace PromotionService.API.Application.Queries;
/// <summary>
/// EN: Query to get a campaign by ID.
/// VI: Query để lấy chiến dịch theo ID.
/// </summary>
public record GetCampaignQuery(Guid CampaignId) : IRequest<CampaignDto?>;
/// <summary>
/// EN: Query to get campaigns by merchant.
/// VI: Query để lấy chiến dịch theo merchant.
/// </summary>
public record GetCampaignsQuery(Guid? MerchantId = null, bool ActiveOnly = false) : IRequest<IEnumerable<CampaignSummaryDto>>;
/// <summary>
/// EN: Query to get campaign statistics.
/// VI: Query để lấy thống kê chiến dịch.
/// </summary>
public record GetCampaignStatisticsQuery(Guid CampaignId) : IRequest<CampaignStatisticsDto?>;
/// <summary>
/// EN: Query to validate a voucher code.
/// VI: Query để xác thực mã voucher.
/// </summary>
public record ValidateVoucherQuery(string VoucherCode, Guid UserId) : IRequest<VoucherValidationDto>;
/// <summary>
/// EN: Query to get user's vouchers.
/// VI: Query để lấy voucher của người dùng.
/// </summary>
public record GetUserVouchersQuery(Guid UserId) : IRequest<IEnumerable<VoucherSummaryDto>>;

View File

@@ -0,0 +1,138 @@
using MediatR;
using PromotionService.API.Application.DTOs;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
namespace PromotionService.API.Application.Queries;
/// <summary>
/// EN: Handler for GetCampaignQuery.
/// VI: Handler cho GetCampaignQuery.
/// </summary>
public class GetCampaignQueryHandler : IRequestHandler<GetCampaignQuery, CampaignDto?>
{
private readonly ICampaignRepository _campaignRepository;
public GetCampaignQueryHandler(ICampaignRepository campaignRepository)
{
_campaignRepository = campaignRepository;
}
public async Task<CampaignDto?> 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"
};
}
/// <summary>
/// EN: Handler for GetCampaignsQuery.
/// VI: Handler cho GetCampaignsQuery.
/// </summary>
public class GetCampaignsQueryHandler : IRequestHandler<GetCampaignsQuery, IEnumerable<CampaignSummaryDto>>
{
private readonly ICampaignRepository _campaignRepository;
public GetCampaignsQueryHandler(ICampaignRepository campaignRepository)
{
_campaignRepository = campaignRepository;
}
public async Task<IEnumerable<CampaignSummaryDto>> Handle(GetCampaignsQuery request, CancellationToken cancellationToken)
{
IEnumerable<Campaign> 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));
}
}
/// <summary>
/// EN: Handler for ValidateVoucherQuery.
/// VI: Handler cho ValidateVoucherQuery.
/// </summary>
public class ValidateVoucherQueryHandler : IRequestHandler<ValidateVoucherQuery, VoucherValidationDto>
{
private readonly ICampaignRepository _campaignRepository;
public ValidateVoucherQueryHandler(ICampaignRepository campaignRepository)
{
_campaignRepository = campaignRepository;
}
public async Task<VoucherValidationDto> 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);
}
}
/// <summary>
/// EN: Handler for GetUserVouchersQuery.
/// VI: Handler cho GetUserVouchersQuery.
/// </summary>
public class GetUserVouchersQueryHandler : IRequestHandler<GetUserVouchersQuery, IEnumerable<VoucherSummaryDto>>
{
private readonly ICampaignRepository _campaignRepository;
public GetUserVouchersQueryHandler(ICampaignRepository campaignRepository)
{
_campaignRepository = campaignRepository;
}
public async Task<IEnumerable<VoucherSummaryDto>> 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));
}
}

View File

@@ -0,0 +1,81 @@
namespace PromotionService.API.Application.Services;
/// <summary>
/// EN: Interface for Wallet Service client (escrow operations).
/// VI: Interface cho Wallet Service client (thao tác ký quỹ).
/// </summary>
public interface IWalletServiceClient
{
/// <summary>
/// EN: Create an escrow hold in the wallet.
/// VI: Tạo một lệnh giữ ký quỹ trong ví.
/// </summary>
Task<HoldResult> CreateHoldAsync(
Guid walletId,
decimal amount,
string currencyType,
string referenceType,
Guid referenceId,
string description,
CancellationToken cancellationToken = default);
/// <summary>
/// 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ữ.
/// </summary>
Task<bool> ExecuteHoldAsync(
Guid walletId,
Guid holdId,
decimal amount,
string? executionRef = null,
CancellationToken cancellationToken = default);
/// <summary>
/// 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ữ.
/// </summary>
Task<bool> ReleaseHoldAsync(
Guid walletId,
Guid holdId,
decimal? amount = null,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Cancel the entire hold.
/// VI: Hủy toàn bộ lệnh giữ.
/// </summary>
Task<bool> CancelHoldAsync(
Guid walletId,
Guid holdId,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get wallet by user ID.
/// VI: Lấy ví theo ID người dùng.
/// </summary>
Task<WalletInfo?> GetWalletByUserIdAsync(
Guid userId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// EN: Result of escrow hold creation.
/// VI: Kết quả tạo lệnh giữ ký quỹ.
/// </summary>
public record HoldResult(
Guid HoldId,
Guid WalletId,
decimal Amount,
string CurrencyType,
string ReferenceType,
Guid ReferenceId,
string Status);
/// <summary>
/// EN: Wallet information.
/// VI: Thông tin ví.
/// </summary>
public record WalletInfo(
Guid Id,
Guid UserId,
string Status);

View File

@@ -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;
/// <summary>
/// EN: HTTP client implementation for Wallet Service.
/// VI: Triển khai HTTP client cho Wallet Service.
/// </summary>
public class WalletServiceClient : IWalletServiceClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<WalletServiceClient> _logger;
private readonly AsyncRetryPolicy<HttpResponseMessage> _retryPolicy;
public WalletServiceClient(HttpClient httpClient, ILogger<WalletServiceClient> logger)
{
_httpClient = httpClient;
_logger = logger;
// Retry policy with exponential backoff
_retryPolicy = Policy<HttpResponseMessage>
.Handle<HttpRequestException>()
.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<HoldResult> 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<HoldResult>(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<bool> 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<bool> 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<bool> 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<WalletInfo?> 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<WalletInfo>(cancellationToken: cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get wallet for user {UserId}", userId);
return null;
}
}
}

View File

@@ -1,25 +0,0 @@
using FluentValidation;
using PromotionService.API.Application.Commands;
namespace PromotionService.API.Application.Validations;
/// <summary>
/// EN: Validator for CreateSampleCommand.
/// VI: Validator cho CreateSampleCommand.
/// </summary>
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
{
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);
}
}

View File

@@ -1,29 +0,0 @@
using FluentValidation;
using PromotionService.API.Application.Commands;
namespace PromotionService.API.Application.Validations;
/// <summary>
/// EN: Validator for UpdateSampleCommand.
/// VI: Validator cho UpdateSampleCommand.
/// </summary>
public class UpdateSampleCommandValidator : AbstractValidator<UpdateSampleCommand>
{
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);
}
}

View File

@@ -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;
/// <summary>
/// EN: Controller for Campaign management.
/// VI: Controller để quản lý Campaign.
/// </summary>
[ApiController]
[Route("api/v1/[controller]")]
[Produces("application/json")]
public class CampaignsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<CampaignsController> _logger;
public CampaignsController(IMediator mediator, ILogger<CampaignsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Create a new campaign.
/// VI: Tạo chiến dịch mới.
/// </summary>
[HttpPost]
[Authorize(Roles = "Merchant,Admin")]
[ProducesResponseType(typeof(CampaignDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CampaignDto>> 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);
}
/// <summary>
/// EN: Get campaign by ID.
/// VI: Lấy chiến dịch theo ID.
/// </summary>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(CampaignDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CampaignDto>> GetCampaign(Guid id)
{
var result = await _mediator.Send(new GetCampaignQuery(id));
return result == null ? NotFound() : Ok(result);
}
/// <summary>
/// EN: Get campaigns list.
/// VI: Lấy danh sách chiến dịch.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<CampaignSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<CampaignSummaryDto>>> GetCampaigns(
[FromQuery] Guid? merchantId = null,
[FromQuery] bool activeOnly = false)
{
var result = await _mediator.Send(new GetCampaignsQuery(merchantId, activeOnly));
return Ok(result);
}
/// <summary>
/// EN: Activate a campaign.
/// VI: Kích hoạt chiến dịch.
/// </summary>
[HttpPost("{id:guid}/activate")]
[Authorize(Roles = "Merchant,Admin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ActivateCampaign(Guid id)
{
var result = await _mediator.Send(new ActivateCampaignCommand(id));
return result ? Ok() : NotFound();
}
/// <summary>
/// EN: Pause a campaign.
/// VI: Tạm dừng chiến dịch.
/// </summary>
[HttpPost("{id:guid}/pause")]
[Authorize(Roles = "Merchant,Admin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> PauseCampaign(Guid id)
{
var result = await _mediator.Send(new PauseCampaignCommand(id));
return result ? Ok() : NotFound();
}
/// <summary>
/// EN: Cancel a campaign.
/// VI: Hủy chiến dịch.
/// </summary>
[HttpPost("{id:guid}/cancel")]
[Authorize(Roles = "Merchant,Admin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> CancelCampaign(Guid id)
{
var result = await _mediator.Send(new CancelCampaignCommand(id));
return result ? Ok() : NotFound();
}
}

View File

@@ -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;
/// <summary>
/// EN: Controller for Sample CRUD operations using CQRS pattern.
/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[Produces("application/json")]
public class SamplesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<SamplesController> _logger;
public SamplesController(IMediator mediator, ILogger<SamplesController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get all samples.
/// VI: Lấy tất cả samples.
/// </summary>
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<SampleViewModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSamples()
{
var samples = await _mediator.Send(new GetSamplesQuery());
return Ok(new { success = true, data = samples });
}
/// <summary>
/// EN: Get a sample by ID.
/// VI: Lấy một sample theo ID.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <returns>EN: Sample details / VI: Chi tiết sample</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Create a new sample.
/// VI: Tạo một sample mới.
/// </summary>
/// <param name="request">EN: Create request / VI: Request tạo</param>
/// <returns>EN: Created sample ID / VI: ID sample đã tạo</returns>
[HttpPost]
[ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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 });
}
/// <summary>
/// EN: Update an existing sample.
/// VI: Cập nhật một sample đã tồn tại.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <param name="request">EN: Update request / VI: Request cập nhật</param>
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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" });
}
/// <summary>
/// EN: Delete a sample.
/// VI: Xóa một sample.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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();
}
/// <summary>
/// EN: Change sample status.
/// VI: Thay đổi trạng thái sample.
/// </summary>
/// <param name="id">EN: Sample ID / VI: ID sample</param>
/// <param name="request">EN: Status change request / VI: Request thay đổi trạng thái</param>
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
[HttpPatch("{id:guid}/status")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> 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" });
}
}
/// <summary>
/// EN: Request model for creating a sample.
/// VI: Model request để tạo sample.
/// </summary>
public record CreateSampleRequest(string Name, string? Description);
/// <summary>
/// EN: Request model for updating a sample.
/// VI: Model request để cập nhật sample.
/// </summary>
public record UpdateSampleRequest(string Name, string? Description);
/// <summary>
/// EN: Request model for changing sample status.
/// VI: Model request để thay đổi trạng thái sample.
/// </summary>
public record ChangeStatusRequest(string Status);

View File

@@ -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;
/// <summary>
/// EN: Controller for Voucher operations.
/// VI: Controller cho các thao tác Voucher.
/// </summary>
[ApiController]
[Route("api/v1/[controller]")]
[Produces("application/json")]
public class VouchersController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<VouchersController> _logger;
public VouchersController(IMediator mediator, ILogger<VouchersController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Claim a free voucher.
/// VI: Nhận voucher miễn phí.
/// </summary>
[HttpPost("claim")]
[Authorize]
[ProducesResponseType(typeof(VoucherDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<VoucherDto>> ClaimVoucher([FromBody] ClaimVoucherCommand command)
{
var result = await _mediator.Send(command);
return Ok(result);
}
/// <summary>
/// EN: Validate a voucher code.
/// VI: Xác thực mã voucher.
/// </summary>
[HttpGet("validate/{code}")]
[Authorize]
[ProducesResponseType(typeof(VoucherValidationDto), StatusCodes.Status200OK)]
public async Task<ActionResult<VoucherValidationDto>> ValidateVoucher(string code, [FromQuery] Guid userId)
{
var result = await _mediator.Send(new ValidateVoucherQuery(code, userId));
return Ok(result);
}
/// <summary>
/// EN: Redeem a voucher.
/// VI: Sử dụng voucher.
/// </summary>
[HttpPost("redeem")]
[Authorize]
[ProducesResponseType(typeof(RedemptionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<RedemptionDto>> RedeemVoucher([FromBody] RedeemVoucherCommand command)
{
var result = await _mediator.Send(command);
return Ok(result);
}
/// <summary>
/// EN: Get user's vouchers.
/// VI: Lấy voucher của người dùng.
/// </summary>
[HttpGet("user/{userId:guid}")]
[Authorize]
[ProducesResponseType(typeof(IEnumerable<VoucherSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<VoucherSummaryDto>>> GetUserVouchers(Guid userId)
{
var result = await _mediator.Send(new GetUserVouchersQuery(userId));
return Ok(result);
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace PromotionService.API.HealthChecks;
/// <summary>
/// EN: Health check for Wallet Service connectivity.
/// VI: Kiểm tra sức khỏe kết nối Wallet Service.
/// </summary>
public class WalletServiceHealthCheck : IHealthCheck
{
private readonly HttpClient _httpClient;
private readonly ILogger<WalletServiceHealthCheck> _logger;
public WalletServiceHealthCheck(IHttpClientFactory httpClientFactory, ILogger<WalletServiceHealthCheck> logger)
{
_httpClient = httpClientFactory.CreateClient("WalletService");
_logger = logger;
}
public async Task<HealthCheckResult> 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);
}
}
}

View File

@@ -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<IWalletServiceClient, WalletServiceClient>(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<WalletServiceHealthCheck>("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<bool>("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");

View File

@@ -14,6 +14,11 @@
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />

View File

@@ -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
}
}

View File

@@ -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": "*"
}

View File

@@ -1,61 +0,0 @@
using PromotionService.Domain.SeedWork;
namespace PromotionService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Repository interface for Sample aggregate.
/// VI: Interface repository cho Sample aggregate.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface ISampleRepository : IRepository<Sample>
{
/// <summary>
/// EN: Get a sample by its ID.
/// VI: Lấy một sample theo ID.
/// </summary>
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
Task<Sample?> GetAsync(Guid sampleId);
/// <summary>
/// EN: Get all samples.
/// VI: Lấy tất cả samples.
/// </summary>
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
Task<IEnumerable<Sample>> GetAllAsync();
/// <summary>
/// EN: Add a new sample.
/// VI: Thêm một sample mới.
/// </summary>
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
Sample Add(Sample sample);
/// <summary>
/// EN: Update an existing sample.
/// VI: Cập nhật một sample đã tồn tại.
/// </summary>
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
void Update(Sample sample);
/// <summary>
/// EN: Delete a sample.
/// VI: Xóa một sample.
/// </summary>
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
void Delete(Sample sample);
/// <summary>
/// EN: Get samples by status.
/// VI: Lấy samples theo trạng thái.
/// </summary>
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
}

View File

@@ -1,158 +0,0 @@
using PromotionService.Domain.Events;
using PromotionService.Domain.Exceptions;
using PromotionService.Domain.SeedWork;
namespace PromotionService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Sample aggregate root demonstrating DDD patterns.
/// VI: Sample aggregate root minh họa các pattern DDD.
/// </summary>
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;
/// <summary>
/// EN: Sample name (required).
/// VI: Tên sample (bắt buộc).
/// </summary>
public string Name => _name;
/// <summary>
/// EN: Optional description.
/// VI: Mô tả tùy chọn.
/// </summary>
public string? Description => _description;
/// <summary>
/// EN: Current status.
/// VI: Trạng thái hiện tại.
/// </summary>
public SampleStatus Status => _status;
/// <summary>
/// EN: Status ID for EF Core mapping.
/// VI: ID trạng thái cho EF Core mapping.
/// </summary>
public int StatusId { get; private set; }
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected Sample()
{
}
/// <summary>
/// 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.
/// </summary>
/// <param name="name">EN: Sample name / VI: Tên sample</param>
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
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));
}
/// <summary>
/// EN: Update sample information.
/// VI: Cập nhật thông tin sample.
/// </summary>
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;
}
/// <summary>
/// EN: Activate the sample.
/// VI: Kích hoạt sample.
/// </summary>
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));
}
/// <summary>
/// EN: Complete the sample.
/// VI: Hoàn thành sample.
/// </summary>
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));
}
/// <summary>
/// EN: Cancel the sample.
/// VI: Hủy sample.
/// </summary>
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));
}
}

View File

@@ -1,77 +0,0 @@
using PromotionService.Domain.SeedWork;
namespace PromotionService.Domain.AggregatesModel.SampleAggregate;
/// <summary>
/// EN: Sample status enumeration following type-safe enum pattern.
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
/// </summary>
public class SampleStatus : Enumeration
{
/// <summary>
/// EN: Draft status - initial state
/// VI: Trạng thái nháp - trạng thái ban đầu
/// </summary>
public static SampleStatus Draft = new(1, nameof(Draft));
/// <summary>
/// EN: Active status - ready for use
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
/// </summary>
public static SampleStatus Active = new(2, nameof(Active));
/// <summary>
/// EN: Completed status - finished processing
/// VI: Trạng thái hoàn thành - đã xử lý xong
/// </summary>
public static SampleStatus Completed = new(3, nameof(Completed));
/// <summary>
/// EN: Cancelled status - cancelled by user
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
/// </summary>
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
public SampleStatus(int id, string name) : base(id, name)
{
}
/// <summary>
/// EN: Get all available statuses.
/// VI: Lấy tất cả các trạng thái có sẵn.
/// </summary>
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
/// <summary>
/// EN: Parse status from name.
/// VI: Parse trạng thái từ tên.
/// </summary>
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;
}
/// <summary>
/// EN: Parse status from ID.
/// VI: Parse trạng thái từ ID.
/// </summary>
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;
}
}

View File

@@ -1,22 +0,0 @@
using MediatR;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.Domain.Events;
/// <summary>
/// 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.
/// </summary>
public class SampleCreatedDomainEvent : INotification
{
/// <summary>
/// EN: The newly created sample.
/// VI: Sample mới được tạo.
/// </summary>
public Sample Sample { get; }
public SampleCreatedDomainEvent(Sample sample)
{
Sample = sample;
}
}

View File

@@ -1,39 +0,0 @@
using MediatR;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.Domain.Events;
/// <summary>
/// EN: Domain event raised when Sample status changes.
/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
/// </summary>
public class SampleStatusChangedDomainEvent : INotification
{
/// <summary>
/// EN: The sample ID.
/// VI: ID của sample.
/// </summary>
public Guid SampleId { get; }
/// <summary>
/// EN: Previous status before the change.
/// VI: Trạng thái trước khi thay đổi.
/// </summary>
public SampleStatus PreviousStatus { get; }
/// <summary>
/// EN: New status after the change.
/// VI: Trạng thái mới sau khi thay đổi.
/// </summary>
public SampleStatus NewStatus { get; }
public SampleStatusChangedDomainEvent(
Guid sampleId,
SampleStatus previousStatus,
SampleStatus newStatus)
{
SampleId = sampleId;
PreviousStatus = previousStatus;
NewStatus = newStatus;
}
}

View File

@@ -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<ISampleRepository, SampleRepository>();
services.AddScoped<ICampaignRepository, CampaignRepository>();
services.AddScoped<IRedemptionRepository, RedemptionRepository>();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped<IRequestManager, RequestManager>();

View File

@@ -0,0 +1,129 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
namespace PromotionService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Campaign entity.
/// VI: Cấu hình EF Core cho entity Campaign.
/// </summary>
public class CampaignEntityTypeConfiguration : IEntityTypeConfiguration<Campaign>
{
public void Configure(EntityTypeBuilder<Campaign> 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");
}
}

View File

@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
namespace PromotionService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Redemption entity.
/// VI: Cấu hình EF Core cho entity Redemption.
/// </summary>
public class RedemptionEntityTypeConfiguration : IEntityTypeConfiguration<Redemption>
{
public void Configure(EntityTypeBuilder<Redemption> 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");
}
}

View File

@@ -1,61 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Sample entity.
/// VI: Cấu hình EF Core cho entity Sample.
/// </summary>
public class SampleEntityTypeConfiguration : IEntityTypeConfiguration<Sample>
{
public void Configure(EntityTypeBuilder<Sample> 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<string>("_name")
.HasColumnName("name")
.HasMaxLength(200)
.IsRequired();
builder.Property<string?>("_description")
.HasColumnName("description")
.HasMaxLength(1000);
builder.Property<DateTime>("_createdAt")
.HasColumnName("created_at")
.IsRequired();
builder.Property<DateTime?>("_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");
}
}

View File

@@ -1,39 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
namespace PromotionService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for SampleStatus enumeration.
/// VI: Cấu hình EF Core cho enumeration SampleStatus.
/// </summary>
public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration<SampleStatus>
{
public void Configure(EntityTypeBuilder<SampleStatus> 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
);
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
namespace PromotionService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: EF Core configuration for Voucher entity.
/// VI: Cấu hình EF Core cho entity Voucher.
/// </summary>
public class VoucherEntityTypeConfiguration : IEntityTypeConfiguration<Voucher>
{
public void Configure(EntityTypeBuilder<Voucher> 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");
}
}

View File

@@ -0,0 +1,286 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("AcquisitionPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("acquisition_price");
b.Property<int>("AcquisitionTypeId")
.HasColumnType("integer")
.HasColumnName("acquisition_type_id");
b.Property<string>("BackingAssetCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("backing_asset_code");
b.Property<int>("BackingAssetTypeId")
.HasColumnType("integer")
.HasColumnName("backing_asset_type_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<DateTime>("EndDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("end_date");
b.Property<decimal>("EscrowAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("escrow_amount");
b.Property<Guid?>("EscrowHoldId")
.HasColumnType("uuid")
.HasColumnName("escrow_hold_id");
b.Property<Guid?>("EscrowWalletId")
.HasColumnType("uuid")
.HasColumnName("escrow_wallet_id");
b.Property<decimal>("FaceValue")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("face_value");
b.Property<int>("IssuedVouchers")
.HasColumnType("integer")
.HasColumnName("issued_vouchers");
b.Property<int>("MaxPerUser")
.HasColumnType("integer")
.HasColumnName("max_per_user");
b.Property<Guid>("MerchantId")
.HasColumnType("uuid")
.HasColumnName("merchant_id");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("name");
b.Property<DateTime>("StartDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("start_date");
b.Property<int>("StatusId")
.HasColumnType("integer")
.HasColumnName("status_id");
b.Property<int>("TotalVouchers")
.HasColumnType("integer")
.HasColumnName("total_vouchers");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<int>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid")
.HasColumnName("campaign_id");
b.Property<DateTime?>("ClaimedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("claimed_at");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("code");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<decimal>("FaceValue")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("face_value");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid")
.HasColumnName("owner_id");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("redeemed_at");
b.Property<decimal>("RemainingValue")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("remaining_value");
b.Property<int>("StatusId")
.HasColumnType("integer")
.HasColumnName("status_id");
b.Property<DateTime>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("AmountRefunded")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("amount_refunded");
b.Property<decimal>("AmountUsed")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("amount_used");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid")
.HasColumnName("campaign_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("ExecutionReference")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("execution_reference");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid")
.HasColumnName("order_id");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("redeemed_at");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<Guid>("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
}
}
}

View File

@@ -0,0 +1,163 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PromotionService.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "campaigns",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
merchant_id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
backing_asset_type_id = table.Column<int>(type: "integer", nullable: false),
backing_asset_code = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
face_value = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
acquisition_type_id = table.Column<int>(type: "integer", nullable: false),
acquisition_price = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
escrow_hold_id = table.Column<Guid>(type: "uuid", nullable: true),
escrow_wallet_id = table.Column<Guid>(type: "uuid", nullable: true),
escrow_amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
total_vouchers = table.Column<int>(type: "integer", nullable: false),
issued_vouchers = table.Column<int>(type: "integer", nullable: false),
max_per_user = table.Column<int>(type: "integer", nullable: false),
start_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
end_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
voucher_validity_days = table.Column<int>(type: "integer", nullable: false),
status_id = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
voucher_id = table.Column<Guid>(type: "uuid", nullable: false),
campaign_id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
order_id = table.Column<Guid>(type: "uuid", nullable: true),
amount_used = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
amount_refunded = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
execution_reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
redeemed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
campaign_id = table.Column<Guid>(type: "uuid", nullable: false),
code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
owner_id = table.Column<Guid>(type: "uuid", nullable: true),
face_value = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
remaining_value = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
status_id = table.Column<int>(type: "integer", nullable: false),
claimed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
redeemed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "redemptions");
migrationBuilder.DropTable(
name: "vouchers");
migrationBuilder.DropTable(
name: "campaigns");
}
}
}

View File

@@ -0,0 +1,283 @@
// <auto-generated />
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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("AcquisitionPrice")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("acquisition_price");
b.Property<int>("AcquisitionTypeId")
.HasColumnType("integer")
.HasColumnName("acquisition_type_id");
b.Property<string>("BackingAssetCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("backing_asset_code");
b.Property<int>("BackingAssetTypeId")
.HasColumnType("integer")
.HasColumnName("backing_asset_type_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("description");
b.Property<DateTime>("EndDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("end_date");
b.Property<decimal>("EscrowAmount")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("escrow_amount");
b.Property<Guid?>("EscrowHoldId")
.HasColumnType("uuid")
.HasColumnName("escrow_hold_id");
b.Property<Guid?>("EscrowWalletId")
.HasColumnType("uuid")
.HasColumnName("escrow_wallet_id");
b.Property<decimal>("FaceValue")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("face_value");
b.Property<int>("IssuedVouchers")
.HasColumnType("integer")
.HasColumnName("issued_vouchers");
b.Property<int>("MaxPerUser")
.HasColumnType("integer")
.HasColumnName("max_per_user");
b.Property<Guid>("MerchantId")
.HasColumnType("uuid")
.HasColumnName("merchant_id");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)")
.HasColumnName("name");
b.Property<DateTime>("StartDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("start_date");
b.Property<int>("StatusId")
.HasColumnType("integer")
.HasColumnName("status_id");
b.Property<int>("TotalVouchers")
.HasColumnType("integer")
.HasColumnName("total_vouchers");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<int>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid")
.HasColumnName("campaign_id");
b.Property<DateTime?>("ClaimedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("claimed_at");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("code");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<decimal>("FaceValue")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("face_value");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid")
.HasColumnName("owner_id");
b.Property<DateTime?>("RedeemedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("redeemed_at");
b.Property<decimal>("RemainingValue")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("remaining_value");
b.Property<int>("StatusId")
.HasColumnType("integer")
.HasColumnName("status_id");
b.Property<DateTime>("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<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("AmountRefunded")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("amount_refunded");
b.Property<decimal>("AmountUsed")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("amount_used");
b.Property<Guid>("CampaignId")
.HasColumnType("uuid")
.HasColumnName("campaign_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("ExecutionReference")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("execution_reference");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid")
.HasColumnName("order_id");
b.Property<DateTime>("RedeemedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("redeemed_at");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<Guid>("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
}
}
}

View File

@@ -1,7 +1,8 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
using PromotionService.Domain.SeedWork;
using PromotionService.Infrastructure.EntityConfigurations;
@@ -17,10 +18,22 @@ public class PromotionServiceContext : DbContext, IUnitOfWork
private IDbContextTransaction? _currentTransaction;
/// <summary>
/// EN: Samples table.
/// VI: Bảng Samples.
/// EN: Campaigns table.
/// VI: Bảng Campaigns.
/// </summary>
public DbSet<Sample> Samples => Set<Sample>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
/// <summary>
/// EN: Vouchers table.
/// VI: Bảng Vouchers.
/// </summary>
public DbSet<Voucher> Vouchers => Set<Voucher>();
/// <summary>
/// EN: Redemptions table.
/// VI: Bảng Redemptions.
/// </summary>
public DbSet<Redemption> Redemptions => Set<Redemption>();
/// <summary>
/// EN: Read-only access to current transaction.
@@ -50,8 +63,9 @@ public class PromotionServiceContext : DbContext, IUnitOfWork
{
// EN: Apply entity configurations
// VI: Áp dụng các cấu hình entity
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new CampaignEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new VoucherEntityTypeConfiguration());
modelBuilder.ApplyConfiguration(new RedemptionEntityTypeConfiguration());
}
/// <summary>

View File

@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore;
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
using PromotionService.Domain.SeedWork;
namespace PromotionService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for Campaign aggregate.
/// VI: Triển khai repository cho Campaign aggregate.
/// </summary>
public class CampaignRepository : ICampaignRepository
{
private readonly PromotionServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public CampaignRepository(PromotionServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<Campaign?> GetByIdAsync(Guid id)
{
return await _context.Campaigns
.Include(c => c.Vouchers)
.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<IEnumerable<Campaign>> GetByMerchantIdAsync(Guid merchantId)
{
return await _context.Campaigns
.Where(c => c.MerchantId == merchantId)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
}
public async Task<IEnumerable<Campaign>> GetActiveAsync()
{
var now = DateTime.UtcNow;
return await _context.Campaigns
.Where(c => c.StatusId == CampaignStatus.Active.Id
&& c.StartDate <= now
&& c.EndDate >= now)
.OrderBy(c => c.EndDate)
.ToListAsync();
}
public async Task<Voucher?> GetVoucherByCodeAsync(string code)
{
return await _context.Vouchers
.FirstOrDefaultAsync(v => v.Code == code);
}
public async Task<IEnumerable<Voucher>> GetUserVouchersAsync(Guid userId)
{
return await _context.Vouchers
.Where(v => v.OwnerId == userId)
.OrderByDescending(v => v.ClaimedAt)
.ToListAsync();
}
public Campaign Add(Campaign campaign)
{
return _context.Campaigns.Add(campaign).Entity;
}
public void Update(Campaign campaign)
{
_context.Entry(campaign).State = EntityState.Modified;
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
using PromotionService.Domain.SeedWork;
namespace PromotionService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for Redemption aggregate.
/// VI: Triển khai repository cho Redemption aggregate.
/// </summary>
public class RedemptionRepository : IRedemptionRepository
{
private readonly PromotionServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public RedemptionRepository(PromotionServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task<IEnumerable<Redemption>> GetByVoucherIdAsync(Guid voucherId)
{
return await _context.Redemptions
.Where(r => r.VoucherId == voucherId)
.OrderByDescending(r => r.RedeemedAt)
.ToListAsync();
}
public async Task<IEnumerable<Redemption>> GetByUserIdAsync(Guid userId)
{
return await _context.Redemptions
.Where(r => r.UserId == userId)
.OrderByDescending(r => r.RedeemedAt)
.ToListAsync();
}
public async Task<IEnumerable<Redemption>> GetByCampaignIdAsync(Guid campaignId)
{
return await _context.Redemptions
.Where(r => r.CampaignId == campaignId)
.OrderByDescending(r => r.RedeemedAt)
.ToListAsync();
}
public Redemption Add(Redemption redemption)
{
return _context.Redemptions.Add(redemption).Entity;
}
}

View File

@@ -1,72 +0,0 @@
using Microsoft.EntityFrameworkCore;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
using PromotionService.Domain.SeedWork;
namespace PromotionService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for Sample aggregate.
/// VI: Triển khai repository cho Sample aggregate.
/// </summary>
public class SampleRepository : ISampleRepository
{
private readonly PromotionServiceContext _context;
/// <summary>
/// EN: Unit of work for transaction management.
/// VI: Unit of work cho quản lý transaction.
/// </summary>
public IUnitOfWork UnitOfWork => _context;
public SampleRepository(PromotionServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc/>
public async Task<Sample?> GetAsync(Guid sampleId)
{
var sample = await _context.Samples
.Include(s => s.Status)
.FirstOrDefaultAsync(s => s.Id == sampleId);
return sample;
}
/// <inheritdoc/>
public async Task<IEnumerable<Sample>> GetAllAsync()
{
return await _context.Samples
.Include(s => s.Status)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
/// <inheritdoc/>
public Sample Add(Sample sample)
{
return _context.Samples.Add(sample).Entity;
}
/// <inheritdoc/>
public void Update(Sample sample)
{
_context.Entry(sample).State = EntityState.Modified;
}
/// <inheritdoc/>
public void Delete(Sample sample)
{
_context.Samples.Remove(sample);
}
/// <inheritdoc/>
public async Task<IEnumerable<Sample>> GetByStatusAsync(int statusId)
{
return await _context.Samples
.Include(s => s.Status)
.Where(s => s.StatusId == statusId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
}

View File

@@ -1,65 +0,0 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using PromotionService.API.Application.Commands;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
using PromotionService.Domain.SeedWork;
using Xunit;
namespace PromotionService.UnitTests.Application;
/// <summary>
/// EN: Unit tests for CreateSampleCommandHandler.
/// VI: Unit tests cho CreateSampleCommandHandler.
/// </summary>
public class CreateSampleCommandHandlerTests
{
private readonly Mock<ISampleRepository> _mockRepository;
private readonly Mock<ILogger<CreateSampleCommandHandler>> _mockLogger;
private readonly CreateSampleCommandHandler _handler;
public CreateSampleCommandHandlerTests()
{
_mockRepository = new Mock<ISampleRepository>();
_mockLogger = new Mock<ILogger<CreateSampleCommandHandler>>();
var mockUnitOfWork = new Mock<IUnitOfWork>();
mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object);
_handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object);
}
[Fact]
public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId()
{
// Arrange
var command = new CreateSampleCommand("Test Sample", "Test Description");
_mockRepository.Setup(r => r.Add(It.IsAny<Sample>()))
.Returns((Sample s) => s);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Id.Should().NotBeEmpty();
_mockRepository.Verify(r => r.Add(It.IsAny<Sample>()), Times.Once);
}
[Fact]
public async Task Handle_WithValidCommand_ShouldCallSaveEntities()
{
// Arrange
var command = new CreateSampleCommand("Test Sample", null);
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
_mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -1,151 +0,0 @@
using FluentAssertions;
using PromotionService.Domain.AggregatesModel.SampleAggregate;
using PromotionService.Domain.Exceptions;
using Xunit;
namespace PromotionService.UnitTests.Domain;
/// <summary>
/// EN: Unit tests for Sample aggregate.
/// VI: Unit tests cho Sample aggregate.
/// </summary>
public class SampleAggregateTests
{
[Fact]
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
{
// Arrange
var name = "Test Sample";
var description = "Test Description";
// Act
var sample = new Sample(name, description);
// Assert
sample.Name.Should().Be(name);
sample.Description.Should().Be(description);
sample.Status.Should().Be(SampleStatus.Draft);
sample.Id.Should().NotBeEmpty();
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
}
[Fact]
public void CreateSample_WithEmptyName_ShouldThrowException()
{
// Arrange
var name = "";
// Act
var act = () => new Sample(name);
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Sample name cannot be empty");
}
[Fact]
public void Activate_WhenDraft_ShouldChangeToActive()
{
// Arrange
var sample = new Sample("Test Sample");
sample.ClearDomainEvents();
// Act
sample.Activate();
// Assert
sample.Status.Should().Be(SampleStatus.Active);
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
}
[Fact]
public void Activate_WhenNotDraft_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
// Act
var act = () => sample.Activate();
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Only draft samples can be activated");
}
[Fact]
public void Complete_WhenActive_ShouldChangeToCompleted()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
sample.ClearDomainEvents();
// Act
sample.Complete();
// Assert
sample.Status.Should().Be(SampleStatus.Completed);
}
[Fact]
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
{
// Arrange
var sample = new Sample("Test Sample");
// Act
sample.Cancel();
// Assert
sample.Status.Should().Be(SampleStatus.Cancelled);
}
[Fact]
public void Cancel_WhenCompleted_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Activate();
sample.Complete();
// Act
var act = () => sample.Cancel();
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Cannot cancel a completed sample");
}
[Fact]
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
{
// Arrange
var sample = new Sample("Original Name", "Original Description");
var newName = "Updated Name";
var newDescription = "Updated Description";
// Act
sample.Update(newName, newDescription);
// Assert
sample.Name.Should().Be(newName);
sample.Description.Should().Be(newDescription);
sample.UpdatedAt.Should().NotBeNull();
}
[Fact]
public void Update_WhenCancelled_ShouldThrowException()
{
// Arrange
var sample = new Sample("Test Sample");
sample.Cancel();
// Act
var act = () => sample.Update("New Name", null);
// Assert
act.Should().Throw<SampleDomainException>()
.WithMessage("Cannot update a cancelled sample");
}
}