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
+ }
+}