diff --git a/app/Services/Billing/StripePlanSyncService.php b/app/Services/Billing/StripePlanSyncService.php new file mode 100644 index 0000000..253b6e1 --- /dev/null +++ b/app/Services/Billing/StripePlanSyncService.php @@ -0,0 +1,121 @@ + $changes + */ + public function syncAfterUpdate(Plan $plan, array $changes): void + { + if (! $this->isConfigured()) { + return; + } + + $stripe = Cashier::stripe(); + + if (! $plan->stripe_product_id) { + $this->createProductWithPrices($stripe, $plan); + + return; + } + + if (array_key_exists('name', $changes)) { + $stripe->products->update($plan->stripe_product_id, ['name' => $plan->name]); + } + + if (array_key_exists('monthly_price_cents', $changes)) { + $plan->forceFill([ + 'stripe_price_id_monthly' => $this->rotatePrice( + $stripe, + $plan, + $plan->stripe_price_id_monthly, + $plan->monthly_price_cents, + 'month', + ), + ])->save(); + } + + if (array_key_exists('yearly_price_cents', $changes)) { + $plan->forceFill([ + 'stripe_price_id_yearly' => $this->rotatePrice( + $stripe, + $plan, + $plan->stripe_price_id_yearly, + $plan->yearly_price_cents, + 'year', + ), + ])->save(); + } + } + + /** + * Erstanlage für Tarife ohne Stripe-Verknüpfung — gleiche Struktur wie + * `billing:sync-stripe-plans`, nur direkt aus der Admin-Oberfläche. + */ + private function createProductWithPrices(StripeClient $stripe, Plan $plan): void + { + $product = $stripe->products->create([ + 'name' => $plan->name, + 'metadata' => ['plan_slug' => $plan->slug], + ]); + + $plan->forceFill([ + 'stripe_product_id' => $product->id, + 'stripe_price_id_monthly' => $this->createPrice($stripe, $product->id, $plan, $plan->monthly_price_cents, 'month'), + 'stripe_price_id_yearly' => $this->createPrice($stripe, $product->id, $plan, $plan->yearly_price_cents, 'year'), + ])->save(); + } + + /** + * Neuen Preis anlegen und den bisherigen (falls vorhanden) für neue + * Buchungen deaktivieren. Gibt die neue Price-ID zurück. + */ + private function rotatePrice(StripeClient $stripe, Plan $plan, ?string $oldPriceId, int $unitAmount, string $interval): string + { + $newPriceId = $this->createPrice($stripe, (string) $plan->stripe_product_id, $plan, $unitAmount, $interval); + + if ($oldPriceId) { + $stripe->prices->update($oldPriceId, ['active' => false]); + } + + return $newPriceId; + } + + private function createPrice(StripeClient $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/dev/frontend/hub-flux/PROGRESS.md b/dev/frontend/hub-flux/PROGRESS.md index b108c21..930df73 100644 --- a/dev/frontend/hub-flux/PROGRESS.md +++ b/dev/frontend/hub-flux/PROGRESS.md @@ -5,6 +5,34 @@ --- +## 2026-06-12 · Admin-Zahlungsmodul (P8-Rest) · Zahlungen + Tarif-Verwaltung ✅ + +- **Was**: Den Phase-8-Platzhalter `/admin/payments` durch das echte + Zahlungsmodul ersetzt: KPI-Reihe (aktive Abos, MRR netto, Umsatz + 30 Tage brutto, offene Einzel-PMs), Tabellen für Stripe-Abos (mit + Tarif-Auflösung über die Price-IDs), Einmalkäufe (Typ/Status/PM-Link) + und den lokalen Rechnungsausgang (STR-/MAN-Badge), User-Suche über + alle drei Bereiche. Neu: `/admin/payments/plans` — Tarif-Verwaltung + mit Edit-Modal (Name, Netto-Preise, PM-Kontingent, Tageslimit, + aktiv/inaktiv, Sortierung) und **Sofort-Sync nach Stripe** über den + neuen `StripePlanSyncService`: Preisänderung legt ein neues + Price-Objekt an und deaktiviert das alte (Stripe-Preise sind + unveränderlich), Namensänderung aktualisiert das Produkt, unverknüpfte + Tarife werden komplett angelegt. Bestandsabos behalten ihren Preis + (Hinweis in UI und Speichermeldung). Buchungs-Seite zieht die Preise + ohnehin live aus `plans` → Änderungen wirken sofort überall. + Sidebar: eigener Eintrag „Tarife & Pakete" unter Billing. +- **Dateien**: `resources/views/livewire/admin/payments/index.blade.php` + (Neufassung), `resources/views/livewire/admin/payments/plans.blade.php` + (neu), `app/Services/Billing/StripePlanSyncService.php` (neu), + `routes/admin.php`, Sidebar. +- **Build/Test**: Suite 532 passed / 4 skipped, Pint clean; 13 neue Tests + (`AdminPlansPageTest`, `AdminPaymentsPageTest`), Stripe im Test gemockt. +- **Offene Fragen**: Refund-Workflow aus dem Admin (vorerst über das + Stripe-Dashboard); Einzel-PM-Preis bleibt Config/ENV-basiert. +- **Nächster Schritt**: User-Panel-Restarbeiten (Kevin sammelt Liste), + Login/Registrierungs-Flow durchtesten, 9G Tageslimit. + ## 2026-06-12 · Phase 9F · Tarif-Seite + Checkout-UI ✅ - **Was**: „Buchungen & Add-ons" vom Credit-Konzept-Mock auf echte Daten diff --git a/docs/user-admin/Billing-und-Rechnungskreise.md b/docs/user-admin/Billing-und-Rechnungskreise.md index 9245e09..e913e51 100644 --- a/docs/user-admin/Billing-und-Rechnungskreise.md +++ b/docs/user-admin/Billing-und-Rechnungskreise.md @@ -134,6 +134,7 @@ Rechnungsadresse bestimmt: |---|---|---| | `billing:generate-manual-invoices` | MAN-Fälligkeitslauf (Abschnitt 3) | täglich 04:30 | | `billing:sync-stripe-plans` | Tarife + Einzel-PM als Netto-Produkte/Preise nach Stripe synchronisieren (idempotent; `--dry-run`) | manuell | +| — Admin-UI: `/admin/payments/plans` | Tarif-Pflege (Preise, Kontingent, Tageslimit, aktiv/inaktiv) mit Sofort-Sync nach Stripe (`StripePlanSyncService`): Preisänderung legt ein neues Price-Objekt an und deaktiviert das alte; Bestandsabos behalten ihren Preis | — | | `legacy:grandfather-subscriptions` | Aktive Legacy-Abos aus dem Archiv migrieren | manuell (Migrations-Runbook) | | `press-releases:reset-monthly-quota` | Monatlicher Reset des Plan-Kontingent-Zählers (`press_release_quota_used_this_month`) | monatlich, 1. um 00:05 | @@ -184,6 +185,12 @@ CLI ausgegebene `whsec_…` temporär als `STRIPE_WEBHOOK_SECRET` in die `.env`. 1. **Phase 9F erledigt** (12.06.2026): Die Buchungs-Seite zeigt das echte Tarif-Raster (Monat/Jahr-Toggle), den Einzel-PM-Block, Bestandstarife und „Abo verwalten" (Stripe Billing Portal, `me.checkout.billing-portal`). +1b. **Admin-Zahlungsmodul erledigt** (12.06.2026): `/admin/payments` zeigt + KPIs (aktive Abos, MRR netto, Umsatz 30 Tage, offene Einzel-PMs) plus + Abo-, Einmalkauf- und Rechnungstabellen (STR/MAN) mit User-Suche; + `/admin/payments/plans` pflegt die Tarife mit Sofort-Sync nach Stripe + (Abschnitt 5). Refund-Workflow direkt aus dem Admin bleibt offen + (vorerst über das Stripe-Dashboard). 2. **Stripe Tax**: im Dashboard aktiviert (12.06.2026, Produkt-Steuercode „SaaS – business use", Steuer nicht im Preis enthalten — passt zu den Netto-Preisen). Vor Relaunch im **Live-Mode** wiederholen; dort auch diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index cb1228a..f5e34b9 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -144,9 +144,13 @@ {{ __('Legacy Rechnungen') }} + :current="request()->routeIs('admin.payments.index')" wire:navigate> {{ __('Zahlungen') }} + + {{ __('Tarife & Pakete') }} + {{ __('Gutscheine') }} diff --git a/resources/views/livewire/admin/payments/index.blade.php b/resources/views/livewire/admin/payments/index.blade.php index 5427161..264523b 100644 --- a/resources/views/livewire/admin/payments/index.blade.php +++ b/resources/views/livewire/admin/payments/index.blade.php @@ -1,14 +1,102 @@ resetPage('subscriptionsPage'); + $this->resetPage('purchasesPage'); + $this->resetPage('invoicesPage'); + } + public function with(): array { - return []; + $plans = Plan::query()->get(); + + /** @var array $plansByPriceId */ + $plansByPriceId = []; + + foreach ($plans as $plan) { + if ($plan->stripe_price_id_monthly) { + $plansByPriceId[$plan->stripe_price_id_monthly] = ['plan' => $plan, 'interval' => __('monatlich')]; + } + if ($plan->stripe_price_id_yearly) { + $plansByPriceId[$plan->stripe_price_id_yearly] = ['plan' => $plan, 'interval' => __('jährlich')]; + } + } + + $activeSubscriptions = Subscription::query()->active()->get(); + + $monthlyRecurringCents = $activeSubscriptions->sum(function (Subscription $subscription) use ($plansByPriceId): int { + $entry = $plansByPriceId[$subscription->stripe_price] ?? null; + + if (! $entry) { + return 0; + } + + return $entry['interval'] === __('jährlich') + ? (int) round($entry['plan']->yearly_price_cents / 12) + : $entry['plan']->monthly_price_cents; + }); + + return [ + 'plansByPriceId' => $plansByPriceId, + 'stats' => [ + 'active_subscriptions' => $activeSubscriptions->count(), + 'mrr_cents' => $monthlyRecurringCents, + 'revenue_30d_cents' => (int) Invoice::query() + ->where('status', InvoiceStatus::Paid->value) + ->where('paid_at', '>=', now()->subDays(30)) + ->sum('total_cents'), + 'open_purchases' => SinglePurchase::query()->grantingSubmission()->count(), + ], + 'subscriptions' => $this->searchByUser(Subscription::query()->with('owner')) + ->latest('created_at') + ->paginate(25, pageName: 'subscriptionsPage'), + 'purchases' => $this->searchByUser(SinglePurchase::query()->with(['user', 'pressRelease'])) + ->latest('created_at') + ->paginate(25, pageName: 'purchasesPage'), + 'invoices' => $this->searchByUser(Invoice::query()->with('user')) + ->latest('invoice_date') + ->latest('id') + ->paginate(25, pageName: 'invoicesPage'), + ]; + } + + /** + * Wendet die User-Suche (Name oder E-Mail) auf eine der drei + * Zahlungs-Tabellen an. Abos hängen über `owner` am User, Käufe und + * Rechnungen über `user`. + */ + private function searchByUser(Builder $query): Builder + { + if (! filled($this->search)) { + return $query; + } + + $search = trim($this->search); + $relation = $query->getModel() instanceof Subscription ? 'owner' : 'user'; + + return $query->whereHas($relation, function (Builder $query) use ($search): void { + $query + ->where('name', 'like', '%'.$search.'%') + ->orWhere('email', 'like', '%'.$search.'%'); + }); } }; ?> @@ -19,40 +107,280 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
{{ __('Admin Backend') }} {{ __('Administration · Finanzen') }} - {{ __('In Vorbereitung') }}

{{ __('Zahlungen') }}

- {{ __('Zahlungsabwicklung läuft in Phase 8 ausschließlich über Stripe – alte Zahlungsarten (Rechnung, PayPal, SPK Berlin, Cortal Consors, Bar/Post) entfallen komplett.') }} + {{ __('Stripe-Abos, Einmalkäufe und der lokale Rechnungsausgang (STR-/MAN-Kreis) auf einen Blick. Stripe bleibt Zahlungs- und Belegquelle — diese Übersicht spiegelt die per Webhook synchronisierten Daten.') }}

+
+ + {{ __('Legacy-Rechnungen') }} + + + {{ __('Tarife & Pakete') }} + +
+ {{-- ============== KPI-Reihe ============== --}} +
+ + {{ __('Stripe-Subscriptions') }} + + + {{ __('monatlich wiederkehrend') }} + + + {{ __('bezahlte Rechnungen, brutto') }} + + + {{ __('bezahlt, noch nicht eingelöst') }} + +
+ + {{-- ============== SUCHE ============== --}}
- {{ __('Geplant für P8') }} + {{ __('Suche') }}
-
-
    -
  • - - {{ __('Live-Anzeige aller Stripe-Zahlungen mit Filtern nach Status, Methode und Zeitraum.') }} -
  • -
  • - - {{ __('Detail-Ansicht mit Stripe-Transaktions-ID, Webhook-Trail und zugeordneter Rechnung.') }} -
  • -
  • - - {{ __('Refund-Workflow direkt aus dem Admin (sofern Stripe-Berechtigung gegeben).') }} -
  • -
+
+ +
+
-

- {{ __('Datenmodell (user_payments, user_payment_options) ist bereits angelegt; die Anbindung folgt mit Stripe-Webhooks.') }} -

+ {{-- ============== ABOS ============== --}} +
+
+ {{ __('Abos') }} + + {{ __(':count Einträge', ['count' => number_format($subscriptions->total(), 0, ',', '.')]) }} + +
+ + + {{ __('User') }} + {{ __('Tarif') }} + {{ __('Status') }} + {{ __('Seit') }} + {{ __('Endet') }} + + + @forelse ($subscriptions as $subscription) + + + @if ($subscription->owner) +
+ + {{ $subscription->owner->name }} + +
{{ $subscription->owner->email }}
+
+ @else + + @endif +
+ + @php($planEntry = $plansByPriceId[$subscription->stripe_price] ?? null) + @if ($planEntry) +
+
{{ $planEntry['plan']->name }}
+
{{ $planEntry['interval'] }}
+
+ @else +
{{ $subscription->stripe_price ?? '–' }}
+ @endif +
+ + @if (in_array($subscription->stripe_status, ['active', 'trialing'], true)) + {{ $subscription->stripe_status === 'trialing' ? __('Testphase') : __('Aktiv') }} + @elseif (in_array($subscription->stripe_status, ['past_due', 'unpaid', 'incomplete'], true)) + {{ $subscription->stripe_status }} + @else + {{ $subscription->stripe_status }} + @endif + + + {{ $subscription->created_at?->format('d.m.Y') ?? '–' }} + + + {{ $subscription->ends_at?->format('d.m.Y') ?? '–' }} + +
+ @empty + + +
+ {{ __('Noch keine Stripe-Abos vorhanden.') }} +
+
+
+ @endforelse +
+
+ {{ $subscriptions->links('components.portal.pagination') }} +
+
+ + {{-- ============== EINMALKÄUFE ============== --}} +
+
+ {{ __('Einmalkäufe') }} + + {{ __(':count Einträge', ['count' => number_format($purchases->total(), 0, ',', '.')]) }} + +
+ + + {{ __('User') }} + {{ __('Typ') }} + {{ __('Betrag (netto)') }} + {{ __('Status') }} + {{ __('Bezahlt am') }} + {{ __('Eingelöst für') }} + + + @forelse ($purchases as $purchase) + + + @if ($purchase->user) +
+ + {{ $purchase->user->name }} + +
{{ $purchase->user->email }}
+
+ @else + + @endif +
+ + {{ $purchase->type->label() }} + + + {{ number_format($purchase->price_cents / 100, 2, ',', '.') }} € + + + @if ($purchase->status === \App\Enums\SinglePurchaseStatus::Paid) + {{ $purchase->status->label() }} + @elseif ($purchase->status === \App\Enums\SinglePurchaseStatus::Consumed) + {{ $purchase->status->label() }} + @elseif ($purchase->status === \App\Enums\SinglePurchaseStatus::Pending) + {{ $purchase->status->label() }} + @else + {{ $purchase->status->label() }} + @endif + + + {{ $purchase->paid_at?->format('d.m.Y H:i') ?? '–' }} + + + @if ($purchase->pressRelease) + + {{ \Illuminate\Support\Str::limit($purchase->pressRelease->title, 40) }} + + @else + + @endif + +
+ @empty + + +
+ {{ __('Noch keine Einmalkäufe vorhanden.') }} +
+
+
+ @endforelse +
+
+ {{ $purchases->links('components.portal.pagination') }} +
+
+ + {{-- ============== RECHNUNGEN (STR/MAN) ============== --}} +
+
+ {{ __('Rechnungsausgang (STR/MAN)') }} + + {{ __(':count Einträge', ['count' => number_format($invoices->total(), 0, ',', '.')]) }} + +
+ + + {{ __('Nummer') }} + {{ __('Kreis') }} + {{ __('User') }} + {{ __('Betrag (brutto)') }} + {{ __('Status') }} + {{ __('Rechnungsdatum') }} + + + @forelse ($invoices as $invoice) + + + {{ $invoice->number }} + + + @if ($invoice->stripe_invoice_id) + {{ __('Stripe (STR)') }} + @else + {{ __('Manuell (MAN)') }} + @endif + + + @if ($invoice->user) +
+ + {{ $invoice->user->name }} + +
{{ $invoice->user->email }}
+
+ @else + + @endif +
+ + {{ number_format($invoice->total_cents / 100, 2, ',', '.') }} € + + + @if ($invoice->status === \App\Enums\InvoiceStatus::Paid) + {{ $invoice->status->label() }} + @elseif ($invoice->status === \App\Enums\InvoiceStatus::Open) + {{ $invoice->status->label() }} + @else + {{ $invoice->status->label() }} + @endif + + +
+
{{ $invoice->invoice_date?->format('d.m.Y') ?? '–' }}
+ @if ($invoice->paid_at) +
{{ __('bezahlt: :date', ['date' => $invoice->paid_at->format('d.m.Y')]) }}
+ @endif +
+
+
+ @empty + + +
+ {{ __('Noch keine Rechnungen im neuen Rechnungsausgang.') }} +
+
+
+ @endforelse +
+
+ {{ $invoices->links('components.portal.pagination') }}
diff --git a/resources/views/livewire/admin/payments/plans.blade.php b/resources/views/livewire/admin/payments/plans.blade.php new file mode 100644 index 0000000..6fe9781 --- /dev/null +++ b/resources/views/livewire/admin/payments/plans.blade.php @@ -0,0 +1,288 @@ +findOrFail($planId); + + $this->editingPlanId = $plan->id; + $this->name = $plan->name; + $this->monthlyPrice = number_format($plan->monthly_price_cents / 100, 2, ',', ''); + $this->yearlyPrice = number_format($plan->yearly_price_cents / 100, 2, ',', ''); + $this->quota = (string) $plan->press_release_quota; + $this->dailyLimit = $plan->daily_limit === null ? '' : (string) $plan->daily_limit; + $this->isActive = $plan->is_active; + $this->sortOrder = (string) $plan->sort_order; + $this->resetValidation(); + + Flux::modal('plan-edit')->show(); + } + + public function save(StripePlanSyncService $stripeSync): void + { + // Deutsche Dezimal-Eingaben (49,00) für die numeric-Regel normalisieren. + $this->monthlyPrice = str_replace(',', '.', trim($this->monthlyPrice)); + $this->yearlyPrice = str_replace(',', '.', trim($this->yearlyPrice)); + + $validated = $this->validate( + [ + 'name' => ['required', 'string', 'max:120'], + 'monthlyPrice' => ['required', 'numeric', 'min:0'], + 'yearlyPrice' => ['required', 'numeric', 'min:0'], + 'quota' => ['required', 'integer', 'min:0'], + 'dailyLimit' => ['nullable', 'integer', 'min:1'], + 'sortOrder' => ['required', 'integer', 'min:0'], + ], + attributes: [ + 'name' => __('Name'), + 'monthlyPrice' => __('Monatspreis'), + 'yearlyPrice' => __('Jahrespreis'), + 'quota' => __('PM-Kontingent'), + 'dailyLimit' => __('Tageslimit'), + 'sortOrder' => __('Sortierung'), + ], + ); + + $plan = Plan::query()->findOrFail($this->editingPlanId); + + $plan->fill([ + 'name' => trim($validated['name']), + 'monthly_price_cents' => $this->toCents($validated['monthlyPrice']), + 'yearly_price_cents' => $this->toCents($validated['yearlyPrice']), + 'press_release_quota' => (int) $validated['quota'], + 'daily_limit' => $validated['dailyLimit'] === null || $validated['dailyLimit'] === '' ? null : (int) $validated['dailyLimit'], + 'is_active' => $this->isActive, + 'sort_order' => (int) $validated['sortOrder'], + ]); + + $priceChanged = $plan->isDirty(['monthly_price_cents', 'yearly_price_cents']); + + $plan->save(); + $stripeSync->syncAfterUpdate($plan, $plan->getChanges()); + + $this->savedMessage = $priceChanged + ? __('Tarif „:name" gespeichert. Der neue Preis gilt sofort für neue Buchungen — Bestandsabos behalten ihren bisherigen Preis.', ['name' => $plan->name]) + : __('Tarif „:name" gespeichert.', ['name' => $plan->name]); + + Flux::modal('plan-edit')->close(); + } + + /** + * Wandelt eine Preiseingabe (deutsches oder englisches Dezimalformat) + * verlustfrei in Cent um. + */ + private function toCents(string $price): int + { + return (int) round(((float) str_replace(',', '.', $price)) * 100); + } + + public function with(): array + { + return [ + 'plans' => Plan::query()->orderBy('sort_order')->orderBy('id')->get(), + 'singlePmPriceCents' => (int) config('billing.single_pm_price_cents'), + 'singlePmPriceId' => config('billing.single_pm_stripe_price_id'), + ]; + } +}; ?> + +
+ {{-- ============== PAGE HEADER ============== --}} +
+
+
+ {{ __('Admin Backend') }} + {{ __('Administration · Finanzen') }} +
+

+ {{ __('Tarife & Pakete') }} +

+

+ {{ __('Preise, Kontingente und Limits der Tarife pflegen. Änderungen erscheinen sofort auf der Buchungs-Seite und werden direkt nach Stripe synchronisiert.') }} +

+
+
+ + {{ __('Zahlungen') }} + +
+
+ + @if ($savedMessage) +
+ +
{{ $savedMessage }}
+
+ @endif + + {{-- ============== HINWEIS STRIPE-PREISLOGIK ============== --}} +
+ +
+ {{ __('Stripe-Preise sind unveränderlich: Eine Preisänderung legt automatisch ein neues Preis-Objekt in Stripe an und deaktiviert das alte für neue Buchungen. Laufende Abos behalten ihren bisherigen Preis. Alle Preise sind Netto-Preise — die Umsatzsteuer ergänzt Stripe Tax im Checkout.') }} +
+
+ + {{-- ============== TARIF-TABELLE ============== --}} +
+
+ {{ __('Tarife') }} + + {{ __(':count Tarife', ['count' => $plans->count()]) }} + +
+ + + {{ __('Tarif') }} + {{ __('Monatlich (netto)') }} + {{ __('Jährlich (netto)') }} + {{ __('PM-Kontingent') }} + {{ __('Tageslimit') }} + {{ __('Stripe') }} + {{ __('Status') }} + + + + @forelse ($plans as $plan) + + +
+
{{ $plan->name }}
+
{{ $plan->slug }}
+
+
+ + {{ number_format($plan->monthly_price_cents / 100, 2, ',', '.') }} € + + + {{ number_format($plan->yearly_price_cents / 100, 2, ',', '.') }} € + + + {{ __(':count PM / Monat', ['count' => $plan->press_release_quota]) }} + + + {{ $plan->daily_limit ? __('max. :count / Tag', ['count' => $plan->daily_limit]) : __('ohne') }} + + + @if ($plan->stripe_product_id && $plan->stripe_price_id_monthly && $plan->stripe_price_id_yearly) + {{ __('verknüpft') }} + @else + {{ __('nicht synchronisiert') }} + @endif + + + @if ($plan->is_active) + {{ __('Aktiv') }} + @else + {{ __('Inaktiv') }} + @endif + + + + {{ __('Bearbeiten') }} + + +
+ @empty + + +
+ {{ __('Noch keine Tarife angelegt. Der Tarif-Katalog wird über den Seeder bzw. die Migrationen befüllt.') }} +
+
+
+ @endforelse +
+
+ + {{-- ============== EINZEL-PM (KONFIGURATION) ============== --}} +
+
+ {{ __('Einzel-Pressemitteilung') }} +
+
+ +
+

+ {{ __('Preis: :price € netto pro Veröffentlichung.', ['price' => number_format($singlePmPriceCents / 100, 2, ',', '.')]) }} + @if ($singlePmPriceId) + {{ __('Stripe verknüpft') }} + @else + {{ __('STRIPE_PRICE_SINGLE_PM fehlt') }} + @endif +

+

+ {{ __('Der Einzel-PM-Preis wird in config/billing.php bzw. über die ENV-Variable STRIPE_PRICE_SINGLE_PM gepflegt (ein fester Preis, kein Tarif). Eine Änderung erfordert „billing:sync-stripe-plans" mit geleerter ENV-Variable.') }} +

+
+
+
+ + {{-- ============== EDIT-MODAL ============== --}} + +
+
+ {{ __('Tarif bearbeiten') }} + + {{ __('Preisänderungen erzeugen ein neues Stripe-Preis-Objekt und gelten nur für neue Buchungen.') }} + +
+ +
+ + +
+ + +
+ +
+ + + +
+ + +
+ +
+ + {{ __('Abbrechen') }} + + + {{ __('Speichern & mit Stripe abgleichen') }} + +
+
+
+
diff --git a/routes/admin.php b/routes/admin.php index 35bb9d1..9ae8256 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -64,6 +64,7 @@ Route::middleware(['auth', 'verified', EnsureUserIsAdmin::class, LogSlowAdminReq Volt::route('admin/invoices', 'admin.invoices.index')->name('admin.invoices.index'); Route::get('admin/legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('admin.legacy-invoices.pdf'); Volt::route('admin/payments', 'admin.payments.index')->name('admin.payments.index'); + Volt::route('admin/payments/plans', 'admin.payments.plans')->name('admin.payments.plans'); Volt::route('admin/coupons', 'admin.coupons.index')->name('admin.coupons.index'); Volt::route('admin/newsletter-sync', 'admin.newsletter.sync')->name('admin.newsletter.sync'); diff --git a/tests/Feature/Billing/AdminPaymentsPageTest.php b/tests/Feature/Billing/AdminPaymentsPageTest.php new file mode 100644 index 0000000..6ca4afd --- /dev/null +++ b/tests/Feature/Billing/AdminPaymentsPageTest.php @@ -0,0 +1,150 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +function paymentsPageAdmin(): User +{ + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + return $admin; +} + +test('the payments page shows subscriptions with plan name and mrr', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['name' => 'Abo Kunde']); + $plan = Plan::factory()->create([ + 'name' => 'Business', + 'monthly_price_cents' => 4900, + 'stripe_price_id_monthly' => 'price_test_m_biz', + ]); + subscribeUserToPlan($customer, $plan); + + $this->actingAs(paymentsPageAdmin()); + + LivewireVolt::test('admin.payments.index') + ->assertSee('Aktive Abos') + ->assertSee('MRR (netto)') + ->assertSee('49,00 €') + ->assertSee('Abo Kunde') + ->assertSee('Business') + ->assertSee('monatlich'); +}); + +test('a yearly subscription contributes one twelfth to the mrr', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(); + $plan = Plan::factory()->create([ + 'monthly_price_cents' => 4900, + 'yearly_price_cents' => 49000, + 'stripe_price_id_monthly' => 'price_test_m_y', + 'stripe_price_id_yearly' => 'price_test_y_y', + ]); + subscribeUserToPlan($customer, $plan, 'yearly'); + + $this->actingAs(paymentsPageAdmin()); + + LivewireVolt::test('admin.payments.index') + ->assertSee('40,83 €') + ->assertSee('jährlich'); +}); + +test('single purchases appear with type and status', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['name' => 'Einzel Käufer']); + SinglePurchase::factory()->paid()->create(['user_id' => $customer->id]); + + $this->actingAs(paymentsPageAdmin()); + + LivewireVolt::test('admin.payments.index') + ->assertSee('Einzel Käufer') + ->assertSee('Einzel-Pressemitteilung') + ->assertSee('Bezahlt') + ->assertSee('19,00 €'); +}); + +test('local invoices appear with number and circle badge', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(); + Invoice::factory()->create([ + 'user_id' => $customer->id, + 'number' => 'STR-2026-000042', + 'status' => InvoiceStatus::Paid->value, + 'paid_at' => now(), + 'stripe_invoice_id' => 'in_test_123', + ]); + Invoice::factory()->create([ + 'user_id' => $customer->id, + 'number' => 'MAN-2026-000007', + 'status' => InvoiceStatus::Open->value, + 'stripe_invoice_id' => null, + ]); + + $this->actingAs(paymentsPageAdmin()); + + LivewireVolt::test('admin.payments.index') + ->assertSee('STR-2026-000042') + ->assertSee('Stripe (STR)') + ->assertSee('MAN-2026-000007') + ->assertSee('Manuell (MAN)'); +}); + +test('paid invoices of the last 30 days are summed as revenue', function () { + /** @var TestCase $this */ + Invoice::factory()->create([ + 'status' => InvoiceStatus::Paid->value, + 'paid_at' => now()->subDays(5), + 'total_cents' => 11900, + ]); + // Außerhalb des 30-Tage-Fensters: erscheint in der Tabelle, + // zählt aber nicht in die Umsatz-KPI (sonst stünde dort 1.118,00 €). + Invoice::factory()->create([ + 'status' => InvoiceStatus::Paid->value, + 'paid_at' => now()->subDays(60), + 'total_cents' => 99900, + ]); + + $this->actingAs(paymentsPageAdmin()); + + LivewireVolt::test('admin.payments.index') + ->assertSee('Umsatz 30 Tage') + ->assertSee('119,00 €') + ->assertDontSee('1.118,00 €'); +}); + +test('the search filters all panels by user name or email', function () { + /** @var TestCase $this */ + $match = User::factory()->create(['name' => 'Maria Treffer']); + $other = User::factory()->create(['name' => 'Olaf Anders']); + SinglePurchase::factory()->paid()->create(['user_id' => $match->id]); + SinglePurchase::factory()->paid()->create(['user_id' => $other->id]); + + $this->actingAs(paymentsPageAdmin()); + + LivewireVolt::test('admin.payments.index') + ->set('search', 'Maria') + ->assertSee('Maria Treffer') + ->assertDontSee('Olaf Anders'); +}); + +test('the payments page is not accessible for customers', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $this->actingAs($customer) + ->get(route('admin.payments.index')) + ->assertForbidden(); +}); diff --git a/tests/Feature/Billing/AdminPlansPageTest.php b/tests/Feature/Billing/AdminPlansPageTest.php new file mode 100644 index 0000000..467237f --- /dev/null +++ b/tests/Feature/Billing/AdminPlansPageTest.php @@ -0,0 +1,159 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +function plansPageAdmin(): User +{ + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + return $admin; +} + +test('the plans page lists active and inactive plans with prices', function () { + /** @var TestCase $this */ + Plan::factory()->create([ + 'name' => 'Business', + 'monthly_price_cents' => 4900, + 'yearly_price_cents' => 49000, + 'press_release_quota' => 10, + 'daily_limit' => 2, + 'stripe_product_id' => 'prod_test', + 'stripe_price_id_monthly' => 'price_test_m', + 'stripe_price_id_yearly' => 'price_test_y', + ]); + Plan::factory()->inactive()->create(['name' => 'Altpaket']); + + $this->actingAs(plansPageAdmin()); + + LivewireVolt::test('admin.payments.plans') + ->assertSee('Tarife & Pakete') + ->assertSee('Business') + ->assertSee('49,00 €') + ->assertSee('490,00 €') + ->assertSee('10 PM / Monat') + ->assertSee('max. 2 / Tag') + ->assertSee('verknüpft') + ->assertSee('Altpaket') + ->assertSee('Inaktiv'); +}); + +test('a plan without stripe ids is marked as unsynced', function () { + /** @var TestCase $this */ + Plan::factory()->create(['name' => 'Neu', 'stripe_product_id' => null]); + + $this->actingAs(plansPageAdmin()); + + LivewireVolt::test('admin.payments.plans') + ->assertSee('nicht synchronisiert'); +}); + +test('saving a plan updates the local record and triggers the stripe sync', function () { + /** @var TestCase $this */ + $plan = Plan::factory()->create([ + 'name' => 'Business', + 'monthly_price_cents' => 4900, + 'yearly_price_cents' => 49000, + 'press_release_quota' => 10, + 'daily_limit' => null, + ]); + + $this->mock(StripePlanSyncService::class, function ($mock) use ($plan) { + $mock->shouldReceive('syncAfterUpdate') + ->once() + ->withArgs(function (Plan $synced, array $changes) use ($plan): bool { + return $synced->is($plan) + && array_key_exists('monthly_price_cents', $changes) + && array_key_exists('name', $changes); + }); + }); + + $this->actingAs(plansPageAdmin()); + + LivewireVolt::test('admin.payments.plans') + ->call('edit', $plan->id) + ->set('name', 'Business Plus') + ->set('monthlyPrice', '59,00') + ->set('yearlyPrice', '590') + ->set('quota', '15') + ->set('dailyLimit', '3') + ->call('save') + ->assertHasNoErrors() + ->assertSee('Bestandsabos behalten ihren bisherigen Preis'); + + $plan->refresh(); + + expect($plan->name)->toBe('Business Plus') + ->and($plan->monthly_price_cents)->toBe(5900) + ->and($plan->yearly_price_cents)->toBe(59000) + ->and($plan->press_release_quota)->toBe(15) + ->and($plan->daily_limit)->toBe(3); +}); + +test('saving without a price change shows the plain success message', function () { + /** @var TestCase $this */ + $plan = Plan::factory()->create([ + 'name' => 'Starter', + 'monthly_price_cents' => 2900, + 'yearly_price_cents' => 29000, + 'daily_limit' => 1, + ]); + + $this->mock(StripePlanSyncService::class, function ($mock) { + $mock->shouldReceive('syncAfterUpdate')->once(); + }); + + $this->actingAs(plansPageAdmin()); + + LivewireVolt::test('admin.payments.plans') + ->call('edit', $plan->id) + ->set('quota', '5') + ->set('dailyLimit', '') + ->call('save') + ->assertHasNoErrors() + ->assertSee('Tarif „Starter" gespeichert.') + ->assertDontSee('Bestandsabos behalten'); + + $plan->refresh(); + + expect($plan->press_release_quota)->toBe(5) + ->and($plan->daily_limit)->toBeNull(); +}); + +test('invalid prices are rejected with validation errors', function () { + /** @var TestCase $this */ + $plan = Plan::factory()->create(); + + $this->mock(StripePlanSyncService::class, function ($mock) { + $mock->shouldNotReceive('syncAfterUpdate'); + }); + + $this->actingAs(plansPageAdmin()); + + LivewireVolt::test('admin.payments.plans') + ->call('edit', $plan->id) + ->set('monthlyPrice', '-5') + ->set('quota', 'abc') + ->call('save') + ->assertHasErrors(['monthlyPrice', 'quota']); +}); + +test('the plans page is not accessible for customers', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $this->actingAs($customer) + ->get(route('admin.payments.plans')) + ->assertForbidden(); +});