# 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ätigt** —
   `plan_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`-Schranke~~ — **entfä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.php` — `activeProducts()`, 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
