feat(superadmin): implement subscription plan management for merchants

- Add UpdateMerchantPlanCommand in MerchantService (Admin can set plan 0-3)
- Add PUT /api/v1/admin/merchants/{id}/plan endpoint
- Add BFF proxy PUT /api/bff/superadmin/merchants/{id}/plan with audit logging
- Add SubscriptionPlanId + SubscriptionPlanName to AdminMerchantListItemDto and AdminMerchantDetailDto
- Rewrite GetMerchantDetailQueryHandler to use EF joins (fix NullRef on Ignored nav properties)
- Add plan selector UI in MerchantDetail subscription tab (4 clickable plan cards)
- Save button appears when plan differs from current, with success/error feedback
- Add UpdateMerchantPlanAsync to SuperAdminApiService frontend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-29 00:07:49 +07:00
parent b378f39872
commit e3893efa56
8 changed files with 227 additions and 41 deletions

View File

@@ -141,6 +141,7 @@
else if (_activeTab == "subscription")
{
<div class="sa-panel__body">
@* Current plan display *@
<div style="display:flex;align-items:center;gap:16px;padding:20px;background:var(--sa-bg-interactive);border-radius:10px;">
<div style="width:48px;height:48px;border-radius:12px;background:var(--sa-blue-bg);display:flex;align-items:center;justify-content:center;">
<i data-lucide="credit-card" style="width:24px;height:24px;color:var(--sa-blue-light);"></i>
@@ -152,9 +153,46 @@
<div style="font-size:13px;color:var(--sa-text-tertiary);">Gói hiện tại</div>
</div>
</div>
<p style="margin-top:16px;font-size:13px;color:var(--sa-text-tertiary);">
Tính năng nâng/hạ gói sẽ được phát triển trong phiên bản tiếp theo.
</p>
@* Plan selector *@
<h4 style="font-size:14px;font-weight:700;color:var(--sa-text-primary);margin:24px 0 12px;">Đổi gói đăng ký</h4>
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:12px;">
@foreach (var plan in _planOptions)
{
var isActive = plan.Id == (_detail.SubscriptionPlanId);
<div @onclick="@(() => SelectPlan(plan.Id))"
style="padding:16px;border-radius:10px;cursor:pointer;border:2px solid @(isActive ? "var(--sa-blue-light)" : "var(--sa-border)");background:@(isActive ? "var(--sa-blue-bg)" : "var(--sa-bg-interactive)");transition:all 0.15s ease;">
<div style="font-size:15px;font-weight:700;color:var(--sa-text-primary);">@plan.Name</div>
<div style="font-size:13px;color:var(--sa-blue-light);font-weight:600;margin-top:4px;">
@(plan.Price > 0 ? $"{plan.Price:N0}đ/tháng" : plan.Name == "Enterprise" ? "Liên hệ" : "Miễn phí")
</div>
<div style="font-size:11px;color:var(--sa-text-tertiary);margin-top:6px;">
@plan.MaxShops cửa hàng • @plan.MaxStaff nhân viên
</div>
@if (isActive)
{
<span class="sa-badge sa-badge--success" style="margin-top:8px;">Hiện tại</span>
}
</div>
}
</div>
@if (_selectedPlanId != _detail.SubscriptionPlanId)
{
<div style="margin-top:16px;display:flex;align-items:center;gap:12px;">
<button class="sa-btn-primary" @onclick="SavePlanAsync">
<i data-lucide="save" style="width:14px;height:14px;"></i>
Lưu thay đổi → @(_planOptions.FirstOrDefault(p => p.Id == _selectedPlanId)?.Name)
</button>
<button class="sa-btn-outline" @onclick="@(() => _selectedPlanId = _detail.SubscriptionPlanId)">
Hủy
</button>
@if (!string.IsNullOrEmpty(_planMessage))
{
<span style="font-size:13px;color:@(_planSuccess ? "var(--sa-success)" : "var(--sa-danger)");">@_planMessage</span>
}
</div>
}
</div>
}
</div>
@@ -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()

View File

@@ -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<ShopSummaryDto>? 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

View File

@@ -158,6 +158,15 @@ public class SuperAdminController : ControllerBase
return result;
}
[HttpPut("merchants/{id}/plan")]
public async Task<IActionResult> 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<IActionResult> ReactivateMerchant(Guid id)
{
@@ -365,6 +374,22 @@ public class SuperAdminController : ControllerBase
}
}
private async Task<IActionResult> 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<IActionResult> ProxyPost(HttpClient client, string url)
{
try

View File

@@ -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;
/// <summary>
/// EN: Update a merchant's subscription plan (SuperAdmin only).
/// VI: Cập nhật gói đăng ký của merchant (chỉ SuperAdmin).
/// </summary>
public record UpdateMerchantPlanCommand(Guid MerchantId, int PlanId) : IRequest<UpdateMerchantPlanResult>;
public record UpdateMerchantPlanResult(bool Success, string PlanName, string? Error = null);
public class UpdateMerchantPlanCommandHandler : IRequestHandler<UpdateMerchantPlanCommand, UpdateMerchantPlanResult>
{
private readonly IMerchantRepository _merchantRepository;
private static readonly string[] PlanNames = ["Starter", "Growth", "Pro", "Enterprise"];
public UpdateMerchantPlanCommandHandler(IMerchantRepository merchantRepository)
{
_merchantRepository = merchantRepository;
}
public async Task<UpdateMerchantPlanResult> 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);
}
}

View File

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

View File

@@ -87,6 +87,7 @@ public class GetAllMerchantsQueryHandler : IRequestHandler<GetAllMerchantsQuery,
x.StatusName,
x.VerifName,
CreatedAt = EF.Property<DateTime>(x.m, "_createdAt"),
PlanId = EF.Property<int>(x.m, "_subscriptionPlanId"),
x.m.VerifiedAt
})
.ToListAsync(cancellationToken);
@@ -100,9 +101,11 @@ public class GetAllMerchantsQueryHandler : IRequestHandler<GetAllMerchantsQuery,
.Select(g => 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();

View File

@@ -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;
/// <summary>
/// EN: Query to get detailed merchant information (Admin only).
/// VI: Query để lấy thông tin chi tiết merchant (chỉ Admin).
/// </summary>
public record GetMerchantDetailQuery(Guid MerchantId) : IRequest<AdminMerchantDetailDto>;
/// <summary>
/// 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.
/// </summary>
public class GetMerchantDetailQueryHandler : IRequestHandler<GetMerchantDetailQuery, AdminMerchantDetailDto>
{
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<GetMerchantDetailQu
GetMerchantDetailQuery request,
CancellationToken cancellationToken)
{
var merchant = await _context.Merchants
// EN: Fetch raw data with joins (avoid Ignored navigation property NullRef)
// VI: Lấy raw data với joins (tránh NullRef từ navigation property bị Ignore)
var raw = await _context.Merchants
.AsNoTracking()
.Where(m => m.Id == request.MerchantId && !EF.Property<bool>(m, "_isDeleted"))
.Join(_context.Set<MerchantType>(), m => m.TypeId, t => t.Id, (m, t) => new { m, TypeName = t.Name })
.Join(_context.Set<MerchantStatus>(), x => x.m.StatusId, s => s.Id, (x, s) => new { x.m, x.TypeName, StatusName = s.Name })
.Join(_context.Set<VerificationStatus>(), 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<Guid>(x.m, "_userId"),
BusinessName = EF.Property<string>(x.m, "_businessName"),
x.TypeName,
x.StatusName,
x.VerifName,
PlanId = EF.Property<int>(x.m, "_subscriptionPlanId"),
CreatedAt = EF.Property<DateTime>(x.m, "_createdAt"),
UpdatedAt = EF.Property<DateTime?>(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<bool>(s, "_isDeleted"), cancellationToken);
.Where(s => EF.Property<Guid>(s, "_merchantId") == request.MerchantId
&& !EF.Property<bool>(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);
}
}

View File

@@ -214,6 +214,26 @@ public class AdminMerchantsController : ControllerBase
}
}
/// <summary>
/// EN: Update a merchant's subscription plan.
/// VI: Cập nhật gói đăng ký của merchant.
/// </summary>
[HttpPut("{merchantId:guid}/plan")]
[ProducesResponseType(typeof(UpdateMerchantPlanResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> 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.
/// </summary>
public record AdminActionRequest(string Reason);
public record UpdatePlanRequest(int PlanId);
#endregion