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:
Kevin Adametz 2026-06-19 11:14:05 +00:00
parent 31d407d3ad
commit b255d90721
19 changed files with 151 additions and 46 deletions

View file

@ -258,7 +258,7 @@ class GrandfatherLegacySubscriptions extends Command
'type' => 'recurring',
// Katalogpreise sind netto (Entscheidung 12.06.2026).
'price_cents' => $candidate['legacy_conditions']['net_cents'],
'currency' => 'EUR',
'currency' => config('billing.default_currency'),
'interval' => 'yearly',
'is_hidden' => true,
],

View file

@ -114,7 +114,7 @@ class SyncStripePlans extends Command
$price = $stripe->prices->create([
'product' => $product->id,
'currency' => 'eur',
'currency' => strtolower((string) config('billing.default_currency')),
'unit_amount' => $amount,
'tax_behavior' => 'exclusive',
'metadata' => ['purpose' => 'single_pm'],

View file

@ -63,7 +63,7 @@ class CheckoutController extends Controller
'type' => SinglePurchaseType::SinglePm->value,
'status' => SinglePurchaseStatus::Pending->value,
'price_cents' => (int) config('billing.single_pm_price_cents'),
'currency' => 'EUR',
'currency' => config('billing.default_currency'),
]);
return $this->checkout->forSinglePurchase($request->user(), $purchase);

View file

@ -82,7 +82,7 @@ class ProcessStripeWebhook
'amount_cents' => $subtotal,
'tax_cents' => $tax,
'total_cents' => $total,
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? 'eur')),
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? config('billing.default_currency'))),
'is_netto' => $tax === 0,
'invoice_date' => now()->toDateString(),
'paid_at' => now(),

View file

@ -132,6 +132,27 @@ class User extends Authenticatable implements MustVerifyEmail
private ?int $cachedOpenSinglePurchaseCount = null;
/**
* Findet einen User case-insensitive anhand der E-Mail. Single Source of
* Truth für die E-Mail-basierte Auflösung (Magic-Link, Social-Login,
* Kontakt-Zugang).
*
* Bewusst weiterhin über `LOWER(email)`: E-Mails werden nicht normalisiert
* gespeichert, daher ist der case-insensitive Vergleich Pflicht. Ein
* indexfreundlicher Direktvergleich setzt eine Normalisierung beim
* Schreiben voraus (separater, datenseitiger Schritt).
*/
public static function findByEmail(string $email): ?self
{
$email = mb_strtolower(trim($email));
if ($email === '') {
return null;
}
return static::query()->whereRaw('LOWER(email) = ?', [$email])->first();
}
/**
* Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
* gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo.

View file

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

View 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;
}
}

View file

@ -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,
],
);
}
}

View file

@ -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,
]);
}

View file

@ -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(),
]);

View file

@ -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,

View file

@ -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(),
]);

View file

@ -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;
}

View file

@ -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.'),

View file

@ -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);

View file

@ -63,6 +63,11 @@ return [
'vat_rate' => env('BILLING_VAT_RATE', 0.19),
// Standard-Abrechnungswährung (ISO 4217). Intern/Anzeige in Großschreibung
// ('EUR'); für Stripe-API-Aufrufe in Kleinschreibung umwandeln
// (strtolower(config('billing.default_currency'))).
'default_currency' => env('BILLING_DEFAULT_CURRENCY', 'EUR'),
// Eigene deutsche USt-ID des Betreibers — Pflichtangabe für die
// eVatR-Online-Bestätigung ausländischer EU-USt-IDs (BZSt-REST-API).
// Ohne sie bleibt die USt-ID-Prüfung eine reine Formatprüfung.

View file

@ -61,7 +61,7 @@ Keine Fachlogik-Änderung. Reduziert erreichbare Demo-Komponenten und Altlasten.
- [ ] **3.2 API-PressRelease-Zugriff + Eager-Loads zentralisieren** — `findOwnedPressRelease` doppelt (PressRelease/Image-Controller); Relation-Liste (`company:…`, `category.translations`, `images`) mehrfach. → expliziter Scope (`ownedByApiUser`) + `withApiResourceRelations()`. Unique-Slug-Reimplementierung (`PressReleaseController:201-220`) durch bestehendes `HasUniqueSlug` ersetzen.
- [ ] **3.3 `DomainContext`-Service** — Host→Config-Auflösung 4× (`ThemeServiceProvider::boot`, `ThemeMiddleware`, `SetDomainUrl`, `SetCurrentPortal`). **Vorher prüfen, ob `SetDomainUrl` toter Code ist** (scheint nicht in `bootstrap/app.php` registriert). `CurrentPortalContext` mutable static → request-scoped Binding.
- [x] **3.4 Performance** — `LogApiUsage` ist jetzt terminable (INSERT nach gesendeter Antwort, kein Queue-Worker nötig; Startzeit über Request-Attribut). Offene Single-Purchase über `User::openSinglePurchaseCount()` per-Request memoisiert (vorher 3× Count + 1× exists pro Status-Widget-Render → jetzt 1× Count), genutzt von Quota-Total/-Remaining, `hasActiveBooking` und `CustomerStatusSummary`. `CustomerCompanyContext::selectedCompany()` nutzt eine einzige `first()`-Query statt exists()+first() (Verwerfen bei Nichterreichbarkeit bleibt). `LegacySubscriptionService::cancelBundleFor` ist ein einziges Bulk-Update (UserPaymentOption ohne Observer → sicher).
- [ ] **3.5 Kleinere Zentralisierungen** — `VatResolver::normalize/isEuCountry` (3×), `User::findByEmail()` statt `LOWER()`-Queries (defeated Index, 4×), `config('billing.default_currency')` statt `'EUR'`/`'eur'`-Literale, gemeinsame Account-Provisioning-Methode (`ContactAccessService` ≈ `SocialAuthService`).
- [x] **3.5 Kleinere Zentralisierungen** — `VatResolver::normalizeCountryCode()`/`isEuCountry()` als SSOT, genutzt von `resolve()`, `VatIdValidationService` und `StripeCheckoutService` (3×). `User::findByEmail()` bündelt die `LOWER(email)`-Lookups (ContactAccessService 2×, SocialAuthService 1×); bleibt bewusst case-insensitiv, weil E-Mails nicht normalisiert gespeichert werden — der indexfreundliche Direktvergleich braucht erst eine Schreib-Normalisierung (offen). Währung über `config('billing.default_currency')` ('EUR'; für Stripe-Calls `strtolower(...)`), ersetzt 8 verstreute `'EUR'`/`'eur'`-Literale und vereinheitlicht nebenbei die uneinheitliche `SinglePurchase.currency`. Neuer `CustomerAccountProvisioner::provisionVerifiedCustomer()` ist die gemeinsame Provisioning-Methode für ContactAccessService und SocialAuthService (verifiziert + aktiv + customer-Rolle). Tests: `UserFindByEmailTest`, erweiterte `VatResolverTest`.
- [ ] **3.6 Auth-Gates** — `EnsureUserIsAdmin/Customer`, `EnsureApiUserIsActive` von Hand-Rolling → `Gate::define('access-admin'|'access-customer')` + `can:`-Middleware. Impersonation-Redirect aus dem Admin-Gate in eigene Middleware.
---

View file

@ -17,6 +17,22 @@ test('vat treatment follows the 12.06. decision rules', function (?string $count
'Fehlendes Land wie Inland behandeln' => [null, null, VatTreatment::Domestic],
]);
test('normalizeCountryCode uppercases and tolerates null', function () {
$resolver = app(VatResolver::class);
expect($resolver->normalizeCountryCode('at'))->toBe('AT')
->and($resolver->normalizeCountryCode(null))->toBe('')
->and($resolver->normalizeCountryCode('De'))->toBe('DE');
});
test('isEuCountry recognises EU members and excludes DE and third countries', function () {
$resolver = app(VatResolver::class);
expect($resolver->isEuCountry('AT'))->toBeTrue()
->and($resolver->isEuCountry('DE'))->toBeFalse()
->and($resolver->isEuCountry('CH'))->toBeFalse();
});
test('tax cents are derived from the treatment', function () {
$resolver = app(VatResolver::class);

View file

@ -0,0 +1,18 @@
<?php
use App\Models\User;
test('findByEmail matches case-insensitively and trims whitespace', function () {
$user = User::factory()->create(['email' => 'Mixed.Case@Example.com']);
expect(User::findByEmail('mixed.case@example.com')?->id)->toBe($user->id)
->and(User::findByEmail(' MIXED.CASE@EXAMPLE.COM ')?->id)->toBe($user->id);
});
test('findByEmail returns null for blank or unknown email', function () {
User::factory()->create(['email' => 'someone@example.com']);
expect(User::findByEmail(''))->toBeNull()
->and(User::findByEmail(' '))->toBeNull()
->and(User::findByEmail('unknown@example.com'))->toBeNull();
});