Stripe-Integration (S0–S3): Einmalzahlungen über Stripe Checkout + Notfall-Go-Live
Wegen PAYONE-Ausfall vorgezogen live geschaltet: alle Einmalzahlungen (Shop + Salescenter) laufen über Stripe Hosted Checkout, Abos sind bis Phase S4 eingefroren. - Provider-Abstraktion: PaymentProvider-Enum, PaymentGatewayInterface, PaymentProviderManager (Settings-Schalter, provider-treu je Bestand), PayoneGateway als unveränderter Adapter - StripePaymentService: Checkout Session (eine Gesamtposition, Metadata, success/cancel-Redirects), StripeClientWrapper (lazy, Dark Launch), StripeCustomerService, StripeTransactionRecorder (client_secret-Redaction) - Webhook /stripe/webhook: Signaturprüfung, Idempotenz über stripe_webhook_events (Crash-Retry-Reprocessing), txaction-Prioritäten, Betrags-Guard, lockForUpdate-Paid-Pfad, genau eine Bestätigungsmail - Zahlungsmethoden-Anzeige aus charge.succeeded (carddata inkl. Wallet, auftragsunabhängiges Backfill aus dem Event-Store) - Checkout: Wartungsmodus mit Admin-Bypass, Abo-Freeze-Hinweis, Stripe-Option (Online-Zahlung) statt Zahlartenwahl, Vorkasse ausblendbar - Abo-Freeze-Guards: Cron user:make_abo_order, Retry, Kartenupdate - Admin-Settings-Karte Zahlungen (Provider, Wartung, Freeze, Vorkasse) - Hotfix: 500 bei Vorkasse ohne Clearing-Daten in admin/sales/_detail und checkout-final (PAYONE-Ausfall) - Doku dev/2026-07-08-stripe/ (Plan S0–S8, Konzept, IST-Analyse) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1108381988
commit
e1da90ff91
48 changed files with 4428 additions and 831 deletions
13
.env.example
13
.env.example
|
|
@ -98,3 +98,16 @@ PAYONE_TS=checkout.example.test/transaction/status
|
|||
# Hash erzeugen (verdeckte Eingabe, schreibt optional direkt in die .env):
|
||||
# php artisan payone:hash-key --write
|
||||
PAYONE_KEY_HASH=
|
||||
|
||||
# =============================================================================
|
||||
# STRIPE KONFIGURATION (dev/2026-07-08-stripe/)
|
||||
# =============================================================================
|
||||
# Secret Key (sk_test_… / sk_live_…). Kein Boot-Hard-Fail: ohne Key bleibt
|
||||
# Stripe inaktiv; erst die Nutzung des StripeClientWrapper schlägt fehl.
|
||||
STRIPE_SECRET=
|
||||
# Publishable Key (pk_test_… / pk_live_…)
|
||||
STRIPE_KEY=
|
||||
# Webhook-Signing-Secret des Endpoints /api/stripe/webhook (whsec_…)
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
# test | live — muss zum Key-Paar passen
|
||||
STRIPE_MODE=test
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Services\AboHelper;
|
|||
use App\Services\AboOneTimeService;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use App\Services\MyLog;
|
||||
use App\Services\Payment\PaymentProviderManager;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -66,6 +67,16 @@ class UserMakeAboOrder extends Command
|
|||
\Log::channel('cron')->info('UserMakeAboOrder: Befehl gestartet');
|
||||
$this->info('RUN Command user:make_abo_order');
|
||||
|
||||
// Not-Schalter (PAYONE-Ausfall): Abbuchungen komplett aussetzen. Abos werden
|
||||
// NICHT als fehlgeschlagen markiert — nach dem Auftauen holt der Cron sie
|
||||
// über ihr unverändertes next_date normal nach.
|
||||
if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) {
|
||||
\Log::channel('cron')->warning('UserMakeAboOrder: Abo-Zahlungen sind eingefroren (Setting abo-payments-frozen) — Lauf übersprungen.');
|
||||
$this->warn('Abo-Zahlungen sind eingefroren (Setting abo-payments-frozen) — Lauf übersprungen.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->checkAbosToOrder();
|
||||
$executionTime = $this->getExecutionTime();
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
namespace App\Cron;
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Http\Controllers\Pay\PayoneController;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\AboOrderCart;
|
||||
use App\Services\Payment\PaymentProviderManager;
|
||||
use App\Services\Payment\Payone\PayoneTransactionRecorder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Yard;
|
||||
|
|
@ -66,6 +68,12 @@ class UserMakeOrder
|
|||
{
|
||||
Log::info('Starte Zahlungsvorgang für UserAbo ID: '.$this->userAbo->id);
|
||||
|
||||
$provider = app(PaymentProviderManager::class)->providerFor($this->userAbo);
|
||||
if ($provider !== PaymentProvider::Payone) {
|
||||
Log::error('Abo-Abbuchung für Provider "'.$provider->value.'" ist noch nicht implementiert. UserAbo ID: '.$this->userAbo->id);
|
||||
throw new \RuntimeException('Abo-Abbuchung für Provider "'.$provider->value.'" ist noch nicht implementiert (UserAbo '.$this->userAbo->id.').');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pay = new PayoneController;
|
||||
$this->pay->init($this->shopping_user, $this->shopping_order);
|
||||
|
|
|
|||
16
app/Enums/PaymentProvider.php
Normal file
16
app/Enums/PaymentProvider.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Zahlungsanbieter für neue und bestehende Zahlungsvorgänge.
|
||||
*
|
||||
* Jede Zahlung / jedes Abo trägt seinen Provider fest (payment_provider-Spalte);
|
||||
* der Einstellungs-Schalter bestimmt den Provider nur für NEUE Vorgänge.
|
||||
* Siehe dev/2026-07-08-stripe/STRIPE-KONZEPT.md §4.
|
||||
*/
|
||||
enum PaymentProvider: string
|
||||
{
|
||||
case Payone = 'payone';
|
||||
case Stripe = 'stripe';
|
||||
}
|
||||
23
app/Http/Controllers/Api/StripeWebhookController.php
Normal file
23
app/Http/Controllers/Api/StripeWebhookController.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Payment\Stripe\StripeWebhookHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
public function __construct(private readonly StripeWebhookHandler $webhookHandler) {}
|
||||
|
||||
public function handleWebhook(Request $request): Response
|
||||
{
|
||||
$result = $this->webhookHandler->handle(
|
||||
$request->getContent(),
|
||||
$request->header('Stripe-Signature'),
|
||||
);
|
||||
|
||||
return response($result->state, $result->statusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,23 +2,30 @@
|
|||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Pay\PayoneController;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Repositories\CheckoutRepository;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\CustomerPriority;
|
||||
use App\Services\Payment;
|
||||
use App\Services\Payment\PaymentProviderManager;
|
||||
use App\Services\Payment\Payone\Exceptions\PayoneTransportException;
|
||||
use App\Services\Payment\Stripe\StripePaymentService;
|
||||
use App\Services\Shop;
|
||||
use App\Services\Util;
|
||||
use App\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\View\View;
|
||||
use Request;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
use Validator;
|
||||
use Yard;
|
||||
|
||||
|
|
@ -41,7 +48,7 @@ class CheckoutController extends Controller
|
|||
/**
|
||||
* Zeigt die Checkout-Seite an
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
* @return View
|
||||
*/
|
||||
public function checkout()
|
||||
{
|
||||
|
|
@ -53,6 +60,14 @@ class CheckoutController extends Controller
|
|||
@endif
|
||||
@if(Auth::guard('user')->check())
|
||||
*/
|
||||
if ($this->checkoutMaintenanceBlocked()) {
|
||||
return $this->showTransactionError(
|
||||
__('payment.checkout_maintenance_title'),
|
||||
__('payment.checkout_maintenance'),
|
||||
'maintenance',
|
||||
);
|
||||
}
|
||||
|
||||
$shopping_data = Yard::instance($this->instance)->getYardExtra('shopping_data');
|
||||
$is_from = $shopping_data['is_from'] ?? 'shopping';
|
||||
$is_for = $shopping_data['is_for'] ?? false;
|
||||
|
|
@ -81,6 +96,15 @@ class CheckoutController extends Controller
|
|||
$this->prepareShoppingUserData($shopping_user);
|
||||
$payment_methods = $this->checkoutRepo->getPaymentsMethods($is_from, $is_abo);
|
||||
|
||||
// Stripe aktiv: keine Gateway-Zahlartenwahl im eigenen Checkout mehr —
|
||||
// nur die Stripe-Option + systeminterne Methoden (Vorkasse/Rechnung).
|
||||
$stripe_active = $this->stripeCheckoutActive($is_abo, $recovery_user_abo_id);
|
||||
if ($stripe_active) {
|
||||
$payment_methods['active'] = array_intersect_key($payment_methods['active'], array_flip(['VOR', 'FNC']));
|
||||
}
|
||||
|
||||
$abo_payments_frozen = $this->aboPaymentsFrozen($is_abo, $recovery_user_abo_id);
|
||||
|
||||
if ($shopping_user === null) {
|
||||
abort(403, 'ShoppingUser not found');
|
||||
}
|
||||
|
|
@ -98,6 +122,8 @@ class CheckoutController extends Controller
|
|||
'payment_methods' => $payment_methods['default'],
|
||||
'payment_methods_active' => $payment_methods['active'],
|
||||
'payment_data' => $payment_methods['data'],
|
||||
'stripe_active' => $stripe_active,
|
||||
'abo_payments_frozen' => $abo_payments_frozen,
|
||||
'instance_status' => $instance_status ?? false,
|
||||
'is_checkout' => true,
|
||||
'yard_instance' => $this->instance,
|
||||
|
|
@ -144,14 +170,36 @@ class CheckoutController extends Controller
|
|||
/**
|
||||
* Verarbeitet den Checkout-Prozess
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
public function checkoutFinal()
|
||||
{
|
||||
$data = Request::all();
|
||||
|
||||
if ($this->checkoutMaintenanceBlocked()) {
|
||||
return redirect(route('checkout.checkout_card'));
|
||||
}
|
||||
|
||||
$shopping_data_frozen = Yard::instance($this->instance)->getYardExtra('shopping_data');
|
||||
if ($this->aboPaymentsFrozen(
|
||||
(bool) ($data['is_abo'] ?? false),
|
||||
$shopping_data_frozen['recovery_user_abo_id'] ?? null,
|
||||
)) {
|
||||
Session::flash('errormessage', __('payment.abo_payments_frozen'));
|
||||
|
||||
return redirect(route('checkout.checkout_card'))->withInput(Request::all());
|
||||
}
|
||||
|
||||
if (isset($data['payment_method'])) {
|
||||
$this->checkoutRepo->isPaymentsMethodsActive($data['payment_method'], $data['is_from'], $data['is_abo']);
|
||||
if ($data['payment_method'] === 'stripe') {
|
||||
$shopping_data = Yard::instance($this->instance)->getYardExtra('shopping_data');
|
||||
abort_unless($this->stripeCheckoutActive(
|
||||
(bool) ($data['is_abo'] ?? false),
|
||||
$shopping_data['recovery_user_abo_id'] ?? null,
|
||||
), 404);
|
||||
} else {
|
||||
$this->checkoutRepo->isPaymentsMethodsActive($data['payment_method'], $data['is_from'], $data['is_abo']);
|
||||
}
|
||||
}
|
||||
|
||||
Util::setInstanceStatus(2, true); // link_check
|
||||
|
|
@ -198,7 +246,7 @@ class CheckoutController extends Controller
|
|||
* Verarbeitet den Länderwechsel
|
||||
*
|
||||
* @param array $data
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
private function handleCountryChange($data)
|
||||
{
|
||||
|
|
@ -261,6 +309,10 @@ class CheckoutController extends Controller
|
|||
$result = [];
|
||||
$payment_method = Request::get('payment_method');
|
||||
|
||||
if ($payment_method === 'stripe') {
|
||||
return $this->processStripeCheckout($shopping_user, $shopping_order);
|
||||
}
|
||||
|
||||
try {
|
||||
// Kreditkarte prüfen
|
||||
if ($payment_method === 'cc') {
|
||||
|
|
@ -296,13 +348,96 @@ class CheckoutController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe ist für diesen Checkout aktiv, wenn der Anbieter-Schalter auf
|
||||
* Stripe steht. Abo- und Recovery-Checkouts bleiben bis Phase S4 auf
|
||||
* PAYONE (Plan dev/2026-07-08-stripe/, Abgrenzung S3/S4).
|
||||
*/
|
||||
private function stripeCheckoutActive(bool $is_abo, mixed $recovery_user_abo_id): bool
|
||||
{
|
||||
if ($is_abo || $recovery_user_abo_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(PaymentProviderManager::class)->providerForNewCheckout() === PaymentProvider::Stripe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout-Wartungsmodus: blockiert alle Nicht-Admins mit einer
|
||||
* "Technische Schwierigkeiten"-Meldung. Admins (isAdmin) bestellen normal —
|
||||
* so lässt sich Stripe im Live-Betrieb verproben, bevor der Checkout
|
||||
* wieder für alle öffnet.
|
||||
*/
|
||||
private function checkoutMaintenanceBlocked(): bool
|
||||
{
|
||||
if (! app(PaymentProviderManager::class)->checkoutMaintenanceActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = Util::getAuthUser() ?: \Auth::guard('user')->user();
|
||||
|
||||
return ! ($user instanceof User && $user->isAdmin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Abo-Zahlungen eingefroren (Not-Schalter bei PAYONE-Ausfall):
|
||||
* betrifft Abo-Neuabschlüsse und Recovery-Zahlungen im Checkout.
|
||||
*/
|
||||
private function aboPaymentsFrozen(bool $is_abo, mixed $recovery_user_abo_id): bool
|
||||
{
|
||||
if (! $is_abo && ! $recovery_user_abo_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(PaymentProviderManager::class)->aboPaymentsFrozen();
|
||||
}
|
||||
|
||||
/**
|
||||
* Einmalzahlung über Stripe: Checkout Session erstellen und den Browser
|
||||
* zur Hosted Payment Page umleiten. Success/Cancel laufen über die
|
||||
* bestehenden transaction_status-Routen; "bezahlt" setzt nur der Webhook.
|
||||
*
|
||||
* @param ShoppingUser $shopping_user
|
||||
* @param ShoppingOrder $shopping_order
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
private function processStripeCheckout($shopping_user, $shopping_order)
|
||||
{
|
||||
$amount = (int) round(Yard::instance($this->instance)->totalWithShipping(2, '.', '') * 100);
|
||||
$reference = substr(uniqid('m', false), 0, 16);
|
||||
|
||||
try {
|
||||
$result = app(StripePaymentService::class)->initiateCheckoutPayment(
|
||||
$shopping_user,
|
||||
$shopping_order,
|
||||
$amount,
|
||||
'EUR',
|
||||
$reference,
|
||||
route('checkout.transaction_status', ['success', $reference]),
|
||||
route('checkout.transaction_status', ['cancel', $reference]),
|
||||
);
|
||||
|
||||
$this->checkoutRepo->putSessionPayments('payment_reference', $reference);
|
||||
Util::setUserHistoryValue(['status' => 4]);
|
||||
|
||||
return redirect()->away($result->url);
|
||||
} catch (ApiErrorException|\RuntimeException $e) {
|
||||
\Log::channel('stripe')->error('Stripe-Checkout-Session konnte nicht erstellt werden: '.$e->getMessage(), [
|
||||
'shopping_order_id' => $shopping_order->id,
|
||||
]);
|
||||
Session::flash('errormessage', __('payment.stripe_unavailable'));
|
||||
|
||||
return redirect(route('checkout.checkout_card'))->withInput(Request::all());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Kreditkartendaten
|
||||
*
|
||||
* @param array $data
|
||||
* @param ShoppingUser $shopping_user
|
||||
* @param ShoppingOrder $shopping_order
|
||||
* @return bool|\Illuminate\Http\RedirectResponse
|
||||
* @return bool|RedirectResponse
|
||||
*/
|
||||
private function checkCreditCard($data, $shopping_user, $shopping_order)
|
||||
{
|
||||
|
|
@ -328,7 +463,7 @@ class CheckoutController extends Controller
|
|||
* @param array $data
|
||||
* @param ShoppingUser $shopping_user
|
||||
* @param ShoppingOrder $shopping_order
|
||||
* @return bool|\Illuminate\Http\RedirectResponse
|
||||
* @return bool|RedirectResponse
|
||||
*/
|
||||
private function checkSepaAccount($data, $shopping_user, $shopping_order)
|
||||
{
|
||||
|
|
@ -375,7 +510,7 @@ class CheckoutController extends Controller
|
|||
/**
|
||||
* Leitet zur Abschlussseite weiter
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
* @return View
|
||||
*/
|
||||
public function redirectToIsFinal()
|
||||
{
|
||||
|
|
@ -394,7 +529,7 @@ class CheckoutController extends Controller
|
|||
*
|
||||
* @param string $status
|
||||
* @param string $reference
|
||||
* @return \Illuminate\View\View|\Illuminate\Http\RedirectResponse
|
||||
* @return View|RedirectResponse
|
||||
*/
|
||||
public function transactionStatusPost($status, $reference)
|
||||
{
|
||||
|
|
@ -406,7 +541,7 @@ class CheckoutController extends Controller
|
|||
*
|
||||
* @param string $status
|
||||
* @param string $reference
|
||||
* @return \Illuminate\View\View|\Illuminate\Http\RedirectResponse
|
||||
* @return View|RedirectResponse
|
||||
*/
|
||||
public function transactionStatus($status, $reference)
|
||||
{
|
||||
|
|
@ -492,7 +627,7 @@ class CheckoutController extends Controller
|
|||
/**
|
||||
* Zeigt eine Transaktionsfehlerseite an
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
* @return View
|
||||
*/
|
||||
/**
|
||||
* @param array<string, mixed> $extra
|
||||
|
|
@ -516,7 +651,7 @@ class CheckoutController extends Controller
|
|||
*
|
||||
* @param ShoppingPayment $ShoppingPayment
|
||||
* @param string $reference
|
||||
* @return \Illuminate\View\View
|
||||
* @return View
|
||||
*/
|
||||
private function handleSuccessfulTransaction($ShoppingPayment, $reference)
|
||||
{
|
||||
|
|
@ -549,7 +684,7 @@ class CheckoutController extends Controller
|
|||
*
|
||||
* @param int $transactionId
|
||||
* @param string $reference
|
||||
* @return \Illuminate\View\View
|
||||
* @return View
|
||||
*/
|
||||
public function transactionApproved($transactionId, $reference)
|
||||
{
|
||||
|
|
@ -601,7 +736,7 @@ class CheckoutController extends Controller
|
|||
return null;
|
||||
}
|
||||
|
||||
return \App\Models\UserAbo::find($shoppingOrder->recovery_user_abo_id)?->selfServiceDetailUrl();
|
||||
return UserAbo::find($shoppingOrder->recovery_user_abo_id)?->selfServiceDetailUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -660,7 +795,7 @@ class CheckoutController extends Controller
|
|||
* @param string|null $is_for = me | ot | abo-me | abo-ot | ot-customer | abo-ot-customer
|
||||
* @param array|null $shopping_data
|
||||
* @param int|null $homeparty_id
|
||||
* @return \App\Models\ShoppingUser
|
||||
* @return ShoppingUser
|
||||
*/
|
||||
private function initializeShoppingUserSession($is_from, $is_for, $shopping_data = null, $homeparty_id = null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\PaymentTransaction
|
||||
|
|
@ -19,9 +20,9 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @property int|null $errorcode
|
||||
* @property string|null $errormessage
|
||||
* @property string|null $customermessage
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\ShoppingPayment $shopping_payment
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read ShoppingPayment $shopping_payment
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\PaymentTransaction newQuery()
|
||||
|
|
@ -57,6 +58,7 @@ class PaymentTransaction extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'shopping_payment_id',
|
||||
'provider',
|
||||
'request',
|
||||
'txid',
|
||||
'userid',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Services\Util;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* App\Models\ShoppingPayment
|
||||
|
|
@ -18,11 +20,12 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @property string $currency
|
||||
* @property string|null $status
|
||||
* @property string|null $txaction
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\PaymentTransaction[] $payment_transactions
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Collection|PaymentTransaction[] $payment_transactions
|
||||
* @property-read int|null $payment_transactions_count
|
||||
* @property-read \App\Models\ShoppingOrder $shopping_order
|
||||
* @property-read ShoppingOrder $shopping_order
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment query()
|
||||
|
|
@ -38,28 +41,37 @@ use Illuminate\Database\Eloquent\Model;
|
|||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment whereTxaction($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment whereWallettype($value)
|
||||
*
|
||||
* @property string|null $mode
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ShoppingPayment whereMode($value)
|
||||
*
|
||||
* @property array|null $carddata
|
||||
* @property int|null $is_abo
|
||||
* @property int|null $abo_interval
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingPayment whereAboInterval($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingPayment whereCarddata($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|ShoppingPayment whereIsAbo($value)
|
||||
*
|
||||
* @property string|null $identifier
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|ShoppingPayment whereIdentifier($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class ShoppingPayment extends Model
|
||||
{
|
||||
protected $table = 'shopping_payments';
|
||||
|
||||
|
||||
protected $casts = [
|
||||
'carddata' => 'array',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'shopping_order_id',
|
||||
'payment_provider',
|
||||
'external_reference',
|
||||
'clearingtype',
|
||||
'wallettype',
|
||||
'onlinebanktransfertype',
|
||||
|
|
@ -73,47 +85,86 @@ class ShoppingPayment extends Model
|
|||
'identifier',
|
||||
];
|
||||
|
||||
|
||||
public function shopping_order()
|
||||
{
|
||||
return $this->belongsTo('App\Models\ShoppingOrder','shopping_order_id');
|
||||
return $this->belongsTo('App\Models\ShoppingOrder', 'shopping_order_id');
|
||||
}
|
||||
|
||||
public function payment_transactions()
|
||||
{
|
||||
return $this->hasMany('App\Models\PaymentTransaction','shopping_payment_id');
|
||||
return $this->hasMany('App\Models\PaymentTransaction', 'shopping_payment_id');
|
||||
}
|
||||
|
||||
public function getPaymentType(){
|
||||
|
||||
if($this->clearingtype === 'wlt') {
|
||||
public function getPaymentType()
|
||||
{
|
||||
if ($this->payment_provider === 'stripe') {
|
||||
return $this->stripePaymentTypeLabel();
|
||||
}
|
||||
|
||||
if ($this->clearingtype === 'wlt') {
|
||||
if ($this->wallettype === 'PPE') {
|
||||
return __('payment.paypal');
|
||||
}
|
||||
}
|
||||
if($this->clearingtype === 'cc') {
|
||||
return __('payment.credit_card');
|
||||
if ($this->clearingtype === 'cc') {
|
||||
return __('payment.credit_card');
|
||||
}
|
||||
if($this->clearingtype === 'vor') {
|
||||
if ($this->clearingtype === 'vor') {
|
||||
return __('payment.prepayment');
|
||||
}
|
||||
if($this->clearingtype === 'elv') {
|
||||
return __('payment.sepa_direct_debit');
|
||||
if ($this->clearingtype === 'elv') {
|
||||
return __('payment.sepa_direct_debit');
|
||||
}
|
||||
if($this->clearingtype === 'sb') {
|
||||
if ($this->clearingtype === 'sb') {
|
||||
if ($this->onlinebanktransfertype === 'PNT') {
|
||||
return __('payment.sofort_bank_transfer');
|
||||
return __('payment.sofort_bank_transfer');
|
||||
}
|
||||
}
|
||||
if($this->clearingtype === 'fnc') {
|
||||
if ($this->clearingtype === 'fnc') {
|
||||
if ($this->onlinebanktransfertype === 'MIV') {
|
||||
return __('payment.purchase_on_account');
|
||||
return __('payment.purchase_on_account');
|
||||
}
|
||||
}
|
||||
|
||||
return __('payment.unknown');
|
||||
}
|
||||
|
||||
public function getPaymentAmount(){
|
||||
return Util::formatNumber($this->amount/100)." ".$this->currency;
|
||||
public function getPaymentAmount()
|
||||
{
|
||||
return Util::formatNumber($this->amount / 100).' '.$this->currency;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeige-Label für Stripe-Zahlungen aus den gespeicherten
|
||||
* payment_method_details (StripeWebhookHandler::storePaymentMethodDetails).
|
||||
*/
|
||||
private function stripePaymentTypeLabel(): string
|
||||
{
|
||||
$details = is_array($this->carddata) ? $this->carddata : [];
|
||||
$type = $details['type'] ?? null;
|
||||
|
||||
$cardSuffix = ! empty($details['brand']) && ! empty($details['last4'])
|
||||
? ' ('.ucfirst($details['brand']).' •••• '.$details['last4'].')'
|
||||
: '';
|
||||
|
||||
if ($type === 'card') {
|
||||
$wallet = $details['wallet'] ?? null;
|
||||
if ($wallet === 'apple_pay') {
|
||||
return 'Apple Pay'.$cardSuffix;
|
||||
}
|
||||
if ($wallet === 'google_pay') {
|
||||
return 'Google Pay'.$cardSuffix;
|
||||
}
|
||||
|
||||
return __('payment.credit_card').$cardSuffix;
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
'paypal' => __('payment.paypal'),
|
||||
'klarna' => 'Klarna',
|
||||
'amazon_pay' => 'Amazon Pay',
|
||||
'sepa_debit' => __('payment.sepa_direct_debit').(! empty($details['last4']) ? ' (•••• '.$details['last4'].')' : ''),
|
||||
default => __('payment.stripe_online_short'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
38
app/Models/StripeWebhookEvent.php
Normal file
38
app/Models/StripeWebhookEvent.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Empfangenes Stripe-Webhook-Event (Idempotenz-Store + Audit).
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $event_id
|
||||
* @property string $type
|
||||
* @property string|null $api_version
|
||||
* @property array|null $payload
|
||||
* @property Carbon|null $processed_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class StripeWebhookEvent extends Model
|
||||
{
|
||||
protected $table = 'stripe_webhook_events';
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'processed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'type',
|
||||
'api_version',
|
||||
'payload',
|
||||
'processed_at',
|
||||
];
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
namespace App\Providers;
|
||||
|
||||
use App\Services\Payment\Payone\PayonePaymentService;
|
||||
use App\Services\Payment\Stripe\StripeClientWrapper;
|
||||
use App\Services\Util;
|
||||
use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
|
@ -39,10 +42,10 @@ class AppServiceProvider extends ServiceProvider
|
|||
// }
|
||||
|
||||
// Temporär: Verwende immer das normale Verhalten
|
||||
$view->with('user_shop', \App\Services\Util::getUserShop());
|
||||
$view->with('user_shop', Util::getUserShop());
|
||||
} catch (\Exception $e) {
|
||||
// Fallback bei Fehlern
|
||||
$view->with('user_shop', \App\Services\Util::getUserShop());
|
||||
$view->with('user_shop', Util::getUserShop());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -61,8 +64,14 @@ class AppServiceProvider extends ServiceProvider
|
|||
return new PayonePaymentService(config('payone.defaults'));
|
||||
});
|
||||
|
||||
if ($this->app->environment() !== 'production' && class_exists(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class)) {
|
||||
$this->app->register(\Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class);
|
||||
// Stripe: lazy Client-Hülle; Hard-Fail erst bei Nutzung ohne STRIPE_SECRET
|
||||
// (Dark-Launch-fähig, siehe config/stripe.php + dev/2026-07-08-stripe/).
|
||||
$this->app->singleton(StripeClientWrapper::class, function () {
|
||||
return new StripeClientWrapper(config('stripe.secret'));
|
||||
});
|
||||
|
||||
if ($this->app->environment() !== 'production' && class_exists(IdeHelperServiceProvider::class)) {
|
||||
$this->app->register(IdeHelperServiceProvider::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Homeparty;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\ShoppingCollectOrder;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\Payment\PaymentProviderManager;
|
||||
use App\Services\Util;
|
||||
use App\User;
|
||||
use Illuminate\Session\SessionManager;
|
||||
use Illuminate\Support\Collection;
|
||||
use Yard;
|
||||
|
|
@ -246,9 +249,13 @@ class CheckoutRepository extends BaseRepository
|
|||
$payment_methods['data'] = false;
|
||||
}
|
||||
if ($is_abo) {
|
||||
$payment_methods['active'] = \App\Models\PaymentMethod::where('active', true)->where('is_abo', true)->get()->pluck('id', 'short')->toArray();
|
||||
$payment_methods['active'] = PaymentMethod::where('active', true)->where('is_abo', true)->get()->pluck('id', 'short')->toArray();
|
||||
} else {
|
||||
$payment_methods['active'] = \App\Models\PaymentMethod::where('active', true)->get()->pluck('id', 'short')->toArray();
|
||||
$payment_methods['active'] = PaymentMethod::where('active', true)->get()->pluck('id', 'short')->toArray();
|
||||
}
|
||||
|
||||
if (! app(PaymentProviderManager::class)->isVorkasseVisible()) {
|
||||
unset($payment_methods['active']['VOR']);
|
||||
}
|
||||
|
||||
return $payment_methods;
|
||||
|
|
@ -306,7 +313,7 @@ class CheckoutRepository extends BaseRepository
|
|||
return $shopping_user;
|
||||
}
|
||||
|
||||
public function shoppingUserByAuthCustomer(\App\Models\Customer $user)
|
||||
public function shoppingUserByAuthCustomer(Customer $user)
|
||||
{
|
||||
|
||||
// clone shopping user!
|
||||
|
|
@ -325,7 +332,7 @@ class CheckoutRepository extends BaseRepository
|
|||
return $shopping_user;
|
||||
}
|
||||
|
||||
public function shoppingUserByAuthUser(\App\User $user, $is_from, $is_for)
|
||||
public function shoppingUserByAuthUser(User $user, $is_from, $is_for)
|
||||
{
|
||||
|
||||
$shopping_user = new ShoppingUser;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Cron\UserMakeOrder;
|
|||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use App\Services\Payment\PaymentProviderManager;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AboRetryPaymentService
|
||||
|
|
@ -15,6 +16,13 @@ class AboRetryPaymentService
|
|||
*/
|
||||
public function retry(UserAbo $userAbo): array
|
||||
{
|
||||
if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('payment.abo_payments_frozen'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($userAbo->status !== 3) {
|
||||
return [
|
||||
'success' => false,
|
||||
|
|
|
|||
34
app/Services/Payment/Contracts/PaymentGatewayInterface.php
Normal file
34
app/Services/Payment/Contracts/PaymentGatewayInterface.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Contracts;
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Payment\DTOs\AboChargeResult;
|
||||
|
||||
/**
|
||||
* Gemeinsame Schnittstelle der Zahlungsanbieter (PAYONE, Stripe).
|
||||
*
|
||||
* Das Interface wächst phasenweise mit dem Stripe-Plan
|
||||
* (dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md): In S3 kommt
|
||||
* initiateCheckoutPayment(), in S5 setupPaymentMethodUpdate(), in S7 refund()
|
||||
* hinzu — Methoden werden erst deklariert, wenn ein realer Aufrufer existiert.
|
||||
*/
|
||||
interface PaymentGatewayInterface
|
||||
{
|
||||
public function provider(): PaymentProvider;
|
||||
|
||||
/**
|
||||
* Wiederkehrende Abo-Abbuchung mit dem beim Anbieter gespeicherten
|
||||
* Zahlungsmittel (off-session). Betrag in Cent, brutto.
|
||||
*/
|
||||
public function chargeAbo(
|
||||
UserAbo $userAbo,
|
||||
ShoppingOrder $shoppingOrder,
|
||||
ShoppingUser $shoppingUser,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
): AboChargeResult;
|
||||
}
|
||||
43
app/Services/Payment/DTOs/AboChargeResult.php
Normal file
43
app/Services/Payment/DTOs/AboChargeResult.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\DTOs;
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
|
||||
|
||||
/**
|
||||
* Provider-neutrales Ergebnis einer wiederkehrenden Abo-Abbuchung.
|
||||
* Vereinheitlicht PayonePaymentResult (und später das Stripe-Pendant),
|
||||
* damit Cron/Retry nicht pro Anbieter verzweigen müssen.
|
||||
*/
|
||||
class AboChargeResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $rawResponse
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly PaymentProvider $provider,
|
||||
public readonly bool $success,
|
||||
public readonly string $status,
|
||||
public readonly array $rawResponse,
|
||||
public readonly string $transactionId = '',
|
||||
public readonly ?string $errorMessage = null,
|
||||
public readonly ?string $customerMessage = null,
|
||||
public readonly ?ShoppingPayment $shoppingPayment = null,
|
||||
) {}
|
||||
|
||||
public static function fromPayoneResult(PayonePaymentResult $result): self
|
||||
{
|
||||
return new self(
|
||||
provider: PaymentProvider::Payone,
|
||||
success: $result->isApproved(),
|
||||
status: $result->status,
|
||||
rawResponse: $result->rawResponse,
|
||||
transactionId: $result->txid,
|
||||
errorMessage: $result->errorMessage,
|
||||
customerMessage: $result->customerMessage,
|
||||
shoppingPayment: $result->shoppingPayment,
|
||||
);
|
||||
}
|
||||
}
|
||||
91
app/Services/Payment/PaymentProviderManager.php
Normal file
91
app/Services/Payment/PaymentProviderManager.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment;
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Models\Setting;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Payment\Contracts\PaymentGatewayInterface;
|
||||
|
||||
/**
|
||||
* Zentrale Anbieter-Auflösung (dev/2026-07-08-stripe/, Konzept §4):
|
||||
*
|
||||
* - Neue Zahlungsvorgänge folgen dem Einstellungs-Schalter (Setting-Slug
|
||||
* `payment-active-provider`).
|
||||
* - Bestehende Zahlungen/Abos folgen IMMER ihrer eigenen
|
||||
* `payment_provider`-Spalte — der Schalter wirkt nie rückwirkend.
|
||||
* - Unbekannte/fehlende Werte fallen sicher auf PAYONE zurück
|
||||
* (Bestandsverhalten vor der Stripe-Einführung).
|
||||
*/
|
||||
class PaymentProviderManager
|
||||
{
|
||||
public const SETTING_SLUG = 'payment-active-provider';
|
||||
|
||||
public const SETTING_VORKASSE_HIDDEN = 'payment-vorkasse-hidden';
|
||||
|
||||
public const SETTING_ABO_PAYMENTS_FROZEN = 'abo-payments-frozen';
|
||||
|
||||
public const SETTING_CHECKOUT_MAINTENANCE = 'checkout-maintenance';
|
||||
|
||||
/**
|
||||
* Anbieter für neue Checkouts, neue Abos und neue Zahlungslinks.
|
||||
*/
|
||||
public function providerForNewCheckout(): PaymentProvider
|
||||
{
|
||||
$configured = Setting::getContentBySlug(self::SETTING_SLUG);
|
||||
|
||||
return PaymentProvider::tryFrom(is_string($configured) ? $configured : '')
|
||||
?? PaymentProvider::Payone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anbieter eines bestehenden Vorgangs — schalterunabhängig.
|
||||
*/
|
||||
public function providerFor(UserAbo|ShoppingPayment $subject): PaymentProvider
|
||||
{
|
||||
return PaymentProvider::tryFrom((string) $subject->payment_provider)
|
||||
?? PaymentProvider::Payone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sichtbarkeit der Vorkasse im Checkout (N3, 2026-07-08): Vorkasse läuft
|
||||
* über PAYONE und ist über eine eigene Einstellung ausblendbar —
|
||||
* unabhängig vom Provider-Schalter. Fehlende Einstellung = sichtbar.
|
||||
*/
|
||||
public function isVorkasseVisible(): bool
|
||||
{
|
||||
return Setting::getContentBySlug(self::SETTING_VORKASSE_HIDDEN) !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Not-Schalter (2026-07-08, PAYONE-Ausfall): friert ALLE Abo-Zahlungen ein —
|
||||
* Abo-Checkout, Recovery, Retry und den Abbuchungs-Cron. Einmalzahlungen
|
||||
* (Stripe) laufen weiter. Wird nach Phase S4 (Abos über Stripe) aufgehoben.
|
||||
*/
|
||||
public function aboPaymentsFrozen(): bool
|
||||
{
|
||||
return Setting::getContentBySlug(self::SETTING_ABO_PAYMENTS_FROZEN) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout-Wartungsmodus: Nicht-Admins sehen im Checkout nur eine
|
||||
* "Technische Schwierigkeiten"-Meldung; Admins können normal bestellen.
|
||||
* Zweck: Stripe im Live-Modus verproben, bevor der Checkout für alle
|
||||
* wieder öffnet (Go-Live 2026-07-08).
|
||||
*/
|
||||
public function checkoutMaintenanceActive(): bool
|
||||
{
|
||||
return Setting::getContentBySlug(self::SETTING_CHECKOUT_MAINTENANCE) === true;
|
||||
}
|
||||
|
||||
public function gateway(PaymentProvider $provider): PaymentGatewayInterface
|
||||
{
|
||||
return match ($provider) {
|
||||
PaymentProvider::Payone => app(PayoneGateway::class),
|
||||
PaymentProvider::Stripe => throw new \RuntimeException(
|
||||
'Das Stripe-Gateway ist noch nicht implementiert (Plan Phase S2/S4, dev/2026-07-08-stripe/).'
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
44
app/Services/Payment/PayoneGateway.php
Normal file
44
app/Services/Payment/PayoneGateway.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment;
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Payment\Contracts\PaymentGatewayInterface;
|
||||
use App\Services\Payment\DTOs\AboChargeResult;
|
||||
use App\Services\Payment\Payone\PayonePaymentService;
|
||||
|
||||
/**
|
||||
* Dünner Adapter, der den bestehenden PayonePaymentService hinter die
|
||||
* provider-neutrale Gateway-Schnittstelle stellt. Enthält bewusst KEINE
|
||||
* eigene Logik — PAYONE-Verhalten bleibt unverändert (Plan S1.3).
|
||||
*/
|
||||
class PayoneGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function __construct(private readonly PayonePaymentService $paymentService) {}
|
||||
|
||||
public function provider(): PaymentProvider
|
||||
{
|
||||
return PaymentProvider::Payone;
|
||||
}
|
||||
|
||||
public function chargeAbo(
|
||||
UserAbo $userAbo,
|
||||
ShoppingOrder $shoppingOrder,
|
||||
ShoppingUser $shoppingUser,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
): AboChargeResult {
|
||||
$result = $this->paymentService->chargeAbo(
|
||||
$userAbo,
|
||||
$shoppingOrder,
|
||||
$shoppingUser,
|
||||
$amountCents,
|
||||
$currency,
|
||||
);
|
||||
|
||||
return AboChargeResult::fromPayoneResult($result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Stripe\DTOs;
|
||||
|
||||
use App\Models\ShoppingPayment;
|
||||
|
||||
/**
|
||||
* Ergebnis einer erstellten Stripe Checkout Session (Hosted Page):
|
||||
* `url` ist das Redirect-Ziel für den Browser.
|
||||
*/
|
||||
class StripeCheckoutSessionResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $sessionId,
|
||||
public readonly string $url,
|
||||
public readonly string $reference,
|
||||
public readonly ShoppingPayment $shoppingPayment,
|
||||
) {}
|
||||
}
|
||||
49
app/Services/Payment/Stripe/DTOs/StripeWebhookResult.php
Normal file
49
app/Services/Payment/Stripe/DTOs/StripeWebhookResult.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Stripe\DTOs;
|
||||
|
||||
/**
|
||||
* Ergebnis der Stripe-Webhook-Verarbeitung. Anders als beim PAYONE-IPN
|
||||
* (immer "TSOK") steuert der HTTP-Status das Retry-Verhalten von Stripe:
|
||||
* 2xx = erledigt, alles andere wird erneut zugestellt.
|
||||
*/
|
||||
class StripeWebhookResult
|
||||
{
|
||||
private function __construct(
|
||||
public readonly int $statusCode,
|
||||
public readonly string $state,
|
||||
public readonly ?string $eventId = null,
|
||||
public readonly ?string $eventType = null,
|
||||
public readonly ?string $reason = null,
|
||||
) {}
|
||||
|
||||
/** Signatur ungültig oder Payload kein gültiges Event — kein Retry sinnvoll. */
|
||||
public static function invalidSignature(string $reason): self
|
||||
{
|
||||
return new self(statusCode: 400, state: 'invalid_signature', reason: $reason);
|
||||
}
|
||||
|
||||
/** STRIPE_WEBHOOK_SECRET fehlt — 500, damit Stripe später erneut zustellt. */
|
||||
public static function notConfigured(): self
|
||||
{
|
||||
return new self(statusCode: 500, state: 'not_configured', reason: 'STRIPE_WEBHOOK_SECRET missing');
|
||||
}
|
||||
|
||||
/** Event-ID bereits verarbeitet — Retry wird bestätigt, aber ignoriert. */
|
||||
public static function duplicate(string $eventId, string $eventType): self
|
||||
{
|
||||
return new self(statusCode: 200, state: 'duplicate', eventId: $eventId, eventType: $eventType);
|
||||
}
|
||||
|
||||
/** Gültiges Event eines nicht behandelten Typs — protokolliert, bestätigt. */
|
||||
public static function ignored(string $eventId, string $eventType): self
|
||||
{
|
||||
return new self(statusCode: 200, state: 'ignored', eventId: $eventId, eventType: $eventType);
|
||||
}
|
||||
|
||||
/** Event verarbeitet (Transaktion protokolliert, Folgeaktionen angestoßen). */
|
||||
public static function processed(string $eventId, string $eventType): self
|
||||
{
|
||||
return new self(statusCode: 200, state: 'processed', eventId: $eventId, eventType: $eventType);
|
||||
}
|
||||
}
|
||||
39
app/Services/Payment/Stripe/StripeClientWrapper.php
Normal file
39
app/Services/Payment/Stripe/StripeClientWrapper.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Stripe;
|
||||
|
||||
use Stripe\StripeClient;
|
||||
|
||||
/**
|
||||
* Dünne, injizierbare Hülle um den Stripe-SDK-Client.
|
||||
*
|
||||
* Hier sitzt der bewusst aus config/stripe.php verlagerte Hard-Fail
|
||||
* (Plan S0.4-Abweichung): Ein Deployment ohne Stripe-Keys bootet normal
|
||||
* (Dark Launch); erst die tatsächliche Nutzung ohne STRIPE_SECRET
|
||||
* schlägt hart und diagnostizierbar fehl.
|
||||
*/
|
||||
class StripeClientWrapper
|
||||
{
|
||||
private ?StripeClient $client = null;
|
||||
|
||||
public function __construct(private readonly ?string $secret) {}
|
||||
|
||||
public function client(): StripeClient
|
||||
{
|
||||
if ($this->client === null) {
|
||||
if ($this->secret === null || trim($this->secret) === '') {
|
||||
throw new \RuntimeException(
|
||||
'STRIPE_SECRET ist nicht gesetzt. Hinterlege den Stripe Secret Key (sk_…) in der .env, bevor Stripe als Zahlungsanbieter genutzt wird.'
|
||||
);
|
||||
}
|
||||
$this->client = new StripeClient($this->secret);
|
||||
}
|
||||
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->secret !== null && trim($this->secret) !== '';
|
||||
}
|
||||
}
|
||||
36
app/Services/Payment/Stripe/StripeCustomerService.php
Normal file
36
app/Services/Payment/Stripe/StripeCustomerService.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Stripe;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\User;
|
||||
|
||||
/**
|
||||
* Legt pro User/Kunde genau einen Stripe-Customer an und persistiert die
|
||||
* ID (users.stripe_customer_id / customers.stripe_customer_id). Idempotent:
|
||||
* eine vorhandene ID wird nie überschrieben.
|
||||
*/
|
||||
class StripeCustomerService
|
||||
{
|
||||
public function __construct(private readonly StripeClientWrapper $stripe) {}
|
||||
|
||||
public function customerIdFor(User|Customer $owner): string
|
||||
{
|
||||
if (! empty($owner->stripe_customer_id)) {
|
||||
return $owner->stripe_customer_id;
|
||||
}
|
||||
|
||||
$customer = $this->stripe->client()->customers->create([
|
||||
'email' => $owner->email,
|
||||
'name' => trim(($owner->firstname ?? '').' '.($owner->lastname ?? '')) ?: null,
|
||||
'metadata' => [
|
||||
'mivita_type' => $owner instanceof Customer ? 'customer' : 'user',
|
||||
'mivita_id' => (string) $owner->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$owner->forceFill(['stripe_customer_id' => $customer->id])->save();
|
||||
|
||||
return $customer->id;
|
||||
}
|
||||
}
|
||||
103
app/Services/Payment/Stripe/StripePaymentService.php
Normal file
103
app/Services/Payment/Stripe/StripePaymentService.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Stripe;
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult;
|
||||
use App\Services\Util;
|
||||
|
||||
/**
|
||||
* Stripe-Zahlungsvorgänge (Plan dev/2026-07-08-stripe/, Phase S3 ff.).
|
||||
*
|
||||
* Einmalzahlung im Checkout = Hosted Checkout Session (Redirect-Flow,
|
||||
* Konzept §3.1): Zahlartenwahl und Dateneingabe passieren bei Stripe,
|
||||
* "bezahlt" setzt ausschließlich der Webhook (StripeWebhookHandler).
|
||||
*/
|
||||
class StripePaymentService
|
||||
{
|
||||
public function __construct(private readonly StripeClientWrapper $stripe) {}
|
||||
|
||||
/**
|
||||
* Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung.
|
||||
* Die Success-/Cancel-URLs baut der HTTP-Aufrufer (Routen sind
|
||||
* domain-abhängig und gehören nicht in die Service-Schicht).
|
||||
*/
|
||||
public function initiateCheckoutPayment(
|
||||
ShoppingUser $shoppingUser,
|
||||
ShoppingOrder $shoppingOrder,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
string $reference,
|
||||
string $successUrl,
|
||||
string $cancelUrl,
|
||||
): StripeCheckoutSessionResult {
|
||||
$shoppingPayment = ShoppingPayment::create([
|
||||
'shopping_order_id' => $shoppingOrder->id,
|
||||
'payment_provider' => PaymentProvider::Stripe->value,
|
||||
'clearingtype' => 'stripe',
|
||||
'reference' => $reference,
|
||||
'amount' => $amountCents,
|
||||
'currency' => $currency,
|
||||
'is_abo' => (bool) $shoppingOrder->is_abo,
|
||||
'abo_interval' => $shoppingOrder->abo_interval ?? 0,
|
||||
'identifier' => Util::getUserShopIdentifier(),
|
||||
'mode' => $shoppingOrder->mode,
|
||||
]);
|
||||
|
||||
CheckoutFunnelTracker::initiatedPayment(
|
||||
shoppingUserId: $shoppingUser->id,
|
||||
shoppingOrderId: $shoppingOrder->id,
|
||||
shoppingPaymentId: $shoppingPayment->id,
|
||||
consultantUserId: $shoppingUser->auth_user_id ?? null,
|
||||
paymentMethod: 'stripe',
|
||||
amountCents: $amountCents,
|
||||
);
|
||||
|
||||
$metadata = [
|
||||
'shopping_order_id' => (string) $shoppingOrder->id,
|
||||
'shopping_payment_id' => (string) $shoppingPayment->id,
|
||||
'reference' => $reference,
|
||||
'mode' => (string) $shoppingOrder->mode,
|
||||
];
|
||||
|
||||
$session = $this->stripe->client()->checkout->sessions->create([
|
||||
'mode' => 'payment',
|
||||
'client_reference_id' => $reference,
|
||||
'customer_email' => $shoppingUser->billing_email ?: null,
|
||||
'line_items' => [[
|
||||
'quantity' => 1,
|
||||
'price_data' => [
|
||||
'currency' => strtolower($currency),
|
||||
'unit_amount' => $amountCents,
|
||||
'product_data' => [
|
||||
'name' => __('payment.stripe_order_label', ['reference' => $reference]),
|
||||
],
|
||||
],
|
||||
]],
|
||||
'metadata' => $metadata,
|
||||
'payment_intent_data' => ['metadata' => $metadata],
|
||||
'success_url' => $successUrl,
|
||||
'cancel_url' => $cancelUrl,
|
||||
]);
|
||||
|
||||
$shoppingPayment->external_reference = $session->id;
|
||||
$shoppingPayment->save();
|
||||
|
||||
StripeTransactionRecorder::recordFromStripeObject(
|
||||
$shoppingPayment,
|
||||
'checkout.session.created',
|
||||
$session->toArray(),
|
||||
);
|
||||
|
||||
return new StripeCheckoutSessionResult(
|
||||
sessionId: $session->id,
|
||||
url: (string) $session->url,
|
||||
reference: $reference,
|
||||
shoppingPayment: $shoppingPayment,
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/Services/Payment/Stripe/StripeTransactionRecorder.php
Normal file
71
app/Services/Payment/Stripe/StripeTransactionRecorder.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Stripe;
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShoppingPayment;
|
||||
|
||||
/**
|
||||
* Einzige Stelle, die PaymentTransaction-Zeilen für Stripe schreibt
|
||||
* (Muster: PayoneTransactionRecorder). Redaction entfernt Secrets aus
|
||||
* dem Payload, bevor er in transmitted_data landet.
|
||||
*/
|
||||
class StripeTransactionRecorder
|
||||
{
|
||||
/**
|
||||
* Redaktierte Schlüssel: client_secret (PaymentIntent/SetupIntent) reicht
|
||||
* aus, um eine Zahlung im Browser zu übernehmen und gehört nie in die DB.
|
||||
*/
|
||||
private const REDACTED_KEYS = ['client_secret'];
|
||||
|
||||
/**
|
||||
* Protokolliert ein Stripe-Objekt (PaymentIntent, Charge, SetupIntent …)
|
||||
* aus einem Webhook-Event oder einer API-Response zu einer Zahlung.
|
||||
*
|
||||
* @param array<string, mixed> $object Stripe-Objekt als Array (data.object)
|
||||
*/
|
||||
public static function recordFromStripeObject(
|
||||
ShoppingPayment $payment,
|
||||
string $requestType,
|
||||
array $object,
|
||||
): PaymentTransaction {
|
||||
return PaymentTransaction::create([
|
||||
'shopping_payment_id' => $payment->id,
|
||||
'provider' => PaymentProvider::Stripe->value,
|
||||
'request' => $requestType,
|
||||
'txid' => (string) ($object['id'] ?? ''),
|
||||
'userid' => 0,
|
||||
'status' => (string) ($object['status'] ?? ''),
|
||||
'txaction' => $requestType,
|
||||
'transmitted_data' => self::redactPayload($object),
|
||||
'mode' => array_key_exists('livemode', $object)
|
||||
? ($object['livemode'] ? 'live' : 'test')
|
||||
: $payment->mode,
|
||||
'errorcode' => $object['last_payment_error']['code'] ?? null,
|
||||
'errormessage' => $object['last_payment_error']['message'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt sensible Felder rekursiv aus einem Stripe-Payload.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function redactPayload(array $payload): array
|
||||
{
|
||||
foreach ($payload as $key => $value) {
|
||||
if (in_array($key, self::REDACTED_KEYS, true)) {
|
||||
unset($payload[$key]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$payload[$key] = self::redactPayload($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
423
app/Services/Payment/Stripe/StripeWebhookHandler.php
Normal file
423
app/Services/Payment/Stripe/StripeWebhookHandler.php
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Payment\Stripe;
|
||||
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\StripeWebhookEvent;
|
||||
use App\Services\CheckoutFunnelTracker;
|
||||
use App\Services\Payment;
|
||||
use App\Services\Payment\Stripe\DTOs\StripeWebhookResult;
|
||||
use App\Services\ShoppingUserService;
|
||||
use App\Services\Util;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Stripe\Event;
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
use Stripe\Webhook;
|
||||
|
||||
/**
|
||||
* Verarbeitet Stripe-Webhooks: Signaturprüfung, Idempotenz über die
|
||||
* Event-ID (stripe_webhook_events), Transaktions-Protokollierung und der
|
||||
* Paid-Pfad. Muster: PayoneWebhookHandler (txaction-Priorität, DB-Lock,
|
||||
* zentrale Persistenz, Betragsabgleich).
|
||||
*/
|
||||
class StripeWebhookHandler
|
||||
{
|
||||
private const HANDLED_TYPES = [
|
||||
'checkout.session.completed',
|
||||
'checkout.session.expired',
|
||||
'payment_intent.succeeded',
|
||||
'payment_intent.processing',
|
||||
'payment_intent.payment_failed',
|
||||
'charge.succeeded',
|
||||
'charge.refunded',
|
||||
'charge.dispute.created',
|
||||
'setup_intent.succeeded',
|
||||
];
|
||||
|
||||
/**
|
||||
* Gleiche txaction-Taxonomie wie PAYONE, damit alle nachgelagerten
|
||||
* Ansichten/Services (Order-Status, Abo-Logik) unverändert funktionieren.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private const TXACTION_PRIORITY = [
|
||||
'appointed' => 1,
|
||||
'pending' => 2,
|
||||
'failed' => 3,
|
||||
'paid' => 10,
|
||||
];
|
||||
|
||||
public function handle(string $payload, ?string $signatureHeader): StripeWebhookResult
|
||||
{
|
||||
$secret = (string) config('stripe.webhook_secret');
|
||||
if (trim($secret) === '') {
|
||||
Log::channel('stripe')->error('Stripe-Webhook empfangen, aber STRIPE_WEBHOOK_SECRET ist nicht gesetzt.');
|
||||
|
||||
return StripeWebhookResult::notConfigured();
|
||||
}
|
||||
|
||||
try {
|
||||
$event = Webhook::constructEvent($payload, (string) $signatureHeader, $secret);
|
||||
} catch (SignatureVerificationException|\UnexpectedValueException $e) {
|
||||
Log::channel('stripe')->warning('Stripe-Webhook abgewiesen (Signatur/Payload): '.$e->getMessage());
|
||||
|
||||
return StripeWebhookResult::invalidSignature($e->getMessage());
|
||||
}
|
||||
|
||||
/*
|
||||
* Idempotenz: Ein Event gilt als Duplikat, wenn es bereits VOLLSTÄNDIG
|
||||
* verarbeitet wurde (processed_at) oder sein Typ keine Verarbeitung braucht.
|
||||
* Ein gespeichertes, aber unverarbeitetes Event (Crash mitten in der
|
||||
* Verarbeitung → non-2xx → Stripe-Retry) wird erneut verarbeitet.
|
||||
*/
|
||||
$storedEvent = StripeWebhookEvent::where('event_id', $event->id)->first();
|
||||
if ($storedEvent && ($storedEvent->processed_at !== null || ! in_array($event->type, self::HANDLED_TYPES, true))) {
|
||||
Log::channel('stripe')->info("Stripe-Webhook-Duplikat ignoriert: {$event->id} ({$event->type})");
|
||||
|
||||
return StripeWebhookResult::duplicate($event->id, $event->type);
|
||||
}
|
||||
|
||||
$object = $event->data->object->toArray();
|
||||
|
||||
if (! $storedEvent) {
|
||||
try {
|
||||
$storedEvent = StripeWebhookEvent::create([
|
||||
'event_id' => $event->id,
|
||||
'type' => $event->type,
|
||||
'api_version' => $event->api_version,
|
||||
'payload' => StripeTransactionRecorder::redactPayload($object),
|
||||
]);
|
||||
} catch (QueryException) {
|
||||
// Unique-Verletzung: paralleler Retry hat das Event gerade angelegt.
|
||||
return StripeWebhookResult::duplicate($event->id, $event->type);
|
||||
}
|
||||
}
|
||||
|
||||
if (! in_array($event->type, self::HANDLED_TYPES, true)) {
|
||||
Log::channel('stripe')->info("Stripe-Webhook protokolliert, Typ nicht behandelt: {$event->id} ({$event->type})");
|
||||
|
||||
return StripeWebhookResult::ignored($event->id, $event->type);
|
||||
}
|
||||
|
||||
$this->dispatchEvent($event, $object);
|
||||
|
||||
$storedEvent->update(['processed_at' => now()]);
|
||||
|
||||
return StripeWebhookResult::processed($event->id, $event->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $object
|
||||
*/
|
||||
private function dispatchEvent(Event $event, array $object): void
|
||||
{
|
||||
$payment = $this->resolveShoppingPayment($object);
|
||||
|
||||
if (! $payment) {
|
||||
Log::channel('stripe')->warning(sprintf(
|
||||
'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)',
|
||||
$event->id,
|
||||
$event->type,
|
||||
$object['id'] ?? '?',
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
StripeTransactionRecorder::recordFromStripeObject($payment, $event->type, $object);
|
||||
|
||||
$this->rememberPaymentIntentReference($payment, $object);
|
||||
|
||||
match ($event->type) {
|
||||
'checkout.session.completed' => $this->handleSessionCompleted($payment, $object),
|
||||
'checkout.session.expired' => $this->handleSessionExpired($payment),
|
||||
'payment_intent.succeeded' => $this->markPaid($payment, $object),
|
||||
'payment_intent.processing' => $this->markPending($payment),
|
||||
'payment_intent.payment_failed' => $this->markFailed($payment),
|
||||
'charge.succeeded' => $this->storePaymentMethodDetails($payment, $object),
|
||||
// charge.refunded / dispute / setup_intent → Business-Logik folgt in S5–S7.
|
||||
default => Log::channel('stripe')->info("Stripe-Webhook protokolliert (Verarbeitung folgt in späterer Phase): {$event->type}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkt sich die PaymentIntent-ID an der Zahlung, sobald sie bekannt ist.
|
||||
* Charge-Events tragen keine Metadata — sie werden über
|
||||
* external_reference = PaymentIntent-ID zugeordnet.
|
||||
*
|
||||
* @param array<string, mixed> $object
|
||||
*/
|
||||
private function rememberPaymentIntentReference(ShoppingPayment $payment, array $object): void
|
||||
{
|
||||
$paymentIntentId = match ($object['object'] ?? null) {
|
||||
'payment_intent' => $object['id'] ?? null,
|
||||
default => $object['payment_intent'] ?? null,
|
||||
};
|
||||
|
||||
if ($paymentIntentId && $payment->external_reference !== $paymentIntentId) {
|
||||
$payment->external_reference = $paymentIntentId;
|
||||
$payment->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session abgeschlossen: bei `paid` sofort der Paid-Pfad; bei asynchronen
|
||||
* Methoden (SEPA & Co., payment_status=unpaid) ist die Bestellung platziert
|
||||
* und wartet auf payment_intent.succeeded → Status "appointed" wie PAYONE.
|
||||
*
|
||||
* @param array<string, mixed> $object
|
||||
*/
|
||||
private function handleSessionCompleted(ShoppingPayment $payment, array $object): void
|
||||
{
|
||||
if (($object['payment_status'] ?? null) === 'paid') {
|
||||
$this->markPaid($payment, $object);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $payment->shopping_order;
|
||||
$this->upgradeTxaction($order, $payment, 'appointed');
|
||||
$order->setUserHistoryValue(['status' => 7]);
|
||||
ShoppingUserService::snycOrdersByShoppingOrder($order);
|
||||
Util::setInstanceStatusByPayment($payment, 4);
|
||||
|
||||
CheckoutFunnelTracker::confirmedPayment(
|
||||
shoppingPaymentId: $payment->id,
|
||||
txaction: 'appointed',
|
||||
metadata: ['provider' => 'stripe', 'session' => $object['id'] ?? null],
|
||||
);
|
||||
|
||||
Payment::paymentStatusSendMail($order, $payment, [
|
||||
'txaction' => 'appointed',
|
||||
'mode' => $payment->mode,
|
||||
'send_link' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $object
|
||||
*/
|
||||
private function markPaid(ShoppingPayment $payment, array $object): void
|
||||
{
|
||||
if (! $this->amountMatches($payment, $object)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $payment->shopping_order;
|
||||
$this->upgradeTxaction($order, $payment, 'paid');
|
||||
|
||||
$sendLink = false;
|
||||
$sendMail = true;
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$lockedOrder = ShoppingOrder::where('id', $order->id)->lockForUpdate()->first();
|
||||
if (! $lockedOrder->paid) {
|
||||
$sendLink = Payment::paymentStatusPaidAction($lockedOrder, true, $payment);
|
||||
DB::commit();
|
||||
} else {
|
||||
$sendMail = false;
|
||||
DB::commit();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
Log::channel('stripe')->error('Stripe-Paid-Pfad fehlgeschlagen (Stripe stellt erneut zu): '.$e->getMessage(), [
|
||||
'shopping_order_id' => $order->id,
|
||||
'shopping_payment_id' => $payment->id,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
CheckoutFunnelTracker::confirmedPayment(
|
||||
shoppingPaymentId: $payment->id,
|
||||
txaction: 'paid',
|
||||
metadata: ['provider' => 'stripe', 'object' => $object['id'] ?? null, 'mode' => $payment->mode],
|
||||
);
|
||||
|
||||
if ($sendMail) {
|
||||
Payment::paymentStatusSendMail($order, $payment, [
|
||||
'txaction' => 'paid',
|
||||
'mode' => $payment->mode,
|
||||
'send_link' => $sendLink,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->backfillPaymentMethodDetails($payment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert womit gezahlt wurde (Kartenmarke/letzte 4/Wallet, PayPal, Klarna …)
|
||||
* aus den payment_method_details des Charge-Objekts in carddata — für die
|
||||
* Anzeige in Bestellungen/Mails (ShoppingPayment::getPaymentType()).
|
||||
*
|
||||
* @param array<string, mixed> $chargeObject
|
||||
*/
|
||||
private function storePaymentMethodDetails(ShoppingPayment $payment, array $chargeObject): void
|
||||
{
|
||||
$summary = self::paymentMethodSummary($chargeObject);
|
||||
|
||||
if ($summary !== null && empty($payment->carddata)) {
|
||||
$payment->carddata = $summary;
|
||||
$payment->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook-Reihenfolge ist nicht garantiert: Kam charge.succeeded VOR dem
|
||||
* PaymentIntent-Event, war es noch keiner Zahlung zuordenbar — es liegt
|
||||
* aber im Event-Store. Von dort nachziehen, ganz ohne API-Call.
|
||||
*/
|
||||
private function backfillPaymentMethodDetails(ShoppingPayment $payment): void
|
||||
{
|
||||
if (! empty($payment->carddata) || empty($payment->external_reference)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chargeEvent = StripeWebhookEvent::where('type', 'charge.succeeded')
|
||||
->where('payload->payment_intent', $payment->external_reference)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($chargeEvent) {
|
||||
$this->storePaymentMethodDetails($payment, $chargeEvent->payload ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $chargeObject
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function paymentMethodSummary(array $chargeObject): ?array
|
||||
{
|
||||
$details = $chargeObject['payment_method_details'] ?? null;
|
||||
$type = $details['type'] ?? null;
|
||||
|
||||
if (! $type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$summary = ['type' => $type];
|
||||
|
||||
if ($type === 'card') {
|
||||
$summary['brand'] = $details['card']['brand'] ?? null;
|
||||
$summary['last4'] = $details['card']['last4'] ?? null;
|
||||
$summary['wallet'] = $details['card']['wallet']['type'] ?? null;
|
||||
$summary['exp'] = isset($details['card']['exp_month'], $details['card']['exp_year'])
|
||||
? sprintf('%02d/%d', $details['card']['exp_month'], $details['card']['exp_year'])
|
||||
: null;
|
||||
}
|
||||
|
||||
if ($type === 'sepa_debit') {
|
||||
$summary['last4'] = $details['sepa_debit']['last4'] ?? null;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchrone Zahlung unterwegs (SEPA & Co.). Kein Mailversand — die
|
||||
* Bestellbestätigung ging bereits bei checkout.session.completed raus.
|
||||
*/
|
||||
private function markPending(ShoppingPayment $payment): void
|
||||
{
|
||||
$this->upgradeTxaction($payment->shopping_order, $payment, 'pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlgeschlagener Zahlungsversuch. Bewusst KEINE Mail: Auf der Hosted
|
||||
* Checkout Session kann der Kunde direkt erneut zahlen — jeder Fehlversuch
|
||||
* feuert dieses Event, eine Mail pro Versuch wäre Spam. Endgültiges
|
||||
* Scheitern zeigt sich als checkout.session.expired bzw. Cancel-Return.
|
||||
*/
|
||||
private function markFailed(ShoppingPayment $payment): void
|
||||
{
|
||||
$order = $payment->shopping_order;
|
||||
$this->upgradeTxaction($order, $payment, 'failed');
|
||||
$order->setUserHistoryValue(['status' => 6]);
|
||||
Util::setInstanceStatusByPayment($payment, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session nach 24 h abgelaufen, ohne Zahlung: Vorgang als abgebrochen
|
||||
* markieren (wie Cancel-Return), Bestellstatus bleibt unangetastet.
|
||||
*/
|
||||
private function handleSessionExpired(ShoppingPayment $payment): void
|
||||
{
|
||||
if ($payment->shopping_order->paid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payment->status = 'expired';
|
||||
$payment->save();
|
||||
$payment->shopping_order->setUserHistoryValue(['status' => 22]);
|
||||
Util::setInstanceStatusByPayment($payment, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Betragsabgleich (Pendant zum PAYONE-Guard 2006): Der bestätigte Betrag
|
||||
* muss dem initiierten entsprechen, sonst keine Paid-Verarbeitung.
|
||||
*
|
||||
* @param array<string, mixed> $object
|
||||
*/
|
||||
private function amountMatches(ShoppingPayment $payment, array $object): bool
|
||||
{
|
||||
$confirmed = $object['amount_total'] ?? $object['amount_received'] ?? $object['amount'] ?? null;
|
||||
|
||||
if ($confirmed === null || (int) $confirmed !== (int) $payment->amount) {
|
||||
Log::channel('stripe')->error('Stripe-Webhook Betragsabweichung — Paid-Verarbeitung verweigert.', [
|
||||
'shopping_payment_id' => $payment->id,
|
||||
'expected_cents' => (int) $payment->amount,
|
||||
'confirmed_cents' => $confirmed,
|
||||
'object' => $object['id'] ?? null,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function upgradeTxaction(ShoppingOrder $order, ShoppingPayment $payment, string $txaction): void
|
||||
{
|
||||
$currentPriority = self::TXACTION_PRIORITY[$order->txaction] ?? 0;
|
||||
$newPriority = self::TXACTION_PRIORITY[$txaction] ?? 0;
|
||||
|
||||
if ($newPriority > $currentPriority) {
|
||||
$order->txaction = $txaction;
|
||||
$order->save();
|
||||
$payment->txaction = $txaction;
|
||||
$payment->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet ein Stripe-Objekt einer Zahlung zu: über die von uns gesetzte
|
||||
* Metadata (shopping_payment_id, Session UND PaymentIntent tragen sie)
|
||||
* oder über external_reference (Session-/PaymentIntent-ID).
|
||||
*
|
||||
* @param array<string, mixed> $object
|
||||
*/
|
||||
private function resolveShoppingPayment(array $object): ?ShoppingPayment
|
||||
{
|
||||
$metadataId = $object['metadata']['shopping_payment_id'] ?? null;
|
||||
if ($metadataId && ($payment = ShoppingPayment::find((int) $metadataId))) {
|
||||
return $payment;
|
||||
}
|
||||
|
||||
$references = array_filter([
|
||||
$object['id'] ?? null,
|
||||
$object['payment_intent'] ?? null,
|
||||
]);
|
||||
foreach ($references as $reference) {
|
||||
$payment = ShoppingPayment::where('external_reference', $reference)->first();
|
||||
if ($payment) {
|
||||
return $payment;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
9
bootstrap/cache/events.php
vendored
9
bootstrap/cache/events.php
vendored
|
|
@ -1,9 +0,0 @@
|
|||
<?php return array (
|
||||
'App\\Providers\\EventServiceProvider' =>
|
||||
array (
|
||||
'App\\Events\\Event' =>
|
||||
array (
|
||||
0 => 'App\\Listeners\\EventListener',
|
||||
),
|
||||
),
|
||||
);
|
||||
7
bootstrap/cache/packages.php
vendored
7
bootstrap/cache/packages.php
vendored
|
|
@ -123,6 +123,13 @@
|
|||
0 => 'Laravel\\Sail\\SailServiceProvider',
|
||||
),
|
||||
),
|
||||
'laravel/sentinel' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Laravel\\Sentinel\\SentinelServiceProvider',
|
||||
),
|
||||
),
|
||||
'laravel/tinker' =>
|
||||
array (
|
||||
'providers' =>
|
||||
|
|
|
|||
86
bootstrap/cache/services.php
vendored
86
bootstrap/cache/services.php
vendored
|
|
@ -38,28 +38,29 @@
|
|||
34 => 'Laravel\\Passport\\PassportServiceProvider',
|
||||
35 => 'Laravel\\Roster\\RosterServiceProvider',
|
||||
36 => 'Laravel\\Sail\\SailServiceProvider',
|
||||
37 => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||
38 => 'Laravel\\Ui\\UiServiceProvider',
|
||||
39 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
|
||||
40 => 'Carbon\\Laravel\\ServiceProvider',
|
||||
41 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||
42 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
43 => 'Pest\\Laravel\\PestServiceProvider',
|
||||
44 => 'Spatie\\Html\\HtmlServiceProvider',
|
||||
45 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
|
||||
46 => 'Yajra\\DataTables\\DataTablesServiceProvider',
|
||||
47 => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||
48 => 'Acme\\Dhl\\DhlServiceProvider',
|
||||
49 => 'App\\Providers\\AppServiceProvider',
|
||||
50 => 'App\\Providers\\AuthServiceProvider',
|
||||
51 => 'App\\Providers\\EventServiceProvider',
|
||||
52 => 'App\\Providers\\HorizonServiceProvider',
|
||||
53 => 'App\\Providers\\RouteServiceProvider',
|
||||
54 => 'Jenssegers\\Date\\DateServiceProvider',
|
||||
55 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
|
||||
56 => 'Yajra\\DataTables\\DataTablesServiceProvider',
|
||||
57 => 'App\\Providers\\YardServiceProvider',
|
||||
58 => 'Alban\\LaravelCollectiveSpatieHtmlParser\\ServiceProvider',
|
||||
37 => 'Laravel\\Sentinel\\SentinelServiceProvider',
|
||||
38 => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||
39 => 'Laravel\\Ui\\UiServiceProvider',
|
||||
40 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
|
||||
41 => 'Carbon\\Laravel\\ServiceProvider',
|
||||
42 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||
43 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
44 => 'Pest\\Laravel\\PestServiceProvider',
|
||||
45 => 'Spatie\\Html\\HtmlServiceProvider',
|
||||
46 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
|
||||
47 => 'Yajra\\DataTables\\DataTablesServiceProvider',
|
||||
48 => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||
49 => 'Acme\\Dhl\\DhlServiceProvider',
|
||||
50 => 'App\\Providers\\AppServiceProvider',
|
||||
51 => 'App\\Providers\\AuthServiceProvider',
|
||||
52 => 'App\\Providers\\EventServiceProvider',
|
||||
53 => 'App\\Providers\\HorizonServiceProvider',
|
||||
54 => 'App\\Providers\\RouteServiceProvider',
|
||||
55 => 'Jenssegers\\Date\\DateServiceProvider',
|
||||
56 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
|
||||
57 => 'Yajra\\DataTables\\DataTablesServiceProvider',
|
||||
58 => 'App\\Providers\\YardServiceProvider',
|
||||
59 => 'Alban\\LaravelCollectiveSpatieHtmlParser\\ServiceProvider',
|
||||
),
|
||||
'eager' =>
|
||||
array (
|
||||
|
|
@ -85,26 +86,27 @@
|
|||
19 => 'Laravel\\Mcp\\Server\\McpServiceProvider',
|
||||
20 => 'Laravel\\Passport\\PassportServiceProvider',
|
||||
21 => 'Laravel\\Roster\\RosterServiceProvider',
|
||||
22 => 'Laravel\\Ui\\UiServiceProvider',
|
||||
23 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
|
||||
24 => 'Carbon\\Laravel\\ServiceProvider',
|
||||
25 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||
26 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
27 => 'Pest\\Laravel\\PestServiceProvider',
|
||||
28 => 'Spatie\\Html\\HtmlServiceProvider',
|
||||
29 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
|
||||
30 => 'Yajra\\DataTables\\DataTablesServiceProvider',
|
||||
31 => 'Acme\\Dhl\\DhlServiceProvider',
|
||||
32 => 'App\\Providers\\AppServiceProvider',
|
||||
33 => 'App\\Providers\\AuthServiceProvider',
|
||||
34 => 'App\\Providers\\EventServiceProvider',
|
||||
35 => 'App\\Providers\\HorizonServiceProvider',
|
||||
36 => 'App\\Providers\\RouteServiceProvider',
|
||||
37 => 'Jenssegers\\Date\\DateServiceProvider',
|
||||
38 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
|
||||
39 => 'Yajra\\DataTables\\DataTablesServiceProvider',
|
||||
40 => 'App\\Providers\\YardServiceProvider',
|
||||
41 => 'Alban\\LaravelCollectiveSpatieHtmlParser\\ServiceProvider',
|
||||
22 => 'Laravel\\Sentinel\\SentinelServiceProvider',
|
||||
23 => 'Laravel\\Ui\\UiServiceProvider',
|
||||
24 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
|
||||
25 => 'Carbon\\Laravel\\ServiceProvider',
|
||||
26 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||
27 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
28 => 'Pest\\Laravel\\PestServiceProvider',
|
||||
29 => 'Spatie\\Html\\HtmlServiceProvider',
|
||||
30 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
|
||||
31 => 'Yajra\\DataTables\\DataTablesServiceProvider',
|
||||
32 => 'Acme\\Dhl\\DhlServiceProvider',
|
||||
33 => 'App\\Providers\\AppServiceProvider',
|
||||
34 => 'App\\Providers\\AuthServiceProvider',
|
||||
35 => 'App\\Providers\\EventServiceProvider',
|
||||
36 => 'App\\Providers\\HorizonServiceProvider',
|
||||
37 => 'App\\Providers\\RouteServiceProvider',
|
||||
38 => 'Jenssegers\\Date\\DateServiceProvider',
|
||||
39 => 'Maatwebsite\\Excel\\ExcelServiceProvider',
|
||||
40 => 'Yajra\\DataTables\\DataTablesServiceProvider',
|
||||
41 => 'App\\Providers\\YardServiceProvider',
|
||||
42 => 'Alban\\LaravelCollectiveSpatieHtmlParser\\ServiceProvider',
|
||||
),
|
||||
'deferred' =>
|
||||
array (
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"setasign/fpdf": "^1.8.6",
|
||||
"setasign/fpdi": "^2.6",
|
||||
"spatie/laravel-html": "^3.7",
|
||||
"stripe/stripe-php": "^20.3",
|
||||
"wearepixel/laravel-google-shopping-feed": "^4.0",
|
||||
"yajra/laravel-datatables-oracle": "^11.0"
|
||||
},
|
||||
|
|
|
|||
1573
composer.lock
generated
1573
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -58,6 +58,14 @@ return [
|
|||
'level' => env('LOG_LEVEL', 'warning'),
|
||||
'days' => 14,
|
||||
],
|
||||
// 'info' statt 'warning': Während der Stripe-Einführung sollen auch
|
||||
// verarbeitete Webhooks nachvollziehbar sein (Rollout-Beobachtung).
|
||||
'stripe' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/stripe.log'),
|
||||
'level' => env('LOG_LEVEL', 'info'),
|
||||
'days' => 14,
|
||||
],
|
||||
'cleanup' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/cleanup.log'),
|
||||
|
|
|
|||
22
config/stripe.php
Normal file
22
config/stripe.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
* Stripe-Zugangsdaten. Bewusst KEIN Hard-Fail beim Boot (anders als
|
||||
* config/payone.php): Stripe ist erst aktiv, wenn der Anbieter-Schalter
|
||||
* umgelegt wird. Der StripeClientWrapper schlägt beim ersten Zugriff hart
|
||||
* fehl, wenn 'secret' fehlt — so bleibt ein Deployment ohne Stripe-Keys
|
||||
* verhaltensneutral (Dark Launch, Plan Phase S0/S8).
|
||||
*/
|
||||
'secret' => env('STRIPE_SECRET'),
|
||||
|
||||
// Publishable Key — beim Hosted-Checkout-Redirect serverseitig nicht nötig,
|
||||
// aber für Dashboards/Debug-Views und spätere eingebettete Flows hinterlegt.
|
||||
'key' => env('STRIPE_KEY'),
|
||||
|
||||
// Signing-Secret des Webhook-Endpoints (whsec_…), Pflicht für Live-Webhooks.
|
||||
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||
|
||||
// 'test' | 'live' — muss zum verwendeten Key-Paar passen (analog payone mode).
|
||||
'mode' => env('STRIPE_MODE', 'test'),
|
||||
];
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Stripe-Einführung Phase S1 (dev/2026-07-08-stripe/):
|
||||
* Jede Zahlung / jedes Abo trägt seinen Zahlungsanbieter fest, damit der
|
||||
* Einstellungs-Schalter nur NEUE Vorgänge betrifft. Default 'payone'
|
||||
* backfillt zugleich alle Bestandszeilen. PAYONE-Spalten bleiben unverändert.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_abos', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('user_abos', 'payment_provider')) {
|
||||
$table->string('payment_provider', 20)->default('payone')->index()->after('payone_userid');
|
||||
}
|
||||
if (! Schema::hasColumn('user_abos', 'stripe_customer_id')) {
|
||||
$table->string('stripe_customer_id')->nullable()->after('carddata');
|
||||
}
|
||||
if (! Schema::hasColumn('user_abos', 'stripe_payment_method_id')) {
|
||||
$table->string('stripe_payment_method_id')->nullable()->after('stripe_customer_id');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('shopping_payments', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('shopping_payments', 'payment_provider')) {
|
||||
$table->string('payment_provider', 20)->default('payone')->index()->after('clearingtype');
|
||||
}
|
||||
if (! Schema::hasColumn('shopping_payments', 'external_reference')) {
|
||||
$table->string('external_reference')->nullable()->after('reference');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('payment_transactions', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('payment_transactions', 'provider')) {
|
||||
$table->string('provider', 20)->default('payone')->after('shopping_payment_id');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('users', 'stripe_customer_id')) {
|
||||
$table->string('stripe_customer_id')->nullable();
|
||||
}
|
||||
});
|
||||
|
||||
if (Schema::hasTable('customers')) {
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('customers', 'stripe_customer_id')) {
|
||||
$table->string('stripe_customer_id')->nullable();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_abos', function (Blueprint $table) {
|
||||
foreach (['payment_provider', 'stripe_customer_id', 'stripe_payment_method_id'] as $column) {
|
||||
if (Schema::hasColumn('user_abos', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('shopping_payments', function (Blueprint $table) {
|
||||
foreach (['payment_provider', 'external_reference'] as $column) {
|
||||
if (Schema::hasColumn('shopping_payments', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('payment_transactions', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('payment_transactions', 'provider')) {
|
||||
$table->dropColumn('provider');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('users', 'stripe_customer_id')) {
|
||||
$table->dropColumn('stripe_customer_id');
|
||||
}
|
||||
});
|
||||
|
||||
if (Schema::hasTable('customers')) {
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('customers', 'stripe_customer_id')) {
|
||||
$table->dropColumn('stripe_customer_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Stripe-Einführung Phase S2 (dev/2026-07-08-stripe/):
|
||||
* Idempotenz-Store + Audit-Log für Stripe-Webhook-Events. Die eindeutige
|
||||
* event_id verhindert Doppelverarbeitung bei Stripe-Retries (Muster:
|
||||
* Duplikat-Erkennung des PAYONE-Webhooks).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('stripe_webhook_events')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('stripe_webhook_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('event_id')->unique();
|
||||
$table->string('type')->index();
|
||||
$table->string('api_version')->nullable();
|
||||
$table->json('payload')->nullable();
|
||||
$table->timestamp('processed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('stripe_webhook_events');
|
||||
}
|
||||
};
|
||||
133
dev/2026-07-08-stripe/ANALYSE-IST-ZUSTAND.md
Normal file
133
dev/2026-07-08-stripe/ANALYSE-IST-ZUSTAND.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Ist-Analyse: Zahlungsarchitektur vor der Stripe-Einführung
|
||||
|
||||
> **Stand:** 2026-07-08 · Grundlage für `ENTWICKLUNGSPLAN-STRIPE.md`
|
||||
> Vorarbeiten: `dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md` (Phase R abgeschlossen),
|
||||
> `dev/2026-06-25-kreditkarte-update/` (CC-Update-Feature)
|
||||
|
||||
## 1. Zahlungsfluss heute (PAYONE)
|
||||
|
||||
```
|
||||
Checkout (Web/CheckoutController) ──┐
|
||||
Abo-Cron (app/Cron/UserMakeOrder) ──┤
|
||||
Membership (PaymentHelper) ──┤
|
||||
▼
|
||||
Pay\PayoneController (@deprecated Fassade)
|
||||
│
|
||||
▼
|
||||
app/Services/Payment/Payone/PayonePaymentService
|
||||
├── PayoneRequestBuilder + PaymentMethodResolver
|
||||
├── PayoneTransactionRecorder (einzige Stelle für PaymentTransaction)
|
||||
└── App\Services\Payone (HTTP-Client → api.pay1.de)
|
||||
│
|
||||
▼
|
||||
Api\PayoneController → PayoneWebhookHandler (IPN/TSOK)
|
||||
│
|
||||
▼
|
||||
Payment::paymentStatusPaidAction()
|
||||
(Bestellung bezahlt, Abo anlegen, Mails, Incentive)
|
||||
```
|
||||
|
||||
**Wichtig:** Durch Phase R (2026-06-10) ist die PAYONE-Logik bereits in einer testbaren
|
||||
Service-Schicht unter `app/Services/Payment/Payone/` gekapselt. Das ist die ideale
|
||||
Ausgangslage, um Stripe **parallel** daneben zu stellen, statt PAYONE anzufassen.
|
||||
|
||||
## 2. Kernerkenntnis: Das System ist seine eigene Billing-Engine
|
||||
|
||||
Die wiederkehrenden Abo-Abbuchungen werden **nicht vom Zahlungsanbieter** getriggert,
|
||||
sondern vom eigenen Cron:
|
||||
|
||||
- `user:make_abo_order` (04:00 Uhr) → `app/Cron/UserMakeOrder.php`
|
||||
- Der Cron berechnet den Betrag pro Ausführung selbst (Produkte, Länderpreise, Steuern)
|
||||
und ruft dann `PayonePaymentService::chargeAbo()` mit gespeichertem Token
|
||||
(`payone_userid` + `pseudocardpan`) auf.
|
||||
- Rechnungen, Lieferscheine, Provisionsdaten entstehen im System (dompdf, DATEV-Export).
|
||||
|
||||
**Konsequenz für Stripe:** Wir brauchen von Stripe nur „belaste gespeichertes
|
||||
Zahlungsmittel mit Betrag X" (PaymentIntents, off-session) – **nicht** Stripe Billing /
|
||||
Stripe Subscriptions / Stripe Invoicing. Details und Begründung im Konzept
|
||||
(`STRIPE-KONZEPT.md`, Abschnitt 2).
|
||||
|
||||
## 3. Datenmodell (Zahlung & Abo)
|
||||
|
||||
| Tabelle / Model | Relevante Felder | Rolle |
|
||||
|---|---|---|
|
||||
| `user_abos` / `UserAbo` | `payone_userid`, `clearingtype` (`cc`/`wlt`), `wallettype` (`PPE`), `carddata` (JSON: `pseudocardpan`, `cardexpiredate`), `card_update_pending_at` | Zahlungs-Token für wiederkehrende Abbuchung |
|
||||
| `shopping_payments` / `ShoppingPayment` | `clearingtype`, `carddata`, `reference`, `is_abo`, `mode` (`test`/`live`) | Jede Zahlungstransaktion |
|
||||
| `payment_transactions` / `PaymentTransaction` | `txid`, `userid`, `txaction`, `transmitted_data` | Anbieter-Callback-Protokoll (heute PAYONE) |
|
||||
| `user_abo_orders` / `UserAboOrder` | `paid`, `status` | Ausführungs-Historie pro Abo |
|
||||
| `payment_methods` / `PaymentMethod` | `short` (`CC`,`PP`,`SEPA`,`SB`,`VOR`,`FNC`), `active`, `is_abo` | Steuert, welche Zahlarten im Checkout angeboten werden |
|
||||
| `user_abo_card_changes`, `abo_card_update_links` | – | CC-Update-Feature (2026-06-25) |
|
||||
| `order_withdrawals` / `OrderWithdrawal` | – | Widerruf/Erstattung (neu, Juli 2026) |
|
||||
|
||||
**Bekannte Lücke (bereits im Alt-Plan notiert):** Es gibt **kein `payment_provider`-Feld** –
|
||||
der Anbieter wird implizit über `clearingtype`/`wallettype` abgeleitet. Für Stripe zwingend
|
||||
zu ergänzen (Stakeholder-Entscheid O1 im Alt-Plan: `payone_userid` bleibt bestehen,
|
||||
Provider-Felder kommen **zusätzlich**).
|
||||
|
||||
## 4. Zahlarten-Auswahl im Checkout
|
||||
|
||||
- `CheckoutRepository::getPaymentsMethods()` ([CheckoutRepository.php:237](../../app/Repositories/CheckoutRepository.php))
|
||||
liest aktive Methoden aus `payment_methods` (Flags `active`, `is_abo`), zusätzlich
|
||||
User-spezifische Freischaltungen (`$user->payment_methods`).
|
||||
- `isPaymentsMethodsActive()` mappt PAYONE-Codes → Kürzel:
|
||||
`wlt#PPE`→PP, `cc`→CC, `sb#PNT`→SB, `elv`→SEPA, `vor`→VOR, `fnc#MIV`→FNC.
|
||||
- `CheckoutController::processPaymentMethod()` (Z. ~262 ff.) verzweigt je Methode
|
||||
(CC → creditcardcheck/pseudocardpan, ELV → Bankdaten-Check) und ruft
|
||||
`setPrePayment()`.
|
||||
|
||||
**Konsequenz:** Der Checkout kennt bereits ein Schalt-Modell pro Zahlart. Der
|
||||
Stripe/PAYONE-Schalter dockt hier an (Provider-Zuordnung je Zahlart bzw. global).
|
||||
|
||||
## 5. Netto/Brutto & Steuern je Land
|
||||
|
||||
- Preise pro Land in `country_prices` (`c_price`, `c_tax`, `c_currency`) –
|
||||
Models `Country`, `CountryPrice`, `ShippingCountry`.
|
||||
- Der Warenkorb (`packages/digital-bird/shoppingcart`) arbeitet mit `taxRate` und
|
||||
`taxIncluded` pro Position: Bruttopreise inkl. länderspezifischer MwSt.
|
||||
- Netto-Rückrechnung wo nötig, z. B. `AboOrderCart` (Z. ~351):
|
||||
`lineNet = lineGross / ((100 + taxRate) / 100)`.
|
||||
- Rechnungen (dompdf) und DATEV-Export (`DatevExportService`) nutzen diese Werte.
|
||||
|
||||
**Konsequenz:** Die Steuerlogik ist **anbieterunabhängig** und bleibt vollständig im
|
||||
System. Stripe erhält nur den finalen **Bruttobetrag in Cent** + Währung. Stripe Tax wird
|
||||
**nicht** benötigt (siehe Konzept, Abschnitt 5).
|
||||
|
||||
## 6. Wiederverwendbare Bausteine
|
||||
|
||||
| Baustein | Datei | Nutzen für Stripe |
|
||||
|---|---|---|
|
||||
| Service-Schicht-Muster | `app/Services/Payment/Payone/*` | 1:1-Vorlage für `app/Services/Payment/Stripe/*` |
|
||||
| Webhook-Muster (Idempotenz, Duplikate, txaction-Priorität) | `PayoneWebhookHandler` | Vorlage für `StripeWebhookHandler` |
|
||||
| Transaction-Recorder mit PCI-Redaction | `PayoneTransactionRecorder` | Vorlage für `StripeTransactionRecorder` |
|
||||
| Paid-Pfad (Bestellung → Abo → Mails) | `App\Services\Payment::paymentStatusPaidAction()` | Provider-neutral wiederverwenden |
|
||||
| Abo-Recovery-Checkout (Status 3 → Nachzahlung) | `AboRecoveryCheckoutService` | Für fehlgeschlagene Stripe-Abbuchungen (inkl. 3DS-Fall) |
|
||||
| CC-Update-Flow (Inhaber-only, Link, kein Charge) | `AboCardUpdateController`, `AboCardUpdateLink`, `AboPaymentMethodService` | Muster für „Zahlungsmittel bei Stripe hinterlegen/erneuern" via SetupIntent |
|
||||
| Widerruf/Erstattung | `OrderWithdrawalController`, `OrderWithdrawal` | Andockpunkt für Stripe-Refunds |
|
||||
| Payment-Dashboard & Incidents | `Admin/PaymentDashboardController`, `PaymentIncident` (kennt bereits mehrere Provider) | Stripe als Provider ergänzen |
|
||||
| Test-/Live-Trennung | `shopping_payments.mode`, `shopping_orders.mode` | Stripe Test-/Live-Keys analog |
|
||||
| Secrets-Muster | `config/payone.php` (Hard-Fail bei fehlendem Key) | Gleiches Muster für Stripe-Keys |
|
||||
|
||||
## 7. Randbedingungen & Stolperfallen
|
||||
|
||||
1. **PAYONE-Tokens sind nicht portabel.** `pseudocardpan`/`payone_userid` können nicht zu
|
||||
Stripe migriert werden. Bestehende Abos laufen daher auf PAYONE weiter, bis der Kunde
|
||||
sein Zahlungsmittel neu hinterlegt (Wiederverwendung des Card-Update-Link-Flows).
|
||||
Eine PCI-konforme Kartendaten-Migration zwischen Prozessoren ist theoretisch über die
|
||||
Anbieter möglich, aber aufwendig → als Option in Phase S8 dokumentiert.
|
||||
2. **PayPal-direkt-Projekt ist blockiert** (Merchant-Freigabe fehlt, siehe Alt-Plan).
|
||||
Stripe unterstützt PayPal als Zahlungsart in der EU – das könnte den blockierten
|
||||
Strang ersetzen (offene Frage im Plan).
|
||||
3. **`Pay\PayoneController` ist @deprecated Fassade** – Checkout-HTTP-Flow
|
||||
(`setPrePayment`/`ResponseData`) steckt noch dort. Die Stripe-Einführung erzwingt an
|
||||
dieser Stelle die im Alt-Plan aufgeschobene Extraktion (Provider-Verzweigung im
|
||||
Checkout).
|
||||
4. **Multi-Tenant-Routing:** Neue Web-Routen (Checkout-Return, Webhook) müssen in die
|
||||
richtigen `routes/domains/*.php` bzw. `routes/api.php`; Webhooks laufen global unter
|
||||
`api.<domain>`.
|
||||
5. **Tests:** Pest auf SQLite in-memory; `route()` ist in Tests nicht domain-aware
|
||||
(siehe Memory/Projektkonvention) – Stripe-HTTP-Aufrufe im Test immer über
|
||||
injizierbaren Client mocken (gleiches Muster wie `?Client $client` bei PAYONE).
|
||||
6. **Sofortüberweisung (`sb#PNT`):** Stripe hat „Sofort" abgekündigt; Ersatz wäre Klarna
|
||||
oder Wegfall → offene Frage an Stakeholder.
|
||||
7. **Composer-Abhängigkeit:** `stripe/stripe-php` ist noch nicht installiert; Ergänzung
|
||||
braucht Freigabe (Projektregel: keine Dependency-Änderungen ohne Zustimmung).
|
||||
639
dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md
Normal file
639
dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
# Entwicklungsplan: Stripe als Zahlungsanbieter (PAYONE bleibt als Option)
|
||||
|
||||
> **Status:** In Arbeit (S0 ✓, S1 ✓, S2 ✓, S3 ✓ inkl. Browser-E2E — **Notfall-Go-Live wegen PAYONE-Ausfall, Abos eingefroren, s. §7.1**; als Nächstes S4) · **Erstellt:** 2026-07-08 · **Zuletzt aktualisiert:** 2026-07-08
|
||||
> **Test-Server:** Schalter `payment-active-provider` steht auf **stripe** (seit 2026-07-08, für den Manualtest); zurückstellen: `Setting::setContentBySlug('payment-active-provider', 'payone', 'text')`.
|
||||
> **Begleitdokumente:** `ANALYSE-IST-ZUSTAND.md` (Codebase-Analyse) · `STRIPE-KONZEPT.md` (Architektur & API-Mapping)
|
||||
> **Vorarbeiten:** `dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md` (Phase R ✅), `dev/2026-06-25-kreditkarte-update/`
|
||||
|
||||
## Dokumentations-Hinweis (verbindlich)
|
||||
|
||||
Dieser Plan wird **phasenweise** abgearbeitet. Für **jede Phase** gilt:
|
||||
|
||||
- Der **Umsetzungsstatus** wird nach Abschluss auf `ERLEDIGT` gesetzt und sauber
|
||||
dokumentiert (Datum, betroffene Dateien, kurze Begründung).
|
||||
- Abweichungen, Detail-Entscheidungen und Verworfenes werden dokumentiert.
|
||||
- Jede Änderung wird **programmatisch getestet** (Pest), bevor die Phase als erledigt gilt.
|
||||
- Nach Code-Änderungen läuft `./vendor/bin/pint`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziel
|
||||
|
||||
Stripe wird als **primäre Zahlungsabwicklung** eingeführt und ersetzt PAYONE im
|
||||
Tagesgeschäft. Dabei gilt:
|
||||
|
||||
| # | Anforderung |
|
||||
|---|-------------|
|
||||
| A | Stripe übernimmt **nur die Zahlungsabwicklung**. Rechnungen, Lieferscheine, Provisionen, DATEV etc. bleiben im System. |
|
||||
| B | **PAYONE wird nicht entfernt.** Es bleibt vollständig funktionsfähig, sauber vom Stripe-Code separiert. |
|
||||
| C | **Schalter in den Einstellungen** wählt den aktiven Anbieter für **neue** Zahlungsvorgänge (`payone` ↔ `stripe`). Bestandszahlungen/-abos behalten ihren Anbieter. |
|
||||
| D | Scope-Reihenfolge: **1. Einmalzahlungen**, **2. Abos** (Erstzahlung + wiederkehrende Abbuchung). |
|
||||
| E | **Netto/Brutto je Land** wird weiterhin systemseitig berechnet; Stripe erhält nur den Bruttobetrag in Cent. |
|
||||
|
||||
**Kern-Architekturentscheidung** (Begründung in `STRIPE-KONZEPT.md` §2):
|
||||
Stripe wird als reiner **Charge-/Token-Provider** genutzt — **PaymentIntents +
|
||||
gespeicherte PaymentMethods**, *kein* Stripe Billing / Invoicing / Tax. Der Abo-Zyklus
|
||||
(Cron `user:make_abo_order`, Betragsberechnung, Rechnungen) bleibt unverändert im System.
|
||||
|
||||
## 2. Zielarchitektur (Kurzfassung)
|
||||
|
||||
```
|
||||
Checkout / Abo-Cron / Recovery / Widerruf
|
||||
│
|
||||
▼
|
||||
PaymentProviderManager ← Einstellungs-Schalter (neu) + payment_provider-Spalte (Bestand)
|
||||
│
|
||||
├── PayoneGateway → Adapter um bestehenden PayonePaymentService (unverändert)
|
||||
└── StripeGateway → app/Services/Payment/Stripe/* (neu)
|
||||
├── StripeClientWrapper (injizierbar, testbar)
|
||||
├── StripePaymentService (PaymentIntents, SetupIntents, Refunds)
|
||||
├── StripeWebhookHandler (+ DTOs)
|
||||
└── StripeTransactionRecorder
|
||||
```
|
||||
|
||||
Neue DB-Felder: `payment_provider` auf `user_abos`/`shopping_payments`/`payment_transactions`,
|
||||
`stripe_customer_id`/`stripe_payment_method_id` auf `user_abos`, `stripe_customer_id` am
|
||||
User/Customer, `external_reference` auf `shopping_payments`. Details: Konzept §4.3.
|
||||
|
||||
## 3. Phasen-Übersicht
|
||||
|
||||
| Phase | Titel | Aufwand (Schätzung) | Priorität | Abhängigkeiten |
|
||||
|-------|-------|---------------------|-----------|----------------|
|
||||
| **S0** | Grundlagen: Account, SDK, Config, Entscheidungen | 1–2 PT | Blocker | – (O-Fragen beantwortet 2026-07-08) |
|
||||
| **S1** | Provider-Abstraktion, Datenmodell & Einstellungs-Schalter | 4–6 PT | P0 | S0 |
|
||||
| **S2** | Stripe-Basis: Client, Recorder, Webhook-Endpoint | 4–6 PT | P0 | S1 |
|
||||
| **S3** | Einmalzahlung im Checkout über Stripe | 6–10 PT | **P1** | S2 |
|
||||
| **S4** | Abos: Erstzahlung + wiederkehrende Abbuchung | 8–12 PT | **P1** | S3 |
|
||||
| **S5** | Zahlungsmittel-Update über Stripe (SetupIntent) | 3–5 PT | P2 | S4 |
|
||||
| **S6** | Fehlerfälle, 3DS-Recovery & Incident-Anbindung | 4–6 PT | P1–P2 | S4 |
|
||||
| **S7** | Admin & Betrieb: Schalter-UI, Refunds, Monitoring | 4–6 PT | P2 | S3 (Refunds), S1 (UI) |
|
||||
| **S8** | Rollout & optionale Abo-Migration PAYONE → Stripe | 3–5 PT | P3 | S4–S7 |
|
||||
|
||||
*PT = Personentage (1 Entwickler). Einmalzahlung produktiv nach S3+S7-Teilen; Abos nach S4+S6.*
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase S0 – Grundlagen · Status: **WEITGEHEND ERLEDIGT** (2026-07-08)
|
||||
|
||||
### Ziel
|
||||
Technische und organisatorische Voraussetzungen schaffen; keine Feature-Logik.
|
||||
|
||||
### Aufgaben
|
||||
|
||||
| # | Aufgabe | Status |
|
||||
|---|---------|--------|
|
||||
| S0.1 | Offene Fragen O1–O9 klären (Konzept §10); N2/N3 ebenfalls beantwortet. | ✅ 2026-07-08 · **N1 offen** (Klarna/Amazon Pay off_session gegen aktuelle Stripe-Doku prüfen, spätestens zu S4-Beginn) |
|
||||
| S0.2 | Stripe-Account (vorhanden, Stakeholder verwaltet): Test-Keys beschaffen, Webhook-Endpoint (Test) konfigurieren, gewünschte Zahlarten im Dashboard aktivieren (Karte, PayPal, Apple/Google Pay, Amazon Pay, Klarna, …). | ✅ 2026-07-08 — Test-Keys in `.env`, per Balance-API verifiziert (`livemode: false`). Webhook auf dem Test-Server via **Stripe CLI** (`stripe listen --forward-to … --skip-verify`, CLI-Signing-Secret als `STRIPE_WEBHOOK_SECRET`); Dashboard-Endpoint + eigenes `whsec` erst für Produktion nötig (Anleitung §6). Zahlarten-Aktivierung im Dashboard: vor S3-Manualtest prüfen. |
|
||||
| S0.3 | Composer: `stripe/stripe-php` installieren (Freigabe O6). | ✅ 2026-07-08 — `stripe/stripe-php` **^20.3** in `composer.json`/`composer.lock` |
|
||||
| S0.4 | `config/stripe.php` anlegen (`secret`, `key`, `webhook_secret`, `mode`); `.env.example`, `.env`, `phpunit.xml` ergänzt. | ✅ 2026-07-08 — **Abweichung s. u.** |
|
||||
| S0.5 | `PaymentProvider`-Enum anlegen. | ✅ 2026-07-08 — `app/Enums/PaymentProvider.php` (`Payone`/`Stripe`, string-backed) |
|
||||
|
||||
### Abweichungen / Entscheidungen
|
||||
|
||||
- **Kein Boot-Hard-Fail in `config/stripe.php`** (Plan sah das Muster aus
|
||||
`config/payone.php` vor): PAYONE ist produktiv im Einsatz — dort ist ein fehlender Key
|
||||
ein Fehler. Stripe dagegen wird per **Dark Launch** deployt (Rollout-Schritt 2, §12);
|
||||
ein Boot-Hard-Fail würde jedes Deployment ohne gesetzte Stripe-Keys sofort brechen.
|
||||
Stattdessen: Config liest `env()` tolerant, der **`StripeClientWrapper` (Phase S2)
|
||||
schlägt beim ersten Zugriff hart fehl**, wenn `stripe.secret` fehlt. Begründung als
|
||||
Kommentar in `config/stripe.php` dokumentiert.
|
||||
- Enum liegt unter `app/Enums/` (neu, via `make:enum`) — im Plan §15 so vorgesehen.
|
||||
|
||||
### Tests & Verifikation (2026-07-08)
|
||||
|
||||
- `tests/Feature/StripeFoundationTest.php` *(neu)*: Config-Werte aus Env, kein
|
||||
Boot-Fail bei fehlenden Secrets, Enum-Werte/`from`/`tryFrom`, SDK-Klasse vorhanden —
|
||||
**4 Tests, 11 Assertions, grün**.
|
||||
- PAYONE-Regressionssuite (PayoneTest, PaymentMethodResolverTest,
|
||||
PayonePaymentServiceTest, PayoneWebhookHandlerTest, PayoneTransactionRecorderTest,
|
||||
PayoneTransportExceptionHandlingTest): **45 passed, 1 skipped (optionaler
|
||||
Netzwerktest), 209 Assertions** — unverändert grün.
|
||||
- `./vendor/bin/pint --dirty` grün.
|
||||
|
||||
### Akzeptanzkriterien
|
||||
- [x] SDK installiert, `composer.lock` aktualisiert, Test-Suite weiterhin grün.
|
||||
- [x] Keys nur via `.env`; kein Secret im Repo. (Hard-Fail bewusst in S2 verschoben, s. Abweichung.)
|
||||
- [x] Alle O-Fragen beantwortet oder explizit vertagt (N1 mit Vermerk offen).
|
||||
- [ ] Test-Keys + Webhook-Secret in `.env` eingetragen (S0.2, Stakeholder).
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase S1 – Provider-Abstraktion, Datenmodell & Schalter · Status: **ERLEDIGT** (2026-07-08)
|
||||
|
||||
### Ziel
|
||||
Das System kennt zwei Anbieter. Jede Zahlung/jedes Abo trägt seinen Anbieter fest;
|
||||
ein Einstellungs-Schalter bestimmt den Anbieter für **neue** Vorgänge. PAYONE-Verhalten
|
||||
ändert sich **nicht** (Default `payone`, Schalter steht auf `payone`).
|
||||
|
||||
### Aufgaben
|
||||
|
||||
| # | Aufgabe | Status |
|
||||
|---|---------|--------|
|
||||
| S1.1 | Migrationen: `payment_provider` auf `user_abos`, `shopping_payments`, `payment_transactions` (Default `'payone'`); `stripe_customer_id` + `stripe_payment_method_id` auf `user_abos`; `external_reference` auf `shopping_payments`; `stripe_customer_id` an `users`/`customers`. | ✅ `2026_07_08_140000_add_payment_provider_columns.php` (idempotent mit `hasColumn`-Guards, Haus-Stil) |
|
||||
| S1.2 | `PaymentGatewayInterface` + DTOs definieren. | ✅ `Contracts/PaymentGatewayInterface.php`, `DTOs/AboChargeResult.php` — **Abweichung s. u.** |
|
||||
| S1.3 | `PayoneGateway` als Adapter um `PayonePaymentService` — **kein Umbau am PAYONE-Code**. | ✅ `app/Services/Payment/PayoneGateway.php`; `PayonePaymentService` unverändert |
|
||||
| S1.4 | `PaymentProviderManager`: `providerForNewCheckout()`, `providerFor()`, `gateway()`. | ✅ `app/Services/Payment/PaymentProviderManager.php` |
|
||||
| S1.5 | Einstellungs-Schalter als persistente Admin-Einstellung. | ✅ Bestehender Settings-Mechanismus (`Setting::get/setContentBySlug`), Slug **`payment-active-provider`** (Typ `text`); fehlender/ungültiger Wert → Fallback `payone`. Admin-UI + `SysLog` folgen in **S7.1**. |
|
||||
| S1.6 | Abo-Cron/Retry lesen den Provider **des Abos** (nicht den Schalter). | ✅ Guard in `UserMakeOrder::makePayment()`: Nicht-PAYONE-Provider → Log + `RuntimeException` **vor** jedem PAYONE-Aufruf. Deckt auch `AboRetryPaymentService` ab (delegiert an `UserMakeOrder`). Echte Stripe-Verzweigung kommt in S4.4. |
|
||||
|
||||
### Abweichungen / Entscheidungen
|
||||
|
||||
- **Interface wächst phasenweise:** Deklariert sind nur `provider()` und `chargeAbo()`
|
||||
(einziger realer Aufrufer heute). `initiateCheckoutPayment()` (S3),
|
||||
`setupPaymentMethodUpdate()` (S5) und `refund()` (S7) werden erst mit ihrem Aufrufer
|
||||
ergänzt — kein toter Vertrag, kein Zwang zu Dummy-Implementierungen im Adapter.
|
||||
Im Interface-Docblock dokumentiert.
|
||||
- **`payment_provider` bleibt String-Spalte ohne Enum-Cast** am Model — der
|
||||
`PaymentProviderManager` normalisiert via `PaymentProvider::tryFrom()` mit
|
||||
PAYONE-Fallback. So können keine bestehenden Blade-/Array-Zugriffe brechen.
|
||||
- **Schalter-Ablage:** bestehender `settings`-Mechanismus statt eigener Tabelle
|
||||
(gleiches Muster wie `abo-lock-days-change`, `abo-card-update-link-ttl-hours`).
|
||||
- `PaymentProviderManager::gateway(Stripe)` wirft bewusst `RuntimeException`, bis das
|
||||
Stripe-Gateway existiert (S2/S4) — Fail-fast statt stillem Fallback auf PAYONE.
|
||||
- Models: `payment_provider`/`stripe_*`/`external_reference`/`provider` in die
|
||||
`$fillable` von `UserAbo`, `ShoppingPayment`, `PaymentTransaction` aufgenommen.
|
||||
|
||||
### Tests & Verifikation (2026-07-08)
|
||||
|
||||
- `tests/Feature/PaymentProviderManagerTest.php` *(neu)*: **12 Tests, 25 Assertions** —
|
||||
Schalter-Default/`stripe`/ungültiger Wert, Backfill-Default `payone`, Spalte schlägt
|
||||
Schalter, Gateway-Auflösung (PayoneGateway / Stripe-Exception),
|
||||
`AboChargeResult`-Mapping (APPROVED/ERROR), Gateway-Delegation (gemockter
|
||||
`PayonePaymentService`), Cron-Guard blockt Stripe-Abo.
|
||||
- Regression: PAYONE-Suite + `StripeFoundationTest` (**49 passed, 1 skipped, 220
|
||||
Assertions**) und Abo-Retry/Recovery-Suite (`AboPendingInitialPaymentTest`,
|
||||
`AboRecoveryAdvanceTest`, `AboUserRetryTest`, `AdminAboRetryPaymentTest`,
|
||||
`RetryFailedPaypalAbosTest`: **21 passed, 56 Assertions**) — unverändert grün.
|
||||
- `./vendor/bin/pint --dirty` grün (nur Format-Fixes in `ShoppingPayment`).
|
||||
|
||||
### Akzeptanzkriterien
|
||||
- [x] Schalter existiert, Default `payone`, Systemverhalten identisch zu vorher.
|
||||
- [x] Kein PAYONE-Service wurde inhaltlich geändert (nur Adapter obendrauf; einzige Bestandsänderung: defensiver Provider-Guard im Cron + `$fillable`-Erweiterungen).
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase S2 – Stripe-Basisdienste · Status: **ERLEDIGT** (2026-07-08)
|
||||
|
||||
### Ziel
|
||||
Stripe technisch angebunden: Client, Transaktions-Protokollierung, Webhook-Empfang —
|
||||
noch ohne Checkout-UI.
|
||||
|
||||
### Aufgaben
|
||||
|
||||
| # | Aufgabe | Status |
|
||||
|---|---------|--------|
|
||||
| S2.1 | `StripeClientWrapper` — injizierbare Hülle um `\Stripe\StripeClient`; Singleton im `AppServiceProvider`. | ✅ Lazy-Init; **Hard-Fail bei Nutzung ohne `STRIPE_SECRET`** (der aus S0 verschobene Fail-fast); `isConfigured()` für Dark-Launch-Checks |
|
||||
| S2.2 | `StripeTransactionRecorder` — einzige Stelle für `PaymentTransaction` mit `provider='stripe'`; Redaction. | ✅ `recordFromStripeObject()`; `redactPayload()` entfernt `client_secret` **rekursiv** (Stripe-Payloads enthalten kein PAN — sensibel ist das Intent-Secret) |
|
||||
| S2.3 | `StripeCustomerService` — Customer idempotent anlegen, ID persistieren. | ✅ `customerIdFor(User\|Customer)`; vorhandene ID wird nie überschrieben; Metadata `mivita_type`/`mivita_id` |
|
||||
| S2.4 | Webhook: Route, schlanker Controller, Handler mit Signaturprüfung + Idempotenz. | ✅ `POST /stripe/webhook` (`routes/api.php`, läuft unter `api.<domain>`); `Api\StripeWebhookController` (Muster R5); `StripeWebhookHandler` + `StripeWebhookResult`-DTO |
|
||||
| S2.5 | Logging-Kanal `stripe.log`. | ✅ `config/logging.php`, daily, Level `info` (bewusst gesprächiger als `payone` — Rollout-Beobachtung) |
|
||||
|
||||
### Abweichungen / Entscheidungen
|
||||
|
||||
- **Idempotenz über eigene Tabelle `stripe_webhook_events`** (`event_id` unique, Payload
|
||||
redaktiert als Audit, `processed_at`): Migration
|
||||
`2026_07_08_150000_create_stripe_webhook_events_table.php` + Model
|
||||
`StripeWebhookEvent`. Race-sicher: Exists-Check + Unique-Constraint-Catch
|
||||
(paralleler Stripe-Retry → `duplicate`).
|
||||
- **HTTP-Statuscodes statt PAYONE-„immer TSOK":** ungültige Signatur → `400` (kein
|
||||
Retry sinnvoll); fehlendes `STRIPE_WEBHOOK_SECRET` → `500` (Stripe stellt später
|
||||
erneut zu); Duplikat/unbehandelter Typ/verarbeitet → `200`. Im DTO dokumentiert.
|
||||
- **Zahlungs-Zuordnung** im Handler: `metadata.shopping_payment_id` (wird ab S3 gesetzt)
|
||||
oder `external_reference` (PaymentIntent-/Session-ID); ohne Treffer wird das Event
|
||||
trotzdem gespeichert und bestätigt (Business-Verdrahtung folgt in S3/S4 an
|
||||
`dispatchEvent()`).
|
||||
- Behandelte Event-Typen als `HANDLED_TYPES`-Konstante gemäß Konzept §7 (inkl.
|
||||
`checkout.session.completed`/`expired`); unbekannte Typen werden protokolliert + 200.
|
||||
|
||||
### Tests & Verifikation (2026-07-08)
|
||||
|
||||
- `tests/Feature/StripeWebhookHandlerTest.php` *(neu)*: **11 Tests, 35 Assertions** —
|
||||
ungültige Signatur (400, nichts gespeichert), fehlendes Secret (500), Duplikat
|
||||
(200, eine Zeile), unbehandelter Typ (gespeichert + 200), Transaktion via Metadata
|
||||
(provider `stripe`, `client_secret` redaktiert), Auflösung via `external_reference`
|
||||
+ `processed_at`, rekursive Redaction, Wrapper-Hard-Fail ohne Secret, Lazy-Client,
|
||||
CustomerService (vorhandene ID ohne API-Call; Create persistiert ID — Stripe-Client
|
||||
als Partial-Mock auf `request`, kein echter HTTP-Call).
|
||||
- Handler wird wie beim PAYONE-Pendant **direkt** getestet (HTTP-Routen sind in Pest
|
||||
nicht domain-aware); Route via `php artisan route:list` verifiziert:
|
||||
`POST api.mivita.test/stripe/webhook → api.stripe_webhook`.
|
||||
- Regression: `PaymentProviderManagerTest` + `StripeFoundationTest` +
|
||||
`PayoneWebhookHandlerTest` + `PayoneTransactionRecorderTest`: **35 passed,
|
||||
131 Assertions**. `pint --dirty` grün.
|
||||
|
||||
### Akzeptanzkriterien
|
||||
- [x] `PaymentTransaction`-Schreibzugriffe für Stripe existieren **nur** im Recorder.
|
||||
- [x] Webhook idempotent, signaturgeprüft, protokolliert (per Test nachgewiesen).
|
||||
- [x] Ende-zu-Ende gegen das **laufende System** verifiziert *(2026-07-08)*: signierter
|
||||
`payment_intent.succeeded`-Request an `api.mivita.test/stripe/webhook` (echte
|
||||
Signaturprüfung mit dem `whsec` aus `.env`) → `200 processed`, Event-Zeile mit
|
||||
`processed_at`; erneuter identischer Request → `200 duplicate` (weiterhin 1 Zeile);
|
||||
manipulierte Signatur → `400 invalid_signature`. Migrationen auf der
|
||||
Dev-MySQL ausgeführt (`add_payment_provider_columns`, `stripe_webhook_events`).
|
||||
Test-Keys per Balance-API bestätigt (`livemode: false`).
|
||||
- [x] **Echte Stripe-Events über `stripe listen` verifiziert** *(2026-07-08)*: Testmodus-
|
||||
PaymentIntent (API, `pm_card_visa`) → Listener → Endpoint. `payment_intent.succeeded`
|
||||
signaturgeprüft verarbeitet (`processed_at` gesetzt); Begleit-Events
|
||||
(`payment_intent.created`, `charge.succeeded`, `charge.updated`) korrekt als
|
||||
unbehandelte Typen gespeichert + bestätigt. Stolpersteine dabei (für die Nachwelt):
|
||||
1. Listener lief zunächst mit dem API-Key eines **anderen** Stripe-Accounts,
|
||||
2. `api.mivita.test` fehlte in der `/etc/hosts` des Hosts,
|
||||
3. `whsec` muss vom **mivita-Listener** stammen (CLI-Secret ist account-gebunden).
|
||||
|
||||
### Webhook-Einrichtung (S0.2)
|
||||
|
||||
**Test-Server (aktuell im Einsatz):** Stripe CLI auf dem Host — kein Dashboard-Endpoint nötig:
|
||||
|
||||
```bash
|
||||
stripe listen --forward-to https://api.mivita.test/stripe/webhook --skip-verify
|
||||
```
|
||||
|
||||
Das von `stripe listen` ausgegebene CLI-Signing-Secret (`whsec_…`) als
|
||||
`STRIPE_WEBHOOK_SECRET` in die `.env`. `--skip-verify`
|
||||
wegen des selbstsignierten `.test`-Zertifikats. Events auslösen:
|
||||
`stripe trigger payment_intent.succeeded` (bzw. echte Testmodus-Checkouts ab S3 —
|
||||
`stripe listen` leitet standardmäßig alle Events weiter, unser Handler bestätigt
|
||||
unbehandelte Typen mit `200 ignored`).
|
||||
|
||||
> ⚠️ **Mehrere Apps / Accounts auf demselben Test-Server:** Das CLI-Signing-Secret
|
||||
> gehört zum **Stripe-Account des Listeners**, nicht zur Forward-URL. Ein für eine
|
||||
> andere App (anderer Account, z. B. pressekonto) laufender Listener empfängt keine
|
||||
> mivita-Events, und sein `whsec` passt nicht zu unserer Signaturprüfung. Für mivita
|
||||
> daher einen **eigenen Listener** parallel starten — einfachster Weg ohne Re-Login:
|
||||
>
|
||||
> ```bash
|
||||
> stripe listen --api-key sk_test_…(mivita) \
|
||||
> --forward-to https://api.mivita.test/stripe/webhook --skip-verify
|
||||
> ```
|
||||
>
|
||||
> Das dabei ausgegebene `whsec_…` in die mivita-`.env`, dann `php artisan config:clear`.
|
||||
> Testen: `stripe trigger payment_intent.succeeded --api-key sk_test_…(mivita)`.
|
||||
> (Stand 2026-07-08: das zuerst eingetragene `whsec` stammte vom pressekonto-Listener
|
||||
> und muss durch das mivita-Listener-Secret ersetzt werden.)
|
||||
|
||||
**Produktion (später, vor Rollout-Schritt 2):**
|
||||
|
||||
1. Dashboard → Developers → Webhooks → **Add endpoint**.
|
||||
2. **Endpoint-URL:** `https://api.mivita.care/stripe/webhook`.
|
||||
3. **Events auswählen:** `checkout.session.completed`, `checkout.session.expired`,
|
||||
`payment_intent.succeeded`, `payment_intent.processing`,
|
||||
`payment_intent.payment_failed`, `charge.succeeded` *(Zahlart-Anzeige)*,
|
||||
`charge.refunded`, `charge.dispute.created`, `setup_intent.succeeded`.
|
||||
4. Das **Endpoint-Signing-Secret** (`whsec_…`, ein anderes als das CLI-Secret!) als
|
||||
`STRIPE_WEBHOOK_SECRET` in die Produktions-`.env`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase S3 – Einmalzahlung im Checkout ⭐ · Status: **UMGESETZT** (2026-07-08, Browser-E2E offen)
|
||||
|
||||
### Ziel
|
||||
Steht der Schalter auf `stripe`, laufen **neue Einmalzahlungen** (Shop, Zahlungslinks)
|
||||
über die **Stripe-Hosted-Checkout-Seite** (Redirect-Flow, Entscheidung 2026-07-08,
|
||||
Konzept §3.1): Zahlartenwahl und Dateneingabe finden komplett bei Stripe statt.
|
||||
PAYONE-Checkout bleibt bei Schalter `payone` unverändert.
|
||||
|
||||
### Ablauf (Soll)
|
||||
|
||||
```
|
||||
mivita-Checkout (Adresse, Warenkorb, AGB — unverändert)
|
||||
→ „Jetzt zahlen" → CheckoutController
|
||||
→ PaymentProviderManager::providerForNewCheckout()
|
||||
├── payone → bestehender Pfad inkl. Zahlartenwahl (unverändert)
|
||||
└── stripe → StripeGateway::initiateCheckoutPayment()
|
||||
├── ShoppingPayment anlegen (provider=stripe, mode, Betrag brutto)
|
||||
├── Checkout Session erstellen (mode=payment, amount=Cent, EUR,
|
||||
│ customer, metadata: order/payment/mode, success_url, cancel_url;
|
||||
│ Zahlarten dynamisch via Stripe-Dashboard)
|
||||
└── 303 Redirect → checkout.stripe.com
|
||||
Kunde wählt bei Stripe die Zahlart (Karte/PayPal/Apple Pay/Google Pay/Amazon Pay/Klarna/…),
|
||||
zahlt (inkl. 3DS) und wird zurückgeleitet:
|
||||
├── success_url → Bestätigungsseite (Status nur zur UX-Anzeige)
|
||||
└── cancel_url → zurück in den mivita-Checkout (Warenkorb bleibt erhalten)
|
||||
Webhook checkout.session.completed / payment_intent.succeeded
|
||||
→ paymentStatusPaidAction-Pfad (Order bezahlt, Mails, Incentive) — einzige Wahrheit
|
||||
```
|
||||
|
||||
### Aufgaben
|
||||
|
||||
| # | Aufgabe |
|
||||
|---|---------|
|
||||
| S3.1 | Checkout-Verzweigung nach Provider in `CheckoutController`; bei `stripe` **entfällt die Zahlartenwahl** — nach Checkout-Prüfung direkt zu Stripe (N2, entschieden 2026-07-08). Ausnahme: ist der Vorkasse-Schalter (S3.8) aktiv bzw. FNC freigeschaltet, wird eine reduzierte Wahl „Online bezahlen / Vorkasse (/ Rechnung)" angezeigt. Dabei die im Alt-Plan aufgeschobene Extraktion des Checkout-HTTP-Flows aus `Pay\PayoneController` **nur soweit nötig** vorziehen (keine PAYONE-Verhaltensänderung). |
|
||||
| S3.2 | `StripePaymentService::initiateCheckoutPayment()`: Checkout Session (`mode=payment`) erstellen. Betrag: bestehende Cart-Brutto-Summe, Rundung auf Cent an **einer** Stelle; Währung fest EUR; `line_items` aus dem Warenkorb (Anzeige auf der Stripe-Seite), Metadata mit Order-/Payment-Referenz; Session-ID in `shopping_payments.external_reference`. |
|
||||
| S3.3 | Redirect-Handling: 303 auf die Session-URL; `success_url`/`cancel_url` domain-korrekt generieren (Multi-Tenant beachten, `routes/domains/…`). `cancel_url` führt zurück in den Checkout, Warenkorb/ShoppingInstance bleiben nutzbar. |
|
||||
| S3.4 | Success-/Cancel-Controller: liest Session-/PaymentIntent-Status nur für UX (`paid`/`processing`/abgebrochen), verändert **keine** Bezahlt-Flags. Abgelaufene Sessions (`checkout.session.expired`) räumen den offenen Vorgang auf. |
|
||||
| S3.5 | Webhook-Paid-Pfad: `checkout.session.completed` + `payment_intent.succeeded` → gemeinsame, provider-neutrale Paid-Verarbeitung (Wiederverwendung `Payment::paymentStatusPaidAction()`-Logik; ggf. Extraktion in provider-neutralen Service). Preis-/Betragsabgleich wie beim PAYONE-Webhook (Guard 2005-Äquivalent). |
|
||||
| S3.6 | `processing`-Status (SEPA & Co.) im Bestell-/Zahlungsstatus sichtbar machen (Muster „Zahlung in Verarbeitung" aus Alt-Plan §5.2.1). |
|
||||
| S3.7 | Zahlungslinks (`OrderPaymentService`, `ShoppingInstance`) provider-fähig machen: neuer Link erzeugt bei Schalter `stripe` direkt eine Checkout Session (der Link **ist** dann faktisch die Stripe-Seite mit unserem Vor-/Nachlauf). |
|
||||
| S3.8 | **Vorkasse-Sichtbarkeits-Schalter** (N3, entschieden 2026-07-08): eigene Einstellung, um Vorkasse (`vor`, läuft über PAYONE) unabhängig vom Provider-Schalter aus-/einzublenden — Vorkasse bleibt technisch erhalten. Interne Rechnung (FNC) bleibt wie heute über `payment_methods.active` steuerbar (aktuell praktisch überall deaktiviert). |
|
||||
|
||||
### Tests
|
||||
- Feature: Checkout-Verzweigung je Schalterstellung; Session-Parameter (Betrag = Cart-Brutto in Cent, EUR, Metadata, success/cancel-URLs, Abo-Einschränkung greift hier nicht) gegen gemockten Client.
|
||||
- Webhook-Paid: Order wird bezahlt, Mails/Incentive-Pfad läuft, Duplikat-Event ändert nichts, Betrags-Mismatch wird abgewiesen + Incident; `session.expired` räumt auf.
|
||||
- Cancel-Pfad: Rücksprung lässt Warenkorb intakt, kein Zahlungs-/Order-Nebeneffekt.
|
||||
- Netto/Brutto: Testfälle mit mind. 2 Ländern/Steuersätzen (Cart-Brutto ↔ Rechnungs-Netto unverändert zu PAYONE-Pfad).
|
||||
- Regression: kompletter PAYONE-Checkout-Testbestand grün bei Schalter `payone`.
|
||||
|
||||
### Umsetzungsstatus & Abweichungen (2026-07-08)
|
||||
|
||||
| # | Status |
|
||||
|---|--------|
|
||||
| S3.1 | ✅ `CheckoutController`: `stripeCheckoutActive()` (Schalter, **Abos/Recovery bleiben bis S4 auf PAYONE**); View zeigt bei Stripe eine einzige „Online-Zahlung"-Option + VOR/FNC (sofern aktiv); `checkoutFinal` validiert `payment_method=stripe` gegen den Schalter (sonst 404). Keine PAYONE-Extraktion nötig — Verzweigung liegt **vor** dem PAYONE-Pfad. |
|
||||
| S3.2 | ✅ `StripePaymentService::initiateCheckoutPayment()`. **Abweichung:** ein einzelnes `line_item` mit dem Gesamt-Bruttobetrag statt Warenkorb-Positionen — eliminiert jedes Rundungs-/Summenabweichungsrisiko zwischen Cart und Stripe; Positionsdetails stehen auf der Systemrechnung. Metadata (`shopping_order_id`, `shopping_payment_id`, `reference`, `mode`) auf Session **und** PaymentIntent. |
|
||||
| S3.3 | ✅ Redirect via `redirect()->away($session->url)`; Session-ID → `external_reference`; Initiations-Transaktion `checkout.session.created` protokolliert (Bestätigungsseite hat dadurch immer eine `pay_trans`). |
|
||||
| S3.4 | ✅ **Abweichung:** kein neuer Return-Controller — `success_url`/`cancel_url` nutzen die bestehenden `checkout.transaction_status`-Routen (Status-UX, Cart-Destroy, Fehlerseiten inkl. Cancel-Rücksprung wie bei PAYONE-Redirects). `checkout.session.expired` räumt serverseitig auf (Status `expired`, Instanz `link_canceled`). |
|
||||
| S3.5 | ✅ `StripeWebhookHandler::dispatchEvent()` → gleiche txaction-Taxonomie wie PAYONE (`appointed`/`pending`/`failed`/`paid` + Prioritäten): `session.completed(paid)` & `payment_intent.succeeded` → Paid-Pfad (`Payment::paymentStatusPaidAction`, DB-Lock, Betrags-Guard analog 2006, Mail einmalig); `session.completed(unpaid)` → `appointed` + Bestellbestätigung; Reprocessing: gespeichertes, aber unverarbeitetes Event (Crash → Stripe-Retry) wird erneut verarbeitet statt als Duplikat verworfen. |
|
||||
| S3.6 | ✅ `payment_intent.processing` → txaction `pending` — vorhandene Statusanzeigen greifen durch die gemeinsame Taxonomie automatisch. **Keine Mail** (Bestellbestätigung ging bei `appointed` raus). |
|
||||
| S3.7 | ✅ implizit: Zahlungslinks führen in denselben Checkout → Verzweigung greift automatisch. Beim Browser-Manualtest mit verifizieren. |
|
||||
| S3.8 | ✅ Vorkasse-Schalter: Setting **`payment-vorkasse-hidden`** (bool; fehlend/false = sichtbar) → `PaymentProviderManager::isVorkasseVisible()`, Filter in `CheckoutRepository::getPaymentsMethods()` (wirkt provider-unabhängig, auch im PAYONE-Betrieb). Admin-UI folgt in S7.1. |
|
||||
|
||||
**Ergänzung Zahlart-Anzeige (2026-07-08):** `charge.succeeded` wird jetzt verarbeitet —
|
||||
die `payment_method_details` (Typ, Kartenmarke, letzte 4, Apple/Google-Pay-Wallet, Ablauf)
|
||||
werden in `shopping_payments.carddata` gespeichert; `ShoppingPayment::getPaymentType()`
|
||||
zeigt daraus z. B. „Apple Pay (Amex •••• 2003)", „Kreditkarte (Visa •••• 4242)", „PayPal",
|
||||
„Klarna". Reihenfolge-sicher ohne API-Call: Kommt das Charge-Event vor dem
|
||||
PaymentIntent-Event, zieht `markPaid()` die Details aus dem Event-Store nach
|
||||
(`backfillPaymentMethodDetails`). Die PaymentIntent-ID wird dabei als
|
||||
`external_reference` an der Zahlung gemerkt. Checkout-UI-Feinschliff nach
|
||||
Stakeholder-Feedback: Radio heißt „Online-Zahlung", Stripe-Logo
|
||||
(`payments-assets/stripe.png`), darunter Zahlarten-Zeile + Hinweistext.
|
||||
|
||||
**Weitere Entscheidung:** `payment_intent.payment_failed` verschickt bewusst **keine** Mail —
|
||||
auf der Hosted Session kann der Kunde direkt erneut zahlen; jeder Fehlversuch feuert das
|
||||
Event (Mail pro Versuch wäre Spam). `ShoppingPayment::getPaymentType()` zeigt für
|
||||
Stripe-Zahlungen „Online-Zahlung (Stripe)"; neue Lang-Keys `payment.stripe_*` (de/en, du-Form).
|
||||
|
||||
**Tests (2026-07-08):** `tests/Feature/StripeCheckoutTest.php` *(neu)*: **9 Tests** —
|
||||
Session-Parameter/Payment/Initiations-Transaktion (gemockter Client), Paid-Pfad + genau
|
||||
eine Mail, kein Doppel-Mail bei zweitem Success-Event, Betrags-Mismatch verweigert,
|
||||
`appointed` + Bestellbestätigung (async), `failed` ohne Mail, `expired`, Crash-Retry-
|
||||
Reprocessing, Vorkasse-Schalter. Gesamt Stripe-Suite **36 Tests, 102 Assertions** grün;
|
||||
Payment-Regression (PAYONE + Abo-Retry/Recovery, 66 Tests) grün; Blade kompiliert; Pint grün.
|
||||
*(Hinweis: die volle Suite hat eine vorbestehende `makeUser()`-Redeclare-Kollision,
|
||||
unabhängig von S3.)*
|
||||
|
||||
### Akzeptanzkriterien
|
||||
- [ ] **Browser-E2E (Manualtest, offen):** Checkout → Redirect zu Stripe → Zahlung (inkl. 3DS-Testkarte) → Rücksprung → Webhook → Order bezahlt → Rechnung aus dem System.
|
||||
- [ ] Apple Pay/Google Pay erscheinen auf fähigen Geräten ohne Zusatzcode; Zahlarten lassen sich im Stripe-Dashboard zu-/abschalten, ohne Deploy.
|
||||
- [ ] Abbruch bei Stripe führt sauber in den Checkout zurück (Manualtest).
|
||||
- [x] Umschalten des Schalters wechselt ausschließlich neue Checkouts (per Test nachgewiesen).
|
||||
|
||||
---
|
||||
|
||||
## 7.1 Notfall-Go-Live (2026-07-08): PAYONE-Ausfall — Einmalzahlungen live, Abos eingefroren
|
||||
|
||||
**Lage:** PAYONE ist ausgefallen. Entscheidung Stakeholder: Der S3-Stand
|
||||
(Einmalzahlungen über Stripe) geht **sofort produktiv**; Phase S4 (Abos) wird
|
||||
nachgelagert. Bis dahin werden **alle Abo-Zahlungen eingefroren** und mit einem
|
||||
Hinweis versehen („derzeit nicht möglich, wir arbeiten an einer technischen Lösung").
|
||||
|
||||
### Not-Schalter `abo-payments-frozen` (Setting, bool)
|
||||
|
||||
| Eingefroren | Verhalten |
|
||||
|---|---|
|
||||
| Abo-Checkout (Neuabschluss) & Recovery-Checkout | Zahlarten-Box + Kaufen-Button ersetzt durch Warnhinweis (`payment.abo_payments_frozen`); Server-Guard in `checkoutFinal` blockiert Submits zusätzlich. |
|
||||
| Abbuchungs-Cron `user:make_abo_order` | Kompletter Lauf wird übersprungen (Log-Warnung). Abos werden **nicht** als fehlgeschlagen markiert, `next_date` bleibt stehen → nach dem Auftauen holt der Cron alles regulär nach. |
|
||||
| Abo-Retry (Admin/CRM/Portal, `AboRetryPaymentService`) | Liefert Fehler mit dem Hinweistext. |
|
||||
| **Kreditkarte ändern** (Kunden-Link, Portal, CRM) | Zentraler Guard in `AboPaymentMethodService::updateCreditCard()` → `CardUpdateResult::error` mit Hinweistext; das gemeinsame Formular-Partial (`portal/abo/_payment_method_form`) zeigt statt der Kartenfelder die Warnbox; **Versand neuer Card-Update-Links** (`User\AboController::sendCardUpdateLink`) ist geblockt (Flash-Hinweis), damit keine Kunden-Mails auf ein eingefrorenes Formular führen. |
|
||||
| Abschluss neuer Abos | Bereits über den Abo-Checkout-Guard abgedeckt (Hinweis + Server-Block, s. o.). |
|
||||
|
||||
Nicht eingefroren: Einmalzahlungen (Stripe), Vorkasse-/FNC-Sichtbarkeit (eigene
|
||||
Schalter), CC-Update-Flow (schlägt bei PAYONE-Ausfall mit sauberer Fehlermeldung fehl).
|
||||
Der Freeze wird nach S4-Abschluss aufgehoben (`Setting → false`); danach zahlen
|
||||
Bestands-PAYONE-Abos wieder über PAYONE (sofern wiederhergestellt) bzw. neue über Stripe.
|
||||
|
||||
### Checkout-Wartungsmodus mit Admin-Bypass (`checkout-maintenance`, bool)
|
||||
|
||||
Für den kontrollierten Live-Start: Ist der Schalter an, sehen **alle Besucher** im
|
||||
Checkout nur „Technische Schwierigkeiten — wir sind in Kürze wieder für dich da"
|
||||
(`payment.checkout_maintenance`); **Admins** (`User::isAdmin()`, Session gilt
|
||||
domänenübergreifend) können normal bestellen. So lässt sich Stripe im Live-Modus mit
|
||||
einer echten Bestellung verproben, bevor der Checkout für alle öffnet. Guards in
|
||||
`CheckoutController::checkout()` (Meldungsseite) und `checkoutFinal()` (Redirect).
|
||||
|
||||
### Admin-Settings-Karte „Zahlungen (Stripe / PAYONE)" (S7.1 vorgezogen)
|
||||
|
||||
Unter `https://my.mivita.care/admin/settings` (Karte nach „Kauf Einstellungen"):
|
||||
- **Aktiver Zahlungsanbieter** (Select PAYONE/Stripe → `payment-active-provider`)
|
||||
- **Checkout-Wartungsmodus** (Checkbox → `checkout-maintenance`)
|
||||
- **Abo-Zahlungen einfrieren** (Checkbox → `abo-payments-frozen`)
|
||||
- **Vorkasse ausblenden** (Checkbox → `payment-vorkasse-hidden`)
|
||||
|
||||
Alle Schalter sind damit ohne Tinker im Admin schaltbar (bestehender
|
||||
`SettingController`-Mechanismus). `SysLog`-Protokollierung folgt mit dem Rest von S7.1.
|
||||
|
||||
**Tests:** Retry-Freeze, Cron-Skip (Abo bleibt Status 2, keine `user_abo_orders`),
|
||||
CC-Update-Freeze (`updateCreditCard` → Error mit Hinweistext), Manager-Defaults aus —
|
||||
in `StripeCheckoutTest.php`; Karten-Update-Regressionssuite (17 Tests) unverändert grün.
|
||||
|
||||
### Go-Live-Checkliste Produktion
|
||||
|
||||
1. [ ] Deployment des aktuellen Stands (Branch `payment/phase-r-payone-refactoring`).
|
||||
2. [ ] `php artisan migrate` (3 neue Migrationen: Provider-Spalten, `stripe_webhook_events`).
|
||||
3. [ ] Prod-`.env`: `STRIPE_KEY`/`STRIPE_SECRET` = **Live-Keys** (`pk_live_…`/`sk_live_…`), `STRIPE_MODE=live`.
|
||||
4. [ ] Stripe-Dashboard (**Live-Modus**): Zahlarten aktivieren; Webhook-Endpoint
|
||||
`https://api.mivita.care/stripe/webhook` mit der Event-Liste aus §6 anlegen;
|
||||
dessen Signing-Secret als `STRIPE_WEBHOOK_SECRET` in die Prod-`.env`.
|
||||
5. [ ] Caches: `php artisan config:clear && php artisan view:clear` (bzw. Deploy-üblich `config:cache`).
|
||||
6. [ ] Schalter setzen — im Admin unter **Einstellungen → „Zahlungen (Stripe / PAYONE)"**:
|
||||
☑ Checkout-Wartungsmodus (nur Admins bestellen), ☑ Abo-Zahlungen einfrieren,
|
||||
☑ Vorkasse ausblenden (**wichtig:** Vorkasse autorisiert über PAYONE und würde
|
||||
beim Ausfall crashen), Anbieter = **Stripe**.
|
||||
7. [ ] Smoke-Test **als Admin bei aktivem Wartungsmodus**: echte Bestellung (Live-Karte)
|
||||
→ Stripe-Redirect → bezahlt → Rechnung; ggf. im Stripe-Dashboard erstatten.
|
||||
Gegenprobe im Inkognito-Fenster: Besucher sehen die Wartungsmeldung.
|
||||
8. [ ] Checkout öffnen: **Wartungsmodus abhaken** — Einmalzahlungen laufen für alle über Stripe.
|
||||
9. [ ] Kontrolle: `storage/logs/stripe.log`, `stripe_webhook_events`, Payment-Dashboard.
|
||||
10. [ ] Nach S4: `abo-payments-frozen` zurücksetzen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase S4 – Abos über Stripe
|
||||
|
||||
### Ziel
|
||||
Neue Abos: Erstzahlung im Checkout mit Speicherung des Zahlungsmittels; wiederkehrende
|
||||
Abbuchungen über den bestehenden Cron, off-session. Bestehende PAYONE-Abos laufen
|
||||
unverändert über PAYONE.
|
||||
|
||||
### Aufgaben
|
||||
|
||||
| # | Aufgabe |
|
||||
|---|---------|
|
||||
| S4.1 | Abo-Erstzahlung: Checkout Session (`mode=payment`) mit `setup_future_usage=off_session` + `customer`; Zahlarten auf **wiederverwendbare** Methoden eingeschränkt (Konzept §3.2; Klarna/Amazon Pay nur nach positiver N1-Prüfung); nach `succeeded` PaymentMethod-ID aus dem PaymentIntent ermitteln. |
|
||||
| S4.2 | `AboHelper::createNewAbo()`: bei Stripe-Erstzahlung `payment_provider='stripe'`, `stripe_customer_id`, `stripe_payment_method_id` (statt `payone_userid`/`carddata`) übernehmen; maskierte Anzeige-Daten (Brand, letzte 4, Ablauf) als Anzeige-Metadaten speichern. |
|
||||
| S4.3 | `StripePaymentService::chargeAbo()`: off-session PaymentIntent (`customer`, `payment_method`, `off_session=true`, `confirm=true`), Idempotency-Key = `user_abo_order`-Referenz (Schutz gegen Doppel-Abbuchung bei Cron-Retry). |
|
||||
| S4.4 | `UserMakeOrder::makePayment()`: Provider-Dispatch über `PaymentProviderManager::providerFor($abo)` — PAYONE-Zweig byte-identisch. |
|
||||
| S4.5 | Fehler-Mapping: `card_declined`, `authentication_required`, `insufficient_funds` → bestehende Abo-Fehlerstatus (Status 3 / Retry) wie bei PAYONE-Declines; Ergebnis-DTO vereinheitlicht (`AboChargeResult`). |
|
||||
| S4.6 | Abo-Ansichten (CRM `/user/abos`, Portal, Admin `AboController`): Provider + maskiertes Zahlungsmittel anzeigen; PAYONE-Felder bleiben für PAYONE-Abos maßgeblich. |
|
||||
| S4.7 | Abo-Retry (`AboRetryPaymentService`) provider-fähig (nutzt `chargeAbo` des Abo-Providers). |
|
||||
|
||||
### Tests
|
||||
- Erstzahlung → `UserAbo` mit Stripe-Feldern; PAYONE-Felder leer.
|
||||
- Cron-Abbuchung Stripe-Abo: Erfolg (Order paid, `user_abo_orders` fortgeschrieben), Decline (Status 3), Idempotenz (doppelter Cron-Lauf bucht nicht doppelt ab).
|
||||
- Gemischte Bestände: ein PAYONE-Abo + ein Stripe-Abo im selben Cron-Lauf → jeweils richtiger Gateway-Aufruf.
|
||||
|
||||
### Akzeptanzkriterien
|
||||
- [ ] Testmodus Ende-zu-Ende: Abo-Checkout → n Tage später Cron-Abbuchung off-session erfolgreich, Rechnung systemseitig.
|
||||
- [ ] PAYONE-Abos zu 100 % unberührt (Regressionssuite + Stichprobe Staging).
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase S5 – Zahlungsmittel-Update über Stripe
|
||||
|
||||
### Ziel
|
||||
Der Flow aus dem CC-Update-Projekt (2026-06-25) funktioniert für Stripe-Abos:
|
||||
Zahlungsmittel erneuern **ohne Abbuchung**, nur durch den Abo-Inhaber.
|
||||
|
||||
### Aufgaben
|
||||
|
||||
| # | Aufgabe |
|
||||
|---|---------|
|
||||
| S5.1 | `StripePaymentService::setupPaymentMethodUpdate()`: Checkout Session `mode=setup` (hosted, Redirect – konsistent zum Checkout-Flow); Einstieg über die bestehenden Card-Update-Views/Links (Verzweigung nach Abo-Provider). |
|
||||
| S5.2 | `setup_intent.succeeded`-Webhook bzw. Return-Bestätigung: neue `stripe_payment_method_id` am Abo speichern, alte PaymentMethod am Customer detachen; `user_abo_card_changes`-Protokoll weiternutzen. |
|
||||
| S5.3 | Konsistenz-Regeln aus dem CC-Update-Projekt übernehmen: `card_update_pending_at` vor externem Call setzen; API-OK → DB-Update; Fehlerfall → CRITICAL-Log + Incident (Alt-Plan §5.3.1). |
|
||||
| S5.4 | `AboPaymentMethodService` provider-fähig; `AboCardUpdateLink`-Flow (Berater→Kunde-Link) für Stripe-Abos freischalten. |
|
||||
|
||||
### Tests
|
||||
- SetupIntent-Erfolg aktualisiert nur das Ziel-Abo; keine Abbuchung entsteht.
|
||||
- Nicht-Inhaber wird abgewiesen (Policy unverändert).
|
||||
- Pending-Flag/Inkonsistenz-Pfad (API ok, DB-Fehler) erzeugt Incident.
|
||||
|
||||
---
|
||||
|
||||
## 10. Phase S6 – Fehlerfälle, 3DS-Recovery & Incidents
|
||||
|
||||
### Ziel
|
||||
Fehlgeschlagene Stripe-Abbuchungen münden in die bestehenden Recovery-Werkzeuge; das
|
||||
Payment-Dashboard kennt Stripe.
|
||||
|
||||
### Aufgaben
|
||||
|
||||
| # | Aufgabe |
|
||||
|---|---------|
|
||||
| S6.1 | `authentication_required` bei off-session: Abo auf Status 3 + Recovery-Checkout-Link (bestehender `AboRecoveryCheckoutService`); Recovery-Zahlung läuft on-session mit 3DS über den **Abo-Provider**; nach Erfolg `advanceAboAfterRecoveryPayment` wie gehabt. |
|
||||
| S6.2 | `payment_intent.payment_failed` / `charge.dispute.created` / `charge.refunded` → `PaymentIncident` (Provider `stripe`) + Payment-Dashboard-Kacheln. |
|
||||
| S6.3 | `payments:check-accounts` bzw. Uptime-Check um Stripe-Status ergänzen (API-Erreichbarkeit, offene `processing`-Zahlungen älter X Tage). |
|
||||
| S6.4 | Wartungs-Command `stripe:sync-payment-status {paymentIntentId|--pending}` (Gegenstück zu `payone:*`-Reparatur-Commands): Status aktiv nachziehen, wenn ein Webhook verloren ging. |
|
||||
|
||||
### Tests
|
||||
- Failed-Webhook setzt Abo-/Order-Status korrekt, erzeugt Incident, keine Doppel-Mails.
|
||||
- Recovery-Checkout mit Stripe-Abo Ende-zu-Ende (gemockt).
|
||||
|
||||
---
|
||||
|
||||
## 11. Phase S7 – Admin & Betrieb
|
||||
|
||||
### Aufgaben
|
||||
|
||||
| # | Aufgabe |
|
||||
|---|---------|
|
||||
| S7.1 | **Schalter-UI** im Admin (Einstellungen): aktueller Provider, Umschalten mit Bestätigungsdialog + Hinweistext („wirkt nur auf neue Zahlungen"), `SysLog`-Eintrag. Nur berechtigte Admin-Rolle. |
|
||||
| S7.2 | **Refunds:** `OrderWithdrawalController` (Widerruf) um automatische Erstattung erweitern — Provider der Ursprungszahlung entscheidet (Stripe: `Refund`-API; PAYONE: bestehender/manueller Weg). Teil-Erstattungen unterstützen. |
|
||||
| S7.3 | Admin-Detailansichten (`ShoppingOrder`, `UserAbo`, Zahlungen): Provider-Badge, Stripe-IDs verlinkt zum Stripe-Dashboard (Deep-Link `https://dashboard.stripe.com/...`). |
|
||||
| S7.4 | Payment-Dashboard: Umsatz/Fehlerraten je Provider; Übersicht „Abos je Provider" (Fortschritt einer späteren Migration ablesbar). |
|
||||
| S7.5 | Doku: Betriebs-Runbook (Webhook neu ausliefern, Key-Rotation, Testmodus) in diesem Ordner ablegen. |
|
||||
|
||||
### Tests
|
||||
- Schalter-UI: Berechtigung, Persistenz, SysLog.
|
||||
- Refund: Stripe-Refund gegen gemockten Client; `charge.refunded`-Webhook schließt den Kreis; PAYONE-Zahlung nimmt PAYONE-/manuellen Pfad.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phase S8 – Rollout & optionale Abo-Migration
|
||||
|
||||
### Rollout-Plan (empfohlen)
|
||||
|
||||
| Schritt | Inhalt |
|
||||
|---|---|
|
||||
| 1 | Staging/Testmodus: alle Flows mit Stripe-Testkarten + `stripe trigger`; Schalter-Wechselproben. |
|
||||
| 2 | Produktion, Schalter bleibt `payone`: Deployment ist verhaltensneutral (Dark Launch). |
|
||||
| 3 | Pilot: Schalter auf `stripe` in verkehrsarmer Zeit; Einmalzahlungen beobachten (Dashboard, `stripe.log`, Incidents). Rollback = Schalter zurück. |
|
||||
| 4 | Abos auf Stripe freigeben (neue Abo-Abschlüsse). |
|
||||
| 5 | Beobachtungsphase; PAYONE bleibt für Bestands-Abos und als Fallback aktiv. |
|
||||
|
||||
**Rollback-Garantie:** Der Schalter ist jederzeit rückstellbar; da jeder Vorgang seinen
|
||||
Provider trägt, entstehen keine Mischzustände. Offene Stripe-Zahlungen laufen über
|
||||
Webhooks normal aus.
|
||||
|
||||
### Optionale Abo-Migration PAYONE → Stripe (nur nach Entscheid O8)
|
||||
|
||||
- PAYONE-Tokens sind **nicht portabel** → Migration heißt: Kunde hinterlegt Zahlungsmittel
|
||||
neu (SetupIntent, kein Charge) über den Card-Update-Link-Flow; danach
|
||||
`payment_provider='stripe'` am Abo, PAYONE-Felder bleiben als Historie.
|
||||
- Kampagnen-Command `abo:invite-stripe-migration` (Batch-Mails mit Link, Erinnerung,
|
||||
Fortschritts-Report) — Muster: Reminder-Commands aus dem Einmalprodukte-Projekt.
|
||||
- Kein Zwang, kein Big-Bang: nicht migrierte Abos laufen unbefristet über PAYONE.
|
||||
|
||||
---
|
||||
|
||||
## 13. Teststrategie (übergreifend)
|
||||
|
||||
| Ebene | Inhalt |
|
||||
|---|---|
|
||||
| Unit | Zahlarten-Mapping, Betrag→Cent-Rundung, Provider-Resolver, DTO-Mapping, Fehlercode-Mapping |
|
||||
| Feature (Pest, SQLite) | Checkout-Verzweigung, Webhook-Handler (Signatur/Idempotenz/Paid-Pfad), Cron-Dispatch, Recovery, Refund — Stripe-Client **immer gemockt** |
|
||||
| Regression | Gesamte PAYONE-Suite muss in jeder Phase unverändert grün sein (Schalter `payone` = Nullmessung) |
|
||||
| Manuell (Testmodus) | 3DS-Testkarten, SEPA-`processing`, `stripe trigger`-Events, Schalterwechsel unter Last |
|
||||
|
||||
Hinweise: `route()` ist in Pest nicht domain-aware (bestehende Projektkonvention
|
||||
beachten); kein echter Netzwerkverkehr in der Suite; `./vendor/bin/pint` nach jeder Phase.
|
||||
|
||||
## 14. Risiken & Mitigation
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|---|---|
|
||||
| Regression im PAYONE-Bestand | PAYONE-Code nur per Adapter kapseln, nie umbauen; Schalter-Default `payone`; volle Regressionssuite pro Phase |
|
||||
| Doppel-Abbuchung durch Cron-Retry | Stripe Idempotency-Keys pro `user_abo_order`; Tests dafür in S4 |
|
||||
| Webhook-Verlust → Order hängt in „offen" | `stripe:sync-payment-status`-Command (S6.4); Stripe-Retry nutzt non-2xx |
|
||||
| „Bezahlt" fälschlich aus Browser-Return | Paid-Flag ausschließlich im Webhook-Pfad (wie PAYONE-TSOK) |
|
||||
| SEPA-Zahlungen tagelang `processing` | Eigener Anzeige-Status; Versand-/Abo-Logik erst bei `succeeded` (Klärung mit Stakeholder, ob Versand auf `processing` warten muss) |
|
||||
| Schalter wird versehentlich umgelegt | Bestätigungsdialog, Admin-Rolle, SysLog, Dashboard-Anzeige des aktiven Providers |
|
||||
| Stripe-Konto-Einschränkungen (Radar, Verifizierung) | Früh Live-Verifizierung anstoßen (S0/O7); Pilotphase klein halten |
|
||||
| Scope-Kriechen Richtung Stripe Billing | Architektur-Entscheidung (Konzept §2) ist verbindlich dokumentiert |
|
||||
|
||||
## 15. Betroffene Dateien (Vorschau)
|
||||
|
||||
### Neu
|
||||
```
|
||||
config/stripe.php
|
||||
app/Enums/PaymentProvider.php
|
||||
app/Services/Payment/PaymentProviderManager.php
|
||||
app/Services/Payment/Contracts/PaymentGatewayInterface.php (+ DTOs)
|
||||
app/Services/Payment/PayoneGateway.php ← Adapter, kein Umbau
|
||||
app/Services/Payment/Stripe/StripeClientWrapper.php
|
||||
app/Services/Payment/Stripe/StripeCustomerService.php
|
||||
app/Services/Payment/Stripe/StripePaymentService.php
|
||||
app/Services/Payment/Stripe/StripeWebhookHandler.php
|
||||
app/Services/Payment/Stripe/StripeTransactionRecorder.php
|
||||
app/Services/Payment/Stripe/DTOs/*.php
|
||||
app/Http/Controllers/Api/StripeWebhookController.php
|
||||
app/Console/Commands/StripeSyncPaymentStatus.php
|
||||
database/migrations/*_add_payment_provider_columns_*.php (+ Stripe-Felder)
|
||||
app/Http/Controllers/Web/StripeCheckoutReturnController.php (success/cancel)
|
||||
dev/2026-07-08-stripe/* (dieser Ordner: Plan, Analyse, Konzept, Runbook)
|
||||
```
|
||||
|
||||
### Anpassung (provider-bewusst, PAYONE-Pfad unverändert)
|
||||
```
|
||||
app/Http/Controllers/Web/CheckoutController.php
|
||||
app/Cron/UserMakeOrder.php
|
||||
app/Services/AboHelper.php
|
||||
app/Services/AboRetryPaymentService.php
|
||||
app/Services/AboRecoveryCheckoutService.php
|
||||
app/Services/AboPaymentMethodService.php
|
||||
app/Services/OrderPaymentService.php
|
||||
app/Http/Controllers/Admin/OrderWithdrawalController.php
|
||||
app/Http/Controllers/Admin/PaymentDashboardController.php
|
||||
routes/api.php, routes/domains/*.php
|
||||
Abo-/Order-Views (Provider-Badge, maskiertes Zahlungsmittel)
|
||||
```
|
||||
|
||||
## 16. Nächste Schritte
|
||||
|
||||
1. ~~O-Fragen klären~~ ✅ **beantwortet 2026-07-08** (Konzept §10): Hosted Checkout mit
|
||||
Redirect, alle Stripe-Zahlarten inkl. PayPal/Apple Pay/Google Pay/Amazon Pay/Klarna,
|
||||
nur EUR, SDK freigegeben, Account vorhanden, Bestands-Abo-Migration und
|
||||
Membership/ELV bewusst nach hinten gestellt (S8 bzw. Folgeprojekt).
|
||||
2. Phase **S0** starten: Test-Keys + Webhook im (vorhandenen) Stripe-Account einrichten,
|
||||
`stripe/stripe-php` installieren, `config/stripe.php`, **N1-Prüfung** (Klarna/Amazon
|
||||
Pay Abo-Fähigkeit).
|
||||
3. Danach S1 → S2 → S3; nach S3 erster produktiver Meilenstein (Einmalzahlung) möglich.
|
||||
268
dev/2026-07-08-stripe/STRIPE-KONZEPT.md
Normal file
268
dev/2026-07-08-stripe/STRIPE-KONZEPT.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# Stripe-Konzept: Architektur & API-Mapping
|
||||
|
||||
> **Stand:** 2026-07-08 · Ergänzt `ENTWICKLUNGSPLAN-STRIPE.md` (Phasen) und
|
||||
> `ANALYSE-IST-ZUSTAND.md` (Codebase). Hier stehen die **Architektur-Entscheidungen**
|
||||
> und das Mapping der Stripe-API auf unsere Domäne.
|
||||
|
||||
## 1. Leitplanken (vom Stakeholder gesetzt)
|
||||
|
||||
1. **Stripe ersetzt PAYONE als Zahlungsabwicklung** – Rechnungen, Lieferscheine,
|
||||
Provisionen etc. werden **weiterhin im System** generiert.
|
||||
2. **PAYONE wird nicht entfernt.** Es bleibt als Option bestehen und wird sauber
|
||||
separiert. Ein **Schalter in den Einstellungen** wechselt den aktiven Anbieter.
|
||||
3. Scope: **Einmalzahlungen zuerst**, dann **Abos**.
|
||||
4. **Netto/Brutto je Land** bleibt Systemaufgabe (bestehende `country_prices`-Logik).
|
||||
5. **Zahlartenwahl und Dateneingabe finden bei Stripe statt** (Stakeholder 2026-07-08):
|
||||
Der mivita-Checkout leitet per Redirect zur **Stripe-Hosted-Checkout-Seite** weiter;
|
||||
dort wählt der Kunde die Zahlungsmethode, zahlt und springt zurück. Im eigenen
|
||||
Checkout gibt es bei aktivem Stripe **keine** Zahlarten-Formulare mehr (kein
|
||||
Kreditkarten-iFrame etc.). Der PAYONE-Checkout bleibt bei Schalter `payone` wie heute.
|
||||
6. Zahlarten-Scope: **Karte, PayPal, Apple Pay, Google Pay, Amazon Pay, Klarna** und
|
||||
weitere von Stripe bereitgestellte Methoden (Dashboard-gesteuert). Alle Zahlungen in **EUR**.
|
||||
|
||||
## 2. Grundsatzentscheidung: PaymentIntents statt Stripe Billing
|
||||
|
||||
| Kriterium | Stripe Billing (Subscriptions) | **PaymentIntents + gespeicherte Payment Methods (Empfehlung)** |
|
||||
|---|---|---|
|
||||
| Wer bestimmt Betrag & Rhythmus? | Stripe (Preise/Pläne bei Stripe gepflegt) | **Unser Cron** (`user:make_abo_order`) – wie heute bei PAYONE |
|
||||
| Rechnungen | Stripe erzeugt eigene Invoices | **System erzeugt Rechnungen** (dompdf/DATEV) – Anforderung! |
|
||||
| Abo-Inhalt ändert sich pro Ausführung (Produkte, Einmalprodukte, Länderpreise) | Schwer abbildbar (Preis-Objekte müssten synchron gehalten werden) | Trivial – Betrag wird pro Abbuchung frisch berechnet |
|
||||
| Steuerlogik | Stripe Tax nötig oder Duplikation | Bleibt zu 100 % im System |
|
||||
| Parallelität zu PAYONE | Zwei völlig verschiedene Abo-Modelle | **Gleicher Lebenszyklus wie heute**, nur anderer Charge-Kanal |
|
||||
|
||||
**Entscheidung (Empfehlung):** Abos bleiben in unserer Datenhaltung (`user_abos`,
|
||||
`user_abo_orders`), der Cron berechnet wie bisher den Betrag und ruft je nach Provider
|
||||
`chargeAbo()` bei PAYONE **oder** Stripe (off-session PaymentIntent) auf. Stripe Billing,
|
||||
Stripe Invoicing und Stripe Tax werden **nicht** verwendet.
|
||||
|
||||
> Damit ist Stripe für uns ein reiner **Charge-/Token-Provider** – exakt die Rolle, die
|
||||
> PAYONE heute hat. Das minimiert den Umbau und hält beide Anbieter symmetrisch hinter
|
||||
> einer gemeinsamen Schnittstelle.
|
||||
|
||||
## 3. Stripe-Objekte ↔ unsere Domäne
|
||||
|
||||
| Unsere Domäne | PAYONE heute | Stripe |
|
||||
|---|---|---|
|
||||
| Zahlender Kunde | `payone_userid` | `Customer` (`cus_…`) – pro `User`/`Customer` einmalig anlegen, ID speichern |
|
||||
| Gespeichertes Zahlungsmittel (Abo) | `pseudocardpan` + `cardexpiredate` in `user_abos.carddata` | `PaymentMethod` (`pm_…`), attached am Customer; ID in `user_abos.stripe_payment_method_id` |
|
||||
| Einmalzahlung Checkout | `authorization` (preauth/auth) | **Checkout Session** (`cs_…`, `mode=payment`, hosted) → erzeugt intern `PaymentIntent` (`pi_…`) |
|
||||
| Abo-Erstzahlung im Checkout | `authorization` + `recurrence=recurring` | **Checkout Session** `mode=payment` + `setup_future_usage=off_session` (Zahlungsmittel wird am Customer gespeichert) |
|
||||
| Wiederkehrende Abbuchung (Cron) | `authorization` + `userid` + `pseudocardpan` | `PaymentIntent` mit `off_session=true`, `confirm=true`, `customer` + `payment_method` (API-Call, keine Hosted Page) |
|
||||
| Zahlungsmittel aktualisieren ohne Abbuchung | `creditcardcheck` (`storecarddata=yes`) | **Checkout Session** `mode=setup` (hosted) → `SetupIntent` (`seti_…`), kein Charge, SCA-fähig |
|
||||
| Anbieter-Callback | IPN/TSOK (`txaction`) | Webhook-Events (signiert, `whsec_…`) |
|
||||
| Erstattung (Widerruf) | manuell / `refund`-Request | `Refund` (`re_…`) auf PaymentIntent/Charge |
|
||||
| Test/Live | `mode` test/live + Portal | Test-Keys vs. Live-Keys (`sk_test_…`/`sk_live_…`) |
|
||||
|
||||
### 3.1 Integrationsform: Stripe Checkout (Hosted Page) — entschieden 2026-07-08
|
||||
|
||||
**Ablauf:** mivita-Checkout (Adresse, Warenkorb, AGB) → Button „Jetzt zahlen" →
|
||||
Server erstellt eine **Checkout Session** → `303 Redirect` auf `checkout.stripe.com` →
|
||||
Kunde wählt dort die Zahlart und zahlt → Redirect zurück auf unsere `success_url`
|
||||
(bzw. `cancel_url` bei Abbruch). „Bezahlt" setzt weiterhin **nur der Webhook**
|
||||
(`checkout.session.completed` / `payment_intent.succeeded`), nie der Browser-Return.
|
||||
|
||||
Warum Hosted Checkout (statt eingebettetem Payment Element):
|
||||
|
||||
- Entspricht der Stakeholder-Vorgabe: keine Zahlungsdaten-Eingabe mehr im eigenen Checkout.
|
||||
- **Apple Pay / Google Pay funktionieren out of the box** (Domain-Verifizierung übernimmt
|
||||
Stripe auf der eigenen Domain; Wallets erscheinen automatisch nur auf fähigen Geräten).
|
||||
- **Dynamic Payment Methods:** Welche Zahlarten angezeigt werden, wird im
|
||||
**Stripe-Dashboard** konfiguriert — neue Methoden aktivieren ohne Code-Deploy.
|
||||
Der Code erzwingt nur Einschränkungen (z. B. Abo-Checkout → nur wiederverwendbare Methoden).
|
||||
- Stripe optimiert Reihenfolge/Anzeige der Methoden je Gerät, Land, Betrag.
|
||||
- PCI-Aufwand minimal (SAQ-A), kein Stripe.js-Embedding in unseren Blade-Views nötig.
|
||||
|
||||
Konsequenz für den bestehenden Checkout-Schritt „Zahlungsart wählen": Bei Schalter
|
||||
`stripe` entfällt die Detailauswahl im mivita-Checkout (bzw. reduziert sich auf
|
||||
systeminterne Methoden wie Vorkasse/Rechnung + „Online bezahlen via Stripe");
|
||||
bei Schalter `payone` bleibt der heutige Schritt unverändert.
|
||||
|
||||
### 3.2 Zahlarten (Scope lt. Stakeholder: alles, was Stripe bereitstellt)
|
||||
|
||||
| Zahlart | Stripe-Typ | Einmalzahlung | Abo-fähig (off_session) | Hinweis |
|
||||
|---|---|---|---|---|
|
||||
| Kreditkarte | `card` | ✅ | ✅ | Kernfall |
|
||||
| Apple Pay | Wallet auf `card` | ✅ | ✅ (tokenisiert als Karte) | Erscheint automatisch (Safari/iOS) |
|
||||
| Google Pay | Wallet auf `card` | ✅ | ✅ (tokenisiert als Karte) | Erscheint automatisch (Chrome/Android) |
|
||||
| PayPal | `paypal` | ✅ | ✅ (Vaulting via Stripe, EU) | Ersetzt das blockierte PayPal-direkt-Projekt (O2 = ja) |
|
||||
| Klarna | `klarna` | ✅ | ⚠️ in S0 gegen aktuelle Stripe-Doku prüfen | Ersetzt entfallene Sofortüberweisung (O3) |
|
||||
| Amazon Pay | `amazon_pay` | ✅ | ⚠️ in S0 gegen aktuelle Stripe-Doku prüfen | |
|
||||
| SEPA-Lastschrift | `sepa_debit` | ✅ | ✅ (Mandat via Stripe) | `processing` dauert Tage → eigener Status |
|
||||
| Weitere (iDEAL, Bancontact, …) | dynamisch | ✅ | teils | Dashboard-gesteuert, kein Code nötig |
|
||||
| Vorkasse (VOR) | – | systemintern | – | Bleibt außerhalb des Gateways, providerunabhängig |
|
||||
| Rechnung intern (FNC/MIV) | – | systemintern | – | Bleibt außerhalb des Gateways |
|
||||
|
||||
**Regel für Abo-Checkouts:** Die Checkout Session wird auf **wiederverwendbare**
|
||||
Methoden eingeschränkt (`card` inkl. Wallets, `paypal`, `sepa_debit`; Klarna/Amazon Pay
|
||||
nur, falls die S0-Prüfung `setup_future_usage=off_session` bestätigt). Einmalzahlungen
|
||||
bekommen das volle Dashboard-Sortiment.
|
||||
|
||||
## 4. Provider-Abstraktion & Schalter
|
||||
|
||||
### 4.1 Schichtenmodell
|
||||
|
||||
```
|
||||
UI: Checkout / Portal / CRM / Zahlungslink / Abo-Cron
|
||||
│
|
||||
▼
|
||||
PaymentProviderManager (neu)
|
||||
├── providerForNewCheckout(): PaymentProvider ← liest den Einstellungs-Schalter
|
||||
├── providerFor(UserAbo|ShoppingPayment): PaymentProvider ← liest payment_provider-Spalte
|
||||
└── gateway(PaymentProvider): PaymentGatewayInterface
|
||||
│
|
||||
├──────────────────────────────┐
|
||||
▼ ▼
|
||||
PayoneGateway (Adapter um StripeGateway (neu)
|
||||
PayonePaymentService – bestehend, app/Services/Payment/Stripe/*
|
||||
unverändert)
|
||||
```
|
||||
|
||||
```php
|
||||
enum PaymentProvider: string
|
||||
{
|
||||
case Payone = 'payone';
|
||||
case Stripe = 'stripe';
|
||||
}
|
||||
|
||||
interface PaymentGatewayInterface
|
||||
{
|
||||
public function initiateCheckoutPayment(CheckoutPaymentContext $ctx): PaymentInitiationResult;
|
||||
public function chargeAbo(UserAbo $abo, ShoppingOrder $order, ShoppingUser $user, int $amountCents, string $currency): AboChargeResult;
|
||||
public function setupPaymentMethodUpdate(UserAbo $abo): PaymentMethodUpdateSession; // CC-Update / SetupIntent
|
||||
public function refund(ShoppingPayment $payment, int $amountCents, string $reason): RefundResult;
|
||||
}
|
||||
```
|
||||
|
||||
**Wichtig:** PAYONE-Code wird dafür **nicht umgebaut** – der `PayoneGateway` ist ein
|
||||
dünner Adapter um den bestehenden `PayonePaymentService`. Ein Interface-Mismatch wird im
|
||||
Adapter gelöst, nie im Bestandscode.
|
||||
|
||||
### 4.2 Der Schalter (Einstellungen)
|
||||
|
||||
- **Neue Setting** `payment.active_provider` (`payone` | `stripe`), gepflegt im
|
||||
Admin-Bereich (CRM), Anzeige-/Änderungsprotokoll über bestehendes `SysLog`-Muster.
|
||||
- Der Schalter wirkt **nur auf neue Zahlungsvorgänge** (Checkout-Start, neue Abos,
|
||||
neue Zahlungslinks).
|
||||
- **Bestandsdaten sind schaltunabhängig:** Jede `shopping_payments`-Zeile und jedes
|
||||
`user_abos` trägt seinen `payment_provider` fest. Ein PAYONE-Abo wird auch nach dem
|
||||
Umschalten weiter über PAYONE abgebucht; Refunds gehen immer an den Provider der
|
||||
Ursprungszahlung.
|
||||
- Granularität: Start mit **globalem** Schalter; optional später pro Zahlart
|
||||
(`payment_methods.provider`-Spalte ist als Erweiterung vorgesehen) → **O4**.
|
||||
- Empfohlene Ablage: eigene kleine Tabelle/Model `PaymentSetting` oder Erweiterung des
|
||||
bestehenden Settings-Mechanismus (`Setting`/`SySetting`) → im Plan Phase S1 entscheiden,
|
||||
Konvention der Codebase folgt.
|
||||
|
||||
### 4.3 Datenmodell-Erweiterungen
|
||||
|
||||
```sql
|
||||
-- user_abos
|
||||
payment_provider VARCHAR(20) NOT NULL DEFAULT 'payone' -- Enum-Werte s. o.
|
||||
stripe_customer_id VARCHAR(255) NULL
|
||||
stripe_payment_method_id VARCHAR(255) NULL
|
||||
-- payone_userid, clearingtype, carddata bleiben unverändert (Stakeholder-Entscheid O1 Alt-Plan)
|
||||
|
||||
-- shopping_payments
|
||||
payment_provider VARCHAR(20) NOT NULL DEFAULT 'payone'
|
||||
external_reference VARCHAR(255) NULL -- Stripe PaymentIntent-ID (pi_…)
|
||||
|
||||
-- payment_transactions
|
||||
provider VARCHAR(20) NOT NULL DEFAULT 'payone' -- Webhook-Protokoll beider Anbieter
|
||||
|
||||
-- users / customers (oder eigene Tabelle stripe_customers)
|
||||
stripe_customer_id VARCHAR(255) NULL
|
||||
```
|
||||
|
||||
Backfill-Migration: alle Bestandszeilen `payment_provider = 'payone'` (Default deckt
|
||||
das ab). Keine Änderung an bestehenden PAYONE-Spalten.
|
||||
|
||||
## 5. Netto/Brutto & Währungen
|
||||
|
||||
- Betragsermittlung bleibt vollständig im System: `country_prices.c_tax` → Cart
|
||||
(`taxRate`, `taxIncluded`) → Bruttobetrag. Rechnung weist Netto/MwSt/Brutto aus wie
|
||||
heute.
|
||||
- Stripe erhält ausschließlich: `amount` = **Bruttobetrag in Cent**, `currency`,
|
||||
`description`/`metadata` (Order-Referenz). Keine Steuerdaten an Stripe, kein
|
||||
Stripe Tax.
|
||||
- Metadata-Konvention (für Support/Abgleich): `shopping_order_id`, `shopping_payment_id`,
|
||||
`user_abo_id` (falls Abo), `mode`.
|
||||
- Währung: heute faktisch EUR; `country_prices.c_currency` existiert → klären, ob
|
||||
Nicht-EUR-Länder aktiv sind → **O5**.
|
||||
|
||||
## 6. SCA / 3-D Secure
|
||||
|
||||
| Situation | Verhalten |
|
||||
|---|---|
|
||||
| Checkout (on-session) | Die Stripe-Hosted-Checkout-Seite übernimmt die 3DS-Challenge komplett. Danach Redirect auf unsere `success_url` → Status-Anzeige (UX) → „bezahlt" via Webhook. |
|
||||
| Abo-Erstzahlung | `setup_future_usage=off_session` in der Checkout Session → Bank weiß, dass Folgezahlungen kommen (MIT-Rahmen). |
|
||||
| Wiederkehrende Abbuchung (Cron, off-session) | Normalfall: keine SCA nötig (Merchant Initiated). Ausnahme `authentication_required`: Abbuchung schlägt fehl → Abo auf Status 3, Kunde bekommt Link in den **bestehenden Recovery-Checkout** (`AboRecoveryCheckoutService`) und zahlt dort on-session (Hosted Checkout) mit 3DS. |
|
||||
| Zahlungsmittel-Update | Checkout Session `mode=setup` (hosted) → SCA wird dabei gleich mit erledigt, Folgezahlungen laufen sauber. |
|
||||
|
||||
## 7. Webhooks
|
||||
|
||||
- Endpoint: `POST /api/stripe/webhook` (in `routes/api.php`, läuft global unter
|
||||
`api.<domain>` – analog PAYONE-IPN). Ohne CSRF, **mit Signaturprüfung**
|
||||
(`Stripe-Signature`-Header, `STRIPE_WEBHOOK_SECRET`).
|
||||
- Handler nach dem Muster `PayoneWebhookHandler` (Idempotenz über `event.id`,
|
||||
Duplikat-Erkennung, Persistenz **nur** über `StripeTransactionRecorder`).
|
||||
- Relevante Events (Minimal-Set):
|
||||
|
||||
| Event | Reaktion |
|
||||
|---|---|
|
||||
| `checkout.session.completed` | Session→Order-Zuordnung festziehen; bei `payment_status=paid` Paid-Pfad, bei `unpaid` (SEPA etc.) auf `payment_intent.succeeded` warten |
|
||||
| `checkout.session.expired` | Offenen Checkout-Vorgang aufräumen (Session-TTL 24 h) |
|
||||
| `payment_intent.succeeded` | Paid-Pfad (`paymentStatusPaidAction`-Äquivalent): Order bezahlt, Abo anlegen/fortschreiben |
|
||||
| `payment_intent.payment_failed` | Fehlerpfad; bei Abo → Retry-/Recovery-Logik, Incident |
|
||||
| `payment_intent.processing` | SEPA: Zahlung unterwegs → Status „in Verarbeitung" (Muster aus Doppelabo-Schutz 5.2.1 Alt-Plan) |
|
||||
| `charge.refunded` | Widerrufs-/Erstattungsstatus aktualisieren (`OrderWithdrawal`) |
|
||||
| `charge.dispute.created` | Incident im Payment-Dashboard |
|
||||
| `setup_intent.succeeded` | Zahlungsmittel-Update abschließen (`user_abos` aktualisieren) |
|
||||
|
||||
- **Redirect-Return ≠ Wahrheit:** Wie beim PAYONE-TSOK gilt der Webhook als Quelle der
|
||||
Wahrheit für „bezahlt", der Browser-Return nur für UX. SEPA wird z. B. erst Tage
|
||||
später `succeeded`.
|
||||
|
||||
## 8. Sicherheit & Compliance
|
||||
|
||||
| Thema | Umsetzung |
|
||||
|---|---|
|
||||
| PCI-DSS | **Stripe Hosted Checkout** → SAQ-A (geringstmöglicher Scope). Kartendaten berühren weder unseren Server noch unser Frontend. Secret Key nur serverseitig; ein Publishable Key wird beim Redirect-Flow nicht einmal im Frontend benötigt. |
|
||||
| Secrets | `config/stripe.php` mit `env('STRIPE_SECRET')`, `env('STRIPE_KEY')`, `env('STRIPE_WEBHOOK_SECRET')`; Hard-Fail-Muster wie `config/payone.php` (Boot-Exception bei fehlendem Secret im Live-Betrieb). Kein `env()` außerhalb von Config-Dateien. |
|
||||
| Logging | Recorder-Redaction wie PAYONE: keine vollständigen PM-Details in `transmitted_data`/Logs; Stripe-IDs (`pi_`, `pm_`, `cus_`) sind unkritisch und ausdrücklich erwünscht (Traceability). |
|
||||
| Webhook | Signaturprüfung + Idempotenz-Store; bei Verarbeitungsfehler kein 200 → Stripe wiederholt (gleiches Prinzip wie TSOK-Retry). |
|
||||
| AuthZ | Zahlungsmittel-Update nur Abo-Inhaber (Regel aus CC-Update-Projekt: „nur Inhaber gibt Karte ein" gilt unverändert für Stripe). |
|
||||
| Test/Live | Stripe-Testmodus ↔ `mode=test`; Rechnungen weiterhin nur in Produktion (bestehende Test-System-Sperre). |
|
||||
|
||||
## 9. SDK-Entscheidung
|
||||
|
||||
**Empfehlung: `stripe/stripe-php` (offizielles SDK), kein Laravel Cashier.**
|
||||
|
||||
Begründung: Cashier ist um Stripe Billing/Subscriptions herum gebaut (eigene Tabellen
|
||||
`subscriptions`, Trait am User-Model) – genau das Modell, das wir laut Abschnitt 2
|
||||
nicht verwenden. Das nackte SDK passt zur bestehenden Architektur
|
||||
(Service-Schicht + injizierbarer Client wie `?Client $client` bei PAYONE) und bleibt
|
||||
testbar (`StripeClient`-Interface-Wrapper, im Test gemockt).
|
||||
|
||||
→ Composer-Ergänzung `stripe/stripe-php`: **freigegeben** (O6, 2026-07-08).
|
||||
|
||||
## 10. Offene Fragen (Stakeholder) — beantwortet 2026-07-08
|
||||
|
||||
| ID | Frage | Antwort (Stakeholder, 2026-07-08) |
|
||||
|----|-------|-----------------------------------|
|
||||
| O1 | Welche Zahlarten zum Stripe-Start? | **Alle verfügbaren:** Karte, PayPal, Apple Pay, Google Pay, Amazon Pay, Klarna + weitere Stripe-Methoden. Anzeige über Hosted Checkout / Dashboard (s. §3.1/§3.2). Abo-Checkouts auf wiederverwendbare Methoden eingeschränkt. |
|
||||
| O2 | PayPal über Stripe? | **Ja** — implizit durch O1; ersetzt das blockierte PayPal-direkt-Projekt aus dem Alt-Plan. |
|
||||
| O3 | Ersatz für Sofortüberweisung? | **Klarna** (Teil des Zahlarten-Scopes aus O1). |
|
||||
| O4 | Schalter-Granularität | Global starten (Empfehlung angenommen, kein Widerspruch); pro Zahlart als Ausbaustufe. |
|
||||
| O5 | Nicht-EUR-Währungen? | **Nein — alle Zahlungen in EUR.** |
|
||||
| O6 | Freigabe `stripe/stripe-php`? | **Ja, freigegeben.** |
|
||||
| O7 | Stripe-Account? | **Angelegt.** Stakeholder hat Zugang und verwaltet ihn (Keys, Webhook-Konfiguration, Dashboard-Zahlarten). |
|
||||
| O8 | Migration Bestands-Abos? | **Ja, aber später** — bewusst nach hinten gestellt (Phase S8 bleibt am Ende des Plans; kein Blocker für den Rollout). |
|
||||
| O9 | Membership-/ELV-Zahlungen im Scope? | **Nach hinten gestellt** — vorerst PAYONE-only, eigener Folgeschritt nach dem Kern-Rollout. |
|
||||
|
||||
### Neue offene Punkte (aus den Entscheidungen abgeleitet)
|
||||
|
||||
| ID | Punkt | Klärung in | Antwort |
|
||||
|----|-------|-----------|---------|
|
||||
| N1 | Klarna & Amazon Pay: unterstützt Stripe dort aktuell `setup_future_usage=off_session` (Abo-Fähigkeit)? Gegen aktuelle Stripe-Doku prüfen. | Phase S0 | offen — bis zur Prüfung gelten beide als **nur Einmalzahlung** |
|
||||
| N2 | UX des Checkout-Schritts „Zahlungsart" bei Schalter `stripe`? | beantwortet 2026-07-08 | **Keine Zahlartenwahl mehr im mivita-Checkout:** Nach Checkout + Prüfung geht es direkt zu Stripe. Für Abos schränkt die Session serverseitig auf abo-fähige Methoden ein; alles andere wird dort ausgeblendet. |
|
||||
| N3 | Vorkasse (VOR) / interne Rechnung (FNC) im Stripe-Betrieb? | beantwortet 2026-07-08 | **Interne Rechnung (FNC):** bleibt als Option im System erhalten (ist ohnehin fast überall deaktiviert), wird aktuell ausgeblendet. **Vorkasse (VOR):** läuft heute über PAYONE → bekommt einen **eigenen Sichtbarkeits-Schalter in den Einstellungen**, damit sie unabhängig vom Provider-Schalter ein-/ausgeblendet werden kann. Ist Vorkasse ausgeblendet und Stripe aktiv, entfällt der Zahlarten-Schritt vollständig. |
|
||||
|
|
@ -38,5 +38,9 @@
|
|||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="BASIC_AUTH_ENABLED" value="false"/>
|
||||
<env name="PAYONE_KEY_HASH" value="698fb2555f8b2efc74f60b2121421f45"/>
|
||||
<env name="STRIPE_SECRET" value="sk_test_fake_suite_key"/>
|
||||
<env name="STRIPE_KEY" value="pk_test_fake_suite_key"/>
|
||||
<env name="STRIPE_WEBHOOK_SECRET" value="whsec_fake_suite_secret"/>
|
||||
<env name="STRIPE_MODE" value="test"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
BIN
public/images/payments-assets/stripe.png
Normal file
BIN
public/images/payments-assets/stripe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -149,6 +149,21 @@ return [
|
|||
'commission' => 'Provision',
|
||||
'unknown' => 'unbekannt',
|
||||
|
||||
// Stripe (Hosted Checkout)
|
||||
'stripe_online' => 'Online-Zahlung',
|
||||
'stripe_online_methods' => 'Kreditkarte, PayPal, Apple Pay, Google Pay, Klarna u. v. m.',
|
||||
'stripe_online_copy' => 'Du wirst zur sicheren Zahlungsseite weitergeleitet und wählst dort deine Zahlungsart.',
|
||||
'stripe_online_short' => 'Online-Zahlung (Stripe)',
|
||||
'stripe_unavailable' => 'Die Online-Zahlung ist derzeit nicht erreichbar. Bitte versuche es gleich erneut.',
|
||||
'stripe_order_label' => 'Bestellung :reference',
|
||||
|
||||
// Not-Schalter: Abo-Zahlungen eingefroren (PAYONE-Ausfall, bis Stripe-Abos live sind)
|
||||
'prepayment_no_clearing_data' => 'Bankverbindung liegt noch nicht vor — die Zahlung wurde beim Zahlungsanbieter noch nicht erfolgreich registriert.',
|
||||
'checkout_maintenance_title' => 'Technische Schwierigkeiten',
|
||||
'checkout_maintenance' => 'Aktuell sind Bestellungen wegen technischer Schwierigkeiten leider nicht möglich. Wir sind in Kürze wieder für dich da — bitte versuche es später erneut.',
|
||||
'abo_payments_frozen_title' => 'Abo-Zahlungen vorübergehend nicht möglich',
|
||||
'abo_payments_frozen' => 'Abo-Zahlungen sind derzeit leider nicht möglich. Wir arbeiten mit Hochdruck an einer technischen Lösung — bitte versuche es später erneut. Deine Abos und Bestellungen gehen dabei nicht verloren.',
|
||||
|
||||
// Vorkasse Verwendungszweck Hinweise
|
||||
'prepayment_important_notice' => 'Wichtiger Hinweis zur Vorkasse-Zahlung',
|
||||
'prepayment_reference_notice' => 'WICHTIG: Bitte geben Sie als Verwendungszweck ausschließlich folgende Nummer an:',
|
||||
|
|
|
|||
|
|
@ -196,4 +196,19 @@ return [
|
|||
'' => '',
|
||||
'American Express' => 'American Express',
|
||||
'unknown' => 'unknown',
|
||||
|
||||
// Stripe (hosted checkout)
|
||||
'stripe_online' => 'Online payment',
|
||||
'stripe_online_methods' => 'Credit card, PayPal, Apple Pay, Google Pay, Klarna and more',
|
||||
'stripe_online_copy' => 'You will be redirected to a secure payment page where you choose your payment method.',
|
||||
'stripe_online_short' => 'Online payment (Stripe)',
|
||||
'stripe_unavailable' => 'Online payment is currently unavailable. Please try again in a moment.',
|
||||
'stripe_order_label' => 'Order :reference',
|
||||
|
||||
// Emergency switch: subscription payments frozen (PAYONE outage, until Stripe subscriptions are live)
|
||||
'prepayment_no_clearing_data' => 'Bank details are not available yet — the payment has not been successfully registered with the payment provider.',
|
||||
'checkout_maintenance_title' => 'Technical difficulties',
|
||||
'checkout_maintenance' => 'Ordering is currently not possible due to technical difficulties. We will be back shortly — please try again later.',
|
||||
'abo_payments_frozen_title' => 'Subscription payments temporarily unavailable',
|
||||
'abo_payments_frozen' => 'Subscription payments are currently not possible. We are working hard on a technical solution — please try again later. Your subscriptions and orders will not be lost.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -363,29 +363,31 @@
|
|||
<div class="alert alert-warning border-warning mb-3" style="border-left: 4px solid #ffc107 !important;">
|
||||
<h5 class="mb-3"><i class="fa fa-university"></i> {{ __('payment.prepayment_bank_details') }}:</h5>
|
||||
<div style="background: #fff; border-radius: 8px; padding: 15px;">
|
||||
@if($payTrans && isset($payTrans->transmitted_data))
|
||||
@if($payTrans && isset($payTrans->transmitted_data) && !empty($payTrans->transmitted_data['clearing_bankiban']))
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 5px 10px 5px 0; width: 140px;"><strong>{{ __('payment.account_holder') }}:</strong></td>
|
||||
<td style="padding: 5px 0;">{{ $payTrans->transmitted_data['clearing_bankaccountholder'] }}</td>
|
||||
<td style="padding: 5px 0;">{{ $payTrans->transmitted_data['clearing_bankaccountholder'] ?? '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.IBAN') }}:</strong></td>
|
||||
<td style="padding: 5px 0; font-family: monospace; letter-spacing: 1px;">{{ $payTrans->transmitted_data['clearing_bankiban'] }}</td>
|
||||
<td style="padding: 5px 0; font-family: monospace; letter-spacing: 1px;">{{ $payTrans->transmitted_data['clearing_bankiban'] ?? '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.BIC') }}:</strong></td>
|
||||
<td style="padding: 5px 0; font-family: monospace;">{{ $payTrans->transmitted_data['clearing_bankbic'] }}</td>
|
||||
<td style="padding: 5px 0; font-family: monospace;">{{ $payTrans->transmitted_data['clearing_bankbic'] ?? '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.bank') }}:</strong></td>
|
||||
<td style="padding: 5px 0;">{{ $payTrans->transmitted_data['clearing_bankname'] }}</td>
|
||||
<td style="padding: 5px 0;">{{ $payTrans->transmitted_data['clearing_bankname'] ?? '—' }}</td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #ffc107;">
|
||||
<td style="padding: 10px 10px 5px 0;"><strong>{{ __('payment.total_amount') }}:</strong></td>
|
||||
<td style="padding: 10px 0 5px 0; font-size: 18px; font-weight: bold; color: #d35400;">{{ number_format( floatval($payTrans->shopping_payment->amount/100), 2, ",", ".") }} EUR</td>
|
||||
</tr>
|
||||
</table>
|
||||
@else
|
||||
<p class="mb-0 text-muted">{{ __('payment.prepayment_no_clearing_data') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,41 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-body">
|
||||
<h4>Zahlungen (Stripe / PAYONE)</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-6">
|
||||
<label class="form-label">Aktiver Zahlungsanbieter für <strong>neue</strong> Zahlungen</label>
|
||||
{!! Form::select('settings[payment-active-provider][val]', ['payone' => 'PAYONE', 'stripe' => 'Stripe'], \App\Models\Setting::getContentBySlug('payment-active-provider') ?: 'payone', ['class' => 'form-control']) !!}
|
||||
{{ Form::hidden('settings[payment-active-provider][type]', 'text') }}
|
||||
<small class="text-muted">Wirkt nur auf neue Zahlungsvorgänge — bestehende Zahlungen/Abos behalten ihren Anbieter.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group col-sm-12">
|
||||
<label class="custom-control custom-checkbox mt-2">
|
||||
{!! Form::checkbox('settings[checkout-maintenance][val]', 1, \App\Models\Setting::getContentBySlug('checkout-maintenance'), ['class'=>'custom-control-input']) !!}
|
||||
<span class="custom-control-label"><strong>Checkout-Wartungsmodus</strong> — alle Besucher sehen „Technische Schwierigkeiten"; nur Admins können bestellen (zum Live-Testen von Stripe)</span>
|
||||
</label>
|
||||
{{ Form::hidden('settings[checkout-maintenance][type]', 'bool') }}
|
||||
|
||||
<label class="custom-control custom-checkbox mt-3">
|
||||
{!! Form::checkbox('settings[abo-payments-frozen][val]', 1, \App\Models\Setting::getContentBySlug('abo-payments-frozen'), ['class'=>'custom-control-input']) !!}
|
||||
<span class="custom-control-label"><strong>Abo-Zahlungen einfrieren</strong> — Abo-Checkout, Retry und Abbuchungs-Cron pausieren mit Hinweis (Not-Schalter bis Stripe-Abos live sind)</span>
|
||||
</label>
|
||||
{{ Form::hidden('settings[abo-payments-frozen][type]', 'bool') }}
|
||||
|
||||
<label class="custom-control custom-checkbox mt-3">
|
||||
{!! Form::checkbox('settings[payment-vorkasse-hidden][val]', 1, \App\Models\Setting::getContentBySlug('payment-vorkasse-hidden'), ['class'=>'custom-control-input']) !!}
|
||||
<span class="custom-control-label"><strong>Vorkasse ausblenden</strong> — Vorkasse läuft über PAYONE und wird im Checkout nicht mehr angeboten</span>
|
||||
</label>
|
||||
{{ Form::hidden('settings[payment-vorkasse-hidden][type]', 'bool') }}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" name="action" value="save_payment" class="btn btn-primary btn-sm mb-2"><i class="ion ion-ios-save"></i> speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-body">
|
||||
|
|
|
|||
|
|
@ -133,25 +133,25 @@
|
|||
<strong>{{ __('payment.account_holder') }}:</strong>
|
||||
</td>
|
||||
<td style="padding: 5px 0;">
|
||||
{{ $pay_trans->transmitted_data['clearing_bankaccountholder'] }}</td>
|
||||
{{ $pay_trans->transmitted_data['clearing_bankaccountholder'] ?? '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.IBAN') }}:</strong>
|
||||
</td>
|
||||
<td style="padding: 5px 0; font-family: monospace; letter-spacing: 1px;">
|
||||
{{ $pay_trans->transmitted_data['clearing_bankiban'] }}</td>
|
||||
{{ $pay_trans->transmitted_data['clearing_bankiban'] ?? '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.BIC') }}:</strong>
|
||||
</td>
|
||||
<td style="padding: 5px 0; font-family: monospace;">
|
||||
{{ $pay_trans->transmitted_data['clearing_bankbic'] }}</td>
|
||||
{{ $pay_trans->transmitted_data['clearing_bankbic'] ?? '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.bank') }}:</strong>
|
||||
</td>
|
||||
<td style="padding: 5px 0;">
|
||||
{{ $pay_trans->transmitted_data['clearing_bankname'] }}</td>
|
||||
{{ $pay_trans->transmitted_data['clearing_bankname'] ?? '—' }}</td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #ffc107;">
|
||||
<td style="padding: 10px 10px 5px 0;">
|
||||
|
|
|
|||
|
|
@ -940,7 +940,43 @@
|
|||
<div class="toggle-content">
|
||||
<div class="row nomargin-bottom">
|
||||
|
||||
@if(($shopping_user->abo_options === 1) && (Util::getUserPaymentFor() >= 3))
|
||||
@if(!empty($abo_payments_frozen))
|
||||
<div class="col-lg-12 nomargin clearfix">
|
||||
<div class="alert alert-warning mb-0">
|
||||
<strong>{{ __('payment.abo_payments_frozen_title') }}</strong><br>
|
||||
{{ __('payment.abo_payments_frozen') }}
|
||||
</div>
|
||||
</div>
|
||||
@elseif(!empty($stripe_active))
|
||||
{{-- Stripe Hosted Checkout: Zahlartenwahl passiert bei Stripe --}}
|
||||
<div class="col-lg-12 nomargin clearfix">
|
||||
<label class="mt-0 fs-14 fw-400 radio pull-left nomargin-top" style="width: 100%">
|
||||
{!! Form::radio('payment_method', 'stripe', (!old('payment_method') ? '1' : '')) !!}
|
||||
<i></i> <span class="weight-400"><span class="fa fa-check text-primary"></span> {{ __('payment.stripe_online') }}</span>
|
||||
<img class="float-right" width="90" src="{{asset('images/payments-assets/stripe.png')}}" alt="Stripe">
|
||||
</label>
|
||||
<p class="fs-13 mb-1">{{ __('payment.stripe_online_methods') }}</p>
|
||||
<p class="fs-12 text-muted mb-0">{{ __('payment.stripe_online_copy') }}</p>
|
||||
</div>
|
||||
@if(array_key_exists('VOR', $payment_methods_active) && in_array($payment_methods_active['VOR'], $payment_methods))
|
||||
<div class="col-lg-12 nomargin clearfix">
|
||||
<label class="mt-4 fs-14 fw-400 radio pull-left" style="width: 100%">
|
||||
{!! Form::radio('payment_method', 'vor', '') !!}
|
||||
<i></i> <span class="weight-400"><span class="fa fa-check text-primary"></span> {{ __('payment.prepayment') }}</span>
|
||||
<img class="float-right" width="90" src="{{asset('images/payments-assets/vor.png')}}" alt="Vorkasse">
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
@if(array_key_exists('FNC', $payment_methods_active) && in_array($payment_methods_active['FNC'], $payment_methods))
|
||||
<div class="col-lg-12 nomargin clearfix">
|
||||
<label class="mt-4 fs-14 fw-400 radio pull-left" style="width: 100%">
|
||||
{!! Form::radio('payment_method', 'fnc#MIV', '') !!}
|
||||
<i></i> <span class="weight-400"><span class="fa fa-check text-primary"></span> {{ __('payment.purchase_on_account') }}</span>
|
||||
<img class="float-right" width="90" src="{{asset('images/payments-assets/fnc.png')}}" alt="Rechnungskauf">
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
@elseif(($shopping_user->abo_options === 1) && (Util::getUserPaymentFor() >= 3))
|
||||
@if(array_key_exists('SEPA', $payment_methods_active) && in_array($payment_methods_active['SEPA'], $payment_methods))
|
||||
<div class="col-lg-12 nomargin clearfix">
|
||||
<label class="mt-4 fs-14 fw-400 radio pull-left" style="width: 100%">
|
||||
|
|
@ -1331,8 +1367,12 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
@if(!empty($abo_payments_frozen))
|
||||
<div class="alert alert-warning mt-4">{{ __('payment.abo_payments_frozen') }}</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block size-18 btn-text-500 mt-4 faa-parent animated-hover"><i class="fa fa-mail-forward faa-horizontal"></i> {{ __('payment.buy_now') }}</button>
|
||||
<hr><p class="text-center" style="line-height: 1.2em;"><em class="small text-center"> <i class="fa fa-lock"></i> {{ __('payment.buy_now_copy') }}</em></p>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Routes
|
||||
|
|
@ -13,33 +11,32 @@ use Illuminate\Http\Request;
|
|||
|
|
||||
*/
|
||||
|
||||
|
||||
|
||||
Route::match(['get', 'post'], '/payment/status', 'Api\PayoneController@paymentStatus')->name('api.payment_status');
|
||||
|
||||
Route::post('/stripe/webhook', 'Api\StripeWebhookController@handleWebhook')->name('api.stripe_webhook');
|
||||
|
||||
Route::get('/google/merchant/feed', 'Api\GoogleMerchantController@feed')->name('api.google_merchant_feed');
|
||||
|
||||
|
||||
Route::group([
|
||||
'prefix' => 'auth'
|
||||
'prefix' => 'auth',
|
||||
], function () {
|
||||
Route::post('login', 'Api\AuthController@login');
|
||||
//Route::post('signup', 'Api\AuthController@signup');
|
||||
// Route::post('signup', 'Api\AuthController@signup');
|
||||
|
||||
Route::group([
|
||||
'middleware' => 'auth:api'
|
||||
], function() {
|
||||
'middleware' => 'auth:api',
|
||||
], function () {
|
||||
Route::post('logout', 'Api\AuthController@logout');
|
||||
Route::post('checked', 'Api\AuthController@checked');
|
||||
});
|
||||
});
|
||||
|
||||
Route::group([
|
||||
'prefix' => 'wp'
|
||||
'prefix' => 'wp',
|
||||
], function () {
|
||||
Route::group([
|
||||
'middleware' => 'auth:api'
|
||||
], function() {
|
||||
'middleware' => 'auth:api',
|
||||
], function () {
|
||||
Route::post('show', 'Api\ShoppingUserController@show');
|
||||
Route::post('store', 'Api\ShoppingUserController@store');
|
||||
Route::post('update', 'Api\ShoppingUserController@update');
|
||||
|
|
@ -49,4 +46,4 @@ Route::group([
|
|||
Route::post('cancel', 'Api\ShoppingUserController@cancel');
|
||||
Route::post('open', 'Api\ShoppingUserController@open');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
136
tests/Feature/PaymentProviderManagerTest.php
Normal file
136
tests/Feature/PaymentProviderManagerTest.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
use App\Cron\UserMakeOrder;
|
||||
use App\Enums\PaymentProvider;
|
||||
use App\Models\Setting;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Payment\Contracts\PaymentGatewayInterface;
|
||||
use App\Services\Payment\DTOs\AboChargeResult;
|
||||
use App\Services\Payment\PaymentProviderManager;
|
||||
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
|
||||
use App\Services\Payment\Payone\PayonePaymentService;
|
||||
use App\Services\Payment\PayoneGateway;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
// --- Schalter: Anbieter für neue Vorgänge ---
|
||||
|
||||
it('defaults to payone when no provider switch is configured', function () {
|
||||
expect(app(PaymentProviderManager::class)->providerForNewCheckout())
|
||||
->toBe(PaymentProvider::Payone);
|
||||
});
|
||||
|
||||
it('reads the active provider for new checkouts from the settings switch', function () {
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'stripe', 'text');
|
||||
|
||||
expect(app(PaymentProviderManager::class)->providerForNewCheckout())
|
||||
->toBe(PaymentProvider::Stripe);
|
||||
});
|
||||
|
||||
it('falls back to payone for unknown switch values', function () {
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'klarna', 'text');
|
||||
|
||||
expect(app(PaymentProviderManager::class)->providerForNewCheckout())
|
||||
->toBe(PaymentProvider::Payone);
|
||||
});
|
||||
|
||||
// --- Bestandsvorgänge: Anbieter aus der Spalte, schalterunabhängig ---
|
||||
|
||||
it('backfills existing abos with payone as their provider', function () {
|
||||
$abo = makeMeAbo();
|
||||
|
||||
expect($abo->fresh()->payment_provider)->toBe('payone');
|
||||
});
|
||||
|
||||
it('resolves the provider of an existing abo from its column even when the switch says stripe', function () {
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'stripe', 'text');
|
||||
$abo = makeMeAbo();
|
||||
|
||||
expect(app(PaymentProviderManager::class)->providerFor($abo->fresh()))
|
||||
->toBe(PaymentProvider::Payone);
|
||||
});
|
||||
|
||||
it('resolves a stripe abo and a stripe shopping payment from their columns', function () {
|
||||
$abo = makeMeAbo();
|
||||
$abo->update(['payment_provider' => 'stripe']);
|
||||
$payment = new ShoppingPayment(['payment_provider' => 'stripe']);
|
||||
|
||||
$manager = app(PaymentProviderManager::class);
|
||||
|
||||
expect($manager->providerFor($abo->fresh()))->toBe(PaymentProvider::Stripe)
|
||||
->and($manager->providerFor($payment))->toBe(PaymentProvider::Stripe);
|
||||
});
|
||||
|
||||
// --- Gateway-Auflösung ---
|
||||
|
||||
it('resolves the payone gateway adapter for the payone provider', function () {
|
||||
$gateway = app(PaymentProviderManager::class)->gateway(PaymentProvider::Payone);
|
||||
|
||||
expect($gateway)->toBeInstanceOf(PayoneGateway::class)
|
||||
->and($gateway)->toBeInstanceOf(PaymentGatewayInterface::class)
|
||||
->and($gateway->provider())->toBe(PaymentProvider::Payone);
|
||||
});
|
||||
|
||||
it('rejects the stripe gateway until it is implemented', function () {
|
||||
app(PaymentProviderManager::class)->gateway(PaymentProvider::Stripe);
|
||||
})->throws(RuntimeException::class);
|
||||
|
||||
// --- AboChargeResult-Mapping ---
|
||||
|
||||
it('maps an approved payone result to a successful charge result', function () {
|
||||
$result = AboChargeResult::fromPayoneResult(
|
||||
PayonePaymentResult::fromResponse(['status' => 'APPROVED', 'txid' => 'tx-123'])
|
||||
);
|
||||
|
||||
expect($result->success)->toBeTrue()
|
||||
->and($result->provider)->toBe(PaymentProvider::Payone)
|
||||
->and($result->status)->toBe('APPROVED')
|
||||
->and($result->transactionId)->toBe('tx-123')
|
||||
->and($result->errorMessage)->toBeNull();
|
||||
});
|
||||
|
||||
it('maps a declined payone result with its error messages', function () {
|
||||
$result = AboChargeResult::fromPayoneResult(PayonePaymentResult::fromResponse([
|
||||
'status' => 'ERROR',
|
||||
'errormessage' => 'Card declined',
|
||||
'customermessage' => 'Bitte andere Karte verwenden.',
|
||||
]));
|
||||
|
||||
expect($result->success)->toBeFalse()
|
||||
->and($result->status)->toBe('ERROR')
|
||||
->and($result->errorMessage)->toBe('Card declined')
|
||||
->and($result->customerMessage)->toBe('Bitte andere Karte verwenden.');
|
||||
});
|
||||
|
||||
// --- PayoneGateway delegiert 1:1 an den bestehenden Service ---
|
||||
|
||||
it('delegates abo charges to the payone payment service', function () {
|
||||
$abo = new UserAbo;
|
||||
$order = new ShoppingOrder;
|
||||
$shoppingUser = new ShoppingUser;
|
||||
|
||||
$service = Mockery::mock(PayonePaymentService::class);
|
||||
$service->shouldReceive('chargeAbo')
|
||||
->once()
|
||||
->with($abo, $order, $shoppingUser, 1999, 'EUR')
|
||||
->andReturn(PayonePaymentResult::fromResponse(['status' => 'APPROVED', 'txid' => 'tx-9']));
|
||||
|
||||
$result = (new PayoneGateway($service))->chargeAbo($abo, $order, $shoppingUser, 1999, 'EUR');
|
||||
|
||||
expect($result->success)->toBeTrue()
|
||||
->and($result->transactionId)->toBe('tx-9');
|
||||
});
|
||||
|
||||
// --- Cron-Guard: kein Fremd-Provider durch den PAYONE-Pfad ---
|
||||
|
||||
it('blocks abo charging in the cron for non-payone providers', function () {
|
||||
$abo = makeMeAbo();
|
||||
$abo->update(['payment_provider' => 'stripe']);
|
||||
|
||||
(new UserMakeOrder($abo->fresh()))->makePayment();
|
||||
})->throws(RuntimeException::class, 'noch nicht implementiert');
|
||||
449
tests/Feature/StripeCheckoutTest.php
Normal file
449
tests/Feature/StripeCheckoutTest.php
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\MailCheckout;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\Setting;
|
||||
use App\Models\ShippingCountry;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\StripeWebhookEvent;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Models\UserShop;
|
||||
use App\Repositories\CheckoutRepository;
|
||||
use App\Services\AboPaymentMethodService;
|
||||
use App\Services\AboRetryPaymentService;
|
||||
use App\Services\Payment\PaymentProviderManager;
|
||||
use App\Services\Payment\Payone\DTOs\CardUpdateResult;
|
||||
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
|
||||
use App\Services\Payment\Stripe\StripeClientWrapper;
|
||||
use App\Services\Payment\Stripe\StripePaymentService;
|
||||
use App\Services\Payment\Stripe\StripeWebhookHandler;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Stripe\Checkout\Session;
|
||||
use Stripe\StripeClient;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
/**
|
||||
* Vollständige Order-Fixture (Muster: webhookBootstrap aus PayoneWebhookHandlerTest)
|
||||
* für eine 89,00-EUR-Stripe-Zahlung.
|
||||
*
|
||||
* @return array{0: ShoppingOrder, 1: ShoppingPayment, 2: ShoppingUser}
|
||||
*/
|
||||
function stripeCheckoutBootstrap(array $paymentOverrides = []): array
|
||||
{
|
||||
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
|
||||
|
||||
$shopOwner = User::forceCreate([
|
||||
'email' => 'sc-owner-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$userShop = UserShop::first() ?? UserShop::create([
|
||||
'user_id' => $shopOwner->id,
|
||||
'name' => 'SC Shop',
|
||||
'slug' => 'sc-shop-'.uniqid(),
|
||||
'domain' => 'sc-test.local',
|
||||
]);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => $shopOwner->id,
|
||||
'billing_firstname' => 'Stripe',
|
||||
'billing_lastname' => 'Checkout',
|
||||
'billing_email' => 'stripe-co-'.uniqid().'@example.com',
|
||||
'billing_country_id' => $country->id,
|
||||
'shipping_country_id' => $country->id,
|
||||
]);
|
||||
|
||||
$order = ShoppingOrder::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => $shopOwner->id,
|
||||
'country_id' => $country->id,
|
||||
'user_shop_id' => $userShop->id,
|
||||
'total' => 89.00,
|
||||
'paid' => false,
|
||||
'txaction' => null,
|
||||
'mode' => 'test',
|
||||
]);
|
||||
|
||||
$payment = ShoppingPayment::create(array_merge([
|
||||
'shopping_order_id' => $order->id,
|
||||
'payment_provider' => 'stripe',
|
||||
'reference' => 'sc-ref-'.uniqid(),
|
||||
'amount' => 8900,
|
||||
'clearingtype' => 'stripe',
|
||||
'currency' => 'EUR',
|
||||
'mode' => 'test',
|
||||
], $paymentOverrides));
|
||||
|
||||
return [$order, $payment, $shoppingUser];
|
||||
}
|
||||
|
||||
function stripeCheckoutEvent(string $type, array $object, string $eventId): string
|
||||
{
|
||||
return json_encode([
|
||||
'id' => $eventId,
|
||||
'object' => 'event',
|
||||
'api_version' => '2026-06-30',
|
||||
'type' => $type,
|
||||
'data' => ['object' => $object],
|
||||
]);
|
||||
}
|
||||
|
||||
function stripeCheckoutSignature(string $payload): string
|
||||
{
|
||||
$timestamp = time();
|
||||
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, config('stripe.webhook_secret'));
|
||||
|
||||
return "t={$timestamp},v1={$signed}";
|
||||
}
|
||||
|
||||
function handleStripeCheckoutEvent(string $type, array $object, string $eventId): void
|
||||
{
|
||||
$payload = stripeCheckoutEvent($type, $object, $eventId);
|
||||
app(StripeWebhookHandler::class)->handle($payload, stripeCheckoutSignature($payload));
|
||||
}
|
||||
|
||||
// --- Session-Erstellung (S3.2) ---
|
||||
|
||||
it('creates a checkout session with payment and initiation transaction', function () {
|
||||
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
|
||||
$order->shopping_payments()->delete();
|
||||
|
||||
$capturedParams = null;
|
||||
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
|
||||
$client->shouldReceive('request')
|
||||
->once()
|
||||
->withArgs(function ($method, $path, $params) use (&$capturedParams) {
|
||||
$capturedParams = $params;
|
||||
|
||||
return $method === 'post' && $path === '/v1/checkout/sessions';
|
||||
})
|
||||
->andReturn(Session::constructFrom([
|
||||
'id' => 'cs_test_abc',
|
||||
'object' => 'checkout.session',
|
||||
'url' => 'https://checkout.stripe.com/c/pay/cs_test_abc',
|
||||
'status' => 'open',
|
||||
'livemode' => false,
|
||||
]));
|
||||
|
||||
$wrapper = Mockery::mock(StripeClientWrapper::class);
|
||||
$wrapper->shouldReceive('client')->andReturn($client);
|
||||
|
||||
$result = (new StripePaymentService($wrapper))->initiateCheckoutPayment(
|
||||
$shoppingUser,
|
||||
$order,
|
||||
8900,
|
||||
'EUR',
|
||||
'mref123',
|
||||
'https://checkout.test/transaction/status/success/mref123',
|
||||
'https://checkout.test/transaction/status/cancel/mref123',
|
||||
);
|
||||
|
||||
$payment = $result->shoppingPayment->fresh();
|
||||
|
||||
expect($result->url)->toBe('https://checkout.stripe.com/c/pay/cs_test_abc')
|
||||
->and($payment->payment_provider)->toBe('stripe')
|
||||
->and($payment->external_reference)->toBe('cs_test_abc')
|
||||
->and($payment->amount)->toBe(8900)
|
||||
->and($payment->reference)->toBe('mref123')
|
||||
->and($capturedParams['mode'])->toBe('payment')
|
||||
->and($capturedParams['line_items'][0]['price_data']['unit_amount'])->toBe(8900)
|
||||
->and($capturedParams['line_items'][0]['price_data']['currency'])->toBe('eur')
|
||||
->and($capturedParams['metadata']['shopping_payment_id'])->toBe((string) $payment->id)
|
||||
->and($capturedParams['payment_intent_data']['metadata']['shopping_order_id'])->toBe((string) $order->id)
|
||||
->and($capturedParams['success_url'])->toContain('mref123')
|
||||
->and(PaymentTransaction::where('shopping_payment_id', $payment->id)->where('request', 'checkout.session.created')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
// --- Paid-Pfad (S3.5) ---
|
||||
|
||||
it('marks the order paid and sends one mail on payment_intent succeeded', function () {
|
||||
[$order, $payment] = stripeCheckoutBootstrap();
|
||||
|
||||
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
||||
'id' => 'pi_paid_1',
|
||||
'object' => 'payment_intent',
|
||||
'status' => 'succeeded',
|
||||
'amount' => 8900,
|
||||
'amount_received' => 8900,
|
||||
'livemode' => false,
|
||||
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
||||
], 'evt_paid_1');
|
||||
|
||||
$order->refresh();
|
||||
|
||||
expect($order->paid)->toBeTruthy()
|
||||
->and($order->txaction)->toBe('paid')
|
||||
->and($payment->fresh()->txaction)->toBe('paid');
|
||||
Mail::assertSent(MailCheckout::class, 1);
|
||||
});
|
||||
|
||||
it('does not send a second mail when the session completed event arrives after payment', function () {
|
||||
[$order, $payment] = stripeCheckoutBootstrap(['external_reference' => 'cs_double']);
|
||||
|
||||
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
||||
'id' => 'pi_double',
|
||||
'object' => 'payment_intent',
|
||||
'amount_received' => 8900,
|
||||
'livemode' => false,
|
||||
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
||||
], 'evt_double_pi');
|
||||
|
||||
handleStripeCheckoutEvent('checkout.session.completed', [
|
||||
'id' => 'cs_double',
|
||||
'object' => 'checkout.session',
|
||||
'payment_status' => 'paid',
|
||||
'amount_total' => 8900,
|
||||
'livemode' => false,
|
||||
], 'evt_double_cs');
|
||||
|
||||
expect($order->fresh()->paid)->toBeTruthy();
|
||||
Mail::assertSent(MailCheckout::class, 1);
|
||||
});
|
||||
|
||||
it('refuses the paid path when the confirmed amount does not match', function () {
|
||||
[$order, $payment] = stripeCheckoutBootstrap();
|
||||
|
||||
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
||||
'id' => 'pi_wrong',
|
||||
'object' => 'payment_intent',
|
||||
'amount_received' => 999,
|
||||
'livemode' => false,
|
||||
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
||||
], 'evt_wrong_amount');
|
||||
|
||||
expect($order->fresh()->paid)->toBeFalsy();
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('marks appointed and sends the order confirmation for async payments', function () {
|
||||
[$order, $payment] = stripeCheckoutBootstrap(['external_reference' => 'cs_async']);
|
||||
|
||||
handleStripeCheckoutEvent('checkout.session.completed', [
|
||||
'id' => 'cs_async',
|
||||
'object' => 'checkout.session',
|
||||
'payment_status' => 'unpaid',
|
||||
'amount_total' => 8900,
|
||||
'livemode' => false,
|
||||
], 'evt_async_cs');
|
||||
|
||||
$order->refresh();
|
||||
|
||||
expect($order->paid)->toBeFalsy()
|
||||
->and($order->txaction)->toBe('appointed');
|
||||
Mail::assertSent(MailCheckout::class, 1);
|
||||
});
|
||||
|
||||
it('marks failed attempts without sending mail', function () {
|
||||
[$order, $payment] = stripeCheckoutBootstrap();
|
||||
|
||||
handleStripeCheckoutEvent('payment_intent.payment_failed', [
|
||||
'id' => 'pi_fail',
|
||||
'object' => 'payment_intent',
|
||||
'livemode' => false,
|
||||
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
||||
], 'evt_failed');
|
||||
|
||||
expect($order->fresh()->txaction)->toBe('failed');
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('marks an expired session as cancelled without touching paid orders', function () {
|
||||
[, $payment] = stripeCheckoutBootstrap(['external_reference' => 'cs_expired']);
|
||||
|
||||
handleStripeCheckoutEvent('checkout.session.expired', [
|
||||
'id' => 'cs_expired',
|
||||
'object' => 'checkout.session',
|
||||
'livemode' => false,
|
||||
], 'evt_expired');
|
||||
|
||||
expect($payment->fresh()->status)->toBe('expired');
|
||||
});
|
||||
|
||||
it('reprocesses a stored but unprocessed event on retry', function () {
|
||||
[$order, $payment] = stripeCheckoutBootstrap();
|
||||
|
||||
StripeWebhookEvent::create([
|
||||
'event_id' => 'evt_crashed',
|
||||
'type' => 'payment_intent.succeeded',
|
||||
'payload' => [],
|
||||
]);
|
||||
|
||||
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
||||
'id' => 'pi_retry',
|
||||
'object' => 'payment_intent',
|
||||
'amount_received' => 8900,
|
||||
'livemode' => false,
|
||||
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
||||
], 'evt_crashed');
|
||||
|
||||
expect($order->fresh()->paid)->toBeTruthy()
|
||||
->and(StripeWebhookEvent::whereEventId('evt_crashed')->first()->processed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
// --- Zahlart-Anzeige (payment_method_details) ---
|
||||
|
||||
it('stores the used payment method from charge succeeded and shows it as label', function () {
|
||||
[, $payment] = stripeCheckoutBootstrap(['external_reference' => 'pi_details']);
|
||||
|
||||
handleStripeCheckoutEvent('charge.succeeded', [
|
||||
'id' => 'ch_1',
|
||||
'object' => 'charge',
|
||||
'payment_intent' => 'pi_details',
|
||||
'livemode' => false,
|
||||
'payment_method_details' => [
|
||||
'type' => 'card',
|
||||
'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 4, 'exp_year' => 2031, 'wallet' => null],
|
||||
],
|
||||
], 'evt_charge_details');
|
||||
|
||||
$payment->refresh();
|
||||
|
||||
expect($payment->carddata['type'])->toBe('card')
|
||||
->and($payment->carddata['last4'])->toBe('4242')
|
||||
->and($payment->getPaymentType())->toContain('Visa')
|
||||
->and($payment->getPaymentType())->toContain('4242');
|
||||
});
|
||||
|
||||
it('backfills payment method details when the charge event arrived first', function () {
|
||||
[$order, $payment] = stripeCheckoutBootstrap();
|
||||
|
||||
// charge.succeeded kommt zuerst und ist noch keiner Zahlung zuordenbar
|
||||
handleStripeCheckoutEvent('charge.succeeded', [
|
||||
'id' => 'ch_2',
|
||||
'object' => 'charge',
|
||||
'payment_intent' => 'pi_backfill',
|
||||
'livemode' => false,
|
||||
'payment_method_details' => [
|
||||
'type' => 'card',
|
||||
'card' => ['brand' => 'mastercard', 'last4' => '4444', 'wallet' => ['type' => 'apple_pay']],
|
||||
],
|
||||
], 'evt_charge_first');
|
||||
|
||||
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
||||
'id' => 'pi_backfill',
|
||||
'object' => 'payment_intent',
|
||||
'amount_received' => 8900,
|
||||
'livemode' => false,
|
||||
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
||||
], 'evt_pi_after_charge');
|
||||
|
||||
$payment->refresh();
|
||||
|
||||
expect($order->fresh()->paid)->toBeTruthy()
|
||||
->and($payment->external_reference)->toBe('pi_backfill')
|
||||
->and($payment->carddata['wallet'])->toBe('apple_pay')
|
||||
->and($payment->getPaymentType())->toContain('Apple Pay');
|
||||
});
|
||||
|
||||
it('labels paypal payments from the charge details', function () {
|
||||
[, $payment] = stripeCheckoutBootstrap(['external_reference' => 'pi_pp']);
|
||||
|
||||
handleStripeCheckoutEvent('charge.succeeded', [
|
||||
'id' => 'ch_3',
|
||||
'object' => 'charge',
|
||||
'payment_intent' => 'pi_pp',
|
||||
'livemode' => false,
|
||||
'payment_method_details' => ['type' => 'paypal', 'paypal' => []],
|
||||
], 'evt_charge_pp');
|
||||
|
||||
expect($payment->fresh()->getPaymentType())->toBe(__('payment.paypal'));
|
||||
});
|
||||
|
||||
// --- Abo-Freeze (Not-Schalter PAYONE-Ausfall) ---
|
||||
|
||||
it('freezes abo retries while the freeze switch is on', function () {
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
|
||||
|
||||
$abo = makeMeAbo();
|
||||
$abo->update(['status' => 3]);
|
||||
|
||||
$result = app(AboRetryPaymentService::class)->retry($abo->fresh());
|
||||
|
||||
expect($result['success'])->toBeFalse()
|
||||
->and($result['message'])->toBe(__('payment.abo_payments_frozen'));
|
||||
});
|
||||
|
||||
it('skips the abo charge cron entirely while frozen without touching abos', function () {
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
|
||||
|
||||
$abo = makeMeAbo();
|
||||
$abo->update(['next_date' => now()->subDay(), 'status' => 2]);
|
||||
|
||||
$this->artisan('user:make_abo_order')->assertExitCode(0);
|
||||
|
||||
$fresh = $abo->fresh();
|
||||
|
||||
expect($fresh->status)->toBe(2)
|
||||
->and(UserAboOrder::where('user_abo_id', $abo->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('reports the freeze state from the manager with default off', function () {
|
||||
$manager = app(PaymentProviderManager::class);
|
||||
|
||||
expect($manager->aboPaymentsFrozen())->toBeFalse();
|
||||
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
|
||||
|
||||
expect($manager->aboPaymentsFrozen())->toBeTrue();
|
||||
});
|
||||
|
||||
it('freezes credit card updates for abos while the freeze switch is on', function () {
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
|
||||
|
||||
$abo = makeMeAbo();
|
||||
$card = new CreditCardCheckData(
|
||||
cardholderFirstName: 'Max',
|
||||
cardholderLastName: 'Muster',
|
||||
cardPan: '4111111111111111',
|
||||
cardExpireMonth: '12',
|
||||
cardExpireYear: '2031',
|
||||
cardType: 'V',
|
||||
cardCvc2: '123',
|
||||
);
|
||||
|
||||
$result = app(AboPaymentMethodService::class)
|
||||
->updateCreditCard($abo, $card, ['successurl' => 'x', 'errorurl' => 'x', 'backurl' => 'x']);
|
||||
|
||||
expect($result->status)->toBe(CardUpdateResult::ERROR)
|
||||
->and($result->errorMessage)->toBe(__('payment.abo_payments_frozen'));
|
||||
});
|
||||
|
||||
it('reports the checkout maintenance state with default off', function () {
|
||||
$manager = app(PaymentProviderManager::class);
|
||||
|
||||
expect($manager->checkoutMaintenanceActive())->toBeFalse();
|
||||
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_CHECKOUT_MAINTENANCE, 1, 'bool');
|
||||
|
||||
expect($manager->checkoutMaintenanceActive())->toBeTrue();
|
||||
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_CHECKOUT_MAINTENANCE, 0, 'bool');
|
||||
|
||||
expect($manager->checkoutMaintenanceActive())->toBeFalse();
|
||||
});
|
||||
|
||||
// --- Vorkasse-Schalter (S3.8) ---
|
||||
|
||||
it('hides prepayment in the checkout when the vorkasse switch is set', function () {
|
||||
PaymentMethod::create(['name' => 'Vorkasse', 'short' => 'VOR', 'active' => true, 'default' => true]);
|
||||
|
||||
$repo = app(CheckoutRepository::class);
|
||||
|
||||
expect($repo->getPaymentsMethods('shopping')['active'])->toHaveKey('VOR');
|
||||
|
||||
Setting::setContentBySlug(PaymentProviderManager::SETTING_VORKASSE_HIDDEN, 1, 'bool');
|
||||
|
||||
expect($repo->getPaymentsMethods('shopping')['active'])->not->toHaveKey('VOR');
|
||||
});
|
||||
32
tests/Feature/StripeFoundationTest.php
Normal file
32
tests/Feature/StripeFoundationTest.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\PaymentProvider;
|
||||
use Stripe\StripeClient;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('loads the stripe configuration from the environment', function () {
|
||||
expect(config('stripe.secret'))->toBe('sk_test_fake_suite_key')
|
||||
->and(config('stripe.key'))->toBe('pk_test_fake_suite_key')
|
||||
->and(config('stripe.webhook_secret'))->toBe('whsec_fake_suite_secret')
|
||||
->and(config('stripe.mode'))->toBe('test');
|
||||
});
|
||||
|
||||
it('does not hard-fail the boot when stripe secrets are missing', function () {
|
||||
config(['stripe.secret' => null, 'stripe.webhook_secret' => null]);
|
||||
|
||||
expect(config('stripe.secret'))->toBeNull()
|
||||
->and(config('stripe.mode'))->toBe('test');
|
||||
});
|
||||
|
||||
it('exposes both payment providers as enum', function () {
|
||||
expect(PaymentProvider::Payone->value)->toBe('payone')
|
||||
->and(PaymentProvider::Stripe->value)->toBe('stripe')
|
||||
->and(PaymentProvider::from('stripe'))->toBe(PaymentProvider::Stripe)
|
||||
->and(PaymentProvider::tryFrom('unknown'))->toBeNull();
|
||||
});
|
||||
|
||||
it('has the stripe sdk installed', function () {
|
||||
expect(class_exists(StripeClient::class))->toBeTrue();
|
||||
});
|
||||
241
tests/Feature/StripeWebhookHandlerTest.php
Normal file
241
tests/Feature/StripeWebhookHandlerTest.php
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\ShippingCountry;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\StripeWebhookEvent;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\Payment\Stripe\StripeClientWrapper;
|
||||
use App\Services\Payment\Stripe\StripeCustomerService;
|
||||
use App\Services\Payment\Stripe\StripeTransactionRecorder;
|
||||
use App\Services\Payment\Stripe\StripeWebhookHandler;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Stripe\Customer;
|
||||
use Stripe\StripeClient;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function stripeEventPayload(string $type, array $object, string $eventId = 'evt_test_1'): string
|
||||
{
|
||||
return json_encode([
|
||||
'id' => $eventId,
|
||||
'object' => 'event',
|
||||
'api_version' => '2026-06-30',
|
||||
'type' => $type,
|
||||
'data' => ['object' => $object],
|
||||
]);
|
||||
}
|
||||
|
||||
function stripeSignature(string $payload, ?string $secret = null): string
|
||||
{
|
||||
$secret = $secret ?? config('stripe.webhook_secret');
|
||||
$timestamp = time();
|
||||
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
|
||||
|
||||
return "t={$timestamp},v1={$signed}";
|
||||
}
|
||||
|
||||
function makeStripeShoppingPayment(array $overrides = []): ShoppingPayment
|
||||
{
|
||||
$owner = User::forceCreate([
|
||||
'email' => 'stripe-wh-owner-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$country = ShippingCountry::first()
|
||||
?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => $owner->id,
|
||||
'billing_firstname' => 'Stripe',
|
||||
'billing_lastname' => 'Test',
|
||||
'billing_email' => 'stripe-wh-'.uniqid().'@example.com',
|
||||
'billing_country_id' => $country->id,
|
||||
'shipping_country_id' => $country->id,
|
||||
]);
|
||||
|
||||
$userShop = UserShop::first() ?? UserShop::create([
|
||||
'user_id' => $owner->id,
|
||||
'name' => 'WH Stripe Shop',
|
||||
'slug' => 'wh-stripe-'.uniqid(),
|
||||
'domain' => 'wh-stripe.local',
|
||||
]);
|
||||
|
||||
$order = ShoppingOrder::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => $owner->id,
|
||||
'country_id' => $country->country_id,
|
||||
'user_shop_id' => $userShop->id,
|
||||
'total' => 99.00,
|
||||
'paid' => false,
|
||||
'mode' => 'test',
|
||||
]);
|
||||
|
||||
return ShoppingPayment::create(array_merge([
|
||||
'shopping_order_id' => $order->id,
|
||||
'payment_provider' => 'stripe',
|
||||
'reference' => 'test-'.uniqid(),
|
||||
'amount' => 9900,
|
||||
'currency' => 'EUR',
|
||||
'clearingtype' => 'stripe',
|
||||
'mode' => 'test',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
// --- Signatur & Idempotenz ---
|
||||
|
||||
it('rejects a webhook with an invalid signature and stores nothing', function () {
|
||||
$payload = stripeEventPayload('payment_intent.succeeded', ['id' => 'pi_1', 'object' => 'payment_intent']);
|
||||
|
||||
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload, 'whsec_wrong'));
|
||||
|
||||
expect($result->statusCode)->toBe(400)
|
||||
->and($result->state)->toBe('invalid_signature')
|
||||
->and(StripeWebhookEvent::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('answers with 500 when the webhook secret is not configured so stripe retries', function () {
|
||||
config(['stripe.webhook_secret' => '']);
|
||||
$payload = stripeEventPayload('payment_intent.succeeded', ['id' => 'pi_1', 'object' => 'payment_intent']);
|
||||
|
||||
$result = app(StripeWebhookHandler::class)->handle($payload, 't=1,v1=abc');
|
||||
|
||||
expect($result->statusCode)->toBe(500)
|
||||
->and($result->state)->toBe('not_configured');
|
||||
});
|
||||
|
||||
it('acknowledges but ignores a duplicate event id', function () {
|
||||
$object = ['id' => 'pi_dup', 'object' => 'payment_intent', 'status' => 'succeeded'];
|
||||
$payload = stripeEventPayload('payment_intent.succeeded', $object, 'evt_dup');
|
||||
$handler = app(StripeWebhookHandler::class);
|
||||
|
||||
$first = $handler->handle($payload, stripeSignature($payload));
|
||||
$second = $handler->handle($payload, stripeSignature($payload));
|
||||
|
||||
expect($first->state)->toBe('processed')
|
||||
->and($second->state)->toBe('duplicate')
|
||||
->and($second->statusCode)->toBe(200)
|
||||
->and(StripeWebhookEvent::count())->toBe(1);
|
||||
});
|
||||
|
||||
// --- Verarbeitung & Protokollierung ---
|
||||
|
||||
it('stores a valid event of an unhandled type and acknowledges it', function () {
|
||||
$payload = stripeEventPayload('customer.created', ['id' => 'cus_1', 'object' => 'customer'], 'evt_unhandled');
|
||||
|
||||
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
|
||||
|
||||
expect($result->state)->toBe('ignored')
|
||||
->and($result->statusCode)->toBe(200)
|
||||
->and(StripeWebhookEvent::whereEventId('evt_unhandled')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('records a payment transaction when the payment is resolvable via metadata', function () {
|
||||
$payment = makeStripeShoppingPayment();
|
||||
$object = [
|
||||
'id' => 'pi_meta',
|
||||
'object' => 'payment_intent',
|
||||
'status' => 'succeeded',
|
||||
'livemode' => false,
|
||||
'client_secret' => 'pi_meta_secret_abc',
|
||||
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
||||
];
|
||||
$payload = stripeEventPayload('payment_intent.succeeded', $object, 'evt_meta');
|
||||
|
||||
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
|
||||
|
||||
$transaction = PaymentTransaction::where('shopping_payment_id', $payment->id)->first();
|
||||
|
||||
expect($result->state)->toBe('processed')
|
||||
->and($transaction)->not->toBeNull()
|
||||
->and($transaction->provider)->toBe('stripe')
|
||||
->and($transaction->txid)->toBe('pi_meta')
|
||||
->and($transaction->status)->toBe('succeeded')
|
||||
->and($transaction->mode)->toBe('test')
|
||||
->and($transaction->transmitted_data)->not->toHaveKey('client_secret');
|
||||
});
|
||||
|
||||
it('resolves the payment via external_reference and marks the event processed', function () {
|
||||
$payment = makeStripeShoppingPayment(['external_reference' => 'pi_ext_1']);
|
||||
$object = ['id' => 'pi_ext_1', 'object' => 'payment_intent', 'status' => 'processing', 'livemode' => false];
|
||||
$payload = stripeEventPayload('payment_intent.processing', $object, 'evt_ext');
|
||||
|
||||
app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
|
||||
|
||||
expect(PaymentTransaction::where('shopping_payment_id', $payment->id)->count())->toBe(1)
|
||||
->and(StripeWebhookEvent::whereEventId('evt_ext')->first()->processed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('redacts client_secret recursively from stored payloads', function () {
|
||||
$redacted = StripeTransactionRecorder::redactPayload([
|
||||
'id' => 'pi_1',
|
||||
'client_secret' => 'geheim',
|
||||
'charges' => ['data' => [['id' => 'ch_1', 'client_secret' => 'auch-geheim']]],
|
||||
]);
|
||||
|
||||
expect($redacted)->not->toHaveKey('client_secret')
|
||||
->and($redacted['charges']['data'][0])->not->toHaveKey('client_secret')
|
||||
->and($redacted['id'])->toBe('pi_1');
|
||||
});
|
||||
|
||||
// --- Client-Wrapper: verschobener Hard-Fail ---
|
||||
|
||||
it('fails hard on first use when the stripe secret is missing', function () {
|
||||
$wrapper = new StripeClientWrapper(null);
|
||||
|
||||
expect($wrapper->isConfigured())->toBeFalse()
|
||||
->and(fn () => $wrapper->client())->toThrow(RuntimeException::class, 'STRIPE_SECRET');
|
||||
});
|
||||
|
||||
it('builds a stripe client lazily when a secret is configured', function () {
|
||||
$wrapper = new StripeClientWrapper('sk_test_fake');
|
||||
|
||||
expect($wrapper->isConfigured())->toBeTrue()
|
||||
->and($wrapper->client())->toBeInstanceOf(StripeClient::class)
|
||||
->and($wrapper->client())->toBe($wrapper->client());
|
||||
});
|
||||
|
||||
// --- Customer-Service ---
|
||||
|
||||
it('returns an existing stripe customer id without calling the api', function () {
|
||||
$user = User::forceCreate([
|
||||
'email' => 'stripe-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
$user->forceFill(['stripe_customer_id' => 'cus_existing'])->save();
|
||||
|
||||
$wrapper = Mockery::mock(StripeClientWrapper::class);
|
||||
$wrapper->shouldNotReceive('client');
|
||||
|
||||
expect((new StripeCustomerService($wrapper))->customerIdFor($user->fresh()))
|
||||
->toBe('cus_existing');
|
||||
});
|
||||
|
||||
it('creates a stripe customer once and persists its id', function () {
|
||||
$user = User::forceCreate([
|
||||
'email' => 'stripe-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
|
||||
$client->shouldReceive('request')
|
||||
->once()
|
||||
->andReturn(Customer::constructFrom(['id' => 'cus_new_1', 'object' => 'customer']));
|
||||
|
||||
$wrapper = Mockery::mock(StripeClientWrapper::class);
|
||||
$wrapper->shouldReceive('client')->andReturn($client);
|
||||
|
||||
$customerId = (new StripeCustomerService($wrapper))->customerIdFor($user);
|
||||
|
||||
expect($customerId)->toBe('cus_new_1')
|
||||
->and($user->fresh()->stripe_customer_id)->toBe('cus_new_1');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue