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>
593 lines
22 KiB
PHP
593 lines
22 KiB
PHP
<?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;
|
||
}
|
||
}
|