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>
192 lines
7.1 KiB
PHP
192 lines
7.1 KiB
PHP
<?php
|
|
|
|
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']);
|
|
BillingAddress::factory()->create([
|
|
'user_id' => $user->id,
|
|
'company' => 'Beispiel GmbH',
|
|
'name' => 'Max Mustermann',
|
|
]);
|
|
|
|
expect($user->fresh()->stripeName())->toBe('Beispiel GmbH');
|
|
});
|
|
|
|
test('the stripe recipient name falls back to the billing name, then the display name', function () {
|
|
$user = User::factory()->create(['name' => 'Login Anzeigename']);
|
|
BillingAddress::factory()->create([
|
|
'user_id' => $user->id,
|
|
'company' => null,
|
|
'name' => 'Max Mustermann',
|
|
]);
|
|
|
|
expect($user->fresh()->stripeName())->toBe('Max Mustermann');
|
|
|
|
$withoutAddress = User::factory()->create(['name' => 'Nur Anzeigename']);
|
|
|
|
expect($withoutAddress->stripeName())->toBe('Nur Anzeigename');
|
|
});
|
|
|
|
test('the stripe address mirrors the billing address', function () {
|
|
$user = User::factory()->create();
|
|
BillingAddress::factory()->create([
|
|
'user_id' => $user->id,
|
|
'address1' => 'Rechnungsweg 12',
|
|
'address2' => 'Hinterhaus',
|
|
'postal_code' => '33739',
|
|
'city' => 'Bielefeld',
|
|
'country_code' => 'DE',
|
|
]);
|
|
|
|
expect($user->fresh()->stripeAddress())->toBe([
|
|
'line1' => 'Rechnungsweg 12',
|
|
'line2' => 'Hinterhaus',
|
|
'postal_code' => '33739',
|
|
'city' => 'Bielefeld',
|
|
'country' => 'DE',
|
|
]);
|
|
|
|
expect(User::factory()->create()->stripeAddress())->toBeNull();
|
|
});
|
|
|
|
test('checkout session options leave the stripe customer untouched when a billing address exists', function () {
|
|
$user = User::factory()->create();
|
|
BillingAddress::factory()->create(['user_id' => $user->id]);
|
|
|
|
$options = invokeSessionOptions($user->fresh());
|
|
|
|
expect($options['billing_address_collection'])->toBe('auto')
|
|
->and($options['automatic_tax'])->toBe(['enabled' => true])
|
|
->and($options)->not->toHaveKeys(['customer_update', 'tax_id_collection']);
|
|
});
|
|
|
|
test('checkout session options collect the address in stripe when no billing address exists', function () {
|
|
$options = invokeSessionOptions(User::factory()->create());
|
|
|
|
expect($options['billing_address_collection'])->toBe('required')
|
|
->and($options['tax_id_collection'])->toBe(['enabled' => true])
|
|
->and($options['customer_update'])->toBe(['address' => 'auto', 'name' => 'auto']);
|
|
});
|
|
|
|
test('one-time checkouts enable stripe invoice creation, subscription checkouts do not', function () {
|
|
$user = User::factory()->create();
|
|
BillingAddress::factory()->create(['user_id' => $user->id]);
|
|
$user = $user->fresh();
|
|
|
|
// Payment-Mode: ohne invoice_creation erzeugt Stripe keine Rechnung —
|
|
// und damit gäbe es kein invoice.payment_succeeded für die STR-Spiegelung.
|
|
$oneTime = invokeSessionOptions($user, 'oneTimeSessionOptions');
|
|
expect($oneTime['invoice_creation'])->toBe(['enabled' => true])
|
|
->and($oneTime['automatic_tax'])->toBe(['enabled' => true]);
|
|
|
|
// Subscription-Mode lehnt invoice_creation ab — dort erzeugt Stripe
|
|
// die Rechnungen ohnehin selbst.
|
|
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>
|
|
*/
|
|
function invokeSessionOptions(User $user, string $method = 'sessionOptions'): array
|
|
{
|
|
$service = app(StripeCheckoutService::class);
|
|
$method = new ReflectionMethod($service, $method);
|
|
|
|
return $method->invoke($service, $user);
|
|
}
|
|
|
|
function invokeVerifiedVatId(User $user): ?string
|
|
{
|
|
$service = app(StripeCheckoutService::class);
|
|
|
|
return (new ReflectionMethod($service, 'verifiedVatIdForCheckout'))->invoke($service, $user);
|
|
}
|