mycode/myApp/HaruDanim/Shared/Premium.swift
songyc macbook 1e62c082d3 feat(premium): StoreKit 2 결제 레이어 구현 (실결제 연결 직전 단계)
- StoreKitEntitlementProvider: Transaction.currentEntitlements 기반 실권한
  조회 + Transaction.updates 이벤트 반영. 앱 시작 시 PremiumManager.bootstrap
  으로 주입 (확장 타깃은 기존 PremiumGate 캐시 그대로)
- PremiumStore: 상품 로드/구매/복원 (월·년 구독 + 평생 이용권 3종)
- PremiumView: 실구매 UI (상품 목록·가격·구매 복원, 미로드 시 재시도 폴백)
- DEBUG 강제 프리미엄(-premium, 테스트 토글) 중에는 StoreKit 결과가
  덮어쓰지 않도록 보호
- HaruDanim.storekit 로컬 테스트 구성(월 5,000 / 년 50,000 / 일회성 80,000원,
  ko·en·ja 현지화) + 메인 스킴에 연결 → Xcode 실행으로 결제 흐름 테스트 가능
- 남은 것: App Store Connect에 productID 3종 등록만 하면 실결제 연결 완료

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-10 14:49:32 +09:00

200 lines
7.1 KiB
Swift

//
// Premium.swift
// Haru_Danim
//
// (CLAUDE.md §7)
// - EntitlementProvider: . Mock( ), StoreKit 2
// Transaction.currentEntitlements PremiumManager .
// - PremiumManager: UI (@Observable)
// - PremiumGate: ·· (App Group UserDefaults)
//
import Foundation
import Observation
// MARK: - App Group
/// ·· App Group
nonisolated enum AppGroup {
static let identifier = "group.com.yechan.HaruDanim"
/// : @AppStorage(store:)
/// UserDefaults
/// ( )
static let defaults: UserDefaults = UserDefaults(suiteName: identifier) ?? .standard
}
// MARK: - /
/// (CLAUDE.md §7.1)
nonisolated enum FreeLimits {
static let actions = 10
static let goals = 2
static let questsPerGoal = 3
}
/// (CLAUDE.md §7.2)
nonisolated enum PremiumFeature {
/// ··
case unlimitedItems
///
case homeWidgets
///
case lockScreenWidgets
/// +
case watchApp
/// (App Intents)
case siriShortcuts
/// iCloud
case cloudSync
}
// MARK: - (StoreKit )
/// .
/// StoreKit 2 `Transaction.currentEntitlements`
/// `Transaction.updates` entitlementUpdates() .
nonisolated protocol EntitlementProvider: Sendable {
///
func fetchIsEntitled() async -> Bool
/// ·
func entitlementUpdates() -> AsyncStream<Bool>
}
/// :
nonisolated struct MockEntitlementProvider: EntitlementProvider {
func fetchIsEntitled() async -> Bool {
PremiumGate.isPremium
}
func entitlementUpdates() -> AsyncStream<Bool> {
// Mock ( PremiumManager )
AsyncStream { $0.finish() }
}
}
// MARK: -
/// ··App Intents PremiumManager
/// App Group
nonisolated enum PremiumGate {
/// UserDefaults.standard ( )
static let cacheKey = "settings.isPremium"
static var isPremium: Bool {
AppGroup.defaults.bool(forKey: cacheKey)
}
static func isUnlocked(_ feature: PremiumFeature) -> Bool {
// .
isPremium
}
static func cache(_ value: Bool) {
AppGroup.defaults.set(value, forKey: cacheKey)
}
}
// MARK: -
/// .
/// `PremiumManager.shared.isPremium` canAdd~ / isUnlocked .
@MainActor
@Observable
final class PremiumManager {
static private(set) var shared = PremiumManager()
/// (StoreKitEntitlementProvider) .
/// (·) bootstrap Mock + PremiumGate .
static func bootstrap(provider: any EntitlementProvider) {
shared = PremiumManager(provider: provider)
}
private(set) var isPremium: Bool
@ObservationIgnored private let provider: any EntitlementProvider
#if DEBUG
/// (-premium · )
/// StoreKit
static let mockActiveKey = "debug.premiumMockActive"
private var isMockActive: Bool {
UserDefaults.standard.object(forKey: "premium") != nil
|| AppGroup.defaults.bool(forKey: Self.mockActiveKey)
}
#endif
init(provider: any EntitlementProvider = MockEntitlementProvider()) {
self.provider = provider
Self.migrateLegacyFlagIfNeeded()
#if DEBUG
// : -premium YES/NO
if UserDefaults.standard.object(forKey: "premium") != nil {
PremiumGate.cache(UserDefaults.standard.bool(forKey: "premium"))
}
#endif
self.isPremium = PremiumGate.isPremium
Task { [weak self] in
guard let self else { return }
self.applyFromProvider(await self.provider.fetchIsEntitled())
for await value in self.provider.entitlementUpdates() {
self.applyFromProvider(value)
}
}
}
// MARK:
func isUnlocked(_ feature: PremiumFeature) -> Bool {
// .
isPremium
}
func canAddAction(currentCount: Int) -> Bool {
isUnlocked(.unlimitedItems) || currentCount < FreeLimits.actions
}
func canAddGoal(currentCount: Int) -> Bool {
isUnlocked(.unlimitedItems) || currentCount < FreeLimits.goals
}
func canAddQuest(currentCount: Int) -> Bool {
isUnlocked(.unlimitedItems) || currentCount < FreeLimits.questsPerGoal
}
// MARK:
/// / ( )
func refresh() async {
applyFromProvider(await provider.fetchIsEntitled())
}
#if DEBUG
/// (DEBUG ).
func setMockPremium(_ value: Bool) {
AppGroup.defaults.set(true, forKey: Self.mockActiveKey)
apply(value)
}
#endif
/// (StoreKit) . DEBUG
private func applyFromProvider(_ value: Bool) {
#if DEBUG
guard !isMockActive else { return }
#endif
apply(value)
}
private func apply(_ value: Bool) {
isPremium = value
PremiumGate.cache(value)
}
/// UserDefaults.standard settings.isPremium App Group 1
private static func migrateLegacyFlagIfNeeded() {
let standard = UserDefaults.standard
guard AppGroup.defaults.object(forKey: PremiumGate.cacheKey) == nil,
let legacy = standard.object(forKey: PremiumGate.cacheKey) as? Bool else { return }
PremiumGate.cache(legacy)
}
}