timeStart = microtime(true); \Log::channel('cron')->info('UserMakeAboOrder: Befehl gestartet'); $this->info('RUN Command user:make_abo_order'); // Not-Schalter (PAYONE-Ausfall): Abbuchungen komplett aussetzen. Abos werden // NICHT als fehlgeschlagen markiert — nach dem Auftauen holt der Cron sie // über ihr unverändertes next_date normal nach. if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) { \Log::channel('cron')->warning('UserMakeAboOrder: Abo-Zahlungen sind eingefroren (Setting abo-payments-frozen) — Lauf übersprungen.'); $this->warn('Abo-Zahlungen sind eingefroren (Setting abo-payments-frozen) — Lauf übersprungen.'); return 0; } try { $this->checkAbosToOrder(); // Stripe-Webhooks können die Cron-DB-Transaktion überholen (503 → // Event bleibt unverarbeitet). Nach dem Lauf sind alle Zahlungen // committet — jetzt liegengebliebene Events nachverarbeiten. $this->call('stripe:process-pending-events'); $executionTime = $this->getExecutionTime(); \Log::channel('cron')->info("UserMakeAboOrder: Befehl erfolgreich abgeschlossen in {$executionTime}"); $this->info("Befehl erfolgreich abgeschlossen in {$executionTime}"); return 0; } catch (\Exception $e) { \Log::channel('cron')->error('UserMakeAboOrder: Fehler beim Ausführen des Befehls', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $this->error('Fehler beim Ausführen des Befehls: '.$e->getMessage()); return 1; } } /** * Prüft alle Abos, die heute fällig sind und erstellt Bestellungen * * @return void */ private function checkAbosToOrder() { $dateNow = Carbon::now()->format('Y-m-d'); $forceAboId = $this->option('abo') !== null ? (int) $this->option('abo') : null; $force = (bool) $this->option('force'); \Log::channel('abo_order')->info('UserMakeAboOrder: Suche nach fälligen Abos für Datum', [ 'date' => $dateNow, 'force_abo_id' => $forceAboId, 'force' => $force, ]); if ($force) { $this->warn('FORCE-Modus aktiv: Datums-/Duplikatsprüfung wird übersprungen (echte Bestellung + Zahlung!).'); } $query = UserAbo::query(); if ($forceAboId !== null) { $query->where('id', $forceAboId); } else { $query->where('next_date', '=', $dateNow); } // Im Force-Modus werden die regulären Filter (fällig/aktiv/status/Duplikat) übersprungen, // damit ein einzelnes Abo on-demand getestet werden kann. if (! $force) { $query->where('active', true) ->where('status', '=', 2) // abo_okay ->whereDoesntHave('user_abo_orders', function ($query) use ($dateNow) { $query->whereDate('created_at', $dateNow); }); } $userAbos = $query->get(); $count = $userAbos->count(); \Log::channel('abo_order')->info("UserMakeAboOrder: {$count} fällige Abos gefunden (ohne bereits verarbeitete)"); $this->info("Gefundene fällige Abos: {$count}"); foreach ($userAbos as $userAbo) { \Log::channel('abo_order')->info('UserMakeAboOrder: Verarbeite Abo', [ 'abo_id' => $userAbo->id, 'payone_userid' => $userAbo->payone_userid, ]); $this->info("Verarbeite Abo: {$userAbo->id} (PayoneUserid: {$userAbo->payone_userid})"); try { // Locking-Mechanismus: Verhindert Race Conditions bei paralleler Ausführung $shoppingOrder = DB::transaction(function () use ($userAbo, $dateNow, $force) { // Lock das Abo für Update, um Race Conditions zu vermeiden $lockQuery = UserAbo::where('id', $userAbo->id); if (! $force) { $lockQuery->where('next_date', '=', $dateNow) ->where('active', true) ->where('status', '=', 2); // abo_okay } $lockedAbo = $lockQuery->lockForUpdate()->first(); if (! $lockedAbo) { \Log::channel('abo_order')->warning('UserMakeAboOrder: Abo wurde bereits verarbeitet oder ist nicht mehr aktiv', [ 'abo_id' => $userAbo->id, ]); return null; } // Nochmalige Prüfung auf Duplikat innerhalb der Transaktion (im Force-Modus übersprungen) if (! $force) { $existingOrder = UserAboOrder::where('user_abo_id', $lockedAbo->id) ->whereDate('created_at', $dateNow) ->first(); if ($existingOrder) { \Log::channel('abo_order')->info('UserMakeAboOrder: Abo wurde bereits heute verarbeitet', [ 'abo_id' => $lockedAbo->id, 'existing_order_id' => $existingOrder->shopping_order_id, ]); return null; } } return $this->makeOrder($lockedAbo); }, 3); // 3 Versuche bei Deadlocks if ($shoppingOrder) { \Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, ]); $this->info("Bestellung erstellt: {$shoppingOrder->id}"); } else { \Log::channel('abo_order')->warning('UserMakeAboOrder: Keine Bestellung erstellt für Abo', ['abo_id' => $userAbo->id]); $this->warn("Keine Bestellung erstellt für Abo: {$userAbo->id}"); } } catch (\Throwable $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Fehler bei der Verarbeitung des Abos', [ 'abo_id' => $userAbo->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $this->error("Fehler bei Abo {$userAbo->id}: ".$e->getMessage()); } } } /** * Erstellt eine Bestellung für ein Abo * * @param UserAbo $userAbo * @return mixed */ 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); $shoppingOrder = null; $userOrder = new UserMakeOrder($userAbo); try { if (! $userOrder->createShoppingUser()) { \Log::channel('abo_order')->error('UserMakeAboOrder: Konnte Shopping-User nicht erstellen', ['abo_id' => $userAbo->id]); $this->error("Konnte Shopping-User für Abo {$userAbo->id} nicht erstellen"); return null; } $shoppingOrder = $userOrder->makeShoppingOrder(); if (! $shoppingOrder) { \Log::channel('abo_order')->error('UserMakeAboOrder: Konnte Bestellung nicht erstellen', ['abo_id' => $userAbo->id]); $this->error("Konnte Bestellung für Abo {$userAbo->id} nicht erstellen"); return null; } \Log::channel('abo_order')->info('UserMakeAboOrder: Bestellung erstellt, starte Zahlungsvorgang', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, ]); $response = $userOrder->makePayment(); $this->info('makePayment response: '.json_encode($response)); // Prüfe ob Response ein Array ist (kann auch Objekt sein) if (is_object($response)) { $response = (array) $response; } if (! isset($response['status'])) { \Log::channel('abo_order')->error('UserMakeAboOrder: Ungültige Zahlungsantwort', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, 'response' => $response, ]); $this->error("Ungültige Zahlungsantwort für Abo {$userAbo->id}"); // Bei fehlender Status-Information: Abo nicht aktualisieren, damit es beim nächsten Lauf erneut versucht wird // Aber Bestellung speichern für Nachverfolgung $this->updateAboOnError($userAbo, $shoppingOrder, 'Ungültige Zahlungsantwort - kein Status'); return $shoppingOrder; } if ($response['status'] === 'APPROVED') { \Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung erfolgreich', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, 'response' => $response, ]); $this->info("Zahlung erfolgreich für Abo {$userAbo->id}"); // Nur bei erfolgreicher Zahlung: next_date aktualisieren $this->updateAbo($userAbo, $shoppingOrder, 1); } elseif ($response['status'] === 'ERROR') { \Log::channel('abo_order')->error('UserMakeAboOrder: Zahlungsfehler', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, 'error' => $response, ]); $this->error("Zahlungsfehler für Abo {$userAbo->id}"); MyLog::writeLog( 'userabo', 'error', 'Error:3002 App\Console\Commands\UserMakeAboOrder::makeOrder / makePayment Error response', $response ); // Bei Zahlungsfehler: Status setzen, aber next_date NICHT aktualisieren // Damit wird das Abo beim nächsten Cron-Lauf erneut versucht $this->updateAboOnError($userAbo, $shoppingOrder, 3, $response); // Fehlgeschlagene Folgezahlung: den Inhaber per Pay-now-Mail informieren // (ersetzt die frühere generische Fehler-Mail). Der Link führt auf die Abo-Detailseite, // wo die Zahlung nachgeholt und die Zahlart für künftige Abbuchungen aktualisiert wird. $this->sendPaymentFailedMail($userAbo); } elseif ($response['status'] === 'PENDING' || $response['status'] === 'REDIRECT') { // Pending/Redirect Status: Bestellung speichern, aber Abo nicht aktualisieren \Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung ausstehend/weiterleitung', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, 'status' => $response['status'], ]); $this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$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', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, 'status' => $response['status'], ]); $this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$response['status']}"); $this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: '.$response['status']); } } catch (\Throwable $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [ 'abo_id' => $userAbo->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $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()); return $shoppingOrder; } // Noch keine ShoppingOrder (createShoppingUser / makeShoppingOrder): Exception durchreichen, // sonst ruft der Aufrufer nur "null" ohne Ursache (z. B. Testbench, fehlende country_id im Yard). throw $e; } return $shoppingOrder; } /** * Aktualisiert das Abo nach einer erfolgreichen Bestellung * Aktualisiert next_date für den nächsten Abo-Zyklus * * @param UserAbo $userAbo * @param mixed $shoppingOrder * @param int $status * @return void */ private function updateAbo($userAbo, $shoppingOrder, $status = 1) { \Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo nach erfolgreicher Zahlung', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, 'status' => $status, ]); $this->info("Aktualisiere Abo: {$userAbo->id} mit Status {$status}"); try { DB::transaction(function () use ($userAbo, $shoppingOrder, $status) { $updateData = [ 'next_date' => AboHelper::setNextDate(now(), $userAbo->abo_interval), 'last_date' => now(), ]; if ($status !== 1) { $updateData['status'] = $status; } $userAbo->update($updateData); UserAboOrder::create([ 'user_abo_id' => $userAbo->id, 'shopping_order_id' => $shoppingOrder->id, 'status' => $status, 'paid' => true, ]); \Log::channel('abo_order')->info('UserMakeAboOrder: Abo erfolgreich aktualisiert', [ 'abo_id' => $userAbo->id, 'next_date' => $updateData['next_date'], ]); }); // 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()); throw $e; // Re-throw für besseres Error-Handling } } /** * Aktualisiert das Abo bei Fehlern - OHNE next_date zu aktualisieren * Damit wird das Abo beim nächsten Cron-Lauf erneut versucht * * @param UserAbo $userAbo * @param mixed $shoppingOrder * @param int|string $status Status-Code oder Fehlermeldung * @param array|null $errorResponse Optionale Fehlerantwort von Payment * @return void */ private function updateAboOnError($userAbo, $shoppingOrder, $status, $errorResponse = null) { \Log::channel('abo_order')->info('UserMakeAboOrder: Aktualisiere Abo bei Fehler (ohne next_date)', [ 'abo_id' => $userAbo->id, 'order_id' => $shoppingOrder->id, 'status' => $status, ]); $this->info("Aktualisiere Abo bei Fehler: {$userAbo->id} (Status: {$status})"); try { DB::transaction(function () use ($userAbo, $shoppingOrder, $status) { // Nur last_date aktualisieren, next_date bleibt unverändert // Damit wird das Abo beim nächsten Cron-Lauf erneut versucht $updateData = [ 'last_date' => now(), ]; // Status nur setzen wenn es ein numerischer Wert ist if (is_numeric($status)) { $updateData['status'] = $status; } $userAbo->update($updateData); // UserAboOrder mit Fehlerstatus speichern $orderStatus = is_numeric($status) ? $status : 3; // Default zu 3 (abo_hold) wenn String UserAboOrder::create([ 'user_abo_id' => $userAbo->id, 'shopping_order_id' => $shoppingOrder->id, 'status' => $orderStatus, 'paid' => false, ]); \Log::channel('abo_order')->info('UserMakeAboOrder: Abo bei Fehler aktualisiert (next_date unverändert)', [ 'abo_id' => $userAbo->id, 'next_date' => $userAbo->next_date, 'status' => $status, ]); }); } catch (\Exception $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos bei Fehler', [ 'abo_id' => $userAbo->id, 'error' => $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 } } /** * Informiert den Abo-Inhaber per E-Mail über die fehlgeschlagene Folgezahlung inkl. Pay-now-Link. * Fehler beim Versand dürfen den Cron-Lauf nicht abbrechen. */ private function sendPaymentFailedMail(UserAbo $userAbo): void { if (! $userAbo->email) { return; } try { $locale = $userAbo->shopping_user?->getLocale() ?: config('app.locale'); Mail::to($userAbo->email)->locale($locale)->send(new AboPaymentFailedMail($userAbo)); } catch (\Throwable $e) { \Log::channel('abo_order')->error('UserMakeAboOrder: Pay-now-Fehlermail konnte nicht gesendet werden', [ 'abo_id' => $userAbo->id, 'error' => $e->getMessage(), ]); } } /** * Berechnet die Ausführungszeit * * @return string */ private function getExecutionTime() { $diff = microtime(true) - $this->timeStart; $sec = intval($diff); $micro = $diff - $sec; return $sec.' Sekunden und '.round($micro * 1000, 2).' ms'; } }