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 {} }