vat_id); } /** * True only for the value that was actually checked. Binding it to the * value rather than to a flag means editing the field cannot inherit the * old confirmation, whichever code path does the editing. */ public function hasVerifiedVatId(): bool { // Both sides normalised: a verifier that stores the number in display // form ("DE 811 907 980") must not invalidate a genuine confirmation. return $this->vat_id_verified_at !== null && $this->normalisedVatId() !== '' && $this->normalisedVatId() === self::normaliseVatId($this->vat_id_verified_value); } /** @use HasFactory<\Database\Factories\CustomerFactory> */ use HasFactory, HasUuid; protected $fillable = [ 'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address', 'locale', 'stripe_customer_id', 'status', 'closed_at', 'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color', ]; protected function casts(): array { return ['closed_at' => 'datetime', 'vat_id_verified_at' => 'datetime']; } public function seats(): HasMany { return $this->hasMany(Seat::class); } /** * Resolve branding: customer values where set, else CluPilot defaults. Used * for previews and snapshotted into the provisioning run so retries are * deterministic. NULL is stored for "unset" — defaults are never copied in. * * @return array{display_name:string,logo_path:?string,primary_color:string,accent_color:string,is_default:bool} */ public function brandingResolved(): array { $defaults = (array) config('provisioning.branding_defaults'); return [ 'display_name' => $this->brand_display_name ?: ($defaults['display_name'] ?? 'CluPilot'), 'logo_path' => $this->brand_logo_path ?: ($defaults['logo_path'] ?? null), 'primary_color' => $this->brand_primary_color ?: ($defaults['primary_color'] ?? '#f97316'), 'accent_color' => $this->brand_accent_color ?: ($defaults['accent_color'] ?? '#c2560a'), 'is_default' => $this->brand_display_name === null && $this->brand_logo_path === null && $this->brand_primary_color === null && $this->brand_accent_color === null, ]; } public function user(): BelongsTo { return $this->belongsTo(User::class); } public function orders(): HasMany { return $this->hasMany(Order::class); } public function instances(): HasMany { return $this->hasMany(Instance::class); } /** * Find or create the portal login account for this customer and link it. * Race-safe against the unique email index. Never links an operator * account by construction, not by a runtime check: operators live in * `operators`, on their own guard, and are never rows in `users` at all * (see R21) — there is no longer an admin `users` row this could find. */ public function ensureUser(): User { if ($this->user) { return $this->user; } if (($existing = User::query()->where('email', $this->email)->first()) !== null) { $this->update(['user_id' => $existing->id]); return $existing; } try { $user = User::query()->create([ 'email' => $this->email, 'name' => $this->name, 'password' => Hash::make(Str::random(40)), ]); } catch (UniqueConstraintViolationException) { // Concurrent first-time creation — re-fetch and link the winner. $user = User::query()->where('email', $this->email)->firstOrFail(); } $this->update(['user_id' => $user->id]); return $user->refresh(); } }