MotorSignal-App/lib/services/billing/durable_verification_queue....

226 lines
6.6 KiB
Dart

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<String, dynamic> toJson() => {
'purchaseId': purchaseId,
'productId': productId,
'purchaseToken': purchaseToken,
'purchaseTokenHash': purchaseTokenHash,
'attemptCount': attemptCount,
'queuedAt': queuedAt.toIso8601String(),
'nextRetryAt': nextRetryAt.toIso8601String(),
};
factory QueuedVerificationItem.fromJson(Map<String, dynamic> 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<List<QueuedVerificationItem>> loadQueue();
Future<void> enqueue(StorePurchase purchase);
Future<void> dequeue(String purchaseId);
Future<void> recordRetry(String purchaseId, {required Duration backoffDelay});
Future<void> clear();
}
/// In-memory queue for deterministic testing.
class InMemoryVerificationQueue implements DurableVerificationQueue {
final Map<String, QueuedVerificationItem> _items = {};
@override
Future<List<QueuedVerificationItem>> loadQueue() async {
return _items.values.toList();
}
@override
Future<void> 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<void> dequeue(String purchaseId) async {
_items.remove(purchaseId);
}
@override
Future<void> 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<void> 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<List<QueuedVerificationItem>> 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<String, dynamic>.from(item as Map),
),
)
.toList();
} catch (e) {
debugPrint('[SecureVerificationQueue] Failed to load queue: $e');
return [];
}
}
@override
Future<void> 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<QueuedVerificationItem>.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<void> dequeue(String purchaseId) async {
final current = await loadQueue();
final updated = current.where((i) => i.purchaseId != purchaseId).toList();
await _persistQueue(updated);
}
@override
Future<void> 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<QueuedVerificationItem>.from(current);
updated[index] = item.copyWithRetry(
attemptCount: item.attemptCount + 1,
nextRetryAt: DateTime.now().add(backoffDelay),
);
await _persistQueue(updated);
}
@override
Future<void> clear() async {
try {
await _secureStorage.delete(key: _storageKey);
} catch (_) {}
}
Future<void> _persistQueue(List<QueuedVerificationItem> 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');
}
}
}