In App Purchases for iOS & PHP

In App Purchases for iOS & PHP

In App Purchases are super fun to work with. Fun in the sense that you like watching your hair fall out after each StackOverflow post you try to digest. You’ll find CargoBay, RMStore, openssl libraries and things that make you go why am I decoding NSA level stuff?! Well I’ll walk you through how I got in-app purchases going, with receipt/purchase validation and management serverside (PHP so eat it).

The Gist

The app requests a list of transactions from Apple. App send a list of transactions to our server and our server responds with transaction ID’s we don’t know about yet (purchased but not used). User makes a purchase, send the list of transactions to our server again. Our server cross references the list of transactions that is available to the user. Transaction is added to list on our server and we then activate functionality on our server.

How it works for the user

Lets say the application is a user journal. The in app purchase it to enable the ability for users to add comments to the user’s journal for a year. The owner of the blog, now the user of the application can go into their blog, click on a button that says enable comments for 1 year. The purchase gets made and users can now post comments. What this looks like technically:

  • App starts up and requests a list of existing transactions from the server
  • User navigates to an area where they can purchase a feature:
  • If the user has unused tokens for this feature we save the transaction on our server and activate the feature
  • If the user has no tokens for feature we do an in app purchase with a callback after successful payment to our server to then recheck the list of items
  • Feature is enabled on the server and app functionality opened

iOS Code

I have most of the code in my own StoreKit model, called JTStoreKit. JTStoreKit is a singleton with some properties such as an NSArray containing unused tokens. On app launch we find unused tokens, of which calls to our server with signed code

- (void)checkForUnusedCredits {
	NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
	NSData *receipt = [NSData dataWithContentsOfURL:receiptURL];
	NSDictionary *receiptDict = @{@"receipt":[receipt base64EncodedStringWithOptions:0]};
	
	JTHTTPRequestOperationManager *manager = [JTHTTPRequestOperationManager manager];

	NSMutableURLRequest *request = [manager.requestSerializer requestWithMethod:@"POST" URLString:@"http://domain.com/purchases/verify" parameters:receiptDict error:nil];
	AFHTTPRequestOperation *operation = [manager HTTPRequestOperationWithRequest:request success:^(AFHTTPRequestOperation *operation, id responseObject) {
		NSDictionary *responseDict = (NSDictionary *)responseObject;
		NSMutableArray *unusedPurchases = [NSMutableArray array];
		if (responseDict[@"unusedPurchase"]) {
			for (NSDictionary *dict in responseDict[@"unusedPurchase"]) {
				JTUnusedPurchase *purchase = [[JTUnusedPurchase alloc] initWithDictionary:dict];
				[unusedPurchases addObject:purchase];
			}
			self.unusedPurchases = unusedPurchases;
		}
	} failure:^(AFHTTPRequestOperation *operation, NSError *error) {

	}];
	[operation start];
}

Then we have a button action, that will first look to the Unused tokens array to determine if we should buy another token to use or if we should just tell the server we’ll use an already purchased and unused token:

- (IBAction)purchaseComments:(id)sender {
	NSInteger commentTokens = [[JTStoreKit sharedInstance] hasUnusedPurchasesForItem:kComments12Months];
	if (commentTokens == 0) {
		self.commentsObserver.paymentDelegate = self;
		[[SKPaymentQueue defaultQueue] addTransactionObserver:self.commentsObserver];
		SKProduct *comments = [JTStoreKit getProductWithIdentifier:kComments12Months];
		SKPayment *payment = [SKPayment paymentWithProduct:comments];
		[[SKPaymentQueue defaultQueue] addPayment:payment];
	} else {
		[self enableCommentsWithCompletion:^(BOOL success){
			NSLog(@"%i", success);
			[[JTStoreKit sharedInstance] checkForUnusedCredits];
		}];
	}
}

As you can see this looks at “[[JTStoreKit sharedInstance] hasUnusedPurchasesForItem:kComments12Months]”. This just returns a number of unused tokens for the IAP identifier defined in the app store:

- (NSInteger)hasUnusedPurchasesForItem:(NSString *)identifier {
	NSInteger unused = 0;
	for (JTUnusedPurchase *purchase in self.unusedPurchases) {
		if ([purchase.productId isEqualToString:identifier]) {
			unused++;
		}
	}
	return unused;
}

So where are we at? Well at the moment we have client code that checks our server for a list of unused tokens. We then have a method that can check how many unused tokens we have for a particular IAP product. So we can now do two things: Throw up a warning or change in the view to signify that when the user clicks buy they will be using an already purchased token, and secondly if no tokens have been left unused then send them down the route of using native IAP to make the purchase.

I will now split up both user journeys.

Purchase to be made

Here you use payment observers. Its just an observer pattern that basically says listen for payment activities. An example in this context is:

Make the payment (found in earlier method of – (IBAction)purchaseComments:(id)sender):

[[SKPaymentQueue defaultQueue] addTransactionObserver:self.commentsObserver];
		SKProduct *comments = [JTStoreKit getProductWithIdentifier:kComments12Months];
		SKPayment *payment = [SKPayment paymentWithProduct:comments];
		[[SKPaymentQueue defaultQueue] addPayment:payment];

An IAP dialogue will be displayed and if all goes well this observer method will be hit:

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {
	for (SKPaymentTransaction * transaction in transactions) {
		switch (transaction.transactionState) {
			case SKPaymentTransactionStatePurchased:
				[self enableCommentsWithCompletion:nil];
				break;
			default:
				break;
		}
	};
}

This then goes to enableCommentsWithCompletion:nil. Nil because we don’t need to worry about when the comments get enabled in terms of payment at this stage. So we’ve got payment and we’re about to activate the comments. But let’s say we had unused tokens.

Use Unused Tokens

Here we use unused tokens instead. This may be due to our server being offline after an IAP was made or if the user went offline. Whatever reason we have purchases that our server doesn’t know about, so we need to tell our server to use them instead. Going from the code above:

- (IBAction)purchaseComments:(id)sender {
	NSInteger commentTokens = [[JTStoreKit sharedInstance] hasUnusedPurchasesForItem:kComments12Months];
	if (commentTokens == 0) {
		......
	} else {
		[self enableCommentsWithCompletion:^(BOOL success){
			[[JTStoreKit sharedInstance] checkForUnusedCredits];
		}];
	}
}

Here you can see its a simple case of checking in with our Store singleton if we have unused tokens. This was done on app launch using “checkForUnusedCredits” in JTStoreKit.

Now here is where we send a request off to our own server:

- (void)enableCommentsWithCompletion:(void(^)(BOOL success))completion {
	NSString *urlString = [NSString stringWithFormat:@"http://domain.com/journals/%@/enableComments", self.journal.uuid];
	[[JTStoreKit sharedInstance] purchaseToServerWithUrl:urlString withCompletion:^(BOOL success) {
		if (completion) {
			completion(success);
		}
	}];
}
#pragma mark Purchase With Receipts
- (void)purchaseToServerWithUrl:(NSString *)urlString withCompletion:(void(^)(BOOL success))completion {
	NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
	NSData *receipt = [NSData dataWithContentsOfURL:receiptURL];
	NSDictionary *receiptDict = @{@"receipt":[receipt base64EncodedStringWithOptions:0]};
	
	JTHTTPRequestOperationManager *manager = [JTHTTPRequestOperationManager manager];
	
	NSMutableURLRequest *request = [manager.requestSerializer requestWithMethod:@"POST" URLString:urlString parameters:receiptDict error:nil];
	AFHTTPRequestOperation *operation = [manager HTTPRequestOperationWithRequest:request success:^(AFHTTPRequestOperation *operation, id responseObject) {
		if (completion) {
			BOOL success = [responseObject[@"success"] boolValue];
			completion(success);
		}
	} failure:^(AFHTTPRequestOperation *operation, NSError *error) {
		if (completion) {
			completion(NO);
		}
	}];
	[operation start];
}

What this is doing is sending off our receipt identifier to our server along the endpoint of enabling comments. Our endpoint will then request from Apple a list of products purchased based on our identifier and then determine if any are suitable for activating the requested feature. So if the user has unused tokens, or if the user just made a purchase via IAP, Apple is the single source of truth here, the signer, the one who knows where the money went, we will find out from Apple, cross reference these purchases against our own DB, then if there are any unused tokens, activate features.

The Serverside Code

The code’s job is to get the user’s store receipt identifier on the endpoint that is looking for activation. The server then gets a list of transactions, looks against it’s own source of truth, its database, then activates the feature and marks that transaction as used in the DB.

const SANDBOX_URL = 'https://sandbox.itunes.apple.com/verifyReceipt';
  const PRODUCTION_URL = 'https://buy.itunes.apple.com/verifyReceipt';
	
	public function findUnusedPurchases($receipts) {
		$unpurchased = array();
		foreach ($receipts as &$purchase) {
			$foundPurchase = $this->findByTransactionId($purchase->transaction_id);
			if (empty($foundPurchase)) {
				$unpurchased[] = $purchase;
			}
		}
		return $unpurchased;
	}

	public function verifyWithAppStore($data) {

		$encodedReceipt = json_encode(array('receipt-data' => $data, 'password' => 'dasdjladjsakljkldsajkldsa'));
		$url = Configure::read('debug') == 0 ? self::PRODUCTION_URL : self::SANDBOX_URL;

    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $encodedReceipt);

    $response = curl_exec($ch);
    $errno = curl_errno($ch);
    $errmsg = curl_error($ch);
    curl_close($ch);

    if ($errno != 0) {
        throw new Exception($errmsg, $errno);
    }

    return json_decode($response);
	}

So the endpoint for activating comments would look like:

public function enable_comments() {
    $response = $this->Purchase->verifyWithAppStore($this->request->data["receipt"]);
    $unpurchased = $this->Purchase->findUnusedPurchases($response->receipt->in_app);
    $unusedPurchase;
    foreach ($unpurchased as $purchase) {
      if ($purchase->product_id == "com.domain.comments12") {
        $unusedPurchase = $purchase;
        break;
      }
    }
    $journal = $this->Journal->findByUuid($this->request->param('journal_id'));
    $success = false;
    if (!empty($journal)) {
      $time = strtotime('+1 year', strtotime($journal['Journal']['comments_expiry']));

      $journal['Journal']['comments_expiry'] = date("Y-m-d H:i:s", $time);
      if ($this->Journal->save($journal)) {
        if ($this->Purchase->save($purchase)) {
          $success = true;
        }
      }
    }
    $this->set(compact('success'));
  }

So this reads as:

  • Ask apple for transactions for the user’s receipt id
  • Look in our DB for unused transaction ids
  • If there is an unused transaction id for the product we’re looking at purchasing then bump the time by 1 year on the comments_expiry field
  • If we just activated the feature lets ourselves mark the payment as being made in our own DB

Posted by voidet

Categorised under ios
Bookmark the permalink or leave a trackback.

Post a Comment

Your email is never published nor shared. Required fields are marked *

*
*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code class="" title="" data-url=""> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong> <pre class="" title="" data-url=""> <span class="" title="" data-url="">

or