diff --git a/AGENTS.md b/AGENTS.md
index 8bbc7c51..9c6e2139 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -141,6 +141,7 @@ private string searchQuery = string.Empty;
- Wrap async operations in try-catch
- Log errors via IWinoLogger
- For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations.
+- When a `[RelayCommand]` needs enable/disable logic, prefer the command's `CanExecute` over binding `Button.IsEnabled` in XAML; use `[NotifyCanExecuteChangedFor]` on dependent properties and call `NotifyCanExecuteChanged()` explicitly when non-generated state affects the command.
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
- ViewModels should only handle UI interaction/state and delegate business logic to services; account-management work belongs in `WinoAccountProfileService`, and preferences import/export/apply logic belongs in `PreferencesService`.
- In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`.
diff --git a/Wino.Core.Domain/Enums/WinoAddOnProductType.cs b/Wino.Core.Domain/Enums/WinoAddOnProductType.cs
new file mode 100644
index 00000000..e4e08540
--- /dev/null
+++ b/Wino.Core.Domain/Enums/WinoAddOnProductType.cs
@@ -0,0 +1,7 @@
+namespace Wino.Core.Domain.Enums;
+
+public enum WinoAddOnProductType
+{
+ AI_PACK,
+ UNLIMITED_ACCOUNTS
+}
diff --git a/Wino.Core.Domain/Interfaces/IStoreManagementService.cs b/Wino.Core.Domain/Interfaces/IStoreManagementService.cs
index ea61f1b7..7f7550cb 100644
--- a/Wino.Core.Domain/Interfaces/IStoreManagementService.cs
+++ b/Wino.Core.Domain/Interfaces/IStoreManagementService.cs
@@ -1,7 +1,5 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Enums;
-using Wino.Core.Domain.Models.Store;
-
namespace Wino.Core.Domain.Interfaces;
public interface IStoreManagementService
@@ -9,10 +7,10 @@ public interface IStoreManagementService
///
/// Checks whether user has the type of an add-on purchased.
///
- Task HasProductAsync(StoreProductType productType);
+ Task HasProductAsync(WinoAddOnProductType productType);
///
/// Attempts to purchase the given add-on.
///
- Task PurchaseAsync(StoreProductType productType);
+ Task PurchaseAsync(WinoAddOnProductType productType);
}
diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs
index befc1dc4..07d53f42 100644
--- a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs
+++ b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs
@@ -2,21 +2,25 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
+using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common;
namespace Wino.Core.Domain.Interfaces;
public interface IWinoAccountApiClient
{
- Task> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
- Task> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
- Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
+ Task> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
+ Task> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
+ Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
Task> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
Task> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task> GetAiStatusAsync(CancellationToken cancellationToken = default);
- Task> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default);
+ Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
+ Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
Task GetSettingsAsync(CancellationToken cancellationToken = default);
Task SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
}
diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs
index 6f3f7bee..b95f5f5d 100644
--- a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs
+++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs
@@ -2,9 +2,11 @@
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
+using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common;
namespace Wino.Core.Domain.Interfaces;
@@ -17,8 +19,10 @@ public interface IWinoAccountProfileService
Task GetActiveAccountAsync();
Task GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default);
Task HasActiveAccountAsync();
+ Task HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
Task> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task> GetAiStatusAsync(CancellationToken cancellationToken = default);
- Task> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default);
+ Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
+ Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
Task SignOutAsync(CancellationToken cancellationToken = default);
}
diff --git a/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs b/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs
new file mode 100644
index 00000000..b2e0fc6c
--- /dev/null
+++ b/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs
@@ -0,0 +1,12 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Wino.Core.Domain.Models.Accounts;
+
+namespace Wino.Core.Domain.Interfaces;
+
+public interface IWinoAddOnService
+{
+ Task> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default);
+}
diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs
new file mode 100644
index 00000000..2baafc87
--- /dev/null
+++ b/Wino.Core.Domain/Models/Accounts/WinoAccountApiResult.cs
@@ -0,0 +1,26 @@
+#nullable enable
+
+namespace Wino.Core.Domain.Models.Accounts;
+
+public sealed class WinoAccountApiResult
+{
+ public bool IsSuccess { get; init; }
+ public string? ErrorCode { get; init; }
+ public string? ErrorMessage { get; init; }
+ public T? Result { get; init; }
+
+ public static WinoAccountApiResult Success(T result)
+ => new()
+ {
+ IsSuccess = true,
+ Result = result
+ };
+
+ public static WinoAccountApiResult Failure(string? errorCode, string? errorMessage = null)
+ => new()
+ {
+ IsSuccess = false,
+ ErrorCode = errorCode,
+ ErrorMessage = errorMessage
+ };
+}
diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs
index 15763b40..47253403 100644
--- a/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs
+++ b/Wino.Core.Domain/Models/Accounts/WinoAccountOperationResult.cs
@@ -7,6 +7,7 @@ public sealed class WinoAccountOperationResult
{
public bool IsSuccess { get; init; }
public string? ErrorCode { get; init; }
+ public string? ErrorMessage { get; init; }
public WinoAccount? Account { get; init; }
public static WinoAccountOperationResult Success(WinoAccount account)
@@ -16,10 +17,11 @@ public sealed class WinoAccountOperationResult
Account = account
};
- public static WinoAccountOperationResult Failure(string? errorCode)
+ public static WinoAccountOperationResult Failure(string? errorCode, string? errorMessage = null)
=> new()
{
IsSuccess = false,
- ErrorCode = errorCode
+ ErrorCode = errorCode,
+ ErrorMessage = errorMessage
};
}
diff --git a/Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs b/Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs
new file mode 100644
index 00000000..fd553f8a
--- /dev/null
+++ b/Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs
@@ -0,0 +1,12 @@
+using System;
+using Wino.Core.Domain.Enums;
+
+namespace Wino.Core.Domain.Models.Accounts;
+
+public sealed record WinoAddOnInfo(
+ WinoAddOnProductType ProductType,
+ bool IsPurchased,
+ int? UsageCount = null,
+ int? UsageLimit = null,
+ double UsagePercentage = 0,
+ DateTimeOffset? RenewalDateUtc = null);
diff --git a/Wino.Core.Domain/Models/Store/StoreProductType.cs b/Wino.Core.Domain/Models/Store/StoreProductType.cs
deleted file mode 100644
index f8c620ed..00000000
--- a/Wino.Core.Domain/Models/Store/StoreProductType.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Wino.Core.Domain.Models.Store;
-
-public enum StoreProductType
-{
- UnlimitedAccounts
-}
diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json
index f7b98981..1265ffc3 100644
--- a/Wino.Core.Domain/Translations/en_US/resources.json
+++ b/Wino.Core.Domain/Translations/en_US/resources.json
@@ -84,6 +84,7 @@
"Buttons_SyncAliases": "Synchronize Aliases",
"Buttons_TryAgain": "Try Again",
"Buttons_Yes": "Yes",
+ "Purchased": "Purchased",
"Sync_SynchronizingFolder": "Synchronizing {0} {1}%",
"Sync_DownloadedMessages": "Downloaded {0} messages from {1}",
"SyncAction_Archiving": "Archiving {0} mail(s)",
@@ -860,7 +861,7 @@
"WinoAccount_Management_SignedOutTitle": "Sign in to Wino Mail",
"WinoAccount_Management_SignedOutDescription": "Sign in or create an account to sync your email, access AI features, and manage your settings across devices.",
"WinoAccount_Management_ProfileSectionHeader": "Profile",
- "WinoAccount_Management_AiPackSectionHeader": "AI Pack",
+ "WinoAccount_Management_AddOnsSectionHeader": "Wino Add-Ons",
"WinoAccount_Management_DataSectionHeader": "Data",
"WinoAccount_Management_AccountActionsSectionHeader": "Account actions",
"WinoAccount_Management_AccountCardTitle": "Account",
@@ -887,14 +888,10 @@
"WinoAccount_Management_AiPackFeatureTranslate": "Translate",
"WinoAccount_Management_AiPackFeatureRewrite": "Rewrite",
"WinoAccount_Management_AiPackFeatureSummarize": "Summarize",
- "WinoAccount_Management_ImportSettingsTitle": "Import settings",
- "WinoAccount_Management_ImportSettingsDescription": "Restore your preferences from cloud.",
- "WinoAccount_Management_ExportSettingsTitle": "Export settings",
- "WinoAccount_Management_ExportSettingsDescription": "Save your preferences to cloud.",
+ "WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences",
+ "WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.",
"WinoAccount_Management_SignOutTitle": "Sign out",
"WinoAccount_Management_SignOutDescription": "Sign out of your account on this device",
- "WinoAccount_Management_SettingsCardTitle": "Settings sync",
- "WinoAccount_Management_SettingsCardDescription": "Export your current settings to your Wino Account or import them back on this device.",
"WinoAccount_Management_StatusLabel": "Status: {0}",
"WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.",
"WinoAccount_Management_ExportSucceeded": "Your settings were exported to your Wino Account.",
@@ -905,6 +902,12 @@
"WinoAccount_Management_ImportEmpty": "The synchronized settings payload does not contain any values to restore.",
"WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.",
"WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.",
+ "WinoAddOn_AI_PACK_Name": "AI Pack",
+ "WinoAddOn_AI_PACK_Description": "Translate, rewrite, and summarize emails with Wino AI.",
+ "WinoAddOn_AI_PACK_Keywords": "Translate · Rewrite · Summarize",
+ "WinoAddOn_UNLIMITED_ACCOUNTS_Name": "Unlimited Accounts",
+ "WinoAddOn_UNLIMITED_ACCOUNTS_Description": "Remove the 3-account limit and add as many accounts as you need.",
+ "WinoAddOn_UNLIMITED_ACCOUNTS_Keywords": "More accounts · No limit · One-time purchase",
"SettingsSearch_MessageList_Keywords": "message;messages;list;threading;threads;avatar;preview;sender",
"SettingsSearch_ReadComposePane_Keywords": "reader;compose;composer;font;fonts;external content;display;reading",
"SettingsSearch_SignatureAndEncryption_Keywords": "signature;signatures;encryption;certificate;certificates;s mime;smime;security",
diff --git a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs
index 9ad47176..c32054b9 100644
--- a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs
+++ b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs
@@ -3,7 +3,9 @@ using System.Threading.Tasks;
using FluentAssertions;
using Moq;
using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common;
using Wino.Services;
@@ -15,6 +17,7 @@ namespace Wino.Core.Tests.Services;
public class WinoAccountProfileServiceTests : IAsyncLifetime
{
private readonly Mock _apiClient = new();
+ private readonly Mock _storeManagementService = new();
private InMemoryDatabaseService _databaseService = null!;
private WinoAccountProfileService _service = null!;
@@ -22,7 +25,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
{
_databaseService = new InMemoryDatabaseService();
await _databaseService.InitializeAsync();
- _service = new WinoAccountProfileService(_databaseService, _apiClient.Object);
+ _service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object);
}
public async Task DisposeAsync()
@@ -37,7 +40,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
_apiClient
.Setup(x => x.LoginAsync("first@example.com", "pw", default))
- .ReturnsAsync(ApiEnvelope.Success(authResult));
+ .ReturnsAsync(WinoAccountApiResult.Success(authResult));
var result = await _service.LoginAsync("first@example.com", "pw");
@@ -56,11 +59,11 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
{
_apiClient
.Setup(x => x.LoginAsync("first@example.com", "pw", default))
- .ReturnsAsync(ApiEnvelope.Success(CreateAuthResult("first@example.com")));
+ .ReturnsAsync(WinoAccountApiResult.Success(CreateAuthResult("first@example.com")));
_apiClient
.Setup(x => x.LoginAsync("second@example.com", "pw", default))
- .ReturnsAsync(ApiEnvelope.Success(CreateAuthResult("second@example.com")));
+ .ReturnsAsync(WinoAccountApiResult.Success(CreateAuthResult("second@example.com")));
await _service.LoginAsync("first@example.com", "pw");
await _service.LoginAsync("second@example.com", "pw");
@@ -77,7 +80,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
_apiClient
.Setup(x => x.LoginAsync("signout@example.com", "pw", default))
- .ReturnsAsync(ApiEnvelope.Success(authResult));
+ .ReturnsAsync(WinoAccountApiResult.Success(authResult));
_apiClient
.Setup(x => x.LogoutAsync(authResult.RefreshToken, default))
@@ -90,6 +93,32 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
persisted.Should().BeEmpty();
}
+ [Fact]
+ public async Task HasAddOnAsync_ShouldUseLegacyStoreForUnlimitedAccounts()
+ {
+ _storeManagementService
+ .Setup(x => x.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS))
+ .ReturnsAsync(true);
+
+ var hasAddOn = await _service.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS);
+
+ hasAddOn.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task LoginAsync_ShouldPreserveEnvelopeErrorMessage()
+ {
+ _apiClient
+ .Setup(x => x.LoginAsync("first@example.com", "pw", default))
+ .ReturnsAsync(WinoAccountApiResult.Failure(ApiErrorCodes.InvalidCredentials, "Password does not match this account."));
+
+ var result = await _service.LoginAsync("first@example.com", "pw");
+
+ result.IsSuccess.Should().BeFalse();
+ result.ErrorCode.Should().Be(ApiErrorCodes.InvalidCredentials);
+ result.ErrorMessage.Should().Be("Password does not match this account.");
+ }
+
private static AuthResultDto CreateAuthResult(string email)
{
return new AuthResultDto(
diff --git a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs
index 03184d43..ede1311c 100644
--- a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs
+++ b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs
@@ -9,7 +9,6 @@ using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
-using Wino.Core.Domain.Models.Store;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Navigation;
@@ -44,6 +43,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
protected IAccountService AccountService { get; }
protected IProviderService ProviderService { get; }
protected IStoreManagementService StoreManagementService { get; }
+ protected IWinoAccountProfileService WinoAccountProfileService { get; }
protected IAuthenticationProvider AuthenticationProvider { get; }
protected IPreferencesService PreferencesService { get; }
@@ -52,6 +52,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
IAccountService accountService,
IProviderService providerService,
IStoreManagementService storeManagementService,
+ IWinoAccountProfileService winoAccountProfileService,
IAuthenticationProvider authenticationProvider,
IPreferencesService preferencesService)
{
@@ -60,6 +61,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
AccountService = accountService;
ProviderService = providerService;
StoreManagementService = storeManagementService;
+ WinoAccountProfileService = winoAccountProfileService;
AuthenticationProvider = authenticationProvider;
PreferencesService = preferencesService;
}
@@ -75,7 +77,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
[RelayCommand]
public async Task PurchaseUnlimitedAccountAsync()
{
- var purchaseResult = await StoreManagementService.PurchaseAsync(StoreProductType.UnlimitedAccounts);
+ var purchaseResult = await StoreManagementService.PurchaseAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS);
if (purchaseResult == StorePurchaseResult.Succeeded)
DialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle, Translator.Info_PurchaseThankYouMessage, InfoBarMessageType.Success);
@@ -92,14 +94,12 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
public async Task ManageStorePurchasesAsync()
{
- await ExecuteUIThread(async () =>
- {
- HasUnlimitedAccountProduct = await StoreManagementService.HasProductAsync(StoreProductType.UnlimitedAccounts);
+ var hasUnlimitedAccountProduct = await WinoAccountProfileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
- if (!HasUnlimitedAccountProduct)
- IsAccountCreationBlocked = Accounts.Count >= FREE_ACCOUNT_COUNT;
- else
- IsAccountCreationBlocked = false;
+ await ExecuteUIThread(() =>
+ {
+ HasUnlimitedAccountProduct = hasUnlimitedAccountProduct;
+ IsAccountCreationBlocked = !hasUnlimitedAccountProduct && Accounts.Count >= FREE_ACCOUNT_COUNT;
});
}
diff --git a/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs b/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs
new file mode 100644
index 00000000..6accda61
--- /dev/null
+++ b/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs
@@ -0,0 +1,52 @@
+#nullable enable
+using CommunityToolkit.Mvvm.ComponentModel;
+using System.Windows.Input;
+using Wino.Core.Domain.Enums;
+
+namespace Wino.Core.ViewModels.Data;
+
+public partial class WinoAddOnItemViewModel : ObservableObject
+{
+ public WinoAddOnProductType ProductType { get; }
+
+ public string NameKey => $"WinoAddOn_{ProductType}_Name";
+ public string DescriptionKey => $"WinoAddOn_{ProductType}_Description";
+ public string KeywordsKey => $"WinoAddOn_{ProductType}_Keywords";
+
+ public string IconGlyph => ProductType switch
+ {
+ WinoAddOnProductType.AI_PACK => "\uE945",
+ WinoAddOnProductType.UNLIMITED_ACCOUNTS => "\uE716",
+ _ => "\uE10F"
+ };
+
+ [ObservableProperty]
+ public partial bool IsPurchased { get; set; }
+
+ public ICommand? PurchaseCommand { get; set; }
+
+ public ICommand? ManageCommand { get; set; }
+
+ [ObservableProperty]
+ public partial bool IsPurchaseInProgress { get; set; }
+
+ [ObservableProperty]
+ public partial int UsageCount { get; set; }
+
+ [ObservableProperty]
+ public partial int UsageLimit { get; set; } = 1;
+
+ [ObservableProperty]
+ public partial double UsagePercentage { get; set; }
+
+ [ObservableProperty]
+ public partial string RenewalText { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial string UsageResetText { get; set; } = string.Empty;
+
+ public WinoAddOnItemViewModel(WinoAddOnProductType productType)
+ {
+ ProductType = productType;
+ }
+}
diff --git a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs
index 29dadf8e..4b0f0969 100644
--- a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs
+++ b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs
@@ -1,17 +1,19 @@
#nullable enable
using System;
-using System.Globalization;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
-using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Navigation;
-using Wino.Mail.Api.Contracts.Ai;
-using Wino.Mail.Api.Contracts.Auth;
+using Wino.Mail.Api.Contracts.Billing;
+using Wino.Core.ViewModels.Data;
using Wino.Messaging.UI;
namespace Wino.Core.ViewModels;
@@ -20,27 +22,20 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
IRecipient,
IRecipient
{
- private const string AiPackProductId = "ai-pack-monthly";
- private const string ManageAiPackUrl = "https://example.com/wino-ai-pack/manage";
-
private readonly IWinoAccountProfileService _profileService;
+ private readonly IWinoAddOnService _addOnService;
private readonly IMailDialogService _dialogService;
private readonly INativeAppService _nativeAppService;
+ public ObservableCollection AddOns { get; } = [];
+
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSignedOut))]
- [NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool IsSignedIn { get; set; }
- [ObservableProperty]
- public partial string AccountDisplayName { get; set; } = string.Empty;
-
- [ObservableProperty]
- public partial string AccountInitials { get; set; } = string.Empty;
-
[ObservableProperty]
public partial string AccountEmail { get; set; } = string.Empty;
@@ -48,48 +43,18 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
public partial string AccountStatusText { get; set; } = string.Empty;
[ObservableProperty]
- [NotifyPropertyChangedFor(nameof(CanShowAiUsage))]
- [NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
- public partial bool HasAiPack { get; set; }
-
- [ObservableProperty]
- public partial string AiPackStateText { get; set; } = Translator.WinoAccount_Management_AiPackInactive;
-
- [ObservableProperty]
- public partial string AiUsageSummary { get; set; } = string.Empty;
-
- [ObservableProperty]
- public partial string AiBillingPeriodSummary { get; set; } = string.Empty;
-
- [ObservableProperty]
- public partial string AiPackRenewalText { get; set; } = string.Empty;
-
- [ObservableProperty]
- public partial int AiUsageCount { get; set; }
-
- [ObservableProperty]
- public partial int AiUsageLimit { get; set; } = 1;
-
- [ObservableProperty]
- public partial double AiUsagePercentage { get; set; }
-
- [ObservableProperty]
- public partial string AiUsageResetText { get; set; } = string.Empty;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(CanBuyAiPack))]
- public partial bool IsAiPackCheckoutInProgress { get; set; }
+ [NotifyCanExecuteChangedFor(nameof(PurchaseAddOnCommand))]
+ public partial bool IsCheckoutInProgress { get; set; }
public bool IsSignedOut => !IsSignedIn;
- public bool CanShowAiUsage => HasAiPack;
- public bool CanShowBuyAiPack => IsSignedIn && !HasAiPack;
- public bool CanBuyAiPack => !IsAiPackCheckoutInProgress;
public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService,
+ IWinoAddOnService addOnService,
IMailDialogService dialogService,
INativeAppService nativeAppService)
{
_profileService = profileService;
+ _addOnService = addOnService;
_dialogService = dialogService;
_nativeAppService = nativeAppService;
}
@@ -147,16 +112,15 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
InfoBarMessageType.Success);
}
- [RelayCommand]
- private async Task BuyAiPackAsync()
+ [RelayCommand(CanExecute = nameof(CanPurchaseAddOn))]
+ private async Task PurchaseAddOnAsync(WinoAddOnItemViewModel? addOn)
{
- if (IsAiPackCheckoutInProgress)
+ if (addOn == null)
{
return;
}
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
-
if (account == null)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
@@ -165,13 +129,17 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
return;
}
- await ExecuteUIThread(() => IsAiPackCheckoutInProgress = true);
+ await ExecuteUIThread(() =>
+ {
+ IsCheckoutInProgress = true;
+ addOn.IsPurchaseInProgress = true;
+ });
try
{
- var checkoutSession = await _profileService.CreateCheckoutSessionAsync(AiPackProductId).ConfigureAwait(false);
+ var checkoutSession = await _profileService.CreateCheckoutSessionAsync(addOn.ProductType).ConfigureAwait(false);
- if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result))
+ if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result?.Url))
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed,
@@ -179,8 +147,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
return;
}
- var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result)).ConfigureAwait(false);
-
+ var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result.Url)).ConfigureAwait(false);
if (!isLaunched)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
@@ -199,12 +166,49 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
}
finally
{
- await ExecuteUIThread(() => IsAiPackCheckoutInProgress = false);
+ await ExecuteUIThread(() =>
+ {
+ IsCheckoutInProgress = false;
+ addOn.IsPurchaseInProgress = false;
+ });
}
}
+ private bool CanPurchaseAddOn(WinoAddOnItemViewModel? addOn)
+ => addOn != null && !addOn.IsPurchased && !IsCheckoutInProgress;
+
[RelayCommand]
- private async Task ManageAiPackAsync() => await _nativeAppService.LaunchUriAsync(new Uri(ManageAiPackUrl));
+ private async Task ManageAiPackAsync()
+ {
+ try
+ {
+ var portalSession = await _profileService.CreateCustomerPortalSessionAsync().ConfigureAwait(false);
+ if (!portalSession.IsSuccess || string.IsNullOrWhiteSpace(portalSession.Result?.Url))
+ {
+ _dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
+ Translator.WinoAccount_Management_PurchaseStartFailed,
+ InfoBarMessageType.Error);
+ return;
+ }
+
+ var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(portalSession.Result.Url)).ConfigureAwait(false);
+ if (!isLaunched)
+ {
+ _dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
+ Translator.WinoAccount_Management_PurchaseStartFailed,
+ InfoBarMessageType.Error);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception)
+ {
+ _dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
+ Translator.WinoAccount_Management_PurchaseStartFailed,
+ InfoBarMessageType.Error);
+ }
+ }
[RelayCommand]
private Task ExportSettingsAsync() => Task.CompletedTask;
@@ -232,7 +236,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
=> _ = LoadAsync();
public void Receive(WinoAccountSignedOutMessage message)
- => _ = ResetSignedOutStateAsync();
+ => _ = LoadAsync();
private async Task LoadAsync()
{
@@ -241,44 +245,25 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
try
{
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
-
- if (account == null)
- {
- await ResetSignedOutStateAsync();
- return;
- }
-
- var currentUserResponse = await _profileService.GetCurrentUserAsync().ConfigureAwait(false);
- var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false);
-
- var resolvedUser = currentUserResponse.IsSuccess && currentUserResponse.Result != null
- ? currentUserResponse.Result
- : new AuthUserDto(account.Id, account.Email, account.AccountStatus, account.HasPassword, account.HasGoogleLogin, account.HasFacebookLogin);
+ var addOns = await _addOnService.GetAvailableAddOnsAsync().ConfigureAwait(false);
await ExecuteUIThread(() =>
{
- IsSignedIn = true;
- AccountEmail = resolvedUser.Email;
- AccountDisplayName = ExtractDisplayName(resolvedUser.Email);
- AccountInitials = ExtractInitials(resolvedUser.Email);
- AccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, resolvedUser.AccountStatus);
+ IsSignedIn = account != null;
+ AccountEmail = account?.Email ?? string.Empty;
+ AccountStatusText = account == null
+ ? string.Empty
+ : string.Format(Translator.WinoAccount_Management_StatusLabel, account.AccountStatus);
});
- UpdateAiPackState(aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null);
-
- if (!currentUserResponse.IsSuccess || !aiStatusResponse.IsSuccess)
- {
- _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
- Translator.WinoAccount_Management_LoadFailed,
- InfoBarMessageType.Warning);
- }
+ await UpdateAddOnsAsync(addOns).ConfigureAwait(false);
}
catch (Exception)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_LoadFailed,
InfoBarMessageType.Error);
- await ResetSignedOutStateAsync();
+ await ResetStateAsync().ConfigureAwait(false);
}
finally
{
@@ -286,89 +271,54 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
}
}
- private async Task ResetSignedOutStateAsync()
+ private async Task ResetStateAsync()
{
await ExecuteUIThread(() =>
{
IsSignedIn = false;
- AccountDisplayName = string.Empty;
- AccountInitials = string.Empty;
AccountEmail = string.Empty;
AccountStatusText = string.Empty;
- HasAiPack = false;
- AiPackStateText = Translator.WinoAccount_Management_AiPackInactive;
- AiUsageSummary = string.Empty;
- AiBillingPeriodSummary = string.Empty;
- AiPackRenewalText = string.Empty;
- AiUsageCount = 0;
- AiUsageLimit = 1;
- AiUsagePercentage = 0;
- AiUsageResetText = string.Empty;
- IsAiPackCheckoutInProgress = false;
+ IsCheckoutInProgress = false;
+ AddOns.Clear();
+ PurchaseAddOnCommand.NotifyCanExecuteChanged();
});
}
- private void UpdateAiPackState(AiStatusResultDto? aiStatus)
+ private async Task UpdateAddOnsAsync(IReadOnlyList addOns)
{
- var hasAiPack = aiStatus?.HasAiPack == true;
- var usageText = Translator.WinoAccount_Management_AiPackUnknownUsage;
- var billingText = string.Empty;
- var renewalText = string.Empty;
- var usageCount = 0;
- var usageLimit = 1;
- var usagePercentage = 0d;
- var resetText = string.Empty;
+ var items = addOns.Select(CreateAddOnItem).ToList();
- if (hasAiPack && aiStatus?.Used is int used && aiStatus.MonthlyLimit is int limit && aiStatus.Remaining is int remaining)
+ await ExecuteUIThread(() =>
{
- usageText = string.Format(Translator.WinoAccount_Management_AiPackUsage, used, limit, remaining);
- usageCount = used;
- usageLimit = limit > 0 ? limit : 1;
- usagePercentage = (double)used / usageLimit * 100;
- }
+ AddOns.Clear();
- if (hasAiPack && aiStatus?.CurrentPeriodStartUtc is DateTimeOffset periodStart && aiStatus.CurrentPeriodEndUtc is DateTimeOffset periodEnd)
- {
- billingText = string.Format(Translator.WinoAccount_Management_AiPackBillingPeriod, periodStart.LocalDateTime, periodEnd.LocalDateTime);
- renewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, periodEnd.LocalDateTime);
- resetText = string.Format(Translator.WinoAccount_Management_AiPackResets, periodEnd.LocalDateTime);
- }
+ foreach (var item in items)
+ {
+ AddOns.Add(item);
+ }
- _ = ExecuteUIThread(() =>
- {
- HasAiPack = hasAiPack;
- AiPackStateText = hasAiPack
- ? Translator.WinoAccount_Management_AiPackActive
- : Translator.WinoAccount_Management_AiPackInactive;
- AiUsageSummary = hasAiPack ? usageText : string.Empty;
- AiBillingPeriodSummary = hasAiPack ? billingText : string.Empty;
- AiPackRenewalText = hasAiPack ? renewalText : string.Empty;
- AiUsageCount = usageCount;
- AiUsageLimit = usageLimit;
- AiUsagePercentage = usagePercentage;
- AiUsageResetText = hasAiPack ? resetText : string.Empty;
+ PurchaseAddOnCommand.NotifyCanExecuteChanged();
});
}
- private static string ExtractDisplayName(string email)
+ private WinoAddOnItemViewModel CreateAddOnItem(WinoAddOnInfo addOn)
{
- if (string.IsNullOrWhiteSpace(email))
- return string.Empty;
+ var item = new WinoAddOnItemViewModel(addOn.ProductType)
+ {
+ IsPurchased = addOn.IsPurchased,
+ PurchaseCommand = PurchaseAddOnCommand,
+ ManageCommand = ManageAiPackCommand,
+ UsageCount = addOn.UsageCount ?? 0,
+ UsageLimit = addOn.UsageLimit is > 0 ? addOn.UsageLimit.Value : 1,
+ UsagePercentage = addOn.UsagePercentage
+ };
- var atIndex = email.IndexOf('@');
- var localPart = atIndex > 0 ? email[..atIndex] : email;
+ if (addOn.RenewalDateUtc is DateTimeOffset renewalDateUtc)
+ {
+ item.RenewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, renewalDateUtc.LocalDateTime);
+ item.UsageResetText = string.Format(Translator.WinoAccount_Management_AiPackResets, renewalDateUtc.LocalDateTime);
+ }
- if (localPart.Length == 0)
- return string.Empty;
-
- return char.ToUpper(localPart[0], CultureInfo.CurrentCulture) + localPart[1..];
- }
-
- private static string ExtractInitials(string email)
- {
- var displayName = ExtractDisplayName(email);
- return displayName.Length > 0
- ? displayName[..1].ToUpper(CultureInfo.CurrentCulture)
- : string.Empty;
+ return item;
}
}
diff --git a/Wino.Mail.ViewModels/AccountManagementViewModel.cs b/Wino.Mail.ViewModels/AccountManagementViewModel.cs
index 50c2d46f..2c531298 100644
--- a/Wino.Mail.ViewModels/AccountManagementViewModel.cs
+++ b/Wino.Mail.ViewModels/AccountManagementViewModel.cs
@@ -37,11 +37,12 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
IAccountService accountService,
IProviderService providerService,
IStoreManagementService storeManagementService,
+ IWinoAccountProfileService winoAccountProfileService,
IWinoLogger winoLogger,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
ICalDavClient calDavClient,
IAuthenticationProvider authenticationProvider,
- IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService)
+ IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, winoAccountProfileService, authenticationProvider, preferencesService)
{
MailDialogService = dialogService;
_winoLogger = winoLogger;
diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs
index bd88b239..efb001b9 100644
--- a/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs
+++ b/Wino.Mail.WinUI/Dialogs/WinoAccountLoginDialog.xaml.cs
@@ -62,7 +62,7 @@ public sealed partial class WinoAccountLoginDialog : ContentDialog
if (!result.IsSuccess || result.Account == null)
{
- ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode));
+ ShowError(WinoAccountAuthErrorTranslator.Format(result.ErrorCode, result.ErrorMessage));
return;
}
diff --git a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs
index ab049102..5fbd6ad9 100644
--- a/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs
+++ b/Wino.Mail.WinUI/Dialogs/WinoAccountRegistrationDialog.xaml.cs
@@ -64,7 +64,7 @@ public sealed partial class WinoAccountRegistrationDialog : ContentDialog
if (!result.IsSuccess || result.Account == null)
{
- ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode));
+ ShowError(WinoAccountAuthErrorTranslator.Format(result.ErrorCode, result.ErrorMessage));
return;
}
diff --git a/Wino.Mail.WinUI/Selectors/WinoAddOnTemplateSelector.cs b/Wino.Mail.WinUI/Selectors/WinoAddOnTemplateSelector.cs
new file mode 100644
index 00000000..47401360
--- /dev/null
+++ b/Wino.Mail.WinUI/Selectors/WinoAddOnTemplateSelector.cs
@@ -0,0 +1,30 @@
+using System;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Wino.Core.Domain.Enums;
+using Wino.Core.ViewModels.Data;
+
+namespace Wino.Selectors;
+
+public partial class WinoAddOnTemplateSelector : DataTemplateSelector
+{
+ public DataTemplate? NotPurchasedTemplate { get; set; }
+ public DataTemplate? AiPackPurchasedTemplate { get; set; }
+ public DataTemplate? UnlimitedAccountsPurchasedTemplate { get; set; }
+
+ protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
+ {
+ if (item is not WinoAddOnItemViewModel addOn)
+ throw new ArgumentException(nameof(item));
+
+ if (!addOn.IsPurchased)
+ return NotPurchasedTemplate ?? throw new ArgumentException(nameof(NotPurchasedTemplate));
+
+ return addOn.ProductType switch
+ {
+ WinoAddOnProductType.AI_PACK => AiPackPurchasedTemplate ?? throw new ArgumentException(nameof(AiPackPurchasedTemplate)),
+ WinoAddOnProductType.UNLIMITED_ACCOUNTS => UnlimitedAccountsPurchasedTemplate ?? throw new ArgumentException(nameof(UnlimitedAccountsPurchasedTemplate)),
+ _ => NotPurchasedTemplate ?? throw new ArgumentException(nameof(NotPurchasedTemplate))
+ };
+ }
+}
diff --git a/Wino.Mail.WinUI/Services/StoreManagementService.cs b/Wino.Mail.WinUI/Services/StoreManagementService.cs
index c39a0da3..a0e11121 100644
--- a/Wino.Mail.WinUI/Services/StoreManagementService.cs
+++ b/Wino.Mail.WinUI/Services/StoreManagementService.cs
@@ -3,8 +3,8 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Windows.Services.Store;
using Wino.Core.Domain.Interfaces;
-using Wino.Core.Domain.Models.Store;
using WinoStorePurchaseResult = Wino.Core.Domain.Enums.StorePurchaseResult;
+using WinoAddOnProductType = Wino.Core.Domain.Enums.WinoAddOnProductType;
namespace Wino.Mail.WinUI.Services;
@@ -12,14 +12,14 @@ public class StoreManagementService : IStoreManagementService
{
private StoreContext CurrentContext { get; }
- private readonly Dictionary productIds = new Dictionary()
+ private readonly Dictionary productIds = new Dictionary()
{
- { StoreProductType.UnlimitedAccounts, "UnlimitedAccounts" }
+ { WinoAddOnProductType.UNLIMITED_ACCOUNTS, "UnlimitedAccounts" }
};
- private readonly Dictionary skuIds = new Dictionary()
+ private readonly Dictionary skuIds = new Dictionary()
{
- { StoreProductType.UnlimitedAccounts, "9P02MXZ42GSM" }
+ { WinoAddOnProductType.UNLIMITED_ACCOUNTS, "9P02MXZ42GSM" }
};
public StoreManagementService()
@@ -27,9 +27,11 @@ public class StoreManagementService : IStoreManagementService
CurrentContext = StoreContext.GetDefault();
}
- public async Task HasProductAsync(StoreProductType productType)
+ public async Task HasProductAsync(WinoAddOnProductType productType)
{
- var productKey = productIds[productType];
+ if (!productIds.TryGetValue(productType, out var productKey))
+ return false;
+
var appLicense = await CurrentContext.GetAppLicenseAsync();
if (appLicense == null)
@@ -49,14 +51,15 @@ public class StoreManagementService : IStoreManagementService
return false;
}
- public async Task PurchaseAsync(StoreProductType productType)
+ public async Task PurchaseAsync(WinoAddOnProductType productType)
{
+ if (!skuIds.TryGetValue(productType, out var productKey))
+ return WinoStorePurchaseResult.NotPurchased;
+
if (await HasProductAsync(productType))
return WinoStorePurchaseResult.AlreadyPurchased;
else
{
- var productKey = skuIds[productType];
-
var result = await CurrentContext.RequestPurchaseAsync(productKey);
switch (result.Status)
diff --git a/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs
index 68152833..22e7b1f0 100644
--- a/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs
+++ b/Wino.Mail.WinUI/Services/WinoAccountAuthErrorTranslator.cs
@@ -30,4 +30,34 @@ public static class WinoAccountAuthErrorTranslator
_ => errorCode
};
}
+
+ public static string Format(string? errorCode, string? errorMessage)
+ {
+ var translatedCode = Translate(errorCode);
+ var hasCode = !string.IsNullOrWhiteSpace(errorCode);
+ var hasMessage = !string.IsNullOrWhiteSpace(errorMessage);
+
+ if (!hasCode && !hasMessage)
+ {
+ return Translator.GeneralTitle_Error;
+ }
+
+ var formattedCode = translatedCode;
+ if (hasCode && !string.Equals(translatedCode, errorCode, System.StringComparison.Ordinal))
+ {
+ formattedCode = $"{translatedCode} ({errorCode})";
+ }
+
+ if (!hasMessage || string.Equals(errorMessage, translatedCode, System.StringComparison.OrdinalIgnoreCase) || string.Equals(errorMessage, errorCode, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return formattedCode;
+ }
+
+ if (string.IsNullOrWhiteSpace(formattedCode))
+ {
+ return errorMessage!;
+ }
+
+ return $"{formattedCode}{System.Environment.NewLine}{errorMessage}";
+ }
}
diff --git a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml
index 458768ba..92c86f0a 100644
--- a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml
+++ b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml
@@ -7,13 +7,172 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:selectors="using:Wino.Selectors"
+ xmlns:viewModelData="using:Wino.Core.ViewModels.Data"
+ x:Name="root"
Title="{x:Bind domain:Translator.WinoAccount_SettingsSection_Title}"
mc:Ignorable="d">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+ Text="{x:Bind domain:Translator.WinoAccount_Management_AddOnsSectionHeader}" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+ Text="{x:Bind domain:Translator.WinoAccount_Management_AddOnsSectionHeader}" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
-
+
+
+
+
-
-
-
-
-
-
-
-
();
services.AddSingleton();
services.AddTransient();
+ services.AddTransient();
services.AddSingleton();
services.AddTransient();
diff --git a/Wino.Services/WinoAccountApiClient.cs b/Wino.Services/WinoAccountApiClient.cs
index 2bcacd8c..ea1ac65d 100644
--- a/Wino.Services/WinoAccountApiClient.cs
+++ b/Wino.Services/WinoAccountApiClient.cs
@@ -11,9 +11,12 @@ using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
+using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common;
namespace Wino.Services;
@@ -46,13 +49,13 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
_ownsHttpClient = true;
}
- public Task> RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
+ public Task> RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
=> SendAuthRequestAsync("api/v1/auth/register", new RegisterRequest(email, password), WinoAccountApiJsonContext.Default.RegisterRequest, cancellationToken);
- public Task> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
+ public Task> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
=> SendAuthRequestAsync("api/v1/auth/login", new LoginRequest(email, password), WinoAccountApiJsonContext.Default.LoginRequest, cancellationToken);
- public Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
+ public Task> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
=> SendAuthRequestAsync("api/v1/auth/refresh", new RefreshRequest(refreshToken), WinoAccountApiJsonContext.Default.RefreshRequest, cancellationToken);
public async Task> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default)
@@ -84,20 +87,27 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
public Task> GetAiStatusAsync(CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken);
- public Task> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default)
+ public Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
{
var endpoint = productId switch
{
- "ai-pack-monthly" => "api/v1/billing/ai-pack/checkout-session",
- "unlimited-accounts" => "api/v1/billing/unlimited-accounts/checkout-session",
+ WinoAddOnProductType.AI_PACK => "api/v1/billing/ai-pack/checkout-session",
+ WinoAddOnProductType.UNLIMITED_ACCOUNTS => "api/v1/billing/unlimited-accounts/checkout-session",
_ => string.Empty
};
return string.IsNullOrWhiteSpace(endpoint)
- ? Task.FromResult(ApiEnvelope.Failure("UnknownProduct"))
- : SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeString, cancellationToken);
+ ? Task.FromResult(ApiEnvelope.Failure("UnknownProduct"))
+ : SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeCheckoutSessionResultDto, cancellationToken);
}
+ public Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default)
+ => SendAuthorizedRequestAsync(
+ HttpMethod.Post,
+ "api/v1/billing/ai-pack/customer-portal-session",
+ WinoAccountApiJsonContext.Default.ApiEnvelopeCustomerPortalResultDto,
+ cancellationToken);
+
public async Task GetSettingsAsync(CancellationToken cancellationToken = default)
{
try
@@ -141,7 +151,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
}
}
- private async Task> SendAuthRequestAsync(string endpoint, TRequest request, JsonTypeInfo typeInfo, CancellationToken cancellationToken)
+ private async Task> SendAuthRequestAsync(string endpoint, TRequest request, JsonTypeInfo typeInfo, CancellationToken cancellationToken)
{
try
{
@@ -156,14 +166,83 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
? null
: JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeAuthResultDto);
- return envelope ?? ApiEnvelope.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim());
+ if (envelope?.IsSuccess == true && envelope.Result != null)
+ {
+ return WinoAccountApiResult.Success(envelope.Result);
+ }
+
+ var errorCode = envelope?.ErrorCode ?? $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim();
+ var errorMessage = ExtractErrorMessage(payload) ?? response.ReasonPhrase;
+
+ return WinoAccountApiResult.Failure(errorCode, errorMessage);
}
catch (Exception ex)
{
- return ApiEnvelope.Failure(ex.Message);
+ return WinoAccountApiResult.Failure(ex.GetType().Name, ex.Message);
}
}
+ private static string? ExtractErrorMessage(string? payload)
+ {
+ if (string.IsNullOrWhiteSpace(payload))
+ {
+ return null;
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(payload);
+ return TryGetErrorMessage(document.RootElement);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static string? TryGetErrorMessage(JsonElement element)
+ {
+ if (TryGetStringProperty(element, "errorMessage", out var errorMessage))
+ {
+ return errorMessage;
+ }
+
+ if (TryGetStringProperty(element, "message", out var message))
+ {
+ return message;
+ }
+
+ if (TryGetStringProperty(element, "detail", out var detail))
+ {
+ return detail;
+ }
+
+ if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("error", out var errorElement))
+ {
+ return TryGetErrorMessage(errorElement);
+ }
+
+ return null;
+ }
+
+ private static bool TryGetStringProperty(JsonElement element, string propertyName, out string? value)
+ {
+ value = null;
+
+ if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
+ {
+ return false;
+ }
+
+ if (property.ValueKind != JsonValueKind.String)
+ {
+ return false;
+ }
+
+ value = property.GetString();
+ return !string.IsNullOrWhiteSpace(value);
+ }
+
private Task> SendAuthorizedRequestAsync(string endpoint, JsonTypeInfo> typeInfo, CancellationToken cancellationToken)
=> SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken);
@@ -234,6 +313,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
[JsonSerializable(typeof(ApiEnvelope))]
[JsonSerializable(typeof(ApiEnvelope))]
[JsonSerializable(typeof(ApiEnvelope))]
-[JsonSerializable(typeof(ApiEnvelope))]
+[JsonSerializable(typeof(ApiEnvelope))]
+[JsonSerializable(typeof(ApiEnvelope))]
[JsonSerializable(typeof(ApiEnvelope))]
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
diff --git a/Wino.Services/WinoAccountProfileService.cs b/Wino.Services/WinoAccountProfileService.cs
index d7dbc4a5..8ed48da4 100644
--- a/Wino.Services/WinoAccountProfileService.cs
+++ b/Wino.Services/WinoAccountProfileService.cs
@@ -1,13 +1,16 @@
#nullable enable
using System;
+using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
+using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common;
using Wino.Messaging.UI;
@@ -16,11 +19,15 @@ namespace Wino.Services;
public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccountProfileService
{
private readonly IWinoAccountApiClient _apiClient;
+ private readonly IStoreManagementService _storeManagementService;
private readonly ILogger _logger = Log.ForContext();
- public WinoAccountProfileService(IDatabaseService databaseService, IWinoAccountApiClient apiClient) : base(databaseService)
+ public WinoAccountProfileService(IDatabaseService databaseService,
+ IWinoAccountApiClient apiClient,
+ IStoreManagementService storeManagementService) : base(databaseService)
{
_apiClient = apiClient;
+ _storeManagementService = storeManagementService;
}
public async Task RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
@@ -108,6 +115,16 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
public async Task HasActiveAccountAsync()
=> await Connection.Table().CountAsync().ConfigureAwait(false) > 0;
+ public async Task HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
+ {
+ return productId switch
+ {
+ WinoAddOnProductType.AI_PACK => await HasAiPackAsync(cancellationToken).ConfigureAwait(false),
+ WinoAddOnProductType.UNLIMITED_ACCOUNTS => await HasUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false),
+ _ => false
+ };
+ }
+
public async Task> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
@@ -142,12 +159,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return response;
}
- public async Task> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default)
+ public async Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
{
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
if (account == null)
{
- return ApiEnvelope.Failure("MissingAccessToken");
+ return ApiEnvelope.Failure("MissingAccessToken");
}
var response = await _apiClient.CreateCheckoutSessionAsync(productId, cancellationToken).ConfigureAwait(false);
@@ -159,6 +176,23 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return response;
}
+ public async Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default)
+ {
+ var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
+ if (account == null)
+ {
+ return ApiEnvelope.Failure("MissingAccessToken");
+ }
+
+ var response = await _apiClient.CreateCustomerPortalSessionAsync(cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccess)
+ {
+ _logger.Warning("Failed to create customer portal session for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
+ }
+
+ return response;
+ }
+
public async Task SignOutAsync(CancellationToken cancellationToken = default)
{
var account = await GetActiveAccountAsync().ConfigureAwait(false);
@@ -187,12 +221,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
}
}
- private async Task PersistResponseAsync(ApiEnvelope response)
+ private async Task PersistResponseAsync(WinoAccountApiResult response)
{
if (!response.IsSuccess || response.Result == null)
{
- _logger.Warning("Wino account operation failed. Error code: {ErrorCode}", response.ErrorCode);
- return WinoAccountOperationResult.Failure(response.ErrorCode);
+ _logger.Warning("Wino account operation failed. Error code: {ErrorCode}. Error message: {ErrorMessage}", response.ErrorCode, response.ErrorMessage);
+ return WinoAccountOperationResult.Failure(response.ErrorCode, response.ErrorMessage);
}
var account = Map(response.Result);
@@ -203,6 +237,48 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return WinoAccountOperationResult.Success(account);
}
+ private async Task HasAiPackAsync(CancellationToken cancellationToken)
+ {
+ var response = await GetAiStatusAsync(cancellationToken).ConfigureAwait(false);
+ return response.IsSuccess && response.Result?.HasAiPack == true;
+ }
+
+ private async Task HasUnlimitedAccountsAsync(CancellationToken cancellationToken)
+ {
+ if (await HasRemoteUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false))
+ {
+ return true;
+ }
+
+ return await _storeManagementService.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
+ }
+
+ private async Task HasRemoteUnlimitedAccountsAsync(CancellationToken cancellationToken)
+ {
+ var response = await GetCurrentUserAsync(cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccess || response.Result == null)
+ {
+ return false;
+ }
+
+ return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts;
+ }
+
+ [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The reflected contract property is a stable API field read from a concrete DTO instance.")]
+ private static bool TryGetBooleanProperty(object instance, string propertyName, out bool value)
+ {
+ value = false;
+
+ var property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
+ if (property?.PropertyType != typeof(bool))
+ {
+ return false;
+ }
+
+ value = (bool)(property.GetValue(instance) ?? false);
+ return true;
+ }
+
private static WinoAccount Map(AuthResultDto result)
=> new()
{
diff --git a/Wino.Services/WinoAddOnService.cs b/Wino.Services/WinoAddOnService.cs
new file mode 100644
index 00000000..dd069453
--- /dev/null
+++ b/Wino.Services/WinoAddOnService.cs
@@ -0,0 +1,52 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Accounts;
+
+namespace Wino.Services;
+
+public sealed class WinoAddOnService : IWinoAddOnService
+{
+ private static readonly WinoAddOnProductType[] AvailableAddOns =
+ [
+ WinoAddOnProductType.AI_PACK,
+ WinoAddOnProductType.UNLIMITED_ACCOUNTS
+ ];
+
+ private readonly IWinoAccountProfileService _profileService;
+
+ public WinoAddOnService(IWinoAccountProfileService profileService)
+ {
+ _profileService = profileService;
+ }
+
+ public async Task> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default)
+ {
+ var aiStatusTask = _profileService.GetAiStatusAsync(cancellationToken);
+ var hasUnlimitedAccountsTask = _profileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS, cancellationToken);
+
+ await Task.WhenAll(aiStatusTask, hasUnlimitedAccountsTask).ConfigureAwait(false);
+
+ var aiStatusResponse = await aiStatusTask.ConfigureAwait(false);
+ var aiStatus = aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null;
+
+ return
+ [
+ new WinoAddOnInfo(
+ WinoAddOnProductType.AI_PACK,
+ aiStatus?.HasAiPack == true,
+ aiStatus?.Used,
+ aiStatus?.MonthlyLimit,
+ aiStatus?.HasAiPack == true && aiStatus.MonthlyLimit is int limit && limit > 0 && aiStatus.Used is int used
+ ? (double)used / limit * 100
+ : 0,
+ aiStatus?.CurrentPeriodEndUtc),
+ new WinoAddOnInfo(
+ WinoAddOnProductType.UNLIMITED_ACCOUNTS,
+ await hasUnlimitedAccountsTask.ConfigureAwait(false))
+ ];
+ }
+}