hyochan / flutter_inapp_purchase

(Sun Rised!) Flutter plugin for In App Purchase.
MIT License
553 stars 235 forks source link

How to detect iOS Subscription cancelled #259

Closed NighttCoder closed 3 years ago

NighttCoder commented 3 years ago

Version of flutter_inapp_purchase - 3.0.1

Platforms you faced the error - IOS

Tested environment (Emulator? Real Device?)

Real Device

In iOS when I use getPurchaseHistory its return all purchase history , which is fine. But there is no way for me to know if a particular transaction was cancelled.

For Ex: A user could have subscribed to a trail period but then cancelled during the trial period.I only know this transaction as "Transaction.Restored" there is no way to know if the user cancelled the trial.

Any help is appreciated.

srsudar commented 3 years ago

I'm confused about this as well. I've made a subscription request to the sandbox environment, which I believe should autorenew six times and then stop. Therefore in the current app state, I don't believe I should have any active subscriptions, yet there's no obvious way for me to tell this I don't think.

Maybe I need to do something with the validateReceiptIos method? I'm still investigating, but if anyone has pointers I'd appreciate it.

srsudar commented 3 years ago

Oh, maybe it's this? From the Receipt Validation section of the readme:

  validateReceipt() async {
    var receiptBody = {
      'receipt-data': purchased.transactionReceipt,
      'password': '******'
    };
    const result = await validateReceiptIos(receiptBody, false);
    console.log(result);
  }

IIUC, the receiptBody is coming from this object, which I found from the linked guide. There is an expiration_intent field of which Apple says:

The reason a subscription expired. This field is only present for a receipt that contains an expired auto-renewable subscription.

That makes it sound like if that field is present, then there is no active subscription.

After a few hours I have something that is generally close to working, as far as I can tell, at least on iOS. This is still something of a work in progress. There is a lot of complexity to get right even with the help of this plugin, and I'm not at all sure that I've done it correctly. Here are some of the relevant bits from my IAPManager class. Note that this won't compile as-is--I haven't bothered to remove the irrelevant bits, so it refers to parts of the class that aren't included in these snippets.

If anyone has suggestions, confirmations, or corrections, they would be much appreciated.

const int _IOS_STATUS_IS_SANDBOX = 21007;
const int _IOS_STATUS_IS_OK = 0;

  /// Note that this method can throw errors! This allows the caller to
  /// handle it, but callers should be aware of this and wrap in a try.
  ///
  /// For now the return result here is kind of ugly. IIUC, handling most
  /// purchases is idempotent (so we can call as much as we want with
  /// different purchases and still get the right state) and cheap. The one
  /// exception is handling subscriptions on iOS, where we need to use the
  /// plugin to call the server. If this method returns true, that means that
  /// the server has been successfully queried and future subs in this batch
  /// (which have presumably been fetched from the same App Store back end)
  /// do not need to be queried again. This came up in testing where we got
  /// six back at a time, eg, and all were expired. One query from any of
  /// them was enough to get the latest state. See notes in the relevant
  /// methods.
  Future<bool> _handlePurchase(PurchasedItem item) async {
    if (item.transactionId == null) {
      // After using the test card that auto-declines, we are getting some
      // events in our purchase history that aren't obviously declined.
      // Looking at the debug values, however, they have no transactionId.
      // We're going to ignore those, assuming that they are not what we care
      // about.
      // Note for github: there's probably a better way to do this.
      debugPrint('got an item with no transactionId: $item');
      return false;
    }

    bool isIOSAndFutureSubscriptionCallsUnnecessary = false;

    if (item.purchaseStateAndroid == PurchaseState.purchased) {
      if (!item.isAcknowledgedAndroid) {
        debugPrint('XXX need to ack purchase');
        // "Lastly, if you want to abstract three different methods into one,
        // consider using finishTransaction method."
        // https://pub.dev/packages/flutter_inapp_purchase
        String finishTxnResult = await _plugin.finishTransaction(item);
        debugPrint('acknowledge successfully: $finishTxnResult');
      }
    } else if (item.transactionStateIOS == TransactionState.purchased) {
      await _plugin.finishTransaction(item);
    } else {
      debugPrint('XXX do not need to ack purchase');
    }

    // Do we own the purchase?
    if (Platform.isIOS && item.productId == 'remove_ads_oneyear') {
      // Subscriptions are special cased on iOS.
      debugPrint('XXX found subscription, going to validate receipt');
      bool ownSub = await _iosSubscriptionIsActive(item);
      if (ownSub) {
        _storeState = _storeState.takePurchase(item);
      } else {
        _storeState = _storeState.removePurchase(item);
      }
      isIOSAndFutureSubscriptionCallsUnnecessary = true;
    } else {
      // Then I think we own it...
      _storeState = _storeState.takePurchase(item);
    }

    return isIOSAndFutureSubscriptionCallsUnnecessary;
  }

  /// There is no way to tell that a subscription is active on iOS without
  /// issues a call via the transaction receipt. Note that this method calls
  /// the plugin without a try/catch--callers should try/catch.
  Future<bool> _iosSubscriptionIsActive(PurchasedItem item) async {
    // We can either validate against the sandbox (for test accounts) or
    // against production. The apple docs say always first validate against
    // production, only trying the sandbox if there is a specific error code.
    // * https://stackoverflow.com/questions/9677193/ios-storekit-can-i-detect-when-im-in-the-sandbox
    // * https://developer.apple.com/library/archive/technotes/tn2413/_index.html#//apple_ref/doc/uid/DTS40016228-CH1-RECEIPTURL

    // Always use prod first--see above.
    var json = await _validateTransactionIOSHelper(item, false);
    if (_statusEquals(json, _IOS_STATUS_IS_SANDBOX)) {
      debugPrint('item was sandbox purchase');
      json = await _validateTransactionIOSHelper(item, true);
    }
    if (!_statusEquals(json, _IOS_STATUS_IS_OK)) {
      throw Exception('iOS subscription validation status not ok: '
          '${json['status']}');
    }
    debugPrint(
        'parsed validation receipt for subscription with sku: {${item.productId}');
    debugPrint('  validation receipt: $json');
    return _validationResponseIndicatesActiveSubscription(json);
  }

  bool _validationResponseIndicatesActiveSubscription(
      Map<String, dynamic> body) {
    // The presence of expiration_intent is enough to tell us that this is
    // expired. If it is absent, I believe that means either that we do not
    // own the subscription or this wasn't a properly formed request
    // initially, eg maybe not a subscription.
    //
    // https://developer.apple.com/documentation/appstorereceipts/responsebody/pending_renewal_info
    // expiration_intent:
    // The reason a subscription expired. This field is only present for a
    // receipt that contains an expired auto-renewable subscription.
    //
    // We are looking for something like this (the irrelevant bits trimmed):
    // {
    //   status: 0,
    //   pending_renewal_info: [
    //     {
    //       expiration_intent: "1", // if this key present, it is expired, regardless of value
    //     }
    //   ],
    // }
    if (body == null) {
      return false;
    }
    List<dynamic> pendingRenewalInfoList = body['pending_renewal_info'];
    if (pendingRenewalInfoList == null) {
      debugPrint('no pending_renewal_info property, returning false');
      return false;
    }
    if (pendingRenewalInfoList.length < 1) {
      debugPrint('pending_renewal_info set, but no empty list');
      return false;
    }
    if (pendingRenewalInfoList.length > 1) {
      debugPrint('pending_renewal_info.length > 1, which is unexpected');
    }
    Map<String, dynamic> pendingRenewalInfo =
        pendingRenewalInfoList[0] as Map<String, dynamic>;
    debugPrint('pending_renewal_info successfully parsed: $pendingRenewalInfo');
    // If non-null, then it has been canceled.
    return pendingRenewalInfo['expiration_intent'] == null;
  }

  bool _statusEquals(Map<String, dynamic> json, int statusCode) {
    if (json == null || json['status'] == null) {
      return false;
    }
    return json['status'] == statusCode || json['status'] == '$statusCode';
  }

  Future<Map<String, dynamic>> _validateTransactionIOSHelper(
      PurchasedItem item, bool useSandbox) async {
    var reqBody = Map<String, String>();
    reqBody['receipt-data'] = item.transactionReceipt;
    // I got this from the appstoreconnect IAP section.
    reqBody['password'] = 'snip';
    reqBody['exclude-old-transactions'] = 'true';

    http.Response resp =
        await _plugin.validateTransactionIOS(reqBody, useSandbox);
    if (resp.statusCode != HttpStatus.ok) {
      throw Exception('could not validate iOS transaction, status code: '
          '${resp.statusCode}');
    }
    Map<String, dynamic> json = jsonDecode(resp.body);
    return json;
  }

  Future<void> getPurchaseHistory(bool takeOwnershipOfLoading) async {
    if (!_cxnIsInitialized) {
      debugPrint('getPurchaseHistory called but cxn not initialized');
      return;
    }
    if (takeOwnershipOfLoading) {
      _isLoaded = false;
    }
    // Don't reset _hasFetchedPurchases. As long as we've fetched them once,
    // that is enough.
    notifyListeners();
    try {
      List<PurchasedItem> purchases = await _plugin.getPurchaseHistory();
      debugPrint('XXX got purchaseHistory: ${purchases.length}');
      for (var item in purchases) {
        debugPrint('ZZZ purchase: ${item.transactionDate}');
      }

      // See the long comment on _handlePurchase about what this is doing.
      bool isIOSAndFutureSubscriptionCallsUnnecessary = false;
      for (PurchasedItem item in purchases) {
        if (isIOSAndFutureSubscriptionCallsUnnecessary &&
            item.productId == 'remove_ads_oneyear') {
          debugPrint('skipping already validated subscription in batch');
          continue;
        }
        isIOSAndFutureSubscriptionCallsUnnecessary =
            await _handlePurchase(item);
        debugPrint('XXX found a purchased item: $item');
        debugPrint('XXX new state: $_storeState');
      }
      _hasFetchedPurchases = true;
    } catch (e) {
      debugPrint('getPurchaseHistory: ugly universal catch block: $e');
      _pluginErrorMsg = e.toString();
    }
    debugPrint('XXX getPurchaseHistory setting _isLoaded = true');
    if (takeOwnershipOfLoading) {
      _isLoaded = true;
    }
    debugPrint('XXX loaded purchases: $_storeState');
    notifyListeners();
  }
ventr1x commented 3 years ago

Even as a Cross platform code monkey you should invest a few hours trying to understand native features you want to implement. This plugin is pretty basic and only uses a few platform channel calls you could duplicate in a matter of minutes. It's literally just 600 loc, so not too much to ask.

Something more on topic: Purchase history does NOT update the original receipt. You cannot get the subscription state out of it. Use validate on an original receipt is not the intended use for this (I don't even know if Apple sends the updated receipt this way back to you). For this you have to implement server-to-server API to which Apple/google sends constant receipt updates.

srsudar commented 3 years ago

I investigated, couldn't figure it out, and asked for help. Seems like standard procedure to me.

github-actions[bot] commented 3 years ago

This issue is stale because it has been open 90 days with no activity. Leave a comment or this will be closed in 7 days.