diff --git a/Directory.Packages.props b/Directory.Packages.props index 97c038f1..dde3211e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,7 +33,7 @@ - + diff --git a/Wino.Core.Domain/Entities/Shared/WinoAccountAddOnCache.cs b/Wino.Core.Domain/Entities/Shared/WinoAccountAddOnCache.cs deleted file mode 100644 index aae4a63e..00000000 --- a/Wino.Core.Domain/Entities/Shared/WinoAccountAddOnCache.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using SQLite; - -namespace Wino.Core.Domain.Entities.Shared; - -public class WinoAccountAddOnCache -{ - [PrimaryKey] - public Guid AccountId { get; set; } - - public bool HasAiPack { get; set; } - - public int? AiUsageCount { get; set; } - - public int? AiUsageLimit { get; set; } - - public DateTime? AiBillingPeriodStartUtc { get; set; } - - public DateTime? AiBillingPeriodEndUtc { get; set; } - - public bool HasUnlimitedAccounts { get; set; } - - public DateTime LastUpdatedUtc { get; set; } -} diff --git a/Wino.Core.Domain/Interfaces/IStoreManagementService.cs b/Wino.Core.Domain/Interfaces/IStoreManagementService.cs index 7f7550cb..bd945540 100644 --- a/Wino.Core.Domain/Interfaces/IStoreManagementService.cs +++ b/Wino.Core.Domain/Interfaces/IStoreManagementService.cs @@ -1,5 +1,7 @@ -using System.Threading.Tasks; +#nullable enable +using System.Threading.Tasks; using Wino.Core.Domain.Enums; + namespace Wino.Core.Domain.Interfaces; public interface IStoreManagementService @@ -13,4 +15,14 @@ public interface IStoreManagementService /// Attempts to purchase the given add-on. /// Task PurchaseAsync(WinoAddOnProductType productType); + + /// + /// Requests a Microsoft Store collections ID key for the current customer. + /// + Task GetCustomerCollectionsIdAsync(string serviceTicket, string publisherUserId); + + /// + /// Requests a Microsoft Store purchase ID key for the current customer. + /// + Task GetCustomerPurchaseIdAsync(string serviceTicket, string publisherUserId); } diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs index 4685acd4..615de7d6 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountApiClient.cs @@ -2,11 +2,9 @@ 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; @@ -24,8 +22,9 @@ public interface IWinoAccountApiClient Task> SummarizeAsync(string html, CancellationToken cancellationToken = default); Task> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default); Task> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default); - Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); - Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default); + Task> CreateCollectionsIdTicketAsync(CancellationToken cancellationToken = default); + Task> CreatePurchaseIdTicketAsync(CancellationToken cancellationToken = default); + Task> SyncStoreEntitlementsAsync(string? storeIdKey, string? purchaseIdKey, 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 8326cde3..66083d43 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs @@ -4,11 +4,9 @@ using System.Text.Json; 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; @@ -22,17 +20,14 @@ public interface IWinoAccountProfileService Task> ResendEmailConfirmationAsync(string endpoint, string ticket, CancellationToken cancellationToken = default); Task> ForgotPasswordAsync(string email, CancellationToken cancellationToken = default); Task GetActiveAccountAsync(); - Task GetCachedAddOnSnapshotAsync(); 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> SummarizeAsync(string html, CancellationToken cancellationToken = default); Task> TranslateAsync(string html, string targetLanguage, CancellationToken cancellationToken = default); Task> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default); - Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); - Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default); + Task> SyncStoreEntitlementsAsync(CancellationToken cancellationToken = default); Task ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default); Task SignOutAsync(CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs b/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs deleted file mode 100644 index 9d5b36eb..00000000 --- a/Wino.Core.Domain/Interfaces/IWinoAddOnService.cs +++ /dev/null @@ -1,12 +0,0 @@ -#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(bool useCachedDataOnly = false, CancellationToken cancellationToken = default); -} diff --git a/Wino.Core.Domain/Models/Accounts/WinoAccountAddOnSnapshot.cs b/Wino.Core.Domain/Models/Accounts/WinoAccountAddOnSnapshot.cs deleted file mode 100644 index 55affe1f..00000000 --- a/Wino.Core.Domain/Models/Accounts/WinoAccountAddOnSnapshot.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace Wino.Core.Domain.Models.Accounts; - -public sealed record WinoAccountAddOnSnapshot( - bool HasAiPack, - int? UsageCount = null, - int? UsageLimit = null, - DateTimeOffset? BillingPeriodStartUtc = null, - DateTimeOffset? BillingPeriodEndUtc = null, - bool HasUnlimitedAccounts = false, - DateTimeOffset? LastUpdatedUtc = null); diff --git a/Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs b/Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs deleted file mode 100644 index fd553f8a..00000000 --- a/Wino.Core.Domain/Models/Accounts/WinoAddOnInfo.cs +++ /dev/null @@ -1,12 +0,0 @@ -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/Accounts/WinoStoreCollectionsIdTicketInfo.cs b/Wino.Core.Domain/Models/Accounts/WinoStoreCollectionsIdTicketInfo.cs new file mode 100644 index 00000000..19278214 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/WinoStoreCollectionsIdTicketInfo.cs @@ -0,0 +1,9 @@ +#nullable enable +using System; + +namespace Wino.Core.Domain.Models.Accounts; + +public sealed record WinoStoreCollectionsIdTicketInfo( + string ServiceTicket, + string PublisherUserId, + DateTimeOffset ExpiresAtUtc); diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 5455d5f9..4e592398 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1240,14 +1240,17 @@ "WinoAddOn_UNLIMITED_ACCOUNTS_Description": "Remove the account limit and add as many mail accounts as you need.", "WinoAddOn_UNLIMITED_ACCOUNTS_Keywords": "accounts, unlimited, premium, add-on", "WinoAccount_Management_PurchaseRequiresSignIn": "Sign in with your Wino Account to complete this purchase.", - "WinoAccount_Management_PurchaseStartFailed": "Wino could not start the checkout session for this add-on.", + "WinoAccount_Management_PurchaseStartFailed": "Wino could not complete this Microsoft Store purchase.", + "WinoAccount_Management_StoreSyncFailed": "Your purchase finished, but Wino could not refresh your account benefits yet. Please try again in a moment.", "WinoAccount_Management_AiPackSubscriptionActive": "Your subscription is active", "WinoAccount_Management_AiPackRenews": "Renews {0:d}", "WinoAccount_Management_AiPackRequestsUsed": "Requests used this month", "WinoAccount_Management_AiPackResets": "Resets {0:d}", + "WinoAccount_Management_AiPackUsageLoadFailed": "We had issues loading your AI usage balance.", "WinoAccount_Management_AiPackFeatureTranslate": "Translate", "WinoAccount_Management_AiPackFeatureRewrite": "Rewrite", "WinoAccount_Management_AiPackFeatureSummarize": "Summarize", + "WinoAccount_Management_AddOnLoadFailed": "We had issues loading this add-on.", "WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences", "WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.", "WinoAccount_Management_SignOutTitle": "Sign out", diff --git a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs index 498fbfd4..3bc97b3d 100644 --- a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs +++ b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs @@ -27,7 +27,6 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime { _databaseService = new InMemoryDatabaseService(); await _databaseService.InitializeAsync(); - await _databaseService.Connection.CreateTableAsync(); _service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object); } @@ -96,18 +95,6 @@ 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() { @@ -190,8 +177,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime "Premium", authResult.User.HasPassword, authResult.User.HasGoogleLogin, - authResult.User.HasFacebookLogin, - authResult.User.HasUnlimitedAccounts))); + authResult.User.HasFacebookLogin))); await _service.LoginAsync("first@example.com", "pw"); @@ -237,7 +223,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime } [Fact] - public async Task SummarizeAsync_ShouldPersistQuotaSnapshot() + public async Task SummarizeAsync_ShouldReturnQuotaBackedResponse() { var authResult = CreateAuthResult("first@example.com"); @@ -265,18 +251,12 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime response.IsSuccess.Should().BeTrue(); response.Result?.Html.Should().Be("

Summary

"); - - var cachedSnapshot = await _service.GetCachedAddOnSnapshotAsync(); - cachedSnapshot.Should().NotBeNull(); - cachedSnapshot!.HasAiPack.Should().BeTrue(); - cachedSnapshot.UsageCount.Should().Be(4); - cachedSnapshot.UsageLimit.Should().Be(1000); } private static AuthResultDto CreateAuthResult(string email) { return new AuthResultDto( - new AuthUserDto(Guid.NewGuid(), email, "Active", true, false, false, false), + new AuthUserDto(Guid.NewGuid(), email, "Active", true, false, false), "access-token", DateTimeOffset.UtcNow.AddMinutes(30), "refresh-token", diff --git a/Wino.Core.Tests/Synchronizers/GmailSynchronizerRequestSuccessTests.cs b/Wino.Core.Tests/Synchronizers/GmailSynchronizerRequestSuccessTests.cs new file mode 100644 index 00000000..0e82a9b0 --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/GmailSynchronizerRequestSuccessTests.cs @@ -0,0 +1,187 @@ +using System.Net; +using System.Net.Http; +using System.Reflection; +using FluentAssertions; +using Google.Apis.Requests; +using Moq; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Integration.Processors; +using Wino.Core.Requests.Bundles; +using Wino.Core.Requests.Mail; +using Wino.Core.Synchronizers.Mail; +using Xunit; + +namespace Wino.Core.Tests.Synchronizers; + +public sealed class GmailSynchronizerRequestSuccessTests +{ + [Fact] + public async Task ProcessSingleNativeRequestResponseAsync_BatchMarkReadRequest_PersistsLocalReadStateForEachMail() + { + var changeProcessor = new Mock(MockBehavior.Strict); + changeProcessor + .Setup(x => x.ChangeMailReadStatusAsync("mail-1", true)) + .Returns(Task.CompletedTask); + changeProcessor + .Setup(x => x.ChangeMailReadStatusAsync("mail-2", true)) + .Returns(Task.CompletedTask); + + var synchronizer = CreateSynchronizer(changeProcessor.Object); + var request = new BatchMarkReadRequest( + [ + new MarkReadRequest(CreateMailCopy("mail-1"), IsRead: true), + new MarkReadRequest(CreateMailCopy("mail-2"), IsRead: true) + ]); + var bundle = new HttpRequestBundle(Mock.Of(), request); + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty) + }; + + await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response); + + changeProcessor.Verify(x => x.ChangeMailReadStatusAsync("mail-1", true), Times.Once); + changeProcessor.Verify(x => x.ChangeMailReadStatusAsync("mail-2", true), Times.Once); + } + + [Fact] + public async Task ProcessSingleNativeRequestResponseAsync_BatchChangeFlagRequest_PersistsLocalFlagStateForEachMail() + { + var changeProcessor = new Mock(MockBehavior.Strict); + changeProcessor + .Setup(x => x.ChangeFlagStatusAsync("mail-1", true)) + .Returns(Task.CompletedTask); + changeProcessor + .Setup(x => x.ChangeFlagStatusAsync("mail-2", true)) + .Returns(Task.CompletedTask); + + var synchronizer = CreateSynchronizer(changeProcessor.Object); + var request = new BatchChangeFlagRequest( + [ + new ChangeFlagRequest(CreateMailCopy("mail-1"), IsFlagged: true), + new ChangeFlagRequest(CreateMailCopy("mail-2"), IsFlagged: true) + ]); + var bundle = new HttpRequestBundle(Mock.Of(), request); + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty) + }; + + await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response); + + changeProcessor.Verify(x => x.ChangeFlagStatusAsync("mail-1", true), Times.Once); + changeProcessor.Verify(x => x.ChangeFlagStatusAsync("mail-2", true), Times.Once); + } + + [Fact] + public async Task ProcessSingleNativeRequestResponseAsync_HandledRequestError_DoesNotPersistLocalReadState() + { + var changeProcessor = new Mock(MockBehavior.Strict); + var errorFactory = new Mock(MockBehavior.Strict); + errorFactory + .Setup(x => x.HandleErrorAsync(It.IsAny())) + .ReturnsAsync(true); + + var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object); + var request = new BatchMarkReadRequest( + [ + new MarkReadRequest(CreateMailCopy("mail-1"), IsRead: true) + ]); + var bundle = new HttpRequestBundle(Mock.Of(), request); + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty) + }; + var error = new RequestError + { + Code = 429, + Message = "rate limit" + }; + + await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error); + + changeProcessor.Verify(x => x.ChangeMailReadStatusAsync(It.IsAny(), It.IsAny()), Times.Never); + errorFactory.Verify(x => x.HandleErrorAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessSingleNativeRequestResponseAsync_HandledRequestError_RevertsOptimisticReadState() + { + var changeProcessor = new Mock(MockBehavior.Strict); + var errorFactory = new Mock(MockBehavior.Strict); + errorFactory + .Setup(x => x.HandleErrorAsync(It.IsAny())) + .ReturnsAsync(true); + + var mail = CreateMailCopy("mail-1"); + var request = new BatchMarkReadRequest( + [ + new MarkReadRequest(mail, IsRead: true) + ]); + request.ApplyUIChanges(); + + var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object); + var bundle = new HttpRequestBundle(Mock.Of(), request); + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty) + }; + var error = new RequestError + { + Code = 429, + Message = "rate limit" + }; + + await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error); + + mail.IsRead.Should().BeFalse(); + changeProcessor.Verify(x => x.ChangeMailReadStatusAsync(It.IsAny(), It.IsAny()), Times.Never); + errorFactory.Verify(x => x.HandleErrorAsync(It.IsAny()), Times.Once); + } + + private static GmailSynchronizer CreateSynchronizer( + IGmailChangeProcessor changeProcessor, + IGmailSynchronizerErrorHandlerFactory? errorFactory = null) + { + var account = new MailAccount + { + Id = Guid.NewGuid(), + Name = "Gmail", + Address = "user@example.com" + }; + + var authenticator = new Mock(MockBehavior.Loose); + + return new GmailSynchronizer(account, authenticator.Object, changeProcessor, errorFactory ?? Mock.Of()); + } + + private static MailCopy CreateMailCopy(string id) => + new() + { + UniqueId = Guid.NewGuid(), + Id = id, + FolderId = Guid.NewGuid(), + IsRead = false, + IsFlagged = false + }; + + private static async Task InvokeProcessSingleNativeRequestResponseAsync( + GmailSynchronizer synchronizer, + HttpRequestBundle bundle, + HttpResponseMessage response, + RequestError? error = null) + { + var method = typeof(GmailSynchronizer).GetMethod( + "ProcessSingleNativeRequestResponseAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + + method.Should().NotBeNull(); + + var task = method!.Invoke(synchronizer, [bundle, error, response, CancellationToken.None]) as Task; + task.Should().NotBeNull(); + await task!; + } +} diff --git a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs index ede1311c..5b407df5 100644 --- a/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs +++ b/Wino.Core.ViewModels/AccountManagementPageViewModelBase.cs @@ -27,15 +27,15 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsPurchasePanelVisible))] - private bool hasUnlimitedAccountProduct; + public partial bool HasUnlimitedAccountProduct { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsAccountCreationAlmostOnLimit))] [NotifyPropertyChangedFor(nameof(IsPurchasePanelVisible))] - private bool isAccountCreationBlocked; + public partial bool IsAccountCreationBlocked { get; set; } [ObservableProperty] - private IAccountProviderDetailViewModel _startupAccount; + public partial IAccountProviderDetailViewModel StartupAccount { get; set; } public int FREE_ACCOUNT_COUNT { get; } = 3; protected IDialogServiceBase DialogService { get; } @@ -94,7 +94,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM public async Task ManageStorePurchasesAsync() { - var hasUnlimitedAccountProduct = await WinoAccountProfileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false); + var hasUnlimitedAccountProduct = await StoreManagementService.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false); await ExecuteUIThread(() => { diff --git a/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs b/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs index 6accda61..692ba90a 100644 --- a/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs +++ b/Wino.Core.ViewModels/Data/WinoAddOnItemViewModel.cs @@ -21,30 +21,63 @@ public partial class WinoAddOnItemViewModel : ObservableObject }; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowPurchaseState))] + [NotifyPropertyChangedFor(nameof(ShowUsageSummary))] public partial bool IsPurchased { get; set; } public ICommand? PurchaseCommand { get; set; } - public ICommand? ManageCommand { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingState))] + [NotifyPropertyChangedFor(nameof(ShowPurchaseState))] + [NotifyPropertyChangedFor(nameof(ShowUsageSummary))] + [NotifyPropertyChangedFor(nameof(ShowErrorState))] + public partial bool IsLoading { get; set; } [ObservableProperty] public partial bool IsPurchaseInProgress { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowErrorState))] + [NotifyPropertyChangedFor(nameof(ShowUsageSummary))] + public partial bool HasUsageData { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowErrorState))] + [NotifyPropertyChangedFor(nameof(ShowUsageSummary))] + public partial string ErrorText { get; set; } = string.Empty; + [ObservableProperty] public partial int UsageCount { get; set; } [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUsageSummary))] public partial int UsageLimit { get; set; } = 1; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUsageSummary))] public partial double UsagePercentage { get; set; } [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUsageSummary))] public partial string RenewalText { get; set; } = string.Empty; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowUsageSummary))] public partial string UsageResetText { get; set; } = string.Empty; + public bool ShowLoadingState => IsLoading; + + public bool ShowPurchaseState => !IsLoading && string.IsNullOrWhiteSpace(ErrorText); + + public bool ShowUsageSummary => ProductType == WinoAddOnProductType.AI_PACK && + IsPurchased && + !IsLoading && + string.IsNullOrWhiteSpace(ErrorText) && + HasUsageData; + + public bool ShowErrorState => !IsLoading && !string.IsNullOrWhiteSpace(ErrorText); + public WinoAddOnItemViewModel(WinoAddOnProductType productType) { ProductType = productType; diff --git a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs index 65d0a5d7..3f771eed 100644 --- a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs +++ b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs @@ -1,8 +1,7 @@ #nullable enable using System; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; +using Wino.Core.Domain.Entities.Shared; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -10,7 +9,6 @@ using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; -using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Navigation; using Wino.Core.ViewModels.Data; using Wino.Mail.Api.Contracts.Common; @@ -24,9 +22,10 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, IRecipient { private readonly IWinoAccountProfileService _profileService; - private readonly IWinoAddOnService _addOnService; private readonly IMailDialogService _dialogService; - private readonly INativeAppService _nativeAppService; + private readonly IStoreManagementService _storeManagementService; + private readonly WinoAddOnItemViewModel _aiPackAddOn; + private readonly WinoAddOnItemViewModel _unlimitedAccountsAddOn; public ObservableCollection AddOns { get; } = []; @@ -50,14 +49,17 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, public bool IsSignedOut => !IsSignedIn; public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService, - IWinoAddOnService addOnService, IMailDialogService dialogService, - INativeAppService nativeAppService) + IStoreManagementService storeManagementService) { _profileService = profileService; - _addOnService = addOnService; _dialogService = dialogService; - _nativeAppService = nativeAppService; + _storeManagementService = storeManagementService; + + _aiPackAddOn = CreateAddOnItem(WinoAddOnProductType.AI_PACK); + _unlimitedAccountsAddOn = CreateAddOnItem(WinoAddOnProductType.UNLIMITED_ACCOUNTS); + AddOns.Add(_aiPackAddOn); + AddOns.Add(_unlimitedAccountsAddOn); } public override void OnNavigatedTo(NavigationMode mode, object parameters) @@ -166,15 +168,6 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, return; } - var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); - if (account == null) - { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, - Translator.WinoAccount_Management_PurchaseRequiresSignIn, - InfoBarMessageType.Warning); - return; - } - await ExecuteUIThread(() => { IsCheckoutInProgress = true; @@ -183,9 +176,9 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, try { - var checkoutSession = await _profileService.CreateCheckoutSessionAsync(addOn.ProductType).ConfigureAwait(false); + var purchaseResult = await _storeManagementService.PurchaseAsync(addOn.ProductType); - if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result?.Url)) + if (purchaseResult == StorePurchaseResult.NotPurchased) { _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, Translator.WinoAccount_Management_PurchaseStartFailed, @@ -193,13 +186,16 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, return; } - var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result.Url)).ConfigureAwait(false); - if (!isLaunched) + var syncResult = await _profileService.SyncStoreEntitlementsAsync().ConfigureAwait(false); + if (!syncResult.IsSuccess && !string.Equals(syncResult.ErrorCode, "MissingAccessToken", StringComparison.Ordinal)) { _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, - Translator.WinoAccount_Management_PurchaseStartFailed, + TranslateStoreSyncError(syncResult.ErrorCode), InfoBarMessageType.Error); + return; } + + await HandleAddOnPurchasedAsync().ConfigureAwait(false); } catch (OperationCanceledException) { @@ -221,40 +217,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, } private bool CanPurchaseAddOn(WinoAddOnItemViewModel? addOn) - => addOn != null && !addOn.IsPurchased && !IsCheckoutInProgress; - - [RelayCommand] - 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); - } - } + => addOn != null && !addOn.IsPurchased && !addOn.IsLoading && !IsCheckoutInProgress; [RelayCommand] private Task ExportSettingsAsync() => Task.CompletedTask; @@ -296,43 +259,58 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, private async Task LoadAsync() { + WinoAccount? cachedAccount = null; + try { - var cachedAccount = await _profileService.GetActiveAccountAsync().ConfigureAwait(false); - var cachedAddOns = await _addOnService.GetAvailableAddOnsAsync(true).ConfigureAwait(false); + cachedAccount = await _profileService.GetActiveAccountAsync().ConfigureAwait(false); if (cachedAccount != null) { await ApplyAccountStateAsync(cachedAccount).ConfigureAwait(false); } - if (cachedAddOns.Count > 0) + await ExecuteUIThread(() => IsBusy = true); + await ResetAddOnStatesAsync().ConfigureAwait(false); + var loadAiPackTask = LoadAiPackAddOnAsync(); + var loadUnlimitedAccountsTask = LoadUnlimitedAccountsAddOnAsync(); + + var resolvedAccount = cachedAccount; + + if (cachedAccount == null || IsAccessTokenExpired(cachedAccount)) { - await UpdateAddOnsAsync(cachedAddOns).ConfigureAwait(false); + try + { + var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); + if (account != null) + { + resolvedAccount = account; + + var refreshedProfileResult = await _profileService.RefreshProfileAsync().ConfigureAwait(false); + if (refreshedProfileResult.IsSuccess && refreshedProfileResult.Account != null) + { + resolvedAccount = refreshedProfileResult.Account; + } + } + } + catch (Exception) + { + resolvedAccount ??= cachedAccount; + } } - await ExecuteUIThread(() => IsBusy = cachedAccount == null && cachedAddOns.Count == 0); - - var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); - var refreshedProfileResult = account == null - ? null - : await _profileService.RefreshProfileAsync().ConfigureAwait(false); - var addOns = await _addOnService.GetAvailableAddOnsAsync().ConfigureAwait(false); - - var resolvedAccount = refreshedProfileResult?.IsSuccess == true && refreshedProfileResult.Account != null - ? refreshedProfileResult.Account - : account; - await ApplyAccountStateAsync(resolvedAccount).ConfigureAwait(false); - - await UpdateAddOnsAsync(addOns).ConfigureAwait(false); + await Task.WhenAll(loadAiPackTask, loadUnlimitedAccountsTask).ConfigureAwait(false); } catch (Exception) { - _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, - Translator.WinoAccount_Management_LoadFailed, - InfoBarMessageType.Error); - await ResetStateAsync().ConfigureAwait(false); + if (cachedAccount == null) + { + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, + Translator.WinoAccount_Management_LoadFailed, + InfoBarMessageType.Error); + await ResetStateAsync().ConfigureAwait(false); + } } finally { @@ -369,46 +347,151 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, AccountEmail = string.Empty; AccountStatusText = string.Empty; IsCheckoutInProgress = false; - AddOns.Clear(); PurchaseAddOnCommand.NotifyCanExecuteChanged(); }); + + await ResetAddOnStatesAsync().ConfigureAwait(false); } - private async Task UpdateAddOnsAsync(IReadOnlyList addOns) + private WinoAddOnItemViewModel CreateAddOnItem(WinoAddOnProductType productType) { - var items = addOns.Select(CreateAddOnItem).ToList(); + return new WinoAddOnItemViewModel(productType) + { + PurchaseCommand = PurchaseAddOnCommand, + UsageLimit = 1 + }; + } + private async Task ResetAddOnStatesAsync() + { await ExecuteUIThread(() => { - AddOns.Clear(); - - foreach (var item in items) - { - AddOns.Add(item); - } - + ResetAddOnItem(_aiPackAddOn); + ResetAddOnItem(_unlimitedAccountsAddOn); PurchaseAddOnCommand.NotifyCanExecuteChanged(); }); } - private WinoAddOnItemViewModel CreateAddOnItem(WinoAddOnInfo addOn) + private static void ResetAddOnItem(WinoAddOnItemViewModel addOn) { - var item = new WinoAddOnItemViewModel(addOn.ProductType) + addOn.IsLoading = true; + addOn.IsPurchased = false; + addOn.IsPurchaseInProgress = false; + addOn.HasUsageData = false; + addOn.ErrorText = string.Empty; + addOn.UsageCount = 0; + addOn.UsageLimit = 1; + addOn.UsagePercentage = 0; + addOn.RenewalText = string.Empty; + addOn.UsageResetText = string.Empty; + } + + private static string TranslateStoreSyncError(string? errorCode) + => errorCode switch { - IsPurchased = addOn.IsPurchased, - PurchaseCommand = PurchaseAddOnCommand, - ManageCommand = ManageAiPackCommand, - UsageCount = addOn.UsageCount ?? 0, - UsageLimit = addOn.UsageLimit is > 0 ? addOn.UsageLimit.Value : 1, - UsagePercentage = addOn.UsagePercentage + _ => Translator.WinoAccount_Management_StoreSyncFailed }; - 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); - } + private static bool IsAccessTokenExpired(WinoAccount account) + => string.IsNullOrWhiteSpace(account.AccessToken) || account.AccessTokenExpiresAtUtc <= DateTime.UtcNow; - return item; + private async Task LoadUnlimitedAccountsAddOnAsync() + { + try + { + var hasUnlimitedAccounts = await _storeManagementService.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false); + await ExecuteUIThread(() => + { + _unlimitedAccountsAddOn.IsPurchased = hasUnlimitedAccounts; + _unlimitedAccountsAddOn.ErrorText = string.Empty; + }); + } + catch (Exception) + { + await ExecuteUIThread(() => + { + _unlimitedAccountsAddOn.ErrorText = Translator.WinoAccount_Management_AddOnLoadFailed; + }); + } + finally + { + await ExecuteUIThread(() => + { + _unlimitedAccountsAddOn.IsLoading = false; + PurchaseAddOnCommand.NotifyCanExecuteChanged(); + }); + } + } + + private async Task LoadAiPackAddOnAsync() + { + try + { + var hasAiPack = await _storeManagementService.HasProductAsync(WinoAddOnProductType.AI_PACK).ConfigureAwait(false); + + await ExecuteUIThread(() => + { + _aiPackAddOn.IsPurchased = hasAiPack; + _aiPackAddOn.ErrorText = string.Empty; + }); + + if (!hasAiPack) + { + return; + } + + var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false); + if (!aiStatusResponse.IsSuccess || aiStatusResponse.Result == null) + { + await ExecuteUIThread(() => + { + _aiPackAddOn.HasUsageData = false; + _aiPackAddOn.ErrorText = Translator.WinoAccount_Management_AiPackUsageLoadFailed; + }); + return; + } + + var aiStatus = aiStatusResponse.Result; + if (aiStatus.MonthlyLimit is not int usageLimit || usageLimit <= 0 || aiStatus.Used is not int usageCount) + { + await ExecuteUIThread(() => + { + _aiPackAddOn.HasUsageData = false; + _aiPackAddOn.ErrorText = Translator.WinoAccount_Management_AiPackUsageLoadFailed; + }); + return; + } + + await ExecuteUIThread(() => + { + _aiPackAddOn.HasUsageData = true; + _aiPackAddOn.ErrorText = string.Empty; + _aiPackAddOn.UsageCount = usageCount; + _aiPackAddOn.UsageLimit = usageLimit; + _aiPackAddOn.UsagePercentage = usageLimit > 0 ? (double)usageCount / usageLimit * 100 : 0; + _aiPackAddOn.RenewalText = aiStatus.CurrentPeriodEndUtc is DateTimeOffset renewalDateUtc + ? string.Format(Translator.WinoAccount_Management_AiPackRenews, renewalDateUtc.LocalDateTime) + : string.Empty; + _aiPackAddOn.UsageResetText = aiStatus.CurrentPeriodEndUtc is DateTimeOffset resetDateUtc + ? string.Format(Translator.WinoAccount_Management_AiPackResets, resetDateUtc.LocalDateTime) + : string.Empty; + }); + } + catch (Exception) + { + await ExecuteUIThread(() => + { + _aiPackAddOn.HasUsageData = false; + _aiPackAddOn.ErrorText = Translator.WinoAccount_Management_AddOnLoadFailed; + }); + } + finally + { + await ExecuteUIThread(() => + { + _aiPackAddOn.IsLoading = false; + PurchaseAddOnCommand.NotifyCanExecuteChanged(); + }); + } } } diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 0d5a8edf..b65bfb23 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -1622,6 +1622,16 @@ public class GmailSynchronizer : WinoSynchronizer request is BatchMarkReadRequest + || request is MarkReadRequest + || request is BatchChangeFlagRequest + || request is ChangeFlagRequest; + private bool ShouldUpdateSyncIdentifier(ulong? historyId) { if (historyId == null) return false; @@ -1672,7 +1688,13 @@ public class GmailSynchronizer : WinoSynchronizer messageBundle) { @@ -1735,6 +1757,34 @@ public class GmailSynchronizer : WinoSynchronizer bundle) + { + switch (bundle.UIChangeRequest) + { + case BatchMarkReadRequest batchMarkReadRequest: + foreach (var request in batchMarkReadRequest) + { + await _gmailChangeProcessor.ChangeMailReadStatusAsync(request.Item.Id, request.IsRead).ConfigureAwait(false); + } + break; + + case MarkReadRequest markReadRequest: + await _gmailChangeProcessor.ChangeMailReadStatusAsync(markReadRequest.Item.Id, markReadRequest.IsRead).ConfigureAwait(false); + break; + + case BatchChangeFlagRequest batchChangeFlagRequest: + foreach (var request in batchChangeFlagRequest) + { + await _gmailChangeProcessor.ChangeFlagStatusAsync(request.Item.Id, request.IsFlagged).ConfigureAwait(false); + } + break; + + case ChangeFlagRequest changeFlagRequest: + await _gmailChangeProcessor.ChangeFlagStatusAsync(changeFlagRequest.Item.Id, changeFlagRequest.IsFlagged).ConfigureAwait(false); + break; + } + } + /// /// Gmail Archive is a special folder that is not visible in the Gmail web interface. /// We need to handle it separately. diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index b8c3469b..0c55e6d6 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -145,7 +145,7 @@ public class ImapSynchronizer : WinoSynchronizer + Version="2.0.15.0" /> diff --git a/Wino.Mail.WinUI/Services/StoreManagementService.cs b/Wino.Mail.WinUI/Services/StoreManagementService.cs index a0e11121..86f9a2f9 100644 --- a/Wino.Mail.WinUI/Services/StoreManagementService.cs +++ b/Wino.Mail.WinUI/Services/StoreManagementService.cs @@ -3,8 +3,9 @@ using System.Collections.Generic; using System.Threading.Tasks; using Windows.Services.Store; using Wino.Core.Domain.Interfaces; -using WinoStorePurchaseResult = Wino.Core.Domain.Enums.StorePurchaseResult; +using WinRT.Interop; using WinoAddOnProductType = Wino.Core.Domain.Enums.WinoAddOnProductType; +using WinoStorePurchaseResult = Wino.Core.Domain.Enums.StorePurchaseResult; namespace Wino.Mail.WinUI.Services; @@ -14,11 +15,13 @@ public class StoreManagementService : IStoreManagementService private readonly Dictionary productIds = new Dictionary() { - { WinoAddOnProductType.UNLIMITED_ACCOUNTS, "UnlimitedAccounts" } + { WinoAddOnProductType.UNLIMITED_ACCOUNTS, "UnlimitedAccounts" }, + { WinoAddOnProductType.AI_PACK, "AI_PACK" }, }; private readonly Dictionary skuIds = new Dictionary() { + { WinoAddOnProductType.AI_PACK, "9N2FH734RBVS" }, { WinoAddOnProductType.UNLIMITED_ACCOUNTS, "9P02MXZ42GSM" } }; @@ -60,6 +63,7 @@ public class StoreManagementService : IStoreManagementService return WinoStorePurchaseResult.AlreadyPurchased; else { + InitializeStoreContextWithWindow(); var result = await CurrentContext.RequestPurchaseAsync(productKey); switch (result.Status) @@ -73,4 +77,39 @@ public class StoreManagementService : IStoreManagementService } } } + + public async Task GetCustomerCollectionsIdAsync(string serviceTicket, string publisherUserId) + { + if (string.IsNullOrWhiteSpace(serviceTicket) || string.IsNullOrWhiteSpace(publisherUserId)) + { + return null; + } + + InitializeStoreContextWithWindow(); + var collectionsId = await CurrentContext.GetCustomerCollectionsIdAsync(serviceTicket, publisherUserId); + return string.IsNullOrWhiteSpace(collectionsId) ? null : collectionsId; + } + + public async Task GetCustomerPurchaseIdAsync(string serviceTicket, string publisherUserId) + { + if (string.IsNullOrWhiteSpace(serviceTicket) || string.IsNullOrWhiteSpace(publisherUserId)) + { + return null; + } + + InitializeStoreContextWithWindow(); + var purchaseId = await CurrentContext.GetCustomerPurchaseIdAsync(serviceTicket, publisherUserId); + return string.IsNullOrWhiteSpace(purchaseId) ? null : purchaseId; + } + + private void InitializeStoreContextWithWindow() + { + var mainWindow = WinoApplication.MainWindow; + if (mainWindow == null) + { + return; + } + + InitializeWithWindow.Initialize(CurrentContext, WindowNative.GetWindowHandle(mainWindow)); + } } diff --git a/Wino.Mail.WinUI/Styles/DataTemplates.xaml b/Wino.Mail.WinUI/Styles/DataTemplates.xaml index 79b6d7bb..e0334f2b 100644 --- a/Wino.Mail.WinUI/Styles/DataTemplates.xaml +++ b/Wino.Mail.WinUI/Styles/DataTemplates.xaml @@ -67,7 +67,7 @@ - + diff --git a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml index 50bfe27a..01d05821 100644 --- a/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml +++ b/Wino.Mail.WinUI/Views/Settings/WinoAccountManagementPage.xaml @@ -32,6 +32,26 @@ Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.GetTranslatedString(KeywordsKey)}" TextWrapping="WrapWholeWords" /> + + + + + + Style="{StaticResource AccentButtonStyle}" + Visibility="{x:Bind ShowPurchaseState, Mode=OneWay}" /> - - - - - - - - + + + + + + + + + + + + + + - - + + - - + Text="{x:Bind domain:Translator.Busy}" /> + + @@ -148,11 +193,32 @@ Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.GetTranslatedString(KeywordsKey)}" TextWrapping="WrapWholeWords" /> + + + + + + CornerRadius="12" + Visibility="{x:Bind ShowPurchaseState, Mode=OneWay}"> - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index 42bae844..18addf24 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -68,8 +68,7 @@ public class DatabaseService : IDatabaseService Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync()); + Connection.CreateTableAsync()); await EnsureSchemaUpgradesAsync().ConfigureAwait(false); await EnsureIndexesAsync().ConfigureAwait(false); @@ -142,6 +141,8 @@ public class DatabaseService : IDatabaseService .ExecuteAsync($"ALTER TABLE {nameof(AccountContact)} ADD COLUMN {nameof(AccountContact.ContactPictureFileId)} TEXT NULL") .ConfigureAwait(false); } + + await Connection.ExecuteAsync("DROP TABLE IF EXISTS WinoAccountAddOnCache").ConfigureAwait(false); } private async Task EnsureKeyboardShortcutSchemaAsync() @@ -208,7 +209,6 @@ SET {nameof(KeyboardShortcut.Action)} = await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MailAccountPreferences_AccountId ON MailAccountPreferences(AccountId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_CustomServerInformation_AccountId ON CustomServerInformation(AccountId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_WinoAccount_Email ON WinoAccount(Email)").ConfigureAwait(false); - await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_WinoAccountAddOnCache_AccountId ON WinoAccountAddOnCache(AccountId)").ConfigureAwait(false); // Calendar indexes await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_AccountCalendar_AccountId ON AccountCalendar(AccountId)").ConfigureAwait(false); diff --git a/Wino.Services/ServicesContainerSetup.cs b/Wino.Services/ServicesContainerSetup.cs index 63f3c729..5fd390a3 100644 --- a/Wino.Services/ServicesContainerSetup.cs +++ b/Wino.Services/ServicesContainerSetup.cs @@ -29,7 +29,6 @@ public static class ServicesContainerSetup services.AddTransient(); services.AddSingleton(); services.AddSingleton(); - services.AddTransient(); services.AddSingleton(); services.AddTransient(); diff --git a/Wino.Services/WinoAccountApiClient.cs b/Wino.Services/WinoAccountApiClient.cs index 3ed8c29f..5424ad7a 100644 --- a/Wino.Services/WinoAccountApiClient.cs +++ b/Wino.Services/WinoAccountApiClient.cs @@ -11,12 +11,10 @@ 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; @@ -137,25 +135,27 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable WinoAccountApiJsonContext.Default.ApiEnvelopeAiTextResultDto, cancellationToken); - public Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default) - { - var endpoint = productId switch - { - 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.ApiEnvelopeCheckoutSessionResultDto, cancellationToken); - } - - public Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default) + public Task> CreateCollectionsIdTicketAsync(CancellationToken cancellationToken = default) => SendAuthorizedRequestAsync( HttpMethod.Post, - "api/v1/billing/ai-pack/customer-portal-session", - WinoAccountApiJsonContext.Default.ApiEnvelopeCustomerPortalResultDto, + "api/v1/store/collections-id-ticket", + WinoAccountApiJsonContext.Default.ApiEnvelopeWinoStoreCollectionsIdTicketInfo, + cancellationToken); + + public Task> CreatePurchaseIdTicketAsync(CancellationToken cancellationToken = default) + => SendAuthorizedRequestAsync( + HttpMethod.Post, + "api/v1/store/purchase-id-ticket", + WinoAccountApiJsonContext.Default.ApiEnvelopeWinoStoreCollectionsIdTicketInfo, + cancellationToken); + + public Task> SyncStoreEntitlementsAsync(string? storeIdKey, string? purchaseIdKey, CancellationToken cancellationToken = default) + => SendAuthorizedRequestAsync( + HttpMethod.Post, + "api/v1/store/entitlements/sync", + new SyncStoreEntitlementsRequest(storeIdKey, purchaseIdKey), + WinoAccountApiJsonContext.Default.SyncStoreEntitlementsRequest, + WinoAccountApiJsonContext.Default.ApiEnvelopeJsonElement, cancellationToken); public async Task GetSettingsAsync(CancellationToken cancellationToken = default) @@ -471,7 +471,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable return false; } - if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1)) + if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow) { return true; } @@ -542,12 +542,14 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable [JsonSerializable(typeof(SummarizeRequest))] [JsonSerializable(typeof(TranslateRequest))] [JsonSerializable(typeof(RewriteRequest))] +[JsonSerializable(typeof(SyncStoreEntitlementsRequest))] [JsonSerializable(typeof(ApiEnvelope))] [JsonSerializable(typeof(ApiEnvelope))] [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; + +internal sealed record SyncStoreEntitlementsRequest(string? StoreIdKey, string? PurchaseIdKey); diff --git a/Wino.Services/WinoAccountProfileService.cs b/Wino.Services/WinoAccountProfileService.cs index 3a0c2513..da662b16 100644 --- a/Wino.Services/WinoAccountProfileService.cs +++ b/Wino.Services/WinoAccountProfileService.cs @@ -1,6 +1,5 @@ #nullable enable using System; -using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -11,7 +10,6 @@ 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; @@ -79,7 +77,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return WinoAccountOperationResult.Failure(ApiErrorCodes.RefreshTokenInvalid); } - if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1)) + if (!string.IsNullOrWhiteSpace(account.AccessToken) && account.AccessTokenExpiresAtUtc > DateTime.UtcNow) { return WinoAccountOperationResult.Success(account); } @@ -123,14 +121,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun } var refreshedAccount = MergeAccountProfile(account, response.Result); - var hasUnlimitedAccounts = TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var cachedHasUnlimitedAccounts) - ? cachedHasUnlimitedAccounts - : (bool?)null; - - if (hasUnlimitedAccounts.HasValue) - { - await PersistAddOnCacheAsync(refreshedAccount.Id, null, hasUnlimitedAccounts.Value).ConfigureAwait(false); - } if (AreEquivalentProfiles(account, refreshedAccount)) { @@ -149,18 +139,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return account; } - public async Task GetCachedAddOnSnapshotAsync() - { - var account = await GetActiveAccountAsync().ConfigureAwait(false); - if (account == null) - { - return null; - } - - var cache = await GetAddOnCacheAsync(account.Id).ConfigureAwait(false); - return cache == null ? null : Map(cache); - } - public async Task GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default) { var account = await GetActiveAccountAsync().ConfigureAwait(false); @@ -176,7 +154,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return null; } - if (account.AccessTokenExpiresAtUtc > DateTime.UtcNow.AddMinutes(1)) + if (account.AccessTokenExpiresAtUtc > DateTime.UtcNow) { return account; } @@ -193,16 +171,6 @@ 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); @@ -221,11 +189,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun if (response.Result != null) { var refreshedAccount = MergeAccountProfile(account, response.Result); - var hasUnlimitedAccounts = TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var cachedHasUnlimitedAccounts) - ? cachedHasUnlimitedAccounts - : (bool?)null; - - await PersistProfileDataAsync(account, refreshedAccount, hasUnlimitedAccounts).ConfigureAwait(false); + await PersistProfileDataAsync(account, refreshedAccount).ConfigureAwait(false); } return response; @@ -246,11 +210,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return response; } - if (response.Result != null) - { - await PersistAddOnCacheAsync(account.Id, response.Result).ConfigureAwait(false); - } - return response; } @@ -263,36 +222,65 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun public async Task> RewriteAsync(string html, string mode, CancellationToken cancellationToken = default) => await ExecuteAiOperationAsync(account => _apiClient.RewriteAsync(html, mode, cancellationToken), "rewrite", cancellationToken).ConfigureAwait(false); - public async Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default) + public async Task> SyncStoreEntitlementsAsync(CancellationToken cancellationToken = default) { - var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); - if (account == null) + var account = await GetActiveAccountAsync().ConfigureAwait(false); + if (account == null || string.IsNullOrWhiteSpace(account.AccessToken)) { - return ApiEnvelope.Failure("MissingAccessToken"); + return ApiEnvelope.Failure("MissingAccessToken"); } - var response = await _apiClient.CreateCheckoutSessionAsync(productId, cancellationToken).ConfigureAwait(false); + string? storeIdKey = null; + string? purchaseIdKey = null; + + var collectionsTicketResponse = await _apiClient.CreateCollectionsIdTicketAsync(cancellationToken).ConfigureAwait(false); + if (collectionsTicketResponse.IsSuccess && collectionsTicketResponse.Result != null) + { + storeIdKey = await _storeManagementService.GetCustomerCollectionsIdAsync( + collectionsTicketResponse.Result.ServiceTicket, + collectionsTicketResponse.Result.PublisherUserId).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(storeIdKey)) + { + _logger.Warning("Failed to obtain Microsoft Store collections ID key for Wino account {Email}.", account.Email); + } + } + else + { + _logger.Warning("Failed to create Microsoft Store collections ticket for Wino account {Email}. Error code: {ErrorCode}", account.Email, collectionsTicketResponse.ErrorCode); + } + + var purchaseTicketResponse = await _apiClient.CreatePurchaseIdTicketAsync(cancellationToken).ConfigureAwait(false); + if (purchaseTicketResponse.IsSuccess && purchaseTicketResponse.Result != null) + { + purchaseIdKey = await _storeManagementService.GetCustomerPurchaseIdAsync( + purchaseTicketResponse.Result.ServiceTicket, + purchaseTicketResponse.Result.PublisherUserId).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(purchaseIdKey)) + { + _logger.Warning("Failed to obtain Microsoft Store purchase ID key for Wino account {Email}.", account.Email); + } + } + else + { + _logger.Warning("Failed to create Microsoft Store purchase ticket for Wino account {Email}. Error code: {ErrorCode}", account.Email, purchaseTicketResponse.ErrorCode); + } + + if (string.IsNullOrWhiteSpace(storeIdKey) && string.IsNullOrWhiteSpace(purchaseIdKey)) + { + return ApiEnvelope.Failure("StoreEntitlementKeysMissing"); + } + + var response = await _apiClient.SyncStoreEntitlementsAsync(storeIdKey, purchaseIdKey, cancellationToken).ConfigureAwait(false); if (!response.IsSuccess) { - _logger.Warning("Failed to create checkout session for product {ProductId} and Wino account {Email}. Error code: {ErrorCode}", productId, account.Email, response.ErrorCode); + _logger.Warning("Failed to sync Microsoft Store entitlements for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode); + return response; } - 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); - } + await RefreshProfileAsync(cancellationToken).ConfigureAwait(false); + await GetAiStatusAsync(cancellationToken).ConfigureAwait(false); return response; } @@ -324,7 +312,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun var refreshResult = await RefreshProfileAsync(cancellationToken).ConfigureAwait(false); - if (refreshResult.IsSuccess && await HasAddOnAsync(targetProductType.Value, cancellationToken).ConfigureAwait(false)) + if (refreshResult.IsSuccess && await _storeManagementService.HasProductAsync(targetProductType.Value).ConfigureAwait(false)) { ReportUIChange(new WinoAccountAddOnPurchasedMessage(targetProductType.Value)); return true; @@ -365,8 +353,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun } await Connection.DeleteAllAsync().ConfigureAwait(false); - await Connection.DeleteAllAsync().ConfigureAwait(false); - if (account != null) { ReportUIChange(new WinoAccountProfileDeletedMessage(account)); @@ -395,29 +381,18 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false); } - private async Task PersistProfileDataAsync(WinoAccount originalAccount, WinoAccount refreshedAccount, bool? hasUnlimitedAccounts) + private async Task PersistProfileDataAsync(WinoAccount originalAccount, WinoAccount refreshedAccount) { if (!AreEquivalentProfiles(originalAccount, refreshedAccount)) { await PersistAccountAsync(refreshedAccount).ConfigureAwait(false); PublishProfileUpdated(refreshedAccount); } - - if (hasUnlimitedAccounts.HasValue) - { - await PersistAddOnCacheAsync(refreshedAccount.Id, null, hasUnlimitedAccounts.Value).ConfigureAwait(false); - } } private void PublishProfileUpdated(WinoAccount account) => ReportUIChange(new WinoAccountProfileUpdatedMessage(account)); - private async Task HasAiPackAsync(CancellationToken cancellationToken) - { - var response = await GetAiStatusAsync(cancellationToken).ConfigureAwait(false); - return response.IsSuccess && response.Result?.HasAiPack == true; - } - private async Task> ExecuteAiOperationAsync(Func>> executeAsync, string operationName, CancellationToken cancellationToken) @@ -434,94 +409,9 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun _logger.Warning("Failed to {Operation} HTML with AI for Wino account {Email}. Error code: {ErrorCode}", operationName, account.Email, response.ErrorCode); } - if (response.Quota != null) - { - await PersistAddOnCacheAsync(account.Id, Map(response.Quota)).ConfigureAwait(false); - } - return response; } - private async Task HasUnlimitedAccountsAsync(CancellationToken cancellationToken) - { - var cachedSnapshot = await GetCachedAddOnSnapshotAsync().ConfigureAwait(false); - if (cachedSnapshot?.HasUnlimitedAccounts == true) - { - return true; - } - - 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 (await GetCachedAddOnSnapshotAsync().ConfigureAwait(false))?.HasUnlimitedAccounts == true; - } - - return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts; - } - - private async Task GetAddOnCacheAsync(Guid accountId) - => await Connection.Table() - .Where(cache => cache.AccountId == accountId) - .FirstOrDefaultAsync() - .ConfigureAwait(false); - - private async Task PersistAddOnCacheAsync(Guid accountId, AiStatusResultDto? aiStatus = null, bool? hasUnlimitedAccounts = null) - { - var cache = await GetAddOnCacheAsync(accountId).ConfigureAwait(false) ?? new WinoAccountAddOnCache - { - AccountId = accountId - }; - - if (aiStatus != null) - { - cache.HasAiPack = aiStatus.HasAiPack; - cache.AiUsageCount = aiStatus.HasAiPack ? aiStatus.Used : null; - cache.AiUsageLimit = aiStatus.HasAiPack ? aiStatus.MonthlyLimit : null; - cache.AiBillingPeriodStartUtc = aiStatus.CurrentPeriodStartUtc?.UtcDateTime; - cache.AiBillingPeriodEndUtc = aiStatus.CurrentPeriodEndUtc?.UtcDateTime; - } - - if (hasUnlimitedAccounts.HasValue) - { - cache.HasUnlimitedAccounts = hasUnlimitedAccounts.Value; - } - - cache.LastUpdatedUtc = DateTime.UtcNow; - - await Connection.InsertOrReplaceAsync(cache, typeof(WinoAccountAddOnCache)).ConfigureAwait(false); - } - - private static WinoAccountAddOnSnapshot Map(WinoAccountAddOnCache cache) - => new( - cache.HasAiPack, - cache.AiUsageCount, - cache.AiUsageLimit, - cache.AiBillingPeriodStartUtc is DateTime periodStartUtc ? new DateTimeOffset(DateTime.SpecifyKind(periodStartUtc, DateTimeKind.Utc)) : null, - cache.AiBillingPeriodEndUtc is DateTime periodEndUtc ? new DateTimeOffset(DateTime.SpecifyKind(periodEndUtc, DateTimeKind.Utc)) : null, - cache.HasUnlimitedAccounts, - new DateTimeOffset(DateTime.SpecifyKind(cache.LastUpdatedUtc, DateTimeKind.Utc))); - - private static AiStatusResultDto Map(QuotaInfoDto quota) - => new( - quota.HasAiPack, - quota.EntitlementStatus, - quota.CurrentPeriodStartUtc, - quota.CurrentPeriodEndUtc, - quota.MonthlyLimit, - quota.Used, - quota.Remaining, - quota.Product); - private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right) => left.Id == right.Id && string.Equals(left.Email, right.Email, StringComparison.Ordinal) && @@ -546,21 +436,6 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun LastAuthenticatedUtc = existingAccount.LastAuthenticatedUtc }; - [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 WinoAddOnProductType? ResolveProductType(Uri callbackUri) { var productCode = GetQueryParameter(callbackUri, "productCode"); diff --git a/Wino.Services/WinoAddOnService.cs b/Wino.Services/WinoAddOnService.cs deleted file mode 100644 index 47dcf100..00000000 --- a/Wino.Services/WinoAddOnService.cs +++ /dev/null @@ -1,86 +0,0 @@ -#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 readonly IWinoAccountProfileService _profileService; - - public WinoAddOnService(IWinoAccountProfileService profileService) - { - _profileService = profileService; - } - - public async Task> GetAvailableAddOnsAsync(bool useCachedDataOnly = false, CancellationToken cancellationToken = default) - { - var cachedSnapshot = await _profileService.GetCachedAddOnSnapshotAsync().ConfigureAwait(false); - - if (useCachedDataOnly) - { - return BuildAddOnInfos(cachedSnapshot); - } - - 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; - var hasUnlimitedAccounts = await hasUnlimitedAccountsTask.ConfigureAwait(false); - - if (aiStatus == null && cachedSnapshot != null) - { - return BuildAddOnInfos(cachedSnapshot with - { - HasUnlimitedAccounts = hasUnlimitedAccounts || cachedSnapshot.HasUnlimitedAccounts - }); - } - - 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, - hasUnlimitedAccounts || cachedSnapshot?.HasUnlimitedAccounts == true) - ]; - } - - private static IReadOnlyList BuildAddOnInfos(WinoAccountAddOnSnapshot? snapshot) - { - if (snapshot == null) - { - return []; - } - - return - [ - new WinoAddOnInfo( - WinoAddOnProductType.AI_PACK, - snapshot.HasAiPack, - snapshot.UsageCount, - snapshot.UsageLimit, - snapshot.HasAiPack && snapshot.UsageLimit is int limit && limit > 0 && snapshot.UsageCount is int used - ? (double)used / limit * 100 - : 0, - snapshot.BillingPeriodEndUtc), - new WinoAddOnInfo( - WinoAddOnProductType.UNLIMITED_ACCOUNTS, - snapshot.HasUnlimitedAccounts) - ]; - } -}