presseportale/app/Console/Commands/ExpireGrandfathering.php
Kevin Adametz 9a88e82baa billing/dev: Vertragsende in der MAN-Abrechnung, Rechnungslauf ohne Tageskappe, Qualitätsgates (Audit Block 5+6, Deploy E)
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 <noreply@anthropic.com>
2026-09-18 10:41:04 +00:00

99 lines
3.6 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Enums\UserPaymentOptionStatus;
use App\Mail\GrandfatheringExpired;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\ActivityLog;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Beendet Bestandsschutz-Posten, deren explizites Legacy-Vertragsende
* (`grandfathered_until`, aus valid_until_date importiert) überschritten ist
* (Audit F06, Plan 5.2).
*
* Bisher endete ein solcher Vertrag nur im Zugriff (scopeGrandfatheredNow),
* nicht im Datensatz: Status blieb `grandfathered`, der MAN-Rechnungslauf
* hätte weiter abgerechnet, und niemand sagte dem Kunden Bescheid. Jetzt
* läuft der Befehl täglich vor dem Rechnungslauf, setzt die Posten auf
* `cancelled` und schickt je Kunde genau eine Mail. Idempotent: bereits
* gekündigte Posten werden nicht mehr angefasst.
*/
class ExpireGrandfathering extends Command
{
protected $signature = 'billing:expire-grandfathering
{--dry-run : Nur anzeigen, welche Posten beendet würden}';
protected $description = 'Beendet Bestandsschutz-Posten mit überschrittenem Legacy-Vertragsende und informiert den Kunden.';
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
{
$expired = UserPaymentOption::query()
->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;
}
}