Optimierung 3.5: Kleinere Zentralisierungen (VAT, findByEmail, Waehrung, Provisioning)
- 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>
This commit is contained in:
parent
31d407d3ad
commit
b255d90721
19 changed files with 151 additions and 46 deletions
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Enums\RegistrationType;
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
|
|
@ -27,7 +26,7 @@ class ContactAccessService
|
|||
{
|
||||
public function __construct(
|
||||
private readonly MagicLinkGenerator $magicLinks,
|
||||
private readonly UserRolePermissionSyncService $roleSync,
|
||||
private readonly CustomerAccountProvisioner $accountProvisioner,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -47,7 +46,7 @@ class ContactAccessService
|
|||
$email = mb_strtolower(trim($email));
|
||||
$contacts = $this->contactsFor($email);
|
||||
$companies = $this->companiesFor($email);
|
||||
$existing = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||
$existing = User::findByEmail($email);
|
||||
|
||||
if (! $existing && $contacts->isEmpty() && $companies->isEmpty()) {
|
||||
return false;
|
||||
|
|
@ -173,7 +172,7 @@ class ContactAccessService
|
|||
*/
|
||||
private function resolveUser(string $email, Collection $contacts, Collection $companies): User
|
||||
{
|
||||
return User::query()->whereRaw('LOWER(email) = ?', [$email])->first()
|
||||
return User::findByEmail($email)
|
||||
?? $this->createAccount($email, $contacts, $companies);
|
||||
}
|
||||
|
||||
|
|
@ -194,17 +193,7 @@ class ContactAccessService
|
|||
$name = (string) ($companies->first()->name ?? '');
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $name !== '' ? $name : $email,
|
||||
'email' => $email,
|
||||
'registration_type' => RegistrationType::Company->value,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
|
||||
|
||||
return $user;
|
||||
return $this->accountProvisioner->provisionVerifiedCustomer($email, $name);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
41
app/Services/Auth/CustomerAccountProvisioner.php
Normal file
41
app/Services/Auth/CustomerAccountProvisioner.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Enums\RegistrationType;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Legt verifizierte Kunden-Accounts an, die über einen vertrauenswürdigen Kanal
|
||||
* entstehen – Magic-Link an eine hinterlegte E-Mail (ContactAccessService) oder
|
||||
* Social-Login (SocialAuthService).
|
||||
*
|
||||
* Single Source of Truth für das lazy Provisioning: Account direkt aktiv mit
|
||||
* customer-Rolle, die E-Mail gilt über den bestätigenden Kanal als verifiziert
|
||||
* (Entscheidung 15.06.2026). Der Name fällt auf die E-Mail zurück, wenn keiner
|
||||
* bekannt ist.
|
||||
*/
|
||||
class CustomerAccountProvisioner
|
||||
{
|
||||
public function __construct(private readonly UserRolePermissionSyncService $roleSync) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extraAttributes Zusätzliche Spalten beim
|
||||
* Anlegen (z. B. OAuth-Provider).
|
||||
*/
|
||||
public function provisionVerifiedCustomer(string $email, string $name, array $extraAttributes = []): User
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => $name !== '' ? $name : $email,
|
||||
'email' => $email,
|
||||
'registration_type' => RegistrationType::Company->value,
|
||||
'is_active' => true,
|
||||
...$extraAttributes,
|
||||
]);
|
||||
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Enums\RegistrationType;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
|
|
@ -14,7 +13,10 @@ use App\Models\User;
|
|||
*/
|
||||
class SocialAuthService
|
||||
{
|
||||
public function __construct(private readonly UserRolePermissionSyncService $roleSync) {}
|
||||
public function __construct(
|
||||
private readonly UserRolePermissionSyncService $roleSync,
|
||||
private readonly CustomerAccountProvisioner $accountProvisioner,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Liefert den (ggf. neu angelegten) User zur Social-Identität. Ein
|
||||
|
|
@ -29,7 +31,7 @@ class SocialAuthService
|
|||
return null;
|
||||
}
|
||||
|
||||
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||
$user = User::findByEmail($email);
|
||||
|
||||
if (! $user) {
|
||||
return $this->createUser($provider, $providerId, $email, $name);
|
||||
|
|
@ -64,20 +66,13 @@ class SocialAuthService
|
|||
|
||||
private function createUser(string $provider, string $providerId, string $email, ?string $name): User
|
||||
{
|
||||
$name = $name !== null ? trim($name) : '';
|
||||
|
||||
$user = User::create([
|
||||
'name' => $name !== '' ? $name : $email,
|
||||
'email' => $email,
|
||||
'registration_type' => RegistrationType::Company->value,
|
||||
'is_active' => true,
|
||||
'oauth_provider' => $provider,
|
||||
'oauth_provider_id' => $providerId,
|
||||
]);
|
||||
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
|
||||
|
||||
return $user;
|
||||
return $this->accountProvisioner->provisionVerifiedCustomer(
|
||||
$email,
|
||||
$name !== null ? trim($name) : '',
|
||||
[
|
||||
'oauth_provider' => $provider,
|
||||
'oauth_provider_id' => $providerId,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ class CreditTopupService
|
|||
'pack_key' => $pack['key'],
|
||||
'credits' => $pack['credits'],
|
||||
'price_cents' => $pack['price_cents'],
|
||||
'currency' => 'EUR',
|
||||
'currency' => config('billing.default_currency'),
|
||||
'status' => SinglePurchaseStatus::Pending,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class ExtraPmPurchaseService
|
|||
'type' => SinglePurchaseType::ExtraPm,
|
||||
'status' => SinglePurchaseStatus::Paid,
|
||||
'price_cents' => $credits * 100,
|
||||
'currency' => 'eur',
|
||||
'currency' => config('billing.default_currency'),
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ class ManualInvoiceService
|
|||
'amount_cents' => $netCents,
|
||||
'tax_cents' => $taxCents,
|
||||
'total_cents' => $netCents + $taxCents,
|
||||
'currency' => $option->paymentOption?->currency ?? 'EUR',
|
||||
'currency' => $option->paymentOption?->currency ?? config('billing.default_currency'),
|
||||
'is_netto' => $treatment->isTaxExempt(),
|
||||
'tax_note' => $treatment->taxNote(),
|
||||
'invoice_date' => $asOf,
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class ProofPdfService
|
|||
'type' => SinglePurchaseType::ProofPdf,
|
||||
'status' => SinglePurchaseStatus::Paid,
|
||||
'price_cents' => $credits * 100,
|
||||
'currency' => 'eur',
|
||||
'currency' => config('billing.default_currency'),
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ use Laravel\Cashier\Checkout;
|
|||
*/
|
||||
class StripeCheckoutService
|
||||
{
|
||||
public function __construct(private readonly VatResolver $vatResolver) {}
|
||||
|
||||
/**
|
||||
* Stripe-Checkout für ein Tarif-Abo (monatlich/jährlich). Die Steuer
|
||||
* ergänzt Stripe Tax automatisch (Cashier::calculateTaxes, Netto-Preise).
|
||||
|
|
@ -53,7 +55,7 @@ class StripeCheckoutService
|
|||
|
||||
$prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2);
|
||||
|
||||
if ($prefixCountry !== 'DE' && ! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
|
||||
if ($prefixCountry !== 'DE' && ! $this->vatResolver->isEuCountry($prefixCountry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class VatIdValidationService
|
|||
);
|
||||
}
|
||||
|
||||
if (! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
|
||||
if (! $this->vatResolver->isEuCountry($prefixCountry)) {
|
||||
return $this->result(
|
||||
VatIdCheckStatus::Unverified,
|
||||
__('Format plausibel. Online-Bestätigung ist nur für EU-USt-IDs möglich.'),
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ class VatResolver
|
|||
{
|
||||
public function resolve(?string $countryCode, ?string $vatId = null): VatTreatment
|
||||
{
|
||||
$countryCode = strtoupper((string) $countryCode);
|
||||
$countryCode = $this->normalizeCountryCode($countryCode);
|
||||
|
||||
if ($countryCode === '' || $countryCode === 'DE') {
|
||||
return VatTreatment::Domestic;
|
||||
}
|
||||
|
||||
if (! in_array($countryCode, (array) config('billing.eu_country_codes', []), true)) {
|
||||
if (! $this->isEuCountry($countryCode)) {
|
||||
return VatTreatment::ThirdCountry;
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +36,24 @@ class VatResolver
|
|||
: VatTreatment::EuConsumer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert einen Ländercode auf den ISO-3166-1-alpha-2-Großbuchstaben.
|
||||
* Single Source of Truth, damit alle USt-Pfade dieselbe Schreibweise nutzen.
|
||||
*/
|
||||
public function normalizeCountryCode(?string $countryCode): string
|
||||
{
|
||||
return strtoupper((string) $countryCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist der (bereits normalisierte) Ländercode ein EU-Mitgliedstaat? DE zählt
|
||||
* hier bewusst NICHT mit (Inland, siehe config `billing.eu_country_codes`).
|
||||
*/
|
||||
public function isEuCountry(string $countryCode): bool
|
||||
{
|
||||
return in_array($countryCode, (array) config('billing.eu_country_codes', []), true);
|
||||
}
|
||||
|
||||
public function rateFor(VatTreatment $treatment): float
|
||||
{
|
||||
return $treatment->isTaxExempt() ? 0.0 : (float) config('billing.vat_rate', 0.19);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue