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>
296 lines
12 KiB
PHP
296 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\PaymentProvider;
|
|
use App\Models\UserAbo;
|
|
use App\Services\Payment\PaymentProviderManager;
|
|
use App\Services\Payment\Payone\DTOs\CardUpdateResult;
|
|
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
|
|
use App\Services\Payment\Payone\PayonePaymentService;
|
|
use App\Services\Payment\Stripe\StripePaymentService;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Orchestrates updating the payment method of an existing Abo. The card data /
|
|
* payment method is chosen exclusively by the Abo owner; guard-/owner-specific
|
|
* authorization happens in the calling controller, the invariant in
|
|
* {@see UserAbo::canUpdatePaymentMethod()}.
|
|
*
|
|
* Two flows:
|
|
* - PAYONE (legacy, CC only): creditcardcheck + preauthorization, no charge
|
|
* (Phase-1 plan dev/2026-06-25-kreditkarte-update/).
|
|
* - Stripe (Plan S5, dev/2026-07-08-stripe/): hosted Checkout Session in
|
|
* setup mode — free choice of any reusable payment method, no charge.
|
|
* Applies to PAYONE legacy abos too: changing the payment method moves the
|
|
* abo to Stripe (§7.2 no. 3, soft migration).
|
|
*/
|
|
class AboPaymentMethodService
|
|
{
|
|
public function __construct(private readonly PayonePaymentService $payone) {}
|
|
|
|
/**
|
|
* Der Stripe-Setup-Flow gilt, sobald der Anbieter-Schalter auf Stripe
|
|
* steht — unabhängig vom Provider des Abos (PAYONE-Abos ziehen beim
|
|
* Wechsel auf Stripe um).
|
|
*/
|
|
public function usesStripeChange(): bool
|
|
{
|
|
return app(PaymentProviderManager::class)->providerForNewCheckout() === PaymentProvider::Stripe;
|
|
}
|
|
|
|
/**
|
|
* Zentrale Einstiegs-Prüfung für Controller/Views: Im Stripe-Setup-Flow
|
|
* ist jede Zahlart wechselbar, im PAYONE-Legacy-Flow nur Kreditkarten.
|
|
*/
|
|
public function canStartChange(UserAbo $abo): bool
|
|
{
|
|
return $this->usesStripeChange()
|
|
? $abo->canChangePaymentMethodAnyType()
|
|
: $abo->canUpdatePaymentMethod();
|
|
}
|
|
|
|
/**
|
|
* Runs the card update. On success the new token is written atomically; a 3-D Secure
|
|
* challenge is surfaced as a REDIRECT outcome (finalized after return via
|
|
* {@see applyNewCard()}); failures leave the stored card untouched.
|
|
*
|
|
* @param array<string, string> $returnUrls successurl/errorurl/backurl for the 3DS redirect.
|
|
*/
|
|
public function updateCreditCard(UserAbo $abo, CreditCardCheckData $card, array $returnUrls, ?string $source = null): CardUpdateResult
|
|
{
|
|
if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) {
|
|
return CardUpdateResult::error(__('payment.abo_payments_frozen'));
|
|
}
|
|
|
|
if (! $abo->canUpdatePaymentMethod()) {
|
|
return CardUpdateResult::error(__('abo.card_update_not_allowed'));
|
|
}
|
|
|
|
$shoppingUser = $abo->shopping_user;
|
|
if (! $shoppingUser) {
|
|
return CardUpdateResult::error(__('abo.card_update_failed'));
|
|
}
|
|
|
|
$mode = $this->resolveMode($abo);
|
|
|
|
// Konsistenz-Spur: vor externem PAYONE-Call + DB-Write setzen, damit ein Crash
|
|
// zwischen erfolgreichem PAYONE-Call und DB-Write auffindbar bleibt.
|
|
$abo->forceFill(['card_update_pending_at' => now()])->save();
|
|
|
|
$result = $this->payone->refreshAboCardToken($shoppingUser, $card, $returnUrls, $mode);
|
|
|
|
if ($result->isApproved()) {
|
|
$this->applyNewCard($abo, $result->carddata, $result->userid, $source);
|
|
// 1-Cent-Reservierung freigeben; Fehler hier ist unkritisch (verfällt sonst).
|
|
$this->safelyReleasePreauth($result->txid, $mode);
|
|
|
|
return $result;
|
|
}
|
|
|
|
if ($result->isRedirect()) {
|
|
// Pending-Spur bleibt bestehen; Finalisierung nach 3DS-Rückkehr (Controller).
|
|
return $result;
|
|
}
|
|
|
|
// Fehler: Spur zurücknehmen, kein DB-Update.
|
|
$abo->forceFill(['card_update_pending_at' => null])->save();
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Writes the new card token atomically and idempotently and clears the pending trace.
|
|
* Also used by the 3-D-Secure return path after successful authentication.
|
|
*
|
|
* @param array<string, mixed> $carddata
|
|
*/
|
|
public function applyNewCard(UserAbo $abo, array $carddata, int $userid, ?string $source = null): void
|
|
{
|
|
try {
|
|
DB::transaction(function () use ($abo, $carddata, $userid, $source) {
|
|
$abo->forceFill([
|
|
'carddata' => $carddata,
|
|
'payone_userid' => $userid,
|
|
'card_update_pending_at' => null,
|
|
])->save();
|
|
|
|
$abo->card_changes()->create([
|
|
'masked_card' => UserAbo::formatMaskedCard($carddata),
|
|
'source' => $source,
|
|
]);
|
|
});
|
|
} catch (Throwable $e) {
|
|
// PAYONE hat die neue Karte, der lokale DB-Write schlug fehl → Inkonsistenz:
|
|
// card_update_pending_at bleibt gesetzt (auffindbare Spur), CRITICAL-Alert.
|
|
MyLog::writeLog(
|
|
'payment',
|
|
'critical',
|
|
'Kreditkarten-Update: DB-Write nach erfolgreichem PAYONE-Call fehlgeschlagen (Abo '.$abo->id.')',
|
|
['user_abo_id' => $abo->id, 'payone_userid' => $userid, 'exception' => $e->getMessage()],
|
|
);
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Startet den Stripe-Setup-Flow (Plan S5.1/S5.3): Pending-Spur setzen,
|
|
* Setup-Session erstellen, Redirect-URL zurückgeben. Kein Charge.
|
|
*
|
|
* @param string $successUrl Muss den Platzhalter {CHECKOUT_SESSION_ID} enthalten.
|
|
*/
|
|
public function beginStripeChange(UserAbo $abo, string $successUrl, string $cancelUrl, ?string $source = null): CardUpdateResult
|
|
{
|
|
if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) {
|
|
return CardUpdateResult::error(__('payment.abo_payments_frozen'));
|
|
}
|
|
|
|
if (! $abo->canChangePaymentMethodAnyType()) {
|
|
return CardUpdateResult::error(__('abo.card_update_not_allowed'));
|
|
}
|
|
|
|
// Konsistenz-Spur wie beim PAYONE-Flow: vor dem externen Call setzen.
|
|
$abo->forceFill(['card_update_pending_at' => now()])->save();
|
|
|
|
try {
|
|
$session = app(StripePaymentService::class)->createPaymentMethodChangeSession(
|
|
$abo,
|
|
$successUrl,
|
|
$cancelUrl,
|
|
$source,
|
|
);
|
|
} catch (Throwable $e) {
|
|
$abo->forceFill(['card_update_pending_at' => null])->save();
|
|
\Log::channel('stripe')->error('Zahlungsart-Wechsel: Setup-Session konnte nicht erstellt werden: '.$e->getMessage(), [
|
|
'user_abo_id' => $abo->id,
|
|
]);
|
|
|
|
return CardUpdateResult::error(__('payment.stripe_unavailable'));
|
|
}
|
|
|
|
return CardUpdateResult::redirect((string) $session->url, [], '');
|
|
}
|
|
|
|
/**
|
|
* Return-Pfad des Setup-Flows: Session nachladen, SetupIntent prüfen und
|
|
* die neue Zahlungsart verankern. Der setup_intent.succeeded-Webhook macht
|
|
* dasselbe als Sicherheitsnetz — applyStripePaymentMethod() ist idempotent.
|
|
*/
|
|
public function finalizeStripeChangeFromSession(UserAbo $abo, string $sessionId, ?string $source = null): bool
|
|
{
|
|
try {
|
|
$session = app(StripePaymentService::class)->retrieveSetupSession($sessionId);
|
|
} catch (Throwable $e) {
|
|
\Log::channel('stripe')->error('Zahlungsart-Wechsel: Setup-Session nicht abrufbar: '.$e->getMessage(), [
|
|
'user_abo_id' => $abo->id,
|
|
'session_id' => $sessionId,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
|
|
// Session muss zu genau diesem Abo gehören (kein fremdes session_id-Query-Param).
|
|
if ((string) ($session->metadata['user_abo_id'] ?? '') !== (string) $abo->id) {
|
|
return false;
|
|
}
|
|
|
|
$setupIntent = $session->setup_intent;
|
|
if (! $setupIntent || $setupIntent->status !== 'succeeded' || empty($setupIntent->payment_method)) {
|
|
$abo->forceFill(['card_update_pending_at' => null])->save();
|
|
|
|
return false;
|
|
}
|
|
|
|
$paymentMethodId = is_string($setupIntent->payment_method)
|
|
? $setupIntent->payment_method
|
|
: $setupIntent->payment_method->id;
|
|
$customerId = is_string($session->customer) ? $session->customer : $session->customer->id;
|
|
|
|
$this->applyStripePaymentMethod($abo, $customerId, $paymentMethodId, $source);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Verankert die neue Stripe-Zahlungsart atomar und idempotent am Abo und
|
|
* zieht PAYONE-Abos dabei auf Stripe um (§7.2 Nr. 3). Wird vom Return-Pfad
|
|
* UND vom setup_intent.succeeded-Webhook aufgerufen.
|
|
*/
|
|
public function applyStripePaymentMethod(UserAbo $abo, string $customerId, string $paymentMethodId, ?string $source = null): void
|
|
{
|
|
// Idempotenz: Webhook und Return-Pfad laufen beide — nur einmal verankern.
|
|
if ($abo->stripe_payment_method_id === $paymentMethodId) {
|
|
if ($abo->card_update_pending_at !== null) {
|
|
$abo->forceFill(['card_update_pending_at' => null])->save();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$previousPaymentMethodId = $abo->payment_provider === PaymentProvider::Stripe->value
|
|
? $abo->stripe_payment_method_id
|
|
: null;
|
|
|
|
$summary = app(StripePaymentService::class)->retrievePaymentMethodSummary($paymentMethodId);
|
|
|
|
try {
|
|
DB::transaction(function () use ($abo, $customerId, $paymentMethodId, $summary, $source) {
|
|
$abo->forceFill([
|
|
'payment_provider' => PaymentProvider::Stripe->value,
|
|
'stripe_customer_id' => $customerId,
|
|
'stripe_payment_method_id' => $paymentMethodId,
|
|
'clearingtype' => 'stripe',
|
|
'wallettype' => null,
|
|
'carddata' => $summary,
|
|
'card_update_pending_at' => null,
|
|
])->save();
|
|
|
|
$abo->card_changes()->create([
|
|
'masked_card' => UserAbo::formatMaskedCard($summary),
|
|
'source' => $source,
|
|
]);
|
|
});
|
|
} catch (Throwable $e) {
|
|
// Stripe hat die neue Zahlungsart, der lokale DB-Write schlug fehl → Inkonsistenz:
|
|
// card_update_pending_at bleibt gesetzt (auffindbare Spur), CRITICAL-Alert.
|
|
MyLog::writeLog(
|
|
'payment',
|
|
'critical',
|
|
'Zahlungsart-Wechsel (Stripe): DB-Write nach erfolgreichem Setup fehlgeschlagen (Abo '.$abo->id.')',
|
|
['user_abo_id' => $abo->id, 'payment_method' => $paymentMethodId, 'exception' => $e->getMessage()],
|
|
);
|
|
|
|
throw $e;
|
|
}
|
|
|
|
if ($previousPaymentMethodId && $previousPaymentMethodId !== $paymentMethodId) {
|
|
app(StripePaymentService::class)->detachPaymentMethod($previousPaymentMethodId);
|
|
}
|
|
}
|
|
|
|
private function safelyReleasePreauth(string $txid, string $mode): void
|
|
{
|
|
if ($txid === '') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->payone->releasePreauth($txid, $mode);
|
|
} catch (Throwable $e) {
|
|
MyLog::writeLog(
|
|
'payone',
|
|
'warning',
|
|
'Preauth-Freigabe (capture amount=0) fehlgeschlagen, Reservierung verfällt',
|
|
['txid' => $txid, 'error' => $e->getMessage()],
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
|
|
private function resolveMode(UserAbo $abo): string
|
|
{
|
|
return $abo->initialShoppingPayment()?->mode ?: config('payone.defaults.mode');
|
|
}
|
|
}
|