We're working on the app with auto-renewal subscription. We have just one option - monthly auto-renewal subscription with one week free trial. We also use "Subscription Status URL" to receive subscription notifications.
The app itself is similar to "TO DO LIST" applications, that can share tasks between multiple users. Thus, we keep data on the server.
Each time user loads the app or creates a task, data comes from the server with current_subscription_status parameter, e.g. we do subscription status validation on the server by simply checking receipt expiration date against current date on the server.
Currently we have only iOS version, but working on Android version as well. And user should be able to sing in to his/her account on different devices with different apple id.
The problem, we've met, is we don't receive actual purchase (subscription) notifications. E.g. when user taps "Start your free 1-week trial" button and subscribes we receive a notification (type INITIAL_BUY). After this one week trial period, we supposed to get another notification with type of something like "RENEWAL", but we receive nothing.
We contacted Apple developer support, but didn't get a real help. They just send links to Apple documentation. Where we found the following note (here is the link):
"To obtain up-to-date information as you process events, your app should verify the latest receipt with the App Store."
So, based on this, I have a question about a possible use case scenario:
User subscribes on iOS device for monthly auto-renewal subscription, e.g. he/she would like to be charged of his/her apple iTunes account. But, after initial purchase he/she closes (kills) the app and doesn't even open it on iOS device anymore. User downloads the app to Android device and use the app only on Android. So, App Store supposed to charge this user each month and send subscription status notifications to the server even if user never opened the app on his iOS device again. So, "...latest receipt verification..." never happens with the iOS app. How can this be implemented?
Tech details:
We use SwiftyStoreKit. These are two parts of in-app purchase implementation:
In AppDelegate we have:
// Auto-renewal complete transactions
SwiftyStoreKit.completeTransactions(atomically: true) { purchases in
for purchase in purchases {
if purchase.transaction.transactionState == .purchased || purchase.transaction.transactionState == .restored {
if purchase.needsFinishTransaction {
// Deliver content from server, then:
SwiftyStoreKit.finishTransaction(purchase.transaction)
}
}
}
}
Here is subscribe function called when user taps "Start free trial button":
func subscribe(completion: #escaping (_ response:Bool, _ message: String?) -> Void) {
SwiftyStoreKit.purchaseProduct(self.productId, atomically: true) { result in
if case .success(let purchase) = result {
if purchase.needsFinishTransaction {
SwiftyStoreKit.finishTransaction(purchase.transaction)
}
let appleValidator = AppleReceiptValidator(service: self.env, sharedSecret: self.sharedSecret)
SwiftyStoreKit.verifyReceipt(using: appleValidator) { result in
if case .success(let receipt) = result {
let purchaseResult = SwiftyStoreKit.verifySubscription(
type: .autoRenewable,
productId: self.productId,
inReceipt: receipt)
switch purchaseResult {
case .purchased(let expiryDate, let receiptItems):
if let receiptItem = receiptItems.first {
// Send receipt to the server functionality
.............................
}
completion(true, nil)
case .expired(let expiryDate, let receiptItems):
completion(false, "Receipt has been expired")
case .notPurchased:
completion(false, "Purchase has not been processed")
}
} else {
// receipt verification error
completion(false, "ERROR OCCURED")
}
}
} else {
// purchase error
completion(false, "Canceled")
}
}}
Related
I'm listening for transaction updates during the whole lifetime of the app as described in the Transaction.updates:
func startObservingTransactions() -> Task<Void, Error> {
return Task.detached {
for await result in Transaction.updates {
print("Transaction update")
if case .verified(let transaction) = result {
await transaction.finish()
...
}
}
}
}
The Transaction.updates never emits transactions when a subscription renews. Also, I don't receive unfinished transactions on the app launch. Refunding a transaction, however, produces an update as expected.
I experience the same behavior on Simulator and real devices using Xcode, Sandbox, and TestFlight environments.
Is it even supposed to notify about the subscription renewals or should I just observe the transaction queue as usual?
I'm using Xcode 13.2.1, iOS 15.2.
I have been using SwiftyStoreKit.verifyReceipt to make sure that the user is still subscribed to the auto renewable membership. I run this code at viewWillAppear, it works well, but the problem is that it keep asking for the Apple ID and password each time, is it because the app is still under development / the in app purchase have not been verified by Apple or I am using SwiftyStoreKit.verifyReceipt incorrectly.
Documentation: https://github.com/bizz84/SwiftyStoreKit
My Code in viewWillAppear:
let appleValidator = AppleReceiptValidator(service: .production, sharedSecret: "123")
SwiftyStoreKit.verifyReceipt(using: appleValidator) { result in
switch result {
case .success(let receipt):
let productId = "123"
// Verify the purchase of a Subscription
let purchaseResult = SwiftyStoreKit.verifySubscription(
ofType: .autoRenewable, // or .nonRenewing (see below)
productId: productId,
inReceipt: receipt)
switch purchaseResult {
case .purchased(let expiryDate, let items):
print("\(productId) is valid until \(expiryDate)\n\(items)\n")
OneSignal.sendTag("isUserVIPMember", value: "true")
case .expired(let expiryDate, let items):
print("\(productId) is expired since \(expiryDate)\n\(items)\n")
OneSignal.sendTag("isUserVIPMember", value: "false")
case .notPurchased:
print("The user has never purchased \(productId)")
OneSignal.sendTag("isUserVIPMember", value: "false")
}
case .error(let error):
print("Receipt verification failed: \(error)")
}
}
My assumption would be that verifyReceipt doesn't use the local receipt, but requests it from the App Store. Requesting the receipt require the consent of the user and therefore would explain why he has to enter his credentials.
The code on Github states that the default value of forceRefresh is false, but could you add forceRefresh: false to the verifyReceipt call.
Also could you please check if receiptString contains any data?
let receiptData = SwiftyStoreKit.localReceiptData
let receiptString = receiptData.base64EncodedString(options: [])
And if so, please try to do SwiftyStoreKit.verifySubscription() with the local receipt. Do you still have to enter your credentials?
I have no idea what kind of app you are building, but I would recommend not to verify the receipt in viewWillAppear. The local receipt won't change anyway (except for renewals). So do it once on app start or if it is really crucial that the user loses access, to what ever he gets with your subscription, I would recommend to use a custom server. The server will be notified if something changes in the subscription status of a user.
Side note: At the moment I would advise against the usage of SwiftyStoreKit, because they verify the receipt against the server endpoint from Apple and this is only allowed from another server. Chances are that Apple will reject your app.
Warning
Do not call the App Store server verifyReceipt endpoint from your app. You can't build a trusted connection between a user’s device and the App Store directly, because you don’t control either end of that connection, which makes it susceptible to a man-in-the-middle attack.
Source
I am making a music app with swift. The app lets users play music through their Apple Music subscription via their Apple Music app. I am able to check whether the user has an Apple Music subscription via:
SKCloudServiceController().requestCapabilities { (capability:SKCloudServiceCapability, err:Error?) in
guard err == nil else {
print("error in capability check is \(err!)")
return
}
if capability.contains(SKCloudServiceCapability.musicCatalogPlayback) {
print("user has Apple Music subscription")
}
if capability.contains(SKCloudServiceCapability.musicCatalogSubscriptionEligible) {
print("user does not have subscription")
}
}
However: there are scenarios where somebody will, for some reason, have an Apple Music subscription but not have the Apple Music app downloaded on their device. If the user has the subscription but not the device, I want to essentially treat that case as if they do not have a subscription at all, i.e. we cannot play music via Apple Music.
So, I go searching for ways to add a check for if Apple Music is on the user's device. I find this answer: Check whether an app is installed using Swift combined with this resource for finding Apple Music's url scheme and conclude I can check if a user has both an Apple Music subscription and the Apple Music app installed on their device via:
SKCloudServiceController()requestCapabilities { (capability:SKCloudServiceCapability, err:Error?) in
guard err == nil else {
print("error in capability check is \(err!)")
return
}
if capability.contains(SKCloudServiceCapability.musicCatalogPlayback) && UIApplication.shared.canOpenURL(URL(string: "music://")!) {
print("user has Apple Music subscription and has the apple music app installed")
}
if capability.contains(SKCloudServiceCapability.musicCatalogSubscriptionEligible) || !UIApplication.shared.canOpenURL(URL(string: "music://")!) {
print("user does not have subscription or doesn't have apple music installed")
}
}
The issue is, even after deleting Apple Music from my device, the first case, i.e. the one that prints user has Apple Music subscription and has the apple music app installed is still being called. I believe I have the correct url scheme because when changing "music://" to "musi://", the second case, i.e. the one that prints user does not have subscription or doesn't have apple music installed is being called.
When trying to open URL(string: "music://") with Apple Music deleted via UIApplication.shared.open(URL(string: "music://")!), I am hit with the following alert:
So why is the device saying that I can open URL(string: "music://") even after Apple Music is deleted? Is the URL capable of being opened, but the result is simply the presentation of the above alert? Is this the correct way to confirm that the user has Apple Music installed on their device? Is there even a way to confirm the user has Apple Music installed on their device? If Apple gives users the option to delete the Apple Music app, they should also give developers the ability to check if the app is installed.
The best solution I've got, though I expect there is something better out there, is to use MPMusicPlayer.prepareToPlay(completionHandler:) to check if there is an error when trying to play a track:
SKCloudServiceController().requestCapabilities { (capability:SKCloudServiceCapability, err:Error?) in
guard err == nil else {
print("error in capability check is \(err!)")
return
}
if capability.contains(SKCloudServiceCapability.musicCatalogPlayback) {
print("user has Apple Music subscription")
MPMusicPlayerController.systemMusicPlayer.setQueue(with: ["1108845248"])
systemMusicPlayer.prepareToPlay { (error) in
if error != nil && error!.localizedDescription == "The operation couldn’t be completed. (MPCPlayerRequestErrorDomain error 1.)" {
//It would appear that the user does not have the Apple Music App installed
}
}
}
if capability.contains(SKCloudServiceCapability.musicCatalogSubscriptionEligible) {
print("user does not have subscription")
}
}
I am not sure how this could apply to anybody using Apple Music within their app for anything other than playing tracks, but this seems to definitely work as a check when you are about to play a check. Whenever I am hit with that error, I simply create an alert telling the individual they have an Apple Music subscription but doesn't have the app installed.
Still, it would be great to be able to check without some completion handler as that would allow the boolean check to be integrated into conditional statements (via if capability.contains(SKCloudServiceCapability.musicCatalogPlayback) && hasAppleMusicAppInstalled { //do something }).
Luckily Apple provides you a method which returns false if no app installed on the device is registered to handle the URL’s scheme, or if you have not declared the URL’s scheme in your Info.plist file; otherwise, true.
func canOpenURL(_ url: URL) -> Bool
Following i'm posting the url schemes
Open = music://
Open = musics://
Open = audio-player-event://
Add the ones you will further use into your info.plist file.
After this use 'canOpenURL' to check
for more information check Apple docs
https://developer.apple.com/documentation/uikit/uiapplication/1622952-canopenurl
For the lucky souls that need music app installed to display MPMediaPickerController, the easiest way to check is to see if view was presented. If Music app is missing, it will fail silently.
let mediaPickerController = MPMediaPickerController(mediaTypes: MPMediaType.anyAudio)
mediaPickerController.delegate = self
mediaPickerController.prompt = "prompt"
presenter.present(mediaPickerController, animated: true, completion: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] () -> () in
if self?.presenter.presentedViewController == nil {
self?.callback(.failure(.failedToPresentMusicPickerError))
}
}
A possible solution is doing the following: Setup a developer token through the Apple Music API (Used so you can query Apple Music REST endpoints). Submit a request to the following StoreKit function (Documentation):
requestUserToken(forDeveloperToken:completionHandler:)
If your developer token is valid and the user token value returned is still nil/null then the device user is not a subscriber of the Apple Music service. An error that is generated with the HTTP status code is 401 (Unauthorized). This still requires you checking an error however does not require to try and play a specific track (Especially for some reason if the content track id your checking against becomes invalid or changed).
For the issue of account signed into the device and has a subscription but not the Music app downloaded: Handle the error upon attempting to play specific content and either provide information to the user or use content that does not require an Apple Music subscription as an alternative when an error occurs.
Yes, We can check most of the Applications by following these Steps:
Use the Deep URL or URL Scheme for the particular application you want to open, add that into info.plist
Use the same URL and Call this method
func canOpenURL(_ url: URL) -> Bool
let url = URL(string: "music://")
UIApplication.shared.open(url!) { (result) in
if result {
// The URL was delivered successfully!
}
}
You can use this property to detect if Apple Music is installed.
import MediaPlayer
var isAppleMusicInstalled: Bool {
get async {
await withCheckedContinuation { continuation in
MPMusicPlayerController.systemMusicPlayer.setQueue(with: ["1108845248"])
self.systemMusicPlayer.prepareToPlay { (error) in
if error != nil && error!.localizedDescription.contains("error 6") {
print("Apple Music App not installed")
continuation.resume(returning: false)
} else {
continuation.resume(returning: true)
}
}
}
}
}
Note that it triggers a permission dialog and that you need to add the NSAppleMusicUsageDescription key in your Info.plist with a value like "to check if Apple Music is installed".
Simply do: UIApplication.shared.canOpenUrl(URL(string: “music://”)!)
SKPaymentQueue's restoreCompletedTransactions() does not update the appStoreReceiptURL receipt after completion.
I am working on a Mac app that will be supported via auto-renewing subscriptions. After restoring purchases finishes, I need the updated receipt because I will need to check the current subscription status (expiration date, etc.).
The only solution I could come up with was to refresh the receipt right after restore completed transactions finished, as shown below. This does appear to work. However, the user will be prompted to enter in their username and password twice, which is a less than a perfect experience. In addition, I am concerned that such behavior will lead to a rejection by app review.
public func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
if !fileExistsAtAppStoreReceiptURL() {
print("No receipt.")
}
let refreshRequest = SKReceiptRefreshRequest(receiptProperties: nil)
refreshRequest.delegate = self
refreshRequest.start()
}
private func fileExistsAtAppStoreReceiptURL() -> Bool {
guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL else { return false }
return FileManager().fileExists(atPath: appStoreReceiptURL.path)
}
public func requestDidFinish(_ request: SKRequest) {
if request is SKReceiptRefreshRequest {
updateCachedReceiptFromAppStoreReceiptURL()
reverifyKnownPurchases()
notify { self.delegate?.inAppPurchaseController(self, restoreCompletedTransactionsDidFinishWith: .success) }
} else {
notify { self.delegate?.inAppPurchaseController(self, didFinishRequestingIndividualProductInformationWith: .success) }
}
}
Some other notes:
No receipt. does show up in the log.
Because this behavior occurs when using the sandbox environment, I don't know if the behavior is the same when the code executes in production.
The behavior did not change after an attempt with a newly-created test user.
Because I wanted to simulate a real-life situation, I restored purchases on a second Mac, and not the Mac that purchased the subscription.
The rest of the restore process appears to work correctly. paymentQueue:updatedTransactions: is called for each restored transaction with a transactionState of .restored.
SKPaymentQueue.default().finishTransaction(transaction) is called for each restored transaction.
SKPaymentQueue.default().add(self) is called in applicationDidFinishLaunching:.
Although I am making use of an auto-renewing subscription, it does not play a role. A receipt does not appear even if I make use of a non-consumable, for example.
I am not using a server.
The Restoring Purchased Products and the restoreCompletedTransactions() documentation neither explicitly confirm nor deny that the receipt is updated as a part of the restore process. Could this be the expected behavior?
Because I had a similar issue with an iOS test app earlier in the year, this may not be a Mac-only situation.
With all of that in mind, does anyone know how to properly handle such a situation? Has anyone had success with restore + refresh?
There is one thing I don't quite understand when it comes to In-App Subscription purchase.
I obtain the receipt on iOS client like this:
private func loadReceipt() -> Data? {
guard let url = Bundle.main.appStoreReceiptURL else {
return nil
}
do {
let receipt = try Data(contentsOf: url)
return receipt
} catch {
print("Error loading receipt data: \(error.localizedDescription)")
return nil
}
}
And send it for verification to my server (written n Python).
def verify_receipt(self, receipt):
r = requests.post(config.APPLE_STORE_URL, json=receipt)
request_date_ms = DateUtils.generate_ms_from_current_time()
for item in r.json()['latest_receipt_info']:
expires_date_ms = int(item['expires_date_ms'])
if expires_date_ms > request_date_ms:
return True
return False
I'm not sure if this is the correct way of verifying if a subscription is still valid.
I get the expires_date_ms from latest_receipt_info, and if it's greater than the current time in milliseconds, then the subscription counts as still valid.
However what I noticed is that the separate latest_receipt, which is supposed to be equal to the one I have just sent in, is actually changing every time I call the API. But why? I haven't subscribed to anything new, why is the latest receipt changing?
According to the docs:
latest_receipt
Only returned for receipts containing auto-renewable subscriptions.
For iOS 6 style transaction receipts, this is the base-64 encoded
receipt for the most recent renewal. For iOS 7 style app receipts,
this is the latest base-64 encoded app receipt.
If this is against the sandbox then the subscription is auto-renewing on a predefined period. See:
https://help.apple.com/app-store-connect/#/dev7e89e149d