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:
parent
671022a28d
commit
6f21845607
48 changed files with 4428 additions and 831 deletions
23
app/Http/Controllers/Api/StripeWebhookController.php
Normal file
23
app/Http/Controllers/Api/StripeWebhookController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue