presseportale/app/Services/PressRelease/ReviewCheckService.php
Kevin Adametz 3e8844245d 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>
2026-06-17 14:19:37 +00:00

115 lines
3.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\PressRelease;
use App\Enums\ReviewCheckSource;
use App\Exceptions\ReviewLimitException;
use App\Models\PressRelease;
use App\Models\ReviewCheck;
use App\Models\User;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\CreditWalletService;
use Illuminate\Support\Facades\DB;
/**
* Prüfzähler-Logik laut Decision-Update §4.2/§4.3.
*
* Reihenfolge je Prüfung:
* 1. Tageslimit (Burst-Schutz) harte Bremse, nicht freikaufbar.
* 2. Monats-Freikontingent (tier-gestaffelt, aggregiert pro Account).
* 3. Overflow ist das Freikontingent leer, zieht jede weitere Prüfung
* `credits.review.overflow_cost` Credits aus der Wallet.
*/
class ReviewCheckService
{
public function __construct(
private readonly CreditPricingService $pricing,
private readonly CreditWalletService $wallet,
) {}
/**
* Verbrauchte Prüfungen im laufenden Monat (Account-Aggregat).
*/
public function usedThisMonth(User $user): int
{
return $user->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,
]);
});
}
}