*/ use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. * * @var list */ protected $fillable = [ 'name', 'email', 'portal', 'registration_type', 'language', 'is_active', 'is_super_admin', 'last_login_at', 'last_login_ip', 'gdpr_consent_at', 'last_seen_at', 'legacy_portal', 'legacy_id', 'password', 'oauth_provider', 'oauth_provider_id', 'press_release_quota_used_this_month', ]; /** * The attributes that should be hidden for serialization. * * @var list */ protected $hidden = [ 'password', 'remember_token', ]; /** * Get the attributes that should be cast. * * @return array */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'portal' => Portal::class, 'registration_type' => RegistrationType::class, 'is_active' => 'boolean', 'is_super_admin' => 'boolean', 'last_login_at' => 'datetime', 'gdpr_consent_at' => 'datetime', 'last_seen_at' => 'datetime', 'deleted_at' => 'datetime', 'password' => 'hashed', 'press_release_quota_used_this_month' => 'integer', ]; } /** * Adresse für die Stripe-Customer-Anlage (Cashier-Hook). Stripe Tax * braucht eine gültige Kundenadresse — falls lokal eine * Rechnungsadresse gepflegt ist, wird sie direkt mitgegeben; sonst * speichert der Checkout die dort erfasste Adresse (customer_update). * * @return array|null */ public function stripeAddress(): ?array { $address = $this->billingAddress; if (! $address) { return null; } return [ 'line1' => $address->address1, 'line2' => $address->address2, 'postal_code' => $address->postal_code, 'city' => $address->city, 'country' => $address->country_code, ]; } /** * Name für den Stripe-Customer (Cashier-Hook): Rechnungsempfänger ist * die Firma bzw. der Name aus der Rechnungsadresse — nicht der * Login-Anzeigename (QA-Befund 07.07.: falscher Empfänger auf der * Stripe-Rechnung). */ public function stripeName(): ?string { $address = $this->billingAddress; return $address?->company ?: ($address?->name ?: $this->name); } /** * Buchungs-Voraussetzung (Entscheidung 12.06.2026): Tarif- und * Einzel-PM-Checkouts erfordern eine vollständige Rechnungsadresse. */ public function hasCompleteBillingAddress(): bool { return (bool) $this->billingAddress?->isComplete(); } /** * Per-Request-Memoisierung: Tarif und Unlimited-Flag werden pro Render * mehrfach abgefragt (u. a. vom immer sichtbaren Status-Widget und der * Buchungsseite). Beide ändern sich innerhalb eines Requests nicht über * In-App-Aktionen (Abo läuft über Stripe-Redirect, Bestandstarif über * CLI), daher ist die Memoisierung sicher; `fresh()` liefert eine neue * Instanz und setzt den Cache zurück. */ private bool $currentPlanResolved = false; private ?Plan $cachedCurrentPlan = null; private ?bool $cachedHasUnlimitedQuota = null; private ?int $cachedOpenSinglePurchaseCount = null; /** * @var list|null */ private ?array $cachedAccessibleCompanyIds = 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. */ public function currentPlan(): ?Plan { if ($this->currentPlanResolved) { return $this->cachedCurrentPlan; } $this->currentPlanResolved = true; $subscription = $this->subscription(); if (! $subscription?->valid()) { return $this->cachedCurrentPlan = null; } $priceId = $subscription->stripe_price; if (! $priceId) { return $this->cachedCurrentPlan = null; } return $this->cachedCurrentPlan = Plan::query() ->where('stripe_price_id_monthly', $priceId) ->orWhere('stripe_price_id_yearly', $priceId) ->first(); } /** * Abrechnungs-Tier für die Credit-Preisableitung. Ohne aktives Abo gilt * `Tier::Einzel` (Pay-per-Release). */ public function currentTier(): Tier { return Tier::fromPlanSlug($this->currentPlan()?->slug); } /** * Hat dieser User ein unbegrenztes PM-Kontingent? * * Entscheidung 12.06.2026: Bestandskunden (aktive/grandfathered * Legacy-Vereinbarung) behalten ihren Bestandsschutz unverändert — * das Alt-Produkt sah unbegrenzte PMs vor. Solange der Launch-Schalter * `billing.enforce_booking` aus ist, gilt das Kontingent für niemanden. */ public function hasUnlimitedPressReleaseQuota(): bool { if ($this->cachedHasUnlimitedQuota !== null) { return $this->cachedHasUnlimitedQuota; } if (! config('billing.enforce_booking')) { return $this->cachedHasUnlimitedQuota = true; } return $this->cachedHasUnlimitedQuota = $this->hasActivePaymentOption(); } /** * Period-aware Prüfung auf eine aktuell gültige Zahlungsoption * (aktives Abo in laufender Periode oder laufender Bestandsschutz). * Single Source of Truth für API-Gate und Buchungs-/Kontingent-Checks. */ public function hasActivePaymentOption(): bool { return $this->userPaymentOptions()->activeNow()->exists(); } /** * Verbleibendes PM-Kontingent: Rest des Plan-Monatskontingents plus * bezahlte, noch nicht eingelöste Einzel-/Extra-PM-Käufe. * Null bedeutet unbegrenzt. */ public function pressReleaseQuotaRemaining(): ?int { if ($this->hasUnlimitedPressReleaseQuota()) { return null; } $planRemaining = max( 0, ($this->currentPlan()?->press_release_quota ?? 0) - (int) $this->press_release_quota_used_this_month, ); return $planRemaining + $this->openSinglePurchaseCount(); } /** * Bezahlte, noch nicht eingelöste Einmalkäufe (Einzel-/Extra-PM), die zur * Einreichung berechtigen. Per-Request memoisiert, weil das Status-Widget * den Wert je Render mehrfach abfragt (Kontingent, Buchungslabel, Gate). */ public function openSinglePurchaseCount(): int { if ($this->cachedOpenSinglePurchaseCount !== null) { return $this->cachedOpenSinglePurchaseCount; } return $this->cachedOpenSinglePurchaseCount = $this->singlePurchases() ->grantingSubmission() ->count(); } /** * Gesamtes PM-Kontingent (Plan-Monatskontingent plus offene Einmalkäufe) * für die Anzeige „verbleibend / gesamt". Null bedeutet unbegrenzt. */ public function pressReleaseQuotaTotal(): ?int { if ($this->hasUnlimitedPressReleaseQuota()) { return null; } return ($this->currentPlan()?->press_release_quota ?? 0) + $this->openSinglePurchaseCount(); } /** * Submit-Gate aus dem Decision-Update §5.1: Einreichen zur Prüfung * erfordert eine aktive Buchung. * * Hybrides Modell: Eine Buchung ist entweder ein aktives Stripe-Abo * (Cashier, STR-Kreis), ein bezahlter Einmalkauf (Einzel-PM/Extra-PM) * oder eine laufende Legacy-Zahlungsvereinbarung (manueller MAN-Kreis). * Solange `billing.enforce_booking` deaktiviert ist, bleibt das Gate * offen (Launch-Schalter). */ public function hasActiveBooking(): bool { if (! config('billing.enforce_booking')) { return true; } if ($this->subscribed()) { return true; } if ($this->openSinglePurchaseCount() > 0) { return true; } return $this->hasActivePaymentOption(); } /** * Get the user's initials */ public function initials(): string { return Str::of($this->name) ->explode(' ') ->map(fn (string $name) => Str::of($name)->substr(0, 1)) ->implode(''); } public function profile(): HasOne { return $this->hasOne(Profile::class); } public function magicLinks(): HasMany { return $this->hasMany(MagicLink::class); } public function ownedCompanies(): HasMany { return $this->hasMany(Company::class, 'owner_user_id'); } public function companies(): BelongsToMany { return $this->belongsToMany(Company::class) ->withPivot('role') ->withTimestamps(); } public function contacts(): BelongsToMany { return $this->belongsToMany(Contact::class) ->withTimestamps(); } public function pressReleases(): HasMany { return $this->hasMany(PressRelease::class); } public function newsletterSubscriptions(): HasMany { return $this->hasMany(NewsletterSubscription::class); } public function billingAddress(): HasOne { return $this->hasOne(BillingAddress::class); } public function userPaymentOptions(): HasMany { return $this->hasMany(UserPaymentOption::class); } public function singlePurchases(): HasMany { return $this->hasMany(SinglePurchase::class); } public function creditWallet(): HasOne { return $this->hasOne(CreditWallet::class); } public function creditTransactions(): HasMany { return $this->hasMany(CreditTransaction::class); } public function reviewChecks(): HasMany { return $this->hasMany(ReviewCheck::class); } public function creditTopups(): HasMany { return $this->hasMany(CreditTopup::class); } /** * Aktuelles Credit-Guthaben (1 Credit = 1 €). 0, solange keine Wallet * angelegt wurde. */ public function creditBalance(): int { return (int) ($this->creditWallet?->balance_credits ?? 0); } /** * Lokale Rechnungen (STR- und MAN-Kreis). Überschreibt bewusst die * gleichnamige Cashier-Methode — Stripe-Rechnungen werden beim * Webhook-Sync (9E) in diese Tabelle gespiegelt. */ public function invoices(): HasMany { return $this->hasMany(Invoice::class); } public function legacyInvoices(): HasMany { return $this->hasMany(LegacyInvoice::class); } /** * Neueste Legacy-Rechnung des Users (nach Rechnungsdatum, dann ID). * Zentralisiert die zuvor mehrfach reimplementierte „latest"-Abfrage. */ public function latestLegacyInvoice(): ?LegacyInvoice { return $this->legacyInvoices()->latestFirst()->first(); } public function filterPresets(): HasMany { return $this->hasMany(UserFilterPreset::class); } public function canAccessAdmin(): bool { if (! $this->is_active) { return false; } if ($this->is_super_admin) { return true; } return $this->hasAnyRole(['admin', 'editor']); } public function canAccessCustomer(): bool { if (! $this->is_active) { return false; } return $this->hasAnyRole(['admin', 'editor', 'customer']); } /** * Firmen, auf die dieser User Zugriff hat: als Owner (owner_user_id) oder * als Mitglied über den company_user-Pivot. Basis für das Firmen-Scoping * von Pressemitteilungen (Zugriff = Autor ODER Firmenzugehörigkeit). * * @return list */ public function accessibleCompanyIds(): array { if ($this->cachedAccessibleCompanyIds !== null) { return $this->cachedAccessibleCompanyIds; } // toBase() statt der Relation-pluck(): Eloquents Builder::pluck() // hydratisiert sonst pro ID ein vollständiges Company-Model (bei // Power-Usern mit zehntausenden Firmen ein massives Speicher-/Zeit-Leck). // toBase() wendet die Global Scopes weiter an, liefert aber nur die // nackten ID-Werte ohne Model-Hydration. return $this->cachedAccessibleCompanyIds = $this->ownedCompanies()->toBase()->pluck('companies.id') ->merge($this->companies()->toBase()->pluck('companies.id')) ->unique() ->map(fn ($id): int => (int) $id) ->values() ->all(); } public function canAccessCompany(?int $companyId): bool { if ($companyId === null) { return false; } return in_array($companyId, $this->accessibleCompanyIds(), true); } /** * Synchronisiert die Firmenzuordnungen samt Pivot-Rolle. Fehlt zu einer * Firma eine Rolle, greift 'member'. Gemeinsamer Persistenz-Kern des * Anlegen- und Bearbeiten-Formulars (admin/users). * * @param list $companyIds * @param array $roles companyId => role */ public function syncCompaniesWithRoles(array $companyIds, array $roles): void { $payload = []; foreach ($companyIds as $companyId) { $payload[$companyId] = ['role' => $roles[$companyId] ?? 'member']; } $this->companies()->sync($payload); } }