← poker-tutor
import Foundation
import PokerSession
import Observation
#if os(Android)
import SkipFuse
#endif
#if canImport(StoreKit)
import StoreKit
/// Apple's half of the subscription.
///
/// Everything here is about talking to the App Store: what the subscription costs in the buyer's
/// own currency, buying it, restoring it on a second device, and noticing when it renews, lapses,
/// or is refunded. What it deliberately does NOT contain is any rule about who may play — that
/// lives in `FreeTrial` and `TrialTally`, is ours, and is testable without a network or an Apple
/// account. This class answers exactly one question for that code: `isSubscribed`.
///
/// The split matters because StoreKit cannot be exercised by the test suite. Anything decided in
/// here is decided somewhere no test can reach, so as little as possible is decided in here.
@Observable
@MainActor
public final class SubscriptionStore {
/// The one thing sold — this product's, from `AppVariant`. ⚠️ Permanent once a build carrying
/// it has been uploaded: a product ID cannot be reused or renamed, and the string must match
/// App Store Connect exactly.
public static var yearlyProductID: String { AppVariant.current.storeProductID }
/// What the store is doing, for the paywall to show.
public enum Status: Equatable, Sendable {
case idle
/// Fetching the price, or a purchase is in flight.
case working
/// Bought, but somebody else has to approve it — Ask to Buy on a child's account, or a
/// bank asking for confirmation. Access is NOT granted yet. The transaction listener picks
/// it up if and when it is approved, which may be days later.
case waitingForApproval
/// Something went wrong, in words a player can read.
case failed(String)
}
/// Whether Apple says there is a live subscription. This is the only output the rest of the app
/// reads. It starts false and stays false until Apple has actually been asked — see
/// `hasCheckedWithApple` for why that distinction matters.
public private(set) var isSubscribed = false
/// Whether Apple has answered at all yet.
///
/// Before the first answer, `isSubscribed` is false because nothing is known, not because
/// somebody is unsubscribed. A paywall shown in that window would be shown to a paying
/// customer, which is the worst thing this file can do. The app waits for this.
public private(set) var hasCheckedWithApple = false
/// The price, in the buyer's own currency and formatting — "$9.99", "£8.99". Nil until the
/// product has been fetched. Never build this string by hand: the number, the symbol and where
/// the symbol goes are all Apple's to decide, and they differ by storefront.
public private(set) var displayPrice: String?
public private(set) var status: Status = .idle
/// Called on the main actor after Apple's answer changes. The app uses it to take a paywall
/// down the moment a purchase lands, wherever it landed.
@ObservationIgnored public var didChange: (@MainActor () -> Void)?
@ObservationIgnored private let productID: String
@ObservationIgnored private var product: Product?
@ObservationIgnored private var listener: Task<Void, Never>?
public init(productID: String = SubscriptionStore.yearlyProductID) {
self.productID = productID
}
/// A store that never talks to Apple, with the answer already given.
///
/// For the suite and for previews. Everything the app decides about who may play depends on
/// `isSubscribed` and `hasCheckedWithApple`, and both are otherwise only reachable by making a
/// real purchase — which would leave the gate itself, the part that can lock a paying customer
/// out, as the one piece with no test behind it.
public static func stub(subscribed: Bool, price: String? = nil) -> SubscriptionStore {
let store = SubscriptionStore()
store.isSubscribed = subscribed
store.hasCheckedWithApple = true
store.displayPrice = price
return store
}
deinit { listener?.cancel() }
// MARK: - Starting up
/// Fetch the price, ask Apple what the buyer already owns, and start listening for changes.
///
/// Safe to call more than once. Call it at launch, before any paywall could be shown.
public func start() async {
if listener == nil { listener = makeListener() }
await refreshEntitlement()
await loadProduct()
}
/// Watches for transactions that arrive without the app asking: a renewal, a refund, a
/// purchase made on another device, or an Ask to Buy that a parent finally approved.
///
/// Without this the app would only notice a lapsed subscription on the next cold launch, and
/// would keep a refunded customer playing for free.
private func makeListener() -> Task<Void, Never> {
Task { [weak self] in
for await update in Transaction.updates {
guard let self else { return }
if case .verified(let transaction) = update {
await transaction.finish()
}
await self.refreshEntitlement()
}
}
}
/// Ask Apple for the price. Failure here is not fatal and is not the buyer's fault — it is
/// usually no network, occasionally a product that has not finished propagating after being
/// created in App Store Connect.
private func loadProduct() async {
do {
let products = try await Product.products(for: [productID])
guard let found = products.first else {
// The ID is wrong, or the product is not approved yet. Worth being loud about in
// development; the paywall just shows no price.
status = .failed(Copy.priceUnavailable)
return
}
product = found
displayPrice = found.displayPrice
if case .failed = status { status = .idle }
} catch {
status = .failed(Copy.priceUnavailable)
}
}
// MARK: - What Apple says the buyer owns
/// Ask Apple what is currently owned and set `isSubscribed` from the answer.
///
/// `currentEntitlements` already leaves out subscriptions that have expired, so what arrives
/// here is live. Two things are still checked by hand: the transaction has to be *verified* —
/// an unverified one is a forgery and must never open the table — and it must not have been
/// revoked, which is what a refund looks like.
public func refreshEntitlement() async {
var owned = false
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
guard transaction.productID == productID else { continue }
guard transaction.revocationDate == nil else { continue }
owned = true
}
isSubscribed = owned
hasCheckedWithApple = true
didChange?()
}
// MARK: - Buying
/// Buy the subscription.
///
/// Every branch of `purchase()` is handled on purpose. The two that are easy to get wrong are
/// `.pending`, which must NOT grant access, and an unverified success, which must not either.
public func purchase() async {
guard let product else {
// No price was ever fetched, so there is nothing to buy. Try once more — this is
// usually somebody who opened the paywall while offline and has since reconnected.
await loadProduct()
guard product != nil else {
status = .failed(Copy.priceUnavailable)
return
}
await purchase()
return
}
status = .working
do {
switch try await product.purchase() {
case .success(let verification):
guard case .verified(let transaction) = verification else {
// Signed by something that is not Apple. No access, and say nothing about how
// the check works.
status = .failed(Copy.purchaseFailed)
return
}
await transaction.finish()
await refreshEntitlement()
status = .idle
case .pending:
// Ask to Buy, or a bank wanting confirmation. Nothing is owed and nothing is
// granted; the listener will pick it up if it is approved.
status = .waitingForApproval
case .userCancelled:
// Not an error. Somebody changed their mind, and telling them off for it would be
// strange.
status = .idle
@unknown default:
status = .idle
}
} catch {
status = .failed(Copy.purchaseFailed)
}
}
/// Restore a subscription bought on another device, or before a reinstall.
///
/// Apple requires this button to exist on any screen that sells a subscription, and rejects
/// apps without it. `AppStore.sync()` prompts for a password, so it must only ever run because
/// somebody tapped something — never on its own at launch.
public func restore() async {
status = .working
do {
try await AppStore.sync()
await refreshEntitlement()
status = isSubscribed ? .idle : .failed(Copy.nothingToRestore)
} catch {
// A cancelled password prompt lands here too, which is why this says nothing was found
// rather than accusing anybody of an error.
await refreshEntitlement()
status = isSubscribed ? .idle : .failed(Copy.nothingToRestore)
}
}
/// Clear a message the player has read.
public func dismissMessage() {
if case .failed = status { status = .idle }
if case .waitingForApproval = status { status = .idle }
}
}
/// Everything this file can say to a player.
///
/// Kept together and in plain English on purpose: the app's copy rules ban jargon, and "the
/// transaction could not be verified" is jargon wearing a suit. None of these blame the player for
/// something that is almost always a network or an account.
enum Copy {
static let priceUnavailable =
"The price could not be loaded just now. Check your connection and try again."
static let purchaseFailed =
"That did not go through, and you have not been charged. Please try again."
static let nothingToRestore =
"No subscription was found on this Apple Account."
}
#else
/// The store on a device with no App Store — Android, for now.
///
/// The same shape as the Apple one above, so nothing that sits on top of it has to know which
/// device it is on. It never says anybody is subscribed, because it has nobody to ask: Google Play
/// billing is the piece still to be built, and until it exists this class answers "not subscribed"
/// and explains why when somebody tries to buy. `hasCheckedWithApple` keeps its name so the gate
/// above it reads the same on every device; here it means "checked, and there is nothing to check".
@Observable
@MainActor
public final class SubscriptionStore {
public static var yearlyProductID: String { AppVariant.current.storeProductID }
public enum Status: Equatable, Sendable {
case idle
case working
case waitingForApproval
case failed(String)
}
public private(set) var isSubscribed = false
public private(set) var hasCheckedWithApple = false
public private(set) var displayPrice: String?
public private(set) var status: Status = .idle
/// Called on the main actor after Apple's answer changes. The app uses it to take a paywall
/// down the moment a purchase lands, wherever it landed.
@ObservationIgnored public var didChange: (@MainActor () -> Void)?
public init(productID: String = SubscriptionStore.yearlyProductID) {}
public static func stub(subscribed: Bool, price: String? = nil) -> SubscriptionStore {
let store = SubscriptionStore()
store.isSubscribed = subscribed
store.hasCheckedWithApple = true
store.displayPrice = price
return store
}
public func start() async { hasCheckedWithApple = true }
public func refreshEntitlement() async { hasCheckedWithApple = true }
public func purchase() async { status = .failed(Copy.notOnThisDevice) }
public func restore() async { status = .failed(Copy.notOnThisDevice) }
public func dismissMessage() {
if case .failed = status { status = .idle }
if case .waitingForApproval = status { status = .idle }
}
}
enum Copy {
static let notOnThisDevice =
"Subscriptions are not available on this device yet."
}
#endif