diff --git a/.env.example b/.env.example index fbfad7fe..e9d12764 100644 --- a/.env.example +++ b/.env.example @@ -79,8 +79,8 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=https://pressekonto.com/auth/google/callback -# Abo-/Bestandsschutz-Gate (WS-5). Ist der Schalter aus, darf jeder einreichen -# (Dev-Default). Für den Launch auf true setzen: Einreichen zur Prüfung erfordert -# dann ein aktives Abo, einen bezahlten Einzelkauf oder Bestandsschutz -# (grandfathered). Siehe User::hasActiveBooking(). -BILLING_ENFORCE_BOOKING=false +# Abo-/Bestandsschutz-Gate (WS-5). Default (auch ohne Env-Key) ist true: +# Einreichen zur Prüfung erfordert ein aktives Abo, einen bezahlten Einzelkauf +# oder Bestandsschutz (grandfathered) — siehe User::hasActiveBooking(). +# Nur für lokale Entwicklung bewusst auf false setzen (Gate offen für alle). +BILLING_ENFORCE_BOOKING=true diff --git a/config/billing.php b/config/billing.php index 9997334c..f0021108 100644 --- a/config/billing.php +++ b/config/billing.php @@ -8,13 +8,18 @@ return [ |-------------------------------------------------------------------------- | | "Speichern" ist immer frei; "Speichern & zur Prüfung einreichen" ist - | hinter eine aktive Buchung gegated. Bis das Tarif-Modul (Phase 9D/9E) - | die echte Buchungs-Prüfung liefert, bleibt das Gate deaktiviert — - | User::hasActiveBooking() gibt dann für alle true zurück. + | hinter eine aktive Buchung gegated (Stripe-Abo, offener Einmalkauf oder + | laufende Legacy-Vereinbarung inkl. Bestandsschutz). + | + | Default bewusst true (safe by default, R1.1 03.07.2026): Ein in + | Produktion vergessener Env-Key darf das Gate nicht öffnen — sonst + | könnte jeder ohne Zahlung veröffentlichen. Deaktivieren nur explizit + | via BILLING_ENFORCE_BOOKING=false; dann gibt User::hasActiveBooking() + | für alle true zurück. | */ - 'enforce_booking' => env('BILLING_ENFORCE_BOOKING', false), + 'enforce_booking' => env('BILLING_ENFORCE_BOOKING', true), /* |-------------------------------------------------------------------------- diff --git a/tests/Feature/Billing/HasActiveBookingTest.php b/tests/Feature/Billing/HasActiveBookingTest.php index 1c7e059c..c346c658 100644 --- a/tests/Feature/Billing/HasActiveBookingTest.php +++ b/tests/Feature/Billing/HasActiveBookingTest.php @@ -73,3 +73,62 @@ test('a cancelled legacy agreement does not count as booking', function () { expect($user->hasActiveBooking())->toBeFalse(); }); + +test('an active legacy agreement outside its current period does not count as booking', function () { + $user = User::factory()->create(); + + UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'payment_option_id' => PaymentOption::factory()->create()->id, + 'status' => UserPaymentOptionStatus::Active->value, + 'current_period_start' => now()->subMonths(2)->startOfMonth(), + 'current_period_end' => now()->subMonths(2)->endOfMonth(), + 'grandfathered_until' => null, + 'stripe_subscription_id' => null, + ]); + + expect($user->hasActiveBooking())->toBeFalse(); +}); + +test('a grandfathered agreement with valid protection counts as booking', 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' => now()->addYear(), + 'stripe_subscription_id' => null, + ]); + + expect($user->hasActiveBooking())->toBeTrue(); +}); + +test('a grandfathered agreement with expired protection does not count as booking', 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' => now()->subDay(), + 'stripe_subscription_id' => null, + ]); + + 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). + $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, + 'stripe_subscription_id' => null, + ]); + + expect($user->hasActiveBooking())->toBeFalse(); +}); diff --git a/tests/Feature/PressReleaseBookingGateTest.php b/tests/Feature/PressReleaseBookingGateTest.php index 60c7557d..22733bf6 100644 --- a/tests/Feature/PressReleaseBookingGateTest.php +++ b/tests/Feature/PressReleaseBookingGateTest.php @@ -1,11 +1,17 @@ assertSee('Zur Prüfung einreichen'); }); +test('submitting without a booking is rejected at the service level', function () { + /** @var TestCase $this */ + config()->set('billing.enforce_booking', true); + Queue::fake(); + ['pr' => $pr] = draftForGate(); + + expect(fn () => app(PressReleaseService::class)->submitForReview($pr)) + ->toThrow(BookingRequiredException::class); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); +}); + +test('a legacy grandfathered booking allows submitting for review', function () { + /** @var TestCase $this */ + config()->set('billing.enforce_booking', true); + Queue::fake(); + ['customer' => $customer, 'pr' => $pr] = draftForGate(); + + UserPaymentOption::factory()->create([ + 'user_id' => $customer->id, + 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => now()->addYear(), + 'stripe_subscription_id' => null, + ]); + + app(PressReleaseService::class)->submitForReview($pr); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review); +}); + test('with the gate off the notice never shows', function () { /** @var TestCase $this */ config()->set('billing.enforce_booking', false);