Handling of paddle purchases and add-ons.

This commit is contained in:
Burak Kaan Köse
2026-03-19 01:50:14 +01:00
parent f306f6eb1c
commit b0ee5c9974
26 changed files with 779 additions and 513 deletions
@@ -9,7 +9,6 @@ using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Store;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Navigation;
@@ -44,6 +43,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
protected IAccountService AccountService { get; }
protected IProviderService ProviderService { get; }
protected IStoreManagementService StoreManagementService { get; }
protected IWinoAccountProfileService WinoAccountProfileService { get; }
protected IAuthenticationProvider AuthenticationProvider { get; }
protected IPreferencesService PreferencesService { get; }
@@ -52,6 +52,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
IAccountService accountService,
IProviderService providerService,
IStoreManagementService storeManagementService,
IWinoAccountProfileService winoAccountProfileService,
IAuthenticationProvider authenticationProvider,
IPreferencesService preferencesService)
{
@@ -60,6 +61,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
AccountService = accountService;
ProviderService = providerService;
StoreManagementService = storeManagementService;
WinoAccountProfileService = winoAccountProfileService;
AuthenticationProvider = authenticationProvider;
PreferencesService = preferencesService;
}
@@ -75,7 +77,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
[RelayCommand]
public async Task PurchaseUnlimitedAccountAsync()
{
var purchaseResult = await StoreManagementService.PurchaseAsync(StoreProductType.UnlimitedAccounts);
var purchaseResult = await StoreManagementService.PurchaseAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS);
if (purchaseResult == StorePurchaseResult.Succeeded)
DialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle, Translator.Info_PurchaseThankYouMessage, InfoBarMessageType.Success);
@@ -92,14 +94,12 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
public async Task ManageStorePurchasesAsync()
{
await ExecuteUIThread(async () =>
{
HasUnlimitedAccountProduct = await StoreManagementService.HasProductAsync(StoreProductType.UnlimitedAccounts);
var hasUnlimitedAccountProduct = await WinoAccountProfileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
if (!HasUnlimitedAccountProduct)
IsAccountCreationBlocked = Accounts.Count >= FREE_ACCOUNT_COUNT;
else
IsAccountCreationBlocked = false;
await ExecuteUIThread(() =>
{
HasUnlimitedAccountProduct = hasUnlimitedAccountProduct;
IsAccountCreationBlocked = !hasUnlimitedAccountProduct && Accounts.Count >= FREE_ACCOUNT_COUNT;
});
}
@@ -0,0 +1,52 @@
#nullable enable
using CommunityToolkit.Mvvm.ComponentModel;
using System.Windows.Input;
using Wino.Core.Domain.Enums;
namespace Wino.Core.ViewModels.Data;
public partial class WinoAddOnItemViewModel : ObservableObject
{
public WinoAddOnProductType ProductType { get; }
public string NameKey => $"WinoAddOn_{ProductType}_Name";
public string DescriptionKey => $"WinoAddOn_{ProductType}_Description";
public string KeywordsKey => $"WinoAddOn_{ProductType}_Keywords";
public string IconGlyph => ProductType switch
{
WinoAddOnProductType.AI_PACK => "\uE945",
WinoAddOnProductType.UNLIMITED_ACCOUNTS => "\uE716",
_ => "\uE10F"
};
[ObservableProperty]
public partial bool IsPurchased { get; set; }
public ICommand? PurchaseCommand { get; set; }
public ICommand? ManageCommand { get; set; }
[ObservableProperty]
public partial bool IsPurchaseInProgress { get; set; }
[ObservableProperty]
public partial int UsageCount { get; set; }
[ObservableProperty]
public partial int UsageLimit { get; set; } = 1;
[ObservableProperty]
public partial double UsagePercentage { get; set; }
[ObservableProperty]
public partial string RenewalText { get; set; } = string.Empty;
[ObservableProperty]
public partial string UsageResetText { get; set; } = string.Empty;
public WinoAddOnItemViewModel(WinoAddOnProductType productType)
{
ProductType = productType;
}
}
@@ -1,17 +1,19 @@
#nullable enable
using System;
using System.Globalization;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Navigation;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Core.ViewModels.Data;
using Wino.Messaging.UI;
namespace Wino.Core.ViewModels;
@@ -20,27 +22,20 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
IRecipient<WinoAccountSignedInMessage>,
IRecipient<WinoAccountSignedOutMessage>
{
private const string AiPackProductId = "ai-pack-monthly";
private const string ManageAiPackUrl = "https://example.com/wino-ai-pack/manage";
private readonly IWinoAccountProfileService _profileService;
private readonly IWinoAddOnService _addOnService;
private readonly IMailDialogService _dialogService;
private readonly INativeAppService _nativeAppService;
public ObservableCollection<WinoAddOnItemViewModel> AddOns { get; } = [];
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSignedOut))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool IsSignedIn { get; set; }
[ObservableProperty]
public partial string AccountDisplayName { get; set; } = string.Empty;
[ObservableProperty]
public partial string AccountInitials { get; set; } = string.Empty;
[ObservableProperty]
public partial string AccountEmail { get; set; } = string.Empty;
@@ -48,48 +43,18 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
public partial string AccountStatusText { get; set; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanShowAiUsage))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool HasAiPack { get; set; }
[ObservableProperty]
public partial string AiPackStateText { get; set; } = Translator.WinoAccount_Management_AiPackInactive;
[ObservableProperty]
public partial string AiUsageSummary { get; set; } = string.Empty;
[ObservableProperty]
public partial string AiBillingPeriodSummary { get; set; } = string.Empty;
[ObservableProperty]
public partial string AiPackRenewalText { get; set; } = string.Empty;
[ObservableProperty]
public partial int AiUsageCount { get; set; }
[ObservableProperty]
public partial int AiUsageLimit { get; set; } = 1;
[ObservableProperty]
public partial double AiUsagePercentage { get; set; }
[ObservableProperty]
public partial string AiUsageResetText { get; set; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanBuyAiPack))]
public partial bool IsAiPackCheckoutInProgress { get; set; }
[NotifyCanExecuteChangedFor(nameof(PurchaseAddOnCommand))]
public partial bool IsCheckoutInProgress { get; set; }
public bool IsSignedOut => !IsSignedIn;
public bool CanShowAiUsage => HasAiPack;
public bool CanShowBuyAiPack => IsSignedIn && !HasAiPack;
public bool CanBuyAiPack => !IsAiPackCheckoutInProgress;
public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService,
IWinoAddOnService addOnService,
IMailDialogService dialogService,
INativeAppService nativeAppService)
{
_profileService = profileService;
_addOnService = addOnService;
_dialogService = dialogService;
_nativeAppService = nativeAppService;
}
@@ -147,16 +112,15 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
InfoBarMessageType.Success);
}
[RelayCommand]
private async Task BuyAiPackAsync()
[RelayCommand(CanExecute = nameof(CanPurchaseAddOn))]
private async Task PurchaseAddOnAsync(WinoAddOnItemViewModel? addOn)
{
if (IsAiPackCheckoutInProgress)
if (addOn == null)
{
return;
}
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
if (account == null)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
@@ -165,13 +129,17 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
return;
}
await ExecuteUIThread(() => IsAiPackCheckoutInProgress = true);
await ExecuteUIThread(() =>
{
IsCheckoutInProgress = true;
addOn.IsPurchaseInProgress = true;
});
try
{
var checkoutSession = await _profileService.CreateCheckoutSessionAsync(AiPackProductId).ConfigureAwait(false);
var checkoutSession = await _profileService.CreateCheckoutSessionAsync(addOn.ProductType).ConfigureAwait(false);
if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result))
if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result?.Url))
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed,
@@ -179,8 +147,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
return;
}
var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result)).ConfigureAwait(false);
var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result.Url)).ConfigureAwait(false);
if (!isLaunched)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
@@ -199,12 +166,49 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
}
finally
{
await ExecuteUIThread(() => IsAiPackCheckoutInProgress = false);
await ExecuteUIThread(() =>
{
IsCheckoutInProgress = false;
addOn.IsPurchaseInProgress = false;
});
}
}
private bool CanPurchaseAddOn(WinoAddOnItemViewModel? addOn)
=> addOn != null && !addOn.IsPurchased && !IsCheckoutInProgress;
[RelayCommand]
private async Task ManageAiPackAsync() => await _nativeAppService.LaunchUriAsync(new Uri(ManageAiPackUrl));
private async Task ManageAiPackAsync()
{
try
{
var portalSession = await _profileService.CreateCustomerPortalSessionAsync().ConfigureAwait(false);
if (!portalSession.IsSuccess || string.IsNullOrWhiteSpace(portalSession.Result?.Url))
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed,
InfoBarMessageType.Error);
return;
}
var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(portalSession.Result.Url)).ConfigureAwait(false);
if (!isLaunched)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed,
InfoBarMessageType.Error);
}
}
catch (OperationCanceledException)
{
}
catch (Exception)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed,
InfoBarMessageType.Error);
}
}
[RelayCommand]
private Task ExportSettingsAsync() => Task.CompletedTask;
@@ -232,7 +236,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
=> _ = LoadAsync();
public void Receive(WinoAccountSignedOutMessage message)
=> _ = ResetSignedOutStateAsync();
=> _ = LoadAsync();
private async Task LoadAsync()
{
@@ -241,44 +245,25 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
try
{
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
if (account == null)
{
await ResetSignedOutStateAsync();
return;
}
var currentUserResponse = await _profileService.GetCurrentUserAsync().ConfigureAwait(false);
var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false);
var resolvedUser = currentUserResponse.IsSuccess && currentUserResponse.Result != null
? currentUserResponse.Result
: new AuthUserDto(account.Id, account.Email, account.AccountStatus, account.HasPassword, account.HasGoogleLogin, account.HasFacebookLogin);
var addOns = await _addOnService.GetAvailableAddOnsAsync().ConfigureAwait(false);
await ExecuteUIThread(() =>
{
IsSignedIn = true;
AccountEmail = resolvedUser.Email;
AccountDisplayName = ExtractDisplayName(resolvedUser.Email);
AccountInitials = ExtractInitials(resolvedUser.Email);
AccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, resolvedUser.AccountStatus);
IsSignedIn = account != null;
AccountEmail = account?.Email ?? string.Empty;
AccountStatusText = account == null
? string.Empty
: string.Format(Translator.WinoAccount_Management_StatusLabel, account.AccountStatus);
});
UpdateAiPackState(aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null);
if (!currentUserResponse.IsSuccess || !aiStatusResponse.IsSuccess)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
Translator.WinoAccount_Management_LoadFailed,
InfoBarMessageType.Warning);
}
await UpdateAddOnsAsync(addOns).ConfigureAwait(false);
}
catch (Exception)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_LoadFailed,
InfoBarMessageType.Error);
await ResetSignedOutStateAsync();
await ResetStateAsync().ConfigureAwait(false);
}
finally
{
@@ -286,89 +271,54 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
}
}
private async Task ResetSignedOutStateAsync()
private async Task ResetStateAsync()
{
await ExecuteUIThread(() =>
{
IsSignedIn = false;
AccountDisplayName = string.Empty;
AccountInitials = string.Empty;
AccountEmail = string.Empty;
AccountStatusText = string.Empty;
HasAiPack = false;
AiPackStateText = Translator.WinoAccount_Management_AiPackInactive;
AiUsageSummary = string.Empty;
AiBillingPeriodSummary = string.Empty;
AiPackRenewalText = string.Empty;
AiUsageCount = 0;
AiUsageLimit = 1;
AiUsagePercentage = 0;
AiUsageResetText = string.Empty;
IsAiPackCheckoutInProgress = false;
IsCheckoutInProgress = false;
AddOns.Clear();
PurchaseAddOnCommand.NotifyCanExecuteChanged();
});
}
private void UpdateAiPackState(AiStatusResultDto? aiStatus)
private async Task UpdateAddOnsAsync(IReadOnlyList<WinoAddOnInfo> addOns)
{
var hasAiPack = aiStatus?.HasAiPack == true;
var usageText = Translator.WinoAccount_Management_AiPackUnknownUsage;
var billingText = string.Empty;
var renewalText = string.Empty;
var usageCount = 0;
var usageLimit = 1;
var usagePercentage = 0d;
var resetText = string.Empty;
var items = addOns.Select(CreateAddOnItem).ToList();
if (hasAiPack && aiStatus?.Used is int used && aiStatus.MonthlyLimit is int limit && aiStatus.Remaining is int remaining)
await ExecuteUIThread(() =>
{
usageText = string.Format(Translator.WinoAccount_Management_AiPackUsage, used, limit, remaining);
usageCount = used;
usageLimit = limit > 0 ? limit : 1;
usagePercentage = (double)used / usageLimit * 100;
}
AddOns.Clear();
if (hasAiPack && aiStatus?.CurrentPeriodStartUtc is DateTimeOffset periodStart && aiStatus.CurrentPeriodEndUtc is DateTimeOffset periodEnd)
{
billingText = string.Format(Translator.WinoAccount_Management_AiPackBillingPeriod, periodStart.LocalDateTime, periodEnd.LocalDateTime);
renewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, periodEnd.LocalDateTime);
resetText = string.Format(Translator.WinoAccount_Management_AiPackResets, periodEnd.LocalDateTime);
}
foreach (var item in items)
{
AddOns.Add(item);
}
_ = ExecuteUIThread(() =>
{
HasAiPack = hasAiPack;
AiPackStateText = hasAiPack
? Translator.WinoAccount_Management_AiPackActive
: Translator.WinoAccount_Management_AiPackInactive;
AiUsageSummary = hasAiPack ? usageText : string.Empty;
AiBillingPeriodSummary = hasAiPack ? billingText : string.Empty;
AiPackRenewalText = hasAiPack ? renewalText : string.Empty;
AiUsageCount = usageCount;
AiUsageLimit = usageLimit;
AiUsagePercentage = usagePercentage;
AiUsageResetText = hasAiPack ? resetText : string.Empty;
PurchaseAddOnCommand.NotifyCanExecuteChanged();
});
}
private static string ExtractDisplayName(string email)
private WinoAddOnItemViewModel CreateAddOnItem(WinoAddOnInfo addOn)
{
if (string.IsNullOrWhiteSpace(email))
return string.Empty;
var item = new WinoAddOnItemViewModel(addOn.ProductType)
{
IsPurchased = addOn.IsPurchased,
PurchaseCommand = PurchaseAddOnCommand,
ManageCommand = ManageAiPackCommand,
UsageCount = addOn.UsageCount ?? 0,
UsageLimit = addOn.UsageLimit is > 0 ? addOn.UsageLimit.Value : 1,
UsagePercentage = addOn.UsagePercentage
};
var atIndex = email.IndexOf('@');
var localPart = atIndex > 0 ? email[..atIndex] : email;
if (addOn.RenewalDateUtc is DateTimeOffset renewalDateUtc)
{
item.RenewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, renewalDateUtc.LocalDateTime);
item.UsageResetText = string.Format(Translator.WinoAccount_Management_AiPackResets, renewalDateUtc.LocalDateTime);
}
if (localPart.Length == 0)
return string.Empty;
return char.ToUpper(localPart[0], CultureInfo.CurrentCulture) + localPart[1..];
}
private static string ExtractInitials(string email)
{
var displayName = ExtractDisplayName(email);
return displayName.Length > 0
? displayName[..1].ToUpper(CultureInfo.CurrentCulture)
: string.Empty;
return item;
}
}