swiftship/internal/skills/data/features/subscription-manager/SKILL.md
Subscription and credit management architecture with RevenueCat. Use when implementing subscription state, credit balance, or feature gating.
npx skillsauth add abdullah4ai/apple-dev-docs subscription-managerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Single-layer: SubscriptionManager is an @Observable @MainActor singleton that wraps Purchases.shared directly. It holds RevenueCat Package objects — NOT custom plan models.
Do NOT create ANY of these:
enum SubscriptionPlan / enum SubscriptionTier / struct Plan with price propertiesvar price: String returning "$X.XX"static let sampleData: [SomePlanType] with hardcoded plans[SubscriptionPlan] or similar custom plan arraysimport Foundation
import RevenueCat
@Observable
@MainActor
final class SubscriptionManager {
static let shared = SubscriptionManager()
var isPremium = false
var packages: [Package] = [] // RevenueCat Package objects ONLY
var selectedPackage: Package?
var isLoading = false
var errorMessage: String?
var purchaseSuccess = false // Signals successful purchase for UX feedback
private init() {}
func configure() {
#if DEBUG
Purchases.logLevel = .debug
#endif
Purchases.configure(
with: Configuration.Builder(withAPIKey: AppConfig.revenueCatAPIKey)
.with(storeKitVersion: .storeKit2)
.build()
)
Task { await refreshStatus() }
Task { await listenForChanges() }
}
func loadOfferings() async {
isLoading = true
defer { isLoading = false }
do {
let offerings = try await Purchases.shared.offerings()
packages = offerings.current?.availablePackages ?? []
if selectedPackage == nil { selectedPackage = packages.first }
} catch {
errorMessage = "Could not load plans."
}
}
func purchase(_ package: Package) async {
isLoading = true
defer { isLoading = false }
do {
let result = try await Purchases.shared.purchase(package: package)
if !result.userCancelled {
isPremium = result.customerInfo.entitlements[AppConfig.entitlementID]?.isActive == true
if isPremium { purchaseSuccess = true }
}
} catch {
errorMessage = error.localizedDescription
}
}
func resetPurchaseSuccess() {
purchaseSuccess = false
}
func restore() async {
isLoading = true
defer { isLoading = false }
do {
let info = try await Purchases.shared.restorePurchases()
isPremium = info.entitlements[AppConfig.entitlementID]?.isActive == true
if !isPremium { errorMessage = "No active subscriptions found." }
} catch {
errorMessage = error.localizedDescription
}
}
private func refreshStatus() async {
do {
let info = try await Purchases.shared.customerInfo()
isPremium = info.entitlements[AppConfig.entitlementID]?.isActive == true
} catch {}
}
private func listenForChanges() async {
for try await info in Purchases.shared.customerInfoStream {
isPremium = info.entitlements[AppConfig.entitlementID]?.isActive == true
}
}
}
Key points:
packages is [Package] from RevenueCat — NOT a custom plan arraypurchase() takes a Package — NOT a custom enumpackage.storeProduct.localizedPriceString in the view layerAppConfig.entitlementID — never hardcode "premium"if SubscriptionManager.shared.isPremium {
// Premium feature
} else {
// Show paywall
}
AppConfig.entitlementIDAppConfig.ProductID constantsSubscriptionManager is the ONLY type that calls Purchases.shared. No other file should import and call Purchases.shared methods.
BANNED:
PaywallViewModel or any ViewModel that wraps/duplicates SubscriptionManagerPurchases.shared.purchase(), Purchases.shared.offerings(), or Purchases.shared.restorePurchases() directly@State private var manager = SubscriptionManager.shared — not its own ViewModelViewModels that gate features behind a subscription MUST observe SubscriptionManager.shared.isPremium reactively. Because SubscriptionManager is @Observable, any SwiftUI view or @Observable class that reads isPremium will automatically re-evaluate when subscription status changes (purchase, renewal, expiration, restore).
@Observable
@MainActor
final class SomeFeatureViewModel {
private let subscriptionManager = SubscriptionManager.shared
var canAccessFeature: Bool {
subscriptionManager.isPremium
}
var showUpgradePrompt: Bool {
!subscriptionManager.isPremium
}
}
Key points:
isPremium into a local property — always read through SubscriptionManager.shared@Observable tracking propagates automatically: when isPremium changes, any computed property reading it triggers view updatescustomerInfoStream in SubscriptionManager handles all external changes (renewals, expirations, family sharing, App Store refunds) — ViewModels do NOT need their own listeners@State private var manager = SubscriptionManager.shared also react automaticallyBANNED:
NotificationCenter or Combine publishers for subscription state — use @Observable trackingvar isPremium copies in ViewModels — always read from SubscriptionManager.sharedTask loops polling Purchases.shared.customerInfo() — the stream handles thisSee Model Pattern for why custom tier enums are banned.
testing
Use for 3D games: racing, 3D sports, board games, marble maze, tower defense, bowling. SceneKit + SceneView architecture, 3D scene hierarchy, physics, game loop, primitives, materials, cameras, particles, audio.
documentation
Game UI patterns: SwiftUI HUD overlays on SpriteKit, menus (main/pause/game-over), virtual joystick/d-pad, score displays, health bars, tutorial onboarding.
tools
Download free game sprites/textures/3D models and generate procedural assets. Covers nw_download_asset tool, texture factories, sprite atlas organization, 3D model loading, and programmatic asset creation.
testing
Use for 2D games: arcade, puzzle, sports, ping pong, platformer, shooter, 2D racing. SpriteKit + SpriteView architecture, scene hierarchy, physics, game loop, audio, particles, game feel.