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>
449 lines
16 KiB
PHP
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');
|
|
});
|