From 3e4a2398765e2c2aa30948f7bc5a2d4e2f966e81 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 18:30:57 -0400 Subject: [PATCH] feat: integrate Google Play billing lifecycle --- lib/services/billing/billing_models.dart | 100 +++++ lib/services/billing/billing_providers.dart | 18 + lib/services/billing/billing_service.dart | 29 ++ .../billing/fake_billing_service.dart | 115 ++++++ .../billing/play_billing_service.dart | 255 +++++++++++++ lib/services/billing/product_catalog.dart | 33 ++ .../entitlements/entitlement_coordinator.dart | 147 ++++++++ .../entitlements/entitlement_providers.dart | 18 +- pubspec.lock | 32 ++ pubspec.yaml | 1 + test/billing_lifecycle_test.dart | 347 ++++++++++++++++++ test/entitlement_domain_test.dart | 84 +++-- 12 files changed, 1136 insertions(+), 43 deletions(-) create mode 100644 lib/services/billing/billing_models.dart create mode 100644 lib/services/billing/billing_providers.dart create mode 100644 lib/services/billing/billing_service.dart create mode 100644 lib/services/billing/fake_billing_service.dart create mode 100644 lib/services/billing/play_billing_service.dart create mode 100644 lib/services/billing/product_catalog.dart create mode 100644 lib/services/entitlements/entitlement_coordinator.dart create mode 100644 test/billing_lifecycle_test.dart diff --git a/lib/services/billing/billing_models.dart b/lib/services/billing/billing_models.dart new file mode 100644 index 0000000..1aa4eb3 --- /dev/null +++ b/lib/services/billing/billing_models.dart @@ -0,0 +1,100 @@ +import 'package:flutter/foundation.dart'; + +/// Normalized purchase status reported by the store. +enum StorePurchaseStatus { pending, purchased, restored, canceled, error } + +/// Store-neutral representation of a catalog product queried from Google Play. +@immutable +class StoreProduct { + final String id; + final String title; + final String description; + final String price; + final double rawPrice; + final String currencyCode; + final String currencySymbol; + final bool isSubscription; + + const StoreProduct({ + required this.id, + required this.title, + required this.description, + required this.price, + required this.rawPrice, + required this.currencyCode, + this.currencySymbol = '', + this.isSubscription = false, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StoreProduct && + runtimeType == other.runtimeType && + id == other.id && + price == other.price && + currencyCode == other.currencyCode; + + @override + int get hashCode => id.hashCode ^ price.hashCode ^ currencyCode.hashCode; +} + +/// Store-neutral purchase transaction emitted by the billing stream. +@immutable +class StorePurchase { + final String purchaseId; + final String productId; + final StorePurchaseStatus status; + final String transactionDate; + final String? purchaseToken; + final String? purchaseTokenHash; + final bool pendingCompletePurchase; + final String? errorMessage; + final Object? originalPayload; + + const StorePurchase({ + required this.purchaseId, + required this.productId, + required this.status, + required this.transactionDate, + this.purchaseToken, + this.purchaseTokenHash, + this.pendingCompletePurchase = false, + this.errorMessage, + this.originalPayload, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StorePurchase && + runtimeType == other.runtimeType && + purchaseId == other.purchaseId && + productId == other.productId && + status == other.status && + purchaseTokenHash == other.purchaseTokenHash; + + @override + int get hashCode => + purchaseId.hashCode ^ + productId.hashCode ^ + status.hashCode ^ + purchaseTokenHash.hashCode; +} + +/// Failure details encountered during store queries or operations. +@immutable +class BillingFailure { + final String code; + final String message; + final Object? details; + + const BillingFailure({ + required this.code, + required this.message, + this.details, + }); + + @override + String toString() => 'BillingFailure(code: $code, message: $message)'; +} diff --git a/lib/services/billing/billing_providers.dart b/lib/services/billing/billing_providers.dart new file mode 100644 index 0000000..42a26fc --- /dev/null +++ b/lib/services/billing/billing_providers.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'billing_service.dart'; +import 'play_billing_service.dart'; + +/// Provider for the platform [BillingService]. +/// Defaults to [PlayBillingService] in production. +/// In automated tests, override with [FakeBillingService]. +final billingServiceProvider = Provider((ref) { + final service = PlayBillingService(); + ref.onDispose(() => service.dispose()); + return service; +}); + +/// Convenience provider exposing availability of the billing service. +final billingAvailabilityProvider = FutureProvider((ref) async { + final service = ref.watch(billingServiceProvider); + return await service.isAvailable(); +}); diff --git a/lib/services/billing/billing_service.dart b/lib/services/billing/billing_service.dart new file mode 100644 index 0000000..9544175 --- /dev/null +++ b/lib/services/billing/billing_service.dart @@ -0,0 +1,29 @@ +import 'billing_models.dart'; + +/// Clean abstraction over store billing operations. +/// All platform-specific types (e.g. in_app_purchase / BillingClient) remain isolated behind this interface. +abstract class BillingService { + /// Stream emitting real-time purchase updates from the store. + Stream> get purchaseStream; + + /// Initializes the billing service and verifies connection to the store. + Future initialize(); + + /// Whether the store service (e.g. Google Play Billing) is currently available on the device. + Future isAvailable(); + + /// Queries the store for product details matching the catalog. + Future> queryProducts(); + + /// Initiates purchase flow for a product. + Future launchPurchaseFlow(String productId); + + /// Restores prior purchases from the store (e.g. on reinstall or new device). + Future restorePurchases(); + + /// Acknowledges / completes an eligible purchase transaction with the store. + Future completePurchase(StorePurchase purchase); + + /// Disposes listeners and resources. + void dispose(); +} diff --git a/lib/services/billing/fake_billing_service.dart b/lib/services/billing/fake_billing_service.dart new file mode 100644 index 0000000..690a7ae --- /dev/null +++ b/lib/services/billing/fake_billing_service.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'package:crypto/crypto.dart'; +import 'billing_models.dart'; +import 'billing_service.dart'; +import 'product_catalog.dart'; + +/// Test/Mock implementation of [BillingService] for automated test suites. +/// Never selected in release execution paths. +class FakeBillingService implements BillingService { + final StreamController> _purchaseStreamController = + StreamController>.broadcast(); + + final Set _completedPurchaseTokens = {}; + bool available = true; + bool initializeSuccess = true; + List mockProducts = [ + const StoreProduct( + id: ProductCatalog.proMonthly, + title: 'MotorSignal Pro Monthly', + description: 'Monthly unlimited access to all Pro features', + price: '\$4.99', + rawPrice: 4.99, + currencyCode: 'USD', + currencySymbol: '\$', + isSubscription: true, + ), + const StoreProduct( + id: ProductCatalog.proLifetime, + title: 'MotorSignal Pro Lifetime', + description: 'Permanent unlimited access to all Pro features', + price: '\$49.99', + rawPrice: 49.99, + currencyCode: 'USD', + currencySymbol: '\$', + isSubscription: false, + ), + ]; + + int launchCount = 0; + int restoreCount = 0; + String? lastPurchasedProductId; + + @override + Stream> get purchaseStream => + _purchaseStreamController.stream; + + @override + Future initialize() async => initializeSuccess && available; + + @override + Future isAvailable() async => available; + + @override + Future> queryProducts() async { + if (!available) return []; + return mockProducts; + } + + @override + Future launchPurchaseFlow(String productId) async { + launchCount++; + lastPurchasedProductId = productId; + if (!available || !ProductCatalog.isValidProduct(productId)) return false; + return true; + } + + @override + Future restorePurchases() async { + restoreCount++; + } + + @override + Future completePurchase(StorePurchase purchase) async { + final token = purchase.purchaseToken ?? purchase.purchaseId; + _completedPurchaseTokens.add(token); + } + + bool isPurchaseCompleted(String tokenOrId) => + _completedPurchaseTokens.contains(tokenOrId); + + /// Helper for tests to simulate incoming purchase events from the store. + void emitPurchases(List purchases) { + if (!_purchaseStreamController.isClosed) { + _purchaseStreamController.add(purchases); + } + } + + /// Helper to create a fake StorePurchase for tests. + StorePurchase createFakePurchase({ + required String productId, + required StorePurchaseStatus status, + String? purchaseId, + String? token, + bool pendingComplete = true, + }) { + final pToken = + token ?? 'fake_token_${DateTime.now().millisecondsSinceEpoch}'; + return StorePurchase( + purchaseId: + purchaseId ?? + 'fake_purchase_${DateTime.now().millisecondsSinceEpoch}', + productId: productId, + status: status, + transactionDate: DateTime.now().toIso8601String(), + purchaseToken: pToken, + purchaseTokenHash: sha256.convert(pToken.codeUnits).toString(), + pendingCompletePurchase: pendingComplete, + ); + } + + @override + void dispose() { + _purchaseStreamController.close(); + } +} diff --git a/lib/services/billing/play_billing_service.dart b/lib/services/billing/play_billing_service.dart new file mode 100644 index 0000000..9008ee9 --- /dev/null +++ b/lib/services/billing/play_billing_service.dart @@ -0,0 +1,255 @@ +import 'dart:async'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'billing_models.dart'; +import 'billing_service.dart'; +import 'product_catalog.dart'; + +/// Production implementation of [BillingService] backed by the official `in_app_purchase` plugin. +/// Wraps Google Play Billing 8.x via in_app_purchase_android. +class PlayBillingService implements BillingService { + final InAppPurchase? iap; + final StreamController> _purchaseStreamController = + StreamController>.broadcast(); + + StreamSubscription>? _subscription; + final Set _completedPurchaseTokens = {}; + Map _cachedProductDetails = {}; + bool _initialized = false; + + InAppPurchase get _instance => iap ?? InAppPurchase.instance; + + PlayBillingService({this.iap}); + + @override + Stream> get purchaseStream => + _purchaseStreamController.stream; + + @override + Future initialize() async { + if (_initialized) return true; + + try { + final available = await _instance.isAvailable(); + if (!available) { + debugPrint( + '[PlayBillingService] InAppPurchase is not available on this device', + ); + return false; + } + + // Establish singleton purchase stream listener + _subscription ??= _instance.purchaseStream.listen( + _handleIncomingPurchaseDetails, + onError: (dynamic error) { + debugPrint('[PlayBillingService] Purchase stream error: $error'); + }, + onDone: () { + debugPrint('[PlayBillingService] Purchase stream completed'); + }, + ); + + _initialized = true; + return true; + } catch (e, st) { + debugPrint('[PlayBillingService] Initialization failed: $e\n$st'); + return false; + } + } + + @override + Future isAvailable() async { + try { + return await _instance.isAvailable(); + } catch (e) { + debugPrint('[PlayBillingService] isAvailable check failed: $e'); + return false; + } + } + + @override + Future> queryProducts() async { + try { + final response = await _instance.queryProductDetails( + ProductCatalog.allProductIds, + ); + if (response.error != null) { + debugPrint( + '[PlayBillingService] Query products error: ${response.error}', + ); + return []; + } + + final results = []; + final newCache = {}; + + for (final details in response.productDetails) { + if (!ProductCatalog.isValidProduct(details.id)) continue; + newCache[details.id] = details; + + results.add( + StoreProduct( + id: details.id, + title: details.title, + description: details.description, + price: details.price, + rawPrice: details.rawPrice, + currencyCode: details.currencyCode, + currencySymbol: details.currencySymbol, + isSubscription: ProductCatalog.isSubscription(details.id), + ), + ); + } + + _cachedProductDetails = newCache; + return results; + } catch (e, st) { + debugPrint('[PlayBillingService] queryProducts failed: $e\n$st'); + return []; + } + } + + @override + Future launchPurchaseFlow(String productId) async { + if (!ProductCatalog.isValidProduct(productId)) { + debugPrint( + '[PlayBillingService] Invalid productId requested: $productId', + ); + return false; + } + + // Ensure product details are available + var productDetails = _cachedProductDetails[productId]; + if (productDetails == null) { + await queryProducts(); + productDetails = _cachedProductDetails[productId]; + } + + if (productDetails == null) { + debugPrint( + '[PlayBillingService] Product details unavailable for $productId', + ); + return false; + } + + try { + final purchaseParam = PurchaseParam(productDetails: productDetails); + if (ProductCatalog.isSubscription(productId)) { + return await _instance.buyNonConsumable(purchaseParam: purchaseParam); + } else { + return await _instance.buyNonConsumable(purchaseParam: purchaseParam); + } + } catch (e, st) { + debugPrint('[PlayBillingService] launchPurchaseFlow failed: $e\n$st'); + return false; + } + } + + @override + Future restorePurchases() async { + try { + await _instance.restorePurchases(); + } catch (e, st) { + debugPrint('[PlayBillingService] restorePurchases failed: $e\n$st'); + } + } + + @override + Future completePurchase(StorePurchase purchase) async { + final original = purchase.originalPayload; + if (original is! PurchaseDetails) { + debugPrint( + '[PlayBillingService] completePurchase called with non-plugin payload', + ); + return; + } + + final token = purchase.purchaseToken ?? purchase.purchaseId; + if (_completedPurchaseTokens.contains(token)) { + debugPrint( + '[PlayBillingService] Purchase $token already completed/acknowledged, skipping duplicate', + ); + return; + } + + try { + if (original.pendingCompletePurchase) { + await _instance.completePurchase(original); + _completedPurchaseTokens.add(token); + debugPrint( + '[PlayBillingService] Completed purchase: ${purchase.productId}', + ); + } + } catch (e, st) { + debugPrint( + '[PlayBillingService] completePurchase failed for ${purchase.productId}: $e\n$st', + ); + } + } + + void _handleIncomingPurchaseDetails(List detailsList) { + final mapped = []; + + for (final details in detailsList) { + if (!ProductCatalog.isValidProduct(details.productID)) { + debugPrint( + '[PlayBillingService] Ignored unrecognized product: ${details.productID}', + ); + continue; + } + + final token = details.verificationData.serverVerificationData; + final tokenHash = token.isNotEmpty + ? sha256.convert(token.codeUnits).toString() + : null; + + StorePurchaseStatus status; + switch (details.status) { + case PurchaseStatus.pending: + status = StorePurchaseStatus.pending; + break; + case PurchaseStatus.purchased: + status = StorePurchaseStatus.purchased; + break; + case PurchaseStatus.restored: + status = StorePurchaseStatus.restored; + break; + case PurchaseStatus.canceled: + status = StorePurchaseStatus.canceled; + break; + case PurchaseStatus.error: + status = StorePurchaseStatus.error; + break; + } + + mapped.add( + StorePurchase( + purchaseId: + details.purchaseID ?? + 'unknown_${DateTime.now().millisecondsSinceEpoch}', + productId: details.productID, + status: status, + transactionDate: + details.transactionDate ?? DateTime.now().toIso8601String(), + purchaseToken: token.isNotEmpty ? token : null, + purchaseTokenHash: tokenHash, + pendingCompletePurchase: details.pendingCompletePurchase, + errorMessage: details.error?.message, + originalPayload: details, + ), + ); + } + + if (!_purchaseStreamController.isClosed && mapped.isNotEmpty) { + _purchaseStreamController.add(mapped); + } + } + + @override + void dispose() { + _subscription?.cancel(); + _subscription = null; + _purchaseStreamController.close(); + } +} diff --git a/lib/services/billing/product_catalog.dart b/lib/services/billing/product_catalog.dart new file mode 100644 index 0000000..06f5ce5 --- /dev/null +++ b/lib/services/billing/product_catalog.dart @@ -0,0 +1,33 @@ +/// Authoritative catalog of in-app products and subscriptions for MotorSignal. +class ProductCatalog { + ProductCatalog._(); + + /// Monthly recurring subscription giving access to all Pro features. + static const String proMonthly = 'motorsignal_pro_monthly'; + + /// One-time non-consumable purchase giving permanent access to all Pro features. + static const String proLifetime = 'motorsignal_pro_lifetime'; + + /// Base plan ID configured in Google Play Console for monthly subscription. + static const String proMonthlyBasePlan = 'monthly-sub'; + + /// Set of all authorized store product identifiers for MotorSignal. + static const Set allProductIds = {proMonthly, proLifetime}; + + /// Set of all subscription product identifiers. + static const Set subscriptionProductIds = {proMonthly}; + + /// Set of all one-time in-app product identifiers. + static const Set inAppProductIds = {proLifetime}; + + /// Returns true if [productId] is a known and supported product. + static bool isValidProduct(String productId) => + allProductIds.contains(productId); + + /// Returns true if [productId] is a subscription product. + static bool isSubscription(String productId) => + subscriptionProductIds.contains(productId); + + /// Returns true if [productId] is a one-time non-consumable product. + static bool isLifetime(String productId) => productId == proLifetime; +} diff --git a/lib/services/entitlements/entitlement_coordinator.dart b/lib/services/entitlements/entitlement_coordinator.dart new file mode 100644 index 0000000..b5d174f --- /dev/null +++ b/lib/services/entitlements/entitlement_coordinator.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import '../billing/billing_models.dart'; +import '../billing/billing_service.dart'; +import '../billing/product_catalog.dart'; +import 'entitlement_state.dart'; +import 'entitlement_storage.dart'; + +/// Seam coordinating incoming store purchase events with cached entitlement state. +/// Pre-server verification (Stage 4B-2): +/// Observes store purchase events, stages unverified purchases, and preserves verified cache. +/// In Stage 4B-3, server verification will be inserted between detection and final acknowledgement. +class EntitlementCoordinator extends ChangeNotifier { + final BillingService billingService; + final EntitlementStorage storage; + + StreamSubscription>? _purchaseSubscription; + EntitlementState _state = const EntitlementState.free(); + bool _initialized = false; + + // Staged store purchases observed on client, awaiting server verification (Stage 4B-3). + final List _unverifiedPurchases = []; + + EntitlementCoordinator({required this.billingService, required this.storage}); + + EntitlementState get state => _state; + List get unverifiedPurchases => + List.unmodifiable(_unverifiedPurchases); + + Future initialize() async { + if (_initialized) return; + + // 1. Load cached entitlement from secure storage + _state = await storage.read(); + notifyListeners(); + + // 2. Initialize billing service & subscribe to purchase stream + final available = await billingService.initialize(); + if (available) { + _purchaseSubscription = billingService.purchaseStream.listen( + _handleStorePurchases, + onError: (dynamic error) { + debugPrint('[EntitlementCoordinator] Purchase stream error: $error'); + }, + ); + } + + _initialized = true; + } + + /// Restores purchases from the app store. + Future restorePurchases() async { + await billingService.restorePurchases(); + } + + /// Processes store purchases delivered from the billing stream. + void _handleStorePurchases(List purchases) { + for (final purchase in purchases) { + if (!ProductCatalog.isValidProduct(purchase.productId)) { + debugPrint( + '[EntitlementCoordinator] Unknown product ignored: ${purchase.productId}', + ); + continue; + } + + switch (purchase.status) { + case StorePurchaseStatus.purchased: + case StorePurchaseStatus.restored: + _handlePurchasedOrRestored(purchase); + break; + case StorePurchaseStatus.pending: + _handlePending(purchase); + break; + case StorePurchaseStatus.canceled: + case StorePurchaseStatus.error: + // Canceled or transient store errors must not invalidate an already verified entitlement. + debugPrint( + '[EntitlementCoordinator] Purchase ${purchase.status.name} for ${purchase.productId}', + ); + break; + } + } + } + + void _handlePurchasedOrRestored(StorePurchase purchase) { + // Stage 4B-2 invariant: STORE PURCHASE OBSERVED != ENTITLEMENT VERIFIED. + // We register the unverified purchase in the queue for Stage 4B-3 server verification. + _unverifiedPurchases.removeWhere( + (p) => p.purchaseId == purchase.purchaseId, + ); + _unverifiedPurchases.add(purchase); + + // If existing state is already valid Pro, preserve it. + // If not verified yet, transition status to syncPending. + if (!_state.isPro) { + final plan = ProductCatalog.isLifetime(purchase.productId) + ? MotorSignalPlan.proLifetime + : MotorSignalPlan.proMonthly; + + _state = _state.copyWith( + plan: plan, + status: MotorSignalEntitlementStatus.syncPending, + verificationStatus: EntitlementVerificationStatus.unverified, + productId: purchase.productId, + purchaseTokenHash: purchase.purchaseTokenHash, + ); + notifyListeners(); + } + } + + void _handlePending(StorePurchase purchase) { + if (!_state.isPro) { + _state = _state.copyWith( + status: MotorSignalEntitlementStatus.syncPending, + verificationStatus: EntitlementVerificationStatus.unverified, + productId: purchase.productId, + ); + notifyListeners(); + } + } + + /// Injected completion helper for testing and Stage 4B-3 verification callback. + /// When server verification confirms the purchase, the coordinator completes it and writes to durable storage. + Future applyVerifiedEntitlement( + EntitlementState verifiedState, { + StorePurchase? completedPurchase, + }) async { + _state = verifiedState; + await storage.write(_state); + + if (completedPurchase != null) { + _unverifiedPurchases.removeWhere( + (p) => p.purchaseId == completedPurchase.purchaseId, + ); + await billingService.completePurchase(completedPurchase); + } + + notifyListeners(); + } + + @override + void dispose() { + _purchaseSubscription?.cancel(); + _purchaseSubscription = null; + super.dispose(); + } +} diff --git a/lib/services/entitlements/entitlement_providers.dart b/lib/services/entitlements/entitlement_providers.dart index 89c5977..d4141e4 100644 --- a/lib/services/entitlements/entitlement_providers.dart +++ b/lib/services/entitlements/entitlement_providers.dart @@ -1,4 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../billing/billing_providers.dart'; +import 'entitlement_coordinator.dart'; import 'entitlement_state.dart'; import 'entitlement_storage.dart'; import 'feature_access_service.dart'; @@ -9,11 +11,19 @@ final entitlementStorageProvider = Provider((ref) { return const SecureEntitlementStorage(); }); +/// Provider for the [EntitlementCoordinator] which bridges store events to entitlement state. +final entitlementCoordinatorProvider = + ChangeNotifierProvider((ref) { + final billing = ref.watch(billingServiceProvider); + final storage = ref.watch(entitlementStorageProvider); + return EntitlementCoordinator(billingService: billing, storage: storage); + }); + /// Authoritative entitlement state provider. -/// Defaults to [EntitlementState.free] until the billing coordinator or cache loader runs. -/// In Stage 4B-2, this will be wired to the billing lifecycle coordinator. -final entitlementProvider = StateProvider((ref) { - return const EntitlementState.free(); +/// Follows the state published by [EntitlementCoordinator]. +final entitlementProvider = Provider((ref) { + final coordinator = ref.watch(entitlementCoordinatorProvider); + return coordinator.state; }); /// Centralized provider for checking feature accessibility. diff --git a/pubspec.lock b/pubspec.lock index 91689d6..252498f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -781,6 +781,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.2" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "0e9510b80b0074e89ab0a8e0fc901439b779dc9ae575ab8d419253c6e1627716" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + in_app_purchase_android: + dependency: transitive + description: + name: in_app_purchase_android + sha256: f7327b5fd70d8dc1b419fb4cd5c17520175c4059441afd985e8a9c3fa2cefed9 + url: "https://pub.dev" + source: hosted + version: "0.5.3" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "0b0076cac8ce4fa7048f01e76af8b123aeb6a7c4e0dea2a5206d6664454f3e36" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: "66aa6db2236aed23d1b0dd508fb33641ed9878d5e5fb6db5fdbd690b7de6fe98" + url: "https://pub.dev" + source: hosted + version: "0.4.11+2" intl: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index fcd52fa..2fb152b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: google_sign_in: ^6.2.2 cloud_firestore: ^5.6.5 flutter_secure_storage: ^10.2.1 + in_app_purchase: 3.3.0 dev_dependencies: flutter_test: diff --git a/test/billing_lifecycle_test.dart b/test/billing_lifecycle_test.dart new file mode 100644 index 0000000..c94f269 --- /dev/null +++ b/test/billing_lifecycle_test.dart @@ -0,0 +1,347 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/services/billing/billing_models.dart'; +import 'package:motorcycle_dash/services/billing/billing_providers.dart'; +import 'package:motorcycle_dash/services/billing/fake_billing_service.dart'; +import 'package:motorcycle_dash/services/billing/play_billing_service.dart'; +import 'package:motorcycle_dash/services/billing/product_catalog.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_coordinator.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_state.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart'; +import 'package:motorcycle_dash/services/entitlements/feature_access_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ProductCatalog rules', () { + test('contains expected product IDs and correct classification', () { + expect(ProductCatalog.proMonthly, 'motorsignal_pro_monthly'); + expect(ProductCatalog.proLifetime, 'motorsignal_pro_lifetime'); + expect(ProductCatalog.allProductIds, contains('motorsignal_pro_monthly')); + expect( + ProductCatalog.allProductIds, + contains('motorsignal_pro_lifetime'), + ); + + expect(ProductCatalog.isSubscription('motorsignal_pro_monthly'), isTrue); + expect( + ProductCatalog.isSubscription('motorsignal_pro_lifetime'), + isFalse, + ); + + expect(ProductCatalog.isLifetime('motorsignal_pro_lifetime'), isTrue); + expect(ProductCatalog.isLifetime('motorsignal_pro_monthly'), isFalse); + + expect(ProductCatalog.isValidProduct('unknown_product'), isFalse); + }); + }); + + group('BillingService & FakeBillingService lifecycle', () { + test('initializes and reports availability correctly', () async { + final fakeBilling = FakeBillingService(); + expect(await fakeBilling.initialize(), isTrue); + expect(await fakeBilling.isAvailable(), isTrue); + + fakeBilling.available = false; + expect(await fakeBilling.isAvailable(), isFalse); + }); + + test('queryProducts returns localized product catalog', () async { + final fakeBilling = FakeBillingService(); + final products = await fakeBilling.queryProducts(); + expect(products.length, 2); + + final monthly = products.firstWhere( + (p) => p.id == ProductCatalog.proMonthly, + ); + expect(monthly.isSubscription, isTrue); + expect(monthly.price, '\$4.99'); + expect(monthly.rawPrice, 4.99); + + final lifetime = products.firstWhere( + (p) => p.id == ProductCatalog.proLifetime, + ); + expect(lifetime.isSubscription, isFalse); + expect(lifetime.price, '\$49.99'); + expect(lifetime.rawPrice, 49.99); + }); + + test('queryProducts handles store unavailable safely', () async { + final fakeBilling = FakeBillingService()..available = false; + final products = await fakeBilling.queryProducts(); + expect(products, isEmpty); + }); + + test('launchPurchaseFlow tracks requests and validates product', () async { + final fakeBilling = FakeBillingService(); + final success = await fakeBilling.launchPurchaseFlow( + ProductCatalog.proMonthly, + ); + expect(success, isTrue); + expect(fakeBilling.launchCount, 1); + expect(fakeBilling.lastPurchasedProductId, ProductCatalog.proMonthly); + + final invalidSuccess = await fakeBilling.launchPurchaseFlow( + 'invalid_sku', + ); + expect(invalidSuccess, isFalse); + }); + + test('restorePurchases increments call count', () async { + final fakeBilling = FakeBillingService(); + await fakeBilling.restorePurchases(); + expect(fakeBilling.restoreCount, 1); + }); + }); + + group('EntitlementCoordinator purchase lifecycle & verification boundary', () { + late FakeBillingService billing; + late InMemoryEntitlementStorage storage; + late EntitlementCoordinator coordinator; + + setUp(() async { + billing = FakeBillingService(); + storage = InMemoryEntitlementStorage(); + coordinator = EntitlementCoordinator( + billingService: billing, + storage: storage, + ); + await coordinator.initialize(); + }); + + tearDown(() { + coordinator.dispose(); + billing.dispose(); + }); + + test('initial state defaults to cached free tier', () { + expect(coordinator.state.plan, MotorSignalPlan.free); + expect(coordinator.state.isPro, isFalse); + }); + + test( + 'purchase observed transitions unverified state to syncPending (does NOT unlock Pro yet)', + () async { + final purchase = billing.createFakePurchase( + productId: ProductCatalog.proMonthly, + status: StorePurchaseStatus.purchased, + ); + + billing.emitPurchases([purchase]); + await Future.delayed(Duration.zero); + + // Critical Stage 4B-2 invariant: STORE PURCHASE OBSERVED != ENTITLEMENT VERIFIED + expect( + coordinator.state.status, + MotorSignalEntitlementStatus.syncPending, + ); + expect( + coordinator.state.isPro, + isFalse, + reason: 'Must not grant Pro before server verification', + ); + expect(coordinator.unverifiedPurchases.length, 1); + expect( + coordinator.unverifiedPurchases.first.purchaseId, + purchase.purchaseId, + ); + }, + ); + + test( + 'pending store purchase transitions status to syncPending without granting access', + () async { + final pendingPurchase = billing.createFakePurchase( + productId: ProductCatalog.proMonthly, + status: StorePurchaseStatus.pending, + ); + + billing.emitPurchases([pendingPurchase]); + await Future.delayed(Duration.zero); + + expect( + coordinator.state.status, + MotorSignalEntitlementStatus.syncPending, + ); + expect(coordinator.state.isPro, isFalse); + }, + ); + + test( + 'canceled or error purchase events do not erase existing verified entitlement', + () async { + // Setup an active verified Pro state in storage + final verifiedState = EntitlementState.proLifetime(); + await coordinator.applyVerifiedEntitlement(verifiedState); + expect(coordinator.state.isPro, isTrue); + + // Now emit an error or canceled event + final errorPurchase = billing.createFakePurchase( + productId: ProductCatalog.proMonthly, + status: StorePurchaseStatus.error, + ); + billing.emitPurchases([errorPurchase]); + await Future.delayed(Duration.zero); + + // State remains verified Pro + expect(coordinator.state.isPro, isTrue); + expect(coordinator.state.plan, MotorSignalPlan.proLifetime); + }, + ); + + test( + 'restored purchase is staged for verification without automatic unlock', + () async { + final restoredPurchase = billing.createFakePurchase( + productId: ProductCatalog.proLifetime, + status: StorePurchaseStatus.restored, + ); + + billing.emitPurchases([restoredPurchase]); + await Future.delayed(Duration.zero); + + expect(coordinator.unverifiedPurchases.length, 1); + expect( + coordinator.state.status, + MotorSignalEntitlementStatus.syncPending, + ); + expect(coordinator.state.isPro, isFalse); + }, + ); + + test('unknown product IDs are ignored by coordinator', () async { + final unknownPurchase = billing.createFakePurchase( + productId: 'hacked_super_pro', + status: StorePurchaseStatus.purchased, + ); + + billing.emitPurchases([unknownPurchase]); + await Future.delayed(Duration.zero); + + expect(coordinator.unverifiedPurchases, isEmpty); + expect(coordinator.state.status, MotorSignalEntitlementStatus.free); + }); + + test( + 'applying verified entitlement completes purchase and updates durable storage', + () async { + final purchase = billing.createFakePurchase( + productId: ProductCatalog.proLifetime, + status: StorePurchaseStatus.purchased, + token: 'valid_token_123', + ); + + billing.emitPurchases([purchase]); + await Future.delayed(Duration.zero); + expect(coordinator.unverifiedPurchases.length, 1); + + // Simulate verification callback from Stage 4B-3 + final verifiedState = EntitlementState.proLifetime( + productId: ProductCatalog.proLifetime, + purchaseTokenHash: purchase.purchaseTokenHash, + ); + + await coordinator.applyVerifiedEntitlement( + verifiedState, + completedPurchase: purchase, + ); + + expect(coordinator.state.isPro, isTrue); + expect(coordinator.unverifiedPurchases, isEmpty); + expect(billing.isPurchaseCompleted('valid_token_123'), isTrue); + + // Verify persisted to storage + final cached = await storage.read(); + expect(cached.isPro, isTrue); + expect(cached.plan, MotorSignalPlan.proLifetime); + }, + ); + }); + + group('Riverpod integration & Provider boundaries', () { + test( + 'featureAccessProvider reacts dynamically to verified coordinator changes', + () async { + final fakeBilling = FakeBillingService(); + final storage = InMemoryEntitlementStorage(); + + final container = ProviderContainer( + overrides: [ + billingServiceProvider.overrideWithValue(fakeBilling), + entitlementStorageProvider.overrideWithValue(storage), + ], + ); + addTearDown(container.dispose); + + final coordinator = container.read(entitlementCoordinatorProvider); + await coordinator.initialize(); + + // Starts Free + expect( + container + .read(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport), + isFalse, + ); + + // Emit store purchase (unverified -> still false) + final purchase = fakeBilling.createFakePurchase( + productId: ProductCatalog.proMonthly, + status: StorePurchaseStatus.purchased, + ); + fakeBilling.emitPurchases([purchase]); + await Future.delayed(Duration.zero); + expect( + container + .read(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport), + isFalse, + ); + + // Apply verification -> unlocks csvExport + await coordinator.applyVerifiedEntitlement( + EntitlementState.proMonthlyActive(), + ); + expect( + container + .read(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport), + isTrue, + ); + }, + ); + + test( + 'production billingServiceProvider defaults to PlayBillingService', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final service = container.read(billingServiceProvider); + expect(service, isA()); + expect(service, isNot(isA())); + }, + ); + }); + + group('Account Independence & Safety', () { + test( + 'billing service has no dependency on Firebase Auth or cloud accounts', + () { + final billing = FakeBillingService(); + expect(billing, isA()); + // Zero AuthUser or Firebase imports required + }, + ); + + test( + 'restoring purchases operates without any account prerequisites', + () async { + final billing = FakeBillingService(); + await billing.restorePurchases(); + expect(billing.restoreCount, 1); + }, + ); + }); +} diff --git a/test/entitlement_domain_test.dart b/test/entitlement_domain_test.dart index cca2f83..4ad2a99 100644 --- a/test/entitlement_domain_test.dart +++ b/test/entitlement_domain_test.dart @@ -288,48 +288,54 @@ void main() { }); group('Riverpod Provider integration', () { - test('featureAccessProvider reacts to entitlementProvider updates', () { - final container = ProviderContainer( - overrides: [ - entitlementStorageProvider.overrideWithValue( - InMemoryEntitlementStorage(), - ), - ], - ); - addTearDown(container.dispose); + test( + 'featureAccessProvider reacts to entitlementProvider updates', + () async { + final container = ProviderContainer( + overrides: [ + entitlementStorageProvider.overrideWithValue( + InMemoryEntitlementStorage(), + ), + ], + ); + addTearDown(container.dispose); - // Default is Free - expect( - container - .read(featureAccessProvider) - .canAccess(MotorSignalFeature.csvExport), - isFalse, - ); + // Default is Free + expect( + container + .read(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport), + isFalse, + ); - // Update to Pro Lifetime - container.read(entitlementProvider.notifier).state = - EntitlementState.proLifetime(); - expect( - container - .read(featureAccessProvider) - .canAccess(MotorSignalFeature.csvExport), - isTrue, - ); + // Update to Pro Lifetime + await container + .read(entitlementCoordinatorProvider) + .applyVerifiedEntitlement(EntitlementState.proLifetime()); + expect( + container + .read(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport), + isTrue, + ); - // Downgrade to Expired - container - .read(entitlementProvider.notifier) - .state = const EntitlementState( - plan: MotorSignalPlan.proMonthly, - status: MotorSignalEntitlementStatus.proMonthlyExpired, - ); - expect( - container - .read(featureAccessProvider) - .canAccess(MotorSignalFeature.csvExport), - isFalse, - ); - }); + // Downgrade to Expired + await container + .read(entitlementCoordinatorProvider) + .applyVerifiedEntitlement( + const EntitlementState( + plan: MotorSignalPlan.proMonthly, + status: MotorSignalEntitlementStatus.proMonthlyExpired, + ), + ); + expect( + container + .read(featureAccessProvider) + .canAccess(MotorSignalFeature.csvExport), + isFalse, + ); + }, + ); }); group('Architectural boundaries & Rider safety invariant', () {