Skip to content

Der Meldedienst: was Apple und Google wirklich verlangen

Stand 20.09.2026. Ergänzt docs/mobile-store-kaufflaechen.md (Zweig feat/mobile-store-compliance-concept) um die Melde-Mechanik, die beim Bauen der Erstattungen geklärt wurde.

Warum dieses Dokument existiert: Ich habe den Meldedienst zuerst nach Googles Mechanik gebaut — eine Transaktion, eine Kennung, ein Sofort-Aufruf — und stillschweigend angenommen, Apple funktioniere genauso. Das ist falsch. Die beiden Plattformen verlangen strukturell Verschiedenes, und der Unterschied ist groß genug, dass ein gemeinsamer Versandweg nicht entsteht.


1. Die beiden Mechaniken nebeneinander

Google Play Apple
Takt je Transaktion, binnen 24 h monatlich, binnen 15 Tagen nach Monatsende
Bezugsobjekt die Transaktion der Token
Kennung externalTransactionId (von uns) lineItemId (von uns, UUID)
Erstattung Operation auf der Transaktion: POST …/externalTransactions/{id}:refund eigene Zeile im Bericht, eventType: REFUND, referenceLineItemId → Ursprung
Erstattungs-Beträge nur refundPreTaxAmount (Netto) — Google rechnet die Steuer selbst vier Felder: amountTaxExclusive, amountTaxInclusive, taxAmount, netAmountTaxExclusive
Einheit Mikro-Einheiten Milli-Einheiten
Erstattungs-Frist in der Doku nicht genannt dieselbe Monatsfrist
Mindest-OS iOS 26.2 fürs Entitlement, 26.4 für die Melde-API

Unsere Datenbank führt Cent. Beim Versand sind das also drei Einheiten — nicht zwei. Das gehört in den Versandweg, nicht ins Modell.

2. Die vier Apple-Eigenheiten, die man nicht errät

(a) Der Bericht ist token-zentriert, nicht transaktionszentriert. Ein Bericht je externalPurchaseToken, und es müssen alle Tokens gemeldet werden — auch die, die zu gar keinem Kauf geführt haben (status: NO_LINE_ITEM). In der EU fordert die App beim Start zwei Token-Typen an (ACQUISITION und SERVICES) und bindet sie ans Konto; es ist kein Token je Kauf. Unser heutiges Modell hält genau einen Token je Abo — das ist die Google-Form und für Apple zu wenig.

(b) netAmountTaxExclusive ist nicht der Erstattungsbetrag. Es ist der Netto-Rest nach der Erstattung. Wer eine Erstattungszeile aus einem einzelnen Stripe-Ereignis baut, kann ihn nicht kennen: man braucht den Ursprungsbetrag und alle bisherigen Erstattungen derselben Zeile. Deshalb der Index auf refundedExternalTransactionId.

(c0) DAS ENTITLEMENT SELBST HÄNGT AN DER OS-VERSION — und das ist der härteste Satz auf der ganzen Seite. Wörtlich:

„The Entitlement Profile is compatible with and may only be used in apps on EU storefronts on devices running a minimum of iOS 26.2, iPadOS 26.2, macOS 26.6, tvOS 26.6, visionOS 26.6, and watchOS 26.6." „To support devices running earlier OS versions, please contact us for additional details."

Unser Mindestsystem ist iOS 16.4 (ios/Podfile:25). Auf allem zwischen 16.4 und 26.1 ist die Alternativzahlung damit gar nicht nutzbar — nicht „eingeschränkt", sondern nicht erlaubt. Für diese Geräte gibt es drei Wege: Mindestsystem anheben, Apple über das Kontaktformular fragen, oder diese Nutzer kaufen im Web (was Guideline 3.1.3(b) Multiplatform ausdrücklich zulässt, der Gründer aber als Regelweg abgelehnt hat).

Offene Gründerentscheidung, Stand 20.09.2026.

(c) Der Meldeweg hängt zusätzlich an der OS-Version. Wörtlich:

„For apps running iOS 26.4 … and later, use the External Purchase Server API to report transactions to Apple." „For apps running earlier OS versions … you'll need to complete your transaction reports following this example."

Die Vorlage ist ein Download (transaction-reports-eea.zip). Zwischen 26.2 und 26.4 gilt also: Kauf erlaubt, Meldung von Hand. Ein reiner API-Meldedienst deckt den Start also nicht ab — und die OS-Version muss je Kauf mitgeführt werden, sonst ist später nicht entscheidbar, welcher Weg gilt. Die Spalte gibt es seit dem 20.09.: die App schickt Platform.Version mit dem Melde-Block, das Backend legt sie am Abo ab und vererbt sie an jede Meldezeile — auch an Verlängerungen, Erstattungen und Rückbuchungen, bei denen gar kein Gerät beteiligt ist.

(d) Korrekturen sind Vollzustand. Eine bereits gesendete Zeile wird über dieselbe lineItemId mit restatement: true neu gesendet, eine Rücknahme zusätzlich mit erroneouslySubmitted: true. Das heißt: jede gesendete Zeile muss vollständig archiviert werden, sonst ist sie nicht korrigierbar. (Vgl. die Projektregeln „Ruleset: PUT, nicht PATCH" und „Teil-PUT löscht Nachbarfelder".)

3. Warum eine nicht gemeldete Erstattung Geld kostet

DPLA Attachment 14 § 5.2(C): Apple erstattet die auf den Ursprungsumsatz gezahlte Provision nur für gemeldete Erstattungen — und zwar als Gutschrift auf eine Folgerechnung, nicht als Auszahlung. Eine nicht gemeldete Erstattung ist also kein Formfehler, sondern barer Verlust. Die Buchhaltung darf außerdem nicht auf eine Rückzahlung warten.

Rückbuchungen zählen dabei als Erstattung („Report chargebacks as a refund"). Sie kommen über charge.dispute.* und sind seit dem 20.09. angebunden — mit einem eigenen Zustands-Wortschatz: meldepflichtig ist nur lost. won und prevented heissen, dass der Umsatz bleibt, und warning_* sind Vorwarnungen des Kartennetzes, noch gar keine Rückbuchung. Die Herkunft steht in refundOrigin, weil refundStatus sonst nicht auslegbar wäre: succeeded und lost stammen aus zwei verschiedenen Wortschätzen.

4. Was wir vertraglich fahren

„Alternative Payment Processing" heißt laut Attachment 14 Zahlung innerhalb der App — und schließt den Webview ausdrücklich ein. „Out-of-App Offers" ist das Leiten nach draußen. Unser nativer PaymentSheet-Weg ist damit eindeutig Alternative Payment Processing: 20 % (10 % im Small Business Program bzw. ab dem zweiten Abo-Jahr), gegenüber 15 %/10 % beim Link-out.

Das ist die Gründerentscheidung vom 20.09.: Kauf in der App, kein Browser-Ausstieg, fünf Prozentpunkte teurer. Kein offener Punkt — nur der Vollständigkeit halber hier festgehalten, damit die Zahl nicht später als Überraschung auftaucht.

5. Was gebaut ist — und was nicht

Gebaut: Die Erfassung. Käufe, Verlängerungen, Erstattungen und Rückbuchungen landen in StoreTransactionReport, jede Zeile mit suppressedReason. Erstattungen laufen über refund.created / refund.updated / refund.failed (melde-fähig nur bei succeeded), Rückbuchungen über charge.dispute.created / .updated / .closed (melde-fähig nur bei lost).

Nicht gebaut, bewusst:

  • Der Versand. Beide Freigaben fehlen (Google: Programmanmeldung, Apple: Entitlement nach Zeichnung des aktualisierten DPLA). Ein Versandweg ohne Gegenstelle wäre unprüfbar.
  • Apples Token-Modell. Zwei Token-Typen beim App-Start, kontogebunden, plus Meldung ungenutzter Tokens. Das ist ein eigener Umbau in App und Backend.
  • Die Melde-Kennung selbst. melderKennung() in frontend/mobile/src/screens/season/storeReporting.ts liefert heute null, weil beide Freigaben fehlen. Ohne sie geht auch die OS-Fassung nicht mit — der Server nimmt sie nur als Teil des Melde-Blocks an, und ohne Kennung ist ein Kauf bei keiner Plattform meldbar.
  • Nachholen vergangener Vorgänge. Ein unbehandeltes Webhook-Ereignis wird trotzdem als verarbeitet vermerkt (markStripeEventProcessed läuft unbedingt), eine Wiederzustellung also abgewiesen. Alles, was vor dem Abonnement der Erstattungs-Ereignisse passiert ist, kann nur über die Stripe-API nachgeholt werden, nicht über den Ereignisstrom.

6. Die Stripe-API-Fassung des PaymentSheet

TS_STRIPE_MOBILE_API_VERSION war nie gesetzt, und der Kauf endete deshalb mit 503 stripe_mobile_api_version_not_configuredbevor das PaymentSheet überhaupt aufging.

Der Wert gehört nicht zum Server, sondern zum mobilen SDK: Stripes PaymentSheet spricht eine im NATIVEN SDK festverdrahtete Fassung. In der hier eingebauten (Stripe iOS 25.11.0) ist das 2020-08-27 — gemessen in StripeCore/.../STPAPIClient.swift:260, nicht geschätzt.

Seit dem 20.09. fragt die App das SDK selbst (Constants.API_VERSIONS.CORE → iOS STPAPIClient.apiVersion, Android ApiVersion.API_VERSION_CODE) und schickt die Fassung mit. Die Umgebungsvariable bleibt als Rückfall — für App-Fassungen im Store, die den Wert noch nicht mitschicken, und als Notnagel ohne App-Update. Sie muss nicht mehr gepflegt werden; wird sie gesetzt, gilt trotzdem die Angabe des Clients, weil die aus dem SDK stammt, das gleich das Sheet öffnet.

Unverändert offen: ob das Sheet damit wirklich aufgeht. Das ist bis heute nie passiert — es braucht Stripe-Testschlüssel und einen echten Lauf.

7. Zwei Handgriffe, die nur der Gründer machen kann

  1. Stripe-Dashboard: sechs Ereignisse zum Webhook-Endpunkt hinzufügen — refund.created, refund.updated, refund.failed sowie charge.dispute.created, charge.dispute.updated, charge.dispute.closed. Die Ereignis-Anmeldung steht nirgends im Repo — sie existiert nur dort. Ohne sie laufen Erstattungs- und Rückbuchungsstrecke leer, ohne einen einzigen Fehler zu erzeugen.

Ob sie heute schon ankommen, beantwortet die Prod-Datenbank:

sql SELECT value->>'type' AS ereignis, count(*), max(value->>'processedAt') AS zuletzt FROM "Setting" WHERE key LIKE 'billing:stripe:webhook:%' GROUP BY 1 ORDER BY 2 DESC;

  1. App Store Connect: das aktualisierte DPLA als Account Holder zeichnen. Erst dadurch entsteht ab 01.10.2026 der Zugang zum Entitlement, und Apple behält sich vor, es abzulehnen und jederzeit zu widerrufen. Das ist ein Termin-Risiko wie seinerzeit der Paid-Apps-Vertrag: früh beantragen.