Handling of paddle purchases and add-ons.
This commit is contained in:
@@ -29,6 +29,7 @@ public static class ServicesContainerSetup
|
||||
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
|
||||
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
|
||||
services.AddTransient<IWinoAccountProfileService, WinoAccountProfileService>();
|
||||
services.AddTransient<IWinoAddOnService, WinoAddOnService>();
|
||||
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
|
||||
|
||||
services.AddTransient<ICalDavClient, CalDavClient>();
|
||||
|
||||
@@ -11,9 +11,12 @@ using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Mail.Api.Contracts.Ai;
|
||||
using Wino.Mail.Api.Contracts.Auth;
|
||||
using Wino.Mail.Api.Contracts.Billing;
|
||||
using Wino.Mail.Api.Contracts.Common;
|
||||
|
||||
namespace Wino.Services;
|
||||
@@ -46,13 +49,13 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
|
||||
public Task<ApiEnvelope<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||
public Task<WinoAccountApiResult<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||
=> SendAuthRequestAsync("api/v1/auth/register", new RegisterRequest(email, password), WinoAccountApiJsonContext.Default.RegisterRequest, cancellationToken);
|
||||
|
||||
public Task<ApiEnvelope<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||
public Task<WinoAccountApiResult<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||
=> SendAuthRequestAsync("api/v1/auth/login", new LoginRequest(email, password), WinoAccountApiJsonContext.Default.LoginRequest, cancellationToken);
|
||||
|
||||
public Task<ApiEnvelope<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
public Task<WinoAccountApiResult<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
=> SendAuthRequestAsync("api/v1/auth/refresh", new RefreshRequest(refreshToken), WinoAccountApiJsonContext.Default.RefreshRequest, cancellationToken);
|
||||
|
||||
public async Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
@@ -84,20 +87,27 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
public Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default)
|
||||
=> SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken);
|
||||
|
||||
public Task<ApiEnvelope<string>> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default)
|
||||
public Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var endpoint = productId switch
|
||||
{
|
||||
"ai-pack-monthly" => "api/v1/billing/ai-pack/checkout-session",
|
||||
"unlimited-accounts" => "api/v1/billing/unlimited-accounts/checkout-session",
|
||||
WinoAddOnProductType.AI_PACK => "api/v1/billing/ai-pack/checkout-session",
|
||||
WinoAddOnProductType.UNLIMITED_ACCOUNTS => "api/v1/billing/unlimited-accounts/checkout-session",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
return string.IsNullOrWhiteSpace(endpoint)
|
||||
? Task.FromResult(ApiEnvelope<string>.Failure("UnknownProduct"))
|
||||
: SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeString, cancellationToken);
|
||||
? Task.FromResult(ApiEnvelope<CheckoutSessionResultDto>.Failure("UnknownProduct"))
|
||||
: SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeCheckoutSessionResultDto, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default)
|
||||
=> SendAuthorizedRequestAsync(
|
||||
HttpMethod.Post,
|
||||
"api/v1/billing/ai-pack/customer-portal-session",
|
||||
WinoAccountApiJsonContext.Default.ApiEnvelopeCustomerPortalResultDto,
|
||||
cancellationToken);
|
||||
|
||||
public async Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
@@ -141,7 +151,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApiEnvelope<AuthResultDto>> SendAuthRequestAsync<TRequest>(string endpoint, TRequest request, JsonTypeInfo<TRequest> typeInfo, CancellationToken cancellationToken)
|
||||
private async Task<WinoAccountApiResult<AuthResultDto>> SendAuthRequestAsync<TRequest>(string endpoint, TRequest request, JsonTypeInfo<TRequest> typeInfo, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -156,14 +166,83 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
? null
|
||||
: JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeAuthResultDto);
|
||||
|
||||
return envelope ?? ApiEnvelope<AuthResultDto>.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim());
|
||||
if (envelope?.IsSuccess == true && envelope.Result != null)
|
||||
{
|
||||
return WinoAccountApiResult<AuthResultDto>.Success(envelope.Result);
|
||||
}
|
||||
|
||||
var errorCode = envelope?.ErrorCode ?? $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim();
|
||||
var errorMessage = ExtractErrorMessage(payload) ?? response.ReasonPhrase;
|
||||
|
||||
return WinoAccountApiResult<AuthResultDto>.Failure(errorCode, errorMessage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ApiEnvelope<AuthResultDto>.Failure(ex.Message);
|
||||
return WinoAccountApiResult<AuthResultDto>.Failure(ex.GetType().Name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractErrorMessage(string? payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
return TryGetErrorMessage(document.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetErrorMessage(JsonElement element)
|
||||
{
|
||||
if (TryGetStringProperty(element, "errorMessage", out var errorMessage))
|
||||
{
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
if (TryGetStringProperty(element, "message", out var message))
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
if (TryGetStringProperty(element, "detail", out var detail))
|
||||
{
|
||||
return detail;
|
||||
}
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("error", out var errorElement))
|
||||
{
|
||||
return TryGetErrorMessage(errorElement);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetStringProperty(JsonElement element, string propertyName, out string? value)
|
||||
{
|
||||
value = null;
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (property.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = property.GetString();
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
private Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TResponse>(string endpoint, JsonTypeInfo<ApiEnvelope<TResponse>> typeInfo, CancellationToken cancellationToken)
|
||||
=> SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken);
|
||||
|
||||
@@ -234,6 +313,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
|
||||
[JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<string>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<CheckoutSessionResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<CustomerPortalResultDto>))]
|
||||
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
|
||||
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Mail.Api.Contracts.Ai;
|
||||
using Wino.Mail.Api.Contracts.Auth;
|
||||
using Wino.Mail.Api.Contracts.Billing;
|
||||
using Wino.Mail.Api.Contracts.Common;
|
||||
using Wino.Messaging.UI;
|
||||
|
||||
@@ -16,11 +19,15 @@ namespace Wino.Services;
|
||||
public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccountProfileService
|
||||
{
|
||||
private readonly IWinoAccountApiClient _apiClient;
|
||||
private readonly IStoreManagementService _storeManagementService;
|
||||
private readonly ILogger _logger = Log.ForContext<WinoAccountProfileService>();
|
||||
|
||||
public WinoAccountProfileService(IDatabaseService databaseService, IWinoAccountApiClient apiClient) : base(databaseService)
|
||||
public WinoAccountProfileService(IDatabaseService databaseService,
|
||||
IWinoAccountApiClient apiClient,
|
||||
IStoreManagementService storeManagementService) : base(databaseService)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_storeManagementService = storeManagementService;
|
||||
}
|
||||
|
||||
public async Task<WinoAccountOperationResult> RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
|
||||
@@ -108,6 +115,16 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
public async Task<bool> HasActiveAccountAsync()
|
||||
=> await Connection.Table<WinoAccount>().CountAsync().ConfigureAwait(false) > 0;
|
||||
|
||||
public async Task<bool> 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<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -142,12 +159,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<ApiEnvelope<string>> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default)
|
||||
public async Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (account == null)
|
||||
{
|
||||
return ApiEnvelope<string>.Failure("MissingAccessToken");
|
||||
return ApiEnvelope<CheckoutSessionResultDto>.Failure("MissingAccessToken");
|
||||
}
|
||||
|
||||
var response = await _apiClient.CreateCheckoutSessionAsync(productId, cancellationToken).ConfigureAwait(false);
|
||||
@@ -159,6 +176,23 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (account == null)
|
||||
{
|
||||
return ApiEnvelope<CustomerPortalResultDto>.Failure("MissingAccessToken");
|
||||
}
|
||||
|
||||
var response = await _apiClient.CreateCustomerPortalSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccess)
|
||||
{
|
||||
_logger.Warning("Failed to create customer portal session for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task SignOutAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var account = await GetActiveAccountAsync().ConfigureAwait(false);
|
||||
@@ -187,12 +221,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<WinoAccountOperationResult> PersistResponseAsync(ApiEnvelope<AuthResultDto> response)
|
||||
private async Task<WinoAccountOperationResult> PersistResponseAsync(WinoAccountApiResult<AuthResultDto> response)
|
||||
{
|
||||
if (!response.IsSuccess || response.Result == null)
|
||||
{
|
||||
_logger.Warning("Wino account operation failed. Error code: {ErrorCode}", response.ErrorCode);
|
||||
return WinoAccountOperationResult.Failure(response.ErrorCode);
|
||||
_logger.Warning("Wino account operation failed. Error code: {ErrorCode}. Error message: {ErrorMessage}", response.ErrorCode, response.ErrorMessage);
|
||||
return WinoAccountOperationResult.Failure(response.ErrorCode, response.ErrorMessage);
|
||||
}
|
||||
|
||||
var account = Map(response.Result);
|
||||
@@ -203,6 +237,48 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
|
||||
return WinoAccountOperationResult.Success(account);
|
||||
}
|
||||
|
||||
private async Task<bool> HasAiPackAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await GetAiStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccess && response.Result?.HasAiPack == true;
|
||||
}
|
||||
|
||||
private async Task<bool> HasUnlimitedAccountsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasRemoteUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return await _storeManagementService.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> HasRemoteUnlimitedAccountsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await GetCurrentUserAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccess || response.Result == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts;
|
||||
}
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The reflected contract property is a stable API field read from a concrete DTO instance.")]
|
||||
private static bool TryGetBooleanProperty(object instance, string propertyName, out bool value)
|
||||
{
|
||||
value = false;
|
||||
|
||||
var property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
|
||||
if (property?.PropertyType != typeof(bool))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = (bool)(property.GetValue(instance) ?? false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static WinoAccount Map(AuthResultDto result)
|
||||
=> new()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
|
||||
namespace Wino.Services;
|
||||
|
||||
public sealed class WinoAddOnService : IWinoAddOnService
|
||||
{
|
||||
private static readonly WinoAddOnProductType[] AvailableAddOns =
|
||||
[
|
||||
WinoAddOnProductType.AI_PACK,
|
||||
WinoAddOnProductType.UNLIMITED_ACCOUNTS
|
||||
];
|
||||
|
||||
private readonly IWinoAccountProfileService _profileService;
|
||||
|
||||
public WinoAddOnService(IWinoAccountProfileService profileService)
|
||||
{
|
||||
_profileService = profileService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WinoAddOnInfo>> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var aiStatusTask = _profileService.GetAiStatusAsync(cancellationToken);
|
||||
var hasUnlimitedAccountsTask = _profileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS, cancellationToken);
|
||||
|
||||
await Task.WhenAll(aiStatusTask, hasUnlimitedAccountsTask).ConfigureAwait(false);
|
||||
|
||||
var aiStatusResponse = await aiStatusTask.ConfigureAwait(false);
|
||||
var aiStatus = aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null;
|
||||
|
||||
return
|
||||
[
|
||||
new WinoAddOnInfo(
|
||||
WinoAddOnProductType.AI_PACK,
|
||||
aiStatus?.HasAiPack == true,
|
||||
aiStatus?.Used,
|
||||
aiStatus?.MonthlyLimit,
|
||||
aiStatus?.HasAiPack == true && aiStatus.MonthlyLimit is int limit && limit > 0 && aiStatus.Used is int used
|
||||
? (double)used / limit * 100
|
||||
: 0,
|
||||
aiStatus?.CurrentPeriodEndUtc),
|
||||
new WinoAddOnInfo(
|
||||
WinoAddOnProductType.UNLIMITED_ACCOUNTS,
|
||||
await hasUnlimitedAccountsTask.ConfigureAwait(false))
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user