# StoreKit 2 Advanced Patterns

Reach for this file once purchasing, the launch-time listener and entitlement
checks from [core-patterns.md](core-patterns.md) are in place. Each heading
names the release an API needs; the skill as a whole targets iOS 26.

## Contents

- [Paywall Control Styles](#paywall-control-styles)
- [Where the Controls Sit](#where-the-controls-sit)
- [Button Labels](#button-labels)
- [Container Backgrounds](#container-backgrounds)
- [Store Buttons](#store-buttons)
- [Policy Destinations](#policy-destinations)
- [An Icon per Option](#an-icon-per-option)
- [Sign-In Action](#sign-in-action)
- [Grouping Options (iOS 18+)](#grouping-options-ios-18)
- [Introductory Offers](#introductory-offers)
- [Promotional Offers](#promotional-offers)
- [Win-Back Offers (iOS 18+)](#win-back-offers-ios-18)
- [Offer Codes](#offer-codes)
- [Which Offer a Transaction Used](#which-offer-a-transaction-used)
- [Server-Side Validation](#server-side-validation)
- [Testing in Xcode](#testing-in-xcode)
- [Renewal States and What to Grant](#renewal-states-and-what-to-grant)
- [Grace Period and Billing Retry](#grace-period-and-billing-retry)
- [Refunds](#refunds)
- [Family Sharing](#family-sharing)
- [Ask to Buy](#ask-to-buy)
- [Task Modifiers](#task-modifiers)
- [Live Subscription Status](#live-subscription-status)
- [Promoted Purchases on the Product Page](#promoted-purchases-on-the-product-page)
- [Price Increases](#price-increases)
- [Unfinished Transactions](#unfinished-transactions)
- [Advanced Mistakes](#advanced-mistakes)

## Paywall Control Styles

`.subscriptionStoreControlStyle(_:)` chooses how plans are presented.

| Style | Look | Since |
|-------|------|-------|
| `.automatic` | the system decides for the context | iOS 17 |
| `.buttons` | one button per plan | iOS 17 |
| `.picker` | an inline picker | iOS 17 |
| `.prominentPicker` | a picker that emphasises the selected plan | iOS 17 |
| `.pagedPicker` | one plan per swipeable page | iOS 18 |
| `.pagedProminentPicker` | paged, with the selection emphasised | iOS 18 |
| `.compactPicker` | a minimal picker for tight layouts | iOS 18 |

## Where the Controls Sit

`.subscriptionStoreControlStyle(_:placement:)` (iOS 18) adds a placement. The
placement type belongs to the style, so the valid values differ:

| Style | Placements on iOS |
|-------|-------------------|
| `.picker`, `.prominentPicker` | `.automatic`, `.scrollView`, `.buttonsInBottomBar` |
| `.pagedPicker`, `.pagedProminentPicker`, `.compactPicker` | `.automatic`, `.scrollView`, `.bottomBar`, `.buttonsInBottomBar` |
| `.automatic` | `.automatic`, `.scrollView`, `.bottomBar`, `.buttonsInBottomBar` |

`.leading`, `.trailing` and `.bottom` exist only on tvOS, so
`.subscriptionStoreControlStyle(.picker, placement: .bottom)` does not compile
for iOS. Even a valid placement is a request: the system can fall back to its
default on some platforms, so do not promise an exact position.

```swift
@available(iOS 18, *)
struct PagedPlans: View {
    let group: String

    var body: some View {
        SubscriptionStoreView(groupID: group)
            .subscriptionStoreControlStyle(.pagedPicker, placement: .bottomBar)
    }
}
```

## Button Labels

`.subscriptionStoreButtonLabel(_:)` (iOS 17):

| Label | Shows |
|-------|-------|
| `.multiline` | name, price and period |
| `.price` | only the price |
| `.displayName` | only the plan name |
| `.action` | a verb such as "Subscribe" |
| `.singleLine` | everything squeezed onto one line |
| `.automatic` | the system default |

## Container Backgrounds

| Call | Paints |
|------|--------|
| `.containerBackground(.indigo.gradient, for: .subscriptionStore)` | the whole paywall, from a style |
| `.containerBackground(for: .subscriptionStoreHeader) { Image("hero").resizable() }` | only the header area |
| `.containerBackground(for: .subscriptionStoreFullHeight) { LinearGradient(...) }` | full height, behind everything |

## Store Buttons

`.storeButton(_:for:)` shows or hides system buttons. Kinds:
`.restorePurchases`, `.redeemCode`, `.cancellation`, `.policies`, `.signIn`.
Visibility is `.visible` or `.hidden`.

```swift
SubscriptionStoreView(groupID: group)
    .storeButton(.visible, for: .restorePurchases, .redeemCode)
    .storeButton(.hidden, for: .signIn)
```

## Policy Destinations

- By URL: `.subscriptionStorePolicyDestination(url:for:)` with
  `.termsOfService` or `.privacyPolicy`.
- By view: `.subscriptionStorePolicyDestination(for: .termsOfService) { TermsView() }`.
- Link colour: `.subscriptionStorePolicyForegroundStyle(.secondary)`.

## An Icon per Option

```swift
SubscriptionStoreView(groupID: group)
    .subscriptionStoreControlIcon { _, info in
        Image(systemName: info.subscriptionPeriod.unit == .year ? "star.circle.fill" : "calendar")
    }
```

## Sign-In Action

Show the system sign-in button and decide what it does:

```swift
SubscriptionStoreView(groupID: group)
    .storeButton(.visible, for: .signIn)
    .subscriptionStoreSignInAction { showLogin = true }
    .sheet(isPresented: $showLogin) { LoginSheet() }
```

## Grouping Options (iOS 18+)

The hierarchy helpers arrange plans inside `SubscriptionStoreView`; the
content-builder initializer that takes them is iOS 18.

```swift
@available(iOS 18, *)
struct TieredPlans: View {
    let group: String

    var body: some View {
        SubscriptionStoreView(groupID: group) {
            SubscriptionOptionGroup("Monthly") { $0.subscription?.subscriptionPeriod.unit == .month }
            SubscriptionOptionGroup("Yearly") { $0.subscription?.subscriptionPeriod.unit == .year }
        }
    }
}
```

- `SubscriptionOptionGroup(_:isIncluded:)` groups plans by a predicate.
- `SubscriptionPeriodGroupSet()` groups by billing period for you.
- `SubscriptionOptionSection(_:isIncluded:)` adds a section with a header;
  the predicate can look at `product.subscription?.groupLevel` to split tiers.

`SubscriptionStoreView(groupID:visibleRelationships:)` is iOS 17: `.all`
lists every plan, `.upgrade` only plans above the current one.

`SubscriptionStoreButton`, `SubscriptionStorePicker`,
`SubscriptionOptionGroup` and their relatives are building blocks for a custom
`SubscriptionStoreControlStyle` and belong inside its
`makeBody(configuration:)`. Placing them as free-standing content outside a
control style is not supported.

## Introductory Offers

```swift
func trialLine(for plan: Product, group: String) async -> String? {
    guard let intro = plan.subscription?.introductoryOffer,
          await Product.SubscriptionInfo.isEligibleForIntroOffer(for: group) else { return nil }
    let length = "\(intro.periodCount) x \(intro.period.value) \(intro.period.unit)"
    switch intro.paymentMode {
    case .freeTrial:
        return "Free for \(length), then \(plan.displayPrice)"
    case .payAsYouGo:
        return "\(intro.displayPrice) per period for \(length)"
    case .payUpFront:
        return "\(intro.displayPrice) up front for \(length)"
    default:
        return nil
    }
}
```

- Eligibility per product: `product.subscription?.isEligibleForIntroOffer`,
  treat `nil` as `false`. Per group:
  `await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)`.
- `introductoryOffer` gives `displayPrice` (localized), `period`
  (`Product.SubscriptionPeriod`), `periodCount` and `paymentMode`.
- An eligible user gets the intro offer automatically; no purchase option is
  involved.

## Promotional Offers

Promotional offers need a signature produced by your server. List them from
`product.subscription?.promotionalOffers` (empty when `nil`); each has an
optional `id` and a `displayPrice`.

On iOS 26 the server signs a compact JWS and the app passes it with
`Product.PurchaseOption.promotionalOffer(_:compactJWS:)`, which returns an
array of options and is back-deployed:

```swift
enum OfferError: Error { case missingID }

func redeem(_ offer: Product.SubscriptionOffer, on plan: Product, signer: OfferSigner) async throws -> Product.PurchaseResult {
    guard let offerID = offer.id else { throw OfferError.missingID }
    let jws = try await signer.compactJWS(product: plan.id, offer: offerID)
    let options = Set(Product.PurchaseOption.promotionalOffer(offerID, compactJWS: jws))
    return try await plan.purchase(options: options)
}
```

The older forms, `.promotionalOffer(offerID:signature:)` with a
`Product.SubscriptionOffer.Signature(keyID:nonce:timestamp:signature:)`
(iOS 17.4) and `.promotionalOffer(offerID:keyID:nonce:signature:timestamp:)`
(iOS 15), are deprecated in iOS 26. Keep them only behind an availability
check for older targets.

Store views on iOS 26 take the offer and the signature as closures:

```swift
@available(iOS 26, *)
struct WinTheUserBack: View {
    let group: String
    let signer: OfferSigner

    var body: some View {
        SubscriptionStoreView(groupID: group)
            .subscriptionPromotionalOffer { _, subscription in
                subscription.promotionalOffers.first
            } compactJWS: { product, _, offer in
                try await signer.compactJWS(product: product.id, offer: offer.id ?? "")
            }
    }
}
```

That replaces `.subscriptionPromotionalOffer(offer:signature:)`, deprecated in
iOS 26.

`.preferredSubscriptionOffer { product, subscription, eligibleOffers in ... }`
(iOS 18) picks which eligible offer a store view presents to this user;
return `nil` for none.

## Win-Back Offers (iOS 18+)

Win-back offers target people who used to subscribe and cancelled.
`product.subscription?.winBackOffers` (empty when `nil`) is the raw list for
the product, not for this user: filter it through the verified
`renewalInfo.eligibleWinBackOfferIDs` before showing or applying anything.

```swift
@available(iOS 18, *)
func eligibleWinBacks(for plan: Product, group: String) async throws -> [Product.SubscriptionOffer] {
    let all = plan.subscription?.winBackOffers ?? []
    let byID = Dictionary(all.compactMap { offer in offer.id.map { ($0, offer) } },
                          uniquingKeysWith: { first, _ in first })
    var allowedIDs: [String] = []
    for status in try await Product.SubscriptionInfo.status(for: group) {
        if case .verified(let renewal) = status.renewalInfo {
            allowedIDs.append(contentsOf: renewal.eligibleWinBackOfferIDs)
        }
    }
    return allowedIDs.compactMap { byID[$0] }
}
```

The result keeps the order of `eligibleWinBackOfferIDs`. Buy with
`plan.purchase(options: [.winBackOffer(offer)])`.

## Offer Codes

```swift
struct RedeemRow: View {
    @State private var showRedeem = false

    var body: some View {
        Button("Redeem code") { showRedeem = true }
            .offerCodeRedemption(isPresented: $showRedeem) { result in
                switch result {
                case .success:
                    Task { await EntitlementStore.shared.refresh() }
                case .failure:
                    showRedeem = false
                }
            }
    }
}
```

- `.offerCodeRedemption(isPresented:onCompletion:)` (iOS 16) presents the
  system sheet; refresh entitlements on success, handle the error on failure.
- On a subscription store, `.storeButton(.visible, for: .redeemCode)` adds a
  redeem button instead.
- Test in a StoreKit configuration file and in sandbox. In StoreKit testing,
  `.codeOffer(referenceName:)` from `StoreKitTest` applies a code by its
  reference name. Also drive the real redemption path (button or sheet), then
  check `transaction.offer?.type == .code` and the expected
  `transaction.offer?.id`.

## Which Offer a Transaction Used

`transaction.offer` (iOS 17.2) is optional and has `type` and `id`. The
`type` is `.introductory`, `.promotional`, `.code` or `.winBack`; keep a
`default` branch for future cases. It replaces `offerType` and `offerID`,
deprecated in iOS 17.2.

## Server-Side Validation

Every StoreKit 2 transaction is a signed JWS. Send the raw string, not decoded
fields:

```swift
func report(_ check: VerificationResult<StoreKit.Transaction>, to api: ReceiptAPI) async throws {
    let transaction = try verified(check)
    try await api.submit(jws: check.jwsRepresentation, product: transaction.productID)
    await transaction.finish()
}
```

- On the server, Apple's App Store Server Library decodes and checks it:
  `verifyAndDecodeTransaction(signedTransaction:)` for transactions and
  `verifyAndDecodeRenewalInfo(signedRenewalInfo:)` for renewal info.
- The format is the `JWSTransaction` used by the App Store Server API and App
  Store Server Notifications V2.
- Against replay, also send `transaction.deviceVerification` and
  `transaction.deviceVerificationNonce` so the server can bind the
  transaction to the device.

## Testing in Xcode

Setup:

1. File > New > File > StoreKit Configuration File.
2. Add products that mirror App Store Connect.
3. Choose it in Edit Scheme > Run > Options > StoreKit Configuration.

The file holds product ID, reference name, type, price and locale,
subscription group and level, introductory and promotional offers, and Family
Sharing.

Purchase options for tests:

| Option | Source | Effect |
|--------|--------|--------|
| `.simulatesAskToBuyInSandbox(true)` | StoreKit | turns the purchase into an Ask to Buy request |
| `.purchaseDate(someDate, renewalBehavior: .renewUntilNow)` | StoreKitTest, iOS 17 | backdates the purchase; `.cancelImmediately` is the other behaviour |
| `.codeOffer(referenceName: "SPRING")` | StoreKitTest, iOS 17 | applies an offer code |

What the local test environment lets you do:

- make transactions fail, interrupt purchases, issue refunds;
- leave Ask to Buy pending for promotional, win-back and offer-code purchases;
- speed renewals up to minutes instead of months;
- enter grace period and billing retry;
- clear purchase history between runs;
- redeem offers;
- expire or cancel subscriptions.

The Transaction Manager (Debug > StoreKit > Manage Transactions) lists test
transactions and can delete them (to reset), refund them, expire
subscriptions, and approve or decline Ask to Buy.

## Renewal States and What to Grant

| State | Access | Tell the user |
|-------|--------|---------------|
| `.subscribed` | full | nothing |
| `.inGracePeriod` | full | a gentle prompt to update the payment method |
| `.inBillingRetryPeriod` | your call; Apple suggests limited access to encourage a fix | a "payment failed" banner |
| `.expired` | none | depends on `renewalInfo.expirationReason` |
| `.revoked` | none, Apple refunded it | nothing to sell |

Expiration reasons and a reasonable response:

| `expirationReason` | Meaning | Response |
|--------------------|---------|----------|
| `.autoRenewDisabled` | the user cancelled | a resubscribe offer |
| `.billingError` | payment failed | ask for a new payment method |
| `.didNotConsentToPriceIncrease` | declined a price rise | explain the new price |
| `.productUnavailable` | the plan is no longer sold | nothing |
| `.unknown` | no reason given | nothing specific |

## Grace Period and Billing Retry

- Grace period is switched on in App Store Connect; subscribers keep access
  for a short time after a failed charge. Detect it with
  `status.state == .inGracePeriod` and read
  `renewalInfo.gracePeriodExpirationDate` from the verified renewal info.
  Grant access and ask for a payment update.
- Apple retries failed charges by itself. The state is
  `.inBillingRetryPeriod`, and `renewalInfo.isInBillingRetry` is `true`.

## Refunds

- From a transaction: `try await transaction.beginRefundRequest(in: windowScene)`.
- From an ID: `try await Transaction.beginRefundRequest(for: transactionID, in: windowScene)`.
- In SwiftUI: `.refundRequestSheet(for: transactionID, isPresented: $asking) { result in ... }`.

A granted refund arrives on `Transaction.updates` with a `revocationDate`.
Remove access for `transaction.productID`; `transaction.revocationPercentage`
tells a partial refund from a full one.

## Family Sharing

- `transaction.ownershipType` is `.purchased` (bought by this user) or
  `.familyShared` (can disappear when the organiser stops sharing); keep a
  default branch.
- `product.isFamilyShareable` says whether the product can be shared; sharing
  is enabled per product in App Store Connect.
- Sharing changes come through `Transaction.updates`; when a member stops
  sharing, the transaction is revoked.

## Ask to Buy

- Applies to child accounts in a Family Sharing group.
- The purchase returns `.pending`. Show a waiting state, keep content locked,
  and unlock only when the approved transaction arrives on
  `Transaction.updates`.
- Test with `.simulatesAskToBuyInSandbox(true)` and approve or decline in the
  Transaction Manager.
- Run the Ask to Buy path through promotional, win-back, offer-code and
  renewal tests too, so a deferred approval cannot skip verification or the
  entitlement refresh.

## Task Modifiers

- `.currentEntitlementTask(for: productID) { state in ... }` (iOS 17) reports
  an `EntitlementTaskState<VerificationResult<Transaction>?>`: `.loading`,
  `.success(value)` or `.failure(any Error)`. Grant access only for
  `.success(.some(.verified(let transaction)))` with a `nil`
  `revocationDate`.
- `.storeProductTask(for: id) { state in ... }` loads one product;
  `Product.TaskState` is `.loading`, `.success(Product)`, `.unavailable` or
  `.failure`.
- `.storeProductsTask(for: ids) { state in ... }` loads several;
  `Product.CollectionTaskState` is `.loading`,
  `.success([Product], unavailable:)` or `.failure`.
- `.subscriptionStatusTask(for: groupID) { state in ... }` follows a group's
  status.

## Live Subscription Status

`Product.SubscriptionInfo.Status.all` (iOS 17) streams status changes for
every group:

```swift
func watchPlans(onChange: @escaping @Sendable (Product.SubscriptionInfo.RenewalState) -> Void) -> Task<Void, Never> {
    Task {
        for await (_, statuses) in Product.SubscriptionInfo.Status.all {
            for status in statuses {
                guard case .verified = status.renewalInfo else { continue }
                onChange(status.state)
            }
        }
    }
}
```

## Promoted Purchases on the Product Page

`Product.PromotionInfo` (iOS 16.4) reads and overrides, on this device, the
order and visibility of in-app purchases promoted on the App Store product
page. Configure the defaults in App Store Connect and override per device only
when needed.

## Price Increases

Read `renewalInfo.priceIncreaseStatus` from verified renewal info:

| Value | Meaning |
|-------|---------|
| `.noIncreasePending` | nothing to show |
| `.pending` | the user has not agreed yet; show the new price from `renewalInfo.renewalPrice` and `renewalInfo.currency` |
| `.agreed` | accepted |

## Unfinished Transactions

`Transaction.updates` hands over unfinished transactions once, just after
launch. `Transaction.unfinished` lets you sweep for them on purpose, for
example after your delivery server was down or when the listener started
late:

```swift
func sweepUnfinished(using store: EntitlementStore) async {
    for await pending in StoreKit.Transaction.unfinished {
        guard case .verified(let transaction) = pending else { continue }
        try? await store.deliver(transaction)
        await transaction.finish()
    }
}
```

The listener remains the always-on path; the sweep is recovery, not a
substitute.

## Advanced Mistakes

| Wrong | Right |
|-------|-------|
| An `App` initializer with neither listener nor sweep | start the listener in `init()`, and run the unfinished sweep in a `Task` when recovery is needed |
| Removing access in `.inBillingRetryPeriod` | limited access plus a prompt to fix payment |
| Treating `.familyShared` as permanent | also require `revocationDate == nil` and let `Transaction.updates` deliver a later revocation |
