CluPilotCloud/docs/superpowers/specs/2026-07-30-stripe-adoption-...

14 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 über remember() 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

  1. bei Stripe aktiv ist,
  2. mindestens einen tragenden Metadatenschlüssel bestätigtplan_family_id bei Familien, addon bei Modulen — und
  3. 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 FakeStripeClient kann diese Felder gar nicht ausdrücken, also kann kein Test von AdoptStripePrice das Geld-Tor erreichen — genau die Zusage, um die es geht;
  • eine dritte Implementierung von StripeClient ließ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 wiederkehrenden Preise geholt und die aufgelistet, die keine Zeile in stripe_addon_prices oder stripe_plan_prices kennt — mit ID, Betrag, Währung, Intervall und Metadaten. Nichts wird angefasst.
  • Mit --archive: dieselbe Liste wird stillgelegt. Für alles, was dieses Projekt verkauft, ist das harmlos, und zwar aus demselben Grund wie überall sonst hier: ein archivierter Preis verrechnet laufende Abos weiter, er wird nur nicht mehr verkauft. Für eine übernehmbare Waise gilt das ausdrücklich nicht — siehe die Berichtigung unten.
  • --dry-run wie bei stripe:sync-catalogue, damit die beiden Kommandos sich gleich anfühlen.

Berichtigt am 2026-07-30. Hier stand: „Eine Waise, die der Wiedererkennungsschritt hätte übernehmen können, taucht hier gar nicht erst auf — sie wurde beim letzten Abgleich übernommen." Das stimmt nur, wenn seit dem Auftauchen der Waise ein Abgleich gelaufen ist, und genau diese Vorbedingung erzwingt das Kommando nicht. Es fragt ausschließlich, ob eine Zeile die ID kennt; es fragt AdoptStripePrice nicht, ob die Waise übernehmbar wäre. Die Liste ist deshalb weiter als das, was §12 „nicht übernehmbar" nannte, und die Implementierung hat den falschen Satz getreu nachgebaut.

Die Vorbedingung lautet: zuerst stripe:sync-catalogue, dann dieses Kommando. Sie steht im Klassenkommentar, in $description, in der Berichtsausgabe und im Betriebsteil des Handoffs — nicht als Ordnungsliebe, sondern weil --archive auf eine übernehmbare Waise sie für den Abgleich unsichtbar macht (activePricesFor() fragt nur aktive Preise ab). Der Abgleich fällt dann auf createPrice() mit demselben Idempotenz-Schlüssel zurück, den der abgebrochene Lauf benutzt hat, und Stripe antwortet 24 Stunden lang mit der gespeicherten Antwort — der ID des stillgelegten Preises, im Zustand von damals. Das Register hält den Tarif dann für live auf einem Preis, auf den Stripe keinen Checkout öffnet.

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):

  1. FakeStripeClient::updatePriceMetadata() ersetzt, wo Stripe zusammenführt. Bestehende Schlüssel bleiben künftig stehen.

  2. FakeStripeClient::activePricesFor() gibt Metadaten ungecastet zurück, wo HttpStripeClient auf String castet. Angleichen.

  3. created im Fake kann gleichstehen: createPrice() stempelt count($prices) + 1, plantPrice() hat die Vorgabe 1. Gleichstand wird über die Preis-ID aufgelöst, damit die Wahl über Seitengrenzen hinweg reproduzierbar ist.

  4. HttpStripeClient::activePricesFor() bricht das Blättern still ab, wenn dem letzten Eintrag einer nicht-letzten Seite die id fehlt. Künftig wirft es. Begründung im Kommentar: bei invoiceLines() entsteht dadurch ein zu kurzer Belegtext, hier eine ungesehene Waise und ein zweiter aktiver Preis — also der Vorfall selbst.

  5. stripe:sync-catalogue zä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 AdoptStripePrice seine eigenen Übernahmen mit, und das Kommando liest den Zähler am Anfang und am Ende. Dafür wird die Klasse in AppServiceProvider als Singleton gebunden: sie wird heute je Aufruf frisch aufgelöst (SyncStripeCatalogue holt PlanPrices pro 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.

  6. PlanPrices::ensure() bekommt die <= 0-Schrankeentfällt, siehe §3: der Umzug des Geld-Tors nimmt ihr den Zweck, und sie würde einen kostenlosen Tarif unverkäuflich machen.

7. Was nicht dazugehört

  • Der sprunghafte PlanCatalogueTest. Ursachensuche, keine Bauarbeit, und sie braucht ein anderes Vorgehen als dieser Plan.
  • CLUPILOT_DNS_ZONE in phpunit.xml festnageln. 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.php
  • app/Console/Commands/SweepOrphanStripePrices.php
  • tests/Feature/Billing/StripeProductAdoptionTest.php
  • tests/Feature/Billing/SweepOrphanPricesTest.php

Geändert

  • app/Services/Stripe/StripeClient.phpactiveProducts(), Vertrag von activePricesFor()
  • app/Services/Stripe/HttpStripeClient.php — Produkte auflisten, Filter auflösen, Blättern wirft
  • app/Services/Stripe/FakeStripeClient.php — Produkte auflisten, vier Felder in plantPrice(), Metadaten zusammenführen, created-Gleichstand
  • app/Services/Billing/AdoptStripePrice.php — die vier Eigenschaften prüfen, Warnung drosseln
  • app/Services/Billing/AddonPrices.php — Produkt-Wiedererkennung in product()
  • app/Providers/AppServiceProvider.php — beide Adoptions-Klassen als Singleton, damit ihr Lauf-Protokoll über den ganzen Lauf trägt. Es heißt in den beiden Klassen nicht gleich: AdoptStripePrice::$adoptions zählt die übernommenen Preise, AdoptStripeProduct::$duplicates sammelt die IDs der doppelten Produkte.
  • app/Console/Commands/SyncStripeCatalogue.php — Produkt-Wiedererkennung, getrennte Zählung, Kommentar berichtigt
  • tests/Feature/Billing/StripePriceAdoptionTest.php, StripeIdempotencyKeyTest.php — angehängt
  • docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md — §14 abhaken, was hier erledigt wird