From eb95b3b8305618e959eb92b2b298758ec1602674 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 18 Sep 2026 12:03:04 +0000 Subject: [PATCH] billing: Vertragsablauf bedingt beenden, Kundenmail nachholbar (Block 7, U11/U12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Beenden je Kunde in einer Transaktion mit erneuter Prüfung aller Auswahlbedingungen unter Zeilensperre; Ereignis nur für getroffene Zeilen - Marker grandfathering_expired_at/-notified_at; Mail in zweiter Phase, bei Fehler beim Einplanen FAILURE und Nachholen im nächsten Lauf Co-Authored-By: Claude Fable 5.1 --- app/Console/Commands/ExpireGrandfathering.php | 205 ++++++++++++++---- app/Models/UserPaymentOption.php | 4 + ..._markers_to_user_payment_options_table.php | 42 ++++ .../Billing/ExpireGrandfatheringTest.php | 158 ++++++++++++++ 4 files changed, 363 insertions(+), 46 deletions(-) create mode 100644 database/migrations/2026_09_18_180100_add_grandfathering_expiry_markers_to_user_payment_options_table.php diff --git a/app/Console/Commands/ExpireGrandfathering.php b/app/Console/Commands/ExpireGrandfathering.php index 3738713a..1e7e4ae7 100644 --- a/app/Console/Commands/ExpireGrandfathering.php +++ b/app/Console/Commands/ExpireGrandfathering.php @@ -10,6 +10,8 @@ use App\Models\UserPaymentOption; use App\Services\ActivityLog; use App\Services\Mail\EmailSuppressionService; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; @@ -21,79 +23,190 @@ use Illuminate\Support\Facades\Mail; * 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. + * läuft der Befehl täglich vor dem Rechnungslauf in zwei Phasen: + * + * Phase 1 (Audit U11): je Kunde in einer Transaktion. Die Kandidaten werden + * unter Zeilensperre mit allen Auswahlbedingungen erneut gelesen und nur mit + * denselben Bedingungen auf `cancelled` gesetzt (`cancelled_at` und + * `grandfathering_expired_at` im selben Schreibzugriff). Eine zwischenzeitliche + * Verlängerung bleibt so erhalten; ein überlappender Lauf trifft null Zeilen + * und schreibt weder Ereignis noch Benachrichtigungsauftrag. Das + * ActivityLog-Ereignis liegt im selben Commit und zählt nur die tatsächlich + * getroffenen Posten. + * + * Phase 2 (Audit U12): alle Posten mit `grandfathering_expired_at` ohne + * `grandfathering_notified_at` werden je Kunde mit genau einer Mail + * nachgeholt und erst danach als benachrichtigt markiert. Scheitert das + * Einplanen, bleibt der Marker leer, der Befehl meldet FAILURE (Scheduler- + * Alarm) und der nächste Lauf holt die Mail nach – ohne den Vertrag erneut + * anzufassen. Gesperrte Adressen werden ohne Mail als erledigt markiert. */ class ExpireGrandfathering extends Command { protected $signature = 'billing:expire-grandfathering - {--dry-run : Nur anzeigen, welche Posten beendet würden}'; + {--dry-run : Nur anzeigen, welche Posten beendet und welche Kunden benachrichtigt 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(); + $candidates = $this->expiredQuery()->with('user')->get(); + $pending = $this->pendingNotificationQuery()->with('user')->get(); - if ($expired->isEmpty()) { - $this->info('Kein Bestandsschutz mit überschrittenem Vertragsende.'); + if ($candidates->isEmpty() && $pending->isEmpty()) { + $this->info('Kein Bestandsschutz mit überschrittenem Vertragsende, keine offene Benachrichtigung.'); return self::SUCCESS; } if ($this->option('dry-run')) { - foreach ($expired as $option) { + foreach ($candidates as $option) { $this->line(sprintf('[dry-run] Posten #%d (User #%s) endete am %s', $option->id, $option->user_id, $option->grandfathered_until?->toDateString())); } + foreach ($pending as $option) { + $this->line(sprintf('[dry-run] Posten #%d (User #%s): Benachrichtigung seit %s offen', $option->id, $option->user_id, $option->grandfathering_expired_at?->toDateTimeString())); + } + return self::SUCCESS; } - $ended = 0; + $ended = $this->endExpired($candidates, $activityLog); - foreach ($expired->groupBy('user_id') as $userId => $options) { - /** @var User|null $user */ - $user = $options->first()?->user; + [$notified, $failed] = $this->notifyPending($this->pendingNotificationQuery()->with('user')->get(), $suppressions); - 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(), - ]); - }); + $this->info(sprintf('%d Bestandsschutz-Posten beendet, %d Kunden benachrichtigt.', $ended, $notified)); - if ($user === null) { - continue; - } + if ($failed > 0) { + $this->error(sprintf('%d Benachrichtigung(en) konnten nicht eingeplant werden – der nächste Lauf holt sie nach.', $failed)); - $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)); - } + return self::FAILURE; } - $this->info(sprintf('%d Bestandsschutz-Posten beendet.', $ended)); - return self::SUCCESS; } + + /** + * Phase 1: bedingtes Beenden je Kunde. Liefert die Zahl der tatsächlich + * von diesem Lauf beendeten Posten. + * + * @param Collection $candidates + */ + private function endExpired(Collection $candidates, ActivityLog $activityLog): int + { + $ended = 0; + + foreach ($candidates->groupBy('user_id') as $options) { + /** @var User|null $user */ + $user = $options->first()?->user; + $ids = $options->pluck('id')->all(); + + $ended += DB::transaction(function () use ($ids, $user, $activityLog): int { + /** @var Collection $current */ + $current = $this->expiredQuery()->whereIn('id', $ids)->lockForUpdate()->get(); + + if ($current->isEmpty()) { + return 0; + } + + $now = now(); + + $affected = $this->expiredQuery() + ->whereIn('id', $current->pluck('id')->all()) + ->update([ + 'status' => UserPaymentOptionStatus::Cancelled->value, + 'cancelled_at' => $now, + 'grandfathering_expired_at' => $now, + ]); + + if ($affected === 0) { + return 0; + } + + $latestEnd = $current->max(fn (UserPaymentOption $option) => $option->grandfathered_until); + + $activityLog->record( + ActivityType::SubscriptionChange, + sprintf('Bestandsschutz zum Vertragsende %s beendet (%d Posten).', $latestEnd?->format('d.m.Y'), $affected), + $user, + null, + ['action' => 'expire_grandfathering', 'posts' => $affected, 'until' => $latestEnd?->toDateString()], + 'system', + ); + + return $affected; + }); + } + + return $ended; + } + + /** + * Phase 2: je Kunde genau eine Mail für alle offenen Posten, danach + * Marker setzen. Liefert [benachrichtigte Kunden, fehlgeschlagene Kunden]. + * + * @param Collection $pending + * @return array{0: int, 1: int} + */ + private function notifyPending(Collection $pending, EmailSuppressionService $suppressions): array + { + $notified = 0; + $failed = 0; + + foreach ($pending->groupBy('user_id') as $userId => $options) { + /** @var User|null $user */ + $user = $options->first()?->user; + $ids = $options->pluck('id')->all(); + + if ($user !== null && ! $suppressions->isSuppressed($user->email)) { + $latestEnd = $options->max(fn (UserPaymentOption $option) => $option->grandfathered_until); + + try { + Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd)); + } catch (\Throwable $exception) { + report($exception); + $this->error(sprintf('Benachrichtigung für User #%s konnte nicht eingeplant werden: %s', $userId, $exception->getMessage())); + $failed++; + + continue; + } + + $notified++; + } + + UserPaymentOption::query() + ->whereIn('id', $ids) + ->whereNull('grandfathering_notified_at') + ->update(['grandfathering_notified_at' => now()]); + } + + return [$notified, $failed]; + } + + /** + * Auswahlbedingungen für abgelaufenen Bestandsschutz – bei Auswahl, + * Sperre und UPDATE identisch (U11). + * + * @return Builder + */ + private function expiredQuery(): Builder + { + return UserPaymentOption::query() + ->where('status', UserPaymentOptionStatus::Grandfathered->value) + ->whereNull('cancelled_at') + ->whereNotNull('grandfathered_until') + ->whereDate('grandfathered_until', '<', today()); + } + + /** + * Beendete Posten, deren Kunde noch nicht informiert wurde (U12). + * + * @return Builder + */ + private function pendingNotificationQuery(): Builder + { + return UserPaymentOption::query() + ->whereNotNull('grandfathering_expired_at') + ->whereNull('grandfathering_notified_at'); + } } diff --git a/app/Models/UserPaymentOption.php b/app/Models/UserPaymentOption.php index bd3f2dfc..a49251bc 100644 --- a/app/Models/UserPaymentOption.php +++ b/app/Models/UserPaymentOption.php @@ -26,6 +26,8 @@ class UserPaymentOption extends Model 'billing_hold_at', 'stripe_subscription_id', 'cancelled_at', + 'grandfathering_expired_at', + 'grandfathering_notified_at', ]; protected function casts(): array @@ -38,6 +40,8 @@ class UserPaymentOption extends Model 'current_period_end' => 'date', 'billing_hold_at' => 'datetime', 'cancelled_at' => 'datetime', + 'grandfathering_expired_at' => 'datetime', + 'grandfathering_notified_at' => 'datetime', ]; } diff --git a/database/migrations/2026_09_18_180100_add_grandfathering_expiry_markers_to_user_payment_options_table.php b/database/migrations/2026_09_18_180100_add_grandfathering_expiry_markers_to_user_payment_options_table.php new file mode 100644 index 00000000..5b606054 --- /dev/null +++ b/database/migrations/2026_09_18_180100_add_grandfathering_expiry_markers_to_user_payment_options_table.php @@ -0,0 +1,42 @@ +timestamp('grandfathering_expired_at')->nullable()->after('cancelled_at'); + } + + if (! Schema::hasColumn('user_payment_options', 'grandfathering_notified_at')) { + $table->timestamp('grandfathering_notified_at')->nullable()->after('grandfathering_expired_at'); + } + }); + } + + public function down(): void + { + Schema::table('user_payment_options', function (Blueprint $table) { + $table->dropColumn(array_filter([ + Schema::hasColumn('user_payment_options', 'grandfathering_expired_at') ? 'grandfathering_expired_at' : null, + Schema::hasColumn('user_payment_options', 'grandfathering_notified_at') ? 'grandfathering_notified_at' : null, + ])); + }); + } +}; diff --git a/tests/Feature/Billing/ExpireGrandfatheringTest.php b/tests/Feature/Billing/ExpireGrandfatheringTest.php index b24954f8..83f30dd2 100644 --- a/tests/Feature/Billing/ExpireGrandfatheringTest.php +++ b/tests/Feature/Billing/ExpireGrandfatheringTest.php @@ -1,12 +1,16 @@ id !== $target->id) { + return; + } + + $done = true; + $mutation(); + }); +} + +test('a contract end extended between selection and update is kept (U11)', function () { + /** @var TestCase $this */ + Mail::fake(); + Carbon::setTestNow('2026-09-18 04:15:00'); + + $extended = grandfatheredAgreement('2026-09-17'); + + onFirstRetrieval($extended, fn () => UserPaymentOption::query() + ->whereKey($extended->id) + ->update(['grandfathered_until' => '2027-09-17'])); + + $this->artisan(ExpireGrandfathering::class)->assertSuccessful(); + + expect($extended->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered) + ->and($extended->fresh()->cancelled_at)->toBeNull() + ->and($extended->fresh()->grandfathered_until->toDateString())->toBe('2027-09-17') + ->and(ActivityEvent::query()->where('type', ActivityType::SubscriptionChange->value)->count())->toBe(0); + + Mail::assertNothingQueued(); + + Carbon::setTestNow(); +}); + +test('a contract already ended by an overlapping run gets no second event or mail (U11)', function () { + /** @var TestCase $this */ + Mail::fake(); + Carbon::setTestNow('2026-09-18 04:15:00'); + + $raced = grandfatheredAgreement('2026-09-17'); + + onFirstRetrieval($raced, fn () => UserPaymentOption::query() + ->whereKey($raced->id) + ->update([ + 'status' => UserPaymentOptionStatus::Cancelled->value, + 'cancelled_at' => now()->subSecond(), + 'grandfathering_expired_at' => now()->subSecond(), + 'grandfathering_notified_at' => now()->subSecond(), + ])); + + $this->artisan(ExpireGrandfathering::class)->assertSuccessful(); + + expect($raced->fresh()->cancelled_at->toDateTimeString())->toBe('2026-09-18 04:14:59') + ->and(ActivityEvent::query()->where('type', ActivityType::SubscriptionChange->value)->count())->toBe(0); + + Mail::assertNothingQueued(); + + Carbon::setTestNow(); +}); + +test('a failed enqueue is retried on the next run without touching the contract again (U12)', function () { + /** @var TestCase $this */ + Carbon::setTestNow('2026-09-18 04:15:00'); + + $expired = grandfatheredAgreement('2026-09-17'); + + // Partieller Proxy auf den echten MailManager: nur `to()` scheitert, + // alles andere (auch das spätere Mail::fake()) läuft normal weiter. + $brokenMailer = Mockery::mock(app('mail.manager'))->makePartial(); + $brokenMailer->shouldReceive('to')->once()->andThrow(new RuntimeException('Queue weg')); + Mail::swap($brokenMailer); + + $this->artisan(ExpireGrandfathering::class)->assertFailed(); + + $afterFirstRun = $expired->fresh(); + expect($afterFirstRun->status)->toBe(UserPaymentOptionStatus::Cancelled) + ->and($afterFirstRun->cancelled_at)->not->toBeNull() + ->and($afterFirstRun->grandfathering_expired_at)->not->toBeNull() + ->and($afterFirstRun->grandfathering_notified_at)->toBeNull() + ->and(ActivityEvent::query()->where('type', ActivityType::SubscriptionChange->value)->count())->toBe(1); + + // Nächster Lauf: Nachbenachrichtigung, kein zweiter Zustandswechsel. + Mail::fake(); + Carbon::setTestNow('2026-09-19 04:15:00'); + + $this->artisan(ExpireGrandfathering::class)->assertSuccessful(); + + Mail::assertQueued(GrandfatheringExpired::class, 1); + Mail::assertQueued(GrandfatheringExpired::class, fn (GrandfatheringExpired $mail) => $mail->user->is($expired->user) + && $mail->endedOn?->toDateString() === '2026-09-17'); + + $afterSecondRun = $expired->fresh(); + expect($afterSecondRun->cancelled_at->toDateTimeString())->toBe($afterFirstRun->cancelled_at->toDateTimeString()) + ->and($afterSecondRun->grandfathering_expired_at->toDateTimeString())->toBe($afterFirstRun->grandfathering_expired_at->toDateTimeString()) + ->and($afterSecondRun->grandfathering_notified_at?->toDateTimeString())->toBe('2026-09-19 04:15:00') + ->and(ActivityEvent::query()->where('type', ActivityType::SubscriptionChange->value)->count())->toBe(1); + + // Dritter Lauf: nichts mehr offen. + $this->artisan(ExpireGrandfathering::class)->assertSuccessful(); + Mail::assertQueued(GrandfatheringExpired::class, 1); + + Carbon::setTestNow(); +}); + +test('a suppressed address is marked as notified without a mail so it does not hang (U12)', function () { + /** @var TestCase $this */ + Mail::fake(); + Carbon::setTestNow('2026-09-18 04:15:00'); + + $expired = grandfatheredAgreement('2026-09-17'); + app(EmailSuppressionService::class)->suppress($expired->user->email, EmailSuppressionReason::HardBounce, 'imap'); + + $this->artisan(ExpireGrandfathering::class)->assertSuccessful(); + + Mail::assertNothingQueued(); + expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled) + ->and($expired->fresh()->grandfathering_notified_at)->not->toBeNull(); + + Carbon::setTestNow(); +}); + +test('dry-run lists pending notifications and changes nothing', function () { + /** @var TestCase $this */ + Mail::fake(); + Carbon::setTestNow('2026-09-18 04:15:00'); + + $expired = grandfatheredAgreement('2026-09-17'); + $pending = grandfatheredAgreement('2026-09-01', [ + 'status' => UserPaymentOptionStatus::Cancelled->value, + 'cancelled_at' => now()->subDay(), + 'grandfathering_expired_at' => now()->subDay(), + 'grandfathering_notified_at' => null, + ]); + + $this->artisan(ExpireGrandfathering::class, ['--dry-run' => true]) + ->expectsOutputToContain(sprintf('Posten #%d', $expired->id)) + ->expectsOutputToContain(sprintf('Posten #%d', $pending->id)) + ->assertSuccessful(); + + Mail::assertNothingQueued(); + expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered) + ->and($pending->fresh()->grandfathering_notified_at)->toBeNull(); + + Carbon::setTestNow(); +});