- 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>
97 lines
3.1 KiB
PHP
97 lines
3.1 KiB
PHP
<?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);
|
||
}
|
||
}
|