Abo (A3): Status und Grund eines Abos sichtbar machen

Neue Spalten user_abos.status_reason / paused_until und
user_abo_orders.error_message. Der Grund wird zentral am Model gepflegt
(saving-Event): Zahlungsfehler, Kuendigung/Deaktivierung mit Akteur aus dem
Formular-Kanal, Loeschen beim Zurueckkehren in den Lauf; die Pause setzt
paused_until und paused_by_*. Cron- und Retry-Zahlung schreiben den Fehlertext
des Anbieters an die Abo-Bestellung.

AboStatusExplainer uebersetzt das in einen Satz ("Pausiert bis ... vom Kunden
am ...", "Zahlung am ... fehlgeschlagen - Grund: ...", "Gekuendigt am ... vom
Berater"), Partial _status_explain in Abo-Detail (CRM, Portal, Admin),
Abo-Uebersicht, Kundenabo-Liste, Team-Kundenabos und Admin-Datatable; dort
neuer Filter "Zustand" (pausiert / Zahlung fehlgeschlagen). Backfill-Kommando
abo:backfill-status-reasons fuer Bestandsdaten, Fallback ueber das
Statusprotokoll und die Pausenfelder.

Sprachdateien de/en/es/fr, Tests in AboStatusExplainTest, Plan-Status.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-16 10:24:18 +00:00
parent c0535be02f
commit ec3ecadc5d
22 changed files with 977 additions and 15 deletions

View file

@ -7,6 +7,8 @@
namespace App\Models;
use App\Enums\PaymentProvider;
use App\Services\Abo\AboStatusExplainer;
use App\Services\Abo\AboStatusInfo;
use App\Services\Abo\TeamAboOverviewService;
use App\Services\AboPaymentMethodService;
use App\Services\Backoffice\BackofficeDashboardService;
@ -108,11 +110,109 @@ class UserAbo extends Model
}
});
static::saving(function (UserAbo $abo): void {
$abo->applyStatusReasonDefaults();
});
static::updated(function (UserAbo $abo): void {
$abo->logStatusTransition();
});
}
/**
* Ticket A3: Grund eines Zustands zentral am Model pflegen, damit jeder
* Schreibpfad (Cron, Retry, Recovery, Formular, spaeter B2) ihn hinterlaesst.
* Explizit gesetzte Werte im selben Save gewinnen.
*
* - Status 3 ohne Grund => `payment_failed` (jeder Weg nach 3 ist ein Zahlungsfehler).
* - Status 4 / 6 ohne Grund => `cancelled_by_*` / `deactivated_by_*` mit dem
* Kanal des laufenden Formulars ({@see withStatusLogView()}), sonst `system`.
* - Zurueck in den Lauf (2, 7, 8) => Grund geloescht.
* - `last_date` aendert sich nur durch eine Ausfuehrung (Erfolg oder Fehler):
* damit ist eine Pause vorbei, `paused_until` wird geloescht.
*/
protected function applyStatusReasonDefaults(): void
{
if ($this->isDirty('status') && ! $this->isDirty('status_reason')) {
$this->status_reason = match ((int) $this->status) {
3 => self::REASON_PAYMENT_FAILED,
4 => self::REASON_PREFIX_CANCELLED.self::reasonActorFromView(static::$statusLogView),
6 => self::REASON_PREFIX_DEACTIVATED.self::reasonActorFromView(static::$statusLogView),
2, 7, 8 => null,
default => $this->status_reason,
};
}
if ($this->isDirty('last_date') && ! $this->isDirty('paused_until')) {
$this->paused_until = null;
}
}
/**
* Akteur fuer `status_reason` aus dem Formular-Kanal: Kundenportal => Kunde,
* CRM (eigenes Abo oder Kundenabo) => Berater, Admin => Admin, kein Kanal => System.
*/
public static function reasonActorFromView(?string $view): string
{
return match ($view) {
'portal' => self::ACTOR_CUSTOMER,
'me', 'ot' => self::ACTOR_CONSULTANT,
'admin' => self::ACTOR_ADMIN,
default => self::ACTOR_SYSTEM,
};
}
/**
* Ende der laufenden Pause. Fuer Bestandsdaten ohne `paused_until` gilt:
* Pause gezaehlt und seit der letzten Ausfuehrung nicht wieder geliefert
* => die Pause laeuft bis zum naechsten Termin.
*/
public function pausedUntil(): ?Carbon
{
if ($this->getRawOriginal('paused_until')) {
return Carbon::parse($this->getRawOriginal('paused_until'));
}
if ((int) $this->pause_count > 0 && $this->getRawOriginal('last_pause_date') && $this->getRawOriginal('next_date')) {
$lastDate = $this->getRawOriginal('last_date');
if (! $lastDate || Carbon::parse($lastDate)->lt(Carbon::parse($this->getRawOriginal('last_pause_date')))) {
return Carbon::parse($this->getRawOriginal('next_date'));
}
}
return null;
}
public function isPaused(): bool
{
if ((int) $this->status !== 2 || ! $this->active) {
return false;
}
$until = $this->pausedUntil();
return $until !== null && $until->gte(Carbon::today());
}
/**
* Laufende Abos, deren Pause noch nicht vorbei ist (Admin-Filter).
*/
public function scopePaused(Builder $query): Builder
{
return $query->where('status', 2)->where('active', true)->whereDate('paused_until', '>=', Carbon::today());
}
public function scopePaymentFailed(Builder $query): Builder
{
return $query->where('status', 3);
}
public function statusInfo(): AboStatusInfo
{
return app(AboStatusExplainer::class)->explain($this);
}
/**
* Kanal des laufenden Schreibvorgangs (Formular-View) für das Statusprotokoll.
* Systempfade — Cron, Zahlungs-Recovery, Webhooks — lassen ihn leer und
@ -191,6 +291,7 @@ class UserAbo extends Model
'cancel_date' => 'datetime',
'last_pause_date' => 'date',
'pause_count' => 'int',
'paused_until' => 'date',
'user_deleted_at' => 'datetime',
'carddata' => 'array',
'card_update_pending_at' => 'datetime',
@ -221,12 +322,31 @@ class UserAbo extends Model
'cancel_date',
'last_pause_date',
'pause_count',
'paused_until',
'status_reason',
'user_deleted_at',
'card_update_pending_at',
];
public static $aboDeliveryDays = [5, 10, 20, 25];
/** `status_reason` (Ticket A3): Zahlungsfehler, Pause/Kuendigung/Deaktivierung je Akteur. */
public const REASON_PAYMENT_FAILED = 'payment_failed';
public const REASON_PREFIX_PAUSED = 'paused_by_';
public const REASON_PREFIX_CANCELLED = 'cancelled_by_';
public const REASON_PREFIX_DEACTIVATED = 'deactivated_by_';
public const ACTOR_CUSTOMER = 'customer';
public const ACTOR_CONSULTANT = 'consultant';
public const ACTOR_ADMIN = 'admin';
public const ACTOR_SYSTEM = 'system';
public static $statusTypes = [
0 => 'abo_new',
1 => 'abo_new',

View file

@ -49,6 +49,7 @@ class UserAboOrder extends Model
'user_id',
'shopping_order_id',
'status',
'error_message',
'paid',
];