'pmc-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', ]); return UserAbo::create(array_merge([ 'user_id' => $owner->id, 'shopping_user_id' => 1, 'is_for' => 'me', 'email' => $owner->email, 'payone_userid' => 900700, 'payment_provider' => 'payone', 'clearingtype' => 'cc', 'active' => true, 'status' => 2, 'abo_interval' => 5, 'next_date' => now()->addDays(5), ], $overrides)); } /** * Stripe-Client-Mock für n request()-Aufrufe; Argumente aller Aufrufe werden * zur Assertion gesammelt (Muster: StripeAboTest). * * @param array $results */ function pmChangeMockClient(array $results, ?array &$calls = null): void { $calls = []; $client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]); $client->shouldReceive('request') ->times(count($results)) ->withArgs(function (...$args) use (&$calls) { $calls[] = $args; return true; }) ->andReturnValues($results); $wrapper = Mockery::mock(StripeClientWrapper::class); $wrapper->shouldReceive('client')->andReturn($client); app()->instance(StripeClientWrapper::class, $wrapper); } function pmChangeWebhookEvent(string $type, array $object, string $eventId): void { $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')); app(StripeWebhookHandler::class)->handle($payload, "t={$timestamp},v1={$signed}"); } // --- Invariante: freie Zahlartenwahl nur im Stripe-Flow --- it('allows changing the payment method of non-card abos only while the stripe flow is active', function () { $abo = pmChangeAbo(['clearingtype' => 'wlt', 'wallettype' => 'PPE']); $service = app(AboPaymentMethodService::class); expect($service->canStartChange($abo))->toBeTrue() // Model-Invariante bleibt pur: PAYONE-Legacy-Flow nur für CC-Abos ->and($abo->canUpdatePaymentMethod())->toBeFalse(); Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'payone', 'text'); expect($service->canStartChange($abo->fresh()))->toBeFalse(); }); it('never allows changing terminated abos regardless of the flow', function () { $abo = pmChangeAbo(['status' => 4]); expect(app(AboPaymentMethodService::class)->canStartChange($abo))->toBeFalse(); }); // --- S5.1: Setup-Session --- it('creates a setup session with abo metadata and sets the pending trace', function () { $abo = pmChangeAbo(['payment_provider' => 'stripe', 'stripe_customer_id' => 'cus_existing']); $calls = null; pmChangeMockClient([Session::constructFrom([ 'id' => 'cs_setup_1', 'object' => 'checkout.session', 'url' => 'https://checkout.stripe.com/c/setup/cs_setup_1', 'livemode' => false, ])], $calls); $result = app(AboPaymentMethodService::class)->beginStripeChange( $abo, 'https://portal.test/return/success?session_id={CHECKOUT_SESSION_ID}', 'https://portal.test/return/cancel', 'portal', ); $params = $calls[0][2]; expect($result->isRedirect())->toBeTrue() ->and($result->redirectUrl)->toBe('https://checkout.stripe.com/c/setup/cs_setup_1') ->and($params['mode'])->toBe('setup') ->and($params['currency'])->toBe('eur') ->and($params['customer'])->toBe('cus_existing') ->and($params['setup_intent_data']['metadata']['user_abo_id'])->toBe((string) $abo->id) ->and($params['success_url'])->toContain('{CHECKOUT_SESSION_ID}') ->and($abo->fresh()->card_update_pending_at)->not->toBeNull(); }); it('creates a stripe customer first for payone legacy abos and persists it', function () { $abo = pmChangeAbo(); pmChangeMockClient([ Customer::constructFrom(['id' => 'cus_fresh_1', 'object' => 'customer', 'livemode' => false]), Session::constructFrom([ 'id' => 'cs_setup_2', 'object' => 'checkout.session', 'url' => 'https://checkout.stripe.com/c/setup/cs_setup_2', 'livemode' => false, ]), ]); $result = app(AboPaymentMethodService::class)->beginStripeChange($abo, 'https://x.test/s?session_id={CHECKOUT_SESSION_ID}', 'https://x.test/c'); $fresh = $abo->fresh(); expect($result->isRedirect())->toBeTrue() ->and($fresh->stripe_customer_id)->toBe('cus_fresh_1') // Provider bleibt PAYONE, bis die neue Zahlungsart wirklich verankert ist ->and($fresh->payment_provider)->toBe('payone'); }); it('blocks the setup flow while abo payments are frozen', function () { Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool'); $abo = pmChangeAbo(); $result = app(AboPaymentMethodService::class)->beginStripeChange($abo, 'https://x.test/s', 'https://x.test/c'); expect($result->isError())->toBeTrue() ->and($result->errorMessage)->toBe(__('payment.abo_payments_frozen')) ->and($abo->fresh()->card_update_pending_at)->toBeNull(); }); // --- S5.2: Verankern der neuen Zahlungsart --- it('applies a new payment method and moves a payone abo to stripe', function () { $abo = pmChangeAbo(['card_update_pending_at' => now()]); $stripeService = Mockery::mock(StripePaymentService::class); $stripeService->shouldReceive('retrievePaymentMethodSummary') ->once()->with('pm_new_1') ->andReturn(['type' => 'paypal']); $stripeService->shouldNotReceive('detachPaymentMethod'); app()->instance(StripePaymentService::class, $stripeService); app(AboPaymentMethodService::class)->applyStripePaymentMethod($abo, 'cus_apply_1', 'pm_new_1', 'portal'); $fresh = $abo->fresh(); expect($fresh->payment_provider)->toBe('stripe') ->and($fresh->stripe_customer_id)->toBe('cus_apply_1') ->and($fresh->stripe_payment_method_id)->toBe('pm_new_1') ->and($fresh->clearingtype)->toBe('stripe') ->and($fresh->carddata)->toBe(['type' => 'paypal']) ->and($fresh->card_update_pending_at)->toBeNull() ->and($fresh->getPaymentType())->toBe(__('payment.paypal')) ->and($fresh->card_changes()->count())->toBe(1) ->and($fresh->card_changes()->first()->source)->toBe('portal'); }); it('is idempotent and detaches the previous stripe payment method on a real change', function () { $abo = pmChangeAbo([ 'payment_provider' => 'stripe', 'clearingtype' => 'stripe', 'stripe_customer_id' => 'cus_apply_2', 'stripe_payment_method_id' => 'pm_old', ]); $stripeService = Mockery::mock(StripePaymentService::class); $stripeService->shouldReceive('retrievePaymentMethodSummary') ->once()->with('pm_new_2') ->andReturn(['type' => 'card', 'brand' => 'visa', 'last4' => '4242', 'exp' => '04/2031', 'wallet' => null]); $stripeService->shouldReceive('detachPaymentMethod')->once()->with('pm_old'); app()->instance(StripePaymentService::class, $stripeService); $service = app(AboPaymentMethodService::class); $service->applyStripePaymentMethod($abo, 'cus_apply_2', 'pm_new_2', 'link'); // Zweiter Aufruf (Webhook nach Return-Pfad): keine zweite Verankerung $service->applyStripePaymentMethod($abo->fresh(), 'cus_apply_2', 'pm_new_2', 'stripe-webhook'); $fresh = $abo->fresh(); expect($fresh->stripe_payment_method_id)->toBe('pm_new_2') ->and($fresh->card_changes()->count())->toBe(1) ->and(UserAbo::formatMaskedCard($fresh->carddata))->toBe('Visa •••• 4242 (04/2031)'); }); it('anchors the payment method from the setup_intent succeeded webhook', function () { $abo = pmChangeAbo(['card_update_pending_at' => now()]); $stripeService = Mockery::mock(StripePaymentService::class); $stripeService->shouldReceive('retrievePaymentMethodSummary') ->once()->with('pm_hook_1') ->andReturn(['type' => 'sepa_debit', 'last4' => '3000']); app()->instance(StripePaymentService::class, $stripeService); pmChangeWebhookEvent('setup_intent.succeeded', [ 'id' => 'seti_1', 'object' => 'setup_intent', 'status' => 'succeeded', 'customer' => 'cus_hook_1', 'payment_method' => 'pm_hook_1', 'livemode' => false, 'metadata' => ['user_abo_id' => (string) $abo->id, 'source' => 'portal'], ], 'evt_seti_1'); $fresh = $abo->fresh(); expect($fresh->payment_provider)->toBe('stripe') ->and($fresh->stripe_payment_method_id)->toBe('pm_hook_1') ->and($fresh->card_update_pending_at)->toBeNull() ->and($fresh->card_changes()->first()->source)->toBe('portal'); }); // --- Kunden-Link-Mail: Texte je Flow --- it('uses payment method wording in the customer link mail while the stripe flow is active', function () { $abo = pmChangeAbo(); $stripeMail = (new AboCardUpdateLinkMail($abo, 'token-x'))->build(); expect($stripeMail->subject)->toBe(__('abo.payment_method_change_link_mail_subject')) ->and($stripeMail->viewData['mailButton'])->toBe(__('abo.payment_method_change_link_mail_button')); Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'payone', 'text'); $payoneMail = (new AboCardUpdateLinkMail($abo, 'token-x'))->build(); expect($payoneMail->subject)->toBe(__('abo.card_update_link_mail_subject')); }); // --- Return-Pfad --- it('finalizes the change from the return session and rejects foreign sessions', function () { $abo = pmChangeAbo(['card_update_pending_at' => now()]); $ownSession = Session::constructFrom([ 'id' => 'cs_ret_1', 'object' => 'checkout.session', 'customer' => 'cus_ret_1', 'metadata' => ['user_abo_id' => (string) $abo->id], 'setup_intent' => ['id' => 'seti_ret', 'object' => 'setup_intent', 'status' => 'succeeded', 'payment_method' => 'pm_ret_1'], ]); $foreignSession = Session::constructFrom([ 'id' => 'cs_ret_2', 'object' => 'checkout.session', 'customer' => 'cus_other', 'metadata' => ['user_abo_id' => '999999'], 'setup_intent' => ['id' => 'seti_x', 'object' => 'setup_intent', 'status' => 'succeeded', 'payment_method' => 'pm_x'], ]); $stripeService = Mockery::mock(StripePaymentService::class); $stripeService->shouldReceive('retrieveSetupSession')->with('cs_ret_1')->andReturn($ownSession); $stripeService->shouldReceive('retrieveSetupSession')->with('cs_ret_2')->andReturn($foreignSession); $stripeService->shouldReceive('retrievePaymentMethodSummary')->once()->andReturn(['type' => 'card', 'brand' => 'visa', 'last4' => '4242']); app()->instance(StripePaymentService::class, $stripeService); $service = app(AboPaymentMethodService::class); expect($service->finalizeStripeChangeFromSession($abo, 'cs_ret_2', 'portal'))->toBeFalse() ->and($abo->fresh()->payment_provider)->toBe('payone') ->and($service->finalizeStripeChangeFromSession($abo->fresh(), 'cs_ret_1', 'portal'))->toBeTrue() ->and($abo->fresh()->stripe_payment_method_id)->toBe('pm_ret_1'); });