Repository navigation
[in_app_purchase][ios] AppStorePurchaseDetails.pendingCompletePurchase = true after calling completePurchase #98484
Copy link
Copy link
Open
Labels
P2Important issues not at the top of the work listImportant issues not at the top of the work listfound in release: 2.10Found to occur in 2.10Found to occur in 2.10found in release: 2.11Found to occur in 2.11Found to occur in 2.11has reproducible stepsThe issue has been confirmed reproducible and is ready to work onThe issue has been confirmed reproducible and is ready to work onp: in_app_purchasePlugin for in-app purchasePlugin for in-app purchasepackageflutter/packages repository. See also p: labels.flutter/packages repository. See also p: labels.platform-iosiOS applications specificallyiOS applications specificallyteam-iosOwned by iOS platform teamOwned by iOS platform teamtriaged-iosTriaged by iOS platform teamTriaged by iOS platform team
Description
Steps to Reproduce
- Setup a sandbox environment for in app purchase on iOS with a subscription
- Log in to the sandbox account on a iOS device
- Purchase the subscription
- InAppPurchase.instance.purchaseStream emits PurchaseDetails for the subscription
- Call InAppPurchase.instance.completePurchase if pendingCompletePurchase is true
- Restore purchases
- PurchaseDetails.pendingCompletePurchase is still true
Expected results: PurchaseDetails.pendingCompletePurchase = false after call to completePurchase
Actual results: PurchaseDetails.pendingCompletePurchase = true after call to completePurchase
AppStorePurchaseDetails.pendingCompletePurchase returns _pendingCompletePurchase.
I have also tested on Android, there pendingCompletePurchase changes to false as expected.
Code sample
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(_MyApp());
}
const String _kSubscriptionId = 'sku_monthly_level_1'; // TODO Set subscription id for your project here
const Set<String> _kProductIds = <String>{_kSubscriptionId};
class _MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<_MyApp> {
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
late StreamSubscription<List<PurchaseDetails>> _subscription;
List<ProductDetails> _products = [];
List<PurchaseDetails> _purchases = [];
bool _isAvailable = false;
bool _purchasePending = false;
bool _loading = true;
String? _queryProductError;
@override
void initState() {
final Stream<List<PurchaseDetails>> purchaseUpdated = _inAppPurchase.purchaseStream;
_subscription = purchaseUpdated.listen((purchaseDetailsList) {
_listenToPurchaseUpdated(purchaseDetailsList);
}, onDone: () {
_subscription.cancel();
}, onError: (error) {
// handle error here.
});
initStoreInfo();
super.initState();
}
Future<void> initStoreInfo() async {
final bool isAvailable = await _inAppPurchase.isAvailable();
if (!isAvailable) {
setState(() {
_isAvailable = isAvailable;
_products = [];
_purchases = [];
_purchasePending = false;
_loading = false;
});
return;
}
ProductDetailsResponse productDetailResponse = await _inAppPurchase.queryProductDetails(_kProductIds);
if (productDetailResponse.error != null) {
setState(() {
_queryProductError = productDetailResponse.error!.message;
_isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_purchases = [];
_purchasePending = false;
_loading = false;
});
return;
}
if (productDetailResponse.productDetails.isNotEmpty) {
setState(() {
_queryProductError = null;
_isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_purchases = [];
_purchasePending = false;
_loading = false;
});
return;
}
}
@override
void dispose() {
if (Platform.isIOS) {
var iosPlatformAddition = _inAppPurchase.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
iosPlatformAddition.setDelegate(null);
}
_subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
List<Widget> stack = [];
if (_queryProductError == null) {
stack.add(
ListView(
children: [
_buildConnectionCheckTile(),
_buildProductList(),
_buildRestoreButton(),
],
),
);
} else {
stack.add(Center(
child: Text(_queryProductError!),
));
}
if (_purchasePending) {
stack.add(
Stack(
children: [
Opacity(
opacity: 0.3,
child: const ModalBarrier(dismissible: false, color: Colors.grey),
),
Center(
child: CircularProgressIndicator(),
),
],
),
);
}
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('IAP Example'),
),
body: Stack(
children: stack,
),
),
);
}
Card _buildConnectionCheckTile() {
if (_loading) {
return Card(child: ListTile(title: const Text('Trying to connect...')));
}
final Widget storeHeader = ListTile(
leading: Icon(_isAvailable ? Icons.check : Icons.block,
color: _isAvailable ? Colors.green : ThemeData.light().errorColor),
title: Text('The store is ' + (_isAvailable ? 'available' : 'unavailable') + '.'),
);
final List<Widget> children = <Widget>[storeHeader];
if (!_isAvailable) {
children.addAll([
Divider(),
ListTile(
title: Text('Not connected', style: TextStyle(color: ThemeData.light().errorColor)),
subtitle: const Text(
'Unable to connect to the payments processor. Has this app been configured correctly? See the example README for instructions.'),
),
]);
}
return Card(child: Column(children: children));
}
Card _buildProductList() {
if (_loading) {
return Card(child: (ListTile(leading: CircularProgressIndicator(), title: Text('Fetching products...'))));
}
if (!_isAvailable) {
return Card();
}
final ListTile productHeader = ListTile(title: Text('Products for Sale'));
List<ListTile> productList = <ListTile>[];
// This loading previous purchases code is just a demo. Please do not use this as it is.
// In your app you should always verify the purchase data using the `verificationData` inside the [PurchaseDetails] object before trusting it.
// We recommend that you use your own server to verify the purchase data.
Map<String, PurchaseDetails> purchases = Map.fromEntries(_purchases.map((PurchaseDetails purchase) {
if (purchase.pendingCompletePurchase) {
_inAppPurchase.completePurchase(purchase);
}
return MapEntry<String, PurchaseDetails>(purchase.productID, purchase);
}));
productList.addAll(_products.map(
(ProductDetails productDetails) {
PurchaseDetails? previousPurchase = purchases[productDetails.id];
return ListTile(
title: Text(
productDetails.title,
),
subtitle: Text(
productDetails.description,
),
trailing: previousPurchase != null
? Icon(Icons.check)
: TextButton(
child: Text(productDetails.price),
style: TextButton.styleFrom(
backgroundColor: Colors.green[800],
primary: Colors.white,
),
onPressed: () {
PurchaseParam purchaseParam = GooglePlayPurchaseParam(
productDetails: productDetails,
applicationUserName: null,
);
_inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);
},
));
},
));
return Card(child: Column(children: <Widget>[productHeader, Divider()] + productList));
}
Widget _buildRestoreButton() {
if (_loading) {
return Container();
}
return Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
child: Text('Restore purchases'),
style: TextButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
primary: Colors.white,
),
onPressed: () => _inAppPurchase.restorePurchases(),
),
],
),
);
}
void showPendingUI() {
setState(() {
_purchasePending = true;
});
}
void deliverProduct(PurchaseDetails purchaseDetails) async {
// IMPORTANT!! Always verify purchase details before delivering the product.
setState(() {
_purchases.add(purchaseDetails);
_purchasePending = false;
});
}
void handleError(IAPError error) {
setState(() {
_purchasePending = false;
});
}
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) {
// IMPORTANT!! Always verify a purchase before delivering the product.
// For the purpose of an example, we directly return true.
return Future<bool>.value(true);
}
void _handleInvalidPurchase(PurchaseDetails purchaseDetails) {
// handle invalid purchase here if _verifyPurchase` failed.
}
void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) {
purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async {
if (purchaseDetails.status == PurchaseStatus.pending) {
showPendingUI();
} else {
if (purchaseDetails.status == PurchaseStatus.error) {
handleError(purchaseDetails.error!);
} else if (purchaseDetails.status == PurchaseStatus.purchased ||
purchaseDetails.status == PurchaseStatus.restored) {
bool valid = await _verifyPurchase(purchaseDetails);
if (valid) {
deliverProduct(purchaseDetails);
} else {
_handleInvalidPurchase(purchaseDetails);
return;
}
}
if (purchaseDetails.pendingCompletePurchase) {
await _inAppPurchase.completePurchase(purchaseDetails);
}
}
});
}
}
Logs
No errors or analysis warnings
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
P2Important issues not at the top of the work listImportant issues not at the top of the work listfound in release: 2.10Found to occur in 2.10Found to occur in 2.10found in release: 2.11Found to occur in 2.11Found to occur in 2.11has reproducible stepsThe issue has been confirmed reproducible and is ready to work onThe issue has been confirmed reproducible and is ready to work onp: in_app_purchasePlugin for in-app purchasePlugin for in-app purchasepackageflutter/packages repository. See also p: labels.flutter/packages repository. See also p: labels.platform-iosiOS applications specificallyiOS applications specificallyteam-iosOwned by iOS platform teamOwned by iOS platform teamtriaged-iosTriaged by iOS platform teamTriaged by iOS platform team
Type
Projects
- StatusShow more project fieldsComplex, High Priority

Activity
[-][in_app_purchase] PurchaseDetails marked as pendingCompletePurchase = true on iOS after calling completePurchase[/-][+][in_app_purchase] PurchaseDetails.pendingCompletePurchase = true on iOS after calling completePurchase[/+][-][in_app_purchase] PurchaseDetails.pendingCompletePurchase = true on iOS after calling completePurchase[/-][+][in_app_purchase][ios] `PurchaseDetails.pendingCompletePurchase = true` after calling `completePurchase`[/+]danagbemava-nc commentedon Feb 15, 2022
Hi @andaas4, thanks for filing the issue.
Please provide the output of
flutter doctor -vas well as the version ofin_app_purchaseyou're using.Thank you
andaas4 commentedon Feb 15, 2022
Sure, here you go: @danagbemava-nc
in_app_purchase: 2.0.1flutter doctor -v
[-][in_app_purchase][ios] `PurchaseDetails.pendingCompletePurchase = true` after calling `completePurchase`[/-][+][in_app_purchase][ios] `PurchaseDetails._pendingCompletePurchase = true` after calling `completePurchase`[/+]danagbemava-nc commentedon Feb 15, 2022
Hi @andaas4, I just noticed that the value you're referencing in your screenshot is a private setter, so that value shouldn't be accessible to you, but the public getter,
pendingCompletePurchasewhich retrieves the value you'd need is actually showing the correct value.Is your issue to point out the discrepancy between the two values?
andaas4 commentedon Feb 15, 2022
Yeah, I know. If you look at app_store_purchase_details.dart the logic there is
bool get pendingCompletePurchase => _pendingCompletePurchase;
I think the value for pendingCompletePurchase shown in the screenshot is from purchase_details.dart with defaults to false, as AppStorePurchaseDetails extends PurchaseDetails, but the actual value returned when accessing pendingCompletePurchase on AppStorePurchaseDetails is the value from _pendingCompletePurchase.
Sorry if that was misleading, just thought it was a good thing to point out. But the issue is still present in my testing, pendingCompletePurchase on AppStorePurchaseDetails is true after calling completePurchase multiple times. @danagbemava-nc
[-][in_app_purchase][ios] `PurchaseDetails._pendingCompletePurchase = true` after calling `completePurchase`[/-][+][in_app_purchase][ios] `AppStorePurchaseDetails.pendingCompletePurchase = true` after calling `completePurchase`[/+]danagbemava-nc commentedon Feb 16, 2022
I just wanted to make sure that we were on the same page. I am seeing something similar to what you're seeing. I don't think this is intended, but I'm not an expert on
in_app_purchases. Labeling for further insight from the team. Steps to reproduce can be found in #98484 (comment)This can be reproduced with the plugin's example code as well.
code sample
consumable_store.dartscreenshot of debugger
Notice that there are 2 values for
pendingCompletePurchaseand they don't seem to match up with each other.The private setter also seems to be true
flutter doctor -v
cc @mvanbeusekom
36 remaining items
shaynec25 commentedon Feb 22, 2024
junixapp commentedon Mar 23, 2024
AunCly commentedon Apr 9, 2024
Hi everyone i'm facing the same issue.
Is anyone knows if the problem is only with storekit/testflight or if it possibly be trigger in production mode ?
Thanks for answer ! hope it was solved soon
hieugia312 commentedon Apr 25, 2024
It only appears in storekit/testflight mode, I have never seen it be triggered in production mode.
vamseevk2001 commentedon Jul 17, 2024
I am still facing the same issue. Has anyone found the workaround or any other solution. Thanks in advance! :)
VitorLamego commentedon Aug 9, 2024
Painwraith commentedon Aug 18, 2024
BrantK commentedon Sep 24, 2024
Is the in_app_purchase library no longer being maintained? This issue was raised years ago and no one seems to be looking into it.
Painwraith commentedon Sep 24, 2024
@BrantK The package works fine, its how they spam you during testing to make sure your system is robust and your own data & records can sift through the noise to give appropriate behaviors. Production is not much better btw.
jmagman commentedon Sep 24, 2024
The in_app_purchase_storekit package is being almost entirely rewritten to use StoreKit2 APIs. You can follow along with #116383 (comment) to see the progress.
I would be very interested in seeing what the behavior of this is after flutter/packages#7574 merges. cc @LouiseHsu
BrantK commentedon Sep 25, 2024
Ah I didn't know that. I'll be keeping an eye on this then, thanks!
wmadden commentedon Sep 26, 2024
The issue is here (
in_app_purchase_storekit-0.3.18+1/lib/src/types/app_store_purchase_details.dart):The condition is negated. The developer must call
complete()ifstatus == PurchaseStatus.pending.Since
pendingCompletePurchaseis just a "convenience", it's simpler to just ignore it and usestatusdirectly.