苹果内购之推介促销优惠和订阅优惠

最近完成了苹果的两个关于订阅的优惠开发:推介促销优惠和订阅优惠. 整理一篇文档
已换工作,不在更新

本文主要介绍的以下几个方面

  1. 促销优惠和订阅优惠的基本概念以及开发流程

  2. 开发中遇到的各种坑

本文的前提是读者已经了解过苹果内购.

基础概念

推介促销优惠

可以为自动续期订阅设定的具有指定时限和类型(随用随付、提前支付、免费)的推介促销折扣价,此价格面向新顾客。推介促销优惠可用于吸引新顾客。

请谨记:

  1. 顾客可以享受每个订阅群组的一个推介促销优惠

  2. 您可以针对每个地区设置一个当前推介促销优惠和一个未来推介促销优惠

  3. 您可以在 App Store Connect 中管理地区销售范围、开始和结束日期

  4. 如果您已推广您的 App 内购买项目,推介促销优惠将显示在您的 App Store 产品页上

  5. 推介促销优惠适用于运行 iOS 10、Apple TVOS 10 和 macOS 10.12.6 及更高版本的顾客

推介促销的付款方式有三种类型

支付方式

image

哪些用户可以享有推介促销优惠?

两种用户

  1. 全新的appid

  2. 购买过这个订阅群组的商品但是从来没有享受过促销优惠的appid都可以享受一次优惠.

如下图

1

由于控制权完全就在苹果那里控制, 导致用户可以更换appID然后无限次以推介促销优惠价格购买商品, 为了解决这个问题当初想了两种方案:

方案A,用户可以购买,苹果也会扣费, 但是我们不给下发会员, 但是会计担心用户回去消费者协会投诉, 所以就放弃了.

方案B,创建了两个一个月的商品, 一个有推介促销优惠, 另外一个没有推介促销优惠.根据不同的用户下发不同的商品, 这样就可以防止被薅羊毛了, 当然这也为后来埋下了一个天坑, 在下面会详细解释一下

由于这个优惠策略的局限性, 后来苹果后续推出了订阅优惠

订阅促销优惠

在2019年3月, 随着iOS12.2 一起发布了一种新的促销优惠: 订阅优惠(Subscribe Offer)

提供自动续期订阅的App可向现有顾客和曾订阅过的顾客提供限时折扣价格。提供订阅优惠有助于您赢回曾取消订阅的顾客,或推动顾客以特价升级至更高等级的订阅。已使用过推介促销优惠的顾客也可以享受该优惠。

订阅优惠其实就是一种优惠券的功能, 可以给任何一个商品新建优惠券, 每个商品可以有多种优惠等级的优惠券, 只可以把优惠券给曾经买过对应商品的用户使用, 让他们以优惠价购买对应商品, 一个用户可以使用多次优惠券购买(具体几次由产品策略控制, 这也就是这个策略最灵活的地方)

可以看看2019年WWDC苹果提供的Session 305订阅优惠最佳实践

比较一下两种优惠

image

开发

推介促销优惠

这个开发量很少, 客户端没有开发量, 主要是在api那边. 客户端只需要在ITunes connect 后台配置一下推介促销优惠信息, 然后api那边根据凭证里面的两个字段is_in_intro_offer_period(是否享受折扣)或者is_trial_period(是否免费) 字段就可以判断当前用户的购买是否享受到了优惠策略, 方便和财务对账

ITunes connect 配置流程: 我的app->App内购买项目->内购商品->推介促销->选择国家地区->开始结束日期->选择价格->确认

image

订阅促销优惠

这个工作量稍微多一些了, 需要客户端,api协同开发

主要分工

客户端:

  1. 在ITunes connect 后台配置促销优惠

  2. 负责拿着优惠券去苹果端支付

1. 创建秘钥

去ITunes Connect->用户和访问->订阅->秘钥

image

这个秘钥要注意下, 只能下载一次, 丢了需要重新申请

2. 创建促销优惠

ITunes connect 配置流程

我的app->App内购买项目->内购商品->促销促销->促销名称和优惠产品代码->支付方式->价钱和时限->确认

image

3. 和苹果交互

这里就很简单了, 给SKMuablePAymentSKPaymentDiscount赋值

  1. 向API申请sign
// Fetch the signature from your server to be applied to the offer.
// At this point you know the user and the product the offer is for, and which offer you want to display.
public func prepareOffer(usernameHash: String, productIdentifier: String, offerIdentifier: String, completion: (SKPaymentDiscount) -> Void) {

    // Make a secure request to your server, providing the username, product, and discount data
    // Your server will use these values to generate the signature and return it, along with the nonce, timestamp, and key identifier that it uses to generate the signature.
    YourServer.fetchOfferDetails(username: usernameHash, productIdentifier: productIdentifier, offerIdentifier: offerIdentifier, completion: { (nonce: UUID, timestamp: NSNumber, keyIdentifier: String, signature: String) in 

        // Create an SKPaymentDiscount to be used later when the user initiates the purchase
        let discountOffer = SKPaymentDiscount(identifier: offerIdentifier, keyIdentifier: keyIdentifier, nonce: nonce, signature: discountsignature, timestamp: timestamp)

        completion(discountOffer)
    })
}
  1. 创建discount
// An example function that makes a buy request with a subscription offer attached.
public func buyProduct(productIdentifier: SKProduct, forUser usernameHash: String, withOffer discountOffer: SKPaymentDiscount) {

    // The original product being purchased.
    let payment = SKMutablePayment(product: product)

    // You must set applicationUsername to be the same as the one used to generate the signature.
    payment.applicationUsername = usernameHash

    // Add the offer to the payment.
    payment.paymentDiscount = discountOffer

    // Add the payment to the queue for purchase.
    SKPaymentQueue.default().add(payment)
}

如果sign生成错误, 在支付的时候会出弹窗, 可以看看系统给你返回的什么错误.

图片

api

  1. 判断当前用户是否可以享受优惠策略
  2. 判断当前用户享受那种优惠策略(因为一个商品可以有很多促销id)
  3. 生成签名
  4. 验证凭证和财务对账

api在获得凭证里面会看到一个字段promotional_offer_id, 这个字段就是你在ITunes connect 后台创建促销ID

字段名 定义 提供方
appBundleID 应用的bundleId api
keyIdentifier 标示用的是哪一个密钥, 订阅后台可以拿到 api
productIdentifier 商品Id 客户端
offerIdentifier 促销Id api
applicationUsername 传用户Id 客户端
nonce 有效期24小时的唯一Id,注意必须是小写 api
timestamp 服务器时间戳,毫秒,时间戳24小时内的优惠是有效的 api
算法过程
  1. 先对以上的参数以字符串\u2063作为分隔符按顺序拼接:
appBundleId + '\u2063' + keyIdentifier + '\u2063' + productIdentifier + '\u2063' + offerIdentifier + '\u2063' + applicationUsername + '\u2063' + nonce + '\u2063' + timestamp
  1. 对拼接好的字符串做签名:使用ECDSA算法,SHA-256哈希和iTunes Connect后台生成的密钥

  2. 对二进制数据做base64,返回给App

    本文末尾是签名的python代码

具体的获取签名的流程最好和贵司的安全风控聊一下, 防止有安全问题, 比如优惠券任意使用

遇到的问题

有很多坑苹果文档没说清楚,也没有谷歌到, 都是硬趟的. 也有很多不细心导致的

1. 如何设置和测试App Store推广

测试的时候只能用URL Scheme测试, itms-services://?action=purchaseIntent&bundleId=com.example.app&productIdentifier=product_name 自己在测试的时候, 刚开始只跳转App, 不回调方法, 最后查来查去, 发现自己的 URL Scheme里面多了一个空格, 醉了醉了

2. App Store 推广的优惠商品什么时候可以看到

我在商品上线之后,点击了推广, 在App Store的app的详情页的订阅入口是过了半个多小时才出现, 在App Store的检索页搜索APP, App下发的那个推广是过了一晚上才出现, 这个应该是App Store的延迟导致的

3. 新上的App Store推广商品老版本用户在App Store看到如何处理

用户点击推广商品, 还是会打开老版本App. 但是在App Store会弹出一个更新的页面, 如下图, 关键是这个页面用户看不到的, 我已经从App Store跳转到App了, 怎么可能看到这个界面, 而且跳转到App Store之后是进入主页面打不开支付页面的. 因为你没实现苹果的监听协议

image

4.确认用户是否享受推介促销优惠

这个权限完全是在苹果那里控制, 苹果根据Appid判断, 客户端是无法知晓的, 我司的做法是创建了两个商品(比如爱奇艺,腾讯视频),给从未购买过促销商品的用户才下发带有促销订阅优惠的商品. 给享受过促销优惠的下发不带促销订阅的商品, 当然你们也可以只有一个商品(比如网易云音乐), 但是要防止用户更换appid薅羊毛

5. 订阅优惠创建

这个是在创建签名的时候发现我的开发者账号没办法创建, 虽然leader给了我各种权限但是还是不行, 最后确认是账号持有人的权限才可以

6.切换订阅

先看下苹果定义

您可以将同一订阅群组中的每个 App 内购买项目都分配至一个订阅等级。您的订阅等级应该按降序排列,从提供最高等级服务的那一个等级开始。如果订阅提供的服务被视为等同的,则您可以向每个等级添加一个以上的订阅。顾客可以在订阅等级之间调整,即可以升级、降级和跨级。如果支付原价的订阅者需要升级、降级或跨级,则他们需要支付订阅的当前价格,而不是保留的原价。

升级:当顾客从较低等级的订阅切换到较高等级的订阅时,顾客先前的 App 内购买项目金额将会按比例退还到原始付款方式。新的 App 内购买项目将收取完整价格并立即生效,顾客的续期日期也随之更改为升级日期。

降级:当顾客从较高等级的订阅切换到较低等级的订阅时,在下一个续期日期,会以新费率向顾客收费。

跨级:当顾客在相同等级的订阅间进行切换时,如果 App 内购买项目的时限相同,那么顾客先前的 App 内购买项目金额将会按比例退还到原始付款方式。新的 App 内购买项目将收取完整价格并立即生效,顾客的续期日期也随之更改为升级日期。如果 App 内购买项目的时限不同,那么跨级将会在顾客的下一个续期日期生效。

-------------我是分割线-----------------------

下面说一下我遇到的天坑

因为当初为了防止用户薅羊毛(更换appid, 一直以促销优惠价格购买), 所以就又新建了一个月的商品, 为了方便描述, 把原来的一个月vip称为vipA, 新建的一个月vip称为vipB.

因为我们app一个群组的所有订阅商品等级都是一样的, 不涉及到升级和降级. 只涉及到了跨级. 用户可以在App Store->管理订阅->编辑订阅中切换订阅, 比如腾讯视频, 他有五个一个月的商品(当初我发现我们app的天坑之后我也去腾讯视频薅过羊毛, 2333333)

image

重点来了!!!

由于vipA 和vip 都是相同时限, 相同价格, 假如用户买完vipA, 苹果下发了一个凭证, 客户端透传给API, API拿着这个凭证去苹果校验, 苹果说这个凭证是真的, 然后API就给用户下发了一个月会员期, 然后用户马上再次购买vipB, 然后苹果会把vipA的钱给退掉, 扣掉vipB的钱, 这个时候会生成一份新的支付凭证, API拿这个凭证去苹果那里验证, 苹果也说这个凭证是真的, 然后支付就给再次给这个用户加了一个月的会员期限, 这样用户就有两个月会员期了, 一个是vipA的一个是vipB的. 关键是苹果已经把vipA的钱给退给了用户, 但是咱们给用户下发了两个月的会员期. 这就是被薅羊毛了. 当初就是这个问题特意去苹果中国那里去聊了一下, 最后和API沟通之后确认了一下扣减会员的方案, 主要是根据server-to-server通知, 具体如下

  1. 区分CANCLE收据

(1).切换成档位产生CANCLE通知: "notification_type":"CANCEL" "auto_renew_status":"true";

(2).取消订阅且切换了档位产生CANCLE通知: "notification_type":"CANCEL" "auto_renew_status":"false"

(3).正常的退款CANCEL通知:"notification_type":"CANCEL" "auto_renew_status":"false" "cancellation_date_ms":"1566487449000"

  1. 收到上面三种收据后根据original_transaction_id拿到userID

  2. 根据userID+CANCLE通知的transaction_id可以直接查询到具体哪一笔订单

  3. 根据CANCLE通知的时间和当前订单的create_time创建时间判断是否已经超过周期了

  4. 根据CANCLE通知的时间扣减会员

上述操作一定要留有操作记录, 否则用户发现自己会员期减少给客服投诉的时候要拿出完整的证据

7 transaction_id 不可靠

最近有个用户反馈他更换设备之后, 客户端告诉他会员扣费成功, 然后他就来我们客服那里投诉. 投诉我们会员没到期为什么扣费.

大体流程是, 用户买的是订阅会员, 10.1号到期了, 然后苹果给客户端下发了凭证, 也通过server-to-server告诉我们服务器有新的凭证了, 但是这两个凭证是同一个,transaction_id都一样,所以给用户下发了一个月的会员. 但是在10.17号用户更新了设备, 导致苹果给客户端又下发了一份凭证,这个凭证的transaction_id和以前的不一样, 数字比以前的加1了,而且苹果也告知是合法凭证, 我们服务器觉得这是一个新的续费凭证, 就给用户加了一个月的会员, 然后发送通知告诉用户续费成功, 所以用户对我们进行了投诉. 其实是我们公司亏了, 白给用户加了一个月的会员, 但是苹果没有给用户扣费. 后来和苹果的技术支持聊天, 告诉我们transaction_id不可靠, 最好还是用purchase-date-ms+bid+product-id来标识这个凭证的唯一.

下面是苹果的文档关于expires-date的介绍, 里面也说了, 更换设备会更改transaction_id

https://developer.apple.com/documentation/appstorereceipts/transaction_id?language=objc

20191031更新
后来和苹果的工程师沟通了一下, 用purchase-date-ms是毫秒, 所以这个值的后三位可能不可靠,建议用purchase-date比较, 聊的时候还说到了original-transaction-id, 这个字段在用户切换在App Store国家的时候购买也会变, 但是几乎很少有用户会触发

这个是凭证中字段的解释, 有问题还是看文档
https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html

8 transactionDate 不可靠

20200116更新
最近后台发现有很多客户端上传的transactionDate为空, 导致在鉴权的时候鉴权失败, 我这边查了下问题, 发现是苹果在支付结果返回的方法- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactionsSKPaymentTransactionStatePurchased情况, 返回的对象SKPaymentTransaction里面的transactionDatenil, 查了下苹果文档, 这个字段是将商品添加到server queue的时候的时间. 后来问了下服务端的同学, 发现这个字段没什么用, 只是判断是否为空, 然后就把这个鉴权给去掉了.

是在搞不懂, 苹果为啥有的时候把这个字段返回为nil, 坑

8 跨级凭证问题

20200120更新
今天偶然发现一个跨级的问题. 因为我们的一个订阅群组里是有四个商品, 分别是, 订阅周期一个月的A1,订阅周期三个月的A3 订阅周期六个月的A6, 订阅周期12个月的A12. 之前我司的策略是只要用户的会员在有效期内, 用户在切换商品的时候都是不允许的. 包括用户已经在苹果订阅取消订阅了, 但是还没过会员期, 也是无法购买的. 近期因为app合规问题, 所以我们放开了这个策略, 改成了无论是否在会员期内, 用户都可以购买其他订阅周期的商品.

现在我描述下用户的行为, 用户先买了一个A1, 然后用户再次购买A3商品, 因为A1和A3是同一等级, 但是有效期不一样, 用户的续费会在A1结束后再以A3进行续费. 用户在购买A3的时候返回的凭证里面的productID是A1商品的productID, 这里需要留意下

9 解析凭证新方法

以前解析苹果的扣费凭证都是这个凭证用base64工具解码, 后来看到一种新操作, 用命令行, 在命令行执行下面的代码, 就可以拿到凭证的信息了, 其实就是给苹果发请求验证凭证

正式环境的凭证

curl -i -H "Content-Type:application/json" -X POST -d '{"password":"秘钥(找后台要)","receipt-data":"凭证"}' https://buy.itunes.apple.com/verifyReceipt

沙箱环境的凭证

curl -i -H "Content-Type:application/json" -X POST -d '{"password":"秘钥(找后台要)","receipt-data":"凭证"}' https://sandbox.itunes.apple.com/verifyReceipt

20200304更新

有位大佬总结了获取不到IPA产品列表的原因,列了13条, 感觉挺全的, 如果有问题建议看看这篇文章
https://inneka.com/programming/ios/reasons-for-skproductsrequest-returning-0-products/

20200326更新
苹果在iOS13上把拉取商品请求的回调改成了异步线程回调, 这个时候如果有ui上的操作的时候, 要回调到主线程, 否则会crash
iOS13.4+Xcode11, 必现crash

附录

订阅促销签名的python代码 引自 链接

首先从iTunes Connect后台下载.p8格式的密钥,转换成.cer,方便后续用python读取:

openssl pkcs8 -nocrypt -in SubscriptionKey_xxxxxxxx.p8 -out cert.der -outform der


import json

import uuid

import time

import hashlib

import base64

from ecdsa import SigningKey

from ecdsa.util import sigencode_der

bundle_id = 'com.myapp'

key_id = 'XWSXTGQVX2'

product = 'com.myapp.product.a'

offer = 'REFERENCE_CODE' # This is the code set in ASC

application_username = 'user_name' # Should be the same you use when

                                  # making purchases

nonce = uuid.uuid4()

timestamp = int(round(time.time() * 1000))

payload = '\u2063'.join([bundle_id,

                        key_id,

                        product,

                        offer,

                        application_username,

                        str(nonce), # Should be lower case

                        str(timestamp)])

# Read the key file

with open('cert.der', 'rb') as myfile:

  der = myfile.read()

signing_key = SigningKey.from_der(der)

signature = signing_key.sign(payload.encode('utf-8'),

                            hashfunc=hashlib.sha256,

                            sigencode=sigencode_der)

encoded_signature = base64.b64encode(signature)

print(str(encoded_signature, 'utf-8'), str(nonce), str(timestamp), key_id)

有什么问题可以在下面讨论, 希望可以帮助到你

我也写了一篇WWDC2019关于内购新功能的文章

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,165评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,720评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,849评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,245评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,596评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,747评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,977评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,708评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,448评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,657评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,141评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,493评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,153评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,890评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,799评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,685评论 2 272