mycode/myApp/HaruDanim/IOS/Core/Store.swift
songyc macbook 040198beb4 feat(premium): add store-identical Release run scheme and real-purchase priority
- 'Haru_Danim-Store' 공유 스킴 추가: Release 구성으로 실행해 DEBUG 코드
  (프리미엄 테스트 토글·검증용 런치 인자)가 전부 빠진 '스토어에 올릴
  그대로'의 앱을 실기기에서 확인. StoreKit 로컬 구성은 유지되어 실제 결제
  시트가 뜨되 돈은 청구되지 않는다
- 실구매/복원 시 DEBUG 테스트 토글 강제 모드를 자동 해제(실권한 우선),
  개발용 섹션에 '토글 강제 해제' 버튼과 동작 설명 footer 추가
- Localizable.xcstrings는 Xcode 직렬화 포맷 정규화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 16:14:11 +09:00

190 lines
7.0 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
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<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: -
/// " "
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()
}
}