*/ 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()); }); } }