$shoppingOrder->id, 'payment_provider' => PaymentProvider::Stripe->value, 'clearingtype' => 'stripe', 'reference' => $reference, 'amount' => $amountCents, 'currency' => $currency, 'is_abo' => (bool) $shoppingOrder->is_abo, 'abo_interval' => $shoppingOrder->abo_interval ?? 0, 'identifier' => Util::getUserShopIdentifier(), 'mode' => $shoppingOrder->mode, ]); CheckoutFunnelTracker::initiatedPayment( shoppingUserId: $shoppingUser->id, shoppingOrderId: $shoppingOrder->id, shoppingPaymentId: $shoppingPayment->id, consultantUserId: $shoppingUser->auth_user_id ?? null, paymentMethod: 'stripe', amountCents: $amountCents, ); $metadata = [ 'shopping_order_id' => (string) $shoppingOrder->id, 'shopping_payment_id' => (string) $shoppingPayment->id, 'reference' => $reference, 'mode' => (string) $shoppingOrder->mode, ]; $sessionParams = [ 'mode' => 'payment', 'client_reference_id' => $reference, 'customer_email' => $shoppingUser->billing_email ?: null, 'line_items' => [[ 'quantity' => 1, 'price_data' => [ 'currency' => strtolower($currency), 'unit_amount' => $amountCents, 'product_data' => [ 'name' => __('payment.stripe_order_label', ['reference' => $reference]), ], ], ]], 'metadata' => $metadata, 'payment_intent_data' => ['metadata' => $metadata], 'success_url' => $successUrl, 'cancel_url' => $cancelUrl, ]; 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(); StripeTransactionRecorder::recordFromStripeObject( $shoppingPayment, 'checkout.session.created', $session->toArray(), ); return new StripeCheckoutSessionResult( sessionId: $session->id, url: (string) $session->url, reference: $reference, 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'), }; } }