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>
115 lines
3.4 KiB
PHP
115 lines
3.4 KiB
PHP
<?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,
|
||
]);
|
||
});
|
||
}
|
||
}
|