197bddcd7e
CRÍTICO — enlaces del App Store rotos (adquisición viral = 0): - GoalShareService.appStoreURL apuntaba a id6744983373 (404): el QR de TODAS las imágenes compartidas llevaba a una página muerta. Corregido a id6757678318. - SettingsView enlazaba id6741412965 (una novela romántica). Ahora usa la constante. Monetización: - IAPService multi-producto: sub anual (com.portfoliojournal.premium.annual, con intro offer/trial) + lifetime como ancla. isPremium acepta cualquiera de los dos. Paywall con selector de plan (anual por defecto si existe; degrada a solo lifetime mientras el producto no esté creado en App Store Connect). - paywallBenefits: añadidos Multiple Accounts y Family Sharing (diferenciadores). - Teaser de historia bloqueada en Charts: "N meses más con Premium" en vez de truncar en silencio (FreemiumValidator.hiddenHistoryMonths + banner). - Trigger de paywall de backups ahora instrumentado (logPaywallShown "backups"). Retención / adquisición: - Rating velocity: prompt tras 2 check-ins y 30 días (antes 3 + 90 — en una app mensual eso era >3 meses sin poder pedir la primera review con ~0 ratings). - Sample data ON por defecto en onboarding (skip ya no aterriza en dashboard vacío). - Notificación de protección de streak el día 25 si el check-in del mes está pendiente. - App Intents + AppShortcutsProvider: "Update my portfolio" / "Check my portfolio" vía Siri/Shortcuts/Spotlight. - Instrumentación: onboarding_step/onboarding_skipped y content_shared (viral loop). - ScreenshotMode (--screenshots) para capturas de marketing automatizadas. ASO: - 6 locales nuevos en metadata (en-GB, en-CA, en-AU, es-MX, fr-CA, pt-PT) reusando traducciones — 6 campos de keywords extra; pt-PT adaptado (reforma). - Categoría secundaria: PRODUCTIVITY. - 17 strings nuevas localizadas en los 7 idiomas. Pendiente manual: crear la sub anual en App Store Connect (grupo de suscripción + intro offer 7 días) con el id exacto com.portfoliojournal.premium.annual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
216 lines
6.3 KiB
Swift
216 lines
6.3 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
enum FreemiumLimits {
|
|
static let maxSources = 5
|
|
static let maxHistoricalMonths = 12
|
|
}
|
|
|
|
class FreemiumValidator: ObservableObject {
|
|
private let iapService: IAPService
|
|
|
|
init(iapService: IAPService) {
|
|
self.iapService = iapService
|
|
}
|
|
|
|
// MARK: - Source Limits
|
|
|
|
var isPremium: Bool {
|
|
iapService.isPremium
|
|
}
|
|
|
|
func canAddSource(currentCount: Int) -> Bool {
|
|
if iapService.isPremium { return true }
|
|
return currentCount < FreemiumLimits.maxSources
|
|
}
|
|
|
|
func remainingSources(currentCount: Int) -> Int {
|
|
if iapService.isPremium { return Int.max }
|
|
return max(0, FreemiumLimits.maxSources - currentCount)
|
|
}
|
|
|
|
var sourceLimit: Int {
|
|
iapService.isPremium ? Int.max : FreemiumLimits.maxSources
|
|
}
|
|
|
|
var sourceLimitDescription: String {
|
|
if iapService.isPremium {
|
|
return "Unlimited"
|
|
}
|
|
return "\(FreemiumLimits.maxSources) sources"
|
|
}
|
|
|
|
// MARK: - Historical Data Limits
|
|
|
|
func filterSnapshots(_ snapshots: [Snapshot]) -> [Snapshot] {
|
|
if iapService.isPremium { return snapshots }
|
|
|
|
let cutoffDate = Calendar.current.date(
|
|
byAdding: .month,
|
|
value: -FreemiumLimits.maxHistoricalMonths,
|
|
to: Date()
|
|
) ?? Date()
|
|
|
|
return snapshots.filter { $0.date >= cutoffDate }
|
|
}
|
|
|
|
/// Number of distinct months of data that exist BEYOND the free history window.
|
|
/// Used to make the locked value concrete ("8 more months with Premium") instead
|
|
/// of silently truncating — users can't want what they can't see.
|
|
func hiddenHistoryMonths(in snapshots: [Snapshot]) -> Int {
|
|
if iapService.isPremium { return 0 }
|
|
|
|
let cutoffDate = Calendar.current.date(
|
|
byAdding: .month,
|
|
value: -FreemiumLimits.maxHistoricalMonths,
|
|
to: Date()
|
|
) ?? Date()
|
|
|
|
let calendar = Calendar.current
|
|
let hiddenMonths = Set(
|
|
snapshots
|
|
.filter { $0.date < cutoffDate }
|
|
.map { calendar.dateComponents([.year, .month], from: $0.date) }
|
|
)
|
|
return hiddenMonths.count
|
|
}
|
|
|
|
func isSnapshotAccessible(_ snapshot: Snapshot) -> Bool {
|
|
if iapService.isPremium { return true }
|
|
|
|
let cutoffDate = Calendar.current.date(
|
|
byAdding: .month,
|
|
value: -FreemiumLimits.maxHistoricalMonths,
|
|
to: Date()
|
|
) ?? Date()
|
|
|
|
return snapshot.date >= cutoffDate
|
|
}
|
|
|
|
var historicalLimit: Int {
|
|
iapService.isPremium ? Int.max : FreemiumLimits.maxHistoricalMonths
|
|
}
|
|
|
|
var historicalLimitDescription: String {
|
|
if iapService.isPremium {
|
|
return "Full history"
|
|
}
|
|
return "Last \(FreemiumLimits.maxHistoricalMonths) months"
|
|
}
|
|
|
|
// MARK: - Feature Access
|
|
|
|
func canExport() -> Bool {
|
|
return iapService.isPremium
|
|
}
|
|
|
|
func canViewPredictions() -> Bool {
|
|
return iapService.isPremium
|
|
}
|
|
|
|
func canViewAdvancedCharts() -> Bool {
|
|
return iapService.isPremium
|
|
}
|
|
|
|
func canAccessFeature(_ feature: PremiumFeature) -> Bool {
|
|
if iapService.isPremium { return true }
|
|
|
|
switch feature {
|
|
case .multipleAccounts:
|
|
return false
|
|
case .unlimitedSources:
|
|
return false
|
|
case .fullHistory:
|
|
return false
|
|
case .advancedCharts:
|
|
return false
|
|
case .predictions:
|
|
return false
|
|
case .export:
|
|
return false
|
|
case .noAds:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Premium Features Enum
|
|
|
|
enum PremiumFeature: String, CaseIterable, Identifiable {
|
|
case multipleAccounts = "multiple_accounts"
|
|
case unlimitedSources = "unlimited_sources"
|
|
case fullHistory = "full_history"
|
|
case advancedCharts = "advanced_charts"
|
|
case predictions = "predictions"
|
|
case export = "export"
|
|
case noAds = "no_ads"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .multipleAccounts: return "Multiple Accounts"
|
|
case .unlimitedSources: return "Unlimited Sources"
|
|
case .fullHistory: return "Full History"
|
|
case .advancedCharts: return "Advanced Charts"
|
|
case .predictions: return "Predictions"
|
|
case .export: return "Export Data"
|
|
case .noAds: return "No Ads"
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .multipleAccounts: return "person.2"
|
|
case .unlimitedSources: return "infinity"
|
|
case .fullHistory: return "clock.arrow.circlepath"
|
|
case .advancedCharts: return "chart.bar.xaxis"
|
|
case .predictions: return "wand.and.stars"
|
|
case .export: return "square.and.arrow.up"
|
|
case .noAds: return "xmark.circle"
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .multipleAccounts:
|
|
return "Track separate portfolios for family or business"
|
|
case .unlimitedSources:
|
|
return "Track as many investment sources as you want"
|
|
case .fullHistory:
|
|
return "Access your complete investment history"
|
|
case .advancedCharts:
|
|
return "5 types of detailed analytics charts"
|
|
case .predictions:
|
|
return "AI-powered 12-month forecasts"
|
|
case .export:
|
|
return "Export to CSV and JSON formats"
|
|
case .noAds:
|
|
return "Ad-free experience forever"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Paywall Triggers
|
|
|
|
enum PaywallTrigger: String {
|
|
case sourceLimit = "source_limit"
|
|
case historyLimit = "history_limit"
|
|
case advancedCharts = "advanced_charts"
|
|
case predictions = "predictions"
|
|
case export = "export"
|
|
case settingsUpgrade = "settings_upgrade"
|
|
case manualTap = "manual_tap"
|
|
}
|
|
|
|
func shouldShowPaywall(for trigger: PaywallTrigger) -> Bool {
|
|
guard !iapService.isPremium else { return false }
|
|
|
|
switch trigger {
|
|
case .sourceLimit, .historyLimit, .advancedCharts, .predictions, .export:
|
|
return true
|
|
case .settingsUpgrade, .manualTap:
|
|
return true
|
|
}
|
|
}
|
|
}
|