storekit2-iap-defaults
Default StoreKit 2 architecture for a single non-consumable IAP (Remove Ads, Pro Unlock): `StoreKitBridge` isolates `import StoreKit` to one Live file; launch-time `Transaction.updates`; `Transaction.currentEntitlements` for unlock state; `finish()` timing; `AppStore.sync()` rest
Install
npx skills add https://github.com/wei18/apple-dev-skills/tree/main/apple-dev-skills/skills/storekit2-iap-defaults
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install wei18-apple-dev-skills@llmmart
git clone https://github.com/wei18/apple-dev-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole wei18/apple-dev-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
StoreKit 2 IAP Defaults
Default shape for the smallest IAP most solo/small apps ship: one
non-consumable unlock (Remove Ads, Pro Unlock). Product, Transaction, and
AppStore have no public initializers — you cannot construct a fixture — so
the seam below exists to make StoreKit 2 testable at all, not for abstraction's
sake.
When to invoke
- Adding a first non-consumable IAP to a new or existing app.
- Wiring
Product.products(for:),Transaction.updates,Transaction.currentEntitlements, orAppStore.sync(). - Deciding where entitlement state lives, when to call
finish(), or how to implement Restore Purchases. - Setting up a
.storekitconfiguration file or a StoreKit unit-test seam. - Asked "how do I test a purchase without a sandbox account" or "why isn't my unlock surviving reinstall".
Scope
Owns: bridge/seam shape, entitlement-derivation rules, test strategy for non-consumable IAP. Does NOT own:
- Subscriptions/consumables — different renewal semantics; this skill's
currentEntitlements()shape is deliberately "own it or don't." - Ad SDK isolation — same bridge-protocol pattern, different domain →
monetization-sdk-integration. - What App Review requires of Restore Purchases / IAP pricing clarity (3.1.1)
→
app-store-review-rejections. - Creating the IAP product in App Store Connect — the ASC API 2.0 has
POST /v2/inAppPurchasesplusinAppPurchaseLocalizations,inAppPurchasePriceSchedules, andinAppPurchaseSubmissionsfor automating this end-to-end →asc-api-automation; the web UI is the manual alternative. - Getting the binary containing this code to TestFlight →
local-archive-export-upload.
The bridge seam
Product/Transaction/AppStore are untestable globals. Put a protocol
between the client and StoreKit; tests inject a fake instead:
// StoreKitBridge.swift — no `import StoreKit`; fully fake-able.
protocol StoreKitBridge: Sendable {
func products(for ids: Set<String>) async throws -> [BridgeProduct]
func currentEntitlements() async -> Set<String>
func purchase(productId: String) async throws -> BridgePurchaseOutcome
func sync() async throws
func transactionUpdates() -> AsyncStream<BridgeTransactionEvent>
}
struct BridgeProduct: Sendable, Equatable { let id, displayName, displayPrice: String }
enum BridgePurchaseOutcome: Sendable, Equatable {
case success(productId: String), userCancelled, pending, failed(reason: String)
}
// LiveStoreKitBridge.swift — the ONLY file that imports StoreKit.
import StoreKit
struct LiveStoreKitBridge: StoreKitBridge {
func currentEntitlements() async -> Set<String> {
var ids: Set<String> = []
for await result in Transaction.currentEntitlements {
guard case .verified(let t) = result, t.revocationDate == nil else { continue }
ids.insert(t.productID)
}
return ids
}
// products(for:) / purchase(productId:) / sync() / transactionUpdates()
// follow the same shape: Product.products(for:), Product.purchase(options:)
// (visionOS: purchase(confirmIn:options:) instead — purchase(options:)
// isn't available there), AppStore.sync(), Transaction.updates.
}
Everything above LiveStoreKitBridge talks only to any StoreKitBridge — zero
import StoreKit. Verify: rg '^(internal |public )*import StoreKit' Sources/
→ expect exactly 1 hit.
Entitlement state, finish(), restore
- Unlock state is derived, not stored. Don't persist "isPurchased"
independently — derive it from
currentEntitlements()each time (a non-consumable withrevocationDate == nilis entitled); an independently stored boolean drifts from Apple's record on refund/family-share/restore. - Call
finish()after the entitlement is applied, not before (risks losing the unlock on a mid-purchase crash) and not never (an unfinished transaction is redelivered viaTransaction.updateson every launch). - The
Transaction.updateslistener starts at app launch, not lazily on first paywall visit — refunds/family-share revocations/Ask-to-Buy approvals can arrive while the user is anywhere in the app. restorePurchases()always callsAppStore.sync()first, even when a local cache looks empty — Apple'ssync()docs position it as the forced sync behind a user-initiated Restore Purchases control (Guideline 3.1.1 itself only asks for a restore mechanism), so it's not an optimization to skip:
func restorePurchases() async throws -> [BridgeProduct] {
try await bridge.sync()
let entitled = await bridge.currentEntitlements()
guard !entitled.isEmpty else { return [] }
return try await bridge.products(for: entitled)
}
Testing strategy
| Layer | Tool | Covers |
|---|---|---|
| Unit tests | FakeStoreKitBridge (scripted outcomes + call counters) |
Client logic — zero StoreKit dependency, runs in CI |
| Interactive local run | .storekit file wired into the Xcode scheme (Run → Options → StoreKit Configuration) |
Manual purchase-flow smoke test, no sandbox Apple ID |
| Automated purchase-flow tests | StoreKitTest's SKTestSession (loads the same .storekit file) |
XCUITest/integration-level flows against the real StoreKit stack |
A .storekit file is a testing fixture with no effect on a shipped build;
it's what enables the last two rows, not a gap in unit-test coverage if absent.
War stories (evidence tier in italics)
Product.products(for:)returns[], not a thrown error, for an unknown ID (typo/sandbox drift) — decide what "no products" means forpurchase()before you hit it (one real app:.failed(reason: "product not found: <id>")rather than a silent no-op). Practice observed.- A verified
Transaction.updates/purchase-path switch onProduct.PurchaseResultneeds@unknown default, not a plaindefault— without it Swift 6 mode fails to compile ("switch covers known cases, but 'Product.PurchaseResult' may have additional unknown values"; a warning in Swift 5 mode), and a plaindefaultwould silence the warning a case Apple adds later should raise (Switching Over Future Enumeration Cases); recheck on every OS-support bump. Compiled-verified (Swift 6.3.2). - Post-purchase catalog refetch can come back empty even though the purchase
succeeded (rare ASC catalog instability). Synthesizing a minimal entitled
product (id + a locale-neutral placeholder price) beats
.failedfor a purchase Apple already charged for; pair it with a telemetry hook so the desync is observable. Practice observed. - The transaction-observer
Task's priority is a real UX decision: a refund/family-share event should flip entitlement state promptly while the user may be in-session..backgrounddeprioritizes it behind arbitrary work; one real app shipped.backgroundfirst and upgraded to.utilityafter review. Practice observed.
Provenance for the bridge/skeleton's verification claims: references/official-docs.md.
Rationale
The bridge exists because Product/Transaction have no public
initializers — "test the untestable" is the first wall a from-scratch
StoreKit 2 implementation hits, not a hypothetical. Isolating import StoreKit to one file also keeps the client testable on CI runners without a
signed-in sandbox tester.
Deviation considerations
- A small catalog of non-consumables — extend
BridgeProduct's fields, but keepcurrentEntitlements()a flatSet<String>. - Subscriptions — the "own it or don't" model is too coarse; you need
Transaction.subscriptionStatusand renewal-state handling this skill does not cover.
Common Mistakes
- Persisting
isPurchasedinstead of deriving it fromcurrentEntitlements(). - Never calling
finish(), or calling it before the entitlement is applied. - Starting the
Transaction.updateslistener lazily instead of at launch. - Treating
products(for:)returning[]as a thrown-error case. - Skipping
AppStore.sync()inrestorePurchases()"because the cache is empty." - No
@unknown defaulton theProduct.PurchaseResultswitch (Swift 6 mode won't compile it), or a plaindefaultthat hides cases Apple adds later. - Treating the
.storekitfile as unit-test infrastructure — it configures the interactive runtime andStoreKitTest, not the fake bridge.
Review Checklist
-
import StoreKitappears in exactly one file. - Unlock state is derived from
currentEntitlements(), not stored as an independent boolean. -
Transaction.updateslistener starts at app launch. -
finish()is called after the entitlement is applied, on every path. -
restorePurchases()always callssync()before reading entitlements. -
Product.PurchaseResult's switch has an@unknown defaultarm (not a plaindefault). - A fake bridge covers purchase success/cancel/pending/failed and restore empty/non-empty in unit tests.
- A visible Restore Purchases control exists — this catalog's default
places it in Settings (3.1.1 —
app-store-review-rejections).
Related skills
monetization-sdk-integration— same bridge-isolation pattern for ad SDKs.app-store-review-rejections— Restore Purchases / pricing-clarity review gate (3.1.1).asc-api-automation— TestFlight/App Store ops after the build exists.local-archive-export-upload/xcode-cloud-single-track-ci— shipping the binary.swift-dependency-injection— the general protocol-injection pattern this bridge instantiates.swift-testing-baseline— where this bridge's fake fits this catalog's test stack.apple-skills:storekit(aggregated external) — StoreKit 2 API reference incl. subscriptions,SubscriptionStoreView, renewal state — the part this skill does not cover.- Official sources: when verifying or updating a factual or version-sensitive claim, read
references/official-docs.md.
Files (apple-dev-skills)
-
references
-
official-docs.md 2.3 KB
Official pages backing this skill's claims; read when verifying or updating a factual or version-sensitive claim. | Page | URL | Backs | |---|---|---| | currentEntitlements | https://developer.apple.com/documentation/storekit/transaction/currententitlements | Unlock state derives from entitlements | | updates | https://developer.apple.com/documentation/storekit/transaction/updates | Listener starts at launch; unfinished transactions arrive at launch | | finish() | https://developer.apple.com/documentation/storekit/transaction/finish() | Call only after delivering content | | sync() | https://developer.apple.com/documentation/storekit/appstore/sync() | "Include some mechanism ... such as a Restore Purchases button"; "Call this function only in response to an explicit user action"; "In regular operations, there's no need to call sync()" | | purchase(options:) | https://developer.apple.com/documentation/storekit/product/purchase(options:) | Platform table doesn't include visionOS; with the rows above, the bridge/skeleton's symbols (`Transaction.updates`, `.currentEntitlements`, `.finish()`, `.revocationDate`, `AppStore.sync()`, `Product.products(for:)`, `.purchase(options:)`, `.PurchaseResult`, `VerificationResult`) were checked against Apple's StoreKit docs (*Apple-doc-verified*) | | Setting up StoreKit Testing in Xcode | https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode | Edit Scheme -> Run -> Options -> StoreKit Configuration | | In-App Purchases | https://developer.apple.com/documentation/appstoreconnectapi/in-app-purchases | Scope: the `/v2/inAppPurchases` endpoint family | | App Review Guidelines §3.1.1 | https://developer.apple.com/app-store/review/guidelines/#in-app-purchase | "you should make sure you have a restore mechanism for any restorable in-app purchases" (no API or placement mandated) | | Statements — Switching Over Future Enumeration Cases (The Swift Programming Language) | https://docs.swift.org/swift-book/documentation/the-swift-programming-language/statements/#Switching-Over-Future-Enumeration-Cases | `@unknown default` semantics; omitting it is an error in Swift 6. The skill's bridge/skeleton typechecks clean under `swiftc -swift-version 6 -typecheck` (Swift 6.3.2 / Xcode 26.5), 0 errors/warnings (*Compiled-verified*) |
-
-
SKILL.md 10.5 KB
--- name: storekit2-iap-defaults description: 'Default StoreKit 2 architecture for a single non-consumable IAP (Remove Ads, Pro Unlock): `StoreKitBridge` isolates `import StoreKit` to one Live file; launch-time `Transaction.updates`; `Transaction.currentEntitlements` for unlock state; `finish()` timing; `AppStore.sync()` restore; `.storekit` + Fake-bridge test seam. Invoke when adding IAP, wiring StoreKit 2, or asked "how do I unlock a purchase / restore purchases / test IAP". Does NOT cover subscriptions → apple-skills:storekit, or ad SDKs → monetization-sdk-integration.' --- # StoreKit 2 IAP Defaults Default shape for the smallest IAP most solo/small apps ship: one non-consumable unlock (Remove Ads, Pro Unlock). `Product`, `Transaction`, and `AppStore` have no public initializers — you cannot construct a fixture — so the seam below exists to make StoreKit 2 testable at all, not for abstraction's sake. ## When to invoke - Adding a first non-consumable IAP to a new or existing app. - Wiring `Product.products(for:)`, `Transaction.updates`, `Transaction.currentEntitlements`, or `AppStore.sync()`. - Deciding where entitlement state lives, when to call `finish()`, or how to implement Restore Purchases. - Setting up a `.storekit` configuration file or a StoreKit unit-test seam. - Asked "how do I test a purchase without a sandbox account" or "why isn't my unlock surviving reinstall". ## Scope Owns: bridge/seam shape, entitlement-derivation rules, test strategy for **non-consumable IAP**. Does NOT own: - Subscriptions/consumables — different renewal semantics; this skill's `currentEntitlements()` shape is deliberately "own it or don't." - Ad SDK isolation — same bridge-protocol *pattern*, different domain → `monetization-sdk-integration`. - What App Review requires of Restore Purchases / IAP pricing clarity (3.1.1) → `app-store-review-rejections`. - Creating the IAP product in App Store Connect — the ASC API 2.0 has `POST /v2/inAppPurchases` plus `inAppPurchaseLocalizations`, `inAppPurchasePriceSchedules`, and `inAppPurchaseSubmissions` for automating this end-to-end → `asc-api-automation`; the web UI is the manual alternative. - Getting the binary containing this code to TestFlight → `local-archive-export-upload`. ## The bridge seam `Product`/`Transaction`/`AppStore` are untestable globals. Put a protocol between the client and StoreKit; tests inject a fake instead: ```swift // StoreKitBridge.swift — no `import StoreKit`; fully fake-able. protocol StoreKitBridge: Sendable { func products(for ids: Set<String>) async throws -> [BridgeProduct] func currentEntitlements() async -> Set<String> func purchase(productId: String) async throws -> BridgePurchaseOutcome func sync() async throws func transactionUpdates() -> AsyncStream<BridgeTransactionEvent> } struct BridgeProduct: Sendable, Equatable { let id, displayName, displayPrice: String } enum BridgePurchaseOutcome: Sendable, Equatable { case success(productId: String), userCancelled, pending, failed(reason: String) } // LiveStoreKitBridge.swift — the ONLY file that imports StoreKit. import StoreKit struct LiveStoreKitBridge: StoreKitBridge { func currentEntitlements() async -> Set<String> { var ids: Set<String> = [] for await result in Transaction.currentEntitlements { guard case .verified(let t) = result, t.revocationDate == nil else { continue } ids.insert(t.productID) } return ids } // products(for:) / purchase(productId:) / sync() / transactionUpdates() // follow the same shape: Product.products(for:), Product.purchase(options:) // (visionOS: purchase(confirmIn:options:) instead — purchase(options:) // isn't available there), AppStore.sync(), Transaction.updates. } ``` Everything above `LiveStoreKitBridge` talks only to `any StoreKitBridge` — zero `import StoreKit`. Verify: `rg '^(internal |public )*import StoreKit' Sources/` → expect exactly 1 hit. ## Entitlement state, `finish()`, restore - **Unlock state is derived, not stored.** Don't persist "isPurchased" independently — derive it from `currentEntitlements()` each time (a non-consumable with `revocationDate == nil` is entitled); an independently stored boolean drifts from Apple's record on refund/family-share/restore. - **Call `finish()` after the entitlement is applied**, not before (risks losing the unlock on a mid-purchase crash) and not never (an unfinished transaction is redelivered via `Transaction.updates` on every launch). - **The `Transaction.updates` listener starts at app launch**, not lazily on first paywall visit — refunds/family-share revocations/Ask-to-Buy approvals can arrive while the user is anywhere in the app. - **`restorePurchases()` always calls `AppStore.sync()` first**, even when a local cache looks empty — Apple's `sync()` docs position it as the forced sync behind a user-initiated Restore Purchases control (Guideline 3.1.1 itself only asks for a restore mechanism), so it's not an optimization to skip: ```swift func restorePurchases() async throws -> [BridgeProduct] { try await bridge.sync() let entitled = await bridge.currentEntitlements() guard !entitled.isEmpty else { return [] } return try await bridge.products(for: entitled) } ``` ## Testing strategy | Layer | Tool | Covers | |---|---|---| | Unit tests | `FakeStoreKitBridge` (scripted outcomes + call counters) | Client logic — zero StoreKit dependency, runs in CI | | Interactive local run | `.storekit` file wired into the Xcode scheme (Run → Options → StoreKit Configuration) | Manual purchase-flow smoke test, no sandbox Apple ID | | Automated purchase-flow tests | `StoreKitTest`'s `SKTestSession` (loads the same `.storekit` file) | XCUITest/integration-level flows against the real StoreKit stack | A `.storekit` file is a testing fixture with no effect on a shipped build; it's what enables the last two rows, not a gap in unit-test coverage if absent. ## War stories (evidence tier in italics) - `Product.products(for:)` returns `[]`, not a thrown error, for an unknown ID (typo/sandbox drift) — decide what "no products" means for `purchase()` before you hit it (one real app: `.failed(reason: "product not found: <id>")` rather than a silent no-op). *Practice observed.* - A verified `Transaction.updates`/purchase-path switch on `Product.PurchaseResult` needs `@unknown default`, not a plain `default` — without it Swift 6 mode fails to compile ("switch covers known cases, but 'Product.PurchaseResult' may have additional unknown values"; a warning in Swift 5 mode), and a plain `default` would silence the warning a case Apple adds later should raise ([Switching Over Future Enumeration Cases](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/statements/#Switching-Over-Future-Enumeration-Cases)); recheck on every OS-support bump. *Compiled-verified* (Swift 6.3.2). - Post-purchase catalog refetch can come back empty even though the purchase succeeded (rare ASC catalog instability). Synthesizing a minimal entitled product (id + a locale-neutral placeholder price) beats `.failed` for a purchase Apple already charged for; pair it with a telemetry hook so the desync is observable. *Practice observed.* - The transaction-observer `Task`'s priority is a real UX decision: a refund/family-share event should flip entitlement state promptly while the user may be in-session. `.background` deprioritizes it behind arbitrary work; one real app shipped `.background` first and upgraded to `.utility` after review. *Practice observed.* Provenance for the bridge/skeleton's verification claims: `references/official-docs.md`. ## Rationale The bridge exists because `Product`/`Transaction` have no public initializers — "test the untestable" is the first wall a from-scratch StoreKit 2 implementation hits, not a hypothetical. Isolating `import StoreKit` to one file also keeps the client testable on CI runners without a signed-in sandbox tester. ## Deviation considerations - **A small catalog of non-consumables** — extend `BridgeProduct`'s fields, but keep `currentEntitlements()` a flat `Set<String>`. - **Subscriptions** — the "own it or don't" model is too coarse; you need `Transaction.subscriptionStatus` and renewal-state handling this skill does not cover. ## Common Mistakes 1. Persisting `isPurchased` instead of deriving it from `currentEntitlements()`. 2. Never calling `finish()`, or calling it before the entitlement is applied. 3. Starting the `Transaction.updates` listener lazily instead of at launch. 4. Treating `products(for:)` returning `[]` as a thrown-error case. 5. Skipping `AppStore.sync()` in `restorePurchases()` "because the cache is empty." 6. No `@unknown default` on the `Product.PurchaseResult` switch (Swift 6 mode won't compile it), or a plain `default` that hides cases Apple adds later. 7. Treating the `.storekit` file as unit-test infrastructure — it configures the interactive runtime and `StoreKitTest`, not the fake bridge. ## Review Checklist - [ ] `import StoreKit` appears in exactly one file. - [ ] Unlock state is derived from `currentEntitlements()`, not stored as an independent boolean. - [ ] `Transaction.updates` listener starts at app launch. - [ ] `finish()` is called after the entitlement is applied, on every path. - [ ] `restorePurchases()` always calls `sync()` before reading entitlements. - [ ] `Product.PurchaseResult`'s switch has an `@unknown default` arm (not a plain `default`). - [ ] A fake bridge covers purchase success/cancel/pending/failed and restore empty/non-empty in unit tests. - [ ] A visible Restore Purchases control exists — this catalog's default places it in Settings (3.1.1 — `app-store-review-rejections`). ## Related skills - `monetization-sdk-integration` — same bridge-isolation pattern for ad SDKs. - `app-store-review-rejections` — Restore Purchases / pricing-clarity review gate (3.1.1). - `asc-api-automation` — TestFlight/App Store ops after the build exists. - `local-archive-export-upload` / `xcode-cloud-single-track-ci` — shipping the binary. - `swift-dependency-injection` — the general protocol-injection pattern this bridge instantiates. - `swift-testing-baseline` — where this bridge's fake fits this catalog's test stack. - `apple-skills:storekit` (aggregated external) — StoreKit 2 API reference incl. subscriptions, `SubscriptionStoreView`, renewal state — the part this skill does not cover. - Official sources: when verifying or updating a factual or version-sensitive claim, read `references/official-docs.md`.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.