diff --git a/myApp/HaruDanim/HaruDanim.storekit b/myApp/HaruDanim/HaruDanim.storekit new file mode 100644 index 0000000..3007a04 --- /dev/null +++ b/myApp/HaruDanim/HaruDanim.storekit @@ -0,0 +1,132 @@ +{ + "identifier" : "F1A2B3C4-0001-4A2B-9C3D-5E6F7A8B9C0D", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "80000", + "familyShareable" : false, + "internalID" : "F1A2B3C4-0002-4A2B-9C3D-5E6F7A8B9C0D", + "localizations" : [ + { + "description" : "한 번 결제로 모든 프리미엄 기능을 평생 사용해요", + "displayName" : "평생 이용권", + "locale" : "ko" + }, + { + "description" : "Pay once and use all premium features forever", + "displayName" : "Lifetime", + "locale" : "en" + }, + { + "description" : "1回の購入ですべてのプレミアム機能をずっと利用できます", + "displayName" : "買い切り", + "locale" : "ja" + } + ], + "productID" : "com.yechan.HaruDanim.premium.lifetime", + "referenceName" : "Premium Lifetime", + "type" : "NonConsumable" + } + ], + "settings" : { + "_failTransactionsEnabled" : false, + "_locale" : "ko_KR", + "_storefront" : "KOR", + "_storeKitErrors" : [ + + ] + }, + "subscriptionGroups" : [ + { + "id" : "F1A2B3C4-0003-4A2B-9C3D-5E6F7A8B9C0D", + "localizations" : [ + + ], + "name" : "HaruDanim Premium", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "5000", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "F1A2B3C4-0004-4A2B-9C3D-5E6F7A8B9C0D", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "모든 프리미엄 기능을 월 단위로 구독해요", + "displayName" : "월 구독", + "locale" : "ko" + }, + { + "description" : "Subscribe to all premium features monthly", + "displayName" : "Monthly", + "locale" : "en" + }, + { + "description" : "すべてのプレミアム機能を月単位で購読します", + "displayName" : "月額プラン", + "locale" : "ja" + } + ], + "productID" : "com.yechan.HaruDanim.premium.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Premium Monthly", + "subscriptionGroupID" : "F1A2B3C4-0003-4A2B-9C3D-5E6F7A8B9C0D", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "50000", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "F1A2B3C4-0005-4A2B-9C3D-5E6F7A8B9C0D", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "모든 프리미엄 기능을 년 단위로 구독해요", + "displayName" : "년 구독", + "locale" : "ko" + }, + { + "description" : "Subscribe to all premium features yearly", + "displayName" : "Yearly", + "locale" : "en" + }, + { + "description" : "すべてのプレミアム機能を年単位で購読します", + "displayName" : "年額プラン", + "locale" : "ja" + } + ], + "productID" : "com.yechan.HaruDanim.premium.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Premium Yearly", + "subscriptionGroupID" : "F1A2B3C4-0003-4A2B-9C3D-5E6F7A8B9C0D", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/myApp/HaruDanim/Haru_Danim.xcodeproj/xcshareddata/xcschemes/Haru_Danim.xcscheme b/myApp/HaruDanim/Haru_Danim.xcodeproj/xcshareddata/xcschemes/Haru_Danim.xcscheme new file mode 100644 index 0000000..61f273a --- /dev/null +++ b/myApp/HaruDanim/Haru_Danim.xcodeproj/xcshareddata/xcschemes/Haru_Danim.xcscheme @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index a495334..c8f560c 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -17,8 +17,11 @@ struct ContentView: View { ZStack { #if DEBUG // 검증용: -widgetPreview YES(홈 위젯) / lock(잠금화면 위젯) → 위젯 미리보기 화면 + // -premiumPreview YES → 프리미엄 결제 화면 직접 표시 if let mode = UserDefaults.standard.string(forKey: "widgetPreview") { WidgetPreviewScreen(showLock: mode == "lock") + } else if UserDefaults.standard.bool(forKey: "premiumPreview") { + NavigationStack { PremiumView() } } else { MainTabView() } diff --git a/myApp/HaruDanim/IOS/Core/Store.swift b/myApp/HaruDanim/IOS/Core/Store.swift new file mode 100644 index 0000000..aa3341d --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/Store.swift @@ -0,0 +1,129 @@ +// +// 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 = [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 { + 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() + } +} diff --git a/myApp/HaruDanim/IOS/Haru_DanimApp.swift b/myApp/HaruDanim/IOS/Haru_DanimApp.swift index 6bba99c..69dc2e4 100644 --- a/myApp/HaruDanim/IOS/Haru_DanimApp.swift +++ b/myApp/HaruDanim/IOS/Haru_DanimApp.swift @@ -16,6 +16,8 @@ struct Haru_DanimApp: App { @Environment(\.scenePhase) private var scenePhase init() { + // 실결제 권한 제공자 주입 (다른 무엇보다 먼저 — shared 최초 접근 전에 교체) + PremiumManager.bootstrap(provider: StoreKitEntitlementProvider()) SettingsMigration.runIfNeeded() container = DataStore.shared DataStore.ensureUniqueEntityIDs(context: container.mainContext) diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 4a27df7..53315a5 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI import SwiftData +import StoreKit import WidgetKit struct SettingsView: View { @@ -246,6 +247,7 @@ struct SettingsView: View { struct PremiumView: View { private let premium = PremiumManager.shared + private let store = PremiumStore.shared @AppStorage(DataStore.cloudSyncKey, store: AppGroup.defaults) private var cloudSync = false var body: some View { @@ -269,16 +271,7 @@ struct PremiumView: View { Text("iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요.") } } else { - Section { - Button { - // StoreKit 결제는 Apple Developer Program 가입 후 연결 (CLAUDE.md §2.3) - } label: { - Label("프리미엄 구매 (준비 중)", systemImage: "cart.fill") - } - .disabled(true) - } footer: { - Text("결제 기능은 App Store 배포 준비와 함께 제공될 예정이에요.") - } + purchaseSection } #if DEBUG Section("개발용") { @@ -294,6 +287,80 @@ struct PremiumView: View { .navigationTitle("프리미엄") .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) + .task { + await store.loadProducts() + } + } + + // MARK: 구매 (StoreKit 2) + + @ViewBuilder + private var purchaseSection: some View { + Section { + if store.products.isEmpty { + if store.isLoading { + HStack { + ProgressView() + Text("상품 정보를 불러오는 중...") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + // App Store Connect에 상품 등록 전이거나 네트워크 문제 + Label("상품 정보를 불러올 수 없어요", systemImage: "wifi.exclamationmark") + .font(.callout) + .foregroundStyle(.secondary) + Button("다시 시도") { + Task { await store.loadProducts() } + } + } + } else { + ForEach(store.products) { product in + productRow(product) + } + } + Button { + Task { await store.restore() } + } label: { + Text("구매 복원") + .font(.callout) + } + .disabled(store.isPurchasing) + } header: { + Text("프리미엄 구매") + } footer: { + if let message = store.lastErrorMessage { + Text(message).foregroundStyle(.red) + } else { + Text("구독은 언제든 해지할 수 있고, 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요.") + } + } + } + + private func productRow(_ product: Product) -> some View { + Button { + Task { await store.purchase(product) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(product.displayName) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + if !product.description.isEmpty { + Text(product.description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Text(product.displayPrice) + .font(.body.weight(.bold)) + .foregroundStyle(AppTheme.green) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(store.isPurchasing) } private func featureRow(_ symbol: String, _ title: String, _ detail: String) -> some View { diff --git a/myApp/HaruDanim/Shared/Premium.swift b/myApp/HaruDanim/Shared/Premium.swift index 1ad963a..aa62954 100644 --- a/myApp/HaruDanim/Shared/Premium.swift +++ b/myApp/HaruDanim/Shared/Premium.swift @@ -102,11 +102,27 @@ nonisolated enum PremiumGate { @MainActor @Observable final class PremiumManager { - static let shared = PremiumManager() + static private(set) var shared = PremiumManager() + + /// 앱 본체가 시작 시 실결제 제공자(StoreKitEntitlementProvider)를 주입하는 지점. + /// 확장(위젯·워치)은 bootstrap 없이 Mock + PremiumGate 캐시를 그대로 쓴다. + static func bootstrap(provider: any EntitlementProvider) { + shared = PremiumManager(provider: provider) + } private(set) var isPremium: Bool @ObservationIgnored private let provider: any EntitlementProvider + #if DEBUG + /// 개발용 강제 프리미엄(-premium 인자·테스트 토글)이 켜져 있으면 + /// 실제 StoreKit 권한 조회 결과가 이를 덮어쓰지 않게 막는다 + static let mockActiveKey = "debug.premiumMockActive" + private var isMockActive: Bool { + UserDefaults.standard.object(forKey: "premium") != nil + || AppGroup.defaults.bool(forKey: Self.mockActiveKey) + } + #endif + init(provider: any EntitlementProvider = MockEntitlementProvider()) { self.provider = provider Self.migrateLegacyFlagIfNeeded() @@ -119,9 +135,9 @@ final class PremiumManager { self.isPremium = PremiumGate.isPremium Task { [weak self] in guard let self else { return } - self.apply(await self.provider.fetchIsEntitled()) + self.applyFromProvider(await self.provider.fetchIsEntitled()) for await value in self.provider.entitlementUpdates() { - self.apply(value) + self.applyFromProvider(value) } } } @@ -147,13 +163,24 @@ final class PremiumManager { // MARK: 상태 갱신 - /// 결제/복원 후 권한 재조회 (StoreKit 연동 시 구매 완료 지점에서 호출) + /// 결제/복원 후 권한 재조회 (구매 완료 지점에서 호출) func refresh() async { - apply(await provider.fetchIsEntitled()) + applyFromProvider(await provider.fetchIsEntitled()) } - /// 설정 탭 테스트 토글용. StoreKit 도입 후에는 DEBUG 전용으로 남긴다 + #if DEBUG + /// 설정 탭 테스트 토글용 (DEBUG 전용). 켜는 순간 실권한 조회를 무시하는 모드가 된다 func setMockPremium(_ value: Bool) { + AppGroup.defaults.set(true, forKey: Self.mockActiveKey) + apply(value) + } + #endif + + /// 권한 제공자(StoreKit) 결과 반영. DEBUG 강제 프리미엄 중에는 무시 + private func applyFromProvider(_ value: Bool) { + #if DEBUG + guard !isMockActive else { return } + #endif apply(value) }