feat: implement merchant subscription management and enhanced user account/security features with a new BFF layer.

This commit is contained in:
Ho Ngoc Hai
2026-03-06 12:34:53 +07:00
parent 193b9edd23
commit 2e1bb65bd3
19 changed files with 1620 additions and 193 deletions

View File

@@ -3,10 +3,12 @@
@inherits AdminBase
@inject WebClientTpos.Client.Services.AuthStateService AuthState
@inject WebClientTpos.Client.Services.PosDataService DataService
@inject IJSRuntime JS
@inject NavigationManager Nav
@*
EN: Admin settings page — account info, shop overview, general settings.
VI: Trang cài đặt admin — thông tin tài khoản, tổng quan cửa hàng, cài đặt chung.
EN: Admin settings page — account, security, subscription, notifications, system overview.
VI: Trang cài đặt admin — tài khoản, bảo mật, gói dịch vụ, thông báo, tổng quan hệ thống.
*@
<PageTitle>Cài đặt — GoodGo Admin</PageTitle>
@@ -21,26 +23,455 @@
@* ═══ TABS ═══ *@
<div class="admin-tabs">
<button class="admin-tab @(_tab == "general" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "general")">
<i data-lucide="settings" style="width:14px;height:14px;"></i> Tổng quan
</button>
<button class="admin-tab @(_tab == "account" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "account")">
<button class="admin-tab @(_tab == "account" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("account"))">
<i data-lucide="user" style="width:14px;height:14px;"></i> Tài khoản
</button>
<button class="admin-tab @(_tab == "notifications" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "notifications")">
<button class="admin-tab @(_tab == "security" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("security"))">
<i data-lucide="lock" style="width:14px;height:14px;"></i> Bảo mật
</button>
<button class="admin-tab @(_tab == "subscription" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("subscription"))">
<i data-lucide="crown" style="width:14px;height:14px;"></i> Gói dịch vụ
</button>
<button class="admin-tab @(_tab == "notifications" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("notifications"))">
<i data-lucide="bell" style="width:14px;height:14px;"></i> Thông báo
</button>
<button class="admin-tab @(_tab == "security" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "security")">
<i data-lucide="lock" style="width:14px;height:14px;"></i> Bảo mật
<button class="admin-tab @(_tab == "general" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("general"))">
<i data-lucide="settings" style="width:14px;height:14px;"></i> Hệ thống
</button>
</div>
@* ═══ CONTENT ═══ *@
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
@if (_tab == "general")
@* ════════════════════════════════════════════════════════════════════
TAB: TÀI KHOẢN — Profile + Merchant Info
════════════════════════════════════════════════════════════════════ *@
@if (_tab == "account")
{
@* ── Profile Info ── *@
<div class="admin-panel">
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 class="admin-panel__title">
<i data-lucide="user" style="color:var(--admin-orange-primary);"></i>
Thông tin cá nhân
</h3>
@if (!_editingProfile)
{
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 14px;" @onclick="StartEditProfile">
<i data-lucide="pencil" style="width:12px;height:12px;"></i> Chỉnh sửa
</button>
}
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
@if (_loadingAccount)
{
<div style="text-align:center;padding:24px;"><MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" /></div>
}
else
{
<div style="display:flex;align-items:center;gap:20px;padding-bottom:16px;border-bottom:1px solid var(--admin-border-subtle);">
<div style="width:72px;height:72px;border-radius:50%;background:linear-gradient(135deg,var(--admin-orange-primary),#F59E0B);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:white;">
@(string.IsNullOrEmpty(_firstName) ? (AuthState.UserEmail?[..1].ToUpper() ?? "U") : _firstName[..1].ToUpper())
</div>
<div>
<div style="font-size:18px;font-weight:700;color:var(--admin-text-primary);">
@(string.IsNullOrEmpty(_firstName) && string.IsNullOrEmpty(_lastName)
? (AuthState.UserEmail ?? "—")
: $"{_firstName} {_lastName}".Trim())
</div>
<div style="font-size:13px;color:var(--admin-text-tertiary);">@(AuthState.UserEmail ?? "—")</div>
<div class="admin-status-badge admin-status-badge--online" style="width:fit-content;margin-top:4px;">
<span class="admin-status-badge__dot"></span> Chủ doanh nghiệp
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Họ</label>
<input class="admin-form-input" @bind="_lastName" readonly="@(!_editingProfile)" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Tên</label>
<input class="admin-form-input" @bind="_firstName" readonly="@(!_editingProfile)" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Email</label>
<input class="admin-form-input" value="@(AuthState.UserEmail ?? "—")" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Múi giờ</label>
<input class="admin-form-input" @bind="_timezone" readonly="@(!_editingProfile)" />
</div>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Giới thiệu</label>
<textarea class="admin-form-input" rows="2" @bind="_bio" readonly="@(!_editingProfile)" style="resize:vertical;"></textarea>
</div>
@if (_editingProfile)
{
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 14px;" @onclick="CancelEditProfile">Hủy</button>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="SaveProfile" disabled="@_saving">
@if (_saving) { <MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Surface" Style="width:14px;height:14px;" /> }
else { <span>Lưu thay đổi</span> }
</button>
</div>
}
@if (!string.IsNullOrEmpty(_profileMsg))
{
<div style="padding:8px 12px;border-radius:8px;font-size:12px;background:@(_profileMsgOk ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)");color:@(_profileMsgOk ? "#22C55E" : "#EF4444");">
@_profileMsg
</div>
}
}
</div>
</div>
@* ── Merchant Info ── *@
<div class="admin-panel">
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 class="admin-panel__title">
<i data-lucide="building-2" style="color:#8B5CF6;"></i>
Thông tin doanh nghiệp
</h3>
@if (!_editingMerchant)
{
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 14px;" @onclick="() => _editingMerchant = true">
<i data-lucide="pencil" style="width:12px;height:12px;"></i> Chỉnh sửa
</button>
}
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Tên doanh nghiệp</label>
<input class="admin-form-input" @bind="_businessName" readonly="@(!_editingMerchant)" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Loại hình</label>
<input class="admin-form-input" value="@_merchantType" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Mã số thuế</label>
<input class="admin-form-input" @bind="_taxId" readonly="@(!_editingMerchant)" placeholder="Nhập MST..." />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Trạng thái xác minh</label>
<div class="admin-status-badge @(_verificationStatus == "Verified" ? "admin-status-badge--online" : "admin-status-badge--warning")" style="width:fit-content;">
<span class="admin-status-badge__dot"></span>
@(_verificationStatus == "Verified" ? "Đã xác minh" : _verificationStatus == "Pending" ? "Đang chờ" : "Chưa xác minh")
</div>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Số cửa hàng</label>
<input class="admin-form-input" value="@_shopCount" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Ngày đăng ký</label>
<input class="admin-form-input" value="@_merchantCreatedAt" readonly />
</div>
</div>
@if (_editingMerchant)
{
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 14px;" @onclick="() => _editingMerchant = false">Hủy</button>
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="SaveMerchant" disabled="@_saving">Lưu</button>
</div>
}
@if (!string.IsNullOrEmpty(_merchantMsg))
{
<div style="padding:8px 12px;border-radius:8px;font-size:12px;background:@(_merchantMsgOk ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)");color:@(_merchantMsgOk ? "#22C55E" : "#EF4444");">
@_merchantMsg
</div>
}
</div>
</div>
}
@* ════════════════════════════════════════════════════════════════════
TAB: BẢO MẬT — Password, 2FA, Sessions
════════════════════════════════════════════════════════════════════ *@
else if (_tab == "security")
{
@* ── Change Password ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="key" style="color:var(--admin-orange-primary);"></i>
Đổi mật khẩu
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Mật khẩu hiện tại</label>
<input class="admin-form-input" type="password" @bind="_currentPassword" placeholder="••••••••" />
</div>
<div></div>
<div class="admin-form-group">
<label class="admin-form-label">Mật khẩu mới</label>
<input class="admin-form-input" type="password" @bind="_newPassword" placeholder="Tối thiểu 8 ký tự" />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Xác nhận mật khẩu mới</label>
<input class="admin-form-input" type="password" @bind="_confirmPassword" placeholder="Nhập lại mật khẩu mới" />
</div>
</div>
<div style="display:flex;justify-content:flex-end;">
<button class="admin-btn-primary" style="font-size:12px;padding:8px 20px;" @onclick="ChangePassword" disabled="@_saving">
Đổi mật khẩu
</button>
</div>
@if (!string.IsNullOrEmpty(_pwMsg))
{
<div style="padding:8px 12px;border-radius:8px;font-size:12px;background:@(_pwMsgOk ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)");color:@(_pwMsgOk ? "#22C55E" : "#EF4444");">@_pwMsg</div>
}
</div>
</div>
@* ── 2FA ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="shield-check" style="color:#22C55E;"></i>
Xác thực 2 bước (2FA)
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div>
<span style="font-size:13px;font-weight:600;">Trạng thái 2FA</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Bảo mật tài khoản bằng mã OTP từ ứng dụng Authenticator</p>
</div>
@if (_twoFactorEnabled)
{
<div style="display:flex;align-items:center;gap:8px;">
<div class="admin-status-badge admin-status-badge--online"><span class="admin-status-badge__dot"></span> Đã bật</div>
<button class="admin-btn-secondary" style="font-size:11px;padding:4px 10px;color:#EF4444;" @onclick="StartDisable2FA">Tắt 2FA</button>
</div>
}
else
{
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="StartEnable2FA" disabled="@_saving">Bật 2FA</button>
}
</div>
@if (_show2FASetup)
{
<div style="padding:16px;background:var(--admin-bg-interactive);border-radius:10px;display:flex;flex-direction:column;gap:12px;">
<p style="font-size:13px;color:var(--admin-text-secondary);margin:0;">Quét mã QR bằng Google Authenticator hoặc Authy:</p>
@if (!string.IsNullOrEmpty(_qrCodeBase64))
{
<div style="text-align:center;">
<img src="@_qrCodeBase64" alt="QR Code" style="width:180px;height:180px;border-radius:8px;background:white;padding:8px;" />
</div>
}
@if (!string.IsNullOrEmpty(_manualKey))
{
<div style="font-size:11px;color:var(--admin-text-tertiary);text-align:center;">
Mã thủ công: <code style="background:var(--admin-bg-elevated);padding:2px 6px;border-radius:4px;">@_manualKey</code>
</div>
}
<div class="admin-form-group" style="max-width:300px;margin:0 auto;">
<label class="admin-form-label">Nhập mã 6 số từ app:</label>
<input class="admin-form-input" @bind="_totpCode" placeholder="000000" maxlength="6" style="text-align:center;font-size:20px;letter-spacing:8px;" />
</div>
<div style="display:flex;gap:8px;justify-content:center;">
<button class="admin-btn-secondary" style="font-size:12px;" @onclick="() => _show2FASetup = false">Hủy</button>
<button class="admin-btn-primary" style="font-size:12px;" @onclick="Verify2FA" disabled="@_saving">Xác nhận</button>
</div>
</div>
}
@if (_showDisable2FA)
{
<div style="padding:16px;background:var(--admin-bg-interactive);border-radius:10px;display:flex;flex-direction:column;gap:12px;">
<p style="font-size:13px;color:var(--admin-text-secondary);margin:0;">Nhập mã OTP hiện tại để tắt 2FA:</p>
<div class="admin-form-group" style="max-width:300px;margin:0 auto;">
<input class="admin-form-input" @bind="_totpCode" placeholder="000000" maxlength="6" style="text-align:center;font-size:20px;letter-spacing:8px;" />
</div>
<div style="display:flex;gap:8px;justify-content:center;">
<button class="admin-btn-secondary" style="font-size:12px;" @onclick="() => _showDisable2FA = false">Hủy</button>
<button class="admin-btn-primary" style="font-size:12px;background:#EF4444;" @onclick="Disable2FA" disabled="@_saving">Tắt 2FA</button>
</div>
</div>
}
@if (!string.IsNullOrEmpty(_tfaMsg))
{
<div style="padding:8px 12px;border-radius:8px;font-size:12px;background:@(_tfaMsgOk ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)");color:@(_tfaMsgOk ? "#22C55E" : "#EF4444");">@_tfaMsg</div>
}
</div>
</div>
@* ── Linked Accounts ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="link" style="color:#3B82F6;"></i>
Tài khoản liên kết
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:28px;height:28px;border-radius:6px;background:#4285F4;display:flex;align-items:center;justify-content:center;">
<span style="color:white;font-weight:700;font-size:14px;">G</span>
</div>
<span style="font-size:13px;font-weight:500;">Google</span>
</div>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@(_linkedGoogle ? "Đã liên kết" : "Chưa liên kết")</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:28px;height:28px;border-radius:6px;background:#1877F2;display:flex;align-items:center;justify-content:center;">
<span style="color:white;font-weight:700;font-size:14px;">f</span>
</div>
<span style="font-size:13px;font-weight:500;">Facebook</span>
</div>
<span style="font-size:12px;color:var(--admin-text-tertiary);">@(_linkedFacebook ? "Đã liên kết" : "Chưa liên kết")</span>
</div>
</div>
</div>
@* ── Danger Zone ── *@
<div class="admin-panel" style="border:1px solid rgba(239,68,68,0.3);">
<div class="admin-panel__header">
<h3 class="admin-panel__title" style="color:#EF4444;">
<i data-lucide="alert-triangle" style="color:#EF4444;"></i>
Vùng nguy hiểm
</h3>
</div>
<div class="admin-panel__body">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<span style="font-size:13px;font-weight:600;">Xóa tài khoản</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Xóa vĩnh viễn tài khoản và toàn bộ dữ liệu</p>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;color:#EF4444;border-color:rgba(239,68,68,0.3);">Xóa tài khoản</button>
</div>
</div>
</div>
}
@* ════════════════════════════════════════════════════════════════════
TAB: GÓI DỊCH VỤ — Subscription plans
════════════════════════════════════════════════════════════════════ *@
else if (_tab == "subscription")
{
@* ── Current Plan ── *@
<div class="admin-panel" style="border:2px solid var(--admin-orange-primary);">
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 class="admin-panel__title">
<i data-lucide="crown" style="color:var(--admin-orange-primary);"></i>
Gói hiện tại
</h3>
<div style="padding:4px 12px;border-radius:20px;background:var(--admin-orange-primary);color:white;font-size:11px;font-weight:700;">
@_currentPlanName
</div>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;">
<div style="padding:12px;background:var(--admin-bg-interactive);border-radius:10px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:var(--admin-text-primary);">@_usageShops/@(FormatLimit(_limitShops))</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">Cửa hàng</div>
</div>
<div style="padding:12px;background:var(--admin-bg-interactive);border-radius:10px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:var(--admin-text-primary);">@_usageStaff/@(FormatLimit(_limitStaff))</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">Nhân viên/shop</div>
</div>
<div style="padding:12px;background:var(--admin-bg-interactive);border-radius:10px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:var(--admin-text-primary);">@_usageVerticals/@(FormatLimit(_limitVerticals))</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">Ngành nghề</div>
</div>
<div style="padding:12px;background:var(--admin-bg-interactive);border-radius:10px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:var(--admin-text-primary);">@_usageProducts/@(FormatLimit(_limitProducts))</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">Sản phẩm</div>
</div>
</div>
</div>
</div>
@* ── Plan Comparison ── *@
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;">
@foreach (var plan in _plans)
{
var isCurrent = plan.Id == _currentPlanId;
<div class="admin-panel" style="@(isCurrent ? "border:2px solid var(--admin-orange-primary);" : "") display:flex;flex-direction:column;">
<div class="admin-panel__header" style="text-align:center;">
@if (plan.Id == 2)
{
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--admin-orange-primary);margin-bottom:4px;">Phổ biến nhất</div>
}
<h3 style="font-size:16px;font-weight:700;margin:0;">@plan.Name</h3>
<div style="font-size:24px;font-weight:800;color:var(--admin-orange-primary);margin:8px 0;">
@(plan.Price == 0 ? "Miễn phí" : $"{plan.Price:N0}₫")
</div>
<div style="font-size:11px;color:var(--admin-text-tertiary);">@(plan.Price > 0 ? "/tháng" : "mãi mãi")</div>
</div>
<div class="admin-panel__body" style="flex:1;display:flex;flex-direction:column;gap:8px;font-size:12px;">
@foreach (var f in plan.Features)
{
<div style="display:flex;align-items:center;gap:6px;">
<i data-lucide="@(f.Included ? "check" : "x")" style="width:14px;height:14px;color:@(f.Included ? "#22C55E" : "var(--admin-text-tertiary)");flex-shrink:0;"></i>
<span style="color:@(f.Included ? "var(--admin-text-primary)" : "var(--admin-text-tertiary)");">@f.Label</span>
</div>
}
<div style="margin-top:auto;padding-top:12px;">
@if (isCurrent)
{
<button class="admin-btn-secondary" style="width:100%;justify-content:center;font-size:12px;" disabled>Gói hiện tại</button>
}
else if (plan.Id > _currentPlanId)
{
<button class="admin-btn-primary" style="width:100%;justify-content:center;font-size:12px;" @onclick="() => UpgradePlan(plan.Id)">
Nâng cấp
</button>
}
</div>
</div>
</div>
}
</div>
}
@* ════════════════════════════════════════════════════════════════════
TAB: THÔNG BÁO
════════════════════════════════════════════════════════════════════ *@
else if (_tab == "notifications")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="bell" style="color:#F59E0B;"></i>
Cài đặt thông báo
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@foreach (var notif in _notifSettings)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div style="display:flex;flex-direction:column;">
<span style="font-size:13px;font-weight:600;">@notif.Label</span>
<span style="font-size:11px;color:var(--admin-text-tertiary);">@notif.Desc</span>
</div>
<MudSwitch T="bool" Value="notif.Enabled" Color="Color.Primary" />
</div>
}
</div>
</div>
}
@* ════════════════════════════════════════════════════════════════════
TAB: HỆ THỐNG
════════════════════════════════════════════════════════════════════ *@
else if (_tab == "general")
{
@* ── System Info ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
@@ -68,7 +499,6 @@
</div>
</div>
@* ── Service Health ── *@
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
@@ -91,150 +521,72 @@
</div>
</div>
}
else if (_tab == "account")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="user" style="color:var(--admin-orange-primary);"></i>
Thông tin tài khoản
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="admin-form-group">
<label class="admin-form-label">Email</label>
<input class="admin-form-input" value="@(AuthState.UserEmail ?? "—")" readonly />
</div>
<div class="admin-form-group">
<label class="admin-form-label">Vai trò</label>
<input class="admin-form-input" value="Owner" readonly />
</div>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Trạng thái</label>
<div class="admin-status-badge admin-status-badge--online" style="width:fit-content;">
<span class="admin-status-badge__dot"></span>
Đã xác thực
</div>
</div>
</div>
</div>
}
else if (_tab == "notifications")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="bell" style="color:#F59E0B;"></i>
Cài đặt thông báo
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
@foreach (var notif in _notifSettings)
{
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div style="display:flex;flex-direction:column;">
<span style="font-size:13px;font-weight:600;">@notif.Label</span>
<span style="font-size:11px;color:var(--admin-text-tertiary);">@notif.Desc</span>
</div>
<MudSwitch T="bool" Value="notif.Enabled" Color="Color.Primary" />
</div>
}
</div>
</div>
}
else if (_tab == "security")
{
<div class="admin-panel">
<div class="admin-panel__header">
<h3 class="admin-panel__title">
<i data-lucide="lock" style="color:#EF4444;"></i>
Bảo mật
</h3>
</div>
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div>
<span style="font-size:13px;font-weight:600;">Đổi mật khẩu</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Thay đổi mật khẩu đăng nhập</p>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;">Đổi mật khẩu</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div>
<span style="font-size:13px;font-weight:600;">Xác thực 2 bước (2FA)</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Bảo mật tài khoản bằng mã OTP</p>
</div>
<MudSwitch T="bool" Value="false" Color="Color.Primary" />
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
<div>
<span style="font-size:13px;font-weight:600;">Phiên đăng nhập</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Quản lý các phiên đang hoạt động</p>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;">Xem phiên</button>
</div>
</div>
</div>
@* ─── DANGER ZONE ─── *@
<div class="admin-panel" style="border:1px solid rgba(239,68,68,0.3);">
<div class="admin-panel__header">
<h3 class="admin-panel__title" style="color:#EF4444;">
<i data-lucide="alert-triangle" style="color:#EF4444;"></i>
Vùng nguy hiểm
</h3>
</div>
<div class="admin-panel__body">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<span style="font-size:13px;font-weight:600;">Xóa tài khoản</span>
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Xóa vĩnh viễn tài khoản và toàn bộ dữ liệu</p>
</div>
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;color:#EF4444;border-color:rgba(239,68,68,0.3);">Xóa tài khoản</button>
</div>
</div>
</div>
}
</div>
@code {
private string _tab = "general";
private string _tab = "account";
private int _shopCount = 0;
private bool _loadingAccount = true;
private bool _saving = false;
// ═══ Account/Profile state ═══
private string _firstName = "";
private string _lastName = "";
private string _bio = "";
private string _timezone = "Asia/Ho_Chi_Minh";
private bool _editingProfile = false;
private string? _profileMsg;
private bool _profileMsgOk;
// ═══ Merchant state ═══
private string _businessName = "";
private string _merchantType = "Individual";
private string _taxId = "";
private string _verificationStatus = "Unverified";
private string _merchantCreatedAt = "—";
private bool _editingMerchant = false;
private string? _merchantMsg;
private bool _merchantMsgOk;
// ═══ Security state ═══
private string _currentPassword = "";
private string _newPassword = "";
private string _confirmPassword = "";
private string? _pwMsg;
private bool _pwMsgOk;
private bool _twoFactorEnabled = false;
private bool _show2FASetup = false;
private bool _showDisable2FA = false;
private string? _qrCodeBase64;
private string? _manualKey;
private string _totpCode = "";
private string? _tfaMsg;
private bool _tfaMsgOk;
private bool _linkedGoogle = false;
private bool _linkedFacebook = false;
// ═══ Subscription state ═══
private int _currentPlanId = 0;
private string _currentPlanName = "Starter";
private int _usageShops = 0, _limitShops = 1;
private int _usageStaff = 0, _limitStaff = 3;
private int _usageVerticals = 0, _limitVerticals = 1;
private int _usageProducts = 0, _limitProducts = 50;
/// <summary>
/// EN: Format limit value, showing ∞ for unlimited (>= 9999).
/// VI: Định dạng giới hạn, hiển thị ∞ cho không giới hạn (>= 9999).
/// </summary>
private string FormatLimit(int limit) => limit >= 9999 ? "∞" : limit.ToString();
// EN: Service list matching actual deployed Docker containers
// VI: Danh sách dịch vụ khớp với Docker containers thực tế
private readonly (string Name, string Icon)[] _services = new[]
{
("IAM Service", "shield"),
("Merchant Service", "store"),
("Catalog Service", "package"),
("Order Service", "shopping-bag"),
("Inventory Service", "warehouse"),
("Wallet Service", "wallet"),
("Membership Service", "users"),
("Promotion Service", "tag"),
("Booking Service", "calendar"),
("F&B Engine", "utensils"),
("Storage Service", "hard-drive"),
("IAM Service", "shield"), ("Merchant Service", "store"), ("Catalog Service", "package"),
("Order Service", "shopping-bag"), ("Inventory Service", "warehouse"), ("Wallet Service", "wallet"),
("Membership Service", "users"), ("Promotion Service", "tag"), ("Booking Service", "calendar"),
("F&B Engine", "utensils"), ("Storage Service", "hard-drive"),
};
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
try
{
var shops = await DataService.GetShopsAsync();
_shopCount = shops.Count;
}
catch (Exception ex)
{
_shopCount = 0;
Console.Error.WriteLine($"[AdminSettings] Error loading shop count: {ex.Message}");
}
}
private record NotifSetting(string Label, string Desc, bool Enabled);
private readonly NotifSetting[] _notifSettings = new[]
{
@@ -245,4 +597,270 @@
new NotifSetting("Doanh thu bất thường", "Cảnh báo doanh thu", true),
new NotifSetting("Email hàng tuần", "Báo cáo doanh thu hàng tuần", true),
};
// ═══ Subscription Plans (static) ═══
private record PlanFeature(string Label, bool Included);
private record PlanInfo(int Id, string Name, decimal Price, PlanFeature[] Features);
private readonly PlanInfo[] _plans = new[]
{
new PlanInfo(0, "Starter", 0, new[]
{
new PlanFeature("1 cửa hàng", true), new PlanFeature("3 nhân viên/shop", true),
new PlanFeature("50 sản phẩm", true), new PlanFeature("POS cơ bản", true),
new PlanFeature("1 ngành nghề", true), new PlanFeature("Báo cáo cơ bản", true),
new PlanFeature("Kitchen Display", false), new PlanFeature("Booking", false),
new PlanFeature("Marketing CRM", false),
}),
new PlanInfo(1, "Growth", 299000, new[]
{
new PlanFeature("3 cửa hàng", true), new PlanFeature("10 nhân viên/shop", true),
new PlanFeature("500 sản phẩm", true), new PlanFeature("POS đa ngành", true),
new PlanFeature("2 ngành nghề", true), new PlanFeature("Báo cáo tiêu chuẩn", true),
new PlanFeature("Kitchen Display", true), new PlanFeature("Booking", true),
new PlanFeature("Marketing CRM", false),
}),
new PlanInfo(2, "Pro", 799000, new[]
{
new PlanFeature("10 cửa hàng", true), new PlanFeature("50 nhân viên/shop", true),
new PlanFeature("Sản phẩm không giới hạn", true), new PlanFeature("POS đa ngành", true),
new PlanFeature("4 ngành nghề", true), new PlanFeature("Báo cáo nâng cao", true),
new PlanFeature("Kitchen Display", true), new PlanFeature("Booking", true),
new PlanFeature("Marketing CRM + Chatbot", true),
}),
new PlanInfo(3, "Enterprise", 0, new[]
{
new PlanFeature("Không giới hạn cửa hàng", true), new PlanFeature("Không giới hạn nhân viên", true),
new PlanFeature("Không giới hạn sản phẩm", true), new PlanFeature("Tất cả ngành nghề", true),
new PlanFeature("White-label", true), new PlanFeature("API Access", true),
new PlanFeature("SLA & Support ưu tiên", true), new PlanFeature("Báo cáo tùy chỉnh", true),
new PlanFeature("Mọi tính năng", true),
}),
};
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadAccountData();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
}
private async Task LoadAccountData()
{
_loadingAccount = true;
try
{
var shops = await DataService.GetShopsAsync();
_shopCount = shops.Count;
_usageShops = shops.Count;
}
catch { _shopCount = 0; }
// EN: Load user profile from BFF
// VI: Tải profile user từ BFF
try
{
var me = await DataService.GetFromApiAsync<AccountMeDto>("api/bff/account/me");
if (me != null)
{
_firstName = me.FirstName ?? "";
_lastName = me.LastName ?? "";
}
}
catch { }
try
{
var profile = await DataService.GetFromApiAsync<ProfileDto>("api/bff/account/profile");
if (profile != null)
{
_bio = profile.Bio ?? "";
_timezone = profile.Timezone ?? "Asia/Ho_Chi_Minh";
}
}
catch { }
// EN: Load merchant profile
// VI: Tải thông tin merchant
try
{
var merchant = await DataService.GetFromApiAsync<MerchantDto>("api/bff/account/merchant");
if (merchant != null)
{
_businessName = merchant.BusinessName ?? "";
_merchantType = merchant.Type ?? "Individual";
_taxId = merchant.BusinessInfo?.TaxId ?? "";
_verificationStatus = merchant.VerificationStatus ?? "Unverified";
_merchantCreatedAt = merchant.CreatedAt?.ToString("dd/MM/yyyy") ?? "—";
// EN: Set subscription plan from merchant data
// VI: Thiết lập gói đăng ký từ dữ liệu merchant
_currentPlanId = merchant.SubscriptionPlanId;
_currentPlanName = _currentPlanId >= 0 && _currentPlanId < _plans.Length
? _plans[_currentPlanId].Name
: "Starter";
(_limitShops, _limitStaff, _limitVerticals, _limitProducts) = _currentPlanId switch
{
1 => (3, 10, 2, 500),
2 => (10, 50, 4, 9999),
3 => (9999, 9999, 9999, 9999),
_ => (1, 3, 1, 50),
};
}
}
catch { }
// EN: Load linked accounts
// VI: Tải tài khoản liên kết
try
{
var linked = await DataService.GetFromApiAsync<LinkedAccountsDto>("api/bff/account/linked-accounts");
if (linked?.LinkedProviders != null)
{
_linkedGoogle = linked.LinkedProviders.Any(p => p.Provider == "Google");
_linkedFacebook = linked.LinkedProviders.Any(p => p.Provider == "Facebook");
}
}
catch { }
_loadingAccount = false;
}
private void SwitchTab(string tab) { _tab = tab; _profileMsg = null; _merchantMsg = null; _pwMsg = null; _tfaMsg = null; }
// ═══ Profile Edit ═══
private string _origFirstName = "", _origLastName = "", _origBio = "", _origTimezone = "";
private void StartEditProfile()
{
_origFirstName = _firstName; _origLastName = _lastName; _origBio = _bio; _origTimezone = _timezone;
_editingProfile = true; _profileMsg = null;
}
private void CancelEditProfile()
{
_firstName = _origFirstName; _lastName = _origLastName; _bio = _origBio; _timezone = _origTimezone;
_editingProfile = false; _profileMsg = null;
}
private async Task SaveProfile()
{
_saving = true; _profileMsg = null;
try
{
var ok1 = await DataService.PutAsync("api/bff/account/me", new { firstName = _firstName, lastName = _lastName });
var ok2 = await DataService.PutAsync("api/bff/account/profile", new { bio = _bio, timezone = _timezone });
_profileMsg = ok1 || ok2 ? "Cập nhật thành công!" : "Không thể cập nhật";
_profileMsgOk = ok1 || ok2;
if (_profileMsgOk) _editingProfile = false;
}
catch (Exception ex) { _profileMsg = ex.Message; _profileMsgOk = false; }
finally { _saving = false; }
}
private async Task SaveMerchant()
{
_saving = true; _merchantMsg = null;
try
{
var ok = await DataService.PutAsync("api/bff/account/merchant", new { businessName = _businessName, taxId = _taxId });
_merchantMsg = ok ? "Cập nhật doanh nghiệp thành công!" : "Không thể cập nhật";
_merchantMsgOk = ok;
if (ok) _editingMerchant = false;
}
catch (Exception ex) { _merchantMsg = ex.Message; _merchantMsgOk = false; }
finally { _saving = false; }
}
// ═══ Change Password ═══
private async Task ChangePassword()
{
_pwMsg = null;
if (string.IsNullOrEmpty(_currentPassword) || string.IsNullOrEmpty(_newPassword))
{ _pwMsg = "Vui lòng nhập đầy đủ"; _pwMsgOk = false; return; }
if (_newPassword != _confirmPassword)
{ _pwMsg = "Mật khẩu xác nhận không khớp"; _pwMsgOk = false; return; }
if (_newPassword.Length < 8)
{ _pwMsg = "Mật khẩu mới phải ít nhất 8 ký tự"; _pwMsgOk = false; return; }
_saving = true;
try
{
var ok = await DataService.PostAsync("api/bff/account/change-password",
new { currentPassword = _currentPassword, newPassword = _newPassword });
_pwMsg = ok ? "Đổi mật khẩu thành công!" : "Mật khẩu hiện tại không đúng";
_pwMsgOk = ok;
if (ok) { _currentPassword = ""; _newPassword = ""; _confirmPassword = ""; }
}
catch (Exception ex) { _pwMsg = ex.Message; _pwMsgOk = false; }
finally { _saving = false; }
}
// ═══ 2FA ═══
private async Task StartEnable2FA()
{
_saving = true; _tfaMsg = null;
try
{
var result = await DataService.PostAndGetAsync<TwoFASetupDto>("api/bff/account/2fa/enable", new { });
if (result != null)
{
_qrCodeBase64 = result.QrCodeBase64;
_manualKey = result.ManualEntryKey;
_show2FASetup = true;
}
else { _tfaMsg = "Không thể khởi tạo 2FA"; _tfaMsgOk = false; }
}
catch (Exception ex) { _tfaMsg = ex.Message; _tfaMsgOk = false; }
finally { _saving = false; }
}
private async Task Verify2FA()
{
if (string.IsNullOrEmpty(_totpCode) || _totpCode.Length != 6)
{ _tfaMsg = "Vui lòng nhập mã 6 số"; _tfaMsgOk = false; return; }
_saving = true;
try
{
var ok = await DataService.PostAsync("api/bff/account/2fa/verify", new { code = _totpCode });
if (ok) { _twoFactorEnabled = true; _show2FASetup = false; _tfaMsg = "2FA đã được bật thành công!"; _tfaMsgOk = true; }
else { _tfaMsg = "Mã OTP không hợp lệ"; _tfaMsgOk = false; }
}
catch (Exception ex) { _tfaMsg = ex.Message; _tfaMsgOk = false; }
finally { _saving = false; _totpCode = ""; }
}
private void StartDisable2FA() { _showDisable2FA = true; _tfaMsg = null; _totpCode = ""; }
private async Task Disable2FA()
{
if (string.IsNullOrEmpty(_totpCode))
{ _tfaMsg = "Vui lòng nhập mã OTP"; _tfaMsgOk = false; return; }
_saving = true;
try
{
var ok = await DataService.PostAsync("api/bff/account/2fa/disable", new { code = _totpCode });
if (ok) { _twoFactorEnabled = false; _showDisable2FA = false; _tfaMsg = "2FA đã tắt"; _tfaMsgOk = true; }
else { _tfaMsg = "Mã OTP không hợp lệ"; _tfaMsgOk = false; }
}
catch (Exception ex) { _tfaMsg = ex.Message; _tfaMsgOk = false; }
finally { _saving = false; _totpCode = ""; }
}
// ═══ Subscription ═══
private void UpgradePlan(int planId)
{
// EN: In production, this would redirect to payment flow
// VI: Trong production, sẽ redirect đến luồng thanh toán
}
// ═══ DTOs ═══
private record AccountMeDto { public string? Id { get; init; } public string? Email { get; init; } public string? FirstName { get; init; } public string? LastName { get; init; } public string? FullName { get; init; } public string[]? Roles { get; init; } }
private record ProfileDto { public string? Bio { get; init; } public string? AvatarUrl { get; init; } public string? Timezone { get; init; } public string? Locale { get; init; } }
private record MerchantDto { public string? BusinessName { get; init; } public string? Type { get; init; } public string? VerificationStatus { get; init; } public BusinessInfoDto? BusinessInfo { get; init; } public DateTime? CreatedAt { get; init; } public int SubscriptionPlanId { get; init; } }
private record BusinessInfoDto { public string? TaxId { get; init; } }
private record LinkedAccountsDto { public List<LinkedProviderDto>? LinkedProviders { get; init; } }
private record LinkedProviderDto { public string? Provider { get; init; } }
private record TwoFASetupDto { public string? QrCodeBase64 { get; init; } public string? ManualEntryKey { get; init; } public string[]? RecoveryCodes { get; init; } }
}

View File

@@ -76,6 +76,45 @@ public class PosDataService
return resp.IsSuccessStatusCode;
}
/// <summary>
/// EN: Generic GET helper — deserializes single object from BFF (handles ApiResponse envelope).
/// VI: Helper GET chung — deserialize object đơn từ BFF (xử lý ApiResponse envelope).
/// </summary>
public Task<T?> GetFromApiAsync<T>(string url) where T : class
=> GetObjectFromApiAsync<T>(url);
/// <summary>
/// EN: Generic PUT helper — sends JSON body to BFF endpoint.
/// VI: Helper PUT chung — gửi JSON body đến BFF endpoint.
/// </summary>
public async Task<bool> PutAsync(string url, object body)
{
AttachToken();
var resp = await _http.PutAsJsonAsync(url, body, _writeOptions);
return resp.IsSuccessStatusCode;
}
/// <summary>
/// EN: POST and deserialize response — sends JSON body and returns deserialized result.
/// VI: POST và deserialize response — gửi JSON body và trả về kết quả đã deserialize.
/// </summary>
public async Task<T?> PostAndGetAsync<T>(string url, object body) where T : class
{
AttachToken();
var resp = await _http.PostAsJsonAsync(url, body, _writeOptions);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(json)) return null;
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
return JsonSerializer.Deserialize<T>(data.GetRawText(), _jsonOptions);
if (root.ValueKind == JsonValueKind.Object)
return JsonSerializer.Deserialize<T>(json, _jsonOptions);
return null;
}
/// <summary>
/// EN: Robust list deserialization — handles plain arrays, PagedResult wrappers, and ApiResponse envelopes.
/// VI: Deserialize list linh hoạt — xử lý array thuần, PagedResult wrapper, và ApiResponse envelope.

View File

@@ -0,0 +1,195 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using WebClientTpos.Server.Infrastructure;
namespace WebClientTpos.Server.Controllers;
/// <summary>
/// EN: Account controller — proxies to IAM and Merchant services for owner account management.
/// VI: Controller tài khoản — proxy đến IAM và Merchant cho quản lý tài khoản chủ doanh nghiệp.
/// </summary>
[ApiController]
[Route("api/bff/account")]
public class AccountController : ControllerBase
{
private readonly HttpClient _iam;
private readonly HttpClient _merchant;
public AccountController(IHttpClientFactory httpClientFactory)
{
_iam = httpClientFactory.CreateClient("IamService");
_merchant = httpClientFactory.CreateClient("MerchantService");
}
// ═══ USER PROFILE ═══
/// <summary>
/// EN: Get current user info from IAM (id, email, firstName, lastName, roles).
/// VI: Lấy thông tin user hiện tại từ IAM (id, email, firstName, lastName, vai trò).
/// </summary>
[HttpGet("me")]
public async Task<IActionResult> GetMe()
{
var userId = ExtractUserIdFromJwt();
if (userId == null) return Unauthorized(new { success = false, message = "Not authenticated" });
return await _iam.GetAsync($"/api/v1/users/{userId}").ProxyAsync();
}
/// <summary>
/// EN: Get user profile (bio, avatar, timezone, locale, address, phone).
/// VI: Lấy profile user (bio, avatar, timezone, ngôn ngữ, địa chỉ, SĐT).
/// </summary>
[HttpGet("profile")]
public async Task<IActionResult> GetProfile()
{
var userId = ExtractUserIdFromJwt();
if (userId == null) return Unauthorized(new { success = false, message = "Not authenticated" });
return await _iam.GetAsync($"/api/v1/users/{userId}/profile").ProxyAsync();
}
/// <summary>
/// EN: Update user profile (bio, timezone, locale, avatarUrl).
/// VI: Cập nhật profile (bio, timezone, ngôn ngữ, avatarUrl).
/// </summary>
[HttpPut("profile")]
public async Task<IActionResult> UpdateProfile([FromBody] JsonElement body)
{
var userId = ExtractUserIdFromJwt();
if (userId == null) return Unauthorized(new { success = false, message = "Not authenticated" });
return await _iam.PutAsJsonAsync($"/api/v1/users/{userId}/profile", body).ProxyAsync();
}
/// <summary>
/// EN: Update user basic info (firstName, lastName).
/// VI: Cập nhật tên user (firstName, lastName).
/// </summary>
[HttpPut("me")]
public async Task<IActionResult> UpdateMe([FromBody] JsonElement body)
{
var userId = ExtractUserIdFromJwt();
if (userId == null) return Unauthorized(new { success = false, message = "Not authenticated" });
return await _iam.PutAsJsonAsync($"/api/v1/users/{userId}", body).ProxyAsync();
}
// ═══ MERCHANT PROFILE ═══
/// <summary>
/// EN: Get current merchant profile.
/// VI: Lấy thông tin merchant hiện tại.
/// </summary>
[HttpGet("merchant")]
public Task<IActionResult> GetMerchant() =>
_merchant.GetAsync("/api/v1/merchants/me").ProxyAsync();
/// <summary>
/// EN: Update merchant profile (businessName, etc).
/// VI: Cập nhật thông tin merchant (tên doanh nghiệp, ...).
/// </summary>
[HttpPut("merchant")]
public Task<IActionResult> UpdateMerchant([FromBody] JsonElement body) =>
_merchant.PutAsJsonAsync("/api/v1/merchants/me", body).ProxyAsync();
// ═══ SECURITY ═══
/// <summary>
/// EN: Change password.
/// VI: Đổi mật khẩu.
/// </summary>
[HttpPost("change-password")]
public Task<IActionResult> ChangePassword([FromBody] JsonElement body) =>
_iam.PostAsJsonAsync("/api/v1/auth/change-password", body).ProxyAsync();
/// <summary>
/// EN: Enable 2FA — returns QR code and recovery codes.
/// VI: Bật 2FA — trả về QR code và mã khôi phục.
/// </summary>
[HttpPost("2fa/enable")]
public Task<IActionResult> Enable2FA() =>
_iam.PostAsync("/api/v1/auth/2fa/enable", null).ProxyAsync();
/// <summary>
/// EN: Verify 2FA code to complete setup.
/// VI: Xác thực mã 2FA để hoàn tất thiết lập.
/// </summary>
[HttpPost("2fa/verify")]
public Task<IActionResult> Verify2FA([FromBody] JsonElement body) =>
_iam.PostAsJsonAsync("/api/v1/auth/2fa/verify", body).ProxyAsync();
/// <summary>
/// EN: Disable 2FA.
/// VI: Tắt 2FA.
/// </summary>
[HttpPost("2fa/disable")]
public Task<IActionResult> Disable2FA([FromBody] JsonElement body) =>
_iam.PostAsJsonAsync("/api/v1/auth/2fa/disable", body).ProxyAsync();
/// <summary>
/// EN: Get linked OAuth accounts (Google, Facebook).
/// VI: Lấy tài khoản liên kết (Google, Facebook).
/// </summary>
[HttpGet("linked-accounts")]
public Task<IActionResult> GetLinkedAccounts() =>
_iam.GetAsync("/api/v1/auth/linked-accounts").ProxyAsync();
/// <summary>
/// EN: Logout and revoke tokens.
/// VI: Đăng xuất và thu hồi token.
/// </summary>
[HttpPost("logout")]
public Task<IActionResult> Logout() =>
_iam.PostAsync("/api/v1/auth/logout", null).ProxyAsync();
// ═══ SUBSCRIPTION ═══
/// <summary>
/// EN: Get current subscription info.
/// VI: Lấy thông tin gói dịch vụ hiện tại.
/// </summary>
[HttpGet("subscription")]
public Task<IActionResult> GetSubscription() =>
_merchant.GetAsync("/api/v1/subscriptions/me").ProxyAsync();
/// <summary>
/// EN: Get available subscription plans.
/// VI: Lấy danh sách gói dịch vụ.
/// </summary>
[HttpGet("subscription/plans")]
public Task<IActionResult> GetPlans() =>
_merchant.GetAsync("/api/v1/subscriptions/plans").ProxyAsync();
/// <summary>
/// EN: Subscribe or upgrade to a plan.
/// VI: Đăng ký hoặc nâng cấp gói.
/// </summary>
[HttpPost("subscription/subscribe")]
public Task<IActionResult> Subscribe([FromBody] JsonElement body) =>
_merchant.PostAsJsonAsync("/api/v1/subscriptions/subscribe", body).ProxyAsync();
/// <summary>
/// EN: Get current usage vs limits.
/// VI: Lấy mức sử dụng hiện tại so với giới hạn.
/// </summary>
[HttpGet("subscription/usage")]
public Task<IActionResult> GetUsage() =>
_merchant.GetAsync("/api/v1/subscriptions/usage").ProxyAsync();
// ═══ HELPERS ═══
private string? ExtractUserIdFromJwt()
{
var authHeader = Request.Headers["Authorization"].FirstOrDefault();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
try
{
var token = authHeader["Bearer ".Length..];
var parts = token.Split('.');
if (parts.Length < 2) return null;
var payload = parts[1].Replace('-', '+').Replace('_', '/');
switch (payload.Length % 4) { case 2: payload += "=="; break; case 3: payload += "="; break; }
using var jwtDoc = JsonDocument.Parse(Convert.FromBase64String(payload));
return jwtDoc.RootElement.TryGetProperty("sub", out var sub) ? sub.GetString() : null;
}
catch { return null; }
}
}

View File

@@ -80,7 +80,7 @@ http:
# EN: Merchant Service - Merchant & Shop Management
# VI: Merchant Service - Quản lý Merchant & Shop
merchant-service-router:
rule: "PathPrefix(`/api/v1/merchants`) || PathPrefix(`/api/v1/shops`)"
rule: "PathPrefix(`/api/v1/merchants`) || PathPrefix(`/api/v1/shops`) || PathPrefix(`/api/v1/subscriptions`)"
service: merchant-service
priority: 100
middlewares:

View File

@@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591;CA2017</NoWarn>
<NoWarn>$(NoWarn);1591;CA2017;NU1902;NU1903;NU1904</NoWarn>
</PropertyGroup>
<PropertyGroup>

View File

@@ -34,23 +34,14 @@ public class UpdateUserCommandHandler : IRequestHandler<UpdateUserCommand, Updat
throw new DomainException($"User with ID {request.UserId} not found.");
}
// EN: Update user properties
// VI: Cập nhật thuộc tính user
if (!string.IsNullOrWhiteSpace(request.FirstName))
{
user.UpdateProfile(request.FirstName, user.LastName);
}
// EN: Update user properties — resolve final firstName/lastName before calling domain method once.
// VI: Cập nhật thuộc tính user — xác định firstName/lastName cuối cùng trước khi gọi domain method.
var newFirstName = !string.IsNullOrWhiteSpace(request.FirstName) ? request.FirstName : user.FirstName;
var newLastName = !string.IsNullOrWhiteSpace(request.LastName) ? request.LastName : user.LastName;
if (!string.IsNullOrWhiteSpace(request.LastName))
if (!string.IsNullOrWhiteSpace(newFirstName) && !string.IsNullOrWhiteSpace(newLastName))
{
user.UpdateProfile(user.FirstName, request.LastName);
}
// EN: If both are provided, update together
// VI: Nếu cả hai được cung cấp, cập nhật cùng lúc
if (!string.IsNullOrWhiteSpace(request.FirstName) && !string.IsNullOrWhiteSpace(request.LastName))
{
user.UpdateProfile(request.FirstName, request.LastName);
user.UpdateProfile(newFirstName, newLastName);
}
var result = await _userManager.UpdateAsync(user);

View File

@@ -0,0 +1,29 @@
using FluentValidation;
using IamService.API.Application.Commands.Auth;
namespace IamService.API.Application.Validations;
/// <summary>
/// EN: Validator for ChangePasswordCommand.
/// VI: Validator cho ChangePasswordCommand.
/// </summary>
public class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidator()
{
RuleFor(x => x.UserId)
.NotEmpty().WithMessage("User ID is required / User ID là bắt buộc");
RuleFor(x => x.CurrentPassword)
.NotEmpty().WithMessage("Current password is required / Mật khẩu hiện tại là bắt buộc");
RuleFor(x => x.NewPassword)
.NotEmpty().WithMessage("New password is required / Mật khẩu mới là bắt buộc")
.MinimumLength(8).WithMessage("New password must be at least 8 characters / Mật khẩu mới phải có ít nhất 8 ký tự")
.Matches("[A-Z]").WithMessage("New password must contain at least one uppercase letter / Mật khẩu mới phải chứa ít nhất một chữ hoa")
.Matches("[a-z]").WithMessage("New password must contain at least one lowercase letter / Mật khẩu mới phải chứa ít nhất một chữ thường")
.Matches("[0-9]").WithMessage("New password must contain at least one digit / Mật khẩu mới phải chứa ít nhất một chữ số")
.Matches("[^a-zA-Z0-9]").WithMessage("New password must contain at least one special character / Mật khẩu mới phải chứa ít nhất một ký tự đặc biệt")
.NotEqual(x => x.CurrentPassword).WithMessage("New password must be different from current password / Mật khẩu mới phải khác mật khẩu hiện tại");
}
}

View File

@@ -32,7 +32,12 @@ public static class AuthorizationExtensions
// EN: Auditor - Read-only audit access
// VI: Auditor - Quyền xem audit logs
.AddPolicy("RequireAuditor", policy =>
policy.RequireRole("SuperAdmin", "Admin", "Auditor"));
policy.RequireRole("SuperAdmin", "Admin", "Auditor"))
// EN: OwnerOrAdmin - user accessing own resource or Admin/SuperAdmin
// VI: OwnerOrAdmin - user truy cập resource của mình hoặc Admin/SuperAdmin
.AddPolicy("OwnerOrAdmin", policy =>
policy.Requirements.Add(new OwnerOrAdminRequirement()));
// EN: Register custom authorization handler for OwnerOrAdmin policy
// VI: Đăng ký custom authorization handler cho policy OwnerOrAdmin

View File

@@ -29,7 +29,7 @@
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<!-- EN: Email sending with MailKit / VI: Gửi email với MailKit -->
<PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="MailKit" Version="4.10.0" />
<!-- EN: 2FA with TOTP / VI: 2FA với TOTP -->
<PackageReference Include="Otp.NET" Version="1.4.0" />

View File

@@ -181,7 +181,21 @@ public class IamServiceContext : IdentityDbContext<ApplicationUser, ApplicationR
// EN: Configure Identity tables with custom names
// VI: Cấu hình bảng Identity với tên tùy chỉnh
modelBuilder.Entity<ApplicationUser>().ToTable("users");
modelBuilder.Entity<ApplicationUser>(b =>
{
b.ToTable("users");
b.Property<string>("_firstName").HasColumnName("first_name").HasMaxLength(100);
b.Property<string>("_lastName").HasColumnName("last_name").HasMaxLength(100);
b.Property<DateTime>("_createdAt").HasColumnName("created_at");
b.Property<DateTime?>("_lastLoginAt").HasColumnName("last_login_at");
b.Ignore(u => u.FirstName);
b.Ignore(u => u.LastName);
b.Ignore(u => u.FullName);
b.Ignore(u => u.Status);
b.Ignore(u => u.CreatedAt);
b.Ignore(u => u.LastLoginAt);
b.Ignore(u => u.DomainEvents);
});
modelBuilder.Entity<ApplicationRole>().ToTable("roles");
modelBuilder.Entity<IdentityUserRole<Guid>>().ToTable("user_roles");
modelBuilder.Entity<IdentityUserClaim<Guid>>().ToTable("user_claims");

View File

@@ -0,0 +1,77 @@
// EN: Command to subscribe or upgrade merchant's plan.
// VI: Command để đăng ký hoặc nâng cấp gói của merchant.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
namespace MerchantService.API.Application.Commands.Subscriptions;
/// <summary>
/// EN: Command to subscribe or upgrade to a plan.
/// VI: Command để đăng ký hoặc nâng cấp gói.
/// </summary>
/// <param name="PlanId">Plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise) / ID gói</param>
public record SubscribeCommand(int PlanId) : IRequest<SubscribeCommandResult>;
/// <summary>
/// EN: Result of subscribe command.
/// VI: Kết quả của subscribe command.
/// </summary>
/// <param name="Success">Whether the operation was successful / Thao tác có thành công không</param>
/// <param name="Message">Result message / Thông điệp kết quả</param>
/// <param name="PlanId">Subscribed plan ID / ID gói đã đăng ký</param>
/// <param name="PlanName">Subscribed plan name / Tên gói đã đăng ký</param>
public record SubscribeCommandResult(bool Success, string Message, int PlanId, string PlanName);
/// <summary>
/// EN: Handler for SubscribeCommand.
/// VI: Handler cho SubscribeCommand.
/// </summary>
public class SubscribeCommandHandler : IRequestHandler<SubscribeCommand, SubscribeCommandResult>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<SubscribeCommandHandler> _logger;
public SubscribeCommandHandler(
IMerchantRepository merchantRepository,
IHttpContextAccessor httpContextAccessor,
ILogger<SubscribeCommandHandler> logger)
{
_merchantRepository = merchantRepository;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public async Task<SubscribeCommandResult> Handle(SubscribeCommand request, CancellationToken cancellationToken)
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
return new SubscribeCommandResult(false, "User not authenticated", -1, "");
}
var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken);
if (merchant == null)
{
_logger.LogWarning("EN: Merchant not found for user {UserId} / VI: Không tìm thấy merchant cho user {UserId}", userId);
return new SubscribeCommandResult(false, "Merchant not found / Không tìm thấy merchant", -1, "");
}
// EN: Update subscription plan via domain behavior method.
// VI: Cập nhật gói đăng ký qua domain behavior method.
merchant.UpdateSubscriptionPlan(request.PlanId);
_merchantRepository.Update(merchant);
await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
var planNames = new[] { "Starter", "Growth", "Pro", "Enterprise" };
var planName = request.PlanId >= 0 && request.PlanId < planNames.Length ? planNames[request.PlanId] : "Unknown";
_logger.LogInformation("EN: Merchant {MerchantId} subscribed to {PlanName} / VI: Merchant {MerchantId} đã đăng ký {PlanName}",
merchant.Id, planName);
return new SubscribeCommandResult(true, $"Subscribed to {planName} plan successfully", request.PlanId, planName);
}
}

View File

@@ -29,12 +29,18 @@ public record MerchantProfileDto
public string Type { get; init; } = null!;
public string Status { get; init; } = null!;
public string VerificationStatus { get; init; } = null!;
public BusinessInfoDto? BusinessInfo { get; init; }
public SettlementConfigDto? SettlementConfig { get; init; }
public BusinessInfoDto? BusinessInfo { get; set; }
public SettlementConfigDto? SettlementConfig { get; set; }
public DateTime? VerifiedAt { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public int ShopCount { get; init; }
/// <summary>
/// EN: Subscription plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
/// VI: ID gói đăng ký (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
/// </summary>
public int SubscriptionPlanId { get; init; }
}
public record BusinessInfoDto
@@ -48,7 +54,7 @@ public record BusinessInfoDto
public record SettlementConfigDto
{
public BankAccountDto? BankAccount { get; init; }
public BankAccountDto? BankAccount { get; set; }
public decimal CommissionRate { get; init; }
public string SettlementCycle { get; init; } = null!;
public bool AutoSettlement { get; init; }

View File

@@ -4,6 +4,7 @@
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.AggregatesModel.ShopAggregate;
using MerchantService.Domain.SeedWork;
namespace MerchantService.API.Application.Queries.Merchants;
@@ -50,25 +51,54 @@ public class GetMerchantProfileQueryHandler : IRequestHandler<GetMerchantProfile
private static MerchantProfileDto MapToDto(Merchant merchant, int shopCount)
{
return new MerchantProfileDto
// EN: Use TypeId/StatusId/VerificationStatusId to resolve Enumeration names,
// because navigation properties (_type, _status, _verificationStatus) are Ignored in EF config.
// VI: Dùng TypeId/StatusId/VerificationStatusId để resolve tên Enumeration,
// vì navigation properties (_type, _status, _verificationStatus) bị Ignore trong EF config.
var typeName = Enumeration.FromValue<MerchantType>(merchant.TypeId)?.Name ?? "Unknown";
var statusName = Enumeration.FromValue<MerchantStatus>(merchant.StatusId)?.Name ?? "Unknown";
var verificationName = Enumeration.FromValue<VerificationStatus>(merchant.VerificationStatusId)?.Name ?? "Unknown";
var dto = new MerchantProfileDto
{
Id = merchant.Id,
UserId = merchant.UserId,
BusinessName = merchant.BusinessName,
Type = merchant.Type.Name,
Status = merchant.Status.Name,
VerificationStatus = merchant.VerificationStatus.Name,
BusinessInfo = new BusinessInfoDto
Type = typeName,
Status = statusName,
VerificationStatus = verificationName,
VerifiedAt = merchant.VerifiedAt,
CreatedAt = merchant.CreatedAt,
UpdatedAt = merchant.UpdatedAt,
ShopCount = shopCount,
SubscriptionPlanId = merchant.SubscriptionPlanId
};
if (merchant.BusinessInfo != null)
{
dto.BusinessInfo = new BusinessInfoDto
{
TaxId = merchant.BusinessInfo.TaxId,
BusinessLicenseNumber = merchant.BusinessInfo.BusinessLicenseNumber,
CompanyRegistrationNumber = merchant.BusinessInfo.CompanyRegistrationNumber,
EstablishedDate = merchant.BusinessInfo.EstablishedDate,
IsComplete = merchant.BusinessInfo.IsCompleteForVerification
},
SettlementConfig = new SettlementConfigDto
};
}
if (merchant.SettlementConfig != null)
{
dto.SettlementConfig = new SettlementConfigDto
{
BankAccount = new BankAccountDto
CommissionRate = merchant.SettlementConfig.CommissionRate,
SettlementCycle = SettlementCycle.FromValue<SettlementCycle>(merchant.SettlementConfig.SettlementCycleId).Name,
AutoSettlement = merchant.SettlementConfig.AutoSettlement,
IsComplete = merchant.SettlementConfig.IsComplete
};
if (merchant.SettlementConfig.BankAccount != null)
{
dto.SettlementConfig.BankAccount = new BankAccountDto
{
BankCode = merchant.SettlementConfig.BankAccount.BankCode,
BankName = merchant.SettlementConfig.BankAccount.BankName,
@@ -76,17 +106,11 @@ public class GetMerchantProfileQueryHandler : IRequestHandler<GetMerchantProfile
? $"****{merchant.SettlementConfig.BankAccount.AccountNumber[^4..]}"
: null,
AccountHolderName = merchant.SettlementConfig.BankAccount.AccountHolderName
},
CommissionRate = merchant.SettlementConfig.CommissionRate,
SettlementCycle = SettlementCycle.FromValue<SettlementCycle>(merchant.SettlementConfig.SettlementCycleId).Name,
AutoSettlement = merchant.SettlementConfig.AutoSettlement,
IsComplete = merchant.SettlementConfig.IsComplete
},
VerifiedAt = merchant.VerifiedAt,
CreatedAt = merchant.CreatedAt,
UpdatedAt = merchant.UpdatedAt,
ShopCount = shopCount
};
};
}
}
return dto;
}
}
@@ -122,13 +146,14 @@ public class GetMerchantByIdQueryHandler : IRequestHandler<GetMerchantByIdQuery,
Id = merchant.Id,
UserId = merchant.UserId,
BusinessName = merchant.BusinessName,
Type = merchant.Type.Name,
Status = merchant.Status.Name,
VerificationStatus = merchant.VerificationStatus.Name,
Type = Enumeration.FromValue<MerchantType>(merchant.TypeId)?.Name ?? "Unknown",
Status = Enumeration.FromValue<MerchantStatus>(merchant.StatusId)?.Name ?? "Unknown",
VerificationStatus = Enumeration.FromValue<VerificationStatus>(merchant.VerificationStatusId)?.Name ?? "Unknown",
VerifiedAt = merchant.VerifiedAt,
CreatedAt = merchant.CreatedAt,
UpdatedAt = merchant.UpdatedAt,
ShopCount = shops.Count
ShopCount = shops.Count,
SubscriptionPlanId = merchant.SubscriptionPlanId
};
}
}

View File

@@ -0,0 +1,106 @@
// EN: Subscription queries and DTOs.
// VI: Queries và DTOs cho gói đăng ký.
using MediatR;
namespace MerchantService.API.Application.Queries.Subscriptions;
/// <summary>
/// EN: Query to get current merchant's subscription info.
/// VI: Query để lấy thông tin gói đăng ký của merchant hiện tại.
/// </summary>
public record GetSubscriptionQuery : IRequest<SubscriptionDto?>;
/// <summary>
/// EN: Query to get all available subscription plans.
/// VI: Query để lấy danh sách tất cả gói đăng ký.
/// </summary>
public record GetSubscriptionPlansQuery : IRequest<IReadOnlyList<SubscriptionPlanDto>>;
/// <summary>
/// EN: Query to get current usage vs limits.
/// VI: Query để lấy mức sử dụng hiện tại so với giới hạn.
/// </summary>
public record GetSubscriptionUsageQuery : IRequest<SubscriptionUsageDto?>;
/// <summary>
/// EN: Current subscription DTO.
/// VI: DTO gói đăng ký hiện tại.
/// </summary>
public record SubscriptionDto
{
/// <summary>
/// EN: Plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
/// VI: ID gói (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
/// </summary>
public int PlanId { get; init; }
/// <summary>
/// EN: Plan name.
/// VI: Tên gói.
/// </summary>
public string PlanName { get; init; } = null!;
/// <summary>
/// EN: Max number of shops allowed.
/// VI: Số shop tối đa được phép.
/// </summary>
public int MaxShops { get; init; }
/// <summary>
/// EN: Max number of staff per shop.
/// VI: Số nhân viên tối đa mỗi shop.
/// </summary>
public int MaxStaffPerShop { get; init; }
/// <summary>
/// EN: Max number of products.
/// VI: Số sản phẩm tối đa.
/// </summary>
public int MaxProducts { get; init; }
/// <summary>
/// EN: Whether analytics feature is available.
/// VI: Tính năng phân tích có khả dụng không.
/// </summary>
public bool HasAnalytics { get; init; }
/// <summary>
/// EN: Whether priority support is available.
/// VI: Hỗ trợ ưu tiên có khả dụng không.
/// </summary>
public bool HasPrioritySupport { get; init; }
}
/// <summary>
/// EN: Subscription plan DTO.
/// VI: DTO gói đăng ký.
/// </summary>
public record SubscriptionPlanDto
{
public int Id { get; init; }
public string Name { get; init; } = null!;
public string Description { get; init; } = null!;
public decimal MonthlyPrice { get; init; }
public int MaxShops { get; init; }
public int MaxStaffPerShop { get; init; }
public int MaxProducts { get; init; }
public bool HasAnalytics { get; init; }
public bool HasPrioritySupport { get; init; }
}
/// <summary>
/// EN: Subscription usage DTO — current usage vs plan limits.
/// VI: DTO mức sử dụng — sử dụng hiện tại so với giới hạn gói.
/// </summary>
public record SubscriptionUsageDto
{
public int PlanId { get; init; }
public string PlanName { get; init; } = null!;
public int ShopsUsed { get; init; }
public int ShopsLimit { get; init; }
public int StaffUsed { get; init; }
public int StaffLimit { get; init; }
public int ProductsUsed { get; init; }
public int ProductsLimit { get; init; }
}

View File

@@ -0,0 +1,172 @@
// EN: Handlers for subscription queries.
// VI: Handlers cho các subscription queries.
using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
using MerchantService.Domain.AggregatesModel.ShopAggregate;
namespace MerchantService.API.Application.Queries.Subscriptions;
/// <summary>
/// EN: Static subscription plan definitions.
/// VI: Định nghĩa tĩnh các gói đăng ký.
/// </summary>
internal static class SubscriptionPlans
{
public static readonly IReadOnlyList<SubscriptionPlanDto> All = new List<SubscriptionPlanDto>
{
new()
{
Id = 0, Name = "Starter", Description = "Free plan for small businesses / Gói miễn phí cho doanh nghiệp nhỏ",
MonthlyPrice = 0m, MaxShops = 1, MaxStaffPerShop = 3, MaxProducts = 50,
HasAnalytics = false, HasPrioritySupport = false
},
new()
{
Id = 1, Name = "Growth", Description = "For growing businesses / Cho doanh nghiệp đang phát triển",
MonthlyPrice = 299_000m, MaxShops = 3, MaxStaffPerShop = 10, MaxProducts = 500,
HasAnalytics = true, HasPrioritySupport = false
},
new()
{
Id = 2, Name = "Pro", Description = "For professional businesses / Cho doanh nghiệp chuyên nghiệp",
MonthlyPrice = 799_000m, MaxShops = 10, MaxStaffPerShop = 50, MaxProducts = 5000,
HasAnalytics = true, HasPrioritySupport = true
},
new()
{
Id = 3, Name = "Enterprise", Description = "For large enterprises / Cho doanh nghiệp lớn",
MonthlyPrice = 1_999_000m, MaxShops = 999, MaxStaffPerShop = 999, MaxProducts = 99999,
HasAnalytics = true, HasPrioritySupport = true
}
};
public static SubscriptionPlanDto GetById(int planId) =>
All.FirstOrDefault(p => p.Id == planId) ?? All[0];
}
/// <summary>
/// EN: Handler for GetSubscriptionQuery — returns current merchant's subscription info.
/// VI: Handler cho GetSubscriptionQuery — trả về thông tin gói đăng ký của merchant hiện tại.
/// </summary>
public class GetSubscriptionQueryHandler : IRequestHandler<GetSubscriptionQuery, SubscriptionDto?>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
public GetSubscriptionQueryHandler(
IMerchantRepository merchantRepository,
IHttpContextAccessor httpContextAccessor)
{
_merchantRepository = merchantRepository;
_httpContextAccessor = httpContextAccessor;
}
public async Task<SubscriptionDto?> Handle(GetSubscriptionQuery request, CancellationToken cancellationToken)
{
var userId = ExtractUserId();
if (userId == null) return null;
var merchant = await _merchantRepository.GetByUserIdAsync(userId.Value, cancellationToken);
if (merchant == null) return null;
var plan = SubscriptionPlans.GetById(merchant.SubscriptionPlanId);
return new SubscriptionDto
{
PlanId = plan.Id,
PlanName = plan.Name,
MaxShops = plan.MaxShops,
MaxStaffPerShop = plan.MaxStaffPerShop,
MaxProducts = plan.MaxProducts,
HasAnalytics = plan.HasAnalytics,
HasPrioritySupport = plan.HasPrioritySupport
};
}
private Guid? ExtractUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
return null;
return userId;
}
}
/// <summary>
/// EN: Handler for GetSubscriptionPlansQuery — returns all available plans.
/// VI: Handler cho GetSubscriptionPlansQuery — trả về tất cả các gói có sẵn.
/// </summary>
public class GetSubscriptionPlansQueryHandler : IRequestHandler<GetSubscriptionPlansQuery, IReadOnlyList<SubscriptionPlanDto>>
{
public Task<IReadOnlyList<SubscriptionPlanDto>> Handle(GetSubscriptionPlansQuery request, CancellationToken cancellationToken)
{
return Task.FromResult(SubscriptionPlans.All);
}
}
/// <summary>
/// EN: Handler for GetSubscriptionUsageQuery — returns current usage vs plan limits.
/// VI: Handler cho GetSubscriptionUsageQuery — trả về mức sử dụng hiện tại so với giới hạn gói.
/// </summary>
public class GetSubscriptionUsageQueryHandler : IRequestHandler<GetSubscriptionUsageQuery, SubscriptionUsageDto?>
{
private readonly IMerchantRepository _merchantRepository;
private readonly IShopRepository _shopRepository;
private readonly IMerchantStaffRepository _staffRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
public GetSubscriptionUsageQueryHandler(
IMerchantRepository merchantRepository,
IShopRepository shopRepository,
IMerchantStaffRepository staffRepository,
IHttpContextAccessor httpContextAccessor)
{
_merchantRepository = merchantRepository;
_shopRepository = shopRepository;
_staffRepository = staffRepository;
_httpContextAccessor = httpContextAccessor;
}
public async Task<SubscriptionUsageDto?> Handle(GetSubscriptionUsageQuery request, CancellationToken cancellationToken)
{
var userId = ExtractUserId();
if (userId == null) return null;
var merchant = await _merchantRepository.GetByUserIdAsync(userId.Value, cancellationToken);
if (merchant == null) return null;
var plan = SubscriptionPlans.GetById(merchant.SubscriptionPlanId);
var shops = await _shopRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken);
var staff = await _staffRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken);
return new SubscriptionUsageDto
{
PlanId = plan.Id,
PlanName = plan.Name,
ShopsUsed = shops.Count,
ShopsLimit = plan.MaxShops,
StaffUsed = staff.Count,
StaffLimit = plan.MaxStaffPerShop * shops.Count,
// EN: Product count not tracked here — would need catalog service integration.
// VI: Số sản phẩm không theo dõi ở đây — cần tích hợp catalog service.
ProductsUsed = 0,
ProductsLimit = plan.MaxProducts
};
}
private Guid? ExtractUserId()
{
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
return null;
return userId;
}
}

View File

@@ -0,0 +1,21 @@
// EN: Validators for Subscription commands.
// VI: Validators cho các commands Subscription.
using FluentValidation;
using MerchantService.API.Application.Commands.Subscriptions;
namespace MerchantService.API.Application.Validations;
/// <summary>
/// EN: Validator for SubscribeCommand.
/// VI: Validator cho SubscribeCommand.
/// </summary>
public class SubscribeCommandValidator : AbstractValidator<SubscribeCommand>
{
public SubscribeCommandValidator()
{
RuleFor(x => x.PlanId)
.InclusiveBetween(0, 3)
.WithMessage("Plan ID must be between 0 and 3 (0=Starter, 1=Growth, 2=Pro, 3=Enterprise) / ID gói phải từ 0 đến 3 (0=Starter, 1=Growth, 2=Pro, 3=Enterprise)");
}
}

View File

@@ -0,0 +1,100 @@
// EN: Subscriptions Controller for subscription plan management.
// VI: Controller Subscriptions để quản lý gói đăng ký.
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MerchantService.API.Application.Commands.Subscriptions;
using MerchantService.API.Application.Queries.Subscriptions;
namespace MerchantService.API.Controllers;
/// <summary>
/// EN: Controller for subscription plan management.
/// VI: Controller để quản lý gói đăng ký.
/// </summary>
[ApiController]
[Route("api/v1/subscriptions")]
[Authorize]
public class SubscriptionsController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<SubscriptionsController> _logger;
public SubscriptionsController(IMediator mediator, ILogger<SubscriptionsController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get current merchant's subscription info.
/// VI: Lấy thông tin gói đăng ký của merchant hiện tại.
/// </summary>
[HttpGet("me")]
[ProducesResponseType(typeof(SubscriptionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSubscription(CancellationToken cancellationToken)
{
var result = await _mediator.Send(new GetSubscriptionQuery(), cancellationToken);
if (result == null)
{
return NotFound(new { success = false, error = new { code = "SUBSCRIPTION_NOT_FOUND", message = "Merchant not found or not subscribed" } });
}
return Ok(new { success = true, data = result });
}
/// <summary>
/// EN: Get all available subscription plans.
/// VI: Lấy danh sách tất cả gói đăng ký có sẵn.
/// </summary>
[HttpGet("plans")]
[AllowAnonymous]
[ProducesResponseType(typeof(IReadOnlyList<SubscriptionPlanDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetPlans(CancellationToken cancellationToken)
{
var result = await _mediator.Send(new GetSubscriptionPlansQuery(), cancellationToken);
return Ok(new { success = true, data = result });
}
/// <summary>
/// EN: Subscribe or upgrade to a plan.
/// VI: Đăng ký hoặc nâng cấp gói.
/// </summary>
[HttpPost("subscribe")]
[ProducesResponseType(typeof(SubscribeCommandResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Subscribe([FromBody] SubscribeCommand command, CancellationToken cancellationToken)
{
try
{
var result = await _mediator.Send(command, cancellationToken);
if (!result.Success)
{
return BadRequest(new { success = false, error = new { code = "SUBSCRIBE_FAILED", message = result.Message } });
}
return Ok(new { success = true, data = result });
}
catch (Domain.Exceptions.DomainException ex)
{
return BadRequest(new { success = false, error = new { code = "INVALID_PLAN", message = ex.Message } });
}
}
/// <summary>
/// EN: Get current usage vs plan limits.
/// VI: Lấy mức sử dụng hiện tại so với giới hạn gói.
/// </summary>
[HttpGet("usage")]
[ProducesResponseType(typeof(SubscriptionUsageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetUsage(CancellationToken cancellationToken)
{
var result = await _mediator.Send(new GetSubscriptionUsageQuery(), cancellationToken);
if (result == null)
{
return NotFound(new { success = false, error = new { code = "USAGE_NOT_FOUND", message = "Merchant not found" } });
}
return Ok(new { success = true, data = result });
}
}

View File

@@ -23,6 +23,7 @@ public class Merchant : Entity, IAggregateRoot
private BusinessInfo _businessInfo = null!;
private SettlementConfig _settlementConfig = null!;
private DateTime _createdAt;
private int _subscriptionPlanId;
private DateTime? _updatedAt;
private bool _isDeleted;
@@ -110,6 +111,12 @@ public class Merchant : Entity, IAggregateRoot
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: Subscription plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
/// VI: ID gói đăng ký (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
/// </summary>
public int SubscriptionPlanId => _subscriptionPlanId;
/// <summary>
/// EN: Soft delete flag.
/// VI: Cờ xóa mềm.
@@ -280,6 +287,20 @@ public class Merchant : Entity, IAggregateRoot
AddDomainEvent(new MerchantBannedDomainEvent(this, reason));
}
/// <summary>
/// EN: Update subscription plan.
/// VI: Cập nhật gói đăng ký.
/// </summary>
/// <param name="planId">Plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise) / ID gói (0=Starter, 1=Growth, 2=Pro, 3=Enterprise)</param>
public void UpdateSubscriptionPlan(int planId)
{
if (planId < 0 || planId > 3)
throw new DomainException("Invalid subscription plan ID. Must be 0-3.");
_subscriptionPlanId = planId;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Soft delete merchant.
/// VI: Xóa mềm merchant.

View File

@@ -115,6 +115,13 @@ public class MerchantEntityTypeConfiguration : IEntityTypeConfiguration<Merchant
.HasColumnName("is_deleted")
.HasDefaultValue(false);
// EN: Subscription plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise)
// VI: ID gói đăng ký (0=Starter, 1=Growth, 2=Pro, 3=Enterprise)
builder.Property<int>("_subscriptionPlanId")
.HasColumnName("subscription_plan_id")
.HasDefaultValue(0)
.IsRequired();
// EN: Indexes
// VI: Indexes
builder.HasIndex("_userId").HasDatabaseName("ix_merchants_user_id");
@@ -134,6 +141,7 @@ public class MerchantEntityTypeConfiguration : IEntityTypeConfiguration<Merchant
builder.Ignore(m => m.CreatedAt);
builder.Ignore(m => m.UpdatedAt);
builder.Ignore(m => m.IsDeleted);
builder.Ignore(m => m.SubscriptionPlanId);
}
}