Effectively Handle iOS Payments in Rails

Vasu K
Share

It’s no longer sufficient to run an exclusively web-based application. You need a mobile strategy if you’re to stay ahead of the competition. However managing payments on mobile is a developer’s worst nightmare. Stripe has solved this very well for web-based applications, but on mobile, it’s a different story.

How Payments Work on iOS

Purchases in iOS are available either in-app or upfront. Payments are taken care of by Apple and they take a hefty 30% commission of the sale. For their part, they cover security of the user data and ensure that the purchases are available in all of the customer’s devices.

ios_flow

While this sounds great on the surface, it is actually a pain to manage in real life. You’ll have to ensure that the customers have access to this content at all times. If you’re running a subscription which works across all devices, things get a little tricky. Apple does not send you any information on who subscribed. Apple protects the user’s identity very well by making the developer’s life harder.

In this article, I’ll help you learn how to accept iOS payments and subscriptions from your Rails app, including the common gotchas, and how to stay on Apple’s good side.

Setup

Before we begin, you’ll have to add the catalog of all that you’re selling inside the “In-App Purchases” page on your Apple dashboard. It is advisable to use the same ID as in your Rails app, so it’s easy to associate the purchases.

Note: This article assumes that you have an iOS app and sufficient knowledge on how to integrate with in-app purchases. I’ll be focusing only on how to manage it from your Rails app and won’t show any ObjectiveC code examples.

We will be using the app that we built in my previous article on Stripe Subscriptions as a base. It uses MongoDB for the database, but the examples here should work with ActiveRecord with just some minor changes. With that out of the way, let us begin.

InApp Purchases

When a user purchases an item from your app, the Rails app will send it to Apple’s servers for processing. Once it’s done, Apple will send you a receipt for validation. You need to validate this receipt before granting access to this user. While you can directly call Apple’s servers from your app, it is not recommended. The ideal way is to handle this through your backend.

Let’s build an endpoint to receive these requests:

# routes.rb
# IOS subscription
post '/receipt_validate/'  => 'checkouts#handle_ios_transaction'

In app/controllers/checkouts_controller:

# checkouts_controller.rb
def handle_ios_transaction
  item_id = params[:item_id]
  item = Items.find(item_id.to_s)
  if Rails.env == 'development' or Rails.env == 'test' or Rails.env == 'staging'
    apple_receipt_verify_url = "https://sandbox.itunes.apple.com/verifyReceipt"
  else
    #apple_receipt_verify_url = "https://sandbox.itunes.apple.com/verifyReceipt"
    apple_receipt_verify_url = "https://buy.itunes.apple.com/verifyReceipt"
  end
  url = URI.parse(apple_receipt_verify_url)
  http = Net::HTTP.new(url.host, url.port)
  http.use_ssl = true
  http.verify_mode = OpenSSL::SSL::VERIFY_NONE
  valid = false
  json_request = {'receipt-data' => params[:receipt_data] }.to_json
  resp = http.post(url.path, json_request, {'Content-Type' => 'application/x-www-form-urlencoded'})
  resp_body = resp
  json_resp = JSON.parse(resp_body.body)

  if resp.code == '200'
    if json_resp['status'] == 0
      valid = true
      current_IAP_receipt = json_resp['receipt']['in_app'].find {|x| x['product_id'] == item_id}
      respond_to do |format|
        format.json { render json: {message: "purchase successful!"} and return }
      end
    else
      Rails.logger.info("Apple_#{Rails.env} json_resp for verify_itunes #{json_resp['status']}")
      respond_to do |format|
        format.json { render json: {status: "invalid", errorCode: "#{json_resp['status']}"} and return }
      end
    end
  else
    format.json { render json: {status: "invalid", resp: "#{resp.code}"} and return }
  end
end

In config/environments/development.rb:

ITUNES = {:receipt_url => "https://sandbox.itunes.apple.com/verifyReceipt"}

Finally, in config/environments/production.rb:

ITUNES = {:receipt_url => "https://buy.itunes.apple.com/verifyReceipt"}

Okay, that’s some verbose code. Don’t worry if you don’t get it at the first glance, I will walk you through it.

Apple has 2 environments: sandbox, and production. In the sandbox environment, your account isn’t charged. Apple will generate a test receipt instead, and will work only in sandbox environments. We need to make sure to validate the receipts with the appropriate environments, otherwise it will lead to a lot of issues.

url = URI.parse(apple_receipt_verify_url)
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
valid = false
json_request = {'receipt-data' =>params[:receipt_data] }.to_json
resp = http.post(url.path, json_request, {'Content-Type' => 'application/x-www-form-urlencoded'})
resp_body = resp
json_resp = JSON.parse(resp_body.body)

Then we’re using Ruby’s HTTP module to send a POST request to Apple’s servers with the receipt data. If the payment is successful Apple will respond with a status code of 0. The detailed list of status codes is available in the developer docs.

Note: The receipt data is base64 encoded.

If the receipt is valid, then save the receipt data into our database, for future use. Add a new model for ITunes receipts:

# models/itunes_receipt.rb
class ItunesReceipt
  include Mongoid::Document
  include Mongoid::Timestamps
  include Mongoid::Paranoia
  include Mongoid::Attributes::Dynamic

  field :original_purchase_date_pst, type: String
  field :purchase_date_ms, type: String
  field :unique_identifier, type: String

  field :original_transaction_id, type: String
  field :bvrs, type: String
  field :transaction_id, type: String
  field :quantity, type: String
  field :unique_vendor_identifier, type: String
  field :item_id, type: String
  field :product_id, type: String
  field :purchase_date, type: String
  field :original_purchase_date, type: String
  field :purchase_date_pst, type: String
  field :bid, type: String
  field :original_purchase_date_ms, type: String
  field :status, type: String
end

Save the following in app/controllers/checkouts_controller.rb:

def save_receipt(receipt_data)
  receipt = Itunesreceipt.new
  receipt.original_purchase_date_pst = receipt_data['original_purchase_date_pst']
  receipt.purchase_date_ms = receipt_data['purchase_date_ms']
  receipt.original_transaction_id = receipt_data['original_transaction_id']
  receipt.transaction_id = receipt_data['transaction_id']
  receipt.quantity = receipt_data['quantity']
  receipt.product_id = receipt_data['product_id']
  receipt.purchase_date = receipt_data['purchase_date']
  receipt.original_purchase_date = receipt_data['original_purchase_date']
  receipt.purchase_date_pst = receipt_data['purchase_date_pst']
  receipt.original_purchase_date_ms = receipt_data['original_purchase_date_ms']
  receipt.save
end

Wait, how will you share this purchase with my website? Pass the user id with the validation request, if available. With that, we can associate the purchase with the user.

Edit your checkouts_controller.rb:

# checkouts_controller.rb
def handle_ios_transaction
  user = User.find(params[:user_id])
  #...
  if json_resp['status'] == 0
    valid = true
    current_IAP_receipt = json_resp['receipt']['in_app'].find {|x| x['product_id'] == item_id}
    if user
      current_IAP_receipt.user_id = user.id
      current_IAP_receipt.save
    end
    respond_to do |format|
      format.json { render json: {message: "purchase successful!"} and return }
    end
  #...
end

Set the relationship in itunes_receipt.rb:

class ItunesReceipt
    #....
    belongs_to :user
    #....
end

Setting Up a Subscription

Subscriptions are a great way to build a recurring customer base and generate a constant flow of cash. iOS has 2 types of subscriptions: Non-renewable and Auto-renewable. Non-renewable subscriptions come with an expiry date, and in the case of renewable subscriptions, Apple renews the contract automatically on the end date.

Non-renewable Subscription

To work with Non-renewable subscriptions, in itunes_receipt.rb add:

class ItunesReceipt
  #..........
    field :expiry_date_ms, type: String
  #..........

  def is_expired?
    Time.now.to_i > self.expired_date_ms.to_i
  end
end

Non-renewable subscriptions work pretty similarly to In-app purchases. Just save the expiry_date in your receipt model and validate against that every time the content is accessed.

Renewable Subscriptions

On the surface, renewable subscriptions look very similar to an In-app purchase but it is a little tricky. Once the subscription ends, Apple automatically renews it, but there is no way for you to know whether the user has cancelled it or not.

Let us start by modelling our subscription object:


class IosSubscription
  include Mongoid::Document
  include Mongoid::Timestamps
  include Mongoid::Paranoia
  include Mongoid::Attributes::Dynamic

  #Fields from Apple
  field :original_purchase_date, type: String
  field :original_purchase_date_pst, type: String
  field :original_purchase_date_ms, type: String
  field :product_id, type: String
  field :is_trial_period, type: Boolean
  field :purchase_date, type: String
  field :purchase_date_pst, type: String
  field :purchase_date_ms, type: String
  field :expires_date, type: String
  field :expires_date_ms, type: String
  field :expires_date_pst, type: String
  field :original_transaction_id, type: String
  field :transaction_id, type: String
  field :web_order_line_item_id, type: String
  field :quantity, type: Integer
  field :receipt_data, type: String
  #Flag for production/test receipts
  field :mode, type: String
  field :udid, type: String
  belongs_to :user
end

and in your checkouts_controller.rb, add:


  def verify_subscription_ios
    user_id = params[:user_id]
    @UDID =  params[:udid]
    @user = User.find(user_id) unless user_id.nil?
    @source = "ipad"
    @shared_secret =  ITUNES[:secret]
    @amount = params[:amount]
    @apple_receipt_verify_url = ITUNES[:receipt_url]
    json_resp = validate_receipt(@apple_receipt_verify_url)
    if @resp.code == '200' and  json_resp['status'] == 0
      save_receipt(json_resp)
      else
      respond_to do |format|
        format.json { render json: {status: "invalid", mode: @mode, errorCode: "#{json_resp['status']}"} and return }
      end
    end
  end

private

# Persist the receipt data in  production server
def save_receipt(json_resp)
  @latest_receipt = json_resp['latest_receipt_info'].last
  #Generate the receipt
  create_receipt
  @valid = true
  respond_to do |format|
    format.json { render json: {status: true, message: "purchase successful!", mode: @mode, expires_at: DateTime.parse(@ios_subscription.expires_date_pst).strftime('%m/%d/%Y')} and return }
  end
end

#Create receipt only if needed
def create_receipt
  log "Checking subscription receipt"
  # If the original transaction id is the same as the current transaction id then this is a new record
  @ios_subscription = IosSubscription.find_by(:original_transaction_id => @latest_receipt['original_transaction_id'])
  if @ios_subscription.nil? and @latest_receipt['original_transaction_id'] ==  @latest_receipt['transaction_id']
    new_receipt()
  else
    unless @ios_subscription.nil?
      #Update Existing receipt
      unless @latest_receipt['original_transaction_id'] ==  @latest_receipt['transaction_id']
        log "Updating receipt #{@ios_subscription.id}"
        @ios_subscription.expires_date =  @latest_receipt['expires_date']
        @ios_subscription.expires_date_ms =  @latest_receipt['expires_date_ms']
        @ios_subscription.expires_date_pst =  @latest_receipt['expires_date_pst']
        @ios_subscription.web_order_line_item_id = @latest_receipt['web_order_line_item_id']
        @ios_subscription.transaction_id = @latest_receipt['transaction_id']
        @ios_subscription.save
      end
    end
  end
end

def new_receipt
  @ios_subscription = IosSubscription.new
  @ios_subscription.original_purchase_date = @latest_receipt['original_purchase_date']
  @ios_subscription.original_purchase_date_pst = @latest_receipt['original_purchase_date_pst']
  @ios_subscription.original_purchase_date_ms = @latest_receipt['original_purchase_date_ms']
  @ios_subscription.purchase_date = @latest_receipt['purchase_date']
  @ios_subscription.purchase_date_pst = @latest_receipt['purchase_date_pst']
  @ios_subscription.purchase_date_ms = @latest_receipt['purchase_date_ms']
  @ios_subscription.expires_date =  @latest_receipt['expires_date']
  @ios_subscription.expires_date_ms =  @latest_receipt['expires_date_ms']
  @ios_subscription.expires_date_pst =  @latest_receipt['expires_date_pst']
  @ios_subscription.original_transaction_id = @latest_receipt['original_transaction_id']
  @ios_subscription.transaction_id = @latest_receipt['transaction_id']
  product_id = @latest_receipt['product_id']
  @ios_subscription.product_id = product_id.split("_").first
  @ios_subscription.quantity = @latest_receipt['quantity']
  @ios_subscription.web_order_line_item_id = @latest_receipt['web_order_line_item_id']
  @ios_subscription.udid = @UDID
  @ios_subscription.receipt_data = @receipt_data
  @ios_subscription.mode = @mode
  # Associate ios subscription to the current user
  unless @user.nil?
    @user.ios_subscription = @ios_subscription
    @user.save
  end
  @ios_subscription.save
end

Okay, this is very similar to the in-app purchase. We’re validating the receipt with Apple, and if it’s valid save the receipt. If a subscription already exists, we then need to update the expiry date accordingly.

However, this leads to a lot of unnecessary network calls and will hog your backend if you’ve a large customer base. To offset this, it is a common practice to store the expiry date in your ios database and only when the expiry date is near, send a request to your Rails app. This will improve the responsiveness of your app.

Handle Sandbox Receipts in Production

When you submit your app to the app store, you’ll point the app to production mode. Unfortunately, Apple verifies the app in Sandbox mode and will use a sandbox receipt to test your purchases. The receipt will fail, and the app might get rejected. Your API should be able to handle this.

In your checkouts_controller:


def verify_subscription_ios
  #...
  if @resp.code == '200'
    if json_resp['status'] == 0
      save_receipt(json_resp)
    elsif  json_resp['status'] == 21007
      @mode = "sandbox"
      json_resp = validate_receipt("https://sandbox.itunes.apple.com/verifyReceipt")
      save_receipt(json_resp)
    else
    respond_to do |format|
      format.json { render json: {status: "invalid", mode: @mode, errorCode: "#{json_resp['status']}"} and return }
    end
  end
  #...
end

If the error code is 21007, set the mode to ‘sandbox’ and validate it against the sandbox environment.

Other Thoughts

Apple doesn’t have an API to cancel the subscription. Users will have to manually cancel it from the Settings for the application. Also, when a user cancels there is no way for you to know who cancelled. To make matters worse, most users don’t know how to cancel from an iOS device. In one of my previous projects, we’ve received an array of negative reviews and tons of angry emails from customers who couldn’t figure it out. Make sure you leave your support email and detailed steps on how to cancel.

Also, it is very difficult for you offer discounts to an existing customer or a reviewer. If you’re running a special promotional campaign on your website, it’s impossible to bring it down to your iOS app.

You need to be mindful of this and have a way for your users to interact with your support team to reduce friction with your customers.

Wrapping Up

Auto-renewal subscriptions are a great way to generate recurring revenue, but if not managed properly they can make your life miserable. I hope you enjoyed this article. Feel free to join us in a discussion below.

CSS Master, 3rd Edition