Migrate billing from Paddle to Lemon Squeezy

This commit is contained in:
Codex Agent
2026-02-03 10:59:54 +01:00
parent 2f4ebfefd4
commit a0ef90e13a
228 changed files with 4369 additions and 4067 deletions

View File

@@ -3,33 +3,33 @@ title: Billing & Zahlungs-Operationen
sidebar_label: Billing-Runbook
---
Dieses Runbook beschreibt, wie mit Zahlungsproblemen, Paddle/RevenueCatWebhooks und PaketInkonsistenzen operativ umzugehen ist.
Dieses Runbook beschreibt, wie mit Zahlungsproblemen, Lemon Squeezy/RevenueCatWebhooks und PaketInkonsistenzen operativ umzugehen ist.
## 1. Komponentenüberblick
- **Paddle**
- **Lemon Squeezy**
- Abwicklung von WebCheckout, Paketen und Subscriptions.
- Webhooks für Käufe, Verlängerungen, Stornos.
- Webhooks für Bestellungen, Verlängerungen, Stornos.
- **Fotospiel Backend**
- Modelle wie `Tenant`, `Packages`, `tenant_packages`, `event_packages`.
- Services/Jobs zur PaketZuweisung, LimitBerechnung und Nutzungstracking.
> Details zur Architektur findest du in den PRPKapiteln (Billing/Freemium) sowie in den bd-Issues zur PaddleMigration und zum KatalogSync.
> Details zur Architektur findest du in den PRPKapiteln (Billing/Freemium) sowie in den bd-Issues zur Lemon SqueezyMigration und zum KatalogSync.
## 2. Typische Problemszenarien
- **Webhook kommt nicht an / schlägt fehl**
- Symptom: Paddle zeigt Zahlung „completed“, TenantPaket im Backend bleibt unverändert.
- Symptom: Lemon Squeezy zeigt Zahlung „completed/paid“, TenantPaket im Backend bleibt unverändert.
- Checkliste:
- Logs der WebhookRoutes prüfen (Statuscodes, Exceptions).
- Endpoint: `POST /paddle/webhook``PaddleWebhookController::handle()`.
- Controller ruft `CheckoutWebhookService::handlePaddleEvent()` auf.
- WebhookReplay über das Paddle Dashboard auslösen (für einzelne Events).
- Endpoint: `POST /lemonsqueezy/webhook``LemonSqueezyWebhookController::handle()`.
- Controller ruft `CheckoutWebhookService::handleLemonSqueezyEvent()` auf.
- WebhookReplay über das Lemon Squeezy Dashboard auslösen (für einzelne Events).
- QueueStatus prüfen:
- Falls Webhooks über Queues verarbeitet werden, auf `default`/`billing`Queues achten (je nach Konfiguration).
- **Doppelte oder fehlende Abbuchungen**
- Abgleich von ZahlungsproviderDaten (Paddle/RevenueCat) mit internem Ledger.
- Bei doppelten Buchungen: Prozess definieren (Refund via Paddle, Anpassung im Ledger).
- Abgleich von ZahlungsproviderDaten (Lemon Squeezy/RevenueCat) mit internem Ledger.
- Bei doppelten Buchungen: Prozess definieren (Refund via Lemon Squeezy, Anpassung im Ledger).
- Bei fehlenden Buchungen: ggf. manuelle Paketzuweisung nach erfolgter Zahlung.
- **Pakete/Limits passen nicht zur Realität**
- Tenant meldet: „Paket falsch“, „Galerie schon abgelaufen“ o.Ä.
@@ -43,21 +43,21 @@ Dieses Runbook beschreibt, wie mit Zahlungsproblemen, Paddle/RevenueCatWebhoo
1. **Event/Tenant identifizieren**
- IDs und relevante PaketInfos aus DB/Admin UI holen.
2. **Provider-Status prüfen**
- PaddleDashboard: ist die Zahlung dort korrekt verbucht? (Transaktions/AbonnementAnsicht).
- Lemon SqueezyDashboard: ist die Zahlung dort korrekt verbucht? (Order/SubscriptionAnsicht).
3. **Backend-Status prüfen**
- Paketzuweisung und Limits in der DB (Readonly!) inspizieren:
- `checkout_sessions` wurde die Session korrekt auf `completed` gesetzt? (`provider = paddle`, `paddle_transaction_id` gefüllt?)
- `checkout_sessions` wurde die Session korrekt auf `completed` gesetzt? (`provider = lemonsqueezy`, `lemonsqueezy_order_id` gefüllt?)
- `package_purchases` existiert ein Eintrag für Tenant/Package mit erwarteter ProviderReferenz?
- `tenant_packages` stimmt der `active`Status und `expires_at` mit dem erwarteten Abostatus überein?
4. **Entscheidung**
- Automatische Nachverarbeitung via WebhookReplay/JobRetry:
- PaddleEvents erneut senden lassen, ggf. `tests/api/_testing/checkout/sessions/{session}/simulate-paddle` (in TestUmgebungen) nutzen.
- Lemon SqueezyEvents erneut senden lassen, ggf. `tests/api/_testing/checkout/sessions/{session}/simulate-lemonsqueezy` (in TestUmgebungen) nutzen.
- Notfall: manuelle PaketAnpassung (nur mit klar dokumentierter Begründung):
- Paket in `tenant_packages` aktivieren/verlängern und `package_purchases` sauber nachziehen.
5. **Dokumentation**
- Vorgang im Ticket oder als bd-Issue festhalten, falls wiederkehrend.
> TODO: Ergänze konkrete Tabellen-/Modellnamen und die relevanten Jobs/Artisan Commands, sobald Paddle/RevenueCat Migration finalisiert ist.
> TODO: Ergänze konkrete Tabellen-/Modellnamen und die relevanten Jobs/Artisan Commands, sobald Lemon Squeezy/RevenueCat Migration finalisiert ist.
## 4. Zusammenarbeit mit Finance/Support
@@ -69,141 +69,138 @@ Dieses Runbook beschreibt, wie mit Zahlungsproblemen, Paddle/RevenueCatWebhoo
## 5. Hinweise zur Implementierung
- **Konfiguration**
- PaddleKeys, WebhookSecrets und FeatureFlags sollten ausschließlich in `.env`/ConfigDateien liegen und niemals im Code/Logs landen.
- Lemon SqueezyKeys, WebhookSecrets und FeatureFlags sollten ausschließlich in `.env`/ConfigDateien liegen und niemals im Code/Logs landen.
- Sandbox vs. LiveKeys klar trennen; Ops sollte wissen, welche Umgebung gerade aktiv ist.
- **Sicherheit**
- WebhookSignaturen und Timestamps prüfen; bei verdächtigen Mustern (z.B. ReplayAngriffe) SecurityRunbooks konsultieren.
- Keine sensiblen PaymentDetails in ApplikationsLogs ausgeben.
Diese Sektion ist bewusst generisch gehalten, damit sie auch nach Implementation der finalen BillingArchitektur noch passt. Details zu Tabellen/Jobs sollten ergänzt werden, sobald die PaddleMigration abgeschlossen ist.
Diese Sektion ist bewusst generisch gehalten, damit sie auch nach Implementation der finalen BillingArchitektur noch passt. Details zu Tabellen/Jobs sollten ergänzt werden, sobald die Lemon SqueezyMigration abgeschlossen ist.
## 6. Konkrete Paddle-Flows im System
## 6. Konkrete Lemon Squeezy-Flows im System
### 6.1 Checkout-Erstellung
- Marketing-Checkout / API:
- `MarketingController` und `PackageController` nutzen `PaddleCheckoutService::createCheckout()` (`App\Services\Paddle\PaddleCheckoutService`).
- `MarketingController` und `PackageController` nutzen `LemonSqueezyCheckoutService::createCheckout()` (`App\Services\LemonSqueezy\LemonSqueezyCheckoutService`).
- Der Service:
- Stellt sicher, dass ein `paddle_customer_id` für den Tenant existiert (`PaddleCustomerService::ensureCustomerId()`).
- Baut Metadaten (`tenant_id`, `package_id`, optional `checkout_session_id`) für spätere Zuordnung.
- Ruft `POST /checkout/links` im PaddleAPI auf und erhält eine `checkout_url`.
- Baut `custom_data` (`tenant_id`, `package_id`, optional `checkout_session_id`) für spätere Zuordnung.
- Ruft `POST /checkouts` im Lemon SqueezyAPI auf und erhält eine `checkout_url`.
- Ops-Sicht:
- Wenn `paddle_price_id` bei einem Package fehlt, wird kein Checkout erzeugt MarketingUI zeigt entsprechende Fehlertexte (siehe `resources/lang/*/marketing.php`).
- Bei wiederkehrenden „checkout failed“Fehlern die Logs (`PaddleCheckoutService`, Controller) und PackageKonfiguration prüfen.
- Wenn `lemonsqueezy_variant_id` bei einem Package fehlt, wird kein Checkout erzeugt MarketingUI zeigt entsprechende Fehlertexte (siehe `resources/lang/*/marketing.php`).
- Bei wiederkehrenden „checkout failed“Fehlern die Logs (`LemonSqueezyCheckoutService`, Controller) und PackageKonfiguration prüfen.
### 6.2 Webhook-Verarbeitung & Idempotenz
- Endpoint: `POST /paddle/webhook``PaddleWebhookController::handle()`.
- Endpoint: `POST /lemonsqueezy/webhook``LemonSqueezyWebhookController::handle()`.
- Service: `CheckoutWebhookService` (`App\Services\Checkout\CheckoutWebhookService`):
- Unterscheidet zwischen **TransaktionsEvents** (`transaction.*`) und **SubscriptionEvents** (`subscription.*`).
- Unterscheidet zwischen **OrderEvents** (`order_*`) und **SubscriptionEvents** (`subscription_*`).
- Idempotenz:
- Nutzt ein CacheLock (`checkout:webhook:paddle:{transaction_id|session_id}`), um parallele Verarbeitung desselben Events zu verhindern.
- Schreibt Metadaten (`paddle_last_event`, `paddle_status`, `paddle_checkout_id`) in `checkout_sessions.provider_metadata`.
- Nutzt ein CacheLock (`checkout:webhook:lemonsqueezy:{order_id|session_id}`), um parallele Verarbeitung desselben Events zu verhindern.
- Schreibt Metadaten (`lemonsqueezy_last_event`, `lemonsqueezy_status`, `lemonsqueezy_checkout_id`, `lemonsqueezy_order_id`) in `checkout_sessions.provider_metadata`.
- Ergebnis:
- Bei `transaction.completed`:
- Bei `order_created`/`order_updated` mit Status `paid`:
- `CheckoutSession` wird als `processing` markiert.
- `CheckoutAssignmentService::finalise()` weist Paket/Tenant zu.
- Session wird auf `completed` gesetzt.
- Bei `transaction.failed` / `transaction.cancelled`:
- Bei `order_payment_failed` / `order_refunded`:
- Session wird auf `failed` gesetzt, Coupons werden als fehlgeschlagen markiert.
### 6.2.1 SandboxWebhook registrieren
- Command (Sandbox-Umgebung aktiv):
- `php artisan paddle:webhooks:register --traffic-source=simulation`
- Command (TestMode aktiv):
- `php artisan lemonsqueezy:webhooks:register --test-mode`
- Optional mit URL-Override:
- `php artisan paddle:webhooks:register --url=https://staging.example.com/paddle/webhook --traffic-source=simulation`
- EventListe kommt aus `config/paddle.php` (`webhook_events`). Override möglich:
- `php artisan paddle:webhooks:register --events=transaction.completed --events=subscription.created`
- `php artisan lemonsqueezy:webhooks:register --url=https://staging.example.com/lemonsqueezy/webhook --test-mode`
- EventListe kommt aus `config/lemonsqueezy.php` (`webhook_events`). Override möglich:
- `php artisan lemonsqueezy:webhooks:register --events=order_created --events=subscription_created`
### 6.2.2 Verarbeitete Paddle-Events
### 6.2.2 Verarbeitete Lemon Squeezy-Events
Die WebhookHandler erwarten mindestens diese Events:
- `transaction.created`
- `transaction.processing`
- `transaction.completed` (inkl. Add-ons/GiftVouchers)
- `transaction.failed`
- `transaction.cancelled`
- `subscription.created`
- `subscription.updated`
- `subscription.paused`
- `subscription.resumed`
- `subscription.cancelled`
- `subscription.past_due`
- `order_created`
- `order_updated`
- `order_payment_failed`
- `order_refunded`
- `subscription_created`
- `subscription_updated`
- `subscription_cancelled`
- `subscription_expired`
- `subscription_paused`
### 6.3 Subscriptions & TenantPackages
- SubscriptionEvents (`subscription.*`) werden ebenfalls von `CheckoutWebhookService` behandelt:
- Tenant wird aus `metadata.tenant_id` oder `paddle_customer_id` ermittelt.
- Package wird über `metadata.package_id` oder `paddle_price_id` aufgelöst.
- `TenantPackage` wird erstellt/aktualisiert (`paddle_subscription_id`, `expires_at`, `active`).
- SubscriptionEvents (`subscription_*`) werden ebenfalls von `CheckoutWebhookService` behandelt:
- Tenant wird aus `metadata.tenant_id` oder `lemonsqueezy_customer_id` ermittelt.
- Package wird über `metadata.package_id` oder `lemonsqueezy_variant_id` aufgelöst.
- `TenantPackage` wird erstellt/aktualisiert (`lemonsqueezy_subscription_id`, `expires_at`, `active`).
- `Tenant.subscription_status` und `subscription_expires_at` werden gesteuert.
- Ops-Sicht:
- Bei abweichenden Abostatus (z.B. Paddle zeigt „active“, Tenant nicht):
- SubscriptionEvents im PaddleDashboard prüfen.
- Letzte `subscription.*`Events in den Logs, `TenantPackage` und `Tenant`Felder gegenprüfen.
- Bei abweichenden Abostatus (z.B. Lemon Squeezy zeigt „active“, Tenant nicht):
- SubscriptionEvents im Lemon SqueezyDashboard prüfen.
- Letzte `subscription_*`Events in den Logs, `TenantPackage` und `Tenant`Felder gegenprüfen.
### 6.4 Paket- & Coupon-Synchronisation
- Pakete:
- ArtisanCommand `paddle:sync-packages` (`App\Console\Commands\PaddleSyncPackages`) stößt für ausgewählte oder alle Pakete `SyncPackageToPaddle`/`PullPackageFromPaddle` Jobs an.
- SyncJobs nutzen `PaddleCatalogService`, um Produkte/Preise in Paddle zu erstellen/aktualisieren und `paddle_product_id`/`paddle_price_id` lokal zu pflegen.
- ArtisanCommand `lemonsqueezy:sync-packages` (`App\Console\Commands\LemonSqueezySyncPackages`) stößt für ausgewählte oder alle Pakete `SyncPackageToLemonSqueezy`/`PullPackageFromLemonSqueezy` Jobs an.
- SyncJobs nutzen `LemonSqueezyCatalogService`, um Produkte/Preise in Lemon Squeezy zu erstellen/aktualisieren und `lemonsqueezy_product_id`/`lemonsqueezy_variant_id` lokal zu pflegen.
- Coupons:
- `SyncCouponToPaddle`Job spiegelt interne CouponKonfiguration in Paddle Discounts (`PaddleDiscountService`).
- `SyncCouponToLemonSqueezy`Job spiegelt interne CouponKonfiguration in Lemon Squeezy Discounts (`LemonSqueezyDiscountService`).
- Ops-Sicht:
- Bei KatalogAbweichungen `paddle:sync-packages --dry-run` verwenden, um Snapshots zu prüfen, bevor tatsächliche Änderungen gesendet werden.
- Fehlgeschlagene Syncs in den Logs (`Paddle package sync failed`, `Paddle discount sync failed`) beobachten.
- Bei KatalogAbweichungen `lemonsqueezy:sync-packages --dry-run` verwenden, um Snapshots zu prüfen, bevor tatsächliche Änderungen gesendet werden.
- Fehlgeschlagene Syncs in den Logs (`Lemon Squeezy package sync failed`, `Lemon Squeezy addon sync failed`, `Failed syncing coupon to Lemon Squeezy`) beobachten.
### 6.5 Recovery-Playbook: KatalogSync fehlgeschlagen
Wenn der KatalogSync fehlschlägt oder Pakete nicht mehr korrekt verknüpft sind:
1. **Status im Admin prüfen**
- `PackageResource` → Felder `paddle_sync_status`, `paddle_synced_at` und `Letzter Fehler`.
- Fehlermeldung stammt aus `paddle_snapshot.error.message`.
- `PackageResource` → Felder `lemonsqueezy_sync_status`, `lemonsqueezy_synced_at` und `Letzter Fehler`.
- Fehlermeldung stammt aus `lemonsqueezy_snapshot.error.message`.
2. **Trockenlauf ausführen**
- `php artisan paddle:sync-packages --package=<id|slug> --dry-run`
- Prüfe die erzeugten PayloadSnapshots (in `paddle_snapshot`) auf falsche IDs/Preise.
- `php artisan lemonsqueezy:sync-packages --package=<id|slug> --dry-run`
- Prüfe die erzeugten PayloadSnapshots (in `lemonsqueezy_snapshot`) auf falsche IDs/Preise.
3. **Mapping prüfen**
- Falls `paddle_product_id` oder `paddle_price_id` fehlt oder falsch ist: im PaketAdmin korrigieren.
- Falls `lemonsqueezy_product_id` oder `lemonsqueezy_variant_id` fehlt oder falsch ist: im PaketAdmin korrigieren.
- BulkSync blockiert unmapped Pakete; gezielte Korrektur vor dem nächsten Lauf ist Pflicht.
4. **Sync erneut anstoßen**
- `php artisan paddle:sync-packages --package=<id|slug> --queue`
- `php artisan lemonsqueezy:sync-packages --package=<id|slug> --queue`
- Bei Bedarf: `--allow-unmapped` nur bewusst verwenden (z.B. initiales Mapping).
5. **Pull für Abgleich**
- `php artisan paddle:sync-packages --package=<id|slug> --pull` zum Abgleich mit Paddle.
- `php artisan lemonsqueezy:sync-packages --package=<id|slug> --pull` zum Abgleich mit Lemon Squeezy.
6. **Logs prüfen**
- Erwartete Logeinträge: `Paddle package sync failed`, `Paddle addon sync failed`, `Paddle discount sync failed`.
- Erwartete Logeinträge: `Lemon Squeezy package sync failed`, `Lemon Squeezy addon sync failed`, `Failed syncing coupon to Lemon Squeezy`.
- Achte auf wiederkehrende Fehler (z.B. invalid product/price IDs).
Diese Untersektion soll dir als Operator helfen zu verstehen, wie PaddleAktionen im System abgebildet sind und an welchen Stellen du im Fehlerfall ansetzen kannst.
Diese Untersektion soll dir als Operator helfen zu verstehen, wie Lemon SqueezyAktionen im System abgebildet sind und an welchen Stellen du im Fehlerfall ansetzen kannst.
## 7. Production Cutover: Paddle Migration
## 7. Production Cutover: Lemon Squeezy Migration
Diese Checkliste beschreibt den kontrollierten Wechsel auf Paddle in Produktion.
Diese Checkliste beschreibt den kontrollierten Wechsel auf Lemon Squeezy in Produktion.
1. **Vorbereitung (T1 Woche)**
- Confirm: `PADDLE_ENVIRONMENT=production`, `PADDLE_API_KEY`, `PADDLE_CLIENT_TOKEN`, `PADDLE_WEBHOOK_SECRET`.
- Package IDs validieren: alle aktiven Packages haben `paddle_product_id` und `paddle_price_id`.
- `paddle:sync-packages --dry-run` auf eine Stichprobe anwenden.
- EventListe prüfen: `config/paddle.php` (`webhook_events`).
- Confirm: `LEMONSQUEEZY_API_KEY`, `LEMONSQUEEZY_STORE_ID`, `LEMONSQUEEZY_WEBHOOK_SECRET`, `LEMONSQUEEZY_TEST_MODE=false`.
- Package IDs validieren: alle aktiven Packages haben `lemonsqueezy_product_id` und `lemonsqueezy_variant_id`.
- `lemonsqueezy:sync-packages --dry-run` auf eine Stichprobe anwenden.
- EventListe prüfen: `config/lemonsqueezy.php` (`webhook_events`).
2. **Staging Smoke (T2 Tage)**
- `paddle:webhooks:register --traffic-source=simulation` auf Staging ausfuehren.
- Testkauf via Paddle Sandbox und Webhook Replay verifizieren.
- `lemonsqueezy:webhooks:register --test-mode` auf Staging ausführen.
- Testkauf via Lemon Squeezy Test Mode und Webhook Replay verifizieren.
3. **Cutover Window (T0)**
- MarketingCheckout kurz einfrieren (kein Checkout waehrend der Umschaltung).
- Production Webhook registrieren:
- `paddle:webhooks:register --traffic-source=platform --url=https://<prod-domain>/paddle/webhook`
- `lemonsqueezy:webhooks:register --url=https://<prod-domain>/lemonsqueezy/webhook`
- Queue worker laufen lassen (Queue: `webhooks`/`billing` sofern konfiguriert).
4. **Activation**
- Erstes ProduktionsCheckout ausfuehren.
- Verify: `checkout_sessions.provider_metadata` wird mit `paddle_*` Feldern befuellt.
- Verify: `checkout_sessions.provider_metadata` wird mit `lemonsqueezy_*` Feldern befuellt.
- Verify: `TenantPackage` aktiv und `subscription_status` korrekt.
5. **Rollback (falls notwendig)**
- Checkout wieder deaktivieren (MarketingCheckout ausblenden).
- Paddle Webhook Destination im Paddle Dashboard deaktivieren.
- Status und Logs sichern (Webhooks, `paddle_sync` Log).
- Lemon Squeezy Webhook Destination im Lemon Squeezy Dashboard deaktivieren.
- Status und Logs sichern (Webhooks, `lemonsqueezy-sync` Log).
6. **PostCutover (T+1)**
- Stichproben auf neue Tenants/Packages.
- Monitoring: Fehlgeschlagene Webhooks, SyncFehler, Support Tickets.