// // Store.swift // Haru_Danim // // StoreKit 2 결제 레이어 (CLAUDE.md §1 수익 모델, §7.2) // - StoreKitEntitlementProvider: Transaction.currentEntitlements 기반 실권한 조회. // 앱 시작 시 PremiumManager.bootstrap으로 주입된다. // - PremiumStore: 상품 로드 / 구매 / 복원. PremiumView가 사용. // - App Store Connect에 아래 productID 3종을 등록하면 실결제가 연결된다. // 로컬 테스트: 프로젝트 루트의 HaruDanim.storekit을 스킴 옵션에서 선택. // import Foundation import Observation import StoreKit // MARK: - 상품 정의 nonisolated enum PremiumProducts { /// 월 구독 (임시가 월 5,000원) static let monthly = "com.yechan.HaruDanim.premium.monthly" /// 년 구독 (임시가 년 50,000원) static let yearly = "com.yechan.HaruDanim.premium.yearly" /// 일회성 평생 이용권 (임시가 80,000원) static let lifetime = "com.yechan.HaruDanim.premium.lifetime" static let all: Set = [monthly, yearly, lifetime] /// 결제 화면 표시 순서 static func sortOrder(_ id: String) -> Int { switch id { case monthly: return 0 case yearly: return 1 default: return 2 } } } // MARK: - 실권한 제공자 (PremiumManager에 주입) /// StoreKit 2의 현재 구매 내역으로 프리미엄 여부를 판단하는 EntitlementProvider 구현체 nonisolated struct StoreKitEntitlementProvider: EntitlementProvider { func fetchIsEntitled() async -> Bool { for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result, PremiumProducts.all.contains(transaction.productID), transaction.revocationDate == nil { return true } } return false } func entitlementUpdates() -> AsyncStream { AsyncStream { continuation in let task = Task { // 구매·갱신·환불 이벤트마다 권한을 재계산해서 흘려보낸다 for await update in Transaction.updates { if case .verified(let transaction) = update { await transaction.finish() } continuation.yield(await fetchIsEntitled()) } continuation.finish() } continuation.onTermination = { _ in task.cancel() } } } } // MARK: - 구매 스토어 (결제 화면용) @MainActor @Observable final class PremiumStore { static let shared = PremiumStore() private(set) var products: [Product] = [] private(set) var isLoading = false private(set) var isPurchasing = false /// 사용자에게 보여줄 마지막 오류 메시지 (nil이면 정상) private(set) var lastErrorMessage: String? /// App Store에서 상품 정보 로드 (결제 화면 진입 시 호출) func loadProducts() async { guard products.isEmpty, !isLoading else { return } isLoading = true defer { isLoading = false } do { let fetched = try await Product.products(for: PremiumProducts.all) products = fetched.sorted { PremiumProducts.sortOrder($0.id) < PremiumProducts.sortOrder($1.id) } lastErrorMessage = nil } catch { lastErrorMessage = String(localized: "상품 정보를 불러오지 못했어요. 네트워크를 확인해 주세요.") } } /// 구매 실행. 성공 시 PremiumManager가 갱신되어 즉시 잠금 해제된다 func purchase(_ product: Product) async { guard !isPurchasing else { return } isPurchasing = true defer { isPurchasing = false } do { let result = try await product.purchase() switch result { case .success(let verification): if case .verified(let transaction) = verification { await transaction.finish() } await PremiumManager.shared.refresh() lastErrorMessage = nil case .userCancelled, .pending: break @unknown default: break } } catch { lastErrorMessage = String(localized: "구매를 완료하지 못했어요. 잠시 후 다시 시도해 주세요.") } } /// 구매 복원 (기기 변경·재설치 시) func restore() async { try? await AppStore.sync() await PremiumManager.shared.refresh() } }