13 KiB
Spec — Die offenen Punkte der Preis-Wiedererkennung
Datum: 2026-07-30
Status: entworfen, noch nicht umgesetzt
Vorgänger: docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md, §14
1. Ausgangslage
Die Preis-Wiedererkennung ist gebaut und gemergt: ein Lauf, der zwischen Stripes Anlage und unserem Schreiben abbricht, hinterlässt keine zweite Preis-Zeile mehr, und eine Änderung an den Metadaten blockiert nichts mehr für 24 Stunden.
§14 des Vorgängers hat aufgeschrieben, was die Prüfungen dabei fanden und niemand behoben hat. Das wird hier gebaut. Drei Punkte mit Entwurfsentscheidung, sechs mechanische.
2. Waisen-Produkte — die eine echte Lücke
activePricesFor() erkennt einen verwaisten Preis wieder. Für ein verwaistes
Produkt gibt es kein Gegenstück, und dort ist der Schaden größer: ein
zweites Produkt lässt jedes activePricesFor($neueId) leer zurückkommen. Damit
ist die Wiedererkennung für dieses Modul oder diese Familie dauerhaft blind
und jede Waise am ersten Produkt unerreichbar. Der Zweig deaktiviert seine eigene
Sicherung durch die Lücke, die er nicht geschlossen hat.
Betroffen sind zwei Stellen, beide mit demselben Muster — Produkt anlegen, ID in einem zweiten Schreibvorgang speichern:
SyncStripeCatalogue::handle()für Paketfamilien ($family->update([…])),AddonPrices::product()für Module (die ID landet erst überremember()in einer Preiszeile).
Wie es gebaut wird
StripeClient::activeProducts(): array — alle aktiven Produkte des Kontos, nach
dem Blätter-Muster von activePricesFor(). Ohne Filter auf Metadaten: Stripe
kann danach nicht suchen, und ein Konto hat eine Handvoll Produkte, keine
Tausende.
App\Services\Billing\AdoptStripeProduct — der Wiedererkennungsschritt, nach
dem Vorbild von AdoptStripePrice, aber deutlich kürzer. Ein Produkt hat keinen
Betrag, also gibt es hier kein Geld-Tor: der Beweis liegt allein in den
Metadaten. Übernommen wird ein Produkt, das
- bei Stripe aktiv ist,
- mindestens einen tragenden Metadatenschlüssel bestätigt —
plan_family_idbei Familien,addonbei Modulen — und - keinem unserer Metadatenschlüssel widerspricht, den es trägt.
Eine Beanspruchungs-Prüfung wie bei den Preisen braucht es nicht: die tragenden Schlüssel sind je Familie und je Modul eindeutig, ein Produkt kann also gar nicht zu zweien gehören.
Doppelte Produkte werden gemeldet, nicht stillgelegt
Entscheidung des Auftraggebers vom 2026-07-30, und sie ist die vorsichtige: das älteste wird übernommen, die übrigen werden gemeldet und nicht angefasst.
Der Grund ist ein echter Unterschied zur Preis-Seite. Ein archivierter Preis ist nachweislich harmlos — Stripe verrechnet laufende Abos weiter, nur verkauft wird er nicht mehr, und darauf beruht die Grandfathering-Regel dieses Projekts schon lange. Ein stillgelegtes Produkt macht dagegen seine Preise unverkäuflich, und auf denen können Verträge laufen. Was bei Preisen bewiesen harmlos war, ist hier nicht bewiesen — also wird es nicht getan.
Gemeldet wird über Log::warning und in der Ausgabe von
stripe:sync-catalogue, weil die Produkt-Anlage im Gegensatz zu ensure() nur
im Kommando läuft und ein Betreiber dort hinsieht.
3. Das Geld-Tor zieht dorthin, wo es zugesagt wird
AdoptStripePrice verspricht im Kopfkommentar, keine Übernahme könne Geld
verschieben — vergleicht aber nur Betrag, Währung und Intervall. Die vier
übrigen Eigenschaften, die entscheiden, was ein Preis verrechnet
(interval_count, usage_type, transform_quantity, billing_scheme),
filtert HttpStripeClient::activePricesFor().
Zwei Folgen, und die zweite ist die schlimmere:
- der
FakeStripeClientkann diese Felder gar nicht ausdrücken, also kann kein Test vonAdoptStripePricedas Geld-Tor erreichen — genau die Zusage, um die es geht; - eine dritte Implementierung von
StripeClientließe die Prüfung still fallen.
Wie es gebaut wird
activePricesFor() filtert nicht mehr, sondern gibt die vier Eigenschaften
im Rückgabe-Array mit: interval_count, usage_type, transform_quantity
(als bool: trägt der Preis eine?), billing_scheme. Absente Schlüssel werden
weiterhin auf Stripes Vorgaben gesetzt — 1, licensed, false, per_unit —
und das bleibt die Richtung, auf die es ankommt: andersherum gelesen würde der
Filter jeden legitimen Preis ablehnen und die Wiedererkennung still zu einem
Nichts machen.
AdoptStripePrice prüft sie neben Betrag, Währung und Intervall. Der
FakeStripeClient bekommt die vier Felder in plantPrice(), damit ein Test
einen vierteljährlichen oder mengenteilenden Preis überhaupt hinstellen kann.
StripeClient::activePricesFor()s Vertrag wird ehrlich: er liefert aktive,
wiederkehrende Preise mit ihren preisbestimmenden Eigenschaften; was davon
zulässig ist, entscheidet der Aufrufer.
Die Null-Schranke in PlanPrices wird nicht gebaut
Der Abschluss-Review hatte sie vorgeschlagen, und §14 des Vorgängers hat sie
notiert: AddonPrices::ensure() hat eine <= 0-Schranke, PlanPrices::ensure()
nicht, und das sei das Einzige, was einen billing_scheme: tiered-Preis (bei dem
Stripe unit_amount: null liefert, hier als 0 gelesen) auf der Paketseite
erreichbar macht.
Das stimmte — bis zu diesem Umzug. Sobald AdoptStripePrice selbst
billing_scheme !== 'per_unit' ablehnt, ist ein gestufter Preis unabhängig vom
Betrag des Aufrufers ausgeschlossen. Die Schranke hätte dann keinen Zweck mehr,
wäre aber eine Verhaltensänderung: ein Paket zu null — ein kostenloser Tarif,
heute nicht im Katalog, aber jederzeit anlegbar — bekäme keinen Stripe-Preis
mehr und wäre nicht verkäuflich. Eine Schranke ohne Nutzen, die einen künftigen
Fall bricht, wird nicht eingebaut.
Der §14-Eintrag wird entsprechend abgehakt: nicht gebaut, und warum.
4. stripe:sweep-orphan-prices — das Aufräum-Kommando
Der Vorgänger hat es bewusst ausgelassen (§12) und dafür die Log-Zeile begründet. Die Log-Zeile allein reicht nicht: sie nennt die Waise, aber niemand kann etwas mit ihr tun, ohne von Hand in Stripes Oberfläche zu gehen.
- Ohne Argument: ein Bericht. Für jedes unserer Produkte werden die aktiven
Preise geholt und die aufgelistet, die keine Zeile in
stripe_addon_pricesoderstripe_plan_priceskennt — mit ID, Betrag, Währung, Intervall und Metadaten. Nichts wird angefasst. - Mit
--archive: dieselbe Liste wird stillgelegt. Das ist harmlos, und zwar aus demselben Grund wie überall sonst in diesem Projekt: ein archivierter Preis verrechnet laufende Abos weiter, er wird nur nicht mehr verkauft. --dry-runwie beistripe:sync-catalogue, damit die beiden Kommandos sich gleich anfühlen.
Eine Waise, die der Wiedererkennungsschritt hätte übernehmen können, taucht hier gar nicht erst auf — sie wurde beim letzten Abgleich übernommen. Was übrig bleibt, ist genau das, was §12 als „nicht übernehmbar" beschrieben hat.
5. Die Warnung wird gedrosselt
AdoptStripePrice meldet einen unerklärlichen aktiven Preis mit
Log::warning — bei jedem Abgleich und jeder Kundenbuchung, solange die
Waise existiert. Der Widerspruchs-Pfad wurde aus genau diesem Grund still
gestellt; der Bestätigungs-Pfad bekam dieselbe Überlegung nicht.
Höchstens einmal pro Preis-ID und Tag, über Cache::add() — dasselbe Muster wie
App\Livewire\Billing:377 und App\Livewire\Admin\Vpn:329. Die Drosselung gilt
nur dieser wiederkehrenden Warnung, nicht der Meldung über ein doppeltes
Produkt und nicht der über einen archivierten Doppel-Preis: die stehen für ein
Ereignis, nicht für einen Zustand.
6. Die sechs mechanischen Punkte
Ohne Entwurfsentscheidung — bauen, Test, weiter (R22.5):
-
FakeStripeClient::updatePriceMetadata()ersetzt, wo Stripe zusammenführt. Bestehende Schlüssel bleiben künftig stehen. -
FakeStripeClient::activePricesFor()gibt Metadaten ungecastet zurück, woHttpStripeClientauf String castet. Angleichen. -
createdim Fake kann gleichstehen:createPrice()stempeltcount($prices) + 1,plantPrice()hat die Vorgabe1. Gleichstand wird über die Preis-ID aufgelöst, damit die Wahl über Seitengrenzen hinweg reproduzierbar ist. -
HttpStripeClient::activePricesFor()bricht das Blättern still ab, wenn dem letzten Eintrag einer nicht-letzten Seite dieidfehlt. Künftig wirft es. Begründung im Kommentar: beiinvoiceLines()entsteht dadurch ein zu kurzer Belegtext, hier eine ungesehene Waise und ein zweiter aktiver Preis — also der Vorfall selbst. -
stripe:sync-cataloguezählt „angelegt" und „übernommen" getrennt statt die Wortwahl zu entschärfen.Der naheliegende Weg funktioniert nicht: die Preis-ID vor und nach dem Aufruf zu vergleichen unterscheidet die beiden Fälle nicht — vorher gibt es in beiden keine, nachher in beiden eine. Stattdessen zählt
AdoptStripePriceseine eigenen Übernahmen mit, und das Kommando liest den Zähler am Anfang und am Ende. Dafür wird die Klasse inAppServiceProviderals Singleton gebunden: sie wird heute je Aufruf frisch aufgelöst (SyncStripeCatalogueholtPlanPricespro Zeile neu), ein Zähler auf der Instanz käme also nie über eins hinaus. Sie hat sonst keinen Zustand, und der Zähler ist das, was den Bericht wahr macht. -
— entfällt, siehe §3: der Umzug des Geld-Tors nimmt ihr den Zweck, und sie würde einen kostenlosen Tarif unverkäuflich machen.PlanPrices::ensure()bekommt die<= 0-Schranke
7. Was nicht dazugehört
- Der sprunghafte
PlanCatalogueTest. Ursachensuche, keine Bauarbeit, und sie braucht ein anderes Vorgehen als dieser Plan. CLUPILOT_DNS_ZONEinphpunit.xmlfestnageln. Fremder Fehler, inzwischen anders gelöst — die zweite Session hat die Zone im Test selbst festgenagelt (a842512).- Alles, was laufende Verträge bewegt. Wie im Vorgänger: dieser Entwurf bewegt keinen einzigen.
8. Tests
tests/Feature/Billing/StripeProductAdoptionTest.php (neu)
| Prüfung | Deckt ab |
|---|---|
Verwaistes Produkt bei Stripe → wird übernommen, createProduct nicht aufgerufen (Familie und Modul) |
§2 |
| Produkt ohne unsere Metadaten → nicht übernommen, neues angelegt, Warnung | §2, Bedingung 2 |
| Produkt, das einem Metadatenschlüssel widerspricht → nicht übernommen | §2, Bedingung 3 |
| Zwei passende Produkte → ältestes übernommen, das andere gemeldet und nicht stillgelegt | §2 |
An StripePriceAdoptionTest.php angehängt:
| Prüfung | Deckt ab |
|---|---|
Ein Preis mit interval_count: 3 wird von AdoptStripePrice abgelehnt — über den Fake, nicht über Http::fake |
§3 |
Dasselbe für transform_quantity, usage_type: metered, billing_scheme: tiered |
§3 |
| Zweite Warnung zur selben Preis-ID innerhalb eines Tages unterbleibt | §5 |
Gleichstand bei created → die kleinere Preis-ID gewinnt, zweimal gelaufen dasselbe Ergebnis |
§6.3 |
| Ein Lauf, der eine Waise übernimmt, meldet „übernommen" und nicht „angelegt" | §6.5 |
tests/Feature/Billing/SweepOrphanPricesTest.php (neu)
| Prüfung | Deckt ab |
|---|---|
| Bericht nennt die Waise und lässt sie in Ruhe | §4 |
--archive legt sie still, eine Zeile mit Preis bleibt unberührt |
§4 |
--dry-run mit --archive legt nichts still |
§4 |
Und an StripeIdempotencyKeyTest.php: Blättern wirft bei fehlender id
(§6.4); der Fake führt Metadaten zusammen statt sie zu ersetzen (§6.1).
9. Berührte Dateien
Neu
app/Services/Billing/AdoptStripeProduct.phpapp/Console/Commands/SweepOrphanStripePrices.phptests/Feature/Billing/StripeProductAdoptionTest.phptests/Feature/Billing/SweepOrphanPricesTest.php
Geändert
app/Services/Stripe/StripeClient.php—activeProducts(), Vertrag vonactivePricesFor()app/Services/Stripe/HttpStripeClient.php— Produkte auflisten, Filter auflösen, Blättern wirftapp/Services/Stripe/FakeStripeClient.php— Produkte auflisten, vier Felder inplantPrice(), Metadaten zusammenführen,created-Gleichstandapp/Services/Billing/AdoptStripePrice.php— die vier Eigenschaften prüfen, Warnung drosselnapp/Services/Billing/AddonPrices.php— Produkt-Wiedererkennung inproduct()app/Providers/AppServiceProvider.php— beide Adoptions-Klassen als Singleton, damit ihr Lauf-Protokoll ($duplicates) trägtapp/Console/Commands/SyncStripeCatalogue.php— Produkt-Wiedererkennung, getrennte Zählung, Kommentar berichtigttests/Feature/Billing/StripePriceAdoptionTest.php,StripeIdempotencyKeyTest.php— angehängtdocs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md— §14 abhaken, was hier erledigt wird