Files
scan-receipts/app/ios/ScanReceipts/Features/Settings/StoreKitPurchaseService.swift

217 lines
8.5 KiB
Swift

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<Void, Never>
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<Product.PurchaseOption> = []
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<Transaction>) 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"
}
}
}