class InAppManager {
// Properties
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
Set<String> _consumableIds = <String>{};
List<String> _notFoundIds = <String>[];
List<ProductDetails> _products = <ProductDetails>[];
bool isAvailable = false;
bool purchasePending = false;
bool loading = true;
bool isRestoring = false;
Function(IAPError error)? onError;
Function(PurchaseDetails details)? onSuccess;
Function(PurchaseDetails details)? onRestore;
Function(String message)? onRestoreNoActive;
late StreamSubscription<List<PurchaseDetails>> _subscription;
List<PurchaseDetails> activePurchases = <PurchaseDetails>[];
// Auto-consume must be true on iOS.
// To try without auto-consume on another platform, change `true` to `false` here.
final bool autoConsume = Platform.isIOS || true;
List<ProductDetails> getProducts() {
return _products;
}
// Init
InAppManager() {
final Stream<List<PurchaseDetails>> purchaseUpdated =
_inAppPurchase.purchaseStream;
_subscription = purchaseUpdated.listen(
(List<PurchaseDetails> purchaseDetailsList) {
_listenToPurchaseUpdated(purchaseDetailsList);
},
onDone: () {
_subscription.cancel();
},
onError: (Object error) {
AlertMessage.instance.showMessage(msg: error.toString());
},
);
}
// Get the purchase and product details info.
Future<void> initStoreInfo() async {
final bool isAvailable = await _inAppPurchase.isAvailable();
debugPrint('_inAppPurchase.isAvailable() : ${isAvailable}');
//await AlertMessage.instance.showMessage(msg: 'inAppPurchase is available: ${isAvailable}');
if (!isAvailable) {
this.isAvailable = isAvailable;
_products = <ProductDetails>[];
_notFoundIds = <String>[];
purchasePending = false;
loading = false;
return;
}
if (Platform.isIOS) {
final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
_inAppPurchase
.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate());
}
debugPrint('Ids : ${_consumableIds}');
//await AlertMessage.instance.showMessage(msg: 'Ids : ${_consumableIds}', milliSec: 5000);
final ProductDetailsResponse productDetailResponse =
await _inAppPurchase.queryProductDetails(_consumableIds);
debugPrint(
'ProductDetailsResponse_notFoundIDs : ${productDetailResponse.notFoundIDs}');
//await AlertMessage.instance.showMessage(msg: 'ProductDetailsResponse : ${ProductDetailsResponse}', milliSec: 5000);
if (productDetailResponse.error != null) {
this.isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_notFoundIds = productDetailResponse.notFoundIDs;
purchasePending = false;
loading = false;
debugPrint('========= productDetailResponse.error =========');
debugPrint('_notFoundIds : ${_notFoundIds}');
debugPrint('${productDetailResponse.error?.code}');
debugPrint('${productDetailResponse.error?.message}');
debugPrint('${productDetailResponse.error?.details}');
debugPrint('============================');
return;
}
if (productDetailResponse.productDetails.isEmpty) {
this.isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_notFoundIds = productDetailResponse.notFoundIDs;
purchasePending = false;
loading = false;
debugPrint(
'========= productDetailResponse.productDetails.isEmpty =========');
debugPrint('_notFoundIds : ${_notFoundIds}');
//await AlertMessage.instance.showMessage(msg: '_notFoundIds : ${_notFoundIds}', milliSec: 5000);
debugPrint('============================');
return;
}
this.isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_notFoundIds = productDetailResponse.notFoundIDs;
purchasePending = false;
debugPrint('========= products =========');
_products.forEach((element) {
debugPrint('${element.id}');
debugPrint('${element.title}');
debugPrint('${element.price}');
debugPrint(' ');
});
debugPrint('============================');
}
// Buy product
Future<void> buyProduct(String productId) async {
final productDetails =
_products.firstWhereOrNull((element) => element.id == productId);
if (productDetails == null) {
return;
}
late PurchaseParam purchaseParam;
if (Platform.isAndroid) {
purchaseParam = GooglePlayPurchaseParam(
productDetails: productDetails,
);
} else {
purchaseParam = PurchaseParam(
productDetails: productDetails,
);
}
await _inAppPurchase.buyConsumable(
purchaseParam: purchaseParam,
autoConsume: autoConsume,
);
}
Future<void> buySubscription(String productId) async {
debugPrint("productId :$productId");
final productDetails =
_products.firstWhereOrNull((element) => element.id == productId);
debugPrint("productDetails :$productDetails");
if (productDetails == null) {
return;
}
late PurchaseParam purchaseParam;
if (Platform.isAndroid) {
purchaseParam = GooglePlayPurchaseParam(
productDetails: productDetails,
);
} else {
purchaseParam = PurchaseParam(
productDetails: productDetails,
);
}
debugPrint("💳 Calling buyNonConsumable...");
try {
await _inAppPurchase.buyNonConsumable(
purchaseParam: purchaseParam,
);
debugPrint("✅ Purchase initiated");
} catch (e) {
debugPrint("❌ Purchase error: $e");
}
}
Future<void> upOrDowngradeSubscription(
String productId,
SubscriptionDetails subscriptionDetails,
) async {
final productDetails =
_products.firstWhereOrNull((element) => element.id == productId);
if (productDetails == null) {
return;
}
late PurchaseParam purchaseParam;
late GooglePlayPurchaseDetails oldPurchaseDetails;
final platform = _inAppPurchase
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final pastPurchases = await platform.queryPastPurchases();
if (pastPurchases.pastPurchases.isEmpty) {
return;
}
oldPurchaseDetails = pastPurchases.pastPurchases.firstWhere(
(element) => element.purchaseID == subscriptionDetails.purchaseId,
);
if (Platform.isAndroid) {
purchaseParam = GooglePlayPurchaseParam(
productDetails: productDetails,
changeSubscriptionParam: ChangeSubscriptionParam(
oldPurchaseDetails: oldPurchaseDetails,
replacementMode: ReplacementMode.withTimeProration,
// prorationMode: ProrationMode.immediateWithTimeProration,
));
}
await _inAppPurchase.buyNonConsumable(
purchaseParam: purchaseParam,
);
}
// Sets products ids
void setConsumableIds(Set<String> ids) {
_consumableIds = ids;
}
// Listen to any purchase update.
Future<void> _listenToPurchaseUpdated(
List<PurchaseDetails> purchaseDetailsList,
) async {
debugPrint(
"📱 Purchase update received: ${purchaseDetailsList.length} items");
for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
debugPrint("Status: ${purchaseDetails.status}");
debugPrint("Product ID: ${purchaseDetails.productID}");
if (purchaseDetails.status == PurchaseStatus.pending) {
showPendingUI();
} else {
if (purchaseDetails.status == PurchaseStatus.error) {
debugPrint("❌ Error: ${purchaseDetails.error?.message}");
handleError(purchaseDetails.error!);
} else if (purchaseDetails.status == PurchaseStatus.purchased ||
purchaseDetails.status == PurchaseStatus.restored) {
if (Platform.isIOS) {
// Small delay so StoreKit finishes processing
await Future.delayed(const Duration(seconds: 2));
final SK2Transaction? activeTransaction =
await getLatestActiveTransaction();
if (activeTransaction != null) {
final expiry = _parseExpiry(activeTransaction.expirationDate);
debugPrint("💳 Found ACTIVE transaction:");
debugPrint(" Transaction ID: ${activeTransaction.id}");
debugPrint(" Original ID: ${activeTransaction.originalId}");
debugPrint(" Product ID: ${activeTransaction.productId}");
debugPrint(" Purchase Date: ${activeTransaction.purchaseDate}");
debugPrint(" Expires: $expiry");
} else {
debugPrint("⚠️ No active transaction found");
debugPrint(" Falling back to PurchaseDetails for backend verification");
}
}
if (isRestoring) {
debugPrint("🔄 Restore Mode: Triggering onRestore callback");
if (purchaseDetails.status == PurchaseStatus.restored || purchaseDetails.status == PurchaseStatus.purchased && onRestore != null) {
onRestore!(purchaseDetails);
}
} else {
final bool valid = await _verifyPurchase(purchaseDetails);
if (valid) {
debugPrint("valid : ${valid}");
_deliverProduct(purchaseDetails);
} else {
_handleInvalidPurchase(purchaseDetails);
return;
}
}
}
if (Platform.isAndroid) {
if (!autoConsume) {
final InAppPurchaseAndroidPlatformAddition androidAddition =
_inAppPurchase.getPlatformAddition<
InAppPurchaseAndroidPlatformAddition>();
await androidAddition.consumePurchase(purchaseDetails);
}
}
if (purchaseDetails.pendingCompletePurchase) {
await _inAppPurchase.completePurchase(purchaseDetails);
}
}
}
}
// Show pending ui if any purchase pending.
void showPendingUI() {
purchasePending = true;
}
// Deliver product with receipt validation.
Future<void> _deliverProduct(PurchaseDetails purchaseDetails) async {
//await ConsumableStore.save(purchaseDetails.purchaseID!);
purchasePending = false;
if (onSuccess != null) {
onSuccess!(purchaseDetails);
}
}
// Handel error
void handleError(IAPError error) {
purchasePending = false;
if (onError != null) {
onError!(error);
}
}
// Verify purchase with receipt validation.
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) {
// IMPORTANT!! Always verify a purchase before delivering the product.
return Future<bool>.value(true);
}
// Handle invalid purchases.
void _handleInvalidPurchase(PurchaseDetails purchaseDetails) {
// handle invalid purchase here if _verifyPurchase` failed.
}
Future<void> checkIosTransactions() async {
if (!Platform.isIOS) return;
try {
final transactions = await SK2Transaction.transactions();
final now = DateTime.now();
debugPrint("📱 Total transactions found: ${transactions.length}");
for (final tx in transactions) {
final expiry = _parseExpiry(tx.expirationDate);
final isActive = expiry != null && expiry.isAfter(now);
if (isActive) {
debugPrint("✅ ACTIVE Transaction:");
debugPrint(" Transaction ID: ${tx.id}");
debugPrint(" Original ID: ${tx.originalId}");
debugPrint(" Product ID: ${tx.productId}");
debugPrint(" Purchase Date: ${tx.purchaseDate}");
debugPrint(" Expires: $expiry");
break; // stop after first active subscription
} else {
debugPrint("⏰ Expired Transaction:");
debugPrint(" Transaction ID: ${tx.id}");
debugPrint(" Product ID: ${tx.productId}");
debugPrint(" Expired At: $expiry");
}
}
} catch (e) {
debugPrint("❌ Error checking iOS transactions: $e");
}
}
// MAIN RESTORE METHOD
Future<void> restorePurchases() async {
debugPrint("🔄 Starting restore purchases...");
if (!isAvailable) {
debugPrint("❌ Store not available");
if (onError != null) {
onError!(IAPError(
source: 'restore',
code: 'store_not_available',
message: 'Store is not available',
));
}
return;
}
isRestoring = true;
activePurchases.clear();
try {
if (Platform.isAndroid) {
await _restoreAndroidPurchases();
} else if (Platform.isIOS) {
await _restoreIOSPurchases();
}
} catch (e) {
debugPrint("❌ Restore error: $e");
isRestoring = false;
if (onError != null) {
onError!(IAPError(
source: 'restore',
code: 'restore_failed',
message: e.toString(),
));
}
}
}
// 3. ANDROID RESTORE IMPLEMENTATION
Future<void> _restoreAndroidPurchases() async {
debugPrint("🤖 Restoring Android purchases...");
final platform = _inAppPurchase
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final QueryPurchaseDetailsResponse pastPurchases =
await platform.queryPastPurchases();
if (pastPurchases.error != null) {
debugPrint("❌ Error querying past purchases: ${pastPurchases.error}");
isRestoring = false;
if (onError != null) {
onError!(pastPurchases.error!);
}
return;
}
debugPrint("📦 Found ${pastPurchases.pastPurchases.length} past purchases");
bool foundActivePurchase = false;
bool foundExpiredPurchase = false;
for (final purchase in pastPurchases.pastPurchases) {
debugPrint("━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
debugPrint("Checking purchase: ${purchase.productID}");
debugPrint("Status: ${purchase.status}");
debugPrint("Purchase ID: ${purchase.purchaseID}");
// Check if it's a valid subscription from your product list
if (_consumableIds.contains(purchase.productID)) {
// ANDROID SPECIFIC: Check if subscription is active
if (purchase is GooglePlayPurchaseDetails) {
final isAutoRenewing = purchase.billingClientPurchase.isAutoRenewing;
final isAcknowledged = purchase.billingClientPurchase.isAcknowledged;
debugPrint("Auto-renewing: $isAutoRenewing");
debugPrint("Acknowledged: $isAcknowledged");
// Check purchase status
if (purchase.status == PurchaseStatus.purchased) {
// CRITICAL: For subscriptions, check if auto-renewing
// If autoRenewing is false, subscription is cancelled/expired
if (isAutoRenewing) {
// Active subscription
final bool valid = await _verifyPurchase(purchase);
if (valid) {
debugPrint("✅ ACTIVE subscription found: ${purchase.productID}");
foundActivePurchase = true;
activePurchases.add(purchase);
// Trigger restore callback
if (onRestore != null) {
onRestore!(purchase);
}
// Complete the purchase if needed
if (purchase.pendingCompletePurchase) {
await _inAppPurchase.completePurchase(purchase);
}
}
} else {
// Subscription is cancelled or expired
debugPrint("⚠️ EXPIRED/CANCELLED subscription: ${purchase.productID}");
foundExpiredPurchase = true;
}
} else {
debugPrint("⚠️ Purchase status is not 'purchased': ${purchase.status}");
}
}
}
}
isRestoring = false;
debugPrint("━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
if (!foundActivePurchase) {
if (foundExpiredPurchase) {
debugPrint("⚠️ Found expired/cancelled subscriptions but no active ones");
if (onRestoreNoActive != null) {
// onRestoreNoActive!("Your previous subscription has expired or been cancelled. Please purchase a new subscription.");
onRestoreNoActive!("No previous purchases found to restore");
} else if (onError != null) {
onError!(IAPError(
source: 'restore',
code: 'subscription_expired',
message: 'Your previous subscription has expired or been cancelled',
));
}
} else {
debugPrint("⚠️ No purchases found at all");
if (onError != null) {
onError!(IAPError(
source: 'restore',
code: 'no_purchases',
message: 'No previous purchases found to restore',
));
}
}
} else {
debugPrint("✅ Restore completed - found ${activePurchases.length} ACTIVE subscription(s)");
}
}
// ============================================
// iOS RESTORE IMPLEMENTATION
Future<void> _restoreIOSPurchases() async {
debugPrint("🍎 Restoring iOS purchases...");
// Call the native restore method
await _inAppPurchase.restorePurchases();
// Wait for the purchase stream to process restored purchases
// The restored purchases will come through _listenToPurchaseUpdated
await Future.delayed(Duration(seconds: 10));
isRestoring = false;
if (activePurchases.isEmpty) {
debugPrint("⚠️ No active purchases found after restore");
// For iOS, we need to check with your backend if subscription exists but expired
// The restore might succeed but subscription could be expired
if (onRestoreNoActive != null) {
// onRestoreNoActive!("No active subscription found. Your previous subscription may have expired.");
onRestoreNoActive!("No previous purchases found to restore");
} else if (onError != null) {
onError!(IAPError(
source: 'restore',
code: 'no_active_subscription',
message: 'No active subscription found to restore',
));
}
} else {
debugPrint("✅ iOS Restore completed - found ${activePurchases.length} active purchase(s)");
}
}
Future<ActiveSubscriptionInfo?> checkActiveSubscription() async {
debugPrint("🔍 Checking for active subscription...");
if (!isAvailable) {
debugPrint("❌ Store not available");
return null;
}
try {
if (Platform.isAndroid) {
return await _checkAndroidActiveSubscription();
} else if (Platform.isIOS) {
return await _checkIOSActiveSubscription();
}
} catch (e) {
debugPrint("❌ Error checking subscription: $e");
return null;
}
return null;
}
Future<ActiveSubscriptionInfo?> _checkAndroidActiveSubscription() async {
debugPrint("🤖 Checking Android active subscription...");
final platform = _inAppPurchase
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final QueryPurchaseDetailsResponse pastPurchases =
await platform.queryPastPurchases();
if (pastPurchases.error != null) {
debugPrint("❌ Error querying past purchases: ${pastPurchases.error}");
return null;
}
debugPrint("📦 Found ${pastPurchases.pastPurchases.length} past purchases");
for (final purchase in pastPurchases.pastPurchases) {
if (_consumableIds.contains(purchase.productID) &&
purchase.status == PurchaseStatus.purchased) {
if (purchase is GooglePlayPurchaseDetails) {
final isAutoRenewing = purchase.billingClientPurchase.isAutoRenewing;
final purchaseTime = purchase.billingClientPurchase.purchaseTime;
debugPrint("━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
debugPrint("Product ID: ${purchase.productID}");
debugPrint("Auto-renewing: $isAutoRenewing");
debugPrint("Purchase Time: $purchaseTime");
if (isAutoRenewing) {
final bool valid = await _verifyPurchase(purchase);
if (valid) {
// Get product details for pricing info
final productDetails = _products.firstWhereOrNull(
(p) => p.id == purchase.productID,
);
debugPrint("✅ ACTIVE subscription found: ${purchase.productID}");
debugPrint("━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
return ActiveSubscriptionInfo(
productId: purchase.productID,
purchaseId: purchase.purchaseID ?? '',
isActive: true,
isAutoRenewing: true,
purchaseToken: purchase.verificationData.serverVerificationData,
purchaseDate: DateTime.fromMillisecondsSinceEpoch(purchaseTime),
productPrice: productDetails?.price,
productTitle: productDetails?.title,
planType: _getPlanType(purchase.productID),
);
}
} else {
debugPrint("⚠️ Subscription exists but is cancelled/expired");
debugPrint("━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
}
}
}
}
debugPrint("⚠️ No active subscription found");
return null;
}
Future<ActiveSubscriptionInfo?> _checkIOSActiveSubscription() async {
debugPrint("Checking iOS active subscription...");
try {
// Using StoreKit 2 for accurate subscription status
final SK2Transaction? activeTransaction = await getLatestActiveTransaction();
if (activeTransaction != null) {
// Get product details
final productDetails = _products.firstWhereOrNull(
(p) => p.id == activeTransaction.productId,
);
final expiry = _parseExpiry(activeTransaction.expirationDate);
debugPrint("✅ ACTIVE iOS subscription found");
debugPrint("Product ID: ${activeTransaction.productId}");
debugPrint("Transaction ID: ${activeTransaction.id}");
debugPrint("Original ID: ${activeTransaction.originalId}");
debugPrint("Expires: $expiry");
return ActiveSubscriptionInfo(
productId: activeTransaction.productId,
purchaseId: activeTransaction.id.toString(),
originalTransactionId: activeTransaction.originalId.toString(),
isActive: true,
isAutoRenewing: true, // If not expired, assume auto-renewing
purchaseDate: DateTime.tryParse(activeTransaction.purchaseDate ?? '') ?? DateTime.now(),
expiryDate: expiry,
productPrice: productDetails?.price,
productTitle: productDetails?.title,
planType: _getPlanType(activeTransaction.productId),
);
}
// Fallback: Check purchase stream
if (activePurchases.isNotEmpty) {
final purchase = activePurchases.first;
final productDetails = _products.firstWhereOrNull(
(p) => p.id == purchase.productID,
);
return ActiveSubscriptionInfo(
productId: purchase.productID,
purchaseId: purchase.purchaseID ?? '',
isActive: true,
isAutoRenewing: true,
productPrice: productDetails?.price,
productTitle: productDetails?.title,
planType: _getPlanType(purchase.productID),
);
}
debugPrint("⚠️ No active iOS subscription found");
return null;
} catch (e) {
debugPrint("❌ Error checking iOS subscription: $e");
return null;
}
}
/// Helper to determine plan type from product ID
String _getPlanType(String productId) {
if (Platform.isAndroid) {
if (productId == androidYear || androidPaywallSubscriptionUpgradeIds.contains(productId)) {
return 'Annual';
} else if (productId == androidMonth) {
return 'Monthly';
}
} else {
if (productId == iOSYear || iOSPaywallSubscriptionUpgradeIds.contains(productId)) {
return 'Annual';
} else if (productId == iOSMonth) {
return 'Monthly';
}
}
return 'Unknown';
}
// Show dialog when active subscription exists
///-----------------
DateTime? _parseExpiry(dynamic expiry) {
if (expiry == null) return null;
if (expiry is DateTime) return expiry;
if (expiry is String) return DateTime.tryParse(expiry);
return null;
}
Future<SK2Transaction?> getLatestActiveTransaction() async {
if (!Platform.isIOS) return null;
try {
final transactions = await SK2Transaction.transactions();
if (transactions.isEmpty) {
debugPrint("⚠️ No transactions found");
return null;
}
final now = DateTime.now();
// Sort by purchase date DESC (latest first)
transactions.sort((a, b) {
final aDate = DateTime.tryParse(a.purchaseDate ?? '') ?? DateTime(0);
final bDate = DateTime.tryParse(b.purchaseDate ?? '') ?? DateTime(0);
return bDate.compareTo(aDate);
});
for (final tx in transactions) {
final expiry = _parseExpiry(tx.expirationDate);
debugPrint("""🔍 Checking transaction id: ${tx.id} originalId: ${tx.originalId} productId: ${tx.productId}purchaseDate: ${tx.purchaseDate}expirationDate: ${tx.expirationDate}""");
if (expiry != null && expiry.isAfter(now)) {
debugPrint("✅ Active subscription found");
return tx;
}
}
debugPrint("⚠️ No active subscription found");
return null;
} catch (e) {
debugPrint("❌ Error checking transactions: $e");
return null;
}
}
Steps to reproduce
Package(s):
in_app_purchase: ^3.2.3
in_app_purchase_storekit: ^0.3.21 (downgraded as workaround)
Description
When a user cancels a subscription and later purchases the same subscription product again, the app receives old receipt data instead of a fresh receipt. The receipt payload still contains the previous expiration date, even though the new purchase completes successfully.
This issue occurs only in production. The same flow works correctly in development/sandbox.
Steps to Reproduce
Install the app from App Store (production build)
Purchase a subscription
Cancel the subscription
After the subscription expires, purchase the same plan again
Read receipt data returned by in_app_purchase
Workarounds Tried
Downgraded in_app_purchase_storekit to ^0.3.21 (as suggested in this issue)
Result:
Works correctly in development/sandbox (cancel, renew, repurchase)
Does not work in production, issue still persists
Expected results
After a successful re-subscription, the app should receive updated receipt data
The receipt payload should contain the latest transaction and expiration date
Actual results
The app receives old receipt data
The expiration date corresponds to the previous subscription
Same behavior observed in receipt payload / webhook data
Code sample
Code sample
Screenshots or Video
Screenshots / Video demonstration
[Upload media here]
Logs
Logs
[Paste your logs here]Flutter Doctor output
Doctor output