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
}