- 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
130 lines
4.5 KiB
Swift
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()
|
|
}
|
|
}
|