mivita/app/Console/Commands/UserMakeAboOrder.php
Kevin ce4750fd1a Stripe-Integration (S4, S5, S7.6): Abos, Zahlungsart ändern, Zahlarten-Kommunikation
Phase S4 – Abos über Stripe (E2E komplett im Testmodus):
- Abo-/Recovery-Checkout: customer_creation=always + setup_future_usage=off_session
- StripePaymentService::chargeAbo (off-session PaymentIntent, Idempotency-Key),
  StripeGateway, Provider-Dispatch im Abbuchungs-Cron (PAYONE-Pfad unverändert)
- Webhook: Zahlungsmittel-Capture vor Paid-Pfad, 503 payment_not_yet_visible bei
  Cron-Transaktions-Race; Command stripe:process-pending-events (Cron-Ende + 10-min
  Scheduler) verarbeitet liegengebliebene Events nach
- createNewAbo/advanceAboAfterRecoveryPayment Stripe-aware inkl. Race-Absicherung;
  Recovery-Orders nutzen advanceAboAfterRecoveryPayment (latenter PAYONE-Duplikat-Bug)
- Migrationen: Stripe-Abo-Spalten auf shopping_payments; clearingtype varchar(3)→(20)
  inkl. Reparatur der zu 'str' gekürzten Live-Zeilen

Phase S5 – Zahlungsart ändern (Scope: jede Zahlart, nicht nur Kreditkarte):
- Stripe Checkout mode=setup ohne Abbuchung; PAYONE-Abos ziehen beim Wechsel auf
  Stripe um (Auslaufmodell §7.2)
- AboPaymentMethodService: beginStripeChange/finalize/applyStripePaymentMethod
  (idempotent, detacht alte PM, card_changes-Log); Webhook setup_intent.succeeded
  als Sicherheitsnetz; Einstiege Portal/CRM/Berater-Kunden-Link flow-abhängig
- Views/Mails flow-abhängig („Zahlungsweise" statt „Kreditkarte")

Phase S7.6 – Zahlarten-Kommunikation:
- Einheitliche Zahlarten-Logos (SVG) + wiederverwendbares Partial payment_logos;
  Checkout/Footer zeigen Kern-Zahlarten + „···"-Icon, /zahlungsarten komplett neu
  mit allen aktiven Stripe-Zahlarten (gegen Account-Konfiguration verifiziert)
  und Vorkasse (PAYONE); Sofort/SEPA entfernt

Tests: StripeAboTest (15), StripePaymentMethodChangeTest (10),
PaymentMethodsCommunicationTest (28), PaymentProviderManagerTest angepasst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:45:17 +00:00

504 lines
20 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Cron\UserMakeOrder;
use App\Mail\AboPaymentFailedMail;
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\PaymentProviderManager;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class UserMakeAboOrder extends Command
{
/**
* ln -sfv /usr/bin/php73 /usr/bin/php
* php artisan business:store month year
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:make_abo_order
{--abo= : Nur dieses eine Abo (ID) ausführen}
{--force : Datums- und Duplikatsprüfung überspringen (manueller Testlauf)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Make Orders from Abos';
private $timeStart;
private $month;
private $year;
private $sendCreditMail = false;
private $sendUpdateMail = false;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->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';
}
}