diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/AdminSettings.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/AdminSettings.razor index 1adf0f15..42f67de2 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/AdminSettings.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/AdminSettings.razor @@ -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. *@ Cài đặt — GoodGo Admin @@ -21,26 +23,455 @@ @* ═══ TABS ═══ *@
- - - + + -
@* ═══ CONTENT ═══ *@
- @if (_tab == "general") + @* ════════════════════════════════════════════════════════════════════ + TAB: TÀI KHOẢN — Profile + Merchant Info + ════════════════════════════════════════════════════════════════════ *@ + @if (_tab == "account") + { + @* ── Profile Info ── *@ +
+
+

+ + Thông tin cá nhân +

+ @if (!_editingProfile) + { + + } +
+
+ @if (_loadingAccount) + { +
+ } + else + { +
+
+ @(string.IsNullOrEmpty(_firstName) ? (AuthState.UserEmail?[..1].ToUpper() ?? "U") : _firstName[..1].ToUpper()) +
+
+
+ @(string.IsNullOrEmpty(_firstName) && string.IsNullOrEmpty(_lastName) + ? (AuthState.UserEmail ?? "—") + : $"{_firstName} {_lastName}".Trim()) +
+
@(AuthState.UserEmail ?? "—")
+
+ Chủ doanh nghiệp +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + @if (_editingProfile) + { +
+ + +
+ } + + @if (!string.IsNullOrEmpty(_profileMsg)) + { +
+ @_profileMsg +
+ } + } +
+
+ + @* ── Merchant Info ── *@ +
+
+

+ + Thông tin doanh nghiệp +

+ @if (!_editingMerchant) + { + + } +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + @(_verificationStatus == "Verified" ? "Đã xác minh" : _verificationStatus == "Pending" ? "Đang chờ" : "Chưa xác minh") +
+
+
+ + +
+
+ + +
+
+ + @if (_editingMerchant) + { +
+ + +
+ } + + @if (!string.IsNullOrEmpty(_merchantMsg)) + { +
+ @_merchantMsg +
+ } +
+
+ } + + @* ════════════════════════════════════════════════════════════════════ + TAB: BẢO MẬT — Password, 2FA, Sessions + ════════════════════════════════════════════════════════════════════ *@ + else if (_tab == "security") + { + @* ── Change Password ── *@ +
+
+

+ + Đổi mật khẩu +

+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ @if (!string.IsNullOrEmpty(_pwMsg)) + { +
@_pwMsg
+ } +
+
+ + @* ── 2FA ── *@ +
+
+

+ + Xác thực 2 bước (2FA) +

+
+
+
+
+ Trạng thái 2FA +

Bảo mật tài khoản bằng mã OTP từ ứng dụng Authenticator

+
+ @if (_twoFactorEnabled) + { +
+
Đã bật
+ +
+ } + else + { + + } +
+ + @if (_show2FASetup) + { +
+

Quét mã QR bằng Google Authenticator hoặc Authy:

+ @if (!string.IsNullOrEmpty(_qrCodeBase64)) + { +
+ QR Code +
+ } + @if (!string.IsNullOrEmpty(_manualKey)) + { +
+ Mã thủ công: @_manualKey +
+ } +
+ + +
+
+ + +
+
+ } + + @if (_showDisable2FA) + { +
+

Nhập mã OTP hiện tại để tắt 2FA:

+
+ +
+
+ + +
+
+ } + + @if (!string.IsNullOrEmpty(_tfaMsg)) + { +
@_tfaMsg
+ } +
+
+ + @* ── Linked Accounts ── *@ +
+
+

+ + Tài khoản liên kết +

+
+
+
+
+
+ G +
+ Google +
+ @(_linkedGoogle ? "Đã liên kết" : "Chưa liên kết") +
+
+
+
+ f +
+ Facebook +
+ @(_linkedFacebook ? "Đã liên kết" : "Chưa liên kết") +
+
+
+ + @* ── Danger Zone ── *@ +
+
+

+ + Vùng nguy hiểm +

+
+
+
+
+ Xóa tài khoản +

Xóa vĩnh viễn tài khoản và toàn bộ dữ liệu

+
+ +
+
+
+ } + + @* ════════════════════════════════════════════════════════════════════ + TAB: GÓI DỊCH VỤ — Subscription plans + ════════════════════════════════════════════════════════════════════ *@ + else if (_tab == "subscription") + { + @* ── Current Plan ── *@ +
+
+

+ + Gói hiện tại +

+
+ @_currentPlanName +
+
+
+
+
+
@_usageShops/@(FormatLimit(_limitShops))
+
Cửa hàng
+
+
+
@_usageStaff/@(FormatLimit(_limitStaff))
+
Nhân viên/shop
+
+
+
@_usageVerticals/@(FormatLimit(_limitVerticals))
+
Ngành nghề
+
+
+
@_usageProducts/@(FormatLimit(_limitProducts))
+
Sản phẩm
+
+
+
+
+ + @* ── Plan Comparison ── *@ +
+ @foreach (var plan in _plans) + { + var isCurrent = plan.Id == _currentPlanId; +
+
+ @if (plan.Id == 2) + { +
Phổ biến nhất
+ } +

@plan.Name

+
+ @(plan.Price == 0 ? "Miễn phí" : $"{plan.Price:N0}₫") +
+
@(plan.Price > 0 ? "/tháng" : "mãi mãi")
+
+
+ @foreach (var f in plan.Features) + { +
+ + @f.Label +
+ } +
+ @if (isCurrent) + { + + } + else if (plan.Id > _currentPlanId) + { + + } +
+
+
+ } +
+ } + + @* ════════════════════════════════════════════════════════════════════ + TAB: THÔNG BÁO + ════════════════════════════════════════════════════════════════════ *@ + else if (_tab == "notifications") + { +
+
+

+ + Cài đặt thông báo +

+
+
+ @foreach (var notif in _notifSettings) + { +
+
+ @notif.Label + @notif.Desc +
+ +
+ } +
+
+ } + + @* ════════════════════════════════════════════════════════════════════ + TAB: HỆ THỐNG + ════════════════════════════════════════════════════════════════════ *@ + else if (_tab == "general") { - @* ── System Info ── *@

@@ -68,7 +499,6 @@

- @* ── Service Health ── *@

@@ -91,150 +521,72 @@

} - else if (_tab == "account") - { -
-
-

- - Thông tin tài khoản -

-
-
-
-
- - -
-
- - -
-
-
- -
- - Đã xác thực -
-
-
-
- } - else if (_tab == "notifications") - { -
-
-

- - Cài đặt thông báo -

-
-
- @foreach (var notif in _notifSettings) - { -
-
- @notif.Label - @notif.Desc -
- -
- } -
-
- } - else if (_tab == "security") - { -
-
-

- - Bảo mật -

-
-
-
-
- Đổi mật khẩu -

Thay đổi mật khẩu đăng nhập

-
- -
-
-
- Xác thực 2 bước (2FA) -

Bảo mật tài khoản bằng mã OTP

-
- -
-
-
- Phiên đăng nhập -

Quản lý các phiên đang hoạt động

-
- -
-
-
- - @* ─── DANGER ZONE ─── *@ -
-
-

- - Vùng nguy hiểm -

-
-
-
-
- Xóa tài khoản -

Xóa vĩnh viễn tài khoản và toàn bộ dữ liệu

-
- -
-
-
- }
@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; + + /// + /// 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). + /// + 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("api/bff/account/me"); + if (me != null) + { + _firstName = me.FirstName ?? ""; + _lastName = me.LastName ?? ""; + } + } + catch { } + + try + { + var profile = await DataService.GetFromApiAsync("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("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("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("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? 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; } } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index c51747f8..5c5963c7 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -76,6 +76,45 @@ public class PosDataService return resp.IsSuccessStatusCode; } + /// + /// 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). + /// + public Task GetFromApiAsync(string url) where T : class + => GetObjectFromApiAsync(url); + + /// + /// EN: Generic PUT helper — sends JSON body to BFF endpoint. + /// VI: Helper PUT chung — gửi JSON body đến BFF endpoint. + /// + public async Task PutAsync(string url, object body) + { + AttachToken(); + var resp = await _http.PutAsJsonAsync(url, body, _writeOptions); + return resp.IsSuccessStatusCode; + } + + /// + /// 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. + /// + public async Task PostAndGetAsync(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(data.GetRawText(), _jsonOptions); + if (root.ValueKind == JsonValueKind.Object) + return JsonSerializer.Deserialize(json, _jsonOptions); + return null; + } + /// /// 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. diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/AccountController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/AccountController.cs new file mode 100644 index 00000000..413056b8 --- /dev/null +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/AccountController.cs @@ -0,0 +1,195 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using WebClientTpos.Server.Infrastructure; + +namespace WebClientTpos.Server.Controllers; + +/// +/// 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. +/// +[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 ═══ + + /// + /// 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ò). + /// + [HttpGet("me")] + public async Task GetMe() + { + var userId = ExtractUserIdFromJwt(); + if (userId == null) return Unauthorized(new { success = false, message = "Not authenticated" }); + return await _iam.GetAsync($"/api/v1/users/{userId}").ProxyAsync(); + } + + /// + /// 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). + /// + [HttpGet("profile")] + public async Task 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(); + } + + /// + /// EN: Update user profile (bio, timezone, locale, avatarUrl). + /// VI: Cập nhật profile (bio, timezone, ngôn ngữ, avatarUrl). + /// + [HttpPut("profile")] + public async Task 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(); + } + + /// + /// EN: Update user basic info (firstName, lastName). + /// VI: Cập nhật tên user (firstName, lastName). + /// + [HttpPut("me")] + public async Task 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 ═══ + + /// + /// EN: Get current merchant profile. + /// VI: Lấy thông tin merchant hiện tại. + /// + [HttpGet("merchant")] + public Task GetMerchant() => + _merchant.GetAsync("/api/v1/merchants/me").ProxyAsync(); + + /// + /// EN: Update merchant profile (businessName, etc). + /// VI: Cập nhật thông tin merchant (tên doanh nghiệp, ...). + /// + [HttpPut("merchant")] + public Task UpdateMerchant([FromBody] JsonElement body) => + _merchant.PutAsJsonAsync("/api/v1/merchants/me", body).ProxyAsync(); + + // ═══ SECURITY ═══ + + /// + /// EN: Change password. + /// VI: Đổi mật khẩu. + /// + [HttpPost("change-password")] + public Task ChangePassword([FromBody] JsonElement body) => + _iam.PostAsJsonAsync("/api/v1/auth/change-password", body).ProxyAsync(); + + /// + /// EN: Enable 2FA — returns QR code and recovery codes. + /// VI: Bật 2FA — trả về QR code và mã khôi phục. + /// + [HttpPost("2fa/enable")] + public Task Enable2FA() => + _iam.PostAsync("/api/v1/auth/2fa/enable", null).ProxyAsync(); + + /// + /// EN: Verify 2FA code to complete setup. + /// VI: Xác thực mã 2FA để hoàn tất thiết lập. + /// + [HttpPost("2fa/verify")] + public Task Verify2FA([FromBody] JsonElement body) => + _iam.PostAsJsonAsync("/api/v1/auth/2fa/verify", body).ProxyAsync(); + + /// + /// EN: Disable 2FA. + /// VI: Tắt 2FA. + /// + [HttpPost("2fa/disable")] + public Task Disable2FA([FromBody] JsonElement body) => + _iam.PostAsJsonAsync("/api/v1/auth/2fa/disable", body).ProxyAsync(); + + /// + /// EN: Get linked OAuth accounts (Google, Facebook). + /// VI: Lấy tài khoản liên kết (Google, Facebook). + /// + [HttpGet("linked-accounts")] + public Task GetLinkedAccounts() => + _iam.GetAsync("/api/v1/auth/linked-accounts").ProxyAsync(); + + /// + /// EN: Logout and revoke tokens. + /// VI: Đăng xuất và thu hồi token. + /// + [HttpPost("logout")] + public Task Logout() => + _iam.PostAsync("/api/v1/auth/logout", null).ProxyAsync(); + + // ═══ SUBSCRIPTION ═══ + + /// + /// EN: Get current subscription info. + /// VI: Lấy thông tin gói dịch vụ hiện tại. + /// + [HttpGet("subscription")] + public Task GetSubscription() => + _merchant.GetAsync("/api/v1/subscriptions/me").ProxyAsync(); + + /// + /// EN: Get available subscription plans. + /// VI: Lấy danh sách gói dịch vụ. + /// + [HttpGet("subscription/plans")] + public Task GetPlans() => + _merchant.GetAsync("/api/v1/subscriptions/plans").ProxyAsync(); + + /// + /// EN: Subscribe or upgrade to a plan. + /// VI: Đăng ký hoặc nâng cấp gói. + /// + [HttpPost("subscription/subscribe")] + public Task Subscribe([FromBody] JsonElement body) => + _merchant.PostAsJsonAsync("/api/v1/subscriptions/subscribe", body).ProxyAsync(); + + /// + /// 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. + /// + [HttpGet("subscription/usage")] + public Task 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; } + } +} diff --git a/infra/traefik/dynamic/routes.yml b/infra/traefik/dynamic/routes.yml index 3f261a58..6f3943c2 100644 --- a/infra/traefik/dynamic/routes.yml +++ b/infra/traefik/dynamic/routes.yml @@ -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: diff --git a/services/iam-service-net/Directory.Build.props b/services/iam-service-net/Directory.Build.props index c3b74373..4a5da7e1 100644 --- a/services/iam-service-net/Directory.Build.props +++ b/services/iam-service-net/Directory.Build.props @@ -6,7 +6,7 @@ enable true true - $(NoWarn);1591;CA2017 + $(NoWarn);1591;CA2017;NU1902;NU1903;NU1904 diff --git a/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommandHandler.cs b/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommandHandler.cs index 59ee01b2..76c0784b 100644 --- a/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommandHandler.cs +++ b/services/iam-service-net/src/IamService.API/Application/Commands/Users/UpdateUserCommandHandler.cs @@ -34,23 +34,14 @@ public class UpdateUserCommandHandler : IRequestHandler +/// EN: Validator for ChangePasswordCommand. +/// VI: Validator cho ChangePasswordCommand. +/// +public class ChangePasswordCommandValidator : AbstractValidator +{ + 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"); + } +} diff --git a/services/iam-service-net/src/IamService.Infrastructure/Authorization/AuthorizationExtensions.cs b/services/iam-service-net/src/IamService.Infrastructure/Authorization/AuthorizationExtensions.cs index f90fb14f..1c6c1b43 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/Authorization/AuthorizationExtensions.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/Authorization/AuthorizationExtensions.cs @@ -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 diff --git a/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj b/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj index 906d9ad8..f1733b95 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj +++ b/services/iam-service-net/src/IamService.Infrastructure/IamService.Infrastructure.csproj @@ -29,7 +29,7 @@ - + diff --git a/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs index bb639258..eb11a8f9 100644 --- a/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs +++ b/services/iam-service-net/src/IamService.Infrastructure/IamServiceContext.cs @@ -181,7 +181,21 @@ public class IamServiceContext : IdentityDbContext().ToTable("users"); + modelBuilder.Entity(b => + { + b.ToTable("users"); + b.Property("_firstName").HasColumnName("first_name").HasMaxLength(100); + b.Property("_lastName").HasColumnName("last_name").HasMaxLength(100); + b.Property("_createdAt").HasColumnName("created_at"); + b.Property("_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().ToTable("roles"); modelBuilder.Entity>().ToTable("user_roles"); modelBuilder.Entity>().ToTable("user_claims"); diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Commands/Subscriptions/SubscribeCommand.cs b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Subscriptions/SubscribeCommand.cs new file mode 100644 index 00000000..3b97037a --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Commands/Subscriptions/SubscribeCommand.cs @@ -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; + +/// +/// EN: Command to subscribe or upgrade to a plan. +/// VI: Command để đăng ký hoặc nâng cấp gói. +/// +/// Plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise) / ID gói +public record SubscribeCommand(int PlanId) : IRequest; + +/// +/// EN: Result of subscribe command. +/// VI: Kết quả của subscribe command. +/// +/// Whether the operation was successful / Thao tác có thành công không +/// Result message / Thông điệp kết quả +/// Subscribed plan ID / ID gói đã đăng ký +/// Subscribed plan name / Tên gói đã đăng ký +public record SubscribeCommandResult(bool Success, string Message, int PlanId, string PlanName); + +/// +/// EN: Handler for SubscribeCommand. +/// VI: Handler cho SubscribeCommand. +/// +public class SubscribeCommandHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public SubscribeCommandHandler( + IMerchantRepository merchantRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _merchantRepository = merchantRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task 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); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQuery.cs index 8b7f100b..9addbed1 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQuery.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQuery.cs @@ -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; } + + /// + /// 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). + /// + 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; } diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQueryHandler.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQueryHandler.cs index c91e2122..a3cc70f6 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQueryHandler.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Merchants/GetMerchantProfileQueryHandler.cs @@ -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(merchant.TypeId)?.Name ?? "Unknown"; + var statusName = Enumeration.FromValue(merchant.StatusId)?.Name ?? "Unknown"; + var verificationName = Enumeration.FromValue(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(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(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(merchant.TypeId)?.Name ?? "Unknown", + Status = Enumeration.FromValue(merchant.StatusId)?.Name ?? "Unknown", + VerificationStatus = Enumeration.FromValue(merchant.VerificationStatusId)?.Name ?? "Unknown", VerifiedAt = merchant.VerifiedAt, CreatedAt = merchant.CreatedAt, UpdatedAt = merchant.UpdatedAt, - ShopCount = shops.Count + ShopCount = shops.Count, + SubscriptionPlanId = merchant.SubscriptionPlanId }; } } diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Subscriptions/SubscriptionQueries.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Subscriptions/SubscriptionQueries.cs new file mode 100644 index 00000000..cca8b27b --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Subscriptions/SubscriptionQueries.cs @@ -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; + +/// +/// 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. +/// +public record GetSubscriptionQuery : IRequest; + +/// +/// EN: Query to get all available subscription plans. +/// VI: Query để lấy danh sách tất cả gói đăng ký. +/// +public record GetSubscriptionPlansQuery : IRequest>; + +/// +/// 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. +/// +public record GetSubscriptionUsageQuery : IRequest; + +/// +/// EN: Current subscription DTO. +/// VI: DTO gói đăng ký hiện tại. +/// +public record SubscriptionDto +{ + /// + /// EN: Plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise). + /// VI: ID gói (0=Starter, 1=Growth, 2=Pro, 3=Enterprise). + /// + public int PlanId { get; init; } + + /// + /// EN: Plan name. + /// VI: Tên gói. + /// + public string PlanName { get; init; } = null!; + + /// + /// EN: Max number of shops allowed. + /// VI: Số shop tối đa được phép. + /// + public int MaxShops { get; init; } + + /// + /// EN: Max number of staff per shop. + /// VI: Số nhân viên tối đa mỗi shop. + /// + public int MaxStaffPerShop { get; init; } + + /// + /// EN: Max number of products. + /// VI: Số sản phẩm tối đa. + /// + public int MaxProducts { get; init; } + + /// + /// EN: Whether analytics feature is available. + /// VI: Tính năng phân tích có khả dụng không. + /// + public bool HasAnalytics { get; init; } + + /// + /// EN: Whether priority support is available. + /// VI: Hỗ trợ ưu tiên có khả dụng không. + /// + public bool HasPrioritySupport { get; init; } +} + +/// +/// EN: Subscription plan DTO. +/// VI: DTO gói đăng ký. +/// +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; } +} + +/// +/// 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. +/// +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; } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Subscriptions/SubscriptionQueryHandlers.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Subscriptions/SubscriptionQueryHandlers.cs new file mode 100644 index 00000000..d0c9c1be --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Subscriptions/SubscriptionQueryHandlers.cs @@ -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; + +/// +/// EN: Static subscription plan definitions. +/// VI: Định nghĩa tĩnh các gói đăng ký. +/// +internal static class SubscriptionPlans +{ + public static readonly IReadOnlyList All = new List + { + 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]; +} + +/// +/// 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. +/// +public class GetSubscriptionQueryHandler : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + + public GetSubscriptionQueryHandler( + IMerchantRepository merchantRepository, + IHttpContextAccessor httpContextAccessor) + { + _merchantRepository = merchantRepository; + _httpContextAccessor = httpContextAccessor; + } + + public async Task 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; + } +} + +/// +/// EN: Handler for GetSubscriptionPlansQuery — returns all available plans. +/// VI: Handler cho GetSubscriptionPlansQuery — trả về tất cả các gói có sẵn. +/// +public class GetSubscriptionPlansQueryHandler : IRequestHandler> +{ + public Task> Handle(GetSubscriptionPlansQuery request, CancellationToken cancellationToken) + { + return Task.FromResult(SubscriptionPlans.All); + } +} + +/// +/// 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. +/// +public class GetSubscriptionUsageQueryHandler : IRequestHandler +{ + 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 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; + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Validations/SubscriptionCommandValidators.cs b/services/merchant-service-net/src/MerchantService.API/Application/Validations/SubscriptionCommandValidators.cs new file mode 100644 index 00000000..7cec2bf0 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Validations/SubscriptionCommandValidators.cs @@ -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; + +/// +/// EN: Validator for SubscribeCommand. +/// VI: Validator cho SubscribeCommand. +/// +public class SubscribeCommandValidator : AbstractValidator +{ + 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)"); + } +} diff --git a/services/merchant-service-net/src/MerchantService.API/Controllers/SubscriptionsController.cs b/services/merchant-service-net/src/MerchantService.API/Controllers/SubscriptionsController.cs new file mode 100644 index 00000000..51de2a66 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Controllers/SubscriptionsController.cs @@ -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; + +/// +/// EN: Controller for subscription plan management. +/// VI: Controller để quản lý gói đăng ký. +/// +[ApiController] +[Route("api/v1/subscriptions")] +[Authorize] +public class SubscriptionsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public SubscriptionsController(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + /// + /// EN: Get current merchant's subscription info. + /// VI: Lấy thông tin gói đăng ký của merchant hiện tại. + /// + [HttpGet("me")] + [ProducesResponseType(typeof(SubscriptionDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 }); + } + + /// + /// EN: Get all available subscription plans. + /// VI: Lấy danh sách tất cả gói đăng ký có sẵn. + /// + [HttpGet("plans")] + [AllowAnonymous] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task GetPlans(CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetSubscriptionPlansQuery(), cancellationToken); + return Ok(new { success = true, data = result }); + } + + /// + /// EN: Subscribe or upgrade to a plan. + /// VI: Đăng ký hoặc nâng cấp gói. + /// + [HttpPost("subscribe")] + [ProducesResponseType(typeof(SubscribeCommandResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task 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 } }); + } + } + + /// + /// 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. + /// + [HttpGet("usage")] + [ProducesResponseType(typeof(SubscriptionUsageDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 }); + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/Merchant.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/Merchant.cs index 20079f39..cbf6a1d6 100644 --- a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/Merchant.cs +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/Merchant.cs @@ -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 /// public DateTime? UpdatedAt => _updatedAt; + /// + /// 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). + /// + public int SubscriptionPlanId => _subscriptionPlanId; + /// /// EN: Soft delete flag. /// VI: Cờ xóa mềm. @@ -280,6 +287,20 @@ public class Merchant : Entity, IAggregateRoot AddDomainEvent(new MerchantBannedDomainEvent(this, reason)); } + /// + /// EN: Update subscription plan. + /// VI: Cập nhật gói đăng ký. + /// + /// Plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise) / ID gói (0=Starter, 1=Growth, 2=Pro, 3=Enterprise) + 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; + } + /// /// EN: Soft delete merchant. /// VI: Xóa mềm merchant. diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/MerchantEntityTypeConfiguration.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/MerchantEntityTypeConfiguration.cs index 4a29618a..4d7dbf3a 100644 --- a/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/MerchantEntityTypeConfiguration.cs +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/EntityConfigurations/MerchantEntityTypeConfiguration.cs @@ -115,6 +115,13 @@ public class MerchantEntityTypeConfiguration : IEntityTypeConfiguration("_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 m.CreatedAt); builder.Ignore(m => m.UpdatedAt); builder.Ignore(m => m.IsDeleted); + builder.Ignore(m => m.SubscriptionPlanId); } }