Improve StoreKit tip diagnostics and pricing
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user