Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Generates StoreKit 2 subscription lifecycle management — grace periods, billing retry, offer codes, win-back offers, upgrade/downgrade paths, and subscription status monitoring. Use when user needs post-purchase subscription state handling beyond the initial paywall.
.claude/skills/rshankras-subscription-lifecycle/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-08 | ✗→✓ | ▲ Improved | 162% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 111% | 0% |
| case-16 | ✗→✓ | ▲ Improved | 156% | 0% |
| case-19 | ✗→✓ | ▲ Improved | 98% | 0% |
| case-21 | ✓→✓ | = Same ✓ | 120% | 0% |
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.
Why retention math dominates acquisition (per Apple's Tech Talk):
Use this skill when the user:
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:
StoreKitManager — don't duplicate product loadingSubscriptionStatus enum if presentIf no existing StoreKit code found:
Grep: "In-App Purchase" or "StoreKit" in *.entitlementsIf missing, warn user to add the In-App Purchase capability in Xcode.
Ask user via AskUserQuestion:
Read patterns.md for lifecycle state diagrams and StoreKit 2 behavior reference. Read templates.md for production Swift code templates.
Generate these files:
SubscriptionState.swift — Comprehensive enum for all lifecycle statesSubscriptionMonitor.swift — @Observable class monitoring real-time status via Transaction.updates and Product.SubscriptionInfoSubscriptionEntitlement.swift — Maps product IDs to feature access levelsBased on configuration:
GracePeriodHandler.swift — If grace period selectedOfferManager.swift — If offer codes or win-back selectedIf dashboard UI selected:
SubscriptionDashboardView.swift — SwiftUI view for plan managementCheck project structure:
Sources/Store/ exists → Sources/Store/Lifecycle/Sources/ exists → Sources/SubscriptionLifecycle/App/ exists → App/SubscriptionLifecycle/SubscriptionLifecycle/After generation, provide:
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)If paywall-generator was already used:
swift// 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:
swift@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:
swiftstruct 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:
swiftstruct 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:
swiftstruct 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() } } }
swift@Test func gracePeriodGrantsAccess() async throws { let monitor = SubscriptionMonitor() monitor.updateState(.inGracePeriod(expiresIn: 3)) #expect(monitor.hasAccess == true) #expect(monitor.gracePeriodDaysRemaining == 3) } @Test func billingRetryLimitsAccess() async throws { let monitor = SubscriptionMonitor() monitor.updateState(.inBillingRetry) // Apple's guidance: stop full service at billing retry (grace period is // where you keep serving). Prefer limited access over a hard cut. #expect(monitor.hasAccess == false) #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) }
swift// 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 }
Model the full subscriber state machine — each state gets its own entitlement decision and its own message:
| State | Entitlement | Message | |-------|-------------|---------| | Active | Full | None | | Active, auto-renew off | Full | Save offer before expiry | | Grace period | Full | Countdown + deep link to payment settings | | Billing retry | Limited access (not a hard cut) | Persistent fix-payment banner — no offers | | Expired | None | Win-back, tailored by expirationReason |
Do NOT re-pitch or discount users in billing retry — the App Store retries billing automatically for up to 60 days and is actively recovering them. And recoveries inside the grace window preserve billing-date continuity: no revenue gap.
swift// 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() } ) }
Per Apple's Tech Talk, >90% of customers cancel with at least 2 days of paid service remaining — the cancel-to-expiry window is your save-offer moment:
swift// Still entitled, but auto-renew just flipped off — surface a save offer NOW if status.state == .subscribed, case .verified(let renewalInfo) = status.renewalInfo, renewalInfo.willAutoRenew == false { showSaveOffer() } // Tailor win-backs after expiry if let reason = renewalInfo.expirationReason, reason == .didNotConsentToPriceIncrease { // Price-sensitive churn — lead with a win-back offer }
The App Store wants to show its own payment-update sheet on next launch after a billing failure. Use the StoreKit Message API to defer it past critical moments (mid-checkout, mid-workout):
swiftfor await message in Message.messages where message.reason == .billingIssue { await deferUntilSafeMoment() try? message.display(in: windowScene) }
| Notification | Meaning | |--------------|---------| | DID_FAIL_TO_RENEW | Subscriber entered billing retry | | DID_RENEW + subtype BILLING_RECOVERY | Retry succeeded — recovery complete | | GRACE_PERIOD_EXPIRED | Grace ended unrecovered — retry continues, entitlement decision is yours |
swift// Present the system offer code redemption sheet try await AppStore.presentOfferCodeRedeemSheet(in: windowScene)
swift// 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 automatically
Transaction.currentEntitlements — Returns currently active transactions. Use for checking if user has access RIGHT NOW. Does not include grace period or billing retry details.Transaction.currentEntitlements(for:) — plural by design: one product can carry multiple current transactions (e.g., the user's own purchase plus a Family Sharing transaction).Product.SubscriptionInfo.status — Returns detailed subscription status array including grace period state, billing retry, renewal info. Use for lifecycle management and showing appropriate UI.currentEntitlements for simple access checks. Use SubscriptionInfo.status for lifecycle state handling.renewalState): keep serving .inGracePeriod; stop full service at .inBillingRetryPeriod — but prefer limited access over a hard cut, plus a persistent fix-payment banner.Transaction.environment tells you if you're in sandbox, production, or XcodeNever forget to call transaction.finish(). Unfinished transactions will be re-delivered on every app launch, causing duplicate processing and potential UI glitches.
generators/paywall-generator — Purchase UI and initial transaction handlingmonetization/monetization-strategy — Pricing tiers and revenue planningOther measured skills in the registry, with their headline benchmark lift.