Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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:

  • OneTime payments will unlock content access for a fixed period, e.g. 3 month
  • Subscription does the same but will automatically renew if not canceled

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?

like image 923
Andrei Herford Avatar asked Sep 26 '18 16:09

Andrei Herford


People also ask

How do I add an existing subscription to Apple?

Go to Users and Accounts > [account name] Subscriptions and select a subscription. Follow the onscreen instructions to change or cancel your subscription.

How do I manage my iPhone Subscriptions on my computer?

From the menu bar at the top of the iTunes window, choose Account, then choose View My Account. Click View Account. Scroll to the Settings section. Next to Subscriptions, click Manage.

How do I access Subscriptions on iOS?

1. Open the Settings app on your iPhone and tap your name and Apple ID at the top. 2. On the Apple ID page, tap "Subscriptions." You may need to enter your password or use Touch ID or Face ID to log into your account.

How do Apple Subscriptions work?

They automatically renew at the end of their duration until the user chooses to cancel. Subscriptions are available on iOS, iPadOS, macOS, watchOS, and tvOS. Great subscription apps justify the recurring payment by providing ongoing value and continually innovating the app experience.


1 Answers

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:

  • To determine if a user has an account you'll need to provide a login screen, if they have an account you're good and you simply need to provide content. If the subscription is expired you cannot hide in app purchase options and/or direct them to the website to subscribe. Your app will be rejected for this.

Problem 2:

  • If a user logs in and already has an account paid for via the website you simply need to provide subscription content. You do not need to have them subscribe via the app if they are already subscribed and have a valid account. New users will need to have the option to create an account and subscribe via in app purchase though.

Problem 3:

  • Your backend API should be tracking the type of subscription a user has and if it is valid. If it is valid they are granted access to the content, if not they should be presented with a renewal/subscription flow.

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:

  • Provide login to existing users whether subscribed via web or app. If they have a valid subscription either web based or in app purchase based, provide content. If not, prompt them to subscribe via in app purchase.
  • Provide signup for new users via the app. Users signing up via the app will use in app purchase to pay for subscriptions.
  • The backend API should track/validate subscriptions purchased via IAP. When the app is launched you can connect to your api to verify a users subscription is still valid using their receipt. If valid provide content, otherwise show your subscription renewal UI.

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:

  • Receipt Validation Programming Guide
  • Subscriptions
like image 178
digitalHound Avatar answered Oct 08 '22 21:10

digitalHound