Proof-PDF + Extra-PM-Verkauf ueber die Credit-Wallet (Decision-Update 2.1/2.3)

- ProofPdfService: Veroeffentlichungsnachweis 3 Credits pauschal, einmal pro
  PM (Zweitdownload kostenfrei); ProofPdfRenderer erzeugt das PDF on-demand
  aus vorhandenen PM-Daten (kein externer Renderer); GET-Download-Endpoint
  /admin/me/press-releases/{id}/nachweis hinter downloadProof-Policy + Kauf-Gate
- ExtraPmPurchaseService: tier-gestaffelter Nachkauf (19/15/12/10/8) aus der
  Wallet; verbucht als bezahlter SinglePurchase(ExtraPm) und greift damit in
  die bestehende Kontingent-/Slot-Mechanik. InsufficientCreditsException
  liefert das Mini-Checkout-Signal (required/available/shortfall)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-17 14:28:08 +00:00
parent c7a4c8bfd4
commit 69411b4c87
8 changed files with 609 additions and 0 deletions

View file

@ -0,0 +1,92 @@
<?php
namespace App\Services\Billing;
use App\Enums\PressReleaseStatus;
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Exceptions\ProofPdfNotAvailableException;
use App\Models\PressRelease;
use App\Models\SinglePurchase;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Kauf des Veröffentlichungsnachweis-PDF laut Decision-Update §2.3: 3 Credits
* pauschal pro PM aus der Wallet. Der Kauf wird als bezahlter SinglePurchase
* (Typ ProofPdf) festgehalten — ein erneuter Download derselben PM ist danach
* kostenfrei (Impulskauf, keine Doppelbelastung).
*/
class ProofPdfService
{
public function __construct(
private readonly CreditPricingService $pricing,
private readonly CreditWalletService $wallet,
) {}
public function priceCredits(): int
{
return $this->pricing->proofPdfCredits();
}
/**
* Ein Nachweis ist nur für veröffentlichte Meldungen sinnvoll.
*/
public function isAvailable(PressRelease $pressRelease): bool
{
return $pressRelease->status === PressReleaseStatus::Published;
}
public function hasPurchased(User $user, PressRelease $pressRelease): bool
{
return $this->purchaseQuery($user, $pressRelease)->exists();
}
/**
* Kauft (oder liefert den bereits gekauften) Nachweis. Belastet die Wallet
* nur beim Erstkauf. Wirft ProofPdfNotAvailableException, wenn die PM nicht
* veröffentlicht ist, und InsufficientCreditsException ohne Deckung.
*/
public function purchase(User $user, PressRelease $pressRelease): SinglePurchase
{
if (! $this->isAvailable($pressRelease)) {
throw ProofPdfNotAvailableException::notPublished();
}
$existing = $this->purchaseQuery($user, $pressRelease)->first();
if ($existing) {
return $existing;
}
$credits = $this->priceCredits();
return DB::transaction(function () use ($user, $pressRelease, $credits): SinglePurchase {
$purchase = $user->singlePurchases()->create([
'type' => SinglePurchaseType::ProofPdf,
'status' => SinglePurchaseStatus::Paid,
'price_cents' => $credits * 100,
'currency' => 'eur',
'press_release_id' => $pressRelease->id,
'paid_at' => now(),
]);
$this->wallet->debit(
$user,
$credits,
"Veröffentlichungsnachweis – PM #{$pressRelease->id}",
$purchase,
);
return $purchase;
});
}
private function purchaseQuery(User $user, PressRelease $pressRelease)
{
return $user->singlePurchases()
->where('type', SinglePurchaseType::ProofPdf->value)
->where('status', SinglePurchaseStatus::Paid->value)
->where('press_release_id', $pressRelease->id);
}
}