1, 'country_id' => 1]); $shopOwner = User::forceCreate([ 'email' => 'sc-owner-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', ]); $userShop = UserShop::first() ?? UserShop::create([ 'user_id' => $shopOwner->id, 'name' => 'SC Shop', 'slug' => 'sc-shop-'.uniqid(), 'domain' => 'sc-test.local', ]); $shoppingUser = ShoppingUser::create([ 'auth_user_id' => $shopOwner->id, 'billing_firstname' => 'Stripe', 'billing_lastname' => 'Checkout', 'billing_email' => 'stripe-co-'.uniqid().'@example.com', 'billing_country_id' => $country->id, 'shipping_country_id' => $country->id, ]); $order = ShoppingOrder::create([ 'shopping_user_id' => $shoppingUser->id, 'auth_user_id' => $shopOwner->id, 'country_id' => $country->id, 'user_shop_id' => $userShop->id, 'total' => 89.00, 'paid' => false, 'txaction' => null, 'mode' => 'test', ]); $payment = ShoppingPayment::create(array_merge([ 'shopping_order_id' => $order->id, 'payment_provider' => 'stripe', 'reference' => 'sc-ref-'.uniqid(), 'amount' => 8900, 'clearingtype' => 'stripe', 'currency' => 'EUR', 'mode' => 'test', ], $paymentOverrides)); return [$order, $payment, $shoppingUser]; } function stripeCheckoutEvent(string $type, array $object, string $eventId): string { return json_encode([ 'id' => $eventId, 'object' => 'event', 'api_version' => '2026-06-30', 'type' => $type, 'data' => ['object' => $object], ]); } function stripeCheckoutSignature(string $payload): string { $timestamp = time(); $signed = hash_hmac('sha256', $timestamp.'.'.$payload, config('stripe.webhook_secret')); return "t={$timestamp},v1={$signed}"; } function handleStripeCheckoutEvent(string $type, array $object, string $eventId): void { $payload = stripeCheckoutEvent($type, $object, $eventId); app(StripeWebhookHandler::class)->handle($payload, stripeCheckoutSignature($payload)); } /** * Stripe-Client-Mock für die Session-Erstellung. Seit Phase SE1 legt der * Service zuerst einen Customer an (Name/Anschrift für die Buchhaltung) und * danach die Checkout Session. * * @param array|null $sessionParams Referenz, gefüllt mit den Session-Parametern * @param array|null $customerParams Referenz, gefüllt mit den Customer-Parametern */ function stripeCheckoutServiceWithMock(&$sessionParams = null, &$customerParams = null): StripePaymentService { $client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]); $client->shouldReceive('request') ->andReturnUsing(function ($method, $path, $params) use (&$sessionParams, &$customerParams) { if ($path === '/v1/customers') { $customerParams = $params; return Customer::constructFrom(['id' => 'cus_test_se1', 'object' => 'customer']); } $sessionParams = $params; return Session::constructFrom([ 'id' => 'cs_test_abc', 'object' => 'checkout.session', 'url' => 'https://checkout.stripe.com/c/pay/cs_test_abc', 'status' => 'open', 'livemode' => false, ]); }); $wrapper = Mockery::mock(StripeClientWrapper::class); $wrapper->shouldReceive('client')->andReturn($client); return new StripePaymentService($wrapper, new StripeCustomerService($wrapper)); } // --- Session-Erstellung (S3.2) --- it('creates a checkout session with payment and initiation transaction', function () { [$order, , $shoppingUser] = stripeCheckoutBootstrap(); $order->shopping_payments()->delete(); $capturedParams = null; $result = stripeCheckoutServiceWithMock($capturedParams)->initiateCheckoutPayment( $shoppingUser, $order, 8900, 'EUR', 'mref123', 'https://checkout.test/transaction/status/success/mref123', 'https://checkout.test/transaction/status/cancel/mref123', ); $payment = $result->shoppingPayment->fresh(); expect($result->url)->toBe('https://checkout.stripe.com/c/pay/cs_test_abc') ->and($payment->payment_provider)->toBe('stripe') ->and($payment->external_reference)->toBe('cs_test_abc') ->and($payment->amount)->toBe(8900) ->and($payment->reference)->toBe('mref123') ->and($capturedParams['mode'])->toBe('payment') ->and($capturedParams['line_items'][0]['price_data']['unit_amount'])->toBe(8900) ->and($capturedParams['line_items'][0]['price_data']['currency'])->toBe('eur') ->and($capturedParams['metadata']['shopping_payment_id'])->toBe((string) $payment->id) ->and($capturedParams['payment_intent_data']['metadata']['shopping_order_id'])->toBe((string) $order->id) ->and($capturedParams['success_url'])->toContain('mref123') ->and(PaymentTransaction::where('shopping_payment_id', $payment->id)->where('request', 'checkout.session.created')->exists())->toBeTrue(); }); // --- Kundendaten & Metadaten (SE1/SE2, dev/2026-08-26-stripe/) --- it('attaches a named stripe customer to the checkout session', function () { [$order, , $shoppingUser] = stripeCheckoutBootstrap(); $order->shopping_payments()->delete(); $shoppingUser->update([ 'billing_firstname' => 'Erika', 'billing_lastname' => 'Mustermann', 'billing_address' => 'Musterweg 1', 'billing_zipcode' => '12345', 'billing_city' => 'Musterstadt', ]); $sessionParams = null; $customerParams = null; stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment( $shoppingUser, $order, 8900, 'EUR', 'mrefse1', 'https://checkout.test/success/mrefse1', 'https://checkout.test/cancel/mrefse1', ); expect($customerParams['name'])->toBe('Erika Mustermann') ->and($customerParams['email'])->toBe($shoppingUser->billing_email) ->and($customerParams['address']['line1'])->toBe('Musterweg 1') ->and($customerParams['address']['postal_code'])->toBe('12345') ->and($customerParams['metadata']['mivita_type'])->toBe('shopping_user') ->and($sessionParams['customer'])->toBe('cus_test_se1') ->and($sessionParams)->not->toHaveKey('customer_email') ->and($shoppingUser->fresh()->stripe_customer_id)->toBe('cus_test_se1'); }); it('reuses an existing stripe customer instead of creating a second one', function () { [$order, , $shoppingUser] = stripeCheckoutBootstrap(); $order->shopping_payments()->delete(); $shoppingUser->forceFill(['stripe_customer_id' => 'cus_existing'])->save(); $sessionParams = null; $customerParams = null; stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment( $shoppingUser, $order, 8900, 'EUR', 'mrefse1b', 'https://checkout.test/success/mrefse1b', 'https://checkout.test/cancel/mrefse1b', ); expect($sessionParams['customer'])->toBe('cus_existing') ->and($shoppingUser->fresh()->stripe_customer_id)->toBe('cus_existing'); }); it('shows the person name first and the company in brackets', function () { [$order, , $shoppingUser] = stripeCheckoutBootstrap(); $order->shopping_payments()->delete(); $shoppingUser->update([ 'billing_firstname' => 'Kevin', 'billing_lastname' => 'Adametz', 'billing_company' => 'adametz.media', ]); $sessionParams = null; $customerParams = null; stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment( $shoppingUser, $order, 8900, 'EUR', 'mrefse1c', 'https://checkout.test/success/mrefse1c', 'https://checkout.test/cancel/mrefse1c', ); expect($customerParams['name'])->toBe('Kevin Adametz (adametz.media)') ->and($sessionParams['metadata']['customer_name'])->toBe('Kevin Adametz (adametz.media)'); }); it('falls back to the company when no person name is stored', function () { [$order, , $shoppingUser] = stripeCheckoutBootstrap(); $order->shopping_payments()->delete(); $shoppingUser->update([ 'billing_firstname' => null, 'billing_lastname' => null, 'billing_company' => 'Muster GmbH', ]); $customerParams = null; $sessionParams = null; stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment( $shoppingUser, $order, 8900, 'EUR', 'mrefse1d', 'https://checkout.test/success/mrefse1d', 'https://checkout.test/cancel/mrefse1d', ); expect($customerParams['name'])->toBe('Muster GmbH'); }); it('sends a searchable description and the full metadata set', function () { [$order, , $shoppingUser] = stripeCheckoutBootstrap(); $order->shopping_payments()->delete(); $sessionParams = null; $result = stripeCheckoutServiceWithMock($sessionParams)->initiateCheckoutPayment( $shoppingUser, $order, 8900, 'EUR', 'mrefse2', 'https://checkout.test/success/mrefse2', 'https://checkout.test/cancel/mrefse2', ); $metadata = $sessionParams['payment_intent_data']['metadata']; expect($sessionParams['payment_intent_data']['description']) ->toContain('Bestellung #'.$order->id) ->toContain('mrefse2') ->toContain('Stripe Checkout') ->and($metadata['shopping_order_id'])->toBe((string) $order->id) ->and($metadata['shopping_payment_id'])->toBe((string) $result->shoppingPayment->id) ->and($metadata['reference'])->toBe('mrefse2') ->and($metadata['order_type'])->toBe('shop') ->and($metadata['mode'])->toBe('test'); }); it('marks abo orders as abo in the metadata and keeps setup_future_usage', function () { [$order, , $shoppingUser] = stripeCheckoutBootstrap(); $order->shopping_payments()->delete(); $order->update(['is_abo' => true, 'abo_interval' => 1]); $sessionParams = null; stripeCheckoutServiceWithMock($sessionParams)->initiateCheckoutPayment( $shoppingUser, $order->fresh(), 8900, 'EUR', 'mrefse2b', 'https://checkout.test/success/mrefse2b', 'https://checkout.test/cancel/mrefse2b', ); expect($sessionParams['metadata']['order_type'])->toBe('abo') ->and($sessionParams['payment_intent_data']['setup_future_usage'])->toBe('off_session') ->and($sessionParams)->not->toHaveKey('customer_creation') ->and($sessionParams['customer'])->toBe('cus_test_se1'); }); it('falls back to customer_email when the stripe customer cannot be created', function () { [$order, , $shoppingUser] = stripeCheckoutBootstrap(); $order->shopping_payments()->delete(); $order->update(['is_abo' => true]); $sessionParams = null; $client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]); $client->shouldReceive('request') ->andReturnUsing(function ($method, $path, $params) use (&$sessionParams) { if ($path === '/v1/customers') { throw new ApiConnectionException('Stripe nicht erreichbar'); } $sessionParams = $params; return Session::constructFrom([ 'id' => 'cs_test_fallback', 'object' => 'checkout.session', 'url' => 'https://checkout.stripe.com/c/pay/cs_test_fallback', 'status' => 'open', 'livemode' => false, ]); }); $wrapper = Mockery::mock(StripeClientWrapper::class); $wrapper->shouldReceive('client')->andReturn($client); $result = (new StripePaymentService($wrapper, new StripeCustomerService($wrapper)))->initiateCheckoutPayment( $shoppingUser, $order->fresh(), 8900, 'EUR', 'mrefse1f', 'https://checkout.test/success/mrefse1f', 'https://checkout.test/cancel/mrefse1f', ); expect($result->url)->toBe('https://checkout.stripe.com/c/pay/cs_test_fallback') ->and($sessionParams)->not->toHaveKey('customer') ->and($sessionParams['customer_email'])->toBe($shoppingUser->billing_email) ->and($sessionParams['customer_creation'])->toBe('always') ->and($sessionParams['payment_intent_data']['setup_future_usage'])->toBe('off_session'); }); // --- Paid-Pfad (S3.5) --- it('marks the order paid and sends one mail on payment_intent succeeded', function () { [$order, $payment] = stripeCheckoutBootstrap(); handleStripeCheckoutEvent('payment_intent.succeeded', [ 'id' => 'pi_paid_1', 'object' => 'payment_intent', 'status' => 'succeeded', 'amount' => 8900, 'amount_received' => 8900, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], 'evt_paid_1'); $order->refresh(); expect($order->paid)->toBeTruthy() ->and($order->txaction)->toBe('paid') ->and($payment->fresh()->txaction)->toBe('paid'); Mail::assertSent(MailCheckout::class, 1); }); it('does not send a second mail when the session completed event arrives after payment', function () { [$order, $payment] = stripeCheckoutBootstrap(['external_reference' => 'cs_double']); handleStripeCheckoutEvent('payment_intent.succeeded', [ 'id' => 'pi_double', 'object' => 'payment_intent', 'amount_received' => 8900, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], 'evt_double_pi'); handleStripeCheckoutEvent('checkout.session.completed', [ 'id' => 'cs_double', 'object' => 'checkout.session', 'payment_status' => 'paid', 'amount_total' => 8900, 'livemode' => false, ], 'evt_double_cs'); expect($order->fresh()->paid)->toBeTruthy(); Mail::assertSent(MailCheckout::class, 1); }); it('refuses the paid path when the confirmed amount does not match', function () { [$order, $payment] = stripeCheckoutBootstrap(); handleStripeCheckoutEvent('payment_intent.succeeded', [ 'id' => 'pi_wrong', 'object' => 'payment_intent', 'amount_received' => 999, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], 'evt_wrong_amount'); expect($order->fresh()->paid)->toBeFalsy(); Mail::assertNothingSent(); }); it('marks appointed and sends the order confirmation for async payments', function () { [$order, $payment] = stripeCheckoutBootstrap(['external_reference' => 'cs_async']); handleStripeCheckoutEvent('checkout.session.completed', [ 'id' => 'cs_async', 'object' => 'checkout.session', 'payment_status' => 'unpaid', 'amount_total' => 8900, 'livemode' => false, ], 'evt_async_cs'); $order->refresh(); expect($order->paid)->toBeFalsy() ->and($order->txaction)->toBe('appointed'); Mail::assertSent(MailCheckout::class, 1); }); it('marks failed attempts without sending mail', function () { [$order, $payment] = stripeCheckoutBootstrap(); handleStripeCheckoutEvent('payment_intent.payment_failed', [ 'id' => 'pi_fail', 'object' => 'payment_intent', 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], 'evt_failed'); expect($order->fresh()->txaction)->toBe('failed'); Mail::assertNothingSent(); }); it('marks an expired session as cancelled without touching paid orders', function () { [, $payment] = stripeCheckoutBootstrap(['external_reference' => 'cs_expired']); handleStripeCheckoutEvent('checkout.session.expired', [ 'id' => 'cs_expired', 'object' => 'checkout.session', 'livemode' => false, ], 'evt_expired'); expect($payment->fresh()->status)->toBe('expired'); }); it('reprocesses a stored but unprocessed event on retry', function () { [$order, $payment] = stripeCheckoutBootstrap(); StripeWebhookEvent::create([ 'event_id' => 'evt_crashed', 'type' => 'payment_intent.succeeded', 'payload' => [], ]); handleStripeCheckoutEvent('payment_intent.succeeded', [ 'id' => 'pi_retry', 'object' => 'payment_intent', 'amount_received' => 8900, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], 'evt_crashed'); expect($order->fresh()->paid)->toBeTruthy() ->and(StripeWebhookEvent::whereEventId('evt_crashed')->first()->processed_at)->not->toBeNull(); }); // --- Zahlart-Anzeige (payment_method_details) --- it('stores the used payment method from charge succeeded and shows it as label', function () { [, $payment] = stripeCheckoutBootstrap(['external_reference' => 'pi_details']); handleStripeCheckoutEvent('charge.succeeded', [ 'id' => 'ch_1', 'object' => 'charge', 'payment_intent' => 'pi_details', 'livemode' => false, 'payment_method_details' => [ 'type' => 'card', 'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 4, 'exp_year' => 2031, 'wallet' => null], ], ], 'evt_charge_details'); $payment->refresh(); expect($payment->carddata['type'])->toBe('card') ->and($payment->carddata['last4'])->toBe('4242') ->and($payment->getPaymentType())->toContain('Visa') ->and($payment->getPaymentType())->toContain('4242'); }); it('backfills payment method details when the charge event arrived first', function () { [$order, $payment] = stripeCheckoutBootstrap(); // charge.succeeded kommt zuerst und ist noch keiner Zahlung zuordenbar handleStripeCheckoutEvent('charge.succeeded', [ 'id' => 'ch_2', 'object' => 'charge', 'payment_intent' => 'pi_backfill', 'livemode' => false, 'payment_method_details' => [ 'type' => 'card', 'card' => ['brand' => 'mastercard', 'last4' => '4444', 'wallet' => ['type' => 'apple_pay']], ], ], 'evt_charge_first'); handleStripeCheckoutEvent('payment_intent.succeeded', [ 'id' => 'pi_backfill', 'object' => 'payment_intent', 'amount_received' => 8900, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], 'evt_pi_after_charge'); $payment->refresh(); expect($order->fresh()->paid)->toBeTruthy() ->and($payment->external_reference)->toBe('pi_backfill') ->and($payment->carddata['wallet'])->toBe('apple_pay') ->and($payment->getPaymentType())->toContain('Apple Pay'); }); it('labels paypal payments from the charge details', function () { [, $payment] = stripeCheckoutBootstrap(['external_reference' => 'pi_pp']); handleStripeCheckoutEvent('charge.succeeded', [ 'id' => 'ch_3', 'object' => 'charge', 'payment_intent' => 'pi_pp', 'livemode' => false, 'payment_method_details' => ['type' => 'paypal', 'paypal' => []], ], 'evt_charge_pp'); expect($payment->fresh()->getPaymentType())->toBe(__('payment.paypal')); }); // --- Abo-Freeze (Not-Schalter PAYONE-Ausfall) --- it('freezes abo retries while the freeze switch is on', function () { Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool'); $abo = makeMeAbo(); $abo->update(['status' => 3]); $result = app(AboRetryPaymentService::class)->retry($abo->fresh()); expect($result['success'])->toBeFalse() ->and($result['message'])->toBe(__('payment.abo_payments_frozen')); }); it('skips the abo charge cron entirely while frozen without touching abos', function () { Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool'); $abo = makeMeAbo(); $abo->update(['next_date' => now()->subDay(), 'status' => 2]); $this->artisan('user:make_abo_order')->assertExitCode(0); $fresh = $abo->fresh(); expect($fresh->status)->toBe(2) ->and(UserAboOrder::where('user_abo_id', $abo->id)->count())->toBe(0); }); it('reports the freeze state from the manager with default off', function () { $manager = app(PaymentProviderManager::class); expect($manager->aboPaymentsFrozen())->toBeFalse(); Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool'); expect($manager->aboPaymentsFrozen())->toBeTrue(); }); it('freezes credit card updates for abos while the freeze switch is on', function () { Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool'); $abo = makeMeAbo(); $card = new CreditCardCheckData( cardholderFirstName: 'Max', cardholderLastName: 'Muster', cardPan: '4111111111111111', cardExpireMonth: '12', cardExpireYear: '2031', cardType: 'V', cardCvc2: '123', ); $result = app(AboPaymentMethodService::class) ->updateCreditCard($abo, $card, ['successurl' => 'x', 'errorurl' => 'x', 'backurl' => 'x']); expect($result->status)->toBe(CardUpdateResult::ERROR) ->and($result->errorMessage)->toBe(__('payment.abo_payments_frozen')); }); it('reports the checkout maintenance state with default off', function () { $manager = app(PaymentProviderManager::class); expect($manager->checkoutMaintenanceActive())->toBeFalse(); Setting::setContentBySlug(PaymentProviderManager::SETTING_CHECKOUT_MAINTENANCE, 1, 'bool'); expect($manager->checkoutMaintenanceActive())->toBeTrue(); Setting::setContentBySlug(PaymentProviderManager::SETTING_CHECKOUT_MAINTENANCE, 0, 'bool'); expect($manager->checkoutMaintenanceActive())->toBeFalse(); }); // --- Vorkasse-Schalter (S3.8) --- it('hides prepayment in the checkout when the vorkasse switch is set', function () { PaymentMethod::create(['name' => 'Vorkasse', 'short' => 'VOR', 'active' => true, 'default' => true]); $repo = app(CheckoutRepository::class); expect($repo->getPaymentsMethods('shopping')['active'])->toHaveKey('VOR'); Setting::setContentBySlug(PaymentProviderManager::SETTING_VORKASSE_HIDDEN, 1, 'bool'); expect($repo->getPaymentsMethods('shopping')['active'])->not->toHaveKey('VOR'); });