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 1108381988
commit e1da90ff91
48 changed files with 4428 additions and 831 deletions

View file

@ -149,6 +149,21 @@ return [
'commission' => 'Provision',
'unknown' => 'unbekannt',
// Stripe (Hosted Checkout)
'stripe_online' => 'Online-Zahlung',
'stripe_online_methods' => 'Kreditkarte, PayPal, Apple Pay, Google Pay, Klarna u. v. m.',
'stripe_online_copy' => 'Du wirst zur sicheren Zahlungsseite weitergeleitet und wählst dort deine Zahlungsart.',
'stripe_online_short' => 'Online-Zahlung (Stripe)',
'stripe_unavailable' => 'Die Online-Zahlung ist derzeit nicht erreichbar. Bitte versuche es gleich erneut.',
'stripe_order_label' => 'Bestellung :reference',
// Not-Schalter: Abo-Zahlungen eingefroren (PAYONE-Ausfall, bis Stripe-Abos live sind)
'prepayment_no_clearing_data' => 'Bankverbindung liegt noch nicht vor — die Zahlung wurde beim Zahlungsanbieter noch nicht erfolgreich registriert.',
'checkout_maintenance_title' => 'Technische Schwierigkeiten',
'checkout_maintenance' => 'Aktuell sind Bestellungen wegen technischer Schwierigkeiten leider nicht möglich. Wir sind in Kürze wieder für dich da — bitte versuche es später erneut.',
'abo_payments_frozen_title' => 'Abo-Zahlungen vorübergehend nicht möglich',
'abo_payments_frozen' => 'Abo-Zahlungen sind derzeit leider nicht möglich. Wir arbeiten mit Hochdruck an einer technischen Lösung — bitte versuche es später erneut. Deine Abos und Bestellungen gehen dabei nicht verloren.',
// Vorkasse Verwendungszweck Hinweise
'prepayment_important_notice' => 'Wichtiger Hinweis zur Vorkasse-Zahlung',
'prepayment_reference_notice' => 'WICHTIG: Bitte geben Sie als Verwendungszweck ausschließlich folgende Nummer an:',

View file

@ -196,4 +196,19 @@ return [
'' => '',
'American Express' => 'American Express',
'unknown' => 'unknown',
// Stripe (hosted checkout)
'stripe_online' => 'Online payment',
'stripe_online_methods' => 'Credit card, PayPal, Apple Pay, Google Pay, Klarna and more',
'stripe_online_copy' => 'You will be redirected to a secure payment page where you choose your payment method.',
'stripe_online_short' => 'Online payment (Stripe)',
'stripe_unavailable' => 'Online payment is currently unavailable. Please try again in a moment.',
'stripe_order_label' => 'Order :reference',
// Emergency switch: subscription payments frozen (PAYONE outage, until Stripe subscriptions are live)
'prepayment_no_clearing_data' => 'Bank details are not available yet — the payment has not been successfully registered with the payment provider.',
'checkout_maintenance_title' => 'Technical difficulties',
'checkout_maintenance' => 'Ordering is currently not possible due to technical difficulties. We will be back shortly — please try again later.',
'abo_payments_frozen_title' => 'Subscription payments temporarily unavailable',
'abo_payments_frozen' => 'Subscription payments are currently not possible. We are working hard on a technical solution — please try again later. Your subscriptions and orders will not be lost.',
];

View file

@ -363,29 +363,31 @@
<div class="alert alert-warning border-warning mb-3" style="border-left: 4px solid #ffc107 !important;">
<h5 class="mb-3"><i class="fa fa-university"></i> {{ __('payment.prepayment_bank_details') }}:</h5>
<div style="background: #fff; border-radius: 8px; padding: 15px;">
@if($payTrans && isset($payTrans->transmitted_data))
@if($payTrans && isset($payTrans->transmitted_data) && !empty($payTrans->transmitted_data['clearing_bankiban']))
<table style="width: 100%;">
<tr>
<td style="padding: 5px 10px 5px 0; width: 140px;"><strong>{{ __('payment.account_holder') }}:</strong></td>
<td style="padding: 5px 0;">{{ $payTrans->transmitted_data['clearing_bankaccountholder'] }}</td>
<td style="padding: 5px 0;">{{ $payTrans->transmitted_data['clearing_bankaccountholder'] ?? '—' }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.IBAN') }}:</strong></td>
<td style="padding: 5px 0; font-family: monospace; letter-spacing: 1px;">{{ $payTrans->transmitted_data['clearing_bankiban'] }}</td>
<td style="padding: 5px 0; font-family: monospace; letter-spacing: 1px;">{{ $payTrans->transmitted_data['clearing_bankiban'] ?? '—' }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.BIC') }}:</strong></td>
<td style="padding: 5px 0; font-family: monospace;">{{ $payTrans->transmitted_data['clearing_bankbic'] }}</td>
<td style="padding: 5px 0; font-family: monospace;">{{ $payTrans->transmitted_data['clearing_bankbic'] ?? '—' }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.bank') }}:</strong></td>
<td style="padding: 5px 0;">{{ $payTrans->transmitted_data['clearing_bankname'] }}</td>
<td style="padding: 5px 0;">{{ $payTrans->transmitted_data['clearing_bankname'] ?? '—' }}</td>
</tr>
<tr style="border-top: 1px solid #ffc107;">
<td style="padding: 10px 10px 5px 0;"><strong>{{ __('payment.total_amount') }}:</strong></td>
<td style="padding: 10px 0 5px 0; font-size: 18px; font-weight: bold; color: #d35400;">{{ number_format( floatval($payTrans->shopping_payment->amount/100), 2, ",", ".") }} EUR</td>
</tr>
</table>
@else
<p class="mb-0 text-muted">{{ __('payment.prepayment_no_clearing_data') }}</p>
@endif
</div>

View file

@ -30,7 +30,41 @@
</div>
</div>
<div class="card mb-2">
<div class="card-body">
<h4>Zahlungen (Stripe / PAYONE)</h4>
<div class="form-row">
<div class="form-group col-sm-6">
<label class="form-label">Aktiver Zahlungsanbieter für <strong>neue</strong> Zahlungen</label>
{!! Form::select('settings[payment-active-provider][val]', ['payone' => 'PAYONE', 'stripe' => 'Stripe'], \App\Models\Setting::getContentBySlug('payment-active-provider') ?: 'payone', ['class' => 'form-control']) !!}
{{ Form::hidden('settings[payment-active-provider][type]', 'text') }}
<small class="text-muted">Wirkt nur auf neue Zahlungsvorgänge — bestehende Zahlungen/Abos behalten ihren Anbieter.</small>
</div>
</div>
<div class="form-row">
<div class="form-group col-sm-12">
<label class="custom-control custom-checkbox mt-2">
{!! Form::checkbox('settings[checkout-maintenance][val]', 1, \App\Models\Setting::getContentBySlug('checkout-maintenance'), ['class'=>'custom-control-input']) !!}
<span class="custom-control-label"><strong>Checkout-Wartungsmodus</strong> — alle Besucher sehen „Technische Schwierigkeiten"; nur Admins können bestellen (zum Live-Testen von Stripe)</span>
</label>
{{ Form::hidden('settings[checkout-maintenance][type]', 'bool') }}
<label class="custom-control custom-checkbox mt-3">
{!! Form::checkbox('settings[abo-payments-frozen][val]', 1, \App\Models\Setting::getContentBySlug('abo-payments-frozen'), ['class'=>'custom-control-input']) !!}
<span class="custom-control-label"><strong>Abo-Zahlungen einfrieren</strong> — Abo-Checkout, Retry und Abbuchungs-Cron pausieren mit Hinweis (Not-Schalter bis Stripe-Abos live sind)</span>
</label>
{{ Form::hidden('settings[abo-payments-frozen][type]', 'bool') }}
<label class="custom-control custom-checkbox mt-3">
{!! Form::checkbox('settings[payment-vorkasse-hidden][val]', 1, \App\Models\Setting::getContentBySlug('payment-vorkasse-hidden'), ['class'=>'custom-control-input']) !!}
<span class="custom-control-label"><strong>Vorkasse ausblenden</strong> — Vorkasse läuft über PAYONE und wird im Checkout nicht mehr angeboten</span>
</label>
{{ Form::hidden('settings[payment-vorkasse-hidden][type]', 'bool') }}
</div>
</div>
<button type="submit" name="action" value="save_payment" class="btn btn-primary btn-sm mb-2"><i class="ion ion-ios-save"></i> speichern</button>
</div>
</div>
<div class="card mb-2">
<div class="card-body">

View file

@ -133,25 +133,25 @@
<strong>{{ __('payment.account_holder') }}:</strong>
</td>
<td style="padding: 5px 0;">
{{ $pay_trans->transmitted_data['clearing_bankaccountholder'] }}</td>
{{ $pay_trans->transmitted_data['clearing_bankaccountholder'] ?? '—' }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.IBAN') }}:</strong>
</td>
<td style="padding: 5px 0; font-family: monospace; letter-spacing: 1px;">
{{ $pay_trans->transmitted_data['clearing_bankiban'] }}</td>
{{ $pay_trans->transmitted_data['clearing_bankiban'] ?? '—' }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.BIC') }}:</strong>
</td>
<td style="padding: 5px 0; font-family: monospace;">
{{ $pay_trans->transmitted_data['clearing_bankbic'] }}</td>
{{ $pay_trans->transmitted_data['clearing_bankbic'] ?? '—' }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.bank') }}:</strong>
</td>
<td style="padding: 5px 0;">
{{ $pay_trans->transmitted_data['clearing_bankname'] }}</td>
{{ $pay_trans->transmitted_data['clearing_bankname'] ?? '—' }}</td>
</tr>
<tr style="border-top: 1px solid #ffc107;">
<td style="padding: 10px 10px 5px 0;">

View file

@ -940,7 +940,43 @@
<div class="toggle-content">
<div class="row nomargin-bottom">
@if(($shopping_user->abo_options === 1) && (Util::getUserPaymentFor() >= 3))
@if(!empty($abo_payments_frozen))
<div class="col-lg-12 nomargin clearfix">
<div class="alert alert-warning mb-0">
<strong>{{ __('payment.abo_payments_frozen_title') }}</strong><br>
{{ __('payment.abo_payments_frozen') }}
</div>
</div>
@elseif(!empty($stripe_active))
{{-- Stripe Hosted Checkout: Zahlartenwahl passiert bei Stripe --}}
<div class="col-lg-12 nomargin clearfix">
<label class="mt-0 fs-14 fw-400 radio pull-left nomargin-top" style="width: 100%">
{!! Form::radio('payment_method', 'stripe', (!old('payment_method') ? '1' : '')) !!}
<i></i> <span class="weight-400"><span class="fa fa-check text-primary"></span> {{ __('payment.stripe_online') }}</span>
<img class="float-right" width="90" src="{{asset('images/payments-assets/stripe.png')}}" alt="Stripe">
</label>
<p class="fs-13 mb-1">{{ __('payment.stripe_online_methods') }}</p>
<p class="fs-12 text-muted mb-0">{{ __('payment.stripe_online_copy') }}</p>
</div>
@if(array_key_exists('VOR', $payment_methods_active) && in_array($payment_methods_active['VOR'], $payment_methods))
<div class="col-lg-12 nomargin clearfix">
<label class="mt-4 fs-14 fw-400 radio pull-left" style="width: 100%">
{!! Form::radio('payment_method', 'vor', '') !!}
<i></i> <span class="weight-400"><span class="fa fa-check text-primary"></span> {{ __('payment.prepayment') }}</span>
<img class="float-right" width="90" src="{{asset('images/payments-assets/vor.png')}}" alt="Vorkasse">
</label>
</div>
@endif
@if(array_key_exists('FNC', $payment_methods_active) && in_array($payment_methods_active['FNC'], $payment_methods))
<div class="col-lg-12 nomargin clearfix">
<label class="mt-4 fs-14 fw-400 radio pull-left" style="width: 100%">
{!! Form::radio('payment_method', 'fnc#MIV', '') !!}
<i></i> <span class="weight-400"><span class="fa fa-check text-primary"></span> {{ __('payment.purchase_on_account') }}</span>
<img class="float-right" width="90" src="{{asset('images/payments-assets/fnc.png')}}" alt="Rechnungskauf">
</label>
</div>
@endif
@elseif(($shopping_user->abo_options === 1) && (Util::getUserPaymentFor() >= 3))
@if(array_key_exists('SEPA', $payment_methods_active) && in_array($payment_methods_active['SEPA'], $payment_methods))
<div class="col-lg-12 nomargin clearfix">
<label class="mt-4 fs-14 fw-400 radio pull-left" style="width: 100%">
@ -1331,8 +1367,12 @@
</div>
@endif
@if(!empty($abo_payments_frozen))
<div class="alert alert-warning mt-4">{{ __('payment.abo_payments_frozen') }}</div>
@else
<button type="submit" class="btn btn-primary btn-lg btn-block size-18 btn-text-500 mt-4 faa-parent animated-hover"><i class="fa fa-mail-forward faa-horizontal"></i> {{ __('payment.buy_now') }}</button>
<hr><p class="text-center" style="line-height: 1.2em;"><em class="small text-center"> <i class="fa fa-lock"></i> {{ __('payment.buy_now_copy') }}</em></p>
@endif
</div>
</div>