diff --git a/app/Models/User.php b/app/Models/User.php index ddfa8dcb..96b8109f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -107,6 +107,19 @@ class User extends Authenticatable implements MustVerifyEmail ]; } + /** + * Name für den Stripe-Customer (Cashier-Hook): Rechnungsempfänger ist + * die Firma bzw. der Name aus der Rechnungsadresse — nicht der + * Login-Anzeigename (QA-Befund 07.07.: falscher Empfänger auf der + * Stripe-Rechnung). + */ + public function stripeName(): ?string + { + $address = $this->billingAddress; + + return $address?->company ?: ($address?->name ?: $this->name); + } + /** * Buchungs-Voraussetzung (Entscheidung 12.06.2026): Tarif- und * Einzel-PM-Checkouts erfordern eine vollständige Rechnungsadresse. diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index ce2de996..071b4f08 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -31,13 +31,37 @@ class StripeCheckoutService ? $plan->stripe_price_id_yearly : $plan->stripe_price_id_monthly; - $this->syncTaxIdFromBillingAddress($user); + $this->syncCustomerFromBillingAddress($user); return $user ->newSubscription('default', $priceId) ->checkout($this->sessionOptions()); } + /** + * Stripe-Customer vor jedem Checkout auf den Stand der lokal gepflegten + * Rechnungsadresse bringen: Name/Firma und Adresse (stripeName/ + * stripeAddress-Hooks am User) plus USt-ID. Ohne diesen Sync stand die + * Profil-Rechnungsadresse nur bei der Erstanlage am Customer und die im + * Checkout getippte Adresse landete auf der Rechnung (QA-Befund 07.07.). + * Fehler blockieren den Checkout nicht — Stripe erfasst die Adresse + * dann selbst im Checkout. + */ + private function syncCustomerFromBillingAddress(User $user): void + { + try { + $user->createOrGetStripeCustomer(); + $user->syncStripeCustomerDetails(); + } catch (\Throwable $exception) { + Log::warning('Stripe-Customer konnte nicht mit der Rechnungsadresse synchronisiert werden.', [ + 'user_id' => $user->id, + 'error' => $exception->getMessage(), + ]); + } + + $this->syncTaxIdFromBillingAddress($user); + } + /** * Lokal gepflegte USt-ID vor dem Checkout an den Stripe-Customer * übergeben (User-Panel-Restarbeiten, 12.06.2026) — Stripe Tax @@ -95,7 +119,11 @@ class StripeCheckoutService return [ 'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']), 'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']), - 'billing_address_collection' => 'required', + // 'auto': Stripe fragt die Adresse nur ab, wenn am Customer keine + // hinterlegt ist — die Profil-Rechnungsadresse (Sync oben) bleibt + // damit maßgeblich und erscheint auf der Rechnung. Unser + // Buchungs-Gate verlangt ohnehin eine vollständige Adresse. + 'billing_address_collection' => 'auto', 'automatic_tax' => ['enabled' => true], 'tax_id_collection' => ['enabled' => true], 'customer_update' => [ @@ -141,7 +169,7 @@ class StripeCheckoutService */ public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout { - $this->syncTaxIdFromBillingAddress($user); + $this->syncCustomerFromBillingAddress($user); return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [ ...$this->sessionOptions(), @@ -156,7 +184,7 @@ class StripeCheckoutService */ public function forDepublication(User $user, SinglePurchase $purchase): Checkout { - $this->syncTaxIdFromBillingAddress($user); + $this->syncCustomerFromBillingAddress($user); return $user->checkout( [$this->adHocLineItem($purchase->price_cents, 'Depublikation einer Pressemitteilung')], @@ -174,7 +202,7 @@ class StripeCheckoutService */ public function forCreditTopup(User $user, CreditTopup $topup): Checkout { - $this->syncTaxIdFromBillingAddress($user); + $this->syncCustomerFromBillingAddress($user); return $user->checkout( [$this->adHocLineItem($topup->price_cents, "Credit-Guthaben ({$topup->credits} Credits)")], diff --git a/tests/Feature/Billing/StripeCustomerHooksTest.php b/tests/Feature/Billing/StripeCustomerHooksTest.php new file mode 100644 index 00000000..4597e8d9 --- /dev/null +++ b/tests/Feature/Billing/StripeCustomerHooksTest.php @@ -0,0 +1,52 @@ +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(); +});