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