$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'); });