*/ private const EU_COUNTRY_CODES = [ 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', 'FI', 'FR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'XI', ]; /** * Entfernt Leerzeichen, Punkte, Kommas und Bindestriche und wandelt in Großbuchstaben um. */ public function normalize(?string $vatId): string { return strtoupper(str_replace([' ', '.', '-', ','], '', trim((string) $vatId))); } /** * Liefert den Ländercode aus dem USt-ID-Präfix, sonst den Fallback (Account-Land). */ public function extractCountryCode(?string $vatId, ?string $fallbackCountryCode = null): ?string { $normalized = $this->normalize($vatId); $prefix = substr($normalized, 0, 2); if (in_array($prefix, self::EU_COUNTRY_CODES, true)) { return $prefix; } $fallback = strtoupper((string) $fallbackCountryCode); return in_array($fallback, self::EU_COUNTRY_CODES, true) ? $fallback : null; } /** * Liefert den Nummernteil ohne Ländercode-Präfix. */ public function extractVatNumber(?string $vatId): string { $normalized = $this->normalize($vatId); if (in_array(substr($normalized, 0, 2), self::EU_COUNTRY_CODES, true)) { return substr($normalized, 2); } return $normalized; } /** * Grobe Formatprüfung: EU-Länderpräfix + 2-13 Zeichen Nummernteil. */ public function isPlausibleFormat(?string $vatId): bool { $normalized = $this->normalize($vatId); return (bool) preg_match('/^[A-Z]{2}[A-Z0-9+*]{2,13}$/', $normalized) && in_array(substr($normalized, 0, 2), self::EU_COUNTRY_CODES, true); } /** * Nur ausländische EU-USt-IDs qualifizieren für steuerfreie igL / Reverse-Charge. * Deutsche USt-IDs (DE...) werden grundsätzlich abgelehnt. */ public function qualifiesForReverseCharge(?string $vatId): bool { return $this->isPlausibleFormat($vatId) && ! $this->isGermanVatId($vatId); } public function isGermanVatId(?string $vatId): bool { return str_starts_with($this->normalize($vatId), 'DE'); } /** * Live-Prüfung gegen VIES. * * @return string self::CHECK_VALID | self::CHECK_INVALID | self::CHECK_UNAVAILABLE */ public function checkVies(?string $vatId, ?string $fallbackCountryCode = null): string { if (! $this->isPlausibleFormat($vatId)) { return self::CHECK_INVALID; } $countryCode = $this->extractCountryCode($vatId, $fallbackCountryCode); $vatNumber = $this->extractVatNumber($vatId); if (! $countryCode || $vatNumber === '') { return self::CHECK_INVALID; } if (! config('services.vies.enabled', true)) { return self::CHECK_UNAVAILABLE; } $timeout = (int) config('services.vies.timeout', 10); try { $client = new \SoapClient(self::VIES_WSDL, [ 'cache_wsdl' => WSDL_CACHE_NONE, 'connection_timeout' => $timeout, 'exceptions' => true, 'stream_context' => stream_context_create([ 'http' => ['timeout' => $timeout], ]), ]); $result = $client->checkVat([ 'countryCode' => $countryCode, 'vatNumber' => $vatNumber, ]); return $result->valid ? self::CHECK_VALID : self::CHECK_INVALID; } catch (\Throwable $e) { Log::warning('VIES-Prüfung nicht erreichbar', [ 'vat_id' => $this->normalize($vatId), 'error' => $e->getMessage(), ]); return self::CHECK_UNAVAILABLE; } } /** * VIES-Prüfung mit Cache (24h). CHECK_UNAVAILABLE wird nur kurz gecacht (10 Min.), * damit ein VIES-Ausfall den Checkout nicht bei jedem Aufruf blockiert. */ public function checkViesCached(?string $vatId, ?string $fallbackCountryCode = null): string { if (! $this->isPlausibleFormat($vatId)) { return self::CHECK_INVALID; } $cacheKey = 'vies-check:'.$this->normalize($vatId); $cached = Cache::get($cacheKey); if ($cached !== null) { return $cached; } $result = $this->checkVies($vatId, $fallbackCountryCode); $ttl = $result === self::CHECK_UNAVAILABLE ? now()->addMinutes(10) : now()->addHours(24); Cache::put($cacheKey, $result, $ttl); return $result; } }