mivita/tests/Feature/StripePaymentMethodChangeTest.php
Kevin ce4750fd1a Stripe-Integration (S4, S5, S7.6): Abos, Zahlungsart ändern, Zahlarten-Kommunikation
Phase S4 – Abos über Stripe (E2E komplett im Testmodus):
- Abo-/Recovery-Checkout: customer_creation=always + setup_future_usage=off_session
- StripePaymentService::chargeAbo (off-session PaymentIntent, Idempotency-Key),
  StripeGateway, Provider-Dispatch im Abbuchungs-Cron (PAYONE-Pfad unverändert)
- Webhook: Zahlungsmittel-Capture vor Paid-Pfad, 503 payment_not_yet_visible bei
  Cron-Transaktions-Race; Command stripe:process-pending-events (Cron-Ende + 10-min
  Scheduler) verarbeitet liegengebliebene Events nach
- createNewAbo/advanceAboAfterRecoveryPayment Stripe-aware inkl. Race-Absicherung;
  Recovery-Orders nutzen advanceAboAfterRecoveryPayment (latenter PAYONE-Duplikat-Bug)
- Migrationen: Stripe-Abo-Spalten auf shopping_payments; clearingtype varchar(3)→(20)
  inkl. Reparatur der zu 'str' gekürzten Live-Zeilen

Phase S5 – Zahlungsart ändern (Scope: jede Zahlart, nicht nur Kreditkarte):
- Stripe Checkout mode=setup ohne Abbuchung; PAYONE-Abos ziehen beim Wechsel auf
  Stripe um (Auslaufmodell §7.2)
- AboPaymentMethodService: beginStripeChange/finalize/applyStripePaymentMethod
  (idempotent, detacht alte PM, card_changes-Log); Webhook setup_intent.succeeded
  als Sicherheitsnetz; Einstiege Portal/CRM/Berater-Kunden-Link flow-abhängig
- Views/Mails flow-abhängig („Zahlungsweise" statt „Kreditkarte")

Phase S7.6 – Zahlarten-Kommunikation:
- Einheitliche Zahlarten-Logos (SVG) + wiederverwendbares Partial payment_logos;
  Checkout/Footer zeigen Kern-Zahlarten + „···"-Icon, /zahlungsarten komplett neu
  mit allen aktiven Stripe-Zahlarten (gegen Account-Konfiguration verifiziert)
  und Vorkasse (PAYONE); Sofort/SEPA entfernt

Tests: StripeAboTest (15), StripePaymentMethodChangeTest (10),
PaymentMethodsCommunicationTest (28), PaymentProviderManagerTest angepasst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:45:17 +00:00

304 lines
12 KiB
PHP

<?php
use App\Mail\AboCardUpdateLinkMail;
use App\Models\Setting;
use App\Models\UserAbo;
use App\Services\AboPaymentMethodService;
use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripePaymentService;
use App\Services\Payment\Stripe\StripeWebhookHandler;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\StripeClient;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function (): void {
// Der Setup-Flow gilt, wenn der Anbieter-Schalter auf Stripe steht.
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'stripe', 'text');
});
function pmChangeAbo(array $overrides = []): UserAbo
{
$owner = User::forceCreate([
'email' => '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<int, mixed> $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');
});