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
|
|
@ -22,6 +22,7 @@ use App\Services\Shop;
|
|||
use App\Services\UserService;
|
||||
use App\Services\Util;
|
||||
use Auth;
|
||||
use Carbon\Carbon;
|
||||
use Request;
|
||||
use Yard;
|
||||
|
||||
|
|
@ -122,8 +123,8 @@ class AboController extends Controller
|
|||
|
||||
if ($changeLocked) {
|
||||
$message = __('abo.error_change_locked', [
|
||||
'days' => (int) now()->diffInDays(\Carbon\Carbon::parse($user_abo->next_date), false),
|
||||
'lock' => \App\Repositories\AboRepository::getLockDaysChange(),
|
||||
'days' => (int) now()->diffInDays(Carbon::parse($user_abo->next_date), false),
|
||||
'lock' => AboRepository::getLockDaysChange(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -552,7 +553,7 @@ class AboController extends Controller
|
|||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPortalPermission($user_abo);
|
||||
|
||||
if (! $user_abo->canUpdatePaymentMethod()) {
|
||||
if (! app(AboPaymentMethodService::class)->canStartChange($user_abo)) {
|
||||
\Session()->flash('alert-error', __('abo.card_update_not_allowed'));
|
||||
|
||||
return redirect(route('portal.my_subscriptions'));
|
||||
|
|
@ -595,11 +596,49 @@ class AboController extends Controller
|
|||
return redirect(route('portal_abos_payment_method_edit', [$user_abo->id]))->withInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Stripe-Setup-Flow "Zahlungsart ändern" (Plan S5): Redirect
|
||||
* zur Hosted-Seite, freie Zahlartenwahl, keine Abbuchung. Gilt auch für
|
||||
* PAYONE-Bestandsabos (Umzug auf Stripe bei Erfolg).
|
||||
*/
|
||||
public function startStripePaymentMethodChange($id, AboPaymentMethodService $service)
|
||||
{
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPortalPermission($user_abo);
|
||||
abort_unless($service->usesStripeChange(), 404);
|
||||
|
||||
$result = $service->beginStripeChange(
|
||||
$user_abo,
|
||||
route('portal_abos_payment_method_return', [$user_abo->id, 'success']).'?session_id={CHECKOUT_SESSION_ID}',
|
||||
route('portal_abos_payment_method_return', [$user_abo->id, 'cancel']),
|
||||
'portal',
|
||||
);
|
||||
|
||||
if ($result->isRedirect()) {
|
||||
return redirect()->away($result->redirectUrl);
|
||||
}
|
||||
|
||||
\Session()->flash('alert-error', $result->errorMessage ?? __('abo.payment_method_change_failed'));
|
||||
|
||||
return redirect(route('portal_abos_payment_method_edit', [$user_abo->id]));
|
||||
}
|
||||
|
||||
public function paymentMethodReturn($id, $status, AboPaymentMethodService $service)
|
||||
{
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPortalPermission($user_abo);
|
||||
|
||||
// Stripe-Setup-Return (Plan S5): Session prüfen und Zahlungsart verankern.
|
||||
if ($status === 'success' && ($sessionId = request('session_id'))) {
|
||||
if ($service->finalizeStripeChangeFromSession($user_abo, (string) $sessionId, 'portal')) {
|
||||
\Session()->flash('alert-success', __('abo.payment_method_change_success'));
|
||||
} else {
|
||||
\Session()->flash('alert-error', __('abo.payment_method_change_failed'));
|
||||
}
|
||||
|
||||
return redirect(route('portal.my_subscriptions'));
|
||||
}
|
||||
|
||||
$stash = session()->pull('abo_card_update.'.$user_abo->id);
|
||||
|
||||
if ($status === 'success' && is_array($stash)) {
|
||||
|
|
|
|||
|
|
@ -650,7 +650,7 @@ class AboController extends Controller
|
|||
$this->checkPermissions($view, $user_abo);
|
||||
$this->assertOwnerCardEntry($view, $user_abo);
|
||||
|
||||
if (! $user_abo->canUpdatePaymentMethod()) {
|
||||
if (! app(AboPaymentMethodService::class)->canStartChange($user_abo)) {
|
||||
\Session()->flash('alert-error', __('abo.card_update_not_allowed'));
|
||||
|
||||
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
|
||||
|
|
@ -693,12 +693,51 @@ class AboController extends Controller
|
|||
return redirect(route('user_abos_payment_method_edit', [$view, $user_abo->id]))->withInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Stripe-Setup-Flow "Zahlungsart ändern" (Plan S5): Redirect
|
||||
* zur Hosted-Seite, freie Zahlartenwahl, keine Abbuchung. Gilt auch für
|
||||
* PAYONE-Bestandsabos (Umzug auf Stripe bei Erfolg).
|
||||
*/
|
||||
public function startStripePaymentMethodChange($view, $id, AboPaymentMethodService $service)
|
||||
{
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPermissions($view, $user_abo);
|
||||
$this->assertOwnerCardEntry($view, $user_abo);
|
||||
abort_unless($service->usesStripeChange(), 404);
|
||||
|
||||
$result = $service->beginStripeChange(
|
||||
$user_abo,
|
||||
route('user_abos_payment_method_return', [$view, $user_abo->id, 'success']).'?session_id={CHECKOUT_SESSION_ID}',
|
||||
route('user_abos_payment_method_return', [$view, $user_abo->id, 'cancel']),
|
||||
'crm',
|
||||
);
|
||||
|
||||
if ($result->isRedirect()) {
|
||||
return redirect()->away($result->redirectUrl);
|
||||
}
|
||||
|
||||
\Session()->flash('alert-error', $result->errorMessage ?? __('abo.payment_method_change_failed'));
|
||||
|
||||
return redirect(route('user_abos_payment_method_edit', [$view, $user_abo->id]));
|
||||
}
|
||||
|
||||
public function paymentMethodReturn($view, $id, $status, AboPaymentMethodService $service)
|
||||
{
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPermissions($view, $user_abo);
|
||||
$this->assertOwnerCardEntry($view, $user_abo);
|
||||
|
||||
// Stripe-Setup-Return (Plan S5): Session prüfen und Zahlungsart verankern.
|
||||
if ($status === 'success' && ($sessionId = request('session_id'))) {
|
||||
if ($service->finalizeStripeChangeFromSession($user_abo, (string) $sessionId, 'crm')) {
|
||||
\Session()->flash('alert-success', __('abo.payment_method_change_success'));
|
||||
} else {
|
||||
\Session()->flash('alert-error', __('abo.payment_method_change_failed'));
|
||||
}
|
||||
|
||||
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
|
||||
}
|
||||
|
||||
$stash = session()->pull('abo_card_update.'.$user_abo->id);
|
||||
|
||||
if ($status === 'success' && is_array($stash)) {
|
||||
|
|
@ -729,7 +768,11 @@ class AboController extends Controller
|
|||
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
|
||||
}
|
||||
|
||||
if ($view !== 'ot' || $user_abo->is_for !== 'ot' || ! $user_abo->isCreditCard() || ! $user_abo->canUpdatePaymentMethod()) {
|
||||
// Stripe-Setup-Flow: Link ist für jede Zahlart möglich (freie Wahl bei
|
||||
// Stripe); die "nur CC"-Einschränkung gilt nur für den PAYONE-Legacy-Flow.
|
||||
$requiresCreditCard = ! app(AboPaymentMethodService::class)->usesStripeChange();
|
||||
|
||||
if ($view !== 'ot' || $user_abo->is_for !== 'ot' || ($requiresCreditCard && ! $user_abo->isCreditCard()) || ! app(AboPaymentMethodService::class)->canStartChange($user_abo)) {
|
||||
\Session()->flash('alert-error', __('abo.card_update_link_not_allowed'));
|
||||
|
||||
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
|
||||
|
|
|
|||
|
|
@ -56,11 +56,46 @@ class AboCardUpdateController extends Controller
|
|||
->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)) {
|
||||
|
|
@ -84,7 +119,7 @@ class AboCardUpdateController extends Controller
|
|||
}
|
||||
|
||||
$abo = $link->user_abo;
|
||||
if (! $abo || ! $abo->canUpdatePaymentMethod()) {
|
||||
if (! $abo || ! app(AboPaymentMethodService::class)->canStartChange($abo)) {
|
||||
abort(410, __('abo.card_update_link_invalid'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ class CheckoutController extends Controller
|
|||
|
||||
// Stripe aktiv: keine Gateway-Zahlartenwahl im eigenen Checkout mehr —
|
||||
// nur die Stripe-Option + systeminterne Methoden (Vorkasse/Rechnung).
|
||||
$stripe_active = $this->stripeCheckoutActive($is_abo, $recovery_user_abo_id);
|
||||
$stripe_active = $this->stripeCheckoutActive();
|
||||
if ($stripe_active) {
|
||||
$payment_methods['active'] = array_intersect_key($payment_methods['active'], array_flip(['VOR', 'FNC']));
|
||||
}
|
||||
|
|
@ -192,11 +192,7 @@ class CheckoutController extends Controller
|
|||
|
||||
if (isset($data['payment_method'])) {
|
||||
if ($data['payment_method'] === 'stripe') {
|
||||
$shopping_data = Yard::instance($this->instance)->getYardExtra('shopping_data');
|
||||
abort_unless($this->stripeCheckoutActive(
|
||||
(bool) ($data['is_abo'] ?? false),
|
||||
$shopping_data['recovery_user_abo_id'] ?? null,
|
||||
), 404);
|
||||
abort_unless($this->stripeCheckoutActive(), 404);
|
||||
} else {
|
||||
$this->checkoutRepo->isPaymentsMethodsActive($data['payment_method'], $data['is_from'], $data['is_abo']);
|
||||
}
|
||||
|
|
@ -350,15 +346,12 @@ class CheckoutController extends Controller
|
|||
|
||||
/**
|
||||
* Stripe ist für diesen Checkout aktiv, wenn der Anbieter-Schalter auf
|
||||
* Stripe steht. Abo- und Recovery-Checkouts bleiben bis Phase S4 auf
|
||||
* PAYONE (Plan dev/2026-07-08-stripe/, Abgrenzung S3/S4).
|
||||
* Stripe steht. Seit Phase S4 gilt das auch für Abo- und Recovery-Checkouts:
|
||||
* Die Erstzahlung speichert das Zahlungsmittel off-session, Recovery-
|
||||
* Zahlungen ziehen das Abo auf Stripe um (Plan dev/2026-07-08-stripe/, §7.2).
|
||||
*/
|
||||
private function stripeCheckoutActive(bool $is_abo, mixed $recovery_user_abo_id): bool
|
||||
private function stripeCheckoutActive(): bool
|
||||
{
|
||||
if ($is_abo || $recovery_user_abo_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(PaymentProviderManager::class)->providerForNewCheckout() === PaymentProvider::Stripe;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue