From 20f53b929034a7b3a6257d650ae37c43e7192100 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sat, 26 Sep 2026 14:38:32 +0100 Subject: [PATCH] Improve StoreKit tip diagnostics and pricing --- FuelBoard/SettingsView.swift | 74 +++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 5308827..ec47236 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -631,19 +631,18 @@ final class TipStore: ObservableObject { let id: String // product ID let name: String let blurb: String - let fallbackPrice: String let accent: Color // card/price accent (matches the fuel palette) } static let tiers: [TipTier] = [ TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash", - blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99", + blurb: "Just enough to keep things moving.", accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank", - blurb: "A generous top-up for development.", fallbackPrice: "£2.99", + blurb: "A generous top-up for development.", accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158 TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up", - blurb: "Keeping the app on the road.", fallbackPrice: "£4.99", + blurb: "Keeping the app on the road.", accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A ] @@ -667,9 +666,19 @@ final class TipStore: ObservableObject { /// the queue but are never finished or acknowledged. private var updatesTask: Task? + /// Uses the existing hidden developer switch in Settings. StoreKit + /// diagnostics must stay silent unless the developer explicitly enables + /// Debug mode with the five-tap Version gesture. + private static func debugLog(_ message: @autoclosure () -> String) { + guard FuelStore.loadDebugMode() else { return } + print(message()) + } + init() { + Self.debugLog("[FuelBoard StoreKit] TipStore initialized; expected IDs=\(Self.tiers.map(\.id).joined(separator: ","))") updatesTask = Task { [weak self] in for await update in Transaction.updates { + Self.debugLog("[FuelBoard StoreKit] Transaction.updates received") await self?.handle(update) } } @@ -681,10 +690,18 @@ final class TipStore: ObservableObject { } private func handle(_ update: VerificationResult) async { + Self.debugLog("[FuelBoard StoreKit] Handling transaction update") // Never deliver or finish an untrusted purchase. - guard case .verified(let transaction) = update else { return } + guard case .verified(let transaction) = update else { + Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update") + return + } // Only acknowledge our own consumables (future products get their own). - guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return } + guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { + Self.debugLog("[FuelBoard StoreKit] Ignoring unrelated product ID=\(transaction.productID)") + return + } + Self.debugLog("[FuelBoard StoreKit] Finishing verified transaction productID=\(transaction.productID)") await transaction.finish() showOutcome( NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""), @@ -713,18 +730,29 @@ final class TipStore: ObservableObject { } func displayPrice(for tier: TipTier) -> String { - products[tier.id]?.displayPrice ?? tier.fallbackPrice + products[tier.id]?.displayPrice + ?? NSLocalizedString("Unavailable", comment: "Tip product has not been returned by StoreKit") } func load() async { // Refreshes product state on every visit so newly-approved products // (or restored transactions) are picked up. + let ids = Self.tiers.map(\.id) + Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))") do { - let fetched = try await Product.products(for: Self.tiers.map(\.id)) + let fetched = try await Product.products(for: ids) + Self.debugLog("[FuelBoard StoreKit] Product.products returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))") + for product in fetched { + Self.debugLog("[FuelBoard StoreKit] Product id=\(product.id) type=\(String(describing: product.type)) displayPrice=\(product.displayPrice) price=\(product.price)") + } merge(fetched) + let matched = Self.tiers.filter { products[$0.id] != nil }.map(\.id) + Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))") } catch { - // No products yet (sideloaded build) — the buttons still show the - // intended prices and report purchase attempts gracefully. + Self.debugLog("[FuelBoard StoreKit] Product load failed type=\(String(describing: type(of: error))) error=\(error.localizedDescription)") + // Keep the cards visible so the user can retry while App Store + // Connect metadata propagates; displayPrice() shows "Unavailable" + // until StoreKit returns a real product. products = [:] } } @@ -734,24 +762,36 @@ final class TipStore: ObservableObject { } private func product(for tier: TipTier) async throws -> Product? { - if let product = products[tier.id] { return product } + if let product = products[tier.id] { + Self.debugLog("[FuelBoard StoreKit] Using cached product ID=\(tier.id)") + return product + } // TestFlight/App Store Connect product availability can lag the Settings // onAppear load. Re-query the tapped tier before falling back so a newly // approved IAP opens the system purchase sheet instead of showing stale // "not available" copy. + Self.debugLog("[FuelBoard StoreKit] Re-querying tapped product ID=\(tier.id)") let fetched = try await Product.products(for: [tier.id]) + Self.debugLog("[FuelBoard StoreKit] Re-query returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))") merge(fetched) - return fetched.first { $0.id == tier.id } + let match = fetched.first { $0.id == tier.id } + Self.debugLog("[FuelBoard StoreKit] Tapped product match=\(match != nil) ID=\(tier.id)") + return match } func purchase(_ tier: TipTier) async { - guard !purchaseInProgress else { return } + Self.debugLog("[FuelBoard StoreKit] Purchase requested ID=\(tier.id) name=\(tier.name)") + guard !purchaseInProgress else { + Self.debugLog("[FuelBoard StoreKit] Purchase ignored because another purchase is in progress") + return + } purchaseInProgress = true defer { purchaseInProgress = false } do { guard let product = try await product(for: tier) else { + Self.debugLog("[FuelBoard StoreKit] Purchase stopped: no StoreKit product for ID=\(tier.id)") showOutcome( NSLocalizedString("This tip isn't available from the App Store yet. Please try again shortly.", comment: ""), icon: "info.circle.fill", @@ -760,11 +800,14 @@ final class TipStore: ObservableObject { return } + Self.debugLog("[FuelBoard StoreKit] Calling product.purchase() ID=\(product.id) displayPrice=\(product.displayPrice)") let result = try await product.purchase() switch result { case .success(let verification): + Self.debugLog("[FuelBoard StoreKit] Purchase result=success") switch verification { case .verified(let transaction): + Self.debugLog("[FuelBoard StoreKit] Purchase verified productID=\(transaction.productID); finishing") // Consume the consumable — otherwise StoreKit re-delivers // it through Transaction.updates on every launch. await transaction.finish() @@ -774,6 +817,7 @@ final class TipStore: ObservableObject { tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158 ) case .unverified: + Self.debugLog("[FuelBoard StoreKit] Purchase result=unverified") showOutcome( NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""), icon: "exclamationmark.triangle.fill", @@ -781,17 +825,21 @@ final class TipStore: ObservableObject { ) } case .userCancelled: + Self.debugLog("[FuelBoard StoreKit] Purchase result=userCancelled") dismissOutcome() // silent — the user just closed the sheet case .pending: + Self.debugLog("[FuelBoard StoreKit] Purchase result=pending") showOutcome( NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""), icon: "hourglass", tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF ) @unknown default: + Self.debugLog("[FuelBoard StoreKit] Purchase result=unknown") dismissOutcome() } } catch { + Self.debugLog("[FuelBoard StoreKit] Purchase threw type=\(String(describing: type(of: error))) error=\(error.localizedDescription)") showOutcome( String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription), icon: "xmark.circle.fill",