feat(premium): complete StoreKit 2 purchase experience

기존 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
This commit is contained in:
songyc macbook 2026-07-13 15:52:49 +09:00
parent c6959950c2
commit 14fc5cb5bd
6 changed files with 477 additions and 7 deletions

View File

@ -13,6 +13,7 @@
import Foundation
import Observation
import StoreKit
import WidgetKit
// MARK: -
@ -68,6 +69,26 @@ nonisolated struct StoreKitEntitlementProvider: EntitlementProvider {
}
}
// 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
@ -78,6 +99,8 @@ final class 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?
@ -109,7 +132,7 @@ final class PremiumStore {
if case .verified(let transaction) = verification {
await transaction.finish()
}
await PremiumManager.shared.refresh()
await didChangeEntitlement()
lastErrorMessage = nil
case .userCancelled, .pending:
break
@ -124,6 +147,39 @@ final class PremiumStore {
/// ( · )
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()
}
}

View File

@ -483,6 +483,22 @@
}
}
},
"%lld%% 절약": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Save %lld%%"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lld%%お得"
}
}
}
},
"%lld/%lld": {
"localizations": {
"en": {
@ -1244,7 +1260,40 @@
}
}
},
"구독 관리 (플랜 변경·해지)": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Manage Subscription (change or cancel)"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サブスクリプション管理(変更・解約)"
}
}
}
},
"구독은 결제 확인 시 Apple 계정으로 청구되고, 기간이 끝나기 24시간 전까지 해지하지 않으면 자동 갱신돼요. 해지는 언제든 App Store 구독 관리에서 할 수 있어요. 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요. 기기를 바꿨다면 '구매 복원'을 눌러 주세요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Subscriptions are charged to your Apple account at purchase confirmation and renew automatically unless cancelled at least 24 hours before the period ends. You can cancel anytime in App Store subscription settings. The lifetime plan is a one-time purchase that keeps every feature forever. Changed devices? Tap 'Restore Purchases'."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サブスクリプションは購入確定時にAppleアカウントに請求され、期間終了の24時間前までに解約しない限り自動更新されます。解約はいつでもApp Storeのサブスクリプション管理から行えます。買い切りは1回の購入ですべての機能をずっと利用できます。機種変更した場合は「購入を復元」をタップしてください。"
}
}
}
},
"구독은 언제든 해지할 수 있고, 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요.": {
"extractionState": "stale",
"localizations": {
"en": {
"stringUnit": {
@ -1260,6 +1309,22 @@
}
}
},
"구독을 해지해도 이미 결제한 기간이 끝날 때까지는 계속 사용할 수 있어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Even after cancelling, you can keep using premium until the paid period ends."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "解約しても、支払い済みの期間が終わるまでは引き続き利用できます。"
}
}
}
},
"구매 복원": {
"localizations": {
"en": {
@ -2067,6 +2132,22 @@
}
}
},
"년 구독": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Yearly"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "年額プラン"
}
}
}
},
"누적 기록": {
"localizations": {
"en": {
@ -2131,6 +2212,22 @@
}
}
},
"다음 갱신: %@": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Renews on %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "次回更新: %@"
}
}
}
},
"다이나믹 아일랜드 · 잠금화면": {
"localizations": {
"en": {
@ -3580,6 +3677,7 @@
}
},
"무료 버전에서는 목표당 다짐을 최대 %lld개까지 만들 수 있어요.": {
"extractionState": "stale",
"localizations": {
"en": {
"stringUnit": {
@ -3595,6 +3693,22 @@
}
}
},
"무료 버전에서는 목표당 다짐을 최대 %lld개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "The free version allows up to %lld quests per goal. Upgrade to premium for unlimited quests."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "無料版では目標ごとに最大%lld個のクエストを作成できます。プレミアムにアップグレードすると無制限になります。"
}
}
}
},
"무료 버전에서는 목표를 최대 %lld개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.": {
"localizations": {
"en": {
@ -5380,6 +5494,22 @@
}
}
},
"월 구독": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Monthly"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "月額プラン"
}
}
}
},
"월간": {
"localizations": {
"en": {
@ -5845,6 +5975,22 @@
}
}
},
"이용 중인 플랜": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Current Plan"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ご利用中のプラン"
}
}
}
},
"이하 유지": {
"localizations": {
"en": {
@ -7335,6 +7481,54 @@
}
}
},
"평생 이용권": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Lifetime"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "買い切り"
}
}
}
},
"평생 이용권으로 전환": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Switch to Lifetime"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "買い切りに切り替え"
}
}
}
},
"평생 이용권을 구매한 뒤에는 구독 관리에서 기존 구독을 해지해 주세요. 결제가 겹치지 않게 남은 구독 기간이 끝날 즈음 구매하는 것도 좋은 방법이에요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "After buying the lifetime plan, cancel your subscription in subscription settings. To avoid overlapping charges, you can also buy it near the end of your current period."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "買い切りを購入した後は、サブスクリプション管理で既存のサブスクリプションを解約してください。支払いが重ならないよう、残りの期間が終わる頃に購入するのもおすすめです。"
}
}
}
},
"표시 방식": {
"localizations": {
"en": {
@ -7479,6 +7673,38 @@
}
}
},
"프리미엄 알아보기": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Explore Premium"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "プレミアムを見る"
}
}
}
},
"프리미엄 이용 중": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Premium active"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "プレミアム利用中"
}
}
}
},
"프리미엄으로 할 수 있는 것": {
"localizations": {
"en": {
@ -7704,7 +7930,24 @@
}
}
},
"하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Create your own diary with Apple Pencil,\nalongside your daily records and stats."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1日の記録・統計とともに、Apple Pencilで書く\n自分だけのダイアリーが作れます。"
}
}
}
},
"하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.\n설정 → 프리미엄에서 잠금 해제해 주세요.": {
"extractionState": "stale",
"localizations": {
"en": {
"stringUnit": {
@ -7816,6 +8059,38 @@
}
}
},
"한 번 결제": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "One-time"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1回払い"
}
}
}
},
"한 번 결제로 모든 기능을 계속 사용할 수 있어요": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "One purchase keeps every feature forever"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "1回の購入ですべての機能をずっと利用できます"
}
}
}
},
"한 줄에 표시할 개수": {
"localizations": {
"en": {

View File

@ -18,6 +18,7 @@ struct ActionListView: View {
@State private var showingAdd = false
@State private var showLimitAlert = false
@State private var showingPremiumSheet = false
@State private var showingTagOrder = false
// (LocalPrefs )
@ -111,10 +112,14 @@ struct ActionListView: View {
TagOrderSheet()
}
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
Button("프리미엄 알아보기") { showingPremiumSheet = true }
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
}
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
}
private func actionLink(_ action: Action) -> some View {

View File

@ -192,6 +192,8 @@ struct DiaryRootView: View {
// MARK: -
struct DiaryLockedView: View {
@State private var showingPremiumSheet = false
var body: some View {
VStack(spacing: 12) {
Image(systemName: "crown.fill")
@ -199,12 +201,25 @@ struct DiaryLockedView: View {
.foregroundStyle(AppTheme.yellow)
Text("일기는 프리미엄 기능이에요")
.font(.title3.weight(.semibold))
Text("하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.\n설정 → 프리미엄에서 잠금 해제해 주세요.")
Text("하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
showingPremiumSheet = true
} label: {
Label("프리미엄 알아보기", systemImage: "crown")
.padding(.horizontal, 6)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.tint(AppTheme.green)
.padding(.top, 6)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
}
}

View File

@ -17,6 +17,7 @@ struct GoalListView: View {
@State private var showingAdd = false
@State private var showLimitAlert = false
@State private var showingPremiumSheet = false
@State private var isReordering = false
@State private var editMode: EditMode = .inactive
@ -138,10 +139,14 @@ struct GoalListView: View {
GoalEditorView(goal: nil)
}
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
Button("프리미엄 알아보기") { showingPremiumSheet = true }
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 목표를 최대 \(FreeLimits.goals)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
}
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
.onAppear {
let math = DayMath()
var statusChanged = false
@ -392,6 +397,7 @@ struct GoalDetailView: View {
@State private var showingDelete = false
@State private var showingAddQuest = false
@State private var showQuestLimitAlert = false
@State private var showingPremiumSheet = false
@State private var showingManualFinish = false
@State private var askManualResult = false
@State private var editingQuest: Quest?
@ -492,9 +498,13 @@ struct GoalDetailView: View {
QuestEditorView(goal: goal, quest: quest)
}
.alert("무료 사용 한도", isPresented: $showQuestLimitAlert) {
Button("프리미엄 알아보기") { showingPremiumSheet = true }
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 목표당 다짐을 최대 \(FreeLimits.questsPerGoal)개까지 만들 수 있어요.")
Text("무료 버전에서는 목표당 다짐을 최대 \(FreeLimits.questsPerGoal)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
}
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
.alert("목표 수동 종료", isPresented: $showingManualFinish) {
Button("종료") {

View File

@ -329,6 +329,7 @@ struct PremiumView: View {
private let premium = PremiumManager.shared
private let store = PremiumStore.shared
@AppStorage(DataStore.cloudSyncKey, store: AppGroup.defaults) private var cloudSync = false
@State private var showingManageSubscriptions = false
var body: some View {
List {
@ -337,12 +338,14 @@ struct PremiumView: View {
featureRow("square.grid.2x2.fill", "홈·잠금화면 위젯", "위젯에서 바로 행동을 실행하고 달성률을 확인해요.")
featureRow("icloud.fill", "아이패드 동기화", "iCloud로 기기 간 데이터가 동기화돼요.")
featureRow("applewatch", "애플워치 앱", "워치에서 추적하고 컴플리케이션으로 확인해요.")
featureRow("book.closed.fill", "아이패드 일기", "하루 정리와 애플 펜슬 다이어리를 사용해요.")
} header: {
Text("프리미엄으로 할 수 있는 것")
} footer: {
Text("무료: 행동 \(FreeLimits.actions)개 · 목표 \(FreeLimits.goals)개 · 목표당 다짐 \(FreeLimits.questsPerGoal)개까지")
}
if premium.isPremium {
currentPlanSection
Section {
Toggle("기기 간 동기화", isOn: $cloudSync)
} header: {
@ -350,6 +353,17 @@ struct PremiumView: View {
} footer: {
Text("iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요.")
}
//
if let plan = store.currentPlan, !plan.isLifetime,
let lifetime = store.products.first(where: { $0.id == PremiumProducts.lifetime }) {
Section {
productRow(lifetime)
} header: {
Text("평생 이용권으로 전환")
} footer: {
Text("평생 이용권을 구매한 뒤에는 구독 관리에서 기존 구독을 해지해 주세요. 결제가 겹치지 않게 남은 구독 기간이 끝날 즈음 구매하는 것도 좋은 방법이에요.")
}
}
} else {
purchaseSection
}
@ -367,8 +381,56 @@ struct PremiumView: View {
.navigationTitle("프리미엄")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.manageSubscriptionsSheet(isPresented: $showingManageSubscriptions)
.task {
await store.loadProducts()
await store.refreshEntitlement()
}
}
// MARK:
@ViewBuilder
private var currentPlanSection: some View {
Section {
HStack(spacing: 12) {
Image(systemName: "crown.fill")
.font(.system(size: 20))
.foregroundStyle(AppTheme.yellow)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
if let plan = store.currentPlan {
Text(store.planDisplayName(plan))
.font(.body.weight(.semibold))
if plan.isLifetime {
Text("한 번 결제로 모든 기능을 계속 사용할 수 있어요")
.font(.caption)
.foregroundStyle(.secondary)
} else if let expiration = plan.expirationDate {
Text("다음 갱신: \(expiration.formatted(.dateTime.year().month().day()))")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Text("프리미엄 이용 중")
.font(.body.weight(.semibold))
}
}
}
if let plan = store.currentPlan, !plan.isLifetime {
Button {
showingManageSubscriptions = true
} label: {
Label("구독 관리 (플랜 변경·해지)", systemImage: "creditcard")
.font(.callout)
}
}
} header: {
Text("이용 중인 플랜")
} footer: {
if let plan = store.currentPlan, !plan.isLifetime {
Text("구독을 해지해도 이미 결제한 기간이 끝날 때까지는 계속 사용할 수 있어요.")
}
}
}
@ -412,7 +474,7 @@ struct PremiumView: View {
if let message = store.lastErrorMessage {
Text(message).foregroundStyle(.red)
} else {
Text("구독은 언제든 해지할 수 있고, 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요.")
Text("구독은 결제 확인 시 Apple 계정으로 청구되고, 기간이 끝나기 24시간 전까지 해지하지 않으면 자동 갱신돼요. 해지는 언제든 App Store 구독 관리에서 할 수 있어요. 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요. 기기를 바꿨다면 '구매 복원'을 눌러 주세요.")
}
}
}
@ -423,9 +485,19 @@ struct PremiumView: View {
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(product.displayName)
.font(.body.weight(.semibold))
.foregroundStyle(.primary)
HStack(spacing: 6) {
Text(product.displayName)
.font(.body.weight(.semibold))
.foregroundStyle(.primary)
if let badge = savingsBadge(product) {
Text(badge)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(AppTheme.yellow, in: Capsule())
}
}
if !product.description.isEmpty {
Text(product.description)
.font(.caption)
@ -443,6 +515,25 @@ struct PremiumView: View {
.disabled(store.isPurchasing)
}
/// : / :
private func savingsBadge(_ product: Product) -> String? {
switch product.id {
case PremiumProducts.lifetime:
return String(localized: "한 번 결제")
case PremiumProducts.yearly:
guard let monthly = store.products.first(where: { $0.id == PremiumProducts.monthly }) else {
return nil
}
let monthlyYearTotal = NSDecimalNumber(decimal: monthly.price).doubleValue * 12
let yearly = NSDecimalNumber(decimal: product.price).doubleValue
guard monthlyYearTotal > yearly, monthlyYearTotal > 0 else { return nil }
let percent = Int(((monthlyYearTotal - yearly) / monthlyYearTotal * 100).rounded())
return String(localized: "\(percent)% 절약")
default:
return nil
}
}
private func featureRow(_ symbol: String, _ title: String, _ detail: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: symbol)
@ -457,3 +548,21 @@ struct PremiumView: View {
.padding(.vertical, 2)
}
}
// MARK: - ( )
/// ·
struct PremiumSheetView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
PremiumView()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("닫기") { dismiss() }
}
}
}
}
}