From c3e199194287d2ca27ebb808e45a260b8008a6ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 19 Mar 2026 10:26:17 +0100 Subject: [PATCH] Deep link on purchase success. --- .../Interfaces/ILaunchProtocolService.cs | 4 +- .../Interfaces/IWinoAccountProfileService.cs | 3 + .../WinoAccountProfileServiceTests.cs | 62 +++++++ .../SettingOptionsPageViewModel.cs | 22 ++- .../WinoAccountManagementPageViewModel.cs | 39 +++-- Wino.Mail.WinUI/App.xaml.cs | 69 +++++++- Wino.Mail.WinUI/Package.appxmanifest | 7 + Wino.Mail.WinUI/ShellWindow.xaml.cs | 16 +- .../UI/WinoAccountAddOnPurchasedMessage.cs | 5 + .../UI/WinoAccountProfileDeletedMessage.cs | 5 + .../UI/WinoAccountProfileUpdatedMessage.cs | 5 + Wino.Services/LaunchProtocolService.cs | 2 +- Wino.Services/ServicesContainerSetup.cs | 2 +- Wino.Services/WinoAccountProfileService.cs | 158 +++++++++++++++++- 14 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 Wino.Messages/UI/WinoAccountAddOnPurchasedMessage.cs create mode 100644 Wino.Messages/UI/WinoAccountProfileDeletedMessage.cs create mode 100644 Wino.Messages/UI/WinoAccountProfileUpdatedMessage.cs diff --git a/Wino.Core.Domain/Interfaces/ILaunchProtocolService.cs b/Wino.Core.Domain/Interfaces/ILaunchProtocolService.cs index 3e11c83d..39a75615 100644 --- a/Wino.Core.Domain/Interfaces/ILaunchProtocolService.cs +++ b/Wino.Core.Domain/Interfaces/ILaunchProtocolService.cs @@ -1,4 +1,5 @@ -using Wino.Core.Domain.Models.Launch; +using System; +using Wino.Core.Domain.Models.Launch; namespace Wino.Core.Domain.Interfaces; @@ -13,4 +14,5 @@ public interface ILaunchProtocolService /// Used to handle mailto links. /// MailToUri MailToUri { get; set; } + } diff --git a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs index b95f5f5d..2ee2c34e 100644 --- a/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs +++ b/Wino.Core.Domain/Interfaces/IWinoAccountProfileService.cs @@ -1,4 +1,5 @@ #nullable enable +using System; using System.Threading; using System.Threading.Tasks; using Wino.Core.Domain.Entities.Shared; @@ -16,6 +17,7 @@ public interface IWinoAccountProfileService Task RegisterAsync(string email, string password, CancellationToken cancellationToken = default); Task LoginAsync(string email, string password, CancellationToken cancellationToken = default); Task RefreshAsync(CancellationToken cancellationToken = default); + Task RefreshProfileAsync(CancellationToken cancellationToken = default); Task GetActiveAccountAsync(); Task GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default); Task HasActiveAccountAsync(); @@ -24,5 +26,6 @@ public interface IWinoAccountProfileService Task> GetAiStatusAsync(CancellationToken cancellationToken = default); Task> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default); Task> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default); + Task ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default); Task SignOutAsync(CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs index c32054b9..5904ff8d 100644 --- a/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs +++ b/Wino.Core.Tests/Services/WinoAccountProfileServiceTests.cs @@ -119,6 +119,68 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime result.ErrorMessage.Should().Be("Password does not match this account."); } + [Fact] + public async Task RefreshProfileAsync_ShouldPersistLatestProfileData() + { + var authResult = CreateAuthResult("first@example.com"); + + _apiClient + .Setup(x => x.LoginAsync("first@example.com", "pw", default)) + .ReturnsAsync(WinoAccountApiResult.Success(authResult)); + + _apiClient + .Setup(x => x.GetCurrentUserAsync(default)) + .ReturnsAsync(ApiEnvelope.Success(new AuthUserDto( + authResult.User.UserId, + "updated@example.com", + "Premium", + authResult.User.HasPassword, + authResult.User.HasGoogleLogin, + authResult.User.HasFacebookLogin))); + + await _service.LoginAsync("first@example.com", "pw"); + + var result = await _service.RefreshProfileAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Account.Should().NotBeNull(); + result.Account!.Email.Should().Be("updated@example.com"); + result.Account.AccountStatus.Should().Be("Premium"); + + var persisted = await _databaseService.Connection.Table().FirstOrDefaultAsync(); + persisted.Should().NotBeNull(); + persisted!.Email.Should().Be("updated@example.com"); + persisted.AccountStatus.Should().Be("Premium"); + persisted.AccessToken.Should().Be(authResult.AccessToken); + persisted.RefreshToken.Should().Be(authResult.RefreshToken); + } + + [Fact] + public async Task ProcessBillingCallbackAsync_ShouldConfirmPurchasedAddOn() + { + var authResult = CreateAuthResult("first@example.com"); + var callbackUri = new Uri("wino://billing/success?productCode=UNLIMITED_ACCOUNTS"); + + _apiClient + .Setup(x => x.LoginAsync("first@example.com", "pw", default)) + .ReturnsAsync(WinoAccountApiResult.Success(authResult)); + + _apiClient + .Setup(x => x.GetCurrentUserAsync(default)) + .ReturnsAsync(ApiEnvelope.Success(authResult.User)); + + _storeManagementService + .Setup(x => x.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS)) + .ReturnsAsync(true); + + await _service.LoginAsync("first@example.com", "pw"); + + var processed = await _service.ProcessBillingCallbackAsync(callbackUri); + + processed.Should().BeTrue(); + _apiClient.Verify(x => x.GetCurrentUserAsync(default), Times.AtLeastOnce); + } + private static AuthResultDto CreateAuthResult(string email) { return new AuthResultDto( diff --git a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs index 1f0ee6b6..3fe47410 100644 --- a/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs +++ b/Wino.Core.ViewModels/SettingOptionsPageViewModel.cs @@ -24,8 +24,9 @@ using Wino.Messaging.UI; namespace Wino.Core.ViewModels; public partial class SettingOptionsPageViewModel : CoreBaseViewModel, - IRecipient, - IRecipient + IRecipient, + IRecipient, + IRecipient { private const string BuyAiPackUrl = "https://example.com/wino-ai-pack"; @@ -420,24 +421,29 @@ public partial class SettingOptionsPageViewModel : CoreBaseViewModel, { base.RegisterRecipients(); - Messenger.Register(this); - Messenger.Register(this); + Messenger.Register(this); + Messenger.Register(this); + Messenger.Register(this); } protected override void UnregisterRecipients() { base.UnregisterRecipients(); - Messenger.Unregister(this); - Messenger.Unregister(this); + Messenger.Unregister(this); + Messenger.Unregister(this); + Messenger.Unregister(this); } - public void Receive(WinoAccountSignedInMessage message) + public void Receive(WinoAccountProfileUpdatedMessage message) => _ = LoadWinoAccountAsync(); - public void Receive(WinoAccountSignedOutMessage message) + public void Receive(WinoAccountProfileDeletedMessage message) => _ = ResetWinoAccountStateAsync(); + public void Receive(WinoAccountAddOnPurchasedMessage message) + => _ = LoadWinoAccountAsync(); + // Wino Account hero card commands and helpers [RelayCommand] diff --git a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs index 4b0f0969..258e274d 100644 --- a/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs +++ b/Wino.Core.ViewModels/WinoAccountManagementPageViewModel.cs @@ -12,15 +12,15 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Navigation; -using Wino.Mail.Api.Contracts.Billing; using Wino.Core.ViewModels.Data; using Wino.Messaging.UI; namespace Wino.Core.ViewModels; public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, - IRecipient, - IRecipient + IRecipient, + IRecipient, + IRecipient { private readonly IWinoAccountProfileService _profileService; private readonly IWinoAddOnService _addOnService; @@ -62,7 +62,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, public override void OnNavigatedTo(NavigationMode mode, object parameters) { base.OnNavigatedTo(mode, parameters); - _ = LoadAsync(); + _ = InitializeAsync(); } [RelayCommand] @@ -220,24 +220,34 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, { base.RegisterRecipients(); - Messenger.Register(this); - Messenger.Register(this); + Messenger.Register(this); + Messenger.Register(this); + Messenger.Register(this); } protected override void UnregisterRecipients() { base.UnregisterRecipients(); - Messenger.Unregister(this); - Messenger.Unregister(this); + Messenger.Unregister(this); + Messenger.Unregister(this); + Messenger.Unregister(this); } - public void Receive(WinoAccountSignedInMessage message) + public void Receive(WinoAccountProfileUpdatedMessage message) => _ = LoadAsync(); - public void Receive(WinoAccountSignedOutMessage message) + public void Receive(WinoAccountProfileDeletedMessage message) => _ = LoadAsync(); + public void Receive(WinoAccountAddOnPurchasedMessage message) + => _ = HandleAddOnPurchasedAsync(); + + private async Task InitializeAsync() + { + await LoadAsync().ConfigureAwait(false); + } + private async Task LoadAsync() { await ExecuteUIThread(() => IsBusy = true); @@ -271,6 +281,15 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel, } } + private async Task HandleAddOnPurchasedAsync() + { + await LoadAsync().ConfigureAwait(false); + + _dialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle, + Translator.Info_PurchaseThankYouMessage, + InfoBarMessageType.Success); + } + private async Task ResetStateAsync() { await ExecuteUIThread(() => diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 2dccf37f..8fc53701 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -50,6 +50,9 @@ public partial class App : WinoApplication, { private const int InboxSyncsPerFullSync = 20; private const string ToggleDefaultModeLaunchArgument = "--mode=toggle-default"; + private const string WinoProtocolScheme = "wino"; + private const string BillingProtocolHost = "billing"; + private const string BillingSuccessPath = "/success"; private ISynchronizationManager? _synchronizationManager; private IPreferencesService? _preferencesService; private IAccountService? _accountService; @@ -257,7 +260,11 @@ public partial class App : WinoApplication, // Initialize theme service after window creation. // Theme service requires the window to exist to properly load and apply themes. await NewThemeService.InitializeAsync(); + + // Wino account loading and activation. await LoadInitialWinoAccountAsync(); + await HandlePostActivationAsync(AppInstance.GetCurrent().GetActivatedEventArgs()); + LogActivation("Theme service initialized."); // If startup task launch, keep window hidden (system tray only). @@ -836,7 +843,7 @@ public partial class App : WinoApplication, if (winoAccount != null) { - WeakReferenceMessenger.Default.Send(new WinoAccountSignedInMessage(winoAccount)); + WeakReferenceMessenger.Default.Send(new WinoAccountProfileUpdatedMessage(winoAccount)); } } @@ -941,7 +948,7 @@ public partial class App : WinoApplication, public void HandleRedirectedActivation(AppActivationArguments args) { // Dispatch to UI thread since this is called from Program.OnActivated - MainWindow?.DispatcherQueue.TryEnqueue(() => + MainWindow?.DispatcherQueue.TryEnqueue(async () => { // Handle different activation kinds if (args.Kind == ExtendedActivationKind.AppNotification) @@ -972,6 +979,8 @@ public partial class App : WinoApplication, } } + await HandlePostActivationAsync(args); + // Bring the existing window to front after handling redirected activation. MainWindow?.BringToFront(); MainWindow?.Activate(); @@ -1017,6 +1026,13 @@ public partial class App : WinoApplication, mode = WinoApplicationMode.Mail; return true; } + + if (string.Equals(scheme, WinoProtocolScheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(protocolArgs.Uri?.Host, BillingProtocolHost, StringComparison.OrdinalIgnoreCase)) + { + mode = WinoApplicationMode.Settings; + return true; + } } if (activationArgs.Kind == ExtendedActivationKind.File && @@ -1060,6 +1076,55 @@ public partial class App : WinoApplication, return null; } + + private async Task HandlePostActivationAsync(AppActivationArguments activationArgs) + { + if (await TryHandleBillingProtocolActivationAsync(activationArgs).ConfigureAwait(false)) + { + return; + } + } + + private async Task TryHandleBillingProtocolActivationAsync(AppActivationArguments activationArgs) + { + if (!TryGetBillingCallbackUri(activationArgs, out var callbackUri)) + { + return false; + } + + Services.GetRequiredService().Navigate( + WinoPage.SettingsPage, + WinoPage.WinoAccountManagementPage, + NavigationReferenceFrame.ShellFrame, + NavigationTransitionType.None); + + var winoAccountProfileService = Services.GetRequiredService(); + await winoAccountProfileService.ProcessBillingCallbackAsync(callbackUri).ConfigureAwait(false); + return true; + } + + private static bool TryGetBillingCallbackUri(AppActivationArguments activationArgs, out Uri callbackUri) + { + callbackUri = null!; + + if (activationArgs.Kind != ExtendedActivationKind.Protocol || + activationArgs.Data is not IProtocolActivatedEventArgs protocolArgs || + protocolArgs.Uri == null) + { + return false; + } + + var uri = protocolArgs.Uri; + if (!string.Equals(uri.Scheme, WinoProtocolScheme, StringComparison.OrdinalIgnoreCase) || + !string.Equals(uri.Host, BillingProtocolHost, StringComparison.OrdinalIgnoreCase) || + !string.Equals(uri.AbsolutePath, BillingSuccessPath, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + callbackUri = uri; + return true; + } } diff --git a/Wino.Mail.WinUI/Package.appxmanifest b/Wino.Mail.WinUI/Package.appxmanifest index d55d2c6c..5c7cb34b 100644 --- a/Wino.Mail.WinUI/Package.appxmanifest +++ b/Wino.Mail.WinUI/Package.appxmanifest @@ -90,6 +90,13 @@ + + + + Wino Mail Protocol + + + diff --git a/Wino.Mail.WinUI/ShellWindow.xaml.cs b/Wino.Mail.WinUI/ShellWindow.xaml.cs index 5efed719..cb14933b 100644 --- a/Wino.Mail.WinUI/ShellWindow.xaml.cs +++ b/Wino.Mail.WinUI/ShellWindow.xaml.cs @@ -31,8 +31,8 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient, IRecipient, IRecipient, - IRecipient, - IRecipient + IRecipient, + IRecipient { public IStatePersistanceService StatePersistanceService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception("StatePersistanceService not registered in DI container."); public IPreferencesService PreferencesService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception("PreferencesService not registered in DI container."); @@ -213,12 +213,12 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, }); } - public void Receive(WinoAccountSignedInMessage message) + public void Receive(WinoAccountProfileUpdatedMessage message) { DispatcherQueue.TryEnqueue(() => UpdateWinoAccountState(message.Account)); } - public void Receive(WinoAccountSignedOutMessage message) + public void Receive(WinoAccountProfileDeletedMessage message) { DispatcherQueue.TryEnqueue(() => UpdateWinoAccountState(null)); } @@ -326,8 +326,8 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); } private void UnregisterRecipients() @@ -337,8 +337,8 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); - WeakReferenceMessenger.Default.Unregister(this); - WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); } private void ShowInfoBarMessage(InfoBarMessageRequested message) diff --git a/Wino.Messages/UI/WinoAccountAddOnPurchasedMessage.cs b/Wino.Messages/UI/WinoAccountAddOnPurchasedMessage.cs new file mode 100644 index 00000000..14a7ecbd --- /dev/null +++ b/Wino.Messages/UI/WinoAccountAddOnPurchasedMessage.cs @@ -0,0 +1,5 @@ +using Wino.Core.Domain.Enums; + +namespace Wino.Messaging.UI; + +public record WinoAccountAddOnPurchasedMessage(WinoAddOnProductType ProductType) : UIMessageBase; diff --git a/Wino.Messages/UI/WinoAccountProfileDeletedMessage.cs b/Wino.Messages/UI/WinoAccountProfileDeletedMessage.cs new file mode 100644 index 00000000..5f22fc6f --- /dev/null +++ b/Wino.Messages/UI/WinoAccountProfileDeletedMessage.cs @@ -0,0 +1,5 @@ +using Wino.Core.Domain.Entities.Shared; + +namespace Wino.Messaging.UI; + +public record WinoAccountProfileDeletedMessage(WinoAccount Account) : UIMessageBase; diff --git a/Wino.Messages/UI/WinoAccountProfileUpdatedMessage.cs b/Wino.Messages/UI/WinoAccountProfileUpdatedMessage.cs new file mode 100644 index 00000000..9ca5a683 --- /dev/null +++ b/Wino.Messages/UI/WinoAccountProfileUpdatedMessage.cs @@ -0,0 +1,5 @@ +using Wino.Core.Domain.Entities.Shared; + +namespace Wino.Messaging.UI; + +public record WinoAccountProfileUpdatedMessage(WinoAccount Account) : UIMessageBase; diff --git a/Wino.Services/LaunchProtocolService.cs b/Wino.Services/LaunchProtocolService.cs index 8dda34e5..ca18b61e 100644 --- a/Wino.Services/LaunchProtocolService.cs +++ b/Wino.Services/LaunchProtocolService.cs @@ -1,4 +1,4 @@ -using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Launch; namespace Wino.Services; diff --git a/Wino.Services/ServicesContainerSetup.cs b/Wino.Services/ServicesContainerSetup.cs index 11be4679..63f3c729 100644 --- a/Wino.Services/ServicesContainerSetup.cs +++ b/Wino.Services/ServicesContainerSetup.cs @@ -28,7 +28,7 @@ public static class ServicesContainerSetup services.AddTransient(); services.AddTransient(); services.AddSingleton(); - services.AddTransient(); + services.AddSingleton(); services.AddTransient(); services.AddSingleton(); diff --git a/Wino.Services/WinoAccountProfileService.cs b/Wino.Services/WinoAccountProfileService.cs index 8ed48da4..7221bcef 100644 --- a/Wino.Services/WinoAccountProfileService.cs +++ b/Wino.Services/WinoAccountProfileService.cs @@ -20,6 +20,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun { private readonly IWinoAccountApiClient _apiClient; private readonly IStoreManagementService _storeManagementService; + private readonly SemaphoreSlim _billingCallbackLock = new(1, 1); private readonly ILogger _logger = Log.ForContext(); public WinoAccountProfileService(IDatabaseService databaseService, @@ -37,6 +38,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun if (result.IsSuccess && result.Account != null) { + PublishProfileUpdated(result.Account); ReportUIChange(new WinoAccountSignedInMessage(result.Account)); } @@ -50,6 +52,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun if (result.IsSuccess && result.Account != null) { + PublishProfileUpdated(result.Account); ReportUIChange(new WinoAccountSignedInMessage(result.Account)); } @@ -72,11 +75,44 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun if (!result.IsSuccess) { _logger.Warning("Wino account token refresh failed for {Email}. Error code: {ErrorCode}", account.Email, result.ErrorCode); + return result; + } + + if (result.Account != null && !AreEquivalentProfiles(account, result.Account)) + { + PublishProfileUpdated(result.Account); } return result; } + public async Task RefreshProfileAsync(CancellationToken cancellationToken = default) + { + var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); + if (account == null) + { + return WinoAccountOperationResult.Failure("MissingAccessToken"); + } + + var response = await _apiClient.GetCurrentUserAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccess || response.Result == null) + { + _logger.Warning("Failed to refresh Wino account profile for {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode); + return WinoAccountOperationResult.Failure(response.ErrorCode); + } + + var refreshedAccount = MergeAccountProfile(account, response.Result); + if (AreEquivalentProfiles(account, refreshedAccount)) + { + return WinoAccountOperationResult.Success(account); + } + + await PersistAccountAsync(refreshedAccount).ConfigureAwait(false); + PublishProfileUpdated(refreshedAccount); + + return WinoAccountOperationResult.Success(refreshedAccount); + } + public async Task GetActiveAccountAsync() { var account = await Connection.Table().FirstOrDefaultAsync().ConfigureAwait(false); @@ -193,6 +229,53 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return response; } + public async Task ProcessBillingCallbackAsync(Uri callbackUri, CancellationToken cancellationToken = default) + { + await _billingCallbackLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + var targetProductType = ResolveProductType(callbackUri); + if (targetProductType == null) + { + _logger.Warning("Billing callback was ignored because productCode is missing or unsupported. Uri: {Uri}", callbackUri); + return false; + } + + if (await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false) == null) + { + _logger.Warning("Billing callback was ignored because there is no authenticated Wino account."); + return false; + } + + const int maxAttempts = 15; + + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var refreshResult = await RefreshProfileAsync(cancellationToken).ConfigureAwait(false); + + if (refreshResult.IsSuccess && await HasAddOnAsync(targetProductType.Value, cancellationToken).ConfigureAwait(false)) + { + ReportUIChange(new WinoAccountAddOnPurchasedMessage(targetProductType.Value)); + return true; + } + + if (attempt < maxAttempts - 1) + { + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + } + } + + return false; + } + finally + { + _billingCallbackLock.Release(); + } + } + public async Task SignOutAsync(CancellationToken cancellationToken = default) { var account = await GetActiveAccountAsync().ConfigureAwait(false); @@ -217,6 +300,7 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun if (account != null) { + ReportUIChange(new WinoAccountProfileDeletedMessage(account)); ReportUIChange(new WinoAccountSignedOutMessage(account)); } } @@ -231,12 +315,20 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun var account = Map(response.Result); - await Connection.DeleteAllAsync().ConfigureAwait(false); - await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false); + await PersistAccountAsync(account).ConfigureAwait(false); return WinoAccountOperationResult.Success(account); } + private async Task PersistAccountAsync(WinoAccount account) + { + await Connection.DeleteAllAsync().ConfigureAwait(false); + await Connection.InsertOrReplaceAsync(account, typeof(WinoAccount)).ConfigureAwait(false); + } + + private void PublishProfileUpdated(WinoAccount account) + => ReportUIChange(new WinoAccountProfileUpdatedMessage(account)); + private async Task HasAiPackAsync(CancellationToken cancellationToken) { var response = await GetAiStatusAsync(cancellationToken).ConfigureAwait(false); @@ -264,6 +356,30 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts; } + private static bool AreEquivalentProfiles(WinoAccount left, WinoAccount right) + => left.Id == right.Id && + string.Equals(left.Email, right.Email, StringComparison.Ordinal) && + string.Equals(left.AccountStatus, right.AccountStatus, StringComparison.Ordinal) && + left.HasPassword == right.HasPassword && + left.HasGoogleLogin == right.HasGoogleLogin && + left.HasFacebookLogin == right.HasFacebookLogin; + + private static WinoAccount MergeAccountProfile(WinoAccount existingAccount, AuthUserDto profile) + => new() + { + Id = profile.UserId, + Email = profile.Email, + AccountStatus = profile.AccountStatus, + HasPassword = profile.HasPassword, + HasGoogleLogin = profile.HasGoogleLogin, + HasFacebookLogin = profile.HasFacebookLogin, + AccessToken = existingAccount.AccessToken, + AccessTokenExpiresAtUtc = existingAccount.AccessTokenExpiresAtUtc, + RefreshToken = existingAccount.RefreshToken, + RefreshTokenExpiresAtUtc = existingAccount.RefreshTokenExpiresAtUtc, + 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) { @@ -279,6 +395,44 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun return true; } + private static WinoAddOnProductType? ResolveProductType(Uri callbackUri) + { + var productCode = GetQueryParameter(callbackUri, "productCode"); + if (string.IsNullOrWhiteSpace(productCode)) + { + return null; + } + + return productCode.Trim().ToUpperInvariant() switch + { + "AI_PACK" => WinoAddOnProductType.AI_PACK, + "UNLIMITED_ACCOUNTS" => WinoAddOnProductType.UNLIMITED_ACCOUNTS, + _ => null + }; + } + + private static string GetQueryParameter(Uri uri, string key) + { + var query = uri.Query; + if (string.IsNullOrWhiteSpace(query)) + { + return string.Empty; + } + + foreach (var part in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var pieces = part.Split('=', 2); + if (pieces.Length == 0 || !string.Equals(pieces[0], key, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return pieces.Length > 1 ? Uri.UnescapeDataString(pieces[1]) : string.Empty; + } + + return string.Empty; + } + private static WinoAccount Map(AuthResultDto result) => new() {