diff --git a/app/Models/UserPaymentOption.php b/app/Models/UserPaymentOption.php index a783a0fc..4e29bd45 100644 --- a/app/Models/UserPaymentOption.php +++ b/app/Models/UserPaymentOption.php @@ -80,12 +80,26 @@ class UserPaymentOption extends Model } /** - * Laufender Bestandsschutz (Grandfathered mit gültigem grandfathered_until). + * Laufender Bestandsschutz. `grandfathered_until` ist das explizite + * Legacy-Vertragsende (valid_until_date) und deckelt, wenn gesetzt. + * Bei offenen Dauervereinbarungen ist es NULL (Regelfall im Import) — + * dann zählt die laufende, vom MAN-Rechnungslauf fortgerollte Periode + * (`current_period_end` = next_due_date). Fix 06.07.2026: vorher galt + * NULL pauschal als abgelaufen, wodurch praktisch kein Legacy-Kunde + * eine aktive Zahlungsoption hatte („Buchung erforderlich"). */ public function scopeGrandfatheredNow(Builder $query): Builder { return $query ->where('status', UserPaymentOptionStatus::Grandfathered->value) - ->whereDate('grandfathered_until', '>=', now()); + ->where(function (Builder $query): void { + $query + ->whereDate('grandfathered_until', '>=', now()) + ->orWhere(function (Builder $rolling): void { + $rolling + ->whereNull('grandfathered_until') + ->whereDate('current_period_end', '>=', now()); + }); + }); } } diff --git a/tests/Feature/Billing/HasActiveBookingTest.php b/tests/Feature/Billing/HasActiveBookingTest.php index c346c658..6760e6aa 100644 --- a/tests/Feature/Billing/HasActiveBookingTest.php +++ b/tests/Feature/Billing/HasActiveBookingTest.php @@ -118,8 +118,10 @@ test('a grandfathered agreement with expired protection does not count as bookin expect($user->hasActiveBooking())->toBeFalse(); }); -test('a grandfathered agreement without an end date does not count as booking', function () { - // NULL = abgelaufen/ungültig (User-Entscheidung 19.06.2026). +test('a grandfathered agreement without an end date counts while its period is running', function () { + // Regelfall des Imports (Fix 06.07.2026): offene Dauervereinbarung — + // grandfathered_until NULL, Gültigkeit über die laufende Periode + // (current_period_end = next_due_date, vom MAN-Lauf fortgerollt). $user = User::factory()->create(); UserPaymentOption::factory()->create([ @@ -127,6 +129,43 @@ test('a grandfathered agreement without an end date does not count as booking', 'payment_option_id' => PaymentOption::factory()->create()->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, 'grandfathered_until' => null, + 'current_period_start' => now()->subMonths(3), + 'current_period_end' => now()->addMonths(9), + 'stripe_subscription_id' => null, + ]); + + expect($user->hasActiveBooking())->toBeTrue(); +}); + +test('a grandfathered agreement without an end date lapses with its period', function () { + $user = User::factory()->create(); + + UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'payment_option_id' => PaymentOption::factory()->create()->id, + 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => null, + 'current_period_start' => now()->subYears(2), + 'current_period_end' => now()->subYear(), + 'stripe_subscription_id' => null, + ]); + + expect($user->hasActiveBooking())->toBeFalse(); +}); + +test('an explicit legacy end date caps a grandfathered agreement despite a running period', function () { + // grandfathered_until = explizites Vertragsende (valid_until_date) — + // wenn gesetzt und überschritten, zählt die Vereinbarung nicht mehr, + // auch wenn die Abrechnungsperiode formal noch läuft. + $user = User::factory()->create(); + + UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'payment_option_id' => PaymentOption::factory()->create()->id, + 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => now()->subDay(), + 'current_period_start' => now()->subMonths(3), + 'current_period_end' => now()->addMonths(9), 'stripe_subscription_id' => null, ]);