Feedback-Umsetzung (dev/2026-07-22/feedback-analyse-2026-07-24.md, alle 10 Punkte):
- VIP-Statistik: eigener Berater ('ich', Linie 0) in Neue-Kundenabos- und beiden Top-10-Listen; Top-10-Umsatz sortiert nach abgerechneten Ist-Punkten + Abo-Anzahl-Spalte; Fußnote nicht-payline-wirksame Punkte (SalesVolumePoints::nonPaylinePoints)
- Führungskräfte-Liste: beide Sichten 'direkt' (an nächster FK abgeschnitten, additiv) und 'gesamt' (komplette Downline), Gesamt-Spalte nach Kundenzählweise
- Team-Abos: je Ebene 'abgerechnet (Monat)' und 'bis zum 25. erwartet'; Payline-Karte mit Teamabo-/Kundenabo-Zählern
- Incentive-Live-Ranking: Qualifizierte zuerst nummeriert (fixt auch isWinner rank<=max_winners)
- Porto: Netto-/Brutto-Kennzeichnung (Berater netto, Endkunden inkl. MwSt., portal_view-Flag; auch CRM-/Shop-Warenkorb + Checkout), Yard::shipping_tax-Fix
- Struktur-Ansicht: Abo-Badges 'A' (eigenes Abo) und '(n)' (Kundenabos) je Teampartner, gebatcht
- Abo-Löschung: Softdelete setzt active=0 (UserAbo deleting-Hook) + Bereinigungs-Migration für gelöschte-aber-aktive Abos
- Doku: technische Analyse + Kundinnen-Feedback-Dokument unter dev/2026-07-22/
- Tests: IncentiveRankingTest, TeamStructureAboBadgesTest, YardShippingTaxTest (neu); BackofficeStatisticsListsTest, TeamAboOverviewTest, PaylineForecastServiceTest, AboAdminDeleteTest, AboOneTimeViewTest erweitert
Außerdem enthalten (Branch-WIP vor dem Feedback): DATEV-Abgleich-Fixes 08+09/2025, USt-ID-Live-Validierung sowie restliche unkommittete Stände (u. a. Abo-Pause-Sprachkeys).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
173 lines
5.3 KiB
PHP
173 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Zentrale Prüfung von Umsatzsteuer-Identifikationsnummern (USt-ID).
|
|
*
|
|
* - Formatprüfung (EU-Länderpräfix + Nummernteil)
|
|
* - Deutsche USt-IDs qualifizieren nie für Reverse-Charge / steuerfreie igL
|
|
* - Live-Prüfung gegen den EU-VIES-Dienst (mit Cache, da VIES langsam/instabil ist)
|
|
*/
|
|
class VatIdService
|
|
{
|
|
public const CHECK_VALID = 'valid';
|
|
|
|
public const CHECK_INVALID = 'invalid';
|
|
|
|
public const CHECK_UNAVAILABLE = 'unavailable';
|
|
|
|
private const VIES_WSDL = 'https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl';
|
|
|
|
/**
|
|
* VIES-Ländercodes (EL = Griechenland, XI = Nordirland).
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
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;
|
|
}
|
|
}
|