1, 'country_id' => 1]); $shopOwner = User::forceCreate([ 'email' => 'sa-owner-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', ]); $userShop = UserShop::first() ?? UserShop::create([ 'user_id' => $shopOwner->id, 'name' => 'SA Shop', 'slug' => 'sa-shop-'.uniqid(), 'domain' => 'sa-test.local', ]); $shoppingUser = ShoppingUser::create([ 'auth_user_id' => $shopOwner->id, 'is_for' => 'me', 'billing_firstname' => 'Stripe', 'billing_lastname' => 'Abo', 'billing_email' => 'stripe-abo-'.uniqid().'@example.com', 'billing_country_id' => $country->id, 'shipping_country_id' => $country->id, ]); $order = ShoppingOrder::create(array_merge([ 'shopping_user_id' => $shoppingUser->id, 'auth_user_id' => $shopOwner->id, 'country_id' => $country->id, 'user_shop_id' => $userShop->id, 'total' => 89.00, 'total_shipping' => 89.00, 'paid' => false, 'txaction' => null, 'mode' => 'test', 'is_abo' => 1, 'abo_interval' => 5, ], $orderOverrides)); return [$order, $shoppingUser]; } function stripeAboUserAbo(array $overrides = []): UserAbo { $consultant = User::forceCreate([ 'email' => 'sa-abo-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', ]); return UserAbo::create(array_merge([ 'user_id' => $consultant->id, 'shopping_user_id' => 1, 'is_for' => 'me', 'email' => $consultant->email, 'payone_userid' => 0, 'payment_provider' => 'stripe', 'stripe_customer_id' => 'cus_abo_1', 'stripe_payment_method_id' => 'pm_abo_1', 'clearingtype' => 'stripe', 'active' => true, 'status' => 2, 'abo_interval' => 5, 'next_date' => now()->addDays(3), ], $overrides)); } /** * 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; }); if ($result instanceof Throwable) { $expectation->andThrow($result); } else { $expectation->andReturn($result); } $wrapper = Mockery::mock(StripeClientWrapper::class); $wrapper->shouldReceive('client')->andReturn($client); app()->instance(StripeClientWrapper::class, $wrapper); } function handleStripeAboEvent(string $type, array $object, string $eventId): StripeWebhookResult { $payload = json_encode([ 'id' => $eventId, 'object' => 'event', 'api_version' => '2026-06-30', 'type' => $type, 'data' => ['object' => $object], ]); $timestamp = time(); $signed = hash_hmac('sha256', $timestamp.'.'.$payload, config('stripe.webhook_secret')); return app(StripeWebhookHandler::class)->handle($payload, "t={$timestamp},v1={$signed}"); } // --- S4.1: Abo-Erstzahlung speichert das Zahlungsmittel --- it('requests off-session saving and customer creation for abo checkout sessions', function () { [$order, $shoppingUser] = stripeAboOrderBootstrap(); $captured = null; stripeAboMockClient(Session::constructFrom([ 'id' => 'cs_abo_1', 'object' => 'checkout.session', 'url' => 'https://checkout.stripe.com/c/pay/cs_abo_1', 'status' => 'open', 'livemode' => false, ]), $captured); app(StripePaymentService::class)->initiateCheckoutPayment( $shoppingUser, $order, 8900, 'EUR', 'aref123', 'https://checkout.test/transaction/status/success/aref123', 'https://checkout.test/transaction/status/cancel/aref123', ); $params = $captured[2]; // 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'); }); it('does not request off-session saving for one-time checkout sessions', function () { [$order, $shoppingUser] = stripeAboOrderBootstrap(['is_abo' => 0, 'abo_interval' => 0]); $captured = null; stripeAboMockClient(Session::constructFrom([ 'id' => 'cs_once_1', 'object' => 'checkout.session', 'url' => 'https://checkout.stripe.com/c/pay/cs_once_1', 'status' => 'open', 'livemode' => false, ]), $captured); app(StripePaymentService::class)->initiateCheckoutPayment( $shoppingUser, $order, 8900, 'EUR', 'oref123', 'https://s.test/ok', 'https://s.test/cancel', ); $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'); }); // --- S4.2: Abo-Anlage über den Webhook-Paid-Pfad --- it('creates the user abo with stripe fields from the webhook paid path', function () { [$order] = stripeAboOrderBootstrap(); $payment = ShoppingPayment::create([ 'shopping_order_id' => $order->id, 'payment_provider' => 'stripe', 'clearingtype' => 'stripe', 'reference' => 'sa-ref-'.uniqid(), 'amount' => 8900, 'currency' => 'EUR', 'is_abo' => true, 'abo_interval' => 5, 'mode' => 'test', ]); handleStripeAboEvent('payment_intent.succeeded', [ 'id' => 'pi_abo_paid', 'object' => 'payment_intent', 'status' => 'succeeded', 'amount_received' => 8900, 'customer' => 'cus_new_1', 'payment_method' => 'pm_new_1', 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], 'evt_abo_paid'); $abo = $order->fresh()->getUserAbo(); expect($order->fresh()->paid)->toBeTruthy() ->and($abo)->not->toBeNull() ->and($abo->payment_provider)->toBe('stripe') ->and($abo->stripe_customer_id)->toBe('cus_new_1') ->and($abo->stripe_payment_method_id)->toBe('pm_new_1') ->and((int) $abo->payone_userid)->toBe(0) ->and($payment->fresh()->stripe_customer_id)->toBe('cus_new_1'); }); it('defers abo creation on browser return until stripe ids arrived via webhook', function () { [$order] = stripeAboOrderBootstrap(); $payment = ShoppingPayment::create([ 'shopping_order_id' => $order->id, 'payment_provider' => 'stripe', 'clearingtype' => 'stripe', 'reference' => 'sa-ref-'.uniqid(), 'amount' => 8900, 'currency' => 'EUR', 'is_abo' => true, 'abo_interval' => 5, 'mode' => 'test', ]); // Browser-Return läuft vor dem Webhook: noch keine Stripe-IDs an der Zahlung AboHelper::createNewAbo($payment); expect(UserAboOrder::where('shopping_order_id', $order->id)->exists())->toBeFalse(); // Webhook liefert die IDs und legt das Abo vollständig an handleStripeAboEvent('payment_intent.succeeded', [ 'id' => 'pi_abo_late', 'object' => 'payment_intent', 'status' => 'succeeded', 'amount_received' => 8900, 'customer' => 'cus_late_1', 'payment_method' => 'pm_late_1', 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], 'evt_abo_late'); $abo = $order->fresh()->getUserAbo(); expect($abo)->not->toBeNull() ->and($abo->stripe_payment_method_id)->toBe('pm_late_1'); }); it('never creates a new abo for recurring cron charges even when the webhook wins the race', function () { $userAbo = stripeAboUserAbo(); [$order] = stripeAboOrderBootstrap(); $payment = ShoppingPayment::create([ 'shopping_order_id' => $order->id, 'payment_provider' => 'stripe', 'clearingtype' => 'stripe', 'reference' => 'sa-ref-'.uniqid(), 'amount' => 8900, 'currency' => 'EUR', 'is_abo' => true, 'abo_interval' => 5, 'mode' => 'test', 'user_abo_id' => $userAbo->id, ]); $abosBefore = UserAbo::count(); handleStripeAboEvent('payment_intent.succeeded', [ 'id' => 'pi_recurring', 'object' => 'payment_intent', 'status' => 'succeeded', 'amount_received' => 8900, 'customer' => 'cus_abo_1', 'payment_method' => 'pm_abo_1', 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id, 'user_abo_id' => (string) $userAbo->id], ], 'evt_recurring'); expect($order->fresh()->paid)->toBeTruthy() ->and(UserAbo::count())->toBe($abosBefore); }); // --- S4 / §7.2 Nr. 3: Recovery-Zahlung zieht das Abo auf Stripe um --- it('advances a payone abo to stripe after a recovery payment via webhook', function () { $userAbo = stripeAboUserAbo([ 'payment_provider' => 'payone', 'payone_userid' => 900500, 'clearingtype' => 'cc', 'stripe_customer_id' => null, 'stripe_payment_method_id' => null, 'status' => 3, ]); [$order] = stripeAboOrderBootstrap(['recovery_user_abo_id' => $userAbo->id]); $payment = ShoppingPayment::create([ 'shopping_order_id' => $order->id, 'payment_provider' => 'stripe', 'clearingtype' => 'stripe', 'reference' => 'sa-ref-'.uniqid(), 'amount' => 8900, 'currency' => 'EUR', 'is_abo' => true, 'abo_interval' => 5, 'mode' => 'test', ]); handleStripeAboEvent('payment_intent.succeeded', [ 'id' => 'pi_recovery', 'object' => 'payment_intent', 'status' => 'succeeded', 'amount_received' => 8900, 'customer' => 'cus_rec_1', 'payment_method' => 'pm_rec_1', 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], 'evt_recovery'); $fresh = $userAbo->fresh(); expect($order->fresh()->paid)->toBeTruthy() ->and($fresh->payment_provider)->toBe('stripe') ->and($fresh->stripe_customer_id)->toBe('cus_rec_1') ->and($fresh->stripe_payment_method_id)->toBe('pm_rec_1') ->and($fresh->status)->toBe(2) ->and(UserAbo::count())->toBe(1) ->and(UserAboOrder::where('shopping_order_id', $order->id)->where('paid', true)->exists())->toBeTrue(); }); // --- S4.3/S4.5: Wiederkehrende Abbuchung off-session --- it('charges a stripe abo off-session with an idempotency key and records the transaction', function () { $userAbo = stripeAboUserAbo(); [$order, $shoppingUser] = stripeAboOrderBootstrap(); $captured = null; stripeAboMockClient(PaymentIntent::constructFrom([ 'id' => 'pi_charge_1', 'object' => 'payment_intent', 'status' => 'succeeded', 'amount' => 8900, 'customer' => 'cus_abo_1', 'payment_method' => 'pm_abo_1', 'livemode' => false, ]), $captured); $result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR'); $params = $captured[2]; $opts = $captured[3] ?? null; $payment = $result->shoppingPayment->fresh(); expect($result->success)->toBeTrue() ->and($result->status)->toBe('APPROVED') ->and($result->transactionId)->toBe('pi_charge_1') ->and($params['customer'])->toBe('cus_abo_1') ->and($params['payment_method'])->toBe('pm_abo_1') ->and($params['off_session'])->toBeTrue() ->and($params['confirm'])->toBeTrue() ->and($params['metadata']['user_abo_id'])->toBe((string) $userAbo->id) ->and($opts['idempotency_key'] ?? null)->toBe('abo-'.$userAbo->id.'-order-'.$order->id) ->and($payment->payment_provider)->toBe('stripe') ->and($payment->user_abo_id)->toBe($userAbo->id) ->and($payment->external_reference)->toBe('pi_charge_1') ->and(PaymentTransaction::where('shopping_payment_id', $payment->id)->where('request', 'authorization')->exists())->toBeTrue(); }); it('maps a card decline to an error result with customer message', function () { $userAbo = stripeAboUserAbo(); [$order, $shoppingUser] = stripeAboOrderBootstrap(); stripeAboMockClient(CardException::factory( 'Your card was declined.', 402, null, ['error' => ['code' => 'card_declined', 'message' => 'Your card was declined.', 'decline_code' => 'generic_decline', 'type' => 'card_error']], )); $result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR'); expect($result->success)->toBeFalse() ->and($result->status)->toBe('ERROR') ->and($result->errorMessage)->toContain('card_declined') ->and($result->customerMessage)->toBe(__('payment.payment_error_reasons.card_declined')) ->and(PaymentTransaction::where('shopping_payment_id', $result->shoppingPayment->id)->where('errorcode', 'card_declined')->exists())->toBeTrue(); }); it('fails without touching stripe when the abo has no stored payment method', function () { $userAbo = stripeAboUserAbo(['stripe_payment_method_id' => null]); [$order, $shoppingUser] = stripeAboOrderBootstrap(); $result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR'); expect($result->success)->toBeFalse() ->and($result->status)->toBe('ERROR') ->and($result->customerMessage)->toBe(__('payment.stripe_abo_payment_method_missing')) ->and($result->shoppingPayment)->toBeNull(); }); // --- S4.4: Provider-Dispatch im Cron --- it('dispatches the cron payment of a stripe abo through the stripe gateway', function () { $userAbo = stripeAboUserAbo(); [$order, $shoppingUser] = stripeAboOrderBootstrap(); stripeAboMockClient(PaymentIntent::constructFrom([ 'id' => 'pi_cron_1', 'object' => 'payment_intent', 'status' => 'succeeded', 'livemode' => false, ])); $userOrder = new UserMakeOrder($userAbo); $reflection = new ReflectionClass($userOrder); foreach (['shopping_user' => $shoppingUser, 'shopping_order' => $order] as $property => $value) { $prop = $reflection->getProperty($property); $prop->setAccessible(true); $prop->setValue($userOrder, $value); } $response = $userOrder->makePayment(); expect($response['status'])->toBe('APPROVED') ->and($response['provider'])->toBe('stripe') ->and($response['txid'])->toBe('pi_cron_1') ->and($userOrder->getShoppingPayment()->payment_provider)->toBe('stripe'); }); it('returns an error response for a declined cron charge so the cron sets abo status 3', function () { $userAbo = stripeAboUserAbo(); [$order, $shoppingUser] = stripeAboOrderBootstrap(); stripeAboMockClient(CardException::factory( 'Authentication required.', 402, null, ['error' => ['code' => 'authentication_required', 'message' => 'Authentication required.', 'type' => 'card_error']], )); $userOrder = new UserMakeOrder($userAbo); $reflection = new ReflectionClass($userOrder); foreach (['shopping_user' => $shoppingUser, 'shopping_order' => $order] as $property => $value) { $prop = $reflection->getProperty($property); $prop->setAccessible(true); $prop->setValue($userOrder, $value); } $response = $userOrder->makePayment(); expect($response['status'])->toBe('ERROR') ->and($response['customermessage'])->toBe(__('payment.stripe_abo_authentication_required')); }); // --- Webhook-Race: Cron-Transaktion noch nicht committet --- it('requests a stripe retry when our payment is not yet visible and processes it later', function () { [$order] = stripeAboOrderBootstrap(); $object = [ 'id' => 'pi_race', 'object' => 'payment_intent', 'status' => 'succeeded', 'amount_received' => 8900, 'customer' => 'cus_abo_1', 'payment_method' => 'pm_abo_1', 'livemode' => false, 'metadata' => ['shopping_payment_id' => '999999'], ]; $first = handleStripeAboEvent('payment_intent.succeeded', $object, 'evt_race'); expect($first->statusCode)->toBe(503) ->and($first->state)->toBe('payment_not_yet_visible') ->and(StripeWebhookEvent::whereEventId('evt_race')->first()->processed_at)->toBeNull(); // Zahlung wird sichtbar (Cron-Commit), Stripe stellt erneut zu ShoppingPayment::forceCreate([ 'id' => 999999, 'shopping_order_id' => $order->id, 'payment_provider' => 'stripe', 'clearingtype' => 'stripe', 'reference' => 'sa-ref-race', 'amount' => 8900, 'currency' => 'EUR', 'mode' => 'test', 'user_abo_id' => stripeAboUserAbo()->id, ]); $second = handleStripeAboEvent('payment_intent.succeeded', $object, 'evt_race'); expect($second->statusCode)->toBe(200) ->and($second->state)->toBe('processed') ->and($order->fresh()->paid)->toBeTruthy(); }); it('processes pending stored events via the maintenance command once the payment is visible', function () { [$order] = stripeAboOrderBootstrap(); $payment = ShoppingPayment::create([ 'shopping_order_id' => $order->id, 'payment_provider' => 'stripe', 'clearingtype' => 'stripe', 'reference' => 'sa-ref-'.uniqid(), 'amount' => 8900, 'currency' => 'EUR', 'is_abo' => true, 'abo_interval' => 5, 'mode' => 'test', 'user_abo_id' => stripeAboUserAbo()->id, ]); // Wie nach einem 503: Event liegt gespeichert, aber unverarbeitet im Store StripeWebhookEvent::create([ 'event_id' => 'evt_pending_cmd', 'type' => 'payment_intent.succeeded', 'payload' => [ 'id' => 'pi_pending_cmd', 'object' => 'payment_intent', 'status' => 'succeeded', 'amount_received' => 8900, 'customer' => 'cus_abo_1', 'payment_method' => 'pm_abo_1', 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ], ]); // Nicht zuordenbares Event bleibt liegen StripeWebhookEvent::create([ 'event_id' => 'evt_still_missing', 'type' => 'payment_intent.succeeded', 'payload' => [ 'id' => 'pi_still_missing', 'object' => 'payment_intent', 'status' => 'succeeded', 'livemode' => false, 'metadata' => ['shopping_payment_id' => '888888'], ], ]); $this->artisan('stripe:process-pending-events')->assertExitCode(0); expect($order->fresh()->paid)->toBeTruthy() ->and(StripeWebhookEvent::whereEventId('evt_pending_cmd')->first()->processed_at)->not->toBeNull() ->and(StripeWebhookEvent::whereEventId('evt_still_missing')->first()->processed_at)->toBeNull(); }); it('still acknowledges foreign events without our metadata', function () { $result = handleStripeAboEvent('payment_intent.succeeded', [ 'id' => 'pi_foreign', 'object' => 'payment_intent', 'status' => 'succeeded', 'livemode' => false, 'metadata' => [], ], 'evt_foreign'); expect($result->statusCode)->toBe(200) ->and(StripeWebhookEvent::whereEventId('evt_foreign')->first()->processed_at)->not->toBeNull(); }); // --- S4.6: Anzeige --- it('labels stripe abos from the stored payment method summary', function () { $userAbo = stripeAboUserAbo([ 'carddata' => ['type' => 'card', 'brand' => 'visa', 'last4' => '4242', 'exp' => '04/2031', 'wallet' => null], ]); expect($userAbo->getPaymentType())->toContain('Visa') ->and($userAbo->getPaymentType())->toContain('4242') ->and(UserAbo::formatMaskedCard($userAbo->carddata))->toBe('Visa •••• 4242 (04/2031)'); }); it('falls back to the stripe type label for masked cards without last4', function () { expect(UserAbo::formatMaskedCard(['type' => 'paypal']))->toBe(__('payment.paypal')) ->and(UserAbo::formatMaskedCard(null))->toBeNull(); });