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:
parent
c0535be02f
commit
ec3ecadc5d
22 changed files with 977 additions and 15 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class UserAboOrder extends Model
|
|||
'user_id',
|
||||
'shopping_order_id',
|
||||
'status',
|
||||
'error_message',
|
||||
'paid',
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue