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

@ -98,3 +98,16 @@ PAYONE_TS=checkout.example.test/transaction/status
# Hash erzeugen (verdeckte Eingabe, schreibt optional direkt in die .env):
# php artisan payone:hash-key --write
PAYONE_KEY_HASH=
# =============================================================================
# STRIPE KONFIGURATION (dev/2026-07-08-stripe/)
# =============================================================================
# Secret Key (sk_test_… / sk_live_…). Kein Boot-Hard-Fail: ohne Key bleibt
# Stripe inaktiv; erst die Nutzung des StripeClientWrapper schlägt fehl.
STRIPE_SECRET=
# Publishable Key (pk_test_… / pk_live_…)
STRIPE_KEY=
# Webhook-Signing-Secret des Endpoints /api/stripe/webhook (whsec_…)
STRIPE_WEBHOOK_SECRET=
# test | live — muss zum Key-Paar passen
STRIPE_MODE=test

View file

@ -10,6 +10,7 @@ use App\Services\AboHelper;
use App\Services\AboOneTimeService;
use App\Services\Incentive\IncentiveTracker;
use App\Services\MyLog;
use App\Services\Payment\PaymentProviderManager;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@ -66,6 +67,16 @@ class UserMakeAboOrder extends Command
\Log::channel('cron')->info('UserMakeAboOrder: Befehl gestartet');
$this->info('RUN Command user:make_abo_order');
// Not-Schalter (PAYONE-Ausfall): Abbuchungen komplett aussetzen. Abos werden
// NICHT als fehlgeschlagen markiert — nach dem Auftauen holt der Cron sie
// über ihr unverändertes next_date normal nach.
if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) {
\Log::channel('cron')->warning('UserMakeAboOrder: Abo-Zahlungen sind eingefroren (Setting abo-payments-frozen) — Lauf übersprungen.');
$this->warn('Abo-Zahlungen sind eingefroren (Setting abo-payments-frozen) — Lauf übersprungen.');
return 0;
}
try {
$this->checkAbosToOrder();
$executionTime = $this->getExecutionTime();

View file

@ -2,11 +2,13 @@
namespace App\Cron;
use App\Enums\PaymentProvider;
use App\Http\Controllers\Pay\PayoneController;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\UserAbo;
use App\Services\AboOrderCart;
use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Payone\PayoneTransactionRecorder;
use Illuminate\Support\Facades\Log;
use Yard;
@ -66,6 +68,12 @@ class UserMakeOrder
{
Log::info('Starte Zahlungsvorgang für UserAbo ID: '.$this->userAbo->id);
$provider = app(PaymentProviderManager::class)->providerFor($this->userAbo);
if ($provider !== PaymentProvider::Payone) {
Log::error('Abo-Abbuchung für Provider "'.$provider->value.'" ist noch nicht implementiert. UserAbo ID: '.$this->userAbo->id);
throw new \RuntimeException('Abo-Abbuchung für Provider "'.$provider->value.'" ist noch nicht implementiert (UserAbo '.$this->userAbo->id.').');
}
try {
$this->pay = new PayoneController;
$this->pay->init($this->shopping_user, $this->shopping_order);

View file

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
/**
* Zahlungsanbieter für neue und bestehende Zahlungsvorgänge.
*
* Jede Zahlung / jedes Abo trägt seinen Provider fest (payment_provider-Spalte);
* der Einstellungs-Schalter bestimmt den Provider nur für NEUE Vorgänge.
* Siehe dev/2026-07-08-stripe/STRIPE-KONZEPT.md §4.
*/
enum PaymentProvider: string
{
case Payone = 'payone';
case Stripe = 'stripe';
}

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)
{

View file

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* App\Models\PaymentTransaction
@ -19,9 +20,9 @@ use Illuminate\Database\Eloquent\Model;
* @property int|null $errorcode
* @property string|null $errormessage
* @property string|null $customermessage
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\ShoppingPayment $shopping_payment
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read ShoppingPayment $shopping_payment
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newQuery()
@ -57,6 +58,7 @@ class PaymentTransaction extends Model
protected $fillable = [
'shopping_payment_id',
'provider',
'request',
'txid',
'userid',

View file

@ -3,7 +3,9 @@
namespace App\Models;
use App\Services\Util;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* App\Models\ShoppingPayment
@ -18,11 +20,12 @@ use Illuminate\Database\Eloquent\Model;
* @property string $currency
* @property string|null $status
* @property string|null $txaction
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\PaymentTransaction[] $payment_transactions
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Collection|PaymentTransaction[] $payment_transactions
* @property-read int|null $payment_transactions_count
* @property-read \App\Models\ShoppingOrder $shopping_order
* @property-read ShoppingOrder $shopping_order
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment query()
@ -38,28 +41,37 @@ use Illuminate\Database\Eloquent\Model;
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment whereTxaction($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment whereWallettype($value)
*
* @property string|null $mode
*
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment whereMode($value)
*
* @property array|null $carddata
* @property int|null $is_abo
* @property int|null $abo_interval
*
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingPayment whereAboInterval($value)
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingPayment whereCarddata($value)
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingPayment whereIsAbo($value)
*
* @property string|null $identifier
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|ShoppingPayment whereIdentifier($value)
*
* @mixin \Eloquent
*/
class ShoppingPayment extends Model
{
protected $table = 'shopping_payments';
protected $casts = [
'carddata' => 'array',
];
protected $fillable = [
'shopping_order_id',
'payment_provider',
'external_reference',
'clearingtype',
'wallettype',
'onlinebanktransfertype',
@ -73,47 +85,86 @@ class ShoppingPayment extends Model
'identifier',
];
public function shopping_order()
{
return $this->belongsTo('App\Models\ShoppingOrder','shopping_order_id');
return $this->belongsTo('App\Models\ShoppingOrder', 'shopping_order_id');
}
public function payment_transactions()
{
return $this->hasMany('App\Models\PaymentTransaction','shopping_payment_id');
return $this->hasMany('App\Models\PaymentTransaction', 'shopping_payment_id');
}
public function getPaymentType(){
if($this->clearingtype === 'wlt') {
public function getPaymentType()
{
if ($this->payment_provider === 'stripe') {
return $this->stripePaymentTypeLabel();
}
if ($this->clearingtype === 'wlt') {
if ($this->wallettype === 'PPE') {
return __('payment.paypal');
}
}
if($this->clearingtype === 'cc') {
return __('payment.credit_card');
if ($this->clearingtype === 'cc') {
return __('payment.credit_card');
}
if($this->clearingtype === 'vor') {
if ($this->clearingtype === 'vor') {
return __('payment.prepayment');
}
if($this->clearingtype === 'elv') {
return __('payment.sepa_direct_debit');
if ($this->clearingtype === 'elv') {
return __('payment.sepa_direct_debit');
}
if($this->clearingtype === 'sb') {
if ($this->clearingtype === 'sb') {
if ($this->onlinebanktransfertype === 'PNT') {
return __('payment.sofort_bank_transfer');
return __('payment.sofort_bank_transfer');
}
}
if($this->clearingtype === 'fnc') {
if ($this->clearingtype === 'fnc') {
if ($this->onlinebanktransfertype === 'MIV') {
return __('payment.purchase_on_account');
return __('payment.purchase_on_account');
}
}
return __('payment.unknown');
}
public function getPaymentAmount(){
return Util::formatNumber($this->amount/100)." ".$this->currency;
public function getPaymentAmount()
{
return Util::formatNumber($this->amount / 100).' '.$this->currency;
}
}
/**
* Anzeige-Label für Stripe-Zahlungen aus den gespeicherten
* payment_method_details (StripeWebhookHandler::storePaymentMethodDetails).
*/
private function stripePaymentTypeLabel(): string
{
$details = is_array($this->carddata) ? $this->carddata : [];
$type = $details['type'] ?? null;
$cardSuffix = ! empty($details['brand']) && ! empty($details['last4'])
? ' ('.ucfirst($details['brand']).' •••• '.$details['last4'].')'
: '';
if ($type === 'card') {
$wallet = $details['wallet'] ?? null;
if ($wallet === 'apple_pay') {
return 'Apple Pay'.$cardSuffix;
}
if ($wallet === 'google_pay') {
return 'Google Pay'.$cardSuffix;
}
return __('payment.credit_card').$cardSuffix;
}
return match ($type) {
'paypal' => __('payment.paypal'),
'klarna' => 'Klarna',
'amazon_pay' => 'Amazon Pay',
'sepa_debit' => __('payment.sepa_direct_debit').(! empty($details['last4']) ? ' (•••• '.$details['last4'].')' : ''),
default => __('payment.stripe_online_short'),
};
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
/**
* Empfangenes Stripe-Webhook-Event (Idempotenz-Store + Audit).
*
* @property int $id
* @property string $event_id
* @property string $type
* @property string|null $api_version
* @property array|null $payload
* @property Carbon|null $processed_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @mixin \Eloquent
*/
class StripeWebhookEvent extends Model
{
protected $table = 'stripe_webhook_events';
protected $casts = [
'payload' => 'array',
'processed_at' => 'datetime',
];
protected $fillable = [
'event_id',
'type',
'api_version',
'payload',
'processed_at',
];
}

View file

@ -3,6 +3,9 @@
namespace App\Providers;
use App\Services\Payment\Payone\PayonePaymentService;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Util;
use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
@ -39,10 +42,10 @@ class AppServiceProvider extends ServiceProvider
// }
// Temporär: Verwende immer das normale Verhalten
$view->with('user_shop', \App\Services\Util::getUserShop());
$view->with('user_shop', Util::getUserShop());
} catch (\Exception $e) {
// Fallback bei Fehlern
$view->with('user_shop', \App\Services\Util::getUserShop());
$view->with('user_shop', Util::getUserShop());
}
});
}
@ -61,8 +64,14 @@ class AppServiceProvider extends ServiceProvider
return new PayonePaymentService(config('payone.defaults'));
});
if ($this->app->environment() !== 'production' && class_exists(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class)) {
$this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
// Stripe: lazy Client-Hülle; Hard-Fail erst bei Nutzung ohne STRIPE_SECRET
// (Dark-Launch-fähig, siehe config/stripe.php + dev/2026-07-08-stripe/).
$this->app->singleton(StripeClientWrapper::class, function () {
return new StripeClientWrapper(config('stripe.secret'));
});
if ($this->app->environment() !== 'production' && class_exists(IdeHelperServiceProvider::class)) {
$this->app->register(IdeHelperServiceProvider::class);
}
}
}

View file

@ -2,13 +2,16 @@
namespace App\Repositories;
use App\Models\Customer;
use App\Models\Homeparty;
use App\Models\PaymentMethod;
use App\Models\ShoppingCollectOrder;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Services\Payment\PaymentProviderManager;
use App\Services\Util;
use App\User;
use Illuminate\Session\SessionManager;
use Illuminate\Support\Collection;
use Yard;
@ -246,9 +249,13 @@ class CheckoutRepository extends BaseRepository
$payment_methods['data'] = false;
}
if ($is_abo) {
$payment_methods['active'] = \App\Models\PaymentMethod::where('active', true)->where('is_abo', true)->get()->pluck('id', 'short')->toArray();
$payment_methods['active'] = PaymentMethod::where('active', true)->where('is_abo', true)->get()->pluck('id', 'short')->toArray();
} else {
$payment_methods['active'] = \App\Models\PaymentMethod::where('active', true)->get()->pluck('id', 'short')->toArray();
$payment_methods['active'] = PaymentMethod::where('active', true)->get()->pluck('id', 'short')->toArray();
}
if (! app(PaymentProviderManager::class)->isVorkasseVisible()) {
unset($payment_methods['active']['VOR']);
}
return $payment_methods;
@ -306,7 +313,7 @@ class CheckoutRepository extends BaseRepository
return $shopping_user;
}
public function shoppingUserByAuthCustomer(\App\Models\Customer $user)
public function shoppingUserByAuthCustomer(Customer $user)
{
// clone shopping user!
@ -325,7 +332,7 @@ class CheckoutRepository extends BaseRepository
return $shopping_user;
}
public function shoppingUserByAuthUser(\App\User $user, $is_from, $is_for)
public function shoppingUserByAuthUser(User $user, $is_from, $is_for)
{
$shopping_user = new ShoppingUser;

View file

@ -6,6 +6,7 @@ use App\Cron\UserMakeOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\Incentive\IncentiveTracker;
use App\Services\Payment\PaymentProviderManager;
use Illuminate\Support\Facades\DB;
class AboRetryPaymentService
@ -15,6 +16,13 @@ class AboRetryPaymentService
*/
public function retry(UserAbo $userAbo): array
{
if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) {
return [
'success' => false,
'message' => __('payment.abo_payments_frozen'),
];
}
if ($userAbo->status !== 3) {
return [
'success' => false,

View file

@ -0,0 +1,34 @@
<?php
namespace App\Services\Payment\Contracts;
use App\Enums\PaymentProvider;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\Payment\DTOs\AboChargeResult;
/**
* Gemeinsame Schnittstelle der Zahlungsanbieter (PAYONE, Stripe).
*
* Das Interface wächst phasenweise mit dem Stripe-Plan
* (dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md): In S3 kommt
* initiateCheckoutPayment(), in S5 setupPaymentMethodUpdate(), in S7 refund()
* hinzu — Methoden werden erst deklariert, wenn ein realer Aufrufer existiert.
*/
interface PaymentGatewayInterface
{
public function provider(): PaymentProvider;
/**
* Wiederkehrende Abo-Abbuchung mit dem beim Anbieter gespeicherten
* Zahlungsmittel (off-session). Betrag in Cent, brutto.
*/
public function chargeAbo(
UserAbo $userAbo,
ShoppingOrder $shoppingOrder,
ShoppingUser $shoppingUser,
int $amountCents,
string $currency,
): AboChargeResult;
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Services\Payment\DTOs;
use App\Enums\PaymentProvider;
use App\Models\ShoppingPayment;
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
/**
* Provider-neutrales Ergebnis einer wiederkehrenden Abo-Abbuchung.
* Vereinheitlicht PayonePaymentResult (und später das Stripe-Pendant),
* damit Cron/Retry nicht pro Anbieter verzweigen müssen.
*/
class AboChargeResult
{
/**
* @param array<string, mixed> $rawResponse
*/
public function __construct(
public readonly PaymentProvider $provider,
public readonly bool $success,
public readonly string $status,
public readonly array $rawResponse,
public readonly string $transactionId = '',
public readonly ?string $errorMessage = null,
public readonly ?string $customerMessage = null,
public readonly ?ShoppingPayment $shoppingPayment = null,
) {}
public static function fromPayoneResult(PayonePaymentResult $result): self
{
return new self(
provider: PaymentProvider::Payone,
success: $result->isApproved(),
status: $result->status,
rawResponse: $result->rawResponse,
transactionId: $result->txid,
errorMessage: $result->errorMessage,
customerMessage: $result->customerMessage,
shoppingPayment: $result->shoppingPayment,
);
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace App\Services\Payment;
use App\Enums\PaymentProvider;
use App\Models\Setting;
use App\Models\ShoppingPayment;
use App\Models\UserAbo;
use App\Services\Payment\Contracts\PaymentGatewayInterface;
/**
* Zentrale Anbieter-Auflösung (dev/2026-07-08-stripe/, Konzept §4):
*
* - Neue Zahlungsvorgänge folgen dem Einstellungs-Schalter (Setting-Slug
* `payment-active-provider`).
* - Bestehende Zahlungen/Abos folgen IMMER ihrer eigenen
* `payment_provider`-Spalte — der Schalter wirkt nie rückwirkend.
* - Unbekannte/fehlende Werte fallen sicher auf PAYONE zurück
* (Bestandsverhalten vor der Stripe-Einführung).
*/
class PaymentProviderManager
{
public const SETTING_SLUG = 'payment-active-provider';
public const SETTING_VORKASSE_HIDDEN = 'payment-vorkasse-hidden';
public const SETTING_ABO_PAYMENTS_FROZEN = 'abo-payments-frozen';
public const SETTING_CHECKOUT_MAINTENANCE = 'checkout-maintenance';
/**
* Anbieter für neue Checkouts, neue Abos und neue Zahlungslinks.
*/
public function providerForNewCheckout(): PaymentProvider
{
$configured = Setting::getContentBySlug(self::SETTING_SLUG);
return PaymentProvider::tryFrom(is_string($configured) ? $configured : '')
?? PaymentProvider::Payone;
}
/**
* Anbieter eines bestehenden Vorgangs — schalterunabhängig.
*/
public function providerFor(UserAbo|ShoppingPayment $subject): PaymentProvider
{
return PaymentProvider::tryFrom((string) $subject->payment_provider)
?? PaymentProvider::Payone;
}
/**
* Sichtbarkeit der Vorkasse im Checkout (N3, 2026-07-08): Vorkasse läuft
* über PAYONE und ist über eine eigene Einstellung ausblendbar —
* unabhängig vom Provider-Schalter. Fehlende Einstellung = sichtbar.
*/
public function isVorkasseVisible(): bool
{
return Setting::getContentBySlug(self::SETTING_VORKASSE_HIDDEN) !== true;
}
/**
* Not-Schalter (2026-07-08, PAYONE-Ausfall): friert ALLE Abo-Zahlungen ein —
* Abo-Checkout, Recovery, Retry und den Abbuchungs-Cron. Einmalzahlungen
* (Stripe) laufen weiter. Wird nach Phase S4 (Abos über Stripe) aufgehoben.
*/
public function aboPaymentsFrozen(): bool
{
return Setting::getContentBySlug(self::SETTING_ABO_PAYMENTS_FROZEN) === true;
}
/**
* Checkout-Wartungsmodus: Nicht-Admins sehen im Checkout nur eine
* "Technische Schwierigkeiten"-Meldung; Admins können normal bestellen.
* Zweck: Stripe im Live-Modus verproben, bevor der Checkout für alle
* wieder öffnet (Go-Live 2026-07-08).
*/
public function checkoutMaintenanceActive(): bool
{
return Setting::getContentBySlug(self::SETTING_CHECKOUT_MAINTENANCE) === true;
}
public function gateway(PaymentProvider $provider): PaymentGatewayInterface
{
return match ($provider) {
PaymentProvider::Payone => app(PayoneGateway::class),
PaymentProvider::Stripe => throw new \RuntimeException(
'Das Stripe-Gateway ist noch nicht implementiert (Plan Phase S2/S4, dev/2026-07-08-stripe/).'
),
};
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Services\Payment;
use App\Enums\PaymentProvider;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\Payment\Contracts\PaymentGatewayInterface;
use App\Services\Payment\DTOs\AboChargeResult;
use App\Services\Payment\Payone\PayonePaymentService;
/**
* Dünner Adapter, der den bestehenden PayonePaymentService hinter die
* provider-neutrale Gateway-Schnittstelle stellt. Enthält bewusst KEINE
* eigene Logik — PAYONE-Verhalten bleibt unverändert (Plan S1.3).
*/
class PayoneGateway implements PaymentGatewayInterface
{
public function __construct(private readonly PayonePaymentService $paymentService) {}
public function provider(): PaymentProvider
{
return PaymentProvider::Payone;
}
public function chargeAbo(
UserAbo $userAbo,
ShoppingOrder $shoppingOrder,
ShoppingUser $shoppingUser,
int $amountCents,
string $currency,
): AboChargeResult {
$result = $this->paymentService->chargeAbo(
$userAbo,
$shoppingOrder,
$shoppingUser,
$amountCents,
$currency,
);
return AboChargeResult::fromPayoneResult($result);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Services\Payment\Stripe\DTOs;
use App\Models\ShoppingPayment;
/**
* Ergebnis einer erstellten Stripe Checkout Session (Hosted Page):
* `url` ist das Redirect-Ziel für den Browser.
*/
class StripeCheckoutSessionResult
{
public function __construct(
public readonly string $sessionId,
public readonly string $url,
public readonly string $reference,
public readonly ShoppingPayment $shoppingPayment,
) {}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Services\Payment\Stripe\DTOs;
/**
* Ergebnis der Stripe-Webhook-Verarbeitung. Anders als beim PAYONE-IPN
* (immer "TSOK") steuert der HTTP-Status das Retry-Verhalten von Stripe:
* 2xx = erledigt, alles andere wird erneut zugestellt.
*/
class StripeWebhookResult
{
private function __construct(
public readonly int $statusCode,
public readonly string $state,
public readonly ?string $eventId = null,
public readonly ?string $eventType = null,
public readonly ?string $reason = null,
) {}
/** Signatur ungültig oder Payload kein gültiges Event — kein Retry sinnvoll. */
public static function invalidSignature(string $reason): self
{
return new self(statusCode: 400, state: 'invalid_signature', reason: $reason);
}
/** STRIPE_WEBHOOK_SECRET fehlt — 500, damit Stripe später erneut zustellt. */
public static function notConfigured(): self
{
return new self(statusCode: 500, state: 'not_configured', reason: 'STRIPE_WEBHOOK_SECRET missing');
}
/** Event-ID bereits verarbeitet — Retry wird bestätigt, aber ignoriert. */
public static function duplicate(string $eventId, string $eventType): self
{
return new self(statusCode: 200, state: 'duplicate', eventId: $eventId, eventType: $eventType);
}
/** Gültiges Event eines nicht behandelten Typs — protokolliert, bestätigt. */
public static function ignored(string $eventId, string $eventType): self
{
return new self(statusCode: 200, state: 'ignored', eventId: $eventId, eventType: $eventType);
}
/** Event verarbeitet (Transaktion protokolliert, Folgeaktionen angestoßen). */
public static function processed(string $eventId, string $eventType): self
{
return new self(statusCode: 200, state: 'processed', eventId: $eventId, eventType: $eventType);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Services\Payment\Stripe;
use Stripe\StripeClient;
/**
* Dünne, injizierbare Hülle um den Stripe-SDK-Client.
*
* Hier sitzt der bewusst aus config/stripe.php verlagerte Hard-Fail
* (Plan S0.4-Abweichung): Ein Deployment ohne Stripe-Keys bootet normal
* (Dark Launch); erst die tatsächliche Nutzung ohne STRIPE_SECRET
* schlägt hart und diagnostizierbar fehl.
*/
class StripeClientWrapper
{
private ?StripeClient $client = null;
public function __construct(private readonly ?string $secret) {}
public function client(): StripeClient
{
if ($this->client === null) {
if ($this->secret === null || trim($this->secret) === '') {
throw new \RuntimeException(
'STRIPE_SECRET ist nicht gesetzt. Hinterlege den Stripe Secret Key (sk_…) in der .env, bevor Stripe als Zahlungsanbieter genutzt wird.'
);
}
$this->client = new StripeClient($this->secret);
}
return $this->client;
}
public function isConfigured(): bool
{
return $this->secret !== null && trim($this->secret) !== '';
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Models\Customer;
use App\User;
/**
* Legt pro User/Kunde genau einen Stripe-Customer an und persistiert die
* ID (users.stripe_customer_id / customers.stripe_customer_id). Idempotent:
* eine vorhandene ID wird nie überschrieben.
*/
class StripeCustomerService
{
public function __construct(private readonly StripeClientWrapper $stripe) {}
public function customerIdFor(User|Customer $owner): string
{
if (! empty($owner->stripe_customer_id)) {
return $owner->stripe_customer_id;
}
$customer = $this->stripe->client()->customers->create([
'email' => $owner->email,
'name' => trim(($owner->firstname ?? '').' '.($owner->lastname ?? '')) ?: null,
'metadata' => [
'mivita_type' => $owner instanceof Customer ? 'customer' : 'user',
'mivita_id' => (string) $owner->getKey(),
],
]);
$owner->forceFill(['stripe_customer_id' => $customer->id])->save();
return $customer->id;
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Enums\PaymentProvider;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Services\CheckoutFunnelTracker;
use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult;
use App\Services\Util;
/**
* Stripe-Zahlungsvorgänge (Plan dev/2026-07-08-stripe/, Phase S3 ff.).
*
* Einmalzahlung im Checkout = Hosted Checkout Session (Redirect-Flow,
* Konzept §3.1): Zahlartenwahl und Dateneingabe passieren bei Stripe,
* "bezahlt" setzt ausschließlich der Webhook (StripeWebhookHandler).
*/
class StripePaymentService
{
public function __construct(private readonly StripeClientWrapper $stripe) {}
/**
* Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung.
* Die Success-/Cancel-URLs baut der HTTP-Aufrufer (Routen sind
* domain-abhängig und gehören nicht in die Service-Schicht).
*/
public function initiateCheckoutPayment(
ShoppingUser $shoppingUser,
ShoppingOrder $shoppingOrder,
int $amountCents,
string $currency,
string $reference,
string $successUrl,
string $cancelUrl,
): StripeCheckoutSessionResult {
$shoppingPayment = ShoppingPayment::create([
'shopping_order_id' => $shoppingOrder->id,
'payment_provider' => PaymentProvider::Stripe->value,
'clearingtype' => 'stripe',
'reference' => $reference,
'amount' => $amountCents,
'currency' => $currency,
'is_abo' => (bool) $shoppingOrder->is_abo,
'abo_interval' => $shoppingOrder->abo_interval ?? 0,
'identifier' => Util::getUserShopIdentifier(),
'mode' => $shoppingOrder->mode,
]);
CheckoutFunnelTracker::initiatedPayment(
shoppingUserId: $shoppingUser->id,
shoppingOrderId: $shoppingOrder->id,
shoppingPaymentId: $shoppingPayment->id,
consultantUserId: $shoppingUser->auth_user_id ?? null,
paymentMethod: 'stripe',
amountCents: $amountCents,
);
$metadata = [
'shopping_order_id' => (string) $shoppingOrder->id,
'shopping_payment_id' => (string) $shoppingPayment->id,
'reference' => $reference,
'mode' => (string) $shoppingOrder->mode,
];
$session = $this->stripe->client()->checkout->sessions->create([
'mode' => 'payment',
'client_reference_id' => $reference,
'customer_email' => $shoppingUser->billing_email ?: null,
'line_items' => [[
'quantity' => 1,
'price_data' => [
'currency' => strtolower($currency),
'unit_amount' => $amountCents,
'product_data' => [
'name' => __('payment.stripe_order_label', ['reference' => $reference]),
],
],
]],
'metadata' => $metadata,
'payment_intent_data' => ['metadata' => $metadata],
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
]);
$shoppingPayment->external_reference = $session->id;
$shoppingPayment->save();
StripeTransactionRecorder::recordFromStripeObject(
$shoppingPayment,
'checkout.session.created',
$session->toArray(),
);
return new StripeCheckoutSessionResult(
sessionId: $session->id,
url: (string) $session->url,
reference: $reference,
shoppingPayment: $shoppingPayment,
);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Enums\PaymentProvider;
use App\Models\PaymentTransaction;
use App\Models\ShoppingPayment;
/**
* Einzige Stelle, die PaymentTransaction-Zeilen für Stripe schreibt
* (Muster: PayoneTransactionRecorder). Redaction entfernt Secrets aus
* dem Payload, bevor er in transmitted_data landet.
*/
class StripeTransactionRecorder
{
/**
* Redaktierte Schlüssel: client_secret (PaymentIntent/SetupIntent) reicht
* aus, um eine Zahlung im Browser zu übernehmen und gehört nie in die DB.
*/
private const REDACTED_KEYS = ['client_secret'];
/**
* Protokolliert ein Stripe-Objekt (PaymentIntent, Charge, SetupIntent …)
* aus einem Webhook-Event oder einer API-Response zu einer Zahlung.
*
* @param array<string, mixed> $object Stripe-Objekt als Array (data.object)
*/
public static function recordFromStripeObject(
ShoppingPayment $payment,
string $requestType,
array $object,
): PaymentTransaction {
return PaymentTransaction::create([
'shopping_payment_id' => $payment->id,
'provider' => PaymentProvider::Stripe->value,
'request' => $requestType,
'txid' => (string) ($object['id'] ?? ''),
'userid' => 0,
'status' => (string) ($object['status'] ?? ''),
'txaction' => $requestType,
'transmitted_data' => self::redactPayload($object),
'mode' => array_key_exists('livemode', $object)
? ($object['livemode'] ? 'live' : 'test')
: $payment->mode,
'errorcode' => $object['last_payment_error']['code'] ?? null,
'errormessage' => $object['last_payment_error']['message'] ?? null,
]);
}
/**
* Entfernt sensible Felder rekursiv aus einem Stripe-Payload.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public static function redactPayload(array $payload): array
{
foreach ($payload as $key => $value) {
if (in_array($key, self::REDACTED_KEYS, true)) {
unset($payload[$key]);
continue;
}
if (is_array($value)) {
$payload[$key] = self::redactPayload($value);
}
}
return $payload;
}
}

View file

@ -0,0 +1,423 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\StripeWebhookEvent;
use App\Services\CheckoutFunnelTracker;
use App\Services\Payment;
use App\Services\Payment\Stripe\DTOs\StripeWebhookResult;
use App\Services\ShoppingUserService;
use App\Services\Util;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Event;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
/**
* Verarbeitet Stripe-Webhooks: Signaturprüfung, Idempotenz über die
* Event-ID (stripe_webhook_events), Transaktions-Protokollierung und der
* Paid-Pfad. Muster: PayoneWebhookHandler (txaction-Priorität, DB-Lock,
* zentrale Persistenz, Betragsabgleich).
*/
class StripeWebhookHandler
{
private const HANDLED_TYPES = [
'checkout.session.completed',
'checkout.session.expired',
'payment_intent.succeeded',
'payment_intent.processing',
'payment_intent.payment_failed',
'charge.succeeded',
'charge.refunded',
'charge.dispute.created',
'setup_intent.succeeded',
];
/**
* Gleiche txaction-Taxonomie wie PAYONE, damit alle nachgelagerten
* Ansichten/Services (Order-Status, Abo-Logik) unverändert funktionieren.
*
* @var array<string, int>
*/
private const TXACTION_PRIORITY = [
'appointed' => 1,
'pending' => 2,
'failed' => 3,
'paid' => 10,
];
public function handle(string $payload, ?string $signatureHeader): StripeWebhookResult
{
$secret = (string) config('stripe.webhook_secret');
if (trim($secret) === '') {
Log::channel('stripe')->error('Stripe-Webhook empfangen, aber STRIPE_WEBHOOK_SECRET ist nicht gesetzt.');
return StripeWebhookResult::notConfigured();
}
try {
$event = Webhook::constructEvent($payload, (string) $signatureHeader, $secret);
} catch (SignatureVerificationException|\UnexpectedValueException $e) {
Log::channel('stripe')->warning('Stripe-Webhook abgewiesen (Signatur/Payload): '.$e->getMessage());
return StripeWebhookResult::invalidSignature($e->getMessage());
}
/*
* Idempotenz: Ein Event gilt als Duplikat, wenn es bereits VOLLSTÄNDIG
* verarbeitet wurde (processed_at) oder sein Typ keine Verarbeitung braucht.
* Ein gespeichertes, aber unverarbeitetes Event (Crash mitten in der
* Verarbeitung → non-2xx → Stripe-Retry) wird erneut verarbeitet.
*/
$storedEvent = StripeWebhookEvent::where('event_id', $event->id)->first();
if ($storedEvent && ($storedEvent->processed_at !== null || ! in_array($event->type, self::HANDLED_TYPES, true))) {
Log::channel('stripe')->info("Stripe-Webhook-Duplikat ignoriert: {$event->id} ({$event->type})");
return StripeWebhookResult::duplicate($event->id, $event->type);
}
$object = $event->data->object->toArray();
if (! $storedEvent) {
try {
$storedEvent = StripeWebhookEvent::create([
'event_id' => $event->id,
'type' => $event->type,
'api_version' => $event->api_version,
'payload' => StripeTransactionRecorder::redactPayload($object),
]);
} catch (QueryException) {
// Unique-Verletzung: paralleler Retry hat das Event gerade angelegt.
return StripeWebhookResult::duplicate($event->id, $event->type);
}
}
if (! in_array($event->type, self::HANDLED_TYPES, true)) {
Log::channel('stripe')->info("Stripe-Webhook protokolliert, Typ nicht behandelt: {$event->id} ({$event->type})");
return StripeWebhookResult::ignored($event->id, $event->type);
}
$this->dispatchEvent($event, $object);
$storedEvent->update(['processed_at' => now()]);
return StripeWebhookResult::processed($event->id, $event->type);
}
/**
* @param array<string, mixed> $object
*/
private function dispatchEvent(Event $event, array $object): void
{
$payment = $this->resolveShoppingPayment($object);
if (! $payment) {
Log::channel('stripe')->warning(sprintf(
'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)',
$event->id,
$event->type,
$object['id'] ?? '?',
));
return;
}
StripeTransactionRecorder::recordFromStripeObject($payment, $event->type, $object);
$this->rememberPaymentIntentReference($payment, $object);
match ($event->type) {
'checkout.session.completed' => $this->handleSessionCompleted($payment, $object),
'checkout.session.expired' => $this->handleSessionExpired($payment),
'payment_intent.succeeded' => $this->markPaid($payment, $object),
'payment_intent.processing' => $this->markPending($payment),
'payment_intent.payment_failed' => $this->markFailed($payment),
'charge.succeeded' => $this->storePaymentMethodDetails($payment, $object),
// charge.refunded / dispute / setup_intent → Business-Logik folgt in S5–S7.
default => Log::channel('stripe')->info("Stripe-Webhook protokolliert (Verarbeitung folgt in späterer Phase): {$event->type}"),
};
}
/**
* Merkt sich die PaymentIntent-ID an der Zahlung, sobald sie bekannt ist.
* Charge-Events tragen keine Metadata — sie werden über
* external_reference = PaymentIntent-ID zugeordnet.
*
* @param array<string, mixed> $object
*/
private function rememberPaymentIntentReference(ShoppingPayment $payment, array $object): void
{
$paymentIntentId = match ($object['object'] ?? null) {
'payment_intent' => $object['id'] ?? null,
default => $object['payment_intent'] ?? null,
};
if ($paymentIntentId && $payment->external_reference !== $paymentIntentId) {
$payment->external_reference = $paymentIntentId;
$payment->save();
}
}
/**
* Session abgeschlossen: bei `paid` sofort der Paid-Pfad; bei asynchronen
* Methoden (SEPA & Co., payment_status=unpaid) ist die Bestellung platziert
* und wartet auf payment_intent.succeeded → Status "appointed" wie PAYONE.
*
* @param array<string, mixed> $object
*/
private function handleSessionCompleted(ShoppingPayment $payment, array $object): void
{
if (($object['payment_status'] ?? null) === 'paid') {
$this->markPaid($payment, $object);
return;
}
$order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'appointed');
$order->setUserHistoryValue(['status' => 7]);
ShoppingUserService::snycOrdersByShoppingOrder($order);
Util::setInstanceStatusByPayment($payment, 4);
CheckoutFunnelTracker::confirmedPayment(
shoppingPaymentId: $payment->id,
txaction: 'appointed',
metadata: ['provider' => 'stripe', 'session' => $object['id'] ?? null],
);
Payment::paymentStatusSendMail($order, $payment, [
'txaction' => 'appointed',
'mode' => $payment->mode,
'send_link' => false,
]);
}
/**
* @param array<string, mixed> $object
*/
private function markPaid(ShoppingPayment $payment, array $object): void
{
if (! $this->amountMatches($payment, $object)) {
return;
}
$order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'paid');
$sendLink = false;
$sendMail = true;
DB::beginTransaction();
try {
$lockedOrder = ShoppingOrder::where('id', $order->id)->lockForUpdate()->first();
if (! $lockedOrder->paid) {
$sendLink = Payment::paymentStatusPaidAction($lockedOrder, true, $payment);
DB::commit();
} else {
$sendMail = false;
DB::commit();
}
} catch (\Exception $e) {
DB::rollBack();
Log::channel('stripe')->error('Stripe-Paid-Pfad fehlgeschlagen (Stripe stellt erneut zu): '.$e->getMessage(), [
'shopping_order_id' => $order->id,
'shopping_payment_id' => $payment->id,
]);
throw $e;
}
CheckoutFunnelTracker::confirmedPayment(
shoppingPaymentId: $payment->id,
txaction: 'paid',
metadata: ['provider' => 'stripe', 'object' => $object['id'] ?? null, 'mode' => $payment->mode],
);
if ($sendMail) {
Payment::paymentStatusSendMail($order, $payment, [
'txaction' => 'paid',
'mode' => $payment->mode,
'send_link' => $sendLink,
]);
}
$this->backfillPaymentMethodDetails($payment);
}
/**
* Speichert womit gezahlt wurde (Kartenmarke/letzte 4/Wallet, PayPal, Klarna …)
* aus den payment_method_details des Charge-Objekts in carddata — für die
* Anzeige in Bestellungen/Mails (ShoppingPayment::getPaymentType()).
*
* @param array<string, mixed> $chargeObject
*/
private function storePaymentMethodDetails(ShoppingPayment $payment, array $chargeObject): void
{
$summary = self::paymentMethodSummary($chargeObject);
if ($summary !== null && empty($payment->carddata)) {
$payment->carddata = $summary;
$payment->save();
}
}
/**
* Webhook-Reihenfolge ist nicht garantiert: Kam charge.succeeded VOR dem
* PaymentIntent-Event, war es noch keiner Zahlung zuordenbar — es liegt
* aber im Event-Store. Von dort nachziehen, ganz ohne API-Call.
*/
private function backfillPaymentMethodDetails(ShoppingPayment $payment): void
{
if (! empty($payment->carddata) || empty($payment->external_reference)) {
return;
}
$chargeEvent = StripeWebhookEvent::where('type', 'charge.succeeded')
->where('payload->payment_intent', $payment->external_reference)
->latest('id')
->first();
if ($chargeEvent) {
$this->storePaymentMethodDetails($payment, $chargeEvent->payload ?? []);
}
}
/**
* @param array<string, mixed> $chargeObject
* @return array<string, mixed>|null
*/
public static function paymentMethodSummary(array $chargeObject): ?array
{
$details = $chargeObject['payment_method_details'] ?? null;
$type = $details['type'] ?? null;
if (! $type) {
return null;
}
$summary = ['type' => $type];
if ($type === 'card') {
$summary['brand'] = $details['card']['brand'] ?? null;
$summary['last4'] = $details['card']['last4'] ?? null;
$summary['wallet'] = $details['card']['wallet']['type'] ?? null;
$summary['exp'] = isset($details['card']['exp_month'], $details['card']['exp_year'])
? sprintf('%02d/%d', $details['card']['exp_month'], $details['card']['exp_year'])
: null;
}
if ($type === 'sepa_debit') {
$summary['last4'] = $details['sepa_debit']['last4'] ?? null;
}
return $summary;
}
/**
* Asynchrone Zahlung unterwegs (SEPA & Co.). Kein Mailversand — die
* Bestellbestätigung ging bereits bei checkout.session.completed raus.
*/
private function markPending(ShoppingPayment $payment): void
{
$this->upgradeTxaction($payment->shopping_order, $payment, 'pending');
}
/**
* Fehlgeschlagener Zahlungsversuch. Bewusst KEINE Mail: Auf der Hosted
* Checkout Session kann der Kunde direkt erneut zahlen — jeder Fehlversuch
* feuert dieses Event, eine Mail pro Versuch wäre Spam. Endgültiges
* Scheitern zeigt sich als checkout.session.expired bzw. Cancel-Return.
*/
private function markFailed(ShoppingPayment $payment): void
{
$order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'failed');
$order->setUserHistoryValue(['status' => 6]);
Util::setInstanceStatusByPayment($payment, 5);
}
/**
* Session nach 24 h abgelaufen, ohne Zahlung: Vorgang als abgebrochen
* markieren (wie Cancel-Return), Bestellstatus bleibt unangetastet.
*/
private function handleSessionExpired(ShoppingPayment $payment): void
{
if ($payment->shopping_order->paid) {
return;
}
$payment->status = 'expired';
$payment->save();
$payment->shopping_order->setUserHistoryValue(['status' => 22]);
Util::setInstanceStatusByPayment($payment, 6);
}
/**
* Betragsabgleich (Pendant zum PAYONE-Guard 2006): Der bestätigte Betrag
* muss dem initiierten entsprechen, sonst keine Paid-Verarbeitung.
*
* @param array<string, mixed> $object
*/
private function amountMatches(ShoppingPayment $payment, array $object): bool
{
$confirmed = $object['amount_total'] ?? $object['amount_received'] ?? $object['amount'] ?? null;
if ($confirmed === null || (int) $confirmed !== (int) $payment->amount) {
Log::channel('stripe')->error('Stripe-Webhook Betragsabweichung — Paid-Verarbeitung verweigert.', [
'shopping_payment_id' => $payment->id,
'expected_cents' => (int) $payment->amount,
'confirmed_cents' => $confirmed,
'object' => $object['id'] ?? null,
]);
return false;
}
return true;
}
private function upgradeTxaction(ShoppingOrder $order, ShoppingPayment $payment, string $txaction): void
{
$currentPriority = self::TXACTION_PRIORITY[$order->txaction] ?? 0;
$newPriority = self::TXACTION_PRIORITY[$txaction] ?? 0;
if ($newPriority > $currentPriority) {
$order->txaction = $txaction;
$order->save();
$payment->txaction = $txaction;
$payment->save();
}
}
/**
* Ordnet ein Stripe-Objekt einer Zahlung zu: über die von uns gesetzte
* Metadata (shopping_payment_id, Session UND PaymentIntent tragen sie)
* oder über external_reference (Session-/PaymentIntent-ID).
*
* @param array<string, mixed> $object
*/
private function resolveShoppingPayment(array $object): ?ShoppingPayment
{
$metadataId = $object['metadata']['shopping_payment_id'] ?? null;
if ($metadataId && ($payment = ShoppingPayment::find((int) $metadataId))) {
return $payment;
}
$references = array_filter([
$object['id'] ?? null,
$object['payment_intent'] ?? null,
]);
foreach ($references as $reference) {
$payment = ShoppingPayment::where('external_reference', $reference)->first();
if ($payment) {
return $payment;
}
}
return null;
}
}

View file

@ -1,9 +0,0 @@
<?php return array (
'App\\Providers\\EventServiceProvider' =>
array (
'App\\Events\\Event' =>
array (
0 => 'App\\Listeners\\EventListener',
),
),
);

View file

@ -123,6 +123,13 @@
0 => 'Laravel\\Sail\\SailServiceProvider',
),
),
'laravel/sentinel' =>
array (
'providers' =>
array (
0 => 'Laravel\\Sentinel\\SentinelServiceProvider',
),
),
'laravel/tinker' =>
array (
'providers' =>

View file

@ -38,28 +38,29 @@
34 => 'Laravel\\Passport\\PassportServiceProvider',
35 => 'Laravel\\Roster\\RosterServiceProvider',
36 => 'Laravel\\Sail\\SailServiceProvider',
37 => 'Laravel\\Tinker\\TinkerServiceProvider',
38 => 'Laravel\\Ui\\UiServiceProvider',
39 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
40 => 'Carbon\\Laravel\\ServiceProvider',
41 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
42 => 'Termwind\\Laravel\\TermwindServiceProvider',
43 => 'Pest\\Laravel\\PestServiceProvider',
44 => 'Spatie\\Html\\HtmlServiceProvider',
45 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
46 => 'Yajra\\DataTables\\DataTablesServiceProvider',
47 => 'Laravel\\Tinker\\TinkerServiceProvider',
48 => 'Acme\\Dhl\\DhlServiceProvider',
49 => 'App\\Providers\\AppServiceProvider',
50 => 'App\\Providers\\AuthServiceProvider',
51 => 'App\\Providers\\EventServiceProvider',
52 => 'App\\Providers\\HorizonServiceProvider',
53 => 'App\\Providers\\RouteServiceProvider',
54 => 'Jenssegers\\Date\\DateServiceProvider',
55 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
56 => 'Yajra\\DataTables\\DataTablesServiceProvider',
57 => 'App\\Providers\\YardServiceProvider',
58 => 'Alban\\LaravelCollectiveSpatieHtmlParser\\ServiceProvider',
37 => 'Laravel\\Sentinel\\SentinelServiceProvider',
38 => 'Laravel\\Tinker\\TinkerServiceProvider',
39 => 'Laravel\\Ui\\UiServiceProvider',
40 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
41 => 'Carbon\\Laravel\\ServiceProvider',
42 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
43 => 'Termwind\\Laravel\\TermwindServiceProvider',
44 => 'Pest\\Laravel\\PestServiceProvider',
45 => 'Spatie\\Html\\HtmlServiceProvider',
46 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
47 => 'Yajra\\DataTables\\DataTablesServiceProvider',
48 => 'Laravel\\Tinker\\TinkerServiceProvider',
49 => 'Acme\\Dhl\\DhlServiceProvider',
50 => 'App\\Providers\\AppServiceProvider',
51 => 'App\\Providers\\AuthServiceProvider',
52 => 'App\\Providers\\EventServiceProvider',
53 => 'App\\Providers\\HorizonServiceProvider',
54 => 'App\\Providers\\RouteServiceProvider',
55 => 'Jenssegers\\Date\\DateServiceProvider',
56 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
57 => 'Yajra\\DataTables\\DataTablesServiceProvider',
58 => 'App\\Providers\\YardServiceProvider',
59 => 'Alban\\LaravelCollectiveSpatieHtmlParser\\ServiceProvider',
),
'eager' =>
array (
@ -85,26 +86,27 @@
19 => 'Laravel\\Mcp\\Server\\McpServiceProvider',
20 => 'Laravel\\Passport\\PassportServiceProvider',
21 => 'Laravel\\Roster\\RosterServiceProvider',
22 => 'Laravel\\Ui\\UiServiceProvider',
23 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
24 => 'Carbon\\Laravel\\ServiceProvider',
25 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
26 => 'Termwind\\Laravel\\TermwindServiceProvider',
27 => 'Pest\\Laravel\\PestServiceProvider',
28 => 'Spatie\\Html\\HtmlServiceProvider',
29 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
30 => 'Yajra\\DataTables\\DataTablesServiceProvider',
31 => 'Acme\\Dhl\\DhlServiceProvider',
32 => 'App\\Providers\\AppServiceProvider',
33 => 'App\\Providers\\AuthServiceProvider',
34 => 'App\\Providers\\EventServiceProvider',
35 => 'App\\Providers\\HorizonServiceProvider',
36 => 'App\\Providers\\RouteServiceProvider',
37 => 'Jenssegers\\Date\\DateServiceProvider',
38 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
39 => 'Yajra\\DataTables\\DataTablesServiceProvider',
40 => 'App\\Providers\\YardServiceProvider',
41 => 'Alban\\LaravelCollectiveSpatieHtmlParser\\ServiceProvider',
22 => 'Laravel\\Sentinel\\SentinelServiceProvider',
23 => 'Laravel\\Ui\\UiServiceProvider',
24 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
25 => 'Carbon\\Laravel\\ServiceProvider',
26 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
27 => 'Termwind\\Laravel\\TermwindServiceProvider',
28 => 'Pest\\Laravel\\PestServiceProvider',
29 => 'Spatie\\Html\\HtmlServiceProvider',
30 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
31 => 'Yajra\\DataTables\\DataTablesServiceProvider',
32 => 'Acme\\Dhl\\DhlServiceProvider',
33 => 'App\\Providers\\AppServiceProvider',
34 => 'App\\Providers\\AuthServiceProvider',
35 => 'App\\Providers\\EventServiceProvider',
36 => 'App\\Providers\\HorizonServiceProvider',
37 => 'App\\Providers\\RouteServiceProvider',
38 => 'Jenssegers\\Date\\DateServiceProvider',
39 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
40 => 'Yajra\\DataTables\\DataTablesServiceProvider',
41 => 'App\\Providers\\YardServiceProvider',
42 => 'Alban\\LaravelCollectiveSpatieHtmlParser\\ServiceProvider',
),
'deferred' =>
array (

View file

@ -26,6 +26,7 @@
"setasign/fpdf": "^1.8.6",
"setasign/fpdi": "^2.6",
"spatie/laravel-html": "^3.7",
"stripe/stripe-php": "^20.3",
"wearepixel/laravel-google-shopping-feed": "^4.0",
"yajra/laravel-datatables-oracle": "^11.0"
},

1573
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -58,6 +58,14 @@ return [
'level' => env('LOG_LEVEL', 'warning'),
'days' => 14,
],
// 'info' statt 'warning': Während der Stripe-Einführung sollen auch
// verarbeitete Webhooks nachvollziehbar sein (Rollout-Beobachtung).
'stripe' => [
'driver' => 'daily',
'path' => storage_path('logs/stripe.log'),
'level' => env('LOG_LEVEL', 'info'),
'days' => 14,
],
'cleanup' => [
'driver' => 'daily',
'path' => storage_path('logs/cleanup.log'),

22
config/stripe.php Normal file
View file

@ -0,0 +1,22 @@
<?php
return [
/*
* Stripe-Zugangsdaten. Bewusst KEIN Hard-Fail beim Boot (anders als
* config/payone.php): Stripe ist erst aktiv, wenn der Anbieter-Schalter
* umgelegt wird. Der StripeClientWrapper schlägt beim ersten Zugriff hart
* fehl, wenn 'secret' fehlt — so bleibt ein Deployment ohne Stripe-Keys
* verhaltensneutral (Dark Launch, Plan Phase S0/S8).
*/
'secret' => env('STRIPE_SECRET'),
// Publishable Key — beim Hosted-Checkout-Redirect serverseitig nicht nötig,
// aber für Dashboards/Debug-Views und spätere eingebettete Flows hinterlegt.
'key' => env('STRIPE_KEY'),
// Signing-Secret des Webhook-Endpoints (whsec_…), Pflicht für Live-Webhooks.
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
// 'test' | 'live' — muss zum verwendeten Key-Paar passen (analog payone mode).
'mode' => env('STRIPE_MODE', 'test'),
];

View file

@ -0,0 +1,97 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Stripe-Einführung Phase S1 (dev/2026-07-08-stripe/):
* Jede Zahlung / jedes Abo trägt seinen Zahlungsanbieter fest, damit der
* Einstellungs-Schalter nur NEUE Vorgänge betrifft. Default 'payone'
* backfillt zugleich alle Bestandszeilen. PAYONE-Spalten bleiben unverändert.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('user_abos', function (Blueprint $table) {
if (! Schema::hasColumn('user_abos', 'payment_provider')) {
$table->string('payment_provider', 20)->default('payone')->index()->after('payone_userid');
}
if (! Schema::hasColumn('user_abos', 'stripe_customer_id')) {
$table->string('stripe_customer_id')->nullable()->after('carddata');
}
if (! Schema::hasColumn('user_abos', 'stripe_payment_method_id')) {
$table->string('stripe_payment_method_id')->nullable()->after('stripe_customer_id');
}
});
Schema::table('shopping_payments', function (Blueprint $table) {
if (! Schema::hasColumn('shopping_payments', 'payment_provider')) {
$table->string('payment_provider', 20)->default('payone')->index()->after('clearingtype');
}
if (! Schema::hasColumn('shopping_payments', 'external_reference')) {
$table->string('external_reference')->nullable()->after('reference');
}
});
Schema::table('payment_transactions', function (Blueprint $table) {
if (! Schema::hasColumn('payment_transactions', 'provider')) {
$table->string('provider', 20)->default('payone')->after('shopping_payment_id');
}
});
Schema::table('users', function (Blueprint $table) {
if (! Schema::hasColumn('users', 'stripe_customer_id')) {
$table->string('stripe_customer_id')->nullable();
}
});
if (Schema::hasTable('customers')) {
Schema::table('customers', function (Blueprint $table) {
if (! Schema::hasColumn('customers', 'stripe_customer_id')) {
$table->string('stripe_customer_id')->nullable();
}
});
}
}
public function down(): void
{
Schema::table('user_abos', function (Blueprint $table) {
foreach (['payment_provider', 'stripe_customer_id', 'stripe_payment_method_id'] as $column) {
if (Schema::hasColumn('user_abos', $column)) {
$table->dropColumn($column);
}
}
});
Schema::table('shopping_payments', function (Blueprint $table) {
foreach (['payment_provider', 'external_reference'] as $column) {
if (Schema::hasColumn('shopping_payments', $column)) {
$table->dropColumn($column);
}
}
});
Schema::table('payment_transactions', function (Blueprint $table) {
if (Schema::hasColumn('payment_transactions', 'provider')) {
$table->dropColumn('provider');
}
});
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'stripe_customer_id')) {
$table->dropColumn('stripe_customer_id');
}
});
if (Schema::hasTable('customers')) {
Schema::table('customers', function (Blueprint $table) {
if (Schema::hasColumn('customers', 'stripe_customer_id')) {
$table->dropColumn('stripe_customer_id');
}
});
}
}
};

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Stripe-Einführung Phase S2 (dev/2026-07-08-stripe/):
* Idempotenz-Store + Audit-Log für Stripe-Webhook-Events. Die eindeutige
* event_id verhindert Doppelverarbeitung bei Stripe-Retries (Muster:
* Duplikat-Erkennung des PAYONE-Webhooks).
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('stripe_webhook_events')) {
return;
}
Schema::create('stripe_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('event_id')->unique();
$table->string('type')->index();
$table->string('api_version')->nullable();
$table->json('payload')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('stripe_webhook_events');
}
};

View file

@ -0,0 +1,133 @@
# Ist-Analyse: Zahlungsarchitektur vor der Stripe-Einführung
> **Stand:** 2026-07-08 · Grundlage für `ENTWICKLUNGSPLAN-STRIPE.md`
> Vorarbeiten: `dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md` (Phase R abgeschlossen),
> `dev/2026-06-25-kreditkarte-update/` (CC-Update-Feature)
## 1. Zahlungsfluss heute (PAYONE)
```
Checkout (Web/CheckoutController) ──┐
Abo-Cron (app/Cron/UserMakeOrder) ──┤
Membership (PaymentHelper) ──┤
▼
Pay\PayoneController (@deprecated Fassade)
│
▼
app/Services/Payment/Payone/PayonePaymentService
├── PayoneRequestBuilder + PaymentMethodResolver
├── PayoneTransactionRecorder (einzige Stelle für PaymentTransaction)
└── App\Services\Payone (HTTP-Client → api.pay1.de)
│
▼
Api\PayoneController → PayoneWebhookHandler (IPN/TSOK)
│
▼
Payment::paymentStatusPaidAction()
(Bestellung bezahlt, Abo anlegen, Mails, Incentive)
```
**Wichtig:** Durch Phase R (2026-06-10) ist die PAYONE-Logik bereits in einer testbaren
Service-Schicht unter `app/Services/Payment/Payone/` gekapselt. Das ist die ideale
Ausgangslage, um Stripe **parallel** daneben zu stellen, statt PAYONE anzufassen.
## 2. Kernerkenntnis: Das System ist seine eigene Billing-Engine
Die wiederkehrenden Abo-Abbuchungen werden **nicht vom Zahlungsanbieter** getriggert,
sondern vom eigenen Cron:
- `user:make_abo_order` (04:00 Uhr) → `app/Cron/UserMakeOrder.php`
- Der Cron berechnet den Betrag pro Ausführung selbst (Produkte, Länderpreise, Steuern)
und ruft dann `PayonePaymentService::chargeAbo()` mit gespeichertem Token
(`payone_userid` + `pseudocardpan`) auf.
- Rechnungen, Lieferscheine, Provisionsdaten entstehen im System (dompdf, DATEV-Export).
**Konsequenz für Stripe:** Wir brauchen von Stripe nur „belaste gespeichertes
Zahlungsmittel mit Betrag X" (PaymentIntents, off-session) – **nicht** Stripe Billing /
Stripe Subscriptions / Stripe Invoicing. Details und Begründung im Konzept
(`STRIPE-KONZEPT.md`, Abschnitt 2).
## 3. Datenmodell (Zahlung & Abo)
| Tabelle / Model | Relevante Felder | Rolle |
|---|---|---|
| `user_abos` / `UserAbo` | `payone_userid`, `clearingtype` (`cc`/`wlt`), `wallettype` (`PPE`), `carddata` (JSON: `pseudocardpan`, `cardexpiredate`), `card_update_pending_at` | Zahlungs-Token für wiederkehrende Abbuchung |
| `shopping_payments` / `ShoppingPayment` | `clearingtype`, `carddata`, `reference`, `is_abo`, `mode` (`test`/`live`) | Jede Zahlungstransaktion |
| `payment_transactions` / `PaymentTransaction` | `txid`, `userid`, `txaction`, `transmitted_data` | Anbieter-Callback-Protokoll (heute PAYONE) |
| `user_abo_orders` / `UserAboOrder` | `paid`, `status` | Ausführungs-Historie pro Abo |
| `payment_methods` / `PaymentMethod` | `short` (`CC`,`PP`,`SEPA`,`SB`,`VOR`,`FNC`), `active`, `is_abo` | Steuert, welche Zahlarten im Checkout angeboten werden |
| `user_abo_card_changes`, `abo_card_update_links` | – | CC-Update-Feature (2026-06-25) |
| `order_withdrawals` / `OrderWithdrawal` | – | Widerruf/Erstattung (neu, Juli 2026) |
**Bekannte Lücke (bereits im Alt-Plan notiert):** Es gibt **kein `payment_provider`-Feld** –
der Anbieter wird implizit über `clearingtype`/`wallettype` abgeleitet. Für Stripe zwingend
zu ergänzen (Stakeholder-Entscheid O1 im Alt-Plan: `payone_userid` bleibt bestehen,
Provider-Felder kommen **zusätzlich**).
## 4. Zahlarten-Auswahl im Checkout
- `CheckoutRepository::getPaymentsMethods()` ([CheckoutRepository.php:237](../../app/Repositories/CheckoutRepository.php))
liest aktive Methoden aus `payment_methods` (Flags `active`, `is_abo`), zusätzlich
User-spezifische Freischaltungen (`$user->payment_methods`).
- `isPaymentsMethodsActive()` mappt PAYONE-Codes → Kürzel:
`wlt#PPE`→PP, `cc`→CC, `sb#PNT`→SB, `elv`→SEPA, `vor`→VOR, `fnc#MIV`→FNC.
- `CheckoutController::processPaymentMethod()` (Z. ~262 ff.) verzweigt je Methode
(CC → creditcardcheck/pseudocardpan, ELV → Bankdaten-Check) und ruft
`setPrePayment()`.
**Konsequenz:** Der Checkout kennt bereits ein Schalt-Modell pro Zahlart. Der
Stripe/PAYONE-Schalter dockt hier an (Provider-Zuordnung je Zahlart bzw. global).
## 5. Netto/Brutto & Steuern je Land
- Preise pro Land in `country_prices` (`c_price`, `c_tax`, `c_currency`) –
Models `Country`, `CountryPrice`, `ShippingCountry`.
- Der Warenkorb (`packages/digital-bird/shoppingcart`) arbeitet mit `taxRate` und
`taxIncluded` pro Position: Bruttopreise inkl. länderspezifischer MwSt.
- Netto-Rückrechnung wo nötig, z. B. `AboOrderCart` (Z. ~351):
`lineNet = lineGross / ((100 + taxRate) / 100)`.
- Rechnungen (dompdf) und DATEV-Export (`DatevExportService`) nutzen diese Werte.
**Konsequenz:** Die Steuerlogik ist **anbieterunabhängig** und bleibt vollständig im
System. Stripe erhält nur den finalen **Bruttobetrag in Cent** + Währung. Stripe Tax wird
**nicht** benötigt (siehe Konzept, Abschnitt 5).
## 6. Wiederverwendbare Bausteine
| Baustein | Datei | Nutzen für Stripe |
|---|---|---|
| Service-Schicht-Muster | `app/Services/Payment/Payone/*` | 1:1-Vorlage für `app/Services/Payment/Stripe/*` |
| Webhook-Muster (Idempotenz, Duplikate, txaction-Priorität) | `PayoneWebhookHandler` | Vorlage für `StripeWebhookHandler` |
| Transaction-Recorder mit PCI-Redaction | `PayoneTransactionRecorder` | Vorlage für `StripeTransactionRecorder` |
| Paid-Pfad (Bestellung → Abo → Mails) | `App\Services\Payment::paymentStatusPaidAction()` | Provider-neutral wiederverwenden |
| Abo-Recovery-Checkout (Status 3 → Nachzahlung) | `AboRecoveryCheckoutService` | Für fehlgeschlagene Stripe-Abbuchungen (inkl. 3DS-Fall) |
| CC-Update-Flow (Inhaber-only, Link, kein Charge) | `AboCardUpdateController`, `AboCardUpdateLink`, `AboPaymentMethodService` | Muster für „Zahlungsmittel bei Stripe hinterlegen/erneuern" via SetupIntent |
| Widerruf/Erstattung | `OrderWithdrawalController`, `OrderWithdrawal` | Andockpunkt für Stripe-Refunds |
| Payment-Dashboard & Incidents | `Admin/PaymentDashboardController`, `PaymentIncident` (kennt bereits mehrere Provider) | Stripe als Provider ergänzen |
| Test-/Live-Trennung | `shopping_payments.mode`, `shopping_orders.mode` | Stripe Test-/Live-Keys analog |
| Secrets-Muster | `config/payone.php` (Hard-Fail bei fehlendem Key) | Gleiches Muster für Stripe-Keys |
## 7. Randbedingungen & Stolperfallen
1. **PAYONE-Tokens sind nicht portabel.** `pseudocardpan`/`payone_userid` können nicht zu
Stripe migriert werden. Bestehende Abos laufen daher auf PAYONE weiter, bis der Kunde
sein Zahlungsmittel neu hinterlegt (Wiederverwendung des Card-Update-Link-Flows).
Eine PCI-konforme Kartendaten-Migration zwischen Prozessoren ist theoretisch über die
Anbieter möglich, aber aufwendig → als Option in Phase S8 dokumentiert.
2. **PayPal-direkt-Projekt ist blockiert** (Merchant-Freigabe fehlt, siehe Alt-Plan).
Stripe unterstützt PayPal als Zahlungsart in der EU – das könnte den blockierten
Strang ersetzen (offene Frage im Plan).
3. **`Pay\PayoneController` ist @deprecated Fassade** – Checkout-HTTP-Flow
(`setPrePayment`/`ResponseData`) steckt noch dort. Die Stripe-Einführung erzwingt an
dieser Stelle die im Alt-Plan aufgeschobene Extraktion (Provider-Verzweigung im
Checkout).
4. **Multi-Tenant-Routing:** Neue Web-Routen (Checkout-Return, Webhook) müssen in die
richtigen `routes/domains/*.php` bzw. `routes/api.php`; Webhooks laufen global unter
`api.<domain>`.
5. **Tests:** Pest auf SQLite in-memory; `route()` ist in Tests nicht domain-aware
(siehe Memory/Projektkonvention) – Stripe-HTTP-Aufrufe im Test immer über
injizierbaren Client mocken (gleiches Muster wie `?Client $client` bei PAYONE).
6. **Sofortüberweisung (`sb#PNT`):** Stripe hat „Sofort" abgekündigt; Ersatz wäre Klarna
oder Wegfall → offene Frage an Stakeholder.
7. **Composer-Abhängigkeit:** `stripe/stripe-php` ist noch nicht installiert; Ergänzung
braucht Freigabe (Projektregel: keine Dependency-Änderungen ohne Zustimmung).

View file

@ -0,0 +1,639 @@
# Entwicklungsplan: Stripe als Zahlungsanbieter (PAYONE bleibt als Option)
> **Status:** In Arbeit (S0 ✓, S1 ✓, S2 ✓, S3 ✓ inkl. Browser-E2E — **Notfall-Go-Live wegen PAYONE-Ausfall, Abos eingefroren, s. §7.1**; als Nächstes S4) · **Erstellt:** 2026-07-08 · **Zuletzt aktualisiert:** 2026-07-08
> **Test-Server:** Schalter `payment-active-provider` steht auf **stripe** (seit 2026-07-08, für den Manualtest); zurückstellen: `Setting::setContentBySlug('payment-active-provider', 'payone', 'text')`.
> **Begleitdokumente:** `ANALYSE-IST-ZUSTAND.md` (Codebase-Analyse) · `STRIPE-KONZEPT.md` (Architektur & API-Mapping)
> **Vorarbeiten:** `dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md` (Phase R ✅), `dev/2026-06-25-kreditkarte-update/`
## Dokumentations-Hinweis (verbindlich)
Dieser Plan wird **phasenweise** abgearbeitet. Für **jede Phase** gilt:
- Der **Umsetzungsstatus** wird nach Abschluss auf `ERLEDIGT` gesetzt und sauber
dokumentiert (Datum, betroffene Dateien, kurze Begründung).
- Abweichungen, Detail-Entscheidungen und Verworfenes werden dokumentiert.
- Jede Änderung wird **programmatisch getestet** (Pest), bevor die Phase als erledigt gilt.
- Nach Code-Änderungen läuft `./vendor/bin/pint`.
---
## 1. Ziel
Stripe wird als **primäre Zahlungsabwicklung** eingeführt und ersetzt PAYONE im
Tagesgeschäft. Dabei gilt:
| # | Anforderung |
|---|-------------|
| A | Stripe übernimmt **nur die Zahlungsabwicklung**. Rechnungen, Lieferscheine, Provisionen, DATEV etc. bleiben im System. |
| B | **PAYONE wird nicht entfernt.** Es bleibt vollständig funktionsfähig, sauber vom Stripe-Code separiert. |
| C | **Schalter in den Einstellungen** wählt den aktiven Anbieter für **neue** Zahlungsvorgänge (`payone` ↔ `stripe`). Bestandszahlungen/-abos behalten ihren Anbieter. |
| D | Scope-Reihenfolge: **1. Einmalzahlungen**, **2. Abos** (Erstzahlung + wiederkehrende Abbuchung). |
| E | **Netto/Brutto je Land** wird weiterhin systemseitig berechnet; Stripe erhält nur den Bruttobetrag in Cent. |
**Kern-Architekturentscheidung** (Begründung in `STRIPE-KONZEPT.md` §2):
Stripe wird als reiner **Charge-/Token-Provider** genutzt — **PaymentIntents +
gespeicherte PaymentMethods**, *kein* Stripe Billing / Invoicing / Tax. Der Abo-Zyklus
(Cron `user:make_abo_order`, Betragsberechnung, Rechnungen) bleibt unverändert im System.
## 2. Zielarchitektur (Kurzfassung)
```
Checkout / Abo-Cron / Recovery / Widerruf
│
▼
PaymentProviderManager ← Einstellungs-Schalter (neu) + payment_provider-Spalte (Bestand)
│
├── PayoneGateway → Adapter um bestehenden PayonePaymentService (unverändert)
└── StripeGateway → app/Services/Payment/Stripe/* (neu)
├── StripeClientWrapper (injizierbar, testbar)
├── StripePaymentService (PaymentIntents, SetupIntents, Refunds)
├── StripeWebhookHandler (+ DTOs)
└── StripeTransactionRecorder
```
Neue DB-Felder: `payment_provider` auf `user_abos`/`shopping_payments`/`payment_transactions`,
`stripe_customer_id`/`stripe_payment_method_id` auf `user_abos`, `stripe_customer_id` am
User/Customer, `external_reference` auf `shopping_payments`. Details: Konzept §4.3.
## 3. Phasen-Übersicht
| Phase | Titel | Aufwand (Schätzung) | Priorität | Abhängigkeiten |
|-------|-------|---------------------|-----------|----------------|
| **S0** | Grundlagen: Account, SDK, Config, Entscheidungen | 1–2 PT | Blocker | – (O-Fragen beantwortet 2026-07-08) |
| **S1** | Provider-Abstraktion, Datenmodell & Einstellungs-Schalter | 4–6 PT | P0 | S0 |
| **S2** | Stripe-Basis: Client, Recorder, Webhook-Endpoint | 4–6 PT | P0 | S1 |
| **S3** | Einmalzahlung im Checkout über Stripe | 6–10 PT | **P1** | S2 |
| **S4** | Abos: Erstzahlung + wiederkehrende Abbuchung | 8–12 PT | **P1** | S3 |
| **S5** | Zahlungsmittel-Update über Stripe (SetupIntent) | 3–5 PT | P2 | S4 |
| **S6** | Fehlerfälle, 3DS-Recovery & Incident-Anbindung | 4–6 PT | P1–P2 | S4 |
| **S7** | Admin & Betrieb: Schalter-UI, Refunds, Monitoring | 4–6 PT | P2 | S3 (Refunds), S1 (UI) |
| **S8** | Rollout & optionale Abo-Migration PAYONE → Stripe | 3–5 PT | P3 | S4–S7 |
*PT = Personentage (1 Entwickler). Einmalzahlung produktiv nach S3+S7-Teilen; Abos nach S4+S6.*
---
## 4. Phase S0 – Grundlagen · Status: **WEITGEHEND ERLEDIGT** (2026-07-08)
### Ziel
Technische und organisatorische Voraussetzungen schaffen; keine Feature-Logik.
### Aufgaben
| # | Aufgabe | Status |
|---|---------|--------|
| S0.1 | Offene Fragen O1–O9 klären (Konzept §10); N2/N3 ebenfalls beantwortet. | ✅ 2026-07-08 · **N1 offen** (Klarna/Amazon Pay off_session gegen aktuelle Stripe-Doku prüfen, spätestens zu S4-Beginn) |
| S0.2 | Stripe-Account (vorhanden, Stakeholder verwaltet): Test-Keys beschaffen, Webhook-Endpoint (Test) konfigurieren, gewünschte Zahlarten im Dashboard aktivieren (Karte, PayPal, Apple/Google Pay, Amazon Pay, Klarna, …). | ✅ 2026-07-08 — Test-Keys in `.env`, per Balance-API verifiziert (`livemode: false`). Webhook auf dem Test-Server via **Stripe CLI** (`stripe listen --forward-to … --skip-verify`, CLI-Signing-Secret als `STRIPE_WEBHOOK_SECRET`); Dashboard-Endpoint + eigenes `whsec` erst für Produktion nötig (Anleitung §6). Zahlarten-Aktivierung im Dashboard: vor S3-Manualtest prüfen. |
| S0.3 | Composer: `stripe/stripe-php` installieren (Freigabe O6). | ✅ 2026-07-08 — `stripe/stripe-php` **^20.3** in `composer.json`/`composer.lock` |
| S0.4 | `config/stripe.php` anlegen (`secret`, `key`, `webhook_secret`, `mode`); `.env.example`, `.env`, `phpunit.xml` ergänzt. | ✅ 2026-07-08 — **Abweichung s. u.** |
| S0.5 | `PaymentProvider`-Enum anlegen. | ✅ 2026-07-08 — `app/Enums/PaymentProvider.php` (`Payone`/`Stripe`, string-backed) |
### Abweichungen / Entscheidungen
- **Kein Boot-Hard-Fail in `config/stripe.php`** (Plan sah das Muster aus
`config/payone.php` vor): PAYONE ist produktiv im Einsatz — dort ist ein fehlender Key
ein Fehler. Stripe dagegen wird per **Dark Launch** deployt (Rollout-Schritt 2, §12);
ein Boot-Hard-Fail würde jedes Deployment ohne gesetzte Stripe-Keys sofort brechen.
Stattdessen: Config liest `env()` tolerant, der **`StripeClientWrapper` (Phase S2)
schlägt beim ersten Zugriff hart fehl**, wenn `stripe.secret` fehlt. Begründung als
Kommentar in `config/stripe.php` dokumentiert.
- Enum liegt unter `app/Enums/` (neu, via `make:enum`) — im Plan §15 so vorgesehen.
### Tests & Verifikation (2026-07-08)
- `tests/Feature/StripeFoundationTest.php` *(neu)*: Config-Werte aus Env, kein
Boot-Fail bei fehlenden Secrets, Enum-Werte/`from`/`tryFrom`, SDK-Klasse vorhanden —
**4 Tests, 11 Assertions, grün**.
- PAYONE-Regressionssuite (PayoneTest, PaymentMethodResolverTest,
PayonePaymentServiceTest, PayoneWebhookHandlerTest, PayoneTransactionRecorderTest,
PayoneTransportExceptionHandlingTest): **45 passed, 1 skipped (optionaler
Netzwerktest), 209 Assertions** — unverändert grün.
- `./vendor/bin/pint --dirty` grün.
### Akzeptanzkriterien
- [x] SDK installiert, `composer.lock` aktualisiert, Test-Suite weiterhin grün.
- [x] Keys nur via `.env`; kein Secret im Repo. (Hard-Fail bewusst in S2 verschoben, s. Abweichung.)
- [x] Alle O-Fragen beantwortet oder explizit vertagt (N1 mit Vermerk offen).
- [ ] Test-Keys + Webhook-Secret in `.env` eingetragen (S0.2, Stakeholder).
---
## 5. Phase S1 – Provider-Abstraktion, Datenmodell & Schalter · Status: **ERLEDIGT** (2026-07-08)
### Ziel
Das System kennt zwei Anbieter. Jede Zahlung/jedes Abo trägt seinen Anbieter fest;
ein Einstellungs-Schalter bestimmt den Anbieter für **neue** Vorgänge. PAYONE-Verhalten
ändert sich **nicht** (Default `payone`, Schalter steht auf `payone`).
### Aufgaben
| # | Aufgabe | Status |
|---|---------|--------|
| S1.1 | Migrationen: `payment_provider` auf `user_abos`, `shopping_payments`, `payment_transactions` (Default `'payone'`); `stripe_customer_id` + `stripe_payment_method_id` auf `user_abos`; `external_reference` auf `shopping_payments`; `stripe_customer_id` an `users`/`customers`. | ✅ `2026_07_08_140000_add_payment_provider_columns.php` (idempotent mit `hasColumn`-Guards, Haus-Stil) |
| S1.2 | `PaymentGatewayInterface` + DTOs definieren. | ✅ `Contracts/PaymentGatewayInterface.php`, `DTOs/AboChargeResult.php` — **Abweichung s. u.** |
| S1.3 | `PayoneGateway` als Adapter um `PayonePaymentService` — **kein Umbau am PAYONE-Code**. | ✅ `app/Services/Payment/PayoneGateway.php`; `PayonePaymentService` unverändert |
| S1.4 | `PaymentProviderManager`: `providerForNewCheckout()`, `providerFor()`, `gateway()`. | ✅ `app/Services/Payment/PaymentProviderManager.php` |
| S1.5 | Einstellungs-Schalter als persistente Admin-Einstellung. | ✅ Bestehender Settings-Mechanismus (`Setting::get/setContentBySlug`), Slug **`payment-active-provider`** (Typ `text`); fehlender/ungültiger Wert → Fallback `payone`. Admin-UI + `SysLog` folgen in **S7.1**. |
| S1.6 | Abo-Cron/Retry lesen den Provider **des Abos** (nicht den Schalter). | ✅ Guard in `UserMakeOrder::makePayment()`: Nicht-PAYONE-Provider → Log + `RuntimeException` **vor** jedem PAYONE-Aufruf. Deckt auch `AboRetryPaymentService` ab (delegiert an `UserMakeOrder`). Echte Stripe-Verzweigung kommt in S4.4. |
### Abweichungen / Entscheidungen
- **Interface wächst phasenweise:** Deklariert sind nur `provider()` und `chargeAbo()`
(einziger realer Aufrufer heute). `initiateCheckoutPayment()` (S3),
`setupPaymentMethodUpdate()` (S5) und `refund()` (S7) werden erst mit ihrem Aufrufer
ergänzt — kein toter Vertrag, kein Zwang zu Dummy-Implementierungen im Adapter.
Im Interface-Docblock dokumentiert.
- **`payment_provider` bleibt String-Spalte ohne Enum-Cast** am Model — der
`PaymentProviderManager` normalisiert via `PaymentProvider::tryFrom()` mit
PAYONE-Fallback. So können keine bestehenden Blade-/Array-Zugriffe brechen.
- **Schalter-Ablage:** bestehender `settings`-Mechanismus statt eigener Tabelle
(gleiches Muster wie `abo-lock-days-change`, `abo-card-update-link-ttl-hours`).
- `PaymentProviderManager::gateway(Stripe)` wirft bewusst `RuntimeException`, bis das
Stripe-Gateway existiert (S2/S4) — Fail-fast statt stillem Fallback auf PAYONE.
- Models: `payment_provider`/`stripe_*`/`external_reference`/`provider` in die
`$fillable` von `UserAbo`, `ShoppingPayment`, `PaymentTransaction` aufgenommen.
### Tests & Verifikation (2026-07-08)
- `tests/Feature/PaymentProviderManagerTest.php` *(neu)*: **12 Tests, 25 Assertions** —
Schalter-Default/`stripe`/ungültiger Wert, Backfill-Default `payone`, Spalte schlägt
Schalter, Gateway-Auflösung (PayoneGateway / Stripe-Exception),
`AboChargeResult`-Mapping (APPROVED/ERROR), Gateway-Delegation (gemockter
`PayonePaymentService`), Cron-Guard blockt Stripe-Abo.
- Regression: PAYONE-Suite + `StripeFoundationTest` (**49 passed, 1 skipped, 220
Assertions**) und Abo-Retry/Recovery-Suite (`AboPendingInitialPaymentTest`,
`AboRecoveryAdvanceTest`, `AboUserRetryTest`, `AdminAboRetryPaymentTest`,
`RetryFailedPaypalAbosTest`: **21 passed, 56 Assertions**) — unverändert grün.
- `./vendor/bin/pint --dirty` grün (nur Format-Fixes in `ShoppingPayment`).
### Akzeptanzkriterien
- [x] Schalter existiert, Default `payone`, Systemverhalten identisch zu vorher.
- [x] Kein PAYONE-Service wurde inhaltlich geändert (nur Adapter obendrauf; einzige Bestandsänderung: defensiver Provider-Guard im Cron + `$fillable`-Erweiterungen).
---
## 6. Phase S2 – Stripe-Basisdienste · Status: **ERLEDIGT** (2026-07-08)
### Ziel
Stripe technisch angebunden: Client, Transaktions-Protokollierung, Webhook-Empfang —
noch ohne Checkout-UI.
### Aufgaben
| # | Aufgabe | Status |
|---|---------|--------|
| S2.1 | `StripeClientWrapper` — injizierbare Hülle um `\Stripe\StripeClient`; Singleton im `AppServiceProvider`. | ✅ Lazy-Init; **Hard-Fail bei Nutzung ohne `STRIPE_SECRET`** (der aus S0 verschobene Fail-fast); `isConfigured()` für Dark-Launch-Checks |
| S2.2 | `StripeTransactionRecorder` — einzige Stelle für `PaymentTransaction` mit `provider='stripe'`; Redaction. | ✅ `recordFromStripeObject()`; `redactPayload()` entfernt `client_secret` **rekursiv** (Stripe-Payloads enthalten kein PAN — sensibel ist das Intent-Secret) |
| S2.3 | `StripeCustomerService` — Customer idempotent anlegen, ID persistieren. | ✅ `customerIdFor(User\|Customer)`; vorhandene ID wird nie überschrieben; Metadata `mivita_type`/`mivita_id` |
| S2.4 | Webhook: Route, schlanker Controller, Handler mit Signaturprüfung + Idempotenz. | ✅ `POST /stripe/webhook` (`routes/api.php`, läuft unter `api.<domain>`); `Api\StripeWebhookController` (Muster R5); `StripeWebhookHandler` + `StripeWebhookResult`-DTO |
| S2.5 | Logging-Kanal `stripe.log`. | ✅ `config/logging.php`, daily, Level `info` (bewusst gesprächiger als `payone` — Rollout-Beobachtung) |
### Abweichungen / Entscheidungen
- **Idempotenz über eigene Tabelle `stripe_webhook_events`** (`event_id` unique, Payload
redaktiert als Audit, `processed_at`): Migration
`2026_07_08_150000_create_stripe_webhook_events_table.php` + Model
`StripeWebhookEvent`. Race-sicher: Exists-Check + Unique-Constraint-Catch
(paralleler Stripe-Retry → `duplicate`).
- **HTTP-Statuscodes statt PAYONE-„immer TSOK":** ungültige Signatur → `400` (kein
Retry sinnvoll); fehlendes `STRIPE_WEBHOOK_SECRET` → `500` (Stripe stellt später
erneut zu); Duplikat/unbehandelter Typ/verarbeitet → `200`. Im DTO dokumentiert.
- **Zahlungs-Zuordnung** im Handler: `metadata.shopping_payment_id` (wird ab S3 gesetzt)
oder `external_reference` (PaymentIntent-/Session-ID); ohne Treffer wird das Event
trotzdem gespeichert und bestätigt (Business-Verdrahtung folgt in S3/S4 an
`dispatchEvent()`).
- Behandelte Event-Typen als `HANDLED_TYPES`-Konstante gemäß Konzept §7 (inkl.
`checkout.session.completed`/`expired`); unbekannte Typen werden protokolliert + 200.
### Tests & Verifikation (2026-07-08)
- `tests/Feature/StripeWebhookHandlerTest.php` *(neu)*: **11 Tests, 35 Assertions** —
ungültige Signatur (400, nichts gespeichert), fehlendes Secret (500), Duplikat
(200, eine Zeile), unbehandelter Typ (gespeichert + 200), Transaktion via Metadata
(provider `stripe`, `client_secret` redaktiert), Auflösung via `external_reference`
+ `processed_at`, rekursive Redaction, Wrapper-Hard-Fail ohne Secret, Lazy-Client,
CustomerService (vorhandene ID ohne API-Call; Create persistiert ID — Stripe-Client
als Partial-Mock auf `request`, kein echter HTTP-Call).
- Handler wird wie beim PAYONE-Pendant **direkt** getestet (HTTP-Routen sind in Pest
nicht domain-aware); Route via `php artisan route:list` verifiziert:
`POST api.mivita.test/stripe/webhook → api.stripe_webhook`.
- Regression: `PaymentProviderManagerTest` + `StripeFoundationTest` +
`PayoneWebhookHandlerTest` + `PayoneTransactionRecorderTest`: **35 passed,
131 Assertions**. `pint --dirty` grün.
### Akzeptanzkriterien
- [x] `PaymentTransaction`-Schreibzugriffe für Stripe existieren **nur** im Recorder.
- [x] Webhook idempotent, signaturgeprüft, protokolliert (per Test nachgewiesen).
- [x] Ende-zu-Ende gegen das **laufende System** verifiziert *(2026-07-08)*: signierter
`payment_intent.succeeded`-Request an `api.mivita.test/stripe/webhook` (echte
Signaturprüfung mit dem `whsec` aus `.env`) → `200 processed`, Event-Zeile mit
`processed_at`; erneuter identischer Request → `200 duplicate` (weiterhin 1 Zeile);
manipulierte Signatur → `400 invalid_signature`. Migrationen auf der
Dev-MySQL ausgeführt (`add_payment_provider_columns`, `stripe_webhook_events`).
Test-Keys per Balance-API bestätigt (`livemode: false`).
- [x] **Echte Stripe-Events über `stripe listen` verifiziert** *(2026-07-08)*: Testmodus-
PaymentIntent (API, `pm_card_visa`) → Listener → Endpoint. `payment_intent.succeeded`
signaturgeprüft verarbeitet (`processed_at` gesetzt); Begleit-Events
(`payment_intent.created`, `charge.succeeded`, `charge.updated`) korrekt als
unbehandelte Typen gespeichert + bestätigt. Stolpersteine dabei (für die Nachwelt):
1. Listener lief zunächst mit dem API-Key eines **anderen** Stripe-Accounts,
2. `api.mivita.test` fehlte in der `/etc/hosts` des Hosts,
3. `whsec` muss vom **mivita-Listener** stammen (CLI-Secret ist account-gebunden).
### Webhook-Einrichtung (S0.2)
**Test-Server (aktuell im Einsatz):** Stripe CLI auf dem Host — kein Dashboard-Endpoint nötig:
```bash
stripe listen --forward-to https://api.mivita.test/stripe/webhook --skip-verify
```
Das von `stripe listen` ausgegebene CLI-Signing-Secret (`whsec_…`) als
`STRIPE_WEBHOOK_SECRET` in die `.env`. `--skip-verify`
wegen des selbstsignierten `.test`-Zertifikats. Events auslösen:
`stripe trigger payment_intent.succeeded` (bzw. echte Testmodus-Checkouts ab S3 —
`stripe listen` leitet standardmäßig alle Events weiter, unser Handler bestätigt
unbehandelte Typen mit `200 ignored`).
> ⚠️ **Mehrere Apps / Accounts auf demselben Test-Server:** Das CLI-Signing-Secret
> gehört zum **Stripe-Account des Listeners**, nicht zur Forward-URL. Ein für eine
> andere App (anderer Account, z. B. pressekonto) laufender Listener empfängt keine
> mivita-Events, und sein `whsec` passt nicht zu unserer Signaturprüfung. Für mivita
> daher einen **eigenen Listener** parallel starten — einfachster Weg ohne Re-Login:
>
> ```bash
> stripe listen --api-key sk_test_…(mivita) \
> --forward-to https://api.mivita.test/stripe/webhook --skip-verify
> ```
>
> Das dabei ausgegebene `whsec_…` in die mivita-`.env`, dann `php artisan config:clear`.
> Testen: `stripe trigger payment_intent.succeeded --api-key sk_test_…(mivita)`.
> (Stand 2026-07-08: das zuerst eingetragene `whsec` stammte vom pressekonto-Listener
> und muss durch das mivita-Listener-Secret ersetzt werden.)
**Produktion (später, vor Rollout-Schritt 2):**
1. Dashboard → Developers → Webhooks → **Add endpoint**.
2. **Endpoint-URL:** `https://api.mivita.care/stripe/webhook`.
3. **Events auswählen:** `checkout.session.completed`, `checkout.session.expired`,
`payment_intent.succeeded`, `payment_intent.processing`,
`payment_intent.payment_failed`, `charge.succeeded` *(Zahlart-Anzeige)*,
`charge.refunded`, `charge.dispute.created`, `setup_intent.succeeded`.
4. Das **Endpoint-Signing-Secret** (`whsec_…`, ein anderes als das CLI-Secret!) als
`STRIPE_WEBHOOK_SECRET` in die Produktions-`.env`.
---
## 7. Phase S3 – Einmalzahlung im Checkout ⭐ · Status: **UMGESETZT** (2026-07-08, Browser-E2E offen)
### Ziel
Steht der Schalter auf `stripe`, laufen **neue Einmalzahlungen** (Shop, Zahlungslinks)
über die **Stripe-Hosted-Checkout-Seite** (Redirect-Flow, Entscheidung 2026-07-08,
Konzept §3.1): Zahlartenwahl und Dateneingabe finden komplett bei Stripe statt.
PAYONE-Checkout bleibt bei Schalter `payone` unverändert.
### Ablauf (Soll)
```
mivita-Checkout (Adresse, Warenkorb, AGB — unverändert)
→ „Jetzt zahlen" → CheckoutController
→ PaymentProviderManager::providerForNewCheckout()
├── payone → bestehender Pfad inkl. Zahlartenwahl (unverändert)
└── stripe → StripeGateway::initiateCheckoutPayment()
├── ShoppingPayment anlegen (provider=stripe, mode, Betrag brutto)
├── Checkout Session erstellen (mode=payment, amount=Cent, EUR,
│ customer, metadata: order/payment/mode, success_url, cancel_url;
│ Zahlarten dynamisch via Stripe-Dashboard)
└── 303 Redirect → checkout.stripe.com
Kunde wählt bei Stripe die Zahlart (Karte/PayPal/Apple Pay/Google Pay/Amazon Pay/Klarna/…),
zahlt (inkl. 3DS) und wird zurückgeleitet:
├── success_url → Bestätigungsseite (Status nur zur UX-Anzeige)
└── cancel_url → zurück in den mivita-Checkout (Warenkorb bleibt erhalten)
Webhook checkout.session.completed / payment_intent.succeeded
→ paymentStatusPaidAction-Pfad (Order bezahlt, Mails, Incentive) — einzige Wahrheit
```
### Aufgaben
| # | Aufgabe |
|---|---------|
| S3.1 | Checkout-Verzweigung nach Provider in `CheckoutController`; bei `stripe` **entfällt die Zahlartenwahl** — nach Checkout-Prüfung direkt zu Stripe (N2, entschieden 2026-07-08). Ausnahme: ist der Vorkasse-Schalter (S3.8) aktiv bzw. FNC freigeschaltet, wird eine reduzierte Wahl „Online bezahlen / Vorkasse (/ Rechnung)" angezeigt. Dabei die im Alt-Plan aufgeschobene Extraktion des Checkout-HTTP-Flows aus `Pay\PayoneController` **nur soweit nötig** vorziehen (keine PAYONE-Verhaltensänderung). |
| S3.2 | `StripePaymentService::initiateCheckoutPayment()`: Checkout Session (`mode=payment`) erstellen. Betrag: bestehende Cart-Brutto-Summe, Rundung auf Cent an **einer** Stelle; Währung fest EUR; `line_items` aus dem Warenkorb (Anzeige auf der Stripe-Seite), Metadata mit Order-/Payment-Referenz; Session-ID in `shopping_payments.external_reference`. |
| S3.3 | Redirect-Handling: 303 auf die Session-URL; `success_url`/`cancel_url` domain-korrekt generieren (Multi-Tenant beachten, `routes/domains/…`). `cancel_url` führt zurück in den Checkout, Warenkorb/ShoppingInstance bleiben nutzbar. |
| S3.4 | Success-/Cancel-Controller: liest Session-/PaymentIntent-Status nur für UX (`paid`/`processing`/abgebrochen), verändert **keine** Bezahlt-Flags. Abgelaufene Sessions (`checkout.session.expired`) räumen den offenen Vorgang auf. |
| S3.5 | Webhook-Paid-Pfad: `checkout.session.completed` + `payment_intent.succeeded` → gemeinsame, provider-neutrale Paid-Verarbeitung (Wiederverwendung `Payment::paymentStatusPaidAction()`-Logik; ggf. Extraktion in provider-neutralen Service). Preis-/Betragsabgleich wie beim PAYONE-Webhook (Guard 2005-Äquivalent). |
| S3.6 | `processing`-Status (SEPA & Co.) im Bestell-/Zahlungsstatus sichtbar machen (Muster „Zahlung in Verarbeitung" aus Alt-Plan §5.2.1). |
| S3.7 | Zahlungslinks (`OrderPaymentService`, `ShoppingInstance`) provider-fähig machen: neuer Link erzeugt bei Schalter `stripe` direkt eine Checkout Session (der Link **ist** dann faktisch die Stripe-Seite mit unserem Vor-/Nachlauf). |
| S3.8 | **Vorkasse-Sichtbarkeits-Schalter** (N3, entschieden 2026-07-08): eigene Einstellung, um Vorkasse (`vor`, läuft über PAYONE) unabhängig vom Provider-Schalter aus-/einzublenden — Vorkasse bleibt technisch erhalten. Interne Rechnung (FNC) bleibt wie heute über `payment_methods.active` steuerbar (aktuell praktisch überall deaktiviert). |
### Tests
- Feature: Checkout-Verzweigung je Schalterstellung; Session-Parameter (Betrag = Cart-Brutto in Cent, EUR, Metadata, success/cancel-URLs, Abo-Einschränkung greift hier nicht) gegen gemockten Client.
- Webhook-Paid: Order wird bezahlt, Mails/Incentive-Pfad läuft, Duplikat-Event ändert nichts, Betrags-Mismatch wird abgewiesen + Incident; `session.expired` räumt auf.
- Cancel-Pfad: Rücksprung lässt Warenkorb intakt, kein Zahlungs-/Order-Nebeneffekt.
- Netto/Brutto: Testfälle mit mind. 2 Ländern/Steuersätzen (Cart-Brutto ↔ Rechnungs-Netto unverändert zu PAYONE-Pfad).
- Regression: kompletter PAYONE-Checkout-Testbestand grün bei Schalter `payone`.
### Umsetzungsstatus & Abweichungen (2026-07-08)
| # | Status |
|---|--------|
| S3.1 | ✅ `CheckoutController`: `stripeCheckoutActive()` (Schalter, **Abos/Recovery bleiben bis S4 auf PAYONE**); View zeigt bei Stripe eine einzige „Online-Zahlung"-Option + VOR/FNC (sofern aktiv); `checkoutFinal` validiert `payment_method=stripe` gegen den Schalter (sonst 404). Keine PAYONE-Extraktion nötig — Verzweigung liegt **vor** dem PAYONE-Pfad. |
| S3.2 | ✅ `StripePaymentService::initiateCheckoutPayment()`. **Abweichung:** ein einzelnes `line_item` mit dem Gesamt-Bruttobetrag statt Warenkorb-Positionen — eliminiert jedes Rundungs-/Summenabweichungsrisiko zwischen Cart und Stripe; Positionsdetails stehen auf der Systemrechnung. Metadata (`shopping_order_id`, `shopping_payment_id`, `reference`, `mode`) auf Session **und** PaymentIntent. |
| S3.3 | ✅ Redirect via `redirect()->away($session->url)`; Session-ID → `external_reference`; Initiations-Transaktion `checkout.session.created` protokolliert (Bestätigungsseite hat dadurch immer eine `pay_trans`). |
| S3.4 | ✅ **Abweichung:** kein neuer Return-Controller — `success_url`/`cancel_url` nutzen die bestehenden `checkout.transaction_status`-Routen (Status-UX, Cart-Destroy, Fehlerseiten inkl. Cancel-Rücksprung wie bei PAYONE-Redirects). `checkout.session.expired` räumt serverseitig auf (Status `expired`, Instanz `link_canceled`). |
| S3.5 | ✅ `StripeWebhookHandler::dispatchEvent()` → gleiche txaction-Taxonomie wie PAYONE (`appointed`/`pending`/`failed`/`paid` + Prioritäten): `session.completed(paid)` & `payment_intent.succeeded` → Paid-Pfad (`Payment::paymentStatusPaidAction`, DB-Lock, Betrags-Guard analog 2006, Mail einmalig); `session.completed(unpaid)` → `appointed` + Bestellbestätigung; Reprocessing: gespeichertes, aber unverarbeitetes Event (Crash → Stripe-Retry) wird erneut verarbeitet statt als Duplikat verworfen. |
| S3.6 | ✅ `payment_intent.processing` → txaction `pending` — vorhandene Statusanzeigen greifen durch die gemeinsame Taxonomie automatisch. **Keine Mail** (Bestellbestätigung ging bei `appointed` raus). |
| S3.7 | ✅ implizit: Zahlungslinks führen in denselben Checkout → Verzweigung greift automatisch. Beim Browser-Manualtest mit verifizieren. |
| S3.8 | ✅ Vorkasse-Schalter: Setting **`payment-vorkasse-hidden`** (bool; fehlend/false = sichtbar) → `PaymentProviderManager::isVorkasseVisible()`, Filter in `CheckoutRepository::getPaymentsMethods()` (wirkt provider-unabhängig, auch im PAYONE-Betrieb). Admin-UI folgt in S7.1. |
**Ergänzung Zahlart-Anzeige (2026-07-08):** `charge.succeeded` wird jetzt verarbeitet —
die `payment_method_details` (Typ, Kartenmarke, letzte 4, Apple/Google-Pay-Wallet, Ablauf)
werden in `shopping_payments.carddata` gespeichert; `ShoppingPayment::getPaymentType()`
zeigt daraus z. B. „Apple Pay (Amex •••• 2003)", „Kreditkarte (Visa •••• 4242)", „PayPal",
„Klarna". Reihenfolge-sicher ohne API-Call: Kommt das Charge-Event vor dem
PaymentIntent-Event, zieht `markPaid()` die Details aus dem Event-Store nach
(`backfillPaymentMethodDetails`). Die PaymentIntent-ID wird dabei als
`external_reference` an der Zahlung gemerkt. Checkout-UI-Feinschliff nach
Stakeholder-Feedback: Radio heißt „Online-Zahlung", Stripe-Logo
(`payments-assets/stripe.png`), darunter Zahlarten-Zeile + Hinweistext.
**Weitere Entscheidung:** `payment_intent.payment_failed` verschickt bewusst **keine** Mail —
auf der Hosted Session kann der Kunde direkt erneut zahlen; jeder Fehlversuch feuert das
Event (Mail pro Versuch wäre Spam). `ShoppingPayment::getPaymentType()` zeigt für
Stripe-Zahlungen „Online-Zahlung (Stripe)"; neue Lang-Keys `payment.stripe_*` (de/en, du-Form).
**Tests (2026-07-08):** `tests/Feature/StripeCheckoutTest.php` *(neu)*: **9 Tests** —
Session-Parameter/Payment/Initiations-Transaktion (gemockter Client), Paid-Pfad + genau
eine Mail, kein Doppel-Mail bei zweitem Success-Event, Betrags-Mismatch verweigert,
`appointed` + Bestellbestätigung (async), `failed` ohne Mail, `expired`, Crash-Retry-
Reprocessing, Vorkasse-Schalter. Gesamt Stripe-Suite **36 Tests, 102 Assertions** grün;
Payment-Regression (PAYONE + Abo-Retry/Recovery, 66 Tests) grün; Blade kompiliert; Pint grün.
*(Hinweis: die volle Suite hat eine vorbestehende `makeUser()`-Redeclare-Kollision,
unabhängig von S3.)*
### Akzeptanzkriterien
- [ ] **Browser-E2E (Manualtest, offen):** Checkout → Redirect zu Stripe → Zahlung (inkl. 3DS-Testkarte) → Rücksprung → Webhook → Order bezahlt → Rechnung aus dem System.
- [ ] Apple Pay/Google Pay erscheinen auf fähigen Geräten ohne Zusatzcode; Zahlarten lassen sich im Stripe-Dashboard zu-/abschalten, ohne Deploy.
- [ ] Abbruch bei Stripe führt sauber in den Checkout zurück (Manualtest).
- [x] Umschalten des Schalters wechselt ausschließlich neue Checkouts (per Test nachgewiesen).
---
## 7.1 Notfall-Go-Live (2026-07-08): PAYONE-Ausfall — Einmalzahlungen live, Abos eingefroren
**Lage:** PAYONE ist ausgefallen. Entscheidung Stakeholder: Der S3-Stand
(Einmalzahlungen über Stripe) geht **sofort produktiv**; Phase S4 (Abos) wird
nachgelagert. Bis dahin werden **alle Abo-Zahlungen eingefroren** und mit einem
Hinweis versehen („derzeit nicht möglich, wir arbeiten an einer technischen Lösung").
### Not-Schalter `abo-payments-frozen` (Setting, bool)
| Eingefroren | Verhalten |
|---|---|
| Abo-Checkout (Neuabschluss) & Recovery-Checkout | Zahlarten-Box + Kaufen-Button ersetzt durch Warnhinweis (`payment.abo_payments_frozen`); Server-Guard in `checkoutFinal` blockiert Submits zusätzlich. |
| Abbuchungs-Cron `user:make_abo_order` | Kompletter Lauf wird übersprungen (Log-Warnung). Abos werden **nicht** als fehlgeschlagen markiert, `next_date` bleibt stehen → nach dem Auftauen holt der Cron alles regulär nach. |
| Abo-Retry (Admin/CRM/Portal, `AboRetryPaymentService`) | Liefert Fehler mit dem Hinweistext. |
| **Kreditkarte ändern** (Kunden-Link, Portal, CRM) | Zentraler Guard in `AboPaymentMethodService::updateCreditCard()` → `CardUpdateResult::error` mit Hinweistext; das gemeinsame Formular-Partial (`portal/abo/_payment_method_form`) zeigt statt der Kartenfelder die Warnbox; **Versand neuer Card-Update-Links** (`User\AboController::sendCardUpdateLink`) ist geblockt (Flash-Hinweis), damit keine Kunden-Mails auf ein eingefrorenes Formular führen. |
| Abschluss neuer Abos | Bereits über den Abo-Checkout-Guard abgedeckt (Hinweis + Server-Block, s. o.). |
Nicht eingefroren: Einmalzahlungen (Stripe), Vorkasse-/FNC-Sichtbarkeit (eigene
Schalter), CC-Update-Flow (schlägt bei PAYONE-Ausfall mit sauberer Fehlermeldung fehl).
Der Freeze wird nach S4-Abschluss aufgehoben (`Setting → false`); danach zahlen
Bestands-PAYONE-Abos wieder über PAYONE (sofern wiederhergestellt) bzw. neue über Stripe.
### Checkout-Wartungsmodus mit Admin-Bypass (`checkout-maintenance`, bool)
Für den kontrollierten Live-Start: Ist der Schalter an, sehen **alle Besucher** im
Checkout nur „Technische Schwierigkeiten — wir sind in Kürze wieder für dich da"
(`payment.checkout_maintenance`); **Admins** (`User::isAdmin()`, Session gilt
domänenübergreifend) können normal bestellen. So lässt sich Stripe im Live-Modus mit
einer echten Bestellung verproben, bevor der Checkout für alle öffnet. Guards in
`CheckoutController::checkout()` (Meldungsseite) und `checkoutFinal()` (Redirect).
### Admin-Settings-Karte „Zahlungen (Stripe / PAYONE)" (S7.1 vorgezogen)
Unter `https://my.mivita.care/admin/settings` (Karte nach „Kauf Einstellungen"):
- **Aktiver Zahlungsanbieter** (Select PAYONE/Stripe → `payment-active-provider`)
- **Checkout-Wartungsmodus** (Checkbox → `checkout-maintenance`)
- **Abo-Zahlungen einfrieren** (Checkbox → `abo-payments-frozen`)
- **Vorkasse ausblenden** (Checkbox → `payment-vorkasse-hidden`)
Alle Schalter sind damit ohne Tinker im Admin schaltbar (bestehender
`SettingController`-Mechanismus). `SysLog`-Protokollierung folgt mit dem Rest von S7.1.
**Tests:** Retry-Freeze, Cron-Skip (Abo bleibt Status 2, keine `user_abo_orders`),
CC-Update-Freeze (`updateCreditCard` → Error mit Hinweistext), Manager-Defaults aus —
in `StripeCheckoutTest.php`; Karten-Update-Regressionssuite (17 Tests) unverändert grün.
### Go-Live-Checkliste Produktion
1. [ ] Deployment des aktuellen Stands (Branch `payment/phase-r-payone-refactoring`).
2. [ ] `php artisan migrate` (3 neue Migrationen: Provider-Spalten, `stripe_webhook_events`).
3. [ ] Prod-`.env`: `STRIPE_KEY`/`STRIPE_SECRET` = **Live-Keys** (`pk_live_…`/`sk_live_…`), `STRIPE_MODE=live`.
4. [ ] Stripe-Dashboard (**Live-Modus**): Zahlarten aktivieren; Webhook-Endpoint
`https://api.mivita.care/stripe/webhook` mit der Event-Liste aus §6 anlegen;
dessen Signing-Secret als `STRIPE_WEBHOOK_SECRET` in die Prod-`.env`.
5. [ ] Caches: `php artisan config:clear && php artisan view:clear` (bzw. Deploy-üblich `config:cache`).
6. [ ] Schalter setzen — im Admin unter **Einstellungen → „Zahlungen (Stripe / PAYONE)"**:
☑ Checkout-Wartungsmodus (nur Admins bestellen), ☑ Abo-Zahlungen einfrieren,
☑ Vorkasse ausblenden (**wichtig:** Vorkasse autorisiert über PAYONE und würde
beim Ausfall crashen), Anbieter = **Stripe**.
7. [ ] Smoke-Test **als Admin bei aktivem Wartungsmodus**: echte Bestellung (Live-Karte)
→ Stripe-Redirect → bezahlt → Rechnung; ggf. im Stripe-Dashboard erstatten.
Gegenprobe im Inkognito-Fenster: Besucher sehen die Wartungsmeldung.
8. [ ] Checkout öffnen: **Wartungsmodus abhaken** — Einmalzahlungen laufen für alle über Stripe.
9. [ ] Kontrolle: `storage/logs/stripe.log`, `stripe_webhook_events`, Payment-Dashboard.
10. [ ] Nach S4: `abo-payments-frozen` zurücksetzen.
---
## 8. Phase S4 – Abos über Stripe
### Ziel
Neue Abos: Erstzahlung im Checkout mit Speicherung des Zahlungsmittels; wiederkehrende
Abbuchungen über den bestehenden Cron, off-session. Bestehende PAYONE-Abos laufen
unverändert über PAYONE.
### Aufgaben
| # | Aufgabe |
|---|---------|
| S4.1 | Abo-Erstzahlung: Checkout Session (`mode=payment`) mit `setup_future_usage=off_session` + `customer`; Zahlarten auf **wiederverwendbare** Methoden eingeschränkt (Konzept §3.2; Klarna/Amazon Pay nur nach positiver N1-Prüfung); nach `succeeded` PaymentMethod-ID aus dem PaymentIntent ermitteln. |
| S4.2 | `AboHelper::createNewAbo()`: bei Stripe-Erstzahlung `payment_provider='stripe'`, `stripe_customer_id`, `stripe_payment_method_id` (statt `payone_userid`/`carddata`) übernehmen; maskierte Anzeige-Daten (Brand, letzte 4, Ablauf) als Anzeige-Metadaten speichern. |
| S4.3 | `StripePaymentService::chargeAbo()`: off-session PaymentIntent (`customer`, `payment_method`, `off_session=true`, `confirm=true`), Idempotency-Key = `user_abo_order`-Referenz (Schutz gegen Doppel-Abbuchung bei Cron-Retry). |
| S4.4 | `UserMakeOrder::makePayment()`: Provider-Dispatch über `PaymentProviderManager::providerFor($abo)` — PAYONE-Zweig byte-identisch. |
| S4.5 | Fehler-Mapping: `card_declined`, `authentication_required`, `insufficient_funds` → bestehende Abo-Fehlerstatus (Status 3 / Retry) wie bei PAYONE-Declines; Ergebnis-DTO vereinheitlicht (`AboChargeResult`). |
| S4.6 | Abo-Ansichten (CRM `/user/abos`, Portal, Admin `AboController`): Provider + maskiertes Zahlungsmittel anzeigen; PAYONE-Felder bleiben für PAYONE-Abos maßgeblich. |
| S4.7 | Abo-Retry (`AboRetryPaymentService`) provider-fähig (nutzt `chargeAbo` des Abo-Providers). |
### Tests
- Erstzahlung → `UserAbo` mit Stripe-Feldern; PAYONE-Felder leer.
- Cron-Abbuchung Stripe-Abo: Erfolg (Order paid, `user_abo_orders` fortgeschrieben), Decline (Status 3), Idempotenz (doppelter Cron-Lauf bucht nicht doppelt ab).
- Gemischte Bestände: ein PAYONE-Abo + ein Stripe-Abo im selben Cron-Lauf → jeweils richtiger Gateway-Aufruf.
### Akzeptanzkriterien
- [ ] Testmodus Ende-zu-Ende: Abo-Checkout → n Tage später Cron-Abbuchung off-session erfolgreich, Rechnung systemseitig.
- [ ] PAYONE-Abos zu 100 % unberührt (Regressionssuite + Stichprobe Staging).
---
## 9. Phase S5 – Zahlungsmittel-Update über Stripe
### Ziel
Der Flow aus dem CC-Update-Projekt (2026-06-25) funktioniert für Stripe-Abos:
Zahlungsmittel erneuern **ohne Abbuchung**, nur durch den Abo-Inhaber.
### Aufgaben
| # | Aufgabe |
|---|---------|
| S5.1 | `StripePaymentService::setupPaymentMethodUpdate()`: Checkout Session `mode=setup` (hosted, Redirect – konsistent zum Checkout-Flow); Einstieg über die bestehenden Card-Update-Views/Links (Verzweigung nach Abo-Provider). |
| S5.2 | `setup_intent.succeeded`-Webhook bzw. Return-Bestätigung: neue `stripe_payment_method_id` am Abo speichern, alte PaymentMethod am Customer detachen; `user_abo_card_changes`-Protokoll weiternutzen. |
| S5.3 | Konsistenz-Regeln aus dem CC-Update-Projekt übernehmen: `card_update_pending_at` vor externem Call setzen; API-OK → DB-Update; Fehlerfall → CRITICAL-Log + Incident (Alt-Plan §5.3.1). |
| S5.4 | `AboPaymentMethodService` provider-fähig; `AboCardUpdateLink`-Flow (Berater→Kunde-Link) für Stripe-Abos freischalten. |
### Tests
- SetupIntent-Erfolg aktualisiert nur das Ziel-Abo; keine Abbuchung entsteht.
- Nicht-Inhaber wird abgewiesen (Policy unverändert).
- Pending-Flag/Inkonsistenz-Pfad (API ok, DB-Fehler) erzeugt Incident.
---
## 10. Phase S6 – Fehlerfälle, 3DS-Recovery & Incidents
### Ziel
Fehlgeschlagene Stripe-Abbuchungen münden in die bestehenden Recovery-Werkzeuge; das
Payment-Dashboard kennt Stripe.
### Aufgaben
| # | Aufgabe |
|---|---------|
| S6.1 | `authentication_required` bei off-session: Abo auf Status 3 + Recovery-Checkout-Link (bestehender `AboRecoveryCheckoutService`); Recovery-Zahlung läuft on-session mit 3DS über den **Abo-Provider**; nach Erfolg `advanceAboAfterRecoveryPayment` wie gehabt. |
| S6.2 | `payment_intent.payment_failed` / `charge.dispute.created` / `charge.refunded` → `PaymentIncident` (Provider `stripe`) + Payment-Dashboard-Kacheln. |
| S6.3 | `payments:check-accounts` bzw. Uptime-Check um Stripe-Status ergänzen (API-Erreichbarkeit, offene `processing`-Zahlungen älter X Tage). |
| S6.4 | Wartungs-Command `stripe:sync-payment-status {paymentIntentId|--pending}` (Gegenstück zu `payone:*`-Reparatur-Commands): Status aktiv nachziehen, wenn ein Webhook verloren ging. |
### Tests
- Failed-Webhook setzt Abo-/Order-Status korrekt, erzeugt Incident, keine Doppel-Mails.
- Recovery-Checkout mit Stripe-Abo Ende-zu-Ende (gemockt).
---
## 11. Phase S7 – Admin & Betrieb
### Aufgaben
| # | Aufgabe |
|---|---------|
| S7.1 | **Schalter-UI** im Admin (Einstellungen): aktueller Provider, Umschalten mit Bestätigungsdialog + Hinweistext („wirkt nur auf neue Zahlungen"), `SysLog`-Eintrag. Nur berechtigte Admin-Rolle. |
| S7.2 | **Refunds:** `OrderWithdrawalController` (Widerruf) um automatische Erstattung erweitern — Provider der Ursprungszahlung entscheidet (Stripe: `Refund`-API; PAYONE: bestehender/manueller Weg). Teil-Erstattungen unterstützen. |
| S7.3 | Admin-Detailansichten (`ShoppingOrder`, `UserAbo`, Zahlungen): Provider-Badge, Stripe-IDs verlinkt zum Stripe-Dashboard (Deep-Link `https://dashboard.stripe.com/...`). |
| S7.4 | Payment-Dashboard: Umsatz/Fehlerraten je Provider; Übersicht „Abos je Provider" (Fortschritt einer späteren Migration ablesbar). |
| S7.5 | Doku: Betriebs-Runbook (Webhook neu ausliefern, Key-Rotation, Testmodus) in diesem Ordner ablegen. |
### Tests
- Schalter-UI: Berechtigung, Persistenz, SysLog.
- Refund: Stripe-Refund gegen gemockten Client; `charge.refunded`-Webhook schließt den Kreis; PAYONE-Zahlung nimmt PAYONE-/manuellen Pfad.
---
## 12. Phase S8 – Rollout & optionale Abo-Migration
### Rollout-Plan (empfohlen)
| Schritt | Inhalt |
|---|---|
| 1 | Staging/Testmodus: alle Flows mit Stripe-Testkarten + `stripe trigger`; Schalter-Wechselproben. |
| 2 | Produktion, Schalter bleibt `payone`: Deployment ist verhaltensneutral (Dark Launch). |
| 3 | Pilot: Schalter auf `stripe` in verkehrsarmer Zeit; Einmalzahlungen beobachten (Dashboard, `stripe.log`, Incidents). Rollback = Schalter zurück. |
| 4 | Abos auf Stripe freigeben (neue Abo-Abschlüsse). |
| 5 | Beobachtungsphase; PAYONE bleibt für Bestands-Abos und als Fallback aktiv. |
**Rollback-Garantie:** Der Schalter ist jederzeit rückstellbar; da jeder Vorgang seinen
Provider trägt, entstehen keine Mischzustände. Offene Stripe-Zahlungen laufen über
Webhooks normal aus.
### Optionale Abo-Migration PAYONE → Stripe (nur nach Entscheid O8)
- PAYONE-Tokens sind **nicht portabel** → Migration heißt: Kunde hinterlegt Zahlungsmittel
neu (SetupIntent, kein Charge) über den Card-Update-Link-Flow; danach
`payment_provider='stripe'` am Abo, PAYONE-Felder bleiben als Historie.
- Kampagnen-Command `abo:invite-stripe-migration` (Batch-Mails mit Link, Erinnerung,
Fortschritts-Report) — Muster: Reminder-Commands aus dem Einmalprodukte-Projekt.
- Kein Zwang, kein Big-Bang: nicht migrierte Abos laufen unbefristet über PAYONE.
---
## 13. Teststrategie (übergreifend)
| Ebene | Inhalt |
|---|---|
| Unit | Zahlarten-Mapping, Betrag→Cent-Rundung, Provider-Resolver, DTO-Mapping, Fehlercode-Mapping |
| Feature (Pest, SQLite) | Checkout-Verzweigung, Webhook-Handler (Signatur/Idempotenz/Paid-Pfad), Cron-Dispatch, Recovery, Refund — Stripe-Client **immer gemockt** |
| Regression | Gesamte PAYONE-Suite muss in jeder Phase unverändert grün sein (Schalter `payone` = Nullmessung) |
| Manuell (Testmodus) | 3DS-Testkarten, SEPA-`processing`, `stripe trigger`-Events, Schalterwechsel unter Last |
Hinweise: `route()` ist in Pest nicht domain-aware (bestehende Projektkonvention
beachten); kein echter Netzwerkverkehr in der Suite; `./vendor/bin/pint` nach jeder Phase.
## 14. Risiken & Mitigation
| Risiko | Mitigation |
|---|---|
| Regression im PAYONE-Bestand | PAYONE-Code nur per Adapter kapseln, nie umbauen; Schalter-Default `payone`; volle Regressionssuite pro Phase |
| Doppel-Abbuchung durch Cron-Retry | Stripe Idempotency-Keys pro `user_abo_order`; Tests dafür in S4 |
| Webhook-Verlust → Order hängt in „offen" | `stripe:sync-payment-status`-Command (S6.4); Stripe-Retry nutzt non-2xx |
| „Bezahlt" fälschlich aus Browser-Return | Paid-Flag ausschließlich im Webhook-Pfad (wie PAYONE-TSOK) |
| SEPA-Zahlungen tagelang `processing` | Eigener Anzeige-Status; Versand-/Abo-Logik erst bei `succeeded` (Klärung mit Stakeholder, ob Versand auf `processing` warten muss) |
| Schalter wird versehentlich umgelegt | Bestätigungsdialog, Admin-Rolle, SysLog, Dashboard-Anzeige des aktiven Providers |
| Stripe-Konto-Einschränkungen (Radar, Verifizierung) | Früh Live-Verifizierung anstoßen (S0/O7); Pilotphase klein halten |
| Scope-Kriechen Richtung Stripe Billing | Architektur-Entscheidung (Konzept §2) ist verbindlich dokumentiert |
## 15. Betroffene Dateien (Vorschau)
### Neu
```
config/stripe.php
app/Enums/PaymentProvider.php
app/Services/Payment/PaymentProviderManager.php
app/Services/Payment/Contracts/PaymentGatewayInterface.php (+ DTOs)
app/Services/Payment/PayoneGateway.php ← Adapter, kein Umbau
app/Services/Payment/Stripe/StripeClientWrapper.php
app/Services/Payment/Stripe/StripeCustomerService.php
app/Services/Payment/Stripe/StripePaymentService.php
app/Services/Payment/Stripe/StripeWebhookHandler.php
app/Services/Payment/Stripe/StripeTransactionRecorder.php
app/Services/Payment/Stripe/DTOs/*.php
app/Http/Controllers/Api/StripeWebhookController.php
app/Console/Commands/StripeSyncPaymentStatus.php
database/migrations/*_add_payment_provider_columns_*.php (+ Stripe-Felder)
app/Http/Controllers/Web/StripeCheckoutReturnController.php (success/cancel)
dev/2026-07-08-stripe/* (dieser Ordner: Plan, Analyse, Konzept, Runbook)
```
### Anpassung (provider-bewusst, PAYONE-Pfad unverändert)
```
app/Http/Controllers/Web/CheckoutController.php
app/Cron/UserMakeOrder.php
app/Services/AboHelper.php
app/Services/AboRetryPaymentService.php
app/Services/AboRecoveryCheckoutService.php
app/Services/AboPaymentMethodService.php
app/Services/OrderPaymentService.php
app/Http/Controllers/Admin/OrderWithdrawalController.php
app/Http/Controllers/Admin/PaymentDashboardController.php
routes/api.php, routes/domains/*.php
Abo-/Order-Views (Provider-Badge, maskiertes Zahlungsmittel)
```
## 16. Nächste Schritte
1. ~~O-Fragen klären~~ ✅ **beantwortet 2026-07-08** (Konzept §10): Hosted Checkout mit
Redirect, alle Stripe-Zahlarten inkl. PayPal/Apple Pay/Google Pay/Amazon Pay/Klarna,
nur EUR, SDK freigegeben, Account vorhanden, Bestands-Abo-Migration und
Membership/ELV bewusst nach hinten gestellt (S8 bzw. Folgeprojekt).
2. Phase **S0** starten: Test-Keys + Webhook im (vorhandenen) Stripe-Account einrichten,
`stripe/stripe-php` installieren, `config/stripe.php`, **N1-Prüfung** (Klarna/Amazon
Pay Abo-Fähigkeit).
3. Danach S1 → S2 → S3; nach S3 erster produktiver Meilenstein (Einmalzahlung) möglich.

View file

@ -0,0 +1,268 @@
# Stripe-Konzept: Architektur & API-Mapping
> **Stand:** 2026-07-08 · Ergänzt `ENTWICKLUNGSPLAN-STRIPE.md` (Phasen) und
> `ANALYSE-IST-ZUSTAND.md` (Codebase). Hier stehen die **Architektur-Entscheidungen**
> und das Mapping der Stripe-API auf unsere Domäne.
## 1. Leitplanken (vom Stakeholder gesetzt)
1. **Stripe ersetzt PAYONE als Zahlungsabwicklung** – Rechnungen, Lieferscheine,
Provisionen etc. werden **weiterhin im System** generiert.
2. **PAYONE wird nicht entfernt.** Es bleibt als Option bestehen und wird sauber
separiert. Ein **Schalter in den Einstellungen** wechselt den aktiven Anbieter.
3. Scope: **Einmalzahlungen zuerst**, dann **Abos**.
4. **Netto/Brutto je Land** bleibt Systemaufgabe (bestehende `country_prices`-Logik).
5. **Zahlartenwahl und Dateneingabe finden bei Stripe statt** (Stakeholder 2026-07-08):
Der mivita-Checkout leitet per Redirect zur **Stripe-Hosted-Checkout-Seite** weiter;
dort wählt der Kunde die Zahlungsmethode, zahlt und springt zurück. Im eigenen
Checkout gibt es bei aktivem Stripe **keine** Zahlarten-Formulare mehr (kein
Kreditkarten-iFrame etc.). Der PAYONE-Checkout bleibt bei Schalter `payone` wie heute.
6. Zahlarten-Scope: **Karte, PayPal, Apple Pay, Google Pay, Amazon Pay, Klarna** und
weitere von Stripe bereitgestellte Methoden (Dashboard-gesteuert). Alle Zahlungen in **EUR**.
## 2. Grundsatzentscheidung: PaymentIntents statt Stripe Billing
| Kriterium | Stripe Billing (Subscriptions) | **PaymentIntents + gespeicherte Payment Methods (Empfehlung)** |
|---|---|---|
| Wer bestimmt Betrag & Rhythmus? | Stripe (Preise/Pläne bei Stripe gepflegt) | **Unser Cron** (`user:make_abo_order`) – wie heute bei PAYONE |
| Rechnungen | Stripe erzeugt eigene Invoices | **System erzeugt Rechnungen** (dompdf/DATEV) – Anforderung! |
| Abo-Inhalt ändert sich pro Ausführung (Produkte, Einmalprodukte, Länderpreise) | Schwer abbildbar (Preis-Objekte müssten synchron gehalten werden) | Trivial – Betrag wird pro Abbuchung frisch berechnet |
| Steuerlogik | Stripe Tax nötig oder Duplikation | Bleibt zu 100 % im System |
| Parallelität zu PAYONE | Zwei völlig verschiedene Abo-Modelle | **Gleicher Lebenszyklus wie heute**, nur anderer Charge-Kanal |
**Entscheidung (Empfehlung):** Abos bleiben in unserer Datenhaltung (`user_abos`,
`user_abo_orders`), der Cron berechnet wie bisher den Betrag und ruft je nach Provider
`chargeAbo()` bei PAYONE **oder** Stripe (off-session PaymentIntent) auf. Stripe Billing,
Stripe Invoicing und Stripe Tax werden **nicht** verwendet.
> Damit ist Stripe für uns ein reiner **Charge-/Token-Provider** – exakt die Rolle, die
> PAYONE heute hat. Das minimiert den Umbau und hält beide Anbieter symmetrisch hinter
> einer gemeinsamen Schnittstelle.
## 3. Stripe-Objekte ↔ unsere Domäne
| Unsere Domäne | PAYONE heute | Stripe |
|---|---|---|
| Zahlender Kunde | `payone_userid` | `Customer` (`cus_…`) – pro `User`/`Customer` einmalig anlegen, ID speichern |
| Gespeichertes Zahlungsmittel (Abo) | `pseudocardpan` + `cardexpiredate` in `user_abos.carddata` | `PaymentMethod` (`pm_…`), attached am Customer; ID in `user_abos.stripe_payment_method_id` |
| Einmalzahlung Checkout | `authorization` (preauth/auth) | **Checkout Session** (`cs_…`, `mode=payment`, hosted) → erzeugt intern `PaymentIntent` (`pi_…`) |
| Abo-Erstzahlung im Checkout | `authorization` + `recurrence=recurring` | **Checkout Session** `mode=payment` + `setup_future_usage=off_session` (Zahlungsmittel wird am Customer gespeichert) |
| Wiederkehrende Abbuchung (Cron) | `authorization` + `userid` + `pseudocardpan` | `PaymentIntent` mit `off_session=true`, `confirm=true`, `customer` + `payment_method` (API-Call, keine Hosted Page) |
| Zahlungsmittel aktualisieren ohne Abbuchung | `creditcardcheck` (`storecarddata=yes`) | **Checkout Session** `mode=setup` (hosted) → `SetupIntent` (`seti_…`), kein Charge, SCA-fähig |
| Anbieter-Callback | IPN/TSOK (`txaction`) | Webhook-Events (signiert, `whsec_…`) |
| Erstattung (Widerruf) | manuell / `refund`-Request | `Refund` (`re_…`) auf PaymentIntent/Charge |
| Test/Live | `mode` test/live + Portal | Test-Keys vs. Live-Keys (`sk_test_…`/`sk_live_…`) |
### 3.1 Integrationsform: Stripe Checkout (Hosted Page) — entschieden 2026-07-08
**Ablauf:** mivita-Checkout (Adresse, Warenkorb, AGB) → Button „Jetzt zahlen" →
Server erstellt eine **Checkout Session** → `303 Redirect` auf `checkout.stripe.com` →
Kunde wählt dort die Zahlart und zahlt → Redirect zurück auf unsere `success_url`
(bzw. `cancel_url` bei Abbruch). „Bezahlt" setzt weiterhin **nur der Webhook**
(`checkout.session.completed` / `payment_intent.succeeded`), nie der Browser-Return.
Warum Hosted Checkout (statt eingebettetem Payment Element):
- Entspricht der Stakeholder-Vorgabe: keine Zahlungsdaten-Eingabe mehr im eigenen Checkout.
- **Apple Pay / Google Pay funktionieren out of the box** (Domain-Verifizierung übernimmt
Stripe auf der eigenen Domain; Wallets erscheinen automatisch nur auf fähigen Geräten).
- **Dynamic Payment Methods:** Welche Zahlarten angezeigt werden, wird im
**Stripe-Dashboard** konfiguriert — neue Methoden aktivieren ohne Code-Deploy.
Der Code erzwingt nur Einschränkungen (z. B. Abo-Checkout → nur wiederverwendbare Methoden).
- Stripe optimiert Reihenfolge/Anzeige der Methoden je Gerät, Land, Betrag.
- PCI-Aufwand minimal (SAQ-A), kein Stripe.js-Embedding in unseren Blade-Views nötig.
Konsequenz für den bestehenden Checkout-Schritt „Zahlungsart wählen": Bei Schalter
`stripe` entfällt die Detailauswahl im mivita-Checkout (bzw. reduziert sich auf
systeminterne Methoden wie Vorkasse/Rechnung + „Online bezahlen via Stripe");
bei Schalter `payone` bleibt der heutige Schritt unverändert.
### 3.2 Zahlarten (Scope lt. Stakeholder: alles, was Stripe bereitstellt)
| Zahlart | Stripe-Typ | Einmalzahlung | Abo-fähig (off_session) | Hinweis |
|---|---|---|---|---|
| Kreditkarte | `card` | ✅ | ✅ | Kernfall |
| Apple Pay | Wallet auf `card` | ✅ | ✅ (tokenisiert als Karte) | Erscheint automatisch (Safari/iOS) |
| Google Pay | Wallet auf `card` | ✅ | ✅ (tokenisiert als Karte) | Erscheint automatisch (Chrome/Android) |
| PayPal | `paypal` | ✅ | ✅ (Vaulting via Stripe, EU) | Ersetzt das blockierte PayPal-direkt-Projekt (O2 = ja) |
| Klarna | `klarna` | ✅ | ⚠️ in S0 gegen aktuelle Stripe-Doku prüfen | Ersetzt entfallene Sofortüberweisung (O3) |
| Amazon Pay | `amazon_pay` | ✅ | ⚠️ in S0 gegen aktuelle Stripe-Doku prüfen | |
| SEPA-Lastschrift | `sepa_debit` | ✅ | ✅ (Mandat via Stripe) | `processing` dauert Tage → eigener Status |
| Weitere (iDEAL, Bancontact, …) | dynamisch | ✅ | teils | Dashboard-gesteuert, kein Code nötig |
| Vorkasse (VOR) | – | systemintern | – | Bleibt außerhalb des Gateways, providerunabhängig |
| Rechnung intern (FNC/MIV) | – | systemintern | – | Bleibt außerhalb des Gateways |
**Regel für Abo-Checkouts:** Die Checkout Session wird auf **wiederverwendbare**
Methoden eingeschränkt (`card` inkl. Wallets, `paypal`, `sepa_debit`; Klarna/Amazon Pay
nur, falls die S0-Prüfung `setup_future_usage=off_session` bestätigt). Einmalzahlungen
bekommen das volle Dashboard-Sortiment.
## 4. Provider-Abstraktion & Schalter
### 4.1 Schichtenmodell
```
UI: Checkout / Portal / CRM / Zahlungslink / Abo-Cron
│
▼
PaymentProviderManager (neu)
├── providerForNewCheckout(): PaymentProvider ← liest den Einstellungs-Schalter
├── providerFor(UserAbo|ShoppingPayment): PaymentProvider ← liest payment_provider-Spalte
└── gateway(PaymentProvider): PaymentGatewayInterface
│
├──────────────────────────────┐
▼ ▼
PayoneGateway (Adapter um StripeGateway (neu)
PayonePaymentService – bestehend, app/Services/Payment/Stripe/*
unverändert)
```
```php
enum PaymentProvider: string
{
case Payone = 'payone';
case Stripe = 'stripe';
}
interface PaymentGatewayInterface
{
public function initiateCheckoutPayment(CheckoutPaymentContext $ctx): PaymentInitiationResult;
public function chargeAbo(UserAbo $abo, ShoppingOrder $order, ShoppingUser $user, int $amountCents, string $currency): AboChargeResult;
public function setupPaymentMethodUpdate(UserAbo $abo): PaymentMethodUpdateSession; // CC-Update / SetupIntent
public function refund(ShoppingPayment $payment, int $amountCents, string $reason): RefundResult;
}
```
**Wichtig:** PAYONE-Code wird dafür **nicht umgebaut** – der `PayoneGateway` ist ein
dünner Adapter um den bestehenden `PayonePaymentService`. Ein Interface-Mismatch wird im
Adapter gelöst, nie im Bestandscode.
### 4.2 Der Schalter (Einstellungen)
- **Neue Setting** `payment.active_provider` (`payone` | `stripe`), gepflegt im
Admin-Bereich (CRM), Anzeige-/Änderungsprotokoll über bestehendes `SysLog`-Muster.
- Der Schalter wirkt **nur auf neue Zahlungsvorgänge** (Checkout-Start, neue Abos,
neue Zahlungslinks).
- **Bestandsdaten sind schaltunabhängig:** Jede `shopping_payments`-Zeile und jedes
`user_abos` trägt seinen `payment_provider` fest. Ein PAYONE-Abo wird auch nach dem
Umschalten weiter über PAYONE abgebucht; Refunds gehen immer an den Provider der
Ursprungszahlung.
- Granularität: Start mit **globalem** Schalter; optional später pro Zahlart
(`payment_methods.provider`-Spalte ist als Erweiterung vorgesehen) → **O4**.
- Empfohlene Ablage: eigene kleine Tabelle/Model `PaymentSetting` oder Erweiterung des
bestehenden Settings-Mechanismus (`Setting`/`SySetting`) → im Plan Phase S1 entscheiden,
Konvention der Codebase folgt.
### 4.3 Datenmodell-Erweiterungen
```sql
-- user_abos
payment_provider VARCHAR(20) NOT NULL DEFAULT 'payone' -- Enum-Werte s. o.
stripe_customer_id VARCHAR(255) NULL
stripe_payment_method_id VARCHAR(255) NULL
-- payone_userid, clearingtype, carddata bleiben unverändert (Stakeholder-Entscheid O1 Alt-Plan)
-- shopping_payments
payment_provider VARCHAR(20) NOT NULL DEFAULT 'payone'
external_reference VARCHAR(255) NULL -- Stripe PaymentIntent-ID (pi_…)
-- payment_transactions
provider VARCHAR(20) NOT NULL DEFAULT 'payone' -- Webhook-Protokoll beider Anbieter
-- users / customers (oder eigene Tabelle stripe_customers)
stripe_customer_id VARCHAR(255) NULL
```
Backfill-Migration: alle Bestandszeilen `payment_provider = 'payone'` (Default deckt
das ab). Keine Änderung an bestehenden PAYONE-Spalten.
## 5. Netto/Brutto & Währungen
- Betragsermittlung bleibt vollständig im System: `country_prices.c_tax` → Cart
(`taxRate`, `taxIncluded`) → Bruttobetrag. Rechnung weist Netto/MwSt/Brutto aus wie
heute.
- Stripe erhält ausschließlich: `amount` = **Bruttobetrag in Cent**, `currency`,
`description`/`metadata` (Order-Referenz). Keine Steuerdaten an Stripe, kein
Stripe Tax.
- Metadata-Konvention (für Support/Abgleich): `shopping_order_id`, `shopping_payment_id`,
`user_abo_id` (falls Abo), `mode`.
- Währung: heute faktisch EUR; `country_prices.c_currency` existiert → klären, ob
Nicht-EUR-Länder aktiv sind → **O5**.
## 6. SCA / 3-D Secure
| Situation | Verhalten |
|---|---|
| Checkout (on-session) | Die Stripe-Hosted-Checkout-Seite übernimmt die 3DS-Challenge komplett. Danach Redirect auf unsere `success_url` → Status-Anzeige (UX) → „bezahlt" via Webhook. |
| Abo-Erstzahlung | `setup_future_usage=off_session` in der Checkout Session → Bank weiß, dass Folgezahlungen kommen (MIT-Rahmen). |
| Wiederkehrende Abbuchung (Cron, off-session) | Normalfall: keine SCA nötig (Merchant Initiated). Ausnahme `authentication_required`: Abbuchung schlägt fehl → Abo auf Status 3, Kunde bekommt Link in den **bestehenden Recovery-Checkout** (`AboRecoveryCheckoutService`) und zahlt dort on-session (Hosted Checkout) mit 3DS. |
| Zahlungsmittel-Update | Checkout Session `mode=setup` (hosted) → SCA wird dabei gleich mit erledigt, Folgezahlungen laufen sauber. |
## 7. Webhooks
- Endpoint: `POST /api/stripe/webhook` (in `routes/api.php`, läuft global unter
`api.<domain>` – analog PAYONE-IPN). Ohne CSRF, **mit Signaturprüfung**
(`Stripe-Signature`-Header, `STRIPE_WEBHOOK_SECRET`).
- Handler nach dem Muster `PayoneWebhookHandler` (Idempotenz über `event.id`,
Duplikat-Erkennung, Persistenz **nur** über `StripeTransactionRecorder`).
- Relevante Events (Minimal-Set):
| Event | Reaktion |
|---|---|
| `checkout.session.completed` | Session→Order-Zuordnung festziehen; bei `payment_status=paid` Paid-Pfad, bei `unpaid` (SEPA etc.) auf `payment_intent.succeeded` warten |
| `checkout.session.expired` | Offenen Checkout-Vorgang aufräumen (Session-TTL 24 h) |
| `payment_intent.succeeded` | Paid-Pfad (`paymentStatusPaidAction`-Äquivalent): Order bezahlt, Abo anlegen/fortschreiben |
| `payment_intent.payment_failed` | Fehlerpfad; bei Abo → Retry-/Recovery-Logik, Incident |
| `payment_intent.processing` | SEPA: Zahlung unterwegs → Status „in Verarbeitung" (Muster aus Doppelabo-Schutz 5.2.1 Alt-Plan) |
| `charge.refunded` | Widerrufs-/Erstattungsstatus aktualisieren (`OrderWithdrawal`) |
| `charge.dispute.created` | Incident im Payment-Dashboard |
| `setup_intent.succeeded` | Zahlungsmittel-Update abschließen (`user_abos` aktualisieren) |
- **Redirect-Return ≠ Wahrheit:** Wie beim PAYONE-TSOK gilt der Webhook als Quelle der
Wahrheit für „bezahlt", der Browser-Return nur für UX. SEPA wird z. B. erst Tage
später `succeeded`.
## 8. Sicherheit & Compliance
| Thema | Umsetzung |
|---|---|
| PCI-DSS | **Stripe Hosted Checkout** → SAQ-A (geringstmöglicher Scope). Kartendaten berühren weder unseren Server noch unser Frontend. Secret Key nur serverseitig; ein Publishable Key wird beim Redirect-Flow nicht einmal im Frontend benötigt. |
| Secrets | `config/stripe.php` mit `env('STRIPE_SECRET')`, `env('STRIPE_KEY')`, `env('STRIPE_WEBHOOK_SECRET')`; Hard-Fail-Muster wie `config/payone.php` (Boot-Exception bei fehlendem Secret im Live-Betrieb). Kein `env()` außerhalb von Config-Dateien. |
| Logging | Recorder-Redaction wie PAYONE: keine vollständigen PM-Details in `transmitted_data`/Logs; Stripe-IDs (`pi_`, `pm_`, `cus_`) sind unkritisch und ausdrücklich erwünscht (Traceability). |
| Webhook | Signaturprüfung + Idempotenz-Store; bei Verarbeitungsfehler kein 200 → Stripe wiederholt (gleiches Prinzip wie TSOK-Retry). |
| AuthZ | Zahlungsmittel-Update nur Abo-Inhaber (Regel aus CC-Update-Projekt: „nur Inhaber gibt Karte ein" gilt unverändert für Stripe). |
| Test/Live | Stripe-Testmodus ↔ `mode=test`; Rechnungen weiterhin nur in Produktion (bestehende Test-System-Sperre). |
## 9. SDK-Entscheidung
**Empfehlung: `stripe/stripe-php` (offizielles SDK), kein Laravel Cashier.**
Begründung: Cashier ist um Stripe Billing/Subscriptions herum gebaut (eigene Tabellen
`subscriptions`, Trait am User-Model) – genau das Modell, das wir laut Abschnitt 2
nicht verwenden. Das nackte SDK passt zur bestehenden Architektur
(Service-Schicht + injizierbarer Client wie `?Client $client` bei PAYONE) und bleibt
testbar (`StripeClient`-Interface-Wrapper, im Test gemockt).
→ Composer-Ergänzung `stripe/stripe-php`: **freigegeben** (O6, 2026-07-08).
## 10. Offene Fragen (Stakeholder) — beantwortet 2026-07-08
| ID | Frage | Antwort (Stakeholder, 2026-07-08) |
|----|-------|-----------------------------------|
| O1 | Welche Zahlarten zum Stripe-Start? | **Alle verfügbaren:** Karte, PayPal, Apple Pay, Google Pay, Amazon Pay, Klarna + weitere Stripe-Methoden. Anzeige über Hosted Checkout / Dashboard (s. §3.1/§3.2). Abo-Checkouts auf wiederverwendbare Methoden eingeschränkt. |
| O2 | PayPal über Stripe? | **Ja** — implizit durch O1; ersetzt das blockierte PayPal-direkt-Projekt aus dem Alt-Plan. |
| O3 | Ersatz für Sofortüberweisung? | **Klarna** (Teil des Zahlarten-Scopes aus O1). |
| O4 | Schalter-Granularität | Global starten (Empfehlung angenommen, kein Widerspruch); pro Zahlart als Ausbaustufe. |
| O5 | Nicht-EUR-Währungen? | **Nein — alle Zahlungen in EUR.** |
| O6 | Freigabe `stripe/stripe-php`? | **Ja, freigegeben.** |
| O7 | Stripe-Account? | **Angelegt.** Stakeholder hat Zugang und verwaltet ihn (Keys, Webhook-Konfiguration, Dashboard-Zahlarten). |
| O8 | Migration Bestands-Abos? | **Ja, aber später** — bewusst nach hinten gestellt (Phase S8 bleibt am Ende des Plans; kein Blocker für den Rollout). |
| O9 | Membership-/ELV-Zahlungen im Scope? | **Nach hinten gestellt** — vorerst PAYONE-only, eigener Folgeschritt nach dem Kern-Rollout. |
### Neue offene Punkte (aus den Entscheidungen abgeleitet)
| ID | Punkt | Klärung in | Antwort |
|----|-------|-----------|---------|
| N1 | Klarna & Amazon Pay: unterstützt Stripe dort aktuell `setup_future_usage=off_session` (Abo-Fähigkeit)? Gegen aktuelle Stripe-Doku prüfen. | Phase S0 | offen — bis zur Prüfung gelten beide als **nur Einmalzahlung** |
| N2 | UX des Checkout-Schritts „Zahlungsart" bei Schalter `stripe`? | beantwortet 2026-07-08 | **Keine Zahlartenwahl mehr im mivita-Checkout:** Nach Checkout + Prüfung geht es direkt zu Stripe. Für Abos schränkt die Session serverseitig auf abo-fähige Methoden ein; alles andere wird dort ausgeblendet. |
| N3 | Vorkasse (VOR) / interne Rechnung (FNC) im Stripe-Betrieb? | beantwortet 2026-07-08 | **Interne Rechnung (FNC):** bleibt als Option im System erhalten (ist ohnehin fast überall deaktiviert), wird aktuell ausgeblendet. **Vorkasse (VOR):** läuft heute über PAYONE → bekommt einen **eigenen Sichtbarkeits-Schalter in den Einstellungen**, damit sie unabhängig vom Provider-Schalter ein-/ausgeblendet werden kann. Ist Vorkasse ausgeblendet und Stripe aktiv, entfällt der Zahlarten-Schritt vollständig. |

View file

@ -38,5 +38,9 @@
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="BASIC_AUTH_ENABLED" value="false"/>
<env name="PAYONE_KEY_HASH" value="698fb2555f8b2efc74f60b2121421f45"/>
<env name="STRIPE_SECRET" value="sk_test_fake_suite_key"/>
<env name="STRIPE_KEY" value="pk_test_fake_suite_key"/>
<env name="STRIPE_WEBHOOK_SECRET" value="whsec_fake_suite_secret"/>
<env name="STRIPE_MODE" value="test"/>
</php>
</phpunit>

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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>

View file

@ -1,7 +1,5 @@
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| API Routes
@ -13,33 +11,32 @@ use Illuminate\Http\Request;
|
*/
Route::match(['get', 'post'], '/payment/status', 'Api\PayoneController@paymentStatus')->name('api.payment_status');
Route::post('/stripe/webhook', 'Api\StripeWebhookController@handleWebhook')->name('api.stripe_webhook');
Route::get('/google/merchant/feed', 'Api\GoogleMerchantController@feed')->name('api.google_merchant_feed');
Route::group([
'prefix' => 'auth'
'prefix' => 'auth',
], function () {
Route::post('login', 'Api\AuthController@login');
//Route::post('signup', 'Api\AuthController@signup');
// Route::post('signup', 'Api\AuthController@signup');
Route::group([
'middleware' => 'auth:api'
], function() {
'middleware' => 'auth:api',
], function () {
Route::post('logout', 'Api\AuthController@logout');
Route::post('checked', 'Api\AuthController@checked');
});
});
Route::group([
'prefix' => 'wp'
'prefix' => 'wp',
], function () {
Route::group([
'middleware' => 'auth:api'
], function() {
'middleware' => 'auth:api',
], function () {
Route::post('show', 'Api\ShoppingUserController@show');
Route::post('store', 'Api\ShoppingUserController@store');
Route::post('update', 'Api\ShoppingUserController@update');
@ -49,4 +46,4 @@ Route::group([
Route::post('cancel', 'Api\ShoppingUserController@cancel');
Route::post('open', 'Api\ShoppingUserController@open');
});
});
});

View file

@ -0,0 +1,136 @@
<?php
use App\Cron\UserMakeOrder;
use App\Enums\PaymentProvider;
use App\Models\Setting;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\Payment\Contracts\PaymentGatewayInterface;
use App\Services\Payment\DTOs\AboChargeResult;
use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
use App\Services\Payment\Payone\PayonePaymentService;
use App\Services\Payment\PayoneGateway;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
// --- Schalter: Anbieter für neue Vorgänge ---
it('defaults to payone when no provider switch is configured', function () {
expect(app(PaymentProviderManager::class)->providerForNewCheckout())
->toBe(PaymentProvider::Payone);
});
it('reads the active provider for new checkouts from the settings switch', function () {
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'stripe', 'text');
expect(app(PaymentProviderManager::class)->providerForNewCheckout())
->toBe(PaymentProvider::Stripe);
});
it('falls back to payone for unknown switch values', function () {
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'klarna', 'text');
expect(app(PaymentProviderManager::class)->providerForNewCheckout())
->toBe(PaymentProvider::Payone);
});
// --- Bestandsvorgänge: Anbieter aus der Spalte, schalterunabhängig ---
it('backfills existing abos with payone as their provider', function () {
$abo = makeMeAbo();
expect($abo->fresh()->payment_provider)->toBe('payone');
});
it('resolves the provider of an existing abo from its column even when the switch says stripe', function () {
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'stripe', 'text');
$abo = makeMeAbo();
expect(app(PaymentProviderManager::class)->providerFor($abo->fresh()))
->toBe(PaymentProvider::Payone);
});
it('resolves a stripe abo and a stripe shopping payment from their columns', function () {
$abo = makeMeAbo();
$abo->update(['payment_provider' => 'stripe']);
$payment = new ShoppingPayment(['payment_provider' => 'stripe']);
$manager = app(PaymentProviderManager::class);
expect($manager->providerFor($abo->fresh()))->toBe(PaymentProvider::Stripe)
->and($manager->providerFor($payment))->toBe(PaymentProvider::Stripe);
});
// --- Gateway-Auflösung ---
it('resolves the payone gateway adapter for the payone provider', function () {
$gateway = app(PaymentProviderManager::class)->gateway(PaymentProvider::Payone);
expect($gateway)->toBeInstanceOf(PayoneGateway::class)
->and($gateway)->toBeInstanceOf(PaymentGatewayInterface::class)
->and($gateway->provider())->toBe(PaymentProvider::Payone);
});
it('rejects the stripe gateway until it is implemented', function () {
app(PaymentProviderManager::class)->gateway(PaymentProvider::Stripe);
})->throws(RuntimeException::class);
// --- AboChargeResult-Mapping ---
it('maps an approved payone result to a successful charge result', function () {
$result = AboChargeResult::fromPayoneResult(
PayonePaymentResult::fromResponse(['status' => 'APPROVED', 'txid' => 'tx-123'])
);
expect($result->success)->toBeTrue()
->and($result->provider)->toBe(PaymentProvider::Payone)
->and($result->status)->toBe('APPROVED')
->and($result->transactionId)->toBe('tx-123')
->and($result->errorMessage)->toBeNull();
});
it('maps a declined payone result with its error messages', function () {
$result = AboChargeResult::fromPayoneResult(PayonePaymentResult::fromResponse([
'status' => 'ERROR',
'errormessage' => 'Card declined',
'customermessage' => 'Bitte andere Karte verwenden.',
]));
expect($result->success)->toBeFalse()
->and($result->status)->toBe('ERROR')
->and($result->errorMessage)->toBe('Card declined')
->and($result->customerMessage)->toBe('Bitte andere Karte verwenden.');
});
// --- PayoneGateway delegiert 1:1 an den bestehenden Service ---
it('delegates abo charges to the payone payment service', function () {
$abo = new UserAbo;
$order = new ShoppingOrder;
$shoppingUser = new ShoppingUser;
$service = Mockery::mock(PayonePaymentService::class);
$service->shouldReceive('chargeAbo')
->once()
->with($abo, $order, $shoppingUser, 1999, 'EUR')
->andReturn(PayonePaymentResult::fromResponse(['status' => 'APPROVED', 'txid' => 'tx-9']));
$result = (new PayoneGateway($service))->chargeAbo($abo, $order, $shoppingUser, 1999, 'EUR');
expect($result->success)->toBeTrue()
->and($result->transactionId)->toBe('tx-9');
});
// --- Cron-Guard: kein Fremd-Provider durch den PAYONE-Pfad ---
it('blocks abo charging in the cron for non-payone providers', function () {
$abo = makeMeAbo();
$abo->update(['payment_provider' => 'stripe']);
(new UserMakeOrder($abo->fresh()))->makePayment();
})->throws(RuntimeException::class, 'noch nicht implementiert');

View file

@ -0,0 +1,449 @@
<?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');
});

View file

@ -0,0 +1,32 @@
<?php
use App\Enums\PaymentProvider;
use Stripe\StripeClient;
use Tests\TestCase;
uses(TestCase::class);
it('loads the stripe configuration from the environment', function () {
expect(config('stripe.secret'))->toBe('sk_test_fake_suite_key')
->and(config('stripe.key'))->toBe('pk_test_fake_suite_key')
->and(config('stripe.webhook_secret'))->toBe('whsec_fake_suite_secret')
->and(config('stripe.mode'))->toBe('test');
});
it('does not hard-fail the boot when stripe secrets are missing', function () {
config(['stripe.secret' => null, 'stripe.webhook_secret' => null]);
expect(config('stripe.secret'))->toBeNull()
->and(config('stripe.mode'))->toBe('test');
});
it('exposes both payment providers as enum', function () {
expect(PaymentProvider::Payone->value)->toBe('payone')
->and(PaymentProvider::Stripe->value)->toBe('stripe')
->and(PaymentProvider::from('stripe'))->toBe(PaymentProvider::Stripe)
->and(PaymentProvider::tryFrom('unknown'))->toBeNull();
});
it('has the stripe sdk installed', function () {
expect(class_exists(StripeClient::class))->toBeTrue();
});

View file

@ -0,0 +1,241 @@
<?php
use App\Models\PaymentTransaction;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\StripeWebhookEvent;
use App\Models\UserShop;
use App\Services\Payment\Stripe\StripeClientWrapper;
use App\Services\Payment\Stripe\StripeCustomerService;
use App\Services\Payment\Stripe\StripeTransactionRecorder;
use App\Services\Payment\Stripe\StripeWebhookHandler;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Customer;
use Stripe\StripeClient;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
// --- Helpers ---
function stripeEventPayload(string $type, array $object, string $eventId = 'evt_test_1'): string
{
return json_encode([
'id' => $eventId,
'object' => 'event',
'api_version' => '2026-06-30',
'type' => $type,
'data' => ['object' => $object],
]);
}
function stripeSignature(string $payload, ?string $secret = null): string
{
$secret = $secret ?? config('stripe.webhook_secret');
$timestamp = time();
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
return "t={$timestamp},v1={$signed}";
}
function makeStripeShoppingPayment(array $overrides = []): ShoppingPayment
{
$owner = User::forceCreate([
'email' => 'stripe-wh-owner-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$country = ShippingCountry::first()
?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $owner->id,
'billing_firstname' => 'Stripe',
'billing_lastname' => 'Test',
'billing_email' => 'stripe-wh-'.uniqid().'@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
$userShop = UserShop::first() ?? UserShop::create([
'user_id' => $owner->id,
'name' => 'WH Stripe Shop',
'slug' => 'wh-stripe-'.uniqid(),
'domain' => 'wh-stripe.local',
]);
$order = ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $owner->id,
'country_id' => $country->country_id,
'user_shop_id' => $userShop->id,
'total' => 99.00,
'paid' => false,
'mode' => 'test',
]);
return ShoppingPayment::create(array_merge([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'reference' => 'test-'.uniqid(),
'amount' => 9900,
'currency' => 'EUR',
'clearingtype' => 'stripe',
'mode' => 'test',
], $overrides));
}
// --- Signatur & Idempotenz ---
it('rejects a webhook with an invalid signature and stores nothing', function () {
$payload = stripeEventPayload('payment_intent.succeeded', ['id' => 'pi_1', 'object' => 'payment_intent']);
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload, 'whsec_wrong'));
expect($result->statusCode)->toBe(400)
->and($result->state)->toBe('invalid_signature')
->and(StripeWebhookEvent::count())->toBe(0);
});
it('answers with 500 when the webhook secret is not configured so stripe retries', function () {
config(['stripe.webhook_secret' => '']);
$payload = stripeEventPayload('payment_intent.succeeded', ['id' => 'pi_1', 'object' => 'payment_intent']);
$result = app(StripeWebhookHandler::class)->handle($payload, 't=1,v1=abc');
expect($result->statusCode)->toBe(500)
->and($result->state)->toBe('not_configured');
});
it('acknowledges but ignores a duplicate event id', function () {
$object = ['id' => 'pi_dup', 'object' => 'payment_intent', 'status' => 'succeeded'];
$payload = stripeEventPayload('payment_intent.succeeded', $object, 'evt_dup');
$handler = app(StripeWebhookHandler::class);
$first = $handler->handle($payload, stripeSignature($payload));
$second = $handler->handle($payload, stripeSignature($payload));
expect($first->state)->toBe('processed')
->and($second->state)->toBe('duplicate')
->and($second->statusCode)->toBe(200)
->and(StripeWebhookEvent::count())->toBe(1);
});
// --- Verarbeitung & Protokollierung ---
it('stores a valid event of an unhandled type and acknowledges it', function () {
$payload = stripeEventPayload('customer.created', ['id' => 'cus_1', 'object' => 'customer'], 'evt_unhandled');
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
expect($result->state)->toBe('ignored')
->and($result->statusCode)->toBe(200)
->and(StripeWebhookEvent::whereEventId('evt_unhandled')->exists())->toBeTrue();
});
it('records a payment transaction when the payment is resolvable via metadata', function () {
$payment = makeStripeShoppingPayment();
$object = [
'id' => 'pi_meta',
'object' => 'payment_intent',
'status' => 'succeeded',
'livemode' => false,
'client_secret' => 'pi_meta_secret_abc',
'metadata' => ['shopping_payment_id' => (string) $payment->id],
];
$payload = stripeEventPayload('payment_intent.succeeded', $object, 'evt_meta');
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
$transaction = PaymentTransaction::where('shopping_payment_id', $payment->id)->first();
expect($result->state)->toBe('processed')
->and($transaction)->not->toBeNull()
->and($transaction->provider)->toBe('stripe')
->and($transaction->txid)->toBe('pi_meta')
->and($transaction->status)->toBe('succeeded')
->and($transaction->mode)->toBe('test')
->and($transaction->transmitted_data)->not->toHaveKey('client_secret');
});
it('resolves the payment via external_reference and marks the event processed', function () {
$payment = makeStripeShoppingPayment(['external_reference' => 'pi_ext_1']);
$object = ['id' => 'pi_ext_1', 'object' => 'payment_intent', 'status' => 'processing', 'livemode' => false];
$payload = stripeEventPayload('payment_intent.processing', $object, 'evt_ext');
app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
expect(PaymentTransaction::where('shopping_payment_id', $payment->id)->count())->toBe(1)
->and(StripeWebhookEvent::whereEventId('evt_ext')->first()->processed_at)->not->toBeNull();
});
it('redacts client_secret recursively from stored payloads', function () {
$redacted = StripeTransactionRecorder::redactPayload([
'id' => 'pi_1',
'client_secret' => 'geheim',
'charges' => ['data' => [['id' => 'ch_1', 'client_secret' => 'auch-geheim']]],
]);
expect($redacted)->not->toHaveKey('client_secret')
->and($redacted['charges']['data'][0])->not->toHaveKey('client_secret')
->and($redacted['id'])->toBe('pi_1');
});
// --- Client-Wrapper: verschobener Hard-Fail ---
it('fails hard on first use when the stripe secret is missing', function () {
$wrapper = new StripeClientWrapper(null);
expect($wrapper->isConfigured())->toBeFalse()
->and(fn () => $wrapper->client())->toThrow(RuntimeException::class, 'STRIPE_SECRET');
});
it('builds a stripe client lazily when a secret is configured', function () {
$wrapper = new StripeClientWrapper('sk_test_fake');
expect($wrapper->isConfigured())->toBeTrue()
->and($wrapper->client())->toBeInstanceOf(StripeClient::class)
->and($wrapper->client())->toBe($wrapper->client());
});
// --- Customer-Service ---
it('returns an existing stripe customer id without calling the api', function () {
$user = User::forceCreate([
'email' => 'stripe-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$user->forceFill(['stripe_customer_id' => 'cus_existing'])->save();
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldNotReceive('client');
expect((new StripeCustomerService($wrapper))->customerIdFor($user->fresh()))
->toBe('cus_existing');
});
it('creates a stripe customer once and persists its id', function () {
$user = User::forceCreate([
'email' => 'stripe-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->once()
->andReturn(Customer::constructFrom(['id' => 'cus_new_1', 'object' => 'customer']));
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('client')->andReturn($client);
$customerId = (new StripeCustomerService($wrapper))->customerIdFor($user);
expect($customerId)->toBe('cus_new_1')
->and($user->fresh()->stripe_customer_id)->toBe('cus_new_1');
});