Проблема с проверкой квитанции об автоматическом продлении подписки на iOS

Apple отклоняет мое приложение для iOS по следующей причине:

  • 2.1 После покупки In App Purchase App Store прошел аутентификацию и подтверждение, но приложению не удалось проверить квитанцию.

Я не сталкивался с этим, когда тестировал приложение в песочнице. Я успешно отключаю рекламу при покупке — вот пример видео, когда я делал покупку на iPad и после этого восстанавливал ее на iPhone:

https://photos.app.goo.gl/jeH1gtSKroF7QjVCA

Я обрабатываю все покупки в своем классе IAPManager — полный код ниже:

//
//  IAPManager.m
//  Sudoku
//
//  Created by szulcu on 03/06/2019.
//  Copyright © 2019 AliorBank. All rights reserved.
//

#import "IAPManager.h"
#import "Utils.h"

@interface IAPManager() <SKProductsRequestDelegate, SKPaymentTransactionObserver>

@property(strong, nonatomic) SKProductsRequest *productsRequest;
@property(strong, nonatomic) NSArray<SKProduct*> *validProducts;
@property(nonatomic, assign) long long validPurchaseMs;
@property(strong, nonatomic) NSTimer *timer;

@end

@implementation IAPManager

+ (instancetype)sharedInstance
{
    static IAPManager *sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedInstance = [[IAPManager alloc] init];
        sharedInstance.validPurchaseMs = -1;
    });
    return sharedInstance;
}

- (void)restorePurchase
{
    [SKPaymentQueue.defaultQueue restoreCompletedTransactions];
}

- (void)addTransactionObserver{
    [SKPaymentQueue.defaultQueue addTransactionObserver:self];
}

- (void)removeTransactionObserver{
    [SKPaymentQueue.defaultQueue removeTransactionObserver:self];
}

- (void)fetchAvailableProducts
{
    NSSet *productIdentifiers = [NSSet setWithArray:_productIdentifiers];
    _productsRequest = [[SKProductsRequest alloc]
                        initWithProductIdentifiers:productIdentifiers];
    _productsRequest.delegate = self;
    [_productsRequest start];
}

- (BOOL)canMakePurchases
{
    return [SKPaymentQueue canMakePayments];
}

- (void)purchaseMyProduct:(SKProduct*)product
{
    SKPayment *payment = [SKPayment paymentWithProduct:product];
    [[SKPaymentQueue defaultQueue] addPayment:payment];
}

#pragma mark StoreKit Delegate

-(void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                NSLog(@"Purchasing");
                break;

            case SKPaymentTransactionStatePurchased:
                NSLog(@"Purchased ");
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
                if(self.delegate != nil)
                {
                    if([self.delegate  respondsToSelector:@selector(productWithIdentifier:valid:)])
                    {
                        [self.delegate productWithIdentifier:transaction.payment.productIdentifier valid:[self checkInAppPurchaseStatus]];
                    }
                }
                break;

            case SKPaymentTransactionStateRestored:
                NSLog(@"Restored ");
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
                if(self.delegate != nil)
                {
                    if([self.delegate  respondsToSelector:@selector(productWithIdentifier:valid:)])
                    {
                        [self.delegate productWithIdentifier:transaction.payment.productIdentifier valid:[self checkInAppPurchaseStatus]];
                    }
                }
                break;

            case SKPaymentTransactionStateFailed:
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
                NSLog(@"Purchase failed ");
                break;
            default:
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
                break;
        }

        if(self.delegate != nil)
        {
            if([self.delegate  respondsToSelector:@selector(transactionStateChanged:)])
            {
                [self.delegate transactionStateChanged:transaction.transactionState];
            }
        }

    }
}

-(void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
    if ([response.products count] > 0) {
        _validProducts = response.products;
    }
}

- (BOOL)hasProducts
{
    return _validProducts !=nil;
}

- (void)showPurchaseDialogInViewController:(UIViewController*) viewController completion:(void (^)(NSString*)) completion
{
    if([self canMakePurchases] == NO)
    {
        UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"iap_warning", nil) message:NSLocalizedString(@"iap_disabled_msg", nil) preferredStyle:UIAlertControllerStyleAlert];

        UIAlertAction *cancel = [UIAlertAction actionWithTitle:NSLocalizedString(@"dialogNewGameOk", nil) style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
            completion(IAP_DIALOG_COMPLETION_CANCEL);
        }];

        [alertController addAction:cancel];
        [viewController presentViewController:alertController animated:YES completion:nil];
    }
    else if(self.hasProducts)
    {

        UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"iap_purchase", nil) message:@"\n\n\n\n\n\n\n\n" preferredStyle:UIAlertControllerStyleActionSheet];

        weakify(self)

        CGFloat margin = 8;
        int textViewWidth = alertController.view.bounds.size.width - margin*4;
        if ([Utils isIpad]) {
            textViewWidth = 304 - margin*2;
        }

        UITextView *linkTextView = [[UITextView alloc] initWithFrame:CGRectMake(margin, margin*4, textViewWidth+100, 60)];
        linkTextView.text = NSLocalizedString(@"iap_select_subscription_link", nil);
        linkTextView.backgroundColor = UIColor.clearColor;
        linkTextView.scrollEnabled = NO;
        linkTextView.dataDetectorTypes = UIDataDetectorTypeLink;
        linkTextView.editable = NO;
        [alertController.view addSubview:linkTextView];

        UITextView *descriptionTextView = [[UITextView alloc] initWithFrame:CGRectMake(margin, margin*4 + 60, textViewWidth, 120)];
        descriptionTextView.text = NSLocalizedString(@"iap_select_subscription", nil);
        descriptionTextView.backgroundColor = UIColor.clearColor;
        descriptionTextView.scrollEnabled = YES;
        descriptionTextView.showsVerticalScrollIndicator = YES;
        descriptionTextView.dataDetectorTypes = UIDataDetectorTypeLink;
        descriptionTextView.editable = NO;
        [alertController.view addSubview:descriptionTextView];
        ///

        for(SKProduct *product in _validProducts)
        {
            UIAlertAction *action = [UIAlertAction actionWithTitle:[NSString stringWithFormat:@"%@ - %@", product.localizedTitle, product.localizedDescription] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
                [weak_self purchaseMyProduct:product];
                completion(IAP_DIALOG_COMPLETION_PURCHASED);
            }];
            [alertController addAction:action];
        }

        UIAlertAction *restore = [UIAlertAction actionWithTitle:NSLocalizedString(@"iap_restore_purchase", nil) style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
            [weak_self restorePurchase];
            completion(IAP_DIALOG_COMPLETION_RESTORED);
        }];

        UIAlertAction *cancel = [UIAlertAction actionWithTitle:NSLocalizedString(@"dialogNewGameCancel", nil) style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
           completion(IAP_DIALOG_COMPLETION_CANCEL);
        }];

        [alertController addAction:restore];
        [alertController addAction:cancel];

        [alertController.popoverPresentationController setPermittedArrowDirections:0];
        CGRect rect = viewController.view.frame;
        rect.origin.x = viewController.view.frame.size.width/20;
        rect.origin.y = viewController.view.frame.size.height/20;
        alertController.popoverPresentationController.sourceView = viewController.view;
        alertController.popoverPresentationController.sourceRect = rect;
        [viewController presentViewController:alertController animated:YES completion:nil];
    }
    else
    {

        UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"iap_warning", nil) message:NSLocalizedString(@"iap_no_products_warning", nil) preferredStyle:UIAlertControllerStyleActionSheet];

        UIAlertAction *ok = [UIAlertAction actionWithTitle:NSLocalizedString(@"dialogNewGameOk", nil) style:UIAlertActionStyleCancel handler:nil];

        [alertController addAction:ok];
        [viewController presentViewController:alertController animated:YES completion:nil];
    }
}

- (BOOL)checkInAppPurchaseStatus
{
    if(self.validPurchaseMs > 0)
    {
        return YES;
    }

    // Load the receipt from the app bundle.
    NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
    NSData *receipt = [NSData dataWithContentsOfURL:receiptURL];
    if (receipt) {
        BOOL sandbox = [[receiptURL lastPathComponent] isEqualToString:@"sandboxReceipt"];
        // Create the JSON object that describes the request
        NSError *error;
        NSDictionary *requestContents = @{
            @"receipt-data": [receipt base64EncodedStringWithOptions:0], @"password":IAP_SHARED_SECRET, @"exclude-old-transactions" : @YES
                                          };
        NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents
                                                              options:0
                                                                error:&error];

        if (requestData) {
            // Create a POST request with the receipt data.
            NSURL *storeURL = [NSURL URLWithString:@"https://buy.itunes.apple.com/verifyReceipt"];
            if (sandbox) {
                storeURL = [NSURL URLWithString:@"https://sandbox.itunes.apple.com/verifyReceipt"];
            }
            NSMutableURLRequest *storeRequest = [NSMutableURLRequest requestWithURL:storeURL];
            [storeRequest setHTTPMethod:@"POST"];
            [storeRequest setHTTPBody:requestData];

            BOOL validPurchase = NO;
            //Can use sendAsynchronousRequest to request to Apple API, here I use sendSynchronousRequest
            NSError *error;
            NSURLResponse *response;

            NSData *resData = [self sendSynchronousRequest:storeRequest returningResponse:&response error:&error];

            if (error) {
                validPurchase = NO;
            }
            else
            {
                NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:resData options:0 error:&error];
                if (!jsonResponse) {
                    validPurchase = NO;
                }
                else
                {
                    NSLog(@"jsonResponse:%@", jsonResponse);

                    NSArray *latestReceiptsInfo = jsonResponse[@"latest_receipt_info"];
                    long long expirationDateMs = [[latestReceiptsInfo valueForKeyPath:@"@max.expires_date_ms"] longLongValue];
                    long long requestDateMs = [jsonResponse[@"receipt"][@"request_date_ms"] longLongValue];
                    NSLog(@"%lld--%lld", expirationDateMs, requestDateMs);
                    validPurchase = [[jsonResponse objectForKey:@"status"] integerValue] == 0 && (expirationDateMs > requestDateMs);
                    self.validPurchaseMs = expirationDateMs - requestDateMs;

                    if(self.validPurchaseMs > 0)
                    {
                        weakify(self)

                        if (@available(iOS 10.0, *)) {
                            _timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
                                [weak_self timerAction];
                            }];
                        } else {
                            // Fallback on earlier versions
                            _timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
                        }
                    }
                }
            }
            return validPurchase;
        }
        else
        {
            return NO;
        }
    }
    else
    {
        return NO;
    }
}

- (void)timerAction
{
    self.validPurchaseMs -= 1000;
    if(self.validPurchaseMs < 0)
    {
        [self.timer invalidate];
        [self.delegate productWithIdentifier:@"" valid:NO];
    }
}

- (NSData *)sendSynchronousRequest:(NSURLRequest *)request returningResponse:(NSURLResponse **)response error:(NSError **)error
{

    NSError __block *err = NULL;
    NSData __block *data;
    BOOL __block reqProcessed = false;
    NSURLResponse __block *resp;

    [[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable _data, NSURLResponse * _Nullable _response, NSError * _Nullable _error) {
        resp = _response;
        err = _error;
        data = _data;
        reqProcessed = true;
    }] resume];

    while (!reqProcessed) {
        [NSThread sleepForTimeInterval:0];
    }

    *response = resp;
    *error = err;
    return data;
}

- (void)paymentQueue:(SKPaymentQueue *)queue removedTransactions:(NSArray<SKPaymentTransaction *> *)transactions{
    for(SKPaymentTransaction *transaction in transactions){
        NSLog(@"Transaction: %@", transaction);
    }
}

// Sent when an error is encountered while adding transactions from the user's purchase history back to the queue.
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error{

}

// Sent when all transactions from the user's purchase history have successfully been added back to the queue.
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue{

}

// Sent when the download state has changed.
- (void)paymentQueue:(SKPaymentQueue *)queue updatedDownloads:(NSArray<SKDownload *> *)downloads{

}

// Sent when a user initiates an IAP buy from the App Store
- (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *)payment forProduct:(SKProduct *)product{
    return true;
}

- (void)paymentQueueDidChangeStorefront:(SKPaymentQueue *)queue{

}

@end

Пожалуйста, помогите понять, что может быть не так.


person Marcin Szulc    schedule 07.05.2020    source источник
comment
Вы не должны завершать транзакцию, пока не будет завершена любая необходимая проверка. Вы не должны проверять квитанцию ​​​​на серверах Apple из своего приложения; Он подвержен спуфинговым атакам MITM и раскрывает ваш общий секрет. Если вы хотите подтвердить квитанцию, вам нужно сделать запрос на свой собственный сервер, и этот сервер проверит квитанцию ​​​​в Apple.   -  person Paulw11    schedule 08.05.2020
comment
Что наиболее важно, ваш код не проверяет код ответа от рабочей конечной точки, который указывает, что у вас есть квитанция песочницы, которую необходимо проверить на конечной точке песочницы; Когда Apple проверит ваше приложение, вы получите квитанцию ​​песочницы, даже если ваш код находится в режиме выпуска.   -  person Paulw11    schedule 08.05.2020


Ответы (1)


https://developer.apple.com/documentation/storekit/in-app_purchase/validating_receipts_with_the_app_store.

Предупреждение

Не вызывайте конечную точку VerifyReceipt сервера App Store из своего приложения. Вы не можете построить доверенное соединение между устройством пользователя и App Store напрямую, потому что вы не контролируете ни один из концов этого соединения, что делает его уязвимым для атаки «человек посередине».

Я думаю, что это причина, по которой Apple отклоняет ваше приложение.

person Rohit Chauhan    schedule 23.07.2020