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
|
|
@ -10,6 +10,8 @@ use App\Models\UserPaymentOption;
|
||||||
use App\Services\ActivityLog;
|
use App\Services\ActivityLog;
|
||||||
use App\Services\Mail\EmailSuppressionService;
|
use App\Services\Mail\EmailSuppressionService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
|
@ -21,79 +23,190 @@ use Illuminate\Support\Facades\Mail;
|
||||||
* Bisher endete ein solcher Vertrag nur im Zugriff (scopeGrandfatheredNow),
|
* Bisher endete ein solcher Vertrag nur im Zugriff (scopeGrandfatheredNow),
|
||||||
* nicht im Datensatz: Status blieb `grandfathered`, der MAN-Rechnungslauf
|
* nicht im Datensatz: Status blieb `grandfathered`, der MAN-Rechnungslauf
|
||||||
* hätte weiter abgerechnet, und niemand sagte dem Kunden Bescheid. Jetzt
|
* hätte weiter abgerechnet, und niemand sagte dem Kunden Bescheid. Jetzt
|
||||||
* läuft der Befehl täglich vor dem Rechnungslauf, setzt die Posten auf
|
* läuft der Befehl täglich vor dem Rechnungslauf in zwei Phasen:
|
||||||
* `cancelled` und schickt je Kunde genau eine Mail. Idempotent: bereits
|
*
|
||||||
* gekündigte Posten werden nicht mehr angefasst.
|
* 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
|
class ExpireGrandfathering extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'billing:expire-grandfathering
|
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.';
|
protected $description = 'Beendet Bestandsschutz-Posten mit überschrittenem Legacy-Vertragsende und informiert den Kunden.';
|
||||||
|
|
||||||
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
|
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
|
||||||
{
|
{
|
||||||
$expired = UserPaymentOption::query()
|
$candidates = $this->expiredQuery()->with('user')->get();
|
||||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
$pending = $this->pendingNotificationQuery()->with('user')->get();
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->whereNotNull('grandfathered_until')
|
|
||||||
->whereDate('grandfathered_until', '<', today())
|
|
||||||
->with('user')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($expired->isEmpty()) {
|
if ($candidates->isEmpty() && $pending->isEmpty()) {
|
||||||
$this->info('Kein Bestandsschutz mit überschrittenem Vertragsende.');
|
$this->info('Kein Bestandsschutz mit überschrittenem Vertragsende, keine offene Benachrichtigung.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->option('dry-run')) {
|
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()));
|
$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;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ended = 0;
|
$ended = $this->endExpired($candidates, $activityLog);
|
||||||
|
|
||||||
foreach ($expired->groupBy('user_id') as $userId => $options) {
|
[$notified, $failed] = $this->notifyPending($this->pendingNotificationQuery()->with('user')->get(), $suppressions);
|
||||||
/** @var User|null $user */
|
|
||||||
$user = $options->first()?->user;
|
|
||||||
|
|
||||||
DB::transaction(function () use ($options, &$ended): void {
|
$this->info(sprintf('%d Bestandsschutz-Posten beendet, %d Kunden benachrichtigt.', $ended, $notified));
|
||||||
$ended += UserPaymentOption::query()
|
|
||||||
->whereIn('id', $options->pluck('id')->all())
|
|
||||||
->whereNull('cancelled_at')
|
|
||||||
->update([
|
|
||||||
'status' => UserPaymentOptionStatus::Cancelled->value,
|
|
||||||
'cancelled_at' => now(),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($user === null) {
|
if ($failed > 0) {
|
||||||
continue;
|
$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);
|
return self::FAILURE;
|
||||||
|
|
||||||
$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;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1: bedingtes Beenden je Kunde. Liefert die Zahl der tatsächlich
|
||||||
|
* von diesem Lauf beendeten Posten.
|
||||||
|
*
|
||||||
|
* @param Collection<int, UserPaymentOption> $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<int, UserPaymentOption> $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<int, UserPaymentOption> $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<UserPaymentOption>
|
||||||
|
*/
|
||||||
|
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<UserPaymentOption>
|
||||||
|
*/
|
||||||
|
private function pendingNotificationQuery(): Builder
|
||||||
|
{
|
||||||
|
return UserPaymentOption::query()
|
||||||
|
->whereNotNull('grandfathering_expired_at')
|
||||||
|
->whereNull('grandfathering_notified_at');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ class UserPaymentOption extends Model
|
||||||
'billing_hold_at',
|
'billing_hold_at',
|
||||||
'stripe_subscription_id',
|
'stripe_subscription_id',
|
||||||
'cancelled_at',
|
'cancelled_at',
|
||||||
|
'grandfathering_expired_at',
|
||||||
|
'grandfathering_notified_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|
@ -38,6 +40,8 @@ class UserPaymentOption extends Model
|
||||||
'current_period_end' => 'date',
|
'current_period_end' => 'date',
|
||||||
'billing_hold_at' => 'datetime',
|
'billing_hold_at' => 'datetime',
|
||||||
'cancelled_at' => 'datetime',
|
'cancelled_at' => 'datetime',
|
||||||
|
'grandfathering_expired_at' => 'datetime',
|
||||||
|
'grandfathering_notified_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachholbare Ablaufbenachrichtigung beim Bestandsschutz (Audit U12).
|
||||||
|
*
|
||||||
|
* `billing:expire-grandfathering` beendete den Posten zuerst und plante die
|
||||||
|
* Kundenmail danach ein. Scheiterte das Einplanen, war der Vertrag bereits
|
||||||
|
* `cancelled` und kein späterer Lauf hätte die Mail nachgeholt.
|
||||||
|
* `grandfathering_expired_at` wird im selben Schreibzugriff wie
|
||||||
|
* `cancelled_at` gesetzt und markiert die offene Benachrichtigung;
|
||||||
|
* `grandfathering_notified_at` schließt sie, sobald die Mail eingeplant
|
||||||
|
* (oder wegen gesperrter Adresse bewusst ausgelassen) wurde. Kleine Tabelle.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('user_payment_options', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('user_payment_options', 'grandfathering_expired_at')) {
|
||||||
|
$table->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,
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Console\Commands\ExpireGrandfathering;
|
use App\Console\Commands\ExpireGrandfathering;
|
||||||
|
use App\Enums\ActivityType;
|
||||||
|
use App\Enums\EmailSuppressionReason;
|
||||||
use App\Enums\UserPaymentOptionStatus;
|
use App\Enums\UserPaymentOptionStatus;
|
||||||
use App\Mail\GrandfatheringExpired;
|
use App\Mail\GrandfatheringExpired;
|
||||||
|
use App\Models\ActivityEvent;
|
||||||
use App\Models\BillingAddress;
|
use App\Models\BillingAddress;
|
||||||
use App\Models\PaymentOption;
|
use App\Models\PaymentOption;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserPaymentOption;
|
use App\Models\UserPaymentOption;
|
||||||
|
use App\Services\Mail\EmailSuppressionService;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
@ -59,3 +63,157 @@ test('grandfathering past its contract end is ended and the customer is told onc
|
||||||
|
|
||||||
Carbon::setTestNow();
|
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