diff --git a/services/iam-service-net/src/IamService.API/Program.cs b/services/iam-service-net/src/IamService.API/Program.cs index 13d4a48a..01b74ec3 100644 --- a/services/iam-service-net/src/IamService.API/Program.cs +++ b/services/iam-service-net/src/IamService.API/Program.cs @@ -4,6 +4,7 @@ using Hellang.Middleware.ProblemDetails; using IamService.API.Application.Behaviors; using IamService.API.Swagger; using IamService.Infrastructure; +using IamService.Infrastructure.Authorization; using Serilog; var builder = WebApplication.CreateBuilder(args); @@ -21,6 +22,11 @@ builder.Host.UseSerilog((context, services, configuration) => configuration // VI: Thêm Infrastructure services (Identity, Duende IdentityServer, Repositories) builder.Services.AddInfrastructure(builder.Configuration, builder.Environment.EnvironmentName); +// EN: Add Authorization Policies for Backoffice APIs +// VI: Thêm Authorization Policies cho các API Backoffice +builder.Services.AddHttpContextAccessor(); +builder.Services.AddAuthorizationPolicies(); + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors builder.Services.AddMediatR(cfg => { diff --git a/services/iam-service-net/src/IamService.Infrastructure/Authorization/AuthorizationExtensions.cs b/services/iam-service-net/src/IamService.Infrastructure/Authorization/AuthorizationExtensions.cs new file mode 100644 index 00000000..f90fb14f --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Authorization/AuthorizationExtensions.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace IamService.Infrastructure.Authorization; + +/// +/// EN: Extension methods for configuring Authorization Policies. +/// VI: Các extension methods để cấu hình Authorization Policies. +/// +public static class AuthorizationExtensions +{ + /// + /// EN: Add authorization policies for IAM Service. + /// VI: Thêm authorization policies cho IAM Service. + /// + /// Service collection / Service collection + /// Service collection with authorization policies / Service collection với authorization policies + public static IServiceCollection AddAuthorizationPolicies(this IServiceCollection services) + { + services.AddAuthorizationBuilder() + // EN: SuperAdmin - Full system access + // VI: SuperAdmin - Quyền truy cập toàn hệ thống + .AddPolicy("RequireSuperAdmin", policy => + policy.RequireRole("SuperAdmin")) + + // EN: Admin - Platform management (SuperAdmin or Admin) + // VI: Admin - Quản lý platform (SuperAdmin hoặc Admin) + .AddPolicy("RequireAdmin", policy => + policy.RequireRole("SuperAdmin", "Admin")) + + // EN: Auditor - Read-only audit access + // VI: Auditor - Quyền xem audit logs + .AddPolicy("RequireAuditor", policy => + policy.RequireRole("SuperAdmin", "Admin", "Auditor")); + + // EN: Register custom authorization handler for OwnerOrAdmin policy + // VI: Đăng ký custom authorization handler cho policy OwnerOrAdmin + services.AddScoped(); + + return services; + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Authorization/OwnerOrAdminRequirement.cs b/services/iam-service-net/src/IamService.Infrastructure/Authorization/OwnerOrAdminRequirement.cs new file mode 100644 index 00000000..183c2558 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Authorization/OwnerOrAdminRequirement.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace IamService.Infrastructure.Authorization; + +/// +/// EN: Authorization requirement for OwnerOrAdmin policy. +/// VI: Yêu cầu authorization cho policy OwnerOrAdmin. +/// +/// +/// EN: This requirement allows access if: +/// - User has Admin or SuperAdmin role, OR +/// - User is accessing their own resource (userId in route matches current user) +/// VI: Yêu cầu này cho phép truy cập nếu: +/// - User có role Admin hoặc SuperAdmin, HOẶC +/// - User đang truy cập resource của chính mình (userId trong route khớp với user hiện tại) +/// +public class OwnerOrAdminRequirement : IAuthorizationRequirement +{ +} + +/// +/// EN: Authorization handler for OwnerOrAdmin requirement. +/// VI: Authorization handler cho yêu cầu OwnerOrAdmin. +/// +public class OwnerOrAdminHandler : AuthorizationHandler +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// EN: Initializes a new instance of OwnerOrAdminHandler. + /// VI: Khởi tạo instance mới của OwnerOrAdminHandler. + /// + /// HTTP context accessor / HTTP context accessor + public OwnerOrAdminHandler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + /// + /// EN: Handle the authorization requirement. + /// VI: Xử lý yêu cầu authorization. + /// + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + OwnerOrAdminRequirement requirement) + { + // EN: Admin or SuperAdmin always allowed + // VI: Admin hoặc SuperAdmin luôn được phép + if (context.User.IsInRole("Admin") || context.User.IsInRole("SuperAdmin")) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + // EN: Get current user ID from claims + // VI: Lấy user ID hiện tại từ claims + var currentUserId = context.User.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(currentUserId)) + { + return Task.CompletedTask; + } + + // EN: Get the resource user ID from route + // VI: Lấy resource user ID từ route + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + { + return Task.CompletedTask; + } + + // EN: Check if route has 'id' parameter + // VI: Kiểm tra route có parameter 'id' không + var routeUserId = httpContext.GetRouteValue("id")?.ToString(); + if (string.IsNullOrEmpty(routeUserId)) + { + return Task.CompletedTask; + } + + // EN: User accessing their own resource + // VI: User đang truy cập resource của chính mình + if (currentUserId.Equals(routeUserId, StringComparison.OrdinalIgnoreCase)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateLevelDefinitionCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateLevelDefinitionCommand.cs new file mode 100644 index 00000000..fc825190 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateLevelDefinitionCommand.cs @@ -0,0 +1,70 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to create a new level definition. +/// VI: Command để tạo level definition mới. +/// +public class CreateLevelDefinitionCommand : IRequest +{ + /// + /// EN: Level number (1, 2, 3...). + /// VI: Số thứ tự level (1, 2, 3...). + /// + [Required] + [Range(1, 100)] + public int LevelNumber { get; set; } + + /// + /// EN: Level name (Bronze, Silver, Gold...). + /// VI: Tên level (Bronze, Silver, Gold...). + /// + [Required] + [StringLength(100, MinimumLength = 1)] + public string Name { get; set; } = string.Empty; + + /// + /// EN: Required EXP to reach this level. + /// VI: EXP cần thiết để đạt level này. + /// + [Required] + [Range(0, int.MaxValue)] + public int RequiredExp { get; set; } + + /// + /// EN: Level description. + /// VI: Mô tả level. + /// + [StringLength(500)] + public string? Description { get; set; } + + /// + /// EN: Icon URL for the level badge. + /// VI: URL icon cho badge level. + /// + [StringLength(500)] + public string? IconUrl { get; set; } + + /// + /// EN: Badge color in hex format (#CD7F32, #FFD700...). + /// VI: Màu badge dạng hex (#CD7F32, #FFD700...). + /// + [StringLength(20)] + [RegularExpression(@"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", ErrorMessage = "Invalid hex color format")] + public string? BadgeColor { get; set; } +} + +/// +/// EN: Result of creating a level definition. +/// VI: Kết quả tạo level definition. +/// +public class CreateLevelDefinitionResult +{ + public Guid Id { get; set; } + public int LevelNumber { get; set; } + public string Name { get; set; } = string.Empty; + public int RequiredExp { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateLevelDefinitionCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateLevelDefinitionCommandHandler.cs new file mode 100644 index 00000000..ecd53df3 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateLevelDefinitionCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.LevelAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for creating a new level definition. +/// VI: Handler để tạo level definition mới. +/// +public class CreateLevelDefinitionCommandHandler : IRequestHandler +{ + private readonly ILevelDefinitionRepository _levelDefinitionRepository; + private readonly ILogger _logger; + + public CreateLevelDefinitionCommandHandler( + ILevelDefinitionRepository levelDefinitionRepository, + ILogger logger) + { + _levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(CreateLevelDefinitionCommand request, CancellationToken cancellationToken) + { + // EN: Check if level number already exists + // VI: Kiểm tra xem level number đã tồn tại chưa + var exists = await _levelDefinitionRepository.ExistsByLevelNumberAsync(request.LevelNumber); + if (exists) + { + throw new InvalidOperationException($"Level number {request.LevelNumber} already exists"); + } + + // EN: Create new level definition + // VI: Tạo level definition mới + var levelDefinition = new LevelDefinition( + request.LevelNumber, + request.Name, + request.RequiredExp, + request.Description, + request.IconUrl, + request.BadgeColor); + + _levelDefinitionRepository.Add(levelDefinition); + await _levelDefinitionRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Created level definition {LevelId} with number {LevelNumber} and name {LevelName}", + levelDefinition.Id, levelDefinition.LevelNumber, levelDefinition.Name); + + return new CreateLevelDefinitionResult + { + Id = levelDefinition.Id, + LevelNumber = levelDefinition.LevelNumber, + Name = levelDefinition.Name, + RequiredExp = levelDefinition.RequiredExp, + CreatedAt = levelDefinition.CreatedAt + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/DeactivateLevelDefinitionCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/DeactivateLevelDefinitionCommand.cs new file mode 100644 index 00000000..b3aeb73e --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/DeactivateLevelDefinitionCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to deactivate a level definition (soft delete). +/// VI: Command để vô hiệu hóa level definition (xóa mềm). +/// +public class DeactivateLevelDefinitionCommand : IRequest +{ + /// + /// EN: Level definition ID. + /// VI: ID của level definition. + /// + [Required] + public Guid Id { get; set; } + + public DeactivateLevelDefinitionCommand() { } + + public DeactivateLevelDefinitionCommand(Guid id) + { + Id = id; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/DeactivateLevelDefinitionCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/DeactivateLevelDefinitionCommandHandler.cs new file mode 100644 index 00000000..f1c8a49e --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/DeactivateLevelDefinitionCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.LevelAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for deactivating a level definition (soft delete). +/// VI: Handler để vô hiệu hóa level definition (xóa mềm). +/// +public class DeactivateLevelDefinitionCommandHandler : IRequestHandler +{ + private readonly ILevelDefinitionRepository _levelDefinitionRepository; + private readonly ILogger _logger; + + public DeactivateLevelDefinitionCommandHandler( + ILevelDefinitionRepository levelDefinitionRepository, + ILogger logger) + { + _levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(DeactivateLevelDefinitionCommand request, CancellationToken cancellationToken) + { + // EN: Get level definition from repository + // VI: Lấy level definition từ repository + var levelDefinition = await _levelDefinitionRepository.GetAsync(request.Id); + if (levelDefinition == null) + { + throw new KeyNotFoundException($"Level definition {request.Id} not found"); + } + + // EN: Soft delete by deactivating + // VI: Xóa mềm bằng cách deactivate + levelDefinition.Deactivate(); + + _levelDefinitionRepository.Update(levelDefinition); + await _levelDefinitionRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Deactivated level definition {LevelId} with number {LevelNumber}", + levelDefinition.Id, levelDefinition.LevelNumber); + + return true; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateLevelDefinitionCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateLevelDefinitionCommand.cs new file mode 100644 index 00000000..4305a5b7 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateLevelDefinitionCommand.cs @@ -0,0 +1,89 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to update an existing level definition. +/// VI: Command để cập nhật level definition. +/// +public class UpdateLevelDefinitionCommand : IRequest +{ + /// + /// EN: Level definition ID. + /// VI: ID của level definition. + /// + [Required] + public Guid Id { get; set; } + + /// + /// EN: Level name (optional, only updates if provided). + /// VI: Tên level (optional, chỉ cập nhật nếu có). + /// + [StringLength(100, MinimumLength = 1)] + public string? Name { get; set; } + + /// + /// EN: Required EXP (optional, only updates if provided). + /// VI: EXP yêu cầu (optional, chỉ cập nhật nếu có). + /// + [Range(0, int.MaxValue)] + public int? RequiredExp { get; set; } + + /// + /// EN: Level description. + /// VI: Mô tả level. + /// + [StringLength(500)] + public string? Description { get; set; } + + /// + /// EN: Icon URL for the level badge. + /// VI: URL icon cho badge level. + /// + [StringLength(500)] + public string? IconUrl { get; set; } + + /// + /// EN: Badge color in hex format. + /// VI: Màu badge dạng hex. + /// + [StringLength(20)] + [RegularExpression(@"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", ErrorMessage = "Invalid hex color format")] + public string? BadgeColor { get; set; } + + /// + /// EN: Whether to clear description. + /// VI: Có xóa description không. + /// + public bool ClearDescription { get; set; } + + /// + /// EN: Whether to clear icon URL. + /// VI: Có xóa icon URL không. + /// + public bool ClearIconUrl { get; set; } + + /// + /// EN: Whether to clear badge color. + /// VI: Có xóa badge color không. + /// + public bool ClearBadgeColor { get; set; } +} + +/// +/// EN: Result of updating a level definition. +/// VI: Kết quả cập nhật level definition. +/// +public class UpdateLevelDefinitionResult +{ + public Guid Id { get; set; } + public int LevelNumber { get; set; } + public string Name { get; set; } = string.Empty; + public int RequiredExp { get; set; } + public string? Description { get; set; } + public string? IconUrl { get; set; } + public string? BadgeColor { get; set; } + public bool IsActive { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateLevelDefinitionCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateLevelDefinitionCommandHandler.cs new file mode 100644 index 00000000..b9e10b5d --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/UpdateLevelDefinitionCommandHandler.cs @@ -0,0 +1,92 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.LevelAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for updating an existing level definition. +/// VI: Handler để cập nhật level definition. +/// +public class UpdateLevelDefinitionCommandHandler : IRequestHandler +{ + private readonly ILevelDefinitionRepository _levelDefinitionRepository; + private readonly ILogger _logger; + + public UpdateLevelDefinitionCommandHandler( + ILevelDefinitionRepository levelDefinitionRepository, + ILogger logger) + { + _levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(UpdateLevelDefinitionCommand request, CancellationToken cancellationToken) + { + // EN: Get level definition from repository + // VI: Lấy level definition từ repository + var levelDefinition = await _levelDefinitionRepository.GetAsync(request.Id); + if (levelDefinition == null) + { + throw new KeyNotFoundException($"Level definition {request.Id} not found"); + } + + // EN: Apply partial updates using domain methods + // VI: Áp dụng partial updates qua các domain methods + if (!string.IsNullOrEmpty(request.Name)) + { + levelDefinition.UpdateName(request.Name); + } + + if (request.RequiredExp.HasValue) + { + levelDefinition.UpdateRequiredExp(request.RequiredExp.Value); + } + + if (request.ClearDescription) + { + levelDefinition.UpdateDescription(null); + } + else if (request.Description != null) + { + levelDefinition.UpdateDescription(request.Description); + } + + if (request.ClearIconUrl) + { + levelDefinition.UpdateIconUrl(null); + } + else if (request.IconUrl != null) + { + levelDefinition.UpdateIconUrl(request.IconUrl); + } + + if (request.ClearBadgeColor) + { + levelDefinition.UpdateBadgeColor(null); + } + else if (request.BadgeColor != null) + { + levelDefinition.UpdateBadgeColor(request.BadgeColor); + } + + _levelDefinitionRepository.Update(levelDefinition); + await _levelDefinitionRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Updated level definition {LevelId} with number {LevelNumber}", + levelDefinition.Id, levelDefinition.LevelNumber); + + return new UpdateLevelDefinitionResult + { + Id = levelDefinition.Id, + LevelNumber = levelDefinition.LevelNumber, + Name = levelDefinition.Name, + RequiredExp = levelDefinition.RequiredExp, + Description = levelDefinition.Description, + IconUrl = levelDefinition.IconUrl, + BadgeColor = levelDefinition.BadgeColor, + IsActive = levelDefinition.IsActive, + UpdatedAt = levelDefinition.UpdatedAt + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Controllers/LevelsController.cs b/services/membership-service-net/src/MembershipService.API/Controllers/LevelsController.cs index 0ecdc93c..88b9cec1 100644 --- a/services/membership-service-net/src/MembershipService.API/Controllers/LevelsController.cs +++ b/services/membership-service-net/src/MembershipService.API/Controllers/LevelsController.cs @@ -2,6 +2,7 @@ using Asp.Versioning; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using MembershipService.API.Application.Commands; using MembershipService.API.Application.Queries; using Swashbuckle.AspNetCore.Annotations; @@ -43,8 +44,83 @@ public class LevelsController : ControllerBase return Ok(levels); } - // TODO: Add admin endpoints in Phase 6 - // POST /api/v1/levels - Create level definition (Admin) - // PUT /api/v1/levels/{id} - Update level definition (Admin) - // DELETE /api/v1/levels/{id} - Deactivate level (Admin) + /// + /// EN: Create a new level definition (Admin only). + /// VI: Tạo level definition mới (chỉ Admin). + /// + [HttpPost] + [Authorize(Roles = "Admin")] + [SwaggerOperation(Summary = "Create level definition", Description = "Creates a new level definition (Admin only)")] + [SwaggerResponse(201, "Level definition created", typeof(CreateLevelDefinitionResult))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(401, "Unauthorized")] + [SwaggerResponse(403, "Forbidden - Admin role required")] + [SwaggerResponse(409, "Level number already exists")] + public async Task> Create( + [FromBody] CreateLevelDefinitionCommand command) + { + try + { + var result = await _mediator.Send(command); + return CreatedAtAction(nameof(GetAll), new { }, result); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("already exists")) + { + return Conflict(new { message = ex.Message }); + } + } + + /// + /// EN: Update an existing level definition (Admin only). + /// VI: Cập nhật level definition (chỉ Admin). + /// + [HttpPut("{id:guid}")] + [Authorize(Roles = "Admin")] + [SwaggerOperation(Summary = "Update level definition", Description = "Updates an existing level definition (Admin only)")] + [SwaggerResponse(200, "Level definition updated", typeof(UpdateLevelDefinitionResult))] + [SwaggerResponse(400, "Invalid request")] + [SwaggerResponse(401, "Unauthorized")] + [SwaggerResponse(403, "Forbidden - Admin role required")] + [SwaggerResponse(404, "Level definition not found")] + public async Task> Update( + Guid id, + [FromBody] UpdateLevelDefinitionCommand command) + { + command.Id = id; + + try + { + var result = await _mediator.Send(command); + return Ok(result); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { message = ex.Message }); + } + } + + /// + /// EN: Deactivate a level definition (Admin only, soft delete). + /// VI: Vô hiệu hóa level definition (chỉ Admin, xóa mềm). + /// + [HttpDelete("{id:guid}")] + [Authorize(Roles = "Admin")] + [SwaggerOperation(Summary = "Deactivate level definition", Description = "Deactivates a level definition - soft delete (Admin only)")] + [SwaggerResponse(204, "Level definition deactivated")] + [SwaggerResponse(401, "Unauthorized")] + [SwaggerResponse(403, "Forbidden - Admin role required")] + [SwaggerResponse(404, "Level definition not found")] + public async Task Deactivate(Guid id) + { + try + { + await _mediator.Send(new DeactivateLevelDefinitionCommand(id)); + return NoContent(); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { message = ex.Message }); + } + } } + diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminDeleteFileCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminDeleteFileCommand.cs new file mode 100644 index 00000000..ad9d8ddf --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminDeleteFileCommand.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace StorageService.API.Application.Commands.Admin; + +/// +/// EN: Command for Admin to delete a file (bypasses ownership check). +/// VI: Command cho Admin xóa file (bỏ qua kiểm tra ownership). +/// +public record AdminDeleteFileCommand( + Guid FileId, + string AdminUserId, + string Reason +) : IRequest; + +/// +/// EN: Result of admin file deletion. +/// VI: Kết quả xóa file bởi admin. +/// +public record AdminDeleteFileResult( + bool Success, + string? DeletedFileName, + string? FileOwnerUserId, + string? Error); diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminDeleteFileCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminDeleteFileCommandHandler.cs new file mode 100644 index 00000000..25f3a456 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminDeleteFileCommandHandler.cs @@ -0,0 +1,99 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.FileAggregate; +using StorageService.Domain.AggregatesModel.QuotaAggregate; +using StorageService.Infrastructure.Caching; +using StorageService.Infrastructure.Storage; + +namespace StorageService.API.Application.Commands.Admin; + +/// +/// EN: Handler for AdminDeleteFileCommand. +/// VI: Handler cho AdminDeleteFileCommand. +/// +public class AdminDeleteFileCommandHandler : IRequestHandler +{ + private readonly IFileRepository _fileRepository; + private readonly IQuotaRepository _quotaRepository; + private readonly IStorageProviderFactory _storageProviderFactory; + private readonly IRedisCacheService _cache; + private readonly ILogger _logger; + + public AdminDeleteFileCommandHandler( + IFileRepository fileRepository, + IQuotaRepository quotaRepository, + IStorageProviderFactory storageProviderFactory, + IRedisCacheService cache, + ILogger logger) + { + _fileRepository = fileRepository; + _quotaRepository = quotaRepository; + _storageProviderFactory = storageProviderFactory; + _cache = cache; + _logger = logger; + } + + public async Task Handle(AdminDeleteFileCommand request, CancellationToken cancellationToken) + { + try + { + // EN: Get file metadata / VI: Lấy metadata file + var file = await _fileRepository.GetByIdAsync(request.FileId, cancellationToken); + + if (file == null) + { + return new AdminDeleteFileResult(false, null, null, "File not found."); + } + + var fileName = file.FileName; + var fileOwner = file.UserId; + var fileSize = file.FileSizeBytes; + + // EN: Log admin action / VI: Log hành động admin + _logger.LogWarning( + "Admin {AdminId} deleting file {FileId} (owner: {OwnerId}) for reason: {Reason}", + request.AdminUserId, request.FileId, fileOwner, request.Reason); + + // EN: Delete from storage provider / VI: Xóa khỏi storage provider + var provider = _storageProviderFactory.GetProvider(file.Provider); + var deleted = await provider.DeleteAsync(file.BucketName, file.ObjectKey, cancellationToken); + + if (!deleted) + { + _logger.LogWarning("Failed to delete file from storage: {FileId}", request.FileId); + } + + // EN: Soft delete file record / VI: Soft delete record file + file.Delete(); + _fileRepository.Update(file); + + // EN: Update quota / VI: Cập nhật quota + var quota = await _quotaRepository.GetByUserIdAsync(fileOwner, cancellationToken); + if (quota != null) + { + quota.RemoveUsage(fileSize); + _quotaRepository.Update(quota); + } + + // EN: Save changes / VI: Lưu thay đổi + await _fileRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + // EN: Invalidate caches / VI: Invalidate caches + await _cache.DeleteAsync(CacheKeys.FileMetadata(request.FileId), cancellationToken); + if (Guid.TryParse(fileOwner, out var ownerId)) + { + await _cache.DeleteAsync(CacheKeys.UserQuota(ownerId), cancellationToken); + } + + _logger.LogInformation( + "File {FileId} deleted by admin {AdminId}. Reason: {Reason}", + request.FileId, request.AdminUserId, request.Reason); + + return new AdminDeleteFileResult(true, fileName, fileOwner, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting file {FileId} by admin", request.FileId); + return new AdminDeleteFileResult(false, null, null, "An error occurred while deleting the file."); + } + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminRevokeShareCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminRevokeShareCommand.cs new file mode 100644 index 00000000..875827d0 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminRevokeShareCommand.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace StorageService.API.Application.Commands.Admin; + +/// +/// EN: Command for Admin to revoke a file share. +/// VI: Command cho Admin revoke chia sẻ file. +/// +public record AdminRevokeShareCommand( + Guid ShareId, + string AdminUserId, + string Reason +) : IRequest; + +/// +/// EN: Result of admin share revocation. +/// VI: Kết quả revoke share bởi admin. +/// +public record AdminRevokeShareResult( + bool Success, + Guid? FileId, + string? ShareOwnerUserId, + string? Error); diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminRevokeShareCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminRevokeShareCommandHandler.cs new file mode 100644 index 00000000..3f40f2e5 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/AdminRevokeShareCommandHandler.cs @@ -0,0 +1,62 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.FileShareAggregate; + +namespace StorageService.API.Application.Commands.Admin; + +/// +/// EN: Handler for AdminRevokeShareCommand. +/// VI: Handler cho AdminRevokeShareCommand. +/// +public class AdminRevokeShareCommandHandler : IRequestHandler +{ + private readonly IFileShareRepository _shareRepository; + private readonly ILogger _logger; + + public AdminRevokeShareCommandHandler( + IFileShareRepository shareRepository, + ILogger logger) + { + _shareRepository = shareRepository; + _logger = logger; + } + + public async Task Handle(AdminRevokeShareCommand request, CancellationToken cancellationToken) + { + try + { + // EN: Get share / VI: Lấy share + var share = await _shareRepository.GetByIdAsync(request.ShareId, cancellationToken); + + if (share == null) + { + return new AdminRevokeShareResult(false, null, null, "Share not found."); + } + + var fileId = share.FileId; + var shareOwner = share.SharedBy; + + // EN: Log admin action / VI: Log hành động admin + _logger.LogWarning( + "Admin {AdminId} revoking share {ShareId} (owner: {OwnerId}, file: {FileId}) for reason: {Reason}", + request.AdminUserId, request.ShareId, shareOwner, fileId, request.Reason); + + // EN: Revoke share / VI: Thu hồi share + share.Revoke(); + _shareRepository.Update(share); + + // EN: Save changes / VI: Lưu thay đổi + await _shareRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Share {ShareId} revoked by admin {AdminId}. Reason: {Reason}", + request.ShareId, request.AdminUserId, request.Reason); + + return new AdminRevokeShareResult(true, fileId, shareOwner, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error revoking share {ShareId} by admin", request.ShareId); + return new AdminRevokeShareResult(false, null, null, "An error occurred while revoking the share."); + } + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/UpdateUserQuotaCommand.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/UpdateUserQuotaCommand.cs new file mode 100644 index 00000000..284fed47 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/UpdateUserQuotaCommand.cs @@ -0,0 +1,36 @@ +using MediatR; + +namespace StorageService.API.Application.Commands.Admin; + +/// +/// EN: Command to update a user's storage quota (Admin only). +/// VI: Command để cập nhật quota storage của user (chỉ Admin). +/// +public record UpdateUserQuotaCommand( + string TargetUserId, + long MaxStorageBytes, + int MaxFileCount, + string? QuotaTier = null +) : IRequest; + +/// +/// EN: Result of quota update operation. +/// VI: Kết quả thao tác cập nhật quota. +/// +public record UpdateUserQuotaResult( + bool Success, + QuotaUpdatedDto? Data, + string? Error); + +/// +/// EN: DTO for updated quota information. +/// VI: DTO cho thông tin quota đã cập nhật. +/// +public record QuotaUpdatedDto( + string UserId, + long MaxStorageBytes, + long UsedStorageBytes, + int MaxFileCount, + int CurrentFileCount, + string? QuotaTier, + DateTime UpdatedAt); diff --git a/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/UpdateUserQuotaCommandHandler.cs b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/UpdateUserQuotaCommandHandler.cs new file mode 100644 index 00000000..7376c544 --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Commands/Admin/UpdateUserQuotaCommandHandler.cs @@ -0,0 +1,91 @@ +using MediatR; +using StorageService.Domain.AggregatesModel.QuotaAggregate; +using StorageService.Infrastructure.Caching; + +namespace StorageService.API.Application.Commands.Admin; + +/// +/// EN: Handler for UpdateUserQuotaCommand. +/// VI: Handler cho UpdateUserQuotaCommand. +/// +public class UpdateUserQuotaCommandHandler : IRequestHandler +{ + private readonly IQuotaRepository _quotaRepository; + private readonly IRedisCacheService _cache; + private readonly ILogger _logger; + + public UpdateUserQuotaCommandHandler( + IQuotaRepository quotaRepository, + IRedisCacheService cache, + ILogger logger) + { + _quotaRepository = quotaRepository; + _cache = cache; + _logger = logger; + } + + public async Task Handle(UpdateUserQuotaCommand request, CancellationToken cancellationToken) + { + try + { + // EN: Get user quota / VI: Lấy quota của user + var quota = await _quotaRepository.GetByUserIdAsync(request.TargetUserId, cancellationToken); + + if (quota == null) + { + // EN: Create new quota if not exists / VI: Tạo quota mới nếu chưa có + quota = new UserStorageQuota( + request.TargetUserId, + request.MaxStorageBytes, + request.MaxFileCount, + request.QuotaTier); + await _quotaRepository.AddAsync(quota, cancellationToken); + + _logger.LogInformation( + "Created new quota for user {UserId}: MaxStorage={MaxStorageBytes}, MaxFiles={MaxFileCount}, Tier={QuotaTier}", + request.TargetUserId, request.MaxStorageBytes, request.MaxFileCount, request.QuotaTier); + } + else + { + // EN: Update existing quota / VI: Cập nhật quota hiện có + quota.UpdateLimits(request.MaxStorageBytes, request.MaxFileCount, request.QuotaTier); + _quotaRepository.Update(quota); + + _logger.LogInformation( + "Updated quota for user {UserId}: MaxStorage={MaxStorageBytes}, MaxFiles={MaxFileCount}, Tier={QuotaTier}", + request.TargetUserId, request.MaxStorageBytes, request.MaxFileCount, request.QuotaTier); + } + + // EN: Save changes / VI: Lưu thay đổi + await _quotaRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + // EN: Invalidate cache / VI: Invalidate cache + if (Guid.TryParse(request.TargetUserId, out var userId)) + { + await _cache.DeleteAsync(CacheKeys.UserQuota(userId), cancellationToken); + } + + var result = new QuotaUpdatedDto( + quota.UserId, + quota.MaxStorageBytes, + quota.UsedStorageBytes, + quota.MaxFileCount, + quota.CurrentFileCount, + quota.QuotaTier, + quota.LastUpdatedAt); + + return new UpdateUserQuotaResult(true, result, null); + } + catch (InvalidOperationException ex) + { + // EN: Business rule violation (e.g., max < current usage) / VI: Vi phạm business rule + _logger.LogWarning(ex, "Failed to update quota for user {UserId}: {Error}", request.TargetUserId, ex.Message); + return new UpdateUserQuotaResult(false, null, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating quota for user {UserId}", request.TargetUserId); + return new UpdateUserQuotaResult(false, null, "An error occurred while updating the quota."); + } + } +} diff --git a/services/storage-service-net/src/StorageService.API/Application/Queries/Admin/AdminQueries.cs b/services/storage-service-net/src/StorageService.API/Application/Queries/Admin/AdminQueries.cs new file mode 100644 index 00000000..7613e4cc --- /dev/null +++ b/services/storage-service-net/src/StorageService.API/Application/Queries/Admin/AdminQueries.cs @@ -0,0 +1,148 @@ +using MediatR; + +namespace StorageService.API.Application.Queries.Admin; + +/// +/// EN: Query to get all users' quotas with pagination (Admin only). +/// VI: Query lấy quota của tất cả users với phân trang (chỉ Admin). +/// +public record GetAllUsersQuotaQuery( + int PageNumber = 1, + int PageSize = 20, + string? QuotaTier = null, + double? MinUsagePercentage = null, + string? SortBy = null, + bool Descending = false +) : IRequest; + +/// +/// EN: Result for all users quota query. +/// VI: Kết quả query quota tất cả users. +/// +public record AllUsersQuotaResult( + IReadOnlyList Items, + int TotalCount, + int PageNumber, + int PageSize, + int TotalPages); + +/// +/// EN: DTO for quota with extended admin info. +/// VI: DTO quota với thông tin admin mở rộng. +/// +public record AdminQuotaDto( + Guid Id, + string UserId, + long MaxStorageBytes, + long UsedStorageBytes, + long RemainingStorageBytes, + int MaxFileCount, + int CurrentFileCount, + int RemainingFileCount, + double UsagePercentage, + string? QuotaTier, + DateTime LastUpdatedAt, + DateTime CreatedAt); + +/// +/// EN: Query to get storage statistics (Admin only). +/// VI: Query lấy thống kê storage (chỉ Admin). +/// +public record GetStorageStatisticsQuery() : IRequest; + +/// +/// EN: DTO for storage statistics. +/// VI: DTO cho thống kê storage. +/// +public record StorageStatisticsDto( + long TotalUsers, + long TotalStorageUsedBytes, + long TotalStorageAllocatedBytes, + long TotalFileCount, + double AverageUsagePercentage, + Dictionary UsersByTier, + int UsersNearLimit, + int UsersOverLimit); + +/// +/// EN: Query to get all files (Admin only). +/// VI: Query lấy tất cả files (chỉ Admin). +/// +public record AdminGetFilesQuery( + int PageNumber = 1, + int PageSize = 20, + string? UserId = null, + string? AccessLevel = null, + string? ContentType = null, + DateTime? UploadedAfter = null, + DateTime? UploadedBefore = null, + string? SortBy = null, + bool Descending = false +) : IRequest; + +/// +/// EN: Result for admin files query. +/// VI: Kết quả query files cho admin. +/// +public record AdminFilesResult( + IReadOnlyList Items, + int TotalCount, + int PageNumber, + int PageSize, + int TotalPages); + +/// +/// EN: DTO for file with admin info. +/// VI: DTO file với thông tin cho admin. +/// +public record AdminFileDto( + Guid Id, + string UserId, + string FileName, + string ContentType, + long FileSizeBytes, + string Provider, + string AccessLevel, + string? FolderId, + DateTime UploadedAt, + DateTime? LastAccessedAt, + bool IsDeleted); + +/// +/// EN: Query to get all file shares (Admin only). +/// VI: Query lấy tất cả file shares (chỉ Admin). +/// +public record AdminGetSharesQuery( + int PageNumber = 1, + int PageSize = 20, + string? Status = null, + string? SharedBy = null +) : IRequest; + +/// +/// EN: Result for admin shares query. +/// VI: Kết quả query shares cho admin. +/// +public record AdminSharesResult( + IReadOnlyList Items, + int TotalCount, + int PageNumber, + int PageSize, + int TotalPages); + +/// +/// EN: DTO for share with admin info. +/// VI: DTO share với thông tin cho admin. +/// +public record AdminShareDto( + Guid Id, + Guid FileId, + string SharedBy, + string? SharedWith, + string Permission, + string Status, + int DownloadCount, + int? MaxDownloads, + DateTime? ExpiresAt, + DateTime CreatedAt, + DateTime? RevokedAt); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommandHandler.cs index da458ab6..31c10a64 100644 --- a/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommandHandler.cs +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/CreateWalletCommandHandler.cs @@ -5,8 +5,8 @@ using WalletService.Domain.AggregatesModel.WalletAggregate; using WalletService.Domain.Exceptions; /// -/// EN: Handler for CreateWalletCommand -/// VI: Handler cho CreateWalletCommand +/// EN: Handler for CreateWalletCommand. +/// VI: Handler cho CreateWalletCommand. /// public class CreateWalletCommandHandler : IRequestHandler { @@ -33,22 +33,40 @@ public class CreateWalletCommandHandler : IRequestHandler(request.Currency.ToUpperInvariant()); + } + catch + { + // EN: Default to VND if currency not found + // VI: Mặc định là VND nếu không tìm thấy tiền tệ + currencyType = CurrencyType.VND; + } + + // EN: Create new wallet with CurrencyType + // VI: Tạo ví mới với CurrencyType + var wallet = new Wallet(request.UserId, currencyType); _walletRepository.Add(wallet); await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); _logger.LogInformation( - "Created wallet {WalletId} for user {UserId}", - wallet.Id, request.UserId); + "Created wallet {WalletId} for user {UserId} with currency {Currency}", + wallet.Id, request.UserId, currencyType.Name); + + // EN: Get default currency balance + // VI: Lấy số dư tiền tệ mặc định + var defaultBalance = wallet.GetBalance(currencyType); return new CreateWalletResult( wallet.Id, wallet.UserId, - wallet.Balance.Amount, - wallet.Balance.Currency, + defaultBalance, + currencyType.Name, wallet.Status.Name, wallet.CreatedAt ); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommandHandler.cs index 00c308ac..c4fcefb5 100644 --- a/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommandHandler.cs +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/DepositCommandHandler.cs @@ -5,8 +5,8 @@ using WalletService.Domain.AggregatesModel.WalletAggregate; using WalletService.Domain.Exceptions; /// -/// EN: Handler for DepositCommand -/// VI: Handler cho DepositCommand +/// EN: Handler for DepositCommand. +/// VI: Handler cho DepositCommand. /// public class DepositCommandHandler : IRequestHandler { @@ -30,13 +30,13 @@ public class DepositCommandHandler : IRequestHandler +/// EN: Command to exchange currency within a wallet. +/// VI: Command để quy đổi tiền tệ trong ví. +/// +public record ExchangeCommand( + Guid UserId, + decimal FromAmount, + int FromCurrencyTypeId, + int ToCurrencyTypeId, + decimal? CustomRate = null +) : IRequest; + +/// +/// EN: Result of exchange operation. +/// VI: Kết quả của thao tác quy đổi. +/// +public record ExchangeResult( + Guid WalletId, + decimal FromAmount, + string FromCurrency, + decimal ToAmount, + string ToCurrency, + decimal ExchangeRate, + DateTime ExchangedAt +); diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/ExchangeCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/ExchangeCommandHandler.cs new file mode 100644 index 00000000..8952672f --- /dev/null +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/ExchangeCommandHandler.cs @@ -0,0 +1,64 @@ +namespace WalletService.API.Application.Commands; + +using MediatR; +using WalletService.Domain.AggregatesModel.WalletAggregate; +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; + +/// +/// EN: Handler for ExchangeCommand. +/// VI: Handler cho ExchangeCommand. +/// +public class ExchangeCommandHandler : IRequestHandler +{ + private readonly IWalletRepository _walletRepository; + private readonly ILogger _logger; + + public ExchangeCommandHandler( + IWalletRepository walletRepository, + ILogger logger) + { + _walletRepository = walletRepository; + _logger = logger; + } + + public async Task Handle( + ExchangeCommand request, + CancellationToken cancellationToken) + { + // EN: Get wallet by user ID + // VI: Lấy ví theo user ID + var wallet = await _walletRepository.GetByUserIdAsync(request.UserId) + ?? throw new WalletDomainException($"Wallet not found for user {request.UserId}"); + + // EN: Parse currency types from IDs + // VI: Parse loại tiền tệ từ IDs + var fromCurrency = Enumeration.FromValue(request.FromCurrencyTypeId); + var toCurrency = Enumeration.FromValue(request.ToCurrencyTypeId); + + // EN: Calculate actual rate + // VI: Tính tỷ giá thực tế + var rate = request.CustomRate ?? fromCurrency.GetExchangeRateTo(toCurrency); + + // EN: Perform exchange (atomic operation within aggregate) + // VI: Thực hiện quy đổi (thao tác atomic trong aggregate) + var receivedAmount = wallet.Exchange(request.FromAmount, fromCurrency, toCurrency, request.CustomRate); + + _walletRepository.Update(wallet); + await _walletRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Exchanged {FromAmount} {FromCurrency} to {ToAmount} {ToCurrency} in wallet {WalletId}", + request.FromAmount, fromCurrency.Name, receivedAmount, toCurrency.Name, wallet.Id); + + return new ExchangeResult( + wallet.Id, + request.FromAmount, + fromCurrency.Name, + receivedAmount, + toCurrency.Name, + rate, + DateTime.UtcNow + ); + } +} diff --git a/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommandHandler.cs b/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommandHandler.cs index 8fd55dc1..c8438fbd 100644 --- a/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommandHandler.cs +++ b/services/wallet-service-net/src/WalletService.API/Application/Commands/WithdrawCommandHandler.cs @@ -5,8 +5,8 @@ using WalletService.Domain.AggregatesModel.WalletAggregate; using WalletService.Domain.Exceptions; /// -/// EN: Handler for WithdrawCommand -/// VI: Handler cho WithdrawCommand +/// EN: Handler for WithdrawCommand. +/// VI: Handler cho WithdrawCommand. /// public class WithdrawCommandHandler : IRequestHandler { @@ -28,11 +28,13 @@ public class WithdrawCommandHandler : IRequestHandler -/// EN: Handler for GetWalletQuery -/// VI: Handler cho GetWalletQuery +/// EN: Handler for GetWalletQuery. +/// VI: Handler cho GetWalletQuery. /// public class GetWalletQueryHandler : IRequestHandler { @@ -23,11 +23,16 @@ public class GetWalletQueryHandler : IRequestHandler if (wallet == null) return null; + // EN: Get balance for default currency + // VI: Lấy số dư cho tiền tệ mặc định + var currencyType = wallet.DefaultCurrencyType ?? CurrencyType.VND; + var balance = wallet.GetBalance(currencyType); + return new WalletDto( wallet.Id, wallet.UserId, - wallet.Balance.Amount, - wallet.Balance.Currency, + balance, + currencyType.Name, wallet.Status.Name, wallet.CreatedAt, wallet.UpdatedAt diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/CurrencyType.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/CurrencyType.cs new file mode 100644 index 00000000..3a46c3ae --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/CurrencyType.cs @@ -0,0 +1,97 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.SeedWork; + +/// +/// EN: Currency type enumeration with exchange rate capabilities. +/// VI: Enumeration loại tiền tệ với khả năng quy đổi tỷ giá. +/// +/// +/// EN: PPoint is treated as a currency type to enable seamless exchange with other currencies. +/// VI: PPoint được coi như một loại tiền tệ để cho phép quy đổi liền mạch với các tiền tệ khác. +/// +public class CurrencyType : Enumeration +{ + /// + /// EN: Vietnamese Dong - base currency. + /// VI: Đồng Việt Nam - tiền tệ cơ sở. + /// + public static CurrencyType VND = new(1, nameof(VND), "Vietnamese Dong", 1m); + + /// + /// EN: US Dollar. + /// VI: Đô la Mỹ. + /// + public static CurrencyType USD = new(2, nameof(USD), "US Dollar", 25000m); + + /// + /// EN: Loyalty points - exchangeable with currency. + /// VI: Điểm thưởng - có thể quy đổi với tiền tệ. + /// + public static CurrencyType PPoint = new(3, nameof(PPoint), "Loyalty Points", 1000m); + + /// + /// EN: Display name of the currency. + /// VI: Tên hiển thị của loại tiền tệ. + /// + public string DisplayName { get; private set; } + + /// + /// EN: Base exchange rate to VND (1 unit of this currency = X VND). + /// VI: Tỷ giá cơ sở quy đổi sang VND (1 đơn vị tiền này = X VND). + /// + /// + /// EN: VND = 1, USD = 25000 (1 USD = 25000 VND), PPoint = 1000 (1 PPoint = 1000 VND). + /// VI: VND = 1, USD = 25000 (1 USD = 25000 VND), PPoint = 1000 (1 PPoint = 1000 VND). + /// + public decimal BaseExchangeRate { get; private set; } + + protected CurrencyType() : base(0, "Unknown") + { + DisplayName = "Unknown"; + BaseExchangeRate = 0; + } + + public CurrencyType(int id, string name, string displayName, decimal baseExchangeRate) + : base(id, name) + { + DisplayName = displayName; + BaseExchangeRate = baseExchangeRate; + } + + /// + /// EN: Calculate exchange rate from this currency to another. + /// VI: Tính tỷ giá quy đổi từ loại tiền này sang loại khác. + /// + /// Target currency / Tiền tệ đích + /// Exchange rate / Tỷ giá quy đổi + public decimal GetExchangeRateTo(CurrencyType toCurrency) + { + if (toCurrency.BaseExchangeRate == 0) + throw new InvalidOperationException("Cannot exchange to currency with zero base rate"); + + return BaseExchangeRate / toCurrency.BaseExchangeRate; + } + + /// + /// EN: Convert amount from this currency to another. + /// VI: Quy đổi số tiền từ loại tiền này sang loại khác. + /// + public decimal ConvertTo(decimal amount, CurrencyType toCurrency) + { + var rate = GetExchangeRateTo(toCurrency); + return amount * rate; + } + + /// + /// EN: Check if this is a points-based currency. + /// VI: Kiểm tra xem đây có phải là loại tiền dựa trên điểm không. + /// + public bool IsPointsBased => Id == PPoint.Id; + + /// + /// EN: Check if this is a fiat currency. + /// VI: Kiểm tra xem đây có phải là tiền pháp định không. + /// + public bool IsFiatCurrency => Id == VND.Id || Id == USD.Id; +} diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs index d8246948..b60c4384 100644 --- a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/Wallet.cs @@ -5,134 +5,190 @@ using WalletService.Domain.Exceptions; using WalletService.Domain.SeedWork; /// -/// EN: Wallet aggregate root representing a user's digital wallet -/// VI: Aggregate root Ví đại diện cho ví điện tử của người dùng +/// EN: Wallet aggregate root representing a user's digital wallet with multi-currency support. +/// VI: Aggregate root Ví đại diện cho ví điện tử của người dùng với hỗ trợ đa tiền tệ. /// +/// +/// EN: Wallet manages multiple balances (one per currency type) and supports currency exchange. +/// VI: Wallet quản lý nhiều số dư (một cho mỗi loại tiền tệ) và hỗ trợ quy đổi tiền tệ. +/// public class Wallet : Entity, IAggregateRoot { /// - /// EN: User ID from IAM Service - /// VI: ID người dùng từ IAM Service + /// EN: User ID from IAM Service. + /// VI: ID người dùng từ IAM Service. /// public Guid UserId { get; private set; } /// - /// EN: Current wallet balance - /// VI: Số dư ví hiện tại + /// EN: Default currency type ID. + /// VI: ID loại tiền tệ mặc định. /// - public Money Balance { get; private set; } = null!; + public int DefaultCurrencyTypeId { get; private set; } /// - /// EN: Wallet status - /// VI: Trạng thái ví + /// EN: Default currency type. + /// VI: Loại tiền tệ mặc định. + /// + public CurrencyType DefaultCurrencyType { get; private set; } = null!; + + /// + /// EN: Legacy balance property - for backward compatibility. Returns default currency balance. + /// VI: Thuộc tính số dư kế thừa - để tương thích ngược. Trả về số dư tiền tệ mặc định. + /// + [Obsolete("Use GetBalance(CurrencyType) instead for multi-currency support")] + public Money Balance => GetLegacyBalance(); + + /// + /// EN: Wallet status. + /// VI: Trạng thái ví. /// public WalletStatus Status { get; private set; } = null!; /// - /// EN: Status ID for EF Core - /// VI: ID trạng thái cho EF Core + /// EN: Status ID for EF Core. + /// VI: ID trạng thái cho EF Core. /// public int StatusId { get; private set; } /// - /// EN: Wallet creation timestamp - /// VI: Thời điểm tạo ví + /// EN: Wallet creation timestamp. + /// VI: Thời điểm tạo ví. /// public DateTime CreatedAt { get; private set; } /// - /// EN: Last update timestamp - /// VI: Thời điểm cập nhật cuối + /// EN: Last update timestamp. + /// VI: Thời điểm cập nhật cuối. /// public DateTime UpdatedAt { get; private set; } + private readonly List _balances = new(); + + /// + /// EN: Multi-currency balances. + /// VI: Số dư đa tiền tệ. + /// + public IReadOnlyCollection Balances => _balances.AsReadOnly(); + private readonly List _transactions = new(); /// - /// EN: Wallet transactions - /// VI: Các giao dịch của ví + /// EN: Wallet transactions. + /// VI: Các giao dịch của ví. /// public IReadOnlyCollection Transactions => _transactions.AsReadOnly(); protected Wallet() { } /// - /// EN: Create a new wallet for a user - /// VI: Tạo ví mới cho người dùng + /// EN: Create a new wallet for a user. + /// VI: Tạo ví mới cho người dùng. /// - public Wallet(Guid userId, string currency = "VND") + /// User ID / ID người dùng + /// Default currency type (default: VND) / Loại tiền tệ mặc định (mặc định: VND) + public Wallet(Guid userId, CurrencyType? defaultCurrencyType = null) { + var currency = defaultCurrencyType ?? CurrencyType.VND; + Id = Guid.NewGuid(); UserId = userId; - Balance = new Money(0, currency); + DefaultCurrencyType = currency; + DefaultCurrencyTypeId = currency.Id; Status = WalletStatus.Active; StatusId = Status.Id; CreatedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; + // Initialize default currency with zero balance + _balances.Add(new WalletItem(Id, currency, 0)); + AddDomainEvent(new WalletCreatedDomainEvent(Id, userId)); } + #region Multi-Currency Methods + /// - /// EN: Deposit money into the wallet - /// VI: Nạp tiền vào ví + /// EN: Get balance for a specific currency type. + /// VI: Lấy số dư cho một loại tiền tệ cụ thể. /// - public void Deposit(Money amount, string description, string? referenceId = null) + public decimal GetBalance(CurrencyType currencyType) + { + var item = _balances.FirstOrDefault(b => b.CurrencyTypeId == currencyType.Id); + return item?.Balance ?? 0; + } + + /// + /// EN: Get or create a wallet item for a currency type. + /// VI: Lấy hoặc tạo wallet item cho một loại tiền tệ. + /// + private WalletItem GetOrCreateBalance(CurrencyType currencyType) + { + var item = _balances.FirstOrDefault(b => b.CurrencyTypeId == currencyType.Id); + if (item == null) + { + item = new WalletItem(Id, currencyType, 0); + _balances.Add(item); + } + return item; + } + + /// + /// EN: Deposit amount into wallet for a specific currency. + /// VI: Nạp tiền vào ví cho một loại tiền tệ cụ thể. + /// + public void Deposit(decimal amount, CurrencyType currencyType, string description, string? referenceId = null) { EnsureWalletIsActive(); - if (amount.Amount <= 0) + if (amount <= 0) throw new WalletDomainException("Deposit amount must be greater than zero"); - if (amount.Currency != Balance.Currency) - throw new WalletDomainException($"Currency mismatch. Wallet uses {Balance.Currency}"); - - Balance = Balance.Add(amount); + var balance = GetOrCreateBalance(currencyType); + balance.Add(amount); UpdatedAt = DateTime.UtcNow; var transaction = new WalletTransaction( Id, - amount, + new Money(amount, currencyType.Name), TransactionType.Credit, - Balance.Amount, + balance.Balance, description, referenceId); _transactions.Add(transaction); AddDomainEvent(new WalletBalanceChangedDomainEvent( - Id, - UserId, + Id, + UserId, TransactionType.Credit.Name, - amount.Amount, - Balance.Amount)); + amount, + balance.Balance)); } /// - /// EN: Withdraw money from the wallet - /// VI: Rút tiền khỏi ví + /// EN: Withdraw amount from wallet for a specific currency. + /// VI: Rút tiền từ ví cho một loại tiền tệ cụ thể. /// - public void Withdraw(Money amount, string description, string? referenceId = null) + public void Withdraw(decimal amount, CurrencyType currencyType, string description, string? referenceId = null) { EnsureWalletIsActive(); - if (amount.Amount <= 0) + if (amount <= 0) throw new WalletDomainException("Withdrawal amount must be greater than zero"); - if (amount.Currency != Balance.Currency) - throw new WalletDomainException($"Currency mismatch. Wallet uses {Balance.Currency}"); + var balance = _balances.FirstOrDefault(b => b.CurrencyTypeId == currencyType.Id); + if (balance == null || !balance.HasSufficientBalance(amount)) + throw new InsufficientBalanceException(balance?.Balance ?? 0, amount); - if (!Balance.IsGreaterThanOrEqual(amount)) - throw new InsufficientBalanceException(Balance.Amount, amount.Amount); - - Balance = Balance.Subtract(amount); + balance.Subtract(amount); UpdatedAt = DateTime.UtcNow; var transaction = new WalletTransaction( Id, - amount, + new Money(amount, currencyType.Name), TransactionType.Debit, - Balance.Amount, + balance.Balance, description, referenceId); @@ -142,32 +198,118 @@ public class Wallet : Entity, IAggregateRoot Id, UserId, TransactionType.Debit.Name, - amount.Amount, - Balance.Amount)); + amount, + balance.Balance)); } /// - /// EN: Transfer money to another wallet - /// VI: Chuyển tiền đến ví khác + /// EN: Exchange currency within the wallet (atomic operation). + /// VI: Quy đổi tiền tệ trong ví (thao tác nguyên tử). + /// + /// Amount to exchange / Số tiền quy đổi + /// Source currency / Tiền tệ nguồn + /// Target currency / Tiền tệ đích + /// Optional custom exchange rate / Tỷ giá tùy chỉnh (tuỳ chọn) + /// Amount received in target currency / Số tiền nhận được bằng tiền tệ đích + public decimal Exchange(decimal fromAmount, CurrencyType fromCurrency, CurrencyType toCurrency, decimal? customRate = null) + { + EnsureWalletIsActive(); + + if (fromAmount <= 0) + throw new WalletDomainException("Exchange amount must be greater than zero"); + + if (fromCurrency.Id == toCurrency.Id) + throw new WalletDomainException("Cannot exchange to the same currency"); + + // Calculate exchange rate + var rate = customRate ?? fromCurrency.GetExchangeRateTo(toCurrency); + if (rate <= 0) + throw new WalletDomainException("Exchange rate must be greater than zero"); + + // Calculate target amount + var toAmount = Math.Round(fromAmount * rate, 2); + + // Withdraw from source (validates sufficient balance) + Withdraw(fromAmount, fromCurrency, $"Exchange to {toCurrency.Name}", null); + + // Deposit to target + Deposit(toAmount, toCurrency, $"Exchange from {fromCurrency.Name}", null); + + // Raise exchange domain event + AddDomainEvent(new WalletExchangedDomainEvent( + Id, + UserId, + fromCurrency.Id, + toCurrency.Id, + fromAmount, + toAmount, + rate)); + + return toAmount; + } + + #endregion + + #region Legacy Methods (Backward Compatibility) + + private Money GetLegacyBalance() + { + var defaultBalance = _balances.FirstOrDefault(b => b.CurrencyTypeId == DefaultCurrencyTypeId); + return new Money(defaultBalance?.Balance ?? 0, DefaultCurrencyType?.Name ?? "VND"); + } + + /// + /// EN: Deposit money into the wallet (legacy method - uses default currency). + /// VI: Nạp tiền vào ví (phương thức kế thừa - sử dụng tiền tệ mặc định). + /// + [Obsolete("Use Deposit(decimal, CurrencyType, string, string?) instead")] + public void Deposit(Money amount, string description, string? referenceId = null) + { + var currencyType = CurrencyType.FromDisplayName(amount.Currency) + ?? throw new WalletDomainException($"Unknown currency: {amount.Currency}"); + + Deposit(amount.Amount, currencyType, description, referenceId); + } + + /// + /// EN: Withdraw money from the wallet (legacy method - uses default currency). + /// VI: Rút tiền khỏi ví (phương thức kế thừa - sử dụng tiền tệ mặc định). + /// + [Obsolete("Use Withdraw(decimal, CurrencyType, string, string?) instead")] + public void Withdraw(Money amount, string description, string? referenceId = null) + { + var currencyType = CurrencyType.FromDisplayName(amount.Currency) + ?? throw new WalletDomainException($"Unknown currency: {amount.Currency}"); + + Withdraw(amount.Amount, currencyType, description, referenceId); + } + + /// + /// EN: Transfer money to another wallet. + /// VI: Chuyển tiền đến ví khác. /// public void TransferOut(Money amount, Guid toWalletId, string description) { EnsureWalletIsActive(); + var currencyType = CurrencyType.FromDisplayName(amount.Currency) + ?? throw new WalletDomainException($"Unknown currency: {amount.Currency}"); + if (amount.Amount <= 0) throw new WalletDomainException("Transfer amount must be greater than zero"); - if (!Balance.IsGreaterThanOrEqual(amount)) - throw new InsufficientBalanceException(Balance.Amount, amount.Amount); + var balance = _balances.FirstOrDefault(b => b.CurrencyTypeId == currencyType.Id); + if (balance == null || !balance.HasSufficientBalance(amount.Amount)) + throw new InsufficientBalanceException(balance?.Balance ?? 0, amount.Amount); - Balance = Balance.Subtract(amount); + balance.Subtract(amount.Amount); UpdatedAt = DateTime.UtcNow; var transaction = new WalletTransaction( Id, amount, TransactionType.TransferOut, - Balance.Amount, + balance.Balance, description, toWalletId.ToString()); @@ -178,25 +320,29 @@ public class Wallet : Entity, IAggregateRoot UserId, TransactionType.TransferOut.Name, amount.Amount, - Balance.Amount)); + balance.Balance)); } /// - /// EN: Receive transfer from another wallet - /// VI: Nhận chuyển khoản từ ví khác + /// EN: Receive transfer from another wallet. + /// VI: Nhận chuyển khoản từ ví khác. /// public void TransferIn(Money amount, Guid fromWalletId, string description) { EnsureWalletIsActive(); - Balance = Balance.Add(amount); + var currencyType = CurrencyType.FromDisplayName(amount.Currency) + ?? throw new WalletDomainException($"Unknown currency: {amount.Currency}"); + + var balance = GetOrCreateBalance(currencyType); + balance.Add(amount.Amount); UpdatedAt = DateTime.UtcNow; var transaction = new WalletTransaction( Id, amount, TransactionType.TransferIn, - Balance.Amount, + balance.Balance, description, fromWalletId.ToString()); @@ -207,12 +353,16 @@ public class Wallet : Entity, IAggregateRoot UserId, TransactionType.TransferIn.Name, amount.Amount, - Balance.Amount)); + balance.Balance)); } + #endregion + + #region Wallet Status Management + /// - /// EN: Freeze the wallet (no transactions allowed) - /// VI: Đóng băng ví (không cho phép giao dịch) + /// EN: Freeze the wallet (no transactions allowed). + /// VI: Đóng băng ví (không cho phép giao dịch). /// public void Freeze() { @@ -225,8 +375,8 @@ public class Wallet : Entity, IAggregateRoot } /// - /// EN: Unfreeze the wallet - /// VI: Mở băng ví + /// EN: Unfreeze the wallet. + /// VI: Mở băng ví. /// public void Unfreeze() { @@ -239,13 +389,14 @@ public class Wallet : Entity, IAggregateRoot } /// - /// EN: Close the wallet permanently - /// VI: Đóng ví vĩnh viễn + /// EN: Close the wallet permanently. + /// VI: Đóng ví vĩnh viễn. /// public void Close() { - if (Balance.Amount > 0) - throw new WalletDomainException("Cannot close wallet with positive balance"); + // Check all balances are zero + if (_balances.Any(b => b.Balance > 0)) + throw new WalletDomainException("Cannot close wallet with positive balance in any currency"); Status = WalletStatus.Closed; StatusId = Status.Id; @@ -257,4 +408,6 @@ public class Wallet : Entity, IAggregateRoot if (Status != WalletStatus.Active) throw new WalletDomainException($"Wallet is {Status.Name}. Only active wallets can perform transactions"); } + + #endregion } diff --git a/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletItem.cs b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletItem.cs new file mode 100644 index 00000000..facef7ed --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/AggregatesModel/WalletAggregate/WalletItem.cs @@ -0,0 +1,114 @@ +namespace WalletService.Domain.AggregatesModel.WalletAggregate; + +using WalletService.Domain.Exceptions; +using WalletService.Domain.SeedWork; + +/// +/// EN: Represents a balance for a specific currency type within a wallet. +/// VI: Đại diện cho số dư của một loại tiền tệ cụ thể trong ví. +/// +/// +/// EN: WalletItem is a child entity of Wallet aggregate, not an aggregate root. +/// Each wallet can have multiple WalletItems, one for each currency type. +/// VI: WalletItem là entity con của Wallet aggregate, không phải aggregate root. +/// Mỗi ví có thể có nhiều WalletItem, một cho mỗi loại tiền tệ. +/// +public class WalletItem : Entity +{ + /// + /// EN: Parent wallet ID. + /// VI: ID của ví cha. + /// + public Guid WalletId { get; private set; } + + /// + /// EN: Currency type ID for EF Core. + /// VI: ID loại tiền tệ cho EF Core. + /// + public int CurrencyTypeId { get; private set; } + + /// + /// EN: Currency type. + /// VI: Loại tiền tệ. + /// + public CurrencyType CurrencyType { get; private set; } = null!; + + /// + /// EN: Current balance amount. + /// VI: Số dư hiện tại. + /// + public decimal Balance { get; private set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời điểm tạo. + /// + public DateTime CreatedAt { get; private set; } + + /// + /// EN: Last update timestamp. + /// VI: Thời điểm cập nhật cuối. + /// + public DateTime UpdatedAt { get; private set; } + + protected WalletItem() { } + + /// + /// EN: Create a new wallet item for a specific currency. + /// VI: Tạo wallet item mới cho một loại tiền tệ cụ thể. + /// + public WalletItem(Guid walletId, CurrencyType currencyType, decimal initialBalance = 0) + { + if (initialBalance < 0) + throw new WalletDomainException("Initial balance cannot be negative"); + + Id = Guid.NewGuid(); + WalletId = walletId; + CurrencyType = currencyType; + CurrencyTypeId = currencyType.Id; + Balance = initialBalance; + CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Add amount to balance. + /// VI: Cộng số tiền vào số dư. + /// + public void Add(decimal amount) + { + if (amount <= 0) + throw new WalletDomainException("Amount to add must be greater than zero"); + + Balance += amount; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Subtract amount from balance. + /// VI: Trừ số tiền từ số dư. + /// + public void Subtract(decimal amount) + { + if (amount <= 0) + throw new WalletDomainException("Amount to subtract must be greater than zero"); + + if (Balance < amount) + throw new InsufficientBalanceException(Balance, amount); + + Balance -= amount; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Check if balance is sufficient for an amount. + /// VI: Kiểm tra số dư có đủ cho một số tiền không. + /// + public bool HasSufficientBalance(decimal amount) => Balance >= amount; + + /// + /// EN: Get formatted balance string. + /// VI: Lấy chuỗi số dư đã được format. + /// + public override string ToString() => $"{Balance:N2} {CurrencyType.Name}"; +} diff --git a/services/wallet-service-net/src/WalletService.Domain/Events/WalletExchangedDomainEvent.cs b/services/wallet-service-net/src/WalletService.Domain/Events/WalletExchangedDomainEvent.cs new file mode 100644 index 00000000..d96e1eaa --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Domain/Events/WalletExchangedDomainEvent.cs @@ -0,0 +1,76 @@ +namespace WalletService.Domain.Events; + +using MediatR; + +/// +/// EN: Domain event raised when currency exchange occurs in a wallet. +/// VI: Domain event được raise khi xảy ra quy đổi tiền tệ trong ví. +/// +public record WalletExchangedDomainEvent : INotification +{ + /// + /// EN: Wallet ID. + /// VI: ID ví. + /// + public Guid WalletId { get; init; } + + /// + /// EN: User ID who owns the wallet. + /// VI: ID người dùng sở hữu ví. + /// + public Guid UserId { get; init; } + + /// + /// EN: Source currency type ID. + /// VI: ID loại tiền tệ nguồn. + /// + public int FromCurrencyTypeId { get; init; } + + /// + /// EN: Target currency type ID. + /// VI: ID loại tiền tệ đích. + /// + public int ToCurrencyTypeId { get; init; } + + /// + /// EN: Amount exchanged from source currency. + /// VI: Số tiền quy đổi từ tiền tệ nguồn. + /// + public decimal FromAmount { get; init; } + + /// + /// EN: Amount received in target currency. + /// VI: Số tiền nhận được bằng tiền tệ đích. + /// + public decimal ToAmount { get; init; } + + /// + /// EN: Exchange rate applied. + /// VI: Tỷ giá áp dụng. + /// + public decimal ExchangeRate { get; init; } + + /// + /// EN: Timestamp of the exchange. + /// VI: Thời điểm quy đổi. + /// + public DateTime ExchangedAt { get; init; } = DateTime.UtcNow; + + public WalletExchangedDomainEvent( + Guid walletId, + Guid userId, + int fromCurrencyTypeId, + int toCurrencyTypeId, + decimal fromAmount, + decimal toAmount, + decimal exchangeRate) + { + WalletId = walletId; + UserId = userId; + FromCurrencyTypeId = fromCurrencyTypeId; + ToCurrencyTypeId = toCurrencyTypeId; + FromAmount = fromAmount; + ToAmount = toAmount; + ExchangeRate = exchangeRate; + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs index 5027d492..fa7704ce 100644 --- a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletEntityTypeConfiguration.cs @@ -5,8 +5,8 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; using WalletService.Domain.AggregatesModel.WalletAggregate; /// -/// EN: EF Core configuration for Wallet aggregate -/// VI: Cấu hình EF Core cho aggregate Wallet +/// EN: EF Core configuration for Wallet aggregate with multi-currency support. +/// VI: Cấu hình EF Core cho aggregate Wallet với hỗ trợ đa tiền tệ. /// public class WalletEntityTypeConfiguration : IEntityTypeConfiguration { @@ -28,20 +28,22 @@ public class WalletEntityTypeConfiguration : IEntityTypeConfiguration .IsUnique() .HasDatabaseName("ix_wallets_user_id"); - // EN: Configure Money value object - // VI: Cấu hình value object Money - builder.OwnsOne(w => w.Balance, balance => - { - balance.Property(m => m.Amount) - .HasColumnName("balance") - .HasColumnType("decimal(18,2)") - .IsRequired(); + // EN: Default currency type ID + // VI: ID loại tiền tệ mặc định + builder.Property(w => w.DefaultCurrencyTypeId) + .HasColumnName("default_currency_type_id") + .IsRequired() + .HasDefaultValue(1); // VND - balance.Property(m => m.Currency) - .HasColumnName("currency") - .HasMaxLength(3) - .IsRequired(); - }); + // EN: Ignore CurrencyType navigation - it's an enumeration + // VI: Bỏ qua navigation CurrencyType - đây là enumeration + builder.Ignore(w => w.DefaultCurrencyType); + + // EN: Ignore legacy Balance property (computed from Balances) + // VI: Bỏ qua property Balance kế thừa (tính từ Balances) + #pragma warning disable CS0618 // Obsolete warning suppressed for EF Core configuration + builder.Ignore(w => w.Balance); + #pragma warning restore CS0618 builder.Property(w => w.StatusId) .HasColumnName("status_id") @@ -57,8 +59,21 @@ public class WalletEntityTypeConfiguration : IEntityTypeConfiguration .HasColumnName("updated_at") .IsRequired(); + // EN: Configure navigation to wallet items (multi-balance) + // VI: Cấu hình navigation đến wallet items (đa số dư) + var balancesNavigation = builder.Metadata.FindNavigation(nameof(Wallet.Balances)); + balancesNavigation?.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(w => w.Balances) + .WithOne() + .HasForeignKey(wi => wi.WalletId) + .OnDelete(DeleteBehavior.Cascade); + // EN: Configure navigation to transactions // VI: Cấu hình navigation đến transactions + var transactionsNavigation = builder.Metadata.FindNavigation(nameof(Wallet.Transactions)); + transactionsNavigation?.SetPropertyAccessMode(PropertyAccessMode.Field); + builder.HasMany(w => w.Transactions) .WithOne() .HasForeignKey(t => t.WalletId) diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletItemEntityTypeConfiguration.cs b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletItemEntityTypeConfiguration.cs new file mode 100644 index 00000000..bf6a21ba --- /dev/null +++ b/services/wallet-service-net/src/WalletService.Infrastructure/EntityConfigurations/WalletItemEntityTypeConfiguration.cs @@ -0,0 +1,59 @@ +namespace WalletService.Infrastructure.EntityConfigurations; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using WalletService.Domain.AggregatesModel.WalletAggregate; + +/// +/// EN: EF Core configuration for WalletItem entity. +/// VI: Cấu hình EF Core cho entity WalletItem. +/// +public class WalletItemEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("wallet_items"); + + builder.HasKey(wi => wi.Id); + + builder.Property(wi => wi.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + builder.Property(wi => wi.WalletId) + .HasColumnName("wallet_id") + .IsRequired(); + + builder.Property(wi => wi.CurrencyTypeId) + .HasColumnName("currency_type_id") + .IsRequired(); + + // EN: Ignore navigation property - CurrencyType is an enumeration + // VI: Bỏ qua navigation property - CurrencyType là enumeration + builder.Ignore(wi => wi.CurrencyType); + + builder.Property(wi => wi.Balance) + .HasColumnName("balance") + .HasColumnType("decimal(18,2)") + .IsRequired(); + + builder.Property(wi => wi.CreatedAt) + .HasColumnName("created_at") + .IsRequired(); + + builder.Property(wi => wi.UpdatedAt) + .HasColumnName("updated_at") + .IsRequired(); + + // EN: Unique constraint: one balance per currency per wallet + // VI: Ràng buộc unique: một số dư cho mỗi loại tiền tệ trong mỗi ví + builder.HasIndex(wi => new { wi.WalletId, wi.CurrencyTypeId }) + .IsUnique() + .HasDatabaseName("ix_wallet_items_wallet_currency"); + + builder.HasIndex(wi => wi.WalletId) + .HasDatabaseName("ix_wallet_items_wallet_id"); + + builder.Ignore(wi => wi.DomainEvents); + } +} diff --git a/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs b/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs index 9840d833..efd4f1c2 100644 --- a/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs +++ b/services/wallet-service-net/src/WalletService.Infrastructure/WalletServiceContext.cs @@ -17,6 +17,7 @@ public class WalletServiceContext : DbContext, IUnitOfWork private IDbContextTransaction? _currentTransaction; public DbSet Wallets { get; set; } = null!; + public DbSet WalletItems { get; set; } = null!; public DbSet WalletTransactions { get; set; } = null!; public DbSet PointAccounts { get; set; } = null!; public DbSet PointTransactions { get; set; } = null!; diff --git a/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs index 8011bd76..771a2465 100644 --- a/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs +++ b/services/wallet-service-net/tests/WalletService.UnitTests/Domain/WalletTests.cs @@ -1,5 +1,5 @@ -// EN: Unit tests for Wallet aggregate -// VI: Unit tests cho Wallet aggregate +// EN: Unit tests for Wallet aggregate with multi-currency support +// VI: Unit tests cho Wallet aggregate với hỗ trợ đa tiền tệ using WalletService.Domain.AggregatesModel.WalletAggregate; using WalletService.Domain.Exceptions; using Xunit; @@ -9,19 +9,18 @@ namespace WalletService.UnitTests.Domain; public class WalletTests { private static readonly Guid TestUserId = Guid.NewGuid(); - private const string TestCurrency = "VND"; [Fact] public void Constructor_ShouldCreateWallet_WithZeroBalance() { // Arrange & Act - var wallet = new Wallet(TestUserId, TestCurrency); + var wallet = new Wallet(TestUserId, CurrencyType.VND); // Assert Assert.NotNull(wallet); Assert.Equal(TestUserId, wallet.UserId); - Assert.Equal(TestCurrency, wallet.Balance.Currency); - Assert.Equal(0, wallet.Balance.Amount); + Assert.Equal(CurrencyType.VND.Id, wallet.DefaultCurrencyTypeId); + Assert.Equal(0, wallet.GetBalance(CurrencyType.VND)); Assert.Equal(WalletStatus.Active, wallet.Status); } @@ -32,21 +31,20 @@ public class WalletTests var wallet = new Wallet(TestUserId); // Assert - Assert.Equal("VND", wallet.Balance.Currency); + Assert.Equal(CurrencyType.VND.Id, wallet.DefaultCurrencyTypeId); } [Fact] public void Deposit_ShouldIncreaseBalance() { // Arrange - var wallet = new Wallet(TestUserId, TestCurrency); - var depositAmount = new Money(100000m, TestCurrency); + var wallet = new Wallet(TestUserId, CurrencyType.VND); // Act - wallet.Deposit(depositAmount, "Test deposit", "REF001"); + wallet.Deposit(100000m, CurrencyType.VND, "Test deposit", "REF001"); // Assert - Assert.Equal(100000m, wallet.Balance.Amount); + Assert.Equal(100000m, wallet.GetBalance(CurrencyType.VND)); Assert.Single(wallet.Transactions); Assert.Equal(TransactionType.Credit, wallet.Transactions.First().Type); } @@ -58,21 +56,21 @@ public class WalletTests // EN: Money constructor throws ArgumentException for negative amounts // VI: Money constructor ném ArgumentException cho số tiền âm Assert.Throws(() => - new Money(-100, TestCurrency)); + new Money(-100, "VND")); } [Fact] public void Withdraw_ShouldDecreaseBalance() { // Arrange - var wallet = new Wallet(TestUserId, TestCurrency); - wallet.Deposit(new Money(100000m, TestCurrency), "Initial deposit", "REF001"); + var wallet = new Wallet(TestUserId, CurrencyType.VND); + wallet.Deposit(100000m, CurrencyType.VND, "Initial deposit", "REF001"); // Act - wallet.Withdraw(new Money(50000m, TestCurrency), "Test withdrawal", "REF002"); + wallet.Withdraw(50000m, CurrencyType.VND, "Test withdrawal", "REF002"); // Assert - Assert.Equal(50000m, wallet.Balance.Amount); + Assert.Equal(50000m, wallet.GetBalance(CurrencyType.VND)); Assert.Equal(2, wallet.Transactions.Count); } @@ -80,19 +78,19 @@ public class WalletTests public void Withdraw_ShouldThrow_WhenInsufficientBalance() { // Arrange - var wallet = new Wallet(TestUserId, TestCurrency); - wallet.Deposit(new Money(50000m, TestCurrency), "Initial deposit", "REF001"); + var wallet = new Wallet(TestUserId, CurrencyType.VND); + wallet.Deposit(50000m, CurrencyType.VND, "Initial deposit", "REF001"); // Act & Assert Assert.Throws(() => - wallet.Withdraw(new Money(100000m, TestCurrency), "Invalid withdrawal")); + wallet.Withdraw(100000m, CurrencyType.VND, "Invalid withdrawal")); } [Fact] public void Freeze_ShouldSetStatusToFrozen() { // Arrange - var wallet = new Wallet(TestUserId, TestCurrency); + var wallet = new Wallet(TestUserId, CurrencyType.VND); // Act wallet.Freeze(); @@ -105,7 +103,7 @@ public class WalletTests public void Unfreeze_ShouldSetStatusToActive() { // Arrange - var wallet = new Wallet(TestUserId, TestCurrency); + var wallet = new Wallet(TestUserId, CurrencyType.VND); wallet.Freeze(); // Act @@ -119,7 +117,7 @@ public class WalletTests public void Close_ShouldSetStatusToClosed() { // Arrange - var wallet = new Wallet(TestUserId, TestCurrency); + var wallet = new Wallet(TestUserId, CurrencyType.VND); // Act wallet.Close(); @@ -132,10 +130,71 @@ public class WalletTests public void Close_ShouldThrow_WhenBalanceIsPositive() { // Arrange - var wallet = new Wallet(TestUserId, TestCurrency); - wallet.Deposit(new Money(100000m, TestCurrency), "Deposit", "REF001"); + var wallet = new Wallet(TestUserId, CurrencyType.VND); + wallet.Deposit(100000m, CurrencyType.VND, "Deposit", "REF001"); // Act & Assert Assert.Throws(() => wallet.Close()); } + + #region Multi-Currency Tests / Tests đa tiền tệ + + [Fact] + public void Deposit_ShouldSupportMultipleCurrencies() + { + // Arrange + var wallet = new Wallet(TestUserId, CurrencyType.VND); + + // Act + wallet.Deposit(100000m, CurrencyType.VND, "VND deposit"); + wallet.Deposit(100m, CurrencyType.USD, "USD deposit"); + wallet.Deposit(1000, CurrencyType.PPoint, "PPoint deposit"); + + // Assert + Assert.Equal(100000m, wallet.GetBalance(CurrencyType.VND)); + Assert.Equal(100m, wallet.GetBalance(CurrencyType.USD)); + Assert.Equal(1000, wallet.GetBalance(CurrencyType.PPoint)); + Assert.Equal(3, wallet.Balances.Count); + } + + [Fact] + public void Exchange_ShouldConvertBetweenCurrencies() + { + // Arrange + var wallet = new Wallet(TestUserId, CurrencyType.VND); + wallet.Deposit(100000m, CurrencyType.VND, "Initial VND deposit"); + + // Act - Exchange 100000 VND to PPoint (rate: 1 VND = 0.001 PPoint) + var receivedPoints = wallet.Exchange(100000m, CurrencyType.VND, CurrencyType.PPoint); + + // Assert + Assert.Equal(0, wallet.GetBalance(CurrencyType.VND)); + Assert.Equal(100, wallet.GetBalance(CurrencyType.PPoint)); // 100000 / 1000 = 100 + } + + [Fact] + public void Exchange_ShouldThrow_WhenSameCurrency() + { + // Arrange + var wallet = new Wallet(TestUserId, CurrencyType.VND); + wallet.Deposit(100000m, CurrencyType.VND, "Deposit"); + + // Act & Assert + Assert.Throws(() => + wallet.Exchange(50000m, CurrencyType.VND, CurrencyType.VND)); + } + + [Fact] + public void Exchange_ShouldThrow_WhenInsufficientBalance() + { + // Arrange + var wallet = new Wallet(TestUserId, CurrencyType.VND); + wallet.Deposit(50000m, CurrencyType.VND, "Deposit"); + + // Act & Assert + Assert.Throws(() => + wallet.Exchange(100000m, CurrencyType.VND, CurrencyType.PPoint)); + } + + #endregion }