Skip to content
This repository has been archived by the owner on Mar 4, 2020. It is now read-only.

Configure Subscription Durations #230

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
35 changes: 32 additions & 3 deletions MKStoreKit.h
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,14 @@ extern NSString *const kMKStoreKitRestoringPurchasesFailedNotification;
extern NSString *const kMKStoreKitReceiptValidationFailedNotification;

/*!
* @abstract This notification is posted when MKStoreKit detects expiration of a auto-renewing subscription
* @abstract This notification is posted when MKStoreKit detects expiration of an auto-renewing subscription
*/
extern NSString *const kMKStoreKitSubscriptionExpiredNotification;

/*!
* @abstract This notification is posted when MKStoreKit updates the expiration date of an auto-renewing subscription
*/
extern NSString *const kMKStoreKitSubscriptionDateUpdatedNotification;

/*!
* @abstract The singleton class that takes care of In App Purchasing
Expand Down Expand Up @@ -162,7 +166,7 @@ extern NSString *const kMKStoreKitSubscriptionExpiredNotification;
* @abstract Refreshes the App Store receipt and prompts the user to authenticate.
*
* @discussion
* This method can generate a reciept while debugging your application. In a production
* This method can generate a receipt while debugging your application. In a production
* environment this should only be used in an appropriate context because it will present
* an App Store login alert to the user (without explanation).
*/
Expand All @@ -187,7 +191,7 @@ extern NSString *const kMKStoreKitSubscriptionExpiredNotification;
*
* @discussion
* This method checks against the local store maintained by MKStoreKit when the app was originally purchased
* This method can be used to determine if a user should recieve a free upgrade. For example, apps transitioning
* This method can be used to determine if a user should receive a free upgrade. For example, apps transitioning
* from a paid system to a freemium system can determine if users are "grandfathered-in" and exempt from extra
* freemium purchases.
*
Expand All @@ -209,6 +213,31 @@ extern NSString *const kMKStoreKitSubscriptionExpiredNotification;
*/
- (BOOL)isProductPurchased:(NSString *)productId;

/*!
* @abstract Returns the duration of an auto-renewing subscription product
*
* @discussion
* This method reads the duration from MKStoreKitConfigs.plist
*
* @seealso
* -expiryDateForProduct
*/
- (NSInteger)subscriptionDurationForProduct:(NSString *)productId;

/*!
* @abstract Returns a JSON formated receipt
*
* @discussion
* The receipt can be sent to a subscription server for validation before returning subscription data.
* It contains two items:
* receipt-data -> the base64 encoded receipt data and the shared secret
* password -> the shared secret for the App Store
*
* @seealso
* -expiryDateForProduct
*/
- (NSData *)receiptJSONData;

/*!
* @abstract Checks the expiry date for the product identified by the given productId
*
Expand Down
70 changes: 48 additions & 22 deletions MKStoreKit.m
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
NSString *const kMKStoreKitRestoringPurchasesFailedNotification = @"com.mugunthkumar.mkstorekit.failedrestoringpurchases";
NSString *const kMKStoreKitReceiptValidationFailedNotification = @"com.mugunthkumar.mkstorekit.failedvalidatingreceipts";
NSString *const kMKStoreKitSubscriptionExpiredNotification = @"com.mugunthkumar.mkstorekit.subscriptionexpired";
NSString *const kMKStoreKitSubscriptionDateUpdatedNotification = @"com.mugunthkumar.mkstorekit.subscriptiondateupdated";

NSString *const kSandboxServer = @"https://sandbox.itunes.apple.com/verifyReceipt";
NSString *const kLiveServer = @"https://buy.itunes.apple.com/verifyReceipt";
Expand Down Expand Up @@ -104,7 +105,7 @@ + (void)initialize {
errorDictionary = @{@(21000) : @"The App Store could not read the JSON object you provided.",
@(21002) : @"The data in the receipt-data property was malformed or missing.",
@(21003) : @"The receipt could not be authenticated.",
@(21004) : @"The shared secret you provided does not match the shared secret on file for your accunt.",
@(21004) : @"The shared secret you provided does not match the shared secret on file for your account.",
@(21005) : @"The receipt server is not currently available.",
@(21006) : @"This receipt is valid but the subscription has expired.",
@(21007) : @"This receipt is from the test environment.",
Expand Down Expand Up @@ -157,9 +158,17 @@ - (BOOL)isProductPurchased:(NSString *)productId {
return [self.purchaseRecord.allKeys containsObject:productId];
}

- (NSInteger)subscriptionDurationForProduct:(NSString *)productId {
NSDictionary *subscriptions = [MKStoreKit configs][@"Subscriptions"];
NSNumber *duration = subscriptions[productId];
if (nil == duration) return -1;
return [duration integerValue];
}

-(NSDate*) expiryDateForProduct:(NSString*) productId {

NSNumber *expiresDateMs = self.purchaseRecord[productId];
if (nil == expiresDateMs || [[NSNull null] isEqual:expiresDateMs]) return nil;
return [NSDate dateWithTimeIntervalSince1970:[expiresDateMs doubleValue] / 1000.0f];
}

Expand Down Expand Up @@ -189,10 +198,12 @@ - (void)startProductRequest {
NSMutableArray *productsArray = [NSMutableArray array];
NSArray *consumables = [[MKStoreKit configs][@"Consumables"] allKeys];
NSArray *others = [MKStoreKit configs][@"Others"];

NSArray *subscriptions = [[MKStoreKit configs][@"Subscriptions"] allKeys];

[productsArray addObjectsFromArray:consumables];
[productsArray addObjectsFromArray:others];

[productsArray addObjectsFromArray:subscriptions];

SKProductsRequest *productsRequest = [[SKProductsRequest alloc]
initWithProductIdentifiers:[NSSet setWithArray:productsArray]];
productsRequest.delegate = self;
Expand Down Expand Up @@ -280,38 +291,46 @@ - (void)requestDidFinish:(SKRequest *)request {
NSLog(@"App receipt exists. Preparing to validate and update local stores.");
[self startValidatingReceiptsAndUpdateLocalStore];
} else {
NSLog(@"Receipt request completed but there is no receipt. The user may have refused to login, or the reciept is missing.");
NSLog(@"Receipt request completed but there is no receipt. The user may have refused to login, or the receipt is missing.");
// Disable features of your app, but do not terminate the app
}
}
}

- (void)startValidatingAppStoreReceiptWithCompletionHandler:(void (^)(NSArray *receipts, NSError *error)) completionHandler {
- (NSData *)receiptJSONData {
NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
NSError *receiptError;
BOOL isPresent = [receiptURL checkResourceIsReachableAndReturnError:&receiptError];
if (!isPresent) {
// No receipt - In App Purchase was never initiated
completionHandler(nil, nil);
return;
NSLog(@"No receipt in NSBundle.appStoreReceiptURL - In App Purchase was never initiated: %@", receiptError.localizedDescription);
return nil;
}

NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
if (!receiptData) {
// Validation fails
NSLog(@"Receipt exists but there is no data available. Try refreshing the reciept payload and then checking again.");
completionHandler(nil, nil);
return;
NSLog(@"Receipt exists but there is no data available. Try refreshing the receipt payload and then checking again.");
return nil;
}

NSError *error;
NSMutableDictionary *requestContents = [NSMutableDictionary dictionaryWithObject:
[receiptData base64EncodedStringWithOptions:0] forKey:@"receipt-data"];
NSString *sharedSecret = [MKStoreKit configs][@"SharedSecret"];
if (sharedSecret) requestContents[@"password"] = sharedSecret;

NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents options:0 error:&error];


return requestData;
}

- (void)startValidatingAppStoreReceiptWithCompletionHandler:(void (^)(NSArray *receipts, NSError *error)) completionHandler {
NSData *requestData = [self receiptJSONData];
if (nil == requestData) {
completionHandler(nil, nil);
return;
}

#ifdef DEBUG
NSMutableURLRequest *storeRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:kSandboxServer]];
#else
Expand Down Expand Up @@ -365,18 +384,24 @@ - (void)startValidatingReceiptsAndUpdateLocalStore {
__block BOOL purchaseRecordDirty = NO;
[receipts enumerateObjectsUsingBlock:^(NSDictionary *receiptDictionary, NSUInteger idx, BOOL *stop) {
NSString *productIdentifier = receiptDictionary[@"product_id"];
NSNumber *expiresDateMs = receiptDictionary[@"expires_date_ms"];
NSNumber *previouslyStoredExpiresDateMs = self.purchaseRecord[productIdentifier];
if (expiresDateMs && ![expiresDateMs isKindOfClass:[NSNull class]] && ![previouslyStoredExpiresDateMs isKindOfClass:[NSNull class]]) {
if ([expiresDateMs doubleValue] > [previouslyStoredExpiresDateMs doubleValue]) {
self.purchaseRecord[productIdentifier] = expiresDateMs;
purchaseRecordDirty = YES;
NSNumber *cenceledDateMs = receiptDictionary[@"cancellation_date_ms"];
if (!cenceledDateMs || [cenceledDateMs isKindOfClass:[NSNull class]]) { // transaction not cancelled
NSNumber *expiresDateMs = receiptDictionary[@"expires_date_ms"];
if (expiresDateMs && ![expiresDateMs isKindOfClass:[NSNull class]]) { // check if expiry date is later than the stored date
if ([previouslyStoredExpiresDateMs isKindOfClass:[NSNull class]] || [expiresDateMs doubleValue] > [previouslyStoredExpiresDateMs doubleValue]) {
self.purchaseRecord[productIdentifier] = expiresDateMs;
purchaseRecordDirty = YES;
}
}
}
}];

if (purchaseRecordDirty) [self savePurchaseRecord];

if (purchaseRecordDirty) {
[self savePurchaseRecord];
[[NSNotificationCenter defaultCenter] postNotificationName:kMKStoreKitSubscriptionDateUpdatedNotification object:nil];
}

[self.purchaseRecord enumerateKeysAndObjectsUsingBlock:^(NSString *productIdentifier, NSNumber *expiresDateMs, BOOL *stop) {
if (![expiresDateMs isKindOfClass: [NSNull class]]) {
if ([[NSDate date] timeIntervalSince1970] > [expiresDateMs doubleValue]) {
Expand Down Expand Up @@ -439,7 +464,7 @@ - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)tran
}

[queue finishTransaction:transaction];

NSDictionary *availableConsumables = [MKStoreKit configs][@"Consumables"];
NSArray *consumables = [availableConsumables allKeys];
if ([consumables containsObject:transaction.payment.productIdentifier]) {
Expand All @@ -457,6 +482,7 @@ - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)tran
}

[self savePurchaseRecord];
[self startValidatingReceiptsAndUpdateLocalStore]; // to get subscription expiry date
[[NSNotificationCenter defaultCenter] postNotificationName:kMKStoreKitProductPurchasedNotification
object:transaction.payment.productIdentifier];
}
Expand Down
13 changes: 7 additions & 6 deletions README.mdown
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#MKStoreKit

An in-App Purchase framework for iOS 7.0+. **MKStoreKit** makes in-App Purchasing super simple by remembering your purchases, validating reciepts, and tracking virtual currencies (consumable purchases). Additionally, it keeps track of auto-renewable subscriptions and their expirationd dates. It couldn't be easier!
An in-App Purchase framework for iOS 7.0+. **MKStoreKit** makes in-App Purchasing super simple by remembering your purchases, validating receipts, and tracking virtual currencies (consumable purchases). Additionally, it keeps track of auto-renewable subscriptions and their expiration dates. It couldn't be easier!

## Compatibility
See the table below for details on MKStoreKit's requirements and compatibility.
Expand All @@ -9,7 +9,7 @@ See the table below for details on MKStoreKit's requirements and compatibility.
|:-------:|:------:|:-----------------------:|:-----------:|:------------:|:------------:|
| 6.0 | Beta 1 | MKStoreKit Version 6.0 | iOS 7.0 + | 10.10 + | YES |

MKStoreKit 6 is a **complete revamp** of the project and is not compatible with previous versions of MKStoreKit. Refactoring should however be fairly simple. See the *Backwards Compatibility* column to determine the earliest comaptible version with the current release.
MKStoreKit 6 is a **complete revamp** of the project and is not compatible with previous versions of MKStoreKit. Refactoring should however be fairly simple. See the *Backwards Compatibility* column to determine the earliest compatible version with the current release.

*MKStoreKit 6 is still in early beta (see the Stage column). Use with caution*

Expand Down Expand Up @@ -39,10 +39,11 @@ It's close to impossible to maintain a working sample code for MKStoreKit as iTu

###Config File Format
MKStoreKit uses a config file, MKStoreKitConfigs.plist for managing your product identifiers.
The config file is a Plist dictionary containing three keys, "Consumables", "Others" and "SharedSecret"
The config file is a Plist dictionary containing three keys, "Consumables", "Subscriptions", "Others" and "SharedSecret"

Consumables is the key where you provide a list of consumables in your app that should be managed as a virtual currency.
Others is the key where you provide a list of in app purchasable products
Consumables is the key where you provide a list of consumables in your app that should be managed as a virtual currency.
Subscriptions is the key where you provide a list of subscriptions and their durations.
Others is the key where you provide a list of in app purchasable products.
SharedSecret is the key where you provide the shared secret generated on your iTunesConnect account.

Here is a sample [MKStoreKitConfigs.plist](https://gist.github.com/MugunthKumar/330fc38b542c96fcecc6)
Expand All @@ -56,7 +57,7 @@ Initialization is as simple as calling
[[MKStoreKit sharedKit] startProductRequest]
```

A sample initialziation code that you can add to your application:didFinishLaunchingWithOptions: is below
A sample initialization code that you can add to your application:didFinishLaunchingWithOptions: is below

``` objective-c
[[MKStoreKit sharedKit] startProductRequest];
Expand Down