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 * {@see applyNewCard()}); failures leave the stored card untouched. * * @param array $returnUrls successurl/errorurl/backurl for the 3DS redirect. */ public function updateCreditCard(UserAbo $abo, CreditCardCheckData $card, array $returnUrls, ?string $source = null): CardUpdateResult { if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) { return CardUpdateResult::error(__('payment.abo_payments_frozen')); } if (! $abo->canUpdatePaymentMethod()) { return CardUpdateResult::error(__('abo.card_update_not_allowed')); } $shoppingUser = $abo->shopping_user; if (! $shoppingUser) { return CardUpdateResult::error(__('abo.card_update_failed')); } $mode = $this->resolveMode($abo); // Konsistenz-Spur: vor externem PAYONE-Call + DB-Write setzen, damit ein Crash // zwischen erfolgreichem PAYONE-Call und DB-Write auffindbar bleibt. $abo->forceFill(['card_update_pending_at' => now()])->save(); $result = $this->payone->refreshAboCardToken($shoppingUser, $card, $returnUrls, $mode); if ($result->isApproved()) { $this->applyNewCard($abo, $result->carddata, $result->userid, $source); // 1-Cent-Reservierung freigeben; Fehler hier ist unkritisch (verfällt sonst). $this->safelyReleasePreauth($result->txid, $mode); return $result; } if ($result->isRedirect()) { // Pending-Spur bleibt bestehen; Finalisierung nach 3DS-Rückkehr (Controller). return $result; } // Fehler: Spur zurücknehmen, kein DB-Update. $abo->forceFill(['card_update_pending_at' => null])->save(); return $result; } /** * Writes the new card token atomically and idempotently and clears the pending trace. * Also used by the 3-D-Secure return path after successful authentication. * * @param array $carddata */ public function applyNewCard(UserAbo $abo, array $carddata, int $userid, ?string $source = null): void { try { DB::transaction(function () use ($abo, $carddata, $userid, $source) { $abo->forceFill([ 'carddata' => $carddata, 'payone_userid' => $userid, 'card_update_pending_at' => null, ])->save(); $abo->card_changes()->create([ 'masked_card' => UserAbo::formatMaskedCard($carddata), 'source' => $source, ]); }); } catch (Throwable $e) { // PAYONE hat die neue Karte, der lokale DB-Write schlug fehl → Inkonsistenz: // card_update_pending_at bleibt gesetzt (auffindbare Spur), CRITICAL-Alert. MyLog::writeLog( 'payment', 'critical', 'Kreditkarten-Update: DB-Write nach erfolgreichem PAYONE-Call fehlgeschlagen (Abo '.$abo->id.')', ['user_abo_id' => $abo->id, 'payone_userid' => $userid, 'exception' => $e->getMessage()], ); throw $e; } } /** * 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 === '') { return; } try { $this->payone->releasePreauth($txid, $mode); } catch (Throwable $e) { MyLog::writeLog( 'payone', 'warning', 'Preauth-Freigabe (capture amount=0) fehlgeschlagen, Reservierung verfällt', ['txid' => $txid, 'error' => $e->getMessage()], false, ); } } private function resolveMode(UserAbo $abo): string { return $abo->initialShoppingPayment()?->mode ?: config('payone.defaults.mode'); } }