Abo Einmalprodukte: Phase 4 - Ausfuehrung, Purge & User-Retry

- UserMakeOrder: bestaetigte Einmal-Artikel in den Yard, is_abo_addon
  auf ShoppingOrderItem; amount bleibt reiner Abo-Betrag (Reihenfolge)
- AboOneTimeService::purgeAfterExecution: loescht alle Einmal-Artikel
  und rechnet Comp-Produkte neu - nur im Erfolgszweig (Cron + Retry)
- User-Retry in Sales Center und Portal mit Berechtigungspruefung,
  gemeinsames Confirm-Modal; Admin-Retry unveraendert
- Tests: AboMakeOrderOneTimeTest, AboUserRetryTest; Plan-Doku Phase 4

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin 2026-06-08 15:32:27 +00:00
parent 8288ea59ac
commit ee04146217
14 changed files with 536 additions and 19 deletions

View file

@ -6,6 +6,7 @@ use App\Cron\UserMakeOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\AboHelper;
use App\Services\AboOneTimeService;
use App\Services\Incentive\IncentiveTracker;
use App\Services\MyLog;
use App\Services\Payment;
@ -74,7 +75,7 @@ class UserMakeAboOrder extends Command
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$this->error('Fehler beim Ausführen des Befehls: ' . $e->getMessage());
$this->error('Fehler beim Ausführen des Befehls: '.$e->getMessage());
return 1;
}
@ -164,7 +165,7 @@ class UserMakeAboOrder extends Command
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$this->error("Fehler bei Abo {$userAbo->id}: " . $e->getMessage());
$this->error("Fehler bei Abo {$userAbo->id}: ".$e->getMessage());
}
}
}
@ -178,7 +179,7 @@ class UserMakeAboOrder extends Command
private function makeOrder($userAbo)
{
\Log::channel('abo_order')->info('UserMakeAboOrder: Starte Bestellungserstellung', ['abo_id' => $userAbo->id]);
$this->info('Starte Bestellungserstellung für Abo: ' . $userAbo->id);
$this->info('Starte Bestellungserstellung für Abo: '.$userAbo->id);
$shoppingOrder = null;
$userOrder = new UserMakeOrder($userAbo);
@ -205,7 +206,7 @@ class UserMakeAboOrder extends Command
]);
$response = $userOrder->makePayment();
$this->info('makePayment response: ' . json_encode($response));
$this->info('makePayment response: '.json_encode($response));
// Prüfe ob Response ein Array ist (kann auch Objekt sein)
if (is_object($response)) {
@ -274,7 +275,7 @@ class UserMakeAboOrder extends Command
'status' => $response['status'],
]);
$this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$response['status']}");
$this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: ' . $response['status']);
$this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: '.$response['status']);
} else {
// Unbekannter Status: Bestellung speichern, aber Abo nicht aktualisieren
\Log::channel('abo_order')->warning('UserMakeAboOrder: Unbekannter Zahlungsstatus', [
@ -283,7 +284,7 @@ class UserMakeAboOrder extends Command
'status' => $response['status'],
]);
$this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$response['status']}");
$this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: ' . $response['status']);
$this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: '.$response['status']);
}
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [
@ -291,11 +292,11 @@ class UserMakeAboOrder extends Command
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$this->error("Ausnahme bei Abo {$userAbo->id}: " . $e->getMessage());
$this->error("Ausnahme bei Abo {$userAbo->id}: ".$e->getMessage());
// Bestellung existiert (z. B. Fehler bei Payone): Abo-Fehlerstatus, Bestellung bleibt nachvollziehbar
if ($shoppingOrder) {
$this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: ' . $e->getMessage());
$this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: '.$e->getMessage());
return $shoppingOrder;
}
@ -356,12 +357,15 @@ class UserMakeAboOrder extends Command
// Wie bei Payment::paymentStatusPaidAction: Incentive nur wenn Callback nicht lief
// (firstOrCreate verhindert Doppelungen wenn Payone später noch trackt)
IncentiveTracker::trackAboActivated($shoppingOrder);
// Nur bei Erfolg: Einmal-Artikel entfernen und Comp-Produkte neu bewerten.
AboOneTimeService::purgeAfterExecution($userAbo);
} catch (\Exception $e) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
]);
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage());
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: ".$e->getMessage());
throw $e; // Re-throw für besseres Error-Handling
}
}
@ -421,7 +425,7 @@ class UserMakeAboOrder extends Command
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
]);
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage());
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: ".$e->getMessage());
// Bei Fehler hier nicht re-throw, damit der Hauptprozess fortgesetzt werden kann
}
}
@ -437,6 +441,6 @@ class UserMakeAboOrder extends Command
$sec = intval($diff);
$micro = $diff - $sec;
return $sec . ' Sekunden und ' . round($micro * 1000, 2) . ' ms';
return $sec.' Sekunden und '.round($micro * 1000, 2).' ms';
}
}

View file

@ -177,8 +177,13 @@ class UserMakeOrder
}
// hier wird die Bestellung erstellt inkl aktueller Preise
// (setzt user_abos.amount auf den REINEN Abo-Betrag)
AboOrderCart::makeOrderYard($this->userAbo);
// Verbindlich bestätigte Einmal-Artikel zusätzlich in den Yard laden
// (verändert user_abos.amount NICHT; Versand/Gewicht werden kombiniert berechnet).
AboOrderCart::addOneTimeItemsToYard($this->userAbo);
$yard = Yard::instance(AboOrderCart::INSTANCE);
// Debug: Logge welche Produkte im Cart sind
@ -268,6 +273,7 @@ class UserMakeOrder
'row_id' => $item->rowId,
'product_id' => $item->id,
'comp' => $item->options->comp,
'is_abo_addon' => (bool) ($item->options->abo_addon ?? false),
'qty' => $item->qty,
'price' => $item->price,
'price_net' => $price_net,

View file

@ -15,6 +15,7 @@ use App\Services\AboHelper;
use App\Services\AboItemHistoryService;
use App\Services\AboOneTimeService;
use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService;
use App\Services\Shop;
use App\Services\UserService;
use App\Services\Util;
@ -491,6 +492,18 @@ class AboController extends Controller
return false;
}
public function retryPayment($id, AboRetryPaymentService $retryPaymentService)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo);
$result = $retryPaymentService->retry($user_abo);
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
return redirect(route('portal.my_subscriptions'));
}
private function checkPortalPermission($user_abo)
{
$user = Auth::guard('customers')->user();

View file

@ -12,6 +12,7 @@ use App\Repositories\AboRepository;
use App\Services\AboHelper;
use App\Services\AboItemHistoryService;
use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService;
use App\Services\Shop;
use App\User;
use Request;
@ -507,6 +508,18 @@ class AboController extends Controller
->make(true);
}
public function retryPayment($view, $id, AboRetryPaymentService $retryPaymentService)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$result = $retryPaymentService->retry($user_abo);
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
return redirect(route('user_abos_detail', [$view, $id]));
}
private function checkPermissions($view, $user_abo)
{
\Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]);

View file

@ -192,6 +192,44 @@ class AboOneTimeService
->sum(fn (UserAboOneTimeItem $item): float => (float) $item->price * (int) $item->qty), 2);
}
/**
* Entfernt nach einer erfolgreichen Ausführung ALLE Einmal-Artikel des Abos
* (bestätigte und offene Entwürfe, inkl. soft-deleted) und bewertet anschließend
* die Kompensationsprodukte des reinen Abos neu (das durch Einmal-Artikel
* verursachte Zusatzgewicht ist nun weg überzählige Comp-Artikel werden entfernt).
*
* Wird ausschließlich im Erfolgszweig aufgerufen. Bei Zahlungsfehler bleiben die
* Einmal-Artikel erhalten (Entscheidung #2/#3). Schlägt etwas fehl, wird der bereits
* erfolgreiche Abo-Lauf NICHT zurückgerollt Fehler werden nur protokolliert.
*/
public static function purgeAfterExecution(UserAbo $userAbo): void
{
try {
UserAboOneTimeItem::withTrashed()
->where('user_abo_id', $userAbo->id)
->get()
->each(fn (UserAboOneTimeItem $item) => $item->forceDelete());
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('AboOneTimeService::purgeAfterExecution: Löschen der Einmal-Artikel fehlgeschlagen', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
]);
return;
}
try {
AboOrderCart::initYard($userAbo);
AboOrderCart::makeOrderYard($userAbo);
AboOrderCart::checkNumOfCompProducts($userAbo);
} catch (\Throwable $e) {
\Log::channel('abo_order')->warning('AboOneTimeService::purgeAfterExecution: Comp-Neuberechnung übersprungen', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
]);
}
}
public static function hasUnconfirmedChanges(UserAbo $userAbo): bool
{
return UserAboOneTimeItem::withTrashed()

View file

@ -157,6 +157,9 @@ class AboRetryPaymentService
'error' => $e->getMessage(),
]);
}
// Nur bei Erfolg: Einmal-Artikel entfernen und Comp-Produkte neu bewerten.
AboOneTimeService::purgeAfterExecution($userAbo);
}
private function markAboError(UserAbo $userAbo, mixed $shoppingOrder): void