feat: add iOS support and harden receipt scanning
This commit is contained in:
97
app/ios/ScanReceipts/Features/Settings/PurchaseService.swift
Normal file
97
app/ios/ScanReceipts/Features/Settings/PurchaseService.swift
Normal 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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user