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

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