Files
InvestmentTrackerApp/PortfolioJournal/Utilities/FreemiumValidator.swift
T
alexandrev-tibco 197bddcd7e Growth y monetización: fixes críticos, paywall multi-plan, teasers y App Intents
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
2026-07-02 22:12:32 +02:00

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