presseportale/app/Services/Billing/ProofPdfService.php
Kevin Adametz aae456954a billing: Nebenläufigkeit & Idempotenz härten (H4, Stripe, Boost-Slots)
- CreditTopup-Gutschrift, MAN-Rechnung, Proof-PDF und Stripe-Webhook-
  Spiegelung sperren die betroffene Zeile innerhalb der Transaktion und
  prüfen die Idempotenz erst danach (verhindert Doppelgutschrift bei
  at-least-once-Webhooks, H4).
- Gemeinsamer LocksModelForUpdate-Trait für die häufige Lock-by-Key-Form
  (CreditTopup, ManualInvoice).
- Unique-Index auf invoices.stripe_invoice_id; Boost-Inventar über
  boost_slot_locks serialisiert, verwaiste Kategorie-Locks werden beim
  Löschen der Rubrik entfernt.

Deployment: vor migrate prüfen, ob invoices.stripe_invoice_id bereits
Duplikate enthält (die Unique-Migration bricht sonst bewusst ab).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:47:21 +00:00

97 lines
3.1 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\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();
}
$credits = $this->priceCredits();
return DB::transaction(function () use ($user, $pressRelease, $credits): SinglePurchase {
$lockedUser = User::query()
->whereKey($user->getKey())
->lockForUpdate()
->firstOrFail();
$existing = $this->purchaseQuery($lockedUser, $pressRelease)->first();
if ($existing) {
return $existing;
}
$purchase = $lockedUser->singlePurchases()->create([
'type' => SinglePurchaseType::ProofPdf,
'status' => SinglePurchaseStatus::Paid,
'price_cents' => $credits * 100,
'currency' => config('billing.default_currency'),
'press_release_id' => $pressRelease->id,
'paid_at' => now(),
]);
$this->wallet->debit(
$lockedUser,
$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);
}
}