Document and support StoreKit sandbox testing

This commit is contained in:
FuelBoard Contributor
2026-09-26 19:34:41 +01:00
parent 20f53b9290
commit d0ee8edac3
4 changed files with 346 additions and 3 deletions
+97 -1
View File
@@ -1,6 +1,6 @@
# FuelBoard App Store Submission Plan # 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* Companion to SECURITY.md (code security) — this file is the *review-gate*
checklist: what must happen, who owns it, and the timeline. 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). Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in).
Onboarding requests WhenInUse only. 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) ## 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 1. **[USER] Apple Developer Program membership** — the app has never been
signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for 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 SideStore). Requires a paid membership ($99/yr, up to 48 h to approve) and
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.8">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BB000000000000000000000F"
BuildableName = "FuelBoard.app"
BlueprintName = "FuelBoard"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<StoreKitConfigurationFileReference
identifier = "../../../FuelBoardTips.storekit">
</StoreKitConfigurationFileReference>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "en"
region = "GB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersion = "1.0"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BB000000000000000000000F"
BuildableName = "FuelBoard.app"
BlueprintName = "FuelBoard"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersion = "1.0"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BB000000000000000000000F"
BuildableName = "FuelBoard.app"
BlueprintName = "FuelBoard"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+100 -1
View File
@@ -249,6 +249,55 @@ struct SettingsView: View {
} }
.onAppear { refreshWidgetDiag() } .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 { Section {
HStack { HStack {
Text("Connection") Text("Connection")
@@ -656,6 +705,22 @@ final class TipStore: ObservableObject {
@Published private(set) var products: [String: Product] = [:] @Published private(set) var products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false @Published private(set) var purchaseInProgress = false
@Published private(set) var outcome: TipOutcome? @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). /// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
private var dismissTask: Task<Void, Never>? private var dismissTask: Task<Void, Never>?
@@ -666,6 +731,12 @@ 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>?
/// 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<Void, Never>?
/// Uses the existing hidden developer switch in Settings. StoreKit /// Uses the existing hidden developer switch in Settings. StoreKit
/// diagnostics must stay silent unless the developer explicitly enables /// diagnostics must stay silent unless the developer explicitly enables
/// Debug mode with the five-tap Version gesture. /// Debug mode with the five-tap Version gesture.
@@ -682,10 +753,17 @@ final class TipStore: ObservableObject {
await self?.handle(update) 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 { deinit {
updatesTask?.cancel() updatesTask?.cancel()
unfinishedTask?.cancel()
dismissTask?.cancel() dismissTask?.cancel()
} }
@@ -693,7 +771,11 @@ final class TipStore: ObservableObject {
Self.debugLog("[FuelBoard StoreKit] Handling transaction update") 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 { guard case .verified(let transaction) = update else {
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") Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update")
}
return return
} }
// Only acknowledge our own consumables (future products get their own). // 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 // 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) let ids = Self.tiers.map(\.id)
productLoadStatus = "Loading…"
lastProductLoadError = nil
Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))") Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))")
do { do {
let fetched = try await Product.products(for: ids) 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: ","))") Self.debugLog("[FuelBoard StoreKit] Product.products returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
for product in fetched { for product in fetched {
Self.debugLog("[FuelBoard StoreKit] Product id=\(product.id) type=\(String(describing: product.type)) displayPrice=\(product.displayPrice) price=\(product.price)") 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) let matched = Self.tiers.filter { products[$0.id] != nil }.map(\.id)
Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))") Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))")
} catch { } 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)") 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 // Keep the cards visible so the user can retry while App Store
// Connect metadata propagates; displayPrice() shows "Unavailable" // Connect metadata propagates; displayPrice() shows "Unavailable"
+63
View File
@@ -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
}
}