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:
parent
78db952130
commit
0660f4e0a0
28 changed files with 1535 additions and 181 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue