- '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
190 lines
7.0 KiB
Swift
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()
|
|
}
|
|
}
|