seed(RolesAndPermissionsSeeder::class); // Das Kontingent greift erst mit dem Launch-Schalter (sonst unbegrenzt). config()->set('billing.enforce_booking', true); }); function quotaTestPressRelease(User $user, string $status = 'draft'): PressRelease { $company = Company::factory()->presseecho()->create(); $user->companies()->attach($company->id, ['role' => 'owner']); return PressRelease::factory()->create([ 'user_id' => $user->id, 'company_id' => $company->id, 'category_id' => Category::factory()->create()->id, 'portal' => $company->portal->value, 'status' => $status, ]); } function quotaTestSubscriber(int $planQuota = 3, int $used = 0): User { $user = User::factory()->create([ 'press_release_quota_used_this_month' => $used, ]); $user->assignRole('customer'); $plan = Plan::factory()->create([ 'press_release_quota' => $planQuota, 'stripe_price_id_monthly' => 'price_test_m_'.fake()->unique()->randomNumber(6), ]); subscribeUserToPlan($user, $plan); return $user; } test('without the launch switch the quota is unlimited', function () { config()->set('billing.enforce_booking', false); $user = User::factory()->create(); expect($user->pressReleaseQuotaRemaining())->toBeNull(); expect($user->pressReleaseQuotaTotal())->toBeNull(); }); test('a subscriber inherits the monthly quota of the plan', function () { $user = quotaTestSubscriber(planQuota: 3, used: 1); expect($user->pressReleaseQuotaRemaining())->toBe(2); expect($user->pressReleaseQuotaTotal())->toBe(3); }); test('paid single purchases extend the quota', function () { $user = quotaTestSubscriber(planQuota: 3, used: 3); SinglePurchase::factory()->paid()->count(2)->create(['user_id' => $user->id]); SinglePurchase::factory()->consumed()->create(['user_id' => $user->id]); expect($user->pressReleaseQuotaRemaining())->toBe(2); expect($user->pressReleaseQuotaTotal())->toBe(5); }); test('a grandfathered legacy user has an unlimited quota', function () { // Entscheidung 12.06.2026: Bestandsschutz — das Alt-Produkt sah // unbegrenzte PMs vor, am Kontingent wird nichts umgestellt. $user = User::factory()->create(); $user->assignRole('customer'); UserPaymentOption::factory()->create([ 'user_id' => $user->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, ]); expect($user->pressReleaseQuotaRemaining())->toBeNull(); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); }); test('submitting a press release does not consume a quota slot', function () { // Decision-Update §3.2: Der Slot zählt erst bei Veröffentlichung runter. Queue::fake(); $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user); app(PressReleaseService::class)->submitForReview($pr); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review); expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); }); test('publishing consumes exactly one plan slot', function () { $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); }); test('re-publishing after archive does not consume a second slot', function () { $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user, 'review'); $service = app(PressReleaseService::class); $service->publish($pr); $service->archive($pr->fresh()); $service->changeStatusFromAdmin($pr->fresh(), PressReleaseStatus::Published); expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); }); test('a rejected press release does not consume a quota slot', function () { $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->reject($pr, 'Unzulässiger Inhalt.', 'ki'); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Rejected); expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); }); test('publishing past the plan quota consumes the oldest paid purchase', function () { $user = quotaTestSubscriber(planQuota: 1, used: 1); $older = SinglePurchase::factory()->paid()->create([ 'user_id' => $user->id, 'paid_at' => now()->subDays(2), ]); $newer = SinglePurchase::factory()->paid()->create([ 'user_id' => $user->id, 'paid_at' => now()->subDay(), ]); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); expect($older->fresh()->status)->toBe(SinglePurchaseStatus::Consumed); expect($older->fresh()->press_release_id)->toBe($pr->id); expect($newer->fresh()->status)->toBe(SinglePurchaseStatus::Paid); }); test('a purchase-only user consumes the purchase on publish', function () { $user = User::factory()->create(); $user->assignRole('customer'); $purchase = SinglePurchase::factory()->paid()->create(['user_id' => $user->id]); expect($user->pressReleaseQuotaRemaining())->toBe(1); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed); expect($purchase->fresh()->consumed_at)->not->toBeNull(); }); test('submitting with an exhausted quota is blocked', function () { Queue::fake(); $user = quotaTestSubscriber(planQuota: 3, used: 3); $pr = quotaTestPressRelease($user); expect(fn () => app(PressReleaseService::class)->submitForReview($pr)) ->toThrow(QuotaExceededException::class); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); }); test('monthly reset command zeroes the used counter', function () { /** @var TestCase $this */ User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]); $untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]); $this->artisan(ResetMonthlyPressReleaseQuota::class)->assertSuccessful(); expect(User::where('press_release_quota_used_this_month', '>', 0)->count())->toBe(0); expect($untouched->fresh()->press_release_quota_used_this_month)->toBe(0); });