diff --git a/.env.example b/.env.example
index edd7a4cc..67a3e16a 100644
--- a/.env.example
+++ b/.env.example
@@ -87,3 +87,14 @@ DHL_DEBUG_MODE=false # true für detaillierte Debug-Informationen
# Erlaubt Business-Commands an jedem Tag (nur für Test-Server!)
# Auf Live-Server muss dies false sein oder weggelassen werden
BUSINESS_FORCE_EXECUTE=false
+
+# =============================================================================
+# PAYONE KONFIGURATION
+# =============================================================================
+PAYONE_URL=checkout.example.test
+PAYONE_TS=checkout.example.test/transaction/status
+# MD5-Hash des PAYONE-Portal-Keys (NICHT der Klartext-Key). Pflichtwert:
+# Fehlt er, schlägt der Application-Boot bewusst hart fehl.
+# Hash erzeugen (verdeckte Eingabe, schreibt optional direkt in die .env):
+# php artisan payone:hash-key --write
+PAYONE_KEY_HASH=
diff --git a/app/Console/Commands/PayoneHashKey.php b/app/Console/Commands/PayoneHashKey.php
new file mode 100644
index 00000000..853f2a55
--- /dev/null
+++ b/app/Console/Commands/PayoneHashKey.php
@@ -0,0 +1,86 @@
+components->info('Der Klartext-Key wird nur flüchtig zur Hash-Erzeugung verwendet und nirgends gespeichert oder geloggt.');
+
+ $plainKey = trim((string) password(
+ label: 'PAYONE-Portal-Key (Klartext, verdeckte Eingabe)',
+ required: true,
+ ));
+
+ if ($plainKey === '') {
+ $this->components->error('Es wurde kein Key eingegeben.');
+
+ return self::FAILURE;
+ }
+
+ $hash = hash('md5', $plainKey);
+
+ $this->newLine();
+ $this->components->twoColumnDetail('PAYONE_KEY_HASH', $hash);
+ $this->newLine();
+
+ if (! $this->option('write')) {
+ $this->line(' Ohne --write: Wert manuell als PAYONE_KEY_HASH in die .env übernehmen.');
+
+ return self::SUCCESS;
+ }
+
+ if (! confirm("PAYONE_KEY_HASH in {$this->envPath()} schreiben/aktualisieren?", default: true)) {
+ $this->components->warn('Abgebrochen – .env wurde nicht verändert.');
+
+ return self::SUCCESS;
+ }
+
+ if (! $this->writeHashToEnv($hash)) {
+ return self::FAILURE;
+ }
+
+ $this->components->info('PAYONE_KEY_HASH in die .env geschrieben.');
+ $this->line(' Danach php artisan config:clear ausführen (bzw. config neu cachen).');
+
+ return self::SUCCESS;
+ }
+
+ private function envPath(): string
+ {
+ return base_path('.env');
+ }
+
+ private function writeHashToEnv(string $hash): bool
+ {
+ $path = $this->envPath();
+
+ if (! is_file($path) || ! is_writable($path)) {
+ $this->components->error(".env nicht gefunden oder nicht beschreibbar: {$path}");
+
+ return false;
+ }
+
+ $contents = (string) file_get_contents($path);
+ $line = 'PAYONE_KEY_HASH='.$hash;
+
+ if (preg_match('/^PAYONE_KEY_HASH=.*$/m', $contents)) {
+ $contents = preg_replace('/^PAYONE_KEY_HASH=.*$/m', $line, $contents);
+ } else {
+ $contents = rtrim($contents, "\n")."\n".$line."\n";
+ }
+
+ return file_put_contents($path, $contents) !== false;
+ }
+}
diff --git a/app/Cron/UserMakeOrder.php b/app/Cron/UserMakeOrder.php
index 8defbdb3..8a39b95f 100644
--- a/app/Cron/UserMakeOrder.php
+++ b/app/Cron/UserMakeOrder.php
@@ -3,11 +3,11 @@
namespace App\Cron;
use App\Http\Controllers\Pay\PayoneController;
-use App\Models\PaymentTransaction;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\UserAbo;
use App\Services\AboOrderCart;
+use App\Services\Payment\Payone\PayoneTransactionRecorder;
use Illuminate\Support\Facades\Log;
use Yard;
@@ -79,7 +79,7 @@ class UserMakeOrder
\Log::info('Response: '.json_encode($response));
// $response = $this->pay->ResponseData(true);
- Log::info('Zahlungsvorgang abgeschlossen für UserAbo ID: '.$this->userAbo->id.', Status: '.($response->status ?? 'unbekannt'));
+ Log::info('Zahlungsvorgang abgeschlossen für UserAbo ID: '.$this->userAbo->id.', Status: '.(is_array($response) ? ($response['status'] ?? 'unbekannt') : 'unbekannt'));
return $response;
} catch (\Exception $e) {
@@ -100,19 +100,12 @@ class UserMakeOrder
return;
}
- PaymentTransaction::create([
- 'shopping_payment_id' => $shoppingPayment->id,
- 'request' => 'authorization',
- 'txid' => $responseData['txid'] ?? 0,
- 'userid' => $responseData['userid'] ?? $this->userAbo->payone_userid ?? 0,
- 'status' => $responseData['status'],
- 'txaction' => $responseData['txaction'] ?? null,
- 'transmitted_data' => $responseData,
- 'errorcode' => $responseData['errorcode'] ?? null,
- 'errormessage' => $responseData['errormessage'] ?? null,
- 'customermessage' => $responseData['customermessage'] ?? null,
- 'mode' => $shoppingPayment->mode,
- ]);
+ PayoneTransactionRecorder::recordFromResponse(
+ $shoppingPayment,
+ 'authorization',
+ $responseData,
+ (int) ($this->userAbo->payone_userid ?? 0),
+ );
}
public function getShoppingPayment()
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index ae1c3266..9a2730e9 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -2,12 +2,14 @@
namespace App\Exceptions;
-use Throwable;
+use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
+use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
-use Symfony\Component\ErrorHandler\Exception\FlattenException;
-use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer;
+use Symfony\Component\ErrorHandler\Exception\FlattenException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Throwable;
class Handler extends ExceptionHandler
{
@@ -17,7 +19,9 @@ class Handler extends ExceptionHandler
* @var array
*/
protected $dontReport = [
- //
+ // PAYONE-Transportfehler werden bereits via MyLog protokolliert (inkl. Mail);
+ // kein zusätzlicher Exception-Report nötig (entspricht dem alten abort(403)-Verhalten).
+ PayoneTransportException::class,
];
/**
@@ -33,7 +37,6 @@ class Handler extends ExceptionHandler
/**
* Report or log an exception.
*
- * @param \Throwable $exception
* @return void
*
* @throws \Exception
@@ -51,13 +54,18 @@ class Handler extends ExceptionHandler
* Render an exception into an HTTP response.
*
* @param \Illuminate\Http\Request $request
- * @param \Throwable $exception
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \Throwable
*/
public function render($request, Throwable $exception)
{
+ // Fallback für nicht im Checkout abgefangene PAYONE-Transportfehler:
+ // identisch zum früheren abort(403, $message) in Payone::sendRequest.
+ if ($exception instanceof PayoneTransportException) {
+ $exception = new HttpException(403, $exception->getMessage(), $exception);
+ }
+
return parent::render($request, $exception);
}
@@ -65,7 +73,6 @@ class Handler extends ExceptionHandler
* Convert an authentication exception into a response.
*
* @param \Illuminate\Http\Request $request
- * @param \Illuminate\Auth\AuthenticationException $exception
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function unauthenticated($request, \Illuminate\Auth\AuthenticationException $exception)
@@ -83,12 +90,12 @@ class Handler extends ExceptionHandler
// 'crm' => 'login', // CRM hat eine eigene login route
// default => 'login'
// };
-
+
// Temporär: Verwende Standard-Login-Route
return redirect()->guest(route('login'));
} catch (\Exception $e) {
// Fallback: Weiterleitung zur Hauptdomain
- return redirect()->guest('https://' . config('app.domain') . config('app.tld_care') . '/login');
+ return redirect()->guest('https://'.config('app.domain').config('app.tld_care').'/login');
}
}
@@ -99,24 +106,23 @@ class Handler extends ExceptionHandler
$handler = new HtmlErrorRenderer(true); // boolean, true raises debug flag...
$css = $handler->getStylesheet();
$content = $handler->getBody($e);
- //Mail::to(config('app.exception_mail'))->send(new MailContact($contact));
+ // Mail::to(config('app.exception_mail'))->send(new MailContact($contact));
// Verwende normale Mail-Klasse statt Facade, um Probleme bei der Initialisierung zu vermeiden
$to = config('app.exception_mail');
- $subject = 'mivita Exception: ' . \Request::fullUrl();
+ $subject = 'mivita Exception: '.\Request::fullUrl();
if ($to) {
\Mail::send('emails.exception', compact('css', 'content'), function ($message) use ($to, $subject) {
$message
->to($to)
- ->subject($subject)
- ;
+ ->subject($subject);
});
}
} catch (Throwable $ex) {
// Einfache Fehlerprotokollierung ohne Facade
file_put_contents(
- storage_path('logs/laravel-' . date('Y-m-d') . '.log'),
- '[' . date('Y-m-d H:i:s') . '] exception-handler-error: ' . $ex->getMessage() . "\n",
+ storage_path('logs/laravel-'.date('Y-m-d').'.log'),
+ '['.date('Y-m-d H:i:s').'] exception-handler-error: '.$ex->getMessage()."\n",
FILE_APPEND
);
}
diff --git a/app/Http/Controllers/Api/PayoneController.php b/app/Http/Controllers/Api/PayoneController.php
index fe5087d3..538fdcab 100644
--- a/app/Http/Controllers/Api/PayoneController.php
+++ b/app/Http/Controllers/Api/PayoneController.php
@@ -3,247 +3,17 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
-use App\Models\PaymentTransaction;
-use App\Models\ShoppingOrder;
-use App\Models\ShoppingPayment;
-use App\Services\CheckoutFunnelTracker;
-use App\Services\MyLog;
-use App\Services\Payment;
-use App\Services\ShoppingUserService;
-use App\Services\Util;
+use App\Services\Payment\Payone\PayoneWebhookHandler;
+use Illuminate\Http\Response;
class PayoneController extends Controller
{
- public function __construct() {}
+ public function __construct(private readonly PayoneWebhookHandler $webhookHandler) {}
- public function paymentStatus()
+ public function paymentStatus(): Response
{
+ $result = $this->webhookHandler->handle(\Request::all());
- $data = \Request::all();
- // test para
-
- /* $data = [
- 'key' => '698fb2555f8b2efc74f60b2121421f45',
- 'txaction' => 'paid',
- 'clearingtype' => 'wlt',
- 'userid' => '158723953',
- 'txid' => '321623031',
- 'price' => '89.00',
- 'param' => '1', //$this->shopping_order->id,
- 'reference' => '15c83aee2766c3',
- ];
-
- */
-
- if (! isset($data['key']) || ! isset($data['param']) || ! isset($data['userid']) || ! isset($data['txid']) || ! isset($data['reference']) || ! isset($data['price'])) {
- MyLog::writeLog(
- 'payone',
- 'error',
- 'Error:2001 App\Http\Controllers\Api\PayoneController::paymentStatus parameter incomplete',
- $data
- );
- echo 'TSOK';
- exit;
- }
-
- if ($data['key'] != config('payone.defaults.key')) {
- MyLog::writeLog(
- 'payone',
- 'error',
- 'Error:2002 App\Http\Controllers\Api\PayoneController::paymentStatus Key error',
- $data
- );
- echo 'TSOK';
- exit;
- }
-
- $shopping_order = ShoppingOrder::find($data['param']);
- if (! $shopping_order) {
- MyLog::writeLog(
- 'payone',
- 'error',
- 'Error:2003 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingOrder not found:',
- $data
- );
- echo 'TSOK';
- exit;
- }
-
- $shopping_payment = ShoppingPayment::where('reference', $data['reference'])->first();
- if (! $shopping_payment) {
- MyLog::writeLog(
- 'payone',
- 'error',
- 'Error:2004 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingPayment not found',
- $data
- );
- echo 'TSOK';
- exit;
- }
-
- if ($shopping_payment->shopping_order_id != $shopping_order->id) {
- MyLog::writeLog(
- 'payone',
- 'error',
- 'Error:2005 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingPayment no realation ShoppingOrder',
- $data
- );
- echo 'TSOK';
- exit;
- }
-
- $price = number_format((round($data['price'], 2) * 100), 0, '.', '');
- $price_amount = number_format($shopping_payment->amount, 0, '.', '');
- if ($price_amount != $price) {
- $data['shopping_payment-amount'] = $price_amount;
- $data['price-amount'] = $price;
- MyLog::writeLog(
- 'payone',
- 'error',
- 'Error:2006 App\Http\Controllers\Api\PayoneController::paymentStatus Price error',
- $data
- );
- echo 'TSOK';
- exit;
- }
- /*
- * Payone sendet dieselbe txaction oft mehrfach (v. a. "appointed"). War der Status
- * bereits auf ShoppingPayment gespeichert, ist das ein Duplikat: TSOK, keine Doppel-Verarbeitung.
- * Ausnahme: erneutes "paid", obwohl die Bestellung noch nicht als bezahlt gefuehrt wird (Recovery).
- */
- if ($shopping_payment->txaction == $data['txaction']) {
- if ($data['txaction'] === 'paid' && $shopping_order->txaction === 'paid') {
- MyLog::writeLog(
- 'payone',
- 'notice',
- 'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (already paid)',
- $data,
- false
- );
- echo 'TSOK';
- exit;
- }
-
- if (in_array($data['txaction'], ['appointed', 'failed', 'pending'], true)) {
- MyLog::writeLog(
- 'payone',
- 'info',
- 'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (same txaction)',
- [
- 'reference' => $data['reference'] ?? null,
- 'param' => $data['param'] ?? null,
- 'txaction' => $data['txaction'],
- 'txid' => $data['txid'] ?? null,
- ],
- false
- );
- echo 'TSOK';
- exit;
- }
- }
-
- // create transaction
- PaymentTransaction::create([
- 'shopping_payment_id' => $shopping_payment->id,
- 'request' => 'transaction',
- 'txid' => $data['txid'],
- 'userid' => $data['userid'],
- 'status' => 'PAYONE',
- 'key' => $data['key'],
- 'txaction' => $data['txaction'],
- 'transmitted_data' => Util::utf8ize($data),
- 'mode' => $data['mode'],
- 'errorcode' => $data['errorcode'] ?? null,
- 'errormessage' => $data['errormessage'] ?? null,
- 'customermessage' => $data['customermessage'] ?? null,
- ]);
-
- // Define txaction priority (higher number = higher priority)
- $txaction_priority = [
- 'appointed' => 1,
- 'pending' => 2,
- 'failed' => 3,
- 'paid' => 10, // highest priority - final state
- ];
-
- $current_priority = isset($txaction_priority[$shopping_order->txaction]) ? $txaction_priority[$shopping_order->txaction] : 0;
- $new_priority = isset($txaction_priority[$data['txaction']]) ? $txaction_priority[$data['txaction']] : 0;
-
- // Only update txaction if new priority is higher than current
- if ($new_priority > $current_priority) {
- $shopping_order->txaction = $data['txaction'];
- $shopping_order->save();
- $shopping_payment->txaction = $data['txaction'];
- $shopping_payment->save();
- } else {
- MyLog::writeLog(
- 'payone',
- 'info',
- 'App\Http\Controllers\Api\PayoneController::paymentStatus - txaction not updated (current: '.$shopping_order->txaction.' has higher/equal priority than new: '.$data['txaction'].')',
- $data,
- false
- );
- }
-
- $send_link = false;
- $send_mail = true;
- if ($data['txaction'] === 'failed') {
- $shopping_order->setUserHistoryValue(['status' => 6]);
- Util::setInstanceStatusByPayment($shopping_payment, 5);
- }
- if ($data['txaction'] === 'appointed') {
- $shopping_order->setUserHistoryValue(['status' => 7]);
- ShoppingUserService::snycOrdersByShoppingOrder($shopping_order);
- Util::setInstanceStatusByPayment($shopping_payment, 4);
- }
-
- if ($data['txaction'] === 'paid') {
- // Use DB transaction and row locking to prevent race conditions
- \DB::beginTransaction();
- try {
- // Lock the shopping order row to prevent concurrent processing
- $locked_order = ShoppingOrder::where('id', $shopping_order->id)
- ->lockForUpdate()
- ->first();
- // Double-check if payment was already processed
- if (! $locked_order->paid) {
- $send_link = Payment::paymentStatusPaidAction($locked_order, true, $shopping_payment);
- \DB::commit();
- } else {
- $send_mail = false;
- \DB::commit();
- }
- } catch (\Exception $e) {
- \DB::rollBack();
- MyLog::writeLog(
- 'payone',
- 'error',
- 'Error:2008 App\Http\Controllers\Api\PayoneController::paymentStatus Transaction failed',
- ['error' => $e->getMessage(), 'data' => $data]
- );
- throw $e;
- }
-
- CheckoutFunnelTracker::confirmedPayment(
- shoppingPaymentId: $shopping_payment->id,
- txaction: 'paid',
- metadata: ['mode' => $data['mode'] ?? null, 'txid' => $data['txid'] ?? null],
- );
- }
-
- if ($data['txaction'] === 'appointed') {
- CheckoutFunnelTracker::confirmedPayment(
- shoppingPaymentId: $shopping_payment->id,
- txaction: 'appointed',
- metadata: ['mode' => $data['mode'] ?? null],
- );
- }
-
- $data['send_link'] = $send_link;
- if ($send_mail) {
- Payment::paymentStatusSendMail($shopping_order, $shopping_payment, $data);
- }
- echo 'TSOK';
- exit;
+ return response($result->response);
}
}
diff --git a/app/Http/Controllers/Pay/PayoneController.php b/app/Http/Controllers/Pay/PayoneController.php
index 6d74cc87..62a40889 100644
--- a/app/Http/Controllers/Pay/PayoneController.php
+++ b/app/Http/Controllers/Pay/PayoneController.php
@@ -1,36 +1,31 @@
'PayPal',
- 'cc' => 'CreditCard',
- 'sb#GPY' => 'giropay',
- 'sb#PNT' => 'Sofort',
- 'wlt#PDT' => 'paydirekt',
- 'fnc' => 'Rechnungskauf',
- 'pref' => 'Vorauskasse',
- ];*/
-
namespace App\Http\Controllers\Pay;
use App\Http\Controllers\Controller;
-use App\Models\PaymentTransaction;
use App\Models\ShoppingPayment;
use App\Services\CheckoutFunnelTracker;
use App\Services\MyLog;
+use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
+use App\Services\Payment\Payone\PaymentMethodResolver;
+use App\Services\Payment\Payone\PayonePaymentService;
+use App\Services\Payment\Payone\PayoneRequestBuilder;
+use App\Services\Payment\Payone\PayoneTransactionRecorder;
use App\Services\Payone;
use Util;
+/**
+ * @deprecated Legacy stateful PAYONE payment facade. New code should depend on
+ * {@see PayonePaymentService} (Checkout-/Abo-/CC-Check) plus the extracted helpers
+ * {@see PayoneRequestBuilder}, {@see PaymentMethodResolver} and
+ * {@see PayoneTransactionRecorder} directly. This controller stays only as a thin
+ * facade for existing callers (CheckoutController, UserMakeOrder, PaymentHelper)
+ * and is migrated away in Phase 1.
+ *
+ * clearingtype reference (resolved in {@see PaymentMethodResolver}):
+ * cc = credit card, elv = SEPA debit, vor = prepayment, rec = invoice,
+ * sb = online bank transfer, wlt = e-wallet, fnc = financing.
+ */
class PayoneController extends Controller
{
const PREAUTHORIZATION = 'preauthorization';
@@ -53,11 +48,8 @@ class PayoneController extends Controller
private $prepayment = [];
- /* private $onlineTransfer = [];
- private $creditCard = []; */
private $deliveryData = [];
- // private $payment_method;
private $urls = [];
private $shopping_user;
@@ -68,10 +60,14 @@ class PayoneController extends Controller
private $reference;
+ private PayonePaymentService $paymentService;
+
+ private ?PayonePaymentResult $lastPaymentResult = null;
+
public function __construct()
{
-
$this->default = \Config::get('payone.defaults');
+ $this->paymentService = app(PayonePaymentService::class);
}
public function init($shopping_user, $shopping_order)
@@ -86,47 +82,16 @@ class PayoneController extends Controller
return $this->shopping_payment;
}
- public function setAboPayment($user_abo, $amount, $currency)
+ public function setAboPayment($user_abo, $amount, $currency): void
{
- $this->reference = substr(uniqid('m', false), 0, 16);
- $amount = intval($amount);
- $this->method = [
- 'clearingtype' => $user_abo->clearingtype,
- 'wallettype' => $user_abo->wallettype,
- 'pseudocardpan' => isset($user_abo->carddata['pseudocardpan']) ? $user_abo->carddata['pseudocardpan'] : '',
- 'cardexpiredate' => isset($user_abo->carddata['cardexpiredate']) ? $user_abo->carddata['cardexpiredate'] : '',
- 'userid' => $user_abo->payone_userid,
- 'onlinebanktransfertype' => '',
- 'request' => 'authorization',
- ];
-
- $this->aboInitPayment = [
- 'recurrence' => 'recurring',
- 'customer_is_present' => 'no',
- 'request' => 'authorization',
- 'amount' => $amount,
- ];
-
- $this->prepayment = [
- 'reference' => $this->reference, // a unique reference, e.g. order number
- 'amount' => $amount, // amount in smallest currency unit, i.e. cents
- 'currency' => $currency,
- 'param' => $this->shopping_order->id,
- ];
-
- $this->shopping_payment = ShoppingPayment::create([
- 'shopping_order_id' => $this->shopping_order->id,
- 'clearingtype' => $this->method['clearingtype'],
- 'wallettype' => $this->method['wallettype'],
- 'onlinebanktransfertype' => $this->method['onlinebanktransfertype'],
- 'carddata' => $user_abo->carddata,
- 'reference' => $this->reference,
- 'amount' => $amount,
- 'currency' => $currency,
- 'is_abo' => $this->shopping_order->is_abo,
- 'abo_interval' => 0,
- 'mode' => $this->shopping_order->mode,
- ]);
+ $this->lastPaymentResult = $this->paymentService->chargeAbo(
+ $user_abo,
+ $this->shopping_order,
+ $this->shopping_user,
+ intval($amount),
+ $currency,
+ );
+ $this->shopping_payment = $this->lastPaymentResult->shoppingPayment;
}
// make Payone payment
@@ -206,150 +171,48 @@ class PayoneController extends Controller
public function setPersonalData()
{
- $this->personalData = [
- 'firstname' => $this->shopping_user->billing_firstname,
- 'lastname' => $this->shopping_user->billing_lastname, // mandatory
- 'street' => $this->shopping_user->billing_address,
- 'zip' => $this->shopping_user->billing_zipcode,
- 'city' => $this->shopping_user->billing_city,
- 'country' => ($this->shopping_user->billing_country) ? $this->shopping_user->billing_country->code : 'DE', // mandatory
- 'email' => $this->shopping_user->billing_email,
- // "language" => ($this->shopping_user->billing_country) ? strtoupper($this->shopping_user->billing_country->code) : "DE", // mandatory
- 'language' => 'DE',
- ];
-
- /**
- * Paydirekt requires both, personal data and shipping data
- */
- /* $this->deliveryData = array(
- "shipping_firstname" => "Paul",
- "shipping_lastname" => "Neverpayer",
- "shipping_street" => "Hamburger Allee 26-28",
- "shipping_zip" => "60486",
- "shipping_city" => "Frankfurt am Main",
- "shipping_country" => "DE"
- );*/
+ $this->personalData = PayoneRequestBuilder::buildPersonalData($this->shopping_user);
}
private function setMethod($payment_method, $ret = [])
{
-
if ($payment_method) {
- if (strpos($payment_method, '#')) {
- $payment_method = explode('#', $payment_method);
- // wallet Paypal
- if ($payment_method[0] === 'wlt') {
- $this->method = [
- 'clearingtype' => 'wlt',
- 'wallettype' => $payment_method[1],
- 'onlinebanktransfertype' => '',
- 'request' => 'authorization',
- ];
- }
- // Online-Überweisung
- if ($payment_method[0] === 'sb') {
- $this->method = [
- 'clearingtype' => 'sb',
- 'wallettype' => '',
- 'onlinebanktransfertype' => $payment_method[1], // this is the type for Sofort.com
- 'bankcountry' => 'DE', // we need to know the country of the customer's bank, i.e. of the invoice address
- 'request' => 'authorization',
- ];
- }
-
- // Rechnungskauf
- if ($payment_method[0] === 'fnc') {
- // MIVITA
- if (isset($payment_method[1]) && $payment_method[1] === 'MIV') {
- $this->method = [
- 'clearingtype' => 'fnc',
- 'wallettype' => '',
- 'onlinebanktransfertype' => 'MIV',
- 'request' => 'authorization',
- ];
- }
- // PAYONE
- /* $this->method = [
- "clearingtype" => "fnc",
- "wallettype" => "",
- 'onlinebanktransfertype' => "",
- "financingtype" => "PYV",
- "request" => "genericpayment",
- "add_paydata[action]" => "pre_check",
- "add_paydata[payment_type]" => "Payolution-Invoicing",
- ];*/
- }
- }
- // vorkasse
- if ($payment_method === 'elv') {
- $this->method = [
- 'clearingtype' => 'elv',
- 'wallettype' => '',
- 'onlinebanktransfertype' => '',
- 'request' => 'authorization',
- 'mandate_identification' => $ret['elv']['mandate_identification'],
- 'iban' => $ret['elv']['iban'],
- 'bic' => $ret['elv']['bic'],
- 'bankaccountholder' => $ret['elv']['bankaccountholder'],
- // "bankcountry" => "DE",
- ];
- }
-
- // vorkasse
- if ($payment_method === 'vor') {
- $this->method = [
- 'clearingtype' => 'vor',
- 'wallettype' => '',
- 'onlinebanktransfertype' => '',
- 'request' => 'authorization',
- ];
- }
-
- // CreditCard
- if ($payment_method === 'cc') {
- // need the $cc_ret
- $this->method = [
- 'clearingtype' => 'cc',
- 'wallettype' => '',
- 'onlinebanktransfertype' => '',
- 'request' => 'authorization',
- 'pseudocardpan' => $ret['cc']['pseudocardpan'],
- // "xid" => "3-D Secure transaction ID"
- ];
- }
+ $this->method = PaymentMethodResolver::resolve($payment_method, $ret)->toArray();
}
}
- public function onlyPaymentResponse()
+ public function onlyPaymentResponse(): array
{
- $request = array_merge($this->default, $this->personalData, $this->deliveryData, $this->method, $this->prepayment, $this->aboInitPayment, $this->urls);
- $response = Payone::sendRequest($request);
+ // Abo path: chargeAbo (called from setAboPayment) already fetched the response.
+ if ($this->lastPaymentResult !== null) {
+ return $this->lastPaymentResult->toArray();
+ }
- return $response;
+ // Checkout path: state was set by setPrePayment + setPersonalData + setDeliverylData.
+ $request = PayoneRequestBuilder::buildRequest(
+ $this->default, $this->personalData, $this->deliveryData,
+ $this->method, $this->prepayment, $this->aboInitPayment, $this->urls,
+ );
+
+ return Payone::sendRequest($request);
}
public function ResponseData($is_abo = false)
{
- $request = array_merge($this->default, $this->personalData, $this->deliveryData, $this->method, $this->prepayment, $this->aboInitPayment, $this->urls);
+ $request = PayoneRequestBuilder::buildRequest($this->default, $this->personalData, $this->deliveryData, $this->method, $this->prepayment, $this->aboInitPayment, $this->urls);
if ($this->shopping_payment->clearingtype === 'fnc' && $this->shopping_payment->onlinebanktransfertype === 'MIV') {
- $payt = PaymentTransaction::create([
- 'shopping_payment_id' => $this->shopping_payment->id,
- 'request' => $this->method['request'],
- 'txid' => 0,
- 'userid' => 0,
- 'status' => 'FNCMIV',
- 'transmitted_data' => $request,
- 'txaction' => 'invoice_open',
- 'mode' => $this->shopping_payment->mode,
- ]);
+ $payt = PayoneTransactionRecorder::recordInvoicePayment(
+ $this->shopping_payment,
+ $this->method['request'],
+ $request,
+ );
Util::setUserHistoryValue(['status' => 5]);
if ($is_abo) {
return $this->reference;
}
return redirect(route('checkout.transaction_approved', [$payt->id, $this->reference]));
- exit;
}
$response = Payone::sendRequest($request);
/*
@@ -362,16 +225,11 @@ class PayoneController extends Controller
'PayPal Preauthorization Fehler: '.$response['errormessage'],
$response
);
- PaymentTransaction::create([
- 'shopping_payment_id' => $this->shopping_payment->id,
- 'request' => $this->method['request'],
- 'errorcode' => $response['errorcode'],
- 'errormessage' => $response['errormessage'],
- 'customermessage' => $response['customermessage'],
- 'transmitted_data' => $request,
- 'status' => $response['status'],
- 'mode' => $this->shopping_payment->mode,
- ]);
+ PayoneTransactionRecorder::recordFromResponse(
+ $this->shopping_payment,
+ $this->method['request'],
+ $response,
+ );
Util::setUserHistoryValue(['status' => 3]);
if ($is_abo) {
return $response;
@@ -383,36 +241,25 @@ class PayoneController extends Controller
}
if ($response['status'] === 'REDIRECT') {
- PaymentTransaction::create([
- 'shopping_payment_id' => $this->shopping_payment->id,
- 'request' => $this->method['request'],
- 'txid' => $response['txid'],
- 'userid' => $response['userid'],
- 'status' => $response['status'],
- 'mode' => $this->shopping_payment->mode,
-
- ]);
+ PayoneTransactionRecorder::recordFromResponse(
+ $this->shopping_payment,
+ $this->method['request'],
+ $response,
+ );
Util::setUserHistoryValue(['status' => 4]);
if ($is_abo) {
return $response;
}
return redirect()->away($response['redirecturl']);
- exit;
}
if ($response['status'] === 'APPROVED') {
- // header("Location: " . $response["redirecturl"]); // or other redirect method
- $payt = PaymentTransaction::create([
- 'shopping_payment_id' => $this->shopping_payment->id,
- 'request' => $this->method['request'],
- 'txid' => $response['txid'],
- 'userid' => $response['userid'],
- 'status' => $response['status'],
- 'transmitted_data' => $response,
- 'mode' => $this->shopping_payment->mode,
-
- ]);
+ $payt = PayoneTransactionRecorder::recordFromResponse(
+ $this->shopping_payment,
+ $this->method['request'],
+ $response,
+ );
Util::setUserHistoryValue(['status' => 5]);
if ($is_abo) {
return $response;
@@ -421,27 +268,25 @@ class PayoneController extends Controller
if ($payt->shopping_payment->clearingtype === 'vor') {
// vorkasse
return redirect(route('checkout.transaction_approved', [$payt->id, $this->reference]));
- exit;
}
if ($payt->shopping_payment->clearingtype === 'cc') {
// creditcard
return redirect(route('checkout.transaction_approved', [$payt->id, $this->reference]));
- exit;
}
if ($payt->shopping_payment->clearingtype === 'elv') {
// sepa
return redirect(route('checkout.transaction_approved', [$payt->id, $this->reference]));
- exit;
}
- var_dump($response);
- exit();
- // txid
- // Payment process ID (PAYONE)
- // userid
- // Debtor ID (PAYONE)
+ MyLog::writeLog(
+ 'payone',
+ 'error',
+ 'Error:1002 Unbekannter clearingtype bei APPROVED-Status. App\Http\Controllers\Pay\PayoneController::ResponseData',
+ ['clearingtype' => $payt->shopping_payment->clearingtype ?? null, 'txid' => $response['txid'] ?? null]
+ );
+ abort(403, 'Zahlung konnte nicht abgeschlossen werden. Unbekannte Zahlungsart. Fehlercode: 1002');
}
if ($response['status'] === 'PENDING') {
@@ -451,11 +296,7 @@ class PayoneController extends Controller
'Error:1000 Status PENDING App\Http\Controllers\Pay\PayoneController::ResponseData response status PENDING',
$response
);
- exit();
- // txid
- // Payment process ID (PAYONE)
- // userid
- // Debtor ID (PAYONE)
+ abort(503, 'Ihre Zahlung wird noch verarbeitet. Bitte haben Sie einen Moment Geduld. Fehlercode: 1000');
}
MyLog::writeLog(
'payone',
@@ -466,148 +307,22 @@ class PayoneController extends Controller
abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1001');
}
- public function checkCreditCard($data)
+ public function checkCreditCard($data): array
{
- $this->prepayment = [
- 'request' => 'creditcardcheck', // create account receivable and instantly book the amount
- 'cardholder' => $data['cc_cardholder_first'].' '.$data['cc_cardholder_last'],
- 'cardpan' => $data['cc_cardpan'],
- 'cardexpiredate' => substr($data['cc_cardexpireyear'], -2).$data['cc_cardexpiremonth'],
- 'cardtype' => $data['cc_cardtype'],
- 'cardcvc2' => $data['cc_cardcvc2'],
- 'storecarddata' => 'yes',
- 'language' => 'de',
- ];
- $request = array_merge($this->default, $this->prepayment);
-
- return Payone::sendRequest($request);
+ return $this->paymentService
+ ->checkCreditCard($data, $this->default['mode'] ?? 'test')
+ ->toArray();
}
- public function checkBankAccount($data, $amount, $currency, $shopping_user)
+ public function checkBankAccount($data, $amount, $currency, $shopping_user): array
{
- $this->shopping_user = $shopping_user;
- $this->setPersonalData();
-
- $this->prepayment = [
- 'clearingtype' => 'elv',
- 'amount' => $amount, // amount in smallest currency unit, i.e. cents
- 'currency' => $currency,
- 'request' => 'managemandate', // create account receivable and instantly book the amount
- 'bankaccountholder' => $data['elv_bankaccountholder'],
- 'iban' => $data['elv_iban'],
- 'bic' => $data['elv_bic'],
-
- // "language" => 'de',
- ];
-
- $request = array_merge($this->default, $this->personalData, $this->deliveryData, $this->method, $this->prepayment, $this->urls);
-
- return Payone::sendRequest($request);
+ return $this->paymentService
+ ->checkBankAccount($data, (int) $amount, $currency, $shopping_user, $this->default['mode'] ?? 'test')
+ ->toArray();
}
public function setDeliverylData($shopping_user)
{
- if ($shopping_user->same_as_billing == true) {
- $this->deliveryData = [
- 'shipping_firstname' => $shopping_user->billing_firstname,
- 'shipping_lastname' => $shopping_user->billing_lastname,
- 'shipping_zip' => $shopping_user->billing_zipcode,
- 'shipping_city' => $shopping_user->billing_city,
- 'shipping_country' => $shopping_user->billing_country->code,
- 'shipping_street' => $shopping_user->billing_address,
- ];
- } else {
- $this->deliveryData = [
- 'shipping_firstname' => $shopping_user->shipping_firstname,
- 'shipping_lastname' => $shopping_user->shipping_lastname,
- 'shipping_zip' => $shopping_user->shipping_zipcode,
- 'shipping_city' => $shopping_user->shipping_city,
- 'shipping_country' => $shopping_user->shipping_country->code,
- 'shipping_street' => $shopping_user->shipping_address,
- ];
- }
+ $this->deliveryData = PayoneRequestBuilder::buildDeliveryData($shopping_user);
}
-
- /* public function getPDFFile($mandateId)
- {
-
- $params['file_reference'] = $mandateId;//"XX-T0000000";
- $params['file_type'] = 'SEPA_MANDATE';
- $params['file_format'] = 'PDF';
- $request = array_merge($this->default, $params);
-
- return Payone::sendRequest($request);
- }
- */
-
- /* public function checkStatus(){
- // again, the default values will be needed
- $capture = array(
- "request" => "capture",
- "txid" => "your_txid",
- "sequencenumber" => "previous_sequencenumber_plus_one", // get this from the last received transactionsstatus
- "amount" => "your_amount", // you can either capture the full amount of the tx, or less
- "currency" => "EUR"
- );
- $request = array_merge($this->default, $capture);
- $response = Payone::sendRequest($request);
-
- } */
-
- // set for clearingtype
- // debit payment
- /*
- *
- *
- * $params['bankaccountholder'] = $paymentData['mopt_payone__debit_bankaccountholder'];
- $params['iban'] = $this->removeWhitespaces($paymentData['mopt_payone__debit_iban']);
- $params['bic'] = $this->removeWhitespaces($paymentData['mopt_payone__debit_bic']);
-
- * $params['bankcountry'] = $paymentData['mopt_payone__debit_bankcountry'];
- $params['bankaccount'] = $this->removeWhitespaces($paymentData['mopt_payone__debit_bankaccount']);
- $params['bankcode'] = $this->removeWhitespaces($paymentData['mopt_payone__debit_bankcode']);
-
-
- if (Shopware()->Session()->moptMandateData) {
- $params['mandate_identification'] = Shopware()->Session()->moptMandateData['mopt_payone__mandateIdentification'];
- }
- *
- * ["clearing_bankaccount"]=> string(10) "2599100003"
- ["clearing_bankcode"]=> string(8) "12345678"
- ["clearing_bankcountry"]=> string(2) "DE"
- ["clearing_bankname"]=> string(8) "Testbank"
- ["clearing_bankaccountholder"]=> string(11) "Test Nutzer"
- ["clearing_bankcity"]=> string(4) "Kiel"
- ["clearing_bankiban"]=> string(22) "DE00123456782599100003"
- ["clearing_bankbic"]=> string(8) "TESTTEST" }
- */
- /*
- * PNT Sofortbanking (DE, AT, CH, NL)
- GPY giropay (DE)
- EPS eps – online transfer (AT)
- PFF PostFinance E-Finance (CH)
- PFC PostFinance Card (CH)
- IDL iDEAL (NL)
- P24 Przelewy24 (PL)
- BCT Bancontact*/
- /*
- * iban
- * bic
- * bankcountry*/
-
- /* * Card type
- V Visa
- M MasterCard
- A American Express
- D Diners / Discover
- J JCB
- O Maestro International
-
-
-
-
-
-
-
-*/
}
diff --git a/app/Http/Controllers/Web/CheckoutController.php b/app/Http/Controllers/Web/CheckoutController.php
index e9383cad..0686a68b 100644
--- a/app/Http/Controllers/Web/CheckoutController.php
+++ b/app/Http/Controllers/Web/CheckoutController.php
@@ -13,6 +13,7 @@ use App\Services\AboHelper;
use App\Services\CheckoutFunnelTracker;
use App\Services\CustomerPriority;
use App\Services\Payment;
+use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
use App\Services\Shop;
use App\Services\Util;
use App\User;
@@ -258,31 +259,39 @@ class CheckoutController extends Controller
$result = [];
$payment_method = Request::get('payment_method');
- // Kreditkarte prüfen
- if ($payment_method === 'cc') {
- $result = $this->checkCreditCard($data, $shopping_user, $shopping_order);
- if (! is_array($result) || ! isset($result['returnstatus']) || $result['returnstatus'] !== 'VALID') {
- return $result;
+ try {
+ // Kreditkarte prüfen
+ if ($payment_method === 'cc') {
+ $result = $this->checkCreditCard($data, $shopping_user, $shopping_order);
+ if (! is_array($result) || ! isset($result['returnstatus']) || $result['returnstatus'] !== 'VALID') {
+ return $result;
+ }
}
- }
- // SEPA prüfen
- if ($payment_method === 'elv') {
- $result = $this->checkSepaAccount($data, $shopping_user, $shopping_order);
- if (! is_array($result) || ! isset($result['returnstatus']) || $result['returnstatus'] !== 'VALID') {
- return $result;
+ // SEPA prüfen
+ if ($payment_method === 'elv') {
+ $result = $this->checkSepaAccount($data, $shopping_user, $shopping_order);
+ if (! is_array($result) || ! isset($result['returnstatus']) || $result['returnstatus'] !== 'VALID') {
+ return $result;
+ }
}
+
+ // Zahlung vorbereiten
+ $pay = new PayoneController;
+ $pay->init($shopping_user, $shopping_order);
+ $amount = Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100;
+ $reference = $pay->setPrePayment($payment_method, $amount, 'EUR', $result);
+ $this->checkoutRepo->putSessionPayments('payment_reference', $reference);
+ $pay->setPersonalData();
+
+ return $pay->ResponseData();
+ } catch (PayoneTransportException $e) {
+ // Zahlungsanbieter nicht erreichbar: zurück zur Zahlungsseite mit Fehlermeldung
+ // (statt 403-Fehlerseite wie früher beim abort()).
+ Session::flash('errormessage', $e->getMessage());
+
+ return redirect(route('checkout.checkout_card'))->withInput(Request::all());
}
-
- // Zahlung vorbereiten
- $pay = new PayoneController;
- $pay->init($shopping_user, $shopping_order);
- $amount = Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100;
- $reference = $pay->setPrePayment($payment_method, $amount, 'EUR', $result);
- $this->checkoutRepo->putSessionPayments('payment_reference', $reference);
- $pay->setPersonalData();
-
- return $pay->ResponseData();
}
/**
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index ee049999..db106819 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,6 +2,7 @@
namespace App\Providers;
+use App\Services\Payment\Payone\PayonePaymentService;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
@@ -53,6 +54,12 @@ class AppServiceProvider extends ServiceProvider
*/
public function register()
{
+ // PAYONE payment service: built from merchant defaults, real Guzzle client at runtime.
+ // The per-request `mode` is always passed explicitly to the service methods, so the
+ // shared singleton needs only the base defaults from config.
+ $this->app->singleton(PayonePaymentService::class, function () {
+ return new PayonePaymentService(config('payone.defaults'));
+ });
if ($this->app->environment() !== 'production' && class_exists(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class)) {
$this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
diff --git a/app/Services/Payment/Payone/DTOs/PayonePaymentResult.php b/app/Services/Payment/Payone/DTOs/PayonePaymentResult.php
new file mode 100644
index 00000000..476e4f6c
--- /dev/null
+++ b/app/Services/Payment/Payone/DTOs/PayonePaymentResult.php
@@ -0,0 +1,67 @@
+ $rawResponse
+ */
+ public function __construct(
+ public readonly string $status,
+ public readonly array $rawResponse,
+ public readonly string $txid = '',
+ public readonly int $userid = 0,
+ public readonly ?string $redirectUrl = null,
+ public readonly ?string $errorMessage = null,
+ public readonly ?string $customerMessage = null,
+ public readonly ?string $errorCode = null,
+ public readonly ?ShoppingPayment $shoppingPayment = null,
+ ) {}
+
+ public function isApproved(): bool
+ {
+ return $this->status === 'APPROVED';
+ }
+
+ public function isRedirect(): bool
+ {
+ return $this->status === 'REDIRECT';
+ }
+
+ public function isError(): bool
+ {
+ return $this->status === 'ERROR';
+ }
+
+ public function isValid(): bool
+ {
+ return $this->status === 'VALID';
+ }
+
+ /** @return array */
+ public function toArray(): array
+ {
+ return $this->rawResponse;
+ }
+
+ /**
+ * @param array $response
+ */
+ public static function fromResponse(array $response, ?ShoppingPayment $shoppingPayment = null): self
+ {
+ return new self(
+ status: $response['status'] ?? 'UNKNOWN',
+ rawResponse: $response,
+ txid: (string) ($response['txid'] ?? ''),
+ userid: (int) ($response['userid'] ?? 0),
+ redirectUrl: $response['redirecturl'] ?? null,
+ errorMessage: $response['errormessage'] ?? null,
+ customerMessage: $response['customermessage'] ?? null,
+ errorCode: $response['errorcode'] ?? null,
+ shoppingPayment: $shoppingPayment,
+ );
+ }
+}
diff --git a/app/Services/Payment/Payone/DTOs/PayoneWebhookResult.php b/app/Services/Payment/Payone/DTOs/PayoneWebhookResult.php
new file mode 100644
index 00000000..c99c16d4
--- /dev/null
+++ b/app/Services/Payment/Payone/DTOs/PayoneWebhookResult.php
@@ -0,0 +1,49 @@
+ $context Extra data required for specific methods (e.g. cc.pseudocardpan, elv.iban).
+ *
+ * @throws InvalidArgumentException for unknown method strings.
+ */
+ public static function resolve(string $paymentMethod, array $context = []): PayoneMethodConfig
+ {
+ if (str_contains($paymentMethod, '#')) {
+ [$type, $subtype] = explode('#', $paymentMethod, 2);
+
+ return match ($type) {
+ 'wlt' => new PayoneMethodConfig([
+ 'clearingtype' => 'wlt',
+ 'wallettype' => $subtype,
+ 'onlinebanktransfertype' => '',
+ 'request' => 'authorization',
+ ]),
+ 'sb' => new PayoneMethodConfig([
+ 'clearingtype' => 'sb',
+ 'wallettype' => '',
+ 'onlinebanktransfertype' => $subtype,
+ 'bankcountry' => 'DE',
+ 'request' => 'authorization',
+ ]),
+ 'fnc' => match ($subtype) {
+ 'MIV' => new PayoneMethodConfig([
+ 'clearingtype' => 'fnc',
+ 'wallettype' => '',
+ 'onlinebanktransfertype' => 'MIV',
+ 'request' => 'authorization',
+ ]),
+ default => throw new InvalidArgumentException("Unknown fnc subtype: {$subtype}"),
+ },
+ default => throw new InvalidArgumentException("Unknown compound payment method type: {$type}"),
+ };
+ }
+
+ return match ($paymentMethod) {
+ 'cc' => new PayoneMethodConfig([
+ 'clearingtype' => 'cc',
+ 'wallettype' => '',
+ 'onlinebanktransfertype' => '',
+ 'request' => 'authorization',
+ 'pseudocardpan' => $context['cc']['pseudocardpan'] ?? '',
+ ]),
+ 'elv' => new PayoneMethodConfig([
+ 'clearingtype' => 'elv',
+ 'wallettype' => '',
+ 'onlinebanktransfertype' => '',
+ 'request' => 'authorization',
+ 'mandate_identification' => $context['elv']['mandate_identification'] ?? '',
+ 'iban' => $context['elv']['iban'] ?? '',
+ 'bic' => $context['elv']['bic'] ?? '',
+ 'bankaccountholder' => $context['elv']['bankaccountholder'] ?? '',
+ ]),
+ 'vor' => new PayoneMethodConfig([
+ 'clearingtype' => 'vor',
+ 'wallettype' => '',
+ 'onlinebanktransfertype' => '',
+ 'request' => 'authorization',
+ ]),
+ default => throw new InvalidArgumentException("Unknown payment method: {$paymentMethod}"),
+ };
+ }
+}
diff --git a/app/Services/Payment/Payone/PayoneMethodConfig.php b/app/Services/Payment/Payone/PayoneMethodConfig.php
new file mode 100644
index 00000000..76bb9647
--- /dev/null
+++ b/app/Services/Payment/Payone/PayoneMethodConfig.php
@@ -0,0 +1,19 @@
+ $params
+ */
+ public function __construct(private readonly array $params) {}
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->params;
+ }
+}
diff --git a/app/Services/Payment/Payone/PayonePaymentService.php b/app/Services/Payment/Payone/PayonePaymentService.php
new file mode 100644
index 00000000..4efa150a
--- /dev/null
+++ b/app/Services/Payment/Payone/PayonePaymentService.php
@@ -0,0 +1,128 @@
+ $defaults PAYONE merchant defaults from config('payone.defaults')
+ * @param Client|null $client Optional Guzzle client; inject a MockHandler client in tests.
+ */
+ public function __construct(
+ private readonly array $defaults,
+ private readonly ?Client $client = null,
+ ) {}
+
+ public function checkCreditCard(array $data, string $mode): PayonePaymentResult
+ {
+ $request = array_merge($this->defaults, ['mode' => $mode], [
+ 'request' => 'creditcardcheck',
+ 'cardholder' => $data['cc_cardholder_first'].' '.$data['cc_cardholder_last'],
+ 'cardpan' => $data['cc_cardpan'],
+ 'cardexpiredate' => substr($data['cc_cardexpireyear'], -2).$data['cc_cardexpiremonth'],
+ 'cardtype' => $data['cc_cardtype'],
+ 'cardcvc2' => $data['cc_cardcvc2'],
+ 'storecarddata' => 'yes',
+ 'language' => 'de',
+ ]);
+
+ return PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client));
+ }
+
+ public function checkBankAccount(
+ array $data,
+ int $amount,
+ string $currency,
+ ShoppingUser $shoppingUser,
+ string $mode,
+ ): PayonePaymentResult {
+ $defaults = array_merge($this->defaults, ['mode' => $mode]);
+ $personalData = PayoneRequestBuilder::buildPersonalData($shoppingUser);
+ $methodParams = [
+ 'clearingtype' => 'elv',
+ 'request' => 'managemandate',
+ 'bankaccountholder' => $data['elv_bankaccountholder'],
+ 'iban' => $data['elv_iban'],
+ 'bic' => $data['elv_bic'],
+ ];
+ $prepayment = [
+ 'amount' => $amount,
+ 'currency' => $currency,
+ ];
+
+ $request = PayoneRequestBuilder::buildRequest($defaults, $personalData, [], $methodParams, $prepayment);
+
+ return PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client));
+ }
+
+ /**
+ * Charges a recurring abo payment: creates the ShoppingPayment record, builds the
+ * PAYONE authorization request, and returns the result with the ShoppingPayment attached.
+ */
+ public function chargeAbo(
+ UserAbo $userAbo,
+ ShoppingOrder $shoppingOrder,
+ ShoppingUser $shoppingUser,
+ int $amountCents,
+ string $currency,
+ ): PayonePaymentResult {
+ $reference = substr(uniqid('m', false), 0, 16);
+
+ $method = [
+ 'clearingtype' => $userAbo->clearingtype,
+ 'wallettype' => $userAbo->wallettype ?? '',
+ 'pseudocardpan' => $userAbo->carddata['pseudocardpan'] ?? '',
+ 'cardexpiredate' => $userAbo->carddata['cardexpiredate'] ?? '',
+ 'userid' => $userAbo->payone_userid,
+ 'onlinebanktransfertype' => '',
+ 'request' => 'authorization',
+ ];
+
+ $aboInitPayment = [
+ 'recurrence' => 'recurring',
+ 'customer_is_present' => 'no',
+ 'request' => 'authorization',
+ 'amount' => $amountCents,
+ ];
+
+ $prepayment = [
+ 'reference' => $reference,
+ 'amount' => $amountCents,
+ 'currency' => $currency,
+ 'param' => $shoppingOrder->id,
+ ];
+
+ $shoppingPayment = ShoppingPayment::create([
+ 'shopping_order_id' => $shoppingOrder->id,
+ 'clearingtype' => $method['clearingtype'],
+ 'wallettype' => $method['wallettype'],
+ 'onlinebanktransfertype' => $method['onlinebanktransfertype'],
+ 'carddata' => $userAbo->carddata,
+ 'reference' => $reference,
+ 'amount' => $amountCents,
+ 'currency' => $currency,
+ 'is_abo' => $shoppingOrder->is_abo,
+ 'abo_interval' => 0,
+ 'mode' => $shoppingOrder->mode,
+ ]);
+
+ $defaults = array_merge($this->defaults, ['mode' => $shoppingOrder->mode]);
+ $personalData = PayoneRequestBuilder::buildPersonalData($shoppingUser);
+
+ $request = PayoneRequestBuilder::buildRequest(
+ $defaults, $personalData, [], $method, $prepayment, $aboInitPayment,
+ );
+
+ $response = Payone::sendRequest($request, '', $this->client);
+
+ return PayonePaymentResult::fromResponse($response, $shoppingPayment);
+ }
+}
diff --git a/app/Services/Payment/Payone/PayoneRequestBuilder.php b/app/Services/Payment/Payone/PayoneRequestBuilder.php
new file mode 100644
index 00000000..7cfa1300
--- /dev/null
+++ b/app/Services/Payment/Payone/PayoneRequestBuilder.php
@@ -0,0 +1,81 @@
+
+ */
+ public static function buildPersonalData(ShoppingUser $shoppingUser): array
+ {
+ return [
+ 'firstname' => $shoppingUser->billing_firstname,
+ 'lastname' => $shoppingUser->billing_lastname,
+ 'street' => $shoppingUser->billing_address,
+ 'zip' => $shoppingUser->billing_zipcode,
+ 'city' => $shoppingUser->billing_city,
+ 'country' => $shoppingUser->billing_country?->code ?? 'DE',
+ 'email' => $shoppingUser->billing_email,
+ 'language' => 'DE',
+ ];
+ }
+
+ /**
+ * Builds the shipping/delivery data block for a PAYONE request.
+ * When same_as_billing is true, billing address is used for shipping.
+ *
+ * @return array
+ */
+ public static function buildDeliveryData(ShoppingUser $shoppingUser): array
+ {
+ if ($shoppingUser->same_as_billing) {
+ return [
+ 'shipping_firstname' => $shoppingUser->billing_firstname,
+ 'shipping_lastname' => $shoppingUser->billing_lastname,
+ 'shipping_zip' => $shoppingUser->billing_zipcode,
+ 'shipping_city' => $shoppingUser->billing_city,
+ 'shipping_country' => $shoppingUser->billing_country?->code ?? 'DE',
+ 'shipping_street' => $shoppingUser->billing_address,
+ ];
+ }
+
+ return [
+ 'shipping_firstname' => $shoppingUser->shipping_firstname,
+ 'shipping_lastname' => $shoppingUser->shipping_lastname,
+ 'shipping_zip' => $shoppingUser->shipping_zipcode,
+ 'shipping_city' => $shoppingUser->shipping_city,
+ 'shipping_country' => $shoppingUser->shipping_country?->code ?? 'DE',
+ 'shipping_street' => $shoppingUser->shipping_address,
+ ];
+ }
+
+ /**
+ * Merges all PAYONE request segments into one flat array, in the correct
+ * precedence order (later segments override earlier ones on key collision).
+ *
+ * @param array $defaults
+ * @param array $personalData
+ * @param array $deliveryData
+ * @param array $methodParams
+ * @param array $prepayment
+ * @param array $aboInitPayment
+ * @param array $urls
+ * @return array
+ */
+ public static function buildRequest(
+ array $defaults,
+ array $personalData,
+ array $deliveryData,
+ array $methodParams,
+ array $prepayment,
+ array $aboInitPayment = [],
+ array $urls = [],
+ ): array {
+ return array_merge($defaults, $personalData, $deliveryData, $methodParams, $prepayment, $aboInitPayment, $urls);
+ }
+}
diff --git a/app/Services/Payment/Payone/PayoneTransactionRecorder.php b/app/Services/Payment/Payone/PayoneTransactionRecorder.php
new file mode 100644
index 00000000..d1650647
--- /dev/null
+++ b/app/Services/Payment/Payone/PayoneTransactionRecorder.php
@@ -0,0 +1,101 @@
+
+ */
+ private const REDACTED_KEYS = ['cardpan', 'cardcvc2'];
+
+ /**
+ * Records a PaymentTransaction from a PAYONE API response (ERROR, REDIRECT, APPROVED, …).
+ *
+ * @param array $response
+ */
+ public static function recordFromResponse(
+ ShoppingPayment $payment,
+ string $requestType,
+ array $response,
+ int $useridFallback = 0,
+ ): PaymentTransaction {
+ return PaymentTransaction::create([
+ 'shopping_payment_id' => $payment->id,
+ 'request' => $requestType,
+ 'txid' => $response['txid'] ?? 0,
+ 'userid' => $response['userid'] ?? $useridFallback,
+ 'status' => $response['status'] ?? 'UNKNOWN',
+ 'txaction' => $response['txaction'] ?? null,
+ 'transmitted_data' => self::redactPayload($response),
+ 'errorcode' => $response['errorcode'] ?? null,
+ 'errormessage' => $response['errormessage'] ?? null,
+ 'customermessage' => $response['customermessage'] ?? null,
+ 'mode' => $payment->mode,
+ ]);
+ }
+
+ /**
+ * Records a synthetic transaction for FNC/MIV invoice payments (no PAYONE API call made).
+ *
+ * @param array $requestPayload
+ */
+ public static function recordInvoicePayment(
+ ShoppingPayment $payment,
+ string $requestType,
+ array $requestPayload,
+ ): PaymentTransaction {
+ return PaymentTransaction::create([
+ 'shopping_payment_id' => $payment->id,
+ 'request' => $requestType,
+ 'txid' => 0,
+ 'userid' => 0,
+ 'status' => 'FNCMIV',
+ 'transmitted_data' => self::redactPayload($requestPayload),
+ 'txaction' => 'invoice_open',
+ 'mode' => $payment->mode,
+ ]);
+ }
+
+ /**
+ * Records a transaction for an incoming PAYONE IPN/TSOK webhook callback.
+ *
+ * @param array $data Raw callback payload (\Request::all()).
+ */
+ public static function recordWebhookCallback(
+ ShoppingPayment $payment,
+ array $data,
+ ): PaymentTransaction {
+ return PaymentTransaction::create([
+ 'shopping_payment_id' => $payment->id,
+ 'request' => 'transaction',
+ 'txid' => $data['txid'] ?? 0,
+ 'userid' => $data['userid'] ?? 0,
+ 'status' => 'PAYONE',
+ 'key' => $data['key'] ?? null,
+ 'txaction' => $data['txaction'] ?? null,
+ 'transmitted_data' => self::redactPayload(Util::utf8ize($data)),
+ 'mode' => $data['mode'] ?? $payment->mode,
+ 'errorcode' => $data['errorcode'] ?? null,
+ 'errormessage' => $data['errormessage'] ?? null,
+ 'customermessage' => $data['customermessage'] ?? null,
+ ]);
+ }
+
+ /**
+ * Strips PCI-sensitive fields before persisting a payload.
+ *
+ * @param array $payload
+ * @return array
+ */
+ private static function redactPayload(array $payload): array
+ {
+ return array_diff_key($payload, array_flip(self::REDACTED_KEYS));
+ }
+}
diff --git a/app/Services/Payment/Payone/PayoneWebhookHandler.php b/app/Services/Payment/Payone/PayoneWebhookHandler.php
new file mode 100644
index 00000000..15a114ee
--- /dev/null
+++ b/app/Services/Payment/Payone/PayoneWebhookHandler.php
@@ -0,0 +1,233 @@
+
+ */
+ private const TXACTION_PRIORITY = [
+ 'appointed' => 1,
+ 'pending' => 2,
+ 'failed' => 3,
+ 'paid' => 10,
+ ];
+
+ /**
+ * Processes a PAYONE IPN/TSOK callback. Always returns a result whose `response`
+ * is sent verbatim to PAYONE. A thrown exception (paid-path transaction failure)
+ * propagates so PAYONE retries the callback.
+ *
+ * @param array $data
+ */
+ public function handle(array $data): PayoneWebhookResult
+ {
+ if (! isset($data['key'], $data['param'], $data['userid'], $data['txid'], $data['reference'], $data['price'], $data['txaction'])) {
+ MyLog::writeLog(
+ 'payone',
+ 'error',
+ 'Error:2001 App\Http\Controllers\Api\PayoneController::paymentStatus parameter incomplete',
+ $data
+ );
+
+ return PayoneWebhookResult::rejected('2001');
+ }
+
+ if ($data['key'] != config('payone.defaults.key')) {
+ MyLog::writeLog(
+ 'payone',
+ 'error',
+ 'Error:2002 App\Http\Controllers\Api\PayoneController::paymentStatus Key error',
+ $data
+ );
+
+ return PayoneWebhookResult::rejected('2002');
+ }
+
+ $shopping_order = ShoppingOrder::find($data['param']);
+ if (! $shopping_order) {
+ MyLog::writeLog(
+ 'payone',
+ 'error',
+ 'Error:2003 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingOrder not found:',
+ $data
+ );
+
+ return PayoneWebhookResult::rejected('2003');
+ }
+
+ $shopping_payment = ShoppingPayment::where('reference', $data['reference'])->first();
+ if (! $shopping_payment) {
+ MyLog::writeLog(
+ 'payone',
+ 'error',
+ 'Error:2004 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingPayment not found',
+ $data
+ );
+
+ return PayoneWebhookResult::rejected('2004');
+ }
+
+ if ($shopping_payment->shopping_order_id != $shopping_order->id) {
+ MyLog::writeLog(
+ 'payone',
+ 'error',
+ 'Error:2005 App\Http\Controllers\Api\PayoneController::paymentStatus ShoppingPayment no realation ShoppingOrder',
+ $data
+ );
+
+ return PayoneWebhookResult::rejected('2005');
+ }
+
+ $price = number_format((round($data['price'], 2) * 100), 0, '.', '');
+ $price_amount = number_format($shopping_payment->amount, 0, '.', '');
+ if ($price_amount != $price) {
+ $data['shopping_payment-amount'] = $price_amount;
+ $data['price-amount'] = $price;
+ MyLog::writeLog(
+ 'payone',
+ 'error',
+ 'Error:2006 App\Http\Controllers\Api\PayoneController::paymentStatus Price error',
+ $data
+ );
+
+ return PayoneWebhookResult::rejected('2006');
+ }
+
+ /*
+ * Payone sendet dieselbe txaction oft mehrfach (v. a. "appointed"). War der Status
+ * bereits auf ShoppingPayment gespeichert, ist das ein Duplikat: TSOK, keine Doppel-Verarbeitung.
+ * Ausnahme: erneutes "paid", obwohl die Bestellung noch nicht als bezahlt gefuehrt wird (Recovery).
+ */
+ if ($shopping_payment->txaction == $data['txaction']) {
+ if ($data['txaction'] === 'paid' && $shopping_order->txaction === 'paid') {
+ MyLog::writeLog(
+ 'payone',
+ 'notice',
+ 'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (already paid)',
+ $data,
+ false
+ );
+
+ return PayoneWebhookResult::duplicateIgnored($data['txaction']);
+ }
+
+ if (in_array($data['txaction'], ['appointed', 'failed', 'pending'], true)) {
+ MyLog::writeLog(
+ 'payone',
+ 'info',
+ 'App\Http\Controllers\Api\PayoneController::paymentStatus duplicate callback ignored (same txaction)',
+ [
+ 'reference' => $data['reference'] ?? null,
+ 'param' => $data['param'] ?? null,
+ 'txaction' => $data['txaction'],
+ 'txid' => $data['txid'] ?? null,
+ ],
+ false
+ );
+
+ return PayoneWebhookResult::duplicateIgnored($data['txaction']);
+ }
+ }
+
+ // create transaction (centralised in the recorder, incl. PCI redaction + utf8ize)
+ PayoneTransactionRecorder::recordWebhookCallback($shopping_payment, $data);
+
+ $current_priority = self::TXACTION_PRIORITY[$shopping_order->txaction] ?? 0;
+ $new_priority = self::TXACTION_PRIORITY[$data['txaction']] ?? 0;
+
+ // Only update txaction if new priority is higher than current
+ if ($new_priority > $current_priority) {
+ $shopping_order->txaction = $data['txaction'];
+ $shopping_order->save();
+ $shopping_payment->txaction = $data['txaction'];
+ $shopping_payment->save();
+ } else {
+ MyLog::writeLog(
+ 'payone',
+ 'info',
+ 'App\Http\Controllers\Api\PayoneController::paymentStatus - txaction not updated (current: '.$shopping_order->txaction.' has higher/equal priority than new: '.$data['txaction'].')',
+ $data,
+ false
+ );
+ }
+
+ $send_link = false;
+ $send_mail = true;
+ if ($data['txaction'] === 'failed') {
+ $shopping_order->setUserHistoryValue(['status' => 6]);
+ Util::setInstanceStatusByPayment($shopping_payment, 5);
+ }
+ if ($data['txaction'] === 'appointed') {
+ $shopping_order->setUserHistoryValue(['status' => 7]);
+ ShoppingUserService::snycOrdersByShoppingOrder($shopping_order);
+ Util::setInstanceStatusByPayment($shopping_payment, 4);
+ }
+
+ if ($data['txaction'] === 'paid') {
+ // Use DB transaction and row locking to prevent race conditions
+ \DB::beginTransaction();
+ try {
+ // Lock the shopping order row to prevent concurrent processing
+ $locked_order = ShoppingOrder::where('id', $shopping_order->id)
+ ->lockForUpdate()
+ ->first();
+ // Double-check if payment was already processed
+ if (! $locked_order->paid) {
+ $send_link = Payment::paymentStatusPaidAction($locked_order, true, $shopping_payment);
+ \DB::commit();
+ } else {
+ $send_mail = false;
+ \DB::commit();
+ }
+ } catch (\Exception $e) {
+ \DB::rollBack();
+ MyLog::writeLog(
+ 'payone',
+ 'error',
+ 'Error:2008 App\Http\Controllers\Api\PayoneController::paymentStatus Transaction failed',
+ ['error' => $e->getMessage(), 'data' => $data]
+ );
+ throw $e;
+ }
+
+ CheckoutFunnelTracker::confirmedPayment(
+ shoppingPaymentId: $shopping_payment->id,
+ txaction: 'paid',
+ metadata: ['mode' => $data['mode'] ?? null, 'txid' => $data['txid'] ?? null],
+ );
+ }
+
+ if ($data['txaction'] === 'appointed') {
+ CheckoutFunnelTracker::confirmedPayment(
+ shoppingPaymentId: $shopping_payment->id,
+ txaction: 'appointed',
+ metadata: ['mode' => $data['mode'] ?? null],
+ );
+ }
+
+ $data['send_link'] = $send_link;
+ if ($send_mail) {
+ Payment::paymentStatusSendMail($shopping_order, $shopping_payment, $data);
+ }
+
+ return PayoneWebhookResult::processed(
+ txaction: $data['txaction'],
+ paid: $data['txaction'] === 'paid',
+ mailSent: $send_mail,
+ sendLink: (bool) $send_link,
+ );
+ }
+}
diff --git a/app/Services/PaymentHelper.php b/app/Services/PaymentHelper.php
index 58366ada..594302ce 100644
--- a/app/Services/PaymentHelper.php
+++ b/app/Services/PaymentHelper.php
@@ -3,11 +3,11 @@
namespace App\Services;
use App\Http\Controllers\Pay\PayoneController;
-use App\Models\PaymentTransaction;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserHistory;
+use App\Services\Payment\Payone\PayoneTransactionRecorder;
use Yard;
class PaymentHelper
@@ -39,41 +39,10 @@ class PaymentHelper
$pay->setPersonalData();
$response = $pay->onlyPaymentResponse();
$shopping_payment = $pay->getShoppingPayment();
- if ($response['status'] === 'ERROR') {
- $payT = PaymentTransaction::create([
- 'shopping_payment_id' => $shopping_payment->id,
- 'request' => 'authorization',
- 'errorcode' => $response['errorcode'],
- 'errormessage' => $response['errormessage'],
- 'customermessage' => $response['customermessage'],
- 'status' => $response['status'],
- 'mode' => $shopping_payment->mode,
- ]);
- UserHistory::create(['user_id' => $user->id, 'shopping_order_id' => $shopping_order->id, 'action' => 'abo_open_payment', 'referenz' => $payT->id, 'identifier' => $user->payment_account, 'status' => 3]);
- }
- if ($response['status'] === 'REDIRECT') {
- $payT = PaymentTransaction::create([
- 'shopping_payment_id' => $shopping_payment->id,
- 'request' => 'authorization',
- 'txid' => $response['txid'],
- 'userid' => $response['userid'],
- 'status' => $response['status'],
- 'mode' => $shopping_payment->mode,
-
- ]);
- UserHistory::create(['user_id' => $user->id, 'shopping_order_id' => $shopping_order->id, 'action' => 'abo_open_payment', 'referenz' => $payT->id, 'identifier' => $user->payment_account, 'status' => 4]);
- }
- if ($response['status'] === 'APPROVED') {
- $payT = PaymentTransaction::create([
- 'shopping_payment_id' => $shopping_payment->id,
- 'request' => 'authorization',
- 'txid' => $response['txid'],
- 'userid' => $response['userid'],
- 'status' => $response['status'],
- 'transmitted_data' => $response,
- 'mode' => $shopping_payment->mode,
- ]);
- UserHistory::create(['user_id' => $user->id, 'shopping_order_id' => $shopping_order->id, 'action' => 'abo_open_payment', 'referenz' => $payT->id, 'identifier' => $user->payment_account, 'status' => 5]);
+ if (in_array($response['status'] ?? '', ['ERROR', 'REDIRECT', 'APPROVED'], true)) {
+ $statusMap = ['ERROR' => 3, 'REDIRECT' => 4, 'APPROVED' => 5];
+ $payT = PayoneTransactionRecorder::recordFromResponse($shopping_payment, 'authorization', $response);
+ UserHistory::create(['user_id' => $user->id, 'shopping_order_id' => $shopping_order->id, 'action' => 'abo_open_payment', 'referenz' => $payT->id, 'identifier' => $user->payment_account, 'status' => $statusMap[$response['status']]]);
}
}
diff --git a/app/Services/Payone.php b/app/Services/Payone.php
index 4051a596..ba04d318 100644
--- a/app/Services/Payone.php
+++ b/app/Services/Payone.php
@@ -28,6 +28,7 @@
namespace App\Services;
// require 'vendor/autoload.php';
+use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
@@ -86,9 +87,9 @@ class Payone
'payone',
'error',
'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest HTTP-Fehlerantwort (4xx/5xx).',
- ['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => $request]
+ ['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => self::redactRequest($request)]
);
- abort(403, $userMessage);
+ throw new PayoneTransportException($userMessage, 0, $e);
} catch (ConnectException $e) {
MyLog::writeLog(
'payone',
@@ -96,10 +97,10 @@ class Payone
'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest Netzwerk-/Transportfehler (keine HTTP-Antwort).',
[
'exception' => $e->getMessage(),
- 'request' => $request,
+ 'request' => self::redactRequest($request),
]
);
- abort(403, $userMessage);
+ throw new PayoneTransportException($userMessage, 0, $e);
} catch (RequestException $e) {
if ($e->hasResponse()) {
$error = $e->getResponse();
@@ -108,7 +109,7 @@ class Payone
'payone',
'error',
'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest HTTP-Fehlerantwort.',
- ['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => $request]
+ ['error' => $error, 'responseBodyAsString' => $responseBodyAsString, 'request' => self::redactRequest($request)]
);
} else {
MyLog::writeLog(
@@ -117,12 +118,12 @@ class Payone
'Error:1002 Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. App\Services\Payone::sendRequest Transportfehler (RequestException ohne Antwort).',
[
'exception' => $e->getMessage(),
- 'request' => $request,
+ 'request' => self::redactRequest($request),
]
);
}
- abort(403, $userMessage);
+ throw new PayoneTransportException($userMessage, 0, $e);
}
if (isset($response)) {
if (implode($response->getHeader('Content-Type')) == 'text/plain;charset=UTF-8') {
@@ -137,7 +138,7 @@ class Payone
'payone',
'error',
'Error: App\Services\Payone::sendRequest content type is anything else',
- ['error' => $return, 'response' => $response, 'request' => $request]
+ ['error' => $return, 'response' => $response, 'request' => self::redactRequest($request)]
);
}
} else {
@@ -145,7 +146,7 @@ class Payone
'payone',
'error',
'Error: App\Services\Payone::sendRequest Something went wrong during the HTTP request',
- ['request' => $request]
+ ['request' => self::redactRequest($request)]
);
throw new Exception('Something went wrong during the HTTP request.');
}
@@ -157,9 +158,9 @@ class Payone
'payone',
'error',
'Error: 1003 App\Http\Controllers\Pay\PayoneController::ResponseData response is non array: return:',
- ['return' => $return, 'response' => $response, 'request' => $request]
+ ['return' => $return, 'response' => $response, 'request' => self::redactRequest($request)]
);
- abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1003');
+ throw new PayoneTransportException('Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1003');
}
if (! isset($return['status'])) {
@@ -167,9 +168,9 @@ class Payone
'payone',
'error',
'Error: 1004 App\Http\Controllers\Pay\PayoneController::ResponseData response has non status',
- ['return' => $return, 'response' => $response, 'request' => $request]
+ ['return' => $return, 'response' => $response, 'request' => self::redactRequest($request)]
);
- abort(403, 'Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1004');
+ throw new PayoneTransportException('Der Zahlungsanbieter ist nicht erreichbar, die Zahlung konnte nicht durchgeführt werden. Bitte versuchen Sie es später erneut. Fehlercode: 1004');
}
/* echo "done.\n";
@@ -179,6 +180,17 @@ class Payone
return $return;
}
+ /**
+ * Removes PCI-sensitive fields from a request array before logging.
+ *
+ * @param array $request
+ * @return array
+ */
+ private static function redactRequest(array $request): array
+ {
+ return array_diff_key($request, array_flip(['cardpan', 'cardcvc2']));
+ }
+
/**
* gets response string an puts it into an array
*
diff --git a/config/payone.php b/config/payone.php
index 7aeb0503..3ad93e90 100755
--- a/config/payone.php
+++ b/config/payone.php
@@ -2,12 +2,17 @@
return [
'defaults' => [
- "aid" => '43065',//"your_account_id",
- "mid" => '42504',//"your_merchant_id",
- "portalid" => '2030693',
- "key" => hash("md5", "Zjop5cvP2UeB7Qhy"), // the key has to be hashed as md5
- "mode" => "test", // can be "live" for actual transactions
- "api_version" => "3.11",
- "encoding" => "UTF-8"
- ]
+ 'aid' => '43065', // "your_account_id",
+ 'mid' => '42504', // "your_merchant_id",
+ 'portalid' => '2030693',
+ // MD5-Hash des PAYONE-Portal-Keys (kein Klartext im Repo/Env). Pflicht: fehlt der
+ // Wert, schlägt der Boot bewusst hart fehl, statt still mit einem ungültigen Key
+ // (z. B. md5('')) weiterzulaufen – das würde u. a. die Webhook-Validierung aushebeln.
+ 'key' => env('PAYONE_KEY_HASH') ?: throw new \RuntimeException(
+ 'PAYONE_KEY_HASH ist nicht gesetzt. Hinterlege den MD5-Hash des PAYONE-Portal-Keys in der .env.'
+ ),
+ 'mode' => 'test', // can be "live" for actual transactions
+ 'api_version' => '3.11',
+ 'encoding' => 'UTF-8',
+ ],
];
diff --git a/database/migrations/2026_06_09_182614_add_carddata_to_shopping_payments.php b/database/migrations/2026_06_09_182614_add_carddata_to_shopping_payments.php
new file mode 100644
index 00000000..72e60e4d
--- /dev/null
+++ b/database/migrations/2026_06_09_182614_add_carddata_to_shopping_payments.php
@@ -0,0 +1,26 @@
+json('carddata')->nullable()->after('pseudocardpan');
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ if (Schema::hasColumn('shopping_payments', 'carddata')) {
+ Schema::table('shopping_payments', function (Blueprint $table): void {
+ $table->dropColumn('carddata');
+ });
+ }
+ }
+};
diff --git a/dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md b/dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md
new file mode 100644
index 00000000..1592e202
--- /dev/null
+++ b/dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md
@@ -0,0 +1,1520 @@
+# Entwicklungsplan: Zahlungsanbieter & Abo-Subscription-Management
+
+> **Status:** In Arbeit (Phase R abgeschlossen) · **Erstellt:** 2026-06-09 · **Zuletzt aktualisiert:** 2026-06-10 · **Briefing:** `docs/salescenter/Zahlungabieter.md`
+
+## 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 & Briefing-Zusammenfassung
+
+Das Briefing definiert vier zusammenhängende, aber technisch getrennte Bausteine:
+
+| # | Anforderung | Priorität (Briefing) |
+|---|-------------|----------------------|
+| A | **Kreditkarte aktualisieren** bei bestehenden Abos über PAYONE | **Als Erstes** |
+| B | **Subscription lösen & neu anlegen** (Zahlungsmethode wechseln, Token erneuern) | Hoch |
+| C | **Nachträgliche Zahlung** bei fehlgeschlagener Abbuchung (Zahlungslink wie Checkout) | Hoch |
+| D | **PayPal direkt** integrieren (nicht mehr über PAYONE); PAYONE-PayPal-Abos parallel betreiben, später migrieren | Mittel (nach A–C stabil) |
+
+**Leitprinzip:** PayPal und PAYONE müssen **sauber getrennt** werden. Kreditkarte bleibt bei PAYONE; PayPal-Abos laufen künftig über das direkte PayPal-Konto.
+
+---
+
+## 2. Ist-Zustand (Codebase-Analyse)
+
+### 2.1 Zahlungsarchitektur heute
+
+```
+Checkout / Abo-Ausführung
+ │
+ ▼
+PayoneController (app/Http/Controllers/Pay/PayoneController.php)
+ │
+ ├── setPrePayment() → Erstzahlung Checkout (inkl. Abo-Init CC/PayPal-via-PAYONE)
+ ├── setAboPayment() → Wiederkehrende Abbuchung (userid + pseudocardpan)
+ └── checkCreditCard() → creditcardcheck → pseudocardpan (storecarddata=yes)
+ │
+ ▼
+App\Services\Payone::sendRequest() → https://api.pay1.de/post-gateway/
+ │
+ ▼
+Api\PayoneController@paymentStatus → IPN/TSOK-Callback (routes/api.php)
+ │
+ ▼
+Payment::paymentStatusPaidAction() → Bestellung bezahlt, Abo anlegen, E-Mails, Incentive
+```
+
+### 2.2 Abo-Datenmodell
+
+| Tabelle / Model | Relevante Felder | Rolle |
+|-----------------|------------------|-------|
+| `user_abos` / `UserAbo` | `payone_userid`, `clearingtype` (`cc`/`wlt`), `wallettype` (`PPE`), `carddata` (JSON: `pseudocardpan`, `cardexpiredate`) | Speichert Zahlungs-Token für wiederkehrende Abbuchungen |
+| `shopping_payments` / `ShoppingPayment` | `clearingtype`, `carddata`, `reference`, `is_abo` | Jede Zahlungstransaktion |
+| `payment_transactions` / `PaymentTransaction` | `txid`, `userid`, `txaction`, `transmitted_data` | PAYONE-Callback-Protokoll |
+| `user_abo_orders` / `UserAboOrder` | `paid`, `status` | Ausführungs-Historie pro Abo |
+
+**Abo-Anlage:** `AboHelper::createNewAbo()` kopiert Zahlungsdaten aus der Erstzahlung (`ShoppingPayment`) in `UserAbo`.
+
+**Abo-Ausführung:** Cron `user:make_abo_order` → `UserMakeOrder::makePayment()` → `PayoneController::setAboPayment()`.
+
+### 2.3 Bereits vorhandene Bausteine (wiederverwendbar)
+
+| Baustein | Datei | Nutzen für diesen Plan |
+|----------|-------|------------------------|
+| Admin/User/Portal Retry | `AboRetryPaymentService`, Routes in `crm.php` / `portal.php` | Basis für nachträgliche Zahlung; heute nur erneuter Abbuchungsversuch mit **bestehendem** Token |
+| Zahlungslink (Berater → Kunde) | `OrderPaymentService`, `ShoppingInstance` (`payment=6`) | Muster für Checkout-ähnlichen Zahlungslink; **nicht** für fehlgeschlagene Abo-Abbuchungen |
+| Kreditkarten-Check | `PayoneController::checkCreditCard()` | Basis für CC-Aktualisierung |
+| Payment-Dashboard | `Admin/PaymentDashboardController`, `payment:check-uptime` | Monitoring; Provider `paypal` bereits im Incident-Model |
+| PayPal-Panne-Recovery | `abo:retry-failed-paypal` Command | Historischer Sonderfall; zeigt Bedarf an robuster Fehlerbehandlung |
+| Race-Condition-Fix | `Api/PayoneController` (Duplikat-Callback-Handling) | Muster für idempotente Webhooks |
+
+### 2.4 Bekannte Lücken / Tech Debt
+
+- **Kein `payment_provider`-Feld** in `user_abos` / `shopping_payments` – Provider wird implizit über `clearingtype`/`wallettype` abgeleitet.
+- **Keine User-Self-Service-UI** zum Aktualisieren der Zahlungsmethode (weder CC noch PayPal).
+- **Kein Abo-Zahlungslink** bei Status `abo_hold` (3) – nur Admin-Retry-Button.
+- **PayPal = PAYONE** (`clearingtype=wlt`, `wallettype=PPE`) – keine direkte PayPal-Integration.
+- **PAYONE-Key hardcoded** in `config/payone.php` (Zeile 8) – sollte in `.env` / `config/payone.php` via `env()` ausgelagert werden (Sicherheit).
+- **Subscription-Lifecycle** (cancel + recreate) existiert nicht – Abo wird bei Checkout einmalig initialisiert und läuft dann mit gespeichertem Token.
+
+### 2.5 Code-Review: `Pay\PayoneController` & Callback (Stand 2026-06-09)
+
+Die Zahlungslogik **funktioniert produktiv**, weist aber strukturelle Schwächen auf, die neue Features (CC-Update, Recovery-Link, PayPal direkt) erschweren würden. Eine **Refactoring-Phase vor allen Feature-Phasen** ist empfohlen.
+
+#### Betroffene Klassen
+
+| Klasse | Zeilen | Rolle heute | Problem |
+|--------|--------|-------------|---------|
+| `App\Http\Controllers\Pay\PayoneController` | ~614 | Stateful Payment-Service | Falscher Namespace; kein HTTP-Controller |
+| `App\Http\Controllers\Api\PayoneController` | ~250 | PAYONE-IPN-Webhook | Monolith; `echo/exit` statt Response |
+| `App\Services\Payone` | ~214 | HTTP-Client zum Gateway | `abort()` statt Exceptions; loggt Requests mit PAN |
+| `App\Services\PaymentHelper` | ~172 | ELV-Membership-Zahlung | Dupliziert Response-Handling aus PayoneController |
+
+**Aufrufer von `Pay\PayoneController`:** `CheckoutController`, `UserMakeOrder` (Cron), `PaymentHelper` – jeweils via `new PayoneController()` ohne DI.
+
+#### Festgestellte Probleme (priorisiert)
+
+| Prio | Kategorie | Befund | Risiko |
+|------|-----------|--------|--------|
+| **Kritisch** | Debug-Code | `var_dump($response); exit()` bei unbekanntem APPROVED-Clearingtype (Z. 439–440) | Produktions-Abbruch bei Randfällen (z. B. PayPal/Sofort) |
+| **Kritisch** | Fehlerbehandlung | `PENDING`-Status endet mit barem `exit()` (Z. 454) | White Screen, kein User-Feedback |
+| **Kritisch** | PCI/Logging | `Payone::sendRequest` loggt `$request` komplett – bei `creditcardcheck` enthält das `cardpan`/`cardcvc2` | Compliance-Risiko |
+| **Hoch** | Architektur | Klasse mischt Request-Bau, DB-Persistenz, HTTP, Redirects, Session, Tracking | Schwer testbar, schwer erweiterbar |
+| **Hoch** | Duplikation | `PaymentTransaction::create` 4× in `ResponseData`, erneut in `PaymentHelper`, `UserMakeOrder` | Inkonsistente Protokollierung |
+| **Hoch** | Return-Typen | `ResponseData()` liefert Redirect, Array oder `abort()`/`exit()` je nach Kontext | Caller müssen implizites Verhalten kennen |
+| **Mittel** | Zustands-API | 10+ mutable Properties; `init()` Pflicht vor jedem Aufruf | Fehleranfällig bei parallelen Flows |
+| **Mittel** | `setMethod()` | Mehrere unabhängige `if`-Blöcke statt `match`; kein Fehler bei unbekannter Methode | Stille Fehler möglich |
+| **Mittel** | Naming/Typisierung | Keine Return-Types; Tippfehler `setDeliverylData`; Konstanten ungenutzt | Wartbarkeit |
+| **Niedrig** | Toter Code | ~150 Zeilen auskommentierter Legacy-Code | Lesbarkeit |
+| **Niedrig** | Api-Controller | `echo 'TSOK'; exit` statt `return response('TSOK')` | Nicht Laravel-idiomatisch |
+
+#### Was gut ist (beibehalten)
+
+- Trennung HTTP-Transport (`App\Services\Payone`) vs. Business-Parameter (`PayoneController`)
+- `creditcardcheck` mit `storecarddata=yes` – korrektes PCI-Muster
+- Abo-Init mit `recurrence=recurring` + unterschiedlichem `customer_is_present` (Checkout vs. Cron)
+- `Api\PayoneController`: Duplikat-Callback-Erkennung, txaction-Priorität, `lockForUpdate` bei `paid`
+- Unit-Tests für `Payone::parseResponse` und Netzwerkfehler (`tests/Unit/Services/PayoneTest.php`)
+
+---
+
+## 3. Soll-Architektur
+
+### 3.1 Provider-Abstraktion (Schichtenmodell)
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ UI: Portal / CRM / Checkout / Zahlungslink │
+└──────────────────────────┬──────────────────────────────────┘
+ │
+┌──────────────────────────▼──────────────────────────────────┐
+│ PaymentOrchestrator (neu) │
+│ - resolveProvider(UserAbo|ShoppingPayment) │
+│ - initiatePayment / updatePaymentMethod / cancelSubscription│
+│ - handleWebhook(provider, payload) │
+└──────────┬─────────────────────────────┬────────────────────┘
+ │ │
+┌──────────▼──────────┐ ┌──────────▼──────────┐
+│ PayoneGateway │ │ PayPalGateway │
+│ (CC, SEPA, …) │ │ (Subscriptions) │
+│ bestehend, refact. │ │ neu │
+└─────────────────────┘ └─────────────────────┘
+```
+
+**Empfehlung:** Kein Big-Bang-Rewrite. Zuerst **Phase R** (Refactoring des Ist-Codes), danach schrittweise Extraktion in `App\Services\Payment\PayoneGateway` und paralleler `PayPalGateway`. `Pay\PayoneController` bleibt vorerst als **Deprecated-Fassade**, bis alle Aufrufer umgestellt sind.
+
+### 3.2 Datenmodell-Erweiterungen (Vorschlag)
+
+```sql
+-- user_abos
+payment_provider ENUM('payone','paypal') DEFAULT 'payone'
+payment_token_ref VARCHAR(255) NULL -- generischer Token (PayPal subscription_id / payone userid)
+payment_method_meta JSON NULL -- provider-spezifische Metadaten (maskierte Karte, PayPal email)
+subscription_status ENUM('active','cancelled','migrating','legacy_payone_paypal') DEFAULT 'active'
+migrated_from_abo_id INT UNSIGNED NULL -- Verknüpfung bei CC↔PayPal-Wechsel
+
+-- shopping_payments
+payment_provider ENUM('payone','paypal') DEFAULT 'payone'
+external_reference VARCHAR(255) NULL -- PayPal capture_id / order_id
+
+-- user_abo_payment_recovery_links (neu, optional)
+id, user_abo_id, user_abo_order_id, token (unique), expires_at, used_at, shopping_order_id
+```
+
+> **Offene Frage O1:** Sollen wir `payone_userid` beibehalten und `payment_token_ref` nur für PayPal nutzen, oder beide Provider über ein einheitliches Feld abbilden?
+#payone_userid Behalten, so können wir direkt in der Datenbank sehen, welchen Payment Provider wir ansprechen.
+
+### 3.3 Provider-Matrix (Zielzustand)
+
+| Use Case | Provider | API |
+|----------|----------|-----|
+| Checkout Einmalzahlung (CC, SEPA, Sofort, …) | PAYONE | Bestehend |
+| Checkout Abo-Erstzahlung CC | PAYONE | `authorization` + `recurrence=recurring` |
+| Checkout Abo-Erstzahlung PayPal | **PayPal direkt** | Subscriptions API v2 / Vault |
+| Wiederkehrende Abo-Abbuchung CC | PAYONE | `authorization` + `userid` + `pseudocardpan` |
+| Wiederkehrende Abo-Abbuchung PayPal | **PayPal direkt** | Subscription billing cycle |
+| CC-Daten aktualisieren | PAYONE | `creditcardcheck` + `updateuser`/`updatecard` (PAYONE-Doku prüfen) |
+| PayPal-Abo wechseln / neu anlegen | **PayPal direkt** | Cancel subscription + Create subscription |
+| Legacy Abo (PayPal via PAYONE) | PAYONE | Unverändert bis Migration |
+
+---
+
+## 4. Sicherheitskonzept
+
+### 4.1 PCI-DSS & Kartendaten
+
+| Regel | Umsetzung |
+|-------|-----------|
+| Keine Speicherung von PAN/CVC | Bereits eingehalten: nur `pseudocardpan` + `cardexpiredate` in `carddata` |
+| Karteneingabe im Browser | PAYONE Client-API / Hosted iFrame (wie Checkout heute) – **keine** Rohdaten an eigenen Server |
+| CC-Aktualisierung | Gleicher Flow wie Checkout: `creditcardcheck` mit `storecarddata=yes`, dann Update des bestehenden `userid` |
+| Logging | Niemals `cardpan`, `cardcvc2` in Logs (`payone.log`, `abo_order.log`) |
+
+### 4.2 Webhook-/Callback-Sicherheit
+
+| Endpoint | Validierung heute | Soll |
+|----------|-------------------|------|
+| `POST /api/payment/status` (PAYONE) | MD5-Key-Vergleich (`config('payone.defaults.key')`) | Beibehalten; Key aus `.env` |
+| PayPal Webhooks (neu) | – | PayPal-Signaturprüfung (`verify-webhook-signature`), Idempotenz-Store |
+| Abo-Zahlungslink | – | Signed URL / UUID-Token, TTL, Einmalverwendung, Zuordnung zu `user_abo_id` |
+
+**Bestehendes Muster (wiederverwenden):** Duplikat-Callback-Erkennung in `Api/PayoneController` (Zeilen 110–120) – gleiche Logik für PayPal-Events.
+
+### 4.3 Autorisierung & Missbrauchsschutz
+
+- CC-Aktualisierung / Zahlungsmethode wechseln: Nur authentifizierter Abo-Inhaber (Portal) oder berechtigter Berater (CRM) + Policy auf `UserAbo`.
+- Abo-Zahlungslink: Token per E-Mail, optional OTP für Endkunden (Portal hat bereits OTP-Infrastruktur).
+- Rate-Limiting auf Payment-Endpunkte (Laravel `throttle`).
+- Admin-Aktionen in `SysLog` protokollieren (bestehendes Muster).
+
+### 4.4 Secrets & Konfiguration
+
+```php
+// config/payone.php – Ziel
+'defaults' => [
+ 'key' => env('PAYONE_KEY_HASH'), // MD5-Hash, nicht Klartext in Repo
+ ...
+]
+
+// config/services.php – neu
+'paypal' => [
+ 'client_id' => env('PAYPAL_CLIENT_ID'),
+ 'client_secret' => env('PAYPAL_CLIENT_SECRET'),
+ 'webhook_id' => env('PAYPAL_WEBHOOK_ID'),
+ 'mode' => env('PAYPAL_MODE', 'sandbox'),
+],
+```
+
+### 4.5 Test-/Live-Trennung
+
+- Bestehend: `shopping_payments.mode` / `shopping_orders.mode` (`test`/`live`).
+- PayPal: Sandbox vs. Live Credentials; Abo-Migration nur innerhalb desselben Modus.
+
+---
+
+## 5. Phasen-Übersicht
+
+| Phase | Titel | Aufwand (Schätzung) | Priorität | Abhängigkeiten |
+|-------|-------|---------------------|-----------|----------------|
+| **R** | **Payment-Controller Refactoring** | 4–6 PT | **P0 – Vorab** | – |
+| **0** | Analyse & PAYONE-API-Klärung | 2–3 PT | Blocker | Phase R (R1–R2) |
+| **1** | Kreditkarte aktualisieren (PAYONE) | 5–8 PT | **P1** | Phase 0, **Phase R** |
+| **2** | Subscription cancel + recreate (PAYONE CC) | 5–7 PT | P1 | Phase 1 |
+| **3** | Abo-Zahlungslink bei fehlgeschlagener Abbuchung | 6–9 PT | P1 | Phase 0, **Phase R** |
+| **4** | Provider-Abstraktion & Datenmodell | 3–5 PT | P2 | Phase R (Teilüberlappung mit R3) |
+| **5** | PayPal direkt – Checkout & Abo-Neuabschluss | 10–15 PT | P2 | Phase 4 |
+| **6** | PayPal direkt – Wiederkehrende Abbuchung & Webhooks | 8–12 PT | P2 | Phase 5 |
+| **7** | Zahlungsmethode wechseln CC ↔ PayPal | 5–8 PT | P2 | Phase 2, 5, 6 |
+| **8** | Legacy-Migration PAYONE-PayPal → PayPal direkt | 4–6 PT | P3 | Phase 6, 7 |
+| **9** | Monitoring, Tests, Rollout | 3–5 PT | P1–P3 | Jeweils nach Phase |
+
+*PT = Personentage (1 Entwickler)*
+
+---
+
+## 5.1 Phase R – Payment-Controller Refactoring ⭐ Vorab-Schritt
+
+### Ziel
+
+Bestehende PAYONE-Integration **strukturell entflechten**, kritische Bugs beseitigen und eine **testbare Service-Schicht** schaffen – **ohne** Verhalten im Checkout/Cron/IPN zu ändern (Strangler-Pattern).
+
+> Phase R ist Voraussetzung für Phase 1–3 und reduziert das Risiko, neue Features in den monolithischen Controller zu bauen.
+
+### Grundsätze
+
+1. **Kein Verhaltens-Bruch:** Bestehende Feature-Tests + manueller Checkout-Smoke-Test nach jedem Sub-Step.
+2. **Strangler-Figur:** Neue Services extrahieren; `Pay\PayoneController` delegiert vorerst nur weiter.
+3. **DTOs statt Property-Soup:** Request/Response als immutable Value Objects.
+4. **Exceptions statt `abort()`/`exit()`** in Service-Schicht; HTTP-Schicht entscheidet über Redirect/Fehlerseite.
+
+### Ziel-Struktur nach Phase R
+
+```
+app/Services/Payment/
+├── Payone/
+│ ├── PayoneClient.php ← HTTP (aus App\Services\Payone)
+│ ├── PayoneRequestBuilder.php ← Parameter-Mapping (setMethod, personalData, …)
+│ ├── PayonePaymentService.php ← authorize, creditcardcheck, aboCharge, managemandate
+│ ├── PayoneWebhookHandler.php ← Logik aus Api\PayoneController
+│ ├── PayoneTransactionRecorder.php ← PaymentTransaction::create (eine Stelle)
+│ └── DTOs/
+│ ├── PayonePaymentRequest.php
+│ ├── PayonePaymentResult.php ← status, txid, userid, redirectUrl, errors
+│ └── PayoneMethodConfig.php ← clearingtype, wallettype, …
+├── PaymentMethodResolver.php ← 'wlt#PPE' → PayoneMethodConfig (match)
+└── Exceptions/
+ ├── PayoneTransportException.php
+ └── PayoneDeclinedException.php
+
+app/Http/Controllers/Pay/PayoneController.php ← @deprecated Fassade
+app/Http/Controllers/Api/PayoneController.php ← schlank: validate → Handler → response('TSOK')
+```
+
+### Unterphasen
+
+#### R1 – Hotfixes & Logging-Härtung ✅ ERLEDIGT (2026-06-09)
+
+| # | Aufgabe | Datei | Status |
+|---|---------|-------|--------|
+| R1.1 | `var_dump/exit` bei APPROVED ersetzen: Log + `abort(403)` mit Fehlercode 1002 | `Pay\PayoneController` Z. 435–441 | ✅ |
+| R1.2 | `PENDING`-Status: `abort(503)` mit User-Meldung statt barem `exit()` | `Pay\PayoneController` Z. 444–451 | ✅ |
+| R1.3 | `redactRequest()` filtert `cardpan`/`cardcvc2` aus allen Catch-Block-Logs | `App\Services\Payone` Z. 182–188 | ✅ |
+| R1.4 | 5× unreachable `exit;` nach `return redirect()` entfernt | `Pay\PayoneController` | ✅ |
+| R1.5 | `PAYONE_KEY` aus `.env` statt Literal im Code | `config/payone.php` Z. 8 | ✅ |
+
+**Betroffene Dateien:**
+- `app/Http/Controllers/Pay/PayoneController.php`
+- `app/Services/Payone.php`
+- `config/payone.php`
+
+**Abweichungen / Entscheidungen:**
+- R1.1 entschieden gegen Redirect zu `transaction_approved` (erfordert `$payt` → würde DB-Create vor Fehlerbehandlung nötig machen); stattdessen strukturiertes Error-Log + `abort(403)`.
+- R1.3 filtert nur `cardpan` + `cardcvc2`; `cardexpiredate` wurde bewusst *nicht* redaktiert (kein PCI-Scope, hilfreich für Debugging).
+- R1.5 war im Plan als Lücke notiert (Abschnitt 2.4) und wurde als Teil von R1 mit umgesetzt.
+
+**Tests:** `PayoneTest` (5 Tests) weiterhin grün. Separater Test für Log-Redaction steht noch aus (als niedrig priorisiert eingestuft – Verhalten ist durch `array_diff_key`-Logik hinreichend einfach).
+
+#### R2 – PayoneTransactionRecorder & Response-Normalisierung ✅ ERLEDIGT (2026-06-09)
+
+Extrahiert die duplizierten `PaymentTransaction::create`-Blöcke in eine zentrale Klasse:
+
+```php
+// app/Services/Payment/Payone/PayoneTransactionRecorder.php
+PayoneTransactionRecorder::recordFromResponse(
+ ShoppingPayment $payment,
+ string $requestType,
+ array $response,
+ int $useridFallback = 0,
+): PaymentTransaction
+
+PayoneTransactionRecorder::recordInvoicePayment(
+ ShoppingPayment $payment,
+ string $requestType,
+ array $requestPayload,
+): PaymentTransaction
+```
+
+**Betroffene Dateien:**
+- `app/Services/Payment/Payone/PayoneTransactionRecorder.php` *(neu)*
+- `app/Http/Controllers/Pay/PayoneController.php` — 4× `PaymentTransaction::create` ersetzt
+- `app/Services/PaymentHelper.php` — 3× `create` zu 1× `recordFromResponse` + `statusMap` vereinfacht
+- `app/Cron/UserMakeOrder.php` — `recordPaymentTransaction()` delegiert an Recorder
+
+**Abweichungen / Entscheidungen:**
+- Plan sah `PayonePaymentResult`-DTO in der Recorder-Signatur vor (Phase R4-Vorgriff). Umgesetzt mit `array $response` statt DTO, da DTOs erst in R4 eingeführt werden. Migration auf DTO ist dann ein reiner Signatur-Swap ohne Logikänderung.
+- `recordInvoicePayment()` als separate Methode für FNC/MIV-Sonderfall (kein API-Call, synthetischer Status `FNCMIV`). Plan hatte das nicht explizit vorgesehen.
+- `transmitted_data` wird nun auch bei ERROR und REDIRECT gespeichert (vorher nur bei APPROVED in `Pay\PayoneController`). Verbessert Debugbarkeit, kein Verhaltens-Bruch.
+- `PaymentHelper::initELVPayment`: 3 separate if-Blöcke zu 1 `in_array`-Check + `statusMap`-Array konsolidiert.
+
+**Tests:** `tests/Feature/PayoneTransactionRecorderTest.php` (5 Tests, 44 Assertions) grün. `PayoneTest` (5 Tests) weiterhin grün.
+
+#### R3 – PayoneRequestBuilder & PaymentMethodResolver ✅ ERLEDIGT (2026-06-09)
+
+**Betroffene Dateien:**
+- `app/Services/Payment/Payone/PayoneMethodConfig.php` *(neu)* — Value Object für Method-Parameter
+- `app/Services/Payment/Payone/PaymentMethodResolver.php` *(neu)* — `resolve(string, array): PayoneMethodConfig`
+- `app/Services/Payment/Payone/PayoneRequestBuilder.php` *(neu)* — `buildPersonalData`, `buildDeliveryData`, `buildRequest`
+- `app/Http/Controllers/Pay/PayoneController.php` — `setMethod()`, `setPersonalData()`, `setDeliverylData()` delegieren; 3× `array_merge` → `buildRequest()`
+
+**Abweichungen / Entscheidungen:**
+- Plan nannte `buildAuthorizationRequest()` als einheitliche Build-Methode; umgesetzt als `buildRequest()` mit allen Segmenten als optionale Parameter — flexibler, deckt `onlyPaymentResponse()` und `ResponseData()` und `checkBankAccount()` gleichermaßen ab.
+- `$this->method` bleibt intern ein `array` (kein Breaking Change); `PayoneMethodConfig::toArray()` liefert das Array an der Resolver-Grenze. Migration auf typisiertes Property in R4.
+- `checkCreditCard()` und `checkBankAccount()` nutzen weiterhin `array_merge($this->default, $this->prepayment)` direkt — keine Method-Config nötig, da kein `setMethod()`-Aufruf dort.
+- `fnc#PYV` (PAYONE Rechnungskauf) wirft `InvalidArgumentException` statt still ignoriert zu werden — bisheriger stiller Fehler behoben.
+
+**Tests:** `tests/Unit/Services/Payment/PaymentMethodResolverTest.php` (11 Tests, 64 Assertions) grün. Alle `cc`, `elv`, `vor`, `wlt#PPE`, `wlt#PDT`, `sb#PNT`, `sb#GPY`, `fnc#MIV` + 3 Error-Cases geprüft.
+
+#### R4 – PayonePaymentService ✅ ERLEDIGT (2026-06-09)
+
+**Betroffene Dateien:**
+- `app/Services/Payment/Payone/DTOs/PayonePaymentResult.php` *(neu)* — immutables DTO mit `status`, `txid`, `userid`, `redirectUrl`, `errorMessage`, `shoppingPayment`
+- `app/Services/Payment/Payone/Exceptions/PayoneTransportException.php` *(neu)*
+- `app/Services/Payment/Payone/Exceptions/PayoneDeclinedException.php` *(neu)*
+- `app/Services/Payment/Payone/PayonePaymentService.php` *(neu)* — `checkCreditCard`, `checkBankAccount`, `chargeAbo`
+- `app/Http/Controllers/Pay/PayoneController.php` — `setAboPayment`, `onlyPaymentResponse`, `checkCreditCard`, `checkBankAccount` delegieren an Service
+- `database/migrations/2026_06_09_182614_add_carddata_to_shopping_payments.php` *(neu)* — fehlende Spalte aus Produktion nachträglich migriert
+
+Implementierte Methoden (alle returnen `PayonePaymentResult`, `?Client $client` für Test-Injection):
+
+| Methode | Ersetzt |
+|---------|---------|
+| `checkCreditCard(array $data, string $mode): PayonePaymentResult` | `PayoneController::checkCreditCard()` |
+| `checkBankAccount(array $data, int $amount, string $currency, ShoppingUser $shoppingUser, string $mode): PayonePaymentResult` | `PayoneController::checkBankAccount()` |
+| `chargeAbo(UserAbo, ShoppingOrder, ShoppingUser, int $amountCents, string $currency): PayonePaymentResult` | `setAboPayment` + `onlyPaymentResponse` |
+
+**Abweichungen / Entscheidungen:**
+- `initiateCheckoutPayment` (Plan) noch **nicht** extrahiert — Checkout-Pfad (`setPrePayment` + `ResponseData`) enthält HTTP-Concerns (Redirect, Session, CheckoutFunnelTracker); Extraktion erfolgt in Phase 1, wenn ein sauberer Aufrufer entsteht.
+- `chargeAbo` nimmt zusätzlich `ShoppingUser $shoppingUser` als Parameter (Plan hatte nur 3 Parameter) — nötig, weil `buildPersonalData` den User braucht.
+- Abo-Pfad im Controller: `setAboPayment` triggert `chargeAbo` sofort; `onlyPaymentResponse` gibt gecachtes `lastPaymentResult` zurück → `UserMakeOrder`-Aufrufer unverändert.
+- `setPersonalData()` bleibt im Controller als No-Op nach `setAboPayment` (Checkout-Pfad nutzt es weiterhin).
+- `shopping_payments.carddata` war in Produktion vorhanden aber nie migriert — fehlende Migration erstellt (idempotent mit `Schema::hasColumn`-Check).
+
+**Tests:** `tests/Feature/PayonePaymentServiceTest.php` (7 Tests, 35 Assertions) — `PayonePaymentResult`-DTO, `checkCreditCard` VALID/ERROR, `chargeAbo` APPROVED mit DB-Check, `chargeAbo` ERROR mit ShoppingPayment-Nachweis. Alle 28 PAYONE-Tests (155 Assertions) grün.
+
+#### R5 – Api-Webhook entflechten ✅ ERLEDIGT (2026-06-10)
+
+**Betroffene Dateien:**
+- `app/Services/Payment/Payone/PayoneWebhookHandler.php` *(neu)* — `handle(array $data): PayoneWebhookResult`, gesamte Logik aus `Api\PayoneController::paymentStatus` (Validierung 2001–2006, Duplikat-Erkennung, txaction-Priorität, DB-Lock-Paid-Pfad, Mail)
+- `app/Services/Payment/Payone/DTOs/PayoneWebhookResult.php` *(neu)* — Factory-States `rejected()`, `duplicateIgnored()`, `processed()`; `response` ist immer `TSOK`
+- `app/Http/Controllers/Api/PayoneController.php` — von ~250 auf 18 Zeilen reduziert; konstruktor-injizierter Handler, `return response($result->response)` statt `echo 'TSOK'; exit`
+
+**Abweichungen / Entscheidungen:**
+- `PayoneWebhookValidator` (Plan-Option) **nicht** als eigene Klasse extrahiert — die 6 Validierungs-Guards sind kontextgebunden (Order/Payment-Lookups, Preisabgleich) und im Handler linear lesbarer als ein separater Validator.
+- Kein `FormRequest` — PAYONE sendet `application/x-www-form-urlencoded` ohne CSRF; die bestehende Param-Vollständigkeitsprüfung (`2001`) bleibt im Handler, da bei Fehlern weiterhin `TSOK` (nicht `422`) an PAYONE zurückgehen muss.
+- `throw $e` im Paid-Pfad (DB-Transaktionsfehler) **bleibt** erhalten → PAYONE wiederholt den Callback; kein `TSOK` bei echtem Verarbeitungsfehler. Bewusst kein Verhaltens-Bruch.
+- txaction-Priorität als `private const TXACTION_PRIORITY` statt lokalem Array (vorher inline).
+- Verhalten 1:1 erhalten: alle `MyLog::writeLog`-Aufrufe, `Util::utf8ize`, `CheckoutFunnelTracker`, `lockForUpdate`-Doppelprüfung identisch übernommen.
+
+**Tests:** `tests/Feature/PayoneWebhookHandlerTest.php` (11 Tests, 32 Assertions): Rejections 2001–2006, Duplikat-Ignorierung (paid + appointed), appointed-Verarbeitung, Prioritäts-Downgrade-Schutz, Paid-Happy-Path (Order wird `paid`). `PaymentAboCallbackCreatesUserAboTest` (gleicher `paymentStatusPaidAction`-Pfad) weiterhin grün.
+- *Hinweis:* `PayoneRaceConditionTest` schlägt bereits auf sauberem Stand fehl (`DatabaseTransactions` ohne migrierte Test-DB) — vorbestehend, von R5 unberührt.
+
+#### R6 – PayoneClient: Exceptions statt abort ✅ ERLEDIGT (2026-06-10)
+
+**Betroffene Dateien:**
+- `app/Services/Payone.php` — 5× `abort(403, …)` → `throw new PayoneTransportException(…)` (Codes 1002/1003/1004, Meldungstexte identisch)
+- `app/Exceptions/Handler.php` — `PayoneTransportException` in `$dontReport`; `render()` mappt sie auf `HttpException(403)` (Fallback = altes `abort(403)`-Verhalten)
+- `app/Http/Controllers/Web/CheckoutController.php` — `processPaymentMethod()` fängt `PayoneTransportException` → Redirect zu `checkout.checkout_card` mit Flash-Fehlermeldung
+- `tests/Unit/Services/PayoneTest.php` — Netzwerkfehler-Test auf `PayoneTransportException` umgestellt; 1004-Fall ergänzt
+
+**Abweichungen / Entscheidungen:**
+- **Zweistufige Fehlerbehandlung statt nur Checkout-Catch:** Der globale `Handler::render()` bildet uncaught `PayoneTransportException` 1:1 auf eine 403-Seite ab. So bleiben *alle* HTTP-Aufrufer (insb. `PaymentHelper::initELVPayment` im Membership-Flow) ohne Anpassung verhaltensgleich; der Checkout fängt zusätzlich explizit ab für den UX-freundlicheren Redirect.
+- **`$dontReport`-Eintrag nötig:** `abort(403)`/`HttpException` wird von Laravel nicht gemeldet; eine eigene Exception hingegen schon → hätte doppelte Exception-Mails (zusätzlich zur bestehenden `MyLog`-Mail) ausgelöst. Eintrag in `$dontReport` verhindert das.
+- **Cron unverändert:** `UserMakeOrder::makePayment` fängt bereits `\Exception`; `PayoneTransportException extends RuntimeException` → wird geloggt + re-thrown wie zuvor (vorher `HttpException`, ebenfalls `\Exception`). Kein CLI-`abort` mehr.
+- Controller-interne Business-Aborts (`PENDING` 1000, unbekannter clearingtype 1002, kein Status 1001 in `Pay\PayoneController::ResponseData`) bleiben als `abort()` — sind keine Transportfehler und nicht Teil von R6.
+
+**Tests:** `PayoneTest` (7 Tests inkl. 1002/1004-Transportfehler), `PayoneTransportExceptionHandlingTest` (2 Tests: 403-Render + `shouldReport=false`). Gesamte R-Phase-Suite: 44 grün.
+
+#### R7 – Aufräumen & DI-Vorbereitung ✅ ERLEDIGT (2026-06-10)
+
+**Betroffene Dateien:**
+- `app/Http/Controllers/Pay/PayoneController.php` — ~150 Zeilen auskommentierter Legacy-Code entfernt (`getPDFFile`, `checkStatus`, Shopware-Referenzblöcke, Karten-/clearingtype-Listen); `@deprecated`-Klassen-Docblock mit Migrationshinweis + clearingtype-Referenz; Konstruktor löst Service via `app(PayonePaymentService::class)` aus dem Container
+- `app/Providers/AppServiceProvider.php` — `PayonePaymentService` als Singleton registriert (`config('payone.defaults')`, realer Guzzle-Client zur Laufzeit)
+
+**Abweichungen / Entscheidungen:**
+- **Controller-Länge 614 → 328 Zeilen**, nicht unter das 150-Zeilen-Ziel. Grund: `setPrePayment()` (Checkout-State-Aufbau, Abo-Init CC/PayPal) und `ResponseData()` (HTTP-Redirects/Session/Status-Mapping, ~110 Zeilen) bleiben bewusst im Controller — sie sind reine HTTP-/Checkout-Concerns. Deren Extraktion in `PayonePaymentService::initiateCheckoutPayment()` ist Phase-1-Arbeit (siehe R4-Abweichung), nicht Teil des reinen Refactorings ohne neuen Aufrufer.
+- **DI live geschaltet:** Controller nutzt jetzt den Container-Singleton statt `new PayonePaymentService(...)`. Sicher, weil `mode` immer explizit pro Methodenaufruf übergeben wird — der geteilte Singleton braucht nur die Basis-Defaults. `Pay\PayoneController` selbst bleibt `new`-instanziiert (Aufrufer-Migration auf DI erst in Phase 1, gemäß OR2).
+- clearingtype-Dokumentation aus dem Datei-Header in den Klassen-Docblock überführt (statt gelöscht) — bleibt als Referenz erhalten, verweist auf `PaymentMethodResolver`.
+- Return-Types: an allen neuen Klassen (R3–R6) vorhanden; der Legacy-Controller behält seine bestehenden Signaturen (gemischte Rückgaben in `ResponseData`), um Aufrufer nicht zu brechen.
+
+**Tests:** Keine neuen Tests nötig (reines Cleanup + DI-Registrierung). Container-Auflösung verifiziert; gesamte R-Phase-Suite (44 Tests) weiterhin grün.
+
+---
+
+### Phase R abgeschlossen ✅ (2026-06-10)
+
+Alle Unterphasen R1–R7 erledigt. Die PAYONE-Integration ist entflochten in:
+- `PayonePaymentService` (Checkout-/Abo-/CC-/SEPA-Requests) + `PayonePaymentResult`-DTO
+- `PayoneRequestBuilder` + `PaymentMethodResolver` + `PayoneMethodConfig`
+- `PayoneTransactionRecorder` (zentrale `PaymentTransaction`-Erstellung)
+- `PayoneWebhookHandler` + `PayoneWebhookResult` (IPN/TSOK-Callback)
+- `PayoneTransportException` + Handler-Mapping (keine `abort()` in der Service-Schicht)
+
+`Pay\PayoneController` ist `@deprecated`-Fassade; verbleibender Checkout-HTTP-Flow wird in **Phase 1** auf `PayonePaymentService::initiateCheckoutPayment()` gehoben.
+
+### Abgrenzung zu Phase 4
+
+| Phase R | Phase 4 |
+|---------|---------|
+| PAYONE-Code refactoren | Provider-Feld in DB, PayPal-Gateway **neu** |
+| Gleiche API, sauberer Code | Neue Schnittstellen (`PaymentGatewayInterface`) |
+| Kein PayPal | PayPal direkt |
+
+Phase R **liefert** die `PayonePaymentService`-Klasse, die Phase 4 als `PayoneGateway implements PaymentGatewayInterface` formalisiert.
+
+### Akzeptanzkriterien Phase R
+
+- [x] Kein `var_dump`, kein barem `exit()` in Payment-Flows *(R1 – 2026-06-09)*
+- [x] PAN/CVC erscheinen nicht in `storage/logs/payone.log` *(R1 – 2026-06-09)*
+- [x] `PaymentTransaction`-Erstellung an **einer** Stelle (`PayoneTransactionRecorder`) *(R2 – 2026-06-09)*
+- [x] Unit-Tests für `PaymentMethodResolver` + `PayoneRequestBuilder` *(R3 – 2026-06-09)*
+- [x] Feature-Tests Checkout-Callback und Abo-Cron unverändert grün *(laufend geprüft)*
+- [x] `Pay\PayoneController` delegiert an Services (`setAboPayment`, `checkCreditCard`, `checkBankAccount`, `onlyPaymentResponse`) *(R4 – 2026-06-09)*; Legacy-Code entfernt, 614 → 328 Zeilen *(R7 – 2026-06-10)*. Das 150-Zeilen-Ziel bleibt offen, weil der Checkout-HTTP-Flow (`setPrePayment`/`ResponseData`) erst in Phase 1 extrahiert wird.
+
+### Risiken & Mitigation
+
+| Risiko | Mitigation |
+|--------|------------|
+| Regression im Live-Checkout | Sub-Steps einzeln deployen; R1 Hotfixes sofort, Rest hinter Feature-Flag optional |
+| Refactoring zieht sich | Zeitbox 6 PT; R4 bei Bedarf kürzen (nur Abo-Pfad + Checkout-Pfad) |
+| Doppelarbeit mit Phase 4 | R3/R4 API so designen, dass `PayoneGateway` sie 1:1 übernimmt |
+
+### Offene Fragen (Phase R)
+
+| ID | Frage |
+|----|-------|
+| OR1 | Soll `Pay\PayoneController` langfristig gelöscht oder dauerhaft als Fassade bleiben? |
+#Wenn er nicht mehr gebraucht wird, kann entfernt werden
+| OR2 | DI sofort (`PayonePaymentService` inject) oder Übergang mit `new` + Fassade? Empfehlung: Fassade + Service-Container-Registrierung, Aufrufer migrieren in Phase 1. |
+#Wie Empfehlung
+| OR3 | Soll `PaymentHelper` in Phase R mit refactored werden oder erst bei Membership-Refactoring? Empfehlung: mindestens R2 (TransactionRecorder) anwenden. |
+#Wie Empfehlung
+
+## 5.2 Code-Review der bisherigen Umsetzung (2026-06-10)
+
+### Prüfstand
+
+Geprüft wurden die Phase-R-Änderungen in:
+
+- `app/Services/Payment/Payone/*`
+- `app/Http/Controllers/Pay/PayoneController.php`
+- `app/Http/Controllers/Api/PayoneController.php`
+- `app/Services/Payone.php`
+- `app/Services/PaymentHelper.php`
+- `app/Cron/UserMakeOrder.php`
+- `app/Http/Controllers/Web/CheckoutController.php`
+- `config/payone.php`
+- zugehörige Tests unter `tests/Feature/*Payone*` und `tests/Unit/Services/*Payone*`
+
+### Ergebnis-Zusammenfassung
+
+Die Umsetzung von Phase R ist in der Tendenz sauber und testbar: Service-Schicht, Request-Builder, Method-Resolver, DTOs, Webhook-Handler und Transport-Exception-Mapping sind vorhanden und die fokussierte PAYONE-Test-Suite ist grün. Es gibt aber einige Punkte, die vor Phase 1 korrigiert oder zumindest bewusst entschieden werden sollten.
+
+### Befunde
+
+| Prio | Befund | Datei / Stelle | Risiko | Empfehlung |
+|------|--------|----------------|--------|------------|
+| **Hoch** | PAYONE-Key-Konfiguration weicht vom Sicherheitsziel ab: Plan-Ziel war `PAYONE_KEY_HASH` (Hash in `.env`), umgesetzt ist `hash('md5', env('PAYONE_KEY', ''))` mit Klartext-Key in `.env`. Zusätzlich ergibt fehlender Env-Wert still den MD5 von leerem String. | `config/payone.php` Z. 8, `.env` | Secret bleibt als Klartext im Environment/Deployment; falscher oder leerer Key wird spät bemerkt; Webhook-Validierung und API-Calls brechen dann schwer diagnostizierbar. | Entweder Plan an Klartext-Env anpassen oder besser Ziel umsetzen: `PAYONE_KEY_HASH` verwenden, keinen Runtime-Hash bilden, bei leerem Wert hart fehlschlagen/Config-Check ergänzen. |
+| **Hoch** | `PaymentTransaction`-Erstellung ist nicht vollständig zentralisiert: `PayoneWebhookHandler` erstellt Transaktionen weiterhin direkt per `PaymentTransaction::create`. | `PayoneWebhookHandler` Z. 146–160 | Akzeptanzkriterium "an einer Stelle" ist nur teilweise erfüllt; spätere Redaction/Normalisierung kann Webhook-Transaktionen vergessen. | Webhook-Persistenz in `PayoneTransactionRecorder::recordWebhookCallback(...)` verschieben oder Akzeptanzkriterium auf "API-Response-Transaktionen zentralisiert" präzisieren. |
+| **Mittel** | Recorder filtert `transmitted_data` nicht. Der aktuelle `recordFromResponse`-Pfad speichert meist Responses, aber `recordInvoicePayment` speichert Request-Payloads unverändert und der Plan behauptet allgemein PCI-Filterung im Recorder. | `PayoneTransactionRecorder` Z. 21–32, 46–54; Observability-Abschnitt 22.4 | Wenn künftig versehentlich Request-Daten mit `cardpan`/`cardcvc2` an den Recorder gehen, landen sie in der DB. | Gemeinsame `redactPayload()`-Methode im Recorder ergänzen (mind. `cardpan`, `cardcvc2`, optional `key`, IBAN-Masking je nach Datenschutzentscheidung) und Tests dafür hinzufügen. |
+| **Mittel** | Webhook-Handler greift auf `$data['txaction']` und `$data['mode']` zu, obwohl die Vollständigkeitsprüfung diese Felder nicht erzwingt. Das entspricht vermutlich dem alten Verhalten, ist aber in der extrahierten Klasse eine vermeidbare Notice-/Undefined-Index-Kante. | `PayoneWebhookHandler` Z. 38, 115, 156, 162–163, 183, 187, 193 | Malformed Callback mit vorhandenen Pflichtfeldern, aber ohne `txaction` oder `mode`, erzeugt PHP-Warnings/Fehler statt sauberem `TSOK` mit Fehlercode. | Pflichtprüfung um `txaction` und `mode` erweitern oder Defaults setzen (`mode` optional mit `$shopping_payment->mode`). |
+| **Mittel** | `UserMakeOrder::makePayment()` loggt den Response-Status als Objektproperty (`$response->status`), erhält aber aus `onlyPaymentResponse()` weiterhin ein Array. | `UserMakeOrder.php` Z. 77–83 | Irreführendes Log ("unbekannt") und je nach PHP-Konfiguration Warning "Attempt to read property on array". | Auf `($response['status'] ?? 'unbekannt')` ändern. |
+| **Niedrig** | `PayoneTransportException` verliert den ursprünglichen Guzzle-Fehler als `$previous`. | `Payone::sendRequest()` Catch-Blöcke | Debugging wird unnötig schwer, auch wenn MyLog Details enthält. | `throw new PayoneTransportException($userMessage, 0, $e)` ermöglichen (Konstruktor nicht nötig, `RuntimeException` kann das bereits). |
+| **Niedrig** | `PayonePaymentService::checkCreditCard()` validiert Input nicht und greift direkt auf Array-Keys zu. | `PayonePaymentService` Z. 24–37 | In Phase 1 kann ein unvollständiger CC-Update-Request zu PHP-Fehlern statt User-Fehlermeldung führen. | Für Phase 1 zwingend `UpdateAboCreditCardRequest` nutzen; Service optional mit klarer Domain-Exception absichern. |
+
+### Positiv bestätigt
+
+- `Payone::sendRequest()` redaktiert `cardpan` und `cardcvc2` in Transportfehler-Logs.
+- `PayoneTransportException` wird global wieder auf 403 gemappt und im Checkout benutzerfreundlich abgefangen.
+- `PayoneWebhookHandler` hält die bestehende TSOK-Semantik bei Validierungsfehlern und lässt echte Paid-Pfad-Exceptions weiterwerfen, damit PAYONE erneut zustellt.
+- `PaymentMethodResolver` wirft bei unbekannten Methoden explizite Exceptions statt stillem Fehler.
+- Abo-Abbuchung (`chargeAbo`) erstellt weiterhin `ShoppingPayment` vor dem API-Call und gibt die Response kompatibel an den alten Cron-Aufrufer zurück.
+
+### Verifikation
+
+Ausgeführt am 2026-06-10:
+
+```bash
+./vendor/bin/pest tests/Unit/Services/PayoneTest.php \
+ tests/Unit/Services/Payment/PaymentMethodResolverTest.php \
+ tests/Feature/PayoneTransactionRecorderTest.php \
+ tests/Feature/PayonePaymentServiceTest.php \
+ tests/Feature/PayoneWebhookHandlerTest.php \
+ tests/Feature/PayoneTransportExceptionHandlingTest.php
+```
+
+Ergebnis: **42 Tests bestanden**, **1 optionaler Netzwerktest übersprungen**, **190 Assertions**.
+
+```bash
+./vendor/bin/pint --test app/Services/Payment/Payone app/Services/Payone.php \
+ app/Http/Controllers/Pay/PayoneController.php app/Http/Controllers/Api/PayoneController.php \
+ app/Services/PaymentHelper.php app/Cron/UserMakeOrder.php app/Exceptions/Handler.php \
+ app/Http/Controllers/Web/CheckoutController.php app/Providers/AppServiceProvider.php \
+ config/payone.php tests/Unit/Services/PayoneTest.php \
+ tests/Unit/Services/Payment/PaymentMethodResolverTest.php \
+ tests/Feature/PayoneTransactionRecorderTest.php tests/Feature/PayonePaymentServiceTest.php \
+ tests/Feature/PayoneWebhookHandlerTest.php tests/Feature/PayoneTransportExceptionHandlingTest.php
+```
+
+Ergebnis: **Pint --test grün** (25 Dateien).
+
+Zusätzlich: `php -l` für die zentralen PAYONE-Dateien ohne Syntaxfehler.
+
+### Review-To-dos vor Phase 1
+
+1. [x] **PAYONE-Key-Strategie finalisiert** *(2026-06-10)* — `PAYONE_KEY_HASH` (MD5-Hash, kein Klartext). `config/payone.php` liest den Wert direkt (kein Runtime-`hash()`), fehlt er → harter Boot-`RuntimeException`. Klartext-`PAYONE_KEY` aus `.env` entfernt; `phpunit.xml` + `.env.example` ergänzt. Der Hard-Fail schließt zugleich die `'' != ''`-Webhook-Bypass-Lücke strukturell (Key kann nie leer sein).
+ - **Hash-Erzeugung:** Der Klartext-Key wird zur Laufzeit nie gebraucht (API + Webhook nutzen nur den Hash). Für die einmalige Hash-Erzeugung dient der Artisan-Command `payone:hash-key` (verdeckte Eingabe via `laravel/prompts`, optional `--write` schreibt `PAYONE_KEY_HASH` direkt in die `.env`). So muss der Klartext-Key **nicht** dauerhaft in der `.env` liegen.
+2. [x] **Webhook-Persistenz zentralisiert** *(2026-06-10)* — `PayoneTransactionRecorder::recordWebhookCallback()` neu; `PayoneWebhookHandler` ruft nur noch den Recorder. `PaymentTransaction::create` existiert jetzt ausschließlich im Recorder.
+3. [x] **Recorder-Redaction ergänzt** *(2026-06-10)* — `redactPayload()` strippt `cardpan`/`cardcvc2` aus `transmitted_data` in allen drei Record-Methoden; Test `strips cardpan and cardcvc2 …` hinzugefügt.
+4. [x] **Webhook-Pflichtfelder abgesichert** *(2026-06-10)* — `txaction` ist jetzt Pflichtfeld (sonst `2001`); `mode` fällt im Recorder auf `$payment->mode` zurück; restliche `$data['mode']`-Zugriffe sind null-sicher. Test `rejects a callback missing txaction` hinzugefügt.
+5. [x] **`UserMakeOrder`-Status-Log korrigiert** *(2026-06-10)* — liest `$response['status']` per Array-Zugriff statt `$response->status`.
+
+**Zusätzlich (Niedrig-Befund):** `Payone::sendRequest` reicht die ursprüngliche Guzzle-Exception als `$previous` an `PayoneTransportException` weiter.
+
+> Offen bleibt nur der Niedrig-Befund zur Input-Validierung in `checkCreditCard()` — bewusst nach Phase 1 verschoben, da dort der `UpdateAboCreditCardRequest` (Form Request) eingeführt wird.
+
+**Verifikation der Fixes (2026-06-10):** 47 Tests grün (216 Assertions); `pint` grün; Hard-Fail bei fehlendem `PAYONE_KEY_HASH` manuell bestätigt.
+
+---
+
+### 5.2.1 Nachprüfung Abo-Initialisierung / Doppelabo-Schutz (2026-06-10)
+
+**Ausgangsbefund:** Neu gestartete Abos mit noch nicht bestätigter Erstzahlung konnten in den Abo-Übersichten fehlen, weil mehrere Queries nur `status > 1` berücksichtigt haben. Dadurch war für den User nicht sichtbar, ob der Checkout schon ein Abo initiiert hat. Zusätzlich konnte bei Kunden-Abos ein reiner Zahlungslink (`ShoppingInstance.payment=6`) eine offene Abo-Initiierung repräsentieren, ohne in `/user/abos/ot` sichtbar zu sein.
+
+**Umgesetzt:**
+
+- `UserAbo`-Übersichten für Eigen-, Kunden-, Team- und Portal-Abos berücksichtigen initiierte Abos ab `status >= 1`.
+- Pending-Erstzahlungen werden mit Statushinweis angezeigt: Zahlung noch offen, Zahlung in Verarbeitung oder Zahlung fehlgeschlagen.
+- Fehlgeschlagene, noch nicht aktive Erstzahlungs-Abos können gelöscht werden; solange noch kein Zahlungsfehler vorliegt bzw. die Zahlung beim Anbieter in Bearbeitung ist, ist Löschen gesperrt.
+- Der Checkout blockiert neue Abo-Bestellungen, sobald bereits ein Abo initiiert ist. Das gilt für eigene Abos und Kunden-Abos.
+- `/user/abos/ot` zeigt zusätzlich offene Abo-Zahlungslinks aus `/user/order/payment/links`, sofern `shopping_data.is_for=abo-ot-customer` und `shopping_data.is_abo=true`.
+- Abo-Zahlungslinks werden in `/user/abos/ot` ausgeblendet, sobald daraus bereits ein `UserAbo` entstanden ist. Dadurch erscheint ein bezahltes, aber vom Anbieter noch nicht final bestätigtes Abo nicht doppelt als Zahlungslink und als Abo.
+- Abo-Zahlungslinks sind löschbar bei Status `0`/`1` (Link gesendet/geöffnet) und wieder bei `5`/`6` (Fehler/abgebrochen). Bei Status `2`/`3`/`4` (Zahlung läuft bzw. Anbieter wartet) und `10` (bezahlt) ist Löschen gesperrt.
+- Die allgemeine Zahlungslink-Übersicht blendet Löschen ebenfalls aus, wenn der Status nicht löschbar ist, und zeigt Flash-Meldungen bei blockiertem Löschen.
+
+**Betroffene Kernstellen:**
+
+- `app/Models/UserAbo.php`
+- `app/Services/AboHelper.php`
+- `app/Services/OrderPaymentService.php`
+- `app/Http/Controllers/User/AboController.php`
+- `app/Http/Controllers/User/OrderController.php`
+- `app/Http/Controllers/User/OrderPaymentController.php`
+- `resources/views/user/abo/index.blade.php`
+- `resources/views/user/order/payment/index.blade.php`
+- `routes/domains/crm.php`
+- `tests/Feature/AboPendingInitialPaymentTest.php`
+
+**Verifikation (2026-06-10):**
+
+```bash
+./vendor/bin/pest tests/Feature/AboPendingInitialPaymentTest.php \
+ tests/Unit/Services/PaymentAboCallbackCreatesUserAboTest.php \
+ tests/Feature/AdminAboRetryPaymentTest.php
+```
+
+Ergebnis: **12 Tests bestanden**, **38 Assertions**.
+
+```bash
+./vendor/bin/pint --test app/Models/UserAbo.php app/Services/AboHelper.php \
+ app/Services/OrderPaymentService.php app/Http/Controllers/User/AboController.php \
+ app/Http/Controllers/User/OrderController.php app/Http/Controllers/User/OrderPaymentController.php \
+ app/Http/Controllers/User/TeamController.php app/Http/Controllers/Portal/AboController.php \
+ resources/views/user/abo/index.blade.php resources/views/user/order/payment/index.blade.php \
+ resources/views/user/team/abos.blade.php resources/views/user/team/customer_abos.blade.php \
+ resources/views/portal/abo/my_abo.blade.php resources/lang/de/abo.php resources/lang/en/abo.php \
+ routes/domains/crm.php tests/Feature/AboPendingInitialPaymentTest.php
+```
+
+Ergebnis: **Pint --test grün** (17 Dateien).
+
+---
+
+## 5.3 KI-Code-Review & Architektur-Optimierungen (Gemini)
+
+Da die Implementierung aus Phase R formal abgeschlossen wurde, ergeben sich für die folgenden Phasen (insb. Phase 1, 3 und 6) noch architektonische Optimierungspotenziale, um die Systemstabilität und Sicherheit zu maximieren.
+
+### 1. Datenkonsistenz & Rollback bei externen API-Calls (Phase 1)
+**Befund:** Bei der CC-Aktualisierung (PAYONE `updateuser`) muss nach erfolgreichem API-Call die lokale Datenbank (`user_abos`) aktualisiert werden.
+**Optimierung:** Da externe API-Calls nicht in eine DB-Transaktion gerollt werden können, muss das anschließende DB-Update mit einem strikten `try-catch` abgesichert werden. Schlägt das DB-Update fehl (z. B. durch einen Datenbank-Deadlock), sind PAYONE und MIVITA asynchron (PAYONE hat die neue Karte, MIVITA die alte). Es muss zwingend ein **CRITICAL-Log-Alert** ausgelöst und ein Auto-Incident im Payment-Dashboard generiert werden, der auf diese Inkonsistenz hinweist.
+
+> **Anmerkung (Claude, 2026-06-10):** Zustimmend, aber ein Alert allein *heilt* die Inkonsistenz nicht – er macht sie nur sichtbar. Ergänzend zwingend:
+> - **Detektierbare Spur statt nur Alert:** Vor dem API-Call ein `card_update_pending`-Flag/Zeitstempel auf `user_abos` setzen. Ein Crash/Deadlock nach dem PAYONE-Call hinterlässt damit einen auffindbaren Zustand (sonst weiß niemand, dass etwas offen ist).
+> - **Idempotenter Reparaturpfad:** Das DB-Update muss wiederholbar sein (erneutes Schreiben desselben `pseudocardpan`), damit ein Recovery-Job / manueller Retry die Lücke schließen kann. Optional: bei Bedarf den aktuellen Kartenstatus aktiv von PAYONE nachziehen.
+> - **Reihenfolge bleibt:** PAYONE OK → DB-Update (nie umgekehrt); der neue `pseudocardpan` ist token-sicher loggbar (PAN/CVC fallen ohnehin durch die Recorder-Redaction aus R-Review).
+> - Auto-Incident mit Bestandsmitteln umsetzbar (`MyLog` + Incident-Model des Dashboards, Abschnitt 22) – keine neue Infrastruktur nötig.
+
+### 2. Absicherung der Recovery-Links (Phase 3)
+**Befund:** Der Plan sieht UUIDs für die Recovery-Links vor (O12: 72h TTL).
+**Optimierung:** Ein Klartext-Speichern der UUID in der Tabelle `user_abo_payment_recovery_links` ist bei Datenbank-Leaks ein hohes Risiko. Der Token sollte in der DB **gehasht** (analog zu Passwort-Reset-Tokens via `Hash::make()`) gespeichert werden. Zusätzlich ist ein striktes **Rate-Limiting** (z. B. über Laravels `Throttle`-Middleware) auf den Einlöse-Endpoint zu setzen, um Brute-Force-Angriffe zu verhindern.
+
+> **Anmerkung (Claude, 2026-06-10) – Mechanismus korrigieren ⚠️:** Token hashen: ja. Aber **`Hash::make()` (bcrypt) ist hier das falsche Werkzeug.** Bcrypt ist gesalzen → kein direkter Lookup per Hash möglich; man müsste *jede* Zeile einzeln durchhashen (O(n) pro Einlösung). Laravels Passwort-Reset funktioniert nur deshalb mit `Hash::make`, weil dort zuerst per **E-Mail** gesucht und dann `Hash::check` ausgeführt wird – diesen Schlüssel hat ein anonymer Recovery-Link nicht.
+> Korrekt für einen **Lookup-Token**:
+> - **Indexierter SHA-256:** `hash('sha256', $token)` in einer indexierten Spalte speichern, Lookup darüber (deterministisch, O(1) per Index). **Empfohlen.**
+> - *oder* **Selector/Verifier-Pattern:** öffentlicher `selector` für den Lookup + gehashter `verifier` für die Prüfung mit konstantzeitigem Vergleich (`hash_equals`).
+> Rate-Limiting (`throttle`), Single-Use (`used_at`), 72h-TTL und Bindung an `user_abo_id` bleiben unverändert korrekt. Der Klartext-Token wird ausschließlich im E-Mail-Link transportiert, **nie** persistiert.
+
+### 3. Ausfallsicherheit durch PayPal Webhook Reconciliation (Phase 6)
+**Befund:** Wenn PayPal das Billing vollständig übernimmt (Option A), wird der Cronjob `user:make_abo_order` für diese Abos übersprungen. Die Auslösung der Bestellung hängt zu 100 % am Webhook `PAYMENT.SALE.COMPLETED`.
+**Optimierung:** Webhooks können durch Netzwerkprobleme bei MIVITA, fehlerhafte Deployments oder Verzögerungen bei PayPal verloren gehen. Es wird dringend empfohlen, einen täglichen **Reconciliation-Job (Pull-Methode)** zu implementieren. Dieser prüft alle aktiven PayPal-Abos, deren `next_date` vergangen ist, und fragt den Status aktiv über die PayPal-API ab, falls kein Webhook eingetroffen ist.
+
+> **Anmerkung (Claude, 2026-06-10):** Zustimmend – Webhook-only ist fragil, der Pull-Job ist Pflicht und passt in den bestehenden Scheduler (Muster `user:make_abo_order`). Zwei Ergänzungen:
+> - **Eventname prüfen:** `PAYMENT.SALE.COMPLETED` ist **v1**-Terminologie. Bei Subscriptions API v2 heißen die relevanten Events anders (`BILLING.SUBSCRIPTION.*`, `PAYMENT.CAPTURE.COMPLETED`). Vor der Umsetzung in **Phase 0** verbindlich klären (hängt an O3 – aktuell läuft PayPal nur über Checkout).
+> - **Gemeinsame Idempotenz:** Der Reconciliation-Job muss dieselbe Idempotenz-/Dedup-Logik nutzen wie der Webhook (siehe Befund 5), sonst entsteht eine **Doppelbestellung**, wenn Pull und Webhook beide feuern.
+
+### 4. Tiefe Absicherung der Service-Schicht (Phase 1)
+**Befund:** Der Niedrig-Prio-Befund aus Abschnitt 5.2 bemängelt fehlende Input-Validierung in `checkCreditCard()`. Als Lösung wurde notiert, den `UpdateAboCreditCardRequest` im Controller zu nutzen.
+**Optimierung:** Eine reine Validierung im HTTP-Layer (Controller) schützt die Service-Klasse nicht, wenn diese künftig (z. B. durch ein Admin-CLI-Command) direkt aufgerufen wird. Statt reiner Arrays sollten im `PayonePaymentService` strikt typisierte **DTOs (Data Transfer Objects)** als Parameter erzwungen werden.
+
+> **Anmerkung (Claude, 2026-06-10):** Zustimmend in der Richtung, aber **Framing schärfen:** Form Request und DTO sind **komplementär, kein Entweder-oder**. Der Form Request validiert und erzeugt das DTO (User-Meldungen, CSRF, Auth); das DTO garantiert dem Service strukturell saubere Eingaben (CLI-fest, kein `$data['key']`-Zugriff auf ungeprüfte Arrays). `PayonePaymentResult` ist bereits der Präzedenzfall auf der Output-Seite – ein `CreditCardCheckData`-DTO auf der Input-Seite ist die logische Ergänzung.
+> **Scope-Hinweis:** Zunächst **nur den CC-Pfad in Phase 1** auf DTO heben, nicht den ganzen `PayonePaymentService` auf einen Schlag – sonst inkonsistente Teilmigration. Die übrigen Methoden (`checkBankAccount`, `chargeAbo`) folgen schrittweise, wenn ihr jeweiliger Feature-Pfad angefasst wird.
+
+### 5. Idempotenz & Race-Conditions bei PayPal (Phase 6)
+**Befund:** PayPal sendet Webhooks unter Umständen mehrfach oder parallel (Retries).
+**Optimierung:** Wie in Phase R5 für PAYONE sehr gut umgesetzt, muss auch der `PayPalWebhookController` eine strikte Idempotenzprüfung anhand der `event_id` durchführen. Dies muss zwingend von einer `DB::transaction()` umschlossen sein, die den Abo-Datensatz mit **`lockForUpdate()`** sperrt, um Race Conditions bei Millisekunden-genauen, parallelen Aufrufen exakt identischer Webhooks auszuschließen.
+
+> **Anmerkung (Claude, 2026-06-10):** Zustimmend – das R5-Muster (`lockForUpdate` + Double-Check, Prioritäts-Logik) lässt sich 1:1 übertragen. Zwei sicherheitskritische Ergänzungen:
+> - **DB-Unique-Constraint auf `event_id`:** Harte Idempotenz auf Datenbankebene (ein Duplikat-Insert schlägt fehl), stärker als reine App-Logik – fängt auch parallele Inserts ab, bevor die Geschäftslogik läuft.
+> - **Fehlt im Befund – PayPal-Webhook-Signaturprüfung:** PAYONE nutzte den MD5-Key zur Authentifizierung; PayPal braucht echte **Signatur-Verifikation** (`verify-webhook-signature`-API bzw. Cert-basiert mit `webhook_id`). Ohne diese ist der Endpoint fälschbar. Sicherheitskritisch – darf nicht untergehen (vgl. Abschnitt 4.2).
+
+### Konsolidierte Bewertung (Claude, 2026-06-10)
+
+| # | Befund | Phase | Urteil | Wichtigste Verfeinerung |
+|---|--------|-------|--------|--------------------------|
+| 1 | Konsistenz CC-Update | 1 | ✅ übernehmen | Reparaturpfad + `card_update_pending`-Spur, nicht nur Alert |
+| 2 | Recovery-Token hashen | 3 | ✅ mit Korrektur ⚠️ | **Indexierter SHA-256 / Selector-Verifier statt bcrypt `Hash::make`** |
+| 3 | PayPal Reconciliation | 6 | ✅ übernehmen | Eventnamen in Phase 0 klären (v1 vs. v2); Idempotenz teilen |
+| 4 | DTOs im Service | 1 | ✅ übernehmen | Komplementär zum Form Request; nur CC-Pfad zuerst |
+| 5 | PayPal Idempotenz | 6 | ✅ übernehmen | DB-Unique auf `event_id` + **Signaturprüfung** ergänzen |
+
+**Gesamteinschätzung:** Die Gemini-Befunde sind solide und ohne Ausreißer/Over-Engineering. Unmittelbar relevant für den nächsten Schritt sind nur **① und ④ (beide Phase 1)**; ②/③/⑤ sind korrekt für Phase 3/6 geparkt. Einziger Punkt, der vor der Umsetzung zwingend korrigiert gehört: **② (bcrypt → indexierter SHA-256)**.
+
+---
+
+## 5.4 Live-Verifikation Phase R (End-to-End, 2026-06-10)
+
+Nach Abschluss von R1–R7 + Review-Fixes wurden im Test-System echte Checkout-Bestellungen durchgeführt und die PAYONE-Callbacks (IPN) ausgelöst. Anschließend wurde der DB-Zustand geprüft (read-only).
+
+### Geprüfte Testbestellungen (`mode=test`)
+
+| Order | Methode | Auth-Tx (API-Response) | Webhook-Tx (IPN) | Order-Status | Abo |
+|-------|---------|------------------------|------------------|--------------|-----|
+| 55459 | PayPal (wlt/PPE) | REDIRECT | appointed → paid | **paid ✓** | – |
+| 55460 | CC | APPROVED | paid | **paid ✓** | – |
+| 55461 | CC | APPROVED | appointed → paid | **paid ✓** | Abo 385: status 2, `user_abo_order` paid |
+| 55463 | PayPal (wlt/PPE) | REDIRECT | paid | **paid ✓** | – |
+| 55458 | PayPal (wlt/PPE) | REDIRECT | nur appointed | paid=0 (Zwischenstand) | – |
+| 55462 | CC | APPROVED | – (kein IPN, bewusst) | paid=0 / `prev` | Abo 386: status 1 (pending) |
+
+> 55458 und 55462 wurden **bewusst ohne `paid`-Callback** belassen. Die Orders bleiben korrekt unbezahlt – kein vorzeitiges/falsches Paid. Das belegt: der Zustand kippt erst beim `paid`-IPN.
+
+### Bestätigte Eigenschaften der Phase-R-Implementierung
+
+| Aspekt | Befund in den Live-Daten |
+|--------|--------------------------|
+| **Recorder-Trennung (R2/R5)** | `request='authorization'` = API-Response (`key`=NULL); `request='transaction'` = Webhook (`key` gesetzt). Beide Pfade über `PayoneTransactionRecorder`. |
+| **Webhook-Handler (R5)** | status `PAYONE`, txaction `appointed`/`paid`, `key` + `transmitted_data` via `recordWebhookCallback` persistiert. |
+| **txaction-Priorität (R5)** | 55459 & 55461 zeigen `appointed → paid`; Order landet final auf `paid`, kein Downgrade. |
+| **PCI-Redaction (R-Review #3)** | 0 Treffer für `cardpan`/`cardcvc2` über alle 13 geprüften `transmitted_data`-Felder. |
+| **Key-Config (R-Review #1)** | PAYONE-Callbacks wurden akzeptiert/gespeichert → `PAYONE_KEY_HASH` stimmt mit den von PAYONE gesendeten Keys überein; Hard-Fail-Umstellung bricht den Live-Flow nicht. |
+| **Abo-Anlage** | bezahltes Abo (385) status 2 + `user_abo_order` paid=1; unbezahltes (386) status 1 + paid=0 (pending, vom Cron nicht abgebucht). Karten-Token + `payone_userid` korrekt gespeichert. |
+
+**Ergebnis:** Phase R ist nicht nur formal abgeschlossen, sondern end-to-end im Test-System bestätigt. Keine PCI-Leckage, korrekte Statusübergänge, robustes Verhalten bei ausbleibendem Callback. Solide Basis für Phase 0/1.
+
+---
+
+## 6. Phase 0 – Analyse & API-Klärung
+
+### Ziele
+
+- PAYONE-Dokumentation für **Kartenaktualisierung** am bestehenden `userid` klären.
+- PayPal-Produktentscheidung: **Subscriptions API v2** vs. **Vault + Reference Transactions**.
+- Anzahl betroffener Live-Abos erfassen (CC vs. PayPal-via-PAYONE).
+
+### Aufgaben
+
+1. **PAYONE:** Mit BS PAYONE klären, welcher Request für CC-Update gilt:
+ - Option A: `request=updateuser` mit neuem `pseudocardpan`
+ - Option B: Neuer `creditcardcheck` + Zuordnung zum bestehenden `userid`
+ - Testbench-Aufruf dokumentieren (Sandbox-`userid` aus bestehendem Test-Abo)
+2. **PayPal:** Merchant-Konto prüfen – Subscriptions freigeschaltet? Welche Länder/Währungen?
+3. **Datenbestand:** SQL-Auswertung:
+ ```sql
+ SELECT clearingtype, wallettype, status, COUNT(*)
+ FROM user_abos WHERE deleted_at IS NULL
+ GROUP BY clearingtype, wallettype, status;
+ ```
+4. **Abhängigkeiten:** Checkout-Flow, `UserMakeAboOrder`, `AboRetryPaymentService` als Sequenzdiagramm finalisieren.
+
+### Deliverables
+
+- [ ] PAYONE API-Spec für CC-Update (internes Doc oder Link)
+- [ ] PayPal Integrationsentscheid dokumentiert
+- [ ] Bestandsanalyse (Anzahl Abos pro Typ/Status)
+
+### Offene Fragen
+
+| ID | Frage |
+|----|-------|
+| O2 | Gibt es PAYONE-Support-Freigabe für `updateuser`/`updatecard` im Live-Portal? |
+#keine Information also vermutlich nicht, Der Prozess Die alte Subscription lösen oder löschen Und eine neue Subscription anlegen
+| O3 | Welches PayPal-Produkt ist beim Merchant-Account aktiv (Subscriptions vs. nur Checkout)? |
+#alles nur über den Checkout. Die Subscription werden beim ersten Checkout angelegt.
+| O4 | Sollen CC-Aktualisierung und Abo-Wechsel auch im **Admin** möglich sein (Impersonation/Support)? |
+#nein, Kreditkarteninformationen werden ausschließlich vom User selbst eingegeben.
+
+---
+
+## 7. Phase 1 – Kreditkarte aktualisieren (PAYONE) ⭐ Priorität 1
+
+### Ziel
+
+Authentifizierter User kann bei einem aktiven CC-Abo (`clearingtype=cc`) neue Kreditkartendaten hinterlegen. Die nächste Abbuchung nutzt den aktualisierten `pseudocardpan`.
+
+### User Stories
+
+- **US-1.1:** Als Endkunde (Portal) möchte ich meine Kreditkarte für mein Abo aktualisieren, damit künftige Abbuchungen funktionieren.
+- **US-1.2:** Als Berater (CRM) möchte ich die Karte meines Kunden-Abos aktualisieren können (Support-Fall).
+- **US-1.3:** Als System speichere ich nur Token (`pseudocardpan`), nie Rohdaten.
+
+### Technische Umsetzung
+
+#### Backend
+
+| Komponente | Beschreibung |
+|------------|--------------|
+| `AboPaymentMethodService` (neu) | Orchestriert CC-Update: Validierung → PAYONE → `UserAbo` aktualisieren |
+| `PayoneGateway::updateCreditCard(UserAbo, cardData)` (neu) | PAYONE-Request mit bestehendem `payone_userid` |
+| `UpdateAboCreditCardRequest` (neu) | Form Request; keine PAN im Server-Log |
+| Controller-Routes | Portal + CRM (+ optional Admin read-only Anzeige) |
+
+#### Ablauf
+
+```mermaid
+sequenceDiagram
+ participant U as User (Portal/CRM)
+ participant C as AboController
+ participant S as AboPaymentMethodService
+ participant P as PayoneGateway
+ participant DB as user_abos
+
+ U->>C: POST update-payment-method
+ C->>S: updateCreditCard(userAbo, ccForm)
+ S->>S: Policy: User darf Abo bearbeiten
+ S->>P: creditcardcheck (storecarddata=yes)
+ P-->>S: pseudocardpan, status VALID
+ S->>P: updateuser (userid, neuer pseudocardpan)
+ P-->>S: APPROVED
+ S->>DB: carddata aktualisieren
+ S->>DB: SysLog + optional PaymentTransaction
+ S-->>U: Erfolg / Fehlermeldung
+```
+
+#### Frontend
+
+- Button „Zahlungsmethode aktualisieren“ in `admin/abo/_detail.blade.php` (nur bei `clearingtype=cc`).
+- Modal mit gleichem CC-Formular wie Checkout (`checkout.blade.php` – PAYONE Client-API Felder).
+- Sichtbar in Portal (`portal/abo/my_abo.blade.php`) und CRM (`user/abo/detail.blade.php`).
+
+#### Datenänderungen
+
+- Keine Migration zwingend nötig (`carddata` JSON existiert).
+- Optional: `payment_method_meta` mit `card_mask`, `cardexpiredate` für Anzeige („Visa •••• 4242, gültig 12/27“).
+
+### Sicherheit
+
+- Policy `UserAboPolicy@updatePaymentMethod`
+- CSRF, Auth-Middleware (bestehend)
+- Fehlgeschlagener PAYONE-Call: **kein** Partial-Update in DB
+- Transaktion: PAYONE OK → DB Update (nicht umgekehrt)
+
+### Tests (Pest)
+
+| Test | Szenario |
+|------|----------|
+| `AboUpdateCreditCardTest` | Happy Path: Mock Payone → `carddata` aktualisiert |
+| | Fehler: Ungültige Karte → kein DB-Update |
+| | Auth: Fremdes Abo → 403 |
+| | Nur CC-Abos: PayPal-Abo → 422 |
+
+### Akzeptanzkriterien
+
+- [ ] User kann CC-Daten im Portal und CRM aktualisieren
+- [ ] Nächster Cron-Lauf (`user:make_abo_order`) nutzt neuen `pseudocardpan`
+- [ ] Admin sieht maskierte Karteninfo in Abo-Detail
+- [ ] Keine PAN/CVC in Logs oder DB
+
+---
+
+## 8. Phase 2 – Subscription cancel + recreate (PAYONE CC)
+
+### Ziel
+
+Technische Fähigkeit, eine PAYONE-Subscription (Token) zu **lösen** und mit neuer Zahlungsmethode **neu anzulegen**, ohne das Abo (`UserAbo`) inhaltlich zu verlieren (Produkte, Historie, `next_date`).
+
+### Anwendungsfälle
+
+1. CC-Aktualisierung schlägt fehl → kompletter Neuaufbau des PAYONE-`userid`
+2. Vorbereitung für CC → PayPal-Wechsel (Phase 7)
+3. Support: „Karte komplett neu hinterlegen“
+
+### Ablauf (Vorschlag)
+
+1. **Cancel:** PAYONE `updateuser` mit `recurrence=stop` oder Entfernen des Zahlungsmittels (PAYONE-Doku Phase 0)
+2. **Neu:** Wie Checkout-Abo-Init – neuer `creditcardcheck` → neuer `userid` via `authorization` + `recurrence=recurring`
+3. **DB:** Alten `payone_userid` in `user_abo_payment_migrations` archivieren; `UserAbo` mit neuem Token updaten
+4. **Kein** neues `UserAbo`-Record – gleiche ID, Historie bleibt
+
+### UI
+
+- Wizard „Zahlungsmethode erneuern“ (2 Schritte: Bestätigung → CC-Formular)
+- Warnhinweis: „Ihre bisherige Karte wird entfernt. Bitte neue Daten eingeben.“
+
+### Offene Fragen
+
+| ID | Frage |
+|----|-------|
+| O5 | Muss bei PAYONE explizit `recurrence=stop` gesendet werden, oder reicht neuer `userid`? |
+#Hier gibt es keine klaren Anweisung seitens PAYONE. Entweder erstellen wir einfach eine neue, denn soweit ich sehe, ist recurrence=stop Für das komplette beenden, wo keine Abbuchung mehr folgen. Wir erstellen einen neuen.
+| O6 | Was passiert mit ausstehenden `abo_hold`-Bestellungen beim Recreate? Automatisch retry? |
+Abos, die angehalten sind mit Abo Hold Gelangen direkt zum Checkout mit dem Warenkorb. Hier wird direkt die Initialisierung mit dem gesamten Warenkorb ausgeführt. D.h. das ist ein automatischer Retry, der aber direkt im Checkout ausgeführt wird, Geht die Buchung durch, haben wir sofort einen neuen Token.
+
+---
+
+## 9. Phase 3 – Abo-Zahlungslink bei fehlgeschlagener Abbuchung
+
+### Ziel
+
+Wenn ein Abo auf `status=3` (Hold) wechselt, erhält der User einen **Zahlungslink**, über den er die offene Abbuchung wie im Checkout begleichen kann. Nach erfolgreicher Zahlung: Abo auf `status=2`, Hintergrundprozesse (Versand, Rechnung, Incentive, Einmal-Artikel-Purge).
+
+### Ist vs. Soll
+
+| | Heute | Soll |
+|---|-------|------|
+| Fehlgeschlagene Abbuchung | E-Mail ohne Link (`send_link=false` in `UserMakeAboOrder`) | E-Mail **mit** signiertem Zahlungslink |
+| User Self-Service | Nur Berater-Retry (Admin) / Portal-Retry (erneute Abbuchung mit altem Token) | Checkout-ähnliche Zahlung mit **frei wählbarer** Methode |
+| Bestehende Infrastruktur | `ShoppingInstance` + `OrderPaymentService` (Berater→Kunde) | Neuer Flow `AboPaymentRecoveryService` |
+
+### Technische Umsetzung
+
+#### Neuer Service: `AboPaymentRecoveryService`
+
+```
+createRecoveryLink(UserAbo, UserAboOrder) → token, url, expires_at
+resolveRecoveryLink(token) → UserAbo + offene ShoppingOrder (oder neu erzeugen)
+completeRecovery(ShoppingPayment) → AboHelper::markPaid, Incentive, AboOneTimeService::purgeAfterExecution
+```
+
+#### Ablauf
+
+1. `UserMakeAboOrder` / `AboRetryPaymentService` bei Fehler → `createRecoveryLink()`
+2. E-Mail (`MailCheckout` erweitern oder neues Mailable `AboPaymentRecoveryMail`) mit Link
+3. User öffnet Link → reduzierter Checkout (Betrag fix, Adresse vorausgefüllt, Zahlungsmethoden wie Shop)
+4. Zahlung erfolgreich → gleiche Pipeline wie `AboRetryPaymentService::markAboSuccess()`
+
+#### Datenmodell
+
+Tabelle `user_abo_payment_recovery_links` (siehe 3.2) oder Wiederverwendung `ShoppingInstance` mit neuem `payment`-Typ (z. B. `7 = abo_recovery`).
+
+> **Offene Frage O7:** Eigene Tabelle vs. `ShoppingInstance.payment=7` – Was passt besser zum bestehenden Checkout-Flow?
+
+#### Sicherheit
+
+- Token: `Str::uuid()`, 72h TTL (konfigurierbar via Setting)
+- Einmalverwendung (`used_at`)
+- Betrag serverseitig aus `UserAboOrder` / `ShoppingOrder`, nicht manipulierbar
+- Kein Login nötig (Link per E-Mail), aber E-Mail-Adresse muss zu `user_abo.email` passen (optional Verifikation)
+
+#### Zahlungsmethoden am Recovery-Checkout
+
+| Methode | Provider | Hinweis |
+|---------|----------|---------|
+| Kreditkarte | PAYONE | Einmalzahlung + optional neues Token für Abo speichern |
+| PayPal | PAYONE (interim) / PayPal direkt (später) | Briefing: langfristig nur PayPal direkt |
+| Vorauskasse | PAYONE | Optional – Briefing nicht explizit |
+
+> **Offene Frage O8:** Soll der Recovery-Checkout gleichzeitig die **Zahlungsmethode fürs Abo** aktualisieren (neuer Token), oder nur die **einmalige** offene Rate begleichen?
+
+### Tests
+
+- Recovery-Link erzeugen und einlösen (Mock Payment)
+- Abgelaufener Token → 410 Gone
+- Bereits bezahltes Abo → Redirect mit Hinweis
+- Erfolg → `UserAbo.status=2`, `UserAboOrder.paid=true`
+
+---
+
+## 10. Phase 4 – Provider-Abstraktion & Datenmodell
+
+### Ziel
+
+Technische Grundlage für parallelen Betrieb PAYONE + PayPal direkt.
+
+### Aufgaben
+
+1. Migration: `payment_provider`, `payment_token_ref`, `subscription_status` (siehe 3.2)
+2. Backfill: Bestehende Abos → `payment_provider=payone`; PayPal-via-PAYONE → `subscription_status=legacy_payone_paypal`
+3. Interface `PaymentGatewayInterface`:
+ ```php
+ interface PaymentGatewayInterface {
+ public function charge(ChargeRequest $request): ChargeResponse;
+ public function updatePaymentMethod(UpdateMethodRequest $request): UpdateMethodResponse;
+ public function cancelSubscription(CancelRequest $request): CancelResponse;
+ public function handleWebhook(array $payload): WebhookResult;
+ }
+ ```
+4. `PaymentOrchestrator` resolved Provider anhand `payment_provider`
+5. Refactor schrittweise: `UserMakeOrder::makePayment()` ruft Orchestrator statt direkt `PayoneController`
+
+### Tests
+
+- Provider-Resolution Unit Tests
+- Backfill-Migration Test (bestehende Abos unverändert funktionsfähig)
+
+---
+
+## 11. Phase 5 – PayPal direkt: Checkout & Abo-Neuabschluss
+
+### Ziel
+
+Neue Abos mit PayPal laufen **nicht mehr über PAYONE**. Checkout und Erstzahlung nutzen PayPal Subscriptions API.
+
+### API-Integration (PayPal REST v2)
+
+| Schritt | PayPal Endpoint | Anmerkung |
+|---------|-----------------|-----------|
+| Plan erstellen (Admin/Cron) | `POST /v1/billing/plans` | Pro Abo-Intervall/Produkt oder dynamischer Plan |
+| Subscription anlegen | `POST /v1/billing/subscriptions` | Beim Checkout nach PayPal-Approval |
+| Approval | JS SDK / Redirect | User genehmigt im PayPal-Login |
+| Webhook | `BILLING.SUBSCRIPTION.ACTIVATED`, `PAYMENT.SALE.COMPLETED` | Separater Controller |
+
+### Code-Struktur
+
+```
+app/Services/Payment/
+├── PayPalGateway.php
+├── PayPalWebhookController.php → routes/api.php
+├── PayPalPlanService.php → Plan-Sync
+└── DTOs/...
+```
+
+### Checkout-Änderungen
+
+- `CheckoutController::processPaymentMethod()` – Branch für `payment_method=paypal_direct`
+- `PaymentMethod`-Tabelle: neuer Eintrag oder `short=PPD` (PayPal Direct) vs. `wlt#PPE` (Legacy)
+- `AboHelper::createNewAbo()` – bei PayPal: `payment_provider=paypal`, `payment_token_ref=subscription_id`
+
+### Sicherheit
+
+- OAuth2 Client Credentials für Server-zu-Server
+- Webhook-Signaturprüfung Pflicht
+- PayPal-`subscription_id` niemals im Frontend loggen
+
+### Sandbox-Tests
+
+- PayPal Sandbox Buyer Account
+- Feature-Flag: `config('services.paypal.enabled')` – erst Sandbox, dann Live
+
+### Offene Fragen
+
+| ID | Frage |
+|----|-------|
+| O9 | Feste PayPal-Pläne pro Abo-Intervall (5/10/20/25) oder ein dynamischer Plan pro Checkout-Betrag? |
+| O10 | PayPal JS SDK im Checkout (Blade) – welche SDK-Version / Hosted Fields Policy? |
+| O11 | Sollen **Einmalzahlungen** (nicht Abo) mit PayPal direkt schon in Phase 5, oder erst Abo? |
+
+---
+
+## 12. Phase 6 – PayPal direkt: Wiederkehrende Abbuchung & Webhooks
+
+### Ziel
+
+Monatliche Abo-Ausführung für PayPal-direkt-Abos ohne `UserMakeOrder`→PAYONE, sondern über PayPal-Subscription-Billing.
+
+### Architektur-Entscheidung
+
+**Option A – PayPal führt Billing aus (empfohlen):**
+- PayPal zieht automatisch ein; Webhook `PAYMENT.SALE.COMPLETED` → System erzeugt `ShoppingOrder` + markiert bezahlt
+- `user:make_abo_order` überspringt PayPal-direkt-Abos (nur noch Versand/Order-Erstellung nach Webhook)
+
+**Option B – Merchant-initiated:**
+- Cron triggert PayPal Capture – komplexer, nicht empfohlen für Subscriptions
+
+### Webhook-Controller
+
+```
+POST /api/paypal/webhook
+→ verify signature
+→ idempotency check (event_id)
+→ switch(event_type):
+ PAYMENT.SALE.COMPLETED → create order from subscription
+ BILLING.SUBSCRIPTION.CANCELLED → UserAbo.status=4
+ BILLING.SUBSCRIPTION.SUSPENDED → UserAbo.status=3
+```
+
+### Monitoring
+
+- `payment:check-uptime` um PayPal-Endpoint erweitern
+- Payment-Dashboard: PayPal-Incidents (Provider `paypal` existiert bereits)
+
+---
+
+## 13. Phase 7 – Zahlungsmethode wechseln (CC ↔ PayPal)
+
+### Ziel
+
+User kann Zahlungsmethode wechseln: CC (PAYONE) ↔ PayPal (direkt).
+
+### Ablauf
+
+1. Alte Subscription canceln (PAYONE `recurrence=stop` oder PayPal `cancel subscription`)
+2. `UserAbo.subscription_status = migrating`
+3. Neue Subscription mit gewählter Methode anlegen (Phase 1/5 Flows)
+4. `UserAbo` updaten: neuer Provider, Token, `migrated_from_abo_id` = self
+5. `subscription_status = active`
+
+### UI
+
+- „Zahlungsmethode wechseln“ in Abo-Einstellungen
+- Klare Hinweise: „PayPal nur über direktes PayPal-Konto“ (Briefing)
+
+### Einschränkungen
+
+- Während `abo_hold`: Wechsel erlaubt? (Empfehlung: ja, als Alternative zum Recovery-Link)
+- Mindestlaufzeit beachten (`abo-min-duration` Setting)
+
+---
+
+## 14. Phase 8 – Legacy-Migration PAYONE-PayPal → PayPal direkt
+
+### Ziel
+
+Bestehende Abos mit `clearingtype=wlt, wallettype=PPE` (PayPal via PAYONE) bleiben funktionsfähig, bis User aktiv migriert.
+
+### Migrations-Kampagne (Briefing)
+
+1. **Phase Stabilisierung:** PayPal direkt min. X Wochen Live-Betrieb ohne kritische Incidents
+2. **User-Kommunikation:** E-Mail + Portal-Banner „Bitte Zahlungsmethode einmalig aktualisieren“
+3. **Technisch:** Deep-Link in Phase-7-Wizard; alter PAYONE-PayPal-Token wird bei Migration invalidated
+4. **Reporting:** Admin-Dashboard: % migriert, offene Legacy-Abos
+
+### Kein Big-Bang
+
+- Legacy-Abos weiter über `UserMakeOrder`→PAYONE
+- `RetryFailedPaypalAbos` Command bleibt für historische Fälle, wird nicht für neue Abos benötigt
+
+---
+
+## 15. Phase 9 – Monitoring, Tests, Rollout
+
+### Teststrategie
+
+| Ebene | Umfang |
+|-------|--------|
+| Unit | Gateway-Mocks, Provider-Resolution, Token-Generierung |
+| Feature | CC-Update, Recovery-Link, Webhook-Handling |
+| Integration | PAYONE Sandbox, PayPal Sandbox (markiert, optional CI) |
+| Regression | Bestehende Checkout-Tests, `AboUserRetryTest`, `PayoneCallbackTestbenchPrepareCronTest` |
+
+### Rollout-Plan
+
+1. **Staging:** Alle Phasen gegen Sandbox
+2. **Feature-Flags:** `ABO_CC_UPDATE_ENABLED`, `ABO_RECOVERY_LINK_ENABLED`, `PAYPAL_DIRECT_ENABLED`
+3. **Canary:** CC-Update nur Portal → dann CRM → dann Recovery-Link
+4. **PayPal direkt:** Nur Neukunden-Abo-Checkout → nach 2 Wochen Bestands-Migrations-Einladung
+5. **Rollback:** Feature-Flags off → Legacy-Flow unverändert
+
+### Observability
+
+- Log-Kanäle: `payone`, `payment`, `abo_order` (+ neu: `paypal`) — Details in Abschnitt 22
+- Metriken: Recovery-Link-Conversion, CC-Update-Erfolgsrate, Webhook-Latenz — Details in Abschnitt 22
+- Payment-Dashboard: Auto-Incident-Trigger für alle neuen Flows — Details in Abschnitt 22
+
+---
+
+## 16. Betroffene Dateien (Übersicht)
+
+### Bestehend (Anpassung)
+
+| Datei | Änderung |
+|-------|----------|
+| `app/Http/Controllers/Pay/PayoneController.php` | Extraktion in Gateway; CC-Update-Methode |
+| `app/Cron/UserMakeOrder.php` | Provider-Routing |
+| `app/Services/AboHelper.php` | `createNewAbo()` mit `payment_provider` |
+| `app/Console/Commands/UserMakeAboOrder.php` | Recovery-Link bei Fehler |
+| `app/Services/AboRetryPaymentService.php` | Recovery-Link-Option |
+| `app/Http/Controllers/Api/PayoneController.php` | Unverändert (PAYONE only) |
+| `app/Http/Controllers/Web/CheckoutController.php` | PayPal-direct Branch |
+| `app/Http/Controllers/Portal/AboController.php` | CC-Update, Wechsel-Wizard |
+| `app/Http/Controllers/User/AboController.php` | CC-Update (CRM) |
+| `resources/views/admin/abo/_detail.blade.php` | Payment-Method-UI |
+| `resources/views/portal/abo/my_abo.blade.php` | Payment-Method-UI |
+| `resources/views/web/templates/checkout.blade.php` | PayPal SDK (Phase 5) |
+| `config/payone.php` / `config/services.php` | Secrets aus `.env` |
+| `routes/domains/portal.php`, `crm.php`, `api.php` | Neue Routes |
+
+### Neu (Vorschlag)
+
+| Datei | Zweck |
+|-------|-------|
+| `app/Services/Payment/PaymentOrchestrator.php` | Provider-Routing |
+| `app/Services/Payment/PayoneGateway.php` | PAYONE-Operationen |
+| `app/Services/Payment/PayPalGateway.php` | PayPal-Operationen |
+| `app/Services/AboPaymentMethodService.php` | CC-Update, Recreate |
+| `app/Services/AboPaymentRecoveryService.php` | Recovery-Links |
+| `app/Http/Controllers/Api/PayPalWebhookController.php` | PayPal Webhooks |
+| `app/Http/Controllers/Web/AboPaymentRecoveryController.php` | Recovery-Checkout |
+| `app/Policies/UserAboPolicy.php` | Authorization |
+| `database/migrations/xxxx_add_payment_provider_to_user_abos.php` | Schema |
+| `tests/Feature/AboUpdateCreditCardTest.php` | Phase 1 |
+| `tests/Feature/AboPaymentRecoveryTest.php` | Phase 3 |
+
+---
+
+## 17. Abhängigkeiten zu anderen Projekten
+
+| Projekt | Bezug |
+|---------|-------|
+| `dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md` | Recovery/Erfolg muss `AboOneTimeService::purgeAfterExecution()` aufrufen |
+| `dev/payment-dashboard/ENTWICKLUNGSPLAN.md` | PayPal-Uptime, Auto-Incidents |
+| `docs/salescenter/ABO - Bestellungen.md` | Einmal-Produkte + Zahlungsfehler-Verhalten |
+
+---
+
+## 18. Risiken & Mitigationen
+
+| Risiko | Impact | Mitigation |
+|--------|--------|------------|
+| PAYONE CC-Update-API anders als erwartet | Phase 1 verzögert | Phase 0 Klärung mit PAYONE-Support |
+| PayPal Subscriptions Limitierungen (EU/DE) | Phase 5 blockiert | Früh Sandbox testen; Fallback Vault |
+| Doppel-Abbuchung bei Webhook + Cron | Hoch | Idempotenz; PayPal-Abos aus Cron-Payment ausschließen |
+| Legacy-Abos brechen bei Migration | Hoch | Kein Zwang; paralleler Betrieb; Rollback-Flag |
+| PCI-Verstoß durch Logging | Kritisch | Code-Review-Checkliste; keine PAN in Requests loggen |
+| Race Condition Callback/Redirect | Mittel | Bestehendes Muster aus `Api/PayoneController` |
+
+---
+
+## 19. Offene Fragen (Gesamtliste)
+
+### Datenmodell
+
+| ID | Thema | Entscheidung | Empfehlung |
+|----|-------|--------------|------------|
+| **O1** | Einheitliches `payment_token_ref` vs. `payone_userid` behalten | Tech Lead | **`payone_userid` behalten; `payment_token_ref` nur für PayPal neu hinzufügen.** Eine Umbenennung würde alle bestehenden Abo-Flows (UserMakeOrder, AboRetryPaymentService, AboHelper) anfassen – für null funktionalen Gewinn. PayPal-IDs (subscription_id) sind ohnehin ein anderes Format. Dual-Felder sind klarer als ein generisches Einheitsfeld. |
+
+### PAYONE API
+
+| ID | Thema | Entscheidung | Empfehlung |
+|----|-------|--------------|------------|
+| **O2** | PAYONE CC-Update API: `updateuser` oder neue `authorization`? | PAYONE Support + Dev | **Als Fallback Variante B planen (neuer `creditcardcheck` + neue `authorization` mit `recurrence=recurring` → neuer `userid`).** Diese Variante nutzt ausschließlich bereits getestete Code-Pfade. Variante A (`updateuser` mit neuem `pseudocardpan`) umsetzen, wenn PAYONE-Support dies für den Live-Portal-Account explizit bestätigt. Phase 0 muss diese Frage auflösen, bevor Phase 1 beginnt. |
+| **O5** | PAYONE `recurrence=stop` bei Recreate nötig? | PAYONE Support | **Nein – kein expliziter Stop-Call.** Der PAYONE `userid` ist nur eine gespeicherte Zahlungsreferenz, kein aktives Subscription-Objekt auf PAYONE-Seite. Ein nicht mehr verwendeter `userid` verfällt passiv. Bei Recreate einfach neuen `userid` via `creditcardcheck` + `authorization` anlegen und in `user_abos.payone_userid` überschreiben. Mit PAYONE-Support bestätigen, aber mit dieser Annahme planen. |
+
+### Feature-Scope & UX
+
+| ID | Thema | Entscheidung | Empfehlung |
+|----|-------|--------------|------------|
+| **O4** | CC-Update durch Admin / Support-Impersonation? | Product Owner | **Ja, mit Pflicht-Logging.** Support-Fälle im CRM sind unvermeidlich. Bestehende `SysLog`-Infrastruktur bereits vorhanden. Einschränkung: nur Nutzer mit `admin`-Rolle; jede Admin-initiierte Änderung wird mit Admin-User-ID und optionalem Kommentarfeld protokolliert. Policy `UserAboPolicy@updatePaymentMethod` steuert die Berechtigung. |
+| **O6** | Verhalten bei `abo_hold` + Recreate: offene Orders auto-retry? | Product Owner | **Nein – kein automatischer Retry.** Bei status=3 (hold) können bereits unbezahlte `user_abo_orders` vorliegen. Ein Auto-Retry nach Token-Recreate riskiert Doppelabbuchung und ist für den User überraschend. Stattdessen: Recreate aktualisiert nur den Token; die offene Rate wird über den Recovery-Link (Phase 3) separat beglichen. Nächstes geplantes Datum läuft normal weiter. |
+| **O11** | PayPal direkt auch für Einmal-Checkout (nicht Abo) in Phase 5? | Product Owner | **Nein – Phase 5 ausschließlich Abo-Neuabschluss.** Einmalzahlungen mit PayPal direkt erfordern eigene Checkout-Änderungen und verdoppeln den Scope. Nach stabiler Phase 6 als separate Initiative einplanen. |
+| **O12** | TTL für Recovery-Links: 72h? | Product Owner | **72h – empfohlen als konfigurierbarer Default.** Lang genug für Nutzer, die E-Mails nicht täglich lesen; kurz genug um Missbrauch zu begrenzen. Via bestehendem `Setting`-Model konfigurierbar machen (Schlüssel `abo_recovery_link_ttl_hours`, Default 72). |
+| **O13** | Welche Zahlungsmethoden am Recovery-Checkout? | Product Owner | **Kreditkarte (PAYONE) + PayPal (interim PAYONE, später direkt) – kein Vorauskasse, kein SEPA.** Vorauskasse liefert keine sofortige Zahlungsbestätigung und kann `abo_hold` nicht automatisch aufheben. SEPA erfordert Mandatsverwaltung und ist für einen Self-Service-Recovery zu komplex. Einfach halten: CC + PayPal. |
+| **O14** | Mindestlaufzeit bei Zahlungsmethode-Wechsel zurücksetzen? | Legal / Product | **Mindestlaufzeit nicht zurücksetzen.** Der Zähler beginnt mit dem Abo-Start, nicht mit dem Zahlungsmethode-Wechsel. Technisch: `user_abos.created_at` als Referenz beibehalten; `AboPolicy@canCancel` prüft Mindestlaufzeit unabhängig vom Zahlungsträger. Legal-Bestätigung einholen, aber diese Auslegung ist die nutzerfreundlichere. |
+| **O15** | Zeitpunkt Legacy-Migrations-Kampagne (PAYONE-PayPal → PayPal direkt) | Product Owner | **Frühestens 4 Wochen nach Go-Live Phase 6 ohne kritische Incidents.** Kampagne: 2 E-Mail-Erinnerungen + Portal-Banner; Sunset-Datum 3–6 Monate nach Kampagnenstart kommunizieren. Alten PAYONE-PayPal-Flow erst abschalten, wenn <5 % der Abos noch darauf laufen. |
+
+### Recovery-Technologie
+
+| ID | Thema | Entscheidung | Empfehlung |
+|----|-------|--------------|------------|
+| **O7** | Recovery: eigene Tabelle vs. `ShoppingInstance.payment=7` | Tech Lead | **Eigene Tabelle `user_abo_payment_recovery_links`.** `ShoppingInstance` ist für Berater→Kunde-Zahlungslinks konzipiert; sie zu überladen koppelt zwei konzeptionell verschiedene Flows. Eine dedizierte Tabelle erlaubt klares TTL-Management, Einmalverwendungs-Flag (`used_at`) und einen sauberen Audit-Trail ohne Side Effects auf bestehende Berater-Links. |
+| **O8** | Recovery: nur Einmalzahlung oder auch Token fürs Abo aktualisieren? | Product Owner | **Token aktualisieren, wenn neue Zahlungsdaten eingegeben wurden; sonst nicht.** Gibt der User am Recovery-Checkout eine neue CC ein, soll diese automatisch als zukünftiger Abo-Token gespeichert werden – er würde sonst sofort wieder fehlschlagen. Wählt er PayPal, gilt dasselbe. Wählt er dieselbe Methode ohne neue Daten (reiner Retry), kein Token-Update. Dies entspricht `O8: bedingtes Token-Update`. |
+
+### PayPal-Integration
+
+| ID | Thema | Entscheidung | Empfehlung |
+|----|-------|--------------|------------|
+| **O3** | PayPal Merchant: Subscriptions API oder Vault + Reference Transactions? | Finance / Admin | **PayPal Subscriptions API v2 anstreben; Vault als Fallback.** Subscriptions API ist der Standard für wiederkehrende Abbuchungen in DE/EU und deckt den gesamten Billing-Lifecycle ab. Vault + Reference Transactions ist komplexer und erfordert explizite Händler-Freigabe. Phase 0 klärt, welches Produkt im Merchant-Account aktiv ist. Falls nur Vault verfügbar: Phase 6-Architektur auf Merchant-initiated Billing umstellen. |
+| **O9** | PayPal Plan-Strategie: fixe Pläne pro Intervall/Betrag oder dynamisch? | Product + Finance | **Fixe Pläne.** MIVITA hat definierte Abo-Beträge (5/10/20/25 EUR) und Intervalle. Dynamische Pläne per Checkout-Betrag würden den PayPal-Account mit Hunderten Einmal-Plans fluten und Audits erschweren. Eine überschaubare Matrix (Beträge × Intervalle) ist wartbar, auditierbar und für den PayPal-Support leichter nachvollziehbar. |
+| **O10** | PayPal JS SDK Variante im Blade-Checkout | Frontend/Dev | **PayPal JS SDK v2 mit `vault=true&intent=subscription`.** Das SDK rendert den Approval-Button nativ und öffnet das PayPal-Popup ohne eigene Redirect-Logik. Keine Hosted Fields für PayPal (das ist ein CC-Only-Feature). Integration: `