Stripe-Integration (S0–S3): Einmalzahlungen über Stripe Checkout + Notfall-Go-Live

Wegen PAYONE-Ausfall vorgezogen live geschaltet: alle Einmalzahlungen
(Shop + Salescenter) laufen über Stripe Hosted Checkout, Abos sind bis
Phase S4 eingefroren.

- Provider-Abstraktion: PaymentProvider-Enum, PaymentGatewayInterface,
  PaymentProviderManager (Settings-Schalter, provider-treu je Bestand),
  PayoneGateway als unveränderter Adapter
- StripePaymentService: Checkout Session (eine Gesamtposition, Metadata,
  success/cancel-Redirects), StripeClientWrapper (lazy, Dark Launch),
  StripeCustomerService, StripeTransactionRecorder (client_secret-Redaction)
- Webhook /stripe/webhook: Signaturprüfung, Idempotenz über
  stripe_webhook_events (Crash-Retry-Reprocessing), txaction-Prioritäten,
  Betrags-Guard, lockForUpdate-Paid-Pfad, genau eine Bestätigungsmail
- Zahlungsmethoden-Anzeige aus charge.succeeded (carddata inkl. Wallet,
  auftragsunabhängiges Backfill aus dem Event-Store)
- Checkout: Wartungsmodus mit Admin-Bypass, Abo-Freeze-Hinweis,
  Stripe-Option (Online-Zahlung) statt Zahlartenwahl, Vorkasse ausblendbar
- Abo-Freeze-Guards: Cron user:make_abo_order, Retry, Kartenupdate
- Admin-Settings-Karte Zahlungen (Provider, Wartung, Freeze, Vorkasse)
- Hotfix: 500 bei Vorkasse ohne Clearing-Daten in admin/sales/_detail
  und checkout-final (PAYONE-Ausfall)
- Doku dev/2026-07-08-stripe/ (Plan S0–S8, Konzept, IST-Analyse)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-08 15:56:41 +00:00
parent 671022a28d
commit 6f21845607
48 changed files with 4428 additions and 831 deletions

View file

@ -0,0 +1,241 @@
<?php
use App\Models\PaymentTransaction;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\StripeWebhookEvent;
use App\Models\UserShop;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripeCustomerService;
use App\Services\Payment\Stripe\StripeTransactionRecorder;
use App\Services\Payment\Stripe\StripeWebhookHandler;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Customer;
use Stripe\StripeClient;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
// --- Helpers ---
function stripeEventPayload(string $type, array $object, string $eventId = 'evt_test_1'): string
{
return json_encode([
'id' => $eventId,
'object' => 'event',
'api_version' => '2026-06-30',
'type' => $type,
'data' => ['object' => $object],
]);
}
function stripeSignature(string $payload, ?string $secret = null): string
{
$secret = $secret ?? config('stripe.webhook_secret');
$timestamp = time();
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
return "t={$timestamp},v1={$signed}";
}
function makeStripeShoppingPayment(array $overrides = []): ShoppingPayment
{
$owner = User::forceCreate([
'email' => 'stripe-wh-owner-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$country = ShippingCountry::first()
?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $owner->id,
'billing_firstname' => 'Stripe',
'billing_lastname' => 'Test',
'billing_email' => 'stripe-wh-'.uniqid().'@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
$userShop = UserShop::first() ?? UserShop::create([
'user_id' => $owner->id,
'name' => 'WH Stripe Shop',
'slug' => 'wh-stripe-'.uniqid(),
'domain' => 'wh-stripe.local',
]);
$order = ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $owner->id,
'country_id' => $country->country_id,
'user_shop_id' => $userShop->id,
'total' => 99.00,
'paid' => false,
'mode' => 'test',
]);
return ShoppingPayment::create(array_merge([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'reference' => 'test-'.uniqid(),
'amount' => 9900,
'currency' => 'EUR',
'clearingtype' => 'stripe',
'mode' => 'test',
], $overrides));
}
// --- Signatur & Idempotenz ---
it('rejects a webhook with an invalid signature and stores nothing', function () {
$payload = stripeEventPayload('payment_intent.succeeded', ['id' => 'pi_1', 'object' => 'payment_intent']);
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload, 'whsec_wrong'));
expect($result->statusCode)->toBe(400)
->and($result->state)->toBe('invalid_signature')
->and(StripeWebhookEvent::count())->toBe(0);
});
it('answers with 500 when the webhook secret is not configured so stripe retries', function () {
config(['stripe.webhook_secret' => '']);
$payload = stripeEventPayload('payment_intent.succeeded', ['id' => 'pi_1', 'object' => 'payment_intent']);
$result = app(StripeWebhookHandler::class)->handle($payload, 't=1,v1=abc');
expect($result->statusCode)->toBe(500)
->and($result->state)->toBe('not_configured');
});
it('acknowledges but ignores a duplicate event id', function () {
$object = ['id' => 'pi_dup', 'object' => 'payment_intent', 'status' => 'succeeded'];
$payload = stripeEventPayload('payment_intent.succeeded', $object, 'evt_dup');
$handler = app(StripeWebhookHandler::class);
$first = $handler->handle($payload, stripeSignature($payload));
$second = $handler->handle($payload, stripeSignature($payload));
expect($first->state)->toBe('processed')
->and($second->state)->toBe('duplicate')
->and($second->statusCode)->toBe(200)
->and(StripeWebhookEvent::count())->toBe(1);
});
// --- Verarbeitung & Protokollierung ---
it('stores a valid event of an unhandled type and acknowledges it', function () {
$payload = stripeEventPayload('customer.created', ['id' => 'cus_1', 'object' => 'customer'], 'evt_unhandled');
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
expect($result->state)->toBe('ignored')
->and($result->statusCode)->toBe(200)
->and(StripeWebhookEvent::whereEventId('evt_unhandled')->exists())->toBeTrue();
});
it('records a payment transaction when the payment is resolvable via metadata', function () {
$payment = makeStripeShoppingPayment();
$object = [
'id' => 'pi_meta',
'object' => 'payment_intent',
'status' => 'succeeded',
'livemode' => false,
'client_secret' => 'pi_meta_secret_abc',
'metadata' => ['shopping_payment_id' => (string) $payment->id],
];
$payload = stripeEventPayload('payment_intent.succeeded', $object, 'evt_meta');
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
$transaction = PaymentTransaction::where('shopping_payment_id', $payment->id)->first();
expect($result->state)->toBe('processed')
->and($transaction)->not->toBeNull()
->and($transaction->provider)->toBe('stripe')
->and($transaction->txid)->toBe('pi_meta')
->and($transaction->status)->toBe('succeeded')
->and($transaction->mode)->toBe('test')
->and($transaction->transmitted_data)->not->toHaveKey('client_secret');
});
it('resolves the payment via external_reference and marks the event processed', function () {
$payment = makeStripeShoppingPayment(['external_reference' => 'pi_ext_1']);
$object = ['id' => 'pi_ext_1', 'object' => 'payment_intent', 'status' => 'processing', 'livemode' => false];
$payload = stripeEventPayload('payment_intent.processing', $object, 'evt_ext');
app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
expect(PaymentTransaction::where('shopping_payment_id', $payment->id)->count())->toBe(1)
->and(StripeWebhookEvent::whereEventId('evt_ext')->first()->processed_at)->not->toBeNull();
});
it('redacts client_secret recursively from stored payloads', function () {
$redacted = StripeTransactionRecorder::redactPayload([
'id' => 'pi_1',
'client_secret' => 'geheim',
'charges' => ['data' => [['id' => 'ch_1', 'client_secret' => 'auch-geheim']]],
]);
expect($redacted)->not->toHaveKey('client_secret')
->and($redacted['charges']['data'][0])->not->toHaveKey('client_secret')
->and($redacted['id'])->toBe('pi_1');
});
// --- Client-Wrapper: verschobener Hard-Fail ---
it('fails hard on first use when the stripe secret is missing', function () {
$wrapper = new StripeClientWrapper(null);
expect($wrapper->isConfigured())->toBeFalse()
->and(fn () => $wrapper->client())->toThrow(RuntimeException::class, 'STRIPE_SECRET');
});
it('builds a stripe client lazily when a secret is configured', function () {
$wrapper = new StripeClientWrapper('sk_test_fake');
expect($wrapper->isConfigured())->toBeTrue()
->and($wrapper->client())->toBeInstanceOf(StripeClient::class)
->and($wrapper->client())->toBe($wrapper->client());
});
// --- Customer-Service ---
it('returns an existing stripe customer id without calling the api', function () {
$user = User::forceCreate([
'email' => 'stripe-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$user->forceFill(['stripe_customer_id' => 'cus_existing'])->save();
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldNotReceive('client');
expect((new StripeCustomerService($wrapper))->customerIdFor($user->fresh()))
->toBe('cus_existing');
});
it('creates a stripe customer once and persists its id', function () {
$user = User::forceCreate([
'email' => 'stripe-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->once()
->andReturn(Customer::constructFrom(['id' => 'cus_new_1', 'object' => 'customer']));
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('client')->andReturn($client);
$customerId = (new StripeCustomerService($wrapper))->customerIdFor($user);
expect($customerId)->toBe('cus_new_1')
->and($user->fresh()->stripe_customer_id)->toBe('cus_new_1');
});