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
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
18
tests/Feature/UserFindByEmailTest.php
Normal file
18
tests/Feature/UserFindByEmailTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue