Document and support StoreKit sandbox testing
This commit is contained in:
+97
-1
@@ -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>
|
||||||
@@ -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 {
|
||||||
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
|
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"
|
||||||
|
|||||||
@@ -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