Add subscription pricing and Base drawing limit
This commit is contained in:
1 parent
ad64d6def0
commit
6a675ce635
15 files changed
+384
-37
No files matched your search
@@ -44,6 +44,8 @@ public sealed class AppLocalizationService
|
||||
["settings.pro.monthly"] = "Pro mensile",
|
||||
["settings.pro.yearly"] = "Pro annuale",
|
||||
["settings.pro.note"] = "Il prodotto LME Pro si configura nella Play Console con piano mensile e annuale.",
|
||||
["drawings.base.limit"] = "Piano Base: fino a 15 disegni, misure, POI e punti GPS. LME Pro non ha limiti.",
|
||||
["drawings.base.limit.reached"] = "Hai raggiunto il limite di 15 oggetti del piano Base. Elimina un disegno o passa a LME Pro per continuare senza limiti.",
|
||||
["settings.pro.manual.code"] = "Codice licenza manuale",
|
||||
["settings.pro.manual.help"] = "Incolla il codice cifrato ricevuto. Nome, email e scadenza vengono letti solo dopo la verifica crittografica.",
|
||||
["settings.pro.manual.activate"] = "Attiva codice",
|
||||
@@ -92,6 +94,8 @@ public sealed class AppLocalizationService
|
||||
["settings.pro.monthly"] = "Monthly Pro",
|
||||
["settings.pro.yearly"] = "Yearly Pro",
|
||||
["settings.pro.note"] = "Create the LME Pro product in Play Console with monthly and yearly base plans.",
|
||||
["drawings.base.limit"] = "Base plan: up to 15 drawings, measurements, POIs and GPS points. LME Pro is unlimited.",
|
||||
["drawings.base.limit.reached"] = "You reached the Base plan limit of 15 objects. Delete a drawing or upgrade to LME Pro to continue without limits.",
|
||||
["settings.pro.manual.code"] = "Manual license code",
|
||||
["settings.pro.manual.help"] = "Paste the encrypted code you received. Name, email and expiry are read only after cryptographic verification.",
|
||||
["settings.pro.manual.activate"] = "Activate code",
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
namespace LMEMappeCatastaliApp.Services;
|
||||
|
||||
public sealed class EntitlementService(ManualLicenseService manualLicenses, IPlayBillingService playBilling)
|
||||
public sealed class EntitlementService(
|
||||
ManualLicenseService manualLicenses,
|
||||
IPlayBillingService playBilling,
|
||||
PlayPurchaseVerificationService purchaseVerification)
|
||||
{
|
||||
public const string ProductId = "lme_pro";
|
||||
public const string MonthlyBasePlanId = "monthly";
|
||||
public const string YearlyBasePlanId = "yearly";
|
||||
public const int BaseDrawingObjectLimit = 15;
|
||||
|
||||
private const string GoogleProActivePreference = "entitlement_google_pro_active";
|
||||
private const string GoogleExpiresAtPreference = "entitlement_google_pro_expires_at";
|
||||
@@ -53,6 +57,10 @@ public sealed class EntitlementService(ManualLicenseService manualLicenses, IPla
|
||||
public bool RequiresPro(string feature) => ProFeatures.Contains(feature);
|
||||
public bool CanUse(string feature) => !RequiresPro(feature) || HasProAccess;
|
||||
|
||||
public Task<IReadOnlyList<PlaySubscriptionPlan>> GetSubscriptionPlansAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
playBilling.GetSubscriptionPlansAsync(cancellationToken);
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsGoogleProActive = Preferences.Default.Get(GoogleProActivePreference, false);
|
||||
@@ -85,32 +93,60 @@ public sealed class EntitlementService(ManualLicenseService manualLicenses, IPla
|
||||
public async Task<PlayBillingResult> RestorePurchasesAsync()
|
||||
{
|
||||
var result = await playBilling.RestoreAsync();
|
||||
ApplyPlayBillingResult(result);
|
||||
return result;
|
||||
return await ApplyPlayBillingResultAsync(result);
|
||||
}
|
||||
|
||||
public async Task<PlayBillingResult> PurchaseGoogleProAsync(string basePlanId)
|
||||
{
|
||||
var result = await playBilling.PurchaseAsync(basePlanId);
|
||||
ApplyPlayBillingResult(result);
|
||||
return result;
|
||||
return await ApplyPlayBillingResultAsync(result);
|
||||
}
|
||||
|
||||
private void ApplyPlayBillingResult(PlayBillingResult result)
|
||||
private async Task<PlayBillingResult> ApplyPlayBillingResultAsync(PlayBillingResult result)
|
||||
{
|
||||
if (!result.Completed)
|
||||
{
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.HasActiveSubscription)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(result.PurchaseToken))
|
||||
{
|
||||
var verification = await purchaseVerification.VerifyAsync(result.PurchaseToken);
|
||||
if (verification.Verified)
|
||||
{
|
||||
if (verification.Active)
|
||||
{
|
||||
ApplyVerifiedGooglePro(verification.ExpiresAt);
|
||||
return result with
|
||||
{
|
||||
Message = string.IsNullOrWhiteSpace(verification.Message)
|
||||
? result.Message
|
||||
: verification.Message
|
||||
};
|
||||
}
|
||||
|
||||
ApplyGoogleBase();
|
||||
return PlayBillingResult.Base(string.IsNullOrWhiteSpace(verification.Message)
|
||||
? "L'abbonamento Google Play non risulta attivo."
|
||||
: verification.Message);
|
||||
}
|
||||
|
||||
if (purchaseVerification.IsRequired)
|
||||
{
|
||||
return PlayBillingResult.Failed(verification.Message);
|
||||
}
|
||||
}
|
||||
|
||||
ApplyVerifiedGooglePro(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyGoogleBase();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void SaveGoogleState()
|
||||
|
||||
@@ -2,13 +2,22 @@ namespace LMEMappeCatastaliApp.Services;
|
||||
|
||||
public interface IPlayBillingService
|
||||
{
|
||||
Task<IReadOnlyList<PlaySubscriptionPlan>> GetSubscriptionPlansAsync(CancellationToken cancellationToken = default);
|
||||
Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default);
|
||||
Task<PlayBillingResult> PurchaseAsync(string basePlanId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record PlayBillingResult(bool Completed, bool HasActiveSubscription, bool UserCancelled, string Message)
|
||||
public sealed record PlaySubscriptionPlan(string BasePlanId, string FormattedPrice, string BillingPeriod);
|
||||
|
||||
public sealed record PlayBillingResult(
|
||||
bool Completed,
|
||||
bool HasActiveSubscription,
|
||||
bool UserCancelled,
|
||||
string Message,
|
||||
string? PurchaseToken = null)
|
||||
{
|
||||
public static PlayBillingResult Active(string message) => new(true, true, false, message);
|
||||
public static PlayBillingResult Active(string message, string? purchaseToken = null) =>
|
||||
new(true, true, false, message, purchaseToken);
|
||||
public static PlayBillingResult Base(string message) => new(true, false, false, message);
|
||||
public static PlayBillingResult Cancelled(string message) => new(false, false, true, message);
|
||||
public static PlayBillingResult Failed(string message) => new(false, false, false, message);
|
||||
@@ -16,6 +25,9 @@ public sealed record PlayBillingResult(bool Completed, bool HasActiveSubscriptio
|
||||
|
||||
public sealed class UnavailablePlayBillingService : IPlayBillingService
|
||||
{
|
||||
public Task<IReadOnlyList<PlaySubscriptionPlan>> GetSubscriptionPlansAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<PlaySubscriptionPlan>>([]);
|
||||
|
||||
public Task<PlayBillingResult> RestoreAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(PlayBillingResult.Failed("Google Play Billing è disponibile solo nell'app Android installata da Google Play."));
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
|
||||
namespace LMEMappeCatastaliApp.Services;
|
||||
|
||||
public sealed class PlayPurchaseVerificationService
|
||||
{
|
||||
private const string EndpointMetadataKey = "PlayBillingVerificationUrl";
|
||||
private const string RequiredMetadataKey = "PlayBillingVerificationRequired";
|
||||
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(20) };
|
||||
|
||||
public bool IsConfigured => VerificationEndpoint is not null;
|
||||
public bool IsRequired => ReadMetadata(RequiredMetadataKey).Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static Uri? VerificationEndpoint
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = ReadMetadata(EndpointMetadataKey);
|
||||
return Uri.TryCreate(value, UriKind.Absolute, out var endpoint)
|
||||
&& endpoint.Scheme == Uri.UriSchemeHttps
|
||||
? endpoint
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlayPurchaseVerificationResult> VerifyAsync(
|
||||
string purchaseToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var endpoint = VerificationEndpoint;
|
||||
if (endpoint is null)
|
||||
{
|
||||
return PlayPurchaseVerificationResult.NotConfigured();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.PostAsJsonAsync(endpoint, new
|
||||
{
|
||||
packageName = AppInfo.Current.PackageName,
|
||||
productId = EntitlementService.ProductId,
|
||||
purchaseToken
|
||||
}, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return PlayPurchaseVerificationResult.Failed(
|
||||
$"Verifica acquisto non disponibile ({(int)response.StatusCode}).");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<VerificationResponse>(cancellationToken);
|
||||
return result is null
|
||||
? PlayPurchaseVerificationResult.Failed("Risposta di verifica acquisto non valida.")
|
||||
: new(true, true, result.Active, result.ExpiresAt, result.Message ?? string.Empty);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return PlayPurchaseVerificationResult.Failed("Il server di verifica acquisti non ha risposto in tempo.");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return PlayPurchaseVerificationResult.Failed(
|
||||
$"Verifica acquisto non disponibile: {error.GetBaseException().Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadMetadata(string key) =>
|
||||
typeof(PlayPurchaseVerificationService).Assembly
|
||||
.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||
.FirstOrDefault(attribute => attribute.Key == key)?.Value?.Trim() ?? string.Empty;
|
||||
|
||||
private sealed record VerificationResponse(bool Active, DateTimeOffset? ExpiresAt, string? Message);
|
||||
}
|
||||
|
||||
public sealed record PlayPurchaseVerificationResult(
|
||||
bool Configured,
|
||||
bool Verified,
|
||||
bool Active,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string Message)
|
||||
{
|
||||
public static PlayPurchaseVerificationResult NotConfigured() =>
|
||||
new(false, false, false, null, "Server di verifica acquisti non configurato.");
|
||||
|
||||
public static PlayPurchaseVerificationResult Failed(string message) =>
|
||||
new(true, false, false, null, message);
|
||||
}
|
||||
Reference in new issue
Block a user