USt-ID vor jeder Buchung per eVatR gegenprüfen, Stripe-Tax-IDs abgleichen
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 <noreply@anthropic.com>
This commit is contained in:
parent
fe8bd2b931
commit
3297179226
2 changed files with 170 additions and 17 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<string, mixed>
|
||||
*/
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue