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

261 lines
8.4 KiB
Dart

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<PurchaseVerificationResult> 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 = <String, String>{
'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<String, dynamic>.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,
);
}
}