- VatResolver::normalizeCountryCode()/isEuCountry() als Single Source of Truth;
resolve(), VatIdValidationService und StripeCheckoutService nutzen jetzt die
zentrale EU-Pruefung statt eigener config-Lookups.
- User::findByEmail() buendelt die LOWER(email)-Lookups (ContactAccessService,
SocialAuthService). Bleibt bewusst case-insensitiv, da E-Mails nicht
normalisiert gespeichert werden; der indexfreundliche Direktvergleich
braucht erst eine Schreib-Normalisierung (offen, dokumentiert).
- config('billing.default_currency') ersetzt 8 verstreute 'EUR'/'eur'-Literale
(Stripe-Calls via strtolower). Vereinheitlicht nebenbei die zuvor uneinheitlich
gespeicherte SinglePurchase.currency.
- Neuer CustomerAccountProvisioner::provisionVerifiedCustomer() ist die
gemeinsame Provisioning-Methode (verifiziert + aktiv + customer-Rolle) fuer
ContactAccessService und SocialAuthService.
- Tests: UserFindByEmailTest, erweiterte VatResolverTest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
2.8 KiB
PHP
92 lines
2.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Enums\CreditTransactionType;
|
|
use App\Enums\SinglePurchaseStatus;
|
|
use App\Models\CreditTopup;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
public function __construct(private readonly CreditWalletService $wallet) {}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
if ($topup->credited_at !== null) {
|
|
return;
|
|
}
|
|
|
|
DB::transaction(function () use ($topup, $sessionId, $paymentIntentId): void {
|
|
$topup->update([
|
|
'status' => SinglePurchaseStatus::Paid,
|
|
'paid_at' => $topup->paid_at ?? now(),
|
|
'credited_at' => now(),
|
|
'stripe_checkout_session_id' => $sessionId ?? $topup->stripe_checkout_session_id,
|
|
'stripe_payment_intent_id' => $paymentIntentId ?? $topup->stripe_payment_intent_id,
|
|
]);
|
|
|
|
$this->wallet->credit(
|
|
$topup->user,
|
|
$topup->credits,
|
|
CreditTransactionType::Topup,
|
|
"Credit-Paket {$topup->pack_key}",
|
|
$topup,
|
|
);
|
|
});
|
|
}
|
|
}
|