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:
parent
6f21845607
commit
ce4750fd1a
68 changed files with 2856 additions and 236 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue