Last active
January 16, 2025 00:50
-
-
Save nezhyborets/70f128de8a5aa157bd80ccb37ad9d822 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
enum ReceiptValidationError: Error { | |
case urlNotInitialized(string: String) | |
case urlResponseIsNil(data: Data?, error: Error?) | |
case wrongResponseJsonFormat(jsonObjectDescription: String) | |
case unacceptableVerificationStatus(Int) | |
case verificationStatusSuccessButNoReceiptFound(responseDescription: String) | |
} | |
struct ReceiptValidationAPI: ReceiptValidationAPIProtocol { | |
private let appSpecificSharedSecret: String | |
private let sandboxURLPath = "https://sandbox.itunes.apple.com/verifyReceipt" | |
private let productionURLPath = "https://buy.itunes.apple.com/verifyReceipt" | |
init(appSpecificSharedSecret: String) { | |
self.appSpecificSharedSecret = appSpecificSharedSecret | |
} | |
func verifyReceiptWithAppStoreServer( | |
receiptData: String, | |
shouldCallProductionURL: Bool, | |
completionBlock: @escaping @Sendable (Result<Receipt, Error>, _ isSandbox: Bool) -> Void | |
) { | |
let receiptVerificationPath = (shouldCallProductionURL ? productionURLPath : sandboxURLPath) | |
guard let receiptVerificationURL = URL(string: receiptVerificationPath) else { | |
completionBlock(.failure(ReceiptValidationError.urlNotInitialized(string: receiptVerificationPath)), false) | |
return | |
} | |
let dictParameters = ["receipt-data": receiptData, | |
"password": appSpecificSharedSecret] as [String: Any] | |
var verificationRequest = URLRequest(url: receiptVerificationURL) | |
verificationRequest.httpMethod = "POST" | |
verificationRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
do { | |
let httpBody = try JSONSerialization.data(withJSONObject: dictParameters, options: []) | |
verificationRequest.httpBody = httpBody | |
let session = URLSession.shared | |
session.dataTask(with: verificationRequest) { (data, response, error) in | |
if let error { | |
completionBlock(.failure(error), !shouldCallProductionURL) | |
return | |
} | |
guard let data = data else { | |
completionBlock( | |
.failure(ReceiptValidationError.urlResponseIsNil(data: data, error: error)), | |
!shouldCallProductionURL | |
) | |
return | |
} | |
do { | |
let json = try JSONSerialization.jsonObject(with: data, options: []) | |
guard let dictResponse = json as? [String: Any] else { | |
throw ReceiptValidationError.wrongResponseJsonFormat( | |
jsonObjectDescription: String(describing: json) | |
) | |
} | |
if let theStatus = dictResponse["status"] as? Int, theStatus != 0 { | |
guard theStatus == 21007 else { | |
throw ReceiptValidationError.unacceptableVerificationStatus(theStatus) | |
} | |
self.verifyReceiptWithAppStoreServer( | |
receiptData: receiptData, | |
shouldCallProductionURL: false, | |
completionBlock: { (result, isSandbox) in | |
completionBlock(result, isSandbox) | |
} | |
) | |
} else if let dictReceiptInfo = dictResponse["receipt"] as? [String: Any] { | |
let receipt = Receipt( | |
dictData: dictReceiptInfo, | |
latestReceiptInfo: dictResponse["latest_receipt_info"] as? [[String: Any]] | |
) | |
completionBlock(.success(receipt), !shouldCallProductionURL) | |
} else { | |
throw ReceiptValidationError.verificationStatusSuccessButNoReceiptFound( | |
responseDescription: String(describing: dictResponse) | |
) | |
} | |
} catch { | |
completionBlock(.failure(error), !shouldCallProductionURL) | |
} | |
}.resume() | |
} catch { | |
completionBlock(.failure(error), !shouldCallProductionURL) | |
} | |
} | |
} | |
struct Receipt { | |
let applicationVersion: String | |
let bundleID: String | |
let arrIAPReceipts: [IAPReceipt] | |
let originalApplicationVersion: String | |
let originalPurchaseDate: Date? | |
let receiptCreationDate: Date? | |
let receiptType: String | |
init(dictData: [String: Any], latestReceiptInfo: [[String: Any]]?) { | |
if let originalApplicationVersion = dictData["original_application_version"] as? String { | |
self.originalApplicationVersion = originalApplicationVersion | |
} else { | |
self.originalApplicationVersion = "" | |
} | |
if let bundleID = dictData["bundle_id"] as? String { | |
self.bundleID = bundleID | |
} else { | |
bundleID = "" | |
} | |
if let applicationVersion = dictData["application_version"] as? String { | |
self.applicationVersion = applicationVersion | |
} else { | |
applicationVersion = "" | |
} | |
if let receiptType = dictData["receipt_type"] as? String { | |
self.receiptType = receiptType | |
} else { | |
receiptType = "" | |
} | |
if | |
let strOriginalPurchaseTime = dictData["original_purchase_date_ms"] as? String, | |
let originalPurchaseTime = Double(strOriginalPurchaseTime) { | |
let theDate = Date(timeIntervalSince1970: TimeInterval(originalPurchaseTime / 1000.0)) | |
self.originalPurchaseDate = theDate | |
} else { | |
originalPurchaseDate = nil | |
} | |
if let strReceiptCreationTime = dictData["receipt_creation_date_ms"] as? String, let receiptCreationTime = Double(strReceiptCreationTime) { | |
let theDate = Date(timeIntervalSince1970: TimeInterval(receiptCreationTime / 1000.0)) | |
self.receiptCreationDate = theDate | |
} else { | |
receiptCreationDate = nil | |
} | |
if let arrInAppReceipts = latestReceiptInfo ?? (dictData["in_app"] as? [[String: Any]]) { | |
var arrIAPReceipts: [IAPReceipt] = [] | |
for i in 0..<arrInAppReceipts.count { | |
let dictIAPReceipt = arrInAppReceipts[i] | |
let theIAPReceipt = IAPReceipt(dictData: dictIAPReceipt) | |
arrIAPReceipts.append(theIAPReceipt) | |
} | |
self.arrIAPReceipts = arrIAPReceipts | |
} else { | |
arrIAPReceipts = [] | |
} | |
} | |
} | |
struct IAPReceipt { | |
let isTrialPeriod: Bool | |
let originalPurchaseDate: Date? | |
let originalTransactionID: String | |
let productID: String | |
let purchaseDate: Date? | |
let quantity: Int | |
let transactionID: String | |
let expireDate: Date? // Only used if user has subscribed | |
init(dictData: [String: Any]) { | |
if let isTrialPeriod = dictData["is_trial_period"] as? Bool { | |
self.isTrialPeriod = isTrialPeriod | |
} else { | |
isTrialPeriod = false | |
} | |
if let productID = dictData["product_id"] as? String { | |
self.productID = productID | |
} else { | |
productID = "" | |
} | |
if let originalTransactionID = dictData["original_transaction_id"] as? String { | |
self.originalTransactionID = originalTransactionID | |
} else { | |
originalTransactionID = "" | |
} | |
if let transactionID = dictData["transaction_id"] as? String { | |
self.transactionID = transactionID | |
} else { | |
transactionID = "" | |
} | |
if let quantity = dictData["quantity"] as? Int { | |
self.quantity = quantity | |
} else { | |
quantity = 0 | |
} | |
if let strOriginalPurchaseTime = dictData["original_purchase_date_ms"] as? String, let originalPurchaseTime = Double(strOriginalPurchaseTime) { | |
let theDate = Date(timeIntervalSince1970: TimeInterval(originalPurchaseTime / 1000.0)) | |
self.originalPurchaseDate = theDate | |
} else { | |
originalPurchaseDate = nil | |
} | |
if let strPurchaseTime = dictData["purchase_date_ms"] as? String, let purchaseTime = Double(strPurchaseTime) { | |
let theDate = Date(timeIntervalSince1970: TimeInterval(purchaseTime / 1000.0)) | |
self.purchaseDate = theDate | |
} else { | |
purchaseDate = nil | |
} | |
if let strExpireTime = dictData["expires_date_ms"] as? String, let expireTime = Double(strExpireTime) { | |
let theDate = Date(timeIntervalSince1970: TimeInterval(expireTime / 1000.0)) | |
self.expireDate = theDate | |
} else { | |
expireDate = nil | |
} | |
} | |
func getExpireDate(isNonRenewableSubscription: Bool, isSandbox: Bool) -> Date? { | |
if isNonRenewableSubscription, let originalPurchaseDate { | |
if isSandbox { | |
return Calendar.current.date(byAdding: .minute, value: 5, to: originalPurchaseDate) | |
} else { | |
return Calendar.current.date(byAdding: .day, value: 31, to: originalPurchaseDate) | |
} | |
} else if let expireDate { | |
return expireDate | |
} | |
return nil | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment