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

Add ReceiptItem to VerifyPurchaseResult, VerifySubscriptionResult #199

Merged
merged 3 commits into from
May 10, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,8 @@ SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secre
inReceipt: receipt)

switch purchaseResult {
case .purchased(let expiresDate):
print("Product is purchased.")
case .purchased(let receiptItem):
print("Product is purchased: \(receiptItem)")
case .notPurchased:
print("The user has never purchased this product")
}
Expand All @@ -263,13 +263,17 @@ SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secre
}
```

Note that for consumable products, the receipt will only include the information for a couples of minutes after the purchase.
Note that for consumable products, the receipt will only include the information for a couple of minutes after the purchase.

### Verify Subscription

This can be used to check if a subscription was previously purchased, and whether it is still active or if it's expired.

If a subscription has been purchased multiple times, this method will return `.purchased` or `.expired` for the most recent one.
From [Apple - Working with Subscriptions](https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Chapters/Subscriptions.html#//apple_ref/doc/uid/TP40008267-CH7-SW6):

> keep a record of the date that each piece of content is published. Read the Original Purchase Date and Subscription Expiration Date field from each receipt entry to determine the start and end dates of the subscription.

When one or more subscriptions are found for a given product id, they are returned as a `ReceiptItem` array ordered by `expiryDate`, with the first one being the newest.

```swift
let appleValidator = AppleReceiptValidator(service: .production)
Expand All @@ -283,10 +287,10 @@ SwiftyStoreKit.verifyReceipt(using: appleValidator, password: "your-shared-secre
inReceipt: receipt)

switch purchaseResult {
case .purchased(let expiresDate):
print("Product is valid until \(expiresDate)")
case .expired(let expiresDate):
print("Product is expired since \(expiresDate)")
case .purchased(let expiryDate, let receiptItems):
print("Product is valid until \(expiryDate)")
case .expired(let expiryDate, let receiptItems):
print("Product is expired since \(expiryDate)")
case .notPurchased:
print("The user has never purchased this product")
}
Expand Down Expand Up @@ -340,9 +344,9 @@ SwiftyStoreKit.purchaseProduct(productId, atomically: true) { result in
inReceipt: receipt)

switch purchaseResult {
case .purchased(let expiryDate):
case .purchased(let expiryDate, let receiptItems):
print("Product is valid until \(expiryDate)")
case .expired(let expiryDate):
case .expired(let expiryDate, let receiptItems):
print("Product is expired since \(expiryDate)")
case .notPurchased:
print("This product has never been purchased")
Expand Down
119 changes: 89 additions & 30 deletions SwiftyStoreKit/InAppReceipt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,59 @@

import Foundation

extension Date {

init?(millisecondsSince1970: String) {
guard let millisecondsNumber = Double(millisecondsSince1970) else {
return nil
}
self = Date(timeIntervalSince1970: millisecondsNumber / 1000)
}
}

extension ReceiptItem {

public init?(receiptInfo: ReceiptInfo) {
guard
let productId = receiptInfo["product_id"] as? String,
let quantityString = receiptInfo["quantity"] as? String,
let quantity = Int(quantityString),
let transactionId = receiptInfo["transaction_id"] as? String,
let originalTransactionId = receiptInfo["original_transaction_id"] as? String,
let purchaseDate = ReceiptItem.parseDate(from: receiptInfo, key: "purchase_date_ms"),
let originalPurchaseDate = ReceiptItem.parseDate(from: receiptInfo, key: "original_purchase_date_ms"),
let webOrderLineItemId = receiptInfo["web_order_line_item_id"] as? String
else {
print("could not parse receipt item: \(receiptInfo). Skipping...")
return nil
}
self.productId = productId
self.quantity = quantity
self.transactionId = transactionId
self.originalTransactionId = originalTransactionId
self.purchaseDate = purchaseDate
self.originalPurchaseDate = originalPurchaseDate
self.webOrderLineItemId = webOrderLineItemId
self.subscriptionExpirationDate = ReceiptItem.parseDate(from: receiptInfo, key: "expires_date_ms")
self.cancellationDate = ReceiptItem.parseDate(from: receiptInfo, key: "cancellation_date_ms")
if let isTrialPeriod = receiptInfo["is_trial_period"] as? String {
self.isTrialPeriod = Bool(isTrialPeriod) ?? false
} else {
self.isTrialPeriod = false
}
}

private static func parseDate(from receiptInfo: ReceiptInfo, key: String) -> Date? {

guard
let requestDateString = receiptInfo[key] as? String,
let requestDateMs = Double(requestDateString) else {
return nil
}
return Date(timeIntervalSince1970: requestDateMs / 1000)
}
}

// MARK - receipt mangement
internal class InAppReceipt {

Expand Down Expand Up @@ -81,8 +134,12 @@ internal class InAppReceipt {
let receiptsInfo = filterReceiptsInfo(receipts: receipts, withProductId: productId)
let nonCancelledReceiptsInfo = receiptsInfo.filter { receipt in receipt["cancellation_date"] == nil }

let receiptItems = nonCancelledReceiptsInfo.flatMap { ReceiptItem(receiptInfo: $0) }
// Verify that at least one receipt has the right product id
return nonCancelledReceiptsInfo.count >= 1 ? .purchased : .notPurchased
if let firstItem = receiptItems.first {
return .purchased(item: firstItem)
}
return .notPurchased
}

/**
Expand All @@ -101,8 +158,6 @@ internal class InAppReceipt {
validUntil date: Date = Date()
) -> VerifySubscriptionResult {

// Verify that at least one receipt has the right product id

// The values of the latest_receipt and latest_receipt_info keys are useful when checking whether an auto-renewable subscription is currently active. By providing any transaction receipt for the subscription and checking these values, you can get information about the currently-active subscription period. If the receipt being validated is for the latest renewal, the value for latest_receipt is the same as receipt-data (in the request) and the value for latest_receipt_info is the same as receipt.
let (receipts, duration) = getReceiptsAndDuration(for: type, inReceipt: receipt)
let receiptsInfo = filterReceiptsInfo(receipts: receipts, withProductId: productId)
Expand All @@ -113,37 +168,42 @@ internal class InAppReceipt {

let receiptDate = getReceiptRequestDate(inReceipt: receipt) ?? date

// Return the expires dates sorted desc
let expiryDateValues = nonCancelledReceiptsInfo
.flatMap { (receipt) -> String? in
let receiptItems = nonCancelledReceiptsInfo.flatMap { ReceiptItem(receiptInfo: $0) }

let key: String = duration != nil ? "original_purchase_date_ms" : "expires_date_ms"
return receipt[key] as? String
}
.flatMap { (dateString) -> Date? in
guard let doubleValue = Double(dateString) else { return nil }
// If duration is set, create an "expires date" value calculated from the original purchase date
let addedDuration = duration ?? 0
let expiryDateDouble = (doubleValue / 1000 + addedDuration)
return Date(timeIntervalSince1970: expiryDateDouble)
}
.sorted { (a, b) -> Bool in
// Sort by descending date order
return a.compare(b) == .orderedDescending
}
if nonCancelledReceiptsInfo.count > receiptItems.count {
print("receipt has \(nonCancelledReceiptsInfo.count) items, but only \(receiptItems.count) were parsed")
}

let sortedExpiryDatesAndItems = expiryDatesAndItems(receiptItems: receiptItems, duration: duration).sorted { a, b in
return a.0 > b.0
}

guard let firstExpiryDate = expiryDateValues.first else {
guard let firstExpiryDateItemPair = sortedExpiryDatesAndItems.first else {
return .notPurchased
}

// Check if at least 1 receipt is valid
if firstExpiryDate.compare(receiptDate) == .orderedDescending {
let sortedReceiptItems = sortedExpiryDatesAndItems.map { $0.1 }
if firstExpiryDateItemPair.0 > receiptDate {
return .purchased(expiryDate: firstExpiryDateItemPair.0, items: sortedReceiptItems)
} else {
return .expired(expiryDate: firstExpiryDateItemPair.0, items: sortedReceiptItems)
}
}

private class func expiryDatesAndItems(receiptItems: [ReceiptItem], duration: TimeInterval?) -> [(Date, ReceiptItem)] {

// The subscription is valid
return .purchased(expiryDate: firstExpiryDate)
if let duration = duration {
return receiptItems.map {
let expirationDate = Date(timeIntervalSince1970: $0.originalPurchaseDate.timeIntervalSince1970 + duration)
return (expirationDate, $0)
}
} else {
// The subscription is expired
return .expired(expiryDate: firstExpiryDate)
return receiptItems.flatMap {
if let expirationDate = $0.subscriptionExpirationDate {
return (expirationDate, $0)
}
return nil
}
}
}

Expand All @@ -159,11 +219,10 @@ internal class InAppReceipt {
private class func getReceiptRequestDate(inReceipt receipt: ReceiptInfo) -> Date? {

guard let receiptInfo = receipt["receipt"] as? ReceiptInfo,
let requestDateString = receiptInfo["request_date_ms"] as? String,
let requestDateMs = Double(requestDateString) else {
let requestDateString = receiptInfo["request_date_ms"] as? String else {
return nil
}
return Date(timeIntervalSince1970: requestDateMs / 1000)
return Date(millisecondsSince1970: requestDateString)
}

/**
Expand Down
29 changes: 26 additions & 3 deletions SwiftyStoreKit/SwiftyStoreKit+Types.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,14 @@ public enum VerifyReceiptResult {

// Result for Consumable and NonConsumable
public enum VerifyPurchaseResult {
case purchased
case purchased(item: ReceiptItem)
case notPurchased
}

// Verify subscription result
public enum VerifySubscriptionResult {
case purchased(expiryDate: Date)
case expired(expiryDate: Date)
case purchased(expiryDate: Date, items: [ReceiptItem])
case expired(expiryDate: Date, items: [ReceiptItem])
case notPurchased
}

Expand All @@ -101,6 +101,29 @@ public enum SubscriptionType {
case nonRenewing(validDuration: TimeInterval)
}

public struct ReceiptItem {
// The product identifier of the item that was purchased. This value corresponds to the productIdentifier property of the SKPayment object stored in the transaction’s payment property.
public let productId: String
// The number of items purchased. This value corresponds to the quantity property of the SKPayment object stored in the transaction’s payment property.
public let quantity: Int
// The transaction identifier of the item that was purchased. This value corresponds to the transaction’s transactionIdentifier property.
public let transactionId: String
// For a transaction that restores a previous transaction, the transaction identifier of the original transaction. Otherwise, identical to the transaction identifier. This value corresponds to the original transaction’s transactionIdentifier property. All receipts in a chain of renewals for an auto-renewable subscription have the same value for this field.
public let originalTransactionId: String
// The date and time that the item was purchased. This value corresponds to the transaction’s transactionDate property.
public let purchaseDate: Date
// For a transaction that restores a previous transaction, the date of the original transaction. This value corresponds to the original transaction’s transactionDate property. In an auto-renewable subscription receipt, this indicates the beginning of the subscription period, even if the subscription has been renewed.
public let originalPurchaseDate: Date
// The primary key for identifying subscription purchases.
public let webOrderLineItemId: String
// The expiration date for the subscription, expressed as the number of milliseconds since January 1, 1970, 00:00:00 GMT. This key is only present for auto-renewable subscription receipts.
public let subscriptionExpirationDate: Date?
// For a transaction that was canceled by Apple customer support, the time and date of the cancellation. Treat a canceled receipt the same as if no purchase had ever been made.
public let cancellationDate: Date?

public let isTrialPeriod: Bool
}

// Error when managing receipt
public enum ReceiptError: Swift.Error {
// No receipt data
Expand Down
Loading