기존 StoreKit 2 골격(상품 3종·구매·복원·권한 주입) 위에 실사용 경험 완성: - PremiumStore.currentPlan: 현재 이용 중인 대표 플랜(평생 > 만료 먼 구독) 추적, 구매·복원 후 위젯 타임라인 즉시 갱신 - 프리미엄 화면: '이용 중인 플랜' 섹션(플랜 이름·다음 갱신일·평생 안내), 구독 관리 시트(manageSubscriptionsSheet), 구독 중 평생 이용권 전환 안내, 년 구독 절약률·평생 '한 번 결제' 배지, 자동 갱신·해지·복원 안내 문구 - 잠금 안내 개선: 행동/목표/다짐 무료 한도 알림에 '프리미엄 알아보기' 버튼 + PremiumSheetView 모달, 아이패드 일기 잠금 화면에도 버튼 추가 - 기능 소개에 '아이패드 일기' 추가, 새 문자열 en/ja 번역 가격(월 5,000/년 50,000/일회성 80,000)은 HaruDanim.storekit(로컬 테스트)과 App Store Connect에서 각각 수정 가능. DEBUG 강제 토글은 그대로 유지. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
186 lines
6.8 KiB
Swift
186 lines
6.8 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 {
|
|
await PremiumManager.shared.refresh()
|
|
await refreshEntitlement()
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
}
|