storekit2-iap-defaults · git:20260907.e6d3cb2 · 2026-09-07 · sha256 5b57d987b1b0a176
storekit2-iap-defaults git:20260907.e6d3cb2A
Immutable. This exact content is served forever at /api/v1/blob/5b57d987b1b0a176.
---
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 or ad SDKs.'
---
# 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 — no REST resource covers
this; use the ASC web UI.
- 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(),
// 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 — that's the 3.1.1 contract, 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` still needs `@unknown default` — the compiler won't
warn when Apple adds a case; recheck on every OS-support bump. *Practice
observed.*
- 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.*
- The bridge/skeleton above typechecks clean under
`swiftc -swift-version 6 -typecheck` (Swift 6.3.2 / Xcode 26.5), 0
errors/warnings. *Compiled-verified.*
- `Transaction.updates`, `.currentEntitlements`, `.finish()`,
`.revocationDate`, `AppStore.sync()`, `Product.products(for:)`,
`.purchase(options:)`, `.PurchaseResult`, `VerificationResult` — each
symbol's existence/signature confirmed against
`developer.apple.com/tutorials/data/documentation/storekit/...json`.
*Apple-doc-verified.*
## 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.
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.
- [ ] A fake bridge covers purchase success/cancel/pending/failed and restore
empty/non-empty in unit tests.
- [ ] Restore Purchases is reachable from 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.