feat: add iOS support and harden receipt scanning
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user