This article has participated in the activity of “New person creation Ceremony”, and started the road of digging gold creation together.

Other articles in the series:

[1] Apple In-app Purchase (IAP) from Starter to Master (1) – In-app purchase product types and configurations

[2] IAPS from Beginner to Master (2) – Bank card and tax information configuration

[3] App Purchase (IAP) from Beginner to Master (3) – Product Recharge Process (non-subscription)

[5] IAPS from beginner to master (5) – drop handling, hook prevention and some pitfalls

1. Recharge process (automatic subscription)

1.1. Purchase of goods

Equivalent to consumable goods purchases. It’s just adding a payment queue listener, initializing SKPayment and adding it to the payment queue, then making the payment, callback to Purchased. I won’t repeat it here. Look at this article for more details: Apple In-App Purchase (IAP) from Starter to Master (3) – Product Recharge Process (non-subscription). The main difference is in the back.

1.2. Bill verification

When requesting apple ticket verification, the request parameter needs to pass a new parameter called “shared key”. This is generated in the apple background where the product ID is configured as follows:

There are many differences between an automatic subscription bill and a consumable bill.

{ environment = Sandbox; "Latest_receipt" = "a long list of encrypted receipts at request "; "latest_receipt_info" = ( { "expires_date" = "2019-09-18 06:44:15 Etc/GMT"; "expires_date_ms" = 1568789055000; "expires_date_pst" = "2019-09-17 23:44:15 America/Los_Angeles"; "is_in_intro_offer_period" = false; "is_trial_period" = true; "original_purchase_date" = "2019-09-18 06:41:16 Etc/GMT"; "original_purchase_date_ms" = 1568788876000; "original_purchase_date_pst" = "2019-09-17 23:41:16 America/Los_Angeles"; "original_transaction_id" = 1000000569412514; "product_id" = "com.auto.pay6"; "purchase_date" = "2019-09-18 06:41:15 Etc/GMT"; "purchase_date_ms" = 1568788875000; "purchase_date_pst" = "2019-09-17 23:41:15 America/Los_Angeles"; quantity = 1; "subscription_group_identifier" = 20548697; "transaction_id" = 1000000569412514; "web_order_line_item_id" = 1000000046978708; }); "pending_renewal_info" = ( { "auto_renew_product_id" = "com.auto.pay6"; "Auto_renew_status" = 1; "Original_transaction_id" = 1000000569412514; "product_id" = "com.auto.pay6"; }); receipt = { "adam_id" = 0; "app_item_id" = 0; "application_version" = 1; "bundle_id" = "com.mytest.0522"; "download_id" = 0; "in_app" = ( { "expires_date" = "2019-09-18 06:44:15 Etc/GMT"; Need // Subscription expiration time "expires_date_ms" = 1568789055000; // subscription expiration timestamp "expires_date_pst" = "2019-09-17 23:44:15 America/Los_Angeles"; // Subscription expiration time (US) "is_in_intro_offer_period" = false; "Is_trial_period" = true; // Whether to enjoy free trial "original_purchase_date" = "2019-09-18 06:41:16 Etc/GMT"; // Original purchase time "original_purchase_date_MS" = 1568788876000; // Original_purchase_date_pst = "2019-09-17 23:41:16 America/Los_Angeles"; // Original_purchase_date_pst = "2019-09-17 23:41:16 America/Los_Angeles"; // Original purchase time (US) "original_transaction_id" = 1000000569412514; ID "product_id" = "com.auto. Pay6 "; // Commodity ID "purchase_date" = "2019-09-18 06:41:15 Etc/GMT"; // Purchase time "purchase_date_MS" = 1568788875000; // Purchase timestamp "purchase_date_pst" = "2019-09-17 23:41:15 America/Los_Angeles"; // Purchase time timestamp "purchase_date_pst" = "2019-09-17 23:41:15 America/Los_Angeles"; // Purchase time (US) quantity = 1; "Transaction_id" = 1000000569412514; // Order ID "web_order_line_item_id" = 1000000046978708; //// unique identifier for cross-device purchase events, including subscription update events. This value is the primary key that identifies subscription purchases}); "Original_application_version" = "1.0"; "original_purchase_date" = "2013-08-01 07:00:00 Etc/GMT"; "original_purchase_date_ms" = 1375340400000; "original_purchase_date_pst" = "2013-08-01 00:00:00 America/Los_Angeles"; "receipt_creation_date" = "2019-09-18 06:41:16 Etc/GMT"; "receipt_creation_date_ms" = 1568788876000; "receipt_creation_date_pst" = "2019-09-17 23:41:16 America/Los_Angeles"; "receipt_type" = ProductionSandbox; "request_date" = "2019-09-18 06:42:32 Etc/GMT"; "request_date_ms" = 1568788952928; "request_date_pst" = "2019-09-17 23:42:32 America/Los_Angeles"; "version_external_identifier" = 0; }; status = 0; }Copy the code

In in_app, the following fields are added

"expires_date" = "2019-09-18 06:44:15 Etc/GMT"; // Subscription expiration time "expires_date_ms" = 1568789055000; // subscription expiration timestamp "expires_date_pst" = "2019-09-17 23:44:15 America/Los_Angeles"; // Subscription expiration time (US) "is_in_intro_offer_period" = false; "Web_order_line_item_id" = 1000000046978708; "web_order_line_ITEM_ID" = 1000000046978708; //// unique identifier for cross-device purchase events, including subscription update events. This value is the primary key that identifies subscription purchasesCopy the code

When I looked at notes for other commodity types, I found that even “non-renewed subscription” items did not have these fields. Therefore, it can be determined that the possession of these fields means that this product is an automatic subscription product. (The server can use these 5 fields to determine whether the ticket is automatically subscribed.)

The logic of checking a bill is the same as that of consumable goods, if it passes the check. Notify the background to send props, update client UI, etc.

2. Renew your subscription

“Auto-subscribe” products, as the name suggests, are automatically deducted and renewed by Apple.

There are two most common situations in which automatic subscriptions fail:

(1) The user manually unsubscribes. Users need to manually go to Settings to unsubscribe. After cancellation, the fee will not be deducted from the next subscription cycle. At the same time, this logic needs to be handled within the game to stop the delivery of the subscription service for the next cycle.

(2) The bank card or credit card bound to the AppleID subscription is out of money. Apple will try repeatedly to deduct money. If none of the charges are successful, the subscription will be stopped. (There are pits, it will be said later)

In addition, Apple automatically deducts money to renew the subscription. There are two ways to determine the success of a renewal:

1. Each time the app is started, the client proactively obtains receipt_data and uploads it to the server to verify the receipt. The server checks whether there is a new renewal transaction in the receipt (the time can be determined), and then issues a new subscription product.

Ii. The verification mode of Server to Server is also recommended by Apple, and Apple will inform us of the status on its own initiative. You need to configure the subscription status URL in the background of Appstore Connect. For details, refer to apple’s official documentation to enable server notifications for automatic subscription renewal. You also need to use the shared secret key (as described above).

As you can see from the official documentation above, Apple provides two server-to-server interfaces, V1 and V2. The interfaces, parameters, and callbacks are different in both versions. V1 version is mainly used in the industry, which is more stable (I heard that V2 has some bugs), so we introduce V1 version here:

If server to server notifications are configured, the background receives the following status update notification types:

NOTIFICATION_TYPE describe
INITIAL_BUY For the first time the subscription
CANCEL unsubscribe
DID_RENEW Automatic renewal succeeded
INTERACTIVE_RENEWAL App or set up interactive renewal
DID_FAIL_TO_RENEW Billing issues could not be renewed
DID_RECOVER Expired subscriptions that could not be renewed in the past have been successfully renewed
DID_CHANGE_RENEWAL_STATUS The renewal status changed. Procedure
DID_CHANGE_RENEWAL_PREF Renew the downgrade
CONSUMPTION_REQUEST Initiate refund application
REFUND Refund success
PRICE_INCREASE_CONSENT The price status
INTERACTIVE_RENEWAL Renew your subscription interactively in App or Settings
REVOKE Can’t continue family sharing

With Server to Server, the server developer can perform logic with the user based on the status and callback of original_transaction_ID matching the order.

Note: The server is best served by the CANCEL type. This is because IAP is a fraud: if you buy a one-year membership and then call Apple customer service for a refund, the one-year membership is valid if the server doesn't process it.

(1) Renewal note:

In the first 10 days of renewal, Apple will conduct a pre-renewal check to ensure that users can properly deduct the money. If there is a problem with the previous check, the user is reminded of the problem that should be addressed.

In the first 24 hours of renewal, Apple will try to deduct the fee. Apple will try to deduct the fee several times. If it fails to deduct all the time, the fee will be stopped and the subscription will be cancelled passively. Note that Apple may try for up to 60 days if it’s a payment-related issue. You can tell if Apple is still trying by looking at is_IN_billing_retry_period in the receipt.

The same order credential can be used all the time, no matter how many times you renew your order, send any one of these credentials to Apple for verification and get all the order information and subscription status. The server needs to save the recipt_data of that order and request the Apple Ticket validation interface at the end of each cycle to get information on whether the user is still renewing based on the returned ticket information.

After renewal, there will be a new set of ticket data in in_app. Represents a new renewal. As follows:

{ environment = Sandbox; "Latest_receipt = = = = = = = = = = = a long string of notes character = = = = = = = = = = = ="; "latest_receipt_info" = ( { "expires_date" = "2019-09-23 09:18:17 Etc/GMT"; "expires_date_ms" = 1569230297000; "expires_date_pst" = "2019-09-23 02:18:17 America/Los_Angeles"; "is_in_intro_offer_period" = false; "is_trial_period" = true; "original_purchase_date" = "2019-09-23 09:15:18 Etc/GMT"; "original_purchase_date_ms" = 1569230118000; "original_purchase_date_pst" = "2019-09-23 02:15:18 America/Los_Angeles"; "original_transaction_id" = 1000000571201990; "product_id" = "com.auto.pay6"; "purchase_date" = "2019-09-23 09:15:17 Etc/GMT"; "purchase_date_ms" = 1569230117000; "purchase_date_pst" = "2019-09-23 02:15:17 America/Los_Angeles"; quantity = 1; "subscription_group_identifier" = 20548697; "transaction_id" = 1000000571201990; "web_order_line_item_id" = 1000000047083065; }, { "expires_date" = "2019-09-23 09:21:17 Etc/GMT"; "expires_date_ms" = 1569230477000; "expires_date_pst" = "2019-09-23 02:21:17 America/Los_Angeles"; "is_in_intro_offer_period" = false; "is_trial_period" = false; "original_purchase_date" = "2019-09-23 09:15:18 Etc/GMT"; "original_purchase_date_ms" = 1569230118000; "original_purchase_date_pst" = "2019-09-23 02:15:18 America/Los_Angeles"; "original_transaction_id" = 1000000571201990; "product_id" = "com.auto.pay6"; "purchase_date" = "2019-09-23 09:18:17 Etc/GMT"; "purchase_date_ms" = 1569230297000; "purchase_date_pst" = "2019-09-23 02:18:17 America/Los_Angeles"; quantity = 1; "subscription_group_identifier" = 20548697; "transaction_id" = 1000000571203602; "web_order_line_item_id" = 1000000047083066; }); "pending_renewal_info" = ( { "auto_renew_product_id" = "com.auto.pay6"; "auto_renew_status" = 1; "original_transaction_id" = 1000000571201990; "product_id" = "com.auto.pay6"; }); receipt = { "adam_id" = 0; "app_item_id" = 0; "application_version" = 1; "bundle_id" = "com.mytest.0522"; "download_id" = 0; "in_app" = ( { "expires_date" = "2019-09-23 09:21:17 Etc/GMT"; "expires_date_ms" = 1569230477000; "expires_date_pst" = "2019-09-23 02:21:17 America/Los_Angeles"; "is_in_intro_offer_period" = false; "is_trial_period" = false; "original_purchase_date" = "2019-09-23 09:15:18 Etc/GMT"; "original_purchase_date_ms" = 1569230118000; "original_purchase_date_pst" = "2019-09-23 02:15:18 America/Los_Angeles"; "original_transaction_id" = 1000000571201990; "product_id" = "com.auto.pay6"; "purchase_date" = "2019-09-23 09:18:17 Etc/GMT"; "purchase_date_ms" = 1569230297000; "purchase_date_pst" = "2019-09-23 02:18:17 America/Los_Angeles"; quantity = 1; "transaction_id" = 1000000571203602; "web_order_line_item_id" = 1000000047083066; }, { "expires_date" = "2019-09-23 09:18:17 Etc/GMT"; "expires_date_ms" = 1569230297000; "expires_date_pst" = "2019-09-23 02:18:17 America/Los_Angeles"; "is_in_intro_offer_period" = false; "is_trial_period" = true; "original_purchase_date" = "2019-09-23 09:15:18 Etc/GMT"; "original_purchase_date_ms" = 1569230118000; "original_purchase_date_pst" = "2019-09-23 02:15:18 America/Los_Angeles"; "original_transaction_id" = 1000000571201990; "product_id" = "com.auto.pay6"; "purchase_date" = "2019-09-23 09:15:17 Etc/GMT"; "purchase_date_ms" = 1569230117000; "purchase_date_pst" = "2019-09-23 02:15:17 America/Los_Angeles"; quantity = 1; "transaction_id" = 1000000571201990; "web_order_line_item_id" = 1000000047083065; }); "Original_application_version" = "1.0"; "original_purchase_date" = "2013-08-01 07:00:00 Etc/GMT"; "original_purchase_date_ms" = 1375340400000; "original_purchase_date_pst" = "2013-08-01 00:00:00 America/Los_Angeles"; "receipt_creation_date" = "2019-09-23 09:20:00 Etc/GMT"; "receipt_creation_date_ms" = 1569230400000; "receipt_creation_date_pst" = "2019-09-23 02:20:00 America/Los_Angeles"; "receipt_type" = ProductionSandbox; "request_date" = "2019-09-23 09:20:10 Etc/GMT"; "request_date_ms" = 1569230410417; "request_date_pst" = "2019-09-23 02:20:10 America/Los_Angeles"; "version_external_identifier" = 0; }; status = 0; }Copy the code

Let’s focus on the following fields in in_app:

  • Purchase_date: Represents the time of deduction for this current order. Renewal is also a deduction. In the case of renewal, that time coincides with the purchase_Date + subscription cycle from your last deduction data.
  • Original_purchase_date: The time that represents the first purchase of this automatically subscribed commodity. The server can be used to determine when the user started the subscription.
  • Original_transaction_id: indicates the ticket ID of the first subscription. Because the server must bind the ticket ID with its own orderId. So to keep track of which order is renewed, use this. Transaction_id changes every time, even when a subscription is restored, so do not use this field.

(2) Check the validity of bills

In general, apple will notify you when you renew your subscription whenever server to Server notification is implemented. But there is a small probability of not telling. It is recommended that the server record the last receipt (receipt_data) and poll with the last receipt periodically 24 hours before expires_date. If the user fails to renew the subscription, check is_IN_billing_RETRy_period. If this is true, Put it in the next training queue and continue checking until IS_IN_billing_retry_period is false, indicating that Apple has abandoned the deduction. If the renewal is successful, the server gets the latest bill and determines whether the time, commodity ID and order ID corresponding to the original bill ID are valid. After that, renew the subscription to the user corresponding to the order ID and issue the corresponding props or permissions.

(3) Subscription cycle in sandbox environment

In a sandbox environment, the time limit is shortened when testing auto-renew subscriptions. In addition, the maximum number of subscriptions per day can be automatically renewed 12 times (including the first subscription).

The actual time limit The test time
1 week 3 minutes
1 month 5 minutes
2 months Ten minutes
3 months 15 minutes
6 months 30 minutes
1 year 1 hour

And by canceling subscriptions, Apple can’t simulate that. So it is generally used to create a new sandbox account to solve.

3. Restore the subscription

For example, A user (person) buys VIP (automatic subscription product) of App on device A. He bought a new phone B and redownloaded the App. However, the App’s VIP is the local Keychain cache setting. After the device is changed, the keychain is not cached, and the user does not enjoy VIP status on device B. But customers pay for the service, so Apple offers a “restore” service that restores access to subscriptions. This is normal logic.

But there’s another case where Apple allows it, but bugs it. The subscription service is tied to the AppleID. But each of our apps basically has its own user system, which has nothing to do with the AppleID (in fact, iOS developers can’t get the user’s AppleID either). Therefore, apple requires: as long as the current App is downloaded by an AppleID that has subscribed, any App account under the App can enjoy the subscription permission.

Isn’t that tricky? Let’s take the simplest example (really the simplest) :

User A has AppleID_A purchased A member (automatic subscription) for account A under this APP. At this point, user A changes account B. Then this account B also needs to enjoy membership. If not, the APP can open A “restore subscription” function, so that user A can restore the subscription service to account B.

Because of Apple’s unreasonable demand, the actual situation is much more complicated. AB user, AB device, AB Apple ID, AB account, AB role…… We will not expand too much here, specific scenes need to be combined with their own APP for debugging.

The restoration process is as follows.

Enable subscription recovery:

[[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; // Restore subscribed items
Copy the code

After clicking the resume button, the payment queue is enabled to listen to the status of the subscribed item.

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
    for (int i = 0; i < [transactions count]; ++i)
    {
        SKPaymentTransaction *transaction = [transactions objectAtIndex:i];
        switch (transaction.transactionState)
        {
            case SKPaymentTransactionStatePurchasing: / / consumption
                / /...
                break;
            case SKPaymentTransactionStatePurchased: // Successful consumption
                / /...
                break;
            case SKPaymentTransactionStateFailed:    // Fail to consume
                / /...
                break;
            case SKPaymentTransactionStateRestored:  // Restore purchased items (consumable items cannot be restored)
            {
            _isRestoring = YES;
            NSLog(@"Restore subscription items: %@; Subscribe to buy time: %@",transaction.originalTransaction.originalTransaction,transaction.transactionDate);
            }
                break;
            default:            // The purchase is pending
                break; }}}Copy the code

UpdatedTransactions agent method, SKPaymentTransactionStateRestored represents the subscribe to restore state. If commodity A is paid 3 times from purchase to renewal (i.e., 2 renewals), then there will be 3 transactions in the transaction. But these transactions are all in one ticket, so the recommendation is not to do much processing here.

Where do you tell the server to revalidate the ticket? The following methods:

/ / proxy method from SKPaymentTransactionObserver
// sent when all transactions from the user's purchase history are successfully added back to the queue
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue
{
    // Subscriptions to be restored
    if (_isRestoring) {
        _isRestoring = NO;  // Restore the default value
        [self verifyTransactionReceiptWithQueue:queue];  // Check the ticket}}// Emulated the server to verify the ticket logic (this is best left to the server to handle)
- (void)verifyTransactionReceiptWithQueue:(SKPaymentQueue *)paymentQueue
{
    NSString *localTestRequestUrl = @"https://sandbox.itunes.apple.com/verifyReceipt";
    NSData *receiptData = [NSData dataWithContentsOfURL:[[NSBundle mainBundle] appStoreReceiptURL]];
    NSString *receiptNewStr = [receiptData base64EncodedStringWithOptions:0];
    
    [self localReceiptVerifyingWithUrl:localTestRequestUrl AndReceipt:receiptNewStr AndPaymentQueue:paymentQueue];
}

// Verify the recovery ticket
- (void)localReceiptVerifyingWithUrl:(NSString *)requestUrl AndReceipt: (NSString *)receiptStr AndPaymentQueue: (SKPaymentQueue *)paymentQueue
{
    NSDictionary *requestContents = @{
                                      @"receipt-data": receiptStr,
                                      @"password" : @"48f920000fd8440d98262000003370e3"
                                      };
    NSError *error;
    // Convert to JSON format
    NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents
                                                          options:0
                                                            error:&error];
    NSString *verifyUrlString = requestUrl;
    NSMutableURLRequest *storeRequest = [NSMutableURLRequest requestWithURL:[[NSURL alloc] initWithString:verifyUrlString] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:10.0f];
    [storeRequest setHTTPMethod:@"POST"];
    [storeRequest setHTTPBody:requestData];
    
    // Submit the validation request in the background column and get the official validation JSON result
    NSURLSession *session = [NSURLSession sharedSession];
    NSURLSessionDataTask *task = [session dataTaskWithRequest:storeRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (error) {
            NSLog(@"Link failed");
            for (SKPaymentTransaction *transaction in paymentQueue.transactions) {
                [[SKPaymentQueuedefaultQueue] finishTransaction:transaction]; }}else {
            NSError *error;
            NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
            if (!jsonResponse) {
                NSLog(@"Verification failed");
                for (SKPaymentTransaction *transaction in paymentQueue.transactions) {
                    [[SKPaymentQueuedefaultQueue] finishTransaction:transaction]; }}NSLog(@"Verification successful");
            //TODO:Take this JSON data to determine if the item is delivered}}]; [task resume]; }Copy the code

PaymentQueueRestoreCompletedTransactionsFinished: method will be called only once. All recovered ticket information is in [[NSBundle mainBundle] appStoreReceiptURL]. So I’m going to request validation from the server at this location.

4. Unsubscribe

No client to listen, server to server form, waiting for Apple callback. If the user unsubscribes, Apple will call back a ticket with a CANCEL status. Then inform the client to cancel the subscription service of the corresponding account of the App (you can also process the subscription goods in the following ways: inform the client to deliver the goods every time the subscription is renewed; if the client unsubscribes, it does not inform the client that the operation is not done; if the client does not receive the message, it will not continue to deliver the goods).

Note: Apple interface, there is a small probability of "renew or unsubscribe do not actively inform" pit. Therefore, it is recommended that the App server poll and verify the Receipt_data of each ticket 24 hours before the subscription expires to check whether the subscription is renewed or unsubscribed.

The order refunded by the user will still appear in the receipt, so the App server needs to be able to identify the refunded order when implementing verification, so as not to deliver the refunded order.

The only indication of a refundable order is that it has a cancellation_date field. When the server validates the credentials, if this field is present, the goods are not distributed.

References:

[1] iOS automatic subscription development

[2] iOS IAP (18) — Automatic subscription renewal (1)

[3] Restore the purchase record of internal purchase