Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better non-subscriptions support #281

Merged
merged 15 commits into from Jul 23, 2020
Merged
2 changes: 1 addition & 1 deletion Cartfile.resolved
@@ -1,2 +1,2 @@
github "AliSoftware/OHHTTPStubs" "9.0.0"
github "Quick/Nimble" "v8.0.7"
github "Quick/Nimble" "v8.1.1"
6 changes: 3 additions & 3 deletions Examples/SwiftExample/Podfile.lock
@@ -1,5 +1,5 @@
PODS:
- Purchases (3.5.0-SNAPSHOT)
- Purchases (3.6.0-SNAPSHOT)

DEPENDENCIES:
- Purchases (from `../../`)
Expand All @@ -9,8 +9,8 @@ EXTERNAL SOURCES:
:path: "../../"

SPEC CHECKSUMS:
Purchases: a7ec2fa5d18658bd6fddd0eb9eb6d2e2d3aea6c8
Purchases: 3ed4a8d10503120cceb9b44ae794d150d4e57d45

PODFILE CHECKSUM: 0b953383523796d33de4db49d07a5c6e3a56c27e

COCOAPODS: 1.9.1
COCOAPODS: 1.9.3
28 changes: 28 additions & 0 deletions Purchases.xcodeproj/project.pbxproj
Expand Up @@ -37,6 +37,9 @@
3585D6A422E680E30079E2C5 /* RCPackage.m in Sources */ = {isa = PBXBuildFile; fileRef = 3585D6A222E680E30079E2C5 /* RCPackage.m */; };
3589D15424C219BE00A65CBB /* AttributionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3589D15324C219BE00A65CBB /* AttributionFetcherTests.swift */; };
3589D15624C21DBD00A65CBB /* RCAttributionFetcher+Protected.h in Headers */ = {isa = PBXBuildFile; fileRef = 3589D15524C21DBD00A65CBB /* RCAttributionFetcher+Protected.h */; settings = {ATTRIBUTES = (Private, ); }; };
3597021024BF6A710010506E /* TransactionsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3597020F24BF6A710010506E /* TransactionsFactory.swift */; };
3597021224BF6AAC0010506E /* TransactionsFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3597021124BF6AAC0010506E /* TransactionsFactoryTests.swift */; };
35A6DC1924BE5FF100C3983D /* Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6DC1824BE5FF100C3983D /* Transaction.swift */; };
35B54E4622EA6F11005918B1 /* RCEntitlementInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 35B54E4522EA6F11005918B1 /* RCEntitlementInfo.m */; };
35B54E4822EA6F4E005918B1 /* RCEntitlementInfo.h in Headers */ = {isa = PBXBuildFile; fileRef = 35B54E4722EA6F4E005918B1 /* RCEntitlementInfo.h */; settings = {ATTRIBUTES = (Public, ); }; };
35B54E4A22EA6FBD005918B1 /* RCEntitlementInfos.h in Headers */ = {isa = PBXBuildFile; fileRef = 35B54E4922EA6FBD005918B1 /* RCEntitlementInfos.h */; settings = {ATTRIBUTES = (Public, ); }; };
Expand Down Expand Up @@ -213,6 +216,9 @@
3585D6A222E680E30079E2C5 /* RCPackage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCPackage.m; sourceTree = "<group>"; };
3589D15324C219BE00A65CBB /* AttributionFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionFetcherTests.swift; sourceTree = "<group>"; };
3589D15524C21DBD00A65CBB /* RCAttributionFetcher+Protected.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RCAttributionFetcher+Protected.h"; sourceTree = "<group>"; };
3597020F24BF6A710010506E /* TransactionsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsFactory.swift; sourceTree = "<group>"; };
3597021124BF6AAC0010506E /* TransactionsFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsFactoryTests.swift; sourceTree = "<group>"; };
35A6DC1824BE5FF100C3983D /* Transaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transaction.swift; sourceTree = "<group>"; };
35B54E4522EA6F11005918B1 /* RCEntitlementInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCEntitlementInfo.m; sourceTree = "<group>"; };
35B54E4722EA6F4E005918B1 /* RCEntitlementInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCEntitlementInfo.h; sourceTree = "<group>"; };
35B54E4922EA6FBD005918B1 /* RCEntitlementInfos.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCEntitlementInfos.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -370,6 +376,7 @@
2D9AF7B124A2B2F000E1D023 /* SwiftSources */ = {
isa = PBXGroup;
children = (
354235D524C11138008C84EE /* Purchasing */,
2D1A28CB24AA6F4B006BE931 /* LocalReceiptParsing */,
);
path = SwiftSources;
Expand All @@ -385,6 +392,7 @@
2DD02D5824AD128000419CD9 /* SwiftSources */ = {
isa = PBXGroup;
children = (
354235D624C11160008C84EE /* Purchasing */,
2DD02D5924AD128A00419CD9 /* LocalReceiptParsing */,
);
path = SwiftSources;
Expand Down Expand Up @@ -500,6 +508,23 @@
name = Frameworks;
sourceTree = "<group>";
};
354235D524C11138008C84EE /* Purchasing */ = {
isa = PBXGroup;
children = (
3597020F24BF6A710010506E /* TransactionsFactory.swift */,
35A6DC1824BE5FF100C3983D /* Transaction.swift */,
);
path = Purchasing;
sourceTree = "<group>";
};
354235D624C11160008C84EE /* Purchasing */ = {
isa = PBXGroup;
children = (
3597021124BF6AAC0010506E /* TransactionsFactoryTests.swift */,
);
path = Purchasing;
sourceTree = "<group>";
};
37E35081077192045E3A8080 /* Mocks */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -911,9 +936,11 @@
37E350AAD0BAE521A7F94846 /* RCAttributionFetcher.m in Sources */,
37E35889F275514EE3125439 /* RCIdentityManager.m in Sources */,
37E358DB4F4016AC297D6B00 /* RCOfferingsFactory.m in Sources */,
35A6DC1924BE5FF100C3983D /* Transaction.swift in Sources */,
37E35DD8BE40E352311AC2C1 /* RCPromotionalOffer.m in Sources */,
37E358250B07BF2DA06EA27B /* RCReceiptFetcher.m in Sources */,
37E35BDDC331C3A5FF72CFFF /* RCStoreKitRequestFetcher.m in Sources */,
3597021024BF6A710010506E /* TransactionsFactory.swift in Sources */,
37E35A299524507A480956D5 /* RCStoreKitWrapper.m in Sources */,
37E35A7ED3312F10B80FFE2B /* RCDeviceCache.m in Sources */,
37E353437E49AA49D6ECC03F /* RCInMemoryCachedObject.m in Sources */,
Expand Down Expand Up @@ -945,6 +972,7 @@
37E35DD380900220C34BB222 /* MockTransaction.swift in Sources */,
37E3554836D4DA9336B9FA70 /* MockProductDiscount.swift in Sources */,
37E35398FCB4931573C56CAF /* MockReceiptFetcher.swift in Sources */,
3597021224BF6AAC0010506E /* TransactionsFactoryTests.swift in Sources */,
37E35F20FB949985BEEB4B58 /* MockRequestFetcher.swift in Sources */,
37E35AD0B0D9EF0CDA29DAC2 /* MockStoreKitWrapper.swift in Sources */,
37E35EBDFC5CD3068E1792A3 /* MockNotificationCenter.swift in Sources */,
Expand Down
10 changes: 7 additions & 3 deletions Purchases/Public/RCPurchaserInfo.h
Expand Up @@ -8,7 +8,7 @@

#import <Foundation/Foundation.h>

@class RCEntitlementInfos;
@class RCEntitlementInfos, RCTransaction;

NS_ASSUME_NONNULL_BEGIN

Expand All @@ -30,8 +30,12 @@ NS_SWIFT_NAME(Purchases.PurchaserInfo)
/// Returns the latest expiration date of all products, nil if there are none
@property (readonly, nullable) NSDate *latestExpirationDate;

/// Returns all the non-consumable purchases a user has made.
@property (nonatomic, readonly) NSSet<NSString *> *nonConsumablePurchases;
/// Returns all product IDs of the non-subscription purchases a user has made.
@property (nonatomic, readonly) NSSet<NSString *> *nonConsumablePurchases DEPRECATED_MSG_ATTRIBUTE("use nonSubscriptionTransactions");

/// Returns all the non-subscription purchases a user has made.
/// The purchases are ordered by purchase date in ascending order.
@property (nonatomic, readonly) NSArray<RCTransaction *> *nonSubscriptionTransactions;

/**
Returns the build number (in iOS) or the marketing version (in macOS) for the version of the application when the user bought the app.
Expand Down
13 changes: 9 additions & 4 deletions Purchases/Public/RCPurchaserInfo.m
Expand Up @@ -11,12 +11,14 @@
#import "RCEntitlementInfos.h"
#import "RCEntitlementInfos+Protected.h"
#import "RCEntitlementInfo.h"
#import "RCPurchasesSwiftImport.h"

@interface RCPurchaserInfo ()

@property (nonatomic) NSDictionary<NSString *, NSDate *> *expirationDatesByProduct;
@property (nonatomic) NSDictionary<NSString *, NSDate *> *purchaseDatesByProduct;
@property (nonatomic) NSSet<NSString *> *nonConsumablePurchases;
@property (nonatomic) NSArray<RCTransaction *> *nonSubscriptionTransactions;
@property (nonatomic, nullable) NSString *originalApplicationVersion;
@property (nonatomic, nullable) NSDate *originalPurchaseDate;
@property (nonatomic) NSDictionary *originalData;
Expand Down Expand Up @@ -80,12 +82,15 @@ - (void)initializeMetadataWithSubscriberData:(NSDictionary *)subscriberData {

- (void)initializePurchasesAndEntitlementsWithSubscriberData:(NSDictionary *)subscriberData
subscriptions:(NSDictionary *)subscriptions {
NSDictionary<NSString *, NSArray *> *nonSubscriptions = subscriberData[@"non_subscriptions"];
self.nonConsumablePurchases = [NSSet setWithArray:[nonSubscriptions allKeys]];
NSDictionary<NSString *, NSArray *> *nonSubscriptionsData = subscriberData[@"non_subscriptions"];
self.nonConsumablePurchases = [NSSet setWithArray:[nonSubscriptionsData allKeys]];
Comment on lines +85 to +86
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so non_subscriptions filters out consumables as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, non_subscriptions contains both consumables and non-consumables. We've named this (nonConsumablePurchases) wrong since the beginning

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ohh, so nonConsumablePurchases should have been named nonSubscriptions. Maybe we should add a warning? Seems like a gotcha

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in that case, disregard my comment about the sample purchases naming, since consumables would also work


RCTransactionsFactory *transactionsFactory = [[RCTransactionsFactory alloc] init];
self.nonSubscriptionTransactions = [transactionsFactory nonSubscriptionTransactionsWith:nonSubscriptionsData dateFormatter:dateFormatter];

NSMutableDictionary<NSString *, id> *nonSubscriptionsLatestPurchases = [[NSMutableDictionary alloc] init];
for (NSString* productId in nonSubscriptions) {
NSArray *arrayOfPurchases = nonSubscriptions[productId];
for (NSString* productId in nonSubscriptionsData) {
NSArray *arrayOfPurchases = nonSubscriptionsData[productId];
if (arrayOfPurchases.count > 0) {
nonSubscriptionsLatestPurchases[productId] = arrayOfPurchases[arrayOfPurchases.count - 1];
}
Expand Down
45 changes: 45 additions & 0 deletions Purchases/SwiftSources/Purchasing/Transaction.swift
@@ -0,0 +1,45 @@
//
// Transaction.swift
// Purchases
//
// Created by RevenueCat.
// Copyright © 2020 Purchases. All rights reserved.
//

import Foundation

@objc(RCTransaction) public class Transaction: NSObject {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all objects that inherit from NSObject have a public init with no arguments.
If we want to prevent that from being used, maybe we can do something like

required init?() { fatalError("init() has not been implemented") }

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't tested that, though

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to provide some context on the why:
there's a bit of a gotcha when it comes to swift-objc interoperability and initialization, and nullability.
if you call init() from objc, that will work, because this is an NSObject. however, the object's properties will not have been initialized, so revenuecatId for example will be nil.
but since the type is declared as non-nil, you won't be able to check against that - if you do

if revenuecatId != nil {

the compiler will complain because the object isn't nullable. so you'll be in a bad place.

Here's a blog post that goes into a bit more detail in case you're curious:
https://fabiancanas.com/blog/2020/1/9/swift-undefined-behavior.html

Copy link
Contributor Author

@vegaro vegaro Jul 22, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation! Unfortunately it doesn't work, it says "Failable initializer 'init()' cannot override a non-failable initializer". Since it throws a fatalError this is actually not a Failable Initializer right? I think it should be this instead:

    required override init() { fatalError("init() has not been implemented") }

Should it be a convenience initializer too?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed the override, but yeah, it should be there.
does failable mean that it's an init?() vs init()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah failable means init?, but since this is throwing a fatalError, it is not an init? because it will actually never finish initializing. If I understand correctly, to make it failable it would have to return nil instead of throwing the error, it needs to return something

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, for a failable it'd be best practice to return nil if it fails.
I'm good with anything, as long as we make sure that [[Transaction alloc] init]; can't be used

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this is the way to go?


@available(*, unavailable, message: "Use init(transactionId, productId, purchaseDate) instead")
override init() {
fatalError("init() has not been implemented")
}
Comment on lines +13 to +16
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌


let revenueCatId: String
let productId: String
let purchaseDate: Date

init(transactionId: String, productId: String, purchaseDate: Date) {
self.revenueCatId = transactionId
self.productId = productId
self.purchaseDate = purchaseDate
super.init()
}

init(with serverResponse: [String: Any], productId: String, dateFormatter: DateFormatter) {
guard let revenueCatId = serverResponse["id"] as? String,
let dateString = serverResponse["purchase_date"] as? String,
let purchaseDate = dateFormatter.date(from: dateString) else {
fatalError("""
Couldn't initialize Transaction from dictionary.
Reason: unexpected format. Dictionary: \(serverResponse).
""")
}

self.revenueCatId = revenueCatId
self.purchaseDate = purchaseDate
self.productId = productId
super.init()
}

}
24 changes: 24 additions & 0 deletions Purchases/SwiftSources/Purchasing/TransactionsFactory.swift
@@ -0,0 +1,24 @@
//
// PurchaserInfoHelper.swift
// Purchases
//
// Created by RevenueCat.
// Copyright © 2020 Purchases. All rights reserved.
//

import Foundation

@objc(RCTransactionsFactory) public class TransactionsFactory: NSObject {

@objc public func nonSubscriptionTransactions(with subscriptionsData: [String: [[String: Any]]],
dateFormatter: DateFormatter) -> [Transaction] {
subscriptionsData.flatMap { (productId: String, transactionData: [[String: Any]]) -> [Transaction] in
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually don't specify the types in these since they can be inferred, but In this case I like it - it helps the compiler and the dev not get lost in the nested block.

transactionData.map {
Transaction(with: $0, productId: productId, dateFormatter: dateFormatter)
}
}.sorted {
$0.purchaseDate < $1.purchaseDate
}
}

}
15 changes: 12 additions & 3 deletions PurchasesTests/Purchasing/PurchaserInfoTests.swift
Expand Up @@ -31,8 +31,11 @@ class BasicPurchaserInfoTests: XCTestCase {
"non_subscriptions": [
"onetime_purchase": [
[
"id": "d6c007ba74",
"is_sandbox": true,
"original_purchase_date": "1990-08-30T02:40:36Z",
"purchase_date": "1990-08-30T02:40:36Z"
"purchase_date": "1990-08-30T02:40:36Z",
"store": "play_store"
]
]
],
Expand Down Expand Up @@ -247,14 +250,20 @@ class BasicPurchaserInfoTests: XCTestCase {
"non_subscriptions": [
"onetime_purchase": [
[
"id": "d6c007ba74",
"original_purchase_date": "1990-08-30T02:40:36Z",
"purchase_date": "1990-08-30T02:40:36Z"
"purchase_date": "1990-08-30T02:40:36Z",
"is_sandbox": true,
"store": "play_store"
]
],
"pro.3": [
[
"id": "d6c007ba75",
"original_purchase_date": "1990-08-30T02:40:36Z",
"purchase_date": "1990-08-30T02:40:36Z"
"purchase_date": "1990-08-30T02:40:36Z",
"is_sandbox": true,
"store": "play_store"
]
]
],
Expand Down
@@ -0,0 +1,84 @@
//
// PurchaserInfoHelperTests.swift
// PurchasesTests
//
// Created by RevenueCat.
// Copyright © 2020 Purchases. All rights reserved.
//

import Nimble
import XCTest
@testable import Purchases

class TransactionsFactoryTests: XCTestCase {

let dateFormatter = DateFormatter()
let transactionsFactory = TransactionsFactory()

let sampleTransactions = [
"100_coins": [
[
"id": "72c26cc69c",
"is_sandbox": true,
"original_purchase_date": "1990-08-30T02:40:36Z",
"purchase_date": "2019-07-11T18:36:20Z",
"store": "app_store"
], [
"id": "6229b0bef1",
"is_sandbox": true,
"original_purchase_date": "2019-11-06T03:26:15Z",
"purchase_date": "2019-11-06T03:26:15Z",
"store": "play_store"
]],
"500_coins": [
[
"id": "d6c007ba74",
"is_sandbox": true,
"original_purchase_date": "2019-07-11T18:36:20Z",
"purchase_date": "2019-07-11T18:36:20Z",
"store": "play_store"
], [
"id": "5b9ba226bc",
"is_sandbox": true,
"original_purchase_date": "2019-07-26T22:10:27Z",
"purchase_date": "2019-07-26T22:10:27Z",
"store": "app_store"
]],
"lifetime_access": [
[
"id": "d6c097ba74",
"is_sandbox": true,
"original_purchase_date": "2018-07-11T18:36:20Z",
"purchase_date": "2018-07-11T18:36:20Z",
"store": "app_store"
]]
]

override func setUp() {
dateFormatter.timeZone = TimeZone(abbreviation: "GMT")
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
}

func testNonSubscriptionsIsCorrectlyCreated() {
let nonSubscriptionTransactions = transactionsFactory.nonSubscriptionTransactions(with: sampleTransactions, dateFormatter: dateFormatter)
expect(nonSubscriptionTransactions.count) == 5

sampleTransactions.forEach { productId, transactionsData in
let filteredTransactions = nonSubscriptionTransactions.filter { $0.productId == productId }
expect(filteredTransactions.count) == transactionsData.count
transactionsData.forEach { dictionary in
guard let transactionId = dictionary["id"] as? String else { fatalError("incorrect dict format") }
let containsTransaction = filteredTransactions.contains { $0.revenueCatId == transactionId }
expect(containsTransaction) == true
}
}

}

func testNonSubscriptionsIsEmptyIfThereAreNoNonSubscriptions() {
let list = transactionsFactory.nonSubscriptionTransactions(with: [:], dateFormatter: dateFormatter)
expect(list).to(beEmpty())
}

}