WS-4: Bestandsschutz-Abrechnung – gekoppelte Kündigung + Admin-Sichtbarkeit

M1 (portalübergreifend) und M5 (04:30-Worker) verifiziert; M2 + M3 umgesetzt.

- M2 (Entscheidung: getrennte Rechnungen, gekoppelte Kündigung): neuer
  LegacySubscriptionService::cancelBundleFor() kündigt das gesamte
  Bestandsschutz-Bündel eines Kunden gemeinsam – keine Einzelkündigung. Kein
  Rechnungs-Schema-Umbau (jede Kondition behält ihre MAN-Rechnung).
- M3: Admin-Payments-Ansicht zeigt eine "Bestandsschutz-Posten"-Tabelle
  (Badge, Portal, Netto, nächste Fälligkeit, "Bündel (N)") + Bündel-Kündigung
  mit Bestätigungsmodal.
- Tests: tests/Feature/Billing/LegacyBundleTest.php (M1 cross-portal, M2 Bündel-
  Kündigung + Gate, M3 Admin-Liste + Kündigung). Detailplan WS-4 dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-16 15:20:18 +00:00
parent 6668f492ad
commit be7d1799a5
4 changed files with 372 additions and 0 deletions

View file

@ -0,0 +1,56 @@
<?php
namespace App\Services\Billing;
use App\Enums\UserPaymentOptionStatus;
use App\Models\User;
use App\Models\UserPaymentOption;
use Illuminate\Support\Collection;
/**
* Bestandsschutz-Vereinbarungen (WS-4, M2).
*
* Legacy-Posten eines Kunden gelten als Bündel: Sie werden je Portal als eigene
* Kondition geführt (kein Tarif-Merge), sind aber NUR GEMEINSAM kündbar es gibt
* bewusst keine Einzelkündigung. Die Abrechnung selbst bleibt pro Posten (eigene
* MAN-Rechnung, siehe ManualInvoiceService).
*/
class LegacySubscriptionService
{
/**
* Noch laufende Bestandsschutz-Posten eines Kunden (das „Bündel").
*
* @return Collection<int, UserPaymentOption>
*/
public function grandfatheredFor(User $user): Collection
{
return UserPaymentOption::query()
->where('user_id', $user->getKey())
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->whereNull('cancelled_at')
->get();
}
/**
* Kündigt das GESAMTE Bestandsschutz-Bündel eines Kunden gemeinsam. Es gibt
* keine Einzelkündigung alle laufenden Grandfathered-Posten werden in einem
* Schritt auf `cancelled` gesetzt, womit der 04:30-Worker keine weiteren
* MAN-Rechnungen mehr erzeugt (duePaymentOptions berücksichtigt nur
* Active/Grandfathered).
*
* @return int Anzahl der gekündigten Posten
*/
public function cancelBundleFor(User $user): int
{
$posts = $this->grandfatheredFor($user);
foreach ($posts as $post) {
$post->update([
'status' => UserPaymentOptionStatus::Cancelled->value,
'cancelled_at' => now(),
]);
}
return $posts->count();
}
}