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:
parent
235d3928ab
commit
1cd9fd11af
4 changed files with 363 additions and 46 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue