
Subscription Lifecycle
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates StoreKit 2 subscription lifecycle management with grace periods, billing retry, offer codes, win-back offers, and upgrade/downgrade paths.
About
Generates StoreKit 2 subscription lifecycle management with real-time status monitoring, grace period and billing-retry handling, offer code redemption, win-back offers, and tier transitions. A developer uses it for post-purchase subscription state handling beyond the initial paywall.
- Grace period, billing retry, and status monitoring
- Offer codes, win-back, and upgrade/downgrade paths
Subscription Lifecycle by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill subscription-lifecycleAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates StoreKit 2 subscription lifecycle management with grace periods, billing retry, offer codes, win-back offers, and upgrade/downgrade paths.
Files
Subscription Lifecycle Generator
Generate production StoreKit 2 subscription lifecycle management with real-time status monitoring, grace period handling, billing retry detection, offer code redemption, win-back offers, and upgrade/downgrade path support.
Different from paywall-generator: The paywall generator handles the purchase UI and initial transaction. This skill handles everything that happens after purchase — monitoring subscription state changes, handling payment failures, retaining churning users, and managing tier transitions.
When This Skill Activates
Use this skill when the user:
- Asks about "subscription management" or "subscription lifecycle"
- Mentions "grace period handling" or "grace period UI"
- Wants "billing retry" detection or payment failure handling
- Asks about "win-back offers" or "re-engagement offers"
- Mentions "subscription status" monitoring or dashboard
- Wants "upgrade/downgrade" path management
- Asks about "offer codes" or "promotional offers"
- Mentions "subscription churn" or "retention"
- Wants to "track subscription state changes"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check deployment target (StoreKit 2 requires iOS 15+)
- [ ] Check for @Observable support (iOS 17+ / macOS 14+)
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Identify source file locations
2. Existing StoreKit Detection
Search for existing subscription code:
Glob: **/*Store*.swift, **/*Subscription*.swift, **/*Entitlement*.swift
Grep: "import StoreKit" or "Transaction.updates" or "Product.SubscriptionInfo"If paywall-generator output found:
- Integrate with existing
StoreKitManager— don't duplicate product loading - Extend existing
SubscriptionStatusenum if present - Wire into existing transaction listener
If no existing StoreKit code found:
- Generate standalone — include minimal product loading
- Recommend running paywall-generator for purchase UI
3. Entitlement Check
Grep: "In-App Purchase" or "StoreKit" in *.entitlementsIf missing, warn user to add the In-App Purchase capability in Xcode.
Configuration Questions
Ask user via AskUserQuestion:
1. Subscription tiers?
- Single tier (one plan, e.g., "Pro")
- Multiple tiers (e.g., "Basic", "Pro", "Business") with upgrade/downgrade paths
2. Lifecycle features? (multi-select)
- Grace period detection and UI messaging
- Billing retry period handling
- Offer code redemption (App Store offer codes)
- Win-back offers for expired subscribers
- Upgrade/downgrade/crossgrade management
3. Include subscription dashboard UI?
- Yes — SwiftUI view showing current plan, renewal date, management options
- No — logic only, integrate into existing UI
4. Server-side verification?
- Client-only (StoreKit 2 on-device verification) — recommended for most apps
- Server-side (App Store Server API v2) — for apps with server backends
Generation Process
Step 1: Read Templates and Patterns
Read patterns.md for lifecycle state diagrams and StoreKit 2 behavior reference. Read templates.md for production Swift code templates.
Step 2: Create Core Files
Generate these files: 1. SubscriptionState.swift — Comprehensive enum for all lifecycle states 2. SubscriptionMonitor.swift — @Observable class monitoring real-time status via Transaction.updates and Product.SubscriptionInfo 3. SubscriptionEntitlement.swift — Maps product IDs to feature access levels
Step 3: Create Lifecycle Handlers
Based on configuration: 4. GracePeriodHandler.swift — If grace period selected 5. OfferManager.swift — If offer codes or win-back selected
Step 4: Create UI Files
If dashboard UI selected: 6. SubscriptionDashboardView.swift — SwiftUI view for plan management
Step 5: Determine File Location
Check project structure:
- If
Sources/Store/exists →Sources/Store/Lifecycle/ - If
Sources/exists →Sources/SubscriptionLifecycle/ - If
App/exists →App/SubscriptionLifecycle/ - Otherwise →
SubscriptionLifecycle/
Output Format
After generation, provide:
Files Created
SubscriptionLifecycle/
├── SubscriptionState.swift # All lifecycle states enum
├── SubscriptionMonitor.swift # Real-time status monitoring
├── SubscriptionEntitlement.swift # Product ID → feature mapping
├── GracePeriodHandler.swift # Grace period detection & UI (optional)
├── OfferManager.swift # Offers, codes, win-back (optional)
└── SubscriptionDashboardView.swift # Plan management UI (optional)Integration with Existing Paywall
If paywall-generator was already used:
// In your existing StoreKitManager, add lifecycle monitoring
@Observable
final class StoreKitManager {
// ... existing product loading and purchase code ...
let lifecycleMonitor = SubscriptionMonitor()
func startMonitoring() async {
await lifecycleMonitor.start(
groupID: "your.subscription.group",
entitlements: SubscriptionEntitlement.default
)
}
}App Entry Point:
@main
struct MyApp: App {
@State private var monitor = SubscriptionMonitor()
var body: some Scene {
WindowGroup {
ContentView()
.environment(monitor)
.task { await monitor.start(groupID: "your.group.id") }
}
}
}Check Access Anywhere:
struct PremiumFeatureView: View {
@Environment(SubscriptionMonitor.self) private var monitor
var body: some View {
if monitor.hasAccess {
// Full feature
PremiumContent()
} else if monitor.state == .inGracePeriod {
// Feature still accessible, but show payment warning
VStack {
PaymentWarningBanner()
PremiumContent()
}
} else {
// Show paywall
PaywallView()
}
}
}Grace Period Notification:
struct ContentView: View {
@Environment(SubscriptionMonitor.self) private var monitor
var body: some View {
NavigationStack {
MainContent()
.overlay(alignment: .top) {
if monitor.state == .inGracePeriod {
GracePeriodBanner(
daysRemaining: monitor.gracePeriodDaysRemaining,
onFixPayment: { /* open manage subscriptions */ }
)
}
}
}
}
}Win-Back Offer:
struct ExpiredUserView: View {
@State private var offerManager = OfferManager()
var body: some View {
if let winBackOffer = offerManager.availableWinBackOffer {
WinBackOfferCard(offer: winBackOffer) {
try await offerManager.redeemWinBackOffer(winBackOffer)
}
} else {
StandardPaywallView()
}
}
}Testing
@Test
func gracePeriodGrantsAccess() async throws {
let monitor = SubscriptionMonitor()
monitor.updateState(.inGracePeriod(expiresIn: 3))
#expect(monitor.hasAccess == true)
#expect(monitor.gracePeriodDaysRemaining == 3)
}
@Test
func billingRetryGrantsAccess() async throws {
let monitor = SubscriptionMonitor()
monitor.updateState(.inBillingRetry)
#expect(monitor.hasAccess == true)
#expect(monitor.shouldShowPaymentWarning == true)
}
@Test
func expiredRevokesAccess() async throws {
let monitor = SubscriptionMonitor()
monitor.updateState(.expired(reason: .autoRenewDisabled))
#expect(monitor.hasAccess == false)
}
@Test
func upgradeChangesEntitlementLevel() async throws {
let entitlements = SubscriptionEntitlement.default
let basicLevel = entitlements.accessLevel(for: "com.app.basic.monthly")
let proLevel = entitlements.accessLevel(for: "com.app.pro.monthly")
#expect(proLevel > basicLevel)
}Common Patterns
Status Checking
// Check current subscription state
let state = monitor.state
switch state {
case .active(let renewalDate):
print("Active until \(renewalDate)")
case .inGracePeriod(let expiresIn):
print("Payment issue — \(expiresIn) days to fix")
case .inBillingRetry:
print("Apple retrying payment")
case .expired(let reason):
print("Expired: \(reason)")
case .revoked:
print("Refunded or revoked")
default:
break
}Grace Period Notification
// Show in-app banner during grace period
if case .inGracePeriod(let days) = monitor.state {
Banner(
message: "Payment issue. Update payment method within \(days) days.",
action: "Fix Now",
onTap: { await openSubscriptionManagement() }
)
}Offer Code Redemption
// Present the system offer code redemption sheet
try await AppStore.presentOfferCodeRedeemSheet(in: windowScene)Tier Upgrade
// Upgrade from Basic to Pro (takes effect immediately)
let proProduct = try await Product.products(for: ["com.app.pro.monthly"]).first!
let result = try await proProduct.purchase()
// StoreKit handles prorating automaticallyGotchas
Transaction.currentEntitlements vs Product.SubscriptionInfo.status
Transaction.currentEntitlements— Returns currently active transactions. Use for checking if user has access RIGHT NOW. Does not include grace period or billing retry details.Product.SubscriptionInfo.status— Returns detailed subscription status array including grace period state, billing retry, renewal info. Use for lifecycle management and showing appropriate UI.- Rule: Use
currentEntitlementsfor simple access checks. UseSubscriptionInfo.statusfor lifecycle state handling.
Grace Period vs Billing Retry Period
- Grace period (if enabled in App Store Connect): User retains access for 6 or 16 days after payment failure. Apple shows its own payment failure messaging.
- Billing retry period: After grace period expires (or if no grace period), Apple retries billing for up to 60 days. User access depends on your app's policy.
- Important: Both
.inGracePeriodand.inBillingRetryPeriodshould typically grant continued access to reduce involuntary churn.
Sandbox vs Production Testing
- Sandbox subscriptions renew at accelerated rates (monthly = ~5 minutes)
- Sandbox does not support all offer types
Transaction.environmenttells you if you're in sandbox, production, or Xcode- Grace periods behave differently in sandbox — shorter durations
- Always test with StoreKit Testing in Xcode first, then sandbox, then TestFlight
Offer Eligibility
- Introductory offers: Only for users who have never subscribed to any product in the subscription group
- Promotional offers: Require signing with your App Store Connect key; you control eligibility
- Offer codes: One-time use codes you generate in App Store Connect; limited to 10M per app per quarter
- Win-back offers (iOS 18+): Apple determines eligibility for lapsed subscribers; you configure in App Store Connect
Transaction.finish() is Critical
Never forget to call transaction.finish(). Unfinished transactions will be re-delivered on every app launch, causing duplicate processing and potential UI glitches.
References
- templates.md — All production Swift code templates
- patterns.md — Lifecycle state diagrams, StoreKit 2 behavior reference, anti-patterns
- Related:
generators/paywall-generator— Purchase UI and initial transaction handling - Related:
monetization/monetization-strategy— Pricing tiers and revenue planning
Subscription Lifecycle Patterns and Best Practices
Subscription Lifecycle States
State Diagram
┌─────────────────────────────────────┐
│ PURCHASE │
│ (via paywall-generator) │
└──────────────┬──────────────────────┘
│
▼
┌──────────────────────────┐
│ ACTIVE │◄──── Renewal succeeds
│ (subscribed state) │◄──── Upgrade/downgrade
└──────────┬───────────────┘
│
Payment fails at renewal
│
┌──────────▼───────────────┐
│ GRACE PERIOD │
│ (6 or 16 days) │──── User still has access
│ Apple shows payment UI │──── Payment succeeds → ACTIVE
└──────────┬───────────────┘
│
Grace period expires,
payment still failing
│
┌──────────▼───────────────┐
│ BILLING RETRY │
│ (up to 60 days) │──── Access at app's discretion
│ Apple retries billing │──── Payment succeeds → ACTIVE
└──────────┬───────────────┘
│
Retry period expires
OR user cancels
│
┌──────────▼───────────────┐
│ EXPIRED │
│ No access │──── Win-back offer eligible
│ Reason tracked │──── Can resubscribe
└──────────┬───────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐
│ REVOKED │ │ User │ │ Win-back │
│ (refund) │ │ resubsc. │ │ offer │
│ No access │ │ → ACTIVE │ │ → ACTIVE │
└────────────┘ └────────────┘ └────────────┘State Transitions Summary
| From | To | Trigger | User Access |
|---|---|---|---|
| Active | Grace Period | Payment fails at renewal | Yes |
| Active | Expired | User disables auto-renew | Yes (until period ends) |
| Active | Upgraded | User upgrades tier | Yes (new tier) |
| Grace Period | Active | Payment succeeds | Yes |
| Grace Period | Billing Retry | Grace period expires | Recommended: Yes |
| Billing Retry | Active | Payment succeeds | Recommended: Yes |
| Billing Retry | Expired | Retry period expires | No |
| Expired | Active | User resubscribes | Yes |
| Any | Revoked | Apple issues refund | No |
Grace Period vs Billing Retry Period
Grace Period
- Duration: 6 or 16 days (you choose in App Store Connect)
- Configuration: App Store Connect > App > Subscriptions > Subscription Group > Billing Grace Period
- Apple behavior: Apple shows payment failure messaging to the user automatically
- User access: Apple expects you to grant full access during this period
- Detection:
Product.SubscriptionInfo.Status.state == .inGracePeriod - Best practice: Always grant access. Show a subtle in-app banner encouraging payment update.
Billing Retry Period
- Duration: Up to 60 days after grace period (or after initial failure if no grace period)
- Configuration: Automatic — Apple retries on their own schedule
- Apple behavior: Apple sends push notifications about failed payment
- User access: Your decision. Recommended: grant access to reduce involuntary churn.
- Detection:
Product.SubscriptionInfo.Status.state == .inBillingRetryPeriod - Best practice: Grant access but show more prominent payment warning than grace period.
Timeline
Payment Fails
│
├── Day 0-6/16: GRACE PERIOD (if enabled)
│ └── Full access, Apple shows payment UI
│
├── Day 6/16 - Day 60: BILLING RETRY
│ └── Access recommended, Apple retries billing
│
└── Day 60+: EXPIRED
└── No access, eligible for win-backWhen to Use Transaction.currentEntitlements vs Product.SubscriptionInfo.status
Transaction.currentEntitlements
// Simple access check: "Does this user have access right now?"
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.productType == .autoRenewable {
grantAccess()
}
}
}Use when:
- You need a simple yes/no access check
- Checking entitlements at app launch
- Gating features behind a subscription
- You don't need to know WHY the user has access
Limitations:
- Does NOT tell you if user is in grace period vs active
- Does NOT provide renewal info or expiration reasons
- Returns transactions, not subscription state
Product.SubscriptionInfo.status
// Detailed lifecycle state: "What exact state is this subscription in?"
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
for status in statuses {
switch status.state {
case .subscribed: handleActive(status)
case .inGracePeriod: handleGracePeriod(status)
case .inBillingRetryPeriod: handleBillingRetry(status)
case .expired: handleExpired(status)
case .revoked: handleRevoked(status)
default: break
}
}Use when:
- Building a subscription dashboard
- Showing lifecycle-aware UI (grace period banners, billing retry warnings)
- Determining eligibility for offers
- Tracking renewal info and expiration reasons
- Managing upgrade/downgrade paths
Decision matrix:
| Need | Use |
|---|---|
| "Can user access feature X?" | Transaction.currentEntitlements |
| "Is user in grace period?" | Product.SubscriptionInfo.status |
| "Why did subscription expire?" | Product.SubscriptionInfo.status + renewalInfo |
| "What plan is user on?" | Either (both provide product ID) |
| "When does subscription renew?" | Product.SubscriptionInfo.status |
| "Simple entitlement gate" | Transaction.currentEntitlements |
Offer Types and Eligibility
Introductory Offers
- What: First-time subscriber discount (free trial, pay-up-front, pay-as-you-go)
- Eligibility: Users who have NEVER subscribed to ANY product in the subscription group
- Configuration: App Store Connect per product
- Detection:
let isEligible = await product.subscription?.isEligibleForIntroOffer ?? false- Limit: One per subscription group per Apple ID, ever
Promotional Offers
- What: Discounts for existing or lapsed subscribers
- Eligibility: You control — determine on your server who should see offers
- Configuration: App Store Connect per product + server-side signing
- Signing required: Yes — generate signature with your App Store Connect API key
- Use cases: Retention (about to churn), win-back (already churned), loyalty (long-time subscriber)
// Server generates signed offer, client applies it
let signedOffer = Product.PurchaseOption.promotionalOffer(
offerID: "retention_50_off",
keyID: "YOUR_KEY_ID",
nonce: nonce,
signature: serverSignature,
timestamp: timestamp
)
let result = try await product.purchase(options: [signedOffer])Offer Codes
- What: One-time use codes you generate in App Store Connect
- Eligibility: Anyone with a valid code (new or existing subscribers)
- Configuration: App Store Connect > Subscriptions > Offer Codes
- Limit: 10 million codes per app per quarter
- Redemption:
// Present system redemption sheet
try await AppStore.presentOfferCodeRedeemSheet(in: windowScene)- Use cases: Marketing campaigns, partnerships, customer support
Win-Back Offers (iOS 18+)
- What: Special offers for users whose subscription has expired
- Eligibility: Apple determines automatically based on subscription history
- Configuration: App Store Connect > Subscriptions > Win-Back Offers
- Detection: Available through
Product.SubscriptionOfferon the product - Apple behavior: Apple may show offers in the App Store automatically
- Use cases: Re-engaging lapsed subscribers
Eligibility Summary
| Offer Type | New Users | Active Subscribers | Lapsed Subscribers | Server Signing |
|---|---|---|---|---|
| Introductory | Yes (first time only) | No | No | No |
| Promotional | Your choice | Your choice | Your choice | Yes |
| Offer Codes | Yes | Yes | Yes | No |
| Win-Back | No | No | Yes (Apple decides) | No |
Upgrade/Downgrade/Crossgrade Behavior
How Tier Changes Work
StoreKit handles tier changes within a subscription group automatically:
| Change Type | When It Takes Effect | Billing |
|---|---|---|
| Upgrade | Immediately | Prorated credit for remaining time |
| Downgrade | Next renewal date | Current tier continues until renewal |
| Crossgrade (same level) | Next renewal date | Current plan continues until renewal |
Tier Ranking in App Store Connect
You define tier ranking in App Store Connect:
- Level 1 = highest (e.g., Business)
- Level 2 = next (e.g., Pro)
- Level 3 = next (e.g., Basic)
Moving from Level 3 to Level 1 = upgrade (immediate). Moving from Level 1 to Level 3 = downgrade (next renewal).
Handling in Code
// Detect upgrade/downgrade
func handleTierChange(from currentProductID: String, to newProductID: String) {
let entitlements = SubscriptionEntitlement.default
let change = entitlements.tierChange(from: currentProductID, to: newProductID)
switch change {
case .upgrade:
// Takes effect immediately after purchase
// Update UI right away
refreshEntitlements()
case .downgrade:
// Won't take effect until next renewal
// Show message: "Your plan will change to X on [renewal date]"
showPendingDowngradeMessage(newProductID: newProductID)
case .crossgrade:
// Same tier, different period (e.g., monthly → yearly)
// Takes effect at next renewal
showPendingCrossgradeMessage(newProductID: newProductID)
}
}Pending Downgrades
When a user downgrades, they keep their current tier until renewal. Check renewalInfo for pending changes:
if let renewalInfo = status.renewalInfo,
case .verified(let info) = renewalInfo {
if info.currentProductID != info.autoRenewPreference {
// There's a pending tier change
let pendingProductID = info.autoRenewPreference
showPendingChangeNotice(to: pendingProductID)
}
}Server-Side Verification with App Store Server API v2
When You Need Server-Side
- Apps with server backends that gate features server-side
- High-value subscriptions requiring additional security
- Cross-platform apps (iOS + web + Android) sharing subscription state
- Compliance requirements for receipt validation
App Store Server API v2 Endpoints
| Endpoint | Purpose |
|---|---|
GET /inApps/v1/subscriptions/{transactionId} | Get subscription status |
GET /inApps/v1/history/{transactionId} | Get transaction history |
POST /inApps/v1/notifications/test | Test server notifications |
GET /inApps/v2/refund/lookup/{transactionId} | Check refund status |
App Store Server Notifications V2
Configure in App Store Connect to receive real-time webhook notifications:
| Notification | When |
|---|---|
SUBSCRIBED | New subscription or resubscription |
DID_RENEW | Successful auto-renewal |
DID_FAIL_TO_RENEW | Renewal payment failed |
GRACE_PERIOD_EXPIRED | Grace period ended without payment |
EXPIRED | Subscription expired |
REFUND | Apple issued a refund |
OFFER_REDEEMED | User redeemed an offer or code |
PRICE_INCREASE | Consent needed for price increase |
Signed Transaction (JWS)
StoreKit 2 transactions are signed as JWS (JSON Web Signature). Verify on your server:
// JWS structure
Header.Payload.Signature
// Verify using Apple's root certificate chain
// Apple provides a certificate chain in the x5c headerSandbox vs Production Testing Differences
Subscription Renewal Timing
| Period | Production | Sandbox | StoreKit Testing (Xcode) |
|---|---|---|---|
| 1 week | 7 days | 3 minutes | Configurable |
| 1 month | ~30 days | 5 minutes | Configurable |
| 2 months | ~60 days | 10 minutes | Configurable |
| 3 months | ~90 days | 15 minutes | Configurable |
| 6 months | ~180 days | 30 minutes | Configurable |
| 1 year | 365 days | 1 hour | Configurable |
Sandbox Limitations
- Subscriptions auto-renew a maximum of 6 times in sandbox (then expire)
- Grace period durations are shorter (proportional to accelerated time)
- Not all offer types are fully testable
- Sandbox Apple IDs are separate from production Apple IDs
- Payment sheet shows "[Environment: Sandbox]" text
StoreKit Testing in Xcode (Recommended for Development)
- Fully local — no App Store Connect required
- Configurable renewal rates
- Transaction Manager for simulating states
- Can simulate: failed transactions, ask-to-buy, interrupted purchases
- Best for: Unit tests, UI development, rapid iteration
Testing Strategy
1. StoreKit Testing in Xcode → Development & unit tests
2. Sandbox → Integration testing
3. TestFlight (sandbox) → Beta testing
4. Production → ReleaseDetecting Environment
// Check transaction environment
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
switch transaction.environment {
case .xcode:
print("StoreKit Testing in Xcode")
case .sandbox:
print("Sandbox environment")
case .production:
print("Production")
default:
break
}
}
}Common Anti-Patterns
Don't Check Receipt File Directly
// Bad — StoreKit 1 pattern, fragile
if let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) {
// Parse ASN.1... this is painful and error-prone
}
// Good — StoreKit 2 handles verification
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
// Already verified by StoreKit
}
}Don't Forget to Handle Revocation
// Bad — only checks if active, ignores refunds
if hasActiveTransaction { grantAccess() }
// Good — explicitly handle revocation
switch status.state {
case .subscribed: grantAccess()
case .revoked: revokeAccess(); showRefundMessage()
// ... other states
}Don't Treat Grace Period as Expired
// Bad — user loses access during grace period → involuntary churn
if status.state != .subscribed {
revokeAccess()
}
// Good — grant access during grace period and billing retry
switch status.state {
case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
grantAccess()
case .expired, .revoked:
revokeAccess()
}Don't Poll for Status Changes
// Bad — wasteful polling
Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in
Task { await checkSubscriptionStatus() }
}
// Good — listen for real-time updates
Task.detached {
for await result in Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
await refreshStatus()
}
}
}Don't Ignore Pending Tier Changes
// Bad — shows current tier without mentioning pending change
Text("Your plan: Pro")
// Good — inform user about upcoming change
if pendingDowngrade != nil {
Text("Your plan: Pro")
Text("Changing to Basic on \(renewalDate)")
.font(.caption)
.foregroundStyle(.secondary)
}Don't Gate All Features During Billing Issues
// Bad — locks out user immediately on billing failure
if state != .active { showPaywall() }
// Good — progressive restriction
switch state {
case .active:
showFullApp()
case .inGracePeriod, .inBillingRetry:
showFullApp() // Keep access
showPaymentWarning() // But warn them
case .expired:
showPaywall() // Now restrict
}Retention Strategies
Involuntary Churn (Payment Failures)
1. Enable grace period in App Store Connect (16 days recommended) 2. Grant access during billing retry to keep users engaged 3. Show in-app payment warning with clear "Fix Now" action 4. Don't block features — a blocked user is more likely to churn permanently
Voluntary Churn (User Cancels)
1. Detect auto-renew disabled via renewalInfo.willAutoRenew == false 2. Show value reminder — highlight features they'll lose 3. Offer downgrade instead of cancellation 4. Win-back offer after expiration (iOS 18+)
Offer Timing
| User State | Offer Type | Timing |
|---|---|---|
| Active, auto-renew off | Promotional offer | Before expiration |
| In grace period | None (Apple handles) | N/A |
| Recently expired (< 30 days) | Win-back offer | First app open after expiry |
| Long-lapsed (> 30 days) | Win-back offer or code | Re-engagement campaign |
| About to hit billing retry limit | Promotional offer | Before retry period ends |
Subscription Lifecycle Code Templates
Production-ready Swift templates for StoreKit 2 subscription lifecycle management. All code targets iOS 15+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency.
SubscriptionState.swift
import Foundation
import StoreKit
/// Comprehensive subscription lifecycle state.
///
/// Covers all possible states a subscription can be in,
/// including grace period, billing retry, and revocation.
enum SubscriptionState: Sendable, Equatable {
/// No subscription found — user has never subscribed.
case notSubscribed
/// Subscription is active and in good standing.
/// - Parameter renewalDate: Next renewal or expiration date.
case active(renewalDate: Date)
/// Payment failed but user retains access during grace period.
/// - Parameter daysRemaining: Approximate days left in grace period.
case inGracePeriod(daysRemaining: Int)
/// Grace period expired; Apple is retrying billing.
/// User access is at your discretion (recommended: grant access).
case inBillingRetry
/// Subscription expired.
/// - Parameter reason: Why the subscription expired.
case expired(reason: ExpirationReason)
/// Subscription was revoked (refund or family sharing removal).
case revoked
/// User upgraded to a higher tier in the same subscription group.
/// - Parameter newProductID: The product ID of the new tier.
case upgraded(newProductID: String)
/// Status is being determined (initial load).
case unknown
/// Whether the user should have access to premium features.
///
/// Grants access during grace period and billing retry
/// to minimize involuntary churn.
var hasAccess: Bool {
switch self {
case .active, .inGracePeriod, .inBillingRetry:
return true
case .notSubscribed, .expired, .revoked, .upgraded, .unknown:
return false
}
}
/// Whether the app should show a payment warning banner.
var shouldShowPaymentWarning: Bool {
switch self {
case .inGracePeriod, .inBillingRetry:
return true
default:
return false
}
}
}
/// Reasons a subscription expired.
enum ExpirationReason: Sendable, Equatable {
/// User disabled auto-renew.
case autoRenewDisabled
/// Billing failed and retry period ended.
case billingError
/// User declined a price increase.
case didNotConsentToPriceIncrease
/// Product is no longer available.
case productUnavailable
/// Unknown or unhandled reason.
case unknown
}SubscriptionMonitor.swift
import Foundation
import StoreKit
import Observation
/// Monitors subscription status in real time using StoreKit 2.
///
/// Listens for `Transaction.updates` and polls `Product.SubscriptionInfo`
/// to keep subscription state current throughout the app lifecycle.
///
/// Usage:
/// ```swift
/// @State private var monitor = SubscriptionMonitor()
///
/// ContentView()
/// .environment(monitor)
/// .task { await monitor.start(groupID: "your.group.id") }
/// ```
@Observable
final class SubscriptionMonitor {
/// Current subscription state.
private(set) var state: SubscriptionState = .unknown
/// Current subscription product ID, if any.
private(set) var currentProductID: String?
/// Next renewal or expiration date, if active.
private(set) var renewalDate: Date?
/// Whether auto-renew is enabled for the current subscription.
private(set) var isAutoRenewEnabled: Bool = false
private var transactionListener: Task<Void, Never>?
private var groupID: String = ""
private var entitlements: SubscriptionEntitlement?
deinit {
transactionListener?.cancel()
}
// MARK: - Public API
/// Start monitoring subscription status.
///
/// Call this once at app launch. The monitor will continuously
/// listen for transaction updates and refresh status.
///
/// - Parameters:
/// - groupID: Your subscription group identifier from App Store Connect.
/// - entitlements: Optional entitlement mapping for multi-tier subscriptions.
func start(groupID: String, entitlements: SubscriptionEntitlement? = nil) async {
self.groupID = groupID
self.entitlements = entitlements
// Listen for real-time transaction updates
transactionListener = Task.detached { [weak self] in
for await result in Transaction.updates {
guard let self else { return }
if case .verified(let transaction) = result {
await transaction.finish()
await self.refreshStatus()
}
}
}
// Initial status check
await refreshStatus()
}
/// Force a status refresh.
///
/// Call after a purchase, restore, or when returning to foreground.
func refreshStatus() async {
do {
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
await MainActor.run {
updateFromStatuses(statuses)
}
} catch {
// If status fetch fails, fall back to entitlements check
await checkEntitlements()
}
}
/// Convenience: whether the user currently has access.
var hasAccess: Bool {
state.hasAccess
}
/// Days remaining in grace period, or nil if not in grace period.
var gracePeriodDaysRemaining: Int? {
if case .inGracePeriod(let days) = state {
return days
}
return nil
}
/// Whether the user should see a payment warning.
var shouldShowPaymentWarning: Bool {
state.shouldShowPaymentWarning
}
/// Open the system subscription management page.
func openSubscriptionManagement() async {
guard let windowScene = await MainActor.run(body: {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first
}) else { return }
do {
try await AppStore.showManageSubscriptions(in: windowScene)
} catch {
// Fallback: open Settings
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
await MainActor.run {
UIApplication.shared.open(url)
}
}
}
}
// MARK: - Internal (exposed for testing)
/// Update state directly. Exposed for unit testing.
func updateState(_ newState: SubscriptionState) {
state = newState
}
// MARK: - Private
private func updateFromStatuses(_ statuses: [Product.SubscriptionInfo.Status]) {
// Find the most relevant status (highest priority)
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo,
case .verified(let transaction) = status.transaction else {
continue
}
currentProductID = transaction.productID
isAutoRenewEnabled = renewalInfo.willAutoRenew
switch status.state {
case .subscribed:
renewalDate = transaction.expirationDate
state = .active(renewalDate: transaction.expirationDate ?? Date.distantFuture)
return
case .inGracePeriod:
let daysRemaining = daysUntil(transaction.expirationDate)
state = .inGracePeriod(daysRemaining: max(daysRemaining, 0))
return
case .inBillingRetryPeriod:
state = .inBillingRetry
return
case .expired:
let reason = mapExpirationReason(renewalInfo)
state = .expired(reason: reason)
return
case .revoked:
state = .revoked
return
default:
continue
}
}
// No active status found
state = .notSubscribed
currentProductID = nil
renewalDate = nil
}
private func checkEntitlements() async {
var foundEntitlement = false
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.productType == .autoRenewable {
foundEntitlement = true
await MainActor.run {
currentProductID = transaction.productID
renewalDate = transaction.expirationDate
state = .active(renewalDate: transaction.expirationDate ?? Date.distantFuture)
}
break
}
}
}
if !foundEntitlement {
await MainActor.run {
state = .notSubscribed
}
}
}
private func mapExpirationReason(_ renewalInfo: Product.SubscriptionInfo.RenewalInfo) -> ExpirationReason {
guard let reason = renewalInfo.expirationReason else {
return .unknown
}
switch reason {
case .autoRenewDisabled:
return .autoRenewDisabled
case .billingError:
return .billingError
case .didNotConsentToPriceIncrease:
return .didNotConsentToPriceIncrease
case .productUnavailable:
return .productUnavailable
default:
return .unknown
}
}
private func daysUntil(_ date: Date?) -> Int {
guard let date else { return 0 }
let interval = date.timeIntervalSince(Date())
return max(Int(ceil(interval / 86400)), 0)
}
}GracePeriodHandler.swift
import Foundation
import StoreKit
import SwiftUI
/// Handles grace period detection and provides UI components
/// for communicating payment issues to the user.
///
/// Grace periods (6 or 16 days, configured in App Store Connect)
/// give users continued access while Apple resolves payment issues.
/// This handler detects the state and provides appropriate messaging.
///
/// Usage:
/// ```swift
/// GracePeriodBanner(
/// daysRemaining: monitor.gracePeriodDaysRemaining ?? 0,
/// onFixPayment: { await monitor.openSubscriptionManagement() }
/// )
/// ```
struct GracePeriodBanner: View {
let daysRemaining: Int
let onFixPayment: () async -> Void
@State private var isProcessing = false
var body: some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Payment Issue")
.font(.subheadline.bold())
Text(warningMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
isProcessing = true
Task {
await onFixPayment()
isProcessing = false
}
} label: {
if isProcessing {
ProgressView()
.controlSize(.small)
} else {
Text("Fix Now")
.font(.subheadline.bold())
}
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(isProcessing)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 12)
.fill(Color.yellow.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.yellow.opacity(0.3), lineWidth: 1)
)
}
.padding(.horizontal)
}
private var warningMessage: String {
if daysRemaining <= 1 {
return "Update your payment method today to keep your subscription."
} else {
return "Update your payment method within \(daysRemaining) days to keep your subscription."
}
}
}
/// Banner for billing retry period (after grace period expires).
struct BillingRetryBanner: View {
let onFixPayment: () async -> Void
@State private var isProcessing = false
var body: some View {
HStack(spacing: 12) {
Image(systemName: "creditcard.trianglebadge.exclamationmark")
.foregroundStyle(.orange)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Billing Issue")
.font(.subheadline.bold())
Text("There's a problem with your payment method. Update it to continue your subscription.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
isProcessing = true
Task {
await onFixPayment()
isProcessing = false
}
} label: {
if isProcessing {
ProgressView()
.controlSize(.small)
} else {
Text("Update")
.font(.subheadline.bold())
}
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(isProcessing)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 12)
.fill(Color.orange.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.orange.opacity(0.3), lineWidth: 1)
)
}
.padding(.horizontal)
}
}
/// Composite view that shows the appropriate banner based on state.
struct SubscriptionWarningOverlay: View {
let state: SubscriptionState
let onFixPayment: () async -> Void
var body: some View {
switch state {
case .inGracePeriod(let daysRemaining):
GracePeriodBanner(
daysRemaining: daysRemaining,
onFixPayment: onFixPayment
)
.transition(.move(edge: .top).combined(with: .opacity))
case .inBillingRetry:
BillingRetryBanner(onFixPayment: onFixPayment)
.transition(.move(edge: .top).combined(with: .opacity))
default:
EmptyView()
}
}
}OfferManager.swift
import Foundation
import StoreKit
import Observation
/// Manages subscription offers: promotional offers, offer codes,
/// and win-back offers for lapsed subscribers.
///
/// Usage:
/// ```swift
/// @State private var offerManager = OfferManager()
///
/// // Check for win-back eligibility
/// if let offer = offerManager.availableWinBackOffer {
/// WinBackOfferCard(offer: offer) {
/// try await offerManager.redeemWinBackOffer(offer)
/// }
/// }
/// ```
@Observable
final class OfferManager {
/// Available win-back offer for lapsed subscribers, if eligible.
private(set) var availableWinBackOffer: WinBackOffer?
/// Available promotional offers for the subscription group.
private(set) var promotionalOffers: [PromotionalOffer] = []
/// Whether an offer redemption is in progress.
private(set) var isRedeeming = false
/// Last error from an offer operation.
private(set) var lastError: Error?
private var groupID: String = ""
private var productIDs: [String] = []
// MARK: - Public API
/// Load available offers for the subscription group.
///
/// - Parameters:
/// - groupID: Your subscription group identifier.
/// - productIDs: Product IDs to check offers for.
func loadOffers(groupID: String, productIDs: [String]) async {
self.groupID = groupID
self.productIDs = productIDs
await loadWinBackOffers()
await loadPromotionalOffers()
}
/// Redeem a win-back offer.
///
/// - Parameter offer: The win-back offer to redeem.
/// - Throws: StoreKit errors if purchase fails.
func redeemWinBackOffer(_ offer: WinBackOffer) async throws {
isRedeeming = true
lastError = nil
defer { isRedeeming = false }
let products = try await Product.products(for: [offer.productID])
guard let product = products.first else {
throw OfferError.productNotFound(offer.productID)
}
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
case .userCancelled:
break
case .pending:
break
@unknown default:
break
}
}
/// Redeem a promotional offer.
///
/// Promotional offers require server-side signing. Pass the signed
/// offer from your server to complete the purchase.
///
/// - Parameters:
/// - offer: The promotional offer to redeem.
/// - product: The product to purchase with this offer.
/// - signedOffer: Server-signed offer parameters.
func redeemPromotionalOffer(
_ offer: PromotionalOffer,
for product: Product,
signedOffer: Product.PurchaseOption
) async throws {
isRedeeming = true
lastError = nil
defer { isRedeeming = false }
let result = try await product.purchase(options: [signedOffer])
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
case .userCancelled:
break
case .pending:
break
@unknown default:
break
}
}
/// Present the system offer code redemption sheet.
///
/// Users can enter offer codes generated in App Store Connect.
@MainActor
func presentOfferCodeRedemption() async throws {
guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first else {
throw OfferError.noWindowScene
}
try await AppStore.presentOfferCodeRedeemSheet(in: windowScene)
}
/// Check if a user is eligible for an introductory offer.
///
/// - Parameter product: The subscription product to check.
/// - Returns: `true` if the user has never subscribed to the group.
func isEligibleForIntroductoryOffer(_ product: Product) async -> Bool {
await product.subscription?.isEligibleForIntroOffer ?? false
}
// MARK: - Private
private func loadWinBackOffers() async {
// Win-back offers are available in iOS 18+
guard #available(iOS 18, *) else { return }
do {
let products = try await Product.products(for: productIDs)
for product in products {
guard let subscription = product.subscription else { continue }
// Check subscription offers for win-back type
for offer in subscription.promotionalOffers {
// Win-back offers are a subset of promotional offers
// Apple determines eligibility automatically
let winBack = WinBackOffer(
id: offer.id ?? "unknown",
productID: product.id,
displayPrice: offer.displayPrice,
period: offer.period,
periodCount: offer.periodCount,
paymentMode: offer.paymentMode
)
availableWinBackOffer = winBack
return // Use first available
}
}
} catch {
lastError = error
}
}
private func loadPromotionalOffers() async {
do {
let products = try await Product.products(for: productIDs)
var offers: [PromotionalOffer] = []
for product in products {
guard let subscription = product.subscription else { continue }
for offer in subscription.promotionalOffers {
offers.append(PromotionalOffer(
id: offer.id ?? "unknown",
productID: product.id,
displayPrice: offer.displayPrice,
period: offer.period,
periodCount: offer.periodCount,
paymentMode: offer.paymentMode
))
}
}
promotionalOffers = offers
} catch {
lastError = error
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let safe):
return safe
case .unverified(_, let error):
throw OfferError.verificationFailed(error)
}
}
}
// MARK: - Models
/// Represents a win-back offer for a lapsed subscriber.
struct WinBackOffer: Identifiable, Sendable {
let id: String
let productID: String
let displayPrice: String
let period: Product.SubscriptionPeriod
let periodCount: Int
let paymentMode: Product.SubscriptionOffer.PaymentMode
var displayDescription: String {
switch paymentMode {
case .freeTrial:
return "Free for \(periodCount) \(period.unit.localizedDescription)"
case .payUpFront:
return "\(displayPrice) for \(periodCount) \(period.unit.localizedDescription)"
case .payAsYouGo:
return "\(displayPrice)/\(period.unit.localizedDescription) for \(periodCount) periods"
default:
return displayPrice
}
}
}
/// Represents a promotional offer.
struct PromotionalOffer: Identifiable, Sendable {
let id: String
let productID: String
let displayPrice: String
let period: Product.SubscriptionPeriod
let periodCount: Int
let paymentMode: Product.SubscriptionOffer.PaymentMode
}
/// Errors specific to offer operations.
enum OfferError: Error, LocalizedError {
case productNotFound(String)
case verificationFailed(Error)
case noWindowScene
var errorDescription: String? {
switch self {
case .productNotFound(let id):
return "Product not found: \(id)"
case .verificationFailed(let error):
return "Verification failed: \(error.localizedDescription)"
case .noWindowScene:
return "No active window scene available"
}
}
}
// MARK: - Period Unit Extension
extension Product.SubscriptionPeriod.Unit {
var localizedDescription: String {
switch self {
case .day: return "day"
case .week: return "week"
case .month: return "month"
case .year: return "year"
@unknown default: return "period"
}
}
}SubscriptionDashboardView.swift
import SwiftUI
import StoreKit
/// Dashboard view showing current subscription status, plan details,
/// renewal information, and management options.
///
/// Usage:
/// ```swift
/// NavigationLink("Subscription") {
/// SubscriptionDashboardView()
/// }
/// ```
struct SubscriptionDashboardView: View {
@Environment(SubscriptionMonitor.self) private var monitor
@State private var products: [Product] = []
@State private var isLoadingProducts = true
@State private var showUpgradeSheet = false
var body: some View {
List {
currentPlanSection
statusSection
managementSection
}
.navigationTitle("Subscription")
.task {
await loadProducts()
}
.sheet(isPresented: $showUpgradeSheet) {
UpgradePlanSheet(products: products)
}
}
// MARK: - Current Plan
@ViewBuilder
private var currentPlanSection: some View {
Section {
if let productID = monitor.currentProductID,
let product = products.first(where: { $0.id == productID }) {
CurrentPlanRow(product: product, monitor: monitor)
} else if monitor.state == .notSubscribed {
NotSubscribedRow()
} else if case .unknown = monitor.state {
LoadingRow()
}
} header: {
Text("Current Plan")
}
}
// MARK: - Status
@ViewBuilder
private var statusSection: some View {
Section {
StatusRow(state: monitor.state)
if let renewalDate = monitor.renewalDate {
LabeledContent("Renewal Date") {
Text(renewalDate, style: .date)
}
}
LabeledContent("Auto-Renew") {
Text(monitor.isAutoRenewEnabled ? "On" : "Off")
.foregroundStyle(monitor.isAutoRenewEnabled ? .primary : .red)
}
} header: {
Text("Status")
}
}
// MARK: - Management
@ViewBuilder
private var managementSection: some View {
Section {
if monitor.hasAccess && products.count > 1 {
Button("Change Plan") {
showUpgradeSheet = true
}
}
Button("Manage Subscription") {
Task { await monitor.openSubscriptionManagement() }
}
Button("Restore Purchases") {
Task {
try? await AppStore.sync()
await monitor.refreshStatus()
}
}
} header: {
Text("Manage")
}
}
// MARK: - Data Loading
private func loadProducts() async {
// Replace with your actual product IDs
let productIDs = SubscriptionEntitlement.default.allProductIDs
do {
products = try await Product.products(for: productIDs)
isLoadingProducts = false
} catch {
isLoadingProducts = false
}
}
}
// MARK: - Row Views
private struct CurrentPlanRow: View {
let product: Product
let monitor: SubscriptionMonitor
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(product.displayName)
.font(.headline)
Text(product.displayPrice + periodSuffix)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if monitor.hasAccess {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.title2)
}
}
.padding(.vertical, 4)
}
private var periodSuffix: String {
guard let period = product.subscription?.subscriptionPeriod else { return "" }
switch period.unit {
case .month: return "/month"
case .year: return "/year"
case .week: return "/week"
case .day: return "/day"
@unknown default: return ""
}
}
}
private struct NotSubscribedRow: View {
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("No Active Subscription")
.font(.headline)
Text("Subscribe to unlock premium features")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "xmark.circle")
.foregroundStyle(.secondary)
.font(.title2)
}
.padding(.vertical, 4)
}
}
private struct LoadingRow: View {
var body: some View {
HStack {
Text("Loading subscription status...")
.foregroundStyle(.secondary)
Spacer()
ProgressView()
}
}
}
private struct StatusRow: View {
let state: SubscriptionState
var body: some View {
LabeledContent("Status") {
HStack(spacing: 6) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
Text(statusLabel)
}
}
}
private var statusLabel: String {
switch state {
case .active:
return "Active"
case .inGracePeriod(let days):
return "Grace Period (\(days) days left)"
case .inBillingRetry:
return "Billing Issue"
case .expired:
return "Expired"
case .revoked:
return "Revoked"
case .upgraded:
return "Upgraded"
case .notSubscribed:
return "Not Subscribed"
case .unknown:
return "Checking..."
}
}
private var statusColor: Color {
switch state {
case .active:
return .green
case .inGracePeriod, .inBillingRetry:
return .yellow
case .expired, .revoked:
return .red
case .upgraded:
return .blue
case .notSubscribed, .unknown:
return .secondary
}
}
}
// MARK: - Upgrade Plan Sheet
private struct UpgradePlanSheet: View {
let products: [Product]
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(sortedProducts, id: \.id) { product in
Button {
Task {
let result = try? await product.purchase()
if case .success = result {
dismiss()
}
}
} label: {
HStack {
VStack(alignment: .leading) {
Text(product.displayName)
.font(.headline)
Text(product.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(product.displayPrice)
.font(.headline)
}
}
}
.navigationTitle("Change Plan")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
private var sortedProducts: [Product] {
products.sorted { ($0.price) < ($1.price) }
}
}SubscriptionEntitlement.swift
import Foundation
/// Maps subscription product IDs to feature access levels.
///
/// Use this to determine what features a user has access to
/// based on their current subscription product.
///
/// Usage:
/// ```swift
/// let entitlements = SubscriptionEntitlement.default
/// let level = entitlements.accessLevel(for: "com.app.pro.monthly")
/// if level >= .pro {
/// // Grant pro features
/// }
/// ```
struct SubscriptionEntitlement: Sendable {
/// Access tier levels, ordered from lowest to highest.
enum AccessLevel: Int, Comparable, Sendable {
case free = 0
case basic = 1
case pro = 2
case business = 3
static func < (lhs: AccessLevel, rhs: AccessLevel) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
/// Mapping of product ID to access level.
private let productLevels: [String: AccessLevel]
/// All registered product IDs.
var allProductIDs: [String] {
Array(productLevels.keys)
}
init(productLevels: [String: AccessLevel]) {
self.productLevels = productLevels
}
/// Get the access level for a product ID.
///
/// - Parameter productID: The StoreKit product identifier.
/// - Returns: The access level, or `.free` if unknown.
func accessLevel(for productID: String) -> AccessLevel {
productLevels[productID] ?? .free
}
/// Check if a product ID grants at least the required level.
///
/// - Parameters:
/// - productID: The StoreKit product identifier.
/// - requiredLevel: Minimum access level needed.
/// - Returns: `true` if the product grants sufficient access.
func hasAccess(for productID: String, requiredLevel: AccessLevel) -> Bool {
accessLevel(for: productID) >= requiredLevel
}
/// Determine upgrade/downgrade/crossgrade relationship.
///
/// - Parameters:
/// - fromProductID: Current product ID.
/// - toProductID: Target product ID.
/// - Returns: The tier change type.
func tierChange(from fromProductID: String, to toProductID: String) -> TierChangeType {
let fromLevel = accessLevel(for: fromProductID)
let toLevel = accessLevel(for: toProductID)
if toLevel > fromLevel {
return .upgrade
} else if toLevel < fromLevel {
return .downgrade
} else {
return .crossgrade
}
}
/// Types of tier changes.
enum TierChangeType: Sendable {
/// Moving to a higher tier (takes effect immediately).
case upgrade
/// Moving to a lower tier (takes effect at next renewal).
case downgrade
/// Moving to an equivalent tier (e.g., monthly to yearly at same level).
case crossgrade
}
// MARK: - Default Configuration
/// Default entitlement configuration.
///
/// **Customize these product IDs** to match your App Store Connect configuration.
static let `default` = SubscriptionEntitlement(productLevels: [
// Basic tier
"com.yourapp.basic.monthly": .basic,
"com.yourapp.basic.yearly": .basic,
// Pro tier
"com.yourapp.pro.monthly": .pro,
"com.yourapp.pro.yearly": .pro,
// Business tier
"com.yourapp.business.monthly": .business,
"com.yourapp.business.yearly": .business,
])
}