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>
This commit is contained in:
Kevin 2026-07-08 15:56:41 +00:00
parent 671022a28d
commit 6f21845607
48 changed files with 4428 additions and 831 deletions

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Payment\Stripe\StripeWebhookHandler;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class StripeWebhookController extends Controller
{
public function __construct(private readonly StripeWebhookHandler $webhookHandler) {}
public function handleWebhook(Request $request): Response
{
$result = $this->webhookHandler->handle(
$request->getContent(),
$request->header('Stripe-Signature'),
);
return response($result->state, $result->statusCode);
}
}

View file

@ -2,23 +2,30 @@
namespace App\Http\Controllers\Web;
use App\Enums\PaymentProvider;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Pay\PayoneController;
use App\Models\PaymentTransaction;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Repositories\CheckoutRepository;
use App\Services\AboHelper;
use App\Services\CheckoutFunnelTracker;
use App\Services\CustomerPriority;
use App\Services\Payment;
use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
use App\Services\Payment\Stripe\StripePaymentService;
use App\Services\Shop;
use App\Services\Util;
use App\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Session;
use Illuminate\View\View;
use Request;
use Stripe\Exception\ApiErrorException;
use Validator;
use Yard;
@ -41,7 +48,7 @@ class CheckoutController extends Controller
/**
* Zeigt die Checkout-Seite an
*
* @return \Illuminate\View\View
* @return View
*/
public function checkout()
{
@ -53,6 +60,14 @@ class CheckoutController extends Controller
@endif
@if(Auth::guard('user')->check())
*/
if ($this->checkoutMaintenanceBlocked()) {
return $this->showTransactionError(
__('payment.checkout_maintenance_title'),
__('payment.checkout_maintenance'),
'maintenance',
);
}
$shopping_data = Yard::instance($this->instance)->getYardExtra('shopping_data');
$is_from = $shopping_data['is_from'] ?? 'shopping';
$is_for = $shopping_data['is_for'] ?? false;
@ -81,6 +96,15 @@ class CheckoutController extends Controller
$this->prepareShoppingUserData($shopping_user);
$payment_methods = $this->checkoutRepo->getPaymentsMethods($is_from, $is_abo);
// Stripe aktiv: keine Gateway-Zahlartenwahl im eigenen Checkout mehr —
// nur die Stripe-Option + systeminterne Methoden (Vorkasse/Rechnung).
$stripe_active = $this->stripeCheckoutActive($is_abo, $recovery_user_abo_id);
if ($stripe_active) {
$payment_methods['active'] = array_intersect_key($payment_methods['active'], array_flip(['VOR', 'FNC']));
}
$abo_payments_frozen = $this->aboPaymentsFrozen($is_abo, $recovery_user_abo_id);
if ($shopping_user === null) {
abort(403, 'ShoppingUser not found');
}
@ -98,6 +122,8 @@ class CheckoutController extends Controller
'payment_methods' => $payment_methods['default'],
'payment_methods_active' => $payment_methods['active'],
'payment_data' => $payment_methods['data'],
'stripe_active' => $stripe_active,
'abo_payments_frozen' => $abo_payments_frozen,
'instance_status' => $instance_status ?? false,
'is_checkout' => true,
'yard_instance' => $this->instance,
@ -144,14 +170,36 @@ class CheckoutController extends Controller
/**
* Verarbeitet den Checkout-Prozess
*
* @return \Illuminate\Http\RedirectResponse
* @return RedirectResponse
*/
public function checkoutFinal()
{
$data = Request::all();
if ($this->checkoutMaintenanceBlocked()) {
return redirect(route('checkout.checkout_card'));
}
$shopping_data_frozen = Yard::instance($this->instance)->getYardExtra('shopping_data');
if ($this->aboPaymentsFrozen(
(bool) ($data['is_abo'] ?? false),
$shopping_data_frozen['recovery_user_abo_id'] ?? null,
)) {
Session::flash('errormessage', __('payment.abo_payments_frozen'));
return redirect(route('checkout.checkout_card'))->withInput(Request::all());
}
if (isset($data['payment_method'])) {
$this->checkoutRepo->isPaymentsMethodsActive($data['payment_method'], $data['is_from'], $data['is_abo']);
if ($data['payment_method'] === 'stripe') {
$shopping_data = Yard::instance($this->instance)->getYardExtra('shopping_data');
abort_unless($this->stripeCheckoutActive(
(bool) ($data['is_abo'] ?? false),
$shopping_data['recovery_user_abo_id'] ?? null,
), 404);
} else {
$this->checkoutRepo->isPaymentsMethodsActive($data['payment_method'], $data['is_from'], $data['is_abo']);
}
}
Util::setInstanceStatus(2, true); // link_check
@ -198,7 +246,7 @@ class CheckoutController extends Controller
* Verarbeitet den Länderwechsel
*
* @param array $data
* @return \Illuminate\Http\RedirectResponse
* @return RedirectResponse
*/
private function handleCountryChange($data)
{
@ -261,6 +309,10 @@ class CheckoutController extends Controller
$result = [];
$payment_method = Request::get('payment_method');
if ($payment_method === 'stripe') {
return $this->processStripeCheckout($shopping_user, $shopping_order);
}
try {
// Kreditkarte prüfen
if ($payment_method === 'cc') {
@ -296,13 +348,96 @@ class CheckoutController extends Controller
}
}
/**
* Stripe ist für diesen Checkout aktiv, wenn der Anbieter-Schalter auf
* Stripe steht. Abo- und Recovery-Checkouts bleiben bis Phase S4 auf
* PAYONE (Plan dev/2026-07-08-stripe/, Abgrenzung S3/S4).
*/
private function stripeCheckoutActive(bool $is_abo, mixed $recovery_user_abo_id): bool
{
if ($is_abo || $recovery_user_abo_id) {
return false;
}
return app(PaymentProviderManager::class)->providerForNewCheckout() === PaymentProvider::Stripe;
}
/**
* Checkout-Wartungsmodus: blockiert alle Nicht-Admins mit einer
* "Technische Schwierigkeiten"-Meldung. Admins (isAdmin) bestellen normal —
* so lässt sich Stripe im Live-Betrieb verproben, bevor der Checkout
* wieder für alle öffnet.
*/
private function checkoutMaintenanceBlocked(): bool
{
if (! app(PaymentProviderManager::class)->checkoutMaintenanceActive()) {
return false;
}
$user = Util::getAuthUser() ?: \Auth::guard('user')->user();
return ! ($user instanceof User && $user->isAdmin());
}
/**
* Abo-Zahlungen eingefroren (Not-Schalter bei PAYONE-Ausfall):
* betrifft Abo-Neuabschlüsse und Recovery-Zahlungen im Checkout.
*/
private function aboPaymentsFrozen(bool $is_abo, mixed $recovery_user_abo_id): bool
{
if (! $is_abo && ! $recovery_user_abo_id) {
return false;
}
return app(PaymentProviderManager::class)->aboPaymentsFrozen();
}
/**
* Einmalzahlung über Stripe: Checkout Session erstellen und den Browser
* zur Hosted Payment Page umleiten. Success/Cancel laufen über die
* bestehenden transaction_status-Routen; "bezahlt" setzt nur der Webhook.
*
* @param ShoppingUser $shopping_user
* @param ShoppingOrder $shopping_order
* @return RedirectResponse
*/
private function processStripeCheckout($shopping_user, $shopping_order)
{
$amount = (int) round(Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100);
$reference = substr(uniqid('m', false), 0, 16);
try {
$result = app(StripePaymentService::class)->initiateCheckoutPayment(
$shopping_user,
$shopping_order,
$amount,
'EUR',
$reference,
route('checkout.transaction_status', ['success', $reference]),
route('checkout.transaction_status', ['cancel', $reference]),
);
$this->checkoutRepo->putSessionPayments('payment_reference', $reference);
Util::setUserHistoryValue(['status' => 4]);
return redirect()->away($result->url);
} catch (ApiErrorException|\RuntimeException $e) {
\Log::channel('stripe')->error('Stripe-Checkout-Session konnte nicht erstellt werden: '.$e->getMessage(), [
'shopping_order_id' => $shopping_order->id,
]);
Session::flash('errormessage', __('payment.stripe_unavailable'));
return redirect(route('checkout.checkout_card'))->withInput(Request::all());
}
}
/**
* Prüft die Kreditkartendaten
*
* @param array $data
* @param ShoppingUser $shopping_user
* @param ShoppingOrder $shopping_order
* @return bool|\Illuminate\Http\RedirectResponse
* @return bool|RedirectResponse
*/
private function checkCreditCard($data, $shopping_user, $shopping_order)
{
@ -328,7 +463,7 @@ class CheckoutController extends Controller
* @param array $data
* @param ShoppingUser $shopping_user
* @param ShoppingOrder $shopping_order
* @return bool|\Illuminate\Http\RedirectResponse
* @return bool|RedirectResponse
*/
private function checkSepaAccount($data, $shopping_user, $shopping_order)
{
@ -375,7 +510,7 @@ class CheckoutController extends Controller
/**
* Leitet zur Abschlussseite weiter
*
* @return \Illuminate\View\View
* @return View
*/
public function redirectToIsFinal()
{
@ -394,7 +529,7 @@ class CheckoutController extends Controller
*
* @param string $status
* @param string $reference
* @return \Illuminate\View\View|\Illuminate\Http\RedirectResponse
* @return View|RedirectResponse
*/
public function transactionStatusPost($status, $reference)
{
@ -406,7 +541,7 @@ class CheckoutController extends Controller
*
* @param string $status
* @param string $reference
* @return \Illuminate\View\View|\Illuminate\Http\RedirectResponse
* @return View|RedirectResponse
*/
public function transactionStatus($status, $reference)
{
@ -492,7 +627,7 @@ class CheckoutController extends Controller
/**
* Zeigt eine Transaktionsfehlerseite an
*
* @return \Illuminate\View\View
* @return View
*/
/**
* @param array<string, mixed> $extra
@ -516,7 +651,7 @@ class CheckoutController extends Controller
*
* @param ShoppingPayment $ShoppingPayment
* @param string $reference
* @return \Illuminate\View\View
* @return View
*/
private function handleSuccessfulTransaction($ShoppingPayment, $reference)
{
@ -549,7 +684,7 @@ class CheckoutController extends Controller
*
* @param int $transactionId
* @param string $reference
* @return \Illuminate\View\View
* @return View
*/
public function transactionApproved($transactionId, $reference)
{
@ -601,7 +736,7 @@ class CheckoutController extends Controller
return null;
}
return \App\Models\UserAbo::find($shoppingOrder->recovery_user_abo_id)?->selfServiceDetailUrl();
return UserAbo::find($shoppingOrder->recovery_user_abo_id)?->selfServiceDetailUrl();
}
/**
@ -660,7 +795,7 @@ class CheckoutController extends Controller
* @param string|null $is_for = me | ot | abo-me | abo-ot | ot-customer | abo-ot-customer
* @param array|null $shopping_data
* @param int|null $homeparty_id
* @return \App\Models\ShoppingUser
* @return ShoppingUser
*/
private function initializeShoppingUserSession($is_from, $is_for, $shopping_data = null, $homeparty_id = null)
{