Pruefzaehler + Pruefkontingent (Decision-Update Phase-2 vorgezogen)

Eigener Pruef-Zaehler, getrennt von der Credit-Wallet (Paragraph 4.2/4.3):

- review_checks Ledger (eine Zeile je Pruefung, source free|credit,
  charged_credits), aggregiert pro Account/Monat statt pro PM
- ReviewCheckService: Tageslimit (harte Bremse, nicht freikaufbar) ->
  Monats-Freikontingent (tier-gestaffelt 4/12/30/60/120) -> Overflow
  zieht 1 Credit/Pruefung aus der Wallet
- ReviewLimitException fuer das erreichte Tageslimit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-17 14:19:37 +00:00
parent b63cd26326
commit 3e8844245d
8 changed files with 387 additions and 0 deletions

View file

@ -0,0 +1,102 @@
<?php
use App\Enums\ReviewCheckSource;
use App\Exceptions\InsufficientCreditsException;
use App\Exceptions\ReviewLimitException;
use App\Models\User;
use App\Services\Billing\CreditWalletService;
use App\Services\PressRelease\ReviewCheckService;
beforeEach(function (): void {
$this->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();
}
}