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