StoreKit 2: Einmalkauf mit eigener Testphase
August 2026
reell Focus ist ein Fokustimer fürs iPhone. Kein Konto, kein Server, keine Synchronisierung. Du kaufst ihn einmal, dann gehört er dir. Diese Entscheidung stand, bevor die erste Zeile Kaufcode existierte, und sie hat alles geprägt, was danach kam.
Die Begründung ist kurz. Ein Abo ist das Versprechen fortlaufender Arbeit: Server, die laufen müssen, Daten, die sicher bleiben müssen, ein Dienst, der aufhören würde zu funktionieren, wenn wir aufhören zu zahlen. reell Focus hat nichts davon. Alles bleibt auf dem Gerät. Monatlich für etwas zu kassieren, das uns monatlich nichts kostet, wäre ein Geschäftsmodell auf der Suche nach einer Rechtfertigung.
Was das im Code bedeutet
Ein Einmalkauf ist ein Non-Consumable. Gegenüber einem Abo fällt eine Menge Maschinerie weg: keine Verlängerungslogik, keine Kulanzzeiten, keine Wiederholungsversuche bei der Abrechnung, keine Statuswechsel, keine Server-Benachrichtigungen, die man empfangen und prüfen müsste. Es bleibt genau eine Frage, und sie ändert sich nie: Besitzt diese Apple-ID das Produkt?
func refreshPurchased() async {
var owned = false
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
transaction.productID == Self.productID,
transaction.revocationDate == nil {
owned = true
}
}
isPurchased = owned
didCheckEntitlements = true
}
Das ist die ganze Prüfung. currentEntitlements liest den lokalen Beleg und
läuft deshalb auch offline durch, was für eine App zählt, die verspricht, ohne
Netz zu funktionieren.
Eines wird allerdings schwieriger. Bei einem Abo trägt das Konto den Kauf, und
die Leute sind es gewohnt, sich anzumelden. Bei einem Einmalkauf ohne jedes
Konto wird die Wiederherstellung zur Pflicht, nicht zur Höflichkeit. Neues
Telefon, Neuinstallation, Gerät in der Familie: der einzige Weg zurück führt
über AppStore.sync() und eine erneute Prüfung.
func restore() async {
try? await AppStore.sync()
await refreshPurchased()
}
Die Testphase ohne Abo
Das ist der Teil, den einem niemand sagt: Apples Einführungsangebote gibt es nur für Abos. Ein eingebautes “7 Tage gratis, danach einmal zahlen” existiert nicht. Wer eine Testphase auf einem Non-Consumable will, baut sie selbst.
Die naive Fassung legt das Datum des ersten Starts in UserDefaults ab und
rechnet. Das funktioniert, und es setzt sich in dem Moment zurück, in dem jemand
die App löscht und neu installiert. Also liegt das Datum an zwei Stellen, und
das frühere gewinnt:
private func resolveInstallDate() {
let keychainDate = Keychain.shared.date(forKey: installKey)
let defaultsDate = UserDefaults.standard.object(forKey: installKey) as? Date
// Frühestes bekanntes Datum gewinnt; sonst jetzt.
let date = [keychainDate, defaultsDate].compactMap { $0 }.min() ?? Date()
if keychainDate == nil { Keychain.shared.set(date, forKey: installKey) }
if defaultsDate != date { UserDefaults.standard.set(date, forKey: installKey) }
}
Der Keychain-Eintrag überlebt das Löschen der App, eine Neuinstallation schenkt
also keine weiteren sieben Tage. Abgelegt ist er mit
kSecAttrAccessibleAfterFirstUnlock, also lesbar im Hintergrund, sobald das
Gerät nach einem Neustart einmal entsperrt wurde.
Jetzt der ehrliche Teil, die Kompromisse, die wir bewusst eingegangen sind:
Ein neues Gerät schenkt eine neue Testphase. Der Keychain-Eintrag ist gerätelokal. Wer unbedingt eine zweite Testphase will, bekommt sie mit einem Telefonwechsel. Das ist in Ordnung: der Aufwand ist grösser als der Preis der App.
Die Uhr zurückstellen verlängert die Testphase. Wir rechnen gegen die Systemuhr, also wirkt es, sie zurückzudrehen. Die Alternative wäre, einen Server nach der Zeit zu fragen, und ein Server ist genau das, was diese App nicht hat. Wer die Systemzeit ändert, um den Preis eines Kaffees zu sparen, ist keine Kundschaft, die wir verlieren.
Nichts wird serverseitig geprüft, weil es keinen Server gibt. Die Testphase ist eine Höflichkeit, kein Schloss. Der Kauf selbst wird von StoreKit geprüft.
Was wir nicht akzeptiert haben, war, ehrliche Nutzer zu bestrafen. isUnlocked
ist die Vereinigung beider Zustände, nie ihre Schnittmenge:
var isUnlocked: Bool { isPurchased || isInTrial }
SwiftUI und StoreKit 2, ohne fremde Bibliothek
In dieser App steckt kein Kauf-SDK. StoreKit 2 mit async/await genügt:
Product.products(for:) zum Laden, product.purchase() zum Kaufen,
Transaction.currentEntitlements zum Prüfen, Transaction.updates für Käufe,
die von anderswo eintreffen, etwa ein unterbrochener Kauf, der später
durchgeht, oder das Gerät eines Familienmitglieds.
private func observeTransactionUpdates() {
Task(priority: .background) { [weak self] in
for await result in Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
await self?.refreshPurchased()
}
}
}
}
Zwei Kleinigkeiten haben mehr Zeit gekostet als der Kaufvorgang selbst:
Das Aufblitzen der Paywall. Beim Start ist isPurchased falsch, bis die
erste Prüfung durch ist. Ein zahlender Kunde sähe die Paywall für einen
Sekundenbruchteil aufblitzen. Die Lösung ist ein zweites Merkmal,
didCheckEntitlements, und eine Oberfläche, die darauf wartet. Bleibt es aus,
erscheint gar keine Paywall: im Zweifel offen zu scheitern ist hier die richtige
Richtung.
Der Preis, wenn StoreKit nicht antwortet. Offline oder wenn das Produkt
nicht lädt, ist product.displayPrice leer. “Freischalten” ohne Preis ist
schlechter als ein ungefährer Preis, deshalb gibt es einen währungsbewussten
Ersatz, der den Grundbetrag in der Gerätewährung formatiert. Sobald StoreKit
antwortet, übernimmt der echte, lokalisierte Preis.
Testen, ohne in der Sandbox zu versinken
Getestet haben wir zweigleisig: im Simulator gegen eine lokale StoreKit-Konfiguration, auf dem eigenen iPhone gegen die echte Sandbox. Diese Aufteilung ist der Grund, warum es hier keine Sandbox-Leidensgeschichte zu erzählen gibt.
Die Konfigurationsdatei ist eine .storekit-Datei im Projekt, in der das
Produkt einmal beschrieben ist:
{
"products": [
{ "productID": "imflow.pro", "type": "NonConsumable",
"displayPrice": "7.99", "referenceName": "reell Focus Pro" }
],
"settings": { "_locale": "de_CH", "_failTransactionsEnabled": false }
}
Damit läuft der ganze Kaufvorgang lokal: kein Sandbox-Konto, keine Netzverzögerung, keine Transaktion, die zehn Minuten später eintrifft. Xcode kann darin ausserdem Fehler erzwingen, was die interessanteren Zustände überhaupt erst testbar macht: Kauf schlägt fehl, Kauf wird abgebrochen, Produkt lädt nicht. Genau diese Fälle sieht man in der Sandbox selten, weil dort meistens alles klappt.
Was der Simulator nicht kann, ist der echte Ablauf: die Passworteingabe, das
Systemdialogfeld, der Beleg von Apple, AppStore.sync() mit einer echten
Apple-ID. Dafür braucht es ein Gerät und ein Sandbox-Konto, angelegt in App
Store Connect und im iPhone unter den Entwicklereinstellungen hinterlegt, nicht
in den normalen App-Store-Einstellungen. Diese Verwechslung ist der häufigste
Grund, warum ein Sandbox-Kauf plötzlich echtes Geld kosten will.
Der zweite Teil des Tests hat mit StoreKit nichts zu tun: die Testphase. Sieben Tage warten ist keine Testmethode, und die Systemuhr vorzustellen bringt das Gerät durcheinander. Deshalb gibt es zwei Schalter, die nur im Debug-Build existieren:
#if DEBUG
var debugTrialDaysOverride: Int? // 0 = abgelaufen -> Paywall
func resetTrial() {
Keychain.shared.removeItem(forKey: installKey)
UserDefaults.standard.removeObject(forKey: installKey)
debugTrialDaysOverride = nil
resolveInstallDate()
}
#endif
Der Rücksetzer muss beide Ablagen leeren, den Keychain-Eintrag und die
UserDefaults. Genau daran merkt man beim Testen, ob die
Doppelspeicherung funktioniert: löscht man nur eine, bleibt die Testphase
abgelaufen, und das ist die richtige Antwort.
Vor der Einreichung
Bei einem Einmalkauf mit selbstgebauter Testphase gibt es drei Stellen, an denen eine Prüfung typischerweise hängen bleibt. Alle drei sind billig zu erfüllen, wenn man sie vorher kennt.
Die Wiederherstellung muss sichtbar sein. Nicht in den Einstellungen vergraben, sondern dort, wo die Kaufaufforderung steht. In dieser App ist es ein Knopf direkt unter dem Preis, beschriftet mit “Restore purchases”. Ohne ihn ist die Ablehnung sicher, denn ohne Konto gibt es sonst keinen Weg zurück zum gekauften Zustand.
Preis und Umfang gehören auf die Paywall, nicht ins Kleingedruckte. Was kostet es, was bekommt man, und dass es einmalig ist. Der Preis kommt dabei aus StoreKit, nicht aus einer Textkonstante: sonst steht in einem Land eine Zahl, die nicht stimmt, und das ist ein eigener Ablehnungsgrund.
Die Testphase darf nicht nach Abo klingen. Sieben Tage gratis, danach ein einmaliger Kauf, kein Abonnement, keine automatische Verlängerung, nichts, was sich kündigen liesse. Wer eine selbstgebaute Testphase mit den Worten eines Abos beschreibt, weckt eine Erwartung, die die App nicht erfüllt.

So sieht das aus: Preis auf dem Knopf, “Einmaliger Kauf, kein Abo” als erste Zeile, und die Wiederherstellung direkt darunter statt in den Einstellungen. Der Dollarbetrag stammt aus der lokalen Testkonfiguration, im Store steht der Preis des jeweiligen Landes.
Dazu kommt die Hausaufgabe im App Store Connect, die nichts mit Code zu tun hat: das Produkt anlegen und den Preis setzen, die Angaben zum Datenschutz ausfüllen, und, weil diese App nichts sammelt, dort auch wirklich “keine Daten erfasst” auswählen. Eine Datenschutz-URL, die auf eine leere Seite zeigt, kostet eine Runde.
Was ich anders machen würde
Die Testphase als echten Zeitraum bauen, nicht als Tageszähler. Alles hängt
an daysSinceInstall, einer Kalenderrechnung gegen “jetzt”. Ein gespeichertes
Enddatum wäre einfacher zu durchdenken, einfacher anzuzeigen und einfacher zu
testen gewesen.
Früher über das zweite Gerät entscheiden. Wir haben den Keychain-Weg ausgeliefert und erst danach zu Ende gedacht, was er bei einem Telefonwechsel bedeutet. Das ist die falsche Reihenfolge: die Frage ist eine Produktentscheidung, keine Umsetzungsfrage, und sie hätte fünf Minuten am Whiteboard verdient statt einer Korrektur im Code.
Die Debug-Schalter von Tag eins an einbauen. debugTrialDaysOverride und
resetTrial() gibt es jetzt, und jede Stunde davor ging fürs Löschen, Warten
oder Uhrstellen drauf. Es sind drei Zeilen je Schalter.
reell Focus liegt im App Store, ein Kauf, sieben Tage gratis davor.