How do you fetch products and build the catalogue screen: ids, ordering, missing products, period labels and comparing plans fairly?
Predict first
What do sorting, missing ids and per-month prices give for a month and a year plan?
import Foundation
struct Item { let id: String; let price: Decimal; let period: (value: Int, unit: String) }
func catalog(requested: [String], found: [Item]) -> (sorted: [Item], missing: [String]) {
(found.sorted { $0.price < $1.price }, requested.filter { id in !found.contains { $0.id == id } })
}
func label(_ p: (value: Int, unit: String)) -> String { p.value == 1 ? "per \(p.unit)" : "every \(p.value) \(p.unit)s" }
func monthly(_ i: Item) -> Decimal {
switch i.period.unit {
case "year": return i.price / Decimal(12 * i.period.value)
case "week": return i.price * Decimal(52) / 12 / Decimal(i.period.value)
default: return i.price / Decimal(i.period.value)
}
}
let found = [Item(id: "pro.year", price: 59.99, period: (1, "year")), Item(id: "pro.month", price: 6.99, period: (1, "month"))]
let r = catalog(requested: ["pro.month", "pro.year", "pro.week", "pro.lifetime"], found: found)
print("sorted by price:", r.sorted.map(\.id))
print("missing from the store:", r.missing)
for i in r.sorted { print(i.id, label(i.period), "=", (monthly(i) as NSDecimalNumber).doubleValue.formatted(.number.precision(.fractionLength(2))), "per month") }
let yearlyPerMonth = (monthly(found[0]) as NSDecimalNumber).doubleValue, monthlyPrice = (monthly(found[1]) as NSDecimalNumber).doubleValue
print("yearly saves about", Int(((1 - yearlyPerMonth / monthlyPrice) * 100).rounded()), "percent")
- sorted by price: ["pro.month", "pro.year"] / missing from the store: ["pro.week", "pro.lifetime"] / pro.month 6.99 per month / pro.year 5.00 per month / yearly saves about 28 percent
- missing from the store: [] / yearly saves 0 percent
- sorted by price: ["pro.year", "pro.month"]
Mental model. A menu board: you print only dishes the kitchen can cook today, in the order you choose, with the real price beside each one, and you notice at once if a dish you listed is not on the kitchen’s list.
Start from a list of product ids you own (hard-coded, or fetched from your server so you can change the lineup without a release) and call Product.products(for:). The result is unordered and only contains products the store knows, so do three things before showing it. Sort deliberately (by price, or by your own display order, not the order returned). Compare ids you asked for with ids you got: the check reports ["pro.week", "pro.lifetime"] as missing, which usually means a typo, a product not yet “Ready to Submit”, an unsigned agreement or a storefront without that product; log it and show a retry or a fallback. Handle the three states (loading, failed, loaded) instead of an empty list.
Show what the store tells you: displayName, description, displayPrice (already localised and currency-correct, Q997), and for subscriptions subscription?.subscriptionPeriod (a value and a unit, for example 1 year) and await subscription?.isEligibleForIntroOffer to decide whether to promote a free trial. To make plans comparable, show the price per month computed from the period (59.99 per year is about 5.00 a month, so a yearly plan saves about 28% against 6.99 monthly, the numbers in the check) but still display the real billed amount and the renewal terms next to it, because review rules and consumer law require the price you will actually charge to be clear. Never compute prices yourself from a table: use the store’s price only for ratios, and its displayPrice for text. Cache the catalogue in memory for the session and refresh on foreground, and fetch early (at launch or on the screen before the paywall) so the paywall opens instantly.
The catalogue is sorted by price, ids the store did not return are reported, and the yearly plan works out to 5.00 a month.
Common traps
- Showing the order the store returned. It is not stable or meaningful; sort it yourself.
- Advertising “save 28%” without the billed price and renewal terms. The clear charge must appear with any comparison.
They might follow up with
- Where do I get a “most popular” badge from? From your own configuration or experiment, not from StoreKit.
- How do I refresh when the user changes country? Reload on
Storefrontchanges and on foreground; prices and availability follow the storefront.
Related questions
- 28.7.1 How do you structure a StoreKit 2 integration: which object owns products, entitlements, the updates listener…
- 28.7.3 What do the SwiftUI StoreKit views (ProductView, StoreView, SubscriptionStoreView) give you for free, and…
- 28.1.1 Which product types does StoreKit 2 sell, why can Product.products(for:) come back empty, and why must prices…
- 28.5.1 How do you design a paywall that is clear, localised and legal, and how do you run experiments on it?
← How do you structure a StoreKit 2 integration: which object owns… What do the SwiftUI StoreKit views (ProductView, StoreView… →