What replaced the receipt, and how do you grandfather paid users with AppTransaction.originalAppVersion?
Predict first
Which original versions count as at least 2.0 by string and by numeric comparison?
import Foundation
func numeric(_ a: String, atLeast b: String) -> Bool { a.compare(b, options: .numeric) != .orderedAscending }
let cutoff = "2.0"
for original in ["1.9", "1.10", "2.0", "2.0.1", "10.0"] {
print(original, "string compare:", original >= cutoff, "| numeric compare:", numeric(original, atLeast: cutoff))
}
- string compare gets only 10.0 wrong; numeric compare is correct
- both comparisons agree for every version
- numeric compare treats 10.0 as older than 2.0
Mental model. A receipt is a crumpled paper bag full of everything you bought; transactions are individual stamped invoices, and AppTransaction is the proof that you bought the shop key in the first place.
The legacy model was an app receipt (a PKCS#7 blob at appStoreReceiptURL) that the app sent to Apple's verifyReceipt endpoint; that endpoint is deprecated, the format is opaque and parsed by hand, and it mixed every purchase into one blob. StoreKit 2 replaces it with individually signed transactions (JWS) plus RenewalInfo, queried on device and via the Server API and notifications. A separate signed object, AppTransaction (AppTransaction.shared), describes the app download itself: originalAppVersion, originalPurchaseDate, environment, device verification id. Use it to move a paid app to a freemium or subscription model without punishing existing buyers: if the user's original version is below the first version of the new model, unlock for free. Two warnings: in the sandbox/TestFlight the original version is not the real one (Apple documents a placeholder), so test the branch with injected values; and check how the value is defined on each platform (a version string such as 1.10, a build number in the legacy receipt).
The check shows the classic bug when comparing versions: as strings "10.0" >= "2.0" is false, because comparison is lexicographic ("1" sorts before "2"), so version 10.0 wrongly counts as older (the other four happen to agree); compare(options: .numeric) gives the right answers (1.9 and 1.10 older, 2.0, 2.0.1 and 10.0 newer). For migration keep both paths: new buyers use transactions; old receipts are read once on the server (receipt data can still be sent to the server and, for historical purposes, converted via the Server API receipt-to-transaction lookup), then the grandfathering decision is stored against your own user record so it survives.
Lexicographic order puts "10.0" before "2.0".
Common traps
- Comparing version strings with
>=. Use numeric comparison or parse components. - Granting grandfathering in code only on the device. Record the decision server side, so reinstalls and other devices agree.
They might follow up with
- Does the user's original purchase survive a refund? A refund revokes the app purchase; treat revoked
AppTransactiondata accordingly. - What about families? Shared apps can report a different original purchaser; decide the policy up front.
Related questions
- 28.4.1 How is a StoreKit JWS built, and what must a server check before trusting signedTransactionInfo?
- 28.4.4 How do you authenticate to the App Store Server API, and what do you use it for?
- 28.3.2 How do restore, Family Sharing and refunds work in StoreKit 2, and how should the app remove access?
- 17.4.2 How does StoreKit 2 work: products, purchases, transaction verification, entitlements, refunds, and what must…
← How do you authenticate to the App Store Server API, and what do you…