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();