mycode/myApp/HaruDanim/IOS/Core/Store.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

130 lines
4.5 KiB
Swift

//
// 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<String> = [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<Bool> {
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()
}
}