Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import StoreTransactionKit
import StoreKit

public enum SubscriptionEntitlement: Hashable, Sendable {
case tier1
Expand Down Expand Up @@ -27,6 +28,26 @@ public enum Plans: AutoRenewableSubscriptionGroup<SubscriptionEntitlement> {

public let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self)

public let subscriptionProductIDs = subscriptionCatalog.productIDs

public func loadDeclaredSubscriptionProducts() async throws -> [Product] {
try await Product.products(for: subscriptionCatalog.productIDs)
}

public func loadSubscriptionStatuses() async throws
-> [Product.SubscriptionInfo.Status]
{
try await Product.SubscriptionInfo.status(
for: subscriptionCatalog.subscriptionGroupID.rawValue
)
}

public func entitlement(
for productID: String
) -> SubscriptionEntitlement? {
subscriptionCatalog.entitlement(for: productID)
}

public let legacySubscriptionProductID =
"external-consumer.subscription.legacy"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ import StoreTransactionKitTesting
import StoreKit
import Testing

@Test
func catalogExposesStoreKitProductLookupInputs() {
#expect(subscriptionCatalog.subscriptionGroupID == Plans.id)
#expect(
subscriptionProductIDs
== [
Plans.ProductID.tier1_Monthly.rawValue,
Plans.ProductID.tier1_Yearly.rawValue,
Plans.ProductID.tier2_Monthly.rawValue,
Plans.ProductID.tier2_Yearly.rawValue,
]
)
#expect(
entitlement(for: Plans.ProductID.tier1_Yearly.rawValue)
== .tier1
)
#expect(entitlement(for: "external-consumer.unknown") == nil)
}

@Test
@MainActor
func tier1PurchaseAndExpirationUpdateViewModel() async throws {
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ struct ContentView: View {
}
.sheet(isPresented: $isShowingPaywall) {
SubscriptionStoreView(
productIDs: Plans.subscriptions.map(\.id.rawValue)
productIDs: subscriptionCatalog.productIDs
)
}
}
Expand All @@ -166,6 +166,24 @@ configuration when running local StoreKit tests.
Keep normal app content usable while the entitlement set is unavailable. Gate
only features that require an active purchase.

## Product information

Load the catalog's declared products when building custom subscription UI:

```swift
import StoreKit

let products = try await Product.products(
for: subscriptionCatalog.productIDs
)
```

`Product` supplies localized presentation, and `Product.SubscriptionInfo`
supplies subscription level and period. `StoreTransactionSnapshot` remains the
verified transaction projection. See
[Defining subscription access](Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md)
for the complete mapping example.

## Override entitlements

An app-defined debug, preview, or distribution environment can bypass StoreKit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Pass the same declared Product IDs to StoreKit's subscription view:
import StoreKit

SubscriptionStoreView(
productIDs: Plans.subscriptions.map(\.id.rawValue)
productIDs: subscriptionCatalog.productIDs
)
```

Expand All @@ -77,6 +77,54 @@ typed access by default, and doesn't by itself make entitlement readiness fail.
See <doc:UnderstandingTransactionHandling> to choose another policy with
``UnrecognizedSubscriptionDelegate``.

## Load product information

The catalog exposes declared Product IDs in declaration order. Load their
current StoreKit metadata for custom merchandising:

```swift
import StoreKit

let loadedProducts = try await Product.products(
for: subscriptionCatalog.productIDs
)
let productsByID = Dictionary(
uniqueKeysWithValues: loadedProducts.map { ($0.id, $0) }
)

for productID in subscriptionCatalog.productIDs {
guard
let product = productsByID[productID],
let entitlement = subscriptionCatalog.entitlement(for: productID),
let subscription = product.subscription
else {
continue
}

let displayName = product.displayName
let displayPrice = product.displayPrice
let groupLevel = subscription.groupLevel
let period = subscription.subscriptionPeriod
}
```

``AutoRenewableSubscriptionCatalog/entitlement(for:)`` joins a StoreKit
`Product` to the app entitlement declared by this binary.
`Product.SubscriptionInfo` supplies the current group level, period, group
metadata, and offers. It is product metadata rather than part of the verified
``StoreTransactionSnapshot``.

Query current renewal status separately with the same catalog identity:

```swift
let statuses = try await Product.SubscriptionInfo.status(
for: subscriptionCatalog.subscriptionGroupID.rawValue
)
```

Use these statuses for renewal and billing presentation. Keep
``TransactionStore/activeEntitlements`` as the access source of truth.

## Read access without blocking the UI

``TransactionStore/isEntitled(to:)`` performs exact set membership and returns
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ import StoreKit
/// and group metadata before publication.
public struct AutoRenewableSubscriptionCatalog<Entitlement>: Sendable
where Entitlement: Hashable & Sendable {
package let subscriptionGroupID: SubscriptionGroupID
private struct Entry: Sendable {
let entitlement: Entitlement
}

private let declaringGroupTypeID: ObjectIdentifier
private let entitlementsByProductID: [Product.ID: Entitlement]
private let entriesByProductID: [Product.ID: Entry]

/// The subscription group identifier configured in App Store Connect.
public let subscriptionGroupID: SubscriptionGroupID

/// The declared product identifiers in declaration order.
///
/// Use this collection with StoreKit product-loading and merchandising APIs.
public let productIDs: [Product.ID]

/// Creates and validates a catalog from one auto-renewable subscription group.
///
Expand All @@ -26,8 +36,10 @@ where Entitlement: Hashable & Sendable {
"An auto-renewable subscription group must declare at least one product."
)

var entitlementsByProductID: [Product.ID: Entitlement] = [:]
entitlementsByProductID.reserveCapacity(subscriptions.count)
var entriesByProductID: [Product.ID: Entry] = [:]
entriesByProductID.reserveCapacity(subscriptions.count)
var productIDs: [Product.ID] = []
productIDs.reserveCapacity(subscriptions.count)

for subscription in subscriptions {
let productID = subscription.id.rawValue
Expand All @@ -37,16 +49,27 @@ where Entitlement: Hashable & Sendable {
"A subscription product identifier must not be empty."
)
precondition(
entitlementsByProductID[productID] == nil,
entriesByProductID[productID] == nil,
"A subscription product identifier must not be declared more than once: \(productID)"
)

entitlementsByProductID[productID] = subscription.entitlement
entriesByProductID[productID] = Entry(
entitlement: subscription.entitlement
)
productIDs.append(productID)
}

subscriptionGroupID = Group.id
self.productIDs = productIDs
declaringGroupTypeID = ObjectIdentifier(groupType)
self.entitlementsByProductID = entitlementsByProductID
self.entriesByProductID = entriesByProductID
}

/// Returns the app entitlement declared for a product identifier.
///
/// The result is `nil` when the identifier isn't declared by this catalog.
public func entitlement(for productID: Product.ID) -> Entitlement? {
entriesByProductID[productID]?.entitlement
}

package func classification(
Expand All @@ -62,13 +85,13 @@ where Entitlement: Hashable & Sendable {
}

package func contains(productID: Product.ID) -> Bool {
entitlementsByProductID[productID] != nil
entriesByProductID[productID] != nil
}

private func validatedTransaction(
_ transaction: StoreTransactionSnapshot
) throws(AutoRenewableSubscriptionCatalogError) -> ValidatedTransaction {
if let entitlement = entitlementsByProductID[transaction.productID] {
if let entry = entriesByProductID[transaction.productID] {
guard transaction.productType == .autoRenewable else {
throw AutoRenewableSubscriptionCatalogError.productTypeMismatch(
productID: transaction.productID,
Expand All @@ -84,7 +107,7 @@ where Entitlement: Hashable & Sendable {
)
}

return .declared(entitlement)
return .declared(entry.entitlement)
}

guard transaction.subscriptionGroupID == subscriptionGroupID.rawValue else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,24 @@ struct AutoRenewableSubscriptionCatalogTests {

#expect(countingSubscriptionsAccessCount.withLock { $0 } == 1)
#expect(catalog.subscriptionGroupID == CountingPlans.id)
#expect(
catalog.productIDs
== [CountingPlans.ProductID.monthly.rawValue]
)
#expect(catalog.isDeclared(by: CountingPlans.self))
#expect(!catalog.isDeclared(by: OtherPlans.self))
#expect(catalog.contains(productID: CountingPlans.ProductID.monthly.rawValue))
#expect(!catalog.contains(productID: "com.example.subscription.undeclared"))
#expect(
catalog.entitlement(
for: CountingPlans.ProductID.monthly.rawValue
) == .tier1
)
#expect(
catalog.entitlement(
for: "com.example.subscription.undeclared"
) == nil
)

let transaction = subscriptionSnapshot(
id: 0,
Expand All @@ -36,6 +50,15 @@ struct AutoRenewableSubscriptionCatalogTests {
@Test("monthly and yearly products classify with their declared entitlements")
func classifiesDeclaredEntitlements() throws {
let catalog = AutoRenewableSubscriptionCatalog(Plans.self)
#expect(
catalog.productIDs
== [
Plans.ProductID.tier1_Monthly.rawValue,
Plans.ProductID.tier1_Yearly.rawValue,
Plans.ProductID.tier2_Monthly.rawValue,
Plans.ProductID.tier2_Yearly.rawValue,
]
)
let classifications = try [
Plans.ProductID.tier1_Monthly,
.tier1_Yearly,
Expand All @@ -60,6 +83,25 @@ struct AutoRenewableSubscriptionCatalogTests {
)
}

@Test("optional entitlement values remain declared")
func optionalEntitlementRemainsDeclared() throws {
let catalog = AutoRenewableSubscriptionCatalog(OptionalPlans.self)
let productID = OptionalPlans.ProductID.monthly.rawValue

#expect(catalog.productIDs == [productID])
#expect(catalog.entitlement(for: productID) == .some(nil))
#expect(catalog.contains(productID: productID))
#expect(
try catalog.classification(
of: subscriptionSnapshot(
id: 4,
productID: productID,
subscriptionGroupID: OptionalPlans.id.rawValue
)
) == .declared(nil)
)
}

@Test("an upgraded declared product remains declared")
func upgradedDeclaredProductRemainsDeclared() throws {
let catalog = AutoRenewableSubscriptionCatalog(Plans.self)
Expand Down Expand Up @@ -294,6 +336,20 @@ private enum OtherPlans:
}
}

private enum OptionalPlans:
AutoRenewableSubscriptionGroup<SubscriptionEntitlement?>
{
static let id = SubscriptionGroupID(rawValue: "optional-group")

enum ProductID: String, Hashable, Sendable {
case monthly = "com.example.optional.monthly"
}

static var subscriptions: StoreSubscriptions {
StoreSubscription(.monthly, entitlement: nil)
}
}

private let countingSubscriptionsAccessCount = Mutex(0)

private enum CountingPlans:
Expand Down