From 32971792262f0ddbf960623d2b9b45d1728a949b Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 8 Jul 2026 13:28:56 +0000 Subject: [PATCH] =?UTF-8?q?USt-ID=20vor=20jeder=20Buchung=20per=20eVatR=20?= =?UTF-8?q?gegenpr=C3=BCfen,=20Stripe-Tax-IDs=20abgleichen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USt-IDs laufen ab — Kunden bestellten anderswo über veraltete IDs steuerfrei. Vor jedem Checkout wird die Profil-USt-ID jetzt einmal per eVatR (BZSt) geprüft (Ergebnis 6 h gecacht): Eine definitiv abgelehnte ID geht nicht an Stripe, nicht mehr passende eu_vat-Einträge am Stripe-Customer werden entfernt — Stripe Tax rechnet dann wieder mit Steuer statt Reverse Charge. Fail-open bei API-Ausfall und deutschen IDs (eVatR prüft nur ausländische EU-IDs). Co-Authored-By: Claude Fable 5 --- .../Billing/StripeCheckoutService.php | 94 +++++++++++++++---- .../Billing/StripeCustomerHooksTest.php | 93 ++++++++++++++++++ 2 files changed, 170 insertions(+), 17 deletions(-) diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index 021ac41b..3d6d3c05 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -25,7 +25,10 @@ use Stripe\Exception\InvalidRequestException; */ class StripeCheckoutService { - public function __construct(private readonly VatResolver $vatResolver) {} + public function __construct( + private readonly VatResolver $vatResolver, + private readonly VatIdValidationService $vatIdValidation, + ) {} /** * Stripe-Checkout für ein Tarif-Abo (monatlich/jährlich). Die Steuer @@ -221,39 +224,96 @@ class StripeCheckoutService /** * 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. + * berücksichtigt sie dann ohne erneute Eingabe im Checkout. + * + * Vor jeder Buchung wird die ID zusätzlich per eVatR gegengeprüft + * (Befund 08.07.2026: USt-IDs laufen ab, Kunden bestellen sonst über + * eine veraltete ID steuerfrei): Eine definitiv abgelehnte ID geht + * nicht an Stripe, und nicht (mehr) passende eu_vat-Einträge am + * Customer werden entfernt — Stripe Tax rechnet damit wieder mit + * Steuer statt Reverse Charge. Stripe-Fehler blockieren den Checkout + * nicht: Stripe validiert die erfasste ID ohnehin selbst. */ private function syncTaxIdFromBillingAddress(User $user): void + { + // Ohne Profil-Rechnungsadresse erfasst Stripe die USt-ID selbst im + // Checkout (tax_id_collection) — dort nichts anfassen. + if (! $user->billingAddress) { + return; + } + + $vatId = $this->verifiedVatIdForCheckout($user); + + try { + $user->createOrGetStripeCustomer(); + + $alreadySet = false; + + foreach ($user->taxIds() as $taxId) { + if ($taxId->type !== 'eu_vat') { + continue; + } + + if ($vatId !== null && strtoupper((string) $taxId->value) === $vatId) { + $alreadySet = true; + + continue; + } + + // Veraltete oder entfernte ID: Ohne diesen Schritt bliebe + // der alte Eintrag am Customer und die Buchung liefe + // weiter steuerfrei. + $user->deleteTaxId($taxId->id); + } + + if ($vatId !== null && ! $alreadySet) { + $user->createTaxId('eu_vat', $vatId); + } + } catch (\Throwable $exception) { + Log::warning('USt-ID konnte nicht mit Stripe abgeglichen werden.', [ + 'user_id' => $user->id, + 'error' => $exception->getMessage(), + ]); + } + } + + /** + * Die USt-ID, die Stripe für diese Buchung erhalten soll — normalisiert + * und per eVatR gegengeprüft (definitive Ergebnisse sind 6 h gecacht, + * siehe VatIdValidationService). Fail-open: Nur eine DEFINITIV + * abgelehnte ID wird zurückgehalten; ist die BZSt-API nicht erreichbar + * oder die ID deutsch (eVatR prüft nur ausländische EU-IDs), bleibt + * sie nutzbar — ein API-Ausfall darf Bestandskunden nicht plötzlich + * Steuer berechnen. + */ + private function verifiedVatIdForCheckout(User $user): ?string { $vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id)); if ($vatId === '') { - return; + return null; } $prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2); if ($prefixCountry !== 'DE' && ! $this->vatResolver->isEuCountry($prefixCountry)) { - return; + return null; } - try { - $user->createOrGetStripeCustomer(); + $check = $this->vatIdValidation->check($vatId, (string) $user->billingAddress?->country_code); - $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.', [ + if (! $check['status']->isUsable()) { + Log::warning('USt-ID vor der Buchung nicht bestätigt — sie wird nicht an Stripe übergeben.', [ 'user_id' => $user->id, - 'error' => $exception->getMessage(), + 'vat_id' => $vatId, + 'status' => $check['status']->value, + 'message' => $check['message'], ]); + + return null; } + + return $vatId; } /** diff --git a/tests/Feature/Billing/StripeCustomerHooksTest.php b/tests/Feature/Billing/StripeCustomerHooksTest.php index b2f07fd2..50bb4339 100644 --- a/tests/Feature/Billing/StripeCustomerHooksTest.php +++ b/tests/Feature/Billing/StripeCustomerHooksTest.php @@ -3,6 +3,7 @@ use App\Models\BillingAddress; use App\Models\User; use App\Services\Billing\StripeCheckoutService; +use Illuminate\Support\Facades\Http; test('the stripe recipient name prefers the billing company over names', function () { $user = User::factory()->create(['name' => 'Login Anzeigename']); @@ -87,6 +88,91 @@ test('one-time checkouts enable stripe invoice creation, subscription checkouts expect(invokeSessionOptions($user))->not->toHaveKey('invoice_creation'); }); +test('the checkout vat id is re-verified via evatr before every booking', function () { + config()->set('billing.own_vat_id', 'DE999999999'); + Http::fake(['api.evatr.vies.bzst.de/*' => Http::response(['status' => 'evatr-0000'])]); + + $user = User::factory()->create(); + BillingAddress::factory()->create([ + 'user_id' => $user->id, + 'country_code' => 'AT', + // Normalisierung: Großschreibung, Leerzeichen entfernt. + 'vat_id' => 'atu 12345678', + ]); + + expect(invokeVerifiedVatId($user->fresh()))->toBe('ATU12345678'); + + Http::assertSent(fn ($request): bool => $request['angefragteUstid'] === 'ATU12345678'); +}); + +test('a vat id rejected by evatr is withheld from stripe', function () { + // Kern des Befunds vom 08.07.: abgelaufene USt-IDs dürfen nicht weiter + // steuerfrei (Reverse Charge) buchen — die Buchung läuft dann mit Steuer. + config()->set('billing.own_vat_id', 'DE999999999'); + Http::fake(['api.evatr.vies.bzst.de/*' => Http::response(['status' => 'evatr-1001'])]); + + $user = User::factory()->create(); + BillingAddress::factory()->create([ + 'user_id' => $user->id, + 'country_code' => 'AT', + 'vat_id' => 'ATU99999999', + ]); + + expect(invokeVerifiedVatId($user->fresh()))->toBeNull(); +}); + +test('an unreachable evatr api keeps the vat id usable for the booking', function () { + // Fail-open: Ein Ausfall der BZSt-API darf Bestandskunden nicht + // plötzlich Steuer berechnen — nur definitive Ablehnungen halten zurück. + config()->set('billing.own_vat_id', 'DE999999999'); + Http::fake(['api.evatr.vies.bzst.de/*' => Http::response(null, 503)]); + + $user = User::factory()->create(); + BillingAddress::factory()->create([ + 'user_id' => $user->id, + 'country_code' => 'NL', + 'vat_id' => 'NL123456789B01', + ]); + + expect(invokeVerifiedVatId($user->fresh()))->toBe('NL123456789B01'); +}); + +test('german vat ids pass without an online check, non-eu and missing ids yield nothing', function () { + config()->set('billing.own_vat_id', 'DE999999999'); + Http::fake(); + + // Deutsche ID: eVatR kann sie nicht prüfen — Formatprüfung genügt. + $german = User::factory()->create(); + BillingAddress::factory()->create(['user_id' => $german->id, 'country_code' => 'DE', 'vat_id' => 'DE123456789']); + expect(invokeVerifiedVatId($german->fresh()))->toBe('DE123456789'); + + // Ohne USt-ID und außerhalb der EU gibt es nichts zu übergeben. + $without = User::factory()->create(); + BillingAddress::factory()->create(['user_id' => $without->id, 'vat_id' => null]); + expect(invokeVerifiedVatId($without->fresh()))->toBeNull(); + + $swiss = User::factory()->create(); + BillingAddress::factory()->create(['user_id' => $swiss->id, 'country_code' => 'CH', 'vat_id' => 'CHE123456789']); + expect(invokeVerifiedVatId($swiss->fresh()))->toBeNull(); + + Http::assertNothingSent(); +}); + +test('a vat id that does not match the billing country is withheld from stripe', function () { + config()->set('billing.own_vat_id', 'DE999999999'); + Http::fake(); + + $user = User::factory()->create(); + BillingAddress::factory()->create([ + 'user_id' => $user->id, + 'country_code' => 'DE', + 'vat_id' => 'ATU12345678', + ]); + + expect(invokeVerifiedVatId($user->fresh()))->toBeNull(); + Http::assertNothingSent(); +}); + /** * @return array */ @@ -97,3 +183,10 @@ function invokeSessionOptions(User $user, string $method = 'sessionOptions'): ar return $method->invoke($service, $user); } + +function invokeVerifiedVatId(User $user): ?string +{ + $service = app(StripeCheckoutService::class); + + return (new ReflectionMethod($service, 'verifiedVatIdForCheckout'))->invoke($service, $user); +}