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.
Why retention math dominates acquisition (per Apple's Tech Talk):
- Commission compounds: 70% net in year one → 85% once a subscriber passes 1 year. Retained subscribers are literally worth more per renewal.
- The base compounds harder: from 10,000 subscribers, 12 months later 85% monthly retention leaves 1,673; 90% leaves 3,487; 95% leaves 5,688. A 5-point retention gain more than doubles the surviving base.
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:
Subscription tiers?
- Single tier (one plan, e.g., "Pro")
- Multiple tiers (e.g., "Basic", "Pro", "Business") with upgrade/downgrade paths
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
Include subscription dashboard UI?
- Yes — SwiftUI view showing current plan, renewal date, management options
- No — logic only, integrate into existing UI
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:
SubscriptionState.swift— Comprehensive enum for all lifecycle statesSubscriptionMonitor.swift— @Observable class monitoring real-time status viaTransaction.updatesandProduct.SubscriptionInfoSubscriptionEntitlement.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 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)
}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
}Per-State Entitlement & Messaging
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.
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() }
)
}Catch Voluntary Churn Before Expiry
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:
// 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
}Defer Apple's Billing Sheet (Message API)
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):
for await message in Message.messages where message.reason == .billingIssue {
await deferUntilSafeMoment()
try? message.display(in: windowScene)
}Server Notifications to Wire (V2)
| 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 |
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.- Migrate to
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.- Rule: Use
currentEntitlementsfor simple access checks. UseSubscriptionInfo.statusfor lifecycle state handling.
Grace Period vs Billing Retry Period
- Grace period (opt-in in App Store Connect): choose 3, 16, or 28 days — pick 28 for maximum recovery. Per Apple's Tech Talk, ~40% of customers fix billing within 3 days, 75% within 16, and up to 90% within 28.
- Billing retry period: After grace period expires (or if no grace period), Apple retries billing automatically for up to 60 days. Don't re-pitch users already in retry — the App Store is recovering them.
- Entitlement rule (
renewalState): keep serving.inGracePeriod; stop full service at.inBillingRetryPeriod— but prefer limited access over a hard cut, plus a persistent fix-payment banner.
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