diff --git a/app/Console/Commands/GrandfatherLegacySubscriptions.php b/app/Console/Commands/GrandfatherLegacySubscriptions.php index dac94939..fbd7c8a5 100644 --- a/app/Console/Commands/GrandfatherLegacySubscriptions.php +++ b/app/Console/Commands/GrandfatherLegacySubscriptions.php @@ -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, ], diff --git a/app/Console/Commands/SyncStripePlans.php b/app/Console/Commands/SyncStripePlans.php index b12e977d..7016dd4f 100644 --- a/app/Console/Commands/SyncStripePlans.php +++ b/app/Console/Commands/SyncStripePlans.php @@ -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'], diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index f087799a..98b7f399 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -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); diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 06fa5492..4f17476b 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -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(), diff --git a/app/Models/User.php b/app/Models/User.php index 5d2cae67..ed0c201c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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. diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index a18911dd..37ecbce0 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -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); } /** diff --git a/app/Services/Auth/CustomerAccountProvisioner.php b/app/Services/Auth/CustomerAccountProvisioner.php new file mode 100644 index 00000000..384c8f33 --- /dev/null +++ b/app/Services/Auth/CustomerAccountProvisioner.php @@ -0,0 +1,41 @@ + $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; + } +} diff --git a/app/Services/Auth/SocialAuthService.php b/app/Services/Auth/SocialAuthService.php index 3d6fa198..3b68b911 100644 --- a/app/Services/Auth/SocialAuthService.php +++ b/app/Services/Auth/SocialAuthService.php @@ -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, + ], + ); } } diff --git a/app/Services/Billing/CreditTopupService.php b/app/Services/Billing/CreditTopupService.php index 46283336..6318577c 100644 --- a/app/Services/Billing/CreditTopupService.php +++ b/app/Services/Billing/CreditTopupService.php @@ -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, ]); } diff --git a/app/Services/Billing/ExtraPmPurchaseService.php b/app/Services/Billing/ExtraPmPurchaseService.php index e06225e0..0325978e 100644 --- a/app/Services/Billing/ExtraPmPurchaseService.php +++ b/app/Services/Billing/ExtraPmPurchaseService.php @@ -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(), ]); diff --git a/app/Services/Billing/ManualInvoiceService.php b/app/Services/Billing/ManualInvoiceService.php index 8b11b237..4fd81414 100644 --- a/app/Services/Billing/ManualInvoiceService.php +++ b/app/Services/Billing/ManualInvoiceService.php @@ -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, diff --git a/app/Services/Billing/ProofPdfService.php b/app/Services/Billing/ProofPdfService.php index eea00ee3..7f7c1316 100644 --- a/app/Services/Billing/ProofPdfService.php +++ b/app/Services/Billing/ProofPdfService.php @@ -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(), ]); diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index a3905156..4f8b485b 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -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; } diff --git a/app/Services/Billing/VatIdValidationService.php b/app/Services/Billing/VatIdValidationService.php index 2ff293dd..6eb83b5d 100644 --- a/app/Services/Billing/VatIdValidationService.php +++ b/app/Services/Billing/VatIdValidationService.php @@ -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.'), diff --git a/app/Services/Billing/VatResolver.php b/app/Services/Billing/VatResolver.php index 197c4711..7cf6d305 100644 --- a/app/Services/Billing/VatResolver.php +++ b/app/Services/Billing/VatResolver.php @@ -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); diff --git a/config/billing.php b/config/billing.php index 793adfae..9997334c 100644 --- a/config/billing.php +++ b/config/billing.php @@ -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. diff --git a/dev/optimize/entwicklungsplan.md b/dev/optimize/entwicklungsplan.md index 993bd238..76872d1d 100644 --- a/dev/optimize/entwicklungsplan.md +++ b/dev/optimize/entwicklungsplan.md @@ -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. --- diff --git a/tests/Feature/Billing/VatResolverTest.php b/tests/Feature/Billing/VatResolverTest.php index a9778852..f1a00e60 100644 --- a/tests/Feature/Billing/VatResolverTest.php +++ b/tests/Feature/Billing/VatResolverTest.php @@ -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); diff --git a/tests/Feature/UserFindByEmailTest.php b/tests/Feature/UserFindByEmailTest.php new file mode 100644 index 00000000..b30abcb0 --- /dev/null +++ b/tests/Feature/UserFindByEmailTest.php @@ -0,0 +1,18 @@ +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(); +});