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)
}