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
342 lines
11 KiB
Swift
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"))
|
|
]}
|
|
}
|