fix(compras): revocar el PRO tras un reembolso sin penalizar a quien pago
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 4m8s

Verificacion silenciosa en segundo plano con queryPastPurchases, al abrir o
volver a la app y al cargar Android Auto. Solo revoca tras dos respuestas
validas de Play sin la compra separadas 12h; sin red o con error no toca nada.
Reactiva el PRO automaticamente si Play confirma la compra.
This commit is contained in:
Javier Bautista Fernández
2026-09-18 11:45:43 +02:00
parent d7366bbf99
commit 0b7919e72e
14 changed files with 1163 additions and 17 deletions
+77 -8
View File
@@ -5,12 +5,14 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../servicios/servicio_compras.dart';
import '../servicios/verificacion_licencia.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
/// premium unlock. Older builds that predate this key simply never read it —
/// no migration needed (Rollout "Versioned key ... is ignored by older
/// builds").
const _keyPremium = 'compra_premium_v1';
/// builds"). Shared with the silent license re-verification
/// (`verificacion_licencia.dart`), which may revoke it after a refund.
const _keyPremium = claveCompraPremium;
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
/// Entitlement Read"): resolves the persisted premium flag directly from
@@ -55,14 +57,21 @@ enum ResultadoEntitlementUsuario {
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
/// since no `Provider` exists on that path.
class EstadoEntitlement extends ChangeNotifier {
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
: _prefs = prefs,
_compras = compras {
EstadoEntitlement({
SharedPreferences? prefs,
PuertoCompras? compras,
DateTime Function()? reloj,
}) : _prefs = prefs,
_compras = compras,
_reloj = reloj {
final flujo = _compras;
if (flujo != null) {
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
}
_cargar();
// The silent license check is chained AFTER the load and never awaited
// by anyone: the persisted flag is served immediately, exactly as
// before, and the check can only adjust it later, in the background.
unawaited(_cargar().then((_) => _verificarLicencia()));
}
/// The single non-consumable product id (Design "Interfaces / Contracts"),
@@ -72,7 +81,11 @@ class EstadoEntitlement extends ChangeNotifier {
final SharedPreferences? _prefs;
final PuertoCompras? _compras;
/// Injectable clock for the license check's throttle/spacing rules.
final DateTime Function()? _reloj;
StreamSubscription<EventoCompra>? _comprasSub;
bool _desechado = false;
bool _esPremium = false;
bool _compraEnCurso = false;
@@ -106,6 +119,53 @@ class EstadoEntitlement extends ChangeNotifier {
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
/// Fire-and-forget hook for app resume: re-syncs with the persisted flag
/// (the Android Auto path may have changed it) and runs the throttled
/// silent license check. Never throws, never touches [compraEnCurso] or
/// [resultadoUsuario].
Future<void> refrescarLicencia() async {
try {
_sincronizarConPrefs(await _resolverPrefs());
} catch (e) {
debugPrint('[PluriWave][licencia] refresco fallido $e');
}
await _verificarLicencia();
}
/// Runs [verificarLicencia] against the purchase port and mirrors any
/// change of the persisted flag. Silent by construction: it only ever
/// updates [esPremium] and notifies — no purchase-stream event, no
/// [resultadoUsuario], no [compraEnCurso].
Future<void> _verificarLicencia() async {
final compras = _compras;
if (compras == null || _desechado) return;
try {
final prefs = await _resolverPrefs();
await verificarLicencia(
consultar: compras.consultarPropiedad,
prefs: prefs,
reloj: _reloj,
);
_sincronizarConPrefs(prefs);
} catch (e) {
debugPrint('[PluriWave][licencia] verificacion fallida $e');
}
}
/// Aligns [esPremium] with the persisted flag. Safe against a racing
/// [_desbloquear]: that one writes the prefs cache in the same synchronous
/// block where it flips [_esPremium], so both always agree here.
void _sincronizarConPrefs(SharedPreferences prefs) {
if (_desechado) return;
final premium = prefs.getBool(_keyPremium) ?? false;
if (premium == _esPremium) return;
_esPremium = premium;
notifyListeners();
// Either direction changes what the car may show (local music is
// premium-gated), so the cached Android Auto tree is stale both ways.
invalidarArbolAuto();
}
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
/// already premium (Spec "Already-purchased attempt is idempotent") — no
/// duplicate charge is even attempted.
@@ -171,11 +231,19 @@ class EstadoEntitlement extends ChangeNotifier {
}
Future<void> _desbloquear() async {
// Prefs resolved FIRST so the in-memory flip and the prefs-cache write
// below happen in one synchronous block (`setBool` updates the cache
// before awaiting the platform) — [_sincronizarConPrefs] can never
// observe one without the other.
final prefs = await _resolverPrefs();
final yaEraPremium = _esPremium;
_esPremium = true;
_compraEnCurso = false;
final prefs = await _resolverPrefs();
await prefs.setBool(_keyPremium, true);
final escritura = prefs.setBool(_keyPremium, true);
// A real purchase/restore is fresh proof of ownership: drop any stale
// absence streak of the silent license check.
await reiniciarAusenciasLicencia(prefs);
await escritura;
notifyListeners();
if (!yaEraPremium) {
// Orchestrator-resolved open question (design.md): actively
@@ -187,6 +255,7 @@ class EstadoEntitlement extends ChangeNotifier {
@override
void dispose() {
_desechado = true;
_comprasSub?.cancel();
super.dispose();
}