diff --git a/app/Console/Commands/SyncStripePlans.php b/app/Console/Commands/SyncStripePlans.php new file mode 100644 index 0000000..aa8023e --- /dev/null +++ b/app/Console/Commands/SyncStripePlans.php @@ -0,0 +1,96 @@ +error('STRIPE_SECRET ist nicht gesetzt.'); + + return self::FAILURE; + } + + $dryRun = (bool) $this->option('dry-run'); + $stripe = Cashier::stripe(); + + foreach (Plan::query()->active()->get() as $plan) { + if ($plan->stripe_product_id && $plan->stripe_price_id_monthly && $plan->stripe_price_id_yearly) { + $this->line("{$plan->slug}: vollständig verknüpft, übersprungen."); + + continue; + } + + if ($dryRun) { + $this->line(sprintf( + '[dry-run] %s: Produkt + Preise %s €/Monat, %s €/Jahr (netto) anlegen.', + $plan->slug, + number_format($plan->monthly_price_cents / 100, 2, ',', '.'), + number_format($plan->yearly_price_cents / 100, 2, ',', '.'), + )); + + continue; + } + + $productId = $plan->stripe_product_id; + + if (! $productId) { + $product = $stripe->products->create([ + 'name' => $plan->name, + 'metadata' => ['plan_slug' => $plan->slug], + ]); + $productId = $product->id; + } + + $monthlyId = $plan->stripe_price_id_monthly + ?: $this->createPrice($stripe, $productId, $plan, $plan->monthly_price_cents, 'month'); + + $yearlyId = $plan->stripe_price_id_yearly + ?: $this->createPrice($stripe, $productId, $plan, $plan->yearly_price_cents, 'year'); + + $plan->update([ + 'stripe_product_id' => $productId, + 'stripe_price_id_monthly' => $monthlyId, + 'stripe_price_id_yearly' => $yearlyId, + ]); + + $this->info("{$plan->slug}: {$productId} · monatlich {$monthlyId} · jährlich {$yearlyId}"); + } + + return self::SUCCESS; + } + + private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string + { + $price = $stripe->prices->create([ + 'product' => $productId, + 'currency' => strtolower($plan->currency), + 'unit_amount' => $unitAmount, + 'tax_behavior' => 'exclusive', + 'recurring' => ['interval' => $interval], + 'metadata' => ['plan_slug' => $plan->slug], + ]); + + return $price->id; + } +} diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php new file mode 100644 index 0000000..18a579a --- /dev/null +++ b/app/Listeners/ProcessStripeWebhook.php @@ -0,0 +1,145 @@ +payload['type'] ?? null) { + 'invoice.payment_succeeded' => $this->mirrorPaidInvoice($event->payload['data']['object'] ?? []), + 'checkout.session.completed' => $this->fulfillSinglePurchase($event->payload['data']['object'] ?? []), + default => null, + }; + } + + /** + * @param array $stripeInvoice + */ + private function mirrorPaidInvoice(array $stripeInvoice): void + { + $stripeInvoiceId = $stripeInvoice['id'] ?? null; + + if (! $stripeInvoiceId) { + return; + } + + // Idempotent: Stripe liefert Webhooks mindestens einmal. + if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) { + return; + } + + $user = Cashier::findBillable($stripeInvoice['customer'] ?? null); + + if (! $user instanceof User) { + Log::warning('STR-Spiegelung übersprungen: kein Billable zum Stripe-Customer.', [ + 'stripe_invoice_id' => $stripeInvoiceId, + 'stripe_customer' => $stripeInvoice['customer'] ?? null, + ]); + + return; + } + + $subtotal = (int) ($stripeInvoice['subtotal'] ?? 0); + $tax = (int) ($stripeInvoice['tax'] ?? 0); + $total = (int) ($stripeInvoice['total'] ?? $subtotal + $tax); + + $invoice = Invoice::query()->create([ + 'user_id' => $user->id, + 'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id, + 'number' => $this->numbers->nextStripeNumber(), + 'status' => InvoiceStatus::Paid->value, + 'amount_cents' => $subtotal, + 'tax_cents' => $tax, + 'total_cents' => $total, + 'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? 'eur')), + 'is_netto' => $tax === 0, + 'invoice_date' => now()->toDateString(), + 'paid_at' => now(), + 'stripe_invoice_id' => $stripeInvoiceId, + ]); + + Log::info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [ + 'number' => $invoice->number, + 'stripe_invoice_id' => $stripeInvoiceId, + ]); + } + + /** + * Adress-Snapshot pro Rechnung: bevorzugt die Adresse aus dem + * Stripe-Payload (maßgeblich für genau diese Rechnung), sonst die + * lokale Rechnungsadresse des Users. + * + * @param array $stripeInvoice + */ + private function snapshotAddress(User $user, array $stripeInvoice): InvoiceBillingAddress + { + $stripeAddress = $stripeInvoice['customer_address'] ?? null; + $local = $user->billingAddress; + + return InvoiceBillingAddress::query()->create([ + 'name' => $stripeInvoice['customer_name'] ?? $local?->name ?? $user->name, + 'address1' => $stripeAddress['line1'] ?? $local?->address1 ?? '', + 'address2' => $stripeAddress['line2'] ?? $local?->address2, + 'postal_code' => $stripeAddress['postal_code'] ?? $local?->postal_code ?? '', + 'city' => $stripeAddress['city'] ?? $local?->city ?? '', + 'country_code' => $stripeAddress['country'] ?? $local?->country_code ?? 'DE', + 'vat_id' => $local?->vat_id, + ]); + } + + /** + * @param array $session + */ + private function fulfillSinglePurchase(array $session): void + { + $purchaseId = $session['metadata']['single_purchase_id'] ?? null; + + if (! $purchaseId) { + return; + } + + $purchase = SinglePurchase::query()->find((int) $purchaseId); + + if (! $purchase || $purchase->status !== SinglePurchaseStatus::Pending) { + return; + } + + $purchase->update([ + 'status' => SinglePurchaseStatus::Paid->value, + 'paid_at' => now(), + 'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id, + 'stripe_payment_intent_id' => $session['payment_intent'] ?? null, + ]); + + Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]); + } +} diff --git a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md index a3caa2c..a0bb2c9 100644 --- a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md +++ b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md @@ -41,7 +41,7 @@ Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken: | **9C** ✅ | Submit-Gate-Schnittstelle (`hasActiveBooking()`-Stub, Modal-Hinweis, Server-Guard) + Fix: Create-Form lief am Funnel vorbei | M | gering | | — | **Review-Stopp mit User** | | | | **9D** ✅ | Tarif-Datenmodell: Pläne, Einzelkäufe, Cashier, Rechnungskreise STR-/MAN-, MAN-Fälligkeitslauf (Stub-Ablösung folgt mit 9E) | L | hoch (Datenmodell) | -| **9E** | Stripe-Anbindung (Cashier installiert ✅): Checkout, Webhooks, STR-Rechnungsspiegelung, Slot-Logik auf Plan-Kontingent | L | mittel | +| **9E** 🔄 | Stripe-Anbindung — Backbone ✅ (Produkt-Sync nach Stripe, Webhook-Listener mit STR-Spiegelung + Einmalkauf-Erfüllung); offen: Checkout-Flows, Webhook-Endpoint registrieren, Slot-Logik auf Plan-Kontingent | L | mittel | | **9F** | Tarif-Seite + Checkout-UI (Raster, Einzel-PM-Block, „2 Monate gratis", Enterprise-Hinweis) | M | gering | | **9G** | Tageslimit je Tier (Business 2 / Pro 3 / Agency 5; gilt auch für Extra-PMs) | S | gering | | **9H** | Einzel-PM-Kauf (19 €) + Einzel→Abo-Brücke (Anrechnung 30 Tage) | M | mittel | diff --git a/docs/user-admin/Billing-und-Rechnungskreise.md b/docs/user-admin/Billing-und-Rechnungskreise.md index 337f81a..4285228 100644 --- a/docs/user-admin/Billing-und-Rechnungskreise.md +++ b/docs/user-admin/Billing-und-Rechnungskreise.md @@ -129,12 +129,22 @@ Stripe/Cashier (`config/cashier.php`): --- -## 7. Offene Punkte (Stand 12.06.2026) +## 7. Offene Punkte (Stand 12.06.2026, nach 9E-Backbone) -1. **Phase 9E**: Stripe-Produkte/Preise anlegen (netto) + IDs in `plans` - pflegen, Checkout (Abo + Einmalkauf), Webhooks inkl. Spiegelung der - Stripe-Rechnungen nach `invoices` mit STR-Nummer, Slot-Logik von - `users.press_release_quota`-Stub auf Plan-Kontingent umstellen. +0. **9E-Backbone erledigt**: Tarife liegen als Netto-Produkte/Preise in + Stripe (Test-Mode, `billing:sync-stripe-plans`, IDs in `plans`); + Webhook-Listener `ProcessStripeWebhook` spiegelt bezahlte + Stripe-Rechnungen in den STR-Kreis (fortlaufende Nummer, + Adress-Snapshot aus dem Stripe-Payload, idempotent) und erfüllt + Einmalkäufe (`checkout.session.completed` → + `single_purchases.status = paid`). Cashier-Route `POST /stripe/webhook` + ist aktiv. +1. **Phase 9E (Rest)**: Checkout-Flows (Abo + Einmalkauf) inkl. + Buchungs-Seite, Webhook-Endpoint im Stripe-Dashboard registrieren + + `STRIPE_WEBHOOK_SECRET` setzen, Slot-Logik von + `users.press_release_quota`-Stub auf Plan-Kontingent umstellen + (fachlich zu klären: Kontingent-Semantik für Grandfathered — + Legacy-Produkt war „unbegrenzte PMs pro Pressemappe"). 2. **VIES-Validierung** der USt-ID (aktuell Formatprüfung). 3. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`); Archiv-PDFs existieren bereits on-demand. diff --git a/tests/Feature/Billing/StripeWebhookProcessingTest.php b/tests/Feature/Billing/StripeWebhookProcessingTest.php new file mode 100644 index 0000000..fd93a3b --- /dev/null +++ b/tests/Feature/Billing/StripeWebhookProcessingTest.php @@ -0,0 +1,143 @@ + 'invoice.payment_succeeded', + 'data' => [ + 'object' => array_replace_recursive([ + 'id' => 'in_test_123', + 'customer' => $user->stripe_id, + 'subtotal' => 4900, + 'tax' => 931, + 'total' => 5831, + 'currency' => 'eur', + 'customer_name' => 'Alpha GmbH', + 'customer_address' => [ + 'line1' => 'Beispielweg 1', + 'line2' => null, + 'postal_code' => '10115', + 'city' => 'Berlin', + 'country' => 'DE', + ], + ], $overrides), + ], + ]; +} + +test('a paid stripe invoice is mirrored into the STR circle', function () { + $user = User::factory()->create(['stripe_id' => 'cus_test_1']); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user))); + + $invoice = Invoice::sole(); + expect($invoice->number)->toBe('STR-00001'); + expect($invoice->user_id)->toBe($user->id); + expect($invoice->status)->toBe(InvoiceStatus::Paid); + expect($invoice->amount_cents)->toBe(4900); + expect($invoice->tax_cents)->toBe(931); + expect($invoice->total_cents)->toBe(5831); + expect($invoice->stripe_invoice_id)->toBe('in_test_123'); + expect($invoice->invoiceBillingAddress->city)->toBe('Berlin'); + expect($invoice->invoiceBillingAddress->name)->toBe('Alpha GmbH'); +}); + +test('duplicate webhook deliveries do not create a second invoice', function () { + $user = User::factory()->create(['stripe_id' => 'cus_test_1']); + $listener = app(ProcessStripeWebhook::class); + + $listener->handle(new WebhookReceived(stripeInvoicePayload($user))); + $listener->handle(new WebhookReceived(stripeInvoicePayload($user))); + + expect(Invoice::count())->toBe(1); +}); + +test('the snapshot falls back to the local billing address including vat id', function () { + $user = User::factory()->create(['stripe_id' => 'cus_test_1']); + BillingAddress::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Lokal GmbH', + 'country_code' => 'AT', + 'vat_id' => 'ATU12345678', + ]); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [ + 'customer_name' => null, + 'customer_address' => null, + ]))); + + $snapshot = Invoice::sole()->invoiceBillingAddress; + expect($snapshot->name)->toBe('Lokal GmbH'); + expect($snapshot->country_code)->toBe('AT'); + expect($snapshot->vat_id)->toBe('ATU12345678'); +}); + +test('an unknown stripe customer is skipped without an invoice', function () { + User::factory()->create(['stripe_id' => 'cus_other']); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload( + new User(['name' => 'Fremd']), + ['customer' => 'cus_unknown'], + ))); + + expect(Invoice::count())->toBe(0); +}); + +test('checkout completion marks the referenced single purchase as paid', function () { + $purchase = SinglePurchase::factory()->create(); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'id' => 'cs_test_1', + 'payment_intent' => 'pi_test_1', + 'metadata' => ['single_purchase_id' => (string) $purchase->id], + ], + ], + ])); + + $fresh = $purchase->fresh(); + expect($fresh->status)->toBe(SinglePurchaseStatus::Paid); + expect($fresh->paid_at)->not->toBeNull(); + expect($fresh->stripe_checkout_session_id)->toBe('cs_test_1'); + expect($fresh->stripe_payment_intent_id)->toBe('pi_test_1'); +}); + +test('an already paid purchase is not touched again', function () { + $purchase = SinglePurchase::factory()->paid()->create([ + 'stripe_payment_intent_id' => 'pi_original', + ]); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived([ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'id' => 'cs_test_2', + 'payment_intent' => 'pi_new', + 'metadata' => ['single_purchase_id' => (string) $purchase->id], + ], + ], + ])); + + expect($purchase->fresh()->stripe_payment_intent_id)->toBe('pi_original'); +}); + +test('the listener is wired to the cashier webhook event', function () { + Event::fake(); + + Event::assertListening( + WebhookReceived::class, + ProcessStripeWebhook::class, + ); +});