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

@ -227,13 +227,19 @@ class AboHelper
}
/**
* Sichtbarkeit des Einmalprodukte-Features. Maßgeblich ist allein das
* konfigurierte Zeitfenster vor der Ausführung (Sales Center und Portal
* gleichermaßen). Das frühere Review-Gate (nur VIP, Portal ausgeblendet)
* wurde nach Abschluss der Abstimmung aufgehoben.
* Sichtbarkeit des Einmalprodukte-Features:
* - VIP-User (Admin) sehen/nutzen das Feature dauerhaft, unabhängig vom Zeitfenster.
* - Alle anderen (normale Berater im Sales Center sowie Endkunden im Portal, die nie
* auf dem `user`-Guard angemeldet sind) nur innerhalb des konfigurierten Zeitfensters
* vor der Ausführung ({@see isOneTimeWindowOpen}, Setting `abo-onetime-window-days`).
*/
public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool
{
$user = \Auth::guard('user')->user();
if ($user instanceof User && $user->isVIP()) {
return true;
}
return self::isOneTimeWindowOpen($userAbo);
}
@ -348,6 +354,80 @@ class AboHelper
}
}
/**
* Schreibt ein bestehendes Abo nach einer erfolgreichen Recovery-Zahlung fort
* (der Inhaber hat die offene Folgerechnung über den Checkout mit freier Zahlart beglichen).
* Es wird KEIN neues Abo angelegt – nur das vorhandene Abo reaktiviert und der nächste Termin gesetzt.
*/
public static function advanceAboAfterRecoveryPayment(ShoppingPayment $shopping_payment): void
{
$order = $shopping_payment->shopping_order;
if (! $order || ! $order->recovery_user_abo_id) {
return;
}
$userAbo = UserAbo::find($order->recovery_user_abo_id);
if (! $userAbo) {
return;
}
// Bereits verknüpft (z. B. Payone-Status-URL vor Checkout-Redirect) oder wiederholter Aufruf
if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) {
return;
}
$aboInterval = (int) ($userAbo->abo_interval ?: $order->abo_interval);
// Die im Recovery-Checkout gewählte Zahlart wird als neues Abo-Zahlungsmittel übernommen,
// damit auch künftige Folgeabbuchungen darüber laufen (das alte Token war i. d. R. defekt).
$paymentTransaction = $shopping_payment->payment_transactions->last();
$payoneUserId = $paymentTransaction && (int) $paymentTransaction->userid > 0
? (int) $paymentTransaction->userid
: (int) $userAbo->payone_userid;
\DB::transaction(function () use ($userAbo, $order, $aboInterval, $shopping_payment, $payoneUserId): void {
$userAbo->update([
'status' => 2,
'active' => true,
'clearingtype' => $shopping_payment->clearingtype,
'wallettype' => $shopping_payment->wallettype,
'carddata' => $shopping_payment->carddata,
'payone_userid' => $payoneUserId,
'next_date' => $aboInterval > 0 ? self::setNextDate(now(), $aboInterval) : $userAbo->next_date,
'last_date' => now(),
]);
UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $order->id,
'status' => 1,
'paid' => true,
]);
if (! empty($shopping_payment->carddata)) {
$userAbo->card_changes()->create([
'masked_card' => UserAbo::formatMaskedCard($shopping_payment->carddata),
'source' => 'recovery-checkout',
]);
}
});
try {
$order->refresh();
if ($order->paid) {
IncentiveTracker::trackAboActivated($order);
}
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('AboHelper::advanceAboAfterRecoveryPayment: Incentive-Tracking fehlgeschlagen', [
'abo_id' => $userAbo->id,
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
AboOneTimeService::purgeAfterExecution($userAbo);
}
public static function createAboItems($user_abo, ShoppingPayment $shopping_payment)
{
foreach ($shopping_payment->shopping_order->shopping_order_items as $item) {