import Foundation import StoreKit /// Errors raised locally by `StoreKitPurchaseService` itself (as opposed to /// errors StoreKit hands back verbatim, e.g. from `VerificationResult`'s /// `.unverified` case, which are re-thrown as-is). Kept in the same /// `Error, LocalizedError` + German `errorDescription` shape as `APIError` /// in `Networking/APIError.swift` so both surface consistently in the UI. enum StoreKitPurchaseError: Error, LocalizedError { /// `Product.products(for:)` returned nothing for the requested /// identifier — misconfigured product ID, or not yet propagated in App /// Store Connect / the local `Configuration.storekit` file. case productNotFound var errorDescription: String? { switch self { case .productNotFound: return "Dieses Produkt ist aktuell nicht verfügbar. Bitte versuche es später erneut." } } } /// Real StoreKit 2 implementation of `PurchaseService` (see /// `Features/Settings/PurchaseService.swift` for the protocol and the /// App Store Review Guideline 3.1.1 background on why this exists at all). /// /// NOTE ON SERVER-SIDE ENTITLEMENT SYNC: this class only talks to StoreKit / /// Apple's servers. It does **not** update `users.plan` on the backend — see /// the long comment on `updatesTask` below, this is the important caveat to /// understand before assuming a successful purchase here means the account /// is actually Pro. final class StoreKitPurchaseService: PurchaseService { /// Background listener for transactions that complete outside the /// synchronous `purchase()` call (renewals, refunds, Ask-to-Buy /// approvals, purchases made on another device). Started once, for the /// lifetime of this service instance. private let updatesTask: Task init() { updatesTask = Task.detached { for await result in Transaction.updates { await StoreKitPurchaseService.handleUpdatedTransaction(result) } } } deinit { updatesTask.cancel() } // MARK: - PurchaseService func loadProducts() async throws -> [PurchaseProduct] { let products = try await Product.products(for: PurchaseProductID.all) let mapped: [PurchaseProduct] = products.compactMap { product in let planID: String switch product.id { case PurchaseProductID.weekly: planID = "weekly" case PurchaseProductID.annual: planID = "annual" case PurchaseProductID.lifetime: planID = "lifetime" default: // Unknown identifier (shouldn't happen — we only asked for // PurchaseProductID.all — but never silently invent a plan // for something we don't recognise). return nil } let periodDescription: String? if product.type == .nonConsumable { periodDescription = nil } else if let subscription = product.subscription { periodDescription = StoreKitPurchaseService.periodDescription(for: subscription.subscriptionPeriod) } else { periodDescription = nil } return PurchaseProduct( id: product.id, planID: planID, displayName: product.displayName, displayPrice: product.displayPrice, periodDescription: periodDescription ) } // Render in a fixed, sensible order regardless of what the store // returns — PaywallView renders in the order we give back. let order = [PurchaseProductID.weekly, PurchaseProductID.annual, PurchaseProductID.lifetime] return mapped.sorted { lhs, rhs in let lhsIndex = order.firstIndex(of: lhs.id) ?? order.count let rhsIndex = order.firstIndex(of: rhs.id) ?? order.count return lhsIndex < rhsIndex } } func purchase(productID: String, appAccountToken: UUID?) async throws -> Bool { guard let product = try await Product.products(for: [productID]).first else { throw StoreKitPurchaseError.productNotFound } // Ties the resulting transaction back to this app's own user id so // the backend (POST /api/webhooks/apple) knows whose account to // grant Pro to — see `User.appleAccountToken`'s doc comment for // exactly how that round-trip works. Purchasing without it (nil) // still completes the purchase on Apple's side, it just can't be // reconciled server-side without a manual look-up later. var options: Set = [] if let appAccountToken { options.insert(.appAccountToken(appAccountToken)) } let result = try await product.purchase(options: options) switch result { case .success(let verificationResult): switch verificationResult { case .verified(let transaction): await transaction.finish() return true case .unverified(_, let error): // StoreKit couldn't verify the transaction's signature (e.g. // jailbroken device, tampered receipt) — surface the // underlying error rather than treating it as a success. throw error } case .userCancelled: // Not an error — the user backed out of the purchase sheet. return false case .pending: // Ask-to-Buy / family approval pending. Nothing more to do // synchronously; the eventual approval (or denial) arrives later // via Transaction.updates, handled by `updatesTask` below. return false @unknown default: return false } } func restorePurchases() async throws { try await AppStore.sync() } // MARK: - Transaction updates listener /// Verifies and finishes a transaction observed via `Transaction.updates`. /// /// IMPORTANT — this is genuinely incomplete, documented honestly rather /// than hidden: finishing a transaction here only tells StoreKit "this /// purchase has been dealt with, stop re-presenting it" — it does /// **not** unlock Pro server-side. The backend has no /// `/api/webhooks/apple` endpoint yet to reconcile a StoreKit purchase /// into `users.plan` (see `app/ios/README.md`'s "What's deliberately NOT /// done yet" section) — that reconciliation is separate, not-yet-built /// backend work driven by Apple's server-to-server notifications. Do /// NOT be tempted to set some local "is Pro" flag here as a substitute: /// the account's real Pro status lives on the server (`users.plan`), /// and nothing in this file can change that yet. private static func handleUpdatedTransaction(_ result: VerificationResult) async { switch result { case .verified(let transaction): await transaction.finish() case .unverified: // Can't verify it; leave it alone rather than finishing a // transaction we couldn't authenticate. It will be re-delivered. break } } // MARK: - Formatting helpers /// Turns a `Product.SubscriptionPeriod` into a short German phrase, e.g. /// `(unit: .week, value: 1)` -> "pro Woche", `(unit: .year, value: 1)` /// -> "pro Jahr", `(unit: .month, value: 3)` -> "alle 3 Monate". This app /// currently only ever configures value == 1 periods (see /// `Configuration.storekit`), but the multi-value phrasing is filled in /// so this doesn't silently misrender if that ever changes. private static func periodDescription(for period: Product.SubscriptionPeriod) -> String { let value = period.value if value == 1 { switch period.unit { case .day: return "pro Tag" case .week: return "pro Woche" case .month: return "pro Monat" case .year: return "pro Jahr" @unknown default: return "pro Abrechnungszeitraum" } } switch period.unit { case .day: return "alle \(value) Tage" case .week: return "alle \(value) Wochen" case .month: return "alle \(value) Monate" case .year: return "alle \(value) Jahre" @unknown default: return "alle \(value) Abrechnungszeiträume" } } }