mivita/app/Console/Commands/AboTestRecovery.php
Kevin 0660f4e0a0 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>
2026-07-02 10:38:05 +00:00

188 lines
6.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use App\Mail\AboPaymentFailedMail;
use App\Models\PaymentTransaction;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\UserAbo;
use App\Services\AboHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
/**
* Testhilfe für die Abo-Recovery-Zahlung (fehlgeschlagene Folgezahlung → nachträglich bezahlen).
*
* Beispiele:
* php artisan abo:test-recovery --abo=123 --fail # Abo als fehlgeschlagen markieren + Mail
* php artisan abo:test-recovery --abo=123 --email=me@test.de # nur Mail an eigene Adresse
* php artisan abo:test-recovery --abo=123 --advance --no-mail # nur erfolgreiche Recovery simulieren
* php artisan abo:test-recovery --abo=123 --fail --advance # kompletter Durchlauf
*/
class AboTestRecovery extends Command
{
protected $signature = 'abo:test-recovery
{--abo= : Abo-ID (Standard: neuestes Abo)}
{--email= : Empfänger der Test-Mail (Standard: E-Mail des Abos)}
{--fail : Abo vorab auf Status 3 (fehlgeschlagen) + aktiv setzen, damit der Pay-now-Button erscheint}
{--advance : Erfolgreiche Recovery-Zahlung simulieren (Zahlart-Update + next_date), ohne echten Payone-Call}
{--no-mail : Keine Test-Mail senden}
{--force : Sicherheitsabfrage bei --advance überspringen}';
protected $description = 'Testet die Abo-Recovery: verschickt die "Zahlung fehlgeschlagen"-Mail und/oder simuliert eine erfolgreiche Recovery-Zahlung.';
public function handle(): int
{
$abo = $this->resolveAbo();
if (! $abo) {
return self::FAILURE;
}
$this->info('=== Abo-Recovery-Test ===');
$this->showAbo($abo, 'Ausgangszustand');
if ($this->option('fail')) {
$abo->forceFill(['status' => 3, 'active' => true])->save();
$this->warn("Abo #{$abo->id} auf Status 3 (fehlgeschlagen) + aktiv gesetzt.");
}
if (! $this->option('no-mail')) {
$this->sendMail($abo);
}
if ($this->option('advance')) {
$this->simulateRecovery($abo);
}
$this->newLine();
$this->info('Fertig.');
return self::SUCCESS;
}
private function resolveAbo(): ?UserAbo
{
if ($id = $this->option('abo')) {
$abo = UserAbo::find($id);
if (! $abo) {
$this->error("Abo mit ID {$id} nicht gefunden.");
return null;
}
return $abo;
}
$abo = UserAbo::where('status', '>=', 1)->orderByDesc('id')->first();
if (! $abo) {
$this->error('Kein Abo gefunden. Bitte --abo=<ID> angeben.');
return null;
}
$this->line("Kein --abo angegeben, nutze neuestes Abo #{$abo->id}.");
return $abo;
}
private function sendMail(UserAbo $abo): void
{
$recipient = $this->option('email') ?: $abo->email;
if (! $recipient) {
$this->warn('Keine Empfänger-Adresse (Abo ohne E-Mail, kein --email). Mail übersprungen.');
return;
}
$link = (new AboPaymentFailedMail($abo))->build()->viewData['link'] ?? '(kein Link)';
$locale = $abo->shopping_user?->getLocale() ?: config('app.locale');
Mail::to($recipient)->locale($locale)->send(new AboPaymentFailedMail($abo));
$this->newLine();
$this->info('✓ "Zahlung fehlgeschlagen"-Mail versendet');
$this->table(['Feld', 'Wert'], [
['Empfänger', $recipient],
['Sprache', $locale],
['Abo-Typ', $abo->is_for.($abo->is_for === 'me' ? ' (Berater-CRM)' : ' (Kundenportal)')],
['Pay-now-Link', $link],
]);
$this->line('Tipp: Mail im Mailpit prüfen (mivita-mail.test).');
}
private function simulateRecovery(UserAbo $abo): void
{
$this->newLine();
$this->warn('ACHTUNG: --advance überschreibt payone_userid/carddata dieses Abos mit TEST-Werten.');
$this->warn('Das echte Abbuchungs-Token geht dabei verloren – nur auf Wegwerf-/Test-Abos ausführen!');
if (! $this->option('force') && ! $this->confirm("Trotzdem Recovery-Zahlung für Abo #{$abo->id} simulieren?", false)) {
$this->line('Simulation abgebrochen.');
return;
}
$this->warn('Simuliere erfolgreiche Recovery-Zahlung (erzeugt Test-Order/-Payment, KEIN echter Payone-Call)...');
$reference = $abo->shopping_user?->shopping_orders()->orderByDesc('id')->first();
$newUserId = (int) $abo->payone_userid + 1; // sichtbar geändertes Token
$fakeCard = ['cardpan' => '411111XXXXXX'.random_int(1000, 9999), 'cardtype' => 'V', 'cardexpiredate' => '3012'];
$order = ShoppingOrder::create([
'shopping_user_id' => $abo->shopping_user_id ?: 1,
'auth_user_id' => $abo->user_id ?: ($reference->auth_user_id ?? 1),
'country_id' => $reference->country_id ?? 1,
'user_shop_id' => $reference->user_shop_id ?? 1,
'recovery_user_abo_id' => $abo->id,
'is_abo' => true,
'abo_interval' => $abo->abo_interval,
'paid' => true,
'mode' => 'test',
]);
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'clearingtype' => 'cc',
'wallettype' => null,
'carddata' => $fakeCard,
'reference' => 'recovery-test-'.uniqid(),
'amount' => 19.9,
'currency' => 'EUR',
'mode' => 'test',
'is_abo' => true,
'abo_interval' => $abo->abo_interval,
]);
PaymentTransaction::create([
'shopping_payment_id' => $payment->id,
'userid' => $newUserId,
'txid' => 'test-'.uniqid(),
'status' => 'APPROVED',
'txaction' => 'appointed',
'mode' => 'test',
]);
AboHelper::advanceAboAfterRecoveryPayment($payment->fresh());
$this->showAbo($abo->fresh(), 'Nach Recovery-Zahlung');
$this->info('✓ Abo fortgeschrieben (Status→2, next_date gesetzt, Zahlart/Token aktualisiert).');
}
private function showAbo(UserAbo $abo, string $title): void
{
$this->newLine();
$this->line("<info>{$title}</info> — Abo #{$abo->id}");
$this->table(['Feld', 'Wert'], [
['Status', $abo->status.' ('.($abo->getStatusType() ?? '?').')'],
['Aktiv', $abo->active ? 'Ja' : 'Nein'],
['is_for', $abo->is_for],
['E-Mail', $abo->email],
['clearingtype', $abo->clearingtype ?? '-'],
['wallettype', $abo->wallettype ?? '-'],
['payone_userid', $abo->payone_userid],
['carddata (maskiert)', $abo->maskedCard() ?? '-'],
['next_date', $abo->next_date],
['last_date', $abo->last_date],
]);
}
}