Which product types does StoreKit 2 sell, why can Product.products(for:) come back empty, and why must prices come from the store?

28.1.1 · L2 · Interview

Predict first

Which line is wrong for a German store viewed on a US-region phone, and what does the store currency look like in other locales? (~ marks a no-break space)

import Foundation

func show(_ amount: Decimal, _ code: String, _ locale: String) -> String {
    amount.formatted(.currency(code: code).locale(Locale(identifier: locale)))
        .replacingOccurrences(of: "\u{00A0}", with: "~")
        .replacingOccurrences(of: "\u{202F}", with: "~")
}
print("raw Decimal:", Decimal(string: "8.99")!)
print("US store, US phone:", show(9.99, "USD", "en_US"))
print("DE store, DE phone:", show(8.99, "EUR", "de_DE"))
print("DE store, US phone:", show(8.99, "EUR", "en_US"))
print("JP store:", show(1500, "JPY", "ja_JP"))
print("GB store:", show(7.99, "GBP", "en_GB"))
print("IN store:", show(899, "INR", "en_IN"))
  • The Decimal prints as 8.99, the German store shows 8,99~€ on a German phone but €8.99 on a US phone, yen has no decimals, rupees keep two
  • All locales print 8.99 EUR the same way
  • The Decimal prints as 8,99

Mental model. The store is the shop till and the phone only chooses the receipt font: the till decides the currency, the phone decides how the number is written.

A Product has a type (.consumable, .nonConsumable, .autoRenewable, .nonRenewable), and the type decides who remembers the purchase. A consumable is gone from the store's memory once you finish it, so you (or your server) keep the balance; a non-consumable and an auto-renewable subscription stay in Transaction.currentEntitlements; a non-renewing subscription never renews by itself and does not expire in the entitlement list, so you compute its end date. Product.products(for:) does not throw for an unknown id, it just leaves it out, so an empty array is the usual symptom of a wrong id, a product not yet in "Ready to Submit" or cleared for sale, an unsigned Paid Apps agreement, a missing storefront availability, or (in the simulator or on a device) no StoreKit configuration or sandbox account selected. Always compare the ids you asked for with the ids you got and show a graceful "unavailable" state rather than a blank paywall.

Show product.displayPrice, or format product.price with product.priceFormatStyle: the amount is in the storefront's currency (the user's App Store country), but the digits, separators and symbol position follow the device locale. The check shows why a hand-built "$\(price)" fails: the same 8.99 EUR prints as 8,99 € on a German phone and €8.99 on a US phone, yen has no decimals and rupees keep two. Never convert currencies yourself, never hard-code a price, and never assume a currency from Locale.current, because a traveller can hold a US storefront while the phone is set to German; if you need the storefront itself use Storefront.current (country code and id). A related trap: price is a Decimal, so compare and divide it as a decimal and not a Double.

The amount and currency come from the store; separators and symbol placement come from the formatting locale.

Common traps

  • Formatting the price yourself. A hard-coded symbol or a currency taken from the device locale is wrong for travellers and expats; use displayPrice or priceFormatStyle.
  • Treating an empty product list as a network error. The call succeeded; the ids were not available, so diff requested against returned ids and log the missing ones.

They might follow up with

  • Where do consumable balances live? In your own store (ideally server side), keyed by the transaction id so a redelivery does not credit twice.
  • Can I show a price before the user is online? Cache the last good displayPrice if you must, but label it as an estimate and refresh before the purchase sheet.

Related questions

  • 28.1.2 What can product.purchase() return or throw, and how should each outcome behave in the UI?
  • 17.4.2 How does StoreKit 2 work: products, purchases, transaction verification, entitlements, refunds, and what must…

What can product.purchase() return or throw, and how should each… →