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

@ -43,9 +43,26 @@
</div>
</div>
@endif
@if ($view === 'me' && $user_abo->status === 3 && $user_abo->active)
<div class="card mt-3 mb-3 border-info">
<div class="card-body">
<h6 class="font-weight-bold text-info">{{ __('abo.pay_now_title') }}</h6>
<p class="mb-2">{{ __('abo.pay_now_copy') }}</p>
<form method="POST" action="{{ route('user_abos_pay_now', [$view, $user_abo->id]) }}">
@csrf
<button type="submit" class="btn btn-info">
<i class="ion ion-md-card mr-1"></i>{{ __('abo.pay_now_button') }}
</button>
</form>
</div>
</div>
@endif
<div class="card">
@include('admin.abo._detail')
</div>
<div class="card mt-3">
@include('admin.abo._detail_abo_info')
</div>
@ -103,7 +120,10 @@
</div>
@else
<div class="card mt-3">
@include('admin.abo._order_abo', ['add_only_mode' => $addOnlyMode, 'change_locked' => $aboChangeLocked])
@include('admin.abo._order_abo', [
'add_only_mode' => $addOnlyMode,
'change_locked' => $aboChangeLocked,
])
</div>
<div id="holder_html_view_comp_product">
@ -121,7 +141,52 @@
@include('admin.abo._executions')
</div>
@include('admin.abo._retry_payment_modal', ['retryAction' => route('user_abos_retry_payment', [$view, $user_abo->id])])
@if ($user_abo->isCreditCard())
<div class="card mt-3 mb-3">
<div class="card-body">
<h6 class="font-weight-bold">{{ __('abo.payment_method') }}</h6>
<p class="mb-2">
{{ __('payment.credit_card') }}@if ($user_abo->maskedCard())
: {{ $user_abo->maskedCard() }}
@endif
</p>
@if ($view === 'me' && $user_abo->canUpdatePaymentMethod())
<a href="{{ route('user_abos_payment_method_edit', [$view, $user_abo->id]) }}"
class="btn btn-sm btn-primary">
{{ __('abo.update_payment_method') }}
</a>
@endif
@if ($view === 'ot' && $user_abo->canUpdatePaymentMethod())
<form method="POST" action="{{ route('user_abos_payment_method_send_link', [$view, $user_abo->id]) }}"
class="d-inline">
@csrf
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="ion ion-md-mail mr-1"></i>{{ __('abo.card_update_link_button') }}
</button>
</form>
@endif
@if ($user_abo->card_changes->isNotEmpty())
<div class="mt-3">
<small class="text-muted d-block mb-1">{{ __('abo.card_change_history') }}</small>
<ul class="list-unstyled mb-0 small">
@foreach ($user_abo->card_changes->take(5) as $change)
<li>
<i class="ion ion-md-time text-muted mr-1"></i>
{{ $change->created_at->format('d.m.Y H:i') }}@if ($change->masked_card)
— {{ $change->masked_card }}
@endif
</li>
@endforeach
</ul>
</div>
@endif
</div>
</div>
@endif
<a href="{{ route('user_abos', [$view]) }}" class="btn btn-sm btn-default float-right">{{ __('back') }}</a>