From ce4750fd1a87f52677b6a94a280c09951e5ecd07 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 9 Jul 2026 14:45:17 +0000 Subject: [PATCH] =?UTF-8?q?Stripe-Integration=20(S4,=20S5,=20S7.6):=20Abos?= =?UTF-8?q?,=20Zahlungsart=20=C3=A4ndern,=20Zahlarten-Kommunikation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Commands/StripeProcessPendingEvents.php | 72 +++ app/Console/Commands/UserMakeAboOrder.php | 6 + app/Console/Kernel.php | 7 + app/Cron/UserMakeOrder.php | 44 +- app/Http/Controllers/Portal/AboController.php | 45 +- app/Http/Controllers/User/AboController.php | 47 +- .../Web/AboCardUpdateController.php | 37 +- .../Controllers/Web/CheckoutController.php | 19 +- app/Mail/AboCardUpdateLinkMail.php | 16 +- app/Models/ShoppingPayment.php | 15 +- app/Models/UserAbo.php | 29 + app/Services/AboHelper.php | 74 ++- app/Services/AboPaymentMethodService.php | 175 ++++- app/Services/Payment.php | 7 +- .../Payment/PaymentProviderManager.php | 4 +- .../Stripe/DTOs/StripeWebhookResult.php | 18 + .../Payment/Stripe/StripePaymentService.php | 302 ++++++++- .../Payment/Stripe/StripeWebhookHandler.php | 192 +++++- app/Services/Payment/StripeGateway.php | 41 ++ ...tripe_abo_columns_to_shopping_payments.php | 41 ++ ...9_120000_widen_clearingtype_for_stripe.php | 53 ++ .../ENTWICKLUNGSPLAN-STRIPE.md | 367 +++++++++-- public/images/payments-assets/amazonpay.svg | 1 + public/images/payments-assets/amex.svg | 1 + public/images/payments-assets/applepay.svg | 1 + public/images/payments-assets/bancontact.svg | 1 + public/images/payments-assets/blik.svg | 1 + public/images/payments-assets/eps.svg | 1 + public/images/payments-assets/googlepay.svg | 1 + public/images/payments-assets/kakaopay.svg | 1 + public/images/payments-assets/klarna.svg | 1 + public/images/payments-assets/link.svg | 1 + public/images/payments-assets/mastercard.svg | 1 + public/images/payments-assets/mbway.svg | 1 + public/images/payments-assets/more.svg | 1 + public/images/payments-assets/naverpay.svg | 1 + public/images/payments-assets/payco.svg | 1 + public/images/payments-assets/paypal.svg | 1 + public/images/payments-assets/pix.svg | 1 + public/images/payments-assets/samsungpay.svg | 1 + public/images/payments-assets/satispay.svg | 1 + public/images/payments-assets/visa.svg | 1 + public/images/payments-assets/vorkasse.svg | 1 + resources/lang/de/abo.php | 14 + resources/lang/de/payment.php | 6 +- resources/lang/de/weborder.php | 35 +- resources/lang/en/abo.php | 14 + resources/lang/en/payment.php | 6 +- resources/lang/en/weborder.php | 33 +- .../emails/abo_card_update_link.blade.php | 8 +- .../portal/abo/_payment_method_form.blade.php | 37 +- .../portal/abo/card_update_done.blade.php | 7 +- .../portal/abo/card_update_link.blade.php | 3 +- resources/views/portal/abo/my_abo.blade.php | 10 +- .../views/portal/abo/payment_method.blade.php | 4 +- resources/views/user/abo/detail.blade.php | 9 +- .../views/user/abo/payment_method.blade.php | 5 +- .../web/layouts/includes/footer.blade.php | 22 +- .../layouts/includes/payment_logos.blade.php | 51 ++ .../views/web/templates/checkout.blade.php | 8 +- .../web/templates/zahlungsarten.blade.php | 151 ++++- .../user/layouts/includes/footer.blade.php | 22 +- routes/domains/crm.php | 1 + routes/domains/portal.php | 2 + .../PaymentMethodsCommunicationTest.php | 70 ++ tests/Feature/PaymentProviderManagerTest.php | 32 +- tests/Feature/StripeAboTest.php | 606 ++++++++++++++++++ .../Feature/StripePaymentMethodChangeTest.php | 304 +++++++++ 68 files changed, 2856 insertions(+), 236 deletions(-) create mode 100644 app/Console/Commands/StripeProcessPendingEvents.php create mode 100644 app/Services/Payment/StripeGateway.php create mode 100644 database/migrations/2026_07_09_090000_add_stripe_abo_columns_to_shopping_payments.php create mode 100644 database/migrations/2026_07_09_120000_widen_clearingtype_for_stripe.php create mode 100644 public/images/payments-assets/amazonpay.svg create mode 100644 public/images/payments-assets/amex.svg create mode 100644 public/images/payments-assets/applepay.svg create mode 100644 public/images/payments-assets/bancontact.svg create mode 100644 public/images/payments-assets/blik.svg create mode 100644 public/images/payments-assets/eps.svg create mode 100644 public/images/payments-assets/googlepay.svg create mode 100644 public/images/payments-assets/kakaopay.svg create mode 100644 public/images/payments-assets/klarna.svg create mode 100644 public/images/payments-assets/link.svg create mode 100644 public/images/payments-assets/mastercard.svg create mode 100644 public/images/payments-assets/mbway.svg create mode 100644 public/images/payments-assets/more.svg create mode 100644 public/images/payments-assets/naverpay.svg create mode 100644 public/images/payments-assets/payco.svg create mode 100644 public/images/payments-assets/paypal.svg create mode 100644 public/images/payments-assets/pix.svg create mode 100644 public/images/payments-assets/samsungpay.svg create mode 100644 public/images/payments-assets/satispay.svg create mode 100644 public/images/payments-assets/visa.svg create mode 100644 public/images/payments-assets/vorkasse.svg create mode 100644 resources/views/web/layouts/includes/payment_logos.blade.php create mode 100644 tests/Feature/PaymentMethodsCommunicationTest.php create mode 100644 tests/Feature/StripeAboTest.php create mode 100644 tests/Feature/StripePaymentMethodChangeTest.php diff --git a/app/Console/Commands/StripeProcessPendingEvents.php b/app/Console/Commands/StripeProcessPendingEvents.php new file mode 100644 index 00000000..1870962c --- /dev/null +++ b/app/Console/Commands/StripeProcessPendingEvents.php @@ -0,0 +1,72 @@ +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; + } +} diff --git a/app/Console/Commands/UserMakeAboOrder.php b/app/Console/Commands/UserMakeAboOrder.php index f38ade48..42f40387 100644 --- a/app/Console/Commands/UserMakeAboOrder.php +++ b/app/Console/Commands/UserMakeAboOrder.php @@ -79,6 +79,12 @@ class UserMakeAboOrder extends Command try { $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(); \Log::channel('cron')->info("UserMakeAboOrder: Befehl erfolgreich abgeschlossen in {$executionTime}"); $this->info("Befehl erfolgreich abgeschlossen in {$executionTime}"); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 76d5f3bf..88f64968 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -52,6 +52,13 @@ class Kernel extends ConsoleKernel $schedule->command('user:cleanup')->dailyAt('03:30'); $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). // BEWUSST INAKTIV bis zum Abschluss des Review-Gates (Feature aktuell nur VIP // im Sales Center, Portal ausgeblendet). Nach Freigabe einkommentieren: diff --git a/app/Cron/UserMakeOrder.php b/app/Cron/UserMakeOrder.php index bdf22c97..424d3f41 100644 --- a/app/Cron/UserMakeOrder.php +++ b/app/Cron/UserMakeOrder.php @@ -64,14 +64,15 @@ class UserMakeOrder return $ret; } + private $stripeShoppingPayment; + public function makePayment($testmode = false) { Log::info('Starte Zahlungsvorgang für UserAbo ID: '.$this->userAbo->id); $provider = app(PaymentProviderManager::class)->providerFor($this->userAbo); - if ($provider !== PaymentProvider::Payone) { - Log::error('Abo-Abbuchung für Provider "'.$provider->value.'" ist noch nicht implementiert. UserAbo ID: '.$this->userAbo->id); - throw new \RuntimeException('Abo-Abbuchung für Provider "'.$provider->value.'" ist noch nicht implementiert (UserAbo '.$this->userAbo->id.').'); + if ($provider === PaymentProvider::Stripe) { + return $this->makeStripePayment(); } 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 + */ + 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 { $shoppingPayment = $this->getShoppingPayment(); @@ -120,6 +154,10 @@ class UserMakeOrder { Log::info('Rufe Zahlungsinformationen ab für UserAbo ID: '.$this->userAbo->id); + if ($this->stripeShoppingPayment) { + return $this->stripeShoppingPayment; + } + if ($this->pay) { $payment = $this->pay->getShoppingPayment(); Log::info('Zahlungsinformationen abgerufen: '.($payment ? 'erfolgreich' : 'nicht verfügbar')); diff --git a/app/Http/Controllers/Portal/AboController.php b/app/Http/Controllers/Portal/AboController.php index 16ff8347..518466dd 100644 --- a/app/Http/Controllers/Portal/AboController.php +++ b/app/Http/Controllers/Portal/AboController.php @@ -22,6 +22,7 @@ use App\Services\Shop; use App\Services\UserService; use App\Services\Util; use Auth; +use Carbon\Carbon; use Request; use Yard; @@ -122,8 +123,8 @@ class AboController extends Controller if ($changeLocked) { $message = __('abo.error_change_locked', [ - 'days' => (int) now()->diffInDays(\Carbon\Carbon::parse($user_abo->next_date), false), - 'lock' => \App\Repositories\AboRepository::getLockDaysChange(), + 'days' => (int) now()->diffInDays(Carbon::parse($user_abo->next_date), false), + 'lock' => AboRepository::getLockDaysChange(), ]); } @@ -552,7 +553,7 @@ class AboController extends Controller $user_abo = UserAbo::findOrFail($id); $this->checkPortalPermission($user_abo); - if (! $user_abo->canUpdatePaymentMethod()) { + if (! app(AboPaymentMethodService::class)->canStartChange($user_abo)) { \Session()->flash('alert-error', __('abo.card_update_not_allowed')); 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(); } + /** + * 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) { $user_abo = UserAbo::findOrFail($id); $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); if ($status === 'success' && is_array($stash)) { diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index 4cfb9577..cc5398a9 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -650,7 +650,7 @@ class AboController extends Controller $this->checkPermissions($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')); 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(); } + /** + * 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) { $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($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); if ($status === 'success' && is_array($stash)) { @@ -729,7 +768,11 @@ class AboController extends Controller 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')); return redirect(route('user_abos_detail', [$view, $user_abo->id])); diff --git a/app/Http/Controllers/Web/AboCardUpdateController.php b/app/Http/Controllers/Web/AboCardUpdateController.php index 303c6e26..73af3a37 100644 --- a/app/Http/Controllers/Web/AboCardUpdateController.php +++ b/app/Http/Controllers/Web/AboCardUpdateController.php @@ -56,11 +56,46 @@ class AboCardUpdateController extends Controller ->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) { $link = $this->resolveRedeemableLink($token); $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); if ($status === 'success' && is_array($stash)) { @@ -84,7 +119,7 @@ class AboCardUpdateController extends Controller } $abo = $link->user_abo; - if (! $abo || ! $abo->canUpdatePaymentMethod()) { + if (! $abo || ! app(AboPaymentMethodService::class)->canStartChange($abo)) { abort(410, __('abo.card_update_link_invalid')); } diff --git a/app/Http/Controllers/Web/CheckoutController.php b/app/Http/Controllers/Web/CheckoutController.php index ff112379..df3c3404 100644 --- a/app/Http/Controllers/Web/CheckoutController.php +++ b/app/Http/Controllers/Web/CheckoutController.php @@ -98,7 +98,7 @@ class CheckoutController extends Controller // Stripe aktiv: keine Gateway-Zahlartenwahl im eigenen Checkout mehr — // nur die Stripe-Option + systeminterne Methoden (Vorkasse/Rechnung). - $stripe_active = $this->stripeCheckoutActive($is_abo, $recovery_user_abo_id); + $stripe_active = $this->stripeCheckoutActive(); if ($stripe_active) { $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 ($data['payment_method'] === 'stripe') { - $shopping_data = Yard::instance($this->instance)->getYardExtra('shopping_data'); - abort_unless($this->stripeCheckoutActive( - (bool) ($data['is_abo'] ?? false), - $shopping_data['recovery_user_abo_id'] ?? null, - ), 404); + abort_unless($this->stripeCheckoutActive(), 404); } else { $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 steht. Abo- und Recovery-Checkouts bleiben bis Phase S4 auf - * PAYONE (Plan dev/2026-07-08-stripe/, Abgrenzung S3/S4). + * Stripe steht. Seit Phase S4 gilt das auch für Abo- und Recovery-Checkouts: + * 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; } diff --git a/app/Mail/AboCardUpdateLinkMail.php b/app/Mail/AboCardUpdateLinkMail.php index a5cd95a5..b8da730d 100644 --- a/app/Mail/AboCardUpdateLinkMail.php +++ b/app/Mail/AboCardUpdateLinkMail.php @@ -3,6 +3,7 @@ namespace App\Mail; use App\Models\UserAbo; +use App\Services\AboPaymentMethodService; use App\Services\Util; use Illuminate\Bus\Queueable; 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 = 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') ->with([ 'salutation' => $salutation, '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'), 'sender' => __('email.sender'), ]); diff --git a/app/Models/ShoppingPayment.php b/app/Models/ShoppingPayment.php index 47bf67ab..2149b99f 100644 --- a/app/Models/ShoppingPayment.php +++ b/app/Models/ShoppingPayment.php @@ -83,6 +83,9 @@ class ShoppingPayment extends Model 'is_abo', 'abo_interval', 'identifier', + 'stripe_customer_id', + 'stripe_payment_method_id', + 'user_abo_id', ]; public function shopping_order() @@ -140,7 +143,17 @@ class ShoppingPayment extends Model */ 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 $details + */ + public static function stripeLabelFromSummary(array $details): string + { $type = $details['type'] ?? null; $cardSuffix = ! empty($details['brand']) && ! empty($details['last4']) diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index 298f6a1d..35de5402 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -6,6 +6,8 @@ namespace App\Models; +use App\Enums\PaymentProvider; +use App\Services\AboPaymentMethodService; use App\Services\Backoffice\BackofficeDashboardService; use App\Services\Util; use App\User; @@ -296,6 +298,17 @@ class UserAbo extends Model { $pan = $carddata['truncatedcardpan'] ?? null; 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; } @@ -325,6 +338,18 @@ class UserAbo extends Model && ! 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() { return $this->hasMany(UserAboItem::class); @@ -463,6 +488,10 @@ class UserAbo extends Model 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'); } } diff --git a/app/Services/AboHelper.php b/app/Services/AboHelper.php index 419708f3..8473e792 100644 --- a/app/Services/AboHelper.php +++ b/app/Services/AboHelper.php @@ -2,7 +2,10 @@ namespace App\Services; +use App\Enums\PaymentProvider; +use App\Models\AboChartSnapshot; use App\Models\Product; +use App\Models\Setting; use App\Models\ShoppingOrder; use App\Models\ShoppingPayment; use App\Models\ShoppingUser; @@ -10,9 +13,11 @@ use App\Models\UserAbo; use App\Models\UserAboItem; use App\Models\UserAboItemHistory; use App\Models\UserAboOrder; +use App\Repositories\AboRepository; use App\Services\Incentive\IncentiveTracker; use App\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; class AboHelper { @@ -92,7 +97,7 @@ class AboHelper 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') @@ -118,7 +123,7 @@ class AboHelper /** * Prüft, ob das Bearbeiten des Abos (Liefertag/Intervall) aktuell durch die * 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). */ 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); - return $daysUntil >= 0 && $daysUntil < \App\Repositories\AboRepository::getLockDaysChange(); + return $daysUntil >= 0 && $daysUntil < AboRepository::getLockDaysChange(); } public static function canEditAbo($user_abo, $view = 'user') @@ -196,7 +201,7 @@ class AboHelper */ 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; } @@ -301,6 +306,13 @@ class AboHelper 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 if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) { return; @@ -311,8 +323,25 @@ class AboHelper 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(); - $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 // is auth_user_id = Berater bestellung @@ -325,6 +354,9 @@ class AboHelper 'email' => $order->shopping_user->billing_email, 'is_for' => $order->shopping_user->is_for, '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, 'wallettype' => $shopping_payment->wallettype, 'carddata' => $shopping_payment->carddata, @@ -378,14 +410,31 @@ class AboHelper $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, // damit auch künftige Folgeabbuchungen darüber laufen (das alte Token war i. d. R. defekt). $paymentTransaction = $shopping_payment->payment_transactions->last(); - $payoneUserId = $paymentTransaction && (int) $paymentTransaction->userid > 0 + $payoneUserId = ! $isStripe && $paymentTransaction && (int) $paymentTransaction->userid > 0 ? (int) $paymentTransaction->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([ 'status' => 2, 'active' => true, @@ -393,6 +442,9 @@ class AboHelper 'wallettype' => $shopping_payment->wallettype, 'carddata' => $shopping_payment->carddata, '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, 'last_date' => now(), ]); @@ -575,7 +627,7 @@ class AboHelper * - start_date <= letzter 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 * @return int[] Array mit 12 Einträgen (Index 0 = Januar, 11 = Dezember) */ @@ -586,13 +638,13 @@ class AboHelper * Aktueller Monat → live berechnet. * 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 int $userId Eingeloggter Berater * @return array 12 Einträge (Index 0 = Jan), null = Zukunft */ public static function getMonthlyAboCounts( - \Illuminate\Database\Eloquent\Builder $liveQuery, + Builder $liveQuery, int $year, string $scope, int $userId @@ -604,7 +656,7 @@ class AboHelper $lastCountableMonth = ($year === $currentYear) ? $currentMonth : 12; // 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('year', $year) ->get() diff --git a/app/Services/AboPaymentMethodService.php b/app/Services/AboPaymentMethodService.php index a43fbe89..ff4072ad 100644 --- a/app/Services/AboPaymentMethodService.php +++ b/app/Services/AboPaymentMethodService.php @@ -2,25 +2,55 @@ namespace App\Services; +use App\Enums\PaymentProvider; use App\Models\UserAbo; use App\Services\Payment\PaymentProviderManager; use App\Services\Payment\Payone\DTOs\CardUpdateResult; use App\Services\Payment\Payone\DTOs\CreditCardCheckData; use App\Services\Payment\Payone\PayonePaymentService; +use App\Services\Payment\Stripe\StripePaymentService; use Illuminate\Support\Facades\DB; use Throwable; /** - * Orchestrates updating the credit-card token of an existing Abo via PAYONE - * (creditcardcheck + preauthorization, no charge — see the Phase-1 plan - * dev/2026-06-25-kreditkarte-update/). The card data is entered exclusively by the - * Abo owner; guard-/owner-specific authorization happens in the calling controller, - * the card-only/active invariant in {@see UserAbo::canUpdatePaymentMethod()}. + * Orchestrates updating the payment method of an existing Abo. The card data / + * payment method is chosen exclusively by the Abo owner; guard-/owner-specific + * authorization happens in the calling controller, the 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 { 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 * 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 { if ($txid === '') { diff --git a/app/Services/Payment.php b/app/Services/Payment.php index b843eaaf..b22892a2 100644 --- a/app/Services/Payment.php +++ b/app/Services/Payment.php @@ -286,7 +286,12 @@ class Payment 'shopping_order.shopping_user', '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); $shopping_order->refresh(); } diff --git a/app/Services/Payment/PaymentProviderManager.php b/app/Services/Payment/PaymentProviderManager.php index b5bc9b0c..f141351f 100644 --- a/app/Services/Payment/PaymentProviderManager.php +++ b/app/Services/Payment/PaymentProviderManager.php @@ -83,9 +83,7 @@ class PaymentProviderManager { return match ($provider) { PaymentProvider::Payone => app(PayoneGateway::class), - PaymentProvider::Stripe => throw new \RuntimeException( - 'Das Stripe-Gateway ist noch nicht implementiert (Plan Phase S2/S4, dev/2026-07-08-stripe/).' - ), + PaymentProvider::Stripe => app(StripeGateway::class), }; } } diff --git a/app/Services/Payment/Stripe/DTOs/StripeWebhookResult.php b/app/Services/Payment/Stripe/DTOs/StripeWebhookResult.php index dd22e7b1..0495baa9 100644 --- a/app/Services/Payment/Stripe/DTOs/StripeWebhookResult.php +++ b/app/Services/Payment/Stripe/DTOs/StripeWebhookResult.php @@ -46,4 +46,22 @@ class StripeWebhookResult { 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', + ); + } } diff --git a/app/Services/Payment/Stripe/StripePaymentService.php b/app/Services/Payment/Stripe/StripePaymentService.php index 8e7247d9..84cc31a1 100644 --- a/app/Services/Payment/Stripe/StripePaymentService.php +++ b/app/Services/Payment/Stripe/StripePaymentService.php @@ -6,9 +6,13 @@ use App\Enums\PaymentProvider; use App\Models\ShoppingOrder; use App\Models\ShoppingPayment; use App\Models\ShoppingUser; +use App\Models\UserAbo; use App\Services\CheckoutFunnelTracker; +use App\Services\Payment\DTOs\AboChargeResult; use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult; use App\Services\Util; +use Stripe\Checkout\Session; +use Stripe\Exception\ApiErrorException; /** * 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, * Konzept §3.1): Zahlartenwahl und Dateneingabe passieren bei Stripe, * "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 { @@ -25,6 +33,11 @@ class StripePaymentService * Erstellt ShoppingPayment + Checkout Session für eine Einmalzahlung. * Die Success-/Cancel-URLs baut der HTTP-Aufrufer (Routen sind * domain-abhängig und gehören nicht in die Service-Schicht). + * + * 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( ShoppingUser $shoppingUser, @@ -64,7 +77,7 @@ class StripePaymentService 'mode' => (string) $shoppingOrder->mode, ]; - $session = $this->stripe->client()->checkout->sessions->create([ + $sessionParams = [ 'mode' => 'payment', 'client_reference_id' => $reference, 'customer_email' => $shoppingUser->billing_email ?: null, @@ -82,7 +95,14 @@ class StripePaymentService 'payment_intent_data' => ['metadata' => $metadata], 'success_url' => $successUrl, '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->save(); @@ -100,4 +120,282 @@ class StripePaymentService 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|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'), + }; + } } diff --git a/app/Services/Payment/Stripe/StripeWebhookHandler.php b/app/Services/Payment/Stripe/StripeWebhookHandler.php index 64085be3..ed2a6b2d 100644 --- a/app/Services/Payment/Stripe/StripeWebhookHandler.php +++ b/app/Services/Payment/Stripe/StripeWebhookHandler.php @@ -5,6 +5,8 @@ namespace App\Services\Payment\Stripe; use App\Models\ShoppingOrder; use App\Models\ShoppingPayment; use App\Models\StripeWebhookEvent; +use App\Models\UserAbo; +use App\Services\AboPaymentMethodService; use App\Services\CheckoutFunnelTracker; use App\Services\Payment; use App\Services\Payment\Stripe\DTOs\StripeWebhookResult; @@ -25,7 +27,7 @@ use Stripe\Webhook; */ class StripeWebhookHandler { - private const HANDLED_TYPES = [ + public const HANDLED_TYPES = [ 'checkout.session.completed', 'checkout.session.expired', 'payment_intent.succeeded', @@ -102,7 +104,9 @@ class StripeWebhookHandler 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()]); @@ -110,37 +114,136 @@ class StripeWebhookHandler } /** - * @param array $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 $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); 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( 'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)', - $event->id, - $event->type, + $eventId, + $eventType, $object['id'] ?? '?', )); - return; + return true; } - StripeTransactionRecorder::recordFromStripeObject($payment, $event->type, $object); + StripeTransactionRecorder::recordFromStripeObject($payment, $eventType, $object); $this->rememberPaymentIntentReference($payment, $object); - match ($event->type) { + match ($eventType) { 'checkout.session.completed' => $this->handleSessionCompleted($payment, $object), 'checkout.session.expired' => $this->handleSessionExpired($payment), 'payment_intent.succeeded' => $this->markPaid($payment, $object), 'payment_intent.processing' => $this->markPending($payment), 'payment_intent.payment_failed' => $this->markFailed($payment), 'charge.succeeded' => $this->storePaymentMethodDetails($payment, $object), - // charge.refunded / dispute / setup_intent → Business-Logik folgt in S5–S7. - default => Log::channel('stripe')->info("Stripe-Webhook protokolliert (Verarbeitung folgt in späterer Phase): {$event->type}"), + // 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): {$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 $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; } + $this->captureOffSessionPaymentMethod($payment, $object); + $order = $payment->shopping_order; $this->upgradeTxaction($order, $payment, 'paid'); @@ -248,6 +353,71 @@ class StripeWebhookHandler $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 $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 …) * aus den payment_method_details des Charge-Objekts in carddata — für die diff --git a/app/Services/Payment/StripeGateway.php b/app/Services/Payment/StripeGateway.php new file mode 100644 index 00000000..b6ebbfc2 --- /dev/null +++ b/app/Services/Payment/StripeGateway.php @@ -0,0 +1,41 @@ +paymentService->chargeAbo( + $userAbo, + $shoppingOrder, + $shoppingUser, + $amountCents, + $currency, + ); + } +} diff --git a/database/migrations/2026_07_09_090000_add_stripe_abo_columns_to_shopping_payments.php b/database/migrations/2026_07_09_090000_add_stripe_abo_columns_to_shopping_payments.php new file mode 100644 index 00000000..761ddbf3 --- /dev/null +++ b/database/migrations/2026_07_09_090000_add_stripe_abo_columns_to_shopping_payments.php @@ -0,0 +1,41 @@ +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); + } + } + }); + } +}; diff --git a/database/migrations/2026_07_09_120000_widen_clearingtype_for_stripe.php b/database/migrations/2026_07_09_120000_widen_clearingtype_for_stripe.php new file mode 100644 index 00000000..2bcb9730 --- /dev/null +++ b/database/migrations/2026_07_09_120000_widen_clearingtype_for_stripe.php @@ -0,0 +1,53 @@ +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']); + } +}; diff --git a/dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md b/dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md index 26da9d7f..65607620 100644 --- a/dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md +++ b/dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md @@ -1,6 +1,21 @@ # 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')`. > **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/` @@ -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 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.)* ### Akzeptanzkriterien -- [ ] **Browser-E2E (Manualtest, offen):** Checkout → Redirect zu Stripe → Zahlung (inkl. 3DS-Testkarte) → Rücksprung → Webhook → Order bezahlt → Rechnung aus dem System. -- [ ] Apple Pay/Google Pay erscheinen auf fähigen Geräten ohne Zusatzcode; Zahlarten lassen sich im Stripe-Dashboard zu-/abschalten, ohne Deploy. -- [ ] Abbruch bei Stripe führt sauber in den Checkout zurück (Manualtest). +- [x] **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".)* +- [x] Apple Pay/Google Pay erscheinen auf fähigen Geräten ohne Zusatzcode; Zahlarten lassen sich im Stripe-Dashboard zu-/abschalten, ohne Deploy. +- [x] Abbruch bei Stripe führt sauber in den Checkout zurück. - [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 — 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`). -2. [ ] `php artisan migrate` (3 neue Migrationen: Provider-Spalten, `stripe_webhook_events`). -3. [ ] Prod-`.env`: `STRIPE_KEY`/`STRIPE_SECRET` = **Live-Keys** (`pk_live_…`/`sk_live_…`), `STRIPE_MODE=live`. -4. [ ] Stripe-Dashboard (**Live-Modus**): Zahlarten aktivieren; Webhook-Endpoint +1. [x] Deployment des aktuellen Stands (Branch `payment/phase-r-payone-refactoring`). +2. [x] `php artisan migrate` (damals 3 neue Migrationen: Provider-Spalten, `stripe_webhook_events`). +3. [x] Prod-`.env`: `STRIPE_KEY`/`STRIPE_SECRET` = **Live-Keys** (`pk_live_…`/`sk_live_…`), `STRIPE_MODE=live`. +4. [x] Stripe-Dashboard (**Live-Modus**): Zahlarten aktivieren; Webhook-Endpoint `https://api.mivita.care/stripe/webhook` mit der Event-Liste aus §6 anlegen; dessen Signing-Secret als `STRIPE_WEBHOOK_SECRET` in die Prod-`.env`. -5. [ ] Caches: `php artisan config:clear && php artisan view:clear` (bzw. Deploy-üblich `config:cache`). -6. [ ] Schalter setzen — im Admin unter **Einstellungen → „Zahlungen (Stripe / PAYONE)"**: + *Stolpersteine (beide gelöst): falsches Webhook-Secret → alle Events 400; + `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, ☑ Vorkasse ausblenden (**wichtig:** Vorkasse autorisiert über PAYONE und würde beim Ausfall crashen), Anbieter = **Stripe**. -7. [ ] Smoke-Test **als Admin bei aktivem Wartungsmodus**: echte Bestellung (Live-Karte) - → Stripe-Redirect → bezahlt → Rechnung; ggf. im Stripe-Dashboard erstatten. - Gegenprobe im Inkognito-Fenster: Besucher sehen die Wartungsmeldung. -8. [ ] Checkout öffnen: **Wartungsmodus abhaken** — Einmalzahlungen laufen für alle über Stripe. -9. [ ] Kontrolle: `storage/logs/stripe.log`, `stripe_webhook_events`, Payment-Dashboard. -10. [ ] Nach S4: `abo-payments-frozen` zurücksetzen. +7. [x] Smoke-Test **als Admin bei aktivem Wartungsmodus**: echte Bestellungen (u. a. + 57831, 57832 — müssen noch storniert/bereinigt werden, Statistik/Provision!). +8. [x] Checkout geöffnet: Einmalzahlungen laufen seit 2026-07-08 abends für alle über Stripe. +9. [x] Kontrolle: `storage/logs/stripe.log`, `stripe_webhook_events`, Payment-Dashboard. +10. [ ] **Nach S4/S5 (JETZT offen, → §16):** Deployment + 3 neue Migrationen, dann + `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 Neue Abos: Erstzahlung im Checkout mit Speicherung des Zahlungsmittels; wiederkehrende Abbuchungen über den bestehenden Cron, off-session. Bestehende PAYONE-Abos laufen -unverändert über PAYONE. +unverändert über PAYONE (Auslaufmodell, §7.2). Recovery-Zahlungen laufen über Stripe +und ziehen das Abo dabei auf Stripe um. ### Aufgaben | # | Aufgabe | |---|---------| -| S4.1 | Abo-Erstzahlung: Checkout Session (`mode=payment`) mit `setup_future_usage=off_session` + `customer`; Zahlarten auf **wiederverwendbare** Methoden eingeschränkt (Konzept §3.2; Klarna/Amazon Pay nur nach positiver N1-Prüfung); nach `succeeded` PaymentMethod-ID aus dem PaymentIntent ermitteln. | +| S4.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.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. | @@ -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.7 | Abo-Retry (`AboRetryPaymentService`) provider-fähig (nutzt `chargeAbo` des Abo-Providers). | -### Tests -- Erstzahlung → `UserAbo` mit Stripe-Feldern; PAYONE-Felder leer. -- Cron-Abbuchung Stripe-Abo: Erfolg (Order paid, `user_abo_orders` fortgeschrieben), Decline (Status 3), Idempotenz (doppelter Cron-Lauf bucht nicht doppelt ab). -- Gemischte Bestände: ein PAYONE-Abo + ein Stripe-Abo im selben Cron-Lauf → jeweils richtiger Gateway-Aufruf. +### Vorab-Designentscheidungen (2026-07-09) + +- **Transport der Stripe-IDs:** Neue Spalten `stripe_customer_id`, `stripe_payment_method_id` + 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 -- [ ] 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). +- [ ] 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 -Der Flow aus dem CC-Update-Projekt (2026-06-25) funktioniert für Stripe-Abos: -Zahlungsmittel erneuern **ohne Abbuchung**, nur durch den Abo-Inhaber. +**Scope-Erweiterung (Stakeholder 2026-07-09):** Nicht mehr nur „Kreditkarte +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 | # | Aufgabe | |---|---------| -| S5.1 | `StripePaymentService::setupPaymentMethodUpdate()`: Checkout Session `mode=setup` (hosted, Redirect – konsistent zum Checkout-Flow); Einstieg über die bestehenden Card-Update-Views/Links (Verzweigung nach Abo-Provider). | -| S5.2 | `setup_intent.succeeded`-Webhook bzw. Return-Bestätigung: neue `stripe_payment_method_id` am Abo speichern, alte PaymentMethod am Customer detachen; `user_abo_card_changes`-Protokoll weiternutzen. | -| S5.3 | Konsistenz-Regeln aus dem CC-Update-Projekt übernehmen: `card_update_pending_at` vor externem Call setzen; API-OK → DB-Update; Fehlerfall → CRITICAL-Log + Incident (Alt-Plan §5.3.1). | -| S5.4 | `AboPaymentMethodService` provider-fähig; `AboCardUpdateLink`-Flow (Berater→Kunde-Link) für Stripe-Abos freischalten. | +| 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 | 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 Redirect setzen; Webhook-OK → DB-Update + Flag löschen; Fehlerfall → CRITICAL-Log (Alt-Plan §5.3.1). | +| 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 -- SetupIntent-Erfolg aktualisiert nur das Ziel-Abo; keine Abbuchung entsteht. -- Nicht-Inhaber wird abgewiesen (Policy unverändert). -- Pending-Flag/Inkonsistenz-Pfad (API ok, DB-Fehler) erzeugt Incident. +### Umsetzungsstatus (2026-07-09) + +| # | Status | +|---|--------| +| 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.2 | `payment_intent.payment_failed` / `charge.dispute.created` / `charge.refunded` → `PaymentIncident` (Provider `stripe`) + Payment-Dashboard-Kacheln. | | S6.3 | `payments:check-accounts` bzw. Uptime-Check um Stripe-Status ergänzen (API-Erreichbarkeit, offene `processing`-Zahlungen älter X Tage). | -| S6.4 | Wartungs-Command `stripe:sync-payment-status {paymentIntentId|--pending}` (Gegenstück zu `payone:*`-Reparatur-Commands): Status aktiv nachziehen, wenn ein Webhook verloren ging. | +| 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 - 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.2 | **Refunds:** `OrderWithdrawalController` (Widerruf) um automatische Erstattung erweitern — Provider der Ursprungszahlung entscheidet (Stripe: `Refund`-API; PAYONE: bestehender/manueller Weg). Teil-Erstattungen unterstützen. | | S7.3 | Admin-Detailansichten (`ShoppingOrder`, `UserAbo`, Zahlungen): Provider-Badge, Stripe-IDs verlinkt zum Stripe-Dashboard (Deep-Link `https://dashboard.stripe.com/...`). | -| S7.4 | Payment-Dashboard: Umsatz/Fehlerraten je Provider; Übersicht „Abos je Provider" (Fortschritt einer späteren Migration ablesbar). | +| S7.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.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 - 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 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 neu (SetupIntent, kein Charge) über den Card-Update-Link-Flow; danach `payment_provider='stripe'` am Abo, PAYONE-Felder bleiben als Historie. -- Kampagnen-Command `abo:invite-stripe-migration` (Batch-Mails mit Link, Erinnerung, - Fortschritts-Report) — Muster: Reminder-Commands aus dem Einmalprodukte-Projekt. - Kein Zwang, kein Big-Bang: nicht migrierte Abos laufen unbefristet über PAYONE. --- +## 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) | Ebene | Inhalt | @@ -627,13 +878,27 @@ routes/api.php, routes/domains/*.php 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 - Redirect, alle Stripe-Zahlarten inkl. PayPal/Apple Pay/Google Pay/Amazon Pay/Klarna, - nur EUR, SDK freigegeben, Account vorhanden, Bestands-Abo-Migration und - Membership/ELV bewusst nach hinten gestellt (S8 bzw. Folgeprojekt). -2. Phase **S0** starten: Test-Keys + Webhook im (vorhandenen) Stripe-Account einrichten, - `stripe/stripe-php` installieren, `config/stripe.php`, **N1-Prüfung** (Klarna/Amazon - Pay Abo-Fähigkeit). -3. Danach S1 → S2 → S3; nach S3 erster produktiver Meilenstein (Einmalzahlung) möglich. +1. ~~**Testmodus-E2E für S4/S5**~~ ✅ **komplett 2026-07-09**: Abo-Abschluss, Cron-Abbuchung + (inkl. `stripe:process-pending-events`), Zahlungsart-Wechsel (CRM + Kunden-Link, + PayPal, PAYONE→Stripe-Umzug), Decline → Status 3, Recovery-Checkout, + authentication_required. Aufräumen: Test-Abos #440/#442 (Abo 442 steht nach dem + letzten Test auf Status 3 mit der `…3184`-Testkarte) und Test-Bestellungen bereinigen. +2. **Deployment + Migrationen** (3 neue: Provider-/Stripe-Abo-Spalten, + `widen_clearingtype`), dann im Admin: **`abo-payments-frozen` aufheben** (§7.2), + 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). diff --git a/public/images/payments-assets/amazonpay.svg b/public/images/payments-assets/amazonpay.svg new file mode 100644 index 00000000..9f047f3c --- /dev/null +++ b/public/images/payments-assets/amazonpay.svg @@ -0,0 +1 @@ +Amazon Pay diff --git a/public/images/payments-assets/amex.svg b/public/images/payments-assets/amex.svg new file mode 100644 index 00000000..0447d7a3 --- /dev/null +++ b/public/images/payments-assets/amex.svg @@ -0,0 +1 @@ +American Express diff --git a/public/images/payments-assets/applepay.svg b/public/images/payments-assets/applepay.svg new file mode 100644 index 00000000..3a566b9c --- /dev/null +++ b/public/images/payments-assets/applepay.svg @@ -0,0 +1 @@ +Apple Pay diff --git a/public/images/payments-assets/bancontact.svg b/public/images/payments-assets/bancontact.svg new file mode 100644 index 00000000..0542f343 --- /dev/null +++ b/public/images/payments-assets/bancontact.svg @@ -0,0 +1 @@ +Bancontact \ No newline at end of file diff --git a/public/images/payments-assets/blik.svg b/public/images/payments-assets/blik.svg new file mode 100644 index 00000000..3bd5734c --- /dev/null +++ b/public/images/payments-assets/blik.svg @@ -0,0 +1 @@ +BLIK \ No newline at end of file diff --git a/public/images/payments-assets/eps.svg b/public/images/payments-assets/eps.svg new file mode 100644 index 00000000..e9a688b9 --- /dev/null +++ b/public/images/payments-assets/eps.svg @@ -0,0 +1 @@ + EPS \ No newline at end of file diff --git a/public/images/payments-assets/googlepay.svg b/public/images/payments-assets/googlepay.svg new file mode 100644 index 00000000..42ea1f68 --- /dev/null +++ b/public/images/payments-assets/googlepay.svg @@ -0,0 +1 @@ +Google Pay diff --git a/public/images/payments-assets/kakaopay.svg b/public/images/payments-assets/kakaopay.svg new file mode 100644 index 00000000..01e19bca --- /dev/null +++ b/public/images/payments-assets/kakaopay.svg @@ -0,0 +1 @@ +Kakao Pay diff --git a/public/images/payments-assets/klarna.svg b/public/images/payments-assets/klarna.svg new file mode 100644 index 00000000..30010458 --- /dev/null +++ b/public/images/payments-assets/klarna.svg @@ -0,0 +1 @@ +Klarna \ No newline at end of file diff --git a/public/images/payments-assets/link.svg b/public/images/payments-assets/link.svg new file mode 100644 index 00000000..4fd25d6c --- /dev/null +++ b/public/images/payments-assets/link.svg @@ -0,0 +1 @@ +Linklink diff --git a/public/images/payments-assets/mastercard.svg b/public/images/payments-assets/mastercard.svg new file mode 100644 index 00000000..6d974721 --- /dev/null +++ b/public/images/payments-assets/mastercard.svg @@ -0,0 +1 @@ +Mastercard diff --git a/public/images/payments-assets/mbway.svg b/public/images/payments-assets/mbway.svg new file mode 100644 index 00000000..c6ac2371 --- /dev/null +++ b/public/images/payments-assets/mbway.svg @@ -0,0 +1 @@ +MB WAY diff --git a/public/images/payments-assets/more.svg b/public/images/payments-assets/more.svg new file mode 100644 index 00000000..8cdf9545 --- /dev/null +++ b/public/images/payments-assets/more.svg @@ -0,0 +1 @@ +Weitere Zahlarten diff --git a/public/images/payments-assets/naverpay.svg b/public/images/payments-assets/naverpay.svg new file mode 100644 index 00000000..14934a29 --- /dev/null +++ b/public/images/payments-assets/naverpay.svg @@ -0,0 +1 @@ +Naver Pay diff --git a/public/images/payments-assets/payco.svg b/public/images/payments-assets/payco.svg new file mode 100644 index 00000000..b82633b6 --- /dev/null +++ b/public/images/payments-assets/payco.svg @@ -0,0 +1 @@ +Payco \ No newline at end of file diff --git a/public/images/payments-assets/paypal.svg b/public/images/payments-assets/paypal.svg new file mode 100644 index 00000000..1537546f --- /dev/null +++ b/public/images/payments-assets/paypal.svg @@ -0,0 +1 @@ +PayPal \ No newline at end of file diff --git a/public/images/payments-assets/pix.svg b/public/images/payments-assets/pix.svg new file mode 100644 index 00000000..09fdaded --- /dev/null +++ b/public/images/payments-assets/pix.svg @@ -0,0 +1 @@ +Pix diff --git a/public/images/payments-assets/samsungpay.svg b/public/images/payments-assets/samsungpay.svg new file mode 100644 index 00000000..d3542293 --- /dev/null +++ b/public/images/payments-assets/samsungpay.svg @@ -0,0 +1 @@ +Samsung Pay diff --git a/public/images/payments-assets/satispay.svg b/public/images/payments-assets/satispay.svg new file mode 100644 index 00000000..196cffab --- /dev/null +++ b/public/images/payments-assets/satispay.svg @@ -0,0 +1 @@ +Satispay \ No newline at end of file diff --git a/public/images/payments-assets/visa.svg b/public/images/payments-assets/visa.svg new file mode 100644 index 00000000..1299545f --- /dev/null +++ b/public/images/payments-assets/visa.svg @@ -0,0 +1 @@ +Visa diff --git a/public/images/payments-assets/vorkasse.svg b/public/images/payments-assets/vorkasse.svg new file mode 100644 index 00000000..9c414dcd --- /dev/null +++ b/public/images/payments-assets/vorkasse.svg @@ -0,0 +1 @@ +Vorkasse€ diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index a2858b19..db365fd0 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -278,4 +278,18 @@ return [ '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_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', ]; diff --git a/resources/lang/de/payment.php b/resources/lang/de/payment.php index b78b2029..1e251f67 100644 --- a/resources/lang/de/payment.php +++ b/resources/lang/de/payment.php @@ -151,11 +151,15 @@ return [ // Stripe (Hosted Checkout) '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_short' => 'Online-Zahlung (Stripe)', 'stripe_unavailable' => 'Die Online-Zahlung ist derzeit nicht erreichbar. Bitte versuche es gleich erneut.', '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) 'prepayment_no_clearing_data' => 'Bankverbindung liegt noch nicht vor — die Zahlung wurde beim Zahlungsanbieter noch nicht erfolgreich registriert.', diff --git a/resources/lang/de/weborder.php b/resources/lang/de/weborder.php index 9de965b1..0701af0c 100644 --- a/resources/lang/de/weborder.php +++ b/resources/lang/de/weborder.php @@ -21,19 +21,32 @@ return [ 'plus_shipping_cost' => 'zzgl. Versandkosten', 'delivery_time_1_3' => 'Lieferzeit: 1-3 Werktage', 'payment_methods' => 'Zahlungsarten', - 'payment_method_paypal' => 'Zahlung mit Paypal', - '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_direct' => 'Sofortüberweisung', - '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_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' => 'PayPal', + '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_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_prepayment' => 'Vorauskasse', - '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_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_applepay' => 'Apple Pay', + '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', 'free_shipping_at' => 'Versandkostenfrei ab :value €', - + 'only_missing_free_shipping_at' => 'Es fehlen nur noch :value € für Deine versandkostenfreie Lieferung.', 'free_shipping' => 'versandkostenfreie Lieferung', 'not_free_shipping' => 'nicht versandkostenfrei', @@ -50,7 +63,7 @@ return [ ]; /* -{{ __('weborder.') }} +{{ __('weborder.') }} {{ __('weborder.name') }} weborder.name */ diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index a535c85b..7c7ee91f 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -278,4 +278,18 @@ return [ 'card_expiry_invalid' => 'Please enter a valid expiry date.', 'card_type_invalid' => 'Please select a valid card type (Visa or Mastercard).', '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', ]; diff --git a/resources/lang/en/payment.php b/resources/lang/en/payment.php index 019ca7a2..e768ee18 100644 --- a/resources/lang/en/payment.php +++ b/resources/lang/en/payment.php @@ -199,11 +199,15 @@ return [ // Stripe (hosted checkout) '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_short' => 'Online payment (Stripe)', 'stripe_unavailable' => 'Online payment is currently unavailable. Please try again in a moment.', '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) 'prepayment_no_clearing_data' => 'Bank details are not available yet — the payment has not been successfully registered with the payment provider.', diff --git a/resources/lang/en/weborder.php b/resources/lang/en/weborder.php index 2db945c3..2b211759 100644 --- a/resources/lang/en/weborder.php +++ b/resources/lang/en/weborder.php @@ -19,17 +19,30 @@ return [ '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_costs_from' => 'All that\'s left is :value € for your free shipping.', - '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_direct' => 'instant bank transfer', - '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' => 'payment with Paypal', - '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_prepayment' => 'prepayment', - '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_sepa' => 'SEPA direct debit', - '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_creditcard' => 'Credit card', + '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_paypal' => 'PayPal', + '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_applepay' => 'Apple Pay', + '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_googlepay' => 'Google Pay', + '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_klarna' => 'Klarna', + '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_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', 'price_net' => 'prices net', 'product_details' => 'product details', diff --git a/resources/views/emails/abo_card_update_link.blade.php b/resources/views/emails/abo_card_update_link.blade.php index 7f124e04..31d0dbbf 100644 --- a/resources/views/emails/abo_card_update_link.blade.php +++ b/resources/views/emails/abo_card_update_link.blade.php @@ -4,7 +4,7 @@ - {{ __('abo.card_update_link_mail_subject') }} + {{ $mailTitle ?? __('abo.card_update_link_mail_subject') }}