Improve StoreKit tip diagnostics and pricing

This commit is contained in:
FuelBoard Contributor
2026-09-26 14:38:32 +01:00
parent 2921271be7
commit 20f53b9290
+61 -13
View File
@@ -631,19 +631,18 @@ final class TipStore: ObservableObject {
let id: String // product ID let id: String // product ID
let name: String let name: String
let blurb: String let blurb: String
let fallbackPrice: String
let accent: Color // card/price accent (matches the fuel palette) let accent: Color // card/price accent (matches the fuel palette)
} }
static let tiers: [TipTier] = [ static let tiers: [TipTier] = [
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash", 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 accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank", 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 accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up", 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 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. /// the queue but are never finished or acknowledged.
private var updatesTask: Task<Void, Never>? private var updatesTask: Task<Void, Never>?
/// 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() { init() {
Self.debugLog("[FuelBoard StoreKit] TipStore initialized; expected IDs=\(Self.tiers.map(\.id).joined(separator: ","))")
updatesTask = Task { [weak self] in updatesTask = Task { [weak self] in
for await update in Transaction.updates { for await update in Transaction.updates {
Self.debugLog("[FuelBoard StoreKit] Transaction.updates received")
await self?.handle(update) await self?.handle(update)
} }
} }
@@ -681,10 +690,18 @@ final class TipStore: ObservableObject {
} }
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async { private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
Self.debugLog("[FuelBoard StoreKit] Handling transaction update")
// Never deliver or finish an untrusted purchase. // 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). // 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() await transaction.finish()
showOutcome( showOutcome(
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""), NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
@@ -713,18 +730,29 @@ final class TipStore: ObservableObject {
} }
func displayPrice(for tier: TipTier) -> String { 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 { func load() async {
// Refreshes product state on every visit so newly-approved products // Refreshes product state on every visit so newly-approved products
// (or restored transactions) are picked up. // (or restored transactions) are picked up.
let ids = Self.tiers.map(\.id)
Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))")
do { 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) 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 { } catch {
// No products yet (sideloaded build) — the buttons still show the Self.debugLog("[FuelBoard StoreKit] Product load failed type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
// intended prices and report purchase attempts gracefully. // 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 = [:] products = [:]
} }
} }
@@ -734,24 +762,36 @@ final class TipStore: ObservableObject {
} }
private func product(for tier: TipTier) async throws -> Product? { 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 // TestFlight/App Store Connect product availability can lag the Settings
// onAppear load. Re-query the tapped tier before falling back so a newly // onAppear load. Re-query the tapped tier before falling back so a newly
// approved IAP opens the system purchase sheet instead of showing stale // approved IAP opens the system purchase sheet instead of showing stale
// "not available" copy. // "not available" copy.
Self.debugLog("[FuelBoard StoreKit] Re-querying tapped product ID=\(tier.id)")
let fetched = try await Product.products(for: [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) 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 { 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 purchaseInProgress = true
defer { purchaseInProgress = false } defer { purchaseInProgress = false }
do { do {
guard let product = try await product(for: tier) else { guard let product = try await product(for: tier) else {
Self.debugLog("[FuelBoard StoreKit] Purchase stopped: no StoreKit product for ID=\(tier.id)")
showOutcome( showOutcome(
NSLocalizedString("This tip isn't available from the App Store yet. Please try again shortly.", comment: ""), NSLocalizedString("This tip isn't available from the App Store yet. Please try again shortly.", comment: ""),
icon: "info.circle.fill", icon: "info.circle.fill",
@@ -760,11 +800,14 @@ final class TipStore: ObservableObject {
return return
} }
Self.debugLog("[FuelBoard StoreKit] Calling product.purchase() ID=\(product.id) displayPrice=\(product.displayPrice)")
let result = try await product.purchase() let result = try await product.purchase()
switch result { switch result {
case .success(let verification): case .success(let verification):
Self.debugLog("[FuelBoard StoreKit] Purchase result=success")
switch verification { switch verification {
case .verified(let transaction): case .verified(let transaction):
Self.debugLog("[FuelBoard StoreKit] Purchase verified productID=\(transaction.productID); finishing")
// Consume the consumable — otherwise StoreKit re-delivers // Consume the consumable — otherwise StoreKit re-delivers
// it through Transaction.updates on every launch. // it through Transaction.updates on every launch.
await transaction.finish() await transaction.finish()
@@ -774,6 +817,7 @@ final class TipStore: ObservableObject {
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158 tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
) )
case .unverified: case .unverified:
Self.debugLog("[FuelBoard StoreKit] Purchase result=unverified")
showOutcome( showOutcome(
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""), NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
icon: "exclamationmark.triangle.fill", icon: "exclamationmark.triangle.fill",
@@ -781,17 +825,21 @@ final class TipStore: ObservableObject {
) )
} }
case .userCancelled: case .userCancelled:
Self.debugLog("[FuelBoard StoreKit] Purchase result=userCancelled")
dismissOutcome() // silent — the user just closed the sheet dismissOutcome() // silent — the user just closed the sheet
case .pending: case .pending:
Self.debugLog("[FuelBoard StoreKit] Purchase result=pending")
showOutcome( showOutcome(
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""), NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
icon: "hourglass", icon: "hourglass",
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
) )
@unknown default: @unknown default:
Self.debugLog("[FuelBoard StoreKit] Purchase result=unknown")
dismissOutcome() dismissOutcome()
} }
} catch { } catch {
Self.debugLog("[FuelBoard StoreKit] Purchase threw type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
showOutcome( showOutcome(
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription), String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
icon: "xmark.circle.fill", icon: "xmark.circle.fill",