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:
parent
036a53499f
commit
afcca34f91
25 changed files with 905 additions and 140 deletions
19
app/Enums/VatIdCheckStatus.php
Normal file
19
app/Enums/VatIdCheckStatus.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Ergebnis der USt-ID-Prüfung (Format + optionale eVatR-Online-Abfrage).
|
||||
*/
|
||||
enum VatIdCheckStatus: string
|
||||
{
|
||||
case Valid = 'valid';
|
||||
case Invalid = 'invalid';
|
||||
case FormatInvalid = 'format_invalid';
|
||||
case Unverified = 'unverified';
|
||||
|
||||
public function isUsable(): bool
|
||||
{
|
||||
return $this !== self::Invalid && $this !== self::FormatInvalid;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,10 @@ class CheckoutController extends Controller
|
|||
$plan = Plan::query()->active()->where('slug', $planSlug)->firstOrFail();
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user->hasCompleteBillingAddress()) {
|
||||
return $this->backToProfile();
|
||||
}
|
||||
|
||||
if ($user->subscribed()) {
|
||||
return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.'));
|
||||
}
|
||||
|
|
@ -45,6 +49,10 @@ class CheckoutController extends Controller
|
|||
|
||||
public function singlePm(Request $request): Checkout|RedirectResponse
|
||||
{
|
||||
if (! $request->user()->hasCompleteBillingAddress()) {
|
||||
return $this->backToProfile();
|
||||
}
|
||||
|
||||
if (! config('billing.single_pm_stripe_price_id')) {
|
||||
return $this->backToBookings(__('Die Einzel-Pressemitteilung ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
|
||||
}
|
||||
|
|
@ -81,4 +89,16 @@ class CheckoutController extends Controller
|
|||
->route('me.bookings.index')
|
||||
->with('checkout-notice', $notice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchungs-Voraussetzung (12.06.2026): ohne vollständige
|
||||
* Rechnungsadresse kein Checkout — der Hinweis erscheint direkt auf
|
||||
* der Profil-Seite über dem Rechnungsadress-Formular.
|
||||
*/
|
||||
private function backToProfile(): RedirectResponse
|
||||
{
|
||||
return redirect()
|
||||
->route('me.profile')
|
||||
->with('checkout-notice', __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung und wird an Stripe übergeben.'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ class ProcessStripeWebhook
|
|||
$local = $user->billingAddress;
|
||||
|
||||
return InvoiceBillingAddress::query()->create([
|
||||
'company' => $local?->company,
|
||||
'name' => $stripeInvoice['customer_name'] ?? $local?->name ?? $user->name,
|
||||
'address1' => $stripeAddress['line1'] ?? $local?->address1 ?? '',
|
||||
'address2' => $stripeAddress['line2'] ?? $local?->address2,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ class BillingAddress extends Model
|
|||
'user_id',
|
||||
'salutation_key',
|
||||
'title',
|
||||
'company',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'name',
|
||||
'address1',
|
||||
'address2',
|
||||
|
|
@ -27,4 +30,17 @@ class BillingAddress extends Model
|
|||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Adresse vollständig genug für eine Rechnung bzw. Buchung?
|
||||
* Maßgeblich: Empfänger (Name), Straße, PLZ, Ort und Land.
|
||||
*/
|
||||
public function isComplete(): bool
|
||||
{
|
||||
return filled($this->name)
|
||||
&& filled($this->address1)
|
||||
&& filled($this->postal_code)
|
||||
&& filled($this->city)
|
||||
&& filled($this->country_code);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class InvoiceBillingAddress extends Model
|
|||
protected $fillable = [
|
||||
'salutation_key',
|
||||
'title',
|
||||
'company',
|
||||
'name',
|
||||
'address1',
|
||||
'address2',
|
||||
|
|
|
|||
|
|
@ -105,6 +105,15 @@ class User extends Authenticatable
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchungs-Voraussetzung (Entscheidung 12.06.2026): Tarif- und
|
||||
* Einzel-PM-Checkouts erfordern eine vollständige Rechnungsadresse.
|
||||
*/
|
||||
public function hasCompleteBillingAddress(): bool
|
||||
{
|
||||
return (bool) $this->billingAddress?->isComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
|
||||
* gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
148
app/Services/Billing/VatIdValidationService.php
Normal file
148
app/Services/Billing/VatIdValidationService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -49,8 +49,9 @@ class VatResolver
|
|||
/**
|
||||
* Formale Plausibilität: beginnt mit dem Ländercode der Adresse und
|
||||
* trägt danach 2–13 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) ?? '');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue