diff --git a/app/Enums/ReviewCheckSource.php b/app/Enums/ReviewCheckSource.php new file mode 100644 index 0000000..546c3ef --- /dev/null +++ b/app/Enums/ReviewCheckSource.php @@ -0,0 +1,21 @@ + 'Freikontingent', + self::Credit => 'Credit (Overflow)', + }; + } +} diff --git a/app/Exceptions/ReviewLimitException.php b/app/Exceptions/ReviewLimitException.php new file mode 100644 index 0000000..b58b062 --- /dev/null +++ b/app/Exceptions/ReviewLimitException.php @@ -0,0 +1,19 @@ + */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'press_release_id', + 'source', + 'charged_credits', + ]; + + protected function casts(): array + { + return [ + 'source' => ReviewCheckSource::class, + 'charged_credits' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } + + /** + * Prüfungen im laufenden Kalendermonat (Aggregat pro Account). + */ + public function scopeThisMonth(Builder $query): Builder + { + return $query->where('created_at', '>=', now()->startOfMonth()); + } + + /** + * Prüfungen am heutigen Tag (Burst-/Tageslimit). + */ + public function scopeToday(Builder $query): Builder + { + return $query->where('created_at', '>=', now()->startOfDay()); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 4f6ad23..962e966 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -311,6 +311,11 @@ class User extends Authenticatable implements MustVerifyEmail return $this->hasMany(CreditTransaction::class); } + public function reviewChecks(): HasMany + { + return $this->hasMany(ReviewCheck::class); + } + /** * Aktuelles Credit-Guthaben (1 Credit = 1 €). 0, solange keine Wallet * angelegt wurde. diff --git a/app/Services/PressRelease/ReviewCheckService.php b/app/Services/PressRelease/ReviewCheckService.php new file mode 100644 index 0000000..9fe348c --- /dev/null +++ b/app/Services/PressRelease/ReviewCheckService.php @@ -0,0 +1,115 @@ +reviewChecks()->thisMonth()->count(); + } + + public function usedToday(User $user): int + { + return $user->reviewChecks()->today()->count(); + } + + /** + * Verbleibende Frei-Prüfungen diesen Monat. + */ + public function freeRemaining(User $user): int + { + $quota = $this->pricing->reviewFreeQuota($user->currentTier()); + + return max(0, $quota - $this->usedThisMonth($user)); + } + + public function dailyLimitReached(User $user): bool + { + return $this->usedToday($user) >= $this->pricing->reviewDailyLimit(); + } + + /** + * Würde die nächste Prüfung Credits kosten (Freikontingent leer)? + */ + public function nextCheckCosts(User $user): bool + { + return $this->freeRemaining($user) === 0; + } + + /** + * Kann der User jetzt eine Prüfung auslösen? Tageslimit nicht erreicht und + * entweder Freikontingent übrig oder genug Guthaben für den Overflow. + */ + public function canCheck(User $user): bool + { + if ($this->dailyLimitReached($user)) { + return false; + } + + if ($this->freeRemaining($user) > 0) { + return true; + } + + return $this->wallet->canAfford($user, $this->pricing->reviewOverflowCost()); + } + + /** + * Bucht eine Prüfung. Wirft ReviewLimitException am Tageslimit und + * InsufficientCreditsException, wenn der Overflow nicht gedeckt ist. + */ + public function recordCheck(User $user, ?PressRelease $pressRelease = null): ReviewCheck + { + if ($this->dailyLimitReached($user)) { + throw new ReviewLimitException($this->pricing->reviewDailyLimit()); + } + + return DB::transaction(function () use ($user, $pressRelease): ReviewCheck { + $useFree = $this->freeRemaining($user) > 0; + $charged = 0; + + if (! $useFree) { + $cost = $this->pricing->reviewOverflowCost(); + $this->wallet->debit( + $user, + $cost, + 'Prüfung (Overflow)', + $pressRelease, + ); + $charged = $cost; + } + + return $user->reviewChecks()->create([ + 'press_release_id' => $pressRelease?->id, + 'source' => $useFree ? ReviewCheckSource::Free : ReviewCheckSource::Credit, + 'charged_credits' => $charged, + ]); + }); + } +} diff --git a/database/factories/ReviewCheckFactory.php b/database/factories/ReviewCheckFactory.php new file mode 100644 index 0000000..61937e9 --- /dev/null +++ b/database/factories/ReviewCheckFactory.php @@ -0,0 +1,27 @@ + + */ +class ReviewCheckFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'press_release_id' => null, + 'source' => ReviewCheckSource::Free, + 'charged_credits' => 0, + ]; + } +} diff --git a/database/migrations/2026_06_17_141703_create_review_checks_table.php b/database/migrations/2026_06_17_141703_create_review_checks_table.php new file mode 100644 index 0000000..659b7a3 --- /dev/null +++ b/database/migrations/2026_06_17_141703_create_review_checks_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete(); + $table->string('source'); + $table->integer('charged_credits')->default(0); + $table->timestamps(); + + $table->index(['user_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('review_checks'); + } +}; diff --git a/tests/Feature/ReviewCheckServiceTest.php b/tests/Feature/ReviewCheckServiceTest.php new file mode 100644 index 0000000..f2ed154 --- /dev/null +++ b/tests/Feature/ReviewCheckServiceTest.php @@ -0,0 +1,102 @@ +service = app(ReviewCheckService::class); + $this->wallet = app(CreditWalletService::class); +}); + +test('an Einzel user has four free checks per month', function () { + $user = User::factory()->create(); + + expect($this->service->freeRemaining($user))->toBe(4); + expect($this->service->nextCheckCosts($user))->toBeFalse(); +}); + +test('free checks are consumed from the monthly quota first', function () { + $user = User::factory()->create(); + + $check = $this->service->recordCheck($user); + + expect($check->source)->toBe(ReviewCheckSource::Free); + expect($check->charged_credits)->toBe(0); + expect($this->service->freeRemaining($user))->toBe(3); + expect($this->service->usedThisMonth($user))->toBe(1); +}); + +test('once the free quota is empty the overflow draws one credit per check', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 5); + + // Vier freie Prüfungen aufbrauchen. + for ($i = 0; $i < 4; $i++) { + $this->service->recordCheck($user); + } + + expect($this->service->freeRemaining($user))->toBe(0); + expect($this->service->nextCheckCosts($user))->toBeTrue(); + + $overflow = $this->service->recordCheck($user); + + expect($overflow->source)->toBe(ReviewCheckSource::Credit); + expect($overflow->charged_credits)->toBe(1); + expect($this->wallet->balance($user))->toBe(4); +}); + +test('overflow without enough credits throws and records nothing', function () { + $user = User::factory()->create(); + for ($i = 0; $i < 4; $i++) { + $this->service->recordCheck($user); + } + + expect(fn () => $this->service->recordCheck($user)) + ->toThrow(InsufficientCreditsException::class); + + expect($this->service->usedThisMonth($user))->toBe(4); // kein Overflow-Eintrag +}); + +test('the daily limit is a hard brake that credits cannot bypass', function () { + $user = User::factory()->create(); + $this->wallet->credit($user, 100); + + // 10 Prüfungen heute (Tageslimit) – 4 frei, 6 per Credit. + for ($i = 0; $i < 10; $i++) { + $this->service->recordCheck($user); + } + + expect($this->service->dailyLimitReached($user))->toBeTrue(); + expect($this->service->canCheck($user))->toBeFalse(); + expect(fn () => $this->service->recordCheck($user)) + ->toThrow(ReviewLimitException::class); +}); + +test('a higher tier check pool resets with the calendar month', function () { + $user = User::factory()->create(); + + // Letzten Monat verbrauchte Prüfungen zählen nicht ins aktuelle Kontingent. + ReviewCheckFactoryHelper($user, 3, now()->subMonth()); + + expect($this->service->usedThisMonth($user))->toBe(0); + expect($this->service->freeRemaining($user))->toBe(4); +}); + +/** + * Legt Prüfungen mit explizitem Datum an (umgeht den Service, der immer + * „jetzt" bucht). + */ +function ReviewCheckFactoryHelper(User $user, int $count, $at): void +{ + for ($i = 0; $i < $count; $i++) { + $check = $user->reviewChecks()->create([ + 'source' => ReviewCheckSource::Free, + 'charged_credits' => 0, + ]); + $check->forceFill(['created_at' => $at, 'updated_at' => $at])->save(); + } +}