import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../entitlements/entitlement_state.dart'; import 'billing_models.dart'; import 'product_catalog.dart'; /// Error classifications distinguishing retryable network issues from permanent rejections. enum VerificationFailureType { /// Invalid token, invalid product ID, product not owned, or tampered payload. terminal, /// Network drop, worker 5xx, Google 5xx, or transient rate limits. retryable, } @immutable class PurchaseVerificationResult { final bool success; final EntitlementState? entitlementState; final VerificationFailureType? failureType; final String? errorCode; final String? errorMessage; final String? acknowledgementState; const PurchaseVerificationResult({ required this.success, this.entitlementState, this.failureType, this.errorCode, this.errorMessage, this.acknowledgementState, }); const PurchaseVerificationResult.success( EntitlementState state, { String? acknowledgementState, }) : this( success: true, entitlementState: state, acknowledgementState: acknowledgementState, ); const PurchaseVerificationResult.failure({ required VerificationFailureType failureType, required String errorCode, required String errorMessage, }) : this( success: false, failureType: failureType, errorCode: errorCode, errorMessage: errorMessage, ); bool get isRetryable => failureType == VerificationFailureType.retryable; bool get isAcknowledged => acknowledgementState == 'acknowledged'; bool get isPendingAckRetry => acknowledgementState == 'pendingRetry'; } /// Service that submits unverified StorePurchase tokens to the MotorSignal Cloudflare Worker gateway. class PurchaseVerificationService { static const String defaultWorkerUrl = String.fromEnvironment( 'R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev', ); final http.Client _client; final String _workerBaseUrl; PurchaseVerificationService({http.Client? client, String? workerBaseUrl}) : _client = client ?? http.Client(), _workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll( RegExp(r'/+$'), '', ); String get endpointUrl => '$_workerBaseUrl/billing/verify-play-purchase'; /// Verifies a StorePurchase token against the server. /// Optional Firebase ID token may be passed if user is signed in, but is NOT required. Future verifyPurchase({ required StorePurchase purchase, String? optionalFirebaseIdToken, Duration timeout = const Duration(seconds: 25), }) async { final token = purchase.purchaseToken; if (token == null || token.trim().isEmpty) { return const PurchaseVerificationResult.failure( failureType: VerificationFailureType.terminal, errorCode: 'MISSING_TOKEN', errorMessage: 'Purchase token is missing or empty.', ); } if (!ProductCatalog.isValidProduct(purchase.productId)) { return PurchaseVerificationResult.failure( failureType: VerificationFailureType.terminal, errorCode: 'INVALID_PRODUCT', errorMessage: 'Product ${purchase.productId} is unrecognized.', ); } final headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', }; if (optionalFirebaseIdToken != null && optionalFirebaseIdToken.trim().isNotEmpty) { headers['Authorization'] = 'Bearer ${optionalFirebaseIdToken.trim()}'; } final requestBody = jsonEncode({ 'productId': purchase.productId, 'purchaseToken': token.trim(), }); try { final response = await _client .post(Uri.parse(endpointUrl), headers: headers, body: requestBody) .timeout(timeout); final status = response.statusCode; if (status >= 200 && status < 300) { return _parseSuccessResponse(response.body, purchase); } else if (status >= 400 && status < 500) { return _parseClientErrorResponse(status, response.body); } else { return PurchaseVerificationResult.failure( failureType: VerificationFailureType.retryable, errorCode: 'SERVER_ERROR_$status', errorMessage: 'Server responded with status $status.', ); } } on TimeoutException { return const PurchaseVerificationResult.failure( failureType: VerificationFailureType.retryable, errorCode: 'TIMEOUT', errorMessage: 'Verification request timed out.', ); } catch (e) { return PurchaseVerificationResult.failure( failureType: VerificationFailureType.retryable, errorCode: 'NETWORK_ERROR', errorMessage: 'Network exception during verification: $e', ); } } PurchaseVerificationResult _parseSuccessResponse( String responseBody, StorePurchase purchase, ) { try { final decoded = jsonDecode(responseBody); if (decoded is! Map) { return const PurchaseVerificationResult.failure( failureType: VerificationFailureType.retryable, errorCode: 'MALFORMED_RESPONSE', errorMessage: 'Response JSON was not a map.', ); } final json = Map.from(decoded); final schemaVersion = json['schemaVersion'] as int? ?? 1; if (schemaVersion > 1) { return const PurchaseVerificationResult.failure( failureType: VerificationFailureType.retryable, errorCode: 'UNSUPPORTED_SCHEMA', errorMessage: 'Server response schema version is not supported.', ); } final planStr = json['plan'] as String?; final statusStr = json['status'] as String?; final verificationStatusStr = json['verificationStatus'] as String?; final ackState = json['acknowledgementState'] as String?; final expiresAtStr = json['expiresAt'] as String?; final tokenHash = json['tokenHash'] as String?; final plan = MotorSignalPlan.values.firstWhere( (p) => p.name == planStr, orElse: () => MotorSignalPlan.free, ); final status = MotorSignalEntitlementStatus.values.firstWhere( (s) => s.name == statusStr, orElse: () => MotorSignalEntitlementStatus.unknown, ); final verificationStatus = EntitlementVerificationStatus.values .firstWhere( (v) => v.name == verificationStatusStr, orElse: () => EntitlementVerificationStatus.unverified, ); final expiresAt = expiresAtStr != null ? DateTime.tryParse(expiresAtStr) : null; final entitlementState = EntitlementState.normalized( plan: plan, status: status, verificationStatus: verificationStatus, source: PurchaseSource.googlePlay, productId: purchase.productId, purchaseTokenHash: tokenHash ?? purchase.purchaseTokenHash, expiresAt: expiresAt, lastVerifiedAt: DateTime.now(), ); return PurchaseVerificationResult.success( entitlementState, acknowledgementState: ackState, ); } catch (e) { return PurchaseVerificationResult.failure( failureType: VerificationFailureType.retryable, errorCode: 'DECODE_ERROR', errorMessage: 'Failed to decode verification response: $e', ); } } PurchaseVerificationResult _parseClientErrorResponse( int statusCode, String responseBody, ) { String errorCode = 'CLIENT_ERROR_$statusCode'; String errorMessage = 'Verification rejected by server ($statusCode).'; try { final decoded = jsonDecode(responseBody); if (decoded is Map) { final code = decoded['code'] as String?; final error = decoded['error'] as String?; if (code != null) errorCode = code; if (error != null) errorMessage = error; } } catch (_) {} // 429 is rate limited -> retryable if (statusCode == 429 || errorCode == 'RATE_LIMITED') { return PurchaseVerificationResult.failure( failureType: VerificationFailureType.retryable, errorCode: errorCode, errorMessage: errorMessage, ); } // Other 4xx are terminal rejections return PurchaseVerificationResult.failure( failureType: VerificationFailureType.terminal, errorCode: errorCode, errorMessage: errorMessage, ); } }