benjaminmayo/merchantkit

suggestion for program flow?

timprepscius opened this issue · 21 comments

While beta testing, these two situations occur, although not commonly, it's not 1/100, it's more like 1/20.

Situation: Purchase screen appears and disappears rapidly

  1. The application starts
  2. The merchantkit instance is created and setup
  3. The state of a product is found to be "notPurchased"
  4. The purchase screen is displayed
  5. Started available purchases task for products
  6. Available purchases finds there is a new purchase and sets expiryDate to nil, which causes the purchase to be again valid
  7. Purchase screen disappears.
  8. Invoked receipt update for purchases, updates receipts

I would like to avoid this purchase screen appearing and disappearing. Do you have any advice for the proper way to avoid this?

Situation: Hung purchase

  1. Receipt is checked
  2. Product is notPurchased
  3. Purchase screen is displayed
  4. Purchased product
  5. Some large network lag occurs, receipt is not gotten
  6. Stays on purchase screen with product showing as "purchased"
  7. There is no call to func merchant(_ merchant: Merchant, didChangeStatesFor products: Set)

I think I should just manually start a test during productInterfaceController didCommit and didRestore, but was wondering any opinion.

What Merchant.Configuration are you using upon Merchant initialisation? I assume this is all IAP sandbox testing?

	func ensureMerchantKit ()
	{
		if merchant == nil
		{
			merchant = Merchant(configuration: .default, delegate: self)
			merchant.canGenerateLogs = true
		
			merchant.register(ProductDatabase.allProducts)
			merchant.setup()
		}
	}

Sandbox and TestFlight

With a cursory appraisal, I'm not sure why the first one is happening. The product initial state should be loaded from storage so if it was already bought it should never be 'notPurchased' at app launch.

The second one does sound like a MerchantKit framework bug and I have an inkling of where the issue lies.

In the first situation the receipt is loaded from storage, it is found to be out of date, and removed. This causes the purchased to be notPurchased.

Hmm, I see. I guess the Merchant logic could attempt to fetch a new receipt before expiring a subscription state based on the persisted storage value.

Actually there is another situation as well. I believe it relates to the second.

What happens in this Log:
Application was run yesterday, with Sandbox renewing subscription
Subscription did renew once after I started the app, but that subscription also expired.

  1. App will start, figure out subscription (as recorded locally) is expired.
  2. Start purchase screen
  3. Purchase controller figures out something was in fact bought after last receipt?
  4. Purchase screen is hidden
  5. Receipt is gotten, but it is out of date
  6. Purchase screen is shown again

I did put in productInterfaceController didCommit purchase AND productInterfaceController didRestorePurchasesWith, a check for a valid receipt

Start run:
Merchant is set up.

2019-06-03 09:31:35.567867-0400 BalletBox[1692:367034] [Receipt] Receipt validation succeeded: [ConstructedReceipt productIdentifiers: ["iap.balletbox.subscription.monthly.all"]]
KeychainPurchaseStore remove iap.balletbox.subscription.monthly.all
KeychainPurchaseStore removed changed records
2019-06-03 09:31:35.584862-0400 BalletBox[1692:367034] [Purchase Storage] Removed record for iap.balletbox.subscription.monthly.all, given expiry date 2019-06-03 02:56:40 +0000 action didChangeRecords
merchant didChangeStatesFor appdelegate [Product 'iap.balletbox.subscription.monthly.all'] notPurchased

Purchase view controller is displayed

PurchaseViewController.instance is nil
2019-06-03 09:31:35.704201-0400 BalletBox[1692:366950] [Tasks] Started available purchases task for products: ["iap.balletbox.subscription.monthly.all"]
2019-06-03 09:31:39.302488-0400 BalletBox[1692:366950] [Tasks] Finished available purchases task: success(MerchantKit.PurchaseSet(storage: ["iap.balletbox.subscription.monthly.all": [Purchase 'iap.balletbox.subscription.monthly.all', price: [Price $4.99]]]))
KeychainPurchaseStore save [PurchaseRecord productIdentifier: iap.balletbox.subscription.monthly.all, expiryDate: nil]
KeychainPurchaseStore save changed records
merchant didChangeStatesFor appdelegate [Product 'iap.balletbox.subscription.monthly.all'] isPurchased(MerchantKit.PurchasedProductInfo(expiryDate: nil))

At this point, the purchase is valid, since the expiryDate is nil
Purchase dialog is dismissed

2019-06-03 09:31:39.484443-0400 BalletBox[1692:366950] [Receipt] Invoked receipt update for purchases [[Product 'iap.balletbox.subscription.monthly.all']]
2019-06-03 09:31:39.484630-0400 BalletBox[1692:366950] [Receipt] Created receipt fetcher for onlyFetch
2019-06-03 09:31:39.488990-0400 BalletBox[1692:366950] [Receipt] Receipt fetch succeeded: found 85749 bytes
2019-06-03 09:31:40.766831-0400 BalletBox[1692:367053] [Receipt] Receipt validation succeeded: [ConstructedReceipt productIdentifiers: ["iap.balletbox.subscription.monthly.all"]]
KeychainPurchaseStore remove iap.balletbox.subscription.monthly.all
KeychainPurchaseStore removed changed records
2019-06-03 09:31:40.772907-0400 BalletBox[1692:367053] [Purchase Storage] Removed record for iap.balletbox.subscription.monthly.all, given expiry date 2019-06-03 07:01:49 +0000 action didChangeRecords
merchant didChangeStatesFor appdelegate [Product 'iap.balletbox.subscription.monthly.all'] notPurchased

Product is again notPurchased, because receipt was was out of date

Shows purchase dialog

2019-06-03 09:31:40.800379-0400 BalletBox[1692:366950] [Tasks] Started available purchases task for products: ["iap.balletbox.subscription.monthly.all"]
2019-06-03 09:31:43.755202-0400 BalletBox[1692:366950] [Tasks] Finished available purchases task: success(MerchantKit.PurchaseSet(storage: ["iap.balletbox.subscription.monthly.all": [Purchase 'iap.balletbox.subscription.monthly.all', price: [Price $4.99]]]))

In your KeyChainPurchase storage, I put at:

    public func save(_ record: PurchaseRecord) -> PurchaseStorageUpdateResult {
    	print("KeychainPurchaseStore save \(record)");


and

    public func removeRecord(forProductIdentifier productIdentifier: String) -> PurchaseStorageUpdateResult {
    	print("KeychainPurchaseStore remove \(productIdentifier)");

Because I wanted to make sure what was happening.
In general, I wish there was much more logging. (Although, obviously not through plain print) (But personally I wouldn't mind a static MerchantLog.print if it is a pain to pass around the logger)

In the sandbox, subscriptions only renew a limited number of times in a 24-hour period. Is this what you are encountering? The expiry date being in the past when the receipt was fetched sure looks like it is the store server refusing to renew it again.

Yes.

The two situations which produce the unwanted behavior are:

  1. User does not cancel a subscription, it is valid. This can cause the purchase dialog to show and then disappear, when the user opens the app the first time after automatic renewal. The time it takes for the dialog to disappear relates to any network lag, apple lag.

  2. User cancels subscription. User does not use app for a month. User uses app. This can cause the purchase dialog to show and disappear and show again.

I understand. I don't know how this is solvable though. We have to wait on Apple to tell us the subscription is renewed. If this takes time, it takes time. The framework can't do anything but report notPurchased until it receives a renewal event that says otherwise.

Here is a possible solution:

  1. Start app
  2. Receipts are validated, and the receipt is newly out of date, state becomes ".outOfDate"
  3. Verification process is started, check for new purchases, grab receipts
  4. After verification verifies receipt is out of date, state becomes ".notPurchased"
  • Or, a new receipt is gotten, and receipt is in fact valid again, receipt becomes .purchased.

.outOfDate would be ignored, no purchase dialog displayed. It is equivalent to my side as "purchased" but to your side it means you have to grab receipts and verify. (It could be also a totally separate variable somewhere, if better for you, but was thinking of one state variable)

perhaps ".verifyPurchaseStateRequired" would better name ?? dunno

Personally, I would go for the most accurate information available to my end, so that if I so chose, I could display relevant dialogs/logging etc. But in this case that I am implementing at the moment, having it reported as purchased would be sufficient.

Actually purchased with a expired expiry-date would be the same information I guess. Although it might be good if you opened up that one method which has the "leeway" variable, so that I don't need to replicate that to ascertain if you are considering a receipt actively purchased or past the fudge zone.

The expiryDate should never be relied on to determine if the subscription is active. It should be used for presentation purposes only.

Anyway, the policy of waiting on the receipt update before removing purchases is definitely tractable. I'll put that on my mental list of todos.

Is there a way for me to force remote receipt fetching after setup? And to get a callback when it is finished?

I was looking at this before and things are very private.... But maybe i missed the method to make this happen.

I can implement this (the receipt update automatically happening when I detect newly notPurchased) without changing your library if I can do this.

One question. StoreKitTransactionObserver.

The receipt updating takes place outside of any of your control. Correct? Meaning, you are not downloading any receipt- it happens and then you find out about it via the StoreKitTransactionObserver?

Ok, after thinking for a while. It turns out you need to do two things:

  1. remove the receipt update from the setup function
    /// Call this method at application launch. It performs necessary initialization routines.
    public func setup() {
        guard !self.hasSetup else { return }
        self.hasSetup = true
        
        self.storeInterface.setup(withDelegate: self)
        
//        self.checkReceipt(updateProducts: .all, policy: .onlyFetch, reason: .initialization)
		
        self.logger.log(message: "Merchant has been setup, with \(self.registeredProducts.count) registered \(self.registeredProducts.count == 1 ? "product" : "products").", category: .initialization)
        
        if self.registeredProducts.isEmpty {
            self.logger.log(message: "There are no registered products for the `Merchant`. Remember to call `Merchant.register(_)` to register a sequence of `Product` items.", category: .initialization)
        }
    }

  1. create a new function
    public func refreshReceiptsLocally ()
    {
        self.checkReceipt(updateProducts: .all, policy: .onlyFetch, reason: .initialization)
    }

which I call manually some time interval after program start.
(I can also choose to call it only if the receipt is purchased but out of date.)

--

The reasoning for this is:

  1. You create a transaction observer, but it is not instantaneous
  2. give apple 30 seconds to send transactions
  3. Update data with regard to receipts after that.

If apple sent a new receipt with renewed, subscription page will not be shown because now valid
If apple did not send a receipt with renewed, subscription page will be shown, AND, on next application start, will be shown immediately (or how I choose).