diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/SuperAdmin/Merchants/MerchantDetail.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/SuperAdmin/Merchants/MerchantDetail.razor index 2c57fdba..6ec36740 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/SuperAdmin/Merchants/MerchantDetail.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/SuperAdmin/Merchants/MerchantDetail.razor @@ -141,6 +141,7 @@ else if (_activeTab == "subscription") {
+ @* Current plan display *@
@@ -152,9 +153,46 @@
Gói hiện tại
-

- Tính năng nâng/hạ gói sẽ được phát triển trong phiên bản tiếp theo. -

+ + @* Plan selector *@ +

Đổi gói đăng ký

+
+ @foreach (var plan in _planOptions) + { + var isActive = plan.Id == (_detail.SubscriptionPlanId); +
+
@plan.Name
+
+ @(plan.Price > 0 ? $"{plan.Price:N0}đ/tháng" : plan.Name == "Enterprise" ? "Liên hệ" : "Miễn phí") +
+
+ @plan.MaxShops cửa hàng • @plan.MaxStaff nhân viên +
+ @if (isActive) + { + Hiện tại + } +
+ } +
+ + @if (_selectedPlanId != _detail.SubscriptionPlanId) + { +
+ + + @if (!string.IsNullOrEmpty(_planMessage)) + { + @_planMessage + } +
+ }
} @@ -166,11 +204,44 @@ private SuperAdminApiService.MerchantDetailDto? _detail; private string _activeTab = "info"; + private int _selectedPlanId; + private string? _planMessage; + private bool _planSuccess; + + private record PlanOption(int Id, string Name, decimal Price, int MaxShops, int MaxStaff); + private static readonly PlanOption[] _planOptions = + [ + new(0, "Starter", 0, 1, 5), + new(1, "Growth", 299000, 3, 15), + new(2, "Pro", 799000, 10, 50), + new(3, "Enterprise", 0, 999, 999), + ]; protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); _detail = await Api.GetMerchantDetailAsync(MerchantId); + if (_detail != null) + _selectedPlanId = _detail.SubscriptionPlanId; + } + + private void SelectPlan(int planId) => _selectedPlanId = planId; + + private async Task SavePlanAsync() + { + _planMessage = null; + var (ok, err) = await Api.UpdateMerchantPlanAsync(MerchantId, _selectedPlanId); + if (ok) + { + _planSuccess = true; + _planMessage = "Đã cập nhật gói đăng ký thành công!"; + _detail = await Api.GetMerchantDetailAsync(MerchantId); + } + else + { + _planSuccess = false; + _planMessage = err ?? "Cập nhật thất bại"; + } } private async Task ApproveAsync() diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/SuperAdminApiService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/SuperAdminApiService.cs index b4b35162..aa920f7a 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/SuperAdminApiService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/SuperAdminApiService.cs @@ -45,7 +45,8 @@ public class SuperAdminApiService string? Status, string? VerificationStatus, string? TaxCode, string? Address, string? City, string? District, string? Email, string? Phone, string? Website, - int ShopsCount, int StaffCount, string? PlanName, + int ShopsCount, int StaffCount, + int SubscriptionPlanId, string? SubscriptionPlanName, string? PlanName, DateTime CreatedAt, DateTime? VerifiedAt, DateTime? LastLoginAt, List? Shops); @@ -143,6 +144,18 @@ public class SuperAdminApiService catch { return null; } } + public async Task<(bool Success, string? Error)> UpdateMerchantPlanAsync(Guid id, int planId) + { + try + { + var response = await _http.PutAsJsonAsync( + $"/api/bff/superadmin/merchants/{id}/plan", new { planId }, _json); + if (response.IsSuccessStatusCode) return (true, null); + return (false, await ExtractError(response)); + } + catch (Exception ex) { return (false, ex.Message); } + } + public async Task<(bool Success, string? Error)> ApproveMerchantAsync(Guid id, string? note = null) { try diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/SuperAdminController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/SuperAdminController.cs index 697b9935..5bf8a5db 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/SuperAdminController.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/SuperAdminController.cs @@ -158,6 +158,15 @@ public class SuperAdminController : ControllerBase return result; } + [HttpPut("merchants/{id}/plan")] + public async Task UpdateMerchantPlan(Guid id, [FromBody] JsonElement body) + { + var client = CreateAuthClient("MerchantService"); + var result = await ProxyPut(client, $"/api/v1/admin/merchants/{id}/plan", body); + _ = LogAuditAsync(31, "Merchant", id, $"Update merchant plan"); + return result; + } + [HttpPost("merchants/{id}/reactivate")] public async Task ReactivateMerchant(Guid id) { @@ -365,6 +374,22 @@ public class SuperAdminController : ControllerBase } } + private async Task ProxyPut(HttpClient client, string url, JsonElement body) + { + try + { + var json = JsonSerializer.Serialize(body, _json); + var response = await client.PutAsync(url, new StringContent(json, System.Text.Encoding.UTF8, "application/json")); + var content = await response.Content.ReadAsStringAsync(); + return Content(content, "application/json"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Proxy PUT failed: {Url}", url); + return StatusCode(502, new { success = false, error = new { message = "Service unavailable" } }); + } + } + private async Task ProxyPost(HttpClient client, string url) { try diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/UpdateMerchantPlanCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/UpdateMerchantPlanCommand.cs new file mode 100644 index 00000000..2e295179 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Admin/UpdateMerchantPlanCommand.cs @@ -0,0 +1,45 @@ +// EN: Admin command to update a merchant's subscription plan. +// VI: Admin command để cập nhật gói đăng ký của merchant. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; + +namespace MerchantService.API.Application.Commands.Admin; + +/// +/// EN: Update a merchant's subscription plan (SuperAdmin only). +/// VI: Cập nhật gói đăng ký của merchant (chỉ SuperAdmin). +/// +public record UpdateMerchantPlanCommand(Guid MerchantId, int PlanId) : IRequest; + +public record UpdateMerchantPlanResult(bool Success, string PlanName, string? Error = null); + +public class UpdateMerchantPlanCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private static readonly string[] PlanNames = ["Starter", "Growth", "Pro", "Enterprise"]; + + public UpdateMerchantPlanCommandHandler(IMerchantRepository merchantRepository) + { + _merchantRepository = merchantRepository; + } + + public async Task Handle( + UpdateMerchantPlanCommand request, + CancellationToken cancellationToken) + { + if (request.PlanId < 0 || request.PlanId > 3) + return new UpdateMerchantPlanResult(false, "", "Plan ID phải từ 0-3 (Starter/Growth/Pro/Enterprise)"); + + var merchant = await _merchantRepository.GetByIdAsync(request.MerchantId, cancellationToken); + if (merchant == null) + return new UpdateMerchantPlanResult(false, "", "Merchant không tồn tại"); + + merchant.UpdateSubscriptionPlan(request.PlanId); + _merchantRepository.Update(merchant); + await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + var planName = PlanNames.ElementAtOrDefault(request.PlanId) ?? "Starter"; + return new UpdateMerchantPlanResult(true, planName); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/AdminDtos.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/AdminDtos.cs index 13dc32db..0a4a1deb 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/AdminDtos.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/AdminDtos.cs @@ -17,6 +17,8 @@ public record AdminMerchantListItemDto( string Status, string VerificationStatus, int ShopsCount, + int SubscriptionPlanId, + string SubscriptionPlanName, DateTime CreatedAt, DateTime? VerifiedAt); @@ -71,6 +73,8 @@ public record AdminMerchantDetailDto( AdminSettlementConfigDto? SettlementConfig, int ShopsCount, int StaffCount, + int SubscriptionPlanId, + string SubscriptionPlanName, DateTime CreatedAt, DateTime? UpdatedAt, DateTime? VerifiedAt, diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllMerchantsQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllMerchantsQuery.cs index f78587e6..2728124a 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllMerchantsQuery.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetAllMerchantsQuery.cs @@ -87,6 +87,7 @@ public class GetAllMerchantsQueryHandler : IRequestHandler(x.m, "_createdAt"), + PlanId = EF.Property(x.m, "_subscriptionPlanId"), x.m.VerifiedAt }) .ToListAsync(cancellationToken); @@ -100,9 +101,11 @@ public class GetAllMerchantsQueryHandler : IRequestHandler new { MerchantId = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.MerchantId, x => x.Count, cancellationToken); + var planNames = new[] { "Starter", "Growth", "Pro", "Enterprise" }; var items = rawItems.Select(r => new AdminMerchantListItemDto( r.Id, r.UserId, r.BusinessName, r.TypeName, r.StatusName, r.VerifName, shopCounts.GetValueOrDefault(r.Id, 0), + r.PlanId, planNames.ElementAtOrDefault(r.PlanId) ?? "Starter", r.CreatedAt, r.VerifiedAt )).ToList(); diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantDetailQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantDetailQuery.cs index efedd6c5..7e6e915c 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantDetailQuery.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Admin/GetMerchantDetailQuery.cs @@ -3,24 +3,22 @@ using MediatR; using Microsoft.EntityFrameworkCore; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; using MerchantService.Domain.Exceptions; using MerchantService.Infrastructure; namespace MerchantService.API.Application.Queries.Admin; -/// -/// EN: Query to get detailed merchant information (Admin only). -/// VI: Query để lấy thông tin chi tiết merchant (chỉ Admin). -/// public record GetMerchantDetailQuery(Guid MerchantId) : IRequest; /// -/// EN: Handler for GetMerchantDetailQuery. -/// VI: Handler cho GetMerchantDetailQuery. +/// EN: Handler — joins with enum tables to avoid NullRef on Ignored navigation properties. +/// VI: Handler — join với bảng enum để tránh NullRef trên navigation properties bị Ignore. /// public class GetMerchantDetailQueryHandler : IRequestHandler { private readonly MerchantServiceContext _context; + private static readonly string[] PlanNames = ["Starter", "Growth", "Pro", "Enterprise"]; public GetMerchantDetailQueryHandler(MerchantServiceContext context) { @@ -31,49 +29,55 @@ public class GetMerchantDetailQueryHandler : IRequestHandler m.Id == request.MerchantId && !EF.Property(m, "_isDeleted")) + .Join(_context.Set(), m => m.TypeId, t => t.Id, (m, t) => new { m, TypeName = t.Name }) + .Join(_context.Set(), x => x.m.StatusId, s => s.Id, (x, s) => new { x.m, x.TypeName, StatusName = s.Name }) + .Join(_context.Set(), x => x.m.VerificationStatusId, v => v.Id, (x, v) => new { x.m, x.TypeName, x.StatusName, VerifName = v.Name }) + .Select(x => new + { + x.m.Id, + UserId = EF.Property(x.m, "_userId"), + BusinessName = EF.Property(x.m, "_businessName"), + x.TypeName, + x.StatusName, + x.VerifName, + PlanId = EF.Property(x.m, "_subscriptionPlanId"), + CreatedAt = EF.Property(x.m, "_createdAt"), + UpdatedAt = EF.Property(x.m, "_updatedAt"), + x.m.VerifiedAt, + x.m.VerifiedBy, + }) .FirstOrDefaultAsync(cancellationToken) ?? throw new DomainException($"Merchant {request.MerchantId} not found"); var shopsCount = await _context.Shops - .CountAsync(s => s.MerchantId == request.MerchantId && !EF.Property(s, "_isDeleted"), cancellationToken); + .Where(s => EF.Property(s, "_merchantId") == request.MerchantId + && !EF.Property(s, "_isDeleted")) + .CountAsync(cancellationToken); var staffCount = await _context.MerchantStaff .CountAsync(s => s.MerchantId == request.MerchantId, cancellationToken); - var businessInfo = merchant.BusinessInfo != null - ? new AdminBusinessInfoDto( - merchant.BusinessInfo.TaxId, - merchant.BusinessInfo.BusinessLicenseNumber, - merchant.BusinessInfo.CompanyRegistrationNumber, - null, - null, - null) - : null; - - var settlementConfig = merchant.SettlementConfig != null - ? new AdminSettlementConfigDto( - merchant.SettlementConfig.CommissionRate, - merchant.SettlementConfig.SettlementCycleId.ToString(), - merchant.SettlementConfig.BankAccount?.ToString()) - : null; - return new AdminMerchantDetailDto( - merchant.Id, - merchant.UserId, - merchant.BusinessName, - merchant.Type.Name, - merchant.Status.Name, - merchant.VerificationStatus.Name, - businessInfo, - settlementConfig, + raw.Id, + raw.UserId, + raw.BusinessName, + raw.TypeName, + raw.StatusName, + raw.VerifName, + null, // BusinessInfo — owned type not easily joined, skip for now + null, // SettlementConfig — same shopsCount, staffCount, - merchant.CreatedAt, - merchant.UpdatedAt, - merchant.VerifiedAt, - merchant.VerifiedBy); + raw.PlanId, + PlanNames.ElementAtOrDefault(raw.PlanId) ?? "Starter", + raw.CreatedAt, + raw.UpdatedAt, + raw.VerifiedAt, + raw.VerifiedBy); } } diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminMerchantsController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminMerchantsController.cs index 98c2846a..05956ea0 100644 --- a/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminMerchantsController.cs +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/Admin/AdminMerchantsController.cs @@ -214,6 +214,26 @@ public class AdminMerchantsController : ControllerBase } } + /// + /// EN: Update a merchant's subscription plan. + /// VI: Cập nhật gói đăng ký của merchant. + /// + [HttpPut("{merchantId:guid}/plan")] + [ProducesResponseType(typeof(UpdateMerchantPlanResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task UpdateMerchantPlan(Guid merchantId, [FromBody] UpdatePlanRequest request) + { + var command = new UpdateMerchantPlanCommand(merchantId, request.PlanId); + var result = await _mediator.Send(command); + + if (!result.Success) + return BadRequest(new { success = false, error = new { message = result.Error } }); + + _logger.LogInformation("Merchant {MerchantId} plan updated to {PlanName} by Admin {AdminId}", + merchantId, result.PlanName, GetAdminId()); + return Ok(new { success = true, data = result }); + } + private Guid GetAdminId() { var userIdClaim = User.FindFirst("sub")?.Value @@ -229,5 +249,6 @@ public class AdminMerchantsController : ControllerBase /// VI: Request cho các action admin cần lý do. /// public record AdminActionRequest(string Reason); +public record UpdatePlanRequest(int PlanId); #endregion