1、 IAP规则详解
本文所述IAP(In-App Purchase),特指苹果App Store的应用内购买,是苹果为App内购买虚拟商品或服务提供的一套交易系统。
首先来讨论一下IAP的基本规则以及其中的一些要点:
1.1 适用范围
在App内需要付费使用的产品功能或虚拟商品/服务,如游戏道具、电子书、音乐、视频、订阅会员、App的高级功能等。
苹果规定,适用范围内的虚拟商品或服务,必须使用IAP购买支付,不允许使用支付宝、微信支付等其它支付方式(包括Apple Pay),也不允许以任何方式(包括跳出App、提示文案等)引导用户通过应用外部渠道购买。
1.2 IAP类型
如前面说的,IAP是一套商品交易系统,而非简单的支付系统。每一个购买项目都需要在App的itunes connect后台创建一个商品,提交给苹果审核,审核通过后,购买项目才会生效。
在创建IAP商品时,主要有4种类型可供选择:

1.3 定价
在创建IAP项目的时候,需要设定价格,这个价格只能从苹果预设的价格等级中选择,比如等级1对应1美元、6元人民币,等级2对应2美元、12元人民币……最高等级87对应999.99美元、6498元人民币。另外可能是为了照顾某些货币区的开发者和用户,还有一些特殊的等级,比如备用等级A对应1美元、1元人民币,备用等级B对应1美元、3元人民币这样。除此之外,IAP项目不能定一个9.9元人民币这样不符合任何等级的价格。详细价格等级表可以看苹果的官方文档:
苹果的价格等级表通常是不会调整的,但也不排除在某些货币汇率发生巨大变化的情况下,对该货币的定价进行调整,调整前苹果会发邮件通知开发者。
1.4 分成
App Store上的付费App和App内购,苹果与开发者默认是3/7分成。
但实际上,在某些地区苹果与开发者分成之前需要先扣除交易税,开发者的实际分成不一定是70%。从2015年10月开始,苹果对中国地区的App Store购买扣除了2%的交易税,对于中国区帐号购买的IAP,开发者的实际分成在68%~69%之间。而且中国以外不同地区的交易税标准也存在差异,如1.3中所述,如果需要严格计算实际收入,可能需要把这个部分也考虑进来。
针对不同地区的内购,内购价格和对应的开发者实际收入在苹果的价格等级表(1.3中的链接)中有详细列举。
另外,根据苹果在2016年6月的新规则,针对Auto-Renewable Subscription类型的IAP,如果用户购买的订阅时间超过1年,那么从第二年开始,开发者可以获得85%的分成。详情可查看:
1.5 结算
针对IAP的交易收入,苹果一般以5周(每年1/4/7/10月)或4周(其余月份)作为一个结算周期,并在每个结算周期结束后第33天向开发者付账。
2、 实现
所述实现包含客户端和服务端两个部分。考虑到当前的折算方法(从基础套餐升级到高级套餐,会把基础套餐剩余的天数折算成相应的钱,然后在高级套餐的付款上扣除折算后剩余的部分,才是用户最终需要支付的金额)存在一些问题:
eg:假若一个账号下面有多台设备,且这些设备都是基础套餐;然后我想把这个基础套餐升级到高级套餐,发现基础套餐折算剩余的金额要高于我购买高级套餐需要支付的费用,这种情况就有问题了。而且在IAP时候,如1.2中用户只能设置4种类型,设置完整之后每次支付的费用是固定的,不能更改的。按照当前的折算方法来,也是行不通的。
针对上述问题,提出如下处理方法:
修改当前折算规则为:总计当前套餐余额,并把这个余额换算成新套餐中新增的服务天数。
这种方法一是解决了上例问题中余额超过的问题,二也解决了IAP的相关问题。因为在IAP的时候,每次购买的服务费用只能是固定的,即便是用户选择“自动订阅型”付款方式,也是没有问题的,只会是先扣款了,而服务时间是可能还剩几天。
2.1 服务端实现
服务端需要修改当前结算规则,修改结算余额的方式为结算天数的方式。
客户端每次支付成功可以获取的消息结构如下:

product_id 是appstore上自己设置的,服务器需要跟这边保持一致。服务端根据每笔订单支付情况来计算服务时间(包括自动续费的),推送到期保持不变。
2.2 客户端实现
appstore上需要设置8种订购规则
1、消耗型(对应1.2中的第一种)
Basic Monthly $2.99
Basic Annually $29.99
Premium Monthly $6.99
Premium Annually $69.99
2、自动订阅型(对应1.2中的第三种)
Basic Monthly $2.99
Basic Annually $29.99
Premium Monthly $6.99
Premium Annually $69.99
消耗型是可以多次购买的,自动订阅型即相当于自动续费类型。
App UI需要做对应的修改,以前是针对某个订单折算所需支付金额,然后调出paypal进行付款。需修改为:针对某个订单,提供上面8种支付方式(亦可只选其中的几种进行展示推广),IAP支付完成后服务端返回订单内容,包含到期时间。
附iOS端实现代码:
头文件:
//
// InpurchaseManager.h
// xxxx
//
// Created by xxx on 2019/4/23.
// Copyright © 2019 xxx. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, Purchase_Status)
{
Purchase_Status_Success = 0, //交易成功
Purchase_Status_Failed, //交易失败
Purchase_Status_NeedAuthor, //未打开应用内购买权限
Purchase_Status_GetProduct_Failed, //获取产品列表失败
};
typedef void (^PurchaseInfo)(Purchase_Status status);
@interface InpurchaseManager : NSObject
+ (instancetype)shareInstance;
/**
检测是否打开内购权限
@return YES:打开 NO:未打开
*/
- (BOOL)canMakePayments;
/**
购买商品,返回购买结果信息
@param productId 商品id
@param orderId 订单id
@param token 云端token
@param purchaseInfo 状态
*/
- (void)payWithPruductId:(NSString *)productId orderId:(NSString *)orderId cloudToken:(NSString *)token infoCallBack:(PurchaseInfo)purchaseInfo;
/**
处理漏单情况(可以在每次进入云服务的时候做一次检查)
*/
- (void)checkOrderStatus;
/**
根据bundle id获取app的名字
@param bundleId app bundle id
@return app名字
*/
- (NSString *)appNameFromBundleId:(NSString *)bundleId;
@end
NS_ASSUME_NONNULL_END
实现文件:
//
// InpurchaseManager.m
// xxxx
//
// Created by xxx on 2019/4/23.
// Copyright © 2019 xxx. All rights reserved.
//
#import "InpurchaseManager.h"
#import <StoreKit/StoreKit.h>
#import "DCHttpSecureRequest.h"
#import "DCCloudProMacro.h"
//共享密钥
#define kShared_Secret (kIsxxxProduct ? @"xxxx" : @"xxxx")
#define kProduct_Prefix [[NSBundle mainBundle] bundleIdentifier]
@interface InpurchaseManager ()<SKProductsRequestDelegate,SKPaymentTransactionObserver>
{
NSString *_productID;
NSString *_orderID;
NSString *_cloudToken;
PurchaseInfo _curPurchaseInfo;
}
@end
@implementation InpurchaseManager
+ (instancetype)shareInstance
{
static InpurchaseManager *manager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
manager = [[InpurchaseManager alloc] init];
});
return manager;
}
//结束上次未完成的交易
- (void)removeAllUncompleteTransactionsBeforeNewPurchase
{
NSArray *arrTransactions = [SKPaymentQueue defaultQueue].transactions;
if (arrTransactions.count > 0) {
for (SKPaymentTransaction* transaction in arrTransactions) {
if (transaction.transactionState == SKPaymentTransactionStatePurchased ||
transaction.transactionState == SKPaymentTransactionStateRestored) {
[[SKPaymentQueue defaultQueue]finishTransaction:transaction];
}
}
}
}
- (BOOL)canMakePayments
{
return [SKPaymentQueue canMakePayments];
}
- (void)payWithPruductId:(NSString *)productId orderId:(NSString *)orderId cloudToken:(NSString *)token infoCallBack:(PurchaseInfo)purchaseInfo
{
_productID = productId;
NSString *itunesConnectProductID = [NSString stringWithFormat:@"%@.%@", kProduct_Prefix, productId];
_orderID = orderId;
_cloudToken = token;
_curPurchaseInfo = [purchaseInfo copy];
if ([self canMakePayments]) {
[self removeAllUncompleteTransactionsBeforeNewPurchase];
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
NSSet *productSet = [NSSet setWithArray:@[itunesConnectProductID]];
SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:productSet];
request.delegate = self;
[request start];
}else{
_curPurchaseInfo(Purchase_Status_NeedAuthor);
}
}
- (void)checkOrderStatus
{
NSDictionary *orderInfo = [self getReceiptData];
if (orderInfo.count > 0) {
[self verifyPurchaseToServerWithDic:orderInfo];
}
}
#pragma mark -- 本地保存一次支付凭证
static NSString *const kSaveReceiptData = @"kSaveReceiptData";
- (void)saveReceiptData:(NSDictionary *)receiptData
{
[[NSUserDefaults standardUserDefaults] setValue:receiptData forKey:kSaveReceiptData];
[[NSUserDefaults standardUserDefaults]synchronize];
}
- (NSDictionary *)getReceiptData
{
return [[NSUserDefaults standardUserDefaults] valueForKey:kSaveReceiptData];
}
- (void)removeLocReceiptData
{
[[NSUserDefaults standardUserDefaults] removeObjectForKey:kSaveReceiptData];
[[NSUserDefaults standardUserDefaults] synchronize];
}
#pragma mark --
//产品返回信息
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
{
NSArray *product = response.products;
if (product.count == 0) {
if (_curPurchaseInfo) {
_curPurchaseInfo(Purchase_Status_GetProduct_Failed);
}
return;
}
SKProduct *p = product.firstObject;
SKPayment *payment = [SKPayment paymentWithProduct:p];
[[SKPaymentQueue defaultQueue] addPayment:payment];
}
//请求失败
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
{
NSLog(@"请求失败:%@", error);
}
//请求完成
- (void)requestDidFinish:(SKRequest *)request
{
NSLog(@"请求完成:%@", request);
}
#pragma mark --
//appstore支付状态
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions
{
for (SKPaymentTransaction *tran in transactions) {
switch (tran.transactionState) {
case SKPaymentTransactionStatePurchased:{
//apple server验证
[self verifyPurchaseWithPaymentTransaction:tran];
[[SKPaymentQueue defaultQueue] finishTransaction:tran];
}break;
case SKPaymentTransactionStatePurchasing:{
NSLog(@"商品添加进列表");
}break;
case SKPaymentTransactionStateRestored:{
NSLog(@"已经购买过商品");
[[SKPaymentQueue defaultQueue] finishTransaction:tran];
}break;
case SKPaymentTransactionStateFailed:{
if (_curPurchaseInfo) {
_curPurchaseInfo(Purchase_Status_Failed);
}
[[SKPaymentQueue defaultQueue] finishTransaction:tran];
}break;
default:
break;
}
}
}
- (void)verifyPurchaseWithPaymentTransaction:(SKPaymentTransaction *)transaction
{
NSString *str = [[NSString alloc] initWithData:transaction.transactionReceipt encoding:NSUTF8StringEncoding];
NSString *strEnvironment = [self environmentForReceipt:str];
BOOL is_sandbox = NO;
if ([strEnvironment isEqualToString:@"environment=Sandbox"]){
is_sandbox = YES;
}
// 验证凭据,获取到苹果返回的交易凭据
// appStoreReceiptURL iOS7.0增加的,购买交易完成后,会将凭据存放在该地址
NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
// 从沙盒中获取到购买凭据
NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
NSString *encodeStr = [receiptData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed];
NSDictionary *dicSend = @{@"token":_cloudToken,
@"receipt-data":encodeStr,
@"password":kShared_Secret,
@"product_id":_productID,
@"transaction_id":transaction.transactionIdentifier,
@"quantity":@"1",
@"order_id":_orderID,
@"is_sandbox":(is_sandbox?@"1":@"0"),
@"bundle_id":kProduct_Prefix
};
[self saveReceiptData:dicSend];
[self verifyPurchaseToServerWithDic:dicSend];
}
- (void)verifyPurchaseToServerWithDic:(NSDictionary *)dicInfo
{
NSString *url = [NSString stringWithFormat:@"%@/postPay/ios", kDCServerAddress];
[[DCHttpSecureRequest sharedHttpRequest] POST:url param:dicInfo completion:^(NSDictionary *response, NSHTTPURLResponse *urlResponse, NSError *error) {
NSLog(@"Purchase status : %@", response);
if ([response[@"status"] integerValue] == 200) {
[self removeLocReceiptData];
if (self->_curPurchaseInfo) {
self->_curPurchaseInfo(Purchase_Status_Success);
}
}else{
if (self->_curPurchaseInfo) {
self->_curPurchaseInfo(Purchase_Status_Failed);
}
}
}];
}
- (NSString *)environmentForReceipt:(NSString *)str
{
str = [str stringByReplacingOccurrencesOfString:@"\r\n" withString:@""];
str = [str stringByReplacingOccurrencesOfString:@"\n" withString:@""];
str = [str stringByReplacingOccurrencesOfString:@"\t" withString:@""];
str = [str stringByReplacingOccurrencesOfString:@" " withString:@""];
str = [str stringByReplacingOccurrencesOfString:@"\"" withString:@""];
NSArray * arr = [str componentsSeparatedByString:@";"];
//存储收据环境的变量
NSString *environment = arr[2];
return environment;
}
- (void)dealloc
{
[[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}
- (NSString *)appNameFromBundleId:(NSString *)bundleId
{
NSString* strLanguage = [[[NSUserDefaults standardUserDefaults] objectForKey:@"AppleLanguages"] objectAtIndex:0];
return xxxx;
}
@end