mivita/app/Services/Payment/Stripe/StripeWebhookHandler.php
Kevin ce4750fd1a 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>
2026-07-09 14:45:17 +00:00

593 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
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;
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
{
public 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);
}
if (! $this->dispatchEvent($event->id, $event->type, $object)) {
return StripeWebhookResult::paymentNotYetVisible($event->id, $event->type);
}
$storedEvent->update(['processed_at' => now()]);
return StripeWebhookResult::processed($event->id, $event->type);
}
/**
* 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
*/
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)',
$eventId,
$eventType,
$object['id'] ?? '?',
));
return true;
}
StripeTransactionRecorder::recordFromStripeObject($payment, $eventType, $object);
$this->rememberPaymentIntentReference($payment, $object);
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 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',
);
}
/**
* 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;
}
$this->captureOffSessionPaymentMethod($payment, $object);
$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);
}
/**
* 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
* 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;
}
}