mivita/app/Http/Controllers/Web/AboCardUpdateController.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

128 lines
4.6 KiB
PHP

<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Models\AboCardUpdateLink;
use App\Services\AboPaymentMethodService;
/**
* Login-free credit-card update via a tokenized link (sent by a consultant to a customer).
* Card data is entered exclusively by the customer here; routes are rate-limited. The token
* is looked up by its SHA-256 hash and is single-use + TTL-bound.
*/
class AboCardUpdateController extends Controller
{
public function show(string $token)
{
$link = $this->resolveRedeemableLink($token);
return view('portal.abo.card_update_link', [
'user_abo' => $link->user_abo,
'token' => $token,
]);
}
public function store(UpdateAboCreditCardRequest $request, string $token, AboPaymentMethodService $service)
{
$link = $this->resolveRedeemableLink($token);
$user_abo = $link->user_abo;
$returnUrls = [
'successurl' => route('abo_card_update_return', [$token, 'success']),
'errorurl' => route('abo_card_update_return', [$token, 'error']),
'backurl' => route('abo_card_update_return', [$token, 'cancel']),
];
$result = $service->updateCreditCard($user_abo, $request->toDto(), $returnUrls, 'link');
if ($result->isApproved()) {
$link->forceFill(['used_at' => now()])->save();
return view('portal.abo.card_update_done', ['success' => true]);
}
if ($result->isRedirect()) {
session(['abo_card_update.'.$user_abo->id => [
'carddata' => $result->carddata,
'userid' => $result->userid,
]]);
return redirect()->away($result->redirectUrl);
}
return redirect(route('abo_card_update_show', [$token]))
->withErrors(['cc_cardpan' => $result->errorMessage ?? __('abo.card_update_failed')]);
}
/**
* Startet den Stripe-Setup-Flow "Zahlungsart ändern" (Plan S5) über den
* tokenisierten Link: Redirect zur Hosted-Seite, freie Zahlartenwahl.
*/
public function startStripe(string $token, AboPaymentMethodService $service)
{
$link = $this->resolveRedeemableLink($token);
abort_unless($service->usesStripeChange(), 404);
$result = $service->beginStripeChange(
$link->user_abo,
route('abo_card_update_return', [$token, 'success']).'?session_id={CHECKOUT_SESSION_ID}',
route('abo_card_update_return', [$token, 'cancel']),
'link',
);
if ($result->isRedirect()) {
return redirect()->away($result->redirectUrl);
}
return redirect(route('abo_card_update_show', [$token]))
->withErrors(['cc_cardpan' => $result->errorMessage ?? __('abo.payment_method_change_failed')]);
}
public function return(string $token, string $status, AboPaymentMethodService $service)
{
$link = $this->resolveRedeemableLink($token);
$user_abo = $link->user_abo;
// Stripe-Setup-Return (Plan S5): Session prüfen, Zahlungsart verankern, Link entwerten.
if ($status === 'success' && ($sessionId = request('session_id'))) {
if ($service->finalizeStripeChangeFromSession($user_abo, (string) $sessionId, 'link')) {
$link->forceFill(['used_at' => now()])->save();
return view('portal.abo.card_update_done', ['success' => true]);
}
return view('portal.abo.card_update_done', ['success' => false]);
}
$stash = session()->pull('abo_card_update.'.$user_abo->id);
if ($status === 'success' && is_array($stash)) {
$service->applyNewCard($user_abo, $stash['carddata'], (int) ($stash['userid'] ?? 0), 'link');
$link->forceFill(['used_at' => now()])->save();
return view('portal.abo.card_update_done', ['success' => true]);
}
$user_abo->forceFill(['card_update_pending_at' => null])->save();
return view('portal.abo.card_update_done', ['success' => false]);
}
private function resolveRedeemableLink(string $token): AboCardUpdateLink
{
$link = AboCardUpdateLink::where('token_hash', AboCardUpdateLink::hashToken($token))->first();
if (! $link || ! $link->isRedeemable()) {
abort(410, __('abo.card_update_link_invalid'));
}
$abo = $link->user_abo;
if (! $abo || ! app(AboPaymentMethodService::class)->canStartChange($abo)) {
abort(410, __('abo.card_update_link_invalid'));
}
return $link;
}
}