Lab note · from ReceiptLog
StoreKit 2 refunded purchase still unlocked: use currentEntitlements
In StoreKit 2, Transaction.all is the customer's full purchase history and a refunded purchase stays in it, so an unlock check that walks Transaction.all looking for your product ID keeps granting the product after Apple refunds it. Transaction.currentEntitlements answers "what does this person own right now" instead: Apple documents that refunded and revoked products don't appear in it. In ReceiptLog, a React Native app that buys through expo-iap, the fix is one option, onlyIncludeActiveItemsIOS: true, which switches the native iteration from Transaction.all to Transaction.currentEntitlements.
Symptoms
- The paid feature stays unlocked after a refund. ReceiptLog Pro is a one-time, non-consumable purchase. The check returns
trueand nothing errors, because the refunded transaction is still a transaction for the right product ID. - For ReceiptLog, the commit that moved purchases to StoreKit 2 (
e2db41c) records that the first draft of the entitlement check returnedtrueafter a refund. It was fixed before that commit landed. The app isn't on the App Store, so no customer ever hit it. - The same commit records a second symptom of reading the full history. On a test device with no Apple Account signed in, an unprompted "Sign in to Apple Account" alert appeared over the paywall on first open, and it stopped once the check switched to current entitlements. This was observed in this project, not documented by Apple.
Why it happens
Apple describes Transaction.all as "a sequence that emits all the customer's transactions for your app". Non-consumables appear in its list of contents with no carve-out for refunds. The refund exclusion is written on currentEntitlements instead: "Products that the App Store has refunded or revoked don't appear in the current entitlements." WWDC21's Meet StoreKit 2 says the same: current entitlements represent "only things that the user should have access to right now", so "any transactions that have been revoked are not included".
A refund doesn't delete the transaction. It marks it. revocationDate is "the date that the App Store refunded the transaction or revoked it from Family Sharing", and Apple's refund testing guide says that after an approved refund, "your app receives a Transaction with refund information in the revocationDate and revocationReason properties". A loop over all that only compares productID matches that transaction and grants the product.
Where the default sits in expo-iap
ReceiptLog calls expo-iap 4.7.2's getAvailablePurchases, which ends up in the OpenIAP Swift module (openiap 2.4.4). That module picks the sequence like this:
// openiap 2.4.4 — OpenIapModule.getAvailablePurchases(_:)
let onlyActive = options?.onlyIncludeActiveItemsIOS ?? false
for await verification in (onlyActive ? Transaction.currentEntitlements : Transaction.all) {
The loop never checks revocationDate. The function's own doc comment says that by default it reads "Transaction.all (the full history including refunded / revoked entries)".
The JavaScript layer doesn't share that default. In expo-iap 4.7.2, getAvailablePurchases fills in onlyIncludeActiveItemsIOS: options?.onlyIncludeActiveItemsIOS ?? true before it calls native code. Two layers of one library disagree about what "no option" means, so ReceiptLog passes the option explicitly, and the answer no longer depends on whichever default happens to apply.
The fix
The entitlement check asks for active items only, then looks for the product:
// src/services/pro.ts
export async function hasProEntitlement(): Promise<boolean> {
if (!(await connectToStore())) return false;
try {
const purchases = (await getAvailablePurchases({
onlyIncludeActiveItemsIOS: true,
})) as Purchase[];
return purchases.some((purchase) => purchase.productId === PRO_PRODUCT_ID);
} catch (error) {
console.warn('Could not read entitlements:', error);
return false;
}
}
In a native Swift app, the equivalent is to iterate Transaction.currentEntitlements instead of Transaction.all. If you do walk all or Transaction.updates, skip any transaction whose revocationDate is set. Apple's own updates example handles a revoked transaction with "Remove access to the product identified by transaction.productID", and in WWDC21 the check is "revocationDate equals nil".
The second half of the fix is the cache. ReceiptLog keeps a local isPro flag in SQLite so gated screens don't flash locked on launch. The flag is only a cache: whenever the store is reachable, the subscription hook re-reads the entitlement and overwrites the flag, including with false:
// src/hooks/useSubscription.ts
if (resolved === 'live') {
const [owned, product] = await Promise.all([hasProEntitlement(), fetchProProduct()]);
if (owned !== local) await persistPro(owned);
}
If a cache can only ever be set to true, a correct entitlement query can't take anything away.
How to check you've fixed it
- Use StoreKit Testing in Xcode. ReceiptLog's configuration file,
ReceiptLog.storekit, sits at the repository root rather than inios/, becauseexpo prebuildregenerates that directory. Select it under Edit Scheme > Run > Options > StoreKit Configuration, as described in Setting up StoreKit Testing in Xcode. - Buy Pro in the simulator.
- Open Debug > StoreKit > Manage Transactions, select the purchase and click Refund. Apple's transaction manager guide notes that changes sync automatically, with no rebuild.
- Open a screen that reads the entitlement, such as Settings. Pro should now be locked. If the check still reads the full history, Pro stays unlocked.
To automate the same check, StoreKitTest has SKTestSession.refundTransaction(identifier:), which "simulates a refund for an Apple In-App Purchase that completes outside of the app". ReceiptLog has no test like that yet. Its Jest suite covers the product configuration (the product exists and is a NonConsumable) and the purchase-mode rules below, not the refund path.
A related hole: the development fallback
The same pass through ReceiptLog's paywall found a hole that didn't need a refund at all. Before commit 0f7c8a3, the purchase handler looked like this whenever the purchase backend (RevenueCat, at the time) had no key:
if (IS_REVENUECAT_CONFIGURED) { /* real purchase */ }
// Dev/test fallback: flip the local Pro flag.
await persistPro(true);
return true;
It was a development convenience with no build-type guard. A release build made without the key would have unlocked Pro for anyone who tapped the button, with no transaction behind it. It's now three named modes, resolved in one place:
// src/services/purchase-mode.ts
export function resolvePurchaseMode(options: { isStoreConnected: boolean; isDev: boolean }): PurchaseMode {
if (options.isStoreConnected) return 'live';
return options.isDev ? 'devToggle' : 'unavailable';
}
export function grantsProWithoutPurchase(mode: PurchaseMode): boolean {
return mode === 'devToggle';
}
The hook passes React Native's __DEV__ as isDev, and it starts in unavailable until the store answers. A release build can therefore reach only live (the App Store grants Pro) or unavailable (nothing is sold, and the paywall says so). A test asserts that neither of those grants Pro without a purchase, so deleting the guard fails the suite.
Notes
- StoreKit 2's
Transaction,allandcurrentEntitlementsare available from iOS 15.0. ReceiptLog's generated Xcode project targets iOS 15.1, Expo's default. Theios/directory is generated and not committed. - Versions: expo-iap 4.7.2, openiap 2.4.4, Expo SDK 54, React Native 0.81.
- Refunds show up on the next check, not the moment they happen. OpenIAP's
Transaction.updateslistener skips revoked transactions (if transaction.revocationDate != nil { continue }), and ReceiptLog's purchase listener only ever grants. So a refund takes effect the next time a screen that uses the hook mounts: the paywall, Settings, Export, the camera or a receipt's detail. Revoking access the instant a refund arrives means handlingrevocationDatein anupdateslistener, as in Apple's example. - The check fails closed.
hasProEntitlementreturnsfalseon any store error, and the hook writes that to the cache. A store error during the check therefore locks Pro until the next successful read. That is the trade-off this design makes. - Consumables never appear in current entitlements, per Apple's documentation. That doesn't matter for a one-time unlock like this one.
More lab notes
- Anthropic API key in an iOS app binary: move it to the KeychainA Swift string literal ships in the app binary, readable with strings. Store a user-supplied key in the Keychain with a ThisDeviceOnly class instead.
- Laplacian sharpness always 0 on iOS: Core Image and Vision trapsRendering a Laplacian to .RGBA8 with a grey colour space wrote nothing, and caught Vision errors became 0. Render to .Rf and return nil when Vision fails.
- AudioContext was not allowed to start: make the click the UIChrome starts an AudioContext made before any user gesture suspended. Create or resume() it in a click or keydown handler, like Meridian 7's power button.
- AXIsProcessTrusted false after rebuild: ad-hoc signing and TCCAn ad-hoc signature's designated requirement is the build's cdhash, so changed code no longer matches its Accessibility grant. Sign with a stable identity.