swift-storekit

0.3.0

One-time purchases and trials over StoreKit 2, for macOS and iOS, with a simulated store for testing them.
Kosikowski/swift-storekit

What's New

0.3.0

2026-09-21T13:48:10Z

One-time purchases, trials and subscriptions over StoreKit 2, for macOS 26 and iOS 26, with a simulated store for testing them.

Early. Non-consumables, trials, auto-renewable subscriptions with their offers, and non-renewing subscriptions. No consumables. The API may still move before 1.0, and it moved here: see Breaking below.

.package(url: "https://github.com/Kosikowski/swift-storekit.git", .upToNextMinor(from: "0.3.0"))

This release adds subscriptions. Before anything was built, each StoreKit behaviour the design depends on was measured against real StoreKit, on macOS 26.6 and in the iOS 27 simulator. Seven of the answers changed the design. For example, the listing cannot decide who is subscribed. And at the end of every period, StoreKit says for a moment that the subscription has ended. A subscriber must never be locked out by that.

Auto-renewable subscriptions

static let catalogue: Catalogue = [
    .subscription(monthly, in: membership, level: 2),
    .subscription(yearly, in: membership, level: 2),
    .subscription(plus, in: membership, level: 1),
]

let membership = store.standing.subscription(in: Shop.membership)   // .unknown, .none, .active or .inactive
  • Access follows Apple's rule: subscribed, or in a grace period. Billing retry is reported, but grants no access. HeldSubscription gives the state, the renewal, the offer in force, who owns it, and the date access ends.
  • The status decides, not the listing. The iOS simulator lists a subscription in billing retry, and both platforms list nothing for a moment at each renewal.
  • A lapse at the end of a period is believed only if it lasts. For up to 0.7 s at every renewal, StoreKit says the subscription has ended. On iOS this looks exactly like a real lapse.
  • Plan changes are read by comparison. A downgrade comes back from purchase() as a success with the old plan still held. The store compares what it asked for with what it got, and returns .planChangeScheduled(to:at:).
  • Status changes are heard as they happen. Expiries, cancellations and grace periods send no transaction, so the App Store front also listens to Status.updates.
  • ManageSubscriptionsButton shows Apple's sheet on iOS. On the Mac, in a Mac Catalyst app, and for an iPhone or iPad app running on a Mac, it opens the App Store's subscriptions page instead. The store reads again when the app becomes active.

Offers

  • StoreProduct.subscription carries each offer's terms as StoreKit states them, so a paywall never writes prices itself.
  • Introductory eligibility has four states. StoreKit's own answer never changes during a process, so the App Store front corrects it using the group's transactions.
  • Win-back offers are the ones Apple allows on the account's own status: winBackOffers(in:).
  • Promotional offers and the introductory override are signed by the app's OfferSigning, usually its server. The package holds no key and never buys without a signature. It never asks the signer for someone who has never subscribed.
  • If StoreKit did not apply an offer, the purchase returns .offerNotApplied.
  • PurchaseOptions carries the offer, a billing plan, and an app account token.

The rest of what StoreKit sells subscriptions with

  • Non-renewing subscriptions: .nonRenewing(_:lasting:stacking:). StoreKit gives them no expiry, so the store works out each period from the purchase dates.
  • 12-month commitments billed monthly: billing plans on a purchase and on a product, and where a subscription stands in its commitment.
  • Purchases asked for outside the app (PurchaseIntent): kept in requestedPurchases until the app buys or dismisses them.
  • Subscription bundles, from the 27 SDK.
  • Apple's messages: .storeMessages(deferredWhile:showing:) holds price-rise, billing and win-back sheets while the app asks it to.
  • Purchases made in Apple's views: takePurchase(_:of:) and takeRedemption(_:). In the iOS simulator, an unlock bought in ProductView is announced nowhere else.

Testing

The simulated store renews on its own clock. It goes into a grace period, then billing retry, then expiry, and by default it reproduces the moment at each renewal. A test runs a year of monthly renewals, and the subscriber is never locked out. It also covers offers, commitments, non-renewing subscriptions and requested purchases.

  • Scenarios gain subscribed=, cancelled=, grace=, retry=, lapsed=, period=, renewal=, intro=, winback=, promo= and signatures=.
  • The debug panel gains a line for each group, and controls to match.
  • StoreKitConfiguration checks subscriptions and offers in the .storekit file against the catalogue.

Not measured

Xcode's test environment could not produce purchase intents, the 12-month commitment, Apple's messages or subscription bundles. These are built on Apple's documented API. Family Sharing, a renewal while the app is closed, and a promotional offer signed with a real key need the sandbox. None of these has been run by hand in the sandbox for this release. The roadmap's known limits lists them.

Verified

  • CI, with Xcode 26.6: every push runs make check, which covers:
    • the tests in debug and release;
    • the iOS and Mac Catalyst builds;
    • the release check, of the package and of the Demo's Release build.
  • The hosted lane: real StoreKit on macOS, with Xcode 26.6. It passes. That test environment renews when told to fail a charge, and gives no grace period when told to give one. The package reports what StoreKit said, and the suite expects this with Xcode 26.6 only (spike).
  • The same suite with Xcode 27, on the Mac and in the iOS 27 simulator: run locally, along with the UI tests.

Breaking

  • ProductAccess gains .subscribed and .nonRenewing. PurchaseCompletion gains .subscribed, .offerNotApplied, .planChangeScheduled and .nonRenewing. Exhaustive switches must handle them.
  • The ports have new requirements, which matter to a store front or fake an app writes itself. ProductPurchasing and PurchaseCommanding take options:, and PurchaseCommanding gains dismissRequestedPurchase(_:). PurchaseStateProviding gains requestedPurchases, introductoryOffer(for:) and winBackOffers(in:). Calls to purchase(_:) and purchase(_:confirmation:) compile as before.

Why each of these was chosen, and what was rejected, is in decisions D35–D59 in docs/10-decisions.md. Start with subscriptions and offers.

swift-storekit

One-time purchases, trials and subscriptions over StoreKit 2, for macOS 26 and iOS 26, with a simulated store for testing them.

⚠️ Early. Non-consumables, trials, auto-renewable subscriptions with their offers, and non-renewing subscriptions. No consumables. The API may still move.

Selling a non-consumable looks like sixty lines of StoreKit, and every app that writes those sixty lines gets a different handful of them wrong. Each of these was found in shipping code or measured against real StoreKit, and each has a test here:

  • Ownership read in a task SwiftUI then cancels. The real store answers a cancelled task with nothing, so a paying customer is shown the paywall.
  • currentEntitlements read the moment purchase() returns, a second before the purchase is listed. The Buy button "does nothing".
  • The listing re-read when an update arrives. An approved Ask to Buy arrives before the listing has it.
  • Another product's transactions finished, so their owner never sees them.
  • Prices waited for before ownership is known, locking owners out offline.
  • A purchase that does not verify reported as a cancellation, to someone who may have been charged.

The one rule

The package reports store facts and performs store actions. Your app owns product policy. What the account owns and since when, whether the store has answered yet, where a trial stands, what is pending approval: here. What a purchase unlocks, limits, paywall wording, what locks on a downgrade: yours. There is deliberately no isPro.

Layout

        what an app imports                                  what a test imports
┌──────────────────┐ ┌────────────┐ ┌────────────────┐ ┌─────────────────┐   ┌─────────────────┐
│ PurchaseStoreKit │ │ PurchaseUI │ │ PurchaseLaunch │ │ PurchaseDebugUI │   │ PurchaseTestKit │
│  the App Store   │ │  SwiftUI   │ │ this launch's  │ │   debug panel   │   │ simulated store,│
│                  │ │            │ │     store      │ │ (empty view in  │   │ manual clock,   │
│                  │ │            │ │                │ │    release)     │   │ .storekit check │
└────────┬─────────┘ └─────┬──────┘ └───┬────────┬───┘ └───┬─────────┬───┘   └───┬─────────┬───┘
         │                 │            │        └─────────┼────┐    │           │         │
         │                 │            │                  │    ▼    ▼           ▼         │
         │                 │            │                  │  ┌───────────────────────┐    │
         │                 │            │                  │  │   PurchaseSimulator   │    │
         │                 │            │                  │  │ DEBUG builds only, and│    │
         │                 │            │                  │  │ imported by no app    │    │
         │                 │            │                  │  └───────────┬───────────┘    │
         └─────────────────┴────────────┴──────────────────┴──────────────┴────────────────┘
                                      ┌─────────────────┐
                                      │  PurchaseCore   │   Foundation and Observation only.
                                      │  all the logic  │   No StoreKit, no SwiftUI.
                                      └─────────────────┘
Product Who imports it In a release build
PurchaseCore, PurchaseStoreKit, PurchaseUI the app Everything
PurchaseLaunch the app The App Store, always. In a debug build a -PurchaseScenario argument chooses a simulated store instead; in release that branch does not exist
PurchaseDebugUI the app A view that draws nothing
PurchaseTestKit test targets, and never an app — it is to this package what StoreKitTest is to StoreKit, and an app that links it does not build Everything that grants nothing (the clock, the waits, the .storekit check); the simulated store only in debug
PurchaseSimulator nobody, usually: it is reached through the two above Nothing at all. Behind #if DEBUG from first line to last, and swift package release-check proves it, of the package and of a built app
PurchaseDirectDistribution a build sold outside the App Store EverythingOwnedStoreFront, and nothing an App Store build should carry

An app's code imports nothing that is missing from a release build, and needs no #if DEBUG about purchases — except round a Window scene, if it gives the debug panel a window of its own, because a scene cannot be conditional.

Using it

.package(url: "https://github.com/Kosikowski/swift-storekit.git", .upToNextMinor(from: "0.3.0"))

Up to the next minor, until 1.0: from: "0.3.0" accepts everything below 1.0, and before 1.0 a minor release is where the API moves.

import PurchaseCore
import PurchaseLaunch
import PurchaseUI
import SwiftUI

let catalogue: Catalogue = [
    .unlock("com.example.pro"),
    .trial("com.example.trial", of: ["com.example.pro"], lasting: .seconds(14 * 86_400)),
]

@main
struct ExampleApp: App {
    // `PurchaseStore` is on the main actor, so it is made where the app is.
    private let launch = StoreLaunch.make(catalogue: catalogue)

    var body: some Scene {
        WindowGroup { ContentView().purchaseStore(launch.store) }
    }
}
struct Paywall: View {
    @Environment(\.purchaseState) private var purchases
    @State private var result: Result<PurchaseCompletion, PurchaseError>?   // local, on purpose

    var body: some View {
        PurchaseButton("com.example.pro") { result = $0 } label: { Text("Buy Pro") }
    }
}

Anything that gates on a purchase waits for the store's first answer rather than reading standing during launch:

let standing = await purchases.knownStanding()
if case .none = standing.access(to: "com.example.pro", at: .now) { showPaywall() }

Five things that decide how to use it

  1. unknown is not none. Until the store has answered, nothing should be locked, offered or judged. Await knownStanding().
  2. Results go to the button that asked. Keep them in that view's @State. A shared flag makes every view watching it raise the same alert.
  3. Ownership does not wait for prices. start() reads what is owned; loadProducts() is separate, and its failure changes nothing about what a person may use.
  4. A trial is a free non-consumable, dated by the App Store. Never store its start. It ends at an instant, so show the time.
  5. The simulated store exists only in DEBUG builds, and Xcode gives a package DEBUG by the configuration's name: Debug-Screenshots yes, Screenshots no.

Testing

make test             # everything that decides anything; offline, no test host
make check            # layers, tests (debug and release), the iOS and Mac Catalyst builds,
                      # the Demo's builds (needs XcodeGen), proof the simulated store is absent
                      # from release (`swift package release-check`, also in Xcode's package
                      # menu), and proof that an app which links the test kit does not build
make integration      # real StoreKit through the real adapter, hosted by Demo/ (needs XcodeGen)
make integration-ios  # the same, in an iOS simulator
make ui-tests         # the Demo launched with scenarios, as a screenshot run launches it
make stress           # the suite ten times, for races

Test your own app against SimulatedStoreFront and a ManualClock, both from import PurchaseTestKit: a trial with five minutes left runs out in no time at all. Launch it for a UI test already owning something with -PurchaseScenario "owns=pro". A test that names the simulated store builds in debug, because that is the only place it exists; an app never names it at all. See testing and the simulated store.

Documentation

Architecture Targets, layers, ports, and where SOLID is bent
Getting started From nothing to a working purchase
Catalogue and standing The values an app reads
Trials The free non-consumable, its dates and its end
Subscriptions Declaring them, where a subscriber stands, renewals, plan changes, managing, the 12-month commitment, bundles, purchases asked for on the App Store, Apple's messages, non-renewing subscriptions, testing
Offers Introductory, win-back and promotional offers, the override, codes, and a server that signs
Testing Apple's environments, and where the simulated store fits
The simulated store Behaviour, scenarios, the debug panel, previews
Release safety Keeping the simulated store out of what ships
The StoreKit adapter Calls made, finish policy, error mapping
App Store Connect The setup that is not code
Decisions Why, with the evidence for each
Not implemented, deliberately Consumables, an offer-code button, and the rest
Subscriptions and offers in StoreKit Research: how the App Store runs subscriptions and offers, and what an app can see
Plan: subscriptions and offers What was built, in what order, what was measured first, and what is next
Migrating an existing app From hand-written StoreKit 2
Checklist Everything to get right, and who handles it

Licence

MIT. See LICENSE.

Description

  • Swift Tools 6.2.0
View More Packages from this Author

Dependencies

  • None
Last updated: Sun Oct 04 2026 23:38:01 GMT-0900 (Hawaii-Aleutian Daylight Time)