---
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.
