feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

View File

@@ -0,0 +1,97 @@
import Foundation
/// Abstraction over "load Pro plans and buy one" so `PaywallView` doesn't
/// need to know whether it's talking to real StoreKit or a stub/preview
/// implementation.
///
/// Apple requires (App Store Review Guideline 3.1.1) that any digital
/// feature unlocked *inside* the app (here: Pro status more scans,
/// folders, export) be sold via Apple's own In-App Purchase, not an
/// external payment link, when the purchase is initiated from inside the
/// app. This protocol is deliberately StoreKit-shaped for that reason.
///
/// Product identifiers (reverse-DNS, matching the app's bundle ID
/// `app.scan-receipts.ios` from project.yml) these must be created with
/// EXACTLY these identifiers in App Store Connect (real submission) and/or
/// `Configuration.storekit` (local Simulator testing, see that file) before
/// `StoreKitPurchaseService.loadProducts()` will return anything:
/// - `app.scan-receipts.ios.pro.weekly` auto-renewable subscription
/// - `app.scan-receipts.ios.pro.annual` auto-renewable subscription
/// - `app.scan-receipts.ios.pro.lifetime` non-consumable
enum PurchaseProductID {
static let weekly = "app.scan-receipts.ios.pro.weekly"
static let annual = "app.scan-receipts.ios.pro.annual"
static let lifetime = "app.scan-receipts.ios.pro.lifetime"
static let all = [weekly, annual, lifetime]
}
/// One purchasable plan, already localized/priced by StoreKit (or filled in
/// with placeholder copy by `StubPurchaseService`). `planID` is the
/// backend's own plan vocabulary (`"weekly" | "annual" | "lifetime"` see
/// `users.plan` in `src/lib/schema/db.ts`), kept separate from the StoreKit
/// product identifier since they're different namespaces that happen to be
/// related, not the same thing.
struct PurchaseProduct: Identifiable, Equatable {
let id: String // StoreKit product identifier (PurchaseProductID.*)
let planID: String
let displayName: String
let displayPrice: String
/// e.g. "per week" nil for the one-time lifetime product.
let periodDescription: String?
}
protocol PurchaseService {
/// Fetches the current products with their StoreKit-localized prices.
/// Call before showing `PaywallView`'s plan list never hardcode prices,
/// Apple requires displaying the actual localized App Store price.
func loadProducts() async throws -> [PurchaseProduct]
/// Initiates a purchase for the given StoreKit product identifier.
/// Returns `true` only once the transaction is verified and finished;
/// `false` means the user cancelled the sheet (not an error). Throws for
/// an actual failure (network, StoreKit error, failed verification).
///
/// `appAccountToken` should be the signed-in user's own
/// `User.appleAccountToken` StoreKit attaches it to the resulting
/// transaction, and `POST /api/webhooks/apple` on the backend uses it to
/// know which account to grant Pro to (see that property's doc comment).
/// Pass `nil` only if genuinely no user is signed in (shouldn't happen
/// the paywall is only ever shown to a signed-in account but a
/// purchase with no attributable owner is still better than none at
/// all, since `restorePurchases()`/a later manual reconciliation can
/// recover it).
func purchase(productID: String, appAccountToken: UUID?) async throws -> Bool
/// Re-syncs already-owned entitlements wired to a "Käufe
/// wiederherstellen" button (required by App Store guidelines for
/// non-consumable/subscription products) and worth calling once at
/// launch too, since Apple doesn't otherwise notify a fresh install
/// about prior purchases made on the same Apple ID.
func restorePurchases() async throws
}
/// Stub/preview implementation returns illustrative placeholder products
/// (so `PaywallView` has something to render in SwiftUI previews and before
/// `StoreKitPurchaseService` is wired in) and always throws on an actual
/// purchase attempt, since there is nothing real behind it.
final class StubPurchaseService: PurchaseService {
func loadProducts() async throws -> [PurchaseProduct] {
[
PurchaseProduct(id: PurchaseProductID.weekly, planID: "weekly", displayName: "Wochen-Pass", displayPrice: "4,99 €", periodDescription: "pro Woche"),
PurchaseProduct(id: PurchaseProductID.annual, planID: "annual", displayName: "Jahres-Pass", displayPrice: "39,99 €", periodDescription: "pro Jahr"),
PurchaseProduct(id: PurchaseProductID.lifetime, planID: "lifetime", displayName: "Lifetime", displayPrice: "59,99 €", periodDescription: nil),
]
}
func purchase(productID: String, appAccountToken: UUID?) async throws -> Bool {
throw NSError(
domain: "ScanReceipts.Purchase",
code: -1,
userInfo: [
NSLocalizedDescriptionKey: "In-App-Käufe sind in dieser Vorschau-Version noch nicht verfügbar."
]
)
}
func restorePurchases() async throws {}
}