Effectively Handle iOS Payments in Rails
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.
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.