User-Panel-Restarbeiten: PM-Guard, Profil-Rework, USt-ID-Prüfung, Buchungspflicht-Adresse

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 14:36:18 +00:00
parent 036a53499f
commit afcca34f91
25 changed files with 905 additions and 140 deletions

View file

@ -104,6 +104,7 @@ class ManualInvoiceService
$addressSnapshot = InvoiceBillingAddress::query()->create([
'salutation_key' => $billingAddress->salutation_key,
'title' => $billingAddress->title,
'company' => $billingAddress->company,
'name' => $billingAddress->name,
'address1' => $billingAddress->address1,
'address2' => $billingAddress->address2,

View file

@ -5,6 +5,7 @@ namespace App\Services\Billing;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Checkout;
/**
@ -27,11 +28,51 @@ class StripeCheckoutService
? $plan->stripe_price_id_yearly
: $plan->stripe_price_id_monthly;
$this->syncTaxIdFromBillingAddress($user);
return $user
->newSubscription('default', $priceId)
->checkout($this->sessionOptions());
}
/**
* Lokal gepflegte USt-ID vor dem Checkout an den Stripe-Customer
* übergeben (User-Panel-Restarbeiten, 12.06.2026) Stripe Tax
* berücksichtigt sie dann ohne erneute Eingabe im Checkout. Fehler
* (z. B. von Stripe abgelehnte ID) blockieren den Checkout nicht:
* Stripe validiert die im Checkout erfasste ID ohnehin selbst.
*/
private function syncTaxIdFromBillingAddress(User $user): void
{
$vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id));
if ($vatId === '') {
return;
}
$prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2);
if ($prefixCountry !== 'DE' && ! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
return;
}
try {
$user->createOrGetStripeCustomer();
$alreadySet = collect($user->taxIds())
->contains(fn (object $taxId): bool => strtoupper((string) $taxId->value) === $vatId);
if (! $alreadySet) {
$user->createTaxId('eu_vat', $vatId);
}
} catch (\Throwable $exception) {
Log::warning('USt-ID konnte nicht an Stripe übergeben werden.', [
'user_id' => $user->id,
'error' => $exception->getMessage(),
]);
}
}
/**
* Gemeinsame Session-Optionen: Stripe Tax braucht eine gültige
* Kundenadresse die im Checkout erfasste Rechnungsadresse (und der
@ -69,6 +110,8 @@ class StripeCheckoutService
*/
public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout
{
$this->syncTaxIdFromBillingAddress($user);
return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [
...$this->sessionOptions(),
'metadata' => ['single_purchase_id' => (string) $purchase->id],

View file

@ -0,0 +1,148 @@
<?php
namespace App\Services\Billing;
use App\Enums\VatIdCheckStatus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Prüft USt-IDs in zwei Stufen (User-Panel-Restarbeiten, 12.06.2026):
*
* 1. **Formatprüfung** (immer): EU-Formatrahmen über den VatResolver,
* inkl. Abgleich mit dem Land der Rechnungsadresse.
* 2. **eVatR-Online-Abfrage** (wenn möglich): REST-API des BZSt
* (`POST /app/v1/abfrage`, Status `evatr-0000` = gültig). Die Abfrage
* setzt die eigene deutsche USt-ID des Betreibers voraus
* (`billing.own_vat_id`) und kann nur ausländische EU-IDs bestätigen
* deutsche IDs und Ausfälle der API führen zu `Unverified`, nie zu
* einem harten Fehler. Stripe validiert die im Checkout erfasste
* USt-ID zusätzlich selbst; diese Prüfung verbessert die Eingabe-UX
* und sichert den manuellen MAN-Rechnungskreis ab.
*/
class VatIdValidationService
{
private const EVATR_ENDPOINT = 'https://api.evatr.vies.bzst.de/app/v1/abfrage';
private const STATUS_VALID = 'evatr-0000';
public function __construct(private readonly VatResolver $vatResolver) {}
/**
* @return array{status: VatIdCheckStatus, message: string}
*/
public function check(string $vatId, ?string $billingCountryCode = null): array
{
$vatId = strtoupper((string) preg_replace('/\s+/', '', $vatId));
if ($vatId === '') {
return $this->result(VatIdCheckStatus::Unverified, __('Keine USt-ID angegeben.'));
}
$prefix = substr($vatId, 0, 2);
$prefixCountry = $prefix === 'EL' ? 'GR' : $prefix;
if (! $this->vatResolver->isPlausibleVatId($vatId, $prefixCountry)) {
return $this->result(
VatIdCheckStatus::FormatInvalid,
__('Das Format der USt-ID ist ungültig (erwartet: Ländercode + Kennziffer, z. B. DE123456789).'),
);
}
$billingCountryCode = strtoupper((string) $billingCountryCode);
if ($billingCountryCode !== '' && ! $this->vatResolver->isPlausibleVatId($vatId, $billingCountryCode)) {
return $this->result(
VatIdCheckStatus::FormatInvalid,
__('Die USt-ID passt nicht zum Land der Rechnungsadresse (:country).', ['country' => $billingCountryCode]),
);
}
if ($prefix === 'DE') {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Deutsche USt-IDs können über eVatR nicht online bestätigt werden.'),
);
}
if (! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Online-Bestätigung ist nur für EU-USt-IDs möglich.'),
);
}
$ownVatId = (string) config('billing.own_vat_id');
if ($ownVatId === '') {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Online-Prüfung ist nicht konfiguriert (BILLING_OWN_VAT_ID).'),
);
}
return $this->confirmViaEvatr($vatId, $ownVatId);
}
/**
* @return array{status: VatIdCheckStatus, message: string}
*/
private function confirmViaEvatr(string $vatId, string $ownVatId): array
{
$cacheKey = 'evatr-check:'.$vatId;
/** @var array{status: VatIdCheckStatus, message: string}|null $cached */
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
try {
$response = Http::timeout(8)
->acceptJson()
->post(self::EVATR_ENDPOINT, [
'anfragendeUstid' => $ownVatId,
'angefragteUstid' => $vatId,
]);
} catch (\Throwable $exception) {
Log::warning('eVatR-Abfrage fehlgeschlagen.', ['vat_id' => $vatId, 'error' => $exception->getMessage()]);
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht erreichbar.'),
);
}
if (! $response->successful()) {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht verfügbar.'),
);
}
$status = (string) $response->json('status');
$result = $status === self::STATUS_VALID
? $this->result(VatIdCheckStatus::Valid, __('USt-ID per eVatR (BZSt) bestätigt — gültig.'))
: $this->result(
VatIdCheckStatus::Invalid,
__('Die USt-ID wurde von eVatR (BZSt) nicht bestätigt (Status :status).', ['status' => $status ?: 'unbekannt']),
);
// Nur definitive Ergebnisse cachen — Ausfälle der API sollen beim
// nächsten Versuch erneut geprüft werden.
Cache::put($cacheKey, $result, now()->addHours(6));
return $result;
}
/**
* @return array{status: VatIdCheckStatus, message: string}
*/
private function result(VatIdCheckStatus $status, string $message): array
{
return ['status' => $status, 'message' => $message];
}
}

View file

@ -49,8 +49,9 @@ class VatResolver
/**
* Formale Plausibilität: beginnt mit dem Ländercode der Adresse und
* trägt danach 213 alphanumerische Zeichen (EU-Formatrahmen).
* Public, damit der VatIdValidationService dieselbe Definition nutzt.
*/
private function isPlausibleVatId(?string $vatId, string $countryCode): bool
public function isPlausibleVatId(?string $vatId, string $countryCode): bool
{
$vatId = strtoupper(preg_replace('/\s+/', '', (string) $vatId) ?? '');