98 lines
5.0 KiB
Swift
98 lines
5.0 KiB
Swift
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 {}
|
|
}
|