From d0ee8edac32b03dc84dd2144432960002ebf68c6 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Sat, 26 Sep 2026 19:34:41 +0100 Subject: [PATCH] Document and support StoreKit sandbox testing --- APPSTORE.md | 98 ++++++++++++++++- .../xcshareddata/xcschemes/FuelBoard.xcscheme | 85 +++++++++++++++ FuelBoard/SettingsView.swift | 103 +++++++++++++++++- FuelBoardTips.storekit | 63 +++++++++++ 4 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 FuelBoard.xcodeproj/xcshareddata/xcschemes/FuelBoard.xcscheme create mode 100644 FuelBoardTips.storekit diff --git a/APPSTORE.md b/APPSTORE.md index f938c23..8c62b82 100644 --- a/APPSTORE.md +++ b/APPSTORE.md @@ -1,6 +1,6 @@ # FuelBoard App Store Submission Plan -Updated: 2026-08-16 · Target: safe App Store submission. +Updated: 2026-09-26 · Target: safe App Store submission. Companion to SECURITY.md (code security) — this file is the *review-gate* checklist: what must happen, who owns it, and the timeline. @@ -15,8 +15,104 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain): Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in). Onboarding requests WhenInUse only. +## StoreKit sandbox: verified working configuration and caveats + +### Verified configuration + +The FuelBoard consumable tip products are: + +- `com.apt.fuelboard.tip099` +- `com.apt.fuelboard.tip299` +- `com.apt.fuelboard.tip499` + +The app requests these exact **Product IDs** with StoreKit 2 +`Product.products(for:)`; the UI names are display labels and are not used for +lookup. The app bundle ID, App Store Connect app, explicit App ID, development +team, and Sandbox Apple Account must belong to the same developer account. + +Two test environments must remain distinct: + +- **Local StoreKit testing:** select `/Users/apt/workspace/fuelboard/FuelBoardTips.storekit` + in Xcode, under Product > Scheme > Edit Scheme > Run > Options. This bypasses + App Store Connect and confirms the app's StoreKit loading and purchase code. +- **Apple sandbox testing:** select **None** for StoreKit Configuration, then + use Product > Run on a physical device with the development-signed build and + a Sandbox Apple Account signed in under Settings > Developer. This exercises + the real App Store Connect catalog. + +The local configuration returning all three products does not prove that the +Apple sandbox catalog is ready. Conversely, a successful local run showing `$` +does not mean the App Store Connect price is USD: `Product.displayPrice` is +formatted for the active StoreKit/device storefront. Local StoreKit prices are +placeholders and do not upload to App Store Connect. + +### Availability versus pricing + +App Store Connect has separate controls: + +- **Availability** controls the storefronts where the IAP is offered. The + verified FuelBoard configuration uses **United Kingdom only**. +- **Price schedule** may show generated prices for **175 countries and + regions**. This is expected and does not make the IAP available in all 175 + storefronts; it is Apple's price conversion schedule. + +The Sandbox Apple Account's App Store Country or Region must match an enabled +IAP storefront. After changing the sandbox tester's storefront, sign out and +back in under Settings > Developer so the storefront change activates. + +### Important propagation/cache caveat + +During sandbox diagnosis, `Product.products(for:)` completed successfully but +returned zero products with the IAP availability set to UK. Temporarily +changing one product's availability to all regions caused it to appear in the +sandbox catalog; changing it back to UK left it working. This indicates an +Apple-side catalog refresh or propagation/cache issue, not a change required in +the StoreKit code. Apple documents that product metadata changes can take up to +approximately one hour to appear in sandbox. + +If an otherwise valid product returns zero products: + +1. Confirm the Sandbox Apple Account belongs to the same developer team. +2. Confirm its storefront is an enabled availability region. +3. Sign out and back in to the sandbox account on the device. +4. Delete/reinstall the development build and retry with StoreKit Configuration + set to **None**. +5. If necessary, temporarily broaden one product's availability to all + regions, allow propagation, verify the product, then restore the intended + availability and allow propagation again. + +Record whether the diagnostic result is **request failed**, **completed with +zero products**, or **a partial product set**. These are different failure +classes. Do not add fallback prices or change product lookup to display names. + +Sandbox testing does not require prior App Review approval of the IAP. It does +require active developer membership, an active Paid Applications Agreement, +complete banking and tax information, valid product metadata and pricing, the +correct app/team association, and a matching Sandbox Apple Account. + ## Hard blockers (must happen before ANY submission) +0. **[USER] Paid Applications Agreement in effect** — REQUIRED for paid IAP + configuration and sandbox testing. App Store Connect → + Business > Paid Applications Agreement → View and Agree. **This is the most + common cause of a 2.1(b) "unable to purchase item / error message" rejection + — without it every sandbox purchase fails.** (REJECTED once for this: + review date 2026-09-25, version 1.0 (21), iPad Air 11″ iPadOS 27.) + **Status 2026-09-25: agreement clicked Agree but shows "Pending user info" — + this is NOT in effect yet.** Complete the Banking (account + sort code/IBAN) + and Tax (W-8/W-9/UK tax ref, e.g. sole-trader UTR) sections under Business > + Payments & Tax until the agreement shows "Active" and every tab is green. + Only then do sandbox IAPs start working. +0. **[USER] Tip IAPs configured and available to the sandbox** — the three + consumables may exist but must ALSO have complete metadata, a current price, + and an enabled storefront availability. Prior App Review approval or + `Cleared for Sale` status is **not required for sandbox product discovery**. + They must be backed by the **In-App Purchase capability on the App ID** + `com.apt.fuelboard` (developer portal → Identifiers → App ID capability). + 3-tier tip code is already correct (retry lookup `7cb23e0`); do NOT churn + SettingsView.swift. Test product discovery and purchase with a Sandbox + Apple Account first: Settings → Support FuelBoard. + 1. **[USER] Apple Developer Program membership** — the app has never been signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for SideStore). Requires a paid membership ($99/yr, up to 48 h to approve) and diff --git a/FuelBoard.xcodeproj/xcshareddata/xcschemes/FuelBoard.xcscheme b/FuelBoard.xcodeproj/xcshareddata/xcschemes/FuelBoard.xcscheme new file mode 100644 index 0000000..e9fdbb1 --- /dev/null +++ b/FuelBoard.xcodeproj/xcshareddata/xcschemes/FuelBoard.xcscheme @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index ec47236..9e8a262 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -249,6 +249,55 @@ struct SettingsView: View { } .onAppear { refreshWidgetDiag() } + Section { + Button { + Task { await tipStore.load() } + } label: { + Label("Probe App Store products", systemImage: "arrow.clockwise") + } + LabeledContent("Probe", value: tipStore.productLoadStatus) + if let loadedAt = tipStore.lastProductLoadDate { + LabeledContent("Last probe", value: loadedAt.formatted(date: .abbreviated, time: .standard)) + } + LabeledContent("Requested", value: "\(TipStore.tiers.count) IDs") + LabeledContent("Returned", value: "\(tipStore.debugProducts.count) products") + if !tipStore.debugProducts.isEmpty { + ForEach(tipStore.debugProducts) { product in + VStack(alignment: .leading, spacing: 3) { + Text(product.id) + .font(.caption.monospaced()) + .textSelection(.enabled) + Text(product.displayName) + .font(.footnote.weight(.medium)) + Text("\(product.type) · \(product.displayPrice) · raw \(product.price)") + .font(.caption2) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } else { + Text("No products were returned by StoreKit.") + .font(.caption) + .foregroundStyle(.secondary) + } + if !tipStore.missingProductIDs.isEmpty { + Text("Missing IDs: \(tipStore.missingProductIDs.joined(separator: ", "))") + .font(.caption2) + .foregroundStyle(.orange) + .textSelection(.enabled) + } + if let error = tipStore.lastProductLoadError { + Text("Error: \(error)") + .font(.caption2) + .foregroundStyle(.red) + .textSelection(.enabled) + } + } header: { + Text("IAP Diagnostics") + } footer: { + Text("Shows the exact product identifiers requested and the StoreKit products returned. Product names and prices are read from StoreKit; missing IDs were not returned by the current App Store environment.") + } + Section { HStack { Text("Connection") @@ -656,6 +705,22 @@ final class TipStore: ObservableObject { @Published private(set) var products: [String: Product] = [:] @Published private(set) var purchaseInProgress = false @Published private(set) var outcome: TipOutcome? + @Published private(set) var productLoadStatus = "Not probed" + @Published private(set) var lastProductLoadDate: Date? + @Published private(set) var lastProductLoadError: String? + @Published private(set) var debugProducts: [DebugProduct] = [] + + struct DebugProduct: Identifiable { + let id: String + let displayName: String + let type: String + let displayPrice: String + let price: String + } + + var missingProductIDs: [String] { + Self.tiers.map(\.id).filter { products[$0] == nil } + } /// Auto-dismiss timer for the outcome banner (replaced on each new outcome). private var dismissTask: Task? @@ -666,6 +731,12 @@ final class TipStore: ObservableObject { /// the queue but are never finished or acknowledged. private var updatesTask: Task? + /// Drains verified FuelBoard transactions that were already unfinished + /// before this store was created (for example after termination during a + /// sandbox purchase). `Transaction.updates` only covers updates delivered + /// while the listener is active, so both sequences are required. + private var unfinishedTask: 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. @@ -682,10 +753,17 @@ final class TipStore: ObservableObject { await self?.handle(update) } } + unfinishedTask = Task { [weak self] in + for await update in Transaction.unfinished { + Self.debugLog("[FuelBoard StoreKit] Transaction.unfinished received") + await self?.handle(update) + } + } } deinit { updatesTask?.cancel() + unfinishedTask?.cancel() dismissTask?.cancel() } @@ -693,7 +771,11 @@ final class TipStore: ObservableObject { Self.debugLog("[FuelBoard StoreKit] Handling transaction update") // Never deliver or finish an untrusted purchase. guard case .verified(let transaction) = update else { - Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update") + if case .unverified(_, let error) = update { + Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction error=\(error.localizedDescription)") + } else { + Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update") + } return } // Only acknowledge our own consumables (future products get their own). @@ -738,17 +820,34 @@ final class TipStore: ObservableObject { // Refreshes product state on every visit so newly-approved products // (or restored transactions) are picked up. let ids = Self.tiers.map(\.id) + productLoadStatus = "Loading…" + lastProductLoadError = nil Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))") do { let fetched = try await Product.products(for: ids) + lastProductLoadDate = Date() 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) + products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) }) + debugProducts = fetched.map { + DebugProduct( + id: $0.id, + displayName: $0.displayName, + type: String(describing: $0.type), + displayPrice: $0.displayPrice, + price: String(describing: $0.price) + ) + } + productLoadStatus = fetched.isEmpty ? "Completed — zero returned" : "Completed" let matched = Self.tiers.filter { products[$0.id] != nil }.map(\.id) Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))") } catch { + lastProductLoadDate = Date() + productLoadStatus = "Failed" + lastProductLoadError = "\(String(describing: type(of: error))): \(error.localizedDescription)" + debugProducts = [] 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" diff --git a/FuelBoardTips.storekit b/FuelBoardTips.storekit new file mode 100644 index 0000000..65e1064 --- /dev/null +++ b/FuelBoardTips.storekit @@ -0,0 +1,63 @@ +{ + "identifier" : "fuelboard-local-tips", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "fuelboard-tip099", + "localizations" : [ + { + "description" : "Just enough to keep things moving.", + "displayName" : "Splash & Dash", + "locale" : "en_GB" + } + ], + "productID" : "com.apt.fuelboard.tip099", + "referenceName" : "FuelBoard Tip 099 - Splash & Dash", + "type" : "Consumable" + }, + { + "displayPrice" : "2.99", + "familyShareable" : false, + "internalID" : "fuelboard-tip299", + "localizations" : [ + { + "description" : "A generous top-up for development.", + "displayName" : "Half a Tank", + "locale" : "en_GB" + } + ], + "productID" : "com.apt.fuelboard.tip299", + "referenceName" : "FuelBoard Tip 299 - Half a Tank", + "type" : "Consumable" + }, + { + "displayPrice" : "4.99", + "familyShareable" : false, + "internalID" : "fuelboard-tip499", + "localizations" : [ + { + "description" : "Keeping the app on the road.", + "displayName" : "Fill 'Er Up", + "locale" : "en_GB" + } + ], + "productID" : "com.apt.fuelboard.tip499", + "referenceName" : "FuelBoard Tip 499 - Fill 'Er Up", + "type" : "Consumable" + } + ], + "settings" : { + + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 2, + "minor" : 0 + } +}