Files
InvestmentTrackerApp/PortfolioJournal/Services/IAPService.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

342 lines
11 KiB
Swift

import Foundation
import StoreKit
import Combine
@MainActor
class IAPService: ObservableObject {
// MARK: - Published Properties
@Published private(set) var isPremium = false
@Published private(set) var products: [Product] = []
@Published private(set) var purchaseState: PurchaseState = .idle
@Published private(set) var isFamilyShared = false
#if DEBUG
@Published var debugOverrideEnabled = false
#endif
// MARK: - Constants
/// Lifetime unlock (non-consumable). Existing product keep the id stable.
static let premiumProductID = "com.portfoliojournal.premium"
/// Annual auto-renewable subscription with intro free trial. Acts as the primary
/// offer; lifetime becomes the "best value" anchor. Must be created in App Store
/// Connect with this exact id until then the paywall gracefully shows lifetime only.
static let annualProductID = "com.portfoliojournal.premium.annual"
static let allProductIDs: Set<String> = [premiumProductID, annualProductID]
static let premiumPrice = "€4.69"
// MARK: - Private Properties
private var updateListenerTask: Task<Void, Error>?
private var cancellables = Set<AnyCancellable>()
private let sharedDefaults = UserDefaults(suiteName: AppConstants.appGroupIdentifier)
// MARK: - Purchase State
enum PurchaseState: Equatable {
case idle
case purchasing
case purchased
case failed(String)
case restored
}
// MARK: - Initialization
init() {
#if DEBUG
debugOverrideEnabled = UserDefaults.standard.bool(forKey: "debugPremiumOverride")
#endif
updateListenerTask = listenForTransactions()
Task {
await loadProducts()
await updatePremiumStatus()
}
}
#if DEBUG
func setPremiumForTesting(_ value: Bool, familyShared: Bool = false) {
isPremium = value
isFamilyShared = familyShared
}
#endif
deinit {
updateListenerTask?.cancel()
}
// MARK: - Load Products
func loadProducts() async {
do {
products = try await Product.products(for: Self.allProductIDs)
print("Loaded \(products.count) products")
} catch {
print("Failed to load products: \(error)")
}
}
// MARK: - Purchase
/// Purchases the lifetime unlock (backwards-compatible entry point).
func purchase() async throws {
guard let product = premiumProduct ?? products.first else {
throw IAPError.productNotFound
}
try await purchase(product)
}
/// Purchases a specific product (annual subscription or lifetime unlock).
func purchase(_ product: Product) async throws {
purchaseState = .purchasing
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
// Check if family shared
isFamilyShared = transaction.ownershipType == .familyShared
await transaction.finish()
await updatePremiumStatus()
purchaseState = .purchased
// Track analytics
FirebaseService.shared.logPurchaseSuccess(
productId: product.id,
price: product.price,
isFamilyShared: isFamilyShared
)
case .userCancelled:
purchaseState = .idle
case .pending:
purchaseState = .idle
@unknown default:
purchaseState = .idle
}
} catch {
purchaseState = .failed(error.localizedDescription)
FirebaseService.shared.logPurchaseFailure(
productId: product.id,
error: error.localizedDescription
)
throw error
}
}
// MARK: - Restore Purchases
func restorePurchases() async {
purchaseState = .purchasing
do {
try await AppStore.sync()
await updatePremiumStatus()
if isPremium {
purchaseState = .restored
} else {
purchaseState = .failed("No purchases to restore")
}
} catch {
purchaseState = .failed(error.localizedDescription)
}
}
// MARK: - TestFlight Detection
/// TestFlight builds use a "sandboxReceipt" instead of the production "receipt".
private var isRunningOnTestFlight: Bool {
Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
}
// MARK: - Update Premium Status
func updatePremiumStatus() async {
var isEntitled = false
var familyShared = false
#if DEBUG
if debugOverrideEnabled {
isPremium = true
isFamilyShared = false
return
}
#endif
// TestFlight builds always get Premium so testers can evaluate all features
if isRunningOnTestFlight {
isPremium = true
isFamilyShared = false
sharedDefaults?.set(true, forKey: "premiumUnlocked")
return
}
var entitledProductID = Self.premiumProductID
for await result in StoreKit.Transaction.currentEntitlements {
if case .verified(let transaction) = result {
// Lifetime unlock OR an active annual subscription both grant premium.
if Self.allProductIDs.contains(transaction.productID) {
isEntitled = true
entitledProductID = transaction.productID
familyShared = transaction.ownershipType == .familyShared
break
}
}
}
isPremium = isEntitled
isFamilyShared = familyShared
sharedDefaults?.set(isEntitled, forKey: "premiumUnlocked")
// Update Core Data
let context = CoreDataStack.shared.viewContext
PremiumStatus.updateStatus(
isPremium: isEntitled,
productIdentifier: entitledProductID,
transactionId: nil,
isFamilyShared: familyShared,
in: context
)
}
#if DEBUG
func setDebugPremiumOverride(_ enabled: Bool) {
debugOverrideEnabled = enabled
UserDefaults.standard.set(enabled, forKey: "debugPremiumOverride")
if enabled {
isPremium = true
isFamilyShared = false
sharedDefaults?.set(true, forKey: "premiumUnlocked")
} else {
Task { await updatePremiumStatus() }
}
}
#endif
// MARK: - Transaction Listener
private func listenForTransactions() -> Task<Void, Error> {
return Task.detached { [weak self] in
for await result in StoreKit.Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
await self?.updatePremiumStatus()
}
}
}
}
// MARK: - Verification
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw IAPError.verificationFailed(error.localizedDescription)
case .verified(let safe):
return safe
}
}
// MARK: - Product Info
var premiumProduct: Product? {
products.first { $0.id == Self.premiumProductID }
}
/// Annual subscription, nil until the product exists in App Store Connect.
var annualProduct: Product? {
products.first { $0.id == Self.annualProductID }
}
var formattedPrice: String {
premiumProduct?.displayPrice ?? Self.premiumPrice
}
var formattedAnnualPrice: String? {
annualProduct?.displayPrice
}
/// Localized description of the annual intro offer ("7 days free"), if configured.
var annualTrialDescription: String? {
guard let intro = annualProduct?.subscription?.introductoryOffer,
intro.paymentMode == .freeTrial else { return nil }
let period = intro.period
let unit: String
switch period.unit {
case .day: unit = String(localized: "paywall_trial_days")
case .week: unit = String(localized: "paywall_trial_weeks")
case .month: unit = String(localized: "paywall_trial_months")
case .year: unit = String(localized: "paywall_trial_years")
@unknown default: unit = ""
}
return String(format: String(localized: "paywall_trial_format"), period.value, unit)
}
}
// MARK: - IAP Error
enum IAPError: LocalizedError {
case productNotFound
case verificationFailed(String)
case purchaseFailed(String)
var errorDescription: String? {
switch self {
case .productNotFound:
return "Product not found. Please try again later."
case .verificationFailed(let message):
return "Verification failed: \(message)"
case .purchaseFailed(let message):
return "Purchase failed: \(message)"
}
}
}
// MARK: - Premium Features
extension IAPService {
static let premiumFeatures: [(icon: String, title: String, description: String)] = [
("person.2", "Multiple Accounts", "Separate portfolios for business or family"),
("infinity", "Unlimited Sources", "Track as many investments as you want"),
("clock.arrow.circlepath", "Full History", "Access your complete investment history"),
("chart.bar.xaxis", "Advanced Charts", "5 types of detailed analytics charts"),
("wand.and.stars", "Predictions", "AI-powered 12-month forecasts"),
("square.and.arrow.up", "Export Data", "Export to CSV and JSON formats"),
("xmark.circle", "No Ads", "Ad-free experience forever"),
("person.2", "Family Sharing", "Share with up to 5 family members")
]
/// Condensed benefits shown on the paywall (outcome-focused). Multiple Accounts and
/// Family Sharing are key differentiators vs competitors keep them visible here.
static var paywallBenefits: [(icon: String, title: String, subtitle: String)] {[
("clock.arrow.circlepath",
String(localized: "paywall_benefit_history_title"),
String(localized: "paywall_benefit_history_subtitle")),
("chart.bar.xaxis",
String(localized: "paywall_benefit_charts_title"),
String(localized: "paywall_benefit_charts_subtitle")),
("wand.and.stars",
String(localized: "paywall_benefit_forecasts_title"),
String(localized: "paywall_benefit_forecasts_subtitle")),
("person.2",
String(localized: "paywall_benefit_accounts_title"),
String(localized: "paywall_benefit_accounts_subtitle")),
("person.3",
String(localized: "paywall_benefit_family_title"),
String(localized: "paywall_benefit_family_subtitle")),
("xmark.circle",
String(localized: "paywall_benefit_noads_title"),
String(localized: "paywall_benefit_noads_subtitle"))
]}
}