From 9a88e82baa219c8549f4d5f29ecc529b56b31968 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 18 Sep 2026 10:41:04 +0000 Subject: [PATCH] =?UTF-8?q?billing/dev:=20Vertragsende=20in=20der=20MAN-Ab?= =?UTF-8?q?rechnung,=20Rechnungslauf=20ohne=20Tageskappe,=20Qualit=C3=A4ts?= =?UTF-8?q?gates=20(Audit=20Block=205+6,=20Deploy=20E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Block 5 — MAN-Abrechnung F06 (E6): Ein explizites Legacy-Vertragsende (grandfathered_until) deckelt jetzt Auswahl und Sperr-Nachprüfung: Beginnt die nächste Periode nach dem Vertragsende, gibt es keine Rechnung mehr. Neuer täglicher Befehl billing:expire-grandfathering (04:15, vor dem Rechnungslauf) setzt abgelaufene Posten auf gekündigt und schickt je Kunde eine Mail (GrandfatheringExpired). Auf der Dev-Kopie trägt kein Posten ein Vertragsende; der Fehler war latent. F16: Nicht abrechenbare Vereinbarungen bekommen einen Sperrvermerk (billing_hold_reason/-at), sichtbar auf der Admin-Zahlungsseite, und stehen im Rechnungslauf hinten an. Der Lauf arbeitet den fälligen Bestand in Seiten ab; --limit deckelt die erstellten Rechnungen (500), nicht die betrachteten Fälle. Vorher blockierten 50 unvollständige Fälle die 51. dauerhaft, und der Durchsatz war auf 50 am Tag begrenzt. Block 6 — Entwicklung F24: Lint-Workflow prüft mit pint --test statt zu formatieren; keine Schreibrechte mehr. F27: composer run dev startet beide Vite-Server (npm run dev:all). F28: docs/README.md als historischer Stand markiert, toter Link auf das Hub-Flux-Archiv korrigiert, aktueller Einstieg dev/README.md. F26: Login-Modal übernimmt den Fokus, hält Tab im Dialog und gibt den Fokus beim Schließen an den Auslöser zurück – ohne neue Abhängigkeit. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/lint.yml | 17 +--- app/Console/Commands/ExpireGrandfathering.php | 99 +++++++++++++++++++ .../Commands/GenerateManualInvoices.php | 84 ++++++++++------ app/Mail/GrandfatheringExpired.php | 48 +++++++++ app/Models/UserPaymentOption.php | 3 + app/Services/Billing/ManualInvoiceService.php | 51 ++++++++-- composer.json | 2 +- ...ing_hold_to_user_payment_options_table.php | 41 ++++++++ docs/README.md | 22 +++-- .../components/web/site-header.blade.php | 33 ++++++- .../billing/grandfathering-expired.blade.php | 28 ++++++ .../livewire/admin/payments/index.blade.php | 7 +- routes/console.php | 10 ++ .../Billing/ExpireGrandfatheringTest.php | 61 ++++++++++++ .../Billing/ManualInvoiceGenerationTest.php | 77 +++++++++++++++ 15 files changed, 518 insertions(+), 65 deletions(-) create mode 100644 app/Console/Commands/ExpireGrandfathering.php create mode 100644 app/Mail/GrandfatheringExpired.php create mode 100644 database/migrations/2026_09_18_180000_add_billing_hold_to_user_payment_options_table.php create mode 100644 resources/views/emails/billing/grandfathering-expired.blade.php create mode 100644 tests/Feature/Billing/ExpireGrandfatheringTest.php diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c3a441b7..ade6264f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,7 @@ on: - main permissions: - contents: write + contents: read jobs: quality: @@ -33,14 +33,7 @@ jobs: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist npm install - - name: Run Pint - run: vendor/bin/pint - - # - name: Commit Changes - # uses: stefanzweifel/git-auto-commit-action@v5 - # with: - # commit_message: fix code style - # commit_options: '--no-verify' - # file_pattern: | - # **/* - # !.github/workflows/* + # Prüfen, nicht formatieren (Audit F24): vorher lief Pint im Schreibmodus + # und der Job war grün, obwohl der Commit selbst unformatiert blieb. + - name: Check code style + run: vendor/bin/pint --test diff --git a/app/Console/Commands/ExpireGrandfathering.php b/app/Console/Commands/ExpireGrandfathering.php new file mode 100644 index 00000000..3738713a --- /dev/null +++ b/app/Console/Commands/ExpireGrandfathering.php @@ -0,0 +1,99 @@ +where('status', UserPaymentOptionStatus::Grandfathered->value) + ->whereNull('cancelled_at') + ->whereNotNull('grandfathered_until') + ->whereDate('grandfathered_until', '<', today()) + ->with('user') + ->get(); + + if ($expired->isEmpty()) { + $this->info('Kein Bestandsschutz mit überschrittenem Vertragsende.'); + + return self::SUCCESS; + } + + if ($this->option('dry-run')) { + foreach ($expired as $option) { + $this->line(sprintf('[dry-run] Posten #%d (User #%s) endete am %s', $option->id, $option->user_id, $option->grandfathered_until?->toDateString())); + } + + return self::SUCCESS; + } + + $ended = 0; + + foreach ($expired->groupBy('user_id') as $userId => $options) { + /** @var User|null $user */ + $user = $options->first()?->user; + + DB::transaction(function () use ($options, &$ended): void { + $ended += UserPaymentOption::query() + ->whereIn('id', $options->pluck('id')->all()) + ->whereNull('cancelled_at') + ->update([ + 'status' => UserPaymentOptionStatus::Cancelled->value, + 'cancelled_at' => now(), + ]); + }); + + if ($user === null) { + continue; + } + + $latestEnd = $options->max(fn (UserPaymentOption $option) => $option->grandfathered_until); + + $activityLog->record( + ActivityType::SubscriptionChange, + sprintf('Bestandsschutz zum Vertragsende %s beendet (%d Posten).', $latestEnd?->format('d.m.Y'), $options->count()), + $user, + null, + ['action' => 'expire_grandfathering', 'posts' => $options->count(), 'until' => $latestEnd?->toDateString()], + 'system', + ); + + if (! $suppressions->isSuppressed($user->email)) { + Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd)); + } + } + + $this->info(sprintf('%d Bestandsschutz-Posten beendet.', $ended)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/GenerateManualInvoices.php b/app/Console/Commands/GenerateManualInvoices.php index 551ff03f..1f49539b 100644 --- a/app/Console/Commands/GenerateManualInvoices.php +++ b/app/Console/Commands/GenerateManualInvoices.php @@ -11,14 +11,22 @@ use Illuminate\Console\Command; * Läuft täglich über den Scheduler und stellt für aktive Legacy- * Zahlungsvereinbarungen (ohne Stripe-Subscription) die fälligen * Rechnungen aus — wie im Legacy-System. Nicht abrechenbare - * Vereinbarungen (fehlende Rechnungsadresse o. ä.) werden geloggt und - * beim nächsten Lauf erneut geprüft. + * Vereinbarungen (fehlende Rechnungsadresse o. ä.) werden geloggt, mit + * Sperrvermerk versehen und beim nächsten Lauf erneut geprüft. + * + * Der Lauf arbeitet den gesamten fälligen Bestand in Seiten ab (Audit + * F16): Vorher nahm er die 50 ältesten Fälligkeiten; standen dort 50 + * nicht abrechenbare Fälle, kam die 51. nie dran, und ohne Blocker war + * der Durchsatz auf 50 Rechnungen am Tag begrenzt. `--limit` deckelt jetzt + * die Zahl der erstellten Rechnungen, nicht der betrachteten Fälle. */ class GenerateManualInvoices extends Command { + private const PAGE_SIZE = 100; + protected $signature = 'billing:generate-manual-invoices {--dry-run : Nur anzeigen, was fällig ist} - {--limit=50 : Maximale Anzahl Vereinbarungen pro Lauf}'; + {--limit=500 : Maximale Anzahl erstellter Rechnungen pro Lauf}'; protected $description = 'Stellt fällige Rechnungen des manuellen Legacy-Rechnungskreises (MAN-) aus'; @@ -27,40 +35,54 @@ class GenerateManualInvoices extends Command $dryRun = (bool) $this->option('dry-run'); $limit = max(1, (int) $this->option('limit')); - $due = $service->duePaymentOptions(limit: $limit); + $created = 0; + $skipped = 0; + $seen = []; - if ($due->isEmpty()) { + while ($created < $limit) { + $due = $service->duePaymentOptions(limit: self::PAGE_SIZE, excludeIds: $seen); + + if ($due->isEmpty()) { + break; + } + + foreach ($due as $option) { + $seen[] = $option->id; + + if ($dryRun) { + $this->line(sprintf( + '[dry-run] Fällig: Vereinbarung #%d (User #%s, Periode bis %s)%s', + $option->id, + $option->user_id, + $option->current_period_end->toDateString(), + $option->billing_hold_reason ? ' – gesperrt: '.$option->billing_hold_reason : '', + )); + + continue; + } + + if ($created >= $limit) { + break; + } + + $invoice = $service->invoiceFor($option); + + if ($invoice) { + $created++; + $this->line(sprintf('Rechnung %s für Vereinbarung #%d erstellt.', $invoice->number, $option->id)); + } else { + $skipped++; + $this->warn(sprintf('Vereinbarung #%d übersprungen: %s.', $option->id, $option->refresh()->billing_hold_reason ?? 'nicht abrechenbar')); + } + } + } + + if ($seen === []) { $this->info('Keine fälligen Zahlungsvereinbarungen gefunden.'); return self::SUCCESS; } - $created = 0; - $skipped = 0; - - foreach ($due as $option) { - if ($dryRun) { - $this->line(sprintf( - '[dry-run] Fällig: Vereinbarung #%d (User #%s, Periode bis %s)', - $option->id, - $option->user_id, - $option->current_period_end->toDateString(), - )); - - continue; - } - - $invoice = $service->invoiceFor($option); - - if ($invoice) { - $created++; - $this->line(sprintf('Rechnung %s für Vereinbarung #%d erstellt.', $invoice->number, $option->id)); - } else { - $skipped++; - $this->warn(sprintf('Vereinbarung #%d übersprungen (siehe Log).', $option->id)); - } - } - if (! $dryRun) { $this->info(sprintf('%d Rechnung(en) erstellt, %d übersprungen.', $created, $skipped)); } diff --git a/app/Mail/GrandfatheringExpired.php b/app/Mail/GrandfatheringExpired.php new file mode 100644 index 00000000..42dbd16c --- /dev/null +++ b/app/Mail/GrandfatheringExpired.php @@ -0,0 +1,48 @@ +bookingUrl = route('me.bookings.index'); + } + + public function envelope(): Envelope + { + $bcc = (string) config('legal.contact_email'); + + return new Envelope( + subject: 'Ihr Bestandsschutz auf pressekonto ist zum Vertragsende ausgelaufen', + bcc: $bcc !== '' ? [$bcc] : [], + ); + } + + public function content(): Content + { + return new Content(view: 'emails.billing.grandfathering-expired'); + } +} diff --git a/app/Models/UserPaymentOption.php b/app/Models/UserPaymentOption.php index 87ed0708..bd3f2dfc 100644 --- a/app/Models/UserPaymentOption.php +++ b/app/Models/UserPaymentOption.php @@ -22,6 +22,8 @@ class UserPaymentOption extends Model 'legacy_conditions', 'current_period_start', 'current_period_end', + 'billing_hold_reason', + 'billing_hold_at', 'stripe_subscription_id', 'cancelled_at', ]; @@ -34,6 +36,7 @@ class UserPaymentOption extends Model 'legacy_conditions' => 'array', 'current_period_start' => 'date', 'current_period_end' => 'date', + 'billing_hold_at' => 'datetime', 'cancelled_at' => 'datetime', ]; } diff --git a/app/Services/Billing/ManualInvoiceService.php b/app/Services/Billing/ManualInvoiceService.php index 55ea23e8..7527ca08 100644 --- a/app/Services/Billing/ManualInvoiceService.php +++ b/app/Services/Billing/ManualInvoiceService.php @@ -9,6 +9,7 @@ use App\Models\InvoiceBillingAddress; use App\Models\LegacyInvoice; use App\Models\UserPaymentOption; use App\Services\Billing\Concerns\LocksModelForUpdate; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; @@ -40,9 +41,16 @@ class ManualInvoiceService ) {} /** + * Fällige, abrechenbare Vereinbarungen. Ein explizites Legacy-Vertrags- + * ende (`grandfathered_until`) deckelt (Audit F06, E6): Liegt der Beginn + * der nächsten Periode dahinter, gibt es keine Rechnung mehr. Blockierte + * Fälle (Sperrvermerk) stehen hinten, damit sie das Fenster nicht + * verstopfen (Audit F16). + * + * @param list $excludeIds bereits in diesem Lauf behandelte IDs * @return Collection */ - public function duePaymentOptions(?Carbon $asOf = null, int $limit = 50): Collection + public function duePaymentOptions(?Carbon $asOf = null, int $limit = 50, array $excludeIds = []): Collection { $asOf = $asOf ?? today(); @@ -53,6 +61,11 @@ class ManualInvoiceService ]) ->whereNull('stripe_subscription_id') ->whereDate('current_period_end', '<=', $asOf) + ->where(fn (Builder $query) => $query + ->whereNull('grandfathered_until') + ->orWhereColumn('grandfathered_until', '>=', 'current_period_end')) + ->when($excludeIds !== [], fn (Builder $query) => $query->whereNotIn('id', $excludeIds)) + ->orderByRaw('(billing_hold_at is not null)') ->orderBy('current_period_end') ->limit($limit) ->with(['user.billingAddress', 'paymentOption']) @@ -77,6 +90,9 @@ class ManualInvoiceService ], true) || $lockedOption->stripe_subscription_id !== null || $lockedOption->current_period_end->isAfter($asOf) + // Vertragsende vor Beginn der nächsten Periode (Audit F06, E6). + || ($lockedOption->grandfathered_until !== null + && $lockedOption->grandfathered_until->lt($lockedOption->current_period_end)) ) { return null; } @@ -86,26 +102,20 @@ class ManualInvoiceService $interval = $this->billingInterval($lockedOption); if (! $user) { - Log::warning('MAN-Rechnung übersprungen: Vereinbarung ohne User.', ['user_payment_option_id' => $lockedOption->id]); - - return null; + return $this->hold($lockedOption, 'Kein Benutzer', ['user_payment_option_id' => $lockedOption->id]); } if (! $interval) { - Log::warning('MAN-Rechnung übersprungen: kein abrechenbares Intervall.', ['user_payment_option_id' => $lockedOption->id]); - - return null; + return $this->hold($lockedOption, 'Kein abrechenbares Intervall', ['user_payment_option_id' => $lockedOption->id]); } $billingAddress = $user->billingAddress; if (! $billingAddress) { - Log::warning('MAN-Rechnung übersprungen: User ohne Rechnungsadresse.', [ + return $this->hold($lockedOption, 'Keine Rechnungsadresse', [ 'user_payment_option_id' => $lockedOption->id, 'user_id' => $user->id, ]); - - return null; } $netCents = $this->resolveNetCents($lockedOption); @@ -155,6 +165,8 @@ class ManualInvoiceService $lockedOption->update([ 'legacy_conditions' => $conditions ?: null, + 'billing_hold_reason' => null, + 'billing_hold_at' => null, 'current_period_start' => $lockedOption->current_period_end, 'current_period_end' => $interval === 'yearly' ? $lockedOption->current_period_end->copy()->addYear() @@ -165,6 +177,25 @@ class ManualInvoiceService }); } + /** + * Nicht abrechenbar: Sperrvermerk setzen (Audit F16), Periode bleibt. + * Der Vermerk erscheint in der Admin-Zahlungsseite und sortiert den Fall + * im Rechnungslauf nach hinten; ein erfolgreicher Lauf löscht ihn. + * + * @param array $context + */ + private function hold(UserPaymentOption $option, string $reason, array $context): null + { + Log::warning('MAN-Rechnung übersprungen: '.$reason.'.', $context); + + $option->forceFill([ + 'billing_hold_reason' => $reason, + 'billing_hold_at' => now(), + ])->save(); + + return null; + } + /** * @param array{number?: string, id?: int} $openLegacyInvoice */ diff --git a/composer.json b/composer.json index 90768d28..1096c338 100644 --- a/composer.json +++ b/composer.json @@ -68,7 +68,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,classification\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,classification\" \"php artisan pail --timeout=0\" \"npm run dev:all\" --names=server,queue,logs,vite" ], "analyse": [ "vendor/bin/phpstan analyse --memory-limit=1G" diff --git a/database/migrations/2026_09_18_180000_add_billing_hold_to_user_payment_options_table.php b/database/migrations/2026_09_18_180000_add_billing_hold_to_user_payment_options_table.php new file mode 100644 index 00000000..78d8313d --- /dev/null +++ b/database/migrations/2026_09_18_180000_add_billing_hold_to_user_payment_options_table.php @@ -0,0 +1,41 @@ +string('billing_hold_reason', 120)->nullable()->after('current_period_end'); + } + + if (! Schema::hasColumn('user_payment_options', 'billing_hold_at')) { + $table->timestamp('billing_hold_at')->nullable()->after('billing_hold_reason'); + } + }); + } + + public function down(): void + { + Schema::table('user_payment_options', function (Blueprint $table) { + $table->dropColumn(array_filter([ + Schema::hasColumn('user_payment_options', 'billing_hold_reason') ? 'billing_hold_reason' : null, + Schema::hasColumn('user_payment_options', 'billing_hold_at') ? 'billing_hold_at' : null, + ])); + }); + } +}; diff --git a/docs/README.md b/docs/README.md index fe0f28ee..8dc7cae3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,16 @@ # `docs/` — Konzept- und Status-Dokumente -Stand: 12.06.2026 — Phase 8, die KI-Prüf-Pipeline (Phasen 0–5) sowie aus -Phase 9 die Stripe-Anbindung (9E), die Tarif-Seite (9F), das +**Historischer Stand (12.06.2026, vor dem Go-Live am 20.07.2026).** Die +Statusangaben in diesem Ordner beschreiben den Planungsstand der Konzeptphase; +was seitdem umgesetzt wurde, steht nicht hier. Aktueller Einstieg für +Betrieb, Backlog und Deploy: [`dev/README.md`](../dev/README.md) mit +[`dev/roadmap.md`](../dev/roadmap.md) und `dev/2026-09-01/`. + +Ursprünglicher Stand 12.06.2026 — Phase 8, die KI-Prüf-Pipeline (Phasen 0–5) +sowie aus Phase 9 die Stripe-Anbindung (9E), die Tarif-Seite (9F), das Admin-Zahlungsmodul, die User-Panel-Restarbeiten und das -Verlinkungs-Decision-Update sind abgeschlossen. Nächste Blöcke: die zwei -neuen Decision-Updates in `weiteres/` (Phase-2-Funktionen & Magic-Link, +Verlinkungs-Decision-Update sind abgeschlossen. Damals nächste Blöcke: die +zwei Decision-Updates in `weiteres/` (Phase-2-Funktionen & Magic-Link, Duplicate-Content), dazu 9G Tageslimit und der Login-/Registrierungs-Flow. Diese README ist der schnellste Einstieg in den `docs/`-Ordner. @@ -19,7 +25,7 @@ Sie verlinkt die zentralen Dokumente und sortiert sie nach „Was ist der aktuel | Wie wird das umgesetzt (aktueller Plan)? | [`PHASE-9-FLOW-UND-TARIFE-PLAN.md`](./PHASE-9-FLOW-UND-TARIFE-PLAN.md) | | Wie funktioniert die KI-Prüfung (Klassifikation, Score, Audit)? | [`user-admin/Entwicklungsplan KI-Pruefung und Veroeffentlichung.md`](./user-admin/Entwicklungsplan%20KI-Pruefung%20und%20Veroeffentlichung.md) | | Was ist pro Phase erledigt, was offen? | [`user-admin/checkliste-user-backend.md`](./user-admin/checkliste-user-backend.md) | -| Welche Hub-Flux-Phasen sind durch? | [`../dev/frontend/hub-flux/PROGRESS.md`](../dev/frontend/hub-flux/PROGRESS.md) | +| Welche Hub-Flux-Phasen sind durch? | [`../dev/archive/frontend/hub-flux/PROGRESS.md`](../dev/archive/frontend/hub-flux/PROGRESS.md) (Archiv) | ## Aufbau @@ -74,7 +80,7 @@ In den überarbeiteten Dokumenten finden sich folgende Markierungen: | **IST-Stand JJJJ-MM-TT** | Kompakte Notiz oben am Abschnitt, was im Code tatsächlich umgesetzt ist. | | Phase 1 / Phase 7 / Phase 8 | Abgeschlossene Roadmap-Blöcke: Grund-User-Backend, PM-Form-Refactor, User-Panel-Konsolidierung. | | KI-Pipeline (Phasen 0–5) | Klassifikation, Routing, Content-Score — abgeschlossen 11.06.2026, siehe Entwicklungsplan. | -| Hub-Flux | Visuelle Migrationsphase des User Backends, gepflegt in `dev/frontend/hub-flux/`. | +| Hub-Flux | Visuelle Migrationsphase des User Backends, gepflegt in `dev/archive/frontend/hub-flux/` (Archiv). | | Phase 2 / Phase 3 | Später — Magic-Link-Flow, Re-Check/Redigieren, Trust-Score, Notice-and-Action, Statistik. | | Launch-Block (offen) | Zahlung/Tarife, Submit-Gate, Slot-Verbrauch bei Veröffentlichung — siehe Decision-Update. | @@ -85,6 +91,6 @@ In den überarbeiteten Dokumenten finden sich folgende Markierungen: - Jeder Phasen-Abschluss aktualisiert - `user-admin/checkliste-user-backend.md` (Erledigt-Block), - `STATUS-ABGLEICH-USER-PANEL.md` (Abgleich), - - `dev/frontend/hub-flux/PROGRESS.md` (Tagebuch), - - und ggf. die Detail-Doku in `dev/frontend/hub-flux/`. + - `dev/archive/frontend/hub-flux/PROGRESS.md` (Tagebuch, Archiv), + - und ggf. die Detail-Doku in `dev/archive/frontend/hub-flux/`. - Neue große Themen bekommen ein eigenes Plan-Dokument auf `docs/`-Top-Level (z. B. `PHASE-8-USER-PANEL-PLAN.md`). diff --git a/resources/views/components/web/site-header.blade.php b/resources/views/components/web/site-header.blade.php index e26a73a1..026cd8cd 100644 --- a/resources/views/components/web/site-header.blade.php +++ b/resources/views/components/web/site-header.blade.php @@ -100,7 +100,35 @@ {{-- Header (Logo + Suche mittig + Buttons rechtsbündig + Burger) --}}