mivita/app/Services/Abo/AboStatusExplainer.php
Kevin d6efe56cd4 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>
2026-09-16 10:24:18 +00:00

215 lines
8.2 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\Services\Abo;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserAboStatusHistory;
use App\Repositories\AboRepository;
use Carbon\Carbon;
/**
* Uebersetzt Status, Grund und Zeitpunkte eines Abos in eine Aussage, die Admin,
* Berater und Kunde ohne Rueckfrage verstehen (Ticket A3): "Pausiert bis
* 24.10.2026 – vom Kunden am 12.09.2026", "Zahlung am 10.09.2026 fehlgeschlagen
* (Karte abgelaufen) – das Abo laeuft nicht, bis die Zahlung nachgeholt ist",
* "Gekuendigt am 01.08.2026 vom Berater".
*
* Quellen: `user_abos.status` / `status_reason` / `paused_until` (gepflegt
* durch {@see UserAbo::applyStatusReasonDefaults()} und die Pause im
* {@see AboRepository}), `user_abo_orders.error_message`
* (Fehlertext des Zahlungsanbieters) und als Rueckfall fuer Bestandsdaten das
* Statusprotokoll {@see UserAboStatusHistory}.
*/
class AboStatusExplainer
{
public function explain(UserAbo $abo): AboStatusInfo
{
$status = (int) $abo->status;
if ($abo->isPendingInitialPayment() || $status === 0) {
return $this->pendingPayment($abo);
}
return match ($status) {
3 => $this->paymentFailed($abo),
4 => $this->cancelled($abo),
5 => new AboStatusInfo(AboStatusInfo::STATE_FINISHED, __('abo.state_finished'), 'dark', __('abo.state_text_finished')),
6 => $this->deactivated($abo),
7 => new AboStatusInfo(AboStatusInfo::STATE_GRACE, __('abo.state_grace'), 'info', __('abo.state_text_grace')),
8 => new AboStatusInfo(AboStatusInfo::STATE_EXTERN_DONE, __('abo.state_extern_done'), 'info', __('abo.state_text_extern_done')),
2 => $abo->active ? $this->running($abo) : $this->deactivated($abo),
default => new AboStatusInfo(AboStatusInfo::STATE_UNKNOWN, $abo->getStatusType() ?: __('abo.state_unknown'), $abo->getStatusColor(), ''),
};
}
/**
* Kurzer Fehlertext aus einer Payone-/Stripe-Antwort fuer
* `user_abo_orders.error_message` — null, wenn die Antwort nichts Lesbares
* enthaelt.
*
* @param array<string, mixed>|null $response
*/
public static function paymentErrorMessage(?array $response): ?string
{
if (! $response) {
return null;
}
$message = $response['customermessage'] ?? $response['errormessage'] ?? $response['message'] ?? null;
$code = $response['errorcode'] ?? $response['decline_code'] ?? $response['code'] ?? null;
if (! is_string($message) || trim($message) === '') {
return null;
}
$text = trim($message);
if (is_scalar($code) && (string) $code !== '') {
$text = '['.$code.'] '.$text;
}
return mb_substr($text, 0, 255);
}
private function running(UserAbo $abo): AboStatusInfo
{
$pausedUntil = $abo->pausedUntil();
if ($pausedUntil !== null && $pausedUntil->gte(Carbon::today())) {
$actor = $this->actorFromReason($abo->status_reason, 'paused_by_')
?? $this->actorFromHistory($abo, UserAboStatusHistory::ACTION_PAUSED);
$date = $abo->last_pause_date ? Carbon::parse($abo->getRawOriginal('last_pause_date')) : null;
return new AboStatusInfo(
AboStatusInfo::STATE_PAUSED,
__('abo.state_paused'),
'warning',
__('abo.state_text_paused', [
'until' => $pausedUntil->format(\Util::formatDateDB()),
'actor' => $this->actorLabel($actor),
'date' => $date ? $date->format(\Util::formatDateDB()) : '-',
]),
$date,
$actor,
);
}
$last = $this->rawDate($abo, 'last_date');
$next = $this->rawDate($abo, 'next_date');
$text = $last
? __('abo.state_text_active', ['last' => $last->format(\Util::formatDateDB()), 'next' => $next?->format(\Util::formatDateDB()) ?? '-'])
: __('abo.state_text_active_first', ['next' => $next?->format(\Util::formatDateDB()) ?? '-']);
return new AboStatusInfo(AboStatusInfo::STATE_ACTIVE, __('abo.state_active'), 'success', $text, $next);
}
private function paymentFailed(UserAbo $abo): AboStatusInfo
{
$date = $this->rawDate($abo, 'last_date');
$error = $this->latestErrorMessage($abo);
$text = __('abo.state_text_payment_failed', ['date' => $date ? $date->format(\Util::formatDateDB()) : '-']);
if ($error !== null) {
$text .= ' '.__('abo.state_text_payment_error', ['error' => $error]);
}
return new AboStatusInfo(AboStatusInfo::STATE_PAYMENT_FAILED, __('abo.state_payment_failed'), 'danger', $text, $date, null, $error);
}
private function cancelled(UserAbo $abo): AboStatusInfo
{
$actor = $this->actorFromReason($abo->status_reason, 'cancelled_by_')
?? $this->actorFromHistory($abo, UserAboStatusHistory::ACTION_CANCELLED);
$date = $this->rawDate($abo, 'cancel_date') ?? $this->historyDate($abo, UserAboStatusHistory::ACTION_CANCELLED);
return new AboStatusInfo(
AboStatusInfo::STATE_CANCELLED,
__('abo.state_cancelled'),
'dark',
__('abo.state_text_cancelled', [
'date' => $date ? $date->format(\Util::formatDateDB()) : '-',
'actor' => $this->actorLabel($actor),
]),
$date,
$actor,
);
}
private function deactivated(UserAbo $abo): AboStatusInfo
{
$actor = $this->actorFromReason($abo->status_reason, 'deactivated_by_')
?? $this->actorFromHistory($abo, UserAboStatusHistory::ACTION_DEACTIVATED);
$date = $this->historyDate($abo, UserAboStatusHistory::ACTION_DEACTIVATED);
$text = $date
? __('abo.state_text_deactivated', ['date' => $date->format(\Util::formatDateDB()), 'actor' => $this->actorLabel($actor)])
: __('abo.state_text_deactivated_no_date', ['actor' => $this->actorLabel($actor)]);
return new AboStatusInfo(AboStatusInfo::STATE_DEACTIVATED, __('abo.state_deactivated'), 'secondary', $text, $date, $actor);
}
private function pendingPayment(UserAbo $abo): AboStatusInfo
{
if ($abo->hasFailedInitialPayment()) {
$text = __('abo.pending_payment_failed_copy');
} elseif ($abo->hasInitialPaymentStatus()) {
$text = __('abo.pending_payment_processing_copy');
} else {
$text = __('abo.pending_payment_waiting_copy');
}
return new AboStatusInfo(AboStatusInfo::STATE_PENDING_PAYMENT, __('abo.state_pending_payment'), 'warning', $text);
}
private function latestErrorMessage(UserAbo $abo): ?string
{
$order = $abo->relationLoaded('user_abo_orders')
? $abo->user_abo_orders->sortByDesc('id')->first(fn (UserAboOrder $order) => (int) $order->status === 3)
: $abo->user_abo_orders()->where('status', 3)->orderByDesc('id')->first();
$message = $order?->error_message;
return is_string($message) && trim($message) !== '' ? $message : null;
}
private function actorFromReason(?string $reason, string $prefix): ?string
{
if (! $reason || ! str_starts_with($reason, $prefix)) {
return null;
}
return substr($reason, strlen($prefix)) ?: null;
}
private function actorFromHistory(UserAbo $abo, string $action): ?string
{
$entry = $this->latestHistory($abo, $action);
return $entry ? UserAbo::reasonActorFromView($entry->view) : null;
}
private function historyDate(UserAbo $abo, string $action): ?Carbon
{
return $this->latestHistory($abo, $action)?->created_at?->copy();
}
private function latestHistory(UserAbo $abo, string $action): ?UserAboStatusHistory
{
return $abo->status_histories()->where('action', $action)->orderByDesc('id')->first();
}
private function actorLabel(?string $actor): string
{
return __('abo.actor_by_'.($actor ?: UserAbo::ACTOR_SYSTEM));
}
private function rawDate(UserAbo $abo, string $column): ?Carbon
{
$raw = $abo->getRawOriginal($column);
return $raw ? Carbon::parse($raw) : null;
}
}