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

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

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

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

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

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

View file

@ -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),
};
}
}

View file

@ -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',
);
}
}

View file

@ -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<string, mixed>|null
*/
public function retrievePaymentMethodSummary(string $paymentMethodId): ?array
{
try {
$paymentMethod = $this->stripe->client()->paymentMethods->retrieve($paymentMethodId);
} catch (\Throwable $e) {
\Log::channel('stripe')->warning('PaymentMethod-Summary nicht abrufbar: '.$e->getMessage(), [
'payment_method' => $paymentMethodId,
]);
return null;
}
$summary = ['type' => $paymentMethod->type];
if ($paymentMethod->type === 'card' && $paymentMethod->card) {
$summary['brand'] = $paymentMethod->card->brand ?? null;
$summary['last4'] = $paymentMethod->card->last4 ?? null;
$summary['wallet'] = $paymentMethod->card->wallet->type ?? null;
$summary['exp'] = isset($paymentMethod->card->exp_month, $paymentMethod->card->exp_year)
? sprintf('%02d/%d', $paymentMethod->card->exp_month, $paymentMethod->card->exp_year)
: null;
}
if ($paymentMethod->type === 'sepa_debit' && $paymentMethod->sepa_debit) {
$summary['last4'] = $paymentMethod->sepa_debit->last4 ?? null;
}
return $summary;
}
/**
* Setup-Session für den Return-Pfad nachladen (inkl. SetupIntent).
*/
public function retrieveSetupSession(string $sessionId): Session
{
return $this->stripe->client()->checkout->sessions->retrieve($sessionId, [
'expand' => ['setup_intent'],
]);
}
/**
* Alte PaymentMethod nach erfolgreichem Wechsel am Customer lösen —
* best effort, ein Fehler darf den Wechsel nie kippen.
*/
public function detachPaymentMethod(string $paymentMethodId): void
{
try {
$this->stripe->client()->paymentMethods->detach($paymentMethodId);
} catch (\Throwable $e) {
\Log::channel('stripe')->warning('Alte PaymentMethod konnte nicht detached werden: '.$e->getMessage(), [
'payment_method' => $paymentMethodId,
]);
}
}
/**
* Fehler-Mapping (Plan S4.5): Stripe-Declines werden protokolliert und als
* ERROR-Ergebnis zurückgegeben — der Cron setzt damit denselben
* Abo-Fehlerstatus (3) wie bei PAYONE-Declines inkl. Pay-now-Mail.
*/
private function aboChargeError(ShoppingPayment $shoppingPayment, ApiErrorException $e): AboChargeResult
{
$stripeError = $e->getError();
$errorObject = $e->getJsonBody()['error'] ?? ['message' => $e->getMessage()];
if (! empty($stripeError?->payment_intent)) {
$shoppingPayment->external_reference = $stripeError->payment_intent->id;
$shoppingPayment->save();
}
StripeTransactionRecorder::recordFromStripeObject($shoppingPayment, 'authorization', [
'id' => $stripeError?->payment_intent->id ?? '',
'status' => 'failed',
'last_payment_error' => [
'code' => $stripeError?->code ?? $e->getStripeCode(),
'message' => $stripeError?->message ?? $e->getMessage(),
],
'decline_code' => $stripeError?->decline_code ?? null,
]);
return new AboChargeResult(
provider: PaymentProvider::Stripe,
success: false,
status: 'ERROR',
rawResponse: is_array($errorObject) ? $errorObject : [],
transactionId: $stripeError?->payment_intent->id ?? '',
errorMessage: trim(($stripeError?->code ?? (string) $e->getStripeCode()).': '.($stripeError?->message ?? $e->getMessage()), ': '),
customerMessage: $this->customerMessageForDecline($stripeError?->code ?? (string) $e->getStripeCode()),
shoppingPayment: $shoppingPayment,
);
}
private function customerMessageForDecline(?string $code): string
{
return match ($code) {
'expired_card' => __('payment.payment_error_reasons.card_expired'),
'card_declined' => __('payment.payment_error_reasons.card_declined'),
'insufficient_funds' => __('payment.payment_error_reasons.insufficient_funds'),
'incorrect_cvc' => __('payment.payment_error_reasons.cvv_invalid'),
'authentication_required' => __('payment.stripe_abo_authentication_required'),
default => __('payment.payment_error_reasons.general'),
};
}
}

View file

@ -5,6 +5,8 @@ namespace App\Services\Payment\Stripe;
use App\Models\ShoppingOrder;
use App\Models\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<string, mixed> $object
* Verarbeitet ein gespeichertes, noch unverarbeitetes Event nach
* (stripe:process-pending-events): z. B. wenn der Webhook die Cron-
* DB-Transaktion überholt hat (503) und kein Stripe-Retry kommt
* (Stripe CLI leitet non-2xx nicht erneut zu). Die Signatur wurde
* bereits beim Empfang geprüft; der Payload liegt redaktiert vor.
*
* @return bool true = verarbeitet (processed_at gesetzt), false = weiterhin nicht zuordenbar
*/
private function dispatchEvent(Event $event, array $object): void
public function processStoredEvent(StripeWebhookEvent $storedEvent): bool
{
if ($storedEvent->processed_at !== null || ! in_array($storedEvent->type, self::HANDLED_TYPES, true)) {
return false;
}
if (! $this->dispatchEvent($storedEvent->event_id, $storedEvent->type, $storedEvent->payload ?? [])) {
return false;
}
$storedEvent->update(['processed_at' => now()]);
return true;
}
/**
* @param array<string, mixed> $object
* @return bool false = Zahlung mit unserer Metadata (noch) nicht sichtbar → Stripe-Retry anfordern
*/
private function dispatchEvent(string $eventId, string $eventType, array $object): bool
{
/*
* Setup-Flow "Zahlungsart ändern" (Plan S5): trägt keine Zahlung,
* sondern ein Abo in der Metadata — vor der Zahlungs-Auflösung behandeln.
*/
if ($eventType === 'setup_intent.succeeded') {
$this->handleSetupIntentSucceeded($eventId, $object);
return true;
}
if ($eventType === 'checkout.session.completed' && ($object['mode'] ?? null) === 'setup') {
Log::channel('stripe')->info("Setup-Session abgeschlossen (Verankerung via setup_intent.succeeded): {$eventId}");
return true;
}
$payment = $this->resolveShoppingPayment($object);
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<string, mixed> $object
*/
private function handleSetupIntentSucceeded(string $eventId, array $object): void
{
$userAboId = (int) ($object['metadata']['user_abo_id'] ?? 0);
if ($userAboId <= 0) {
Log::channel('stripe')->info("SetupIntent ohne Abo-Metadata protokolliert: {$eventId}");
return;
}
$userAbo = UserAbo::find($userAboId);
$paymentMethodId = is_string($object['payment_method'] ?? null) ? $object['payment_method'] : null;
$customerId = is_string($object['customer'] ?? null) ? $object['customer'] : null;
if (! $userAbo || ! $paymentMethodId || ! $customerId) {
Log::channel('stripe')->warning("SetupIntent nicht anwendbar (Abo/PaymentMethod/Customer fehlt): {$eventId}", [
'user_abo_id' => $userAboId,
'payment_method' => $paymentMethodId,
'customer' => $customerId,
]);
return;
}
$source = trim((string) ($object['metadata']['source'] ?? ''));
app(AboPaymentMethodService::class)->applyStripePaymentMethod(
$userAbo,
$customerId,
$paymentMethodId,
$source !== '' ? $source : 'stripe-webhook',
);
}
/**
@ -206,6 +309,8 @@ class StripeWebhookHandler
return;
}
$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<string, mixed> $object
*/
private function captureOffSessionPaymentMethod(ShoppingPayment $payment, array $object): void
{
$order = $payment->shopping_order;
if (! $order->is_abo && empty($order->recovery_user_abo_id)) {
return;
}
if (! empty($payment->stripe_customer_id) && ! empty($payment->stripe_payment_method_id)) {
return;
}
$customerId = is_string($object['customer'] ?? null) ? $object['customer'] : null;
$paymentMethodId = ($object['object'] ?? null) === 'payment_intent' && is_string($object['payment_method'] ?? null)
? $object['payment_method']
: null;
$paymentIntentId = ($object['object'] ?? null) === 'payment_intent'
? ($object['id'] ?? null)
: ($object['payment_intent'] ?? $payment->external_reference);
if (! $paymentMethodId && $paymentIntentId) {
$storedEvent = StripeWebhookEvent::where('type', 'payment_intent.succeeded')
->where('payload->id', $paymentIntentId)
->latest('id')
->first()
?? StripeWebhookEvent::where('type', 'charge.succeeded')
->where('payload->payment_intent', $paymentIntentId)
->latest('id')
->first();
if ($storedEvent) {
$payload = $storedEvent->payload ?? [];
$paymentMethodId = is_string($payload['payment_method'] ?? null) ? $payload['payment_method'] : null;
$customerId = $customerId ?: (is_string($payload['customer'] ?? null) ? $payload['customer'] : null);
}
}
if ((! $paymentMethodId || ! $customerId) && $paymentIntentId) {
try {
$paymentIntent = app(StripeClientWrapper::class)->client()->paymentIntents->retrieve($paymentIntentId);
$paymentMethodId = $paymentMethodId ?: (is_string($paymentIntent->payment_method) ? $paymentIntent->payment_method : ($paymentIntent->payment_method->id ?? null));
$customerId = $customerId ?: (is_string($paymentIntent->customer) ? $paymentIntent->customer : ($paymentIntent->customer->id ?? null));
} catch (\Throwable $e) {
Log::channel('stripe')->error('PaymentIntent-Nachladen für Abo-Zahlungsmittel fehlgeschlagen: '.$e->getMessage(), [
'shopping_payment_id' => $payment->id,
'payment_intent' => $paymentIntentId,
]);
}
}
if ($customerId || $paymentMethodId) {
$payment->stripe_customer_id = $payment->stripe_customer_id ?: $customerId;
$payment->stripe_payment_method_id = $payment->stripe_payment_method_id ?: $paymentMethodId;
$payment->save();
}
}
/**
* Speichert womit gezahlt wurde (Kartenmarke/letzte 4/Wallet, PayPal, Klarna …)
* aus den payment_method_details des Charge-Objekts in carddata — für die

View file

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