Stripe-Integration (S0–S3): Einmalzahlungen über Stripe Checkout + Notfall-Go-Live

Wegen PAYONE-Ausfall vorgezogen live geschaltet: alle Einmalzahlungen
(Shop + Salescenter) laufen über Stripe Hosted Checkout, Abos sind bis
Phase S4 eingefroren.

- Provider-Abstraktion: PaymentProvider-Enum, PaymentGatewayInterface,
  PaymentProviderManager (Settings-Schalter, provider-treu je Bestand),
  PayoneGateway als unveränderter Adapter
- StripePaymentService: Checkout Session (eine Gesamtposition, Metadata,
  success/cancel-Redirects), StripeClientWrapper (lazy, Dark Launch),
  StripeCustomerService, StripeTransactionRecorder (client_secret-Redaction)
- Webhook /stripe/webhook: Signaturprüfung, Idempotenz über
  stripe_webhook_events (Crash-Retry-Reprocessing), txaction-Prioritäten,
  Betrags-Guard, lockForUpdate-Paid-Pfad, genau eine Bestätigungsmail
- Zahlungsmethoden-Anzeige aus charge.succeeded (carddata inkl. Wallet,
  auftragsunabhängiges Backfill aus dem Event-Store)
- Checkout: Wartungsmodus mit Admin-Bypass, Abo-Freeze-Hinweis,
  Stripe-Option (Online-Zahlung) statt Zahlartenwahl, Vorkasse ausblendbar
- Abo-Freeze-Guards: Cron user:make_abo_order, Retry, Kartenupdate
- Admin-Settings-Karte Zahlungen (Provider, Wartung, Freeze, Vorkasse)
- Hotfix: 500 bei Vorkasse ohne Clearing-Daten in admin/sales/_detail
  und checkout-final (PAYONE-Ausfall)
- Doku dev/2026-07-08-stripe/ (Plan S0–S8, Konzept, IST-Analyse)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-08 15:56:41 +00:00
parent 671022a28d
commit 6f21845607
48 changed files with 4428 additions and 831 deletions

View file

@ -6,6 +6,7 @@ use App\Cron\UserMakeOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\Incentive\IncentiveTracker;
use App\Services\Payment\PaymentProviderManager;
use Illuminate\Support\Facades\DB;
class AboRetryPaymentService
@ -15,6 +16,13 @@ class AboRetryPaymentService
*/
public function retry(UserAbo $userAbo): array
{
if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) {
return [
'success' => false,
'message' => __('payment.abo_payments_frozen'),
];
}
if ($userAbo->status !== 3) {
return [
'success' => false,

View file

@ -0,0 +1,34 @@
<?php
namespace App\Services\Payment\Contracts;
use App\Enums\PaymentProvider;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\Payment\DTOs\AboChargeResult;
/**
* Gemeinsame Schnittstelle der Zahlungsanbieter (PAYONE, Stripe).
*
* Das Interface wächst phasenweise mit dem Stripe-Plan
* (dev/2026-07-08-stripe/ENTWICKLUNGSPLAN-STRIPE.md): In S3 kommt
* initiateCheckoutPayment(), in S5 setupPaymentMethodUpdate(), in S7 refund()
* hinzu — Methoden werden erst deklariert, wenn ein realer Aufrufer existiert.
*/
interface PaymentGatewayInterface
{
public function provider(): PaymentProvider;
/**
* Wiederkehrende Abo-Abbuchung mit dem beim Anbieter gespeicherten
* Zahlungsmittel (off-session). Betrag in Cent, brutto.
*/
public function chargeAbo(
UserAbo $userAbo,
ShoppingOrder $shoppingOrder,
ShoppingUser $shoppingUser,
int $amountCents,
string $currency,
): AboChargeResult;
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Services\Payment\DTOs;
use App\Enums\PaymentProvider;
use App\Models\ShoppingPayment;
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
/**
* Provider-neutrales Ergebnis einer wiederkehrenden Abo-Abbuchung.
* Vereinheitlicht PayonePaymentResult (und später das Stripe-Pendant),
* damit Cron/Retry nicht pro Anbieter verzweigen müssen.
*/
class AboChargeResult
{
/**
* @param array<string, mixed> $rawResponse
*/
public function __construct(
public readonly PaymentProvider $provider,
public readonly bool $success,
public readonly string $status,
public readonly array $rawResponse,
public readonly string $transactionId = '',
public readonly ?string $errorMessage = null,
public readonly ?string $customerMessage = null,
public readonly ?ShoppingPayment $shoppingPayment = null,
) {}
public static function fromPayoneResult(PayonePaymentResult $result): self
{
return new self(
provider: PaymentProvider::Payone,
success: $result->isApproved(),
status: $result->status,
rawResponse: $result->rawResponse,
transactionId: $result->txid,
errorMessage: $result->errorMessage,
customerMessage: $result->customerMessage,
shoppingPayment: $result->shoppingPayment,
);
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace App\Services\Payment;
use App\Enums\PaymentProvider;
use App\Models\Setting;
use App\Models\ShoppingPayment;
use App\Models\UserAbo;
use App\Services\Payment\Contracts\PaymentGatewayInterface;
/**
* Zentrale Anbieter-Auflösung (dev/2026-07-08-stripe/, Konzept §4):
*
* - Neue Zahlungsvorgänge folgen dem Einstellungs-Schalter (Setting-Slug
* `payment-active-provider`).
* - Bestehende Zahlungen/Abos folgen IMMER ihrer eigenen
* `payment_provider`-Spalte — der Schalter wirkt nie rückwirkend.
* - Unbekannte/fehlende Werte fallen sicher auf PAYONE zurück
* (Bestandsverhalten vor der Stripe-Einführung).
*/
class PaymentProviderManager
{
public const SETTING_SLUG = 'payment-active-provider';
public const SETTING_VORKASSE_HIDDEN = 'payment-vorkasse-hidden';
public const SETTING_ABO_PAYMENTS_FROZEN = 'abo-payments-frozen';
public const SETTING_CHECKOUT_MAINTENANCE = 'checkout-maintenance';
/**
* Anbieter für neue Checkouts, neue Abos und neue Zahlungslinks.
*/
public function providerForNewCheckout(): PaymentProvider
{
$configured = Setting::getContentBySlug(self::SETTING_SLUG);
return PaymentProvider::tryFrom(is_string($configured) ? $configured : '')
?? PaymentProvider::Payone;
}
/**
* Anbieter eines bestehenden Vorgangs — schalterunabhängig.
*/
public function providerFor(UserAbo|ShoppingPayment $subject): PaymentProvider
{
return PaymentProvider::tryFrom((string) $subject->payment_provider)
?? PaymentProvider::Payone;
}
/**
* Sichtbarkeit der Vorkasse im Checkout (N3, 2026-07-08): Vorkasse läuft
* über PAYONE und ist über eine eigene Einstellung ausblendbar —
* unabhängig vom Provider-Schalter. Fehlende Einstellung = sichtbar.
*/
public function isVorkasseVisible(): bool
{
return Setting::getContentBySlug(self::SETTING_VORKASSE_HIDDEN) !== true;
}
/**
* Not-Schalter (2026-07-08, PAYONE-Ausfall): friert ALLE Abo-Zahlungen ein —
* Abo-Checkout, Recovery, Retry und den Abbuchungs-Cron. Einmalzahlungen
* (Stripe) laufen weiter. Wird nach Phase S4 (Abos über Stripe) aufgehoben.
*/
public function aboPaymentsFrozen(): bool
{
return Setting::getContentBySlug(self::SETTING_ABO_PAYMENTS_FROZEN) === true;
}
/**
* Checkout-Wartungsmodus: Nicht-Admins sehen im Checkout nur eine
* "Technische Schwierigkeiten"-Meldung; Admins können normal bestellen.
* Zweck: Stripe im Live-Modus verproben, bevor der Checkout für alle
* wieder öffnet (Go-Live 2026-07-08).
*/
public function checkoutMaintenanceActive(): bool
{
return Setting::getContentBySlug(self::SETTING_CHECKOUT_MAINTENANCE) === true;
}
public function gateway(PaymentProvider $provider): PaymentGatewayInterface
{
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/).'
),
};
}
}

View file

@ -0,0 +1,44 @@
<?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\Payone\PayonePaymentService;
/**
* Dünner Adapter, der den bestehenden PayonePaymentService hinter die
* provider-neutrale Gateway-Schnittstelle stellt. Enthält bewusst KEINE
* eigene Logik — PAYONE-Verhalten bleibt unverändert (Plan S1.3).
*/
class PayoneGateway implements PaymentGatewayInterface
{
public function __construct(private readonly PayonePaymentService $paymentService) {}
public function provider(): PaymentProvider
{
return PaymentProvider::Payone;
}
public function chargeAbo(
UserAbo $userAbo,
ShoppingOrder $shoppingOrder,
ShoppingUser $shoppingUser,
int $amountCents,
string $currency,
): AboChargeResult {
$result = $this->paymentService->chargeAbo(
$userAbo,
$shoppingOrder,
$shoppingUser,
$amountCents,
$currency,
);
return AboChargeResult::fromPayoneResult($result);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Services\Payment\Stripe\DTOs;
use App\Models\ShoppingPayment;
/**
* Ergebnis einer erstellten Stripe Checkout Session (Hosted Page):
* `url` ist das Redirect-Ziel für den Browser.
*/
class StripeCheckoutSessionResult
{
public function __construct(
public readonly string $sessionId,
public readonly string $url,
public readonly string $reference,
public readonly ShoppingPayment $shoppingPayment,
) {}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Services\Payment\Stripe\DTOs;
/**
* Ergebnis der Stripe-Webhook-Verarbeitung. Anders als beim PAYONE-IPN
* (immer "TSOK") steuert der HTTP-Status das Retry-Verhalten von Stripe:
* 2xx = erledigt, alles andere wird erneut zugestellt.
*/
class StripeWebhookResult
{
private function __construct(
public readonly int $statusCode,
public readonly string $state,
public readonly ?string $eventId = null,
public readonly ?string $eventType = null,
public readonly ?string $reason = null,
) {}
/** Signatur ungültig oder Payload kein gültiges Event — kein Retry sinnvoll. */
public static function invalidSignature(string $reason): self
{
return new self(statusCode: 400, state: 'invalid_signature', reason: $reason);
}
/** STRIPE_WEBHOOK_SECRET fehlt — 500, damit Stripe später erneut zustellt. */
public static function notConfigured(): self
{
return new self(statusCode: 500, state: 'not_configured', reason: 'STRIPE_WEBHOOK_SECRET missing');
}
/** Event-ID bereits verarbeitet — Retry wird bestätigt, aber ignoriert. */
public static function duplicate(string $eventId, string $eventType): self
{
return new self(statusCode: 200, state: 'duplicate', eventId: $eventId, eventType: $eventType);
}
/** Gültiges Event eines nicht behandelten Typs — protokolliert, bestätigt. */
public static function ignored(string $eventId, string $eventType): self
{
return new self(statusCode: 200, state: 'ignored', eventId: $eventId, eventType: $eventType);
}
/** Event verarbeitet (Transaktion protokolliert, Folgeaktionen angestoßen). */
public static function processed(string $eventId, string $eventType): self
{
return new self(statusCode: 200, state: 'processed', eventId: $eventId, eventType: $eventType);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Services\Payment\Stripe;
use Stripe\StripeClient;
/**
* Dünne, injizierbare Hülle um den Stripe-SDK-Client.
*
* Hier sitzt der bewusst aus config/stripe.php verlagerte Hard-Fail
* (Plan S0.4-Abweichung): Ein Deployment ohne Stripe-Keys bootet normal
* (Dark Launch); erst die tatsächliche Nutzung ohne STRIPE_SECRET
* schlägt hart und diagnostizierbar fehl.
*/
class StripeClientWrapper
{
private ?StripeClient $client = null;
public function __construct(private readonly ?string $secret) {}
public function client(): StripeClient
{
if ($this->client === null) {
if ($this->secret === null || trim($this->secret) === '') {
throw new \RuntimeException(
'STRIPE_SECRET ist nicht gesetzt. Hinterlege den Stripe Secret Key (sk_…) in der .env, bevor Stripe als Zahlungsanbieter genutzt wird.'
);
}
$this->client = new StripeClient($this->secret);
}
return $this->client;
}
public function isConfigured(): bool
{
return $this->secret !== null && trim($this->secret) !== '';
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Models\Customer;
use App\User;
/**
* Legt pro User/Kunde genau einen Stripe-Customer an und persistiert die
* ID (users.stripe_customer_id / customers.stripe_customer_id). Idempotent:
* eine vorhandene ID wird nie überschrieben.
*/
class StripeCustomerService
{
public function __construct(private readonly StripeClientWrapper $stripe) {}
public function customerIdFor(User|Customer $owner): string
{
if (! empty($owner->stripe_customer_id)) {
return $owner->stripe_customer_id;
}
$customer = $this->stripe->client()->customers->create([
'email' => $owner->email,
'name' => trim(($owner->firstname ?? '').' '.($owner->lastname ?? '')) ?: null,
'metadata' => [
'mivita_type' => $owner instanceof Customer ? 'customer' : 'user',
'mivita_id' => (string) $owner->getKey(),
],
]);
$owner->forceFill(['stripe_customer_id' => $customer->id])->save();
return $customer->id;
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Enums\PaymentProvider;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Services\CheckoutFunnelTracker;
use App\Services\Payment\Stripe\DTOs\StripeCheckoutSessionResult;
use App\Services\Util;
/**
* Stripe-Zahlungsvorgänge (Plan dev/2026-07-08-stripe/, Phase S3 ff.).
*
* Einmalzahlung im Checkout = Hosted Checkout Session (Redirect-Flow,
* Konzept §3.1): Zahlartenwahl und Dateneingabe passieren bei Stripe,
* "bezahlt" setzt ausschließlich der Webhook (StripeWebhookHandler).
*/
class StripePaymentService
{
public function __construct(private readonly StripeClientWrapper $stripe) {}
/**
* 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).
*/
public function initiateCheckoutPayment(
ShoppingUser $shoppingUser,
ShoppingOrder $shoppingOrder,
int $amountCents,
string $currency,
string $reference,
string $successUrl,
string $cancelUrl,
): StripeCheckoutSessionResult {
$shoppingPayment = ShoppingPayment::create([
'shopping_order_id' => $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,
];
$session = $this->stripe->client()->checkout->sessions->create([
'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,
]);
$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,
);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Enums\PaymentProvider;
use App\Models\PaymentTransaction;
use App\Models\ShoppingPayment;
/**
* Einzige Stelle, die PaymentTransaction-Zeilen für Stripe schreibt
* (Muster: PayoneTransactionRecorder). Redaction entfernt Secrets aus
* dem Payload, bevor er in transmitted_data landet.
*/
class StripeTransactionRecorder
{
/**
* Redaktierte Schlüssel: client_secret (PaymentIntent/SetupIntent) reicht
* aus, um eine Zahlung im Browser zu übernehmen und gehört nie in die DB.
*/
private const REDACTED_KEYS = ['client_secret'];
/**
* Protokolliert ein Stripe-Objekt (PaymentIntent, Charge, SetupIntent …)
* aus einem Webhook-Event oder einer API-Response zu einer Zahlung.
*
* @param array<string, mixed> $object Stripe-Objekt als Array (data.object)
*/
public static function recordFromStripeObject(
ShoppingPayment $payment,
string $requestType,
array $object,
): PaymentTransaction {
return PaymentTransaction::create([
'shopping_payment_id' => $payment->id,
'provider' => PaymentProvider::Stripe->value,
'request' => $requestType,
'txid' => (string) ($object['id'] ?? ''),
'userid' => 0,
'status' => (string) ($object['status'] ?? ''),
'txaction' => $requestType,
'transmitted_data' => self::redactPayload($object),
'mode' => array_key_exists('livemode', $object)
? ($object['livemode'] ? 'live' : 'test')
: $payment->mode,
'errorcode' => $object['last_payment_error']['code'] ?? null,
'errormessage' => $object['last_payment_error']['message'] ?? null,
]);
}
/**
* Entfernt sensible Felder rekursiv aus einem Stripe-Payload.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public static function redactPayload(array $payload): array
{
foreach ($payload as $key => $value) {
if (in_array($key, self::REDACTED_KEYS, true)) {
unset($payload[$key]);
continue;
}
if (is_array($value)) {
$payload[$key] = self::redactPayload($value);
}
}
return $payload;
}
}

View file

@ -0,0 +1,423 @@
<?php
namespace App\Services\Payment\Stripe;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\StripeWebhookEvent;
use App\Services\CheckoutFunnelTracker;
use App\Services\Payment;
use App\Services\Payment\Stripe\DTOs\StripeWebhookResult;
use App\Services\ShoppingUserService;
use App\Services\Util;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Event;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
/**
* Verarbeitet Stripe-Webhooks: Signaturprüfung, Idempotenz über die
* Event-ID (stripe_webhook_events), Transaktions-Protokollierung und der
* Paid-Pfad. Muster: PayoneWebhookHandler (txaction-Priorität, DB-Lock,
* zentrale Persistenz, Betragsabgleich).
*/
class StripeWebhookHandler
{
private const HANDLED_TYPES = [
'checkout.session.completed',
'checkout.session.expired',
'payment_intent.succeeded',
'payment_intent.processing',
'payment_intent.payment_failed',
'charge.succeeded',
'charge.refunded',
'charge.dispute.created',
'setup_intent.succeeded',
];
/**
* Gleiche txaction-Taxonomie wie PAYONE, damit alle nachgelagerten
* Ansichten/Services (Order-Status, Abo-Logik) unverändert funktionieren.
*
* @var array<string, int>
*/
private const TXACTION_PRIORITY = [
'appointed' => 1,
'pending' => 2,
'failed' => 3,
'paid' => 10,
];
public function handle(string $payload, ?string $signatureHeader): StripeWebhookResult
{
$secret = (string) config('stripe.webhook_secret');
if (trim($secret) === '') {
Log::channel('stripe')->error('Stripe-Webhook empfangen, aber STRIPE_WEBHOOK_SECRET ist nicht gesetzt.');
return StripeWebhookResult::notConfigured();
}
try {
$event = Webhook::constructEvent($payload, (string) $signatureHeader, $secret);
} catch (SignatureVerificationException|\UnexpectedValueException $e) {
Log::channel('stripe')->warning('Stripe-Webhook abgewiesen (Signatur/Payload): '.$e->getMessage());
return StripeWebhookResult::invalidSignature($e->getMessage());
}
/*
* Idempotenz: Ein Event gilt als Duplikat, wenn es bereits VOLLSTÄNDIG
* verarbeitet wurde (processed_at) oder sein Typ keine Verarbeitung braucht.
* Ein gespeichertes, aber unverarbeitetes Event (Crash mitten in der
* Verarbeitung → non-2xx → Stripe-Retry) wird erneut verarbeitet.
*/
$storedEvent = StripeWebhookEvent::where('event_id', $event->id)->first();
if ($storedEvent && ($storedEvent->processed_at !== null || ! in_array($event->type, self::HANDLED_TYPES, true))) {
Log::channel('stripe')->info("Stripe-Webhook-Duplikat ignoriert: {$event->id} ({$event->type})");
return StripeWebhookResult::duplicate($event->id, $event->type);
}
$object = $event->data->object->toArray();
if (! $storedEvent) {
try {
$storedEvent = StripeWebhookEvent::create([
'event_id' => $event->id,
'type' => $event->type,
'api_version' => $event->api_version,
'payload' => StripeTransactionRecorder::redactPayload($object),
]);
} catch (QueryException) {
// Unique-Verletzung: paralleler Retry hat das Event gerade angelegt.
return StripeWebhookResult::duplicate($event->id, $event->type);
}
}
if (! in_array($event->type, self::HANDLED_TYPES, true)) {
Log::channel('stripe')->info("Stripe-Webhook protokolliert, Typ nicht behandelt: {$event->id} ({$event->type})");
return StripeWebhookResult::ignored($event->id, $event->type);
}
$this->dispatchEvent($event, $object);
$storedEvent->update(['processed_at' => now()]);
return StripeWebhookResult::processed($event->id, $event->type);
}
/**
* @param array<string, mixed> $object
*/
private function dispatchEvent(Event $event, array $object): void
{
$payment = $this->resolveShoppingPayment($object);
if (! $payment) {
Log::channel('stripe')->warning(sprintf(
'Stripe-Webhook ohne zuordenbare Zahlung: %s (%s, object %s)',
$event->id,
$event->type,
$object['id'] ?? '?',
));
return;
}
StripeTransactionRecorder::recordFromStripeObject($payment, $event->type, $object);
$this->rememberPaymentIntentReference($payment, $object);
match ($event->type) {
'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}"),
};
}
/**
* Merkt sich die PaymentIntent-ID an der Zahlung, sobald sie bekannt ist.
* Charge-Events tragen keine Metadata — sie werden über
* external_reference = PaymentIntent-ID zugeordnet.
*
* @param array<string, mixed> $object
*/
private function rememberPaymentIntentReference(ShoppingPayment $payment, array $object): void
{
$paymentIntentId = match ($object['object'] ?? null) {
'payment_intent' => $object['id'] ?? null,
default => $object['payment_intent'] ?? null,
};
if ($paymentIntentId && $payment->external_reference !== $paymentIntentId) {
$payment->external_reference = $paymentIntentId;
$payment->save();
}
}
/**
* Session abgeschlossen: bei `paid` sofort der Paid-Pfad; bei asynchronen
* Methoden (SEPA & Co., payment_status=unpaid) ist die Bestellung platziert
* und wartet auf payment_intent.succeeded → Status "appointed" wie PAYONE.
*
* @param array<string, mixed> $object
*/
private function handleSessionCompleted(ShoppingPayment $payment, array $object): void
{
if (($object['payment_status'] ?? null) === 'paid') {
$this->markPaid($payment, $object);
return;
}
$order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'appointed');
$order->setUserHistoryValue(['status' => 7]);
ShoppingUserService::snycOrdersByShoppingOrder($order);
Util::setInstanceStatusByPayment($payment, 4);
CheckoutFunnelTracker::confirmedPayment(
shoppingPaymentId: $payment->id,
txaction: 'appointed',
metadata: ['provider' => 'stripe', 'session' => $object['id'] ?? null],
);
Payment::paymentStatusSendMail($order, $payment, [
'txaction' => 'appointed',
'mode' => $payment->mode,
'send_link' => false,
]);
}
/**
* @param array<string, mixed> $object
*/
private function markPaid(ShoppingPayment $payment, array $object): void
{
if (! $this->amountMatches($payment, $object)) {
return;
}
$order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'paid');
$sendLink = false;
$sendMail = true;
DB::beginTransaction();
try {
$lockedOrder = ShoppingOrder::where('id', $order->id)->lockForUpdate()->first();
if (! $lockedOrder->paid) {
$sendLink = Payment::paymentStatusPaidAction($lockedOrder, true, $payment);
DB::commit();
} else {
$sendMail = false;
DB::commit();
}
} catch (\Exception $e) {
DB::rollBack();
Log::channel('stripe')->error('Stripe-Paid-Pfad fehlgeschlagen (Stripe stellt erneut zu): '.$e->getMessage(), [
'shopping_order_id' => $order->id,
'shopping_payment_id' => $payment->id,
]);
throw $e;
}
CheckoutFunnelTracker::confirmedPayment(
shoppingPaymentId: $payment->id,
txaction: 'paid',
metadata: ['provider' => 'stripe', 'object' => $object['id'] ?? null, 'mode' => $payment->mode],
);
if ($sendMail) {
Payment::paymentStatusSendMail($order, $payment, [
'txaction' => 'paid',
'mode' => $payment->mode,
'send_link' => $sendLink,
]);
}
$this->backfillPaymentMethodDetails($payment);
}
/**
* Speichert womit gezahlt wurde (Kartenmarke/letzte 4/Wallet, PayPal, Klarna …)
* aus den payment_method_details des Charge-Objekts in carddata — für die
* Anzeige in Bestellungen/Mails (ShoppingPayment::getPaymentType()).
*
* @param array<string, mixed> $chargeObject
*/
private function storePaymentMethodDetails(ShoppingPayment $payment, array $chargeObject): void
{
$summary = self::paymentMethodSummary($chargeObject);
if ($summary !== null && empty($payment->carddata)) {
$payment->carddata = $summary;
$payment->save();
}
}
/**
* Webhook-Reihenfolge ist nicht garantiert: Kam charge.succeeded VOR dem
* PaymentIntent-Event, war es noch keiner Zahlung zuordenbar — es liegt
* aber im Event-Store. Von dort nachziehen, ganz ohne API-Call.
*/
private function backfillPaymentMethodDetails(ShoppingPayment $payment): void
{
if (! empty($payment->carddata) || empty($payment->external_reference)) {
return;
}
$chargeEvent = StripeWebhookEvent::where('type', 'charge.succeeded')
->where('payload->payment_intent', $payment->external_reference)
->latest('id')
->first();
if ($chargeEvent) {
$this->storePaymentMethodDetails($payment, $chargeEvent->payload ?? []);
}
}
/**
* @param array<string, mixed> $chargeObject
* @return array<string, mixed>|null
*/
public static function paymentMethodSummary(array $chargeObject): ?array
{
$details = $chargeObject['payment_method_details'] ?? null;
$type = $details['type'] ?? null;
if (! $type) {
return null;
}
$summary = ['type' => $type];
if ($type === 'card') {
$summary['brand'] = $details['card']['brand'] ?? null;
$summary['last4'] = $details['card']['last4'] ?? null;
$summary['wallet'] = $details['card']['wallet']['type'] ?? null;
$summary['exp'] = isset($details['card']['exp_month'], $details['card']['exp_year'])
? sprintf('%02d/%d', $details['card']['exp_month'], $details['card']['exp_year'])
: null;
}
if ($type === 'sepa_debit') {
$summary['last4'] = $details['sepa_debit']['last4'] ?? null;
}
return $summary;
}
/**
* Asynchrone Zahlung unterwegs (SEPA & Co.). Kein Mailversand — die
* Bestellbestätigung ging bereits bei checkout.session.completed raus.
*/
private function markPending(ShoppingPayment $payment): void
{
$this->upgradeTxaction($payment->shopping_order, $payment, 'pending');
}
/**
* Fehlgeschlagener Zahlungsversuch. Bewusst KEINE Mail: Auf der Hosted
* Checkout Session kann der Kunde direkt erneut zahlen — jeder Fehlversuch
* feuert dieses Event, eine Mail pro Versuch wäre Spam. Endgültiges
* Scheitern zeigt sich als checkout.session.expired bzw. Cancel-Return.
*/
private function markFailed(ShoppingPayment $payment): void
{
$order = $payment->shopping_order;
$this->upgradeTxaction($order, $payment, 'failed');
$order->setUserHistoryValue(['status' => 6]);
Util::setInstanceStatusByPayment($payment, 5);
}
/**
* Session nach 24 h abgelaufen, ohne Zahlung: Vorgang als abgebrochen
* markieren (wie Cancel-Return), Bestellstatus bleibt unangetastet.
*/
private function handleSessionExpired(ShoppingPayment $payment): void
{
if ($payment->shopping_order->paid) {
return;
}
$payment->status = 'expired';
$payment->save();
$payment->shopping_order->setUserHistoryValue(['status' => 22]);
Util::setInstanceStatusByPayment($payment, 6);
}
/**
* Betragsabgleich (Pendant zum PAYONE-Guard 2006): Der bestätigte Betrag
* muss dem initiierten entsprechen, sonst keine Paid-Verarbeitung.
*
* @param array<string, mixed> $object
*/
private function amountMatches(ShoppingPayment $payment, array $object): bool
{
$confirmed = $object['amount_total'] ?? $object['amount_received'] ?? $object['amount'] ?? null;
if ($confirmed === null || (int) $confirmed !== (int) $payment->amount) {
Log::channel('stripe')->error('Stripe-Webhook Betragsabweichung — Paid-Verarbeitung verweigert.', [
'shopping_payment_id' => $payment->id,
'expected_cents' => (int) $payment->amount,
'confirmed_cents' => $confirmed,
'object' => $object['id'] ?? null,
]);
return false;
}
return true;
}
private function upgradeTxaction(ShoppingOrder $order, ShoppingPayment $payment, string $txaction): void
{
$currentPriority = self::TXACTION_PRIORITY[$order->txaction] ?? 0;
$newPriority = self::TXACTION_PRIORITY[$txaction] ?? 0;
if ($newPriority > $currentPriority) {
$order->txaction = $txaction;
$order->save();
$payment->txaction = $txaction;
$payment->save();
}
}
/**
* Ordnet ein Stripe-Objekt einer Zahlung zu: über die von uns gesetzte
* Metadata (shopping_payment_id, Session UND PaymentIntent tragen sie)
* oder über external_reference (Session-/PaymentIntent-ID).
*
* @param array<string, mixed> $object
*/
private function resolveShoppingPayment(array $object): ?ShoppingPayment
{
$metadataId = $object['metadata']['shopping_payment_id'] ?? null;
if ($metadataId && ($payment = ShoppingPayment::find((int) $metadataId))) {
return $payment;
}
$references = array_filter([
$object['id'] ?? null,
$object['payment_intent'] ?? null,
]);
foreach ($references as $reference) {
$payment = ShoppingPayment::where('external_reference', $reference)->first();
if ($payment) {
return $payment;
}
}
return null;
}
}