where('user_id', $user->id)->first() ?? static::query()->where('email', $user->email)->first(); } /** Comparable form: spaces and punctuation are cosmetic in a VAT number. */ public static function normaliseVatId(?string $value): string { return strtoupper(preg_replace('/[\s.-]+/', '', (string) $value) ?? ''); } public function normalisedVatId(): string { return self::normaliseVatId($this->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); } /** * Has anybody actually asked this customer which they are? * * The question every other answer here is built on. NULL is not a third * kind of customer — it is the absence of a record, and the two methods * below are careful never to let it read as one. */ public function hasRecordedType(): bool { return in_array($this->customer_type, self::TYPES, true); } /** * A business, and only if somebody wrote it down. * * Deliberately not inferred from `vat_id`. A business without a VAT number * is an ordinary small business, so the field is empty for a great many of * them, and treating "no number" as "not a business" would be wrong in the * one direction that matters — it would hand a business a consumer's * withdrawal right and, worse, would let anyone type a number to acquire * the opposite. */ public function isBusiness(): bool { return $this->customer_type === self::TYPE_BUSINESS; } /** * A consumer — INCLUDING everyone nobody has asked yet. * * This is the deliberate asymmetry, and it is the whole reason the column * is nullable. A customer whose type was never recorded is treated as the * protected case: they keep the fourteen-day withdrawal right. Getting this * wrong costs us a refund we may not have owed; getting it the other way * round takes a statutory right away from somebody who has it, which is not * a mistake that can be made up to them afterwards. */ public function isConsumer(): bool { return ! $this->isBusiness(); } /** @use HasFactory */ use HasFactory, HasUuid; protected $fillable = [ 'user_id', 'name', 'contact_name', 'email', 'customer_type', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address', 'billing_street', 'billing_postcode', 'billing_city', 'billing_country', '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); } public function subscriptions(): HasMany { return $this->hasMany(Subscription::class); } public function supportRequests(): HasMany { return $this->hasMany(SupportRequest::class); } /** Everything this installation has sent to them, newest first at the call site. */ public function sentMails(): HasMany { return $this->hasMany(SentMail::class); } /** * True if this address already belongs to a customer identity — R21's * other direction, checked before an `operators` row is created or * renamed onto it (Admin\Settings::saveAccount(), ::inviteStaff(), and * the clupilot:create-operator command — one rule, three call sites). * * Checks `users` directly, not only `customers`: the two are usually in * lockstep (see ensureUser()), but a `users` row can outlive or predate * a matching `customers.email` — an email changed on one side only, or * legacy/orphaned data — and a `customers`-only check is blind to * exactly that row. */ public static function emailTaken(string $email): bool { return static::query()->where('email', $email)->exists() || User::query()->where('email', $email)->exists(); } /** * Find or create the portal login account for this customer and link it. * Race-safe against the unique email index. Refuses by construction to * create or link a `users` row for an address that already belongs to an * `operators` row (R21) — that table split killed the old "an operator * account never has an is_admin users row" guarantee this method used to * lean on, and left this one open: nothing stops the SAME address from * being provisioned as a customer independently of its operator row. * * @throws IdentityCollisionException when the address is already an operator's. */ public function ensureUser(): User { if ($this->user) { return $this->user; } if (Operator::query()->where('email', $this->email)->exists()) { throw new IdentityCollisionException($this->email); } 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(); } }