mivita/tests/Feature/StripeCheckoutTest.php
Kevin 6f21845607 Stripe-Integration (S0–S3): Einmalzahlungen über Stripe Checkout + Notfall-Go-Live
Wegen PAYONE-Ausfall vorgezogen live geschaltet: alle Einmalzahlungen
(Shop + Salescenter) laufen über Stripe Hosted Checkout, Abos sind bis
Phase S4 eingefroren.

- Provider-Abstraktion: PaymentProvider-Enum, PaymentGatewayInterface,
  PaymentProviderManager (Settings-Schalter, provider-treu je Bestand),
  PayoneGateway als unveränderter Adapter
- StripePaymentService: Checkout Session (eine Gesamtposition, Metadata,
  success/cancel-Redirects), StripeClientWrapper (lazy, Dark Launch),
  StripeCustomerService, StripeTransactionRecorder (client_secret-Redaction)
- Webhook /stripe/webhook: Signaturprüfung, Idempotenz über
  stripe_webhook_events (Crash-Retry-Reprocessing), txaction-Prioritäten,
  Betrags-Guard, lockForUpdate-Paid-Pfad, genau eine Bestätigungsmail
- Zahlungsmethoden-Anzeige aus charge.succeeded (carddata inkl. Wallet,
  auftragsunabhängiges Backfill aus dem Event-Store)
- Checkout: Wartungsmodus mit Admin-Bypass, Abo-Freeze-Hinweis,
  Stripe-Option (Online-Zahlung) statt Zahlartenwahl, Vorkasse ausblendbar
- Abo-Freeze-Guards: Cron user:make_abo_order, Retry, Kartenupdate
- Admin-Settings-Karte Zahlungen (Provider, Wartung, Freeze, Vorkasse)
- Hotfix: 500 bei Vorkasse ohne Clearing-Daten in admin/sales/_detail
  und checkout-final (PAYONE-Ausfall)
- Doku dev/2026-07-08-stripe/ (Plan S0–S8, Konzept, IST-Analyse)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:56:41 +00:00

449 lines
16 KiB
PHP

<?php
use App\Mail\MailCheckout;
use App\Models\PaymentMethod;
use App\Models\PaymentTransaction;
use App\Models\Setting;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\StripeWebhookEvent;
use App\Models\UserAboOrder;
use App\Models\UserShop;
use App\Repositories\CheckoutRepository;
use App\Services\AboPaymentMethodService;
use App\Services\AboRetryPaymentService;
use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Payone\DTOs\CardUpdateResult;
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
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 Illuminate\Support\Facades\Mail;
use Stripe\Checkout\Session;
use Stripe\StripeClient;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function (): void {
Mail::fake();
});
/**
* Vollständige Order-Fixture (Muster: webhookBootstrap aus PayoneWebhookHandlerTest)
* für eine 89,00-EUR-Stripe-Zahlung.
*
* @return array{0: ShoppingOrder, 1: ShoppingPayment, 2: ShoppingUser}
*/
function stripeCheckoutBootstrap(array $paymentOverrides = []): array
{
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 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));
}
// --- Session-Erstellung (S3.2) ---
it('creates a checkout session with payment and initiation transaction', function () {
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
$order->shopping_payments()->delete();
$capturedParams = null;
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->once()
->withArgs(function ($method, $path, $params) use (&$capturedParams) {
$capturedParams = $params;
return $method === 'post' && $path === '/v1/checkout/sessions';
})
->andReturn(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);
$result = (new StripePaymentService($wrapper))->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();
});
// --- 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');
});