feat: integrate Google Play billing lifecycle
This commit is contained in:
parent
ee5fc42b37
commit
3e4a239876
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../billing/billing_providers.dart';
|
||||||
|
import 'entitlement_coordinator.dart';
|
||||||
import 'entitlement_state.dart';
|
import 'entitlement_state.dart';
|
||||||
import 'entitlement_storage.dart';
|
import 'entitlement_storage.dart';
|
||||||
import 'feature_access_service.dart';
|
import 'feature_access_service.dart';
|
||||||
|
|
@ -9,11 +11,19 @@ final entitlementStorageProvider = Provider<EntitlementStorage>((ref) {
|
||||||
return const SecureEntitlementStorage();
|
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.
|
/// Authoritative entitlement state provider.
|
||||||
/// Defaults to [EntitlementState.free] until the billing coordinator or cache loader runs.
|
/// Follows the state published by [EntitlementCoordinator].
|
||||||
/// In Stage 4B-2, this will be wired to the billing lifecycle coordinator.
|
final entitlementProvider = Provider<EntitlementState>((ref) {
|
||||||
final entitlementProvider = StateProvider<EntitlementState>((ref) {
|
final coordinator = ref.watch(entitlementCoordinatorProvider);
|
||||||
return const EntitlementState.free();
|
return coordinator.state;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Centralized provider for checking feature accessibility.
|
/// Centralized provider for checking feature accessibility.
|
||||||
|
|
|
||||||
32
pubspec.lock
32
pubspec.lock
|
|
@ -781,6 +781,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.2"
|
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:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ dependencies:
|
||||||
google_sign_in: ^6.2.2
|
google_sign_in: ^6.2.2
|
||||||
cloud_firestore: ^5.6.5
|
cloud_firestore: ^5.6.5
|
||||||
flutter_secure_storage: ^10.2.1
|
flutter_secure_storage: ^10.2.1
|
||||||
|
in_app_purchase: 3.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -288,7 +288,9 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
group('Riverpod Provider integration', () {
|
group('Riverpod Provider integration', () {
|
||||||
test('featureAccessProvider reacts to entitlementProvider updates', () {
|
test(
|
||||||
|
'featureAccessProvider reacts to entitlementProvider updates',
|
||||||
|
() async {
|
||||||
final container = ProviderContainer(
|
final container = ProviderContainer(
|
||||||
overrides: [
|
overrides: [
|
||||||
entitlementStorageProvider.overrideWithValue(
|
entitlementStorageProvider.overrideWithValue(
|
||||||
|
|
@ -307,8 +309,9 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update to Pro Lifetime
|
// Update to Pro Lifetime
|
||||||
container.read(entitlementProvider.notifier).state =
|
await container
|
||||||
EntitlementState.proLifetime();
|
.read(entitlementCoordinatorProvider)
|
||||||
|
.applyVerifiedEntitlement(EntitlementState.proLifetime());
|
||||||
expect(
|
expect(
|
||||||
container
|
container
|
||||||
.read(featureAccessProvider)
|
.read(featureAccessProvider)
|
||||||
|
|
@ -317,11 +320,13 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Downgrade to Expired
|
// Downgrade to Expired
|
||||||
container
|
await container
|
||||||
.read(entitlementProvider.notifier)
|
.read(entitlementCoordinatorProvider)
|
||||||
.state = const EntitlementState(
|
.applyVerifiedEntitlement(
|
||||||
|
const EntitlementState(
|
||||||
plan: MotorSignalPlan.proMonthly,
|
plan: MotorSignalPlan.proMonthly,
|
||||||
status: MotorSignalEntitlementStatus.proMonthlyExpired,
|
status: MotorSignalEntitlementStatus.proMonthlyExpired,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
container
|
container
|
||||||
|
|
@ -329,7 +334,8 @@ void main() {
|
||||||
.canAccess(MotorSignalFeature.csvExport),
|
.canAccess(MotorSignalFeature.csvExport),
|
||||||
isFalse,
|
isFalse,
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('Architectural boundaries & Rider safety invariant', () {
|
group('Architectural boundaries & Rider safety invariant', () {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue