presseportale/app/Services/Billing/CreditTopupService.php
Kevin Adametz 991af42d27 admin: Aktivitäts-Verlauf unter Reports ▸ Verlauf
Chronologisches Protokoll der Systemereignisse als neue Admin-Seite
(/admin/reports/activity) mit Filter nach Ereignistyp, Zeitraum und
Suche. Erfasst ab jetzt fortlaufend:

- Registrierungen inkl. Weg (Formular, Google, Magic-Link)
- Anmeldungen inkl. Weg (Passwort, Google, Magic-Link, Impersonation)
- PM-Statuswechsel mit Ergebnis/Begründung (zentral im Status-Log-Hook;
  KI-/Scheduler-Wechsel erscheinen als „System")
- Zahlungseingänge (Rechnungsspiegelung) und Credit-Aufladungen
- Formular-Eingänge: Kontakt, Rechtsanfrage, Widerruf, Kündigung,
  Newsletter-Anmeldung (Observer auf den Modellen)

Schreiben ist Best-Effort über den ActivityLog-Service — ein
fehlgeschlagener Verlaufseintrag bricht nie den fachlichen Ablauf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:00:58 +00:00

119 lines
3.9 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Enums\ActivityType;
use App\Enums\CreditTransactionType;
use App\Enums\SinglePurchaseStatus;
use App\Models\CreditTopup;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Billing\Concerns\LocksModelForUpdate;
/**
* Wallet-Aufladung über Credit-Pakete (Stripe). Der Kauf wird als Pending-
* CreditTopup angelegt und nach Zahlungseingang (Webhook) der Wallet
* gutgeschrieben — idempotent über die `credited_at`-Marke.
*/
class CreditTopupService
{
use LocksModelForUpdate;
public function __construct(
private readonly CreditWalletService $wallet,
private readonly ActivityLog $activityLog,
) {}
/**
* Verfügbare Credit-Pakete aus der Config.
*
* @return list<array{key: string, price_cents: int, credits: int}>
*/
public function packs(): array
{
return array_values(config('credits.packs', []));
}
/**
* @return array{key: string, price_cents: int, credits: int}|null
*/
public function findPack(string $key): ?array
{
foreach ($this->packs() as $pack) {
if ($pack['key'] === $key) {
return $pack;
}
}
return null;
}
/**
* Legt einen Pending-Topup für ein Paket an (vor dem Stripe-Checkout).
*/
public function startTopup(User $user, string $packKey): CreditTopup
{
$pack = $this->findPack($packKey);
if (! $pack) {
throw new \InvalidArgumentException("Unbekanntes Credit-Paket: {$packKey}.");
}
return $user->creditTopups()->create([
'pack_key' => $pack['key'],
'credits' => $pack['credits'],
'price_cents' => $pack['price_cents'],
'currency' => config('billing.default_currency'),
'status' => SinglePurchaseStatus::Pending,
]);
}
/**
* Schreibt einen bezahlten Topup der Wallet gut. Idempotent: bereits
* gutgeschriebene Topups (credited_at gesetzt) werden übersprungen.
*/
public function fulfill(CreditTopup $topup, ?string $sessionId = null, ?string $paymentIntentId = null): void
{
// Zeile sperren UND Idempotenz innerhalb der Transaktion prüfen: Stripe
// liefert Webhooks at-least-once (Original + Retry). Läge der Guard außen,
// könnten zwei parallele Zustellungen beide `credited_at === null` lesen
// und die Wallet doppelt gutschreiben. Der Lock serialisiert sie.
$this->withLockedByKey($topup, function (CreditTopup $locked) use ($topup, $sessionId, $paymentIntentId): void {
if ($locked->credited_at !== null) {
return;
}
$locked->update([
'status' => SinglePurchaseStatus::Paid,
'paid_at' => $locked->paid_at ?? now(),
'credited_at' => now(),
'stripe_checkout_session_id' => $sessionId ?? $locked->stripe_checkout_session_id,
'stripe_payment_intent_id' => $paymentIntentId ?? $locked->stripe_payment_intent_id,
]);
$this->wallet->credit(
$locked->user,
$locked->credits,
CreditTransactionType::Topup,
"Credit-Paket {$locked->pack_key}",
$locked,
);
$this->activityLog->record(
ActivityType::CreditTopup,
sprintf(
'Wallet-Aufladung: %d Credits (Paket %s) — %s (%s).',
$locked->credits,
$locked->pack_key,
$locked->user->name,
$locked->user->email,
),
user: $locked->user,
subject: $locked,
meta: ['credits' => $locked->credits, 'pack' => $locked->pack_key],
);
$topup->setRawAttributes($locked->getAttributes());
});
}
}