mivita/app/Services/VatIdService.php
Kevin ac3bd6e8ea Statistiken/Abo: Kundenfeedback 2026-07-24 komplett umgesetzt + Steuern: DATEV-Fixes 08+09/2025 (Sammel-Commit Branch-WIP)
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>
2026-07-24 13:56:02 +00:00

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;
}
}