Abo-Recovery: fehlgeschlagene Folgezahlung im Checkout nachholen

- Pay-now-Flow: bei fehlgeschlagenem Abo (Status 3) begleicht der Inhaber die
  offene Rechnung im Standard-Checkout mit frei waehlbarer, abo-faehiger Zahlart
  (nur eigenes Abo: Berater me-View + Kundenportal), payNow-Controller + Routen
- AboRecoveryCheckoutService baut die ShoppingInstance aus den Abo-Artikeln;
  neue Spalte shopping_orders.recovery_user_abo_id verknuepft Bestellung<->Abo
- Bei Erfolg: AboHelper::advanceAboAfterRecoveryPayment schreibt das Abo fort
  (Status->2, next_date) und uebernimmt die gewaehlte Zahlart als neues Token
  fuer kuenftige Abbuchungen
- Neue Pay-now-Fehlermail (AboPaymentFailedMail) ersetzt die generische
  Fehlermail im Abo-Cron; Link fuehrt auf die Abo-Detailseite (CRM bzw. Portal)
- Retry-/"extern erledigt"-Buttons nur noch fuer Admins; tote user/portal-
  Retry-Routen entfernt
- Checkout: Recovery-Hinweis statt "Abschluss des Abonnements", Lieferadresse
  vorbelegt, korrekter Zurueck-Link (Abo statt Shop)
- UserAbo::selfServiceDetailUrl zentralisiert die Abo-URL (Mail, Back-Links)
- Test-Command abo:test-recovery; Tests fuer payNow, Advance-Logik, Fehlermail

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-02 10:38:05 +00:00
parent 78db952130
commit 0660f4e0a0
28 changed files with 1535 additions and 181 deletions

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Http\Requests\Abo\AboOneTimeItemRequest;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Models\Product;
use App\Models\ShoppingInstance;
use App\Models\ShoppingUser;
@ -15,7 +16,8 @@ use App\Services\AboHelper;
use App\Services\AboItemHistoryService;
use App\Services\AboOneTimeService;
use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService;
use App\Services\AboPaymentMethodService;
use App\Services\AboRecoveryCheckoutService;
use App\Services\Shop;
use App\Services\UserService;
use App\Services\Util;
@ -508,16 +510,29 @@ class AboController extends Controller
return false;
}
public function retryPayment($id, AboRetryPaymentService $retryPaymentService)
/**
* Startet den Recovery-Checkout für ein fehlgeschlagenes Abo: der Kunde begleicht die offene
* Folgerechnung seines eigenen Abos über den Checkout mit frei wählbarer (abo-fähiger) Zahlart.
*/
public function payNow($id, AboRecoveryCheckoutService $recoveryCheckout)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo);
$result = $retryPaymentService->retry($user_abo);
if ($user_abo->status !== 3 || ! $user_abo->active) {
\Session()->flash('alert-error', __('abo.pay_now_not_allowed'));
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
return redirect(route('portal.my_subscriptions'));
}
return redirect(route('portal.my_subscriptions'));
$url = $recoveryCheckout->buildCheckoutUrl($user_abo);
if (! $url) {
\Session()->flash('alert-error', __('abo.pay_now_failed'));
return redirect(route('portal.my_subscriptions'));
}
return redirect()->away($url);
}
private function checkPortalPermission($user_abo)
@ -532,6 +547,75 @@ class AboController extends Controller
}
}
public function editPaymentMethod($id)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo);
if (! $user_abo->canUpdatePaymentMethod()) {
\Session()->flash('alert-error', __('abo.card_update_not_allowed'));
return redirect(route('portal.my_subscriptions'));
}
return view('portal.abo.payment_method', ['user_abo' => $user_abo, 'view' => 'portal']);
}
public function updatePaymentMethod(UpdateAboCreditCardRequest $request, $id, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo);
$returnUrls = [
'successurl' => route('portal_abos_payment_method_return', [$user_abo->id, 'success']),
'errorurl' => route('portal_abos_payment_method_return', [$user_abo->id, 'error']),
'backurl' => route('portal_abos_payment_method_return', [$user_abo->id, 'cancel']),
];
$result = $service->updateCreditCard($user_abo, $request->toDto(), $returnUrls, 'portal');
if ($result->isApproved()) {
\Session()->flash('alert-success', __('abo.card_update_success'));
return redirect(route('portal.my_subscriptions'));
}
if ($result->isRedirect()) {
// Karten-Token bis zur 3DS-Rückkehr in der Session zwischenspeichern.
session(['abo_card_update.'.$user_abo->id => [
'carddata' => $result->carddata,
'userid' => $result->userid,
]]);
return redirect()->away($result->redirectUrl);
}
\Session()->flash('alert-error', $result->errorMessage ?? __('abo.card_update_failed'));
return redirect(route('portal_abos_payment_method_edit', [$user_abo->id]))->withInput();
}
public function paymentMethodReturn($id, $status, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo);
$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), 'portal');
\Session()->flash('alert-success', __('abo.card_update_success'));
return redirect(route('portal.my_subscriptions'));
}
// Abbruch oder Fehler an der 3DS-Rückkehr: Pending-Spur zurücknehmen, Karte bleibt unverändert.
$user_abo->forceFill(['card_update_pending_at' => null])->save();
\Session()->flash('alert-error', __('abo.card_update_failed'));
return redirect(route('portal.my_subscriptions'));
}
public function myAboCreate($step)
{
$user = Auth::guard('customers')->user();

View file

@ -4,6 +4,9 @@ namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\Abo\AboOneTimeItemRequest;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Mail\AboCardUpdateLinkMail;
use App\Models\AboCardUpdateLink;
use App\Models\Product;
use App\Models\ShoppingInstance;
use App\Models\UserAbo;
@ -13,7 +16,8 @@ use App\Repositories\AboRepository;
use App\Services\AboHelper;
use App\Services\AboItemHistoryService;
use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService;
use App\Services\AboPaymentMethodService;
use App\Services\AboRecoveryCheckoutService;
use App\Services\OrderPaymentService;
use App\Services\Shop;
use App\User;
@ -543,16 +547,34 @@ class AboController extends Controller
->make(true);
}
public function retryPayment($view, $id, AboRetryPaymentService $retryPaymentService)
/**
* Startet den Recovery-Checkout für ein fehlgeschlagenes Abo: der Berater begleicht die offene
* Folgerechnung seines EIGENEN Abos (view=me) über den Checkout mit frei wählbarer Zahlart.
* Kein Kunden-Abo (ot), kein Admin-Bypass – das übernimmt der Kunde selbst im Portal.
*/
public function payNow($view, $id, AboRecoveryCheckoutService $recoveryCheckout)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$result = $retryPaymentService->retry($user_abo);
if ($view !== 'me' || $user_abo->is_for !== 'me' || (int) $user_abo->user_id !== (int) \Auth::id()) {
abort(403, 'Pay now is only allowed for the subscription owner.');
}
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
if ($user_abo->status !== 3 || ! $user_abo->active) {
\Session()->flash('alert-error', __('abo.pay_now_not_allowed'));
return redirect(route('user_abos_detail', [$view, $id]));
return redirect(route('user_abos_detail', [$view, $id]));
}
$url = $recoveryCheckout->buildCheckoutUrl($user_abo);
if (! $url) {
\Session()->flash('alert-error', __('abo.pay_now_failed'));
return redirect(route('user_abos_detail', [$view, $id]));
}
return redirect()->away($url);
}
public function deletePending($view, $id)
@ -616,6 +638,111 @@ class AboController extends Controller
->values();
}
public function editPaymentMethod($view, $id)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $user_abo);
if (! $user_abo->canUpdatePaymentMethod()) {
\Session()->flash('alert-error', __('abo.card_update_not_allowed'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
return view('user.abo.payment_method', ['user_abo' => $user_abo, 'view' => $view]);
}
public function updatePaymentMethod(UpdateAboCreditCardRequest $request, $view, $id, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $user_abo);
$returnUrls = [
'successurl' => route('user_abos_payment_method_return', [$view, $user_abo->id, 'success']),
'errorurl' => route('user_abos_payment_method_return', [$view, $user_abo->id, 'error']),
'backurl' => route('user_abos_payment_method_return', [$view, $user_abo->id, 'cancel']),
];
$result = $service->updateCreditCard($user_abo, $request->toDto(), $returnUrls, 'crm');
if ($result->isApproved()) {
\Session()->flash('alert-success', __('abo.card_update_success'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
if ($result->isRedirect()) {
session(['abo_card_update.'.$user_abo->id => [
'carddata' => $result->carddata,
'userid' => $result->userid,
]]);
return redirect()->away($result->redirectUrl);
}
\Session()->flash('alert-error', $result->errorMessage ?? __('abo.card_update_failed'));
return redirect(route('user_abos_payment_method_edit', [$view, $user_abo->id]))->withInput();
}
public function paymentMethodReturn($view, $id, $status, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $user_abo);
$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), 'crm');
\Session()->flash('alert-success', __('abo.card_update_success'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
$user_abo->forceFill(['card_update_pending_at' => null])->save();
\Session()->flash('alert-error', __('abo.card_update_failed'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
/**
* Berater verschickt einen Kreditkarten-Update-Link an seinen Kunden (Kunden-Abo, view=ot).
* Der Berater gibt die Karte NICHT selbst ein – der Kunde aktualisiert sie login-frei über den Link.
*/
public function sendCardUpdateLink($view, $id)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
if ($view !== 'ot' || $user_abo->is_for !== 'ot' || ! $user_abo->isCreditCard() || ! $user_abo->canUpdatePaymentMethod()) {
\Session()->flash('alert-error', __('abo.card_update_link_not_allowed'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
[$link, $token] = AboCardUpdateLink::generateFor($user_abo, (int) \Auth::id());
\Mail::to($user_abo->email)->send(new AboCardUpdateLinkMail($user_abo, $token));
\Session()->flash('alert-success', __('abo.card_update_link_sent'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
/**
* Karteneingabe ausschließlich durch den Inhaber selbst (Berater-eigenes Abo, view=me).
* Kein Admin-Bypass, keine Kunden-Abos (ot) – diese laufen über den Update-Link.
*/
private function assertOwnerCardEntry($view, UserAbo $user_abo): void
{
if ($view !== 'me' || $user_abo->is_for !== 'me' || (int) $user_abo->user_id !== (int) \Auth::id()) {
abort(403, 'Card entry is only allowed for the subscription owner.');
}
}
private function checkPermissions($view, $user_abo)
{
\Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]);

View file

@ -58,6 +58,7 @@ class CheckoutController extends Controller
$is_for = $shopping_data['is_for'] ?? false;
$is_abo = isset($shopping_data['is_abo']) ? (bool) $shopping_data['is_abo'] : false;
$abo_interval = $shopping_data['abo_interval'] ?? 0;
$recovery_user_abo_id = $shopping_data['recovery_user_abo_id'] ?? null;
$homeparty_id = $shopping_data['homeparty_id'] ?? null;
$shopping_user = null;
@ -88,6 +89,7 @@ class CheckoutController extends Controller
'is_for' => $is_for,
'is_abo' => $is_abo,
'abo_interval' => $abo_interval,
'recovery_user_abo_id' => $recovery_user_abo_id,
'customer_order_source_options' => ShoppingOrder::customerOrderSourceOptions(),
'shopping_data' => $shopping_data,
'user_shop' => Util::getUserShop(),
@ -522,8 +524,10 @@ class CheckoutController extends Controller
$this->checkoutRepo->sessionDestroy(true);
Util::setInstanceStatus(3, true); // link_pending
// Abo erstellen, falls nötig
if ($ShoppingPayment->shopping_order->is_abo) {
// Abo erstellen bzw. bei Recovery-Zahlung das bestehende Abo fortschreiben
if ($ShoppingPayment->shopping_order->recovery_user_abo_id) {
AboHelper::advanceAboAfterRecoveryPayment($ShoppingPayment);
} elseif ($ShoppingPayment->shopping_order->is_abo) {
AboHelper::createNewAbo($ShoppingPayment);
}
@ -534,6 +538,7 @@ class CheckoutController extends Controller
'pay_trans' => $payt,
'is_checkout' => true,
'yard_instance' => $this->instance,
'back_url' => $this->recoveryBackUrl($ShoppingPayment->shopping_order),
];
return view('web.templates.checkout-final', $data);
@ -562,8 +567,10 @@ class CheckoutController extends Controller
$this->checkoutRepo->sessionDestroy(true);
Util::setInstanceStatus(3, true); // link_pending
// Abo erstellen, falls nötig
if ($payt->shopping_payment->shopping_order->is_abo) {
// Abo erstellen bzw. bei Recovery-Zahlung das bestehende Abo fortschreiben
if ($payt->shopping_payment->shopping_order->recovery_user_abo_id) {
AboHelper::advanceAboAfterRecoveryPayment($payt->shopping_payment);
} elseif ($payt->shopping_payment->shopping_order->is_abo) {
AboHelper::createNewAbo($payt->shopping_payment);
}
@ -578,11 +585,25 @@ class CheckoutController extends Controller
'pay_trans' => $payt,
'is_checkout' => true,
'yard_instance' => $this->instance,
'back_url' => $this->recoveryBackUrl($payt->shopping_payment->shopping_order),
];
return view('web.templates.checkout-final', $data);
}
/**
* Back-Link auf der Bestätigungsseite: bei einer Recovery-Zahlung zurück auf die Abo-Detailseite
* (Berater-CRM bzw. Kundenportal), sonst null → das Blade fällt auf den Shop-Back-Link zurück.
*/
private function recoveryBackUrl($shoppingOrder): ?string
{
if (! $shoppingOrder || ! $shoppingOrder->recovery_user_abo_id) {
return null;
}
return \App\Models\UserAbo::find($shoppingOrder->recovery_user_abo_id)?->selfServiceDetailUrl();
}
/**
* Speichert die Zahlungsdaten des Benutzers
*