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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user