Stripe-Integration (S4, S5, S7.6): Abos, Zahlungsart ändern, Zahlarten-Kommunikation

Phase S4 – Abos über Stripe (E2E komplett im Testmodus):
- Abo-/Recovery-Checkout: customer_creation=always + setup_future_usage=off_session
- StripePaymentService::chargeAbo (off-session PaymentIntent, Idempotency-Key),
  StripeGateway, Provider-Dispatch im Abbuchungs-Cron (PAYONE-Pfad unverändert)
- Webhook: Zahlungsmittel-Capture vor Paid-Pfad, 503 payment_not_yet_visible bei
  Cron-Transaktions-Race; Command stripe:process-pending-events (Cron-Ende + 10-min
  Scheduler) verarbeitet liegengebliebene Events nach
- createNewAbo/advanceAboAfterRecoveryPayment Stripe-aware inkl. Race-Absicherung;
  Recovery-Orders nutzen advanceAboAfterRecoveryPayment (latenter PAYONE-Duplikat-Bug)
- Migrationen: Stripe-Abo-Spalten auf shopping_payments; clearingtype varchar(3)→(20)
  inkl. Reparatur der zu 'str' gekürzten Live-Zeilen

Phase S5 – Zahlungsart ändern (Scope: jede Zahlart, nicht nur Kreditkarte):
- Stripe Checkout mode=setup ohne Abbuchung; PAYONE-Abos ziehen beim Wechsel auf
  Stripe um (Auslaufmodell §7.2)
- AboPaymentMethodService: beginStripeChange/finalize/applyStripePaymentMethod
  (idempotent, detacht alte PM, card_changes-Log); Webhook setup_intent.succeeded
  als Sicherheitsnetz; Einstiege Portal/CRM/Berater-Kunden-Link flow-abhängig
- Views/Mails flow-abhängig („Zahlungsweise" statt „Kreditkarte")

Phase S7.6 – Zahlarten-Kommunikation:
- Einheitliche Zahlarten-Logos (SVG) + wiederverwendbares Partial payment_logos;
  Checkout/Footer zeigen Kern-Zahlarten + „···"-Icon, /zahlungsarten komplett neu
  mit allen aktiven Stripe-Zahlarten (gegen Account-Konfiguration verifiziert)
  und Vorkasse (PAYONE); Sofort/SEPA entfernt

Tests: StripeAboTest (15), StripePaymentMethodChangeTest (10),
PaymentMethodsCommunicationTest (28), PaymentProviderManagerTest angepasst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-09 14:45:17 +00:00
parent 6f21845607
commit ce4750fd1a
68 changed files with 2856 additions and 236 deletions

View file

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use App\Models\StripeWebhookEvent;
use App\Services\Payment\Stripe\StripeWebhookHandler;
use Illuminate\Console\Command;
/**
* Verarbeitet gespeicherte, aber noch unverarbeitete Stripe-Webhook-Events nach
* (Plan dev/2026-07-08-stripe/, S6.4 vorgezogen in S4).
*
* Hintergrund: Der Abo-Cron bucht innerhalb einer DB-Transaktion ab — der Webhook
* kann den Commit überholen und wird dann mit 503 beantwortet. In Produktion
* stellt Stripe non-2xx erneut zu (mit Verzögerung), die Stripe CLI auf dem
* Test-Server jedoch nicht. Dieses Command schließt beide Lücken: es läuft am
* Ende des Abo-Crons und regelmäßig über den Scheduler.
*/
class StripeProcessPendingEvents extends Command
{
protected $signature = 'stripe:process-pending-events
{--event= : Nur dieses eine Event (event_id) nachverarbeiten}';
protected $description = 'Verarbeitet gespeicherte, unverarbeitete Stripe-Webhook-Events nach (z. B. nach Cron-Race oder verpasstem Retry)';
public function handle(StripeWebhookHandler $webhookHandler): int
{
$query = StripeWebhookEvent::whereNull('processed_at')
->whereIn('type', StripeWebhookHandler::HANDLED_TYPES)
->orderBy('id');
if ($this->option('event')) {
$query->where('event_id', $this->option('event'));
}
$pending = $query->get();
if ($pending->isEmpty()) {
$this->info('Keine unverarbeiteten Stripe-Events.');
return self::SUCCESS;
}
$processed = 0;
$skipped = 0;
foreach ($pending as $storedEvent) {
try {
if ($webhookHandler->processStoredEvent($storedEvent)) {
$processed++;
$this->info("Verarbeitet: {$storedEvent->event_id} ({$storedEvent->type})");
} else {
$skipped++;
$this->warn("Weiterhin nicht zuordenbar: {$storedEvent->event_id} ({$storedEvent->type})");
}
} catch (\Throwable $e) {
$skipped++;
$this->error("Fehler bei {$storedEvent->event_id}: {$e->getMessage()}");
\Log::channel('stripe')->error('stripe:process-pending-events fehlgeschlagen', [
'event_id' => $storedEvent->event_id,
'type' => $storedEvent->type,
'error' => $e->getMessage(),
]);
}
}
\Log::channel('stripe')->info("stripe:process-pending-events: {$processed} verarbeitet, {$skipped} übersprungen.");
$this->info("Fertig: {$processed} verarbeitet, {$skipped} übersprungen.");
return self::SUCCESS;
}
}

View file

@ -79,6 +79,12 @@ class UserMakeAboOrder extends Command
try { try {
$this->checkAbosToOrder(); $this->checkAbosToOrder();
// Stripe-Webhooks können die Cron-DB-Transaktion überholen (503 →
// Event bleibt unverarbeitet). Nach dem Lauf sind alle Zahlungen
// committet — jetzt liegengebliebene Events nachverarbeiten.
$this->call('stripe:process-pending-events');
$executionTime = $this->getExecutionTime(); $executionTime = $this->getExecutionTime();
\Log::channel('cron')->info("UserMakeAboOrder: Befehl erfolgreich abgeschlossen in {$executionTime}"); \Log::channel('cron')->info("UserMakeAboOrder: Befehl erfolgreich abgeschlossen in {$executionTime}");
$this->info("Befehl erfolgreich abgeschlossen in {$executionTime}"); $this->info("Befehl erfolgreich abgeschlossen in {$executionTime}");

View file

@ -52,6 +52,13 @@ class Kernel extends ConsoleKernel
$schedule->command('user:cleanup')->dailyAt('03:30'); $schedule->command('user:cleanup')->dailyAt('03:30');
$schedule->command('user:make_abo_order')->dailyAt('04:00'); $schedule->command('user:make_abo_order')->dailyAt('04:00');
// Stripe: liegengebliebene Webhook-Events nachverarbeiten (Cron-Race /
// verzögerter Stripe-Retry; Stripe CLI auf dem Test-Server retryt gar nicht).
$schedule->command('stripe:process-pending-events')
->everyTenMinutes()
->withoutOverlapping()
->runInBackground();
// Abo-Einmalprodukte: Reminder-Mail vor der Ausführung (Einmalprodukte-Fenster). // Abo-Einmalprodukte: Reminder-Mail vor der Ausführung (Einmalprodukte-Fenster).
// BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP // BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP
// im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren: // im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren:

View file

@ -64,14 +64,15 @@ class UserMakeOrder
return $ret; return $ret;
} }
private $stripeShoppingPayment;
public function makePayment($testmode = false) public function makePayment($testmode = false)
{ {
Log::info('Starte Zahlungsvorgang für UserAbo ID: '.$this->userAbo->id); Log::info('Starte Zahlungsvorgang für UserAbo ID: '.$this->userAbo->id);
$provider = app(PaymentProviderManager::class)->providerFor($this->userAbo); $provider = app(PaymentProviderManager::class)->providerFor($this->userAbo);
if ($provider !== PaymentProvider::Payone) { if ($provider === PaymentProvider::Stripe) {
Log::error('Abo-Abbuchung für Provider "'.$provider->value.'" ist noch nicht implementiert. UserAbo ID: '.$this->userAbo->id); return $this->makeStripePayment();
throw new \RuntimeException('Abo-Abbuchung für Provider "'.$provider->value.'" ist noch nicht implementiert (UserAbo '.$this->userAbo->id.').');
} }
try { try {
@ -96,6 +97,39 @@ class UserMakeOrder
} }
} }
/**
* Stripe-Abo (Plan S4.4): off-session Abbuchung über das provider-neutrale
* Gateway. Rückgabeformat entspricht der PAYONE-Response, damit der
* aufrufende Cron/Retry unverändert verzweigen kann (APPROVED/ERROR/PENDING).
*
* @return array<string, mixed>
*/
private function makeStripePayment(): array
{
$gateway = app(PaymentProviderManager::class)->gateway(PaymentProvider::Stripe);
$amountCents = (int) round($this->shopping_order->total_shipping * 100);
$result = $gateway->chargeAbo(
$this->userAbo,
$this->shopping_order,
$this->shopping_user,
$amountCents,
'EUR',
);
$this->stripeShoppingPayment = $result->shoppingPayment;
Log::info('Stripe-Zahlungsvorgang abgeschlossen für UserAbo ID: '.$this->userAbo->id.', Status: '.$result->status);
return [
'status' => $result->status,
'txid' => $result->transactionId,
'errormessage' => $result->errorMessage,
'customermessage' => $result->customerMessage,
'provider' => $result->provider->value,
];
}
private function recordPaymentTransaction(mixed $response): void private function recordPaymentTransaction(mixed $response): void
{ {
$shoppingPayment = $this->getShoppingPayment(); $shoppingPayment = $this->getShoppingPayment();
@ -120,6 +154,10 @@ class UserMakeOrder
{ {
Log::info('Rufe Zahlungsinformationen ab für UserAbo ID: '.$this->userAbo->id); Log::info('Rufe Zahlungsinformationen ab für UserAbo ID: '.$this->userAbo->id);
if ($this->stripeShoppingPayment) {
return $this->stripeShoppingPayment;
}
if ($this->pay) { if ($this->pay) {
$payment = $this->pay->getShoppingPayment(); $payment = $this->pay->getShoppingPayment();
Log::info('Zahlungsinformationen abgerufen: '.($payment ? 'erfolgreich' : 'nicht verfügbar')); Log::info('Zahlungsinformationen abgerufen: '.($payment ? 'erfolgreich' : 'nicht verfügbar'));

View file

@ -22,6 +22,7 @@ use App\Services\Shop;
use App\Services\UserService; use App\Services\UserService;
use App\Services\Util; use App\Services\Util;
use Auth; use Auth;
use Carbon\Carbon;
use Request; use Request;
use Yard; use Yard;
@ -122,8 +123,8 @@ class AboController extends Controller
if ($changeLocked) { if ($changeLocked) {
$message = __('abo.error_change_locked', [ $message = __('abo.error_change_locked', [
'days' => (int) now()->diffInDays(\Carbon\Carbon::parse($user_abo->next_date), false), 'days' => (int) now()->diffInDays(Carbon::parse($user_abo->next_date), false),
'lock' => \App\Repositories\AboRepository::getLockDaysChange(), 'lock' => AboRepository::getLockDaysChange(),
]); ]);
} }
@ -552,7 +553,7 @@ class AboController extends Controller
$user_abo = UserAbo::findOrFail($id); $user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo); $this->checkPortalPermission($user_abo);
if (! $user_abo->canUpdatePaymentMethod()) { if (! app(AboPaymentMethodService::class)->canStartChange($user_abo)) {
\Session()->flash('alert-error', __('abo.card_update_not_allowed')); \Session()->flash('alert-error', __('abo.card_update_not_allowed'));
return redirect(route('portal.my_subscriptions')); return redirect(route('portal.my_subscriptions'));
@ -595,11 +596,49 @@ class AboController extends Controller
return redirect(route('portal_abos_payment_method_edit', [$user_abo->id]))->withInput(); return redirect(route('portal_abos_payment_method_edit', [$user_abo->id]))->withInput();
} }
/**
* Startet den Stripe-Setup-Flow "Zahlungsart ändern" (Plan S5): Redirect
* zur Hosted-Seite, freie Zahlartenwahl, keine Abbuchung. Gilt auch für
* PAYONE-Bestandsabos (Umzug auf Stripe bei Erfolg).
*/
public function startStripePaymentMethodChange($id, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo);
abort_unless($service->usesStripeChange(), 404);
$result = $service->beginStripeChange(
$user_abo,
route('portal_abos_payment_method_return', [$user_abo->id, 'success']).'?session_id={CHECKOUT_SESSION_ID}',
route('portal_abos_payment_method_return', [$user_abo->id, 'cancel']),
'portal',
);
if ($result->isRedirect()) {
return redirect()->away($result->redirectUrl);
}
\Session()->flash('alert-error', $result->errorMessage ?? __('abo.payment_method_change_failed'));
return redirect(route('portal_abos_payment_method_edit', [$user_abo->id]));
}
public function paymentMethodReturn($id, $status, AboPaymentMethodService $service) public function paymentMethodReturn($id, $status, AboPaymentMethodService $service)
{ {
$user_abo = UserAbo::findOrFail($id); $user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo); $this->checkPortalPermission($user_abo);
// Stripe-Setup-Return (Plan S5): Session prüfen und Zahlungsart verankern.
if ($status === 'success' && ($sessionId = request('session_id'))) {
if ($service->finalizeStripeChangeFromSession($user_abo, (string) $sessionId, 'portal')) {
\Session()->flash('alert-success', __('abo.payment_method_change_success'));
} else {
\Session()->flash('alert-error', __('abo.payment_method_change_failed'));
}
return redirect(route('portal.my_subscriptions'));
}
$stash = session()->pull('abo_card_update.'.$user_abo->id); $stash = session()->pull('abo_card_update.'.$user_abo->id);
if ($status === 'success' && is_array($stash)) { if ($status === 'success' && is_array($stash)) {

View file

@ -650,7 +650,7 @@ class AboController extends Controller
$this->checkPermissions($view, $user_abo); $this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $user_abo); $this->assertOwnerCardEntry($view, $user_abo);
if (! $user_abo->canUpdatePaymentMethod()) { if (! app(AboPaymentMethodService::class)->canStartChange($user_abo)) {
\Session()->flash('alert-error', __('abo.card_update_not_allowed')); \Session()->flash('alert-error', __('abo.card_update_not_allowed'));
return redirect(route('user_abos_detail', [$view, $user_abo->id])); return redirect(route('user_abos_detail', [$view, $user_abo->id]));
@ -693,12 +693,51 @@ class AboController extends Controller
return redirect(route('user_abos_payment_method_edit', [$view, $user_abo->id]))->withInput(); return redirect(route('user_abos_payment_method_edit', [$view, $user_abo->id]))->withInput();
} }
/**
* Startet den Stripe-Setup-Flow "Zahlungsart ändern" (Plan S5): Redirect
* zur Hosted-Seite, freie Zahlartenwahl, keine Abbuchung. Gilt auch für
* PAYONE-Bestandsabos (Umzug auf Stripe bei Erfolg).
*/
public function startStripePaymentMethodChange($view, $id, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $user_abo);
abort_unless($service->usesStripeChange(), 404);
$result = $service->beginStripeChange(
$user_abo,
route('user_abos_payment_method_return', [$view, $user_abo->id, 'success']).'?session_id={CHECKOUT_SESSION_ID}',
route('user_abos_payment_method_return', [$view, $user_abo->id, 'cancel']),
'crm',
);
if ($result->isRedirect()) {
return redirect()->away($result->redirectUrl);
}
\Session()->flash('alert-error', $result->errorMessage ?? __('abo.payment_method_change_failed'));
return redirect(route('user_abos_payment_method_edit', [$view, $user_abo->id]));
}
public function paymentMethodReturn($view, $id, $status, AboPaymentMethodService $service) public function paymentMethodReturn($view, $id, $status, AboPaymentMethodService $service)
{ {
$user_abo = UserAbo::findOrFail($id); $user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo); $this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $user_abo); $this->assertOwnerCardEntry($view, $user_abo);
// Stripe-Setup-Return (Plan S5): Session prüfen und Zahlungsart verankern.
if ($status === 'success' && ($sessionId = request('session_id'))) {
if ($service->finalizeStripeChangeFromSession($user_abo, (string) $sessionId, 'crm')) {
\Session()->flash('alert-success', __('abo.payment_method_change_success'));
} else {
\Session()->flash('alert-error', __('abo.payment_method_change_failed'));
}
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
$stash = session()->pull('abo_card_update.'.$user_abo->id); $stash = session()->pull('abo_card_update.'.$user_abo->id);
if ($status === 'success' && is_array($stash)) { if ($status === 'success' && is_array($stash)) {
@ -729,7 +768,11 @@ class AboController extends Controller
return redirect(route('user_abos_detail', [$view, $user_abo->id])); return redirect(route('user_abos_detail', [$view, $user_abo->id]));
} }
if ($view !== 'ot' || $user_abo->is_for !== 'ot' || ! $user_abo->isCreditCard() || ! $user_abo->canUpdatePaymentMethod()) { // Stripe-Setup-Flow: Link ist für jede Zahlart möglich (freie Wahl bei
// Stripe); die "nur CC"-Einschränkung gilt nur für den PAYONE-Legacy-Flow.
$requiresCreditCard = ! app(AboPaymentMethodService::class)->usesStripeChange();
if ($view !== 'ot' || $user_abo->is_for !== 'ot' || ($requiresCreditCard && ! $user_abo->isCreditCard()) || ! app(AboPaymentMethodService::class)->canStartChange($user_abo)) {
\Session()->flash('alert-error', __('abo.card_update_link_not_allowed')); \Session()->flash('alert-error', __('abo.card_update_link_not_allowed'));
return redirect(route('user_abos_detail', [$view, $user_abo->id])); return redirect(route('user_abos_detail', [$view, $user_abo->id]));

View file

@ -56,11 +56,46 @@ class AboCardUpdateController extends Controller
->withErrors(['cc_cardpan' => $result->errorMessage ?? __('abo.card_update_failed')]); ->withErrors(['cc_cardpan' => $result->errorMessage ?? __('abo.card_update_failed')]);
} }
/**
* Startet den Stripe-Setup-Flow "Zahlungsart ändern" (Plan S5) über den
* tokenisierten Link: Redirect zur Hosted-Seite, freie Zahlartenwahl.
*/
public function startStripe(string $token, AboPaymentMethodService $service)
{
$link = $this->resolveRedeemableLink($token);
abort_unless($service->usesStripeChange(), 404);
$result = $service->beginStripeChange(
$link->user_abo,
route('abo_card_update_return', [$token, 'success']).'?session_id={CHECKOUT_SESSION_ID}',
route('abo_card_update_return', [$token, 'cancel']),
'link',
);
if ($result->isRedirect()) {
return redirect()->away($result->redirectUrl);
}
return redirect(route('abo_card_update_show', [$token]))
->withErrors(['cc_cardpan' => $result->errorMessage ?? __('abo.payment_method_change_failed')]);
}
public function return(string $token, string $status, AboPaymentMethodService $service) public function return(string $token, string $status, AboPaymentMethodService $service)
{ {
$link = $this->resolveRedeemableLink($token); $link = $this->resolveRedeemableLink($token);
$user_abo = $link->user_abo; $user_abo = $link->user_abo;
// Stripe-Setup-Return (Plan S5): Session prüfen, Zahlungsart verankern, Link entwerten.
if ($status === 'success' && ($sessionId = request('session_id'))) {
if ($service->finalizeStripeChangeFromSession($user_abo, (string) $sessionId, 'link')) {
$link->forceFill(['used_at' => now()])->save();
return view('portal.abo.card_update_done', ['success' => true]);
}
return view('portal.abo.card_update_done', ['success' => false]);
}
$stash = session()->pull('abo_card_update.'.$user_abo->id); $stash = session()->pull('abo_card_update.'.$user_abo->id);
if ($status === 'success' && is_array($stash)) { if ($status === 'success' && is_array($stash)) {
@ -84,7 +119,7 @@ class AboCardUpdateController extends Controller
} }
$abo = $link->user_abo; $abo = $link->user_abo;
if (! $abo || ! $abo->canUpdatePaymentMethod()) { if (! $abo || ! app(AboPaymentMethodService::class)->canStartChange($abo)) {
abort(410, __('abo.card_update_link_invalid')); abort(410, __('abo.card_update_link_invalid'));
} }

View file

@ -98,7 +98,7 @@ class CheckoutController extends Controller
// Stripe aktiv: keine Gateway-Zahlartenwahl im eigenen Checkout mehr — // Stripe aktiv: keine Gateway-Zahlartenwahl im eigenen Checkout mehr —
// nur die Stripe-Option + systeminterne Methoden (Vorkasse/Rechnung). // nur die Stripe-Option + systeminterne Methoden (Vorkasse/Rechnung).
$stripe_active = $this->stripeCheckoutActive($is_abo, $recovery_user_abo_id); $stripe_active = $this->stripeCheckoutActive();
if ($stripe_active) { if ($stripe_active) {
$payment_methods['active'] = array_intersect_key($payment_methods['active'], array_flip(['VOR', 'FNC'])); $payment_methods['active'] = array_intersect_key($payment_methods['active'], array_flip(['VOR', 'FNC']));
} }
@ -192,11 +192,7 @@ class CheckoutController extends Controller
if (isset($data['payment_method'])) { if (isset($data['payment_method'])) {
if ($data['payment_method'] === 'stripe') { if ($data['payment_method'] === 'stripe') {
$shopping_data = Yard::instance($this->instance)->getYardExtra('shopping_data'); abort_unless($this->stripeCheckoutActive(), 404);
abort_unless($this->stripeCheckoutActive(
(bool) ($data['is_abo'] ?? false),
$shopping_data['recovery_user_abo_id'] ?? null,
), 404);
} else { } else {
$this->checkoutRepo->isPaymentsMethodsActive($data['payment_method'], $data['is_from'], $data['is_abo']); $this->checkoutRepo->isPaymentsMethodsActive($data['payment_method'], $data['is_from'], $data['is_abo']);
} }
@ -350,15 +346,12 @@ class CheckoutController extends Controller
/** /**
* Stripe ist für diesen Checkout aktiv, wenn der Anbieter-Schalter auf * Stripe ist für diesen Checkout aktiv, wenn der Anbieter-Schalter auf
* Stripe steht. Abo- und Recovery-Checkouts bleiben bis Phase S4 auf * Stripe steht. Seit Phase S4 gilt das auch für Abo- und Recovery-Checkouts:
* PAYONE (Plan dev/2026-07-08-stripe/, Abgrenzung S3/S4). * Die Erstzahlung speichert das Zahlungsmittel off-session, Recovery-
* Zahlungen ziehen das Abo auf Stripe um (Plan dev/2026-07-08-stripe/, §7.2).
*/ */
private function stripeCheckoutActive(bool $is_abo, mixed $recovery_user_abo_id): bool private function stripeCheckoutActive(): bool
{ {
if ($is_abo || $recovery_user_abo_id) {
return false;
}
return app(PaymentProviderManager::class)->providerForNewCheckout() === PaymentProvider::Stripe; return app(PaymentProviderManager::class)->providerForNewCheckout() === PaymentProvider::Stripe;
} }

View file

@ -3,6 +3,7 @@
namespace App\Mail; namespace App\Mail;
use App\Models\UserAbo; use App\Models\UserAbo;
use App\Services\AboPaymentMethodService;
use App\Services\Util; use App\Services\Util;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
@ -22,11 +23,24 @@ class AboCardUpdateLinkMail extends Mailable
// Link zeigt immer auf das Kundenportal (in.mivita.care), unabhängig vom Versand-Kontext. // Link zeigt immer auf das Kundenportal (in.mivita.care), unabhängig vom Versand-Kontext.
$link = rtrim(Util::getMyMivitaPortalUrl(), '/').'/abo/karte/'.$this->token; $link = rtrim(Util::getMyMivitaPortalUrl(), '/').'/abo/karte/'.$this->token;
return $this->subject(__('abo.card_update_link_mail_subject')) // Texte je Flow: Stripe = freie Zahlartenwahl (Plan S5), PAYONE-Legacy = Kreditkarte.
$stripeChange = app(AboPaymentMethodService::class)->usesStripeChange();
$subject = $stripeChange
? __('abo.payment_method_change_link_mail_subject')
: __('abo.card_update_link_mail_subject');
return $this->subject($subject)
->view('emails.abo_card_update_link') ->view('emails.abo_card_update_link')
->with([ ->with([
'salutation' => $salutation, 'salutation' => $salutation,
'link' => $link, 'link' => $link,
'mailTitle' => $subject,
'mailIntro' => $stripeChange
? __('abo.payment_method_change_link_mail_intro')
: __('abo.card_update_link_mail_intro'),
'mailButton' => $stripeChange
? __('abo.payment_method_change_link_mail_button')
: __('abo.card_update_link_mail_button'),
'greetings' => __('email.greetings'), 'greetings' => __('email.greetings'),
'sender' => __('email.sender'), 'sender' => __('email.sender'),
]); ]);

View file

@ -83,6 +83,9 @@ class ShoppingPayment extends Model
'is_abo', 'is_abo',
'abo_interval', 'abo_interval',
'identifier', 'identifier',
'stripe_customer_id',
'stripe_payment_method_id',
'user_abo_id',
]; ];
public function shopping_order() public function shopping_order()
@ -140,7 +143,17 @@ class ShoppingPayment extends Model
*/ */
private function stripePaymentTypeLabel(): string private function stripePaymentTypeLabel(): string
{ {
$details = is_array($this->carddata) ? $this->carddata : []; return self::stripeLabelFromSummary(is_array($this->carddata) ? $this->carddata : []);
}
/**
* Statisch nutzbar für alle Stellen mit derselben carddata-Summary
* (z. B. UserAbo::getPaymentType für Stripe-Abos).
*
* @param array<string, mixed> $details
*/
public static function stripeLabelFromSummary(array $details): string
{
$type = $details['type'] ?? null; $type = $details['type'] ?? null;
$cardSuffix = ! empty($details['brand']) && ! empty($details['last4']) $cardSuffix = ! empty($details['brand']) && ! empty($details['last4'])

View file

@ -6,6 +6,8 @@
namespace App\Models; namespace App\Models;
use App\Enums\PaymentProvider;
use App\Services\AboPaymentMethodService;
use App\Services\Backoffice\BackofficeDashboardService; use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Util; use App\Services\Util;
use App\User; use App\User;
@ -296,6 +298,17 @@ class UserAbo extends Model
{ {
$pan = $carddata['truncatedcardpan'] ?? null; $pan = $carddata['truncatedcardpan'] ?? null;
if (! $pan) { if (! $pan) {
// Stripe-Summary (type/brand/last4/exp aus payment_method_details)
if (! empty($carddata['last4'])) {
$label = ! empty($carddata['brand']) ? ucfirst($carddata['brand']).' ' : '';
$exp = ! empty($carddata['exp']) ? ' ('.$carddata['exp'].')' : '';
return $label.'•••• '.$carddata['last4'].$exp;
}
if (! empty($carddata['type'])) {
return ShoppingPayment::stripeLabelFromSummary($carddata);
}
return null; return null;
} }
@ -325,6 +338,18 @@ class UserAbo extends Model
&& ! in_array((int) $this->status, [4, 5, 6], true); && ! in_array((int) $this->status, [4, 5, 6], true);
} }
/**
* Zustands-Invariante ohne die "nur CC"-Einschränkung — für den
* Stripe-Setup-Flow "Zahlungsart ändern" (Plan S5), der jede Zahlart
* wechseln kann. Welcher Flow gilt, entscheidet
* {@see AboPaymentMethodService::canStartChange()}.
*/
public function canChangePaymentMethodAnyType(): bool
{
return ! $this->trashed()
&& ! in_array((int) $this->status, [4, 5, 6], true);
}
public function user_abo_items() public function user_abo_items()
{ {
return $this->hasMany(UserAboItem::class); return $this->hasMany(UserAboItem::class);
@ -463,6 +488,10 @@ class UserAbo extends Model
public function getPaymentType() public function getPaymentType()
{ {
if ($this->payment_provider === PaymentProvider::Stripe->value) {
return ShoppingPayment::stripeLabelFromSummary(is_array($this->carddata) ? $this->carddata : []);
}
return $this->clearingtype === 'wlt' ? __('payment.paypal') : __('payment.credit_card'); return $this->clearingtype === 'wlt' ? __('payment.paypal') : __('payment.credit_card');
} }
} }

View file

@ -2,7 +2,10 @@
namespace App\Services; namespace App\Services;
use App\Enums\PaymentProvider;
use App\Models\AboChartSnapshot;
use App\Models\Product; use App\Models\Product;
use App\Models\Setting;
use App\Models\ShoppingOrder; use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment; use App\Models\ShoppingPayment;
use App\Models\ShoppingUser; use App\Models\ShoppingUser;
@ -10,9 +13,11 @@ use App\Models\UserAbo;
use App\Models\UserAboItem; use App\Models\UserAboItem;
use App\Models\UserAboItemHistory; use App\Models\UserAboItemHistory;
use App\Models\UserAboOrder; use App\Models\UserAboOrder;
use App\Repositories\AboRepository;
use App\Services\Incentive\IncentiveTracker; use App\Services\Incentive\IncentiveTracker;
use App\User; use App\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class AboHelper class AboHelper
{ {
@ -92,7 +97,7 @@ class AboHelper
public static function getAboMinDuration() public static function getAboMinDuration()
{ {
return \App\Models\Setting::getContentBySlug('abo-min-duration'); return Setting::getContentBySlug('abo-min-duration');
} }
public static function canCancelAbo(UserAbo $user_abo, $view = 'user') public static function canCancelAbo(UserAbo $user_abo, $view = 'user')
@ -118,7 +123,7 @@ class AboHelper
/** /**
* Prüft, ob das Bearbeiten des Abos (Liefertag/Intervall) aktuell durch die * Prüft, ob das Bearbeiten des Abos (Liefertag/Intervall) aktuell durch die
* Sperrfrist vor der Ausführung blockiert ist. Admins umgehen die Sperre. * Sperrfrist vor der Ausführung blockiert ist. Admins umgehen die Sperre.
* Spiegelt die serverseitige Prüfung in {@see \App\Repositories\AboRepository::validate()} * Spiegelt die serverseitige Prüfung in {@see AboRepository::validate()}
* für die UI (Feld/Button deaktivieren). * für die UI (Feld/Button deaktivieren).
*/ */
public static function isAboChangeLocked(UserAbo $userAbo, string $view = 'user'): bool public static function isAboChangeLocked(UserAbo $userAbo, string $view = 'user'): bool
@ -129,7 +134,7 @@ class AboHelper
$daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false); $daysUntil = (int) Carbon::now()->diffInDays(Carbon::parse($userAbo->next_date), false);
return $daysUntil >= 0 && $daysUntil < \App\Repositories\AboRepository::getLockDaysChange(); return $daysUntil >= 0 && $daysUntil < AboRepository::getLockDaysChange();
} }
public static function canEditAbo($user_abo, $view = 'user') public static function canEditAbo($user_abo, $view = 'user')
@ -196,7 +201,7 @@ class AboHelper
*/ */
public static function getOneTimeWindowDays(): int public static function getOneTimeWindowDays(): int
{ {
$days = (int) \App\Models\Setting::getContentBySlug('abo-onetime-window-days'); $days = (int) Setting::getContentBySlug('abo-onetime-window-days');
return $days > 0 ? $days : self::DEFAULT_ONETIME_WINDOW_DAYS; return $days > 0 ? $days : self::DEFAULT_ONETIME_WINDOW_DAYS;
} }
@ -301,6 +306,13 @@ class AboHelper
return; return;
} }
// Recovery-Zahlungen schreiben ein BESTEHENDES Abo fort (advanceAboAfterRecoveryPayment),
// wiederkehrende Cron-Abbuchungen (user_abo_id an der Zahlung) gehören ebenfalls
// zu einem bestehenden Abo — beides darf nie ein neues Abo erzeugen.
if ($order->recovery_user_abo_id || $shopping_payment->user_abo_id) {
return;
}
// Bereits verknüpft (z. B. Checkout-Erfolgsseite vor Callback) oder wiederholter Aufruf // Bereits verknüpft (z. B. Checkout-Erfolgsseite vor Callback) oder wiederholter Aufruf
if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) { if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) {
return; return;
@ -311,8 +323,25 @@ class AboHelper
return; return;
} }
$isStripe = $shopping_payment->payment_provider === PaymentProvider::Stripe->value;
/*
* Stripe (Plan S4.2): Customer-/PaymentMethod-ID liefert erst der Webhook
* (captureOffSessionPaymentMethod). Läuft der Browser-Return schneller,
* hier NICHT anlegen — der Webhook-Paid-Pfad ruft createNewAbo erneut auf
* und legt das Abo dann vollständig an (einzige Wahrheit wie bei S3).
*/
if ($isStripe && (empty($shopping_payment->stripe_customer_id) || empty($shopping_payment->stripe_payment_method_id))) {
\Log::channel('stripe')->info('AboHelper::createNewAbo: Stripe-IDs noch nicht an der Zahlung — Abo-Anlage dem Webhook überlassen.', [
'shopping_payment_id' => $shopping_payment->id,
'shopping_order_id' => $order->id,
]);
return;
}
$payment_transaction = $shopping_payment->payment_transactions->last(); $payment_transaction = $shopping_payment->payment_transactions->last();
$payoneUserId = $payment_transaction ? (int) $payment_transaction->userid : 0; $payoneUserId = ! $isStripe && $payment_transaction ? (int) $payment_transaction->userid : 0;
// next_date immer im nächsten Monat starten // next_date immer im nächsten Monat starten
// is auth_user_id = Berater bestellung // is auth_user_id = Berater bestellung
@ -325,6 +354,9 @@ class AboHelper
'email' => $order->shopping_user->billing_email, 'email' => $order->shopping_user->billing_email,
'is_for' => $order->shopping_user->is_for, 'is_for' => $order->shopping_user->is_for,
'payone_userid' => $payoneUserId, 'payone_userid' => $payoneUserId,
'payment_provider' => $shopping_payment->payment_provider ?: PaymentProvider::Payone->value,
'stripe_customer_id' => $isStripe ? $shopping_payment->stripe_customer_id : null,
'stripe_payment_method_id' => $isStripe ? $shopping_payment->stripe_payment_method_id : null,
'clearingtype' => $shopping_payment->clearingtype, 'clearingtype' => $shopping_payment->clearingtype,
'wallettype' => $shopping_payment->wallettype, 'wallettype' => $shopping_payment->wallettype,
'carddata' => $shopping_payment->carddata, 'carddata' => $shopping_payment->carddata,
@ -378,14 +410,31 @@ class AboHelper
$aboInterval = (int) ($userAbo->abo_interval ?: $order->abo_interval); $aboInterval = (int) ($userAbo->abo_interval ?: $order->abo_interval);
$isStripe = $shopping_payment->payment_provider === PaymentProvider::Stripe->value;
/*
* Stripe (Plan S4 / §7.2 Nr. 3): Das Abo zieht mit der Recovery-Zahlung auf
* Stripe um. Die IDs liefert der Webhook — fehlen sie noch (Browser-Return
* vor Webhook), NICHT fortschreiben: der Webhook-Paid-Pfad ruft diese
* Methode erneut auf und schreibt dann vollständig fort.
*/
if ($isStripe && (empty($shopping_payment->stripe_customer_id) || empty($shopping_payment->stripe_payment_method_id))) {
\Log::channel('stripe')->info('AboHelper::advanceAboAfterRecoveryPayment: Stripe-IDs noch nicht an der Zahlung — Fortschreibung dem Webhook überlassen.', [
'shopping_payment_id' => $shopping_payment->id,
'user_abo_id' => $userAbo->id,
]);
return;
}
// Die im Recovery-Checkout gewählte Zahlart wird als neues Abo-Zahlungsmittel übernommen, // Die im Recovery-Checkout gewählte Zahlart wird als neues Abo-Zahlungsmittel übernommen,
// damit auch künftige Folgeabbuchungen darüber laufen (das alte Token war i. d. R. defekt). // damit auch künftige Folgeabbuchungen darüber laufen (das alte Token war i. d. R. defekt).
$paymentTransaction = $shopping_payment->payment_transactions->last(); $paymentTransaction = $shopping_payment->payment_transactions->last();
$payoneUserId = $paymentTransaction && (int) $paymentTransaction->userid > 0 $payoneUserId = ! $isStripe && $paymentTransaction && (int) $paymentTransaction->userid > 0
? (int) $paymentTransaction->userid ? (int) $paymentTransaction->userid
: (int) $userAbo->payone_userid; : (int) $userAbo->payone_userid;
\DB::transaction(function () use ($userAbo, $order, $aboInterval, $shopping_payment, $payoneUserId): void { \DB::transaction(function () use ($userAbo, $order, $aboInterval, $shopping_payment, $payoneUserId, $isStripe): void {
$userAbo->update([ $userAbo->update([
'status' => 2, 'status' => 2,
'active' => true, 'active' => true,
@ -393,6 +442,9 @@ class AboHelper
'wallettype' => $shopping_payment->wallettype, 'wallettype' => $shopping_payment->wallettype,
'carddata' => $shopping_payment->carddata, 'carddata' => $shopping_payment->carddata,
'payone_userid' => $payoneUserId, 'payone_userid' => $payoneUserId,
'payment_provider' => $isStripe ? PaymentProvider::Stripe->value : $userAbo->payment_provider,
'stripe_customer_id' => $isStripe ? $shopping_payment->stripe_customer_id : $userAbo->stripe_customer_id,
'stripe_payment_method_id' => $isStripe ? $shopping_payment->stripe_payment_method_id : $userAbo->stripe_payment_method_id,
'next_date' => $aboInterval > 0 ? self::setNextDate(now(), $aboInterval) : $userAbo->next_date, 'next_date' => $aboInterval > 0 ? self::setNextDate(now(), $aboInterval) : $userAbo->next_date,
'last_date' => now(), 'last_date' => now(),
]); ]);
@ -575,7 +627,7 @@ class AboHelper
* - start_date <= letzter Tag von M * - start_date <= letzter Tag von M
* - cancel_date ist NULL oder >= erster Tag von M * - cancel_date ist NULL oder >= erster Tag von M
* *
* @param \Illuminate\Database\Eloquent\Builder $query Basis-Query (gefiltert nach User/Team etc.) * @param Builder $query Basis-Query (gefiltert nach User/Team etc.)
* @param int $year Jahr für die Berechnung * @param int $year Jahr für die Berechnung
* @return int[] Array mit 12 Einträgen (Index 0 = Januar, 11 = Dezember) * @return int[] Array mit 12 Einträgen (Index 0 = Januar, 11 = Dezember)
*/ */
@ -586,13 +638,13 @@ class AboHelper
* Aktueller Monat → live berechnet. * Aktueller Monat → live berechnet.
* Zukünftige Monate → null (kein Balken im Chart). * Zukünftige Monate → null (kein Balken im Chart).
* *
* @param \Illuminate\Database\Eloquent\Builder $liveQuery Basis-Query für den aktuellen Monat * @param Builder $liveQuery Basis-Query für den aktuellen Monat
* @param string $scope 'ot' | 'team_abos' | 'team_cust_abos' * @param string $scope 'ot' | 'team_abos' | 'team_cust_abos'
* @param int $userId Eingeloggter Berater * @param int $userId Eingeloggter Berater
* @return array<int, int|null> 12 Einträge (Index 0 = Jan), null = Zukunft * @return array<int, int|null> 12 Einträge (Index 0 = Jan), null = Zukunft
*/ */
public static function getMonthlyAboCounts( public static function getMonthlyAboCounts(
\Illuminate\Database\Eloquent\Builder $liveQuery, Builder $liveQuery,
int $year, int $year,
string $scope, string $scope,
int $userId int $userId
@ -604,7 +656,7 @@ class AboHelper
$lastCountableMonth = ($year === $currentYear) ? $currentMonth : 12; $lastCountableMonth = ($year === $currentYear) ? $currentMonth : 12;
// Alle vorhandenen Snapshots für diesen User/Scope/Jahr auf einmal laden // Alle vorhandenen Snapshots für diesen User/Scope/Jahr auf einmal laden
$snapshots = \App\Models\AboChartSnapshot::where('user_id', $userId) $snapshots = AboChartSnapshot::where('user_id', $userId)
->where('scope', $scope) ->where('scope', $scope)
->where('year', $year) ->where('year', $year)
->get() ->get()

View file

@ -2,25 +2,55 @@
namespace App\Services; namespace App\Services;
use App\Enums\PaymentProvider;
use App\Models\UserAbo; use App\Models\UserAbo;
use App\Services\Payment\PaymentProviderManager; use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Payone\DTOs\CardUpdateResult; use App\Services\Payment\Payone\DTOs\CardUpdateResult;
use App\Services\Payment\Payone\DTOs\CreditCardCheckData; use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
use App\Services\Payment\Payone\PayonePaymentService; use App\Services\Payment\Payone\PayonePaymentService;
use App\Services\Payment\Stripe\StripePaymentService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Throwable; use Throwable;
/** /**
* Orchestrates updating the credit-card token of an existing Abo via PAYONE * Orchestrates updating the payment method of an existing Abo. The card data /
* (creditcardcheck + preauthorization, no charge — see the Phase-1 plan * payment method is chosen exclusively by the Abo owner; guard-/owner-specific
* dev/2026-06-25-kreditkarte-update/). The card data is entered exclusively by the * authorization happens in the calling controller, the invariant in
* Abo owner; guard-/owner-specific authorization happens in the calling controller, * {@see UserAbo::canUpdatePaymentMethod()}.
* the card-only/active invariant in {@see UserAbo::canUpdatePaymentMethod()}. *
* Two flows:
* - PAYONE (legacy, CC only): creditcardcheck + preauthorization, no charge
* (Phase-1 plan dev/2026-06-25-kreditkarte-update/).
* - Stripe (Plan S5, dev/2026-07-08-stripe/): hosted Checkout Session in
* setup mode — free choice of any reusable payment method, no charge.
* Applies to PAYONE legacy abos too: changing the payment method moves the
* abo to Stripe (§7.2 no. 3, soft migration).
*/ */
class AboPaymentMethodService class AboPaymentMethodService
{ {
public function __construct(private readonly PayonePaymentService $payone) {} public function __construct(private readonly PayonePaymentService $payone) {}
/**
* Der Stripe-Setup-Flow gilt, sobald der Anbieter-Schalter auf Stripe
* steht — unabhängig vom Provider des Abos (PAYONE-Abos ziehen beim
* Wechsel auf Stripe um).
*/
public function usesStripeChange(): bool
{
return app(PaymentProviderManager::class)->providerForNewCheckout() === PaymentProvider::Stripe;
}
/**
* Zentrale Einstiegs-Prüfung für Controller/Views: Im Stripe-Setup-Flow
* ist jede Zahlart wechselbar, im PAYONE-Legacy-Flow nur Kreditkarten.
*/
public function canStartChange(UserAbo $abo): bool
{
return $this->usesStripeChange()
? $abo->canChangePaymentMethodAnyType()
: $abo->canUpdatePaymentMethod();
}
/** /**
* Runs the card update. On success the new token is written atomically; a 3-D Secure * Runs the card update. On success the new token is written atomically; a 3-D Secure
* challenge is surfaced as a REDIRECT outcome (finalized after return via * challenge is surfaced as a REDIRECT outcome (finalized after return via
@ -105,6 +135,141 @@ class AboPaymentMethodService
} }
} }
/**
* Startet den Stripe-Setup-Flow (Plan S5.1/S5.3): Pending-Spur setzen,
* Setup-Session erstellen, Redirect-URL zurückgeben. Kein Charge.
*
* @param string $successUrl Muss den Platzhalter {CHECKOUT_SESSION_ID} enthalten.
*/
public function beginStripeChange(UserAbo $abo, string $successUrl, string $cancelUrl, ?string $source = null): CardUpdateResult
{
if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) {
return CardUpdateResult::error(__('payment.abo_payments_frozen'));
}
if (! $abo->canChangePaymentMethodAnyType()) {
return CardUpdateResult::error(__('abo.card_update_not_allowed'));
}
// Konsistenz-Spur wie beim PAYONE-Flow: vor dem externen Call setzen.
$abo->forceFill(['card_update_pending_at' => now()])->save();
try {
$session = app(StripePaymentService::class)->createPaymentMethodChangeSession(
$abo,
$successUrl,
$cancelUrl,
$source,
);
} catch (Throwable $e) {
$abo->forceFill(['card_update_pending_at' => null])->save();
\Log::channel('stripe')->error('Zahlungsart-Wechsel: Setup-Session konnte nicht erstellt werden: '.$e->getMessage(), [
'user_abo_id' => $abo->id,
]);
return CardUpdateResult::error(__('payment.stripe_unavailable'));
}
return CardUpdateResult::redirect((string) $session->url, [], '');
}
/**
* Return-Pfad des Setup-Flows: Session nachladen, SetupIntent prüfen und
* die neue Zahlungsart verankern. Der setup_intent.succeeded-Webhook macht
* dasselbe als Sicherheitsnetz — applyStripePaymentMethod() ist idempotent.
*/
public function finalizeStripeChangeFromSession(UserAbo $abo, string $sessionId, ?string $source = null): bool
{
try {
$session = app(StripePaymentService::class)->retrieveSetupSession($sessionId);
} catch (Throwable $e) {
\Log::channel('stripe')->error('Zahlungsart-Wechsel: Setup-Session nicht abrufbar: '.$e->getMessage(), [
'user_abo_id' => $abo->id,
'session_id' => $sessionId,
]);
return false;
}
// Session muss zu genau diesem Abo gehören (kein fremdes session_id-Query-Param).
if ((string) ($session->metadata['user_abo_id'] ?? '') !== (string) $abo->id) {
return false;
}
$setupIntent = $session->setup_intent;
if (! $setupIntent || $setupIntent->status !== 'succeeded' || empty($setupIntent->payment_method)) {
$abo->forceFill(['card_update_pending_at' => null])->save();
return false;
}
$paymentMethodId = is_string($setupIntent->payment_method)
? $setupIntent->payment_method
: $setupIntent->payment_method->id;
$customerId = is_string($session->customer) ? $session->customer : $session->customer->id;
$this->applyStripePaymentMethod($abo, $customerId, $paymentMethodId, $source);
return true;
}
/**
* Verankert die neue Stripe-Zahlungsart atomar und idempotent am Abo und
* zieht PAYONE-Abos dabei auf Stripe um (§7.2 Nr. 3). Wird vom Return-Pfad
* UND vom setup_intent.succeeded-Webhook aufgerufen.
*/
public function applyStripePaymentMethod(UserAbo $abo, string $customerId, string $paymentMethodId, ?string $source = null): void
{
// Idempotenz: Webhook und Return-Pfad laufen beide — nur einmal verankern.
if ($abo->stripe_payment_method_id === $paymentMethodId) {
if ($abo->card_update_pending_at !== null) {
$abo->forceFill(['card_update_pending_at' => null])->save();
}
return;
}
$previousPaymentMethodId = $abo->payment_provider === PaymentProvider::Stripe->value
? $abo->stripe_payment_method_id
: null;
$summary = app(StripePaymentService::class)->retrievePaymentMethodSummary($paymentMethodId);
try {
DB::transaction(function () use ($abo, $customerId, $paymentMethodId, $summary, $source) {
$abo->forceFill([
'payment_provider' => PaymentProvider::Stripe->value,
'stripe_customer_id' => $customerId,
'stripe_payment_method_id' => $paymentMethodId,
'clearingtype' => 'stripe',
'wallettype' => null,
'carddata' => $summary,
'card_update_pending_at' => null,
])->save();
$abo->card_changes()->create([
'masked_card' => UserAbo::formatMaskedCard($summary),
'source' => $source,
]);
});
} catch (Throwable $e) {
// Stripe hat die neue Zahlungsart, der lokale DB-Write schlug fehl → Inkonsistenz:
// card_update_pending_at bleibt gesetzt (auffindbare Spur), CRITICAL-Alert.
MyLog::writeLog(
'payment',
'critical',
'Zahlungsart-Wechsel (Stripe): DB-Write nach erfolgreichem Setup fehlgeschlagen (Abo '.$abo->id.')',
['user_abo_id' => $abo->id, 'payment_method' => $paymentMethodId, 'exception' => $e->getMessage()],
);
throw $e;
}
if ($previousPaymentMethodId && $previousPaymentMethodId !== $paymentMethodId) {
app(StripePaymentService::class)->detachPaymentMethod($previousPaymentMethodId);
}
}
private function safelyReleasePreauth(string $txid, string $mode): void private function safelyReleasePreauth(string $txid, string $mode): void
{ {
if ($txid === '') { if ($txid === '') {

View file

@ -286,7 +286,12 @@ class Payment
'shopping_order.shopping_user', 'shopping_order.shopping_user',
'shopping_order.shopping_order_items', 'shopping_order.shopping_order_items',
]); ]);
if (! $shopping_order->getUserAbo()) { if ($shopping_order->recovery_user_abo_id) {
// Recovery-Zahlung: bestehendes Abo fortschreiben statt neues anlegen
// (vorher lief das nur im Browser-Return — der Webhook/Callback kann schneller sein).
AboHelper::advanceAboAfterRecoveryPayment($shopping_payment);
$shopping_order->refresh();
} elseif (! $shopping_order->getUserAbo()) {
AboHelper::createNewAbo($shopping_payment); AboHelper::createNewAbo($shopping_payment);
$shopping_order->refresh(); $shopping_order->refresh();
} }

View file

@ -83,9 +83,7 @@ class PaymentProviderManager
{ {
return match ($provider) { return match ($provider) {
PaymentProvider::Payone => app(PayoneGateway::class), PaymentProvider::Payone => app(PayoneGateway::class),
PaymentProvider::Stripe => throw new \RuntimeException( PaymentProvider::Stripe => app(StripeGateway::class),
'Das Stripe-Gateway ist noch nicht implementiert (Plan Phase S2/S4, dev/2026-07-08-stripe/).'
),
}; };
} }
} }

View file

@ -46,4 +46,22 @@ class StripeWebhookResult
{ {
return new self(statusCode: 200, state: 'processed', eventId: $eventId, eventType: $eventType); return new self(statusCode: 200, state: 'processed', eventId: $eventId, eventType: $eventType);
} }
/**
* Event trägt unsere Metadata (shopping_payment_id), aber die Zahlung ist
* (noch) nicht auffindbar — z. B. weil die Cron-DB-Transaktion noch nicht
* committet war (Plan S4, Race Cron vs. Webhook). Non-2xx → Stripe stellt
* erneut zu; das gespeicherte Event bleibt unverarbeitet und wird beim
* Retry regulär verarbeitet.
*/
public static function paymentNotYetVisible(string $eventId, string $eventType): self
{
return new self(
statusCode: 503,
state: 'payment_not_yet_visible',
eventId: $eventId,
eventType: $eventType,
reason: 'shopping_payment aus metadata (noch) nicht auffindbar — Retry angefordert',
);
}
} }

View file

@ -6,9 +6,13 @@ use App\Enums\PaymentProvider;
use App\Models\ShoppingOrder; use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment; use App\Models\ShoppingPayment;
use App\Models\ShoppingUser; use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\CheckoutFunnelTracker; use App\Services\CheckoutFunnelTracker;
use App\Services\Payment\DTOs\AboChargeResult;
use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult; use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult;
use App\Services\Util; use App\Services\Util;
use Stripe\Checkout\Session;
use Stripe\Exception\ApiErrorException;
/** /**
* Stripe-Zahlungsvorgänge (Plan dev/2026-07-08-stripe/, Phase S3 ff.). * Stripe-Zahlungsvorgänge (Plan dev/2026-07-08-stripe/, Phase S3 ff.).
@ -16,6 +20,10 @@ use App\Services\Util;
* Einmalzahlung im Checkout = Hosted Checkout Session (Redirect-Flow, * Einmalzahlung im Checkout = Hosted Checkout Session (Redirect-Flow,
* Konzept §3.1): Zahlartenwahl und Dateneingabe passieren bei Stripe, * Konzept §3.1): Zahlartenwahl und Dateneingabe passieren bei Stripe,
* "bezahlt" setzt ausschließlich der Webhook (StripeWebhookHandler). * "bezahlt" setzt ausschließlich der Webhook (StripeWebhookHandler).
*
* Abos (Phase S4): Die Erstzahlung speichert das Zahlungsmittel
* (setup_future_usage=off_session, Customer wird von Stripe angelegt);
* Folgeabbuchungen laufen als off-session PaymentIntent über chargeAbo().
*/ */
class StripePaymentService class StripePaymentService
{ {
@ -25,6 +33,11 @@ class StripePaymentService
* Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung. * Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung.
* Die Success-/Cancel-URLs baut der HTTP-Aufrufer (Routen sind * Die Success-/Cancel-URLs baut der HTTP-Aufrufer (Routen sind
* domain-abhängig und gehören nicht in die Service-Schicht). * domain-abhängig und gehören nicht in die Service-Schicht).
*
* Abo-/Recovery-Bestellungen speichern das Zahlungsmittel für künftige
* off-session Abbuchungen: Stripe legt einen Customer an und blendet
* automatisch nur wiederverwendbare Zahlarten ein (Dynamic Payment
* Methods filtern nach setup_future_usage — löst N1, Plan S4.1).
*/ */
public function initiateCheckoutPayment( public function initiateCheckoutPayment(
ShoppingUser $shoppingUser, ShoppingUser $shoppingUser,
@ -64,7 +77,7 @@ class StripePaymentService
'mode' => (string) $shoppingOrder->mode, 'mode' => (string) $shoppingOrder->mode,
]; ];
$session = $this->stripe->client()->checkout->sessions->create([ $sessionParams = [
'mode' => 'payment', 'mode' => 'payment',
'client_reference_id' => $reference, 'client_reference_id' => $reference,
'customer_email' => $shoppingUser->billing_email ?: null, 'customer_email' => $shoppingUser->billing_email ?: null,
@ -82,7 +95,14 @@ class StripePaymentService
'payment_intent_data' => ['metadata' => $metadata], 'payment_intent_data' => ['metadata' => $metadata],
'success_url' => $successUrl, 'success_url' => $successUrl,
'cancel_url' => $cancelUrl, 'cancel_url' => $cancelUrl,
]); ];
if ($this->requiresReusablePaymentMethod($shoppingOrder)) {
$sessionParams['customer_creation'] = 'always';
$sessionParams['payment_intent_data']['setup_future_usage'] = 'off_session';
}
$session = $this->stripe->client()->checkout->sessions->create($sessionParams);
$shoppingPayment->external_reference = $session->id; $shoppingPayment->external_reference = $session->id;
$shoppingPayment->save(); $shoppingPayment->save();
@ -100,4 +120,282 @@ class StripePaymentService
shoppingPayment: $shoppingPayment, shoppingPayment: $shoppingPayment,
); );
} }
/**
* Abo-/Recovery-Bestellungen brauchen ein wiederverwendbares Zahlungsmittel
* (Erstzahlung eines neuen Abos bzw. Nachholzahlung, die das Abo auf
* Stripe umzieht — §7.2 Nr. 3).
*/
private function requiresReusablePaymentMethod(ShoppingOrder $shoppingOrder): bool
{
return (bool) $shoppingOrder->is_abo || ! empty($shoppingOrder->recovery_user_abo_id);
}
/**
* Wiederkehrende Abo-Abbuchung (Plan S4.3): off-session PaymentIntent mit
* dem am Abo gespeicherten Customer/PaymentMethod. Der Idempotency-Key
* pro Abo+Bestellung schützt gegen Doppel-Abbuchung bei Netzwerk-Retries.
* "Bezahlt" (Order, Rechnung, Mails) setzt auch hier erst der Webhook.
*/
public function chargeAbo(
UserAbo $userAbo,
ShoppingOrder $shoppingOrder,
ShoppingUser $shoppingUser,
int $amountCents,
string $currency,
): AboChargeResult {
if (empty($userAbo->stripe_customer_id) || empty($userAbo->stripe_payment_method_id)) {
return new AboChargeResult(
provider: PaymentProvider::Stripe,
success: false,
status: 'ERROR',
rawResponse: [],
errorMessage: 'Stripe-Abo ohne Customer-/PaymentMethod-ID (user_abo '.$userAbo->id.')',
customerMessage: __('payment.stripe_abo_payment_method_missing'),
);
}
$shoppingPayment = ShoppingPayment::create([
'shopping_order_id' => $shoppingOrder->id,
'payment_provider' => PaymentProvider::Stripe->value,
'clearingtype' => 'stripe',
'reference' => substr(uniqid('m', false), 0, 16),
'amount' => $amountCents,
'currency' => $currency,
'is_abo' => true,
'abo_interval' => $userAbo->abo_interval ?? 0,
'mode' => $shoppingOrder->mode,
'user_abo_id' => $userAbo->id,
'stripe_customer_id' => $userAbo->stripe_customer_id,
'stripe_payment_method_id' => $userAbo->stripe_payment_method_id,
]);
$metadata = [
'shopping_order_id' => (string) $shoppingOrder->id,
'shopping_payment_id' => (string) $shoppingPayment->id,
'user_abo_id' => (string) $userAbo->id,
'reference' => (string) $shoppingPayment->reference,
'mode' => (string) $shoppingOrder->mode,
];
try {
$paymentIntent = $this->stripe->client()->paymentIntents->create([
'amount' => $amountCents,
'currency' => strtolower($currency),
'customer' => $userAbo->stripe_customer_id,
'payment_method' => $userAbo->stripe_payment_method_id,
'off_session' => true,
'confirm' => true,
'metadata' => $metadata,
], [
'idempotency_key' => 'abo-'.$userAbo->id.'-order-'.$shoppingOrder->id,
]);
} catch (ApiErrorException $e) {
// Deckt auch CardException ab (Declines wie card_declined,
// insufficient_funds, authentication_required bei off_session).
return $this->aboChargeError($shoppingPayment, $e);
}
$shoppingPayment->external_reference = $paymentIntent->id;
$shoppingPayment->save();
$piArray = $paymentIntent->toArray();
StripeTransactionRecorder::recordFromStripeObject($shoppingPayment, 'authorization', $piArray);
[$status, $errorMessage, $customerMessage] = match ($paymentIntent->status) {
'succeeded' => ['APPROVED', null, null],
'processing' => ['PENDING', null, null],
'requires_action', 'requires_confirmation' => [
'ERROR',
'authentication_required (PaymentIntent '.$paymentIntent->id.')',
__('payment.stripe_abo_authentication_required'),
],
default => [
'ERROR',
'Unerwarteter PaymentIntent-Status "'.$paymentIntent->status.'" ('.$paymentIntent->id.')',
__('payment.payment_error_reasons.general'),
],
};
return new AboChargeResult(
provider: PaymentProvider::Stripe,
success: $status === 'APPROVED',
status: $status,
rawResponse: StripeTransactionRecorder::redactPayload($piArray),
transactionId: $paymentIntent->id,
errorMessage: $errorMessage,
customerMessage: $customerMessage,
shoppingPayment: $shoppingPayment,
);
}
/**
* "Zahlungsart ändern" (Plan S5.1): Hosted Checkout Session im Setup-Modus —
* der Abo-Inhaber wählt bei Stripe frei eine neue, wiederverwendbare
* Zahlart; es wird NICHTS abgebucht. Verankert wird die Methode nach
* Erfolg über den Return-Pfad bzw. den setup_intent.succeeded-Webhook.
*/
public function createPaymentMethodChangeSession(
UserAbo $userAbo,
string $successUrl,
string $cancelUrl,
?string $source = null,
): Session {
$customerId = $this->ensureAboStripeCustomer($userAbo);
$metadata = [
'user_abo_id' => (string) $userAbo->id,
'source' => (string) ($source ?? ''),
];
return $this->stripe->client()->checkout->sessions->create([
'mode' => 'setup',
// Pflicht im Setup-Modus mit dynamischen Zahlarten: Währung der
// künftigen Abbuchungen (Abos laufen fest in EUR, Plan §1 E).
'currency' => 'eur',
'customer' => $customerId,
'metadata' => $metadata,
'setup_intent_data' => ['metadata' => $metadata],
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
]);
}
/**
* Stripe-Customer des Abos — bei PAYONE-Bestandsabos wird er hier erstmalig
* angelegt und sofort am Abo persistiert (wiederverwendbar bei erneutem
* Versuch; der Provider des Abos bleibt unverändert, bis die neue
* Zahlungsart tatsächlich verankert ist).
*/
private function ensureAboStripeCustomer(UserAbo $userAbo): string
{
if (! empty($userAbo->stripe_customer_id)) {
return $userAbo->stripe_customer_id;
}
$shoppingUser = $userAbo->shopping_user;
$customer = $this->stripe->client()->customers->create([
'email' => $userAbo->email ?: null,
'name' => $shoppingUser
? (trim(($shoppingUser->billing_firstname ?? '').' '.($shoppingUser->billing_lastname ?? '')) ?: null)
: null,
'metadata' => ['mivita_type' => 'user_abo', 'mivita_id' => (string) $userAbo->id],
]);
$userAbo->forceFill(['stripe_customer_id' => $customer->id])->save();
return $customer->id;
}
/**
* Anzeige-Summary einer PaymentMethod (Struktur wie
* StripeWebhookHandler::paymentMethodSummary, aber aus dem
* PaymentMethod-Objekt statt aus Charge-Details). Best effort — null,
* wenn die API nicht erreichbar ist.
*
* @return array<string, mixed>|null
*/
public function retrievePaymentMethodSummary(string $paymentMethodId): ?array
{
try {
$paymentMethod = $this->stripe->client()->paymentMethods->retrieve($paymentMethodId);
} catch (\Throwable $e) {
\Log::channel('stripe')->warning('PaymentMethod-Summary nicht abrufbar: '.$e->getMessage(), [
'payment_method' => $paymentMethodId,
]);
return null;
}
$summary = ['type' => $paymentMethod->type];
if ($paymentMethod->type === 'card' && $paymentMethod->card) {
$summary['brand'] = $paymentMethod->card->brand ?? null;
$summary['last4'] = $paymentMethod->card->last4 ?? null;
$summary['wallet'] = $paymentMethod->card->wallet->type ?? null;
$summary['exp'] = isset($paymentMethod->card->exp_month, $paymentMethod->card->exp_year)
? sprintf('%02d/%d', $paymentMethod->card->exp_month, $paymentMethod->card->exp_year)
: null;
}
if ($paymentMethod->type === 'sepa_debit' && $paymentMethod->sepa_debit) {
$summary['last4'] = $paymentMethod->sepa_debit->last4 ?? null;
}
return $summary;
}
/**
* Setup-Session für den Return-Pfad nachladen (inkl. SetupIntent).
*/
public function retrieveSetupSession(string $sessionId): Session
{
return $this->stripe->client()->checkout->sessions->retrieve($sessionId, [
'expand' => ['setup_intent'],
]);
}
/**
* Alte PaymentMethod nach erfolgreichem Wechsel am Customer lösen —
* best effort, ein Fehler darf den Wechsel nie kippen.
*/
public function detachPaymentMethod(string $paymentMethodId): void
{
try {
$this->stripe->client()->paymentMethods->detach($paymentMethodId);
} catch (\Throwable $e) {
\Log::channel('stripe')->warning('Alte PaymentMethod konnte nicht detached werden: '.$e->getMessage(), [
'payment_method' => $paymentMethodId,
]);
}
}
/**
* Fehler-Mapping (Plan S4.5): Stripe-Declines werden protokolliert und als
* ERROR-Ergebnis zurückgegeben — der Cron setzt damit denselben
* Abo-Fehlerstatus (3) wie bei PAYONE-Declines inkl. Pay-now-Mail.
*/
private function aboChargeError(ShoppingPayment $shoppingPayment, ApiErrorException $e): AboChargeResult
{
$stripeError = $e->getError();
$errorObject = $e->getJsonBody()['error'] ?? ['message' => $e->getMessage()];
if (! empty($stripeError?->payment_intent)) {
$shoppingPayment->external_reference = $stripeError->payment_intent->id;
$shoppingPayment->save();
}
StripeTransactionRecorder::recordFromStripeObject($shoppingPayment, 'authorization', [
'id' => $stripeError?->payment_intent->id ?? '',
'status' => 'failed',
'last_payment_error' => [
'code' => $stripeError?->code ?? $e->getStripeCode(),
'message' => $stripeError?->message ?? $e->getMessage(),
],
'decline_code' => $stripeError?->decline_code ?? null,
]);
return new AboChargeResult(
provider: PaymentProvider::Stripe,
success: false,
status: 'ERROR',
rawResponse: is_array($errorObject) ? $errorObject : [],
transactionId: $stripeError?->payment_intent->id ?? '',
errorMessage: trim(($stripeError?->code ?? (string) $e->getStripeCode()).': '.($stripeError?->message ?? $e->getMessage()), ': '),
customerMessage: $this->customerMessageForDecline($stripeError?->code ?? (string) $e->getStripeCode()),
shoppingPayment: $shoppingPayment,
);
}
private function customerMessageForDecline(?string $code): string
{
return match ($code) {
'expired_card' => __('payment.payment_error_reasons.card_expired'),
'card_declined' => __('payment.payment_error_reasons.card_declined'),
'insufficient_funds' => __('payment.payment_error_reasons.insufficient_funds'),
'incorrect_cvc' => __('payment.payment_error_reasons.cvv_invalid'),
'authentication_required' => __('payment.stripe_abo_authentication_required'),
default => __('payment.payment_error_reasons.general'),
};
}
} }

View file

@ -5,6 +5,8 @@ namespace App\Services\Payment\Stripe;
use App\Models\ShoppingOrder; use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment; use App\Models\ShoppingPayment;
use App\Models\StripeWebhookEvent; use App\Models\StripeWebhookEvent;
use App\Models\UserAbo;
use App\Services\AboPaymentMethodService;
use App\Services\CheckoutFunnelTracker; use App\Services\CheckoutFunnelTracker;
use App\Services\Payment; use App\Services\Payment;
use App\Services\Payment\Stripe\DTOs\StripeWebhookResult; use App\Services\Payment\Stripe\DTOs\StripeWebhookResult;
@ -25,7 +27,7 @@ use Stripe\Webhook;
*/ */
class StripeWebhookHandler class StripeWebhookHandler
{ {
private const HANDLED_TYPES = [ public const HANDLED_TYPES = [
'checkout.session.completed', 'checkout.session.completed',
'checkout.session.expired', 'checkout.session.expired',
'payment_intent.succeeded', 'payment_intent.succeeded',
@ -102,7 +104,9 @@ class StripeWebhookHandler
return StripeWebhookResult::ignored($event->id, $event->type); return StripeWebhookResult::ignored($event->id, $event->type);
} }
$this->dispatchEvent($event, $object); if (! $this->dispatchEvent($event->id, $event->type, $object)) {
return StripeWebhookResult::paymentNotYetVisible($event->id, $event->type);
}
$storedEvent->update(['processed_at' => now()]); $storedEvent->update(['processed_at' => now()]);
@ -110,37 +114,136 @@ class StripeWebhookHandler
} }
/** /**
* @param array<string, mixed> $object * Verarbeitet ein gespeichertes, noch unverarbeitetes Event nach
* (stripe:process-pending-events): z. B. wenn der Webhook die Cron-
* DB-Transaktion überholt hat (503) und kein Stripe-Retry kommt
* (Stripe CLI leitet non-2xx nicht erneut zu). Die Signatur wurde
* bereits beim Empfang geprüft; der Payload liegt redaktiert vor.
*
* @return bool true = verarbeitet (processed_at gesetzt), false = weiterhin nicht zuordenbar
*/ */
private function dispatchEvent(Event $event, array $object): void public function processStoredEvent(StripeWebhookEvent $storedEvent): bool
{ {
if ($storedEvent->processed_at !== null || ! in_array($storedEvent->type, self::HANDLED_TYPES, true)) {
return false;
}
if (! $this->dispatchEvent($storedEvent->event_id, $storedEvent->type, $storedEvent->payload ?? [])) {
return false;
}
$storedEvent->update(['processed_at' => now()]);
return true;
}
/**
* @param array<string, mixed> $object
* @return bool false = Zahlung mit unserer Metadata (noch) nicht sichtbar → Stripe-Retry anfordern
*/
private function dispatchEvent(string $eventId, string $eventType, array $object): bool
{
/*
* Setup-Flow "Zahlungsart ändern" (Plan S5): trägt keine Zahlung,
* sondern ein Abo in der Metadata — vor der Zahlungs-Auflösung behandeln.
*/
if ($eventType === 'setup_intent.succeeded') {
$this->handleSetupIntentSucceeded($eventId, $object);
return true;
}
if ($eventType === 'checkout.session.completed' && ($object['mode'] ?? null) === 'setup') {
Log::channel('stripe')->info("Setup-Session abgeschlossen (Verankerung via setup_intent.succeeded): {$eventId}");
return true;
}
$payment = $this->resolveShoppingPayment($object); $payment = $this->resolveShoppingPayment($object);
if (! $payment) { if (! $payment) {
/*
* Trägt das Event unsere Metadata, existiert die Zahlung sicher —
* sie ist nur noch nicht sichtbar (Cron bucht innerhalb einer
* DB-Transaktion ab, der Webhook kann den Commit überholen).
* Dann Retry anfordern statt das Event als verarbeitet zu begraben.
*/
if (! empty($object['metadata']['shopping_payment_id'])) {
Log::channel('stripe')->warning(sprintf(
'Stripe-Webhook: Zahlung aus metadata.shopping_payment_id=%s (noch) nicht auffindbar — Retry angefordert: %s (%s)',
$object['metadata']['shopping_payment_id'],
$eventId,
$eventType,
));
return false;
}
Log::channel('stripe')->warning(sprintf( Log::channel('stripe')->warning(sprintf(
'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)', 'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)',
$event->id, $eventId,
$event->type, $eventType,
$object['id'] ?? '?', $object['id'] ?? '?',
)); ));
return; return true;
} }
StripeTransactionRecorder::recordFromStripeObject($payment, $event->type, $object); StripeTransactionRecorder::recordFromStripeObject($payment, $eventType, $object);
$this->rememberPaymentIntentReference($payment, $object); $this->rememberPaymentIntentReference($payment, $object);
match ($event->type) { match ($eventType) {
'checkout.session.completed' => $this->handleSessionCompleted($payment, $object), 'checkout.session.completed' => $this->handleSessionCompleted($payment, $object),
'checkout.session.expired' => $this->handleSessionExpired($payment), 'checkout.session.expired' => $this->handleSessionExpired($payment),
'payment_intent.succeeded' => $this->markPaid($payment, $object), 'payment_intent.succeeded' => $this->markPaid($payment, $object),
'payment_intent.processing' => $this->markPending($payment), 'payment_intent.processing' => $this->markPending($payment),
'payment_intent.payment_failed' => $this->markFailed($payment), 'payment_intent.payment_failed' => $this->markFailed($payment),
'charge.succeeded' => $this->storePaymentMethodDetails($payment, $object), 'charge.succeeded' => $this->storePaymentMethodDetails($payment, $object),
// charge.refunded / dispute / setup_intent → Business-Logik folgt in S5–S7. // charge.refunded / dispute / setup_intent → Business-Logik folgt in S5–S7 bzw. S9.
default => Log::channel('stripe')->info("Stripe-Webhook protokolliert (Verarbeitung folgt in späterer Phase): {$event->type}"), default => Log::channel('stripe')->info("Stripe-Webhook protokolliert (Verarbeitung folgt in späterer Phase): {$eventType}"),
}; };
return true;
}
/**
* Setup-Flow "Zahlungsart ändern" (Plan S5.2): neue PaymentMethod am Abo
* verankern. Sicherheitsnetz zum Return-Pfad — die Verankerung selbst
* (AboPaymentMethodService::applyStripePaymentMethod) ist idempotent.
*
* @param array<string, mixed> $object
*/
private function handleSetupIntentSucceeded(string $eventId, array $object): void
{
$userAboId = (int) ($object['metadata']['user_abo_id'] ?? 0);
if ($userAboId <= 0) {
Log::channel('stripe')->info("SetupIntent ohne Abo-Metadata protokolliert: {$eventId}");
return;
}
$userAbo = UserAbo::find($userAboId);
$paymentMethodId = is_string($object['payment_method'] ?? null) ? $object['payment_method'] : null;
$customerId = is_string($object['customer'] ?? null) ? $object['customer'] : null;
if (! $userAbo || ! $paymentMethodId || ! $customerId) {
Log::channel('stripe')->warning("SetupIntent nicht anwendbar (Abo/PaymentMethod/Customer fehlt): {$eventId}", [
'user_abo_id' => $userAboId,
'payment_method' => $paymentMethodId,
'customer' => $customerId,
]);
return;
}
$source = trim((string) ($object['metadata']['source'] ?? ''));
app(AboPaymentMethodService::class)->applyStripePaymentMethod(
$userAbo,
$customerId,
$paymentMethodId,
$source !== '' ? $source : 'stripe-webhook',
);
} }
/** /**
@ -206,6 +309,8 @@ class StripeWebhookHandler
return; return;
} }
$this->captureOffSessionPaymentMethod($payment, $object);
$order = $payment->shopping_order; $order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'paid'); $this->upgradeTxaction($order, $payment, 'paid');
@ -248,6 +353,71 @@ class StripeWebhookHandler
$this->backfillPaymentMethodDetails($payment); $this->backfillPaymentMethodDetails($payment);
} }
/**
* Abo-/Recovery-Zahlungen (Plan S4): Customer- und PaymentMethod-ID an der
* Zahlung persistieren, BEVOR der Paid-Pfad läuft — createNewAbo() bzw.
* advanceAboAfterRecoveryPayment() übernehmen sie von dort ans Abo.
* Session-Events tragen keine payment_method: dann aus dem Event-Store
* (PaymentIntent-/Charge-Event) nachschlagen, als letzter Ausweg per API.
*
* @param array<string, mixed> $object
*/
private function captureOffSessionPaymentMethod(ShoppingPayment $payment, array $object): void
{
$order = $payment->shopping_order;
if (! $order->is_abo && empty($order->recovery_user_abo_id)) {
return;
}
if (! empty($payment->stripe_customer_id) && ! empty($payment->stripe_payment_method_id)) {
return;
}
$customerId = is_string($object['customer'] ?? null) ? $object['customer'] : null;
$paymentMethodId = ($object['object'] ?? null) === 'payment_intent' && is_string($object['payment_method'] ?? null)
? $object['payment_method']
: null;
$paymentIntentId = ($object['object'] ?? null) === 'payment_intent'
? ($object['id'] ?? null)
: ($object['payment_intent'] ?? $payment->external_reference);
if (! $paymentMethodId && $paymentIntentId) {
$storedEvent = StripeWebhookEvent::where('type', 'payment_intent.succeeded')
->where('payload->id', $paymentIntentId)
->latest('id')
->first()
?? StripeWebhookEvent::where('type', 'charge.succeeded')
->where('payload->payment_intent', $paymentIntentId)
->latest('id')
->first();
if ($storedEvent) {
$payload = $storedEvent->payload ?? [];
$paymentMethodId = is_string($payload['payment_method'] ?? null) ? $payload['payment_method'] : null;
$customerId = $customerId ?: (is_string($payload['customer'] ?? null) ? $payload['customer'] : null);
}
}
if ((! $paymentMethodId || ! $customerId) && $paymentIntentId) {
try {
$paymentIntent = app(StripeClientWrapper::class)->client()->paymentIntents->retrieve($paymentIntentId);
$paymentMethodId = $paymentMethodId ?: (is_string($paymentIntent->payment_method) ? $paymentIntent->payment_method : ($paymentIntent->payment_method->id ?? null));
$customerId = $customerId ?: (is_string($paymentIntent->customer) ? $paymentIntent->customer : ($paymentIntent->customer->id ?? null));
} catch (\Throwable $e) {
Log::channel('stripe')->error('PaymentIntent-Nachladen für Abo-Zahlungsmittel fehlgeschlagen: '.$e->getMessage(), [
'shopping_payment_id' => $payment->id,
'payment_intent' => $paymentIntentId,
]);
}
}
if ($customerId || $paymentMethodId) {
$payment->stripe_customer_id = $payment->stripe_customer_id ?: $customerId;
$payment->stripe_payment_method_id = $payment->stripe_payment_method_id ?: $paymentMethodId;
$payment->save();
}
}
/** /**
* Speichert womit gezahlt wurde (Kartenmarke/letzte 4/Wallet, PayPal, Klarna …) * Speichert womit gezahlt wurde (Kartenmarke/letzte 4/Wallet, PayPal, Klarna …)
* aus den payment_method_details des Charge-Objekts in carddata — für die * aus den payment_method_details des Charge-Objekts in carddata — für die

View file

@ -0,0 +1,41 @@
<?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\Stripe\StripePaymentService;
/**
* Dünner Adapter, der den StripePaymentService hinter die provider-neutrale
* Gateway-Schnittstelle stellt (Gegenstück zum PayoneGateway, Plan S4.4).
*/
class StripeGateway implements PaymentGatewayInterface
{
public function __construct(private readonly StripePaymentService $paymentService) {}
public function provider(): PaymentProvider
{
return PaymentProvider::Stripe;
}
public function chargeAbo(
UserAbo $userAbo,
ShoppingOrder $shoppingOrder,
ShoppingUser $shoppingUser,
int $amountCents,
string $currency,
): AboChargeResult {
return $this->paymentService->chargeAbo(
$userAbo,
$shoppingOrder,
$shoppingUser,
$amountCents,
$currency,
);
}
}

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Stripe-Einführung Phase S4 (dev/2026-07-08-stripe/):
* Abo-fähige Stripe-Zahlungen. Der Webhook schreibt Customer-/PaymentMethod-ID
* an die Zahlung (Transport zum Abo in createNewAbo/advanceAboAfterRecoveryPayment);
* user_abo_id markiert wiederkehrende Cron-Abbuchungen, damit der Paid-Pfad
* daraus kein neues Abo anlegt.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shopping_payments', function (Blueprint $table) {
if (! Schema::hasColumn('shopping_payments', 'stripe_customer_id')) {
$table->string('stripe_customer_id')->nullable()->after('external_reference');
}
if (! Schema::hasColumn('shopping_payments', 'stripe_payment_method_id')) {
$table->string('stripe_payment_method_id')->nullable()->after('stripe_customer_id');
}
if (! Schema::hasColumn('shopping_payments', 'user_abo_id')) {
$table->unsignedBigInteger('user_abo_id')->nullable()->index()->after('stripe_payment_method_id');
}
});
}
public function down(): void
{
Schema::table('shopping_payments', function (Blueprint $table) {
foreach (['stripe_customer_id', 'stripe_payment_method_id', 'user_abo_id'] as $column) {
if (Schema::hasColumn('shopping_payments', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View file

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Stripe-Einführung Phase S4 (dev/2026-07-08-stripe/):
* clearingtype war varchar(3) (PAYONE-Kürzel cc/wlt/elv/vor/fnc/sb) — MySQL hat
* 'stripe' still zu 'str' abgeschnitten (SQLite in den Tests erzwingt keine Länge,
* daher unentdeckt). Spalten verbreitern und die abgeschnittenen Bestandswerte
* reparieren. Anzeige-/Logikpfade lesen payment_provider, nicht clearingtype —
* der Fix ist Datenhygiene.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shopping_payments', function (Blueprint $table) {
$table->string('clearingtype', 20)->nullable(false)->change();
});
Schema::table('user_abos', function (Blueprint $table) {
$table->string('clearingtype', 20)->nullable(false)->change();
});
DB::table('shopping_payments')
->where('payment_provider', 'stripe')
->where('clearingtype', 'str')
->update(['clearingtype' => 'stripe']);
DB::table('user_abos')
->where('payment_provider', 'stripe')
->where('clearingtype', 'str')
->update(['clearingtype' => 'stripe']);
}
public function down(): void
{
// Rückweg bewusst ohne Verkürzung auf varchar(3): das würde Daten
// abschneiden. Nur die Werte zurückdrehen.
DB::table('shopping_payments')
->where('payment_provider', 'stripe')
->where('clearingtype', 'stripe')
->update(['clearingtype' => 'str']);
DB::table('user_abos')
->where('payment_provider', 'stripe')
->where('clearingtype', 'stripe')
->update(['clearingtype' => 'str']);
}
};

View file

@ -1,6 +1,21 @@
# Entwicklungsplan: Stripe als Zahlungsanbieter (PAYONE bleibt als Option) # 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 > **Status:** In Arbeit (S0 ✓, S1 ✓, S2 ✓, S3 ✓ — **live in Produktion**; S4 ✓ **E2E komplett** (Abschluss, Cron, Decline→Recovery, authentication_required); S5 ✓ „Zahlungsart ändern" inkl. E2E über CRM + Kunden-Link (nur Portal-Pfad ohne Sichttest, gleicher Code-Pfad); S7.6 ✓ Zahlarten-Kommunikation — **vor Abo-Freigabe:** Deployment + 3 Migrationen, dann `abo-payments-frozen` aufheben, §16) · **Erstellt:** 2026-07-08 · **Zuletzt aktualisiert:** 2026-07-09
>
> **Lage-Update 2026-07-09 (Stakeholder):** Das PAYONE-Konto ist **wieder freigeschaltet**.
> Daraus ergeben sich folgende verbindliche Entscheidungen (Details §7.2):
> 1. **Auslaufmodell statt Migration:** Bestehende PAYONE-Abos laufen unverändert über
> PAYONE weiter (Kunden müssen nicht aktiv werden). **Neue** Abos werden über Stripe
> angelegt; wird das **Zahlungsmittel eines Abos geändert** (Recovery-Zahlung → S4,
> Kreditkarte-ändern-Flow → S5), wechselt das Abo dabei auf Stripe. PAYONE läuft so
> Stück für Stück im Hintergrund aus.
> 2. **Vorkasse (PAYONE) wieder anbieten:** Der Checkout zeigt neben der Stripe-Online-
> Zahlung wieder „Vorkasse (PAYONE)" (Setting `payment-vorkasse-hidden` → aus).
> 3. **Zahlarten-Kommunikation** (→ S7.6): Stripe-Zahlarten klar benennen, möglichst
> mit Logos — im Checkout, auf den Shop-Seiten, im Footer (Zahlungsmöglichkeiten)
> und auf der Seite `/zahlungsarten`. ✅ **umgesetzt 2026-07-09** (§11, Umsetzungsstatus S7.6).
> 4. **Rückbuchungen & Stornierungen erfassen** — als eigener, **nachgelagerter** Punkt
> aufgenommen (→ §12.1 / Phase S9).
> **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')`. > **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) > **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/` > **Vorarbeiten:** `dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md` (Phase R ✅), `dev/2026-06-25-kreditkarte-update/`
@ -284,7 +299,7 @@ unbehandelte Typen mit `200 ignored`).
--- ---
## 7. Phase S3 – Einmalzahlung im Checkout ⭐ · Status: **UMGESETZT** (2026-07-08, Browser-E2E offen) ## 7. Phase S3 – Einmalzahlung im Checkout ⭐ · Status: **ERLEDIGT & LIVE** (2026-07-09)
### Ziel ### Ziel
Steht der Schalter auf `stripe`, laufen **neue Einmalzahlungen** (Shop, Zahlungslinks) Steht der Schalter auf `stripe`, laufen **neue Einmalzahlungen** (Shop, Zahlungslinks)
@ -372,9 +387,9 @@ Payment-Regression (PAYONE + Abo-Retry/Recovery, 66 Tests) grün; Blade kompilie
unabhängig von S3.)* unabhängig von S3.)*
### Akzeptanzkriterien ### Akzeptanzkriterien
- [ ] **Browser-E2E (Manualtest, offen):** Checkout → Redirect zu Stripe → Zahlung (inkl. 3DS-Testkarte) → Rücksprung → Webhook → Order bezahlt → Rechnung aus dem System. - [x] **Browser-E2E:** Checkout → Redirect zu Stripe → Zahlung → Rücksprung → Webhook → Order bezahlt → Rechnung aus dem System. *(2026-07-08 im Testmodus; seit Go-Live in Produktion bestätigt — Stakeholder 2026-07-09: „steht und funktioniert".)*
- [ ] Apple Pay/Google Pay erscheinen auf fähigen Geräten ohne Zusatzcode; Zahlarten lassen sich im Stripe-Dashboard zu-/abschalten, ohne Deploy. - [x] 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] Abbruch bei Stripe führt sauber in den Checkout zurück.
- [x] Umschalten des Schalters wechselt ausschließlich neue Checkouts (per Test nachgewiesen). - [x] Umschalten des Schalters wechselt ausschließlich neue Checkouts (per Test nachgewiesen).
--- ---
@ -425,40 +440,61 @@ Alle Schalter sind damit ohne Tinker im Admin schaltbar (bestehender
CC-Update-Freeze (`updateCreditCard` → Error mit Hinweistext), Manager-Defaults aus — CC-Update-Freeze (`updateCreditCard` → Error mit Hinweistext), Manager-Defaults aus —
in `StripeCheckoutTest.php`; Karten-Update-Regressionssuite (17 Tests) unverändert grün. in `StripeCheckoutTest.php`; Karten-Update-Regressionssuite (17 Tests) unverändert grün.
### Go-Live-Checkliste Produktion ### Go-Live-Checkliste Produktion — ✅ durchgeführt am 2026-07-08 abends
1. [ ] Deployment des aktuellen Stands (Branch `payment/phase-r-payone-refactoring`). 1. [x] Deployment des aktuellen Stands (Branch `payment/phase-r-payone-refactoring`).
2. [ ] `php artisan migrate` (3 neue Migrationen: Provider-Spalten, `stripe_webhook_events`). 2. [x] `php artisan migrate` (damals 3 neue Migrationen: Provider-Spalten, `stripe_webhook_events`).
3. [ ] Prod-`.env`: `STRIPE_KEY`/`STRIPE_SECRET` = **Live-Keys** (`pk_live_…`/`sk_live_…`), `STRIPE_MODE=live`. 3. [x] Prod-`.env`: `STRIPE_KEY`/`STRIPE_SECRET` = **Live-Keys** (`pk_live_…`/`sk_live_…`), `STRIPE_MODE=live`.
4. [ ] Stripe-Dashboard (**Live-Modus**): Zahlarten aktivieren; Webhook-Endpoint 4. [x] Stripe-Dashboard (**Live-Modus**): Zahlarten aktivieren; Webhook-Endpoint
`https://api.mivita.care/stripe/webhook` mit der Event-Liste aus §6 anlegen; `https://api.mivita.care/stripe/webhook` mit der Event-Liste aus §6 anlegen;
dessen Signing-Secret als `STRIPE_WEBHOOK_SECRET` in die Prod-`.env`. 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`). *Stolpersteine (beide gelöst): falsches Webhook-Secret → alle Events 400;
6. [ ] Schalter setzen — im Admin unter **Einstellungen → „Zahlungen (Stripe / PAYONE)"**: `charge.succeeded` war anfangs nicht abonniert → Zahlart-Anzeige blieb auf Fallback.*
5. [x] Caches: `php artisan config:clear && php artisan view:clear` (bzw. Deploy-üblich `config:cache`).
6. [x] Schalter setzen — im Admin unter **Einstellungen → „Zahlungen (Stripe / PAYONE)"**:
☑ Checkout-Wartungsmodus (nur Admins bestellen), ☑ Abo-Zahlungen einfrieren, ☑ Checkout-Wartungsmodus (nur Admins bestellen), ☑ Abo-Zahlungen einfrieren,
☑ Vorkasse ausblenden (**wichtig:** Vorkasse autorisiert über PAYONE und würde ☑ Vorkasse ausblenden (**wichtig:** Vorkasse autorisiert über PAYONE und würde
beim Ausfall crashen), Anbieter = **Stripe**. beim Ausfall crashen), Anbieter = **Stripe**.
7. [ ] Smoke-Test **als Admin bei aktivem Wartungsmodus**: echte Bestellung (Live-Karte) 7. [x] Smoke-Test **als Admin bei aktivem Wartungsmodus**: echte Bestellungen (u. a.
→ Stripe-Redirect → bezahlt → Rechnung; ggf. im Stripe-Dashboard erstatten. 57831, 57832 — müssen noch storniert/bereinigt werden, Statistik/Provision!).
Gegenprobe im Inkognito-Fenster: Besucher sehen die Wartungsmeldung. 8. [x] Checkout geöffnet: Einmalzahlungen laufen seit 2026-07-08 abends für alle über Stripe.
8. [ ] Checkout öffnen: **Wartungsmodus abhaken** — Einmalzahlungen laufen für alle über Stripe. 9. [x] Kontrolle: `storage/logs/stripe.log`, `stripe_webhook_events`, Payment-Dashboard.
9. [ ] Kontrolle: `storage/logs/stripe.log`, `stripe_webhook_events`, Payment-Dashboard. 10. [ ] **Nach S4/S5 (JETZT offen, → §16):** Deployment + 3 neue Migrationen, dann
10. [ ] Nach S4: `abo-payments-frozen` zurücksetzen. `abo-payments-frozen` aufheben und Vorkasse wieder einblenden.
--- ---
## 8. Phase S4 – Abos über Stripe ## 7.2 PAYONE wieder freigeschaltet (2026-07-09): Auslaufmodell & Vorkasse
**Lage:** Das PAYONE-Konto ist wieder aktiv. Entscheidungen Stakeholder (2026-07-09):
| # | Entscheidung | Umsetzung |
|---|--------------|-----------|
| 1 | **Bestands-Abos bleiben auf PAYONE** — Kunden müssen nicht aktiv werden, die alte Verknüpfung läuft weiter. Keine aktive Migrationskampagne (S8 damit vorerst obsolet). | Bereits durch S1 abgedeckt (`providerFor($abo)` liest die Spalte). Nach S4-Abschluss: `abo-payments-frozen` → aus, der Cron bucht PAYONE-Abos wieder regulär ab. |
| 2 | **Neue Abos → Stripe.** Abo-Erstzahlung im Checkout läuft über Stripe (Schalter steht auf `stripe`). | Phase S4 (dieser Sprint). |
| 3 | **Zahlungsmittel-Änderung → Stripe.** Wo ein Abo-Zahlungsmittel neu hinterlegt wird — Recovery-Zahlung (S4) und Kreditkarte-ändern-Flow (S5) — wechselt das Abo dabei von PAYONE auf Stripe. So läuft PAYONE Stück für Stück im Hintergrund aus. | Recovery: S4. CC-Update: S5 (der Flow wird für PAYONE-Abos zum Stripe-SetupIntent-Flow → sanfte Migration; zieht den S8-Mechanismus faktisch vor). |
| 4 | **Vorkasse (PAYONE) wieder anbieten.** Der Checkout zeigt neben „Online-Zahlung" (Stripe) wieder Vorkasse — klar als „Vorkasse (PAYONE)" benannt. | Ops: Setting `payment-vorkasse-hidden` im Admin abhaken. Code: Checkout-Label → `payment.prepayment_payone` („Vorkasse (PAYONE)"). |
**Freeze-Aufhebung (nach S4-Deployment):** `abo-payments-frozen` → aus. Danach:
Bestands-PAYONE-Abos buchen wieder über PAYONE ab (Konto ist wieder frei), neue
Abos entstehen mit `payment_provider='stripe'`, eingefrorene Abbuchungen werden über
das unveränderte `next_date` automatisch nachgeholt.
---
## 8. Phase S4 – Abos über Stripe · Status: **ERLEDIGT** (2026-07-09, Testmodus-E2E komplett; offen nur Prod-Rollout/Freeze-Aufhebung → §16)
### Ziel ### Ziel
Neue Abos: Erstzahlung im Checkout mit Speicherung des Zahlungsmittels; wiederkehrende Neue Abos: Erstzahlung im Checkout mit Speicherung des Zahlungsmittels; wiederkehrende
Abbuchungen über den bestehenden Cron, off-session. Bestehende PAYONE-Abos laufen Abbuchungen über den bestehenden Cron, off-session. Bestehende PAYONE-Abos laufen
unverändert über PAYONE. unverändert über PAYONE (Auslaufmodell, §7.2). Recovery-Zahlungen laufen über Stripe
und ziehen das Abo dabei auf Stripe um.
### Aufgaben ### Aufgaben
| # | Aufgabe | | # | 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.1 | Abo-Erstzahlung: Checkout Session (`mode=payment`) mit `setup_future_usage=off_session` + `customer_creation=always`; nach `succeeded` PaymentMethod-/Customer-ID aus dem PaymentIntent ermitteln. **N1 damit gelöst:** Stripe filtert bei gesetztem `setup_future_usage` die Hosted-Checkout-Zahlarten automatisch auf wiederverwendbare Methoden (Dynamic Payment Methods) — keine eigene Whitelist nötig, Klarna/Amazon Pay erscheinen nur, wenn Stripe sie off-session-fähig anbietet. Gilt auch für Recovery-Checkouts (`recovery_user_abo_id`): Zahlung on-session, Zahlungsmittel wird gespeichert, Abo wechselt auf Stripe (§7.2 Nr. 3). |
| 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.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.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.4 | `UserMakeOrder::makePayment()`: Provider-Dispatch über `PaymentProviderManager::providerFor($abo)` — PAYONE-Zweig byte-identisch. |
@ -466,36 +502,184 @@ unverändert über PAYONE.
| S4.6 | Abo-Ansichten (CRM `/user/abos`, Portal, Admin `AboController`): Provider + maskiertes Zahlungsmittel anzeigen; PAYONE-Felder bleiben für PAYONE-Abos maßgeblich. | | 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). | | S4.7 | Abo-Retry (`AboRetryPaymentService`) provider-fähig (nutzt `chargeAbo` des Abo-Providers). |
### Tests ### Vorab-Designentscheidungen (2026-07-09)
- 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). - **Transport der Stripe-IDs:** Neue Spalten `stripe_customer_id`, `stripe_payment_method_id`
- Gemischte Bestände: ein PAYONE-Abo + ein Stripe-Abo im selben Cron-Lauf → jeweils richtiger Gateway-Aufruf. und `user_abo_id` auf `shopping_payments`. Der Webhook (`markPaid`) schreibt Customer/
PaymentMethod an die Zahlung, bevor der Paid-Pfad läuft; `createNewAbo()`/
`advanceAboAfterRecoveryPayment()` übernehmen sie von dort ans Abo.
- **Race Browser-Return vs. Webhook:** Der Checkout-Erfolgs-Return ruft `createNewAbo()`
ggf. auf, bevor der Webhook die Stripe-IDs geliefert hat. Für Stripe-Zahlungen legt
`createNewAbo()` das Abo daher **nur** an, wenn die IDs bereits an der Zahlung stehen —
sonst übernimmt der Webhook-Paid-Pfad die Anlage (einzige Wahrheit, wie bei S3).
- **Race Cron-Transaktion vs. Webhook:** Der Abo-Cron bucht **innerhalb einer
DB-Transaktion** ab — der Webhook kann eintreffen, bevor die `shopping_payments`-Zeile
committet ist. Zwei Sicherungen: (1) `shopping_payments.user_abo_id` markiert
Folgeabbuchungen, `createNewAbo()` überspringt sie (kein Duplikat-Abo, falls der Webhook
vor dem `user_abo_orders`-Insert des Crons verarbeitet wird); (2) trägt ein Event unsere
`metadata.shopping_payment_id`, ist die Zahlung aber (noch) nicht auffindbar, antwortet
der Webhook **non-2xx** → Stripe stellt erneut zu, statt das Event als verarbeitet zu
begraben. Fremd-Events ohne unsere Metadata werden weiterhin gespeichert + 200.
- **Recovery auch im Webhook-Pfad:** `Payment::paymentStatusPaidAction()` ruft für
Recovery-Bestellungen jetzt `advanceAboAfterRecoveryPayment()` statt `createNewAbo()`
auf (vorher nur im Browser-Return — latenter Duplikat-Abo-Bug, wenn der PAYONE-TSOK
vor dem Redirect lief; provider-neutral gefixt).
- **Idempotency-Key** der Cron-Abbuchung: `abo-{userAboId}-order-{shoppingOrderId}` —
schützt gegen Doppel-Abbuchung bei Netzwerk-Retry innerhalb desselben Laufs.
### Umsetzungsstatus (2026-07-09)
| # | Status |
|---|--------|
| S4.1 | ✅ `initiateCheckoutPayment()`: Abo-/Recovery-Bestellungen → `customer_creation=always` + `payment_intent_data.setup_future_usage=off_session`; Einmalzahlungen unverändert. Zahlarten-Filterung an Stripe delegiert (Dynamic Payment Methods, N1 gelöst). `CheckoutController::stripeCheckoutActive()` ohne Abo-/Recovery-Ausschluss; Abo-Hinweistext (`payment.stripe_abo_hint`) unter der Online-Zahlung. |
| S4.2 | ✅ `createNewAbo()`: Stripe-Zweig setzt `payment_provider='stripe'`, `stripe_customer_id`, `stripe_payment_method_id`, `payone_userid=0`; ohne IDs (Browser-Return vor Webhook) wird die Anlage dem Webhook überlassen. Neue Migration `2026_07_09_090000_add_stripe_abo_columns_to_shopping_payments` + `captureOffSessionPaymentMethod()` im Webhook (PI-Event direkt, Event-Store-Backfill, API-Retrieve als letzter Ausweg). |
| S4.3 | ✅ `StripePaymentService::chargeAbo()`: off-session PaymentIntent (`customer`, `payment_method`, `off_session`, `confirm`), Idempotency-Key `abo-{aboId}-order-{orderId}`, Transaktion als `authorization` protokolliert, Ergebnis als `AboChargeResult`. |
| S4.4 | ✅ `UserMakeOrder::makePayment()`: Dispatch via `providerFor($abo)` — Stripe → `StripeGateway` (neu, Adapter wie PayoneGateway), PAYONE-Zweig byte-identisch; Response-Format (`APPROVED`/`ERROR`/`PENDING`) unverändert für Cron/Retry. |
| S4.5 | ✅ Fehler-Mapping: `CardException`/`ApiErrorException` → ERROR (Cron setzt Status 3 + Pay-now-Mail wie bei PAYONE); Kunden-Meldungen für `card_declined`, `insufficient_funds`, `expired_card`, `incorrect_cvc`, `authentication_required` (bestehende `payment.payment_error_reasons.*`-Keys, neu `payment.stripe_abo_*`); PI-Status `processing` → PENDING (next_date bleibt stehen, Webhook schließt ab). |
| S4.6 | ✅ `UserAbo::getPaymentType()` Stripe-aware (nutzt `ShoppingPayment::stripeLabelFromSummary()`, neu public static); `UserAbo::formatMaskedCard()` versteht die Stripe-Summary („Visa •••• 4242 (04/2031)"); Portal-Abo-Kopf zeigt bei Stripe die Abo-Nummer statt `#0`. *Offen (kosmetisch → S7.3): Provider-Badge in Admin-Listen, `payone_userid`-Spalte im Admin-Datatable.* |
| S4.7 | ✅ implizit: `AboRetryPaymentService` delegiert an `UserMakeOrder::makePayment()` → Dispatch greift. |
**Zusätzliche Absicherungen (Umsetzung der Vorab-Designentscheidungen):**
- `Payment::paymentStatusPaidAction()` behandelt Recovery-Bestellungen jetzt provider-neutral
(`advanceAboAfterRecoveryPayment` statt `createNewAbo`) — fixt den latenten
Duplikat-Abo-Bug auch für PAYONE.
- `createNewAbo()` überspringt Recovery- und Cron-Folgezahlungen (`recovery_user_abo_id` /
`shopping_payments.user_abo_id`) grundsätzlich.
- Webhook: Event mit unserer `metadata.shopping_payment_id`, dessen Zahlung (noch) nicht
sichtbar ist → `503 payment_not_yet_visible` (Stripe-Retry); Fremd-Events weiter `200`.
- `advanceAboAfterRecoveryPayment()`: Stripe-Recovery zieht das Abo auf Stripe um
(Provider + IDs), PAYONE-Felder bleiben als Historie stehen; ohne IDs wird die
Fortschreibung dem Webhook überlassen (Race Browser-Return).
### Tests (2026-07-09)
`tests/Feature/StripeAboTest.php` *(neu)*: **15 Tests, 70 Assertions** — Session-Parameter
(off-session nur für Abo/Recovery), Abo-Anlage aus dem Webhook (Stripe-Felder, `payone_userid=0`),
Browser-Return-Race (Anlage wartet auf Webhook), Cron-Race (kein Duplikat-Abo; 503 → Retry →
processed), Recovery-Umzug PAYONE→Stripe, `chargeAbo` (Erfolg inkl. Idempotency-Key,
Decline-Mapping, fehlendes Zahlungsmittel ohne API-Call), Cron-Dispatch (APPROVED/ERROR),
Anzeige-Labels. `PaymentProviderManagerTest` angepasst (Stripe-Gateway existiert jetzt;
Cron-Guard-Test → Dispatch-Test).
Regression: Stripe-Suite gesamt **59 passed (196 Assertions)**; PAYONE- + CC-Update-Suite
**50 passed (172 Assertions)**; Abo-Retry/Recovery **20 passed** — der eine Fehler in
`AboPendingInitialPaymentTest` („Session store not set", View-Render mit `Form::open`) ist
**vorbestehend auf HEAD** (per `git stash` verifiziert), unabhängig von S4. Blade-Kompilat
(`view:cache`) grün, `./vendor/bin/pint --dirty` grün.
*Abgleich mit dem ursprünglichen Testplan:* Erstzahlung ✅ · Cron-Abbuchung
Erfolg/Decline/Idempotenz ✅ · Recovery ✅ · Gemischte Bestände ▶ implizit über
Dispatch-Tests + PAYONE-Regression, expliziter Mischlauf gehört in den Testmodus-E2E.
### Akzeptanzkriterien ### Akzeptanzkriterien
- [ ] Testmodus Ende-zu-Ende: Abo-Checkout → n Tage später Cron-Abbuchung off-session erfolgreich, Rechnung systemseitig. - [x] Testmodus Ende-zu-Ende ✅ **komplett 2026-07-09** (alles an Abo #442 verprobt):
1. Browser-Abo-Abschluss → Abo mit Provider `stripe`, Customer-/PM-ID, Status 2, Order paid via Webhook.
2. Cron-Abbuchung `--force` → off-session PI erfolgreich, Order bezahlt, `next_date` fortgeschrieben, kein Duplikat-Abo.
3. Abbuchung über per S5-Flow gewechselte Zahlart → erfolgreich (3DS-Karte `…3155`: Setup mit SCA, Folgeabbuchungen als MIT genehmigt — erwartetes Stripe-Verhalten).
4. **Decline** (Karte `…0341`) → Order failed, Abo Status 3 (+ Pay-now-Mail).
5. **Recovery-Checkout** → Recovery-Order bezahlt (`recovery_user_abo_id` gesetzt), Abo zurück auf Status 2.
6. **authentication_required** (Karte `…3184`, verlangt 3DS bei jeder Transaktion) → off-session Decline, Abo Status 3 — der S6.1-Fall mündet korrekt im Recovery-Werkzeug.
*(Rechnung systemseitig: auf .test bewusst deaktiviert, Invoice-Gate; Mischlauf PAYONE+Stripe verifiziert sich nach Freeze-Aufhebung in Produktion.)*
- [ ] PAYONE-Abos zu 100 % unberührt (Regressionssuite + Stichprobe Staging). - [ ] PAYONE-Abos zu 100 % unberührt (Regressionssuite + Stichprobe Staging).
- [ ] Nach Freeze-Aufhebung: PAYONE-Bestandsabos buchen wieder ab, neue Abos entstehen mit `payment_provider='stripe'`.
### Befund aus dem Cron-E2E (2026-07-09): Race trat real auf → `stripe:process-pending-events` (S6.4 vorgezogen)
Beim ersten echten Cron-Lauf (`user:make_abo_order --abo=442 --force`) trat das in den
Designentscheidungen antizipierte **Cron-Race** sofort ein: Der Webhook
(`payment_intent.succeeded`) kam ~5 s nach Zahlungsanlage — die Cron-DB-Transaktion war
noch offen → korrekt **503 `payment_not_yet_visible`**. Aber: Die **Stripe CLI
(`stripe listen`) stellt non-2xx NICHT erneut zu** (anders als der Dashboard-Endpoint in
Produktion) → das Event blieb unverarbeitet, die Bestellung „unbezahlt", obwohl die
Abbuchung bei Stripe erfolgreich war.
**Fix (Root Cause, deckt auch Prod-Retry-Latenz ab):** Neues Wartungs-Command
**`stripe:process-pending-events`** (`--event=` für Einzelfälle) — verarbeitet
gespeicherte Events ohne `processed_at` über `StripeWebhookHandler::processStoredEvent()`
nach (Signatur wurde beim Empfang geprüft; weiterhin nicht zuordenbare Events bleiben
liegen). Verdrahtung: (1) läuft automatisch **am Ende von `user:make_abo_order`**
(alle Zahlungen committet), (2) **Scheduler alle 10 Minuten** (`Kernel.php`,
withoutOverlapping). Damit ist S6.4 im Kern vorgezogen; der geplante
`stripe:sync-payment-status` (aktiver API-Abgleich bei komplett verlorenem Webhook)
bleibt für S6 offen. Nebeneffekt beim ersten Lauf: 4 liegengebliebene
`charge.succeeded`-Events vom Go-Live-Tag nachgeholt (Zahlart-Anzeige).
### Befund aus dem Browser-E2E (2026-07-09): `clearingtype` war varchar(3)
Beim Verproben von Abo #442 fiel auf: `clearingtype` stand auf **`'str'`** — die Spalten
(`shopping_payments`, `user_abos`) waren `varchar(3)` (PAYONE-Kürzel), MySQL hat
`'stripe'` **still abgeschnitten** (betraf auch alle Live-Einmalzahlungen seit dem
S3-Go-Live; SQLite in der Testsuite erzwingt keine Länge, daher unentdeckt). Kein
Logik-Problem — alle Lese-Pfade prüfen `payment_provider`, nie `clearingtype==='stripe'`
(per Grep verifiziert). Fix: Migration `2026_07_09_120000_widen_clearingtype_for_stripe`
(beide Spalten → varchar(20) + Reparatur der `'str'`-Bestandszeilen; `down()` verkürzt
bewusst nicht). Auf der Dev-DB ausgeführt, Abo #442 repariert. **Muss beim
Prod-Deployment mitlaufen** (repariert dort die Live-`'str'`-Zeilen automatisch).
--- ---
## 9. Phase S5 – Zahlungsmittel-Update über Stripe ## 9. Phase S5 – Zahlungsart ändern über Stripe · Status: **ERLEDIGT** (2026-07-09, E2E über CRM + Kunden-Link; nur Portal-Pfad ohne Sichttest)
### Ziel ### Ziel
Der Flow aus dem CC-Update-Projekt (2026-06-25) funktioniert für Stripe-Abos: **Scope-Erweiterung (Stakeholder 2026-07-09):** Nicht mehr nur „Kreditkarte
Zahlungsmittel erneuern **ohne Abbuchung**, nur durch den Abo-Inhaber. aktualisieren" — der Abo-Inhaber kann seine **Zahlungsart frei wechseln**: Karte
abgelaufen, PayPal-Konto weg → „Zahlungsart ändern" führt auf die Stripe-Hosted-Seite
(Session `mode=setup`), dort **freie Wahl** unter allen wiederverwendbaren Zahlarten
(Karte, PayPal, SEPA, … — Stripe filtert automatisch); die neue Methode wird **ohne
Abbuchung** am Abo verankert und gilt für alle folgenden Abbuchungen. Nur durch den
Abo-Inhaber (Policies aus dem CC-Update-Projekt 2026-06-25 unverändert).
Der Flow gilt für **alle Abos, auch PAYONE-Bestandsabos** (§7.2 Nr. 3): Wer die
Zahlungsart ändert, wechselt dabei auf `payment_provider='stripe'` (PAYONE-Felder
bleiben als Historie). Das ist die sanfte Auslauf-Migration; eine aktive
Migrationskampagne (S8) ist damit nicht mehr geplant. Der bestehende
PAYONE-CC-Update-Pfad bleibt im Code erhalten und greift nur noch, solange der
Anbieter-Schalter auf `payone` steht.
### Aufgaben ### Aufgaben
| # | Aufgabe | | # | 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.1 | `StripePaymentService::setupPaymentMethodChange()`: Checkout Session `mode=setup` (hosted, Redirect — konsistent zum Checkout-Flow) mit Customer (vorhandene `stripe_customer_id` des Abos oder neu via `StripeCustomerService`/Abo-Daten) und `setup_intent_data.metadata.user_abo_id`; freie Zahlartenwahl bei Stripe. |
| 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.2 | Webhook `checkout.session.completed` (`mode=setup`) / `setup_intent.succeeded`: Abo via `metadata.user_abo_id` auflösen → `payment_provider='stripe'`, `stripe_customer_id`, `stripe_payment_method_id`, `clearingtype='stripe'`, Anzeige-Summary (`carddata`) aus der PaymentMethod; alte Stripe-PaymentMethod am Customer detachen (best effort); `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.3 | Konsistenz-Regeln aus dem CC-Update-Projekt übernehmen: `card_update_pending_at` vor Redirect setzen; Webhook-OK → DB-Update + Flag löschen; Fehlerfall → CRITICAL-Log (Alt-Plan §5.3.1). |
| S5.4 | `AboPaymentMethodService` provider-fähig; `AboCardUpdateLink`-Flow (Berater→Kunde-Link) für Stripe-Abos freischalten. | | S5.4 | Einstiege umstellen (Verzweigung: Schalter `stripe` → Stripe-Setup-Flow, sonst bestehender PAYONE-CC-Pfad): Portal „Zahlungsart ändern", CRM (Berater-eigenes Abo), `AboCardUpdateLink`-Flow (Berater→Kunde-Link) — Formular-Partial zeigt statt Kartenfeldern einen „Zahlungsart bei Stripe ändern"-Button; gilt für PAYONE- UND Stripe-Abos. |
### Tests ### Umsetzungsstatus (2026-07-09)
- SetupIntent-Erfolg aktualisiert nur das Ziel-Abo; keine Abbuchung entsteht.
- Nicht-Inhaber wird abgewiesen (Policy unverändert). | # | Status |
- Pending-Flag/Inkonsistenz-Pfad (API ok, DB-Fehler) erzeugt Incident. |---|--------|
| S5.1 | ✅ `StripePaymentService::createPaymentMethodChangeSession()` (mode=setup, Metadata `user_abo_id`/`source` auf Session + SetupIntent); `ensureAboStripeCustomer()` legt für PAYONE-Bestandsabos den Stripe-Customer an und persistiert ihn sofort (Provider bleibt PAYONE bis zur Verankerung). |
| S5.2 | ✅ Return-Pfad (`finalizeStripeChangeFromSession`, prüft Session-Zugehörigkeit via Metadata) UND Webhook `setup_intent.succeeded` rufen das idempotente `applyStripePaymentMethod()`: Provider→stripe, Customer-/PM-ID, `clearingtype='stripe'`, Anzeige-Summary (`retrievePaymentMethodSummary`), `user_abo_card_changes`-Eintrag; alte Stripe-PM wird best effort detached. |
| S5.3 | ✅ `card_update_pending_at` vor Session-Erstellung; Fehler → Spur zurück; DB-Write-Fehler nach Setup-Erfolg → CRITICAL-Log, Spur bleibt (Muster CC-Update-Projekt). Freeze-Schalter blockiert `beginStripeChange`. |
| S5.4 | ✅ Drei Einstiege verzweigen nach Schalter: Portal (`portal_abos_payment_method_stripe`), CRM me-Abo (`user_abos_payment_method_stripe`), Kunden-Link (`abo_card_update_stripe`); gemeinsames Partial `_payment_method_form` zeigt bei Stripe einen „Neue Zahlungsart wählen"-Button statt Kartenfeldern; Rückkehr über die bestehenden `…_return`-Routen (`?session_id={CHECKOUT_SESSION_ID}`). **Invariante sauber getrennt:** Model bleibt pur (`canUpdatePaymentMethod` = nur CC; neu `canChangePaymentMethodAnyType`), die Flow-Wahl liegt in `AboPaymentMethodService::canStartChange()` (Controller + Views umgestellt). `sendCardUpdateLink` erlaubt den Link im Stripe-Flow für jede Zahlart. Abo-Detail-Karten (CRM/Portal) zeigen die Zahlart jetzt via `getPaymentType()` auch für Nicht-CC-/Stripe-Abos. |
**Neue Lang-Keys:** `abo.payment_method_change_*`, `abo.current_payment_method` (de/en, du-Form).
Der PAYONE-CC-Pfad ist unverändert und greift bei Schalter `payone`.
### Tests (2026-07-09)
`tests/Feature/StripePaymentMethodChangeTest.php` *(neu)*: **10 Tests** —
*(Fixes nach Manualtests 2026-07-09: (1) Setup-Sessions mit dynamischen Zahlarten
verlangen `currency` — ergänzt, fest `eur`; (2) Erfolgs-/Fehlerseite des Kunden-Links
(`card_update_done`) und die Link-Mail (`AboCardUpdateLinkMail`) sprachen noch von
„Kreditkarte"/PAYONE — Texte jetzt flow-abhängig: „Zahlungsart aktualisiert" bzw.
Stripe-Wortlaut, neue Keys `abo.payment_method_change_done_*`/`…_link_mail_*`.)* —
Flow-Weiche (Nicht-CC-Abos nur im Stripe-Flow, terminale Abos nie), Setup-Session
(Params, Pending-Spur), Customer-Anlage für PAYONE-Abos (Provider bleibt payone),
Freeze-Guard, Verankern (PAYONE→Stripe-Umzug inkl. Anzeige/`card_changes`), Idempotenz +
Detach der alten PM, Webhook-Verankerung, Return-Pfad inkl. Abweisung fremder Sessions.
Regression: Stripe-Gesamtsuite + CC-Update- + Abo-Retry/Recovery-Suiten **99 passed
(303 Assertions)**, Blade-Kompilat grün, Pint grün.
*Ursprünglicher Testplan (Referenz):* Setup ohne Abbuchung ✅ · Nicht-Inhaber abgewiesen ✅
(Policies unverändert, per bestehender Suite) · Pending-/Inkonsistenz-Pfad ✅ (CRITICAL-Log;
Incident-Anbindung folgt mit S6.2).
### Akzeptanzkriterien
- [x] **Browser-E2E (Testmodus), CRM-Pfad:** ✅ 2026-07-09 — Abo #442, neue Karte über die Setup-Seite hinterlegt: neue PM-ID verankert, carddata „Visa •••• 3155", `card_update_pending_at` geräumt, **genau ein** `user_abo_card_changes`-Eintrag (source `crm` — Return-Pfad und Webhook liefen beide, Idempotenz griff), keine Abbuchung.
- [x] **Browser-E2E, Berater-Kunden-Link inkl. Nicht-Karten-Zahlart:** ✅ 2026-07-09 — Abo #440 über den Link auf **PayPal** umgestellt: Provider `stripe`, PM-ID verankert, carddata `{type: paypal}`, Link entwertet (`used_at`), Wechsel-Protokoll source `link`, Status 2. Dabei gefixt: Erfolgsseite + Link-Mail sprachen noch von „Kreditkarte" → Texte jetzt flow-abhängig („Zahlungsart aktualisiert"). *Offen: Portal-Pfad (gleicher Code-Pfad wie CRM, geringe Priorität).*
- [x] **PAYONE-Bestandsabo wechselt vollständig auf Stripe** (Felder + Provider), PAYONE-Felder bleiben als Historie: ✅ Abo #440 war ein PAYONE-CC-Abo (Wechsel-Historie vorhanden) und läuft jetzt über Stripe/PayPal.
--- ---
@ -512,7 +696,7 @@ Payment-Dashboard kennt Stripe.
| 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.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.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.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. | | S6.4 | Wartungs-Command `stripe:sync-payment-status {paymentIntentId|--pending}` (Gegenstück zu `payone:*`-Reparatur-Commands): Status aktiv nachziehen, wenn ein Webhook verloren ging. **Teilweise vorgezogen (2026-07-09, s. §8):** `stripe:process-pending-events` verarbeitet gespeicherte, unverarbeitete Events nach (Cron-Ende + Scheduler alle 10 min). Offen bleibt der aktive API-Abgleich für komplett verlorene Webhooks. |
### Tests ### Tests
- Failed-Webhook setzt Abo-/Order-Status korrekt, erzeugt Incident, keine Doppel-Mails. - Failed-Webhook setzt Abo-/Order-Status korrekt, erzeugt Incident, keine Doppel-Mails.
@ -529,8 +713,55 @@ Payment-Dashboard kennt Stripe.
| 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.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.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.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.4 | Payment-Dashboard (`/admin/payment-dashboard`) **für Stripe anpassen/optimieren** (Stakeholder 2026-07-09): Kacheln/Listen kennen bisher nur PAYONE — Stripe-Zahlungen korrekt ausweisen (Provider-Filter, Zahlart-Labels, Verlinkung ins Stripe-Dashboard), Umsatz/Fehlerraten je Provider, Übersicht „Abos je Provider" (Auslauf-Fortschritt ablesbar). |
| S7.5 | Doku: Betriebs-Runbook (Webhook neu ausliefern, Key-Rotation, Testmodus) in diesem Ordner ablegen. | | S7.5 | Doku: Betriebs-Runbook (Webhook neu ausliefern, Key-Rotation, Testmodus) in diesem Ordner ablegen. |
| S7.6 | **Zahlarten-Kommunikation** (Stakeholder 2026-07-09): Die über Stripe verfügbaren Zahlarten klar benennen, möglichst mit Logos — (a) im Checkout unter der „Online-Zahlung"-Option, (b) auf den Shop-Seiten, (c) im Footer bei den Zahlungsmöglichkeiten, (d) auf der Seite `/zahlungsarten`. Vorkasse als „Vorkasse (PAYONE)" ausweisen. **ERLEDIGT ✅ (2026-07-09), siehe Umsetzungsstatus unten.** |
### Umsetzungsstatus S7.6 (ERLEDIGT ✅, 2026-07-09)
Kommuniziertes Zahlarten-Set (per API gegen die aktive Stripe `PaymentMethodConfiguration`
und die Account-Capabilities verifiziert, Erweiterung auf Stakeholder-Wunsch 2026-07-09):
**Kreditkarte (Visa, Mastercard, American Express), PayPal, Apple Pay, Google Pay,
Klarna, Amazon Pay, Link (Stripe), EPS, Bancontact** sowie als Sammelgruppe „Weitere
Zahlarten": **BLIK, MB WAY, Pix, Satispay, Samsung Pay, Kakao Pay, Naver Pay, PAYCO** —
plus **Vorkasse (PAYONE)**. Sofortüberweisung (von Stripe eingestellt) und
SEPA-Lastschrift (nicht aktiviert) wurden von der `/zahlungsarten`-Seite und aus dem
Footer **entfernt**. ⚠️ Verifiziert gegen den **Test-Modus** — Test-/Live-Modus haben
getrennte Zahlarten-Konfigurationen; das Live-Dashboard sollte dasselbe Set anbieten.
- **Logo-Assets:** einheitliche Marken-SVGs (38×24-Kartenstil, Shopify-`payment_icons`-Set)
unter `public/images/payments-assets/`: `visa/mastercard/amex/paypal/applepay/googlepay/
klarna/amazonpay/eps/bancontact/blik/mbway/pix/satispay/samsungpay/kakaopay/naverpay/
payco.svg` sowie selbst erstellte Badges `vorkasse.svg` („€ →") und `link.svg`
(grünes Stripe-Link-Badge; kein Icon im Set verfügbar). Die alten PNGs bleiben
liegen (Checkout-Badges `stripe.png`/`vor.png`/`fnc.png` werden weiter genutzt).
- **Wiederverwendbares Partial:** `resources/views/web/layouts/includes/payment_logos.blade.php`
(Parameter `logoHeight`, `showVorkasse`, `showAll`, `wrapperClass`; `title`/`alt` je
Marke) — genutzt von Checkout, beiden Footern und `/zahlungsarten`. Standard
(Checkout/Footer): nur die Kern-Zahlarten + „···"-Icon (`more.svg`, Tooltip „Weitere
Zahlarten"); die regionalen Zahlarten (BLIK/MB WAY/Pix/…) erscheinen nur mit
`showAll = true` auf `/zahlungsarten` (Stakeholder-Feedback 2026-07-09).
- **(a) Checkout** (`web/templates/checkout.blade.php`): Logo-Leiste (ohne Vorkasse) unter
der Option „Online-Zahlung", darunter weiterhin die benannte Liste
(`payment.stripe_online_methods`, um Amazon Pay ergänzt). Vorkasse war bereits als
„Vorkasse (PAYONE)" ausgewiesen (S4).
- **(b) Shop-Seiten / (c) Footer:** beide Footer (`web/layouts/includes/footer.blade.php`,
`web/user/layouts/includes/footer.blade.php`) zeigen statt der 5 veralteten PNGs
(PayPal/Sofort/SEPA/CC/Vorkasse) die neue Logo-Leiste inkl. Vorkasse — damit auf allen
Shop-Seiten sichtbar.
- **(d) `/zahlungsarten`** (`web/templates/zahlungsarten.blade.php`): komplett neu —
Intro (Stripe als Zahlungsdienstleister) + Logo-Leiste, dann je Zahlart eine Zeile mit
Logo, Name und Kurzbeschreibung: Kreditkarte, PayPal, Apple Pay, Google Pay, Klarna,
Amazon Pay, Link (Stripe), EPS, Bancontact, Sammelzeile „Weitere Zahlarten"
(BLIK/MB WAY/Pix/Satispay/Samsung Pay/Kakao Pay/Naver Pay/PAYCO), Vorkasse (PAYONE).
Neue Lang-Keys `weborder.payment_method_*` in de (du-Form) und en; alte
Sie-Form-Texte ersetzt, `payment_method_direct*`/`_sepa*` entfernt (waren nur dort
verwendet).
- **Tests:** `tests/Feature/PaymentMethodsCommunicationTest.php` (27 Tests: Partial mit/ohne
Vorkasse, alle SVG-Assets vorhanden, alle de/en-Lang-Keys, „Vorkasse (PAYONE)"-Label).
- **Hinweis:** Die Seite ist statisch — sollte der Provider-Schalter im Notfall zurück auf
`payone` gestellt werden, stimmt die Kommunikation vorübergehend nicht (bewusst in Kauf
genommen; der Checkout selbst bleibt maßgeblich).
### Tests ### Tests
- Schalter-UI: Berechtigung, Persistenz, SysLog. - Schalter-UI: Berechtigung, Persistenz, SysLog.
@ -554,17 +785,37 @@ Payment-Dashboard kennt Stripe.
Provider trägt, entstehen keine Mischzustände. Offene Stripe-Zahlungen laufen über Provider trägt, entstehen keine Mischzustände. Offene Stripe-Zahlungen laufen über
Webhooks normal aus. Webhooks normal aus.
### Optionale Abo-Migration PAYONE → Stripe (nur nach Entscheid O8) ### Optionale Abo-Migration PAYONE → Stripe
> **Entscheid 2026-07-09 (§7.2): Auslaufmodell statt Kampagne.** PAYONE ist wieder
> aktiv, Bestandsabos laufen dort unbefristet weiter. Die „Migration" passiert passiv
> über die Zahlungsmittel-Änderung (S4 Recovery / S5 CC-Update) — eine aktive
> Einladungs-Kampagne (`abo:invite-stripe-migration`) ist **nicht mehr geplant** und
> bleibt hier nur als Option dokumentiert, falls PAYONE doch abgeschaltet werden soll.
- PAYONE-Tokens sind **nicht portabel** → Migration heißt: Kunde hinterlegt Zahlungsmittel - PAYONE-Tokens sind **nicht portabel** → Migration heißt: Kunde hinterlegt Zahlungsmittel
neu (SetupIntent, kein Charge) über den Card-Update-Link-Flow; danach neu (SetupIntent, kein Charge) über den Card-Update-Link-Flow; danach
`payment_provider='stripe'` am Abo, PAYONE-Felder bleiben als Historie. `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. - Kein Zwang, kein Big-Bang: nicht migrierte Abos laufen unbefristet über PAYONE.
--- ---
## 12.1 Phase S9 (nachgelagert) – Rückbuchungen & Stornierungen erfassen
**Stakeholder-Entscheid 2026-07-09:** Als eigener Punkt aufgenommen, bewusst
**nachgelagert** (nach S4–S7). Zukünftig müssen Rückbuchungen und Stornierungen
systemseitig miterfasst werden:
| # | Aufgabe |
|---|---------|
| S9.1 | **Rückbuchungen (Chargebacks/Disputes):** `charge.dispute.created`/`charge.dispute.closed` verarbeiten — Zahlung/Bestellung kennzeichnen, `PaymentIncident` erzeugen (Überschneidung S6.2), Betrag inkl. Dispute-Gebühr ausweisen. PAYONE-Pendant (Rückläufer/`cancelation`) mitdenken. |
| S9.2 | **Erstattungen:** `charge.refunded` (Voll/Teil) an der Zahlung verbuchen; Verzahnung mit dem Widerrufs-/Refund-Flow aus S7.2. |
| S9.3 | **Stornierungen:** Stripe-seitige Cancels (`payment_intent.canceled`, abgebrochene/stornierte Vorgänge) sauber am Vorgang dokumentieren. |
| S9.4 | **Buchhaltung:** Klären, wie Rückbuchungen/Storni in Rechnungskorrektur/DATEV-Export einfließen (mit Stakeholder/Steuerberater). |
| S9.5 | Auswertung: Kacheln/Listen im Payment-Dashboard (Überschneidung S7.4). |
---
## 13. Teststrategie (übergreifend) ## 13. Teststrategie (übergreifend)
| Ebene | Inhalt | | Ebene | Inhalt |
@ -627,13 +878,27 @@ routes/api.php, routes/domains/*.php
Abo-/Order-Views (Provider-Badge, maskiertes Zahlungsmittel) Abo-/Order-Views (Provider-Badge, maskiertes Zahlungsmittel)
``` ```
## 16. Nächste Schritte ## 16. Nächste Schritte (Stand 2026-07-09)
1. ~~O-Fragen klären~~ ✅ **beantwortet 2026-07-08** (Konzept §10): Hosted Checkout mit 1. ~~**Testmodus-E2E für S4/S5**~~ ✅ **komplett 2026-07-09**: Abo-Abschluss, Cron-Abbuchung
Redirect, alle Stripe-Zahlarten inkl. PayPal/Apple Pay/Google Pay/Amazon Pay/Klarna, (inkl. `stripe:process-pending-events`), Zahlungsart-Wechsel (CRM + Kunden-Link,
nur EUR, SDK freigegeben, Account vorhanden, Bestands-Abo-Migration und PayPal, PAYONE→Stripe-Umzug), Decline → Status 3, Recovery-Checkout,
Membership/ELV bewusst nach hinten gestellt (S8 bzw. Folgeprojekt). authentication_required. Aufräumen: Test-Abos #440/#442 (Abo 442 steht nach dem
2. Phase **S0** starten: Test-Keys + Webhook im (vorhandenen) Stripe-Account einrichten, letzten Test auf Status 3 mit der `…3184`-Testkarte) und Test-Bestellungen bereinigen.
`stripe/stripe-php` installieren, `config/stripe.php`, **N1-Prüfung** (Klarna/Amazon 2. **Deployment + Migrationen** (3 neue: Provider-/Stripe-Abo-Spalten,
Pay Abo-Fähigkeit). `widen_clearingtype`), dann im Admin: **`abo-payments-frozen` aufheben** (§7.2),
3. Danach S1 → S2 → S3; nach S3 erster produktiver Meilenstein (Einmalzahlung) möglich. Vorkasse wieder einblenden (`payment-vorkasse-hidden` aus). Ersten 04:00-Cron-Lauf
beobachten (holt eingefrorene PAYONE-Abbuchungen nach → großer Lauf!):
`abo_order.log`, `stripe.log`, `stripe_webhook_events`, Payment-Dashboard.
Der Mischlauf PAYONE+Stripe verifiziert sich dabei. Dazu im **Live**-Stripe-Dashboard
das Zahlarten-Set mit dem Testmodus abgleichen (S7.6 kommuniziert u. a. Link, EPS,
Bancontact — Test/Live sind getrennt konfiguriert!).
3. ~~Phase **S5**~~ ✅ umgesetzt + E2E (2026-07-09, Scope erweitert auf „Zahlungsart
ändern"): CRM-Pfad und Berater-Kunden-Link im Browser verprobt (inkl. PayPal und
PAYONE→Stripe-Umzug, Abo 440). Nur der **Portal-Pfad** ist ohne Sichttest
(identischer Code-Pfad, geringe Prio).
4. ~~**S7.6 Zahlarten-Kommunikation**~~ ✅ umgesetzt (2026-07-09, siehe Umsetzungsstatus
in §11): Logos + Benennung in Checkout, Footer (alle Shop-Seiten) und `/zahlungsarten`.
**Offen:** kurzer Sichttest im Browser (Footer, `/zahlungsarten`, Checkout).
5. **S7.4 Payment-Dashboard** für Stripe anpassen/optimieren (Stakeholder 2026-07-09).
6. Nachgelagert: **S9 Rückbuchungen & Stornierungen** (§12.1).

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" aria-labelledby="pi-amazon"><title id="pi-amazon">Amazon Pay</title><path d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3z" fill="#000" fill-rule="nonzero" opacity=".07"/><path d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32" fill="#FFF" fill-rule="nonzero"/><path d="M25.26 16.23c-1.697 1.48-4.157 2.27-6.275 2.27-2.97 0-5.644-1.3-7.666-3.463-.16-.17-.018-.402.173-.27 2.183 1.504 4.882 2.408 7.67 2.408 1.88 0 3.95-.46 5.85-1.416.288-.145.53.222.248.47v.001zm.706-.957c-.216-.328-1.434-.155-1.98-.078-.167.024-.193-.148-.043-.27.97-.81 2.562-.576 2.748-.305.187.272-.047 2.16-.96 3.063-.14.138-.272.064-.21-.12.205-.604.664-1.96.446-2.29h-.001z" fill="#F90" fill-rule="nonzero"/><path d="M21.814 15.291c-.574-.498-.676-.73-.993-1.205-.947 1.012-1.618 1.315-2.85 1.315-1.453 0-2.587-.938-2.587-2.818 0-1.467.762-2.467 1.844-2.955.94-.433 2.25-.51 3.25-.628v-.235c0-.43.033-.94-.208-1.31-.212-.333-.616-.47-.97-.47-.66 0-1.25.353-1.392 1.085-.03.163-.144.323-.3.33l-1.677-.187c-.14-.033-.296-.153-.257-.38.386-2.125 2.223-2.766 3.867-2.766.84 0 1.94.234 2.604.9.842.82.762 1.918.762 3.11v2.818c0 .847.335 1.22.65 1.676.113.164.138.36-.003.482-.353.308-.98.88-1.326 1.2a.367.367 0 0 1-.414.038zm-1.659-2.533c.34-.626.323-1.214.323-1.918v-.392c-1.25 0-2.57.28-2.57 1.82 0 .782.386 1.31 1.05 1.31.487 0 .922-.312 1.197-.82z" fill="#221F1F"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" fill="none" aria-labelledby="pi-american_express"><title id="pi-american_express">American Express</title><rect x=".5" y=".5" width="37" height="23" rx="2.5" stroke="#000" stroke-opacity=".07" fill="none"/><path d="M35 0H3C1.3 0 0 1.3 0 3V21C0 22.7 1.4 24 3 24H35C36.7 24 38 22.7 38 21V3C38 1.3 36.6 0 35 0Z" fill="#0071CE" style="fill:#0071CE;fill:color(display-p3 0.0000 0.4431 0.8078);fill-opacity:1;"/><path d="M3 0.5H35C36.3348 0.5 37.5 1.58692 37.5 3V21C37.5 22.4239 36.4239 23.5 35 23.5H3C1.66524 23.5 0.5 22.4131 0.5 21V3C0.5 1.57614 1.57614 0.5 3 0.5Z" stroke="black" stroke-opacity="0.07" style="stroke:black;stroke-opacity:0.07;"/><path d="M25.8662 6.33203V3H31L31.8662 5.5332L32.7334 3H37V14.2002H36.7998L34.8672 16.2656L36.7998 18.3594H37V21.2666H33.5996L31.9336 19.3994L30.2002 21.2666H19.4668V12.666H16L20.2666 3H24.4004L25.8662 6.33203ZM20.5996 20.2656H27V18.5322H22.666V17.3994H26.8662V15.666H22.666V14.5322H27V12.7988H20.5996V20.2656ZM30.5332 16.5322L27 20.2656H29.5996L31.8662 17.8662L34.0664 20.2656H36.7324L33.1992 16.4658L36.7324 12.7988H34.1328L31.8662 15.1992L29.7324 12.7988H27L30.5332 16.5322ZM17.666 11.7324H19.9326L20.5332 10.1992H23.999L24.666 11.7324H26.999L23.666 4.19922H20.999L17.666 11.7324ZM33.5996 4.19922L31.9326 8.86621L30.1992 4.19922H27V11.666H29.0664V6.39941L31 11.666H32.7998L34.7324 6.39941V11.666H36.7324V4.13281L33.5996 4.19922ZM23.2656 8.46582H21.2656L22.2656 5.99902L23.2656 8.46582Z" fill="white" style="fill:white;fill-opacity:1;"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" role="img" x="0" y="0" width="38" height="24" viewBox="0 0 165.521 105.965" xml:space="preserve" aria-labelledby="pi-apple_pay"><title id="pi-apple_pay">Apple Pay</title><path fill="#000" d="M150.698 0H14.823c-.566 0-1.133 0-1.698.003-.477.004-.953.009-1.43.022-1.039.028-2.087.09-3.113.274a10.51 10.51 0 0 0-2.958.975 9.932 9.932 0 0 0-4.35 4.35 10.463 10.463 0 0 0-.975 2.96C.113 9.611.052 10.658.024 11.696a70.22 70.22 0 0 0-.022 1.43C0 13.69 0 14.256 0 14.823v76.318c0 .567 0 1.132.002 1.699.003.476.009.953.022 1.43.028 1.036.09 2.084.275 3.11a10.46 10.46 0 0 0 .974 2.96 9.897 9.897 0 0 0 1.83 2.52 9.874 9.874 0 0 0 2.52 1.83c.947.483 1.917.79 2.96.977 1.025.183 2.073.245 3.112.273.477.011.953.017 1.43.02.565.004 1.132.004 1.698.004h135.875c.565 0 1.132 0 1.697-.004.476-.002.952-.009 1.431-.02 1.037-.028 2.085-.09 3.113-.273a10.478 10.478 0 0 0 2.958-.977 9.955 9.955 0 0 0 4.35-4.35c.483-.947.789-1.917.974-2.96.186-1.026.246-2.074.274-3.11.013-.477.02-.954.022-1.43.004-.567.004-1.132.004-1.699V14.824c0-.567 0-1.133-.004-1.699a63.067 63.067 0 0 0-.022-1.429c-.028-1.038-.088-2.085-.274-3.112a10.4 10.4 0 0 0-.974-2.96 9.94 9.94 0 0 0-4.35-4.35A10.52 10.52 0 0 0 156.939.3c-1.028-.185-2.076-.246-3.113-.274a71.417 71.417 0 0 0-1.431-.022C151.83 0 151.263 0 150.698 0z" /><path fill="#FFF" d="M150.698 3.532l1.672.003c.452.003.905.008 1.36.02.793.022 1.719.065 2.583.22.75.135 1.38.34 1.984.648a6.392 6.392 0 0 1 2.804 2.807c.306.6.51 1.226.645 1.983.154.854.197 1.783.218 2.58.013.45.019.9.02 1.36.005.557.005 1.113.005 1.671v76.318c0 .558 0 1.114-.004 1.682-.002.45-.008.9-.02 1.35-.022.796-.065 1.725-.221 2.589a6.855 6.855 0 0 1-.645 1.975 6.397 6.397 0 0 1-2.808 2.807c-.6.306-1.228.511-1.971.645-.881.157-1.847.2-2.574.22-.457.01-.912.017-1.379.019-.555.004-1.113.004-1.669.004H14.801c-.55 0-1.1 0-1.66-.004a74.993 74.993 0 0 1-1.35-.018c-.744-.02-1.71-.064-2.584-.22a6.938 6.938 0 0 1-1.986-.65 6.337 6.337 0 0 1-1.622-1.18 6.355 6.355 0 0 1-1.178-1.623 6.935 6.935 0 0 1-.646-1.985c-.156-.863-.2-1.788-.22-2.578a66.088 66.088 0 0 1-.02-1.355l-.003-1.327V14.474l.002-1.325a66.7 66.7 0 0 1 .02-1.357c.022-.792.065-1.717.222-2.587a6.924 6.924 0 0 1 .646-1.981c.304-.598.7-1.144 1.18-1.623a6.386 6.386 0 0 1 1.624-1.18 6.96 6.96 0 0 1 1.98-.646c.865-.155 1.792-.198 2.586-.22.452-.012.905-.017 1.354-.02l1.677-.003h135.875" /><g><g><path fill="#000" d="M43.508 35.77c1.404-1.755 2.356-4.112 2.105-6.52-2.054.102-4.56 1.355-6.012 3.112-1.303 1.504-2.456 3.959-2.156 6.266 2.306.2 4.61-1.152 6.063-2.858" /><path fill="#000" d="M45.587 39.079c-3.35-.2-6.196 1.9-7.795 1.9-1.6 0-4.049-1.8-6.698-1.751-3.447.05-6.645 2-8.395 5.1-3.598 6.2-.95 15.4 2.55 20.45 1.699 2.5 3.747 5.25 6.445 5.151 2.55-.1 3.549-1.65 6.647-1.65 3.097 0 3.997 1.65 6.696 1.6 2.798-.05 4.548-2.5 6.247-5 1.95-2.85 2.747-5.6 2.797-5.75-.05-.05-5.396-2.101-5.446-8.251-.05-5.15 4.198-7.6 4.398-7.751-2.399-3.548-6.147-3.948-7.447-4.048" /></g><g><path fill="#000" d="M78.973 32.11c7.278 0 12.347 5.017 12.347 12.321 0 7.33-5.173 12.373-12.529 12.373h-8.058V69.62h-5.822V32.11h14.062zm-8.24 19.807h6.68c5.07 0 7.954-2.729 7.954-7.46 0-4.73-2.885-7.434-7.928-7.434h-6.706v14.894z" /><path fill="#000" d="M92.764 61.847c0-4.809 3.665-7.564 10.423-7.98l7.252-.442v-2.08c0-3.04-2.001-4.704-5.562-4.704-2.938 0-5.07 1.507-5.51 3.82h-5.252c.157-4.86 4.731-8.395 10.918-8.395 6.654 0 10.995 3.483 10.995 8.89v18.663h-5.38v-4.497h-.13c-1.534 2.937-4.914 4.782-8.579 4.782-5.406 0-9.175-3.222-9.175-8.057zm17.675-2.417v-2.106l-6.472.416c-3.64.234-5.536 1.585-5.536 3.95 0 2.288 1.975 3.77 5.068 3.77 3.95 0 6.94-2.522 6.94-6.03z" /><path fill="#000" d="M120.975 79.652v-4.496c.364.051 1.247.103 1.715.103 2.573 0 4.029-1.09 4.913-3.899l.52-1.663-9.852-27.293h6.082l6.863 22.146h.13l6.862-22.146h5.927l-10.216 28.67c-2.34 6.577-5.017 8.735-10.683 8.735-.442 0-1.872-.052-2.261-.157z" /></g></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-labelledby="pi-bancontact" role="img" viewBox="0 0 38 24" width="38" height="24"><title id="pi-bancontact">Bancontact</title><path fill="#000" opacity=".07" d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3z"/><path fill="#fff" d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32"/><path d="M4.703 3.077h28.594c.139 0 .276.023.405.068.128.045.244.11.343.194a.9.9 0 0 1 .229.29c.053.107.08.223.08.34V20.03a.829.829 0 0 1-.31.631 1.164 1.164 0 0 1-.747.262H4.703a1.23 1.23 0 0 1-.405-.068 1.09 1.09 0 0 1-.343-.194.9.9 0 0 1-.229-.29.773.773 0 0 1-.08-.34V3.97c0-.118.027-.234.08-.342a.899.899 0 0 1 .23-.29c.098-.082.214-.148.342-.193a1.23 1.23 0 0 1 .405-.068Z" fill="#fff"/><path d="M6.38 18.562v-3.077h1.125c.818 0 1.344.259 1.344.795 0 .304-.167.515-.401.638.338.132.536.387.536.734 0 .62-.536.91-1.37.91H6.38Zm.724-1.798h.537c.328 0 .468-.136.468-.387 0-.268-.255-.356-.599-.356h-.406v.743Zm0 1.262h.448c.438 0 .693-.093.693-.383 0-.286-.219-.404-.63-.404h-.51v.787Zm3.284.589c-.713 0-1.073-.295-1.073-.69 0-.436.422-.69 1.047-.695.156.002.31.014.464.035v-.105c0-.269-.183-.396-.531-.396a2.128 2.128 0 0 0-.688.105l-.13-.474a3.01 3.01 0 0 1 .9-.132c.767 0 1.147.343 1.147.936v1.222c-.214.093-.615.194-1.136.194Zm.438-.497v-.47a2.06 2.06 0 0 0-.37-.036c-.24 0-.427.08-.427.286 0 .185.156.281.432.281a.947.947 0 0 0 .365-.061Zm1.204.444v-2.106a3.699 3.699 0 0 1 1.177-.193c.76 0 1.198.316 1.198.9v1.399h-.719v-1.354c0-.303-.167-.444-.484-.444a1.267 1.267 0 0 0-.459.079v1.719h-.713Zm4.886-2.167-.135.479a1.834 1.834 0 0 0-.588-.11c-.422 0-.652.25-.652.664 0 .453.24.685.688.685.2-.004.397-.043.578-.114l.115.488a2.035 2.035 0 0 1-.75.128c-.865 0-1.365-.453-1.365-1.17 0-.712.495-1.182 1.323-1.182.27-.001.538.043.787.132Zm1.553 2.22c-.802 0-1.302-.47-1.302-1.178 0-.704.5-1.174 1.302-1.174.807 0 1.297.47 1.297 1.173 0 .708-.49 1.179-1.297 1.179Zm0-.502c.37 0 .563-.259.563-.677 0-.413-.193-.672-.563-.672-.364 0-.568.26-.568.672 0 .418.204.677.568.677Zm1.713.449v-2.106a3.699 3.699 0 0 1 1.177-.193c.76 0 1.198.316 1.198.9v1.399h-.719v-1.354c0-.303-.166-.444-.484-.444a1.268 1.268 0 0 0-.459.079v1.719h-.713Zm3.996.053c-.62 0-.938-.286-.938-.866v-.95h-.354v-.484h.355v-.488l.718-.03v.518h.578v.484h-.578v.94c0 .256.125.374.36.374.093 0 .185-.008.276-.026l.036.488c-.149.028-.3.041-.453.04Zm1.814 0c-.713 0-1.073-.295-1.073-.69 0-.436.422-.69 1.047-.695.155.002.31.014.464.035v-.105c0-.269-.183-.396-.532-.396a2.128 2.128 0 0 0-.687.105l-.13-.474a3.01 3.01 0 0 1 .9-.132c.766 0 1.146.343 1.146.936v1.222c-.213.093-.614.194-1.135.194Zm.438-.497v-.47a2.06 2.06 0 0 0-.37-.036c-.24 0-.427.08-.427.286 0 .185.156.281.432.281a.946.946 0 0 0 .365-.061Zm3.157-1.723-.136.479a1.834 1.834 0 0 0-.588-.11c-.422 0-.651.25-.651.664 0 .453.24.685.687.685.2-.004.397-.043.578-.114l.115.488a2.035 2.035 0 0 1-.75.128c-.865 0-1.365-.453-1.365-1.17 0-.712.495-1.182 1.323-1.182.27-.001.538.043.787.132Zm1.58 2.22c-.62 0-.938-.286-.938-.866v-.95h-.354v-.484h.354v-.488l.72-.03v.518h.577v.484h-.578v.94c0 .256.125.374.36.374.092 0 .185-.008.276-.026l.036.488c-.149.028-.3.041-.453.04Z" fill="#1E3764"/><path d="M11.394 13.946c3.803 0 5.705-2.14 7.606-4.28H6.38v4.28h5.014Z" fill="url(#pi-bancontact-a)"/><path d="M26.607 5.385c-3.804 0-5.705 2.14-7.607 4.28h12.62v-4.28h-5.013Z" fill="url(#pi-bancontact-b)"/><defs><linearGradient id="pi-bancontact-a" x1="8.933" y1="12.003" x2="17.734" y2="8.13" gradientUnits="userSpaceOnUse"><stop stop-color="#005AB9"/><stop offset="1" stop-color="#1E3764"/></linearGradient><linearGradient id="pi-bancontact-b" x1="19.764" y1="10.037" x2="29.171" y2="6.235" gradientUnits="userSpaceOnUse"><stop stop-color="#FBA900"/><stop offset="1" stop-color="#FFD800"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" aria-labelledby="pi-blik"><title id="pi-blik">BLIK</title><path fill="#000" opacity=".07" d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3z"/><path fill="#fff" d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32"/><path d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32z" fill="url(#pi-blik-paint0_linear)"/><path d="M30.343 17.155l-2.785-3.639 2.563-3.236h-2.185l-2.456 3.138V6.78h-1.848v10.375h1.848v-3.532l2.456 3.532h2.407zM18.613 6.78h-1.848v10.366h1.848V6.78zm3.433 3.508h-1.848v6.867h1.848v-6.867z" fill="#fff"/><path d="M13.849 9.573a1.651 1.651 0 100-3.302 1.651 1.651 0 000 3.302z" fill="url(#pi-blik-paint1_radial)"/><path d="M12.041 10.206c-.574 0-1.138.144-1.642.419V6.82H8.534v6.9a3.516 3.516 0 103.507-3.515zm0 5.175a1.643 1.643 0 110-3.286 1.643 1.643 0 010 3.286z" fill="#fff"/><defs><radialGradient id="pi-blik-paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(12.51 6.18) scale(5.41297)"><stop stop-color="red"/><stop offset=".49" stop-color="#E83E49"/><stop offset="1" stop-color="#F0F"/></radialGradient><linearGradient id="pi-blik-paint0_linear" x1="19" y1="4.977" x2="19" y2="55.605" gradientUnits="userSpaceOnUse"><stop/><stop offset=".732" stop-color="#fff"/><stop offset="1" stop-color="#fff"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" aria-labelledby="pi-eps"> <title id="pi-eps">EPS</title> <path d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3z" opacity=".07"/> <path d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32" fill="#fff"/> <path fill="#71706f" d="M27.745 12.32h-2.322a.465.465 0 01-.468-.464c0-.258.21-.484.468-.484h3.535V9.628h-3.535c-1.233 0-2.237 1.006-2.237 2.236s1.004 2.237 2.237 2.237h2.29c.259 0 .469.205.469.462 0 .258-.21.448-.468.448h-4.912c-.417.796-.822 1.478-1.645 1.82h6.588c1.213-.018 2.205-1.045 2.205-2.265 0-1.22-.992-2.23-2.205-2.247z"/> <path fill="#71706f" d="M18.845 9.628c-1.968 0-3.571 1.612-3.571 3.594V20.697h1.782V16.83h1.785c1.968 0 3.565-1.634 3.565-3.615 0-1.98-1.593-3.587-3.561-3.587zm0 5.383h-1.79v-1.796c0-1.003.8-1.82 1.79-1.82s1.796.817 1.796 1.82a1.79 1.79 0 01-1.796 1.796z"/> <path fill="#c8036f" d="M9.634 16.83c-1.685 0-3.101-1.2-3.484-2.768 0 0-.111-.519-.111-.86 0-.342.105-.865.105-.865a3.594 3.594 0 013.482-2.73c1.978 0 3.6 1.608 3.6 3.586v.87H7.973c.31.607.938.948 1.662.948h4.724l.006-5.13c0-.764-.625-1.39-1.39-1.39H6.278c-.764 0-1.39.607-1.39 1.371v6.696c0 .765.626 1.41 1.39 1.41h6.696c.686 0 1.259-.493 1.37-1.138h-4.71z"/> <path fill="#c8036f" d="M9.626 11.31c-.72 0-1.348.44-1.66 1.01h3.32c-.312-.57-.939-1.01-1.66-1.01zM12.659 6.314c0-1.635-1.359-2.96-3.034-2.96-1.647 0-2.987 1.282-3.031 2.879v.91c0 .106.086.21.195.21h1.116c.11 0 .205-.104.205-.21v-.83c0-.815.68-1.48 1.516-1.48.837 0 1.516.665 1.516 1.48v.83c0 .106.089.21.198.21h1.116c.109 0 .202-.104.202-.21v-.83z"/> <g> <path fill="#71706f" d="M20.843 19.142l-.16.914c-.082.462-.384.673-.794.673-.347 0-.616-.226-.538-.672l.161-.914h.249l-.161.913c-.05.285.078.447.32.447.249 0 .462-.148.513-.446l.161-.915h.25zm-.977-.285c0 .076.059.112.121.112.079 0 .164-.057.164-.162 0-.074-.05-.112-.114-.112-.08 0-.17.052-.17.162zm.538-.003c0 .079.06.114.126.114.07 0 .161-.056.161-.159 0-.074-.054-.114-.114-.114-.08 0-.173.052-.173.16zM21.445 19.142l-.112.632a.57.57 0 01.423-.199c.278 0 .47.163.47.465 0 .428-.297.686-.678.686-.148 0-.274-.055-.35-.194l-.042.165h-.218l.276-1.554h.23zm-.175 1.066c-.01.183.11.302.282.302a.444.444 0 00.441-.423c.01-.186-.111-.296-.28-.296a.45.45 0 00-.443.417zM22.68 20.242c-.01.151.11.275.311.275.112 0 .26-.044.34-.12l.126.145a.782.782 0 01-.508.188c-.319 0-.505-.19-.505-.482 0-.388.305-.677.695-.677.356 0 .558.216.437.67h-.896zm.708-.19c.018-.19-.094-.267-.28-.267-.168 0-.323.078-.39.267h.67zM24.193 19.597l-.012.146a.414.414 0 01.357-.17c.107 0 .204.039.255.095l-.139.201a.244.244 0 00-.183-.067.346.346 0 00-.352.3l-.106.595h-.23l.195-1.1h.215zM25.905 19.597l.116.87.422-.87h.257l-.578 1.1h-.27l-.11-.725-.178.36-.192.365h-.27l-.194-1.1h.26l.114.87.424-.87h.199zM26.997 20.242c-.01.151.11.275.311.275.112 0 .26-.044.34-.12l.126.145a.782.782 0 01-.508.188c-.319 0-.505-.19-.505-.482 0-.388.305-.677.695-.677.356 0 .558.216.437.67h-.896zm.708-.19c.018-.19-.094-.267-.28-.267-.168 0-.323.078-.39.267h.67zM28.525 19.597l-.194 1.1H28.1l.195-1.1h.23zm-.204-.3c0 .08.059.12.121.12.09 0 .173-.059.173-.168a.113.113 0 00-.117-.117c-.082 0-.177.054-.177.166zM29.55 19.87c-.072-.081-.164-.104-.274-.104-.152 0-.26.052-.26.148 0 .08.088.114.218.126.201.017.459.085.405.37-.038.205-.244.326-.53.326-.18 0-.353-.04-.47-.204l.151-.163c.083.114.229.161.358.164.11 0 .24-.04.26-.144.02-.098-.067-.136-.228-.152-.188-.018-.394-.087-.394-.296 0-.276.298-.372.522-.372.17 0 .295.038.399.148l-.157.152zM30.278 19.597l-.105.592c-.034.189.051.319.244.319.181 0 .336-.15.367-.334l.101-.576h.231l-.195 1.099h-.208l.015-.16a.573.573 0 01-.412.182c-.271 0-.432-.194-.374-.528l.105-.593h.231zM32.13 20.697l.105-.589c.033-.19-.032-.317-.242-.317-.184 0-.339.148-.37.33l-.1.576h-.232l.195-1.1h.21l-.015.16a.583.583 0 01.403-.176c.27 0 .444.187.384.525l-.106.59h-.233zM32.912 20.848c-.016.152.1.213.296.213.17 0 .354-.095.396-.337l.034-.189c-.1.142-.285.197-.421.197-.283 0-.48-.168-.48-.473 0-.434.323-.685.688-.685.155 0 .294.073.343.199l.038-.176h.224l-.193 1.132c-.071.42-.383.55-.668.55-.34 0-.528-.165-.481-.43h.224zm.065-.633c0 .188.123.302.3.302.477 0 .605-.733.128-.733a.42.42 0 00-.428.43z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" aria-labelledby="pi-google_pay"><title id="pi-google_pay">Google Pay</title><path d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3z" fill="#000" opacity=".07"/><path d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32" fill="#FFF"/><path d="M18.093 11.976v3.2h-1.018v-7.9h2.691a2.447 2.447 0 0 1 1.747.692 2.28 2.28 0 0 1 .11 3.224l-.11.116c-.47.447-1.098.69-1.747.674l-1.673-.006zm0-3.732v2.788h1.698c.377.012.741-.135 1.005-.404a1.391 1.391 0 0 0-1.005-2.354l-1.698-.03zm6.484 1.348c.65-.03 1.286.188 1.778.613.445.43.682 1.03.65 1.649v3.334h-.969v-.766h-.049a1.93 1.93 0 0 1-1.673.931 2.17 2.17 0 0 1-1.496-.533 1.667 1.667 0 0 1-.613-1.324 1.606 1.606 0 0 1 .613-1.336 2.746 2.746 0 0 1 1.698-.515c.517-.02 1.03.093 1.49.331v-.208a1.134 1.134 0 0 0-.417-.901 1.416 1.416 0 0 0-.98-.368 1.545 1.545 0 0 0-1.319.717l-.895-.564a2.488 2.488 0 0 1 2.182-1.06zM23.29 13.52a.79.79 0 0 0 .337.662c.223.176.5.269.785.263.429-.001.84-.17 1.146-.472.305-.286.478-.685.478-1.103a2.047 2.047 0 0 0-1.324-.374 1.716 1.716 0 0 0-1.03.294.883.883 0 0 0-.392.73zm9.286-3.75l-3.39 7.79h-1.048l1.281-2.728-2.224-5.062h1.103l1.612 3.885 1.569-3.885h1.097z" fill="#5F6368"/><path d="M13.986 11.284c0-.308-.024-.616-.073-.92h-4.29v1.747h2.451a2.096 2.096 0 0 1-.9 1.373v1.134h1.464a4.433 4.433 0 0 0 1.348-3.334z" fill="#4285F4"/><path d="M9.629 15.721a4.352 4.352 0 0 0 3.01-1.097l-1.466-1.14a2.752 2.752 0 0 1-4.094-1.44H5.577v1.17a4.53 4.53 0 0 0 4.052 2.507z" fill="#34A853"/><path d="M7.079 12.05a2.709 2.709 0 0 1 0-1.735v-1.17H5.577a4.505 4.505 0 0 0 0 4.075l1.502-1.17z" fill="#FBBC04"/><path d="M9.629 8.44a2.452 2.452 0 0 1 1.74.68l1.3-1.293a4.37 4.37 0 0 0-3.065-1.183 4.53 4.53 0 0 0-4.027 2.5l1.502 1.171a2.715 2.715 0 0 1 2.55-1.875z" fill="#EA4335"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1 @@
<svg width="38" height="24" viewBox="0 0 38 24" fill="#ffeb00" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="pi-kakao_pay"><title id="pi-kakao_pay">Kakao Pay</title><rect x="1" y="1" width="36" height="22" rx="2" fill="#fff"/><rect x=".5" y=".5" width="37" height="23" rx="2.5" stroke="#000" stroke-opacity=".07"/><g clip-path="url(#pi-clip0)" fill="#1E1E1E"><path d="M9.274 8C6.637 8 4.5 9.68 4.5 11.764c0 1.344.894 2.52 2.238 3.189-.074.252-.47 1.623-.485 1.73 0 0 0 .082.042.11a.145.145 0 00.113 0c.151-.02 1.74-1.137 2.018-1.332.278.039.558.059.84.059 2.636 0 4.773-1.68 4.773-3.764C14.04 9.67 11.91 8 9.274 8zM19.797 8.54a2.87 2.87 0 00-1.087.199 3.857 3.857 0 00-.584.296l-.096-.34h-1.05v8.498h1.356V14.94c.121.031.244.053.368.067.215.024.43.036.646.036a2.7 2.7 0 001.172-.25 2.52 2.52 0 00.888-.705c.248-.318.436-.679.554-1.064.133-.436.198-.89.193-1.346 0-.945-.189-1.705-.558-2.261-.37-.556-.995-.877-1.802-.877zm.983 3.148c0 .708-.143 1.26-.42 1.642-.336.456-.869.62-1.698.512a2.173 2.173 0 01-.323-.067V10.1c.16-.096.333-.174.512-.23.227-.077.464-.117.703-.118a.99.99 0 01.924.44c.2.328.302.826.302 1.489v.008zM25.602 8.54a3.943 3.943 0 00-1.05.146c-.35.097-.686.239-1.001.42l-.157.088.436 1.03.198-.093c.242-.112.494-.2.753-.265.26-.068.527-.103.796-.105a1.03 1.03 0 01.756.235c.157.156.237.441.237.84v.269h-.96c-.802 0-1.423.168-1.849.502a1.824 1.824 0 00-.665 1.524 1.87 1.87 0 00.49 1.377 1.857 1.857 0 001.37.502 3.04 3.04 0 001.034-.21c.258-.095.503-.22.73-.372l.085.485h1.077v-4.024c0-.796-.193-1.394-.573-1.776-.38-.382-.955-.573-1.707-.573zm-.48 5.302a.726.726 0 01-.546-.182.785.785 0 01-.181-.567.777.777 0 01.283-.672c.21-.153.559-.23 1.05-.23h.84v1.101a1.74 1.74 0 01-.246.179 2.68 2.68 0 01-.812.325c-.128.03-.258.045-.389.046zM33.212 8.491l-1.604 4.619-1.585-4.619-1.333.42 2.208 5.807a4.533 4.533 0 01-.564.883c-.25.308-.532.59-.84.84l-.183.15.874.839.14-.097c.48-.331.894-.747 1.224-1.228a7.8 7.8 0 00.882-1.755l2.089-5.47-1.308-.389z"/></g><defs><clipPath id="pi-clip0"><path fill="#fff" d="M3 7h32v10H3z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" width="38" height="24" viewBox="0 0 38 24" aria-labelledby="pi-klarna" fill="none"><title id="pi-klarna">Klarna</title><rect width="38" height="24" rx="2" fill="#FFA8CD"/><rect x=".5" y=".5" width="37" height="23" rx="1.5" stroke="#000" stroke-opacity=".07"/><path d="M30.62 14.755c-.662 0-1.179-.554-1.179-1.226 0-.673.517-1.226 1.18-1.226.663 0 1.18.553 1.18 1.226 0 .672-.517 1.226-1.18 1.226zm-.33 1.295c.565 0 1.286-.217 1.686-1.068l.04.02c-.176.465-.176.742-.176.81v.11h1.423v-4.786H31.84v.109c0 .069 0 .346.175.81l-.039.02c-.4-.85-1.121-1.068-1.687-1.068-1.355 0-2.31 1.088-2.31 2.522 0 1.433.955 2.521 2.31 2.521zm-4.788-5.043c-.643 0-1.15.228-1.56 1.068l-.039-.02c.175-.464.175-.741.175-.81v-.11h-1.423v4.787h1.462V13.4c0-.662.38-1.078.995-1.078.614 0 .917.356.917 1.068v2.532h1.462v-3.046c0-1.088-.838-1.869-1.989-1.869zm-4.963 1.068l-.039-.02c.176-.464.176-.741.176-.81v-.11h-1.424v4.787h1.463l.01-2.304c0-.673.35-1.078.926-1.078.156 0 .282.02.429.06v-1.464c-.644-.139-1.22.109-1.54.94zm-4.65 2.68c-.664 0-1.18-.554-1.18-1.226 0-.673.516-1.226 1.18-1.226.662 0 1.179.553 1.179 1.226 0 .672-.517 1.226-1.18 1.226zm-.332 1.295c.565 0 1.287-.217 1.687-1.068l.038.02c-.175.465-.175.742-.175.81v.11h1.424v-4.786h-1.424v.109c0 .069 0 .346.175.81l-.038.02c-.4-.85-1.122-1.068-1.687-1.068-1.356 0-2.311 1.088-2.311 2.522 0 1.433.955 2.521 2.31 2.521zm-4.349-.128h1.463V9h-1.463v6.922zM10.136 9H8.644c0 1.236-.751 2.343-1.892 3.134l-.448.317V9h-1.55v6.922h1.55V12.49l2.564 3.43h1.892L8.293 12.64c1.121-.82 1.852-2.096 1.843-3.639z" fill="#0B051D"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" aria-labelledby="pi-link"><title id="pi-link">Link</title><rect width="38" height="24" rx="3" fill="#00D66F"/><rect x=".5" y=".5" width="37" height="23" rx="2.5" fill="none" stroke="#000" stroke-opacity=".07"/><text x="19" y="16.5" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="11" font-weight="bold" fill="#011E0F">link</text></svg>

After

Width:  |  Height:  |  Size: 457 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" fill="none" aria-labelledby="pi-master"><title id="pi-master">Mastercard</title><rect x=".5" y=".5" width="37" height="23" rx="2.5" stroke="#000" stroke-opacity=".07" fill="none"/><path d="M35 0H3C1.3 0 0 1.3 0 3V21C0 22.7 1.4 24 3 24H35C36.7 24 38 22.7 38 21V3C38 1.3 36.6 0 35 0Z" fill="#1C1C1C" style="fill:#1C1C1C;fill:color(display-p3 0.1098 0.1098 0.1098);fill-opacity:1;"/><path d="M35 1C36.1 1 37 1.9 37 3V21C37 22.1 36.1 23 35 23H3C1.9 23 1 22.1 1 21V3C1 1.9 1.9 1 3 1H35Z" fill="#232323" style="fill:#232323;fill:color(display-p3 0.1373 0.1373 0.1373);fill-opacity:1;"/><path d="M14.6364 19.2727C18.8538 19.2727 22.2727 15.8538 22.2727 11.6364C22.2727 7.41892 18.8538 4 14.6364 4C10.4189 4 7 7.41892 7 11.6364C7 15.8538 10.4189 19.2727 14.6364 19.2727Z" fill="#EB001B" style="fill:#EB001B;fill:color(display-p3 0.9216 0.0000 0.1059);fill-opacity:1;"/><path d="M23.3637 19.2727C27.5811 19.2727 31 15.8538 31 11.6364C31 7.41892 27.5811 4 23.3637 4C19.1462 4 15.7273 7.41892 15.7273 11.6364C15.7273 15.8538 19.1462 19.2727 23.3637 19.2727Z" fill="#F79E1B" style="fill:#F79E1B;fill:color(display-p3 0.9686 0.6196 0.1059);fill-opacity:1;"/><path d="M22.2727 11.6362C22.2727 9.01797 20.9637 6.72706 19 5.41797C17.0364 6.83615 15.7273 9.12706 15.7273 11.6362C15.7273 14.1452 17.0364 16.5452 19 17.8543C20.9637 16.5452 22.2727 14.2543 22.2727 11.6362Z" fill="#FF5F00" style="fill:#FF5F00;fill:color(display-p3 1.0000 0.3725 0.0000);fill-opacity:1;"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" aria-labelledby="pi-more"><title id="pi-more">Weitere Zahlarten</title><rect width="38" height="24" rx="3" fill="#fff"/><rect x=".5" y=".5" width="37" height="23" rx="2.5" fill="none" stroke="#000" stroke-opacity=".07"/><circle cx="12" cy="12" r="1.8" fill="#7B7B7E"/><circle cx="19" cy="12" r="1.8" fill="#7B7B7E"/><circle cx="26" cy="12" r="1.8" fill="#7B7B7E"/></svg>

After

Width:  |  Height:  |  Size: 465 B

View file

@ -0,0 +1 @@
<svg width="38" height="24" viewBox="0 0 38 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="pi-naver_pay"><title id="pi-naver_pay">Naver Pay</title><rect x="1" y="1" width="36" height="22" rx="2" fill="#fff"/><rect x=".5" y=".5" width="37" height="23" rx="2.5" stroke="#000" stroke-opacity=".07"/><g clip-path="url(#pi-clip0)"><path d="M20.164 9.553v2.672h1.416c1.037 0 1.555-.429 1.555-1.286v-.18c0-.412-.125-.716-.374-.912-.249-.196-.606-.294-1.072-.294h-1.525zm-.987-.857h2.567c.405 0 .756.051 1.055.154.298.103.545.246.741.429.195.183.341.4.438.653.096.252.144.525.144.817v.21c0 .292-.051.566-.154.822-.103.256-.254.48-.453.673a2.156 2.156 0 01-.74.459c-.295.113-.639.17-1.03.17h-1.58v2.8h-.988M28.23 13.402h-1.367c-.378 0-.65.073-.812.219-.163.146-.244.349-.244.608v.14c0 .265.093.458.279.578.186.12.409.18.668.18.24 0 .447-.029.623-.086.176-.056.337-.14.483-.254a.864.864 0 00.285-.344c.056-.13.084-.297.084-.503v-.538zm-1.576 2.562c-.552 0-.989-.136-1.31-.409-.323-.273-.484-.648-.484-1.127v-.269c0-.465.156-.844.468-1.136.313-.293.818-.44 1.516-.44h1.385v-.318c0-.366-.088-.637-.264-.813-.176-.176-.463-.264-.862-.264-.32 0-.597.05-.833.15-.236.1-.48.232-.732.398l-.479-.717c.266-.187.565-.348.897-.484a3.035 3.035 0 011.157-.204c.305 0 .585.038.837.114.253.077.47.19.653.34.183.149.326.332.429.548.103.216.154.46.154.732v3.819h-.927v-.658h-.01c-.053.1-.124.191-.214.274-.09.083-.188.155-.294.214-.146.08-.304.141-.474.185-.17.043-.373.065-.613.065zM32.556 14.787l1.436-4.357h1.017l-2.832 7.707-.877-.31.708-1.863-1.984-5.534h1.047l1.445 4.357" fill="#333"/><path d="M3.004 18.58h12.58V6H3.003v12.58z" fill="#00C63B"/><path d="M10.294 15.169l-2.03-2.981v2.98H6.15V9.41h2.145l2.03 2.98V9.41h2.114v5.759" fill="#fff"/></g><defs><clipPath id="pi-clip0"><path fill="#fff" d="M3 6h32v12.579H3z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1 @@
<svg width="38" height="24" viewBox="0 0 38 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="pi-payco"><title id="pi-payco">Payco</title><rect x="1" y="1" width="36" height="22" rx="2" fill="#fff"/><rect x=".5" y=".5" width="37" height="23" rx="2.5" stroke="#000" stroke-opacity=".07"/><path d="M5 9.778a.717.717 0 01.722-.722h1.96c1.566 0 2.512.928 2.512 2.273v.018c.002 1.516-1.184 2.305-2.654 2.305H6.444v1.363a.717.717 0 01-.722.722.717.717 0 01-.722-.722V9.778zm2.588 2.588c.722 0 1.144-.432 1.144-.995v-.018c0-.647-.45-.995-1.172-.995H6.444v2.005l1.144.003zM9.965 14.748l2.288-5.166a.893.893 0 01.845-.573h.084a.88.88 0 01.834.572l2.289 5.167c.045.09.07.189.075.29a.686.686 0 01-.686.695c-.338 0-.563-.196-.694-.497l-.441-1.032h-2.887l-.46 1.079a.709.709 0 01-.926.399.668.668 0 01-.411-.624.766.766 0 01.09-.31zm4.06-1.82l-.908-2.166-.91 2.167h1.818zM17.906 13.144l-2.119-2.878a.855.855 0 01-.177-.516.705.705 0 01.721-.694c.33 0 .535.178.713.441l1.59 2.298L20.26 9.48c.178-.268.394-.432.694-.432a.669.669 0 01.703.703.897.897 0 01-.187.507l-2.119 2.86v1.898a.722.722 0 01-1.444 0v-1.871zM21.701 12.414v-.019c0-1.871 1.406-3.395 3.423-3.395.986 0 1.651.267 2.2.668a.72.72 0 01.225.844.705.705 0 01-.657.432.758.758 0 01-.432-.14 2.159 2.159 0 00-1.337-.47c-1.107 0-1.904.92-1.904 2.044v.019c0 1.125.778 2.062 1.904 2.062.52.01 1.024-.173 1.417-.513a.669.669 0 11.862 1.023c-.59.515-1.285.824-2.335.824a3.315 3.315 0 01-3.366-3.379zM28.024 12.414v-.019c0-1.871 1.47-3.395 3.498-3.395 2.028 0 3.475 1.51 3.475 3.375v.019c0 1.871-1.47 3.395-3.498 3.395-2.028 0-3.475-1.509-3.475-3.375zm5.467 0v-.019c0-1.123-.82-2.058-1.988-2.058-1.167 0-1.969.92-1.969 2.044v.018c0 1.126.825 2.063 1.988 2.063s1.969-.917 1.969-2.048z" fill="#E7231E"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg viewBox="0 0 38 24" xmlns="http://www.w3.org/2000/svg" width="38" height="24" role="img" aria-labelledby="pi-paypal"><title id="pi-paypal">PayPal</title><path opacity=".07" d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3z"/><path fill="#fff" d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32"/><path fill="#003087" d="M23.9 8.3c.2-1 0-1.7-.6-2.3-.6-.7-1.7-1-3.1-1h-4.1c-.3 0-.5.2-.6.5L14 15.6c0 .2.1.4.3.4H17l.4-3.4 1.8-2.2 4.7-2.1z"/><path fill="#3086C8" d="M23.9 8.3l-.2.2c-.5 2.8-2.2 3.8-4.6 3.8H18c-.3 0-.5.2-.6.5l-.6 3.9-.2 1c0 .2.1.4.3.4H19c.3 0 .5-.2.5-.4v-.1l.4-2.4v-.1c0-.2.3-.4.5-.4h.3c2.1 0 3.7-.8 4.1-3.2.2-1 .1-1.8-.4-2.4-.1-.5-.3-.7-.5-.8z"/><path fill="#012169" d="M23.3 8.1c-.1-.1-.2-.1-.3-.1-.1 0-.2 0-.3-.1-.3-.1-.7-.1-1.1-.1h-3c-.1 0-.2 0-.2.1-.2.1-.3.2-.3.4l-.7 4.4v.1c0-.3.3-.5.6-.5h1.3c2.5 0 4.1-1 4.6-3.8v-.2c-.1-.1-.3-.2-.5-.2h-.1z"/></svg>

After

Width:  |  Height:  |  Size: 933 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" fill="none" aria-labelledby="pi-pix"><title id="pi-pix">Pix</title><g clip-path="url(#pi-pix-a)"><path fill="#000" d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3Z" opacity=".07"/><path fill="#fff" d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32Z"/><path fill="#939598" fill-rule="evenodd" d="M18.125 8.246c-1.202 0-2.162.937-2.162 2.077v6.882a.216.216 0 1 1-.431 0v-6.882c0-1.393 1.168-2.509 2.593-2.509l2.107.003c1.42.003 2.585 1.117 2.585 2.505v1.465c0 1.393-1.169 2.509-2.594 2.509h-2.977a.216.216 0 0 1 0-.432h2.977c1.202 0 2.162-.937 2.162-2.077v-1.465c0-1.136-.956-2.07-2.154-2.073l-2.106-.003Zm4.849-.216c0-.12.096-.217.216-.217h.914c.313 0 .615.12.84.337.224.216.35.51.35.82v5.14a.216.216 0 1 1-.43 0V8.97a.708.708 0 0 0-.22-.51.778.778 0 0 0-.54-.215h-.914a.216.216 0 0 1-.216-.216Z" clip-rule="evenodd"/><path fill="#32BCAD" d="m24.883 7.233-.414-.4a.255.255 0 0 1-.077-.18.247.247 0 0 1 .077-.18l.414-.399a.269.269 0 0 1 .373 0l.414.4a.252.252 0 0 1 .077.18.247.247 0 0 1-.077.18l-.414.399a.263.263 0 0 1-.186.074.272.272 0 0 1-.187-.074Z"/><path stroke="#939598" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width=".432" d="M26.833 8.025h.906c.466 0 .913.178 1.243.496l2.12 2.045a.705.705 0 0 0 .497.198.726.726 0 0 0 .498-.198l2.112-2.037c.33-.318.777-.497 1.243-.497h.737"/><path fill="#939598" fill-rule="evenodd" d="M31.6 11.542a.51.51 0 0 0-.348.138l-2.12 2.045c-.37.357-.872.557-1.393.557h-.906a.216.216 0 1 1 0-.432h.906a1.57 1.57 0 0 0 1.093-.436l2.12-2.044a.92.92 0 0 1 .647-.26.942.942 0 0 1 .647.26m0 0 2.113 2.037c.288.278.682.436 1.093.436h.737a.216.216 0 1 1 0 .431h-.737c-.52 0-1.022-.2-1.393-.557l-2.112-2.037a.489.489 0 0 0-.348-.138" clip-rule="evenodd"/><path fill="#32BCAD" d="M10.343 15.372c-.454 0-.882-.17-1.204-.48l-1.74-1.678a.34.34 0 0 0-.456 0l-1.745 1.683a1.698 1.698 0 0 1-.553.357 1.746 1.746 0 0 1-.652.124H3.65l2.203 2.124a1.808 1.808 0 0 0 2.491 0l2.209-2.13h-.21ZM3.993 9.44c.455 0 .883.17 1.205.48l1.745 1.684a.33.33 0 0 0 .457 0l1.739-1.677a1.714 1.714 0 0 1 1.204-.481h.21l-2.209-2.13a1.808 1.808 0 0 0-2.491 0L3.65 9.44h.343Z"/><path fill="#32BCAD" d="M12.38 11.208 11.045 9.92a.262.262 0 0 1-.095.018h-.607c-.315 0-.618.122-.842.337l-1.74 1.677a.833.833 0 0 1-.59.235.859.859 0 0 1-.59-.235L4.836 10.27a1.222 1.222 0 0 0-.843-.337h-.746a.26.26 0 0 1-.09-.017l-1.34 1.292a1.656 1.656 0 0 0 0 2.402l1.34 1.293a.259.259 0 0 1 .09-.018h.746c.316 0 .619-.122.843-.337l1.745-1.683a.878.878 0 0 1 1.18 0l1.74 1.677c.224.215.527.336.842.337h.607c.034 0 .066.007.095.019l1.335-1.288a1.656 1.656 0 0 0 0-2.402"/></g><defs><clipPath id="pi-pix-a"><path fill="#fff" d="M0 0h38v24H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1 @@
<svg width="38" height="24" viewBox="0 0 38 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="pi-samsung_pay"><title id="pi-samsung_pay">Samsung Pay</title><rect x="1" y="1" width="36" height="22" rx="2" fill="#fff"/><rect x=".5" y=".5" width="37" height="23" rx="2.5" stroke="#000" stroke-opacity=".07"/><g clip-path="url(#pi-clip0)" fill="#000"><path d="M28.767 7.16l.055 3.195H28.8l-.936-3.194h-1.51v4.028h1l-.056-3.306h.022l1.005 3.306h1.45V7.16M9.602 7.16l-.755 4.07h1.101l.557-3.693h.022l.543 3.693h1.094l-.75-4.07M15.754 7.16l-.503 3.113h-.022l-.502-3.112h-1.66l-.09 4.07h1.018l.026-3.66h.023l.68 3.66h1.033l.68-3.659h.022l.026 3.658h1.018l-.09-4.07M7.134 10.082c.04.099.027.224.008.301-.033.136-.126.274-.396.274-.256 0-.41-.147-.41-.37v-.394H5.243v.315c0 .907.715 1.182 1.48 1.182.737 0 1.342-.251 1.44-.931.049-.351.012-.582-.006-.667-.171-.852-1.716-1.107-1.831-1.583a.535.535 0 01-.005-.214c.03-.131.118-.274.373-.274.238 0 .377.148.377.369v.253h1.017v-.287c0-.887-.797-1.026-1.373-1.026-.724 0-1.315.24-1.424.904-.03.182-.033.344.01.549.176.832 1.623 1.072 1.833 1.6zM20.37 10.075a.52.52 0 01.008.297c-.033.135-.125.272-.393.272-.252 0-.405-.146-.405-.367v-.39h-1.082v.312c0 .898.708 1.17 1.465 1.17.729 0 1.329-.249 1.425-.922a1.954 1.954 0 00-.005-.66c-.17-.845-1.7-1.096-1.813-1.568a.51.51 0 01-.005-.211c.029-.13.116-.271.368-.271.237 0 .375.145.375.365v.25h1.006v-.284c0-.878-.789-1.014-1.36-1.014-.715 0-1.302.236-1.408.894-.03.18-.033.34.01.543.174.824 1.607 1.061 1.814 1.584zM23.788 10.62c.283 0 .37-.194.39-.294a.859.859 0 00.01-.156V7.16h1.03v2.918c.002.075-.007.228-.01.267-.071.76-.672 1.007-1.42 1.007-.75 0-1.35-.247-1.421-1.007a3.499 3.499 0 01-.009-.267V7.16h1.03v3.01c-.002.053 0 .112.009.156.02.1.108.295.39.295zM32.28 10.579c.294 0 .396-.187.415-.295a.791.791 0 00.01-.155v-.591h-.418v-.593h1.443v1.09c0 .077-.002.133-.015.268-.068.742-.71 1.007-1.432 1.007s-1.364-.265-1.431-1.007a2.595 2.595 0 01-.015-.267V8.323c0-.072.009-.2.018-.268.09-.76.706-1.006 1.428-1.006s1.355.243 1.43 1.006c.013.13.008.268.009.268v.136h-1.027v-.228s0-.097-.013-.156c-.018-.089-.096-.295-.407-.295-.295 0-.383.196-.406.295a1.012 1.012 0 00-.015.193v1.861c0 .052 0 .109.009.155.02.108.121.295.416.295zM14.123 13.768h1.49c.901 0 1.569.691 1.569 1.55 0 .865-.668 1.556-1.575 1.556h-.673v1.246h-.811v-4.352zm.81.751v1.604h.62a.785.785 0 00.812-.805.783.783 0 00-.811-.799M17.337 16.38c0-1.158.846-1.82 1.729-1.82.46 0 .87.192 1.12.49v-.417h.806v3.487h-.805v-.453a1.446 1.446 0 01-1.132.525c-.841 0-1.718-.674-1.718-1.813zm2.892-.013c0-.59-.447-1.073-1.044-1.073-.595 0-1.055.471-1.055 1.073 0 .608.46 1.085 1.055 1.085.597 0 1.044-.483 1.044-1.085zM22.686 17.947l-1.431-3.314h.858l.972 2.348.918-2.348h.847l-2.08 5.097h-.817"/></g><defs><clipPath id="pi-clip0"><path fill="#fff" d="M5.243 7.03H33.73v12.7H5.243z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1 @@
<svg width="38" height="24" viewBox="0 0 38 24" xmlns="http://www.w3.org/2000/svg" role="img" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" aria-labelledby="pi-satispay"><title id="pi-satispay">Satispay</title><path d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3Z" style="fill-opacity:.07;fill-rule:nonzero"/><path d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32" style="fill:#ef4323;fill-rule:nonzero"/><path d="M673.066 283.461h640.814l1012.66 924.379c50.64 46.22 79.68 112.06 79.68 180.62 0 68.57-29.04 134.4-79.68 180.63l-1012.66 924.37H673.066c-24.632 0-39.66-16.09-45.492-31.15-5.847-15.06-5.605-37.06 12.582-53.68L1724.9 1418.47a40.74 40.74 0 0 0 13.24-30.01c0-11.39-4.83-22.32-13.24-30L640.156 368.289c-18.187-16.609-18.429-38.617-12.582-53.668 5.832-15.062 20.86-31.16 45.492-31.16" style="fill:#fffcf0;fill-rule:nonzero" transform="matrix(.00765 0 0 -.00765 8.716 22.617)"/><path d="m868.125 2030.03-504.973-460.94c-50.636-46.23-79.683-112.06-79.683-180.63 0-68.56 29.047-134.4 79.683-180.62l504.973-460.949L1251.45 1096.8l-286.657 261.66a40.723 40.723 0 0 0-13.242 30 40.723 40.723 0 0 0 13.242 30.01l286.657 261.66-383.325 349.9Zm1148.515 463.43h-515.95l320.89-292.91 227.95 208.08c18.19 16.62 18.44 38.62 12.6 53.68-5.85 15.06-20.86 31.15-45.49 31.15M1821.58 576.379l-320.89-292.918h515.95c24.63 0 39.64 16.098 45.49 31.16 5.84 15.051 5.59 37.059-12.6 53.668l-227.95 208.09Z" style="fill:#fffcf0;fill-rule:nonzero" transform="matrix(.00765 0 0 -.00765 8.716 22.617)"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" fill="none" aria-labelledby="pi-visa"><title id="pi-visa">Visa</title><rect x=".5" y=".5" width="37" height="23" rx="2.5" stroke="#000" stroke-opacity=".07" fill="none"/><path d="M35 0H3C1.3 0 0 1.3 0 3V21C0 22.7 1.4 24 3 24H35C36.7 24 38 22.7 38 21V3C38 1.3 36.6 0 35 0Z" fill="#142FBD" style="fill:#142FBD;fill:color(display-p3 0.0784 0.1843 0.7412);fill-opacity:1;"/><path d="M35 1C36.1 1 37 1.9 37 3V21C37 22.1 36.1 23 35 23H3C1.9 23 1 22.1 1 21V3C1 1.9 1.9 1 3 1H35Z" fill="#1532CB" style="fill:#1532CB;fill:color(display-p3 0.0824 0.1961 0.7961);fill-opacity:1;"/><path d="M29.5944 10.2167H29.2778C28.8556 11.2722 28.5389 11.8 28.2222 13.3833H30.2278C29.9111 11.8 29.9111 11.0611 29.5944 10.2167V10.2167ZM32.6556 16.4444H30.8611C30.7556 16.4444 30.7556 16.4444 30.65 16.3389L30.4389 15.3889L30.3333 15.1778H27.8C27.6944 15.1778 27.5889 15.1778 27.5889 15.3889L27.2722 16.3389C27.2722 16.4444 27.1667 16.4444 27.1667 16.4444H24.95L25.1611 15.9167L28.2222 8.73889C28.2222 8.21111 28.5389 8 29.0667 8H30.65C30.7556 8 30.8611 8 30.8611 8.21111L32.3389 15.0722C32.4444 15.4944 32.55 15.8111 32.55 16.2333C32.6556 16.3389 32.6556 16.3389 32.6556 16.4444V16.4444ZM18.5111 16.1278L18.9333 14.2278C19.0389 14.2278 19.1444 14.3333 19.1444 14.3333C19.8833 14.65 20.6222 14.8611 21.3611 14.7556C21.5722 14.7556 21.8889 14.65 22.1 14.5444C22.6278 14.3333 22.6278 13.8056 22.2056 13.3833C21.9944 13.1722 21.6778 13.0667 21.3611 12.8556C20.9389 12.6444 20.5167 12.4333 20.2 12.1167C18.9333 11.0611 19.3556 9.58333 20.0944 8.84444C20.7278 8.42222 21.0444 8 21.8889 8C23.1556 8 24.5278 8 25.1611 8.21111H25.2667C25.1611 8.84444 25.0556 9.37222 24.8444 10.0056C24.3167 9.79444 23.7889 9.58333 23.2611 9.58333C22.9444 9.58333 22.6278 9.58333 22.3111 9.68889C22.1 9.68889 21.9944 9.79444 21.8889 9.9C21.6778 10.1111 21.6778 10.4278 21.8889 10.6389L22.4167 11.0611C22.8389 11.2722 23.2611 11.4833 23.5778 11.6944C24.1056 12.0111 24.6333 12.5389 24.7389 13.1722C24.95 14.1222 24.6333 14.9667 23.7889 15.6C23.2611 16.0222 23.05 16.2333 22.3111 16.2333C20.8333 16.2333 19.6722 16.3389 18.7222 16.0222C18.6167 16.2333 18.6167 16.2333 18.5111 16.1278V16.1278ZM14.8167 16.4444C14.9222 15.7056 14.9222 15.7056 15.0278 15.3889C15.5556 13.0667 16.0833 10.6389 16.5056 8.31667C16.6111 8.10556 16.6111 8 16.8222 8H18.7222C18.5111 9.26667 18.3 10.2167 17.9833 11.3778C17.6667 12.9611 17.35 14.5444 16.9278 16.1278C16.9278 16.3389 16.8222 16.3389 16.6111 16.3389L14.8167 16.4444ZM5 8.21111C5 8.10556 5.21111 8 5.31667 8H8.90556C9.43333 8 9.85556 8.31667 9.96111 8.84444L10.9111 13.4889C10.9111 13.5944 10.9111 13.5944 11.0167 13.7C11.0167 13.5944 11.1222 13.5944 11.1222 13.5944L13.3389 8.21111C13.2333 8.10556 13.3389 8 13.4444 8H15.6611C15.6611 8.10556 15.6611 8.10556 15.5556 8.21111L12.2833 15.9167C12.1778 16.1278 12.1778 16.2333 12.0722 16.3389C11.9667 16.4444 11.7556 16.3389 11.5444 16.3389H9.96111C9.85556 16.3389 9.75 16.3389 9.75 16.1278L8.06111 9.58333C7.85 9.37222 7.53333 9.05556 7.11111 8.95C6.47778 8.63333 5.31667 8.42222 5.10556 8.42222L5 8.21111Z" fill="white" style="fill:white;fill-opacity:1;"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 38 24" width="38" height="24" aria-labelledby="pi-vorkasse"><title id="pi-vorkasse">Vorkasse</title><rect width="38" height="24" rx="3" fill="#fff"/><rect x=".5" y=".5" width="37" height="23" rx="2.5" fill="none" stroke="#000" stroke-opacity=".07"/><text x="7" y="16.5" font-family="Helvetica, Arial, sans-serif" font-size="12" font-weight="bold" fill="#37302d">&#8364;</text><path d="M17.5 12h11.5" stroke="#37302d" stroke-width="1.6" fill="none" stroke-linecap="round"/><path d="M25.5 8.5 29 12l-3.5 3.5" stroke="#37302d" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 674 B

View file

@ -278,4 +278,18 @@ return [
'card_expiry_invalid' => 'Bitte geben Sie ein gültiges Ablaufdatum an.', 'card_expiry_invalid' => 'Bitte geben Sie ein gültiges Ablaufdatum an.',
'card_type_invalid' => 'Bitte wählen Sie einen gültigen Kartentyp (Visa oder Mastercard).', 'card_type_invalid' => 'Bitte wählen Sie einen gültigen Kartentyp (Visa oder Mastercard).',
'card_cvc_invalid' => 'Bitte geben Sie eine gültige Prüfziffer (CVC) ein.', 'card_cvc_invalid' => 'Bitte geben Sie eine gültige Prüfziffer (CVC) ein.',
// Zahlungsart ändern über Stripe (Plan S5, dev/2026-07-08-stripe/)
'payment_method_change_title' => 'Zahlungsart ändern',
'payment_method_change_intro' => 'Wähle auf der sicheren Zahlungsseite deine neue Zahlungsart für dieses Abo — zum Beispiel wenn deine Karte abgelaufen ist oder du künftig anders zahlen möchtest.',
'payment_method_change_no_charge' => 'Es wird nichts abgebucht — die neue Zahlungsart gilt ab der nächsten Abo-Abbuchung.',
'payment_method_change_button' => 'Neue Zahlungsart wählen',
'payment_method_change_success' => 'Deine Zahlungsart wurde aktualisiert. Die nächste Abbuchung erfolgt über die neue Zahlungsart.',
'payment_method_change_failed' => 'Die Zahlungsart konnte nicht geändert werden. Bitte versuche es erneut.',
'current_payment_method' => 'Aktuelle Zahlungsart',
'payment_method_change_done_title' => 'Zahlungsart aktualisiert',
'payment_method_change_done_failed_copy' => 'Die Zahlungsart konnte nicht geändert werden. Bitte fordere bei Bedarf einen neuen Link an.',
'payment_method_change_link_mail_subject' => 'Zahlungsart für dein Abo aktualisieren',
'payment_method_change_link_mail_intro' => 'Bitte aktualisiere die Zahlungsart für dein Abo über den folgenden sicheren Link. Deine neue Zahlungsart wählst du direkt auf der sicheren Seite unseres Zahlungsdienstleisters Stripe — es wird dabei nichts abgebucht.',
'payment_method_change_link_mail_button' => 'Zahlungsart aktualisieren',
]; ];

View file

@ -151,11 +151,15 @@ return [
// Stripe (Hosted Checkout) // Stripe (Hosted Checkout)
'stripe_online' => 'Online-Zahlung', 'stripe_online' => 'Online-Zahlung',
'stripe_online_methods' => 'Kreditkarte, PayPal, Apple Pay, Google Pay, Klarna u. v. m.', 'stripe_online_methods' => 'Kreditkarte, PayPal, Apple Pay, Google Pay, Klarna, Amazon Pay u. v. m.',
'stripe_online_copy' => 'Du wirst zur sicheren Zahlungsseite weitergeleitet und wählst dort deine Zahlungsart.', 'stripe_online_copy' => 'Du wirst zur sicheren Zahlungsseite weitergeleitet und wählst dort deine Zahlungsart.',
'stripe_online_short' => 'Online-Zahlung (Stripe)', 'stripe_online_short' => 'Online-Zahlung (Stripe)',
'stripe_unavailable' => 'Die Online-Zahlung ist derzeit nicht erreichbar. Bitte versuche es gleich erneut.', 'stripe_unavailable' => 'Die Online-Zahlung ist derzeit nicht erreichbar. Bitte versuche es gleich erneut.',
'stripe_order_label' => 'Bestellung :reference', 'stripe_order_label' => 'Bestellung :reference',
'stripe_abo_hint' => 'Deine Zahlungsart wird sicher bei unserem Zahlungsdienstleister Stripe hinterlegt und für die folgenden Abo-Abbuchungen verwendet.',
'stripe_abo_payment_method_missing' => 'Für dieses Abo ist kein gültiges Zahlungsmittel hinterlegt. Bitte aktualisiere deine Zahlungsart.',
'stripe_abo_authentication_required' => 'Deine Bank verlangt eine Bestätigung der Zahlung. Bitte hole die Zahlung über deine Abo-Seite nach.',
'prepayment_payone' => 'Vorkasse (PAYONE)',
// Not-Schalter: Abo-Zahlungen eingefroren (PAYONE-Ausfall, bis Stripe-Abos live sind) // 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.', 'prepayment_no_clearing_data' => 'Bankverbindung liegt noch nicht vor — die Zahlung wurde beim Zahlungsanbieter noch nicht erfolgreich registriert.',

View file

@ -21,19 +21,32 @@ return [
'plus_shipping_cost' => 'zzgl. Versandkosten', 'plus_shipping_cost' => 'zzgl. Versandkosten',
'delivery_time_1_3' => 'Lieferzeit: 1-3 Werktage', 'delivery_time_1_3' => 'Lieferzeit: 1-3 Werktage',
'payment_methods' => 'Zahlungsarten', 'payment_methods' => 'Zahlungsarten',
'payment_method_paypal' => 'Zahlung mit Paypal', 'payment_methods_intro' => 'Online-Zahlungen werden sicher über unseren Zahlungsdienstleister Stripe abgewickelt. Im Checkout wirst du auf die Zahlungsseite weitergeleitet und wählst dort deine bevorzugte Zahlungsart.',
'payment_method_paypal_copy' => 'Nach der erfolgreichen Überprüfung werden Sie auf die abgesicherte Bezahlseite von Paypal umgeleitet, um die Zahlung fortzusetzen. Bitte schließen Sie den Browser nach der erfolgreichen Zahlung nicht, bis Sie zum Shop zurückgeleitet wurden.', 'payment_method_paypal' => 'PayPal',
'payment_method_direct' => 'Sofortüberweisung', 'payment_method_paypal_copy' => 'Du wirst zur abgesicherten Bezahlseite von PayPal weitergeleitet und bestätigst dort die Zahlung. Danach geht es automatisch zurück in den Shop.',
'payment_method_direct_copy' => 'Nach der erfolgreichen Überprüfung werden Sie auf die abgesicherte Zahlungsseite von PAYONE für SOFORT Überweisungen umgeleitet, um die Zahlung fortzusetzen. Bitte schließen Sie den Browser nach der erfolgreichen Zahlung nicht, bis Sie zum Shop zurückgeleitet wurden.',
'payment_method_sepa' => 'SEPA Lastschrift',
'payment_method_sepa_copy' => 'Ihr Konto wird nach Abschicken der Bestellung für diese eine Bestellung belastet. Das SEPA-Lastschriftverfahren wird über unseren Zahlungsdienstleister PAYONE abgewickelt.',
'payment_method_creditcard' => 'Kreditkarte', 'payment_method_creditcard' => 'Kreditkarte',
'payment_method_creditcard_copy' => 'Der Betrag wird von Ihrer Kreditkarte abgebucht, sobald die Bestellung abgeschickt wird. Das Kreditkartenverfahren wird über unseren Zahlungsdienstleister PAYONE abgewickelt.', 'payment_method_creditcard_copy' => 'Wir akzeptieren Visa, Mastercard und American Express. Der Betrag wird deiner Kreditkarte belastet, sobald du die Bestellung abschickst. Die Zahlung wird sicher über unseren Zahlungsdienstleister Stripe abgewickelt.',
'payment_method_prepayment' => 'Vorauskasse', 'payment_method_applepay' => 'Apple Pay',
'payment_method_prepayment_copy' => 'Nachdem Sie die Bestellung abgeschickt haben, erhalten Sie eine Email mit den Bankdaten, um die Zahlung durchzuführen. Nach Eingang des Betrages, wird Deine Ware verschickt.', 'payment_method_applepay_copy' => 'Bezahle in wenigen Sekunden mit deinem Apple-Gerät, ohne Kartendaten einzutippen – verfügbar in Safari sowie auf iPhone und iPad.',
'payment_method_googlepay' => 'Google Pay',
'payment_method_googlepay_copy' => 'Bezahle schnell und sicher mit den Zahlungsdaten aus deinem Google-Konto, z. B. in Chrome oder auf deinem Android-Gerät.',
'payment_method_klarna' => 'Klarna',
'payment_method_klarna_copy' => 'Bezahle bequem über Klarna. Welche Klarna-Optionen für dich verfügbar sind, siehst du direkt auf der Zahlungsseite.',
'payment_method_amazonpay' => 'Amazon Pay',
'payment_method_amazonpay_copy' => 'Bezahle mit den Zahlungs- und Adressdaten aus deinem Amazon-Konto – ganz ohne neue Registrierung.',
'payment_method_link' => 'Link (Stripe)',
'payment_method_link_copy' => 'Mit Link speicherst du deine Zahlungsdaten einmalig und sicher bei Stripe und bezahlst danach in allen Shops mit Link-Unterstützung mit einem Klick.',
'payment_method_eps' => 'EPS',
'payment_method_eps_copy' => 'Die Online-Überweisung für Kunden mit österreichischem Bankkonto – du bezahlst direkt über dein Online-Banking.',
'payment_method_bancontact' => 'Bancontact',
'payment_method_bancontact_copy' => 'Die beliebteste Zahlungsart in Belgien – du bezahlst direkt über dein belgisches Bankkonto.',
'payment_method_more' => 'Weitere Zahlarten',
'payment_method_more_copy' => 'Je nach Land und Endgerät stehen dir auf der Zahlungsseite weitere Zahlarten zur Verfügung – z. B. BLIK, MB WAY, Pix, Satispay, Samsung Pay, Kakao Pay, Naver Pay oder PAYCO.',
'payment_method_prepayment' => 'Vorkasse (PAYONE)',
'payment_method_prepayment_copy' => 'Nachdem du die Bestellung abgeschickt hast, erhältst du eine E-Mail mit unseren Bankdaten für die Überweisung. Sobald der Betrag eingegangen ist, wird deine Ware verschickt.',
'shipping_methods_costs' => 'Versandarten & Versandkosten', 'shipping_methods_costs' => 'Versandarten & Versandkosten',
'free_shipping_at' => 'Versandkostenfrei ab :value €', 'free_shipping_at' => 'Versandkostenfrei ab :value €',
'only_missing_free_shipping_at' => 'Es fehlen nur noch :value € für Deine versandkostenfreie Lieferung.', 'only_missing_free_shipping_at' => 'Es fehlen nur noch :value € für Deine versandkostenfreie Lieferung.',
'free_shipping' => 'versandkostenfreie Lieferung', 'free_shipping' => 'versandkostenfreie Lieferung',
'not_free_shipping' => 'nicht versandkostenfrei', 'not_free_shipping' => 'nicht versandkostenfrei',
@ -50,7 +63,7 @@ return [
]; ];
/* /*
{{ __('weborder.') }} {{ __('weborder.') }}
{{ __('weborder.name') }} {{ __('weborder.name') }}
weborder.name weborder.name
*/ */

View file

@ -278,4 +278,18 @@ return [
'card_expiry_invalid' => 'Please enter a valid expiry date.', 'card_expiry_invalid' => 'Please enter a valid expiry date.',
'card_type_invalid' => 'Please select a valid card type (Visa or Mastercard).', 'card_type_invalid' => 'Please select a valid card type (Visa or Mastercard).',
'card_cvc_invalid' => 'Please enter a valid security code (CVC).', 'card_cvc_invalid' => 'Please enter a valid security code (CVC).',
// Change payment method via Stripe (plan S5, dev/2026-07-08-stripe/)
'payment_method_change_title' => 'Change payment method',
'payment_method_change_intro' => 'Choose a new payment method for this subscription on the secure payment page — for example if your card has expired or you want to pay differently in the future.',
'payment_method_change_no_charge' => 'Nothing will be charged — the new payment method applies from the next subscription payment.',
'payment_method_change_button' => 'Choose new payment method',
'payment_method_change_success' => 'Your payment method has been updated. The next payment will use the new method.',
'payment_method_change_failed' => 'The payment method could not be changed. Please try again.',
'current_payment_method' => 'Current payment method',
'payment_method_change_done_title' => 'Payment method updated',
'payment_method_change_done_failed_copy' => 'The payment method could not be changed. Please request a new link if needed.',
'payment_method_change_link_mail_subject' => 'Update the payment method for your subscription',
'payment_method_change_link_mail_intro' => 'Please update the payment method for your subscription via the following secure link. You choose your new payment method directly on the secure page of our payment provider Stripe — nothing will be charged.',
'payment_method_change_link_mail_button' => 'Update payment method',
]; ];

View file

@ -199,11 +199,15 @@ return [
// Stripe (hosted checkout) // Stripe (hosted checkout)
'stripe_online' => 'Online payment', 'stripe_online' => 'Online payment',
'stripe_online_methods' => 'Credit card, PayPal, Apple Pay, Google Pay, Klarna and more', 'stripe_online_methods' => 'Credit card, PayPal, Apple Pay, Google Pay, Klarna, Amazon Pay and more',
'stripe_online_copy' => 'You will be redirected to a secure payment page where you choose your payment method.', '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_online_short' => 'Online payment (Stripe)',
'stripe_unavailable' => 'Online payment is currently unavailable. Please try again in a moment.', 'stripe_unavailable' => 'Online payment is currently unavailable. Please try again in a moment.',
'stripe_order_label' => 'Order :reference', 'stripe_order_label' => 'Order :reference',
'stripe_abo_hint' => 'Your payment method is stored securely with our payment provider Stripe and used for the upcoming subscription payments.',
'stripe_abo_payment_method_missing' => 'There is no valid payment method stored for this subscription. Please update your payment method.',
'stripe_abo_authentication_required' => 'Your bank requires confirmation of this payment. Please complete the payment from your subscription page.',
'prepayment_payone' => 'Prepayment (PAYONE)',
// Emergency switch: subscription payments frozen (PAYONE outage, until Stripe subscriptions are live) // 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.', 'prepayment_no_clearing_data' => 'Bank details are not available yet — the payment has not been successfully registered with the payment provider.',

View file

@ -19,17 +19,30 @@ return [
'not_free_shipping' => 'not free shipping', 'not_free_shipping' => 'not free shipping',
'only_missing_free_shipping_at' => 'All that\'s left is :value € for your free shipping.', 'only_missing_free_shipping_at' => 'All that\'s left is :value € for your free shipping.',
'only_missing_free_shipping_costs_from' => 'All that\'s left is :value € for your free shipping.', 'only_missing_free_shipping_costs_from' => 'All that\'s left is :value € for your free shipping.',
'payment_method_creditcard' => 'credit card', 'payment_method_creditcard' => 'Credit card',
'payment_method_creditcard_copy' => 'The amount will be deducted from your credit card as soon as the order is submitted. The credit card process is processed via our payment service provider PAYONE.', 'payment_method_creditcard_copy' => 'We accept Visa, Mastercard and American Express. The amount is charged to your credit card as soon as you submit the order. The payment is processed securely via our payment service provider Stripe.',
'payment_method_direct' => 'instant bank transfer', 'payment_method_paypal' => 'PayPal',
'payment_method_direct_copy' => 'After successful verification, you will be redirected to PAYONE\'s secured payment page for SOFORT transfers to continue payment. After successful payment, please do not close the browser until you have been redirected back to the shop.', 'payment_method_paypal_copy' => 'You will be redirected to PayPal\'s secure payment page to confirm the payment. Afterwards you are taken back to the shop automatically.',
'payment_method_paypal' => 'payment with Paypal', 'payment_method_applepay' => 'Apple Pay',
'payment_method_paypal_copy' => 'After successful verification, you will be redirected to Paypal\'s secure payment page to continue payment. After successful payment, please do not close the browser until you have been redirected back to the shop.', 'payment_method_applepay_copy' => 'Pay within seconds using your Apple device, without typing in card details – available in Safari and on iPhone and iPad.',
'payment_method_prepayment' => 'prepayment', 'payment_method_googlepay' => 'Google Pay',
'payment_method_prepayment_copy' => 'After you have submitted the order, you will receive an email with the bank details to make the payment. After receipt of the amount, your goods will be shipped.', 'payment_method_googlepay_copy' => 'Pay quickly and securely with the payment details stored in your Google account, e.g. in Chrome or on your Android device.',
'payment_method_sepa' => 'SEPA direct debit', 'payment_method_klarna' => 'Klarna',
'payment_method_sepa_copy' => 'Your account will be charged for this one order after the order is submitted. The SEPA direct debit is processed via our payment service provider PAYONE.', 'payment_method_klarna_copy' => 'Pay conveniently via Klarna. You will see which Klarna options are available to you directly on the payment page.',
'payment_method_amazonpay' => 'Amazon Pay',
'payment_method_amazonpay_copy' => 'Pay with the payment and address details from your Amazon account – no new registration required.',
'payment_method_link' => 'Link (Stripe)',
'payment_method_link_copy' => 'With Link you store your payment details once and securely with Stripe and then pay with one click in every shop that supports Link.',
'payment_method_eps' => 'EPS',
'payment_method_eps_copy' => 'The online bank transfer for customers with an Austrian bank account – you pay directly via your online banking.',
'payment_method_bancontact' => 'Bancontact',
'payment_method_bancontact_copy' => 'The most popular payment method in Belgium – you pay directly via your Belgian bank account.',
'payment_method_more' => 'More payment methods',
'payment_method_more_copy' => 'Depending on your country and device, additional payment methods are available on the payment page – e.g. BLIK, MB WAY, Pix, Satispay, Samsung Pay, Kakao Pay, Naver Pay or PAYCO.',
'payment_method_prepayment' => 'Prepayment (PAYONE)',
'payment_method_prepayment_copy' => 'After you have submitted the order, you will receive an email with our bank details for the transfer. As soon as the amount has been received, your goods will be shipped.',
'payment_methods' => 'payment Methods', 'payment_methods' => 'payment Methods',
'payment_methods_intro' => 'Online payments are processed securely via our payment service provider Stripe. During checkout you are redirected to the payment page where you choose your preferred payment method.',
'plus_shipping_cost' => 'plus shipping and handling', 'plus_shipping_cost' => 'plus shipping and handling',
'price_net' => 'prices net', 'price_net' => 'prices net',
'product_details' => 'product details', 'product_details' => 'product details',

View file

@ -4,7 +4,7 @@
<head> <head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{ __('abo.card_update_link_mail_subject') }}</title> <title>{{ $mailTitle ?? __('abo.card_update_link_mail_subject') }}</title>
<style type="text/css"> <style type="text/css">
a { text-decoration: none; border: 0; outline: none; color: #919f7a; } a { text-decoration: none; border: 0; outline: none; color: #919f7a; }
a:hover { color: #b6b600; } a:hover { color: #b6b600; }
@ -40,14 +40,14 @@
<table cellpadding="10" style="padding: 10px;" cellspacing="0" border="0" width="100%" bgcolor="#ffffff"> <table cellpadding="10" style="padding: 10px;" cellspacing="0" border="0" width="100%" bgcolor="#ffffff">
<tr> <tr>
<td class="headline"> <td class="headline">
<b>{{ __('abo.card_update_link_mail_subject') }}</b> <b>{{ $mailTitle ?? __('abo.card_update_link_mail_subject') }}</b>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="color:#37302d;line-height:1.6em;"> <td style="color:#37302d;line-height:1.6em;">
{{ $salutation }}, {{ $salutation }},
<br><br> <br><br>
{{ __('abo.card_update_link_mail_intro') }} {{ $mailIntro ?? __('abo.card_update_link_mail_intro') }}
</td> </td>
</tr> </tr>
<tr> <tr>
@ -56,7 +56,7 @@
<tr> <tr>
<td style="text-align: center; padding-top: 10px;"> <td style="text-align: center; padding-top: 10px;">
<a href="{{ $link }}" style="display: inline-block; background: #FFCC00; color: #37302d; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px;"> <a href="{{ $link }}" style="display: inline-block; background: #FFCC00; color: #37302d; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px;">
{{ __('abo.card_update_link_mail_button') }} {{ $mailButton ?? __('abo.card_update_link_mail_button') }}
</a> </a>
</td> </td>
</tr> </tr>

View file

@ -1,12 +1,47 @@
@php($stripeChange = app(\App\Services\AboPaymentMethodService::class)->usesStripeChange())
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="font-weight-bold">{{ __('abo.update_card_title') }}</h5> <h5 class="font-weight-bold">{{ $stripeChange ? __('abo.payment_method_change_title') : __('abo.update_card_title') }}</h5>
@if(app(\App\Services\Payment\PaymentProviderManager::class)->aboPaymentsFrozen()) @if(app(\App\Services\Payment\PaymentProviderManager::class)->aboPaymentsFrozen())
<div class="alert alert-warning mb-0"> <div class="alert alert-warning mb-0">
<strong>{{ __('payment.abo_payments_frozen_title') }}</strong><br> <strong>{{ __('payment.abo_payments_frozen_title') }}</strong><br>
{{ __('payment.abo_payments_frozen') }} {{ __('payment.abo_payments_frozen') }}
</div> </div>
<a href="{{ $backUrl ?? route('portal.my_subscriptions') }}" class="btn btn-default mt-3">{{ __('abo.back') }}</a> <a href="{{ $backUrl ?? route('portal.my_subscriptions') }}" class="btn btn-default mt-3">{{ __('abo.back') }}</a>
@elseif($stripeChange)
{{-- Stripe-Setup-Flow (Plan S5): freie Zahlartenwahl auf der Hosted-Seite, keine Abbuchung --}}
<p class="text-muted">{{ __('abo.payment_method_change_intro') }}</p>
@if (isset($errors) && $errors->any())
<div class="alert alert-danger p-2">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@if (Session::has('alert-error'))
<div class="alert alert-danger p-2">{{ Session::get('alert-error') }}</div>
@endif
<div class="alert alert-info p-2">
<i class="ion ion-md-card mr-2" style="font-size: 1.2rem; line-height: 1rem;"></i>
<span>{{ __('abo.current_payment_method') }}: <strong>{{ $user_abo->maskedCard() ?: $user_abo->getPaymentType() }}</strong></span>
</div>
<p class="fs-13 mb-1">{{ __('payment.stripe_online_methods') }}</p>
<p class="fs-12 text-muted">{{ __('abo.payment_method_change_no_charge') }}</p>
<form method="POST" action="{{ $stripeFormAction ?? route('portal_abos_payment_method_stripe', [$user_abo->id]) }}">
@csrf
<button type="submit" class="btn btn-primary mt-2">
{{ __('abo.payment_method_change_button') }}
</button>
<a href="{{ $backUrl ?? route('portal.my_subscriptions') }}" class="btn btn-default mt-2">{{ __('abo.back') }}</a>
<img class="float-right mt-2" width="90" src="{{ asset('images/payments-assets/stripe.png') }}" alt="Stripe">
</form>
@else @else
<p class="text-muted">{{ __('abo.card_update_intro') }}</p> <p class="text-muted">{{ __('abo.card_update_intro') }}</p>

View file

@ -1,15 +1,16 @@
@extends('portal.layouts.public') @extends('portal.layouts.public')
@section('content') @section('content')
@php($stripeChange = app(\App\Services\AboPaymentMethodService::class)->usesStripeChange())
<div class="text-center" style="max-width: 640px; margin: 0 auto;"> <div class="text-center" style="max-width: 640px; margin: 0 auto;">
@if ($success) @if ($success)
<i class="ion ion-md-checkmark-circle text-success" style="font-size: 3rem;"></i> <i class="ion ion-md-checkmark-circle text-success" style="font-size: 3rem;"></i>
<h4 class="font-weight-bold mt-3">{{ __('abo.card_update_done_success_title') }}</h4> <h4 class="font-weight-bold mt-3">{{ $stripeChange ? __('abo.payment_method_change_done_title') : __('abo.card_update_done_success_title') }}</h4>
<p class="text-muted">{{ __('abo.card_update_done_success_copy') }}</p> <p class="text-muted">{{ $stripeChange ? __('abo.payment_method_change_success') : __('abo.card_update_done_success_copy') }}</p>
@else @else
<i class="ion ion-md-close-circle text-danger" style="font-size: 3rem;"></i> <i class="ion ion-md-close-circle text-danger" style="font-size: 3rem;"></i>
<h4 class="font-weight-bold mt-3">{{ __('abo.card_update_done_failed_title') }}</h4> <h4 class="font-weight-bold mt-3">{{ __('abo.card_update_done_failed_title') }}</h4>
<p class="text-muted">{{ __('abo.card_update_done_failed_copy') }}</p> <p class="text-muted">{{ $stripeChange ? __('abo.payment_method_change_done_failed_copy') : __('abo.card_update_done_failed_copy') }}</p>
@endif @endif
<p class="text-muted mt-3">{{ __('abo.card_update_portal_hint') }}</p> <p class="text-muted mt-3">{{ __('abo.card_update_portal_hint') }}</p>

View file

@ -2,11 +2,12 @@
@section('content') @section('content')
<div style="max-width: 640px; margin: 0 auto;"> <div style="max-width: 640px; margin: 0 auto;">
<h4 class="font-weight-bold mb-3">{{ __('abo.update_card_title') }}</h4> <h4 class="font-weight-bold mb-3">{{ app(\App\Services\AboPaymentMethodService::class)->usesStripeChange() ? __('abo.payment_method_change_title') : __('abo.update_card_title') }}</h4>
@include('portal.abo._payment_method_form', [ @include('portal.abo._payment_method_form', [
'user_abo' => $user_abo, 'user_abo' => $user_abo,
'formAction' => route('abo_card_update_store', [$token]), 'formAction' => route('abo_card_update_store', [$token]),
'stripeFormAction' => route('abo_card_update_stripe', [$token]),
'backUrl' => \App\Services\Util::getMyMivitaPortalUrl(), 'backUrl' => \App\Services\Util::getMyMivitaPortalUrl(),
]) ])

View file

@ -4,7 +4,8 @@
<h4 class="font-weight-bold py-2 mb-2"> <h4 class="font-weight-bold py-2 mb-2">
{{ __('navigation.my_abo') }} {{ __('navigation.my_abo') }}
<span class="text-muted">{{ '#' . $user_abo->payone_userid }}</span> {{-- Stripe-Abos haben keine payone_userid — dann die Abo-Nummer zeigen --}}
<span class="text-muted">{{ '#' . ($user_abo->payone_userid ?: $user_abo->id) }}</span>
</h4> </h4>
@if (Session::has('alert-success')) @if (Session::has('alert-success'))
@ -133,16 +134,17 @@
@include('admin.abo._executions') @include('admin.abo._executions')
</div> </div>
@if ($user_abo->isCreditCard()) @php($canStartPaymentChange = app(\App\Services\AboPaymentMethodService::class)->canStartChange($user_abo))
@if ($user_abo->isCreditCard() || $canStartPaymentChange)
<div class="card mt-3 mb-3"> <div class="card mt-3 mb-3">
<div class="card-body"> <div class="card-body">
<h6 class="font-weight-bold">{{ __('abo.payment_method') }}</h6> <h6 class="font-weight-bold">{{ __('abo.payment_method') }}</h6>
<p class="mb-2"> <p class="mb-2">
{{ __('payment.credit_card') }}@if ($user_abo->maskedCard()) {{ $user_abo->getPaymentType() }}@if ($user_abo->maskedCard())
: {{ $user_abo->maskedCard() }} : {{ $user_abo->maskedCard() }}
@endif @endif
</p> </p>
@if ($user_abo->canUpdatePaymentMethod()) @if ($canStartPaymentChange)
<a href="{{ route('portal_abos_payment_method_edit', [$user_abo->id]) }}" <a href="{{ route('portal_abos_payment_method_edit', [$user_abo->id]) }}"
class="btn btn-sm btn-primary"> class="btn btn-sm btn-primary">
{{ __('abo.update_payment_method') }} {{ __('abo.update_payment_method') }}

View file

@ -3,8 +3,8 @@
@section('content') @section('content')
<h4 class="font-weight-bold py-2 mb-2"> <h4 class="font-weight-bold py-2 mb-2">
{{ __('abo.update_card_title') }} {{ app(\App\Services\AboPaymentMethodService::class)->usesStripeChange() ? __('abo.payment_method_change_title') : __('abo.update_card_title') }}
<span class="text-muted">{{ '#' . $user_abo->payone_userid }}</span> <span class="text-muted">{{ '#' . ($user_abo->payone_userid ?: $user_abo->id) }}</span>
</h4> </h4>
@include('portal.abo._payment_method_form', ['user_abo' => $user_abo]) @include('portal.abo._payment_method_form', ['user_abo' => $user_abo])

View file

@ -141,23 +141,24 @@
@include('admin.abo._executions') @include('admin.abo._executions')
</div> </div>
@if ($user_abo->isCreditCard()) @php($canStartPaymentChange = app(\App\Services\AboPaymentMethodService::class)->canStartChange($user_abo))
@if ($user_abo->isCreditCard() || $canStartPaymentChange)
<div class="card mt-3 mb-3"> <div class="card mt-3 mb-3">
<div class="card-body"> <div class="card-body">
<h6 class="font-weight-bold">{{ __('abo.payment_method') }}</h6> <h6 class="font-weight-bold">{{ __('abo.payment_method') }}</h6>
<p class="mb-2"> <p class="mb-2">
{{ __('payment.credit_card') }}@if ($user_abo->maskedCard()) {{ $user_abo->getPaymentType() }}@if ($user_abo->maskedCard())
: {{ $user_abo->maskedCard() }} : {{ $user_abo->maskedCard() }}
@endif @endif
</p> </p>
@if ($view === 'me' && $user_abo->canUpdatePaymentMethod()) @if ($view === 'me' && $canStartPaymentChange)
<a href="{{ route('user_abos_payment_method_edit', [$view, $user_abo->id]) }}" <a href="{{ route('user_abos_payment_method_edit', [$view, $user_abo->id]) }}"
class="btn btn-sm btn-primary"> class="btn btn-sm btn-primary">
{{ __('abo.update_payment_method') }} {{ __('abo.update_payment_method') }}
</a> </a>
@endif @endif
@if ($view === 'ot' && $user_abo->canUpdatePaymentMethod()) @if ($view === 'ot' && $canStartPaymentChange)
<form method="POST" action="{{ route('user_abos_payment_method_send_link', [$view, $user_abo->id]) }}" <form method="POST" action="{{ route('user_abos_payment_method_send_link', [$view, $user_abo->id]) }}"
class="d-inline"> class="d-inline">
@csrf @csrf

View file

@ -3,13 +3,14 @@
@section('content') @section('content')
<h4 class="font-weight-bold py-2 mb-2"> <h4 class="font-weight-bold py-2 mb-2">
{{ __('abo.update_card_title') }} {{ app(\App\Services\AboPaymentMethodService::class)->usesStripeChange() ? __('abo.payment_method_change_title') : __('abo.update_card_title') }}
<span class="text-muted">{{ '#' . $user_abo->payone_userid }}</span> <span class="text-muted">{{ '#' . ($user_abo->payone_userid ?: $user_abo->id) }}</span>
</h4> </h4>
@include('portal.abo._payment_method_form', [ @include('portal.abo._payment_method_form', [
'user_abo' => $user_abo, 'user_abo' => $user_abo,
'formAction' => route('user_abos_payment_method_update', [$view, $user_abo->id]), 'formAction' => route('user_abos_payment_method_update', [$view, $user_abo->id]),
'stripeFormAction' => route('user_abos_payment_method_stripe', [$view, $user_abo->id]),
'backUrl' => route('user_abos_detail', [$view, $user_abo->id]), 'backUrl' => route('user_abos_detail', [$view, $user_abo->id]),
]) ])

View file

@ -118,27 +118,7 @@
<div class="col-sm-1 col-md-2"> <div class="col-sm-1 col-md-2">
</div> </div>
<div class="col-xs-12 col-sm-10 col-md-8 text-center"> <div class="col-xs-12 col-sm-10 col-md-8 text-center">
<div class="row"> @include('web.layouts.includes.payment_logos', ['logoHeight' => 28, 'wrapperClass' => 'text-center'])
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/paypal.png" alt="">
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/sofort.png" alt="">
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/sepa.png" alt="">
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/creditcard.png" alt="">
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/vor.png" alt="">
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,51 @@
{{--
Zahlarten-Logos (Plan S7.6): einheitliche Logo-Leiste für Footer, Checkout und /zahlungsarten.
Standard: Kern-Zahlarten + „···"-Icon (Weitere Zahlarten). $showAll = true zeigt zusätzlich
die regionalen Zahlarten (BLIK, MB WAY, Pix, …) statt des „···"-Icons.
Parameter: $logoHeight (px, Default 24), $showVorkasse (Default true), $showAll (Default false),
$wrapperClass (optional).
--}}
@php($logoHeight = $logoHeight ?? 24)
@php($showVorkasse = $showVorkasse ?? true)
@php($showAll = $showAll ?? false)
@php($paymentLogos = [
'visa' => 'Visa',
'mastercard' => 'Mastercard',
'amex' => 'American Express',
'paypal' => 'PayPal',
'applepay' => 'Apple Pay',
'googlepay' => 'Google Pay',
'klarna' => 'Klarna',
'amazonpay' => 'Amazon Pay',
'link' => 'Link',
'eps' => 'EPS',
'bancontact' => 'Bancontact',
])
@php($morePaymentLogos = [
'blik' => 'BLIK',
'mbway' => 'MB WAY',
'pix' => 'Pix',
'satispay' => 'Satispay',
'samsungpay' => 'Samsung Pay',
'kakaopay' => 'Kakao Pay',
'naverpay' => 'Naver Pay',
'payco' => 'PAYCO',
])
@php($visibleLogos = $showAll ? array_merge($paymentLogos, $morePaymentLogos) : $paymentLogos)
<div class="payment-logos {{ $wrapperClass ?? '' }}" style="line-height: 1;">
@foreach ($visibleLogos as $paymentLogoFile => $paymentLogoLabel)
<img src="{{ asset('images/payments-assets/'.$paymentLogoFile.'.svg') }}"
alt="{{ $paymentLogoLabel }}" title="{{ $paymentLogoLabel }}"
style="height: {{ $logoHeight }}px; width: auto; margin: 2px 3px 2px 0; vertical-align: middle;">
@endforeach
@if ($showVorkasse)
<img src="{{ asset('images/payments-assets/vorkasse.svg') }}"
alt="{{ __('payment.prepayment_payone') }}" title="{{ __('payment.prepayment_payone') }}"
style="height: {{ $logoHeight }}px; width: auto; margin: 2px 3px 2px 0; vertical-align: middle;">
@endif
@unless ($showAll)
<img src="{{ asset('images/payments-assets/more.svg') }}"
alt="{{ __('weborder.payment_method_more') }}" title="{{ __('weborder.payment_method_more') }}"
style="height: {{ $logoHeight }}px; width: auto; margin: 2px 3px 2px 0; vertical-align: middle;">
@endunless
</div>

View file

@ -955,14 +955,20 @@
<i></i> <span class="weight-400"><span class="fa fa-check text-primary"></span> {{ __('payment.stripe_online') }}</span> <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"> <img class="float-right" width="90" src="{{asset('images/payments-assets/stripe.png')}}" alt="Stripe">
</label> </label>
<div class="mb-1">
@include('web.layouts.includes.payment_logos', ['showVorkasse' => false, 'logoHeight' => 26])
</div>
<p class="fs-13 mb-1">{{ __('payment.stripe_online_methods') }}</p> <p class="fs-13 mb-1">{{ __('payment.stripe_online_methods') }}</p>
<p class="fs-12 text-muted mb-0">{{ __('payment.stripe_online_copy') }}</p> <p class="fs-12 text-muted mb-0">{{ __('payment.stripe_online_copy') }}</p>
@if(!empty($is_abo))
<p class="fs-12 text-muted mb-0 mt-1">{{ __('payment.stripe_abo_hint') }}</p>
@endif
</div> </div>
@if(array_key_exists('VOR', $payment_methods_active) && in_array($payment_methods_active['VOR'], $payment_methods)) @if(array_key_exists('VOR', $payment_methods_active) && in_array($payment_methods_active['VOR'], $payment_methods))
<div class="col-lg-12 nomargin clearfix"> <div class="col-lg-12 nomargin clearfix">
<label class="mt-4 fs-14 fw-400 radio pull-left" style="width: 100%"> <label class="mt-4 fs-14 fw-400 radio pull-left" style="width: 100%">
{!! Form::radio('payment_method', 'vor', '') !!} {!! Form::radio('payment_method', 'vor', '') !!}
<i></i> <span class="weight-400"><span class="fa fa-check text-primary"></span> {{ __('payment.prepayment') }}</span> <i></i> <span class="weight-400"><span class="fa fa-check text-primary"></span> {{ __('payment.prepayment_payone') }}</span>
<img class="float-right" width="90" src="{{asset('images/payments-assets/vor.png')}}" alt="Vorkasse"> <img class="float-right" width="90" src="{{asset('images/payments-assets/vor.png')}}" alt="Vorkasse">
</label> </label>
</div> </div>

View file

@ -8,46 +8,19 @@
<h3 class="box-title m-b-0">{{ __('weborder.payment_methods') }} </h3> <h3 class="box-title m-b-0">{{ __('weborder.payment_methods') }} </h3>
<hr> <hr>
<p>{{ __('weborder.payment_methods_intro') }}</p>
<div class="row"> <div class="mb-3">
<div class="col-xs-4 col-sm-3 col-md-2"> @include('web.layouts.includes.payment_logos', ['logoHeight' => 32, 'showAll' => true])
<img class="img-responsive" src="/images/payments-assets/paypal.png" alt="">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_paypal') }} </h4>
<p>{{ __('weborder.payment_method_paypal_copy') }} </p>
</div>
</div> </div>
<hr> <hr>
<div class="row"> <div class="row">
<div class="col-xs-4 col-sm-3 col-md-2"> <div class="col-xs-4 col-sm-3 col-md-2">
<img class="img-responsive" src="/images/payments-assets/sofort.png" alt=""> <img src="/images/payments-assets/visa.svg" alt="Visa" title="Visa" style="height: 32px; width: auto; margin: 2px 3px 2px 0;">
</div> <img src="/images/payments-assets/mastercard.svg" alt="Mastercard" title="Mastercard" style="height: 32px; width: auto; margin: 2px 3px 2px 0;">
<div class="col-xs-12 col-sm-9 col-md-10"> <img src="/images/payments-assets/amex.svg" alt="American Express" title="American Express" style="height: 32px; width: auto; margin: 2px 3px 2px 0;">
<h4 class="block bold size-18">{{ __('weborder.payment_method_direct') }} </h4>
<p>{{ __('weborder.payment_method_direct_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img class="img-responsive" src="/images/payments-assets/sepa.png" alt="">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_sepa') }} </h4>
<p>{{ __('weborder.payment_method_sepa_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img class="img-responsive" src="/images/payments-assets/creditcard.png" alt="">
</div> </div>
<div class="col-xs-12 col-sm-9 col-md-10"> <div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_creditcard') }} </h4> <h4 class="block bold size-18">{{ __('weborder.payment_method_creditcard') }} </h4>
@ -59,7 +32,117 @@
<div class="row"> <div class="row">
<div class="col-xs-4 col-sm-3 col-md-2"> <div class="col-xs-4 col-sm-3 col-md-2">
<img class="img-responsive" src="/images/payments-assets/vor.png" alt=""> <img src="/images/payments-assets/paypal.svg" alt="PayPal" title="PayPal" style="height: 32px; width: auto;">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_paypal') }} </h4>
<p>{{ __('weborder.payment_method_paypal_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img src="/images/payments-assets/applepay.svg" alt="Apple Pay" title="Apple Pay" style="height: 32px; width: auto;">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_applepay') }} </h4>
<p>{{ __('weborder.payment_method_applepay_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img src="/images/payments-assets/googlepay.svg" alt="Google Pay" title="Google Pay" style="height: 32px; width: auto;">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_googlepay') }} </h4>
<p>{{ __('weborder.payment_method_googlepay_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img src="/images/payments-assets/klarna.svg" alt="Klarna" title="Klarna" style="height: 32px; width: auto;">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_klarna') }} </h4>
<p>{{ __('weborder.payment_method_klarna_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img src="/images/payments-assets/amazonpay.svg" alt="Amazon Pay" title="Amazon Pay" style="height: 32px; width: auto;">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_amazonpay') }} </h4>
<p>{{ __('weborder.payment_method_amazonpay_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img src="/images/payments-assets/link.svg" alt="Link" title="Link" style="height: 32px; width: auto;">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_link') }} </h4>
<p>{{ __('weborder.payment_method_link_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img src="/images/payments-assets/eps.svg" alt="EPS" title="EPS" style="height: 32px; width: auto;">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_eps') }} </h4>
<p>{{ __('weborder.payment_method_eps_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img src="/images/payments-assets/bancontact.svg" alt="Bancontact" title="Bancontact" style="height: 32px; width: auto;">
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_bancontact') }} </h4>
<p>{{ __('weborder.payment_method_bancontact_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
@foreach (['blik' => 'BLIK', 'mbway' => 'MB WAY', 'pix' => 'Pix', 'satispay' => 'Satispay', 'samsungpay' => 'Samsung Pay', 'kakaopay' => 'Kakao Pay', 'naverpay' => 'Naver Pay', 'payco' => 'PAYCO'] as $moreLogoFile => $moreLogoLabel)
<img src="/images/payments-assets/{{ $moreLogoFile }}.svg" alt="{{ $moreLogoLabel }}" title="{{ $moreLogoLabel }}" style="height: 24px; width: auto; margin: 2px 3px 2px 0;">
@endforeach
</div>
<div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_more') }} </h4>
<p>{{ __('weborder.payment_method_more_copy') }} </p>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-4 col-sm-3 col-md-2">
<img src="/images/payments-assets/vorkasse.svg" alt="{{ __('weborder.payment_method_prepayment') }}" title="{{ __('weborder.payment_method_prepayment') }}" style="height: 32px; width: auto;">
</div> </div>
<div class="col-xs-12 col-sm-9 col-md-10"> <div class="col-xs-12 col-sm-9 col-md-10">
<h4 class="block bold size-18">{{ __('weborder.payment_method_prepayment') }} </h4> <h4 class="block bold size-18">{{ __('weborder.payment_method_prepayment') }} </h4>

View file

@ -159,27 +159,7 @@
<div class="col-sm-1 col-md-2"> <div class="col-sm-1 col-md-2">
</div> </div>
<div class="col-xs-12 col-sm-10 col-md-8 text-center"> <div class="col-xs-12 col-sm-10 col-md-8 text-center">
<div class="row"> @include('web.layouts.includes.payment_logos', ['logoHeight' => 28, 'wrapperClass' => 'text-center'])
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/paypal.png" alt="">
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/sofort.png" alt="">
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/sepa.png" alt="">
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/creditcard.png" alt="">
</div>
<div class="col-xs-2 col-sm-2 col-md-2">
<img class="img-responsive" src="/images/payments-assets/vor.png" alt="">
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -165,6 +165,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::post('/user/abos/pay-now/{view}/{id}', 'User\AboController@payNow')->name('user_abos_pay_now'); Route::post('/user/abos/pay-now/{view}/{id}', 'User\AboController@payNow')->name('user_abos_pay_now');
Route::get('/user/abos/payment-method/{view}/{id}', 'User\AboController@editPaymentMethod')->name('user_abos_payment_method_edit'); Route::get('/user/abos/payment-method/{view}/{id}', 'User\AboController@editPaymentMethod')->name('user_abos_payment_method_edit');
Route::post('/user/abos/payment-method/{view}/{id}', 'User\AboController@updatePaymentMethod')->name('user_abos_payment_method_update'); Route::post('/user/abos/payment-method/{view}/{id}', 'User\AboController@updatePaymentMethod')->name('user_abos_payment_method_update');
Route::post('/user/abos/payment-method/{view}/{id}/stripe', 'User\AboController@startStripePaymentMethodChange')->name('user_abos_payment_method_stripe');
Route::get('/user/abos/payment-method/{view}/{id}/return/{status}', 'User\AboController@paymentMethodReturn')->name('user_abos_payment_method_return'); Route::get('/user/abos/payment-method/{view}/{id}/return/{status}', 'User\AboController@paymentMethodReturn')->name('user_abos_payment_method_return');
Route::post('/user/abos/payment-method/send-link/{view}/{id}', 'User\AboController@sendCardUpdateLink')->name('user_abos_payment_method_send_link'); Route::post('/user/abos/payment-method/send-link/{view}/{id}', 'User\AboController@sendCardUpdateLink')->name('user_abos_payment_method_send_link');
Route::post('/user/abos/delete-pending/{view}/{id}', 'User\AboController@deletePending')->name('user_abos_delete_pending'); Route::post('/user/abos/delete-pending/{view}/{id}', 'User\AboController@deletePending')->name('user_abos_delete_pending');

View file

@ -38,6 +38,7 @@ Route::domain(config('app.pre_url_portal').config('app.domain').config('app.tld_
// ausserhalb von auth:customers, da der Kunde nicht eingeloggt ist (Token = Berechtigung). // ausserhalb von auth:customers, da der Kunde nicht eingeloggt ist (Token = Berechtigung).
Route::get('abo/karte/{token}', [AboCardUpdateController::class, 'show'])->middleware('throttle:10,1')->name('abo_card_update_show'); Route::get('abo/karte/{token}', [AboCardUpdateController::class, 'show'])->middleware('throttle:10,1')->name('abo_card_update_show');
Route::post('abo/karte/{token}', [AboCardUpdateController::class, 'store'])->middleware('throttle:10,1')->name('abo_card_update_store'); Route::post('abo/karte/{token}', [AboCardUpdateController::class, 'store'])->middleware('throttle:10,1')->name('abo_card_update_store');
Route::post('abo/karte/{token}/stripe', [AboCardUpdateController::class, 'startStripe'])->middleware('throttle:10,1')->name('abo_card_update_stripe');
Route::get('abo/karte/{token}/return/{status}', [AboCardUpdateController::class, 'return'])->middleware('throttle:10,1')->name('abo_card_update_return'); Route::get('abo/karte/{token}/return/{status}', [AboCardUpdateController::class, 'return'])->middleware('throttle:10,1')->name('abo_card_update_return');
// Geschützte Kunden-Routen // Geschützte Kunden-Routen
@ -59,6 +60,7 @@ Route::domain(config('app.pre_url_portal').config('app.domain').config('app.tld_
Route::post('portal/my-subscriptions/pay-now/{id}', [AboController::class, 'payNow'])->name('portal_abos_pay_now'); Route::post('portal/my-subscriptions/pay-now/{id}', [AboController::class, 'payNow'])->name('portal_abos_pay_now');
Route::get('portal/my-subscriptions/payment-method/{id}', [AboController::class, 'editPaymentMethod'])->name('portal_abos_payment_method_edit'); Route::get('portal/my-subscriptions/payment-method/{id}', [AboController::class, 'editPaymentMethod'])->name('portal_abos_payment_method_edit');
Route::post('portal/my-subscriptions/payment-method/{id}', [AboController::class, 'updatePaymentMethod'])->name('portal_abos_payment_method_update'); Route::post('portal/my-subscriptions/payment-method/{id}', [AboController::class, 'updatePaymentMethod'])->name('portal_abos_payment_method_update');
Route::post('portal/my-subscriptions/payment-method/{id}/stripe', [AboController::class, 'startStripePaymentMethodChange'])->name('portal_abos_payment_method_stripe');
Route::get('portal/my-subscriptions/payment-method/{id}/return/{status}', [AboController::class, 'paymentMethodReturn'])->name('portal_abos_payment_method_return'); Route::get('portal/my-subscriptions/payment-method/{id}/return/{status}', [AboController::class, 'paymentMethodReturn'])->name('portal_abos_payment_method_return');
Route::get('portal/my-subscriptions/datatable/{id}', [AboController::class, 'datatable'])->name('user_abo_datatable'); Route::get('portal/my-subscriptions/datatable/{id}', [AboController::class, 'datatable'])->name('user_abo_datatable');
Route::get('portal/my-subscriptions/onetime-datatable/{id}', [AboController::class, 'oneTimeDatatable'])->name('user_abo_onetime_datatable'); Route::get('portal/my-subscriptions/onetime-datatable/{id}', [AboController::class, 'oneTimeDatatable'])->name('user_abo_onetime_datatable');

View file

@ -0,0 +1,70 @@
<?php
use Illuminate\Support\Facades\Lang;
use Tests\TestCase;
uses(TestCase::class);
/**
* Plan S7.6 – Zahlarten-Kommunikation: Logos + Benennung der Stripe-Zahlarten
* (Checkout, Footer, /zahlungsarten).
*/
it('renders the core payment logos with vorkasse and a more icon by default', function () {
$html = view('web.layouts.includes.payment_logos')->render();
foreach (['Visa', 'Mastercard', 'American Express', 'PayPal', 'Apple Pay', 'Google Pay', 'Klarna', 'Amazon Pay', 'Link', 'EPS', 'Bancontact'] as $label) {
expect($html)->toContain('title="'.$label.'"');
}
expect($html)->toContain('vorkasse.svg')
->and($html)->toContain(__('payment.prepayment_payone'))
->and($html)->toContain('more.svg')
->and($html)->toContain(__('weborder.payment_method_more'))
->and($html)->not->toContain('blik.svg');
});
it('renders all regional payment logos without the more icon when showAll is set', function () {
$html = view('web.layouts.includes.payment_logos', ['showAll' => true])->render();
foreach (['BLIK', 'MB WAY', 'Pix', 'Satispay', 'Samsung Pay', 'Kakao Pay', 'Naver Pay', 'PAYCO'] as $label) {
expect($html)->toContain('title="'.$label.'"');
}
expect($html)->not->toContain('more.svg');
});
it('can render the payment logos partial without vorkasse', function () {
$html = view('web.layouts.includes.payment_logos', ['showVorkasse' => false])->render();
expect($html)->not->toContain('vorkasse.svg')
->and($html)->toContain('klarna.svg');
});
it('ships all referenced payment logo assets in public', function () {
foreach (['visa', 'mastercard', 'amex', 'paypal', 'applepay', 'googlepay', 'klarna', 'amazonpay', 'link', 'eps', 'bancontact', 'blik', 'mbway', 'pix', 'satispay', 'samsungpay', 'kakaopay', 'naverpay', 'payco', 'vorkasse', 'more'] as $file) {
expect(file_exists(public_path('images/payments-assets/'.$file.'.svg')))
->toBeTrue('missing asset: '.$file.'.svg');
}
});
it('has german and english copy for every payment method on the zahlungsarten page', function (string $key) {
expect(Lang::has('weborder.'.$key, 'de'))->toBeTrue('missing de key: '.$key)
->and(Lang::has('weborder.'.$key, 'en'))->toBeTrue('missing en key: '.$key);
})->with([
'payment_methods_intro',
'payment_method_creditcard', 'payment_method_creditcard_copy',
'payment_method_paypal', 'payment_method_paypal_copy',
'payment_method_applepay', 'payment_method_applepay_copy',
'payment_method_googlepay', 'payment_method_googlepay_copy',
'payment_method_klarna', 'payment_method_klarna_copy',
'payment_method_amazonpay', 'payment_method_amazonpay_copy',
'payment_method_link', 'payment_method_link_copy',
'payment_method_eps', 'payment_method_eps_copy',
'payment_method_bancontact', 'payment_method_bancontact_copy',
'payment_method_more', 'payment_method_more_copy',
'payment_method_prepayment', 'payment_method_prepayment_copy',
]);
it('labels vorkasse with payone on the zahlungsarten page', function () {
expect(__('weborder.payment_method_prepayment', [], 'de'))->toBe('Vorkasse (PAYONE)');
});

View file

@ -13,6 +13,7 @@ use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Payone\DTOs\PayonePaymentResult; use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
use App\Services\Payment\Payone\PayonePaymentService; use App\Services\Payment\Payone\PayonePaymentService;
use App\Services\Payment\PayoneGateway; use App\Services\Payment\PayoneGateway;
use App\Services\Payment\StripeGateway;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@ -76,9 +77,13 @@ it('resolves the payone gateway adapter for the payone provider', function () {
->and($gateway->provider())->toBe(PaymentProvider::Payone); ->and($gateway->provider())->toBe(PaymentProvider::Payone);
}); });
it('rejects the stripe gateway until it is implemented', function () { it('resolves the stripe gateway adapter for the stripe provider', function () {
app(PaymentProviderManager::class)->gateway(PaymentProvider::Stripe); $gateway = app(PaymentProviderManager::class)->gateway(PaymentProvider::Stripe);
})->throws(RuntimeException::class);
expect($gateway)->toBeInstanceOf(StripeGateway::class)
->and($gateway)->toBeInstanceOf(PaymentGatewayInterface::class)
->and($gateway->provider())->toBe(PaymentProvider::Stripe);
});
// --- AboChargeResult-Mapping --- // --- AboChargeResult-Mapping ---
@ -126,11 +131,24 @@ it('delegates abo charges to the payone payment service', function () {
->and($result->transactionId)->toBe('tx-9'); ->and($result->transactionId)->toBe('tx-9');
}); });
// --- Cron-Guard: kein Fremd-Provider durch den PAYONE-Pfad --- // --- Cron-Dispatch: Stripe-Abos laufen NICHT durch den PAYONE-Pfad (S4.4) ---
it('blocks abo charging in the cron for non-payone providers', function () { it('routes stripe abos in the cron to the stripe gateway instead of payone', function () {
$abo = makeMeAbo(); $abo = makeMeAbo();
$abo->update(['payment_provider' => 'stripe']); $abo->update(['payment_provider' => 'stripe']);
(new UserMakeOrder($abo->fresh()))->makePayment(); $userOrder = new UserMakeOrder($abo->fresh());
})->throws(RuntimeException::class, 'noch nicht implementiert'); $reflection = new ReflectionClass($userOrder);
foreach (['shopping_user' => new ShoppingUser, 'shopping_order' => new ShoppingOrder] as $property => $value) {
$prop = $reflection->getProperty($property);
$prop->setValue($userOrder, $value);
}
// Ohne gespeicherte Stripe-PaymentMethod liefert das Gateway ein
// ERROR-Ergebnis (kein PAYONE-Aufruf, keine Exception).
$response = $userOrder->makePayment();
expect($response['status'])->toBe('ERROR')
->and($response['provider'])->toBe('stripe')
->and($response['customermessage'])->toBe(__('payment.stripe_abo_payment_method_missing'));
});

View file

@ -0,0 +1,606 @@
<?php
use App\Cron\UserMakeOrder;
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\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserShop;
use App\Services\AboHelper;
use App\Services\Payment\Stripe\DTOs\StripeWebhookResult;
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\Exception\CardException;
use Stripe\PaymentIntent;
use Stripe\StripeClient;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function (): void {
Mail::fake();
});
/**
* Order-Fixture für eine 89,00-EUR-Abo-Bestellung (Plan S4).
*
* @return array{0: ShoppingOrder, 1: ShoppingUser}
*/
function stripeAboOrderBootstrap(array $orderOverrides = []): array
{
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
$shopOwner = User::forceCreate([
'email' => 'sa-owner-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$userShop = UserShop::first() ?? UserShop::create([
'user_id' => $shopOwner->id,
'name' => 'SA Shop',
'slug' => 'sa-shop-'.uniqid(),
'domain' => 'sa-test.local',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $shopOwner->id,
'is_for' => 'me',
'billing_firstname' => 'Stripe',
'billing_lastname' => 'Abo',
'billing_email' => 'stripe-abo-'.uniqid().'@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
$order = ShoppingOrder::create(array_merge([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $shopOwner->id,
'country_id' => $country->id,
'user_shop_id' => $userShop->id,
'total' => 89.00,
'total_shipping' => 89.00,
'paid' => false,
'txaction' => null,
'mode' => 'test',
'is_abo' => 1,
'abo_interval' => 5,
], $orderOverrides));
return [$order, $shoppingUser];
}
function stripeAboUserAbo(array $overrides = []): UserAbo
{
$consultant = User::forceCreate([
'email' => 'sa-abo-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
return UserAbo::create(array_merge([
'user_id' => $consultant->id,
'shopping_user_id' => 1,
'is_for' => 'me',
'email' => $consultant->email,
'payone_userid' => 0,
'payment_provider' => 'stripe',
'stripe_customer_id' => 'cus_abo_1',
'stripe_payment_method_id' => 'pm_abo_1',
'clearingtype' => 'stripe',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(3),
], $overrides));
}
/**
* Stripe-Client-Mock, der genau einen request()-Aufruf beantwortet und die
* Argumente zur Assertion herausreicht (Muster: StripeCheckoutTest).
*/
function stripeAboMockClient(mixed $result, ?array &$captured = null): void
{
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$expectation = $client->shouldReceive('request')
->once()
->withArgs(function (...$args) use (&$captured) {
$captured = $args;
return true;
});
if ($result instanceof Throwable) {
$expectation->andThrow($result);
} else {
$expectation->andReturn($result);
}
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('client')->andReturn($client);
app()->instance(StripeClientWrapper::class, $wrapper);
}
function handleStripeAboEvent(string $type, array $object, string $eventId): StripeWebhookResult
{
$payload = json_encode([
'id' => $eventId,
'object' => 'event',
'api_version' => '2026-06-30',
'type' => $type,
'data' => ['object' => $object],
]);
$timestamp = time();
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, config('stripe.webhook_secret'));
return app(StripeWebhookHandler::class)->handle($payload, "t={$timestamp},v1={$signed}");
}
// --- S4.1: Abo-Erstzahlung speichert das Zahlungsmittel ---
it('requests off-session saving and customer creation for abo checkout sessions', function () {
[$order, $shoppingUser] = stripeAboOrderBootstrap();
$captured = null;
stripeAboMockClient(Session::constructFrom([
'id' => 'cs_abo_1',
'object' => 'checkout.session',
'url' => 'https://checkout.stripe.com/c/pay/cs_abo_1',
'status' => 'open',
'livemode' => false,
]), $captured);
app(StripePaymentService::class)->initiateCheckoutPayment(
$shoppingUser,
$order,
8900,
'EUR',
'aref123',
'https://checkout.test/transaction/status/success/aref123',
'https://checkout.test/transaction/status/cancel/aref123',
);
$params = $captured[2];
expect($params['customer_creation'])->toBe('always')
->and($params['payment_intent_data']['setup_future_usage'])->toBe('off_session');
});
it('does not request off-session saving for one-time checkout sessions', function () {
[$order, $shoppingUser] = stripeAboOrderBootstrap(['is_abo' => 0, 'abo_interval' => 0]);
$captured = null;
stripeAboMockClient(Session::constructFrom([
'id' => 'cs_once_1',
'object' => 'checkout.session',
'url' => 'https://checkout.stripe.com/c/pay/cs_once_1',
'status' => 'open',
'livemode' => false,
]), $captured);
app(StripePaymentService::class)->initiateCheckoutPayment(
$shoppingUser,
$order,
8900,
'EUR',
'oref123',
'https://s.test/ok',
'https://s.test/cancel',
);
$params = $captured[2];
expect($params)->not->toHaveKey('customer_creation')
->and($params['payment_intent_data'])->not->toHaveKey('setup_future_usage');
});
// --- S4.2: Abo-Anlage über den Webhook-Paid-Pfad ---
it('creates the user abo with stripe fields from the webhook paid path', function () {
[$order] = stripeAboOrderBootstrap();
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
]);
handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_abo_paid',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_new_1',
'payment_method' => 'pm_new_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
], 'evt_abo_paid');
$abo = $order->fresh()->getUserAbo();
expect($order->fresh()->paid)->toBeTruthy()
->and($abo)->not->toBeNull()
->and($abo->payment_provider)->toBe('stripe')
->and($abo->stripe_customer_id)->toBe('cus_new_1')
->and($abo->stripe_payment_method_id)->toBe('pm_new_1')
->and((int) $abo->payone_userid)->toBe(0)
->and($payment->fresh()->stripe_customer_id)->toBe('cus_new_1');
});
it('defers abo creation on browser return until stripe ids arrived via webhook', function () {
[$order] = stripeAboOrderBootstrap();
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
]);
// Browser-Return läuft vor dem Webhook: noch keine Stripe-IDs an der Zahlung
AboHelper::createNewAbo($payment);
expect(UserAboOrder::where('shopping_order_id', $order->id)->exists())->toBeFalse();
// Webhook liefert die IDs und legt das Abo vollständig an
handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_abo_late',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_late_1',
'payment_method' => 'pm_late_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
], 'evt_abo_late');
$abo = $order->fresh()->getUserAbo();
expect($abo)->not->toBeNull()
->and($abo->stripe_payment_method_id)->toBe('pm_late_1');
});
it('never creates a new abo for recurring cron charges even when the webhook wins the race', function () {
$userAbo = stripeAboUserAbo();
[$order] = stripeAboOrderBootstrap();
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
'user_abo_id' => $userAbo->id,
]);
$abosBefore = UserAbo::count();
handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_recurring',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_abo_1',
'payment_method' => 'pm_abo_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id, 'user_abo_id' => (string) $userAbo->id],
], 'evt_recurring');
expect($order->fresh()->paid)->toBeTruthy()
->and(UserAbo::count())->toBe($abosBefore);
});
// --- S4 / §7.2 Nr. 3: Recovery-Zahlung zieht das Abo auf Stripe um ---
it('advances a payone abo to stripe after a recovery payment via webhook', function () {
$userAbo = stripeAboUserAbo([
'payment_provider' => 'payone',
'payone_userid' => 900500,
'clearingtype' => 'cc',
'stripe_customer_id' => null,
'stripe_payment_method_id' => null,
'status' => 3,
]);
[$order] = stripeAboOrderBootstrap(['recovery_user_abo_id' => $userAbo->id]);
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
]);
handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_recovery',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_rec_1',
'payment_method' => 'pm_rec_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
], 'evt_recovery');
$fresh = $userAbo->fresh();
expect($order->fresh()->paid)->toBeTruthy()
->and($fresh->payment_provider)->toBe('stripe')
->and($fresh->stripe_customer_id)->toBe('cus_rec_1')
->and($fresh->stripe_payment_method_id)->toBe('pm_rec_1')
->and($fresh->status)->toBe(2)
->and(UserAbo::count())->toBe(1)
->and(UserAboOrder::where('shopping_order_id', $order->id)->where('paid', true)->exists())->toBeTrue();
});
// --- S4.3/S4.5: Wiederkehrende Abbuchung off-session ---
it('charges a stripe abo off-session with an idempotency key and records the transaction', function () {
$userAbo = stripeAboUserAbo();
[$order, $shoppingUser] = stripeAboOrderBootstrap();
$captured = null;
stripeAboMockClient(PaymentIntent::constructFrom([
'id' => 'pi_charge_1',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount' => 8900,
'customer' => 'cus_abo_1',
'payment_method' => 'pm_abo_1',
'livemode' => false,
]), $captured);
$result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR');
$params = $captured[2];
$opts = $captured[3] ?? null;
$payment = $result->shoppingPayment->fresh();
expect($result->success)->toBeTrue()
->and($result->status)->toBe('APPROVED')
->and($result->transactionId)->toBe('pi_charge_1')
->and($params['customer'])->toBe('cus_abo_1')
->and($params['payment_method'])->toBe('pm_abo_1')
->and($params['off_session'])->toBeTrue()
->and($params['confirm'])->toBeTrue()
->and($params['metadata']['user_abo_id'])->toBe((string) $userAbo->id)
->and($opts['idempotency_key'] ?? null)->toBe('abo-'.$userAbo->id.'-order-'.$order->id)
->and($payment->payment_provider)->toBe('stripe')
->and($payment->user_abo_id)->toBe($userAbo->id)
->and($payment->external_reference)->toBe('pi_charge_1')
->and(PaymentTransaction::where('shopping_payment_id', $payment->id)->where('request', 'authorization')->exists())->toBeTrue();
});
it('maps a card decline to an error result with customer message', function () {
$userAbo = stripeAboUserAbo();
[$order, $shoppingUser] = stripeAboOrderBootstrap();
stripeAboMockClient(CardException::factory(
'Your card was declined.',
402,
null,
['error' => ['code' => 'card_declined', 'message' => 'Your card was declined.', 'decline_code' => 'generic_decline', 'type' => 'card_error']],
));
$result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR');
expect($result->success)->toBeFalse()
->and($result->status)->toBe('ERROR')
->and($result->errorMessage)->toContain('card_declined')
->and($result->customerMessage)->toBe(__('payment.payment_error_reasons.card_declined'))
->and(PaymentTransaction::where('shopping_payment_id', $result->shoppingPayment->id)->where('errorcode', 'card_declined')->exists())->toBeTrue();
});
it('fails without touching stripe when the abo has no stored payment method', function () {
$userAbo = stripeAboUserAbo(['stripe_payment_method_id' => null]);
[$order, $shoppingUser] = stripeAboOrderBootstrap();
$result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR');
expect($result->success)->toBeFalse()
->and($result->status)->toBe('ERROR')
->and($result->customerMessage)->toBe(__('payment.stripe_abo_payment_method_missing'))
->and($result->shoppingPayment)->toBeNull();
});
// --- S4.4: Provider-Dispatch im Cron ---
it('dispatches the cron payment of a stripe abo through the stripe gateway', function () {
$userAbo = stripeAboUserAbo();
[$order, $shoppingUser] = stripeAboOrderBootstrap();
stripeAboMockClient(PaymentIntent::constructFrom([
'id' => 'pi_cron_1',
'object' => 'payment_intent',
'status' => 'succeeded',
'livemode' => false,
]));
$userOrder = new UserMakeOrder($userAbo);
$reflection = new ReflectionClass($userOrder);
foreach (['shopping_user' => $shoppingUser, 'shopping_order' => $order] as $property => $value) {
$prop = $reflection->getProperty($property);
$prop->setAccessible(true);
$prop->setValue($userOrder, $value);
}
$response = $userOrder->makePayment();
expect($response['status'])->toBe('APPROVED')
->and($response['provider'])->toBe('stripe')
->and($response['txid'])->toBe('pi_cron_1')
->and($userOrder->getShoppingPayment()->payment_provider)->toBe('stripe');
});
it('returns an error response for a declined cron charge so the cron sets abo status 3', function () {
$userAbo = stripeAboUserAbo();
[$order, $shoppingUser] = stripeAboOrderBootstrap();
stripeAboMockClient(CardException::factory(
'Authentication required.',
402,
null,
['error' => ['code' => 'authentication_required', 'message' => 'Authentication required.', 'type' => 'card_error']],
));
$userOrder = new UserMakeOrder($userAbo);
$reflection = new ReflectionClass($userOrder);
foreach (['shopping_user' => $shoppingUser, 'shopping_order' => $order] as $property => $value) {
$prop = $reflection->getProperty($property);
$prop->setAccessible(true);
$prop->setValue($userOrder, $value);
}
$response = $userOrder->makePayment();
expect($response['status'])->toBe('ERROR')
->and($response['customermessage'])->toBe(__('payment.stripe_abo_authentication_required'));
});
// --- Webhook-Race: Cron-Transaktion noch nicht committet ---
it('requests a stripe retry when our payment is not yet visible and processes it later', function () {
[$order] = stripeAboOrderBootstrap();
$object = [
'id' => 'pi_race',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_abo_1',
'payment_method' => 'pm_abo_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => '999999'],
];
$first = handleStripeAboEvent('payment_intent.succeeded', $object, 'evt_race');
expect($first->statusCode)->toBe(503)
->and($first->state)->toBe('payment_not_yet_visible')
->and(StripeWebhookEvent::whereEventId('evt_race')->first()->processed_at)->toBeNull();
// Zahlung wird sichtbar (Cron-Commit), Stripe stellt erneut zu
ShoppingPayment::forceCreate([
'id' => 999999,
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-race',
'amount' => 8900,
'currency' => 'EUR',
'mode' => 'test',
'user_abo_id' => stripeAboUserAbo()->id,
]);
$second = handleStripeAboEvent('payment_intent.succeeded', $object, 'evt_race');
expect($second->statusCode)->toBe(200)
->and($second->state)->toBe('processed')
->and($order->fresh()->paid)->toBeTruthy();
});
it('processes pending stored events via the maintenance command once the payment is visible', function () {
[$order] = stripeAboOrderBootstrap();
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'reference' => 'sa-ref-'.uniqid(),
'amount' => 8900,
'currency' => 'EUR',
'is_abo' => true,
'abo_interval' => 5,
'mode' => 'test',
'user_abo_id' => stripeAboUserAbo()->id,
]);
// Wie nach einem 503: Event liegt gespeichert, aber unverarbeitet im Store
StripeWebhookEvent::create([
'event_id' => 'evt_pending_cmd',
'type' => 'payment_intent.succeeded',
'payload' => [
'id' => 'pi_pending_cmd',
'object' => 'payment_intent',
'status' => 'succeeded',
'amount_received' => 8900,
'customer' => 'cus_abo_1',
'payment_method' => 'pm_abo_1',
'livemode' => false,
'metadata' => ['shopping_payment_id' => (string) $payment->id],
],
]);
// Nicht zuordenbares Event bleibt liegen
StripeWebhookEvent::create([
'event_id' => 'evt_still_missing',
'type' => 'payment_intent.succeeded',
'payload' => [
'id' => 'pi_still_missing',
'object' => 'payment_intent',
'status' => 'succeeded',
'livemode' => false,
'metadata' => ['shopping_payment_id' => '888888'],
],
]);
$this->artisan('stripe:process-pending-events')->assertExitCode(0);
expect($order->fresh()->paid)->toBeTruthy()
->and(StripeWebhookEvent::whereEventId('evt_pending_cmd')->first()->processed_at)->not->toBeNull()
->and(StripeWebhookEvent::whereEventId('evt_still_missing')->first()->processed_at)->toBeNull();
});
it('still acknowledges foreign events without our metadata', function () {
$result = handleStripeAboEvent('payment_intent.succeeded', [
'id' => 'pi_foreign',
'object' => 'payment_intent',
'status' => 'succeeded',
'livemode' => false,
'metadata' => [],
], 'evt_foreign');
expect($result->statusCode)->toBe(200)
->and(StripeWebhookEvent::whereEventId('evt_foreign')->first()->processed_at)->not->toBeNull();
});
// --- S4.6: Anzeige ---
it('labels stripe abos from the stored payment method summary', function () {
$userAbo = stripeAboUserAbo([
'carddata' => ['type' => 'card', 'brand' => 'visa', 'last4' => '4242', 'exp' => '04/2031', 'wallet' => null],
]);
expect($userAbo->getPaymentType())->toContain('Visa')
->and($userAbo->getPaymentType())->toContain('4242')
->and(UserAbo::formatMaskedCard($userAbo->carddata))->toBe('Visa •••• 4242 (04/2031)');
});
it('falls back to the stripe type label for masked cards without last4', function () {
expect(UserAbo::formatMaskedCard(['type' => 'paypal']))->toBe(__('payment.paypal'))
->and(UserAbo::formatMaskedCard(null))->toBeNull();
});

View file

@ -0,0 +1,304 @@
<?php
use App\Mail\AboCardUpdateLinkMail;
use App\Models\Setting;
use App\Models\UserAbo;
use App\Services\AboPaymentMethodService;
use App\Services\Payment\PaymentProviderManager;
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 Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\StripeClient;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function (): void {
// Der Setup-Flow gilt, wenn der Anbieter-Schalter auf Stripe steht.
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'stripe', 'text');
});
function pmChangeAbo(array $overrides = []): UserAbo
{
$owner = User::forceCreate([
'email' => 'pmc-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
return UserAbo::create(array_merge([
'user_id' => $owner->id,
'shopping_user_id' => 1,
'is_for' => 'me',
'email' => $owner->email,
'payone_userid' => 900700,
'payment_provider' => 'payone',
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(5),
], $overrides));
}
/**
* Stripe-Client-Mock für n request()-Aufrufe; Argumente aller Aufrufe werden
* zur Assertion gesammelt (Muster: StripeAboTest).
*
* @param array<int, mixed> $results
*/
function pmChangeMockClient(array $results, ?array &$calls = null): void
{
$calls = [];
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
$client->shouldReceive('request')
->times(count($results))
->withArgs(function (...$args) use (&$calls) {
$calls[] = $args;
return true;
})
->andReturnValues($results);
$wrapper = Mockery::mock(StripeClientWrapper::class);
$wrapper->shouldReceive('client')->andReturn($client);
app()->instance(StripeClientWrapper::class, $wrapper);
}
function pmChangeWebhookEvent(string $type, array $object, string $eventId): void
{
$payload = json_encode([
'id' => $eventId,
'object' => 'event',
'api_version' => '2026-06-30',
'type' => $type,
'data' => ['object' => $object],
]);
$timestamp = time();
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, config('stripe.webhook_secret'));
app(StripeWebhookHandler::class)->handle($payload, "t={$timestamp},v1={$signed}");
}
// --- Invariante: freie Zahlartenwahl nur im Stripe-Flow ---
it('allows changing the payment method of non-card abos only while the stripe flow is active', function () {
$abo = pmChangeAbo(['clearingtype' => 'wlt', 'wallettype' => 'PPE']);
$service = app(AboPaymentMethodService::class);
expect($service->canStartChange($abo))->toBeTrue()
// Model-Invariante bleibt pur: PAYONE-Legacy-Flow nur für CC-Abos
->and($abo->canUpdatePaymentMethod())->toBeFalse();
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'payone', 'text');
expect($service->canStartChange($abo->fresh()))->toBeFalse();
});
it('never allows changing terminated abos regardless of the flow', function () {
$abo = pmChangeAbo(['status' => 4]);
expect(app(AboPaymentMethodService::class)->canStartChange($abo))->toBeFalse();
});
// --- S5.1: Setup-Session ---
it('creates a setup session with abo metadata and sets the pending trace', function () {
$abo = pmChangeAbo(['payment_provider' => 'stripe', 'stripe_customer_id' => 'cus_existing']);
$calls = null;
pmChangeMockClient([Session::constructFrom([
'id' => 'cs_setup_1',
'object' => 'checkout.session',
'url' => 'https://checkout.stripe.com/c/setup/cs_setup_1',
'livemode' => false,
])], $calls);
$result = app(AboPaymentMethodService::class)->beginStripeChange(
$abo,
'https://portal.test/return/success?session_id={CHECKOUT_SESSION_ID}',
'https://portal.test/return/cancel',
'portal',
);
$params = $calls[0][2];
expect($result->isRedirect())->toBeTrue()
->and($result->redirectUrl)->toBe('https://checkout.stripe.com/c/setup/cs_setup_1')
->and($params['mode'])->toBe('setup')
->and($params['currency'])->toBe('eur')
->and($params['customer'])->toBe('cus_existing')
->and($params['setup_intent_data']['metadata']['user_abo_id'])->toBe((string) $abo->id)
->and($params['success_url'])->toContain('{CHECKOUT_SESSION_ID}')
->and($abo->fresh()->card_update_pending_at)->not->toBeNull();
});
it('creates a stripe customer first for payone legacy abos and persists it', function () {
$abo = pmChangeAbo();
pmChangeMockClient([
Customer::constructFrom(['id' => 'cus_fresh_1', 'object' => 'customer', 'livemode' => false]),
Session::constructFrom([
'id' => 'cs_setup_2',
'object' => 'checkout.session',
'url' => 'https://checkout.stripe.com/c/setup/cs_setup_2',
'livemode' => false,
]),
]);
$result = app(AboPaymentMethodService::class)->beginStripeChange($abo, 'https://x.test/s?session_id={CHECKOUT_SESSION_ID}', 'https://x.test/c');
$fresh = $abo->fresh();
expect($result->isRedirect())->toBeTrue()
->and($fresh->stripe_customer_id)->toBe('cus_fresh_1')
// Provider bleibt PAYONE, bis die neue Zahlungsart wirklich verankert ist
->and($fresh->payment_provider)->toBe('payone');
});
it('blocks the setup flow while abo payments are frozen', function () {
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
$abo = pmChangeAbo();
$result = app(AboPaymentMethodService::class)->beginStripeChange($abo, 'https://x.test/s', 'https://x.test/c');
expect($result->isError())->toBeTrue()
->and($result->errorMessage)->toBe(__('payment.abo_payments_frozen'))
->and($abo->fresh()->card_update_pending_at)->toBeNull();
});
// --- S5.2: Verankern der neuen Zahlungsart ---
it('applies a new payment method and moves a payone abo to stripe', function () {
$abo = pmChangeAbo(['card_update_pending_at' => now()]);
$stripeService = Mockery::mock(StripePaymentService::class);
$stripeService->shouldReceive('retrievePaymentMethodSummary')
->once()->with('pm_new_1')
->andReturn(['type' => 'paypal']);
$stripeService->shouldNotReceive('detachPaymentMethod');
app()->instance(StripePaymentService::class, $stripeService);
app(AboPaymentMethodService::class)->applyStripePaymentMethod($abo, 'cus_apply_1', 'pm_new_1', 'portal');
$fresh = $abo->fresh();
expect($fresh->payment_provider)->toBe('stripe')
->and($fresh->stripe_customer_id)->toBe('cus_apply_1')
->and($fresh->stripe_payment_method_id)->toBe('pm_new_1')
->and($fresh->clearingtype)->toBe('stripe')
->and($fresh->carddata)->toBe(['type' => 'paypal'])
->and($fresh->card_update_pending_at)->toBeNull()
->and($fresh->getPaymentType())->toBe(__('payment.paypal'))
->and($fresh->card_changes()->count())->toBe(1)
->and($fresh->card_changes()->first()->source)->toBe('portal');
});
it('is idempotent and detaches the previous stripe payment method on a real change', function () {
$abo = pmChangeAbo([
'payment_provider' => 'stripe',
'clearingtype' => 'stripe',
'stripe_customer_id' => 'cus_apply_2',
'stripe_payment_method_id' => 'pm_old',
]);
$stripeService = Mockery::mock(StripePaymentService::class);
$stripeService->shouldReceive('retrievePaymentMethodSummary')
->once()->with('pm_new_2')
->andReturn(['type' => 'card', 'brand' => 'visa', 'last4' => '4242', 'exp' => '04/2031', 'wallet' => null]);
$stripeService->shouldReceive('detachPaymentMethod')->once()->with('pm_old');
app()->instance(StripePaymentService::class, $stripeService);
$service = app(AboPaymentMethodService::class);
$service->applyStripePaymentMethod($abo, 'cus_apply_2', 'pm_new_2', 'link');
// Zweiter Aufruf (Webhook nach Return-Pfad): keine zweite Verankerung
$service->applyStripePaymentMethod($abo->fresh(), 'cus_apply_2', 'pm_new_2', 'stripe-webhook');
$fresh = $abo->fresh();
expect($fresh->stripe_payment_method_id)->toBe('pm_new_2')
->and($fresh->card_changes()->count())->toBe(1)
->and(UserAbo::formatMaskedCard($fresh->carddata))->toBe('Visa •••• 4242 (04/2031)');
});
it('anchors the payment method from the setup_intent succeeded webhook', function () {
$abo = pmChangeAbo(['card_update_pending_at' => now()]);
$stripeService = Mockery::mock(StripePaymentService::class);
$stripeService->shouldReceive('retrievePaymentMethodSummary')
->once()->with('pm_hook_1')
->andReturn(['type' => 'sepa_debit', 'last4' => '3000']);
app()->instance(StripePaymentService::class, $stripeService);
pmChangeWebhookEvent('setup_intent.succeeded', [
'id' => 'seti_1',
'object' => 'setup_intent',
'status' => 'succeeded',
'customer' => 'cus_hook_1',
'payment_method' => 'pm_hook_1',
'livemode' => false,
'metadata' => ['user_abo_id' => (string) $abo->id, 'source' => 'portal'],
], 'evt_seti_1');
$fresh = $abo->fresh();
expect($fresh->payment_provider)->toBe('stripe')
->and($fresh->stripe_payment_method_id)->toBe('pm_hook_1')
->and($fresh->card_update_pending_at)->toBeNull()
->and($fresh->card_changes()->first()->source)->toBe('portal');
});
// --- Kunden-Link-Mail: Texte je Flow ---
it('uses payment method wording in the customer link mail while the stripe flow is active', function () {
$abo = pmChangeAbo();
$stripeMail = (new AboCardUpdateLinkMail($abo, 'token-x'))->build();
expect($stripeMail->subject)->toBe(__('abo.payment_method_change_link_mail_subject'))
->and($stripeMail->viewData['mailButton'])->toBe(__('abo.payment_method_change_link_mail_button'));
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'payone', 'text');
$payoneMail = (new AboCardUpdateLinkMail($abo, 'token-x'))->build();
expect($payoneMail->subject)->toBe(__('abo.card_update_link_mail_subject'));
});
// --- Return-Pfad ---
it('finalizes the change from the return session and rejects foreign sessions', function () {
$abo = pmChangeAbo(['card_update_pending_at' => now()]);
$ownSession = Session::constructFrom([
'id' => 'cs_ret_1',
'object' => 'checkout.session',
'customer' => 'cus_ret_1',
'metadata' => ['user_abo_id' => (string) $abo->id],
'setup_intent' => ['id' => 'seti_ret', 'object' => 'setup_intent', 'status' => 'succeeded', 'payment_method' => 'pm_ret_1'],
]);
$foreignSession = Session::constructFrom([
'id' => 'cs_ret_2',
'object' => 'checkout.session',
'customer' => 'cus_other',
'metadata' => ['user_abo_id' => '999999'],
'setup_intent' => ['id' => 'seti_x', 'object' => 'setup_intent', 'status' => 'succeeded', 'payment_method' => 'pm_x'],
]);
$stripeService = Mockery::mock(StripePaymentService::class);
$stripeService->shouldReceive('retrieveSetupSession')->with('cs_ret_1')->andReturn($ownSession);
$stripeService->shouldReceive('retrieveSetupSession')->with('cs_ret_2')->andReturn($foreignSession);
$stripeService->shouldReceive('retrievePaymentMethodSummary')->once()->andReturn(['type' => 'card', 'brand' => 'visa', 'last4' => '4242']);
app()->instance(StripePaymentService::class, $stripeService);
$service = app(AboPaymentMethodService::class);
expect($service->finalizeStripeChangeFromSession($abo, 'cs_ret_2', 'portal'))->toBeFalse()
->and($abo->fresh()->payment_provider)->toBe('payone')
->and($service->finalizeStripeChangeFromSession($abo->fresh(), 'cs_ret_1', 'portal'))->toBeTrue()
->and($abo->fresh()->stripe_payment_method_id)->toBe('pm_ret_1');
});