Skip to content

Commit

Permalink
Fixed auto-sub renew issues when app's not running (didn't affect prod)
Browse files Browse the repository at this point in the history
Also added transaction ids to logging; fixed purchased products fallback list persistence
  • Loading branch information
russell-archer committed Apr 11, 2023
1 parent 78fa423 commit 5ba7ebb
Show file tree
Hide file tree
Showing 5 changed files with 66 additions and 44 deletions.
16 changes: 9 additions & 7 deletions Documentation/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ Running the app's iOS target produces:

![](./assets/StoreHelperDemo11.png)

Note that prices are in US dollars. This is because, by default in test environment, the App Store `Storefront` is **United States (USD)** and the localization is **English (US)**. To support testing other locales you can change this. Make sure the `Products.storekit` file is open, then select **Editor > Default Storefront** and change this to another value. You can also changed the localization from **English (US**) with **Editor > Default Localization**.
Note that prices are in US dollars. This is because, by default in the test environment, the App Store `Storefront` is **United States (USD)** and the localization is **English (US)**. To support testing other locales you can change this. Make sure the `Products.storekit` file is open, then select **Editor > Default Storefront** and change this to another value. You can also changed the localization from **English (US**) with **Editor > Default Localization**.

Here I selected **United Kingdom (GBP)** as the storefront and **English (UK)** as the localization. I also created some UK-specific descriptions of the products. Notice how prices are now in UK Pounds:

Expand Down Expand Up @@ -1211,12 +1211,14 @@ struct ContentView: View {
@State private var productId: ProductId = ""

var body: some View {
Products() { productId, promoId in
// Get the app-server to sign the promotional offer with your App Store Connect key
return getSignature(productId: productId, promotionId: promoId)

} productInfoCompletion: { id in
// User wants more info on a product. Show the info sheet...
ScrollView {
Products() { productId, promoId in
// Get the app-server to sign the promotional offer with your App Store Connect key
return getSignature(productId: productId, promotionId: promoId)

} productInfoCompletion: { id in
// User wants more info on a product. Show the info sheet...
}
}
}

Expand Down
19 changes: 11 additions & 8 deletions Sources/StoreHelper/Core/AppStoreHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
import StoreKit

/// Support for StoreKit1. Tells the observer that a user initiated an in-app purchase direct from the App Store,
/// rather than via the app itself. StoreKit2 does not (yet) provide support for this feature so we need to use
/// StoreKit1. This is a requirement in order to promote in-app purchases on the App Store. If your app doesn't
/// have a class that implements `SKPaymentTransactionObserver` and the `paymentQueue(_:updatedTransactions:)`
/// and `paymentQueue(_:shouldAddStorePayment:for:)` delegate methods then you'll get an error when you submit
/// the app to the App Store and you have IAP promotions.
/// rather than via the app itself. Also picks up subscriptions auto-renewals. StoreKit2 does not (yet) provide
/// support for this feature so we need to use StoreKit1. This is a requirement in order to promote in-app purchases
/// on the App Store. If your app doesn't have a class that implements `SKPaymentTransactionObserver` and the
/// `paymentQueue(_:updatedTransactions:)` and `paymentQueue(_:shouldAddStorePayment:for:)` delegate methods then
/// you'll get an error when you submit the app to the App Store and you have IAP promotions.
///
/// Note that any IAPs made from **inside** the app are processed by StoreKit2 and do not involve this helper class.
@available(iOS 15.0, macOS 12.0, *)
Expand All @@ -35,8 +35,8 @@ public class AppStoreHelper: NSObject, SKPaymentTransactionObserver {

/// Delegate method for the StoreKit1 payment queue. Note that because our main StoreKit processing is done
/// via StoreKit2 in StoreHelper, all we have to do here is signal to StoreKit1 to finish purchased, restored
/// or failed transactions. StoreKit1 purchases are immediately available to StoreKit2 (and vice versa), so
/// any purchase will be picked up by StoreHelper as required.
/// or failed transactions. StoreKit1 purchases are (in theory) immediately available to StoreKit2 (and vice
/// versa), so any purchase will be picked up by StoreHelper as required.
/// - Parameters:
/// - queue: StoreKit1 payment queue
/// - transactions: Collection of updated transactions (e.g. `purchased`)
Expand All @@ -45,7 +45,10 @@ public class AppStoreHelper: NSObject, SKPaymentTransactionObserver {
switch (transaction.transactionState) {
case .purchased:
SKPaymentQueue.default().finishTransaction(transaction)
Task.init { await storeHelper?.productPurchased(transaction.payment.productIdentifier) } // Tell StoreKit2-based StoreHelper about purchase

// Let the StoreKit2-based StoreHelper know about this purchase or subscription renewal
Task { await storeHelper?.productPurchased(transaction.payment.productIdentifier, transactionId: transaction.transactionIdentifier ?? "0") }

case .restored: fallthrough
case .failed: SKPaymentQueue.default().finishTransaction(transaction)
default: break
Expand Down
6 changes: 3 additions & 3 deletions Sources/StoreHelper/Core/KeychainHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public struct KeychainHelper {
// Create a query of what we want to search for. Note we don't restrict the search (kSecMatchLimitAll)
let query = [kSecClass as String : kSecClassGenericPassword,
kSecAttrAccount as String : productId,
kSecMatchLimit as String: kSecMatchLimitOne] as CFDictionary
kSecMatchLimit as String: kSecMatchLimitOne] as [String : Any] as CFDictionary

// Search for the item in the keychain
var item: CFTypeRef?
Expand All @@ -77,7 +77,7 @@ public struct KeychainHelper {
kSecAttrAccount as String : productId,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true] as CFDictionary
kSecReturnData as String: true] as [String : Any] as CFDictionary

var item: CFTypeRef?
let status = SecItemCopyMatching(query, &item)
Expand Down Expand Up @@ -131,7 +131,7 @@ public struct KeychainHelper {
let query = [kSecClass as String : kSecClassGenericPassword,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnData as String: true] as CFDictionary
kSecReturnData as String: true] as [String : Any] as CFDictionary

// Search for all the items created by this app in the keychain
var item: CFTypeRef?
Expand Down
43 changes: 28 additions & 15 deletions Sources/StoreHelper/Core/StoreHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,14 +278,14 @@ public class StoreHelper: ObservableObject {
updatePurchasedProducts(for: productId, purchased: purchased, updateFallbackList: false, updateTransactionCheck: false)
return purchased
}

// Make sure we're listening for transactions, the App Store is available, we have a list of localized products
// and that we can create a `Product` from the `ProductId`. If not, we have to rely on the cache of purchased products
guard hasStarted, isAppStoreAvailable, hasProducts, let product = product(from: productId) else {
StoreLog.event(.appStoreNotAvailable)
purchased = purchasedProductsFallback.contains(productId)
StoreLog.event(purchased ? .productIsPurchasedFromCache : .productIsNotPurchased, productId: productId)
return purchasedProductsFallback.contains(productId)
return purchased
}

// Is this a consumable product? We need to treat consumables differently because their transactions are NOT stored in the receipt
Expand All @@ -301,14 +301,24 @@ public class StoreHelper: ObservableObject {
// There's no transaction for the product, so it hasn't been purchased. However, the App Store does sometimes return nil,
// even if the user is entitled to access the product. For this reason we don't update the fallback cache and transaction
// check list for a negative response

// If this is a subscription product, before giving up see if it was renewed while the app was offline and the
// transaction hasn't yet been sent to us
if isSubscription(productId: productId) {
if purchasedProductsFallback.contains(productId) {
StoreLog.event(.productIsPurchasedFromCache, productId: productId)
return true
}
}

StoreLog.event(.productIsNotPurchasedNoEntitlement, productId: productId)
return false
}

// See if the transaction passed StoreKit's automatic verification
let result = checkVerificationResult(result: currentEntitlement)
if !result.verified {
StoreLog.transaction(.transactionValidationFailure, productId: result.transaction.productID)
StoreLog.transaction(.transactionValidationFailure, productId: result.transaction.productID, transactionId: String(result.transaction.id))
throw StoreException.transactionVerificationFailed
}

Expand All @@ -320,7 +330,7 @@ public class StoreHelper: ObservableObject {
default: throw StoreException.productTypeNotSupported
}

StoreLog.event(purchased ? .productIsPurchasedFromTransaction : .productIsNotPurchased, productId: productId)
StoreLog.event(purchased ? .productIsPurchasedFromTransaction : .productIsNotPurchased, productId: productId, transactionId: String(result.transaction.id))
updatePurchasedProducts(for: productId, purchased: purchased)
return purchased
}
Expand Down Expand Up @@ -453,7 +463,7 @@ public class StoreHelper: ObservableObject {
let checkResult = checkVerificationResult(result: verificationResult)
if !checkResult.verified {
purchaseState = .failedVerification
StoreLog.transaction(.transactionValidationFailure, productId: checkResult.transaction.productID)
StoreLog.transaction(.transactionValidationFailure, productId: checkResult.transaction.productID, transactionId: String(checkResult.transaction.id))
throw StoreException.transactionVerificationFailed
}

Expand All @@ -471,7 +481,7 @@ public class StoreHelper: ObservableObject {

// Let the caller know the purchase succeeded and that the user should be given access to the product
purchaseState = .purchased
StoreLog.event(.purchaseSuccess, productId: product.id)
StoreLog.event(.purchaseSuccess, productId: product.id, transactionId: String(validatedTransaction.id))

return (transaction: validatedTransaction, purchaseState: .purchased)

Expand All @@ -495,10 +505,10 @@ public class StoreHelper: ObservableObject {
/// Should be called only when a purchase is handled by the StoreKit1-based AppHelper.
/// This will be as a result of a user dirctly purchasing in IAP in the App Store ("IAP Promotion"), rather than in our app.
/// - Parameter product: The ProductId of the purchased product.
@MainActor public func productPurchased(_ productId: ProductId) {
@MainActor public func productPurchased(_ productId: ProductId, transactionId: String) {
updatePurchasedProducts(for: productId, purchased: true)
purchaseState = .purchased
StoreLog.event(.purchaseSuccess, productId: productId)
StoreLog.event(.purchaseSuccess, productId: productId, transactionId: transactionId)
}

/// The `Product` associated with a `ProductId`.
Expand Down Expand Up @@ -671,6 +681,9 @@ public class StoreHelper: ObservableObject {
// because the they don't purchase anything and are not considered to be part of the app that did the purchasing as far as
// StoreKit is concerned.
AppGroupSupport.syncPurchase(productId: productId, purchased: purchased)

// Persist the fallback list of purchased products
savePurchasedProductsFallbackList()
}

/// Updates and persists our fallback cache of purchased products (`purchasedProductsFallback`). Also makes sure our set of purchase
Expand Down Expand Up @@ -706,38 +719,38 @@ public class StoreHelper: ObservableObject {
let checkResult = await self.checkVerificationResult(result: verificationResult)
guard checkResult.verified else {
// StoreKit's attempts to validate the transaction failed
StoreLog.transaction(.transactionFailure, productId: checkResult.transaction.productID)
StoreLog.transaction(.transactionFailure, productId: checkResult.transaction.productID, transactionId: String(checkResult.transaction.id))
return
}

// The transaction was validated by StoreKit
let transaction = checkResult.transaction
StoreLog.transaction(.transactionReceived, productId: transaction.productID)
StoreLog.transaction(.transactionReceived, productId: transaction.productID, transactionId: String(transaction.id))

if transaction.revocationDate != nil {
// The user's access to the product has been revoked by the App Store (e.g. a refund, etc.)
// See transaction.revocationReason for more details if required
StoreLog.transaction(.transactionRevoked, productId: transaction.productID)
StoreLog.transaction(.transactionRevoked, productId: transaction.productID, transactionId: String(transaction.id))
await self.updatePurchasedProducts(for: transaction.productID, purchased: false)
return
}

if let expirationDate = transaction.expirationDate, expirationDate < Date() {
// The user's subscription has expired
StoreLog.transaction(.transactionExpired, productId: transaction.productID)
StoreLog.transaction(.transactionExpired, productId: transaction.productID, transactionId: String(transaction.id))
await self.updatePurchasedProducts(for: transaction.productID, purchased: false)
return
}

if transaction.isUpgraded {
// Transaction superceeded by an active, higher-value subscription
StoreLog.transaction(.transactionUpgraded, productId: transaction.productID)
await self.updatePurchasedProducts(for: transaction.productID, purchased: false)
StoreLog.transaction(.transactionUpgraded, productId: transaction.productID, transactionId: String(transaction.id))
await self.updatePurchasedProducts(for: transaction.productID, purchased: true)
return
}

// Update the list of products the user has access to
StoreLog.transaction(.transactionSuccess, productId: transaction.productID)
StoreLog.transaction(.transactionSuccess, productId: transaction.productID, transactionId: String(transaction.id))
await self.updatePurchasedProducts(transaction: transaction, purchased: true)
await transaction.finish()
}
Expand Down
Loading

0 comments on commit 5ba7ebb

Please sign in to comment.