Document and support StoreKit sandbox testing
This commit is contained in:
+97
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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<Void, Never>?
|
||||
@@ -666,6 +731,12 @@ final class TipStore: ObservableObject {
|
||||
/// the queue but are never finished or acknowledged.
|
||||
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
|
||||
/// 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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user