*/ use Billable, HasApiTokens, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. * * @var list */ protected $fillable = [ 'name', 'email', 'password', 'encryption_salt', 'onboarded_at', 'paywall_seen_at', 'currency_code', 'locale', 'timezone', 'current_space_id', ]; /** * The attributes that should be hidden for serialization. * * @var list */ protected $hidden = [ 'password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', 'stripe_id', 'pm_type', 'pm_last_four', 'trial_ends_at', 'encryption_salt', ]; /** * Get the attributes that should be cast. * * @return array */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', 'two_factor_confirmed_at' => 'datetime', 'onboarded_at' => 'datetime', 'paywall_seen_at' => 'datetime', 'last_logged_in_at' => 'datetime', 'last_active_at' => 'datetime', 'transactions_last_visited_at' => 'datetime', 'ai_consent_prompt_dismissed_at' => 'datetime', ]; } /** * Memoized active space for the current request lifecycle. */ protected ?Space $resolvedActiveSpace = null; protected static function booted(): void { static::created(function (User $user): void { $user->provisionPersonalSpace(); }); } public function isOnboarded(): bool { return $this->onboarded_at !== null; } public function hasSeenPaywall(): bool { return $this->paywall_seen_at !== null; } /** @return HasOne */ public function setting(): HasOne { return $this->hasOne(UserSetting::class); } /** @return HasOne */ public function encryptedMessage(): HasOne { return $this->hasOne(EncryptedMessage::class); } /** @return HasMany */ public function transactions(): HasMany { return $this->hasMany(Transaction::class); } /** @return HasMany */ public function accounts(): HasMany { return $this->hasMany(Account::class); } /** @return HasMany */ public function categories(): HasMany { return $this->hasMany(Category::class); } /** @return HasMany */ public function banks(): HasMany { return $this->hasMany(Bank::class) ->where(function (Builder $query) { $query->whereNull('user_id') ->orWhere('banks.user_id', $this->id); }); } /** @return BelongsTo */ public function currentSpace(): BelongsTo { return $this->belongsTo(Space::class, 'current_space_id'); } /** @return HasMany */ public function ownedSpaces(): HasMany { return $this->hasMany(Space::class, 'owner_id'); } /** * The one personal space every user owns (created on registration). * * @return HasOne */ public function personalSpace(): HasOne { return $this->hasOne(Space::class, 'owner_id')->where('personal', true); } /** * Spaces the user was invited into (excludes the ones they own). * * @return BelongsToMany */ public function memberSpaces(): BelongsToMany { return $this->belongsToMany(Space::class, 'space_user') ->withPivot('role') ->withTimestamps(); } /** * Every space the user can access: the ones they own plus the ones they were * invited into, ordered with the personal space first. * * @return Collection */ public function accessibleSpaces(): Collection { return Space::query() ->where('owner_id', $this->id) ->orWhereHas('members', fn (Builder $query) => $query->whereKey($this->id)) ->orderByDesc('personal') ->orderBy('name') ->get(); } /** * Idempotently ensure the user has a personal space and points at it. */ public function provisionPersonalSpace(): Space { $space = $this->ownedSpaces()->firstOrCreate( ['personal' => true], ['name' => 'Personal'], ); if ($this->current_space_id === null) { $this->forceFill(['current_space_id' => $space->id])->saveQuietly(); } return $space; } /** * The space the user is currently working in. Falls back to (and repairs * towards) the personal space when the pointer is missing or points at a * space the user can no longer access — e.g. after a membership is revoked or * a Business subscription lapses. */ public function activeSpace(): Space { if ($this->resolvedActiveSpace !== null) { return $this->resolvedActiveSpace; } $space = $this->current_space_id !== null ? Space::query()->find($this->current_space_id) : null; if ($space === null || ! $space->hasMember($this)) { $space = $this->provisionPersonalSpace(); $this->forceFill(['current_space_id' => $space->id])->saveQuietly(); } return $this->resolvedActiveSpace = $space; } /** @return HasMany */ public function automationRules(): HasMany { return $this->hasMany(AutomationRule::class); } /** @return HasMany */ public function aiConsents(): HasMany { return $this->hasMany(AiConsent::class); } /** @return HasMany */ public function suggestionRuns(): HasMany { return $this->hasMany(SuggestionRun::class); } /** @return HasMany */ public function integrationRequests(): HasMany { return $this->hasMany(IntegrationRequest::class); } /** @return HasMany */ public function integrationRequestVotes(): HasMany { return $this->hasMany(IntegrationRequestVote::class); } /** * Whether the user has an active, current-version AI consent. */ public function hasActiveAiConsent(string $scope = AiConsent::SCOPE_FINANCE): bool { return $this->aiConsents()->active($scope)->exists(); } /** * Record an AI consent for the current consent version (idempotent). */ public function recordAiConsent(string $scope = AiConsent::SCOPE_FINANCE): AiConsent { return $this->aiConsents()->firstOrCreate( [ 'scope' => $scope, 'version' => (string) config('ai_suggestions.consent_version'), 'revoked_at' => null, ], ['accepted_at' => now()], ); } /** * Revoke any active AI consents for the given scope. */ public function revokeAiConsent(string $scope = AiConsent::SCOPE_FINANCE): void { $this->aiConsents()->active($scope)->update(['revoked_at' => now()]); } /** * Whether the user has already answered the AI consent prompt (accepted or * dismissed it), so the transactions banner should no longer be shown. */ public function hasDismissedAiConsentPrompt(): bool { return $this->ai_consent_prompt_dismissed_at !== null; } /** * Permanently dismiss the AI consent prompt (idempotent). */ public function dismissAiConsentPrompt(): void { if ($this->ai_consent_prompt_dismissed_at === null) { $this->forceFill(['ai_consent_prompt_dismissed_at' => now()])->save(); } } /** @return HasMany */ public function labels(): HasMany { return $this->hasMany(Label::class); } /** @return HasMany */ public function mailLogs(): HasMany { return $this->hasMany(UserMailLog::class); } /** @return HasMany */ public function budgets(): HasMany { return $this->hasMany(Budget::class); } /** @return HasMany */ public function bankingConnections(): HasMany { return $this->hasMany(BankingConnection::class); } public function hasReceivedEmail(DripEmailType $type): bool { return $this->mailLogs()->where('email_type', $type)->exists(); } public function hasProPlan(): bool { if (! config('subscriptions.enabled')) { return true; } return $this->subscribed('default'); } /** * Whether the user can access the given feature on their current plan. */ public function canUseFeature(PlanFeature $feature): bool { if (! $feature->requiresProPlan()) { return true; } return $this->hasProPlan(); } public function hasPastDueSubscription(): bool { if (! config('subscriptions.enabled')) { return false; } $subscription = $this->subscription('default'); return $subscription !== null && $subscription->stripe_status === 'past_due' && ! $subscription->ended(); } public function hasCanceledSubscription(): bool { if (! config('subscriptions.enabled')) { return false; } $subscription = $this->subscription('default'); return $subscription !== null && $subscription->stripe_status === 'canceled' && $subscription->ended(); } /** * Whether the user is still being billed: on a trial or holding an * active subscription that has not been cancelled (grace period excluded, * as it will not renew). Such users must cancel before deleting their account. */ public function hasActiveSubscriptionOrTrial(): bool { if (! config('subscriptions.enabled')) { return false; } if ($this->onGenericTrial()) { return true; } $subscription = $this->subscription('default'); return $subscription !== null && $subscription->valid() && ! $subscription->onGracePeriod(); } /** * The tax rates that should apply to the customer's subscriptions. * * @return array */ public function taxRates(): array { return config('subscriptions.tax_rates', []); } public function isDemoAccount(): bool { return $this->email === config('app.demo.email'); } public function isAdmin(): bool { return $this->email === config('mail.admin_email'); } public function preferredLocale(): string { return $this->locale ?? 'en'; } public function isDeleted(): bool { return $this->trashed(); } public function markAsDeleted(): void { if ($this->trashed()) { return; } DB::transaction(function () { $this->forceFill([ 'email' => $this->deletedEmail(), ])->saveQuietly(); $this->delete(); }); } public function canReceiveEmails(): bool { return ! $this->isDeleted(); } public function wantsBankTransactionsSyncedEmail(): bool { return $this->setting->notify_on_bank_transactions_synced ?? true; } public function routeNotificationForMail(?Notification $notification = null): ?string { if (! $this->canReceiveEmails()) { return null; } $email = trim((string) $this->email); if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { Log::warning('Skipping mail notification: invalid recipient email', [ 'user_id' => $this->getKey(), 'notification' => $notification ? $notification::class : null, ]); return null; } return $email; } public function sendEmailVerificationNotification(): void { if (! $this->canReceiveEmails()) { return; } $this->notify(new VerifyEmailNotification); } private function deletedEmail(): string { $timestamp = $this->freshTimestamp()->format('YmdHis'); $originalEmail = $this->getOriginal('email'); $candidate = "{$timestamp}_{$originalEmail}"; if (! static::withTrashed()->where('email', $candidate)->exists()) { return $candidate; } return "{$timestamp}_{$this->getKey()}_{$originalEmail}"; } }