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)
- ];
- }
-}