Stripe: Kundendaten, Rechnungsnummer, manueller Zahlungseingang

Rueckmeldung der Buchhaltung und des Stripe-Calls (dev/2026-08-26-stripe/):
Stripe kannte bisher nur die E-Mail des Kunden, Zahlungen waren im Dashboard
weder ueber die Bestell- noch ueber die Rechnungsnummer auffindbar, und ein
Zahlungseingang mit falschem Verwendungszweck zwang die Buchhaltung dazu, die
Rechnung von Hand zu schreiben.

- SE1: Der Checkout haengt einen Stripe-Customer mit Name, E-Mail und
  Rechnungsanschrift an die Session (customer statt customer_email); die ID
  liegt an shopping_users und wird wiederverwendet. Namensformat "Vorname
  Nachname (Firma)", reine Firmenkunden unter der Firma. Faellt die
  Customer-Anlage aus, laeuft der Checkout auf dem bisherigen Weg weiter.
  DSGVO-Loeschungen entfernen jetzt auch das Customer-Objekt; die Charges
  bleiben als Buchungsbeleg bestehen.
- SE2: StripeMetadataBuilder als einzige Quelle fuer Metadaten und die
  description (Bestellnummer, Referenz, Kundenname) - damit greift die
  Dashboard-Suche, die bisher nichts Lesbares zu fassen bekam.
- SE3: Die Rechnungsnummer entsteht erst nach der Zahlung und wird daher per
  Job am PaymentIntent nachgetragen (Vorab-Reservierung wuerde Luecken im
  Nummernkreis erzeugen). stripe:backfill-invoice-metadata traegt sie
  rueckwirkend ab dem Go-Live 2026-07-10 nach.
- SE4: Neuer Admin-Button "Zahlungseingang erfassen" an jeder unbezahlten
  Bestellung - bisher gab es ihn nur bei Rechnungskauf, und er setzte nur
  paid, ohne Rechnung, Punkte, Abo-Aktivierung oder Mail. Der neue Weg laeuft
  durch paymentStatusPaidAction, verlangt den Verwendungszweck als Beleg und
  laesst einen abweichenden Betrag nur mit ausdruecklicher Bestaetigung zu.
- S6.4 (aus dem Juli-Plan vorgezogen): stripe:sync-payment-status gleicht
  offene Zahlungen aktiv gegen die API ab. Aufgefallen beim Testlauf - ein nie
  zugestellter Webhook war bisher nicht reparierbar, das Geld lag bei Stripe
  waehrend die Bestellung auf offen stand. Laeuft stuendlich im Scheduler.
- 110 Tests (Pest) fuer alle Pfade, Stripe-Client durchgehend gemockt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-08-28 09:04:28 +00:00
parent 30fb031d12
commit 68d230d4e5
31 changed files with 3612 additions and 65 deletions

View file

@ -19,6 +19,7 @@ use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Exception\CardException;
use Stripe\PaymentIntent;
use Stripe\StripeClient;
@ -105,15 +106,26 @@ function stripeAboUserAbo(array $overrides = []): UserAbo
}
/**
* Stripe-Client-Mock, der genau einen request()-Aufruf beantwortet und die
* Argumente zur Assertion herausreicht (Muster: StripeCheckoutTest).
* Stripe-Client-Mock, der genau einen fachlichen request()-Aufruf beantwortet
* und die Argumente zur Assertion herausreicht (Muster: StripeCheckoutTest).
*
* Die Customer-Anlage aus Phase SE1 wird transparent mitbeantwortet — sie ist
* Infrastruktur und nicht Gegenstand dieser Assertions.
*/
function stripeAboMockClient(mixed $result, ?array &$captured = null): void
{
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->withArgs(fn (...$args) => ($args[1] ?? null) === '/v1/customers')
->andReturn(Customer::constructFrom(['id' => 'cus_abo_checkout', 'object' => 'customer']));
$expectation = $client->shouldReceive('request')
->once()
->withArgs(function (...$args) use (&$captured) {
if (($args[1] ?? null) === '/v1/customers') {
return false;
}
$captured = $args;
return true;
@ -171,7 +183,10 @@ it('requests off-session saving and customer creation for abo checkout sessions'
$params = $captured[2];
expect($params['customer_creation'])->toBe('always')
// Seit SE1 hängt der Checkout einen benannten Customer an, statt Stripe
// einen namenlosen anlegen zu lassen (customer_creation).
expect($params['customer'])->toBe('cus_abo_checkout')
->and($params)->not->toHaveKey('customer_creation')
->and($params['payment_intent_data']['setup_future_usage'])->toBe('off_session');
});
@ -200,6 +215,7 @@ it('does not request off-session saving for one-time checkout sessions', functio
$params = $captured[2];
expect($params)->not->toHaveKey('customer_creation')
->and($params['customer'])->toBe('cus_abo_checkout')
->and($params['payment_intent_data'])->not->toHaveKey('setup_future_usage');
});