// // 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 import WidgetKit // 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: - 현재 이용 중인 플랜 /// 결제 화면의 "이용 중인 플랜" 표시용 정보 struct PremiumPlan: Equatable { let productID: String /// 구독의 다음 갱신(만료) 시각. 평생 이용권은 nil let expirationDate: Date? var isLifetime: Bool { productID == PremiumProducts.lifetime } /// 상품 정보를 아직 못 불러왔을 때 쓰는 표시 이름 var fallbackName: String { switch productID { case PremiumProducts.monthly: return String(localized: "월 구독") case PremiumProducts.yearly: return String(localized: "년 구독") default: return String(localized: "평생 이용권") } } } // 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 currentPlan: PremiumPlan? /// 사용자에게 보여줄 마지막 오류 메시지 (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 didChangeEntitlement() lastErrorMessage = nil case .userCancelled, .pending: break @unknown default: break } } catch { lastErrorMessage = String(localized: "구매를 완료하지 못했어요. 잠시 후 다시 시도해 주세요.") } } /// 구매 복원 (기기 변경·재설치 시) func restore() async { try? await AppStore.sync() await didChangeEntitlement() } /// 현재 보유 권한에서 대표 플랜을 찾아 둔다 (결제 화면 진입·구매·복원 시 호출) func refreshEntitlement() async { var best: PremiumPlan? for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result, PremiumProducts.all.contains(transaction.productID), transaction.revocationDate == nil else { continue } let plan = PremiumPlan(productID: transaction.productID, expirationDate: transaction.expirationDate) if plan.isLifetime { best = plan break } if best == nil || (plan.expirationDate ?? .distantFuture) > (best?.expirationDate ?? .distantPast) { best = plan } } currentPlan = best } /// 결제 화면에 보여줄 플랜 이름 (상품 정보가 있으면 스토어 현지화 이름 사용) func planDisplayName(_ plan: PremiumPlan) -> String { products.first { $0.id == plan.productID }?.displayName ?? plan.fallbackName } /// 구매·복원 직후: 권한 재계산 → 플랜 정보 갱신 → 위젯·워치에 즉시 반영 private func didChangeEntitlement() async { #if DEBUG // 실구매/복원은 개발용 테스트 토글 강제 모드보다 우선한다 (DEBUG 빌드에서 구매 검증 시 혼동 방지) await PremiumManager.shared.clearMockOverride() #endif await PremiumManager.shared.refresh() await refreshEntitlement() WidgetCenter.shared.reloadAllTimelines() } }