iosin-app-purchasesubscriptionstorekitauto-renewing

How to use iOS subscriptions with existing subscription web service?


While there is a lot of information out there about how to implement iOS subscriptions in general I found no information on how to use them with an existing subscription web service.

Lets assume we are running a newspaper website where users can create an account to access payed content:

Both OneTime payments and subscriptions are sold and managed on the server.

The App:

Of course it would be no problem to let users access the paid content from within our iOS app while still managing purchases and subscriptions on the website only. However we all know that Apple is almost bankrupt and thus desperately needs all the money they can get from developers. Because of this the simple to solution to advertise the subscription from within the app and selling it from the website is strictly prohibited. We have to have remove all links to website purchases from the app and use In-App purchases instead.

How can we do this?

Problem 1 - Has the user an account?

Lets assume that the iOS app offers some basic features for free which does not require any connection to the web service. Offering In-App purchases to buy subscriptions for the web service only makes sens when the web service is used and the user has an account.

Is it allowed to check if the user has a web service account and send them to the webpage to create one? It is allowed to hide/deactivate the In-App purchase option until the user logs in to the web service?

Problem 2 - Is there already an active subscription?

What if the user has connected the iOS app to the web service and the user account has already an active subscription which was purchased from the website?

It would not make sense to offer an In-App purchase subscription to the user because he would pay twice for the same service. Is it OK, to deactive the In-App purchase in this case?

Problem 3 - Is there already an active OneTime package?

What if the user has connected the iOS app to the web service and the user account has already an active OneTime package purchased from the website?

As before it would not make a lot of sens to offer an In-App purchase subscription to the user. Of course the web service could add the subscription period to the end of the OneTime package but the iOS subscription would start immediately. Thus it could happen that there is a significant offset between the period of the iOS subscription and the web service subscription.

The only way to avoid this would be to offer the iOS subscription only, when there is no active web app subscription or OneTime package.

Is this allowed?

...

The bottom line is, that there a lot of potential problems and conflicts between iOS subscriptions and existing web service subscriptions. Is there any information on how to solve and address these?


Solution

  • I've dealt with the same problem you're describing. I had some apps where we sold subscriptions directly from a website and also offered subscriptions via in app purchase.

    We handled this by selling subscriptions via the website to web visitors and in app purchase subscriptions via the app. You can support both subscriptions, but you cannot direct your app users to your website to subscribe.

    First I'll address your enumerated problems based off how we handled it, then tell you what we did.

    Problem 1:

    Problem 2:

    Problem 3:

    From the Apple Subscription page (near the bottom of the page - see link at the bottom of this answer)

    Subscriptions Purchased Outside of an App Subscribers who were acquired outside of your app can read or play content through the app. However, you may not provide external links in your app that allow users to purchase subscriptions outside of the app.

    The main things you need to handle in the app are:

    If you decide to offer in app subscriptions you will need to validate the receipt with Apple's servers (server side for best security) to verify the subscription is active and provide content.

    Following is a php script I used for server side validation of receipts. You may find it useful or be able to adapt it for your use case.

    <?php
        /*  
            This is an overview of fields found in validated receipts
            validated response fields include
            - status                        - 0 if receipt is valid, otherwise error code
            - receipt (In app purchase receipt fields)
                - quantity                  - (the qty of items purchased)
                - product_id                - (the product id of the purchased item)
                - transaction_id            - (the transaction id for the purchased item)
                - original_transaction_id   - (the original transactions transaction id. All renewal receipts for auto renew subscriptions have the same value for this field)
                - purchase_date             - (the most recent purchase/restore date, for auto-renewing subs it's always the date the subscription was purchased or renewed, regardless of restoration)
                - original_purchase_date    - (the original transactions transactionDate property. For auto-renewing subscriptions its the beginning of the subscription period)
                - expires_date              - (only present for auto renew purchases, subscription expiration date)
                - cancellation_date         - (transaction cancelled by Apple support - treat as if no purchase made)
                - app_item_id               - (uniquely identifies the app that created the transaction, use to differentiate which app gets access)
                - version_external_identifier - (uniquely identifies a revision of the application)
                - web_order_line_item_id    - (primary key for identifying subscription purchases)
        // see receipt validation programming guide pg 22 at the bottom for this
            - latest_receipt
                if receipt being validated is for latest renewal, this value is the same as receipt-data (in the request)
            - latest_receipt_info
                value is the same as receipt (below, received in validation response) if receipt being validated is for the latest renewal
    
                "latest_receipt_info":[
                                    {
                                        "quantity":"1", 
                                        "product_id":"myProductId", 
                                        "transaction_id":"transaction_id_goes_here", 
                                        "original_transaction_id":"original_id", 
                                        "purchase_date":"2015-06-19 13:08:37 Etc/GMT", 
                                        "purchase_date_ms":"1434719317000", 
                                        "purchase_date_pst":"2015-06-19 06:08:37 America/Los_Angeles", 
                                        "original_purchase_date":"2015-06-19 13:08:38 Etc/GMT", 
                                        "original_purchase_date_ms":"1434719318000", 
                                        "original_purchase_date_pst":"2015-06-19 06:08:38 America/Los_Angeles", 
                                        "expires_date":"2015-06-19 13:11:37 Etc/GMT", 
                                        "expires_date_ms":"1434719497000", 
                                        "expires_date_pst":"2015-06-19 06:11:37 America/Los_Angeles", 
                                        "web_order_line_item_id":"line_item_id_here", 
                                        "is_trial_period":"true"
                                    },
                                ]
            - receipt (App Receipt Fields)
                - bundle_id                 - the apps bundle id
                - application_version       - the apps version number
                - in_app                    - array of in-app purchase receipts (see receipt validation programming guide p. 24 for more info)
                - original_application_version - version of app that was originally purchased (in sandbox always 1.0)
                - expiration_date           - only for apps in volume purchase program, otherwise receipt does not expire
    */      
    class ReceiptValidation
    {
        public $receipt;
        public $response_json;
        public $response_array;
        private $password;
        private $request_data;
        private $request_json;
        private $live_url;
        private $sand_url;
        public $user;
        public $db;
        private $debugString;
        private $latestReceipt;
        public $error;
        function __construct($receipt, $user, $db)
        {
            $this->receipt      = $receipt;
            $this->db           = $db;
            $this->user         = $user;
            // set apples validation urls
            $this->live_url     = 'https://buy.itunes.apple.com/verifyReceipt';
            $this->sand_url     = 'https://sandbox.itunes.apple.com/verifyReceipt';
        }
        public function setupReceiptRequest()
        {
            // setup in itc as shared secret (this value should be outside the document root)
            $password   = '';
            $this->request_json = '{"receipt-data":"'.$this->receipt.'", "password":"'.$password.'"}';
        }
        /*!
            Sends the receipt to Apple to verify that it's valid. 
            (Called when user first subscribes and inserts data into db)
        */
        function validateIosReceipt($dbProductId)
        {
            $this->setupReceiptRequest();
            $this->validateReceiptOnLive();
            $this->verifyResponseStatus();
            // get the array of latest receipts
            $receipts   = $this->response_array['latest_receipt_info'];
            // get the most recent one
            $this->latestReceipt = end(array_values($receipts));
            $productId          = $this->latestReceipt['product_id'];
            $purchaseDate       = $this->latestReceipt['purchase_date'];
            $purchaseDateMs     = $this->latestReceipt['purchase_date_ms'];
            $expiresDate        = $this->latestReceipt['expires_date'];
            $expiresDateMs      = $this->latestReceipt['expires_date_ms'];
            $isTrialPeriod      = $this->latestReceipt['is_trial_period'];
            $transactionId      = $this->latestReceipt['transaction_id'];
    
            // get the receipt details we're interested in storing
            $tableData = array(
                            'user_id'           => $this->user->uid,
                            'is_active'         => 1,
                            'product'           => $dbProductId,
                            'product_id'        => $productId,
                            'receipt'           => $this->receipt,
                            'purchase_date'     => $purchaseDate,
                            'purchase_date_ms'  => $purchaseDateMs,
                            'transaction_id'    => $transactionId,
                            'expires_date'      => $expiresDate,
                            'expires_date_ms'   => $expiresDateMs,
                            'is_trial_period'   => $isTrialPeriod,
                            );
    
            // save receipt details to db table (this does initial insert to database for purchase)
            $saveStatus = $this->db->saveSubscription($tableData);
    
            // return the status of our save
            return $saveStatus;
        }
    
        // returns 0 (no change to report), 20 (user has admin provided bonus acct), or 30 (subscription expired)
        function validateSubscriptionStatus()
        {
            // check if they have a bonus status from being granted a free member account
            $acctTypeFetch = $this->db->fetchCurrentUserAccountTypeForUser($this->user->uid);
    
            // only run this if the fetch was successful
            if (!empty($acctTypeFetch) && $acctTypeFetch != false)
            {
                // get our result row
                $row = $acctTypeFetch[0];
                // check for validity
                if (isset($row))
                {
                    // get the account type for this user
                    $currentAcctType = $row['acct_type'];
                    // '20' is the account type flag for a user that has our promo account
                    if ($currentAcctType == 20)
                    {
                        // this user has a free acct provided by us, no sub needed, return 20 instead of 0 because if we mark an account as promo
                        // we want the users account to be updated on their device when they close and reopen the app without having to re-login.
                        return 20;
                    }
                    // this user is currently a subscriber, so get their receipt and make sure they're still subscribed
                    else if ($currentAcctType > 5 && $currentAcctType <= 15)
                    {
                        // they don't have a bonus acct & they were at one point subscribed so pull purchase data from db for user
                        $subscriptionData = $this->db->retrieveSubscriptionDataForUserWithID($this->user->uid);
    
                        // the user actually has purchased a subscription in the past so check if they are still subscribed
                        if (!empty($subscriptionData) && $subscriptionData != false)
                        {
                            // get our row of data
                            $subInfo = $subscriptionData[0];
                            // set $this->receipt with fetched receipt
                            $this->receipt = $subInfo['receipt'];
                            // setup our request data to verify with Apple
                            $this->setupReceiptRequest();
                            // validate receipt and check expires date
                            $this->validateReceiptOnLive();
                            $this->verifyResponseStatus();
                            # get the array of latest receipts
                            $receipts   = $this->response_array['latest_receipt_info'];
                            if (!empty($receipts) && $receipts != NULL)
                            {
                                # get the most recent one
                                $this->latestReceipt = end(array_values($receipts));
                                $productId          = $this->latestReceipt['product_id'];
                                $purchaseDate       = $this->latestReceipt['purchase_date'];
                                $purchaseDateMs     = $this->latestReceipt['purchase_date_ms'];
                                $expiresDate        = $this->latestReceipt['expires_date'];
                                $expiresDateMs      = $this->latestReceipt['expires_date_ms'];
                                $isTrialPeriod      = $this->latestReceipt['is_trial_period'];
                                $transactionId      = $this->latestReceipt['transaction_id'];
                                # get current time in ms
                                $now = time();
                                // check if user cancelled subscription, if they did update appropriate tables with account status
                                if ($now > $expiresDateMs)
                                {
                                    // subscription expired, update database
                                    $updateDB = $this->db->updateAccountSubscriptionStatusAsExpired($this->user->uid);
                                    // return expired acct_type key
                                    return 30;
                                }
                            }
    
                        }
                    }
                }
            }
            // user never subscribed or their subscription is current
            // no action needed
            return 0;
        }
    
        function validateReceiptOnLive()
        {
            $this->response_json    = $this->remote_request($this->live_url, $this->request_json);
            $this->response_array   = json_decode($this->response_json, true);
        }
    
        function validateReceiptOnSandbox()
        {
            $this->response_json    = $this->remote_request($this->sand_url, $this->request_json);
            $this->response_array   = json_decode($this->response_json, true);
        }
    
        /*!
            Checks for error 21007 or 21008, meaning that we sent it to the wrong verification server, if we sent to the wrong server it retries by sending to the other server
            for verification
        */
        function verifyResponseStatus()
        {
            if (! (isset($this->response_array['status'])))
            {
                // something went wrong, 
                // TODO: set an error and bail
                return;
            }
            switch ($this->response_array['status']) 
            {
                case 0:
                    # receipt is valid
                    break;
                case 21000:
                    # App store could not read json object provided
                    $this->error = "App store couldn't read json.";
                    break;
                case 21002:
                    # data in receipt-data was malformed or missing
                    $this->error = "Receipt data malformed or missing.";
                    break;
                case 21003:
                    # receipt could not be authenticated
                    $this->error = "Receipt could not be authenticated";
                    break;
                case 21004:
                    # shared secret does not match secret on file
                    $this->error = "Shared secret error";
                    break;
                case 21005:
                    # receipt server is not currently available
                    $this->error = "Receipt server unavailable";
                    break;
                case 21006:
                    # receipt is valid but subscription has expired
                    $this->error = "Subscription expired";
                    break;
                case 21007:
                    # receipt is a sandbox receipt but sent to production server. Resubmit receipt verification to sandbox
                    $this->validateReceiptOnSandbox();
                    break;
                case 21008:
                    # receipt is a production receipt but sent to the sandbox server. Resubmit receipt verification to production
                    $this->validateReceiptOnLive();
                    break;
                default:
                    # unknown error code
                    break;
            }
        }
    
        function remote_request($url, $data) 
        {
            $curl_handle = curl_init($url);
            if(!$curl_handle) return false;
            curl_setopt($curl_handle, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($curl_handle, CURLOPT_POST, true);
            curl_setopt($curl_handle, CURLOPT_POSTFIELDS, $data);
    //      curl_setopt($curl_handle, CURLOPT_SSL_VERIFYHOST, 0);
    //      curl_setopt($curl_handle, CURLOPT_SSL_VERIFYPEER, false);
            $output = curl_exec($curl_handle);
            curl_close($curl_handle);
            return $output;
        }
    }
    
    ?>
    

    In your app you can get the receipt after a purchase like this:

    Swift 4

    private func loadReceipt() -> Data? {
        guard let url = Bundle.main.appStoreReceiptURL else {
            return nil
        }
    
        do {
            let data = try Data(contentsOf: url)
            return data
        } catch {
            print("\(self) Error loading receipt data: \(error.localizedDescription)")
            return nil
        }
    }
    

    and then send it to your server by generating a request something like this:

    // get your receipt data
    guard let data = loadReceipt() else {
        // nil response and error
        completion(nil, MyError.receiptLoadError)
        return
    }
    
    // create body data object for the request    
    let body = [
        "receipt-data": data.base64EncodedString()
    ]
    
    // serialize to Data
    guard let bodyData = try? JSONSerialization.data(withJSONObject: body, options: []), let url = URL(string: myServerUrl) else {
        // nil response and error
        completion(nil, MyError.serializationError)
        return
    }
    
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.httpBody = bodyData
    
    // send request with receipt to server
    let task = URLSession.shared.dataTask(with: request)....
    

    Also, here are some links to documentation you might find useful: