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:
parent
671022a28d
commit
6f21845607
48 changed files with 4428 additions and 831 deletions
|
|
@ -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,
|
||||
|
|
|
|||
34
app/Services/Payment/Contracts/PaymentGatewayInterface.php
Normal file
34
app/Services/Payment/Contracts/PaymentGatewayInterface.php
Normal 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;
|
||||
}
|
||||
43
app/Services/Payment/DTOs/AboChargeResult.php
Normal file
43
app/Services/Payment/DTOs/AboChargeResult.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
91
app/Services/Payment/PaymentProviderManager.php
Normal file
91
app/Services/Payment/PaymentProviderManager.php
Normal 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/).'
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
44
app/Services/Payment/PayoneGateway.php
Normal file
44
app/Services/Payment/PayoneGateway.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
49
app/Services/Payment/Stripe/DTOs/StripeWebhookResult.php
Normal file
49
app/Services/Payment/Stripe/DTOs/StripeWebhookResult.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
app/Services/Payment/Stripe/StripeClientWrapper.php
Normal file
39
app/Services/Payment/Stripe/StripeClientWrapper.php
Normal 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) !== '';
|
||||
}
|
||||
}
|
||||
36
app/Services/Payment/Stripe/StripeCustomerService.php
Normal file
36
app/Services/Payment/Stripe/StripeCustomerService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
103
app/Services/Payment/Stripe/StripePaymentService.php
Normal file
103
app/Services/Payment/Stripe/StripePaymentService.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/Services/Payment/Stripe/StripeTransactionRecorder.php
Normal file
71
app/Services/Payment/Stripe/StripeTransactionRecorder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
423
app/Services/Payment/Stripe/StripeWebhookHandler.php
Normal file
423
app/Services/Payment/Stripe/StripeWebhookHandler.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue