import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'billing_models.dart'; /// Item in the durable purchase verification queue. @immutable class QueuedVerificationItem { final String purchaseId; final String productId; final String purchaseToken; final String? purchaseTokenHash; final int attemptCount; final DateTime queuedAt; final DateTime nextRetryAt; const QueuedVerificationItem({ required this.purchaseId, required this.productId, required this.purchaseToken, this.purchaseTokenHash, this.attemptCount = 0, required this.queuedAt, required this.nextRetryAt, }); QueuedVerificationItem copyWithRetry({ required int attemptCount, required DateTime nextRetryAt, }) { return QueuedVerificationItem( purchaseId: purchaseId, productId: productId, purchaseToken: purchaseToken, purchaseTokenHash: purchaseTokenHash, attemptCount: attemptCount, queuedAt: queuedAt, nextRetryAt: nextRetryAt, ); } Map toJson() => { 'purchaseId': purchaseId, 'productId': productId, 'purchaseToken': purchaseToken, 'purchaseTokenHash': purchaseTokenHash, 'attemptCount': attemptCount, 'queuedAt': queuedAt.toIso8601String(), 'nextRetryAt': nextRetryAt.toIso8601String(), }; factory QueuedVerificationItem.fromJson(Map json) { return QueuedVerificationItem( purchaseId: json['purchaseId'] as String, productId: json['productId'] as String, purchaseToken: json['purchaseToken'] as String, purchaseTokenHash: json['purchaseTokenHash'] as String?, attemptCount: json['attemptCount'] as int? ?? 0, queuedAt: DateTime.parse(json['queuedAt'] as String), nextRetryAt: DateTime.parse(json['nextRetryAt'] as String), ); } } /// Abstract contract for the durable verification queue. abstract class DurableVerificationQueue { Future> loadQueue(); Future enqueue(StorePurchase purchase); Future dequeue(String purchaseId); Future recordRetry(String purchaseId, {required Duration backoffDelay}); Future clear(); } /// In-memory queue for deterministic testing. class InMemoryVerificationQueue implements DurableVerificationQueue { final Map _items = {}; @override Future> loadQueue() async { return _items.values.toList(); } @override Future enqueue(StorePurchase purchase) async { final token = purchase.purchaseToken; if (token == null || token.isEmpty) return; if (!_items.containsKey(purchase.purchaseId)) { _items[purchase.purchaseId] = QueuedVerificationItem( purchaseId: purchase.purchaseId, productId: purchase.productId, purchaseToken: token, purchaseTokenHash: purchase.purchaseTokenHash, queuedAt: DateTime.now(), nextRetryAt: DateTime.now(), ); } } @override Future dequeue(String purchaseId) async { _items.remove(purchaseId); } @override Future recordRetry( String purchaseId, { required Duration backoffDelay, }) async { final item = _items[purchaseId]; if (item != null) { _items[purchaseId] = item.copyWithRetry( attemptCount: item.attemptCount + 1, nextRetryAt: DateTime.now().add(backoffDelay), ); } } @override Future clear() async { _items.clear(); } } /// Production implementation backed by FlutterSecureStorage. /// Isolates raw purchase tokens from general application state. class SecureDurableVerificationQueue implements DurableVerificationQueue { static const String _storageKey = 'motorsignal_pending_verification_queue_v1'; final FlutterSecureStorage _secureStorage; const SecureDurableVerificationQueue({FlutterSecureStorage? secureStorage}) : _secureStorage = secureStorage ?? const FlutterSecureStorage(); @override Future> loadQueue() async { try { final raw = await _secureStorage.read(key: _storageKey); if (raw == null || raw.trim().isEmpty) return []; final decoded = jsonDecode(raw); if (decoded is! List) return []; return decoded .map( (item) => QueuedVerificationItem.fromJson( Map.from(item as Map), ), ) .toList(); } catch (e) { debugPrint('[SecureVerificationQueue] Failed to load queue: $e'); return []; } } @override Future enqueue(StorePurchase purchase) async { final token = purchase.purchaseToken; if (token == null || token.trim().isEmpty) return; final current = await loadQueue(); if (current.any((i) => i.purchaseId == purchase.purchaseId)) { return; // Deduplicate } final updated = List.from(current) ..add( QueuedVerificationItem( purchaseId: purchase.purchaseId, productId: purchase.productId, purchaseToken: token.trim(), purchaseTokenHash: purchase.purchaseTokenHash, queuedAt: DateTime.now(), nextRetryAt: DateTime.now(), ), ); await _persistQueue(updated); } @override Future dequeue(String purchaseId) async { final current = await loadQueue(); final updated = current.where((i) => i.purchaseId != purchaseId).toList(); await _persistQueue(updated); } @override Future recordRetry( String purchaseId, { required Duration backoffDelay, }) async { final current = await loadQueue(); final index = current.indexWhere((i) => i.purchaseId == purchaseId); if (index == -1) return; final item = current[index]; final updated = List.from(current); updated[index] = item.copyWithRetry( attemptCount: item.attemptCount + 1, nextRetryAt: DateTime.now().add(backoffDelay), ); await _persistQueue(updated); } @override Future clear() async { try { await _secureStorage.delete(key: _storageKey); } catch (_) {} } Future _persistQueue(List items) async { try { if (items.isEmpty) { await _secureStorage.delete(key: _storageKey); } else { final serialized = jsonEncode(items.map((i) => i.toJson()).toList()); await _secureStorage.write(key: _storageKey, value: serialized); } } catch (e) { debugPrint('[SecureVerificationQueue] Failed to persist queue: $e'); } } }