billing: Vertragsablauf bedingt beenden, Kundenmail nachholbar (Block 7, U11/U12)

- 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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 12:03:04 +00:00
parent 235d3928ab
commit 1cd9fd11af
4 changed files with 363 additions and 46 deletions

View file

@ -1,12 +1,16 @@
<?php
use App\Console\Commands\ExpireGrandfathering;
use App\Enums\ActivityType;
use App\Enums\EmailSuppressionReason;
use App\Enums\UserPaymentOptionStatus;
use App\Mail\GrandfatheringExpired;
use App\Models\ActivityEvent;
use App\Models\BillingAddress;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
@ -59,3 +63,157 @@ test('grandfathering past its contract end is ended and the customer is told onc
Carbon::setTestNow();
});
/**
* Audit U11: Zwischen Auswahl und Schreibzugriff kann sich der Posten
* ändern. Der `retrieved`-Listener simuliert das – er greift genau einmal,
* beim ersten Laden des Kandidaten, und schreibt am Model vorbei in die DB.
*/
function onFirstRetrieval(UserPaymentOption $target, callable $mutation): void
{
$done = false;
UserPaymentOption::retrieved(function (UserPaymentOption $retrieved) use ($target, $mutation, &$done): void {
if ($done || $retrieved->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();
});