feat: integrate Google Play billing lifecycle

This commit is contained in:
Developer 2026-09-03 18:30:57 -04:00
parent ee5fc42b37
commit 3e4a239876
12 changed files with 1136 additions and 43 deletions

View File

@ -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)';
}

View File

@ -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<BillingService>((ref) {
final service = PlayBillingService();
ref.onDispose(() => service.dispose());
return service;
});
/// Convenience provider exposing availability of the billing service.
final billingAvailabilityProvider = FutureProvider<bool>((ref) async {
final service = ref.watch(billingServiceProvider);
return await service.isAvailable();
});

View File

@ -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<List<StorePurchase>> get purchaseStream;
/// Initializes the billing service and verifies connection to the store.
Future<bool> initialize();
/// Whether the store service (e.g. Google Play Billing) is currently available on the device.
Future<bool> isAvailable();
/// Queries the store for product details matching the catalog.
Future<List<StoreProduct>> queryProducts();
/// Initiates purchase flow for a product.
Future<bool> launchPurchaseFlow(String productId);
/// Restores prior purchases from the store (e.g. on reinstall or new device).
Future<void> restorePurchases();
/// Acknowledges / completes an eligible purchase transaction with the store.
Future<void> completePurchase(StorePurchase purchase);
/// Disposes listeners and resources.
void dispose();
}

View File

@ -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<List<StorePurchase>> _purchaseStreamController =
StreamController<List<StorePurchase>>.broadcast();
final Set<String> _completedPurchaseTokens = <String>{};
bool available = true;
bool initializeSuccess = true;
List<StoreProduct> 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<List<StorePurchase>> get purchaseStream =>
_purchaseStreamController.stream;
@override
Future<bool> initialize() async => initializeSuccess && available;
@override
Future<bool> isAvailable() async => available;
@override
Future<List<StoreProduct>> queryProducts() async {
if (!available) return [];
return mockProducts;
}
@override
Future<bool> launchPurchaseFlow(String productId) async {
launchCount++;
lastPurchasedProductId = productId;
if (!available || !ProductCatalog.isValidProduct(productId)) return false;
return true;
}
@override
Future<void> restorePurchases() async {
restoreCount++;
}
@override
Future<void> 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<StorePurchase> 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();
}
}

View File

@ -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<List<StorePurchase>> _purchaseStreamController =
StreamController<List<StorePurchase>>.broadcast();
StreamSubscription<List<PurchaseDetails>>? _subscription;
final Set<String> _completedPurchaseTokens = <String>{};
Map<String, ProductDetails> _cachedProductDetails = {};
bool _initialized = false;
InAppPurchase get _instance => iap ?? InAppPurchase.instance;
PlayBillingService({this.iap});
@override
Stream<List<StorePurchase>> get purchaseStream =>
_purchaseStreamController.stream;
@override
Future<bool> 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<bool> isAvailable() async {
try {
return await _instance.isAvailable();
} catch (e) {
debugPrint('[PlayBillingService] isAvailable check failed: $e');
return false;
}
}
@override
Future<List<StoreProduct>> queryProducts() async {
try {
final response = await _instance.queryProductDetails(
ProductCatalog.allProductIds,
);
if (response.error != null) {
debugPrint(
'[PlayBillingService] Query products error: ${response.error}',
);
return [];
}
final results = <StoreProduct>[];
final newCache = <String, ProductDetails>{};
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<bool> 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<void> restorePurchases() async {
try {
await _instance.restorePurchases();
} catch (e, st) {
debugPrint('[PlayBillingService] restorePurchases failed: $e\n$st');
}
}
@override
Future<void> 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<PurchaseDetails> detailsList) {
final mapped = <StorePurchase>[];
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();
}
}

View File

@ -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<String> allProductIds = {proMonthly, proLifetime};
/// Set of all subscription product identifiers.
static const Set<String> subscriptionProductIds = {proMonthly};
/// Set of all one-time in-app product identifiers.
static const Set<String> 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;
}

View File

@ -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<List<StorePurchase>>? _purchaseSubscription;
EntitlementState _state = const EntitlementState.free();
bool _initialized = false;
// Staged store purchases observed on client, awaiting server verification (Stage 4B-3).
final List<StorePurchase> _unverifiedPurchases = [];
EntitlementCoordinator({required this.billingService, required this.storage});
EntitlementState get state => _state;
List<StorePurchase> get unverifiedPurchases =>
List.unmodifiable(_unverifiedPurchases);
Future<void> 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<void> restorePurchases() async {
await billingService.restorePurchases();
}
/// Processes store purchases delivered from the billing stream.
void _handleStorePurchases(List<StorePurchase> 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<void> 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();
}
}

View File

@ -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<EntitlementStorage>((ref) {
return const SecureEntitlementStorage();
});
/// Provider for the [EntitlementCoordinator] which bridges store events to entitlement state.
final entitlementCoordinatorProvider =
ChangeNotifierProvider<EntitlementCoordinator>((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<EntitlementState>((ref) {
return const EntitlementState.free();
/// Follows the state published by [EntitlementCoordinator].
final entitlementProvider = Provider<EntitlementState>((ref) {
final coordinator = ref.watch(entitlementCoordinatorProvider);
return coordinator.state;
});
/// Centralized provider for checking feature accessibility.

View File

@ -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:

View File

@ -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:

View File

@ -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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<PlayBillingService>());
expect(service, isNot(isA<FakeBillingService>()));
},
);
});
group('Account Independence & Safety', () {
test(
'billing service has no dependency on Firebase Auth or cloud accounts',
() {
final billing = FakeBillingService();
expect(billing, isA<FakeBillingService>());
// 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);
},
);
});
}

View File

@ -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', () {